mirror of
				https://github.com/bspeice/Melodia
				synced 2025-11-04 02:10:42 -05:00 
			
		
		
		
	Support reorganizing an archive, begin playlist
Also add more fields to the DB representation of a song.
This commit is contained in:
		@ -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
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user