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)
 | 
											self.songs.add(new_song)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	def _update_song_metadata(self, use_echonest = False, progress_callback = lambda x, y: None):
 | 
						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
 | 
							#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
 | 
							#sure that new songs are added, use the _scan_filesystem() method in addition
 | 
				
			||||||
		total_songs  = self.songs.count()
 | 
							total_songs  = self.songs.count()
 | 
				
			||||||
@ -127,3 +128,68 @@ class Archive (models.Model):
 | 
				
			|||||||
		if force_backup or self._needs_backup():
 | 
							if force_backup or self._needs_backup():
 | 
				
			||||||
			import subprocess
 | 
								import subprocess
 | 
				
			||||||
			subprocess.call(['rsync', '-av', self.root_folder, self.backup_location])
 | 
								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.
 | 
					and helps in doing sorting etc.
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_default_title        = "<UNAVAILABLE>"
 | 
					_default_title        = "_UNAVAILABLE_"
 | 
				
			||||||
_default_artist       = "<UNAVAILABLE>"
 | 
					_default_artist       = "_UNAVAILABLE_"
 | 
				
			||||||
_default_album        = "<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_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_bpm          = -1
 | 
				
			||||||
 | 
					_default_disc_number  = -1
 | 
				
			||||||
 | 
					_default_disc_total   = -1
 | 
				
			||||||
 | 
					_default_track_number = -1
 | 
				
			||||||
 | 
					_default_track_total  = -1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_default_bit_rate         = -1
 | 
					_default_bit_rate         = -1
 | 
				
			||||||
_default_duration         = -1
 | 
					_default_duration         = -1
 | 
				
			||||||
@ -28,12 +32,16 @@ class Song (models.Model):
 | 
				
			|||||||
	"""
 | 
						"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	#Standard user-populated metadata
 | 
						#Standard user-populated metadata
 | 
				
			||||||
	title        = models.CharField(max_length = 64, default = _default_title)
 | 
						title        = models.CharField(max_length = 64, default            = _default_title)
 | 
				
			||||||
	artist       = models.CharField(max_length = 64, default = _default_artist)
 | 
						artist       = models.CharField(max_length = 64, default            = _default_artist)
 | 
				
			||||||
	album        = models.CharField(max_length = 64, default = _default_album)
 | 
						album        = models.CharField(max_length = 64, default            = _default_album)
 | 
				
			||||||
	release_date = models.DateField(default = _default_release_date)
 | 
						release_date = models.DateField(default    = _default_release_date)
 | 
				
			||||||
	genre        = models.CharField(max_length = 64, default = _default_genre)
 | 
						genre        = models.CharField(max_length = 64, default            = _default_genre)
 | 
				
			||||||
	bpm          = models.IntegerField(default = _default_bpm)
 | 
						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
 | 
						#File metadata
 | 
				
			||||||
	bit_rate         = models.IntegerField(default = _default_bit_rate)
 | 
						bit_rate         = models.IntegerField(default = _default_bit_rate)
 | 
				
			||||||
@ -73,11 +81,15 @@ class Song (models.Model):
 | 
				
			|||||||
				track                 = audiotools.open(self.url)
 | 
									track                 = audiotools.open(self.url)
 | 
				
			||||||
				track_metadata        = track.get_metadata()
 | 
									track_metadata        = track.get_metadata()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				self.title            = track_metadata.track_name  or _default_title
 | 
									self.title        = track_metadata.track_name  or _default_title
 | 
				
			||||||
				self.artist           = track_metadata.artist_name or _default_artist
 | 
									self.artist       = track_metadata.artist_name or _default_artist
 | 
				
			||||||
				self.album            = track_metadata.album_name  or _default_album
 | 
									self.album        = track_metadata.album_name  or _default_album
 | 
				
			||||||
				self.release_date     = datetime.date(int(track_metadata.year or 1), 1, 1)
 | 
									self.release_date = datetime.date(int(track_metadata.year or 1), 1, 1)
 | 
				
			||||||
				self.bpm              = _default_bpm
 | 
									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.bit_rate         = track.bits_per_sample() or _default_bit_rate
 | 
				
			||||||
				self.duration         = int(track.seconds_length()) or _default_duration
 | 
									self.duration         = int(track.seconds_length()) or _default_duration
 | 
				
			||||||
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user