Ejemplo n.º 1
0
class MusicGenTests(unittest.TestCase):

	def setUp(self):
		self.musicgen     = MusicGen()
		self.filetypes    = ['mp3', 'm4a', 'flac']
		self.audio = {
			'mp3': {
				'cover':    'tests/audio/mp3/14 Betelgeuse_36.mp3',
				'no_cover': 'tests/audio/mp3/17 Eructation concertmatienne.mp3'
			},
			'm4a': {
				'cover':    'tests/audio/m4a/14 Betelgeuse_36.m4a',
				'no_cover': 'tests/audio/m4a/17 Eructation concertmatienne.m4a'
			},
			'flac': {
				'cover':    'tests/audio/flac/14 Betelgeuse_36.flac',
				'no_cover': 'tests/audio/flac/17 Eructation concertmatienne.flac'
			}
		}
		self.tmp_dir      = 'tests/tmp'
		self.out_file     = self.tmp_dir + '/out.jpg'
		self.art_file     = 'tests/artwork/art.png'

		if not path.exists(self.tmp_dir):
			makedirs(self.tmp_dir)

	def tearDown(self):
		# Delete the tmp directory
		shutil.rmtree('tests/tmp')

		if path.isfile(self.out_file):
			remove(self.out_file)

	def test_extract_cover_art_coverless(self):
		"""Tests extracting cover art from a file with no artwork."""

		for filetype in self.filetypes:
			self.assertIsNone(
				self.musicgen.extract_cover_art(self.audio[filetype]['no_cover']),
				'Coverless {} returned cover art data'.format(filetype))

			self.assertIsNone(
				self.musicgen.extract_cover_art(self.audio[filetype]['no_cover'], self.out_file),
				'Coverless {} returned cover art data when saving to file'.format(filetype))

			self.assertFalse(
				path.isfile(self.out_file),
				'Extracting from coverless {} file to output file created output file'.format(filetype))

	def test_extract_cover_art(self):
		"""Tests extracting cover art from a file with artwork."""

		for filetype in self.filetypes:
			self.assertIsNotNone(
				self.musicgen.extract_cover_art(self.audio[filetype]['cover']),
				'{} did not return cover art data'.format(filetype))

			self.assertIsNone(
				self.musicgen.extract_cover_art(self.audio[filetype]['cover'], self.out_file),
				'{} returned cover art data when saving to file'.format(filetype))

			self.assertTrue(
				path.isfile(self.out_file),
				'Extracting from {} file to output file did not create output file'.format(filetype))

			self.assertEqual(
				self.musicgen.extract_cover_art(self.audio[filetype]['cover']),
				open(self.out_file, 'rb').read(),
				'Saved {} cover art differs from embedded artwork'.format(filetype))

	def test_embed_cover_art_coverless(self):
		"""Tests embedding cover art in a coverless audio file with an image file."""

		for filetype in self.filetypes:
			# Copy the audio file to the tmp dir
			tmp_file = path.join(self.tmp_dir, path.basename(self.audio[filetype]['no_cover']))
			shutil.copyfile(self.audio[filetype]['no_cover'], tmp_file)

			self.assertEqual(
				self.musicgen.extract_cover_art(self.audio[filetype]['no_cover']),
				self.musicgen.extract_cover_art(tmp_file),
				'Copied {} cover art differs from original artwork'.format(filetype))

			self.musicgen.embed_cover_art(tmp_file, self.art_file)

			self.assertEqual(
				self.musicgen.extract_cover_art(tmp_file),
				open(self.art_file, 'rb').read(),
				'Newly embedded {} cover art is not same as cover art image file'.format(filetype))

			self.assertNotEqual(
				self.musicgen.extract_cover_art(self.audio[filetype]['cover']),
				self.musicgen.extract_cover_art(tmp_file),
				'Newly embedded {} cover art does not differ from original artwork'.format(filetype))

	def test_embed_cover_art(self):
		"""Tests embedding cover art in an audio file with an image file."""

		for filetype in self.filetypes:
			# Copy the audio file to the tmp dir
			tmp_file = path.join(self.tmp_dir, path.basename(self.audio[filetype]['cover']))
			shutil.copyfile(self.audio[filetype]['cover'], tmp_file)

			self.assertEqual(
				self.musicgen.extract_cover_art(self.audio[filetype]['cover']),
				self.musicgen.extract_cover_art(tmp_file),
				'Copied {} cover art differs from original artwork'.format(filetype))

			self.musicgen.embed_cover_art(tmp_file, self.art_file)

			self.assertEqual(
				self.musicgen.extract_cover_art(tmp_file),
				open(self.art_file, 'rb').read(),
				'Newly embedded {} cover art is not same as cover art image file'.format(filetype))

			self.assertNotEqual(
				self.musicgen.extract_cover_art(self.audio[filetype]['cover']),
				self.musicgen.extract_cover_art(tmp_file),
				'Newly embedded {} cover art does not differ from original artwork'.format(filetype))
Ejemplo n.º 2
0
class AudioManager(object):
	"""
	Provides an interface to a running MPD instance.

	This class also accepts callbacks to be run after the following events are fired:
		'song change': Currently playing song has changed.
		               Callback should accept current song dict as its only argument.

	Callbacks can be registered as per the following example:

		music = SongData(config)

		@music.on('song change')
		def handle_song_change(song):
			print('Song is now {}.'.format(song['title']))

	Properties:
		current_song (dict): Information on the currently playing song.
		                     This dict includes the following keys:
							 artwork:    The URL for the song's artwork.
							 title:      The title of the current song.
							 artist:     The artist of the current song.
							 album:      The album of the current song.
							 length_sec: The length of the song in seconds.
							 time_sec:   The amount of time into the song, in seconds.
							 start_time: UNIX timestamp for when the song began playing.
							 length:     The length of the song as a time string.
							 time:       The amount of time into the song, as a time string.
							 progress:   The percentage of the amount of time into the song.
							 is_playing: True if the song is playing, False otherwise.
	"""

	def __init__(self, config):
		"""
		Creates a new interface to a running MPD instance.

		Arguments:
			config (dict): A dictionary of config values.
			               This is expected to include the following keys:
						   MPD_HOST:           The hostname of the MPD instance.
						   MPD_PORT:           The port that the MPD instance is running on.
						   MUSIC_DIR:          The directory that MPD looks for music in.
						   DEFAULT_ARTWORK:    The URL for default album artwork.
						   COVERS_DIR:         The directory to save album covers to.
						   COVERS_FILETYPE:    The file format to save album covers in.
						   AUDIO_EXTENSIONS:   List of allowed audio file extensions.
						   ARTWORK_EXTENSIONS: List of allowed artwork file extensions.
		"""
		self._locks = []
		self._callbacks = {}
		self._idling = False
		self._config = config
		self._mpd = MPDClient()
		self._musicgen = MusicGen()

		self._mpd.connect(config['MPD_HOST'], config['MPD_PORT'])

		self.current_song = None

		# Spin off a thread to wait for changes in MPD subsystems
		self._mpd_thread = Thread(target=self._mpd_idle, name='mpd-worker', args=())
		self._mpd_thread.setDaemon(True)
		self._mpd_thread.start()



	def on(self, name):
		"""
		Decorator for adding a callback method for events.

		Example:
			music = SongData(config)

			@music.on('song change')
			def handle_song_change(song):
				print('Song is now {}.'.format(song['title']))

		Arguments:
			name (str): The name of the event to register the callback for.
		"""
		def func_wrapper(func):
			self._callbacks[name] = func
			return func
		return func_wrapper

	def fire_event(self, name, *args, **kwargs):
		"""
		Fires the given event, passing the given arguments to the callback.

		Arguments:
			name (str): The name of the event to fire.
		"""
		func = self._callbacks.get(name, None)
		if not func is None:
			return func(*args, **kwargs)



	def _mpd_acquire(self):
		"""
		Allows MPD commands to be executed by the main thread.
		mpd_release() must be called afterwards to allow the idle
		thread to continue polling.
		"""
		self._locks.append(1)
		if (self._idling):
			self._mpd.noidle()
			self._idling = False

	def _mpd_release(self):
		"""Allows the idle thread to continue waiting for subsystem changes."""
		self._locks.pop()
		if (not self._locks and not self._idling):
			self._mpd.send_idle()
			self._idling = True

	def _mpd_idle(self):
		"""
		Calls `mpd idle`, which waits for a change in an MPD subsystem.
		When a change is detected, connected clients are notified and
		`mpd idle` is called again.
		"""
		self._update_current_song()
		self._mpd.send_idle()
		self._idling = True

		while True:
			can_read = select([self._mpd], [], [], 0)[0]
			if can_read and not self._locks:
				self._idling = False
				changes = self._mpd.fetch_idle()

				if 'player' in changes:
					self._update_current_song()

				self._mpd.send_idle()
				self._idling = True

			time.sleep(1)



	def seconds_to_string(self, seconds):
		"""
		Converts seconds into a time string.

		Arguments:
			seconds (int): The total number of seconds.

		Returns:
			A time string as hh:mm:ss, or mm:ss if there are no hours.
		"""
		m, s = divmod(int(float(seconds)), 60)
		h, m = divmod(m, 60)

		if h:
			return '{:d}:{:02d}:{:02d}'.format(h, m, s)
		return '{:d}:{:02d}'.format(m, s)



	def play(self):
		"""Plays the current song"""
		self._mpd_acquire()

		self._mpd.play()
		self._update_current_song()

		self._mpd_release()

	def pause(self):
		"""Pauses the current song"""
		self._mpd_acquire()

		self._mpd.pause()
		self._update_current_song()

		self._mpd_release()

	def play_previous_song(self):
		"""Plays the previous song."""
		self._mpd_acquire()

		self._mpd.previous()
		self._update_current_song()

		self._mpd_release()

	def play_next_song(self):
		"""Plays the next song."""
		self._mpd_acquire()

		self._mpd.next()
		self._update_current_song()

		self._mpd_release()


	def add_new_song(self, filename):
		"""
		Updates the database and add a new file to the current playlist.

		Arguments:
			filename (str): The name of the file relative to the music directory.

		Returns:
			A dict of song data for the added song.
		"""
		self._mpd_acquire()

		self._mpd.update()
		self._mpd.idle('database') # Wait for the database to be updated
		self._mpd.add(filename)

		song = self._mpd.find('filename', filename)[0]

		self._mpd_release()

		return song

	def is_allowed_audio_file(self, filename):
		"""Returns True if the filename has an allowed audio extension."""
		return '.' in filename and filename.rsplit('.', 1)[1] in self._config['AUDIO_EXTENSIONS']

	def is_allowed_artwork_file(self, filename):
		"""Returns True if the filename has an allowed artwork extension."""
		return '.' in filename and filename.rsplit('.', 1)[1] in self._config['ARTWORK_EXTENSIONS']



	def _get_album_artwork_url(self, song_file):
		"""Returns the URL for the currently playing song's artwork.

		If the artwork does not already exist on disk, it will be
		extracted from the audio file. A resized version of the
		artwork will be created and used to reduce bandwidth.

		Arguments:
			song_file (str): The filename of the audio file.

		Returns:
			A string containing the URL for the resized artwork.
		"""
		song_file        = current.get('file', None)
		song_path        = path.join(self._config['MUSIC_DIR'], song_file)

		# The image filename is a hash of the song's filename
		file_hash        = md5(song_file).hexdigest()
		file_hash_path   = path.join(self._config['COVERS_DIR'], file_hash)
		image_file       = file_hash_path + self._config['COVERS_FILETYPE']

		# The resized image filename is {image_filename}_{width}_{height}
		resized_filename = file_hash_path + '_' + '_'.join(map(str, self._config['COVERS_SIZE']))
		resized_file     = resized_filename + self._config['COVERS_FILETYPE']

		if not path.isfile(image_file):
			self._musicgen.extract_cover_art(song_path, image_file)

			resize = Image.open(image_file)
			resize.thumbnail(self._config['COVERS_SIZE'])
			resize.save(resized_file)

		return resized_file

	def change_album_artwork(self, song_file, artwork_file):
		"""Embeds the given artwork in the given song file.
		The artwork file will then be deleted once embedded.

		Arguments:
			song_file (str): The filename of the song to modify the cover art of.
			artwork_file (str): The path to the artwork file to embed.
		"""
		song_path = path.join(self._config['MUSIC_DIR'], song_file)

		# The image filename is a hash of the song's filename
		file_hash      = md5(song_file).hexdigest()
		file_hash_path = path.join(self._config['COVERS_DIR'], file_hash)
		image_file     = file_hash_path + self._config['COVERS_FILETYPE']

		self._musicgen.embed_cover_art(song_path, image_file)

		# Remove existing cached artwork
		if path.isfile(image_file):
			# The resized image filename is {image_filename}_{width}_{height}
			resized_filename = file_hash_path + '_' + '_'.join(map(str, self._config['COVERS_SIZE']))
			resized_file     = resized_filename + self._config['COVERS_FILETYPE']

			remove(resized_file)
			remove(image_file)

		remove(artwork_file)

		# Update the data for the current song
		self._mpd_acquire()
		self._update_current_song(reset_cache=True)
		self._mpd_release()

	def _update_current_song(self, reset_cache=False):
		"""
		Updates the `current_song` global to contain updated information
		about the currently playing song.
		"""
		current = self._mpd.currentsong()
		status  = self._mpd.status()
		timestamp = time.time()
		cache_control = ''

		if reset_cache:
			cache_control = '?=' + str(time.time())

		self.current_song = {
			'artwork':    self._get_album_artwork_url(current['file']) + cache_control,
			'file':       current['file'],
			'title':      current['title'].decode('utf-8'),
			'artist':     current.get('artist', 'Unknown Artist').decode('utf-8'),
			'album':      current['album'].decode('utf-8'),
			'length_sec': current['time'],
			'time_sec':   status['elapsed'],
			'start_time': timestamp - int(float(status['elapsed'])),
			'length':     self.seconds_to_string(current['time']),
			'time':       self.seconds_to_string(status['elapsed']),
			'progress':   float(status['elapsed']) / float(current['time']) * 100,
			'is_playing': True if status['state'] == 'play' else False
		}

		self.fire_event('song change', self.current_song)