mirror of
https://github.com/bspeice/Melodia
synced 2024-12-25 08:08: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)
|
||||
|
||||
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
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.
|
||||
"""
|
||||
|
||||
_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
|
||||
@ -34,6 +38,10 @@ class Song (models.Model):
|
||||
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)
|
||||
@ -77,6 +85,10 @@ class Song (models.Model):
|
||||
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
|
||||
|
Loading…
Reference in New Issue
Block a user