mirror of
https://github.com/bspeice/Melodia
synced 2024-12-26 00:28:13 -05:00
Support reorganizing an archive, begin playlist
Also add more fields to the DB representation of a song.
This commit is contained in:
parent
22bbc568e7
commit
4257e33f38
@ -76,7 +76,8 @@ class Archive (models.Model):
|
|||||||
self.songs.add(new_song)
|
self.songs.add(new_song)
|
||||||
|
|
||||||
def _update_song_metadata(self, use_echonest = False, progress_callback = lambda x, y: None):
|
def _update_song_metadata(self, use_echonest = False, progress_callback = lambda x, y: None):
|
||||||
"Scan every song in this archive (database only) and make sure all songs are correct"
|
"""Scan every song in this archive (database only) and make sure all songs are correct
|
||||||
|
The progress_callback function is called with the current song being operated on first, and the total songs second."""
|
||||||
#This method operates only on the songs that are in the database - if you need to make
|
#This method operates only on the songs that are in the database - if you need to make
|
||||||
#sure that new songs are added, use the _scan_filesystem() method in addition
|
#sure that new songs are added, use the _scan_filesystem() method in addition
|
||||||
total_songs = self.songs.count()
|
total_songs = self.songs.count()
|
||||||
@ -127,3 +128,68 @@ class Archive (models.Model):
|
|||||||
if force_backup or self._needs_backup():
|
if force_backup or self._needs_backup():
|
||||||
import subprocess
|
import subprocess
|
||||||
subprocess.call(['rsync', '-av', self.root_folder, self.backup_location])
|
subprocess.call(['rsync', '-av', self.root_folder, self.backup_location])
|
||||||
|
|
||||||
|
def reorganize(self, format_string, progress_function = lambda x, y: None, song_status_function = lambda x, y: None, dry_run = False):
|
||||||
|
"""Reorganize a music archive using a specified format string.
|
||||||
|
Recognized escape characters:
|
||||||
|
%a - Artist Name %A - Album Name
|
||||||
|
%d - Disc Number %e - Number of discs
|
||||||
|
%f - Current Filename (no extension) %g - Current Filename (with extension)
|
||||||
|
%n - Track Number %o - Number of tracks on disc
|
||||||
|
%y - Album year
|
||||||
|
|
||||||
|
Note that all organization takes place relative to the archive's root folder.
|
||||||
|
The progress_function is called with the current song number as its first argument, and total songs as its second.
|
||||||
|
The song_status_function is called with the current song url as its first argument, and new url as its second."""
|
||||||
|
import os, shutil, errno
|
||||||
|
|
||||||
|
for song in self.songs.all():
|
||||||
|
_current_filename = os.path.basename(song.url)
|
||||||
|
_current_filename_no_extension = os.path.splitext(_current_filename)[0]
|
||||||
|
|
||||||
|
_release_year = song.release_date.year
|
||||||
|
|
||||||
|
new_location = format_string.replace("%a", song.artist)\
|
||||||
|
.replace("%A", song.album)\
|
||||||
|
.replace("%d", song.disc_number)\
|
||||||
|
.replace("%e", song.disc_total)\
|
||||||
|
.replace("%f", _current_filename)\
|
||||||
|
.replace("%g", _current_filename_no_extension)\
|
||||||
|
.replace("%n", song.track_number)\
|
||||||
|
.replace("%o", song.track_total)\
|
||||||
|
.replace("%y", _release_year)
|
||||||
|
|
||||||
|
new_url = os.path.join(self.root_folder, new_location)
|
||||||
|
|
||||||
|
song_status_function(song.url, new_url)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
new_folder = os.path.dirname(new_url)
|
||||||
|
try:
|
||||||
|
#`mkdir -p` functionality
|
||||||
|
if not os.path.isdir(new_folder):
|
||||||
|
os.makedirs(new_folder)
|
||||||
|
|
||||||
|
#Safely copy the file - don't 'move' it, but do a full 'copy' 'rm'
|
||||||
|
#This way if the process is ever interrupted, we have an unaltered copy
|
||||||
|
#of the file.
|
||||||
|
shutil.copyfile(song.url, new_url)
|
||||||
|
shutil.copystat(song.url, new_url)
|
||||||
|
|
||||||
|
#Notify the database about the new URL
|
||||||
|
old_url = song.url
|
||||||
|
song.url = new_url
|
||||||
|
song.save()
|
||||||
|
|
||||||
|
#Actually remove the file since all references to the original location have been removed
|
||||||
|
os.remove(old_url)
|
||||||
|
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno == errno.EEXIST and os.path.isdir(new_folder):
|
||||||
|
#This is safe to skip - makedirs() is complaining about a folder already existing
|
||||||
|
pass
|
||||||
|
else: raise
|
||||||
|
|
||||||
|
except IOError as exc:
|
||||||
|
#shutil error - likely that folders weren't specified correctly
|
||||||
|
raise
|
||||||
|
48
archiver/listfield.py
Normal file
48
archiver/listfield.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
from django.db import models
|
||||||
|
import re, itertools
|
||||||
|
|
||||||
|
class IntegerListField(models.TextField):
|
||||||
|
"""Store a list of integers in a database string.
|
||||||
|
Format is:
|
||||||
|
[<int_1>, <int_2>, <int_3>, ... , <int_n>]"""
|
||||||
|
|
||||||
|
description = "Field type for storing lists of integers."
|
||||||
|
|
||||||
|
__metaclass__ = models.SubfieldBase
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(IntegerListField, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
#Convert database to python
|
||||||
|
def to_python(self, value):
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
|
||||||
|
#Process a database string
|
||||||
|
|
||||||
|
#Validation first
|
||||||
|
if value[0] != '[' or value[-1] != ']':
|
||||||
|
raise ValidationError("Invalid input to parse a list of integers!")
|
||||||
|
|
||||||
|
#Note that any non-digit string is a valid separator
|
||||||
|
_csv_regex = "[0-9]"
|
||||||
|
csv_regex = re.compile(_csv_regex)
|
||||||
|
|
||||||
|
#Synonymous to:
|
||||||
|
#string_list = filter(None, csv_regex.findall(value))
|
||||||
|
string_list = itertools.ifilter(None, csv_regex.findall(value))
|
||||||
|
value_list = [int(i) for i in string_list]
|
||||||
|
|
||||||
|
return value_list
|
||||||
|
|
||||||
|
#Convert python to database
|
||||||
|
def get_prep_value(self, value):
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ValidationError("Invalid list given to put in database!")
|
||||||
|
|
||||||
|
separator_string = ", "
|
||||||
|
|
||||||
|
list_elements = separator_string.join(value)
|
||||||
|
|
||||||
|
return "[" + list_elements + "]"
|
98
archiver/playlist.py
Normal file
98
archiver/playlist.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from song import Song
|
||||||
|
from listfield import IntegerListField
|
||||||
|
|
||||||
|
"""
|
||||||
|
Playlist model
|
||||||
|
Each playlist is a high-level ordering of songs. That's really it...
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Playlist (models.Model):
|
||||||
|
"The Playlist class defines the playlist model and its operations"
|
||||||
|
|
||||||
|
name = models.CharField(max_length = 255)
|
||||||
|
song_order = IntegerListField()
|
||||||
|
|
||||||
|
#This is a bit of a backup field, since technically the song PK's are in
|
||||||
|
#the song_order field. However, it might be useful to just get a reference
|
||||||
|
#to the songs in this playlist. Additionally, it's kind of hackish to reference
|
||||||
|
#the global Song manager, rather than something that is specific for this playlist.
|
||||||
|
songs = models.ManyToManyField(Song)
|
||||||
|
|
||||||
|
def _populate_songs(self):
|
||||||
|
"""Make sure that the 'songs' relation is up-to-date."""
|
||||||
|
#This operation works by getting the ID's for all songs currently in the playlist,
|
||||||
|
#calculates what we need to add, what we need to remove, and then does it.
|
||||||
|
#As much work as is possible is done on the python side to avoid the DB
|
||||||
|
current_song_ids = [song.id for song in self.songs.all()]
|
||||||
|
current_song_ids_set = set(current_song_ids)
|
||||||
|
|
||||||
|
new_song_ids_set = set(song_order)
|
||||||
|
|
||||||
|
remove_set = current_song_ids_set.difference(new_song_ids_set)
|
||||||
|
add_set = new_song_ids_set.difference(current_song_ids_set)
|
||||||
|
|
||||||
|
for song_id in remove_set:
|
||||||
|
song = self.songs.get(id = song_id)
|
||||||
|
song.remove()
|
||||||
|
|
||||||
|
for song_id in add_set:
|
||||||
|
song = Song.objects.get(id = song_id) #Using the global Songs manager is unavoidable for this one
|
||||||
|
self.songs.add(song)
|
||||||
|
|
||||||
|
def insert(self, position, new_song):
|
||||||
|
"""Insert a new song into the playlist at a specific position."""
|
||||||
|
|
||||||
|
if not isinstance(new_song, Song):
|
||||||
|
#Not given a song reference, raise an error
|
||||||
|
raise ValidationError("Not given a song reference to insert.")
|
||||||
|
|
||||||
|
self.songs.add(new_song)
|
||||||
|
|
||||||
|
self.song_order.insert(position, new_song.id)
|
||||||
|
|
||||||
|
_populate_songs()
|
||||||
|
|
||||||
|
def append(self, new_song):
|
||||||
|
"""Add a new song to the end of the playlist."""
|
||||||
|
if not isinstance(new_song, Song):
|
||||||
|
#Not given a song reference, raise an error
|
||||||
|
raise ValidationError("Not given a song reference to insert.")
|
||||||
|
|
||||||
|
self.songs.add(new_song)
|
||||||
|
|
||||||
|
self.song_order.append(new_song.id)
|
||||||
|
|
||||||
|
def move(self, original_position, new_position):
|
||||||
|
"""Move a song from one position to another"""
|
||||||
|
if original_position == new_position:
|
||||||
|
return
|
||||||
|
|
||||||
|
song_id = self.song_order[original_position]
|
||||||
|
|
||||||
|
#This is actually a bit more complicated than it first appears, since the index we're moving to may actually change
|
||||||
|
#when we remove an item.
|
||||||
|
if new_position < original_position:
|
||||||
|
del self.song_order[original_position]
|
||||||
|
self.song_order.insert(new_position, song_id)
|
||||||
|
|
||||||
|
else:
|
||||||
|
del self.song_order[original_position]
|
||||||
|
self.song_order.insert(new_position - 1, song_id) #Account for the list indices shifting down.
|
||||||
|
|
||||||
|
def remove(self, position):
|
||||||
|
if position > len(self.song_order):
|
||||||
|
return False
|
||||||
|
|
||||||
|
del self.song_order[position]
|
||||||
|
|
||||||
|
def export(self, playlist_type = "m3u"):
|
||||||
|
"""Export this internal playlist to a file format."""
|
||||||
|
#Default m3u playlist type, support others as I build them.
|
||||||
|
|
||||||
|
if playlist_type = "pls":
|
||||||
|
pass
|
||||||
|
|
||||||
|
else:
|
||||||
|
#Export m3u, default option
|
@ -9,12 +9,16 @@ This database model is used for storing the metadata information about a song,
|
|||||||
and helps in doing sorting etc.
|
and helps in doing sorting etc.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_default_title = "<UNAVAILABLE>"
|
_default_title = "_UNAVAILABLE_"
|
||||||
_default_artist = "<UNAVAILABLE>"
|
_default_artist = "_UNAVAILABLE_"
|
||||||
_default_album = "<UNAVAILABLE>"
|
_default_album = "_UNAVAILABLE_"
|
||||||
_default_release_date = datetime.datetime.now #Function will be called per new song, rather than once at loading the file
|
_default_release_date = datetime.datetime.now #Function will be called per new song, rather than once at loading the file
|
||||||
_default_genre = "<UNAVAILABLE>"
|
_default_genre = "_UNAVAILABLE_"
|
||||||
_default_bpm = -1
|
_default_bpm = -1
|
||||||
|
_default_disc_number = -1
|
||||||
|
_default_disc_total = -1
|
||||||
|
_default_track_number = -1
|
||||||
|
_default_track_total = -1
|
||||||
|
|
||||||
_default_bit_rate = -1
|
_default_bit_rate = -1
|
||||||
_default_duration = -1
|
_default_duration = -1
|
||||||
@ -34,6 +38,10 @@ class Song (models.Model):
|
|||||||
release_date = models.DateField(default = _default_release_date)
|
release_date = models.DateField(default = _default_release_date)
|
||||||
genre = models.CharField(max_length = 64, default = _default_genre)
|
genre = models.CharField(max_length = 64, default = _default_genre)
|
||||||
bpm = models.IntegerField(default = _default_bpm)
|
bpm = models.IntegerField(default = _default_bpm)
|
||||||
|
disc_number = models.IntegerField(default = _default_disc_number)
|
||||||
|
disc_total = models.IntegerField(default = _default_disc_total)
|
||||||
|
track_number = models.IntegerField(default = _default_track_number)
|
||||||
|
track_total = models.IntegerField(default = _default_track_total)
|
||||||
|
|
||||||
#File metadata
|
#File metadata
|
||||||
bit_rate = models.IntegerField(default = _default_bit_rate)
|
bit_rate = models.IntegerField(default = _default_bit_rate)
|
||||||
@ -77,6 +85,10 @@ class Song (models.Model):
|
|||||||
self.artist = track_metadata.artist_name or _default_artist
|
self.artist = track_metadata.artist_name or _default_artist
|
||||||
self.album = track_metadata.album_name or _default_album
|
self.album = track_metadata.album_name or _default_album
|
||||||
self.release_date = datetime.date(int(track_metadata.year or 1), 1, 1)
|
self.release_date = datetime.date(int(track_metadata.year or 1), 1, 1)
|
||||||
|
self.track_number = track_metadata.track_number or _default_track_number
|
||||||
|
self.track_total = track_metadata.track_total or _default_track_total
|
||||||
|
self.disc_number = track_metadata.album_number or _default_disc_number
|
||||||
|
self.disc_total = track_metadata.album_total or _default_disc_total
|
||||||
self.bpm = _default_bpm
|
self.bpm = _default_bpm
|
||||||
|
|
||||||
self.bit_rate = track.bits_per_sample() or _default_bit_rate
|
self.bit_rate = track.bits_per_sample() or _default_bit_rate
|
||||||
|
Loading…
Reference in New Issue
Block a user