From 4257e33f38edc25f0d106fac1f3652f97224fa24 Mon Sep 17 00:00:00 2001 From: Bradlee Speice Date: Wed, 26 Dec 2012 18:37:14 -0500 Subject: [PATCH] Support reorganizing an archive, begin playlist Also add more fields to the DB representation of a song. --- archiver/archive.py | 68 +++++++++++++++++++++++++++++- archiver/listfield.py | 48 +++++++++++++++++++++ archiver/playlist.py | 98 +++++++++++++++++++++++++++++++++++++++++++ archiver/song.py | 40 +++++++++++------- 4 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 archiver/listfield.py create mode 100644 archiver/playlist.py diff --git a/archiver/archive.py b/archiver/archive.py index c649fba..e275657 100644 --- a/archiver/archive.py +++ b/archiver/archive.py @@ -76,7 +76,8 @@ class Archive (models.Model): self.songs.add(new_song) 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 #sure that new songs are added, use the _scan_filesystem() method in addition total_songs = self.songs.count() @@ -127,3 +128,68 @@ class Archive (models.Model): if force_backup or self._needs_backup(): import subprocess 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 diff --git a/archiver/listfield.py b/archiver/listfield.py new file mode 100644 index 0000000..d1d5c38 --- /dev/null +++ b/archiver/listfield.py @@ -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: + [, , , ... , ]""" + + 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 + "]" diff --git a/archiver/playlist.py b/archiver/playlist.py new file mode 100644 index 0000000..0003d77 --- /dev/null +++ b/archiver/playlist.py @@ -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 diff --git a/archiver/song.py b/archiver/song.py index 9a43c89..e277a65 100644 --- a/archiver/song.py +++ b/archiver/song.py @@ -9,12 +9,16 @@ This database model is used for storing the metadata information about a song, and helps in doing sorting etc. """ -_default_title = "" -_default_artist = "" -_default_album = "" +_default_title = "_UNAVAILABLE_" +_default_artist = "_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_genre = "" +_default_genre = "_UNAVAILABLE_" _default_bpm = -1 +_default_disc_number = -1 +_default_disc_total = -1 +_default_track_number = -1 +_default_track_total = -1 _default_bit_rate = -1 _default_duration = -1 @@ -28,12 +32,16 @@ class Song (models.Model): """ #Standard user-populated metadata - title = models.CharField(max_length = 64, default = _default_title) - artist = models.CharField(max_length = 64, default = _default_artist) - album = models.CharField(max_length = 64, default = _default_album) - release_date = models.DateField(default = _default_release_date) - genre = models.CharField(max_length = 64, default = _default_genre) + title = models.CharField(max_length = 64, default = _default_title) + artist = models.CharField(max_length = 64, default = _default_artist) + album = models.CharField(max_length = 64, default = _default_album) + release_date = models.DateField(default = _default_release_date) + genre = models.CharField(max_length = 64, default = _default_genre) 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 bit_rate = models.IntegerField(default = _default_bit_rate) @@ -73,11 +81,15 @@ class Song (models.Model): track = audiotools.open(self.url) track_metadata = track.get_metadata() - self.title = track_metadata.track_name or _default_title - self.artist = track_metadata.artist_name or _default_artist - self.album = track_metadata.album_name or _default_album - self.release_date = datetime.date(int(track_metadata.year or 1), 1, 1) - self.bpm = _default_bpm + self.title = track_metadata.track_name or _default_title + self.artist = track_metadata.artist_name or _default_artist + self.album = track_metadata.album_name or _default_album + 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.bit_rate = track.bits_per_sample() or _default_bit_rate self.duration = int(track.seconds_length()) or _default_duration