Support reorganizing an archive, begin playlist

Also add more fields to the DB representation of a song.
master
Bradlee Speice 2012-12-26 18:37:14 -05:00
parent 22bbc568e7
commit 4257e33f38
4 changed files with 239 additions and 15 deletions

View File

@ -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

48
archiver/listfield.py Normal file
View 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
View 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

View File

@ -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 = "<UNAVAILABLE>"
_default_artist = "<UNAVAILABLE>"
_default_album = "<UNAVAILABLE>"
_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 = "<UNAVAILABLE>"
_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