From d06f96388d754ed41876f7fccb63f84241d44963 Mon Sep 17 00:00:00 2001 From: Michaƫl Ball Date: Sun, 26 Mar 2017 10:19:59 +0100 Subject: Works on python 2/pypy --- app.db | Bin 4096 -> 2048 bytes app.py | 24 ++++ common/security.py | 6 +- db/db_manager.py | 275 ++++++++++++++++++++--------------------- library.py | 192 +++++++++++++++++++---------- mach2.py | 291 +++++++++++++++++++++++--------------------- models/album.py | 80 ++++++------ models/artist.py | 75 +++++------- models/base.py | 13 +- models/track.py | 152 +++++++++++------------ models/user.py | 17 +-- tests/conftest.py | 8 +- tests/mach2_test.py | 11 +- tests/models/album_test.py | 16 +-- tests/models/artist_test.py | 20 +-- tests/models/track_test.py | 36 +++--- tests/test.db | Bin 19456 -> 19456 bytes tests/testapp.db | Bin 4096 -> 2048 bytes tests/testnew.ogg | Bin 0 -> 3929 bytes tests/watcher_test.py | 96 +++++++++++++++ watcher.py | 120 ++++++++++++++---- 21 files changed, 845 insertions(+), 587 deletions(-) create mode 100644 app.py create mode 100644 tests/testnew.ogg create mode 100644 tests/watcher_test.py diff --git a/app.db b/app.db index 7493558..2d93462 100644 Binary files a/app.db and b/app.db differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..74d87d8 --- /dev/null +++ b/app.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +"""Runs mach2.""" +from gevent import joinall, monkey, spawn +from gevent.pywsgi import WSGIServer +monkey.patch_all(thread=False) + +from six.moves import configparser # NOQA : E402 + +from mach2 import create_app # NOQA : E402 +from watcher import LibraryWatcher # NOQA : E402 + +APP = create_app() + + +if __name__ == "__main__": + config = configparser.ConfigParser() + config.read("mach2.ini") + watcher = spawn(LibraryWatcher, config.get("DEFAULT", "media_dir"), + config.get("DEFAULT", "library")) + + http_server = WSGIServer(('', 5000), APP, log=None) + server = spawn(http_server.serve_forever) + + joinall([server, watcher]) diff --git a/common/security.py b/common/security.py index 91acb2b..e3ab5de 100644 --- a/common/security.py +++ b/common/security.py @@ -1,12 +1,10 @@ -import configparser - from passlib.context import CryptContext - +from six.moves import configparser config = configparser.ConfigParser() config.read("mach2.ini") -secret_key = config["DEFAULT"]["secret_key"] +secret_key = config.get("DEFAULT", "secret_key") pwd_context = CryptContext( schemes=["pbkdf2_sha256", "des_crypt"], diff --git a/db/db_manager.py b/db/db_manager.py index ad2fd14..9c2dd53 100644 --- a/db/db_manager.py +++ b/db/db_manager.py @@ -1,9 +1,18 @@ -import configparser +""" +db_manager exposes a DbManager class to make interacting with sqlite +databases easier. +""" +import logging import os import sqlite3 +import six -class DbManager: + +_LOGGER = logging.getLogger(__name__) + +class DbManager(object): + """DBManager makes interacting with sqlite databases easier.""" create_album_table = "CREATE TABLE IF NOT EXISTS album (id INTEGER "\ "PRIMARY KEY, name TEXT, date TEXT, musicbrainz_albumid TEXT)" @@ -38,141 +47,133 @@ class DbManager: create_track_number_index = "CREATE INDEX IF NOT EXISTS "\ "track_tracknumber_IDX ON track(tracknumber)" - class __DbManager: - config = configparser.ConfigParser() - config.read("mach2.ini") - - def iterdump(connection): - cu = connection.cursor() - yield("BEGIN TRANSACTION;") - - q = """ - SELECT "name", "type", "sql" - FROM "sqlite_master" - WHERE "sql" NOT NULL AND - "type" == 'table' - ORDER BY "name"; - """ - schema_res = cu.execute(q).fetchall() - for table_name, type, sql in schema_res: - if table_name == "sqlite_sequence": - yield("DELETE FROM \"sqlite_sequence\";") - elif table_name == "sqlite_stat1": - yield("ANALYZE \"sqlite_master\";") - elif table_name.startswith("sqlite_"): - continue - else: - yield("{0};".format(sql)) - - table_name_ident = table_name.replace("\"", "\"\"") - res = cu.execute("PRAGMA table_info(\"{0}\")".format( - table_name_ident)) - column_names = [ - str(table_info[1]) for table_info in res.fetchall()] - q = """ - SELECT 'INSERT INTO "{0}" VALUES({1})' FROM "{0}"; - """.format(table_name_ident, ",".join( + @staticmethod + def iterdump(connection): + """Iterates through the database, creating commands to dump all the + tables line by line.""" + cursor = connection.cursor() + + query = """ + SELECT "name", "type", "sql" + FROM "sqlite_master" + WHERE "sql" NOT NULL AND + "type" == 'table' + ORDER BY "name"; + """ + schema_res = cursor.execute(query).fetchall() + for table_name, dummy, sql in schema_res: + if table_name == "sqlite_sequence": + yield "DELETE FROM \"sqlite_sequence\";" + elif table_name == "sqlite_stat1": + yield "ANALYZE \"sqlite_master\";" + elif table_name.startswith("sqlite_"): + continue + else: + yield six.u("{0};").format(sql) + + table_name_ident = table_name.replace("\"", "\"\"") + res = cursor.execute("PRAGMA table_info(\"{0}\")".format( + table_name_ident)) + column_names = [ + str(table_info[1]) for table_info in res.fetchall()] + query = """ + SELECT 'INSERT INTO "{0}" VALUES({1})' FROM "{0}"; + """.format(table_name_ident, ",".join( """'||quote("{0}")||'""".format( col.replace( "\"", "\"\"")) for col in column_names)) - query_res = cu.execute(q) - for row in query_res: - yield("{0};".format(row[0])) - - q = """ - SELECT "name", "type", "sql" - FROM "sqlite_master" - WHERE "sql" NOT NULL AND - "type" IN ('index', 'trigger', 'view') - """ - schema_res = cu.execute(q) - for name, type, sql in schema_res.fetchall(): - yield("{0};".format(sql)) - - yield("COMMIT;") - - def __init__(self, db=None): - new_db = False - cache_size_kb = 9766 - - if db: - self.conn = sqlite3.connect(db) - - else: - if not os.path.isfile(self.config["DEFAULT"]["database"]): - new_db = True - - if new_db: - self.conn = sqlite3.connect(":memory:") - self.create_tables() - else: - self.conn = sqlite3.connect( - self.config["DEFAULT"]["database"]) - library_info = os.stat(self.config["DEFAULT"]["database"]) - cache_size_kb = round((library_info.st_size * 1.2) / 1024) - - cursor = self.conn.cursor() - # Setting pragma with ? placeholder produces an error - cursor.execute("pragma cache_size=-%s" % cache_size_kb) - cursor.close() - - self.conn.row_factory = sqlite3.Row - - def export(self): - if not os.path.isfile(self.config["DEFAULT"]["database"]): - script = "" - - for line in DbManager.__DbManager.iterdump(self.conn): - script = "\n".join((script, line)) - - tempconn = sqlite3.connect( - self.config["DEFAULT"]["database"]) - tempcur = tempconn.cursor() - - tempcur.executescript(script) - tempcur.close() - - def __str__(self): - return repr(self) - - def execute(self, script, parameters=None): - if parameters: - return self.conn.execute(script, parameters) - - return self.conn.execute(script) - - def commit(self): - return self.conn.commit() - - def cursor(self): - return self.conn.cursor() - - def close(self): - return self.conn.close() - - def interrupt(self): - return self.conn.interrupt() - - def create_tables(self): - with self.conn: - self.conn.execute(DbManager.create_album_table) - self.conn.execute(DbManager.create_album_artist_table) - self.conn.execute(DbManager.create_album_track_table) - self.conn.execute(DbManager.create_artist_table) - self.conn.execute(DbManager.create_artist_track_table) - self.conn.execute(DbManager.create_track_table) - self.conn.execute(DbManager.create_musicbrainz_artist_index) - self.conn.execute(DbManager.create_track_filename_index) - self.conn.execute(DbManager.create_track_grouping_index) - self.conn.execute(DbManager.create_track_name_index) - self.conn.execute(DbManager.create_track_number_index) - - instance = None - - def __new__(self, db=None): - if db: - return DbManager.__DbManager(db) - elif not DbManager.instance: - DbManager.instance = DbManager.__DbManager() - - return DbManager.instance + query_res = cursor.execute(query) + for row in query_res: + yield six.u("{0};").format(row[0]) + + query = """ + SELECT "name", "type", "sql" + FROM "sqlite_master" + WHERE "sql" NOT NULL AND + "type" IN ('index', 'trigger', 'view') + """ + schema_res = cursor.execute(query) + for dummy, dummy2, sql in schema_res.fetchall(): + yield six.u("{0};").format(sql) + + def __init__(self, db_file): + new_db = False + cache_size_kb = 9766 + self.db_file = db_file + + if not os.path.isfile(self.db_file): + new_db = True + + if new_db: + self.conn = sqlite3.connect(":memory:") + self.create_tables() + else: + self.conn = sqlite3.connect(self.db_file) + library_info = os.stat(self.db_file) + cache_size_kb = round((library_info.st_size * 1.2) / 1024) + + cursor = self.conn.cursor() + # Setting pragma with ? placeholder produces an error + cursor.execute("pragma cache_size=-%s" % cache_size_kb) + cursor.close() + + self.conn.row_factory = sqlite3.Row + + def export(self): + """Export the database.""" + if not os.path.isfile(self.db_file): + tempconn = sqlite3.connect(self.db_file) + tempcur = tempconn.cursor() + tempcur.execute("PRAGMA journal_mode=WAL;") + tempcur.close() + + try: + with tempconn: + for line in DbManager.iterdump(self.conn): + tempconn.execute(line) + + except sqlite3.Error as exc: + _LOGGER.error(exc) + + tempconn.close() + + def __str__(self): + return repr(self) + + def execute(self, script, parameters=None): + """Execute an sql statement""" + if parameters: + return self.conn.execute(script, parameters) + + return self.conn.execute(script) + + def commit(self): + """Commit the current transaction""" + return self.conn.commit() + + def cursor(self): + """Create a cursor""" + return self.conn.cursor() + + def close(self): + """Close the connection""" + return self.conn.close() + + def interrupt(self): + """Interrupt the connection""" + return self.conn.interrupt() + + def create_tables(self): + """Create the database tables""" + with self.conn: + self.conn.execute(DbManager.create_album_table) + self.conn.execute(DbManager.create_album_artist_table) + self.conn.execute(DbManager.create_album_track_table) + self.conn.execute(DbManager.create_artist_table) + self.conn.execute(DbManager.create_artist_track_table) + self.conn.execute(DbManager.create_track_table) + self.conn.execute(DbManager.create_musicbrainz_artist_index) + self.conn.execute(DbManager.create_track_filename_index) + self.conn.execute(DbManager.create_track_grouping_index) + self.conn.execute(DbManager.create_track_name_index) + self.conn.execute(DbManager.create_track_number_index) diff --git a/library.py b/library.py index 01924a2..272e9e5 100755 --- a/library.py +++ b/library.py @@ -1,99 +1,165 @@ #!/usr/bin/env python -import configparser -import gevent -from gevent import monkey, queue +"""This module implements a library for storing information on audio tracks.""" import logging -import mutagen import os +from gevent import joinall, monkey, queue, sleep, spawn +import mutagen +import six +from six.moves import configparser + from db.db_manager import DbManager from models.track import Track -file_store = queue.Queue() - logging.basicConfig(format="%(asctime)s %(message)s", level=logging.DEBUG) +_LOGGER = logging.getLogger(__name__) + +if six.PY2: + def _u(string): + return unicode(string, encoding="utf_8") +else: + def _u(string): + return string + + +class MediaLibrary(object): + """Implements methods for storing and managing media in a library.""" + + def __init__(self, media_dir, database): + """Create a media library object. + + Args: + media_dir (str): The path of the media directory + database (DatabaseManager): The media database + + """ + self.__media_dir = media_dir + self.__database = database + + def store_track_task(self, file_queue): + """Store a track from the supplied queue. + Args: + file_queue (Queue[str]): A queue containing file paths. -def store_track_task(): - while not file_store.empty(): - path = file_store.get() - m = mutagen.File(path, easy=True) - Track.store(path, m) + """ + while not file_queue.empty(): + path = file_queue.get() + metadata = mutagen.File(path, easy=True) + Track.store(_u(path), metadata, self.__database) - gevent.sleep(0) + sleep(0) + def run(self, path=None): + """Store all tracks located in the supplied path. -def run(path=None): - db = DbManager() - if path is not None: - if os.path.isdir(path): - store_dir(path) + Args: + path (str): The path to an audio file or directory containing audio + files. + + """ + if path is not None: + if os.path.isdir(path): + self.store_dir(path) + else: + self.store_file(path) else: - store_file(path) - else: - store_dir("/media/Music") - db.export() + self.store_dir(self.__media_dir) + + self.__database.export() + + def store_file(self, path): + """Store an audio file. + + Args: + path (str): The path to an audio file. + + """ + metadata = mutagen.File(path, easy=True) + if metadata: + if not Track.store(_u(path), metadata, self.__database): + _LOGGER.error("Problem saving %s", path) + + def store_dir(self, path): + """Store all audio files in a directory. + Args: + path (str): The path to a directory. -def store_file(path): - logger = logging.getLogger("store_file") - m = mutagen.File(path, easy=True) - if m: - if not Track.store(path, m): - logger.error("Problem saving %s" % (path,)) + """ + _LOGGER.info("Scanning files") + file_queue = queue.Queue() -def store_dir(path): - logger = logging.getLogger("store_dir") - logger.info("Scanning files") + allowed_extensions = [".mp3", ".ogg", ".flac", ".wav", ".aac", ".ape"] + for root, dummy, files in os.walk(path): + for name in files: + file_path = "".join([root, "/", name]) + dummy, ext = os.path.splitext(file_path) - allowed_extensions = [".mp3", ".ogg", ".flac", ".wav", ".aac", ".ape"] - for root, dirs, files in os.walk(path): - for name in files: - file_path = "".join([root, "/", name]) - file, ext = os.path.splitext(file_path) + if ext.lower() in allowed_extensions: + file_queue.put(file_path) - if ext.lower() in allowed_extensions: - file_store.put(file_path) + _LOGGER.info("Storing tracks") + joinall([spawn(self.store_track_task, file_queue)] * 18) + _LOGGER.info("Done") - logger.info("Storing tracks") - gevent.joinall([gevent.spawn(store_track_task)] * 6) - logger.info("Done") + def delete_file(self, path): + """Delete a file from the library. + Args: + path (str): The path for the file. -def delete_file(path): - track = Track.find_by_path(path) + """ + track = Track.find_by_path(_u(path), self.__database) - if track: - track_album = track.album - track_artists = track.artists + if track: + track_album = track.album + track_artists = track.artists - track.delete() + track.delete() - if track_album and len(track_album.tracks) == 0: - track_album.delete() + if track_album and len(track_album.tracks) == 0: + track_album.delete() - for artist in track_artists: - if len(artist.tracks) == 0: - artist.delete() + for artist in track_artists: + if len(artist.tracks) == 0: + artist.delete() + def update_file(self, path): + """Update a file in the library. -def update_file(path): - m = mutagen.File(path, easy=True) - if m: - track = Track.find_by_path(path) - track.update(m) + Args: + path (str): The path for the file. + """ + metadata = mutagen.File(path, easy=True) + if metadata: + track = Track.find_by_path(_u(path), self.__database) + track.update(metadata) + + def update_track_filename(self, oldpath, newpath): + """Update a track's filename. + + Args: + oldpath (str): The old path of the file. + newpath (str): The new path of the file. + + """ + track = Track.find_by_path(_u(oldpath), self.__database) + track.filename = _u(newpath) + track.save() -def update_track_filename(oldpath, newpath): - track = Track.find_by_path(oldpath) - track.filename = newpath - track.save() if __name__ == "__main__": monkey.patch_all(thread=False) - config = configparser.ConfigParser() - config.read("mach2.ini") + __CONFIG = configparser.ConfigParser() + __CONFIG.read("mach2.ini") + + db = DbManager(__CONFIG.get("DEFAULT", "library")) + media_path = __CONFIG.get("DEFAULT", "media_dir") + + media_library = MediaLibrary(media_path, db) - run(config["DEFAULT"]["media_dir"]) + media_library.run() diff --git a/mach2.py b/mach2.py index 412ac9d..e1f186c 100644 --- a/mach2.py +++ b/mach2.py @@ -1,70 +1,67 @@ import base64 -import configparser import json -import mimetypes import os +import subprocess import sqlite3 import tempfile -from flask import Blueprint, Flask, Response, current_app, g, redirect, \ - render_template, request, url_for -from flask.ext.compress import Compress -from flask.ext.login import LoginManager, current_user, login_required -from flask.ext.login import login_user, logout_user -from gevent import subprocess +import mimetypes + +from flask import (Blueprint, Flask, Response, current_app, g, redirect, + render_template, request, url_for) +from flask_compress import Compress +from flask_login import (LoginManager, current_user, login_required, + login_user, logout_user) +from six.moves import configparser +from db.db_manager import DbManager from models.album import Album from models.artist import Artist from models.track import Track from models.user import User -import builtins - -builtins.library_db = None +_CONFIG = configparser.ConfigParser() +_CONFIG.read("mach2.ini") +MACH2 = Blueprint("mach2", __name__) -config = configparser.ConfigParser() -config.read("mach2.ini") - -mach2 = Blueprint("mach2", __name__) - -login_manager = LoginManager() -login_manager.login_view = "mach2.login" +_LOGIN_MANAGER = LoginManager() +_LOGIN_MANAGER.login_view = "mach2.login" def get_db(): - db = getattr(g, "_database", None) - if db is None: - db = sqlite3.connect(current_app.config["DATABASE"]) - db.row_factory = sqlite3.Row - setattr(g, "_database", db) + database = getattr(g, "_database", None) + if database is None: + database = sqlite3.connect(current_app.config["DATABASE"]) + database.row_factory = sqlite3.Row + setattr(g, "_database", database) - return db + return database -@mach2.teardown_app_request +@MACH2.teardown_app_request def close_connection(exception): - db = getattr(g, "_database", None) - if db is not None: - db.close() + database = getattr(g, "_database", None) + if database is not None: + database.close() def query_db(query, args=(), one=False): cur = get_db().execute(query, args) - rv = cur.fetchall() + result = cur.fetchall() cur.close() - return (rv[0] if rv else None) if one else rv + return (result[0] if result else None) if one else result -@login_manager.request_loader -def load_user_from_request(request): +@_LOGIN_MANAGER.request_loader +def load_user_from_request(req): # first, try to login using the api_key url arg - api_key = request.args.get('api_key', None) + api_key = req.args.get('api_key', None) if not api_key: # next, try to login using Basic Auth - api_key = request.headers.get('Authorization', None) + api_key = req.headers.get('Authorization', None) if api_key: api_key = api_key.replace('Basic ', '', 1) @@ -84,7 +81,8 @@ def load_user_from_request(request): password_hash=result[2], authenticated=0, active=result[4], - anonymous=result[5]) + anonymous=result[5], + transcode_command=result[7]) if user: return user @@ -93,17 +91,17 @@ def load_user_from_request(request): return None -@mach2.route("/") +@MACH2.route("/") @login_required def index(): return render_template("index.html", user=current_user) -@mach2.route("/albums") +@MACH2.route("/albums") @login_required def albums(): returned_albums = [] - albums = [] + result_albums = [] order_by = request.args.get("order", None) order_direction = request.args.get("direction", None) @@ -138,61 +136,63 @@ def albums(): all_params.update(search_params) if search_params: - returned_albums = Album.search(db=builtins.library_db, **all_params) + returned_albums = Album.search(current_app.config["LIBRARY"], + **all_params) else: - returned_albums = Album.all(db=builtins.library_db, **params) + returned_albums = Album.all(current_app.config["LIBRARY"], **params) - for album in returned_albums: - albums.append(album.as_dict()) + for returned_album in returned_albums: + result_albums.append(returned_album.as_dict()) - return json.dumps(albums) + return json.dumps(result_albums) -@mach2.route("/albums//tracks") +@MACH2.route("/albums//tracks") @login_required def album_tracks(album_id): - tracks = [] - album = Album(db=builtins.library_db, id=album_id) + result_tracks = [] + returned_album = Album(current_app.config["LIBRARY"], id=album_id) - for track in album.tracks: - tracks.append(track.as_dict()) + for album_track in returned_album.tracks: + result_tracks.append(album_track.as_dict()) - return json.dumps(tracks) + return json.dumps(result_tracks) -@mach2.route("/albums//artists") +@MACH2.route("/albums//artists") @login_required def album_artists(album_id): - artists = [] - album = Album(db=builtins.library_db, id=album_id) + result_artists = [] + returned_album = Album(current_app.config["LIBRARY"], id=album_id) - for artist in album.artists: - artists.append(artist.as_dict()) + for album_artist in returned_album.artists: + result_artists.append(album_artist.as_dict()) - return json.dumps(artists) + return json.dumps(result_artists) -@mach2.route("/albums/") +@MACH2.route("/albums/") @login_required def album(album_id): - album = Album(db=builtins.library_db, id=album_id) + returned_album = Album(current_app.config["LIBRARY"], id=album_id) - return json.dumps(album.as_dict()) + return json.dumps(returned_album.as_dict()) -@mach2.route("/albums/") +@MACH2.route("/albums/") @login_required def album_search(album_name): - albums = [] + result_albums = [] - for album in Album.search(db=builtins.library_db, - name={"data": album_name, "operator": "LIKE"}): - albums.append(album.as_dict()) + for returned_album in Album.search(current_app.config["LIBRARY"], + name={"data": album_name, + "operator": "LIKE"}): + result_albums.append(returned_album.as_dict()) - return json.dumps(albums) + return json.dumps(result_albums) -@mach2.route("/artists") +@MACH2.route("/artists") @login_required def artists(): order_by = None @@ -200,7 +200,7 @@ def artists(): lim = None off = None returned_artists = [] - artists = [] + result_artists = [] if request.args.get("order"): order_by = request.args.get("order") @@ -215,66 +215,67 @@ def artists(): off = request.args.get("offset") if order_by: - returned_artists = Artist.all(db=builtins.library_db, order=order_by, - direction=order_direction, - limit=lim, offset=off) + returned_artists = Artist.all(current_app.config["LIBRARY"], + order=order_by, + direction=order_direction, limit=lim, + offset=off) else: - returned_artists = Artist.all(db=builtins.library_db, limit=lim, + returned_artists = Artist.all(current_app.config["LIBRARY"], limit=lim, offset=off) - for artist in returned_artists: - artists.append(artist.as_dict()) + for returned_artist in returned_artists: + result_artists.append(returned_artist.as_dict()) - return json.dumps(artists) + return json.dumps(result_artists) -@mach2.route("/artists//tracks") +@MACH2.route("/artists//tracks") @login_required def artist_tracks(artist_id): - tracks = [] - artist = Artist(db=builtins.library_db, id=artist_id) + result_tracks = [] + returned_artist = Artist(current_app.config["LIBRARY"], id=artist_id) - for track in artist.tracks: - tracks.append(track.as_dict()) + for artist_track in returned_artist.tracks: + result_tracks.append(artist_track.as_dict()) - return json.dumps(tracks) + return json.dumps(result_tracks) -@mach2.route("/artists//albums") +@MACH2.route("/artists//albums") @login_required def artist_albums(artist_id): - albums = [] - artist = Artist(db=builtins.library_db, id=artist_id) + result_albums = [] + returned_artist = Artist(current_app.config["LIBRARY"], id=artist_id) - for album in artist.albums: - albums.append(album.as_dict()) + for artist_album in returned_artist.albums: + result_albums.append(artist_album.as_dict()) - return json.dumps(albums) + return json.dumps(result_albums) -@mach2.route("/artists/") +@MACH2.route("/artists/") @login_required def artist_info(artist_id): - artist = Artist(id=artist_id, db=builtins.library_db) + artist = Artist(current_app.config["LIBRARY"], id=artist_id) return json.dumps(artist.as_dict()) -@mach2.route("/artists/") +@MACH2.route("/artists/") @login_required def artist_search(artist_name): - artists = [] - for artist in Artist.search(db=builtins.libary_db, + result_artists = [] + for artist in Artist.search(current_app.config["LIBRARY"], name={ "data": artist_name, "operator": "LIKE" - }): - artists.append(artist.as_dict()) + }): + result_artists.append(artist.as_dict()) return json.dumps(artists) -@mach2.route("/tracks") +@MACH2.route("/tracks") @login_required def tracks(): order_by = None @@ -282,7 +283,7 @@ def tracks(): lim = None off = None returned_tracks = [] - tracks = [] + result_tracks = [] if request.args.get("order"): order_by = request.args.get("order") @@ -297,50 +298,59 @@ def tracks(): off = request.args.get("offset") if order_by: - returned_tracks = Track.all(db=builtins.library_db, order=order_by, - direction=order_direction, limit=lim, - offset=off) + returned_tracks = Track.all(current_app.config["LIBRARY"], + order=order_by, direction=order_direction, + limit=lim, offset=off) else: - returned_tracks = Track.all(db=builtins.library_db, + returned_tracks = Track.all(current_app.config["LIBRARY"], limit=lim, offset=off) - for track in returned_tracks: - tracks.append(track.as_dict()) + for returned_track in returned_tracks: + result_tracks.append(returned_track.as_dict()) - return json.dumps(tracks) + return json.dumps(result_tracks) -@mach2.route("/tracks//artists") +@MACH2.route("/tracks//artists") @login_required def track_artists(track_id): - artists = [] - track = Track(db=builtins.library_db, id=track_id) + result_artists = [] + returned_track = Track(current_app.config["LIBRARY"], id=track_id) - for artist in track.artists: - artists.append(artist.as_dict()) + for track_artist in returned_track.artists: + result_artists.append(track_artist.as_dict()) - return json.dumps(artists) + return json.dumps(result_artists) -@mach2.route("/tracks/") +@MACH2.route("/tracks/") @login_required def track(track_id): - def stream_file(filename, chunksize=8192): - with open(filename, "rb") as f: + def stream_file(filename, proc, chunksize=8192): + with open(filename, "rb") as streamed_file: while True: - chunk = f.read(chunksize) - if chunk: + chunk = streamed_file.read(chunksize) + if proc.poll() is None or chunk: yield chunk - else: + elif not chunk: os.remove(filename) break - local_track = Track(db=builtins.library_db, id=track_id) + local_track = Track(current_app.config["LIBRARY"], id=track_id) - fd, temp_filename = tempfile.mkstemp() + dummy, temp_filename = tempfile.mkstemp() - subprocess.call(["ffmpeg", "-y", "-i", local_track.filename, "-acodec", - "libopus", "-b:a", "64000", "-f", "opus", temp_filename]) + transcode_tokens = current_user.transcode_command.split() + transcode_command_items = [] + for token in transcode_tokens: + if token == "{filename}": + transcode_command_items.append(local_track.filename) + elif token == "{output}": + transcode_command_items.append(temp_filename) + else: + transcode_command_items.append(token) + + proc = subprocess.Popen(transcode_command_items) mime_string = "application/octet-stream" @@ -348,7 +358,7 @@ def track(track_id): if mime[0]: mime_string = mime[0] - resp = Response(stream_file(temp_filename), mimetype=mime_string) + resp = Response(stream_file(temp_filename, proc), mimetype=mime_string) if mime[1]: resp.headers["Content-Encoding"] = mime[1] @@ -356,30 +366,32 @@ def track(track_id): return resp -@mach2.route("/tracks/") +@MACH2.route("/tracks/") @login_required def track_search(track_name): - tracks = [] - for track in Track.search(db=builtins.library_db, - name={"data": track_name, "operator": "LIKE"}): - tracks.append(track.as_dict()) + result_tracks = [] + for returned_track in Track.search(current_app.config["LIBRARY"], + name={"data": track_name, + "operator": "LIKE"}): + result_tracks.append(returned_track.as_dict()) - return json.dumps(tracks) + return json.dumps(result_tracks) -@login_manager.user_loader +@_LOGIN_MANAGER.user_loader def load_user(userid): user = None result = query_db("SELECT * FROM user WHERE id = ?", [userid], one=True) if result: user = User(id=result[0], username=result[1], password_hash=result[2], - authenticated=1, active=result[4], anonymous=0) + authenticated=1, active=result[4], anonymous=0, + transcode_command=result[7]) return user -@mach2.route("/login", methods=["GET", "POST"]) +@MACH2.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": user = None @@ -391,7 +403,8 @@ def login(): password_hash=result[2], authenticated=0, active=result[4], - anonymous=result[5]) + anonymous=result[5], + transcode_command=result[7]) password = request.form["password"] @@ -404,14 +417,14 @@ def login(): return render_template("login.html") -@mach2.route("/logout") +@MACH2.route("/logout") @login_required def logout(): logout_user() return redirect("/") -@mach2.before_app_first_request +@MACH2.before_app_first_request def setup_globals(): setattr(g, "_db_path", current_app.config["DATABASE"]) @@ -421,17 +434,19 @@ def create_app(database=None, library=None): if database: app.config["DATABASE"] = database else: - app.config["DATABASE"] = config["DEFAULT"]["app_db"] + app.config["DATABASE"] = _CONFIG.get("DEFAULT", "database") if library: - builtins.library_db = library + app.config["LIBRARY"] = library + else: + app.config["LIBRARY"] = DbManager(_CONFIG.get("DEFAULT", "library")) - app.config["DEBUG"] = config["DEFAULT"]["debug"] - app.config["SECRET_KEY"] = config["DEFAULT"]["secret_key"] + app.config["DEBUG"] = _CONFIG.get("DEFAULT", "debug") + app.config["SECRET_KEY"] = _CONFIG.get("DEFAULT", "secret_key") - app.register_blueprint(mach2) + app.register_blueprint(MACH2) - login_manager.init_app(app) + _LOGIN_MANAGER.init_app(app) compress = Compress() compress.init_app(app) @@ -440,5 +455,5 @@ def create_app(database=None, library=None): if __name__ == "__main__": - app = create_app() - app.run() + APP = create_app() + APP.run() diff --git a/models/album.py b/models/album.py index 96bea81..d55fb88 100644 --- a/models/album.py +++ b/models/album.py @@ -1,17 +1,16 @@ from common import utils -from db.db_manager import DbManager from models.base import BaseModel class Album(BaseModel): + """Represents an album.""" - def __init__(self, id=None, db=None, **kwargs): - if db: - self.db = db + def __init__(self, db, id=None, **kwargs): + self._db = db if id is not None: - for row in self.db.execute("SELECT * FROM album WHERE id = ?", - (id,)): + for row in self._db.execute("SELECT * FROM album WHERE id = ?", + (id,)): setattr(self, "id", id) setattr(self, "name", row[1]) setattr(self, "date", row[2]) @@ -23,25 +22,21 @@ class Album(BaseModel): for track in self.tracks: track.delete() - with self.db.conn: + with self._db.conn: delete_album = "DELETE FROM album WHERE id = ?" - self.db.execute(delete_album, (self.id,)) + self._db.execute(delete_album, (self.id,)) delete_track_rel = "DELETE FROM album_track WHERE album_id = ?" - self.db.execute(delete_track_rel, (self.id,)) + self._db.execute(delete_track_rel, (self.id,)) delete_artist_rel = "DELETE FROM album_artist WHERE album_id = ?" - self.db.execute(delete_artist_rel, (self.id,)) + self._db.execute(delete_artist_rel, (self.id,)) return True @property def db(self): - try: - return self._db - except AttributeError: - self._db = DbManager() - return self._db + return self._db @db.setter def db(self, db): @@ -54,12 +49,12 @@ class Album(BaseModel): if not hasattr(self, "_artists"): setattr(self, "_artists", []) - for row in self.db.execute("SELECT artist.* FROM artist INNER " - "JOIN album_artist ON artist.id = " - "album_artist.artist_id WHERE " - "album_id = ? ORDER BY name ASC", - (self.id,)): - artist = Artist(id=row[0], db=self.db, name=row[1], + for row in self._db.execute("SELECT artist.* FROM artist INNER " + "JOIN album_artist ON artist.id = " + "album_artist.artist_id WHERE " + "album_id = ? ORDER BY name ASC", + (self.id,)): + artist = Artist(id=row[0], db=self._db, name=row[1], sortname=row[2], musicbrainz_artistid=row[3]) self._artists.append(artist) @@ -72,13 +67,13 @@ class Album(BaseModel): if not hasattr(self, "_tracks"): setattr(self, "_tracks", []) - for row in self.db.execute("SELECT track.* FROM track INNER " - "JOIN album_track ON track.id = " - "album_track.track_id WHERE " - "album_id = ? ORDER BY tracknumber " - "ASC", (self.id,)): + for row in self._db.execute("SELECT track.* FROM track INNER " + "JOIN album_track ON track.id = " + "album_track.track_id WHERE " + "album_id = ? ORDER BY tracknumber " + "ASC", (self.id,)): - track = Track(id=row["id"], db=self.db, + track = Track(id=row["id"], db=self._db, tracknumber=row["tracknumber"], name=row["name"], grouping=row["grouping"], filename=row["filename"]) @@ -97,14 +92,15 @@ class Album(BaseModel): if len(dirty_attributes) > 0: set_clause = utils.update_clause_from_dict(dirty_attributes) - dirty_attributes[id] = self.id + dirty_attributes["id"] = self.id - sql = " ".join(("UPDATE album"), set_clause, "WHERE id = :id") + sql = " ".join(("UPDATE album", set_clause, "WHERE id = :id")) - with self.db.conn: - self.db.execute(sql, dirty_attributes) + with self._db.conn: + self._db.execute(sql, dirty_attributes) - def search(db=None, **search_params): + @classmethod + def search(cls, database, **search_params): """Find an album with the given params Args: @@ -114,9 +110,6 @@ class Album(BaseModel): """ albums = [] - if not db: - db = DbManager() - # unpack search params where_params = {} value_params = {} @@ -136,21 +129,21 @@ class Album(BaseModel): result = None if where_clause: statement = " ".join(("SELECT * FROM album", where_clause)) - result = db.execute(statement, value_params) + result = database.execute(statement, value_params) else: - result = db.execute("SELECT * FROM album") + result = database.execute("SELECT * FROM album") for row in result: albums.append( - Album(id=row["id"], db=db, name=row["name"], date=row["date"]) + Album(id=row["id"], db=database, name=row["name"], + date=row["date"]) ) return albums - def all(db=None, order="album.id", direction="ASC", limit=None, + @classmethod + def all(cls, database, order="album.id", direction="ASC", limit=None, offset=None): - if not db: - db = DbManager() albums = [] @@ -163,11 +156,12 @@ class Album(BaseModel): select_string = " ".join((select_string, "LIMIT %s OFFSET %s" % (limit, offset))) - result = db.execute(select_string) + result = database.execute(select_string) for row in result: albums.append( - Album(id=row["id"], db=db, name=row["name"], date=row["date"]) + Album(id=row["id"], db=database, name=row["name"], + date=row["date"]) ) return albums diff --git a/models/artist.py b/models/artist.py index a76b2ee..dada665 100644 --- a/models/artist.py +++ b/models/artist.py @@ -1,17 +1,15 @@ from common import utils -from db.db_manager import DbManager from models.base import BaseModel class Artist(BaseModel): - def __init__(self, id=None, db=None, **kwargs): - if db: - self.db = db + def __init__(self, db, id=None, **kwargs): + self._db = db if id is not None: - for row in self.db.execute("SELECT * FROM artist WHERE id = ?", - (id,)): + for row in self._db.execute("SELECT * FROM artist WHERE id = ?", + (id,)): for key in ["id", "name", "sortname", "musicbrainz_artistid"]: setattr(self, key, row[key]) else: @@ -22,25 +20,21 @@ class Artist(BaseModel): for album in self.albums: album.delete() - with self.db.conn: + with self._db.conn: delete_artist = "DELETE FROM artist WHERE id = ?" - self.db.execute(delete_artist, (self.id,)) + self._db.execute(delete_artist, (self.id,)) delete_track_rel = "DELETE FROM artist_track WHERE artist_id = ?" - self.db.execute(delete_track_rel, (self.id,)) + self._db.execute(delete_track_rel, (self.id,)) delete_album_rel = "DELETE FROM album_artist WHERE artist_id = ?" - self.db.execute(delete_album_rel, (self.id,)) + self._db.execute(delete_album_rel, (self.id,)) return True @property def db(self): - try: - return self._db - except AttributeError: - self._db = DbManager() - return self._db + return self._db @db.setter def db(self, db): @@ -53,13 +47,13 @@ class Artist(BaseModel): if not hasattr(self, "_tracks"): setattr(self, "_tracks", []) - for row in self.db.execute("SELECT track.* FROM track INNER " - "JOIN artist_track ON track.id = " - "artist_track.track_id WHERE " - "artist_id = ? ORDER BY name ASC", - (self.id,)): + for row in self._db.execute("SELECT track.* FROM track INNER " + "JOIN artist_track ON track.id = " + "artist_track.track_id WHERE " + "artist_id = ? ORDER BY name ASC", + (self.id,)): - track = Track(id=row["id"], db=self.db, + track = Track(id=row["id"], db=self._db, tracknumber=row["tracknumber"], name=row["name"], grouping=row["grouping"], filename=row["filename"]) @@ -74,12 +68,12 @@ class Artist(BaseModel): if not hasattr(self, "_albums"): setattr(self, "_albums", []) - for row in self.db.execute("SELECT album.* FROM album INNER " - "JOIN album_artist ON album.id = " - "album_artist.album_id WHERE " - "artist_id = ? ORDER BY date ASC", - (self.id,)): - album = Album(id=row["id"], db=self.db, name=row["name"], + for row in self._db.execute("SELECT album.* FROM album INNER " + "JOIN album_artist ON album.id = " + "album_artist.album_id WHERE " + "artist_id = ? ORDER BY date ASC", + (self.id,)): + album = Album(id=row["id"], db=self._db, name=row["name"], date=row["date"]) self._albums.append(album) @@ -98,12 +92,13 @@ class Artist(BaseModel): dirty_attributes[id] = self.id - sql = " ".join(("UPDATE artist"), set_clause, "WHERE id = :id") + sql = " ".join(("UPDATE artist", set_clause, "WHERE id = :id")) - with self.db.conn: - self.db.execute(sql, dirty_attributes) + with self._db.conn: + self._db.execute(sql, dirty_attributes) - def search(db=None, **search_params): + @classmethod + def search(cls, database, **search_params): """Find an artist with the given params Args: @@ -113,9 +108,6 @@ class Artist(BaseModel): """ artists = [] - if not db: - db = DbManager() - # unpack search params where_params = {} value_params = {} @@ -128,23 +120,22 @@ class Artist(BaseModel): result = [] if where_clause: statement = " ".join(("SELECT * FROM artist", where_clause)) - result = db.execute(statement, value_params) + result = database.execute(statement, value_params) else: - result = db.execute("SELECT * FROM artist") + result = database.execute("SELECT * FROM artist") for row in result: artists.append( - Artist(id=row["id"], db=db, name=row["name"], + Artist(id=row["id"], db=database, name=row["name"], sortname=row["sortname"], musicbrainz_artistid=row["musicbrainz_artistid"]) ) return artists - def all(db=None, order="sortname", direction="ASC", limit=None, + @classmethod + def all(cls, database, order="sortname", direction="ASC", limit=None, offset=None): - if not db: - db = DbManager() artists = [] @@ -155,11 +146,11 @@ class Artist(BaseModel): select_string = " ".join((select_string, "LIMIT %s OFFSET %s" % (limit, offset))) - result = db.execute(select_string) + result = database.execute(select_string) for row in result: artists.append( - Artist(id=row["id"], db=db, name=row["name"], + Artist(id=row["id"], db=database, name=row["name"], sortname=row["sortname"], musicbrainz_artistid=row["musicbrainz_artistid"]) ) diff --git a/models/base.py b/models/base.py index fd40001..8a6fc47 100644 --- a/models/base.py +++ b/models/base.py @@ -1,10 +1,15 @@ -class BaseModel(): +"""Implements a base model for other models to inherit.""" +from six import iteritems + +class BaseModel(object): + """BaseModel is meant to be inherited by other models.""" def as_dict(self): + """Exposes all the object's values as a dict.""" this_dict = {} - for k in self.__dict__.keys(): - if k != "_db": - this_dict[k] = getattr(self, k) + for key, val in iteritems(self.__dict__): + if key != "_db": + this_dict[key] = val return this_dict diff --git a/models/track.py b/models/track.py index e0905e6..2a92558 100644 --- a/models/track.py +++ b/models/track.py @@ -2,7 +2,6 @@ import logging import sqlite3 from common import utils -from db.db_manager import DbManager from models.artist import Artist from models.album import Album from models.base import BaseModel @@ -13,15 +12,14 @@ logging.basicConfig(format="%(asctime)s %(message)s", level=logging.DEBUG) class Track(BaseModel): - def __init__(self, id=None, db=None, **kwargs): - if db: - self.db = db + def __init__(self, db, id=None, **kwargs): + self._db = db self.__data = {} if id is not None: - for row in self.db.execute("SELECT * FROM track WHERE id = ?", - (id,)): + for row in self._db.execute("SELECT * FROM track WHERE id = ?", + (id,)): for key in ["id", "tracknumber", "name", "grouping", "filename"]: setattr(self, key, row[key]) @@ -32,11 +30,10 @@ class Track(BaseModel): self.__data[key] = value def delete(self): - delete_sql = "DELETE FROM track WHERE id = ?" - with self.db.conn: - self.db.execute(delete_sql, (self.id,)) + with self._db.conn: + self._db.execute(delete_sql, (self.id,)) # If there is an old album, remove it if it no longer has any # tracks @@ -48,8 +45,8 @@ class Track(BaseModel): old_album = self.album if old_album: - self.db.execute("DELETE FROM album_track WHERE track_id = ?", - (self.id,)) + self._db.execute("DELETE FROM album_track WHERE track_id = ?", + (self.id,)) if not old_album.tracks: old_album.delete() @@ -63,8 +60,8 @@ class Track(BaseModel): old_artists = self.artists for old_artist in old_artists: - self.db.execute("DELETE FROM artist_track WHERE track_id = " - "?", (self.id,)) + self._db.execute("DELETE FROM artist_track WHERE track_id = " + "?", (self.id,)) if not old_artist.tracks: old_artist.delete() @@ -73,11 +70,8 @@ class Track(BaseModel): @property def db(self): - try: - return self._db - except AttributeError: - self._db = DbManager() - return self._db + return self._db + @db.setter def db(self, db): @@ -88,11 +82,11 @@ class Track(BaseModel): if not hasattr(self, "_album"): setattr(self, "_album", None) - for row in self.db.execute("SELECT album.* FROM album INNER " - "JOIN album_track ON album.id = " - "album_track.album_id WHERE " - "track_id = ? LIMIT 1", (self.id,)): - setattr(self, "_album", Album(id=row["id"], db=self.db, + for row in self._db.execute("SELECT album.* FROM album INNER " + "JOIN album_track ON album.id = " + "album_track.album_id WHERE " + "track_id = ? LIMIT 1", (self.id,)): + setattr(self, "_album", Album(id=row["id"], db=self._db, name=row["name"], date=row["date"])) @@ -101,7 +95,7 @@ class Track(BaseModel): @property def artists(self): if not hasattr(self, "_artists"): - cursor = self.db.cursor() + cursor = self._db.cursor() setattr(self, "_artists", []) for row in cursor.execute("SELECT artist.* FROM artist INNER JOIN " @@ -109,7 +103,7 @@ class Track(BaseModel): "artist_track.artist_id WHERE " "artist_track.track_id = ?", (self.id,)): - self._artists.append(Artist(id=row["id"], db=self.db, + self._artists.append(Artist(id=row["id"], db=self._db, name=row["name"], sortname=row["sortname"], musicbrainz_artistid=row[ @@ -118,7 +112,10 @@ class Track(BaseModel): return self._artists def update(self, metadata): - c = self.db.cursor() + c = self._db.cursor() + + artist_changed = False + album_changed = False artist_names = metadata["artist"] musicbrainz_artist_ids = [] @@ -136,6 +133,7 @@ class Track(BaseModel): artists = [] for artist_name in artist_names: + artist = None musicbrainz_artistid = None artistsort = None @@ -161,7 +159,7 @@ class Track(BaseModel): row = rows.fetchone() if row: - artist = Artist(id=row["id"], db=self.db, name=row["name"], + artist = Artist(id=row["id"], db=self._db, name=row["name"], sortname=row["sortname"], musicbrainz_artistid=row[ "musicbrainz_artistid"]) @@ -182,7 +180,7 @@ class Track(BaseModel): (artist_name, artistsort, musicbrainz_artistid)) artist = Artist( - id=c.lastrowid, db=self.db, name=artist_name, + id=c.lastrowid, db=self._db, name=artist_name, sortname=artistsort, musicbrainz_artistid=musicbrainz_artistid ) @@ -219,7 +217,7 @@ class Track(BaseModel): row = rows.fetchone() if row: - album = Album(id=row["id"], db=self.db, name=row["name"], + album = Album(id=row["id"], db=self._db, name=row["name"], date=row["date"], musicbrainz_albumid=row["musicbrainz_albumid"]) else: @@ -227,7 +225,7 @@ class Track(BaseModel): "musicbrainz_albumid) VALUES (?, ?, ?)", (album_name, album_date, mb_albumid)) - album = Album(id=c.lastrowid, db=self.db, name=album_name, + album = Album(id=c.lastrowid, db=self._db, name=album_name, date=album_date, musicbrainz_albumid=mb_albumid) elif album_name: @@ -239,14 +237,14 @@ class Track(BaseModel): row = rows.fetchone() if row: - album = Album(id=row["id"], db=self.db, name=row["name"], + album = Album(id=row["id"], db=self._db, name=row["name"], date=row["date"], musicbrainz_albumid=row["musicbrainz_albumid"]) else: c.execute("INSERT INTO album (name, `date`) VALUES (?, ?)", (album_name, album_date)) - album = Album(id=c.lastrowid, db=self.db, name=album_name, + album = Album(id=c.lastrowid, db=self._db, name=album_name, date=album_date) if album: @@ -290,12 +288,9 @@ class Track(BaseModel): pass old_album = self.album - if old_album: - c.execute("DELETE FROM album_track WHERE track_id = ?", (self.id,)) - - if not old_album.tracks: - old_album.delete() + c.execute("DELETE FROM album_track WHERE track_id = ?", + (self.id,)) # If there are old artists, remove them if they no longer have # any tracks @@ -309,9 +304,6 @@ class Track(BaseModel): c.execute("DELETE FROM artist_track WHERE track_id = ?", (self.id,)) - if not old_artist.tracks: - old_artist.delete() - if album: try: c.execute("INSERT INTO album_track (album_id, track_id) " @@ -319,6 +311,9 @@ class Track(BaseModel): except sqlite3.IntegrityError: pass + if not old_album.tracks: + old_album.delete() + setattr(self, "_album", album) for artist in artists: @@ -328,6 +323,9 @@ class Track(BaseModel): except sqlite3.IntegrityError: pass + if not old_artist.tracks: + old_artist.delete() + if album: try: c.execute( @@ -339,8 +337,8 @@ class Track(BaseModel): if artists: setattr(self, "_artists", artists) - self.db.commit() c.close() + self._db.commit() return True @@ -364,10 +362,11 @@ class Track(BaseModel): sql = " ".join(("UPDATE track", set_clause, "WHERE id = :id")) - with self.db.conn: - self.db.execute(sql, dirty_attributes) + with self._db.conn: + self._db.execute(sql, dirty_attributes) - def search(db=None, **search_params): + @classmethod + def search(cls, database, **search_params): """Find a track with the given params Args: @@ -376,10 +375,6 @@ class Track(BaseModel): grouping: dict, with 'data' and 'operator' keys filename: dict, with 'data' and 'operator' keys """ - - if not db: - db = DbManager() - tracks = [] # unpack search params @@ -394,40 +389,37 @@ class Track(BaseModel): result = None if where_clause: statement = " ".join(("SELECT * FROM track", where_clause)) - result = db.execute(statement, value_params) + result = database.execute(statement, value_params) else: - result = db.execute("SELECT * FROM track") + result = database.execute("SELECT * FROM track") for row in result: tracks.append( - Track(id=row["id"], db=db, tracknumber=row["tracknumber"], + Track(id=row["id"], db=database, tracknumber=row["tracknumber"], name=row["name"], grouping=row["grouping"], filename=row["filename"]) ) return tracks - def find_by_path(path, db=None): - if not db: - db = DbManager() + @classmethod + def find_by_path(cls, path, database): track = None - for row in db.execute("SELECT * FROM track WHERE filename = ? " - "LIMIT 1", (path,)): - track = Track(id=row["id"], db=db, tracknumber=row["tracknumber"], - name=row["name"], grouping=row["grouping"], - filename=row["filename"]) + for row in database.execute("SELECT * FROM track WHERE filename = ? " + "LIMIT 1", (path,)): + track = Track(id=row["id"], db=database, + tracknumber=row["tracknumber"], name=row["name"], + grouping=row["grouping"], filename=row["filename"]) return track - def store(filename, metadata, db=None): - if Track.find_by_path(filename, db=db): + @classmethod + def store(cls, filename, metadata, database): + if Track.find_by_path(filename, database): return True - if not db: - db = DbManager() - - c = db.cursor() + c = database.cursor() artist_names = metadata["artist"] musicbrainz_artist_ids = [] @@ -475,7 +467,7 @@ class Track(BaseModel): row = rows.fetchone() if row: - artist = Artist(id=row["id"], db=db, name=row["name"], + artist = Artist(id=row["id"], db=database, name=row["name"], sortname=row["sortname"], musicbrainz_artistid=row[ "musicbrainz_artistid"]) @@ -499,7 +491,7 @@ class Track(BaseModel): (artist_name, artistsort, musicbrainz_artistid)) artist = Artist( - id=c.lastrowid, db=db, name=artist_name, + id=c.lastrowid, db=database, name=artist_name, sortname=artistsort, musicbrainz_artistid=musicbrainz_artistid ) @@ -534,15 +526,15 @@ class Track(BaseModel): row = rows.fetchone() if row: - album = Album(id=row["id"], db=db, name=row["name"], + album = Album(id=row["id"], db=database, name=row["name"], date=row["date"], musicbrainz_albumid=row[ - "musicbrainz_albumid"]) + "musicbrainz_albumid"]) else: c.execute("INSERT INTO album (name, `date`, " "musicbrainz_albumid) VALUES (?, ?, ?)", (album_name, album_date, mb_albumid)) - album = Album(id=c.lastrowid, db=db, name=album_name, + album = Album(id=c.lastrowid, db=database, name=album_name, date=album_date, musicbrainz_albumid=mb_albumid) elif album_name: @@ -554,13 +546,13 @@ class Track(BaseModel): row = rows.fetchone() if row: - album = Album(id=row["id"], db=db, name=row["name"], + album = Album(id=row["id"], db=database, name=row["name"], date=row["date"]) else: c.execute("INSERT INTO album (name, `date`) VALUES(?, ?)", (album_name, album_date)) - album = Album(id=c.lastrowid, db=db, name=album_name, + album = Album(id=c.lastrowid, db=database, name=album_name, date=album_date) for artist in artists: @@ -591,7 +583,7 @@ class Track(BaseModel): rows = c.execute("SELECT * FROM track WHERE filename = ?", (filename,)) row = rows.fetchone() if row: - track = Track(id=row["id"], db=db, + track = Track(id=row["id"], db=database, tracknumber=row["tracknumber"], name=row["name"], grouping=row["grouping"], filename=row["filename"]) else: @@ -600,7 +592,7 @@ class Track(BaseModel): (track_number, track_name, track_grouping, filename)) - track = Track(id=c.lastrowid, db=db, tracknumber=track_number, + track = Track(id=c.lastrowid, db=database, tracknumber=track_number, name=track_name, grouping=track_grouping, filename=filename) @@ -618,16 +610,14 @@ class Track(BaseModel): except sqlite3.IntegrityError: pass - db.commit() + database.commit() c.close() return track - def all(db=None, order="track.id", direction="ASC", limit=None, + @classmethod + def all(cls, database, order="track.id", direction="ASC", limit=None, offset=None): - if not db: - db = DbManager() - tracks = [] select_string = "SELECT * FROM track LEFT JOIN artist_track ON " \ @@ -641,10 +631,10 @@ class Track(BaseModel): select_string = " ".join((select_string, "LIMIT %s OFFSET %s" % (limit, offset))) - result = db.execute(select_string) + result = database.execute(select_string) for row in result: - tracks.append(Track(id=row["id"], db=db, + tracks.append(Track(id=row["id"], db=database, tracknumber=row["tracknumber"], name=row["name"], grouping=row["name"], filename=row["filename"])) diff --git a/models/user.py b/models/user.py index 925df5e..ea90f39 100644 --- a/models/user.py +++ b/models/user.py @@ -1,11 +1,11 @@ -import os +from os import urandom -from flask.ext.login import make_secure_token +from itsdangerous import URLSafeTimedSerializer from common.security import pwd_context, secret_key -class User: +class User(object): def __init__(self, **kwargs): for (key, value) in kwargs.items(): setattr(self, key, value) @@ -43,16 +43,17 @@ class User: def new_password(self, password, category=None): if self.id: - hash = None + the_hash = None if category: - hash = pwd_context.encrypt(password, category=category) + the_hash = pwd_context.encrypt(password, category=category) else: - hash = pwd_context.encrypt(password) + the_hash = pwd_context.encrypt(password) - api_key = make_secure_token(hash, os.urandom(64), key=secret_key) + serializer = URLSafeTimedSerializer(password, salt=urandom(64)) + api_key = serializer.dumps(the_hash) - return hash, api_key + return the_hash, api_key else: raise ValueError("No user") diff --git a/tests/conftest.py b/tests/conftest.py index 0028475..90364ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,8 +8,8 @@ from db.db_manager import DbManager @pytest.fixture(scope="module") def database(request): database = DbManager( - db=os.path.join(os.path.dirname(os.path.realpath(__file__)), - "test.db")) + os.path.join(os.path.dirname(os.path.realpath(__file__)), + "test.db")) def fin(): database.close() @@ -32,8 +32,8 @@ def app(request): db = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testapp.db") library_db = DbManager( - db=os.path.join(os.path.dirname(os.path.realpath(__file__)), - "test.db")) + os.path.join(os.path.dirname(os.path.realpath(__file__)), + "test.db")) def fin(): library_db.close() diff --git a/tests/mach2_test.py b/tests/mach2_test.py index c45ff2a..c33fd92 100644 --- a/tests/mach2_test.py +++ b/tests/mach2_test.py @@ -2,6 +2,7 @@ import json import unittest import pytest +import six from mach2 import create_app @@ -25,17 +26,17 @@ class Mach2TestCase(unittest.TestCase): def test_login(self): rv = self.login("admin", "testpass") - assert bytes("Log out", "utf-8") in rv.data + assert six.b("Log out") in rv.data self.logout() rv = self.login("wrong", "definitelywrong") - assert bytes("Log out", "utf-8") not in rv.data + assert six.b("Log out") not in rv.data self.logout() def test_album(self): self.login("admin", "testpass") rv = self.app.get("/albums/1") - assert bytes("Album 1", "utf-8") in rv.data + assert six.b("Album 1") in rv.data self.logout() @@ -43,8 +44,8 @@ class Mach2TestCase(unittest.TestCase): self.login("admin", "testpass") rv = self.app.get("/artists") - assert bytes("Artist 1", "utf-8") in rv.data - assert bytes("Artist 2", "utf-8") in rv.data + assert six.b("Artist 1") in rv.data + assert six.b("Artist 2") in rv.data artists = json.loads(rv.data.decode("utf-8")) assert artists diff --git a/tests/models/album_test.py b/tests/models/album_test.py index 680a5cb..5153626 100644 --- a/tests/models/album_test.py +++ b/tests/models/album_test.py @@ -2,20 +2,20 @@ from models.album import Album def test_instance(database): - album = Album(id=1, db=database) + album = Album(database, 1) assert album.id == 1 assert album.name == "Album 1" assert album.date == "1999-02-04" def test_artists(database): - album = Album(id=1, db=database) + album = Album(database, 1) assert len(album.artists) == 1 assert album.artists[0].name == "Artist 2" def test_tracks(database): - album = Album(id=1, db=database) + album = Album(database, 1) assert len(album.tracks) == 2 assert album.tracks[0].name == "Album track 1" assert album.tracks[0].tracknumber == 1 @@ -36,28 +36,28 @@ def test_delete(database): album_id = cursor.lastrowid cursor.close() - album = Album(album_id, db=database) + album = Album(database, album_id) assert album.delete() - test_album = Album(album_id, db=database) + test_album = Album(database, album_id) assert not hasattr(test_album, "name") def test_search(database): search_payload = {"name": {"data": "Album 1", "operator": "="}} - album_results = Album.search(db=database, **search_payload) + album_results = Album.search(database, **search_payload) assert len(album_results) > 0 invalid_search_payload = {"name": {"data": "This album does not exist", "operator": "="}} - no_album_results = Album.search(db=database, **invalid_search_payload) + no_album_results = Album.search(database, **invalid_search_payload) assert len(no_album_results) == 0 def test_all(database): - album_results = Album.all(db=database) + album_results = Album.all(database) assert len(album_results) > 0 diff --git a/tests/models/artist_test.py b/tests/models/artist_test.py index bb66813..b3504a1 100644 --- a/tests/models/artist_test.py +++ b/tests/models/artist_test.py @@ -2,27 +2,27 @@ from models.artist import Artist def test_instance(database): - album = Artist(id=1, db=database) + album = Artist(database, 1) assert album.id == 1 assert album.name == "Artist 1" def test_albums(database): - artist1 = Artist(id=1, db=database) + artist1 = Artist(database, 1) assert len(artist1.albums) == 0 - artist2 = Artist(id=2, db=database) + artist2 = Artist(database, 2) assert len(artist2.albums) == 1 assert artist2.albums[0].name == "Album 1" assert artist2.albums[0].date == "1999-02-04" def test_tracks(database): - artist1 = Artist(id=1, db=database) + artist1 = Artist(database, 1) assert len(artist1.tracks) == 1 assert artist1.tracks[0].name == "Non album track" assert artist1.tracks[0].tracknumber is None assert artist1.tracks[0].filename == "1.mp3" - artist2 = Artist(id=2, db=database) + artist2 = Artist(database, 2) assert artist2.tracks[0].name == "Album track 1" assert artist2.tracks[0].tracknumber == 1 assert artist2.tracks[0].filename == "album/1.mp3" @@ -40,28 +40,28 @@ def test_delete(database): artist_id = cursor.lastrowid - artist = Artist(artist_id, db=database) + artist = Artist(database, artist_id) assert artist.delete() - test_artist = Artist(artist_id, db=database) + test_artist = Artist(database, artist_id) assert not hasattr(test_artist, "name") def test_search(database): search_payload = {"name": {"data": "Artist 1", "operator": "="}} - artist_results = Artist.search(db=database, **search_payload) + artist_results = Artist.search(database, **search_payload) assert len(artist_results) > 0 invalid_search_payload = {"name": {"data": "This artist does not exist", "operator": "="}} - no_artist_results = Artist.search(db=database, **invalid_search_payload) + no_artist_results = Artist.search(database, **invalid_search_payload) assert len(no_artist_results) == 0 def test_all(database): - artist_results = Artist.all(db=database) + artist_results = Artist.all(database) assert len(artist_results) > 0 diff --git a/tests/models/track_test.py b/tests/models/track_test.py index 56685fd..2bb4df1 100644 --- a/tests/models/track_test.py +++ b/tests/models/track_test.py @@ -4,14 +4,14 @@ from models.track import Track def test_instance(database): - track = Track(id=1, db=database) + track = Track(database, 1) assert track.id == 1 assert track.name == "Non album track" assert track.filename == "1.mp3" - + assert track.artists def test_as_dict(database): - track = Track(id=1, db=database) + track = Track(database, 1) track_dict = track.as_dict() @@ -22,35 +22,35 @@ def test_as_dict(database): def test_album(database): - track1 = Track(id=1, db=database) + track1 = Track(database, 1) assert track1.album is None - track2 = Track(id=2, db=database) + track2 = Track(database, 2) assert track2.album.name == "Album 1" assert track2.album.date == "1999-02-04" def test_artists(database): - track = Track(id=1, db=database) - assert track.artists is not None - assert len(track.artists) > 0 + track = Track(database, 1) + assert track.artists assert track.artists[0].name == "Artist 1" def test_find_by_path(database): - track1 = Track.find_by_path("album/2.mp3", db=database) + track1 = Track.find_by_path("album/2.mp3", database) assert track1.filename == "album/2.mp3" assert track1.name == "Album track 2" assert track1.grouping == "swing" + assert track1.artists nonexistent_track = Track.find_by_path("path/does/not/exist.mp3", - db=database) + database) assert nonexistent_track is None def test_search(database): - tracks = Track.search(db=database, name={"data": "Album track %", - "operator": "LIKE"}) + tracks = Track.search(database, name={"data": "Album track %", + "operator": "LIKE"}) assert tracks is not None assert len(tracks) == 2 @@ -59,7 +59,7 @@ def test_search(database): def test_store(database, test_file): metadata = mutagen.File(test_file, easy=True) - test_track = Track.store(test_file, metadata, db=database) + test_track = Track.store(test_file, metadata, database) assert test_track.filename == test_file assert test_track.name == "Silence" @@ -76,7 +76,7 @@ def test_store(database, test_file): def test_update(database, test_file): metadata = {"artist": ["New artist"], "title": ["New title"]} - test_track = Track.find_by_path(test_file, db=database) + test_track = Track.find_by_path(test_file, database) test_track.update(metadata) assert test_track.artists @@ -86,21 +86,21 @@ def test_update(database, test_file): def test_save(database, test_file): - test_track = Track.find_by_path(test_file, db=database) + test_track = Track.find_by_path(test_file, database) test_track.name = "Totally new name" test_track.save() - new_track_to_test = Track.find_by_path(test_file, db=database) + new_track_to_test = Track.find_by_path(test_file, database) assert new_track_to_test.name == "Totally new name" def test_delete(database, test_file): - test_track = Track.find_by_path(test_file, db=database) + test_track = Track.find_by_path(test_file, database) test_track.delete() - should_not_exist = Track.find_by_path(test_file, db=database) + should_not_exist = Track.find_by_path(test_file, database) assert should_not_exist is None diff --git a/tests/test.db b/tests/test.db index ccb7825..1602d6f 100644 Binary files a/tests/test.db and b/tests/test.db differ diff --git a/tests/testapp.db b/tests/testapp.db index 2fc7b8c..3ccfa2f 100644 Binary files a/tests/testapp.db and b/tests/testapp.db differ diff --git a/tests/testnew.ogg b/tests/testnew.ogg new file mode 100644 index 0000000..64a9f49 Binary files /dev/null and b/tests/testnew.ogg differ diff --git a/tests/watcher_test.py b/tests/watcher_test.py new file mode 100644 index 0000000..5f92d36 --- /dev/null +++ b/tests/watcher_test.py @@ -0,0 +1,96 @@ +"""Tests for the watcher module.""" +from os import remove +from os.path import dirname, join, realpath +from shutil import copy, move, rmtree +from tempfile import mkdtemp +import unittest + +import mutagen +import six + +from db.db_manager import DbManager +from models.track import Track +from watcher import LibraryWatcher + + +if six.PY2: + def _u(string): + return unicode(string, encoding="utf_8") +else: + def _u(string): + return string + + +class WatcherTestCase(unittest.TestCase): + """Defines tests for the watcher module. + + Extends: + unittest.Testcase + + """ + + @classmethod + def setUpClass(cls): + """Set up fixtures for the tests.""" + cls._tempdir = mkdtemp() + cls._tempdbdir = mkdtemp() + cls._db_path = join(cls._tempdbdir, "test.db") + copy(join(dirname(realpath(__file__)), "test.db"), cls._db_path) + cls._db = DbManager(cls._db_path) + cls._watcher = LibraryWatcher(cls._tempdir, cls._db_path) + + @classmethod + def tearDownClass(cls): + """Remove test fixtures.""" + cls._watcher.stop() + rmtree(cls._tempdbdir) + rmtree(cls._tempdir) + + def test_watcher_actions(self): + """Test creating, moving, modifying and deleting a file.""" + new_file = join(dirname(realpath(__file__)), "testnew.ogg") + copy(new_file, self._tempdir) + + self._watcher.check_for_events() + + found_track = Track.find_by_path(join(self._tempdir, "testnew.ogg"), + self._db) + assert found_track + assert "testnew.ogg" in found_track.filename + assert found_track.artists + found_artist = False + for artist in found_track.artists: + if _u("Art Ist") == artist.name: + found_artist = True + break + + assert found_artist + + original_file = join(self._tempdir, "testnew.ogg") + moved_file = join(self._tempdir, "testmoved.ogg") + move(original_file, moved_file) + + self._watcher.check_for_events() + + moved_track = Track.find_by_path(moved_file, self._db) + + assert moved_track + assert "testmoved.ogg" in moved_track.filename + + original_metadata = mutagen.File(moved_file, easy=True) + + original_metadata["title"] = [_u("New title")] + original_metadata.save() + + self._watcher.check_for_events() + + modified_track = Track.find_by_path(moved_file, self._db) + + assert modified_track + assert modified_track.name == "New title" + + remove(moved_file) + + self._watcher.check_for_events() + + assert Track.find_by_path(moved_file, self._db) is None diff --git a/watcher.py b/watcher.py index 9af4a6b..353636a 100644 --- a/watcher.py +++ b/watcher.py @@ -1,41 +1,104 @@ -import atexit -import configparser +""" +Defines a watcher service to update the media library. + +This module implements a monitoring service to update the library when files +are added or removed from the media library directory. +""" +from atexit import register +import logging import os import pyinotify -import library +from db.db_manager import DbManager +from library import MediaLibrary + + +_LOGGER = logging.getLogger(__name__) class EventHandler(pyinotify.ProcessEvent): + """Event handler defining actions for adding/moving/removing files. + + Extends: + pyinotify.ProcessEvent + + """ + + def __init__(self, media_library, pevent=None, **kwargs): + """Create the event handler. + + Args: + media_library (MediaLibrary): The media library. + + """ + self.__library = media_library + + super(self.__class__, self).__init__(pevent=None, **kwargs) + def process_IN_CREATE(self, event): - print("Creating:", event.pathname) - library.run(event.pathname) + """Add a file to the library when it is created. + + Args: + event (pynotify.Event) - the event raised by inotify. + + """ + _LOGGER.debug("Creating: %s", event.pathname) + self.__library.run(event.pathname) def process_IN_DELETE(self, event): - print("Removing:", event.pathname) + """Remove a file from the library when it is deleted. + + Args: + event (pynotify.Event) - the event raised by inotify. + + """ + _LOGGER.debug("Removing: %s", event.pathname) if not os.path.isdir(event.pathname): - library.delete_file(event.pathname) + self.__library.delete_file(event.pathname) def process_IN_MOVED_TO(self, event): - print("Moved to:", event.pathname) - library.update_track_filename(event.src_pathname, event.pathname) + """Update a file's information in the library when it is moved. + + Args: + event (pynotify.Event) - the event raised by inotify. + + """ + _LOGGER.debug("Moved to: %s", event.pathname) + self.__library.update_track_filename(event.src_pathname, + event.pathname) # moving the file may also hint that the metadata has changed - library.update_file(event.pathname) + self.__library.update_file(event.pathname) def process_IN_MODIFY(self, event): - print("Modified:", event.pathname) + """Update a file's information in the library when it is modified. + + Args: + event (pynotify.Event) - the event raised by inotify. + + """ + _LOGGER.debug("Modified: %s", event.pathname) if not os.path.isdir(event.pathname): - library.update_file(event.pathname) + self.__library.update_file(event.pathname) -class LibraryWatcher: +class LibraryWatcher(object): + """Watches the library.""" - def __init__(self, path): - print("Setting up library watcher") + def __init__(self, path, database_path): + """Create the LibraryWatcher. + + Args: + path (str): the patch of the directory to watch. + + """ + _LOGGER.info("Setting up library watcher") + database = DbManager(database_path) + library = MediaLibrary(path, database) + _LOGGER.info("Using %s", database) if not hasattr(self, "path"): setattr(self, "path", path) @@ -50,25 +113,38 @@ class LibraryWatcher: if not hasattr(self, "notifier"): setattr(self, "notifier", - pyinotify.ThreadedNotifier(self.wm, EventHandler())) - - self.notifier.coalesce_events() - self.notifier.start() + pyinotify.Notifier(self.wm, EventHandler(library), + timeout=10)) if not hasattr(self, "wdd"): setattr(self, "wdd", self.wm.add_watch(path, mask, rec=True, auto_add=True)) - print("Set up watch on ", path) + self.notifier.coalesce_events() + + _LOGGER.info("Set up watch on %s", path) - atexit.register(self.stop) + register(self.stop) def stop(self): + """Remove all the watched paths.""" if self.wdd[self.path] > 0: self.wm.rm_watch(self.wdd[self.path], rec=True) + def check_for_events(self): + """Check for any notification events.""" + assert self.notifier._timeout is not None + self.notifier.process_events() + while self.notifier.check_events(): + self.notifier.read_events() + self.notifier.process_events() + + if __name__ == "__main__": + from six.moves import configparser + config = configparser.ConfigParser() config.read("mach2.ini") - watch = LibraryWatcher(config["DEFAULT"]["media_dir"]) + watch = LibraryWatcher(config.get("DEFAULT", "media_dir"), + config.get("DEFAULT", "library")) -- cgit v1.2.3