Permalink
Browse files

Built out database schema based on ID3 tag structure. Since this is t…

…he most common type of music file, it's a decent basis for the first pass at our schema. Also refactored database class to follow naming conventions and eliminate race conditions
  • Loading branch information...
1 parent f8c7f29 commit 67fd956be63f113a3469a8e1095b481f8e9cf909 Jonathan Fritz committed Sep 28, 2012
Showing with 238 additions and 57 deletions.
  1. +4 −4 musik.py
  2. +194 −25 musik/db.py
  3. +29 −20 musik/library/importer.py
  4. +3 −1 musik/web/api.py
  5. +8 −7 musik/web/application.py
View
@@ -1,10 +1,10 @@
-import musik.web.application
-import musik.library.importer
-from musik import initLogging
-
import signal
import sys
+from musik import initLogging
+import musik.library.importer
+import musik.web.application
+
# cleans up and safely stops the application
def cleanup(signum=None, frame=None):
View
@@ -1,17 +1,24 @@
-from sqlalchemy import create_engine
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy import Column
-from sqlalchemy.types import String, Integer, DateTime
-from sqlalchemy.orm import sessionmaker
-
from datetime import datetime
import os, os.path
+from sqlalchemy import Column, create_engine, ForeignKey
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.types import String, Integer, DateTime, Boolean, BigInteger
+from sqlalchemy.orm import backref, relationship, sessionmaker
+
+
# Helper to map and register a Python class a db table
Base = declarative_base()
+
# Represents an import task
class ImportTask(Base):
+ """An import task is any operation that results in the import of a media
+ file into the library from some uri.
+ At this time, only local directories and files are supported, but in the
+ future we may support YouTube videos, SoundCloud files, or files hosted
+ on HTTP, FTP, and SSH servers.
+ """
__tablename__ = 'import_tasks'
id = Column(Integer, primary_key=True)
uri = Column(String)
@@ -36,41 +43,203 @@ def __str__(self):
return unicode(self).encode('utf-8')
-# Represents a media file on the local hard drive
-class Media(Base):
- __tablename__ = 'media'
- id = Column(Integer, primary_key=True)
- uri = Column(String)
+class Artist(Base):
+ """An artist is the person or persons responsible for creating some
+ aspect of a Track.
+ Tracks have artists, composers, conductors, lyricists, etc.
+ Internally, all are treated as artists and foreign key to this table.
+ """
+ __tablename__ = 'artists'
+ id = Column(Integer, primary_key=True) # unique id
+ name = Column(String) # artist name
+ name_sort = Column(String) # sortable artist name
+ musicbrainz_artistid = Column(String) # unique 36-digit musicbrainz hex string
+
+ # def _get_tracks(self):
+ # return object_session(self).query(Address).with_parent(self).filter(...).all()
+ # tracks = property(_get_tracks)
+
+ def __init__(self, name):
+ Base.__init__(self)
+ self.name = name
+ self.name_sort = name
+
+ def __unicode__(self):
+ return u'<Artist(name=%s)>' % self.name
+
+ def __str__(self):
+ return unicode(self).encode('utf-8')
+
+
+class Album(Base):
+ """An album is a platonic ideal of a collection of released songs.
+ Back in the day, the only way to get songs was on some physical medium.
+ Modern listeners may not identify with albums in the classical sense, so
+ this class is not intended to represent a physical item, but rather a
+ collection of related songs that may or may not have a physical representation.
+ """
+ __tablename__ = 'albums'
+ id = Column(Integer, primary_key=True) # unique id
+ title = Column(String) # the title of the album
+ title_sort = Column(String) # sortable title of the album
+ barcode = Column(String) # physical album barcode
+ compilation = Column(Boolean) # whether or not this album is a compilation
+ media_type = Column(String) # the type of media (CD, etc)
+ musicbrainz_albumid = Column(String) # unique 36-digit musicbrainz hex string
+ musicbrainz_albumstatus = Column(String)# unique 36-digit musicbrainz hex string
+ musicbrainz_albumtype = Column(String) # unique 36-digit musicbrainz hex string
+ organization = Column(String) # organization that released the album (usually a record company)
+ releasecountry = Column(String) # the country that this album was released in
+
+ def __init__(self, title):
+ Base.__init__(self)
+ self.title = title
+ self.title_sort = title
+
+ def __unicode__(self):
+ return u'<Album(title=%s)>' % self.title
+
+ def __str__(self):
+ return unicode(self).encode('utf-8')
+
+
+class Disc(Base):
+ """A disc is a physical platter that comprises a part of the physical
+ manifestation of an Album. Physical albums must have at least one disc,
+ while digital releases will not have any.
+ CD - Pink Floyd's The Wall was released on two distinct discs
+ LP - Pink Floyd's The Wall was released on two platters, each with two
+ sides. We represent this with four discs, one for each side.
+ Cassette - Pink Floyd's The Wall was released on two cassette tapes,
+ each with two sides. We represent this with four discs, one
+ for each side.
+ """
+ __tablename__ = 'discs'
+
+ # columns
+ id = Column(Integer, primary_key=True) # unique id
+ album_id = Column(Integer, ForeignKey('albums.id')) # the album that this disc belongs to
+ discnumber = Column(Integer) # the play order of this disc in the collection
+ disc_subtitle = Column(String) # the subtitle (if applicable) of this disc
+ musicbrainz_discid = Column(String) # unique 36-digit musicbrainz hex string
+
+ # relationships
+ album = relationship('Album', backref=backref('discs', order_by=discnumber))
+
+ def __init__(self, discnumber):
+ Base.__init__(self)
+ self.discnumber = discnumber
+
+ def __unicode__(self):
+ return u'<Disc(album_id=%i, discnumber=%i)>' % (self.album_id, self.discnumber)
+
+ def __str__(self):
+ return unicode(self).encode('utf-8')
+
+
+class Track(Base):
+ """A track is a single audio file, which usually corresponds to a distinct
+ song, comedy routine, or book chapter. Tracks can be grouped into Albums,
+ and are usually created by one or more Artists.
+ """
+ __tablename__ = 'tracks'
+
+ # columns
+ id = Column(Integer, primary_key=True) # unique id
+ uri = Column(String) # physical location of the track file
+ artist_id = Column(Integer, ForeignKey('artists.id')) # the artist that recorded the track
+ album_id = Column(Integer, ForeignKey('albums.id')) # the album that contains the track
+ album_artist_id = Column(Integer, ForeignKey('artists.id')) # the artist that released the album
+ arranger_id = Column(Integer, ForeignKey('artists.id')) # the artist that arranged the track
+ bpm = Column(Integer) # beats per minute
+ composer_id = Column(Integer, ForeignKey('artists.id')) # the artist that composed the track
+ conductor_id = Column(Integer, ForeignKey('artists.id')) # the artist that conducted the track
+ copyright = Column(String) # copyright information
+ date = Column(String) # date that the track was released
+ disc_id = Column(Integer, ForeignKey('discs.id')) # disc of the album that the track appeared on
+ encodedby = Column(String) # encoder that created the digital file
+ genre = Column(String) # genre of track contents
+ isrc = Column(String) # ISO 3901 12-character International Standard Recording Code
+ length = Column(BigInteger) # length of the track in milliseconds
+ lyricist_id = Column(Integer, ForeignKey('artists.id')) # the artist that wrote the lyrics of the track
+ mood = Column(String) # description of the mood of the track
+ musicbrainz_trackid = Column(String) # unique 36-digit musicbrainz hex string
+ musicbrainz_trmid = Column(String) # semi-unique trm fingerprint
+ musicip_fingerprint = Column(String) # unique musicip (gracenote) fingerprint
+ musicip_puid = Column(String) # unique musicip (gracenote) id
+ performer_id = Column(Integer, ForeignKey('artists.id')) # artist that performed the track
+ title = Column(String) # title of the track
+ title_sort = Column(String) # sortable title of the track
+ tracknumber = Column(Integer) # order of the track on the disc
+ subtitle = Column(String) # sub title of the track
+ playcount = Column(Integer) # number of times the track was played
+ rating = Column(Integer) # rating of the track (0-255)
+
+ # relationships
+ artist = relationship('Artist', primaryjoin='Artist.id == Track.artist_id')
+ album_artist = relationship('Artist', primaryjoin='Artist.id == Track.album_artist_id')
+ arranger = relationship('Artist', primaryjoin='Artist.id == Track.arranger_id')
+ composer = relationship('Artist', primaryjoin='Artist.id == Track.composer_id')
+ conductor = relationship('Artist', primaryjoin='Artist.id == Track.conductor_id')
+ lyricist = relationship('Artist', primaryjoin='Artist.id == Track.lyricist_id')
+ performer = relationship('Artist', primaryjoin='Artist.id == Track.performer_id')
+ album = relationship('Album', backref=backref('tracks', order_by=tracknumber))
+ disc = relationship('Disc', backref=backref('tracks', order_by=tracknumber))
def __init__(self, uri):
Base.__init__(self)
self.uri = uri
def __unicode__(self):
- return u'<Media(uri=%s)>' % self.uri
+ return u'<Track(title=%s, uri=%s)>' % (self.title, self.uri)
def __str__(self):
return unicode(self).encode('utf-8')
# Loosely wraps the SQLAlchemy database types and access methods.
-# The goal here isn't to encapsulate SQLAlchemy. Rather, we want to
-# dictate the process of connecting to and disconnecting from the db,
+# The goal here isn't to encapsulate SQLAlchemy. Rather, we want dictate
+# to the process of connecting to and disconnecting from the db,
# as well as the data types that the application uses once connected.
-class DB:
+# TODO: refactor the class and function names to follow python spec
+class DatabaseWrapper:
+
+ db_path = None
+ sa_engine = None
+ sa_sessionmaker = None
- # constructs the database path, creates a database engine
- # and initializes the database if necessary.
def __init__(self):
- db_path = os.path.abspath(os.path.join(os.curdir, 'musik.db'))
- self.sa_engine = create_engine('sqlite:///%s' % db_path, echo=False)
+ """Creates a new instance of the DatabaseWrapper.
+ This function starts a new database engine and ensures that the
+ database has been initialized.
+ """
+ self.db_path = os.path.abspath(os.path.join(os.curdir, 'musik.db'))
+ self.init_database()
+
+ def get_engine(self):
+ """Initializes and returns an instance of sqlalchemy.engine.base.Engine
+ """
+ if self.sa_engine == None:
+ self.sa_engine = create_engine('sqlite:///%s' % self.db_path, echo=False)
+ return self.sa_engine
+
+ def init_database(self):
+ """Initializes the database schema
+ This method is not thread-safe; users must take steps to ensure that it is
+ only called from one thread at a time.
+ If get_engine has not yet been called, this method will call it implicitly.
+ """
+ if self.sa_engine == None:
+ self.get_engine()
Base.metadata.create_all(self.sa_engine)
- self.sa_sessionmaker = sessionmaker(bind=self.sa_engine)
- # returns an initialized instance of sqlalchemy.engine.base.Engine
- def getEngine(self):
- return self.sa_engine
+ def get_session(self):
+ """Initializes and returns an instance of sqlalchemy.engine.base.Engine
+ If get_engine has not yet been called, this method will call it implicitly.
+ """
+ if self.sa_engine == None:
+ self.get_engine()
- # returns a database session that the caller can use to execute queries and stuff
- def getSession(self):
+ if self.sa_sessionmaker == None:
+ self.sa_sessionmaker = sessionmaker(bind=self.sa_engine)
return self.sa_sessionmaker()
View
@@ -1,35 +1,35 @@
-import musik.db
-from musik.db import ImportTask
-from musik import initLogging
-
from datetime import datetime
import mimetypes
import os
import threading
import time
-class ImportThread(threading.Thread):
-
- # whether or not the thread should continue to run
- running = True
+from musik import initLogging
+import musik.db
+from musik.db import ImportTask, Track
- # database session
- sa_session = None
+class ImportThread(threading.Thread):
- # logging instance
- log = None
+ running = True # whether or not the thread should continue to run
+ sa_session = None # database session
+ log = None # logging instance
- # creates a new instance of ImportThread
def __init__(self):
+ """Creates a new instance of ImportThread and connects to the database.
+ This design pattern is important: since the constructor runs synchonously,
+ it ensures that two threads don't attempt to initialize the database at
+ the same time.
+ """
super(ImportThread, self).__init__(name=__name__)
self.log = initLogging(__name__)
+ db = musik.db.DatabaseWrapper()
+ self.sa_session = db.get_session()
def run(self):
+ """Checks for new import tasks once per second and passes them off to
+ the appropriate handler functions for completion.
+ """
try:
- # get a database connection
- db = musik.db.DB()
- self.sa_session = db.getSession()
-
# process 'till you drop
while self.running:
@@ -59,8 +59,9 @@ def run(self):
finally:
# always clean up - your mom doesn't work here
- self.sa_session.close()
- self.sa_session = None
+ if self.sa_session != None:
+ self.sa_session.close()
+ self.sa_session = None
# adds a directory to the local library
@@ -105,10 +106,18 @@ def isMimeTypeSupported(self, uri):
def importFile(self, uri):
- #TODO: (global) logging needs to default to unicode. need a logging class that writes to file anyway
#TODO: actually import the file
self.log.info(u'ImportFile called with uri %s', uri)
+ mtype = mimetypes.guess_type(uri)[0]
+ if mtype == u'audio/mpeg':
+ pass
+ else:
+ self.log.info(u'Unsupported mime type %s. Ignoring file.', mtype)
+
+
+# def readMp3MetaData(self, uri):
+
# cleans up the thread
def stop(self):
View
@@ -1,8 +1,10 @@
-import cherrypy
import re
+import cherrypy
+
from musik.db import ImportTask
+
class Import:
@cherrypy.expose
def directory(self, path):
View
@@ -1,14 +1,15 @@
-from musik.db import DB
-from musik.web import api
-
import cherrypy
from cherrypy.process import wspbus, plugins
-from sqlalchemy.orm import scoped_session, sessionmaker
-
from mako.template import Template
from mako.lookup import TemplateLookup
+from sqlalchemy.orm import scoped_session, sessionmaker
+
+from musik.db import DatabaseWrapper
+from musik.web import api
+
+
# Template looker-upper that handles loading and caching of mako templates
templates = TemplateLookup(directories=['templates'], module_directory='templates/compiled')
@@ -22,8 +23,8 @@ def __init__(self, bus):
self.bus.subscribe(u'bind', self.bind)
def start(self):
- self.db = DB()
- self.sa_engine = self.db.getEngine()
+ self.db = DatabaseWrapper()
+ self.sa_engine = self.db.get_engine()
def stop(self):
if self.sa_engine:

0 comments on commit 67fd956

Please sign in to comment.