from __future__ import unicode_literals
from __future__ import print_function
from __future__ import absolute_import
from . import logger
from ..db import models
from .. import config
import os
import functools
import datetime
logger = logger.getChild('tracks')
[docs]def requires_track(func):
"""
Decorator that raises :class:`NoTrackEntry` if the song instance has no
associated audio file in the database.
Currently this only checks if `self._track` is falsy.
"""
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
if not self._track:
raise NoTrackEntry()
return func(self, *args, **kwargs)
return wrapper
[docs]class Track(object):
"""
An instance of a known track in our database. This can also be used for
adding new tracks.
A 'known' track is one we have seen before. This means there is no
difference between tracks we have an audio file of and ones we only know
metadata of. The object easily allows you to check if it has a corresponding
audio file available or not.
"""
# This is a dictionary of {'keyword': 'type'} items in it.
# 'type' is the type 'keyword' should have when passed,
# set 'type' to None to not do any checking at all.
# the value of 'type' is passed to :func:`isinstance` without change and
# can thus be a sequence of types.
_accepted_keywords = {
'length': (int, long, float),
'last_requested': datetime.datetime,
'filename': unicode,
'change': bool,
}
def __init__(self, meta, **kwargs):
"""
The constructor accepts only a metadata string and extra values to set
on the new Track object.
The extra keyword arguments won't be used if the track already exists
unless 'change=:const:`True`' is given as one of the keyword arguments.
.. note::
For constructing from a database primary key see the :meth:`from_id`
class method below.
Required parameters:
:param meta: An unicode string of metadata.
Extra keyword arguments are:
:param integer length: Defines the length of the track.
:param last_requested: Same as `last_played` but then for last requested.
:param unicode filename: A filename that points to an audio file for this Track.
:param bool change: Flag if you want to force update the storage with your extra arguments.
"""
super(Track, self).__init__()
self._plays = None
self._requests = None
# Try loading from the database first.
if self._load_from_database(meta):
# If we have a force change flag we shouldn't return right away.
# Since we need to handle all the extra values done below.
if not kwargs.get('change'):
return
# Handle our extra values.
for key, value in kwargs.iteritems():
keytype = self._accepted_keywords.get(key)
if keytype and isinstance(value, keytype):
setattr(key, value)
else:
# Argument is of the wrong type.
if keytype:
raise TypeError(("Argument '{key:s}' is not of the correct "
"type. Expected '{expected!s}' got "
"'{type!s}' instead.").format(
key=key,
expected=keytype,
type=type(value)
)
)
# Argument doesn't exist
# this should NOT be raised if it's a deprecated argument.
else:
raise AttributeError(("Unknown argument '{key:s}' passed "
"to Track.").format(key=key))
@classmethod
[docs] def from_track_id(cls, id):
"""
Returns an instance based on the `tracks` table ID.
.. warning::
Don't use this method in production code.
"""
result = models.Track.get(models.Track.id == id)
return cls(create_metadata_string(result))
@classmethod
[docs] def from_esong_id(cls, id):
"""
Returns an instance based on the `esong` table ID.
.. warning::
Don't use this method in production code.
"""
result = models.Song.get(models.Song.id == id)
return cls(result.meta)
def _load_from_database(self, metadata):
"""
Internal method to load a track from the database.
This checks if there is a matching record in the database.
:param unicode metadata: A string of metadata.
:returns: Boolean indicating if there was a matching record or not.
:rtype: bool
"""
import peewee
song_query = models.Song.query_from_meta(metadata)
song_query.join(models.Play, peewee.JOIN_LEFT_OUTER).annotate(
models.Song,
peewee.fn.Max(models.Play.time).alias('last_played')
)
try:
self._song = song_query.get()
except models.Song.DoesNotExist:
self._song = models.Song()
result = False
else:
result = True
try:
self._track = models.Track.from_meta(metadata)
except models.Track.DoesNotExist:
self._track = None
return result
@property
def metadata(self):
"""
:returns: A metadata string of '[artist -] title' where artist is optional
:rtype: unicode
.. note::
This uses the `tracks` table if available before trying the other
table.
"""
if self._track:
artist = self._track.artist
title = self._track.title
if artist:
metadata = "{0:s} - {1:s}".format(artist, title)
else:
metadata = title
return metadata
return self._song.meta or ''
@metadata.setter
@property
@requires_track
def artist(self):
"""
:returns: The artist of this song.
:rtype: unicode
"""
return self._track.artist
@artist.setter
@requires_track
[docs] def artist(self, value):
"""
Sets the new artist of this song
:params unicode value: The artist
"""
# TODO: This doesn't change the `esong` table.
self._track.artist = value
@property
@requires_track
def title(self):
"""
:returns: The title of this song.
:rtype: unicode
"""
return self._track.title
@title.setter
@requires_track
[docs] def title(self, value):
"""
Sets the new title of this song.
:params unicode value: The new title.
"""
# TODO: This doesn't change the `esong` table.
self._track.title = value
@property
def length(self):
"""
:returns: Length of the song or 0 if none available.
:rtype: :class:`Length`
.. note::
The song length is only 100% accurate if the song has an audio
file available. Otherwise it's an approximation from when it was
last played.
"""
length = self._song.length
if length:
return Length(length)
return Length(0)
@length.setter
[docs] def length(self, value):
"""
Sets the length of the song.
:params integer value: The new length to set.
.. note::
:class:`Length` is a subclass of :const:`int` and can also be
used as value.
"""
self._song.length = int(value)
@property
[docs] def plays(self):
"""
:returns: A mutable object with all the playing data in it.
:rtype: :class:`Plays`
"""
if self._plays is None:
self._plays = Plays(self, (play.time for play in self._song.plays))
return self._plays
@property
@requires_track
[docs] def requests(self):
"""
:returns: A mutable objects with all request data in it.
:rtype: :class:`Requests`
:raises: :class:`NoTrackEntry` if the song has no audio file.
"""
if self._requests is None:
self._requests = Requests([self._track.last_requested])
return self._requests
@property
@requires_track
def filename(self):
"""
:returns unicode: The filename of the audio file.
:raises: :class:`NoTrackEntry` if the song has no audio file.
.. note::
This is relative to the configured `media.directory` configuration.
"""
return self._track.filename
@filename.setter
@requires_track
[docs] def filename(self, value):
"""
:params unicode value: A new filename to set.
:raises: :class:`NoTrackEntry` if the song has no audio file.
"""
self._track.filename = value
@property
@requires_track
[docs] def filepath(self):
"""
:returns unicode: The full path to the audio file.
:raises: :class:`NoTrackEntry` if the song has no audio file.
"""
return os.path.join(config.get('media', 'directory'), self.filename)
@requires_track
[docs] def open(self, mode='rb'):
"""
Opens the associated file and returns a file object.
This handles the path finding for you.
:params unicode mode: The mode to be passed to the :func:`open` call.
:returns: An open file object.
:raises: :class:`NoTrackEntry` if the song has no audio file.
"""
return open(self.filepath, mode)
[docs] def save(self):
"""
Saves all the changes done so far on this object into the database.
.. note::
This method can do multiple queries to the database depending on
the changes done on the object.
"""
if self._track:
self._track.save()
if self._song:
self._song.save()
[docs]class NoTrackEntry(Exception):
"""
Raised when a :class:`Song` instance accesses Track only attributes
without having an audio file attached to it.
"""
pass
[docs]class Length(int):
"""
A simple subclass of :const:`int` to support formatting on it without
having to know the exact format or value in the rest of the code.
"""
[docs]class Plays(list):
"""
A simple subclass of :const:`list` to support some extra attributes.
This class is returned when you access :attr:`Track.plays` and is a
collection of play times of the :class:`Track` in question.
The collection contains :class:`datetime.datetime` objects or objects that
act the same as such with extra methods (for future additions).
"""
def __init__(self, song, sequence):
super(Plays, self).__init__(sequence)
self.new_values = list()
self.deleted_values = list()
self.song = song
@property
[docs] def last(self):
"""
:returns: The time that last occured.
:rtype: :class:`datetime.datetime` object.
"""
return max(self)
[docs] def add(self, time, dj=None):
"""
Adds a played entry to the :class:`Track` object.
The exact time it was played at.
:params time: A :class:`datetime.datetime` instance.
The DJ that played this track at the time.
:params dj: A :class:`hanyuu.abstractions.users.DJ` instance.
:returns None:
.. note::
It's good practice to add the DJ argument to all the code already.
The current database however ignores this argument.
"""
self.new_values.append(time)
self.append(time)
[docs] def remove(self, time, dj=None):
"""
Removes a played entry from the :class:`Track` object.
The time this was played at.
:params time: A :class:`datetime.datetime` instance.
The DJ that played this track at the time.
If this is :const:`None` it will be ignored otherwise it will be used
for exact matching.
:params dj: A :class:`hanyuu.abstractions.users.DJ` instance.
:returns None:
.. note::
Currently the `dj` argument is completely ignored.
"""
self.deleted_values.append(time)
self.remove(time)
[docs] def save(self):
"""
Saves changes to the database.
"""
[docs]class Requests(list):
"""
A simple subclass of :const:`list` to support some extra attributes.
"""
@property
[docs] def last(self):
"""
:returns: The time that last occured.
:rtype: :class:`datetime.datetime` object.
"""
return max(self)