diff --git a/archiver/listfield.py b/archiver/listfield.py new file mode 100644 index 0000000..e50842e --- /dev/null +++ b/archiver/listfield.py @@ -0,0 +1,56 @@ +''' +Testing documentation +''' +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 len(value) <= 0: + return [] + + 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(map(str, value)) + + return "[" + list_elements + "]" diff --git a/archiver/models/__init__.py b/archiver/models/__init__.py index 9abfc4a..3547801 100644 --- a/archiver/models/__init__.py +++ b/archiver/models/__init__.py @@ -1,10 +1,3 @@ -''' -.. currentmodule:: archiver.models - -I'm trying to link to :class:`~archiver.models.archive.Archive`! - -''' - # Create your models here. from archive import Archive from song import Song diff --git a/archiver/models/archive.py b/archiver/models/archive.py index fb635fb..2586a97 100644 --- a/archiver/models/archive.py +++ b/archiver/models/archive.py @@ -1,6 +1,4 @@ """ -.. module:: archiver.models.archive - This is the Archive model for the backend of Melodia. It's functionality is to provide a grouping of songs based on where they are located in the filesystem. It controls the high-level functionality of managing multiple archives diff --git a/archiver/models/feed.py b/archiver/models/feed.py index 6842588..6a56fe3 100644 --- a/archiver/models/feed.py +++ b/archiver/models/feed.py @@ -113,5 +113,8 @@ class Feed(models.Model): :param dry_run: Calculate what would have been downloaded or deleted, but do not actually do either. :param forbid_delete: Run, and only download new episodes. Ignores the :data:`max_episodes` field for this podcast. + .. todo:: + Actually write this method... + """ pass diff --git a/archiver/models/playlist.py b/archiver/models/playlist.py index 150f062..47bea95 100644 --- a/archiver/models/playlist.py +++ b/archiver/models/playlist.py @@ -1,24 +1,17 @@ """ -.. module:: archiver.models - -Playlist model -Each playlist is a high-level ordering of songs. There really isn't much to a playlist - just its name, and the songs inside it. -However, we need to have a way to guarantee song order, in addition to re-ordering. A ManyToMany field can't do this. -As such, a custom IntegerListField is implemented - it takes a python list of ints, converts it to a text field in the DB, -and then back to a python list. This way, we can guarantee order, and have a song appear multiple times. -The IntegerListField itself uses the ID of each song as the int in a list. For example, a list of: - - [1, 3, 5, 17] - -Means that the playlist is made up of four songs. The order of the playlist is the song with index 1, 3, 5, and 17. -Additionally, the ManyToMany field is included to make sure we don't use the global Songs manager - it just seems hackish. +The Playlist model is simply that - it's a playlist of songs. However, we do +have to guarantee the song order, in addition to re-ordering the playlist. +As such, a :class:`models.ManyToManyField` isn't sufficient. We use a custom +database field to store a list of integers - the :class:`IntegerListField`. +This way we can guarantee song order, re-order the playlist, have songs +appear multiple times, etc. """ from django.db import models from django.core.exceptions import ObjectDoesNotExist from song import Song -from listfield import IntegerListField +from archiver.listfield import IntegerListField import re from warnings import warn @@ -28,83 +21,74 @@ class Playlist(models.Model): app_label = 'archiver' """ - The Playlist class defines the playlist model and its operations. - Currently supported are add, move, and remove operations, as well as exporting to - multiple formats. + .. data:: name + + String with the human-readable name for this playlist. + + .. data:: song_list + + List made up of Python integers. Each integer is assumed + to be a primary key to the :data:`Song.id` field for a song. """ name = models.CharField(max_length = 255) song_list = 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 at all costs. - current_song_ids = [song.id for song in self.songs.all()] - current_song_ids_set = set(current_song_ids) - - new_song_ids_set = set(self.song_list) - - 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. + + :param position: **Index** for the position this new song should be inserted at. + :param new_song: Reference to a :class:`Song` instance that will be inserted. """ 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_list.insert(position, new_song.id) - self._populate_songs() - def append(self, new_song): """ Add a new song to the end of the playlist. + + :param new_song: Reference to a :class:`Song` instance to be appended. """ + 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_list.append(new_song.id) - self._populate_songs() - def move(self, original_position, new_position): """ Move a song from one position to another + + :param original_position: The index of the song we want to move + :param new_position: The index of where the song should be. See note below. + + .. note:: + + When moving songs, it's a bit weird since the index we're actually + moving to may change. Consider the scenario -- + + * Function called with indexes 4 and 6 + + * Song removed from index 4 + + * The song that was in index 6 is now at index 5 + + * Song inserted at index 6 in new list - one further than originally intended. + + As such, the behavior is that the song at index ``original_position`` is placed + above the song at ``new_position`` when this function is called. """ if original_position == new_position: return song_id = self.song_list[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_list[original_position] self.song_list.insert(new_position, song_id) @@ -113,25 +97,29 @@ class Playlist(models.Model): del self.song_list[original_position] self.song_list.insert(new_position - 1, song_id) #Account for the list indices shifting down. - self._populate_songs() - def remove(self, position): + """ + Remove a song from this playlist. + + :param position: Index of the song to be removed + """ if position > len(self.song_list): return False del self.song_list[position] - self._populate_songs() - def export(self, playlist_type = "m3u"): """ Export this internal playlist to a file format. Supported formats: - -pls - -m3u - Return value is a string containing the playlist - - you can write it to file as you deem necessary. + + * pls + * m3u + + :param playlist_type: String containing the file type to export to + :rtype: String containing the file content for this playlist. """ + if playlist_type == "pls": #Playlist header playlist_string = "[playlist]" @@ -162,13 +150,26 @@ class Playlist(models.Model): return playlist_string - def _import(self, playlist_string = None): + def playlist_import(self, playlist_string = None): """ Import and convert a playlist into native DB format. - This function will return true if the playlist format was recognized, false otherwise. - It will return true even if there are errors processing individual songs in the playlist. - As a side note - the _import() name is used since python doesn't let - you name a function import(). + + :param playlist_string: A string with the file content we're trying to import. + + :rtype: Returns true of the playlist format was recognized. See notes on processing below. + + .. warning:: + + The semantics on returning are nitpicky. This function will return ``False`` if the + playlist format was not recognized. If there are errors in processing, this + function will still return ``True``. + + For example, if you try to import a song which does not exist in an :class:`Archive`, + it will fail that song silently. + + .. todo:: + + Actually write the import code. """ #TODO: Code playlist importing self.song_list = [] diff --git a/archiver/models/song.py b/archiver/models/song.py index 97342d3..07be561 100644 --- a/archiver/models/song.py +++ b/archiver/models/song.py @@ -1,3 +1,9 @@ +""" +The :class:`Song` model is by far the most complicated and involved model. +Each instance is a single music file. This model is used to store metadata +about the song. +""" + from django.db import models from Melodia import melodia_settings @@ -5,12 +11,6 @@ from archive import Archive import datetime import os.path -""" -The Song model -Each instance of a Song represents a single music file. -This database model is used for storing the metadata information about a song, -and helps in doing sorting etc. -""" _default_string = "_UNAVAILABLE_" _default_date = datetime.datetime.now @@ -32,18 +32,119 @@ _default_rating_choices = ( ) class Song (models.Model): - class Meta: - app_label = 'archiver' - """ - This class defines the fields and functions related to controlling - individual music files. - Note that the Playlist model depends on this model's PK being an int. + .. data:: title + + Title tag of this song + + .. data:: artist + + Artist tag of this song. + + .. data:: album_artist + + Album artist tag of this song. Can be used to group albums where + individual songs were made by different people. + + .. data:: album + + Album tag of this song. + + .. data:: year + + Integer representing the year this song was made. + + .. data:: genre + + Genre tag of this song. This is a general :class:`models.CharField` + field, and is not limited to a specific set of genres. + + .. data:: bpm + + Beats per minute of this song (integer). + + .. data:: disc_number + + Disc number this song came from + + .. data:: disc_total + + Total number of discs in the album this song is from + + .. data:: track_number + + Track number in the album this song is from + + .. data:: track_total + + Total number of tracks in the album this song is from + + .. data:: comment + + Comment tag of this song + + .. data:: bit_rate + + Integer representing the bit rate of this song + + .. data:: duration + + Duration (in seconds, floating-point value) of this song + + .. data:: add_date + + Date (not time) this song was added to the DB. Should **not** be + modified outside of this class' methods. + + .. data:: url + + URL for where this file is located on disk. + + .. data:: file_hash + + The hash string for this file - used to quickly check if the file has + been modified. + + .. data:: file_size + + Size of the file in bytes. + + .. data:: play_count + + How many times this file has been played through (defined as greater + than 50% of the song heard before skipping) + + .. data:: skip_count + + How many times this file has been skipped (defined as less than 50% of + the song heard before skipping) + + .. data:: rating + + Rating for this song. Ratings are as follows in order of increasing favoredness + on a 1--5 scale -- + + ========= ======== ================ + Rating: Value: Class field: + ========= ======== ================ + Default 0 RATING_DEFAULT + Bad 1 RATING_BAD + OK 2 RATING_OK + Decent 3 RATING_DECENT + Good 4 RATING_GOOD + Excellent 5 RATING_EXCELLENT + ========= ======== ================ + + .. todo:: + + Change defaults to allow for ``None`` instead + Change private functions to public as need be """ #Standard song metadata title = models.CharField(max_length = 64, default = _default_string) artist = models.CharField(max_length = 64, default = _default_string) + album_artist = models.CharField(max_length = 64, default = _default_string) album = models.CharField(max_length = 64, default = _default_string) year = models.IntegerField(default = _default_string) genre = models.CharField(max_length = 64, default = _default_string) @@ -58,7 +159,6 @@ class Song (models.Model): bit_rate = models.IntegerField(default = _default_int) duration = models.FloatField(default = _default_int) add_date = models.DateField(default = _default_date) - echonest_song_id = models.CharField(max_length = 64, default = _default_string) url = models.CharField(max_length = 255) file_hash = melodia_settings.HASH_RESULT_DB_TYPE file_size = models.IntegerField(default = _default_int) @@ -79,6 +179,9 @@ class Song (models.Model): RATING_GOOD = _default_rating_good RATING_EXCELLENT = _default_rating_excellent + class Meta: + app_label = 'archiver' + def _get_full_url(self): "Combine this song's URL with the URL of its parent" return os.path.join(parent_archive.root_folder, self.url) @@ -111,10 +214,6 @@ class Song (models.Model): self.file_hash = hash(file_handle.read()) self.file_size = os.stat(self._get_full_url).st_size - def _grab_metadata_echonest(self): - "Populate this song's metadata using EchoNest" - pass - def _grab_metadata_local(self): "Populate this song's metadata using what is locally available" #Use mutagen to get the song metadata @@ -145,8 +244,10 @@ class Song (models.Model): #Couldn't grab the local data return False - def populate_metadata(self, use_echonest = False): - "Populate the metadata of this song (only if file hash has changed), and save the result." + def populate_metadata(self): + """ + Populate the metadata of this song (only if file hash has changed), and save the result. + """ if self._file_not_changed(): return @@ -157,6 +258,15 @@ class Song (models.Model): else: self._grab_metadata_local() - def convert(self, output_location, output_format, progress_func = lambda x, y: None): - "Convert a song to a new format." + def convert(self, output_location, output_format): + """ + Convert a song to a new format. + + :param output_location: String URL of where the resulting file should be stored + :param output_format: Output format of the resulting file + + .. todo:: + + Actually write the code to convert files, or abandon if necessary + """ pass #Need to get pydub code in place diff --git a/doc/conf/conf.py b/doc/conf/conf.py index b4138fb..e2a1a00 100644 --- a/doc/conf/conf.py +++ b/doc/conf/conf.py @@ -26,7 +26,13 @@ sys.path.insert(0, os.path.abspath('../..')) # Django project root # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', 'sphinx.ext.todo'] + +# Enable TODO support +todo_include_todos = True + +# Document class members in source order +autodoc_member_order = 'bysource' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/doc/docs/archiver.models.rst b/doc/docs/archiver.models.rst new file mode 100644 index 0000000..cfeba98 --- /dev/null +++ b/doc/docs/archiver.models.rst @@ -0,0 +1,39 @@ +models Package +============== + +:mod:`models` Package +--------------------- + +.. automodule:: archiver.models + :members: + :undoc-members: + :show-inheritance: + +:mod:`archive` Module +--------------------- + +.. automodule:: archiver.models.archive + :members: + :show-inheritance: + +:mod:`feed` Module +------------------ + +.. automodule:: archiver.models.feed + :members: + :show-inheritance: + +:mod:`playlist` Module +---------------------- + +.. automodule:: archiver.models.playlist + :members: + :show-inheritance: + +:mod:`song` Module +------------------ + +.. automodule:: archiver.models.song + :members: + :show-inheritance: + diff --git a/doc/docs/archiver.rst b/doc/docs/archiver.rst index 13e564c..5a4c71a 100644 --- a/doc/docs/archiver.rst +++ b/doc/docs/archiver.rst @@ -1,5 +1,42 @@ -==== -Archive backend documentation -==== +archiver Package +================ + +:mod:`archiver` Package +----------------------- + +.. automodule:: archiver.__init__ + :members: + :undoc-members: + :show-inheritance: + +:mod:`listfield` Module +----------------------- + +.. automodule:: archiver.listfield + :members: + :undoc-members: + :show-inheritance: + +:mod:`tests` Module +------------------- + +.. automodule:: archiver.tests + :members: + :undoc-members: + :show-inheritance: + +:mod:`views` Module +------------------- + +.. automodule:: archiver.views + :members: + :undoc-members: + :show-inheritance: + +Subpackages +----------- + +.. toctree:: + + archiver.models -.. automodule:: archiver diff --git a/doc/docs/modules.rst b/doc/docs/modules.rst new file mode 100644 index 0000000..e93c9ef --- /dev/null +++ b/doc/docs/modules.rst @@ -0,0 +1,7 @@ +archiver +======== + +.. toctree:: + :maxdepth: 4 + + archiver