diff --git a/.travis.yml b/.travis.yml index 90f182c..fab5014 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,6 @@ python: - 3.5 - 3.6 install: - - pip install -r requirements.txt - - pip install lxml coverage codecov + - pip install -r travis-requirements.txt script: coverage run setup.py test after_script: codecov diff --git a/README.md b/README.md index 815e058..eefbacc 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Current supported features are: * streaming of various audio file formats * [transcoding] * user or random playlists -* cover arts (`cover.jpg` files in the same folder as music files) +* cover arts (as image files in the same folder as music files) * starred tracks/albums and ratings * [Last.FM][lastfm] scrobbling @@ -33,6 +33,7 @@ details, go check the [API implementation status][docs-api]. + [As a standalone debug server](#as-a-standalone-debug-server) + [As an Apache WSGI application](#as-an-apache-wsgi-application) + [Other options](#other-options) + + [Docker](#docker) * [Quickstart](#quickstart) * [Watching library changes](#watching-library-changes) * [Upgrading](#upgrading) @@ -41,10 +42,18 @@ details, go check the [API implementation status][docs-api]. _Supysonic_ can run as a standalone application (not recommended for a "production" server) or as a WSGI application (on _Apache_ for instance). -To install it, run: + +To install it, either run: $ python setup.py install +or + + $ pip install . + +but not both. Please note that the `pip` method doesn't seem to work with +Python 2.7. + ### Prerequisites You'll need these to run _Supysonic_: @@ -55,13 +64,14 @@ You'll need these to run _Supysonic_: * [Python Imaging Library](https://github.com/python-pillow/Pillow) * [requests](http://docs.python-requests.org/) * [mutagen](https://mutagen.readthedocs.io/en/latest/) -* [watchdog](https://github.com/gorakhargosh/watchdog) +* [watchdog](https://github.com/gorakhargosh/watchdog) (if you want to use the + [watcher](#watching-library-changes)) -You can install all of them using `pip`: +All the dependencies (except _watchdog_) will automatically be installed by the +installation command above. - $ pip install -r requirements.txt - -You may also need a database specific package: +You may also need a database specific package if you don't want to use SQLite +(the default): * _MySQL_: `pip install pymysql` or `pip install mysqlclient` * _PostgreSQL_: `pip install psycopg2-binary` @@ -156,8 +166,7 @@ example of what it looks like: WSGIApplicationGroup %{GLOBAL} WSGIPassAuthorization On - Order deny,allow - Allow from all + Require all granted You might also need to run _Apache_ using the system default locale, as the one @@ -183,6 +192,18 @@ Here are some quick docs on how to configure your server for [FastCGI][] or [fastcgi]: http://flask.pocoo.org/docs/deploying/fastcgi/ [cgi]: http://flask.pocoo.org/docs/deploying/cgi/ +### Docker + +If you want to run _Supysonic_ in a _Docker_ container, here are some images +provided by the community. + +- https://github.com/ultimate-pms/docker-supysonic +- https://github.com/ogarcia/docker-supysonic +- https://github.com/foosinn/supysonic +- https://github.com/mikafouenski/docker-supysonic +- https://github.com/oakman/supysonic-docker +- https://github.com/glogiotatidis/supysonic-docker + ## Quickstart To start using _Supysonic_, you'll first have to specify where your music @@ -213,11 +234,16 @@ Instead of manually running a scan every time your library changes, you can run a watcher that will listen to any library change and update the database accordingly. -The watcher is `bin/supysonic-watcher`, it is a non-exiting process and doesn't +The watcher is `supysonic-watcher`, it is a non-exiting process and doesn't print anything to the console. If you want to keep it running in background, either use the old `nohup` or `screen` methods, or start it as a simple _systemd_ unit (unit file not included). +It needs some additional dependencies which can be installed with the following +command: + + $ pip install -e .[watcher] + ## Upgrading Some commits might introduce changes in the database schema. When that's the diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7c1623b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -flask>=0.11 -pony>=0.7.2 -Pillow -requests>=1.0.0 -mutagen>=1.33 -watchdog>=0.8.0 diff --git a/schema/migration/20180521.mysql.sql b/schema/migration/20180521.mysql.sql new file mode 100644 index 0000000..285c827 --- /dev/null +++ b/schema/migration/20180521.mysql.sql @@ -0,0 +1,12 @@ +START TRANSACTION; + +ALTER TABLE folder ADD cover_art VARCHAR(256) AFTER has_cover_art; + +UPDATE folder +SET cover_art = 'cover.jpg' +WHERE has_cover_art; + +ALTER TABLE folder DROP COLUMN has_cover_art; + +COMMIT; + diff --git a/schema/migration/20180521.postgresql.sql b/schema/migration/20180521.postgresql.sql new file mode 100644 index 0000000..2c7da54 --- /dev/null +++ b/schema/migration/20180521.postgresql.sql @@ -0,0 +1,12 @@ +START TRANSACTION; + +ALTER TABLE folder ADD cover_art VARCHAR(256); + +UPDATE folder +SET cover_art = 'cover.jpg' +WHERE has_cover_art; + +ALTER TABLE folder DROP COLUMN has_cover_art; + +COMMIT; + diff --git a/schema/migration/20180521.sqlite.sql b/schema/migration/20180521.sqlite.sql new file mode 100644 index 0000000..4ffbdc2 --- /dev/null +++ b/schema/migration/20180521.sqlite.sql @@ -0,0 +1,27 @@ +BEGIN TRANSACTION; + +DROP INDEX index_folder_path; +ALTER TABLE folder RENAME TO folder_old; + +CREATE TABLE folder ( + id CHAR(36) PRIMARY KEY, + root BOOLEAN NOT NULL, + name VARCHAR(256) NOT NULL COLLATE NOCASE, + path VARCHAR(4096) NOT NULL, + path_hash BLOB NOT NULL, + created DATETIME NOT NULL, + cover_art VARCHAR(256), + last_scan INTEGER NOT NULL, + parent_id CHAR(36) REFERENCES folder +); +CREATE UNIQUE INDEX index_folder_path ON folder(path_hash); + +INSERT INTO folder(id, root, name, path, path_hash, created, cover_art, last_scan, parent_id) +SELECT id, root, name, path, path_hash, created, CASE WHEN has_cover_art THEN 'cover.jpg' ELSE NULL END, last_scan, parent_id +FROM folder_old; + +DROP TABLE folder_old; + +COMMIT; +VACUUM; + diff --git a/schema/mysql.sql b/schema/mysql.sql index 518c9c0..edc54e2 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -5,7 +5,7 @@ CREATE TABLE folder ( path VARCHAR(4096) NOT NULL, path_hash BINARY(20) NOT NULL, created DATETIME NOT NULL, - has_cover_art BOOLEAN NOT NULL, + cover_art VARCHAR(256), last_scan INTEGER NOT NULL, parent_id BINARY(16) REFERENCES folder ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/schema/postgresql.sql b/schema/postgresql.sql index e34a3e2..0c4f7a4 100644 --- a/schema/postgresql.sql +++ b/schema/postgresql.sql @@ -5,7 +5,7 @@ CREATE TABLE folder ( path VARCHAR(4096) NOT NULL, path_hash BYTEA NOT NULL, created TIMESTAMP NOT NULL, - has_cover_art BOOLEAN NOT NULL, + cover_art VARCHAR(256), last_scan INTEGER NOT NULL, parent_id UUID REFERENCES folder ); diff --git a/schema/sqlite.sql b/schema/sqlite.sql index ac076d3..5ee370d 100644 --- a/schema/sqlite.sql +++ b/schema/sqlite.sql @@ -5,7 +5,7 @@ CREATE TABLE folder ( path VARCHAR(4096) NOT NULL, path_hash BLOB NOT NULL, created DATETIME NOT NULL, - has_cover_art BOOLEAN NOT NULL, + cover_art VARCHAR(256), last_scan INTEGER NOT NULL, parent_id CHAR(36) REFERENCES folder ); diff --git a/setup.py b/setup.py index 98bea82..3b46027 100755 --- a/setup.py +++ b/setup.py @@ -14,10 +14,19 @@ import supysonic as project from setuptools import setup from setuptools import find_packages -from pip.req import parse_requirements -from pip.download import PipSession +reqs = [ + 'flask>=0.11', + 'pony>=0.7.6', + 'Pillow', + 'requests>=1.0.0', + 'mutagen>=1.33' +] +extras = { + 'watcher': [ 'watchdog>=0.8.0' ] +} + setup( name=project.NAME, version=project.VERSION, @@ -28,14 +37,14 @@ setup( author_email=project.AUTHOR_EMAIL, url=project.URL, license=project.LICENSE, - packages=find_packages(), - install_requires=[str(x.req) for x in - parse_requirements('requirements.txt', session=PipSession())], + packages=find_packages(exclude=['tests*']), + install_requires = reqs, + extras_require = extras, scripts=['bin/supysonic-cli', 'bin/supysonic-watcher'], zip_safe=False, include_package_data=True, - test_suite="tests.suite", - tests_require = [ 'lxml' ], + test_suite='tests.suite', + tests_require = [ 'lxml' ] + [ r for er in extras.values() for r in er ], classifiers=[ 'Development Status :: 3 - Alpha', 'Environment :: Console', diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index b5abb9a..f0934fb 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -67,7 +67,7 @@ def album_list(): elif ltype == 'frequent': query = query.order_by(lambda f: desc(avg(f.tracks.play_count))) elif ltype == 'recent': - query = query.order_by(lambda f: desc(max(f.tracks.last_play))) + query = select(t.folder for t in Track if max(t.folder.tracks.last_play) is not None).order_by(lambda f: desc(max(f.tracks.last_play))) elif ltype == 'starred': query = select(s.starred for s in StarredFolder if s.user.id == request.user.id and count(s.starred.tracks) > 0) elif ltype == 'alphabeticalByName': @@ -99,7 +99,7 @@ def album_list_id3(): elif ltype == 'frequent': query = query.order_by(lambda a: desc(avg(a.tracks.play_count))) elif ltype == 'recent': - query = query.order_by(lambda a: desc(max(a.tracks.last_play))) + query = Album.select(lambda a: max(a.tracks.last_play) is not None).order_by(lambda a: desc(max(a.tracks.last_play))) elif ltype == 'starred': query = select(s.starred for s in StarredAlbum if s.user.id == request.user.id) elif ltype == 'alphabeticalByName': diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 9fe30fe..8ccf426 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -132,29 +132,30 @@ def download_media(): @api.route('/getCoverArt.view', methods = [ 'GET', 'POST' ]) def cover_art(): res = get_entity(Folder) - if not res.has_cover_art or not os.path.isfile(os.path.join(res.path, 'cover.jpg')): + if not res.cover_art or not os.path.isfile(os.path.join(res.path, res.cover_art)): raise NotFound('Cover art') + cover_path = os.path.join(res.path, res.cover_art) size = request.values.get('size') if size: size = int(size) else: - return send_file(os.path.join(res.path, 'cover.jpg')) + return send_file(cover_path) - im = Image.open(os.path.join(res.path, 'cover.jpg')) - if size > im.size[0] and size > im.size[1]: - return send_file(os.path.join(res.path, 'cover.jpg')) + im = Image.open(cover_path) + if size > im.width and size > im.height: + return send_file(cover_path) size_path = os.path.join(current_app.config['WEBAPP']['cache_dir'], str(size)) path = os.path.abspath(os.path.join(size_path, str(res.id))) if os.path.exists(path): - return send_file(path, mimetype = 'image/jpeg') + return send_file(path, mimetype = 'image/' + im.format.lower()) if not os.path.exists(size_path): os.makedirs(size_path) im.thumbnail([size, size], Image.ANTIALIAS) - im.save(path, 'JPEG') - return send_file(path, mimetype = 'image/jpeg') + im.save(path, im.format) + return send_file(path, mimetype = 'image/' + im.format.lower()) @api.route('/getLyrics.view', methods = [ 'GET', 'POST' ]) def lyrics(): diff --git a/supysonic/api/search.py b/supysonic/api/search.py index 3ade661..758e7d1 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -41,7 +41,7 @@ def old_search(): if offset + count > fcount: toff = max(0, offset - fcount) tend = offset + count - fcount - res += tracks[toff : tend] + res = res[:] + tracks[toff : tend][:] return request.formatter('searchResult', dict( totalHits = folders.count() + tracks.count(), diff --git a/supysonic/covers.py b/supysonic/covers.py new file mode 100644 index 0000000..c0a9ef3 --- /dev/null +++ b/supysonic/covers.py @@ -0,0 +1,84 @@ +# coding: utf-8 +# +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2018 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import os, os.path +import re + +from PIL import Image + +EXTENSIONS = ('.jpg', '.jpeg', '.png', '.bmp') +NAMING_SCORE_RULES = ( + ('cover', 5), + ('albumart', 5), + ('folder', 5), + ('front', 10), + ('back', -10), + ('large', 2), + ('small', -2) +) + +class CoverFile(object): + __clean_regex = re.compile(r'[^a-z]') + @staticmethod + def __clean_name(name): + return CoverFile.__clean_regex.sub('', name.lower()) + + def __init__(self, name, album_name = None): + self.name = name + self.score = 0 + + for part, score in NAMING_SCORE_RULES: + if part in name.lower(): + self.score += score + + if album_name: + basename, _ = os.path.splitext(name) + clean = CoverFile.__clean_name(basename) + album_name = CoverFile.__clean_name(album_name) + if clean in album_name or album_name in clean: + self.score += 20 + +def is_valid_cover(path): + if not os.path.isfile(path): + return False + + _, ext = os.path.splitext(path) + if ext.lower() not in EXTENSIONS: + return False + + try: # Ensure the image can be read + with Image.open(path): + return True + except IOError: + return False + +def find_cover_in_folder(path, album_name = None): + if not os.path.isdir(path): + raise ValueError('Invalid path') + + candidates = [] + for f in os.listdir(path): + try: + file_path = os.path.join(path, f) + except UnicodeError: + continue + + if not is_valid_cover(file_path): + continue + + cover = CoverFile(f, album_name) + candidates.append(cover) + + if not candidates: + return None + if len(candidates) == 1: + return candidates[0] + + return sorted(candidates, key = lambda c: c.score, reverse = True)[0] + diff --git a/supysonic/db.py b/supysonic/db.py index a716069..260a4f3 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -31,6 +31,11 @@ def now(): db = Database() +@db.on_connect(provider = 'sqlite') +def sqlite_case_insensitive_like(db, connection): + cursor = connection.cursor() + cursor.execute('PRAGMA case_sensitive_like = OFF') + class PathMixin(object): @classmethod def get(cls, *args, **kwargs): @@ -59,7 +64,7 @@ class Folder(PathMixin, db.Entity): path = Required(str, 4096) # unique _path_hash = Required(buffer, column = 'path_hash') created = Required(datetime, precision = 0, default = now) - has_cover_art = Required(bool, default = False) + cover_art = Optional(str, nullable = True) last_scan = Required(int, default = 0) parent = Optional(lambda: Folder, reverse = 'children', column = 'parent_id') @@ -82,7 +87,7 @@ class Folder(PathMixin, db.Entity): if not self.root: info['parent'] = str(self.parent.id) info['artist'] = self.parent.name - if self.has_cover_art: + if self.cover_art: info['coverArt'] = str(self.id) try: @@ -107,7 +112,7 @@ class Folder(PathMixin, db.Entity): not exists(f for f in Folder if f.parent == self) and not self.root) total = 0 while True: - count = query.delete(bulk = True) + count = query.delete() total += count if not count: return total @@ -140,7 +145,7 @@ class Artist(db.Entity): @classmethod def prune(cls): return cls.select(lambda self: not exists(a for a in Album if a.artist == self) and \ - not exists(t for t in Track if t.artist == self)).delete(bulk = True) + not exists(t for t in Track if t.artist == self)).delete() class Album(db.Entity): _table_ = 'album' @@ -163,7 +168,7 @@ class Album(db.Entity): created = min(self.tracks.created).isoformat() ) - track_with_cover = self.tracks.select(lambda t: t.folder.has_cover_art).first() + track_with_cover = self.tracks.select(lambda t: t.folder.cover_art is not None).first() if track_with_cover is not None: info['coverArt'] = str(track_with_cover.folder.id) @@ -180,7 +185,7 @@ class Album(db.Entity): @classmethod def prune(cls): - return cls.select(lambda self: not exists(t for t in Track if t.album == self)).delete(bulk = True) + return cls.select(lambda self: not exists(t for t in Track if t.album == self)).delete() class Track(PathMixin, db.Entity): _table_ = 'track' @@ -242,7 +247,7 @@ class Track(PathMixin, db.Entity): info['year'] = self.year if self.genre: info['genre'] = self.genre - if self.folder.has_cover_art: + if self.folder.cover_art: info['coverArt'] = str(self.folder.id) try: diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index fbe9bd6..7d45aaf 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -58,7 +58,7 @@ def user_index(): @frontend.route('/user/') @me_or_uuid def user_profile(uid, user): - return render_template('profile.html', user = user, has_lastfm = current_app.config['LASTFM']['api_key'] != None, clients = user.clients) + return render_template('profile.html', user = user, api_key = current_app.config['LASTFM']['api_key'], clients = user.clients) @frontend.route('/user/', methods = [ 'POST' ]) @me_or_uuid diff --git a/supysonic/managers/folder.py b/supysonic/managers/folder.py index 13d6834..3670333 100644 --- a/supysonic/managers/folder.py +++ b/supysonic/managers/folder.py @@ -54,7 +54,7 @@ class FolderManager: Track.select(lambda t: t.root_folder == folder).delete(bulk = True) Album.prune() Artist.prune() - Folder.prune() + Folder.select(lambda f: not f.root and f.path.startswith(folder.path)).delete(bulk = True) folder.delete() diff --git a/supysonic/scanner.py b/supysonic/scanner.py index 8bef577..99b5527 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -14,6 +14,7 @@ import time from pony.orm import db_session +from .covers import find_cover_in_folder, CoverFile from .db import Folder, Artist, Album, Track, User from .db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack from .db import RatingFolder, RatingTrack @@ -80,11 +81,16 @@ class Scanner: if not self.__is_valid_path(track.path): self.remove_file(track.path) - # Update cover art info + # Remove deleted/moved folders and update cover art info folders = [ folder ] while folders: f = folders.pop() - f.has_cover_art = os.path.isfile(os.path.join(f.path, 'cover.jpg')) + + if not f.root and not os.path.isdir(f.path): + f.delete() # Pony will cascade + continue + + self.find_cover(f.path) folders += f.children folder.last_scan = int(time.time()) @@ -113,27 +119,24 @@ class Scanner: return tag = self.__try_load_tag(path) - if not tag: + if tag is None: self.remove_file(path) return trdict = {} else: tag = self.__try_load_tag(path) - if not tag: + if tag is None: return trdict = { 'path': path } - artist = self.__try_read_tag(tag, 'artist') - if not artist: - return - + artist = self.__try_read_tag(tag, 'artist', '[unknown]') album = self.__try_read_tag(tag, 'album', '[non-album tracks]') albumartist = self.__try_read_tag(tag, 'albumartist', artist) trdict['disc'] = self.__try_read_tag(tag, 'discnumber', 1, lambda x: int(x[0].split('/')[0])) trdict['number'] = self.__try_read_tag(tag, 'tracknumber', 1, lambda x: int(x[0].split('/')[0])) - trdict['title'] = self.__try_read_tag(tag, 'title', '') + trdict['title'] = self.__try_read_tag(tag, 'title', os.path.basename(path)) trdict['year'] = self.__try_read_tag(tag, 'date', None, lambda x: int(x[0].split('-')[0])) trdict['genre'] = self.__try_read_tag(tag, 'genre') trdict['duration'] = int(tag.info.length) @@ -202,6 +205,46 @@ class Scanner: tr.folder = folder tr.path = dst_path + @db_session + def find_cover(self, dirpath): + if not isinstance(dirpath, strtype): # pragma: nocover + raise TypeError('Expecting string, got ' + str(type(dirpath))) + + folder = Folder.get(path = dirpath) + if folder is None: + return + + album_name = None + track = folder.tracks.select().first() + if track is not None: + album_name = track.album.name + + cover = find_cover_in_folder(folder.path, album_name) + folder.cover_art = cover.name if cover is not None else None + + @db_session + def add_cover(self, path): + if not isinstance(path, strtype): # pragma: nocover + raise TypeError('Expecting string, got ' + str(type(path))) + + folder = Folder.get(path = os.path.dirname(path)) + if folder is None: + return + + cover_name = os.path.basename(path) + if not folder.cover_art: + folder.cover_art = cover_name + else: + album_name = None + track = folder.tracks.select().first() + if track is not None: + album_name = track.album.name + + current_cover = CoverFile(folder.cover_art, album_name) + new_cover = CoverFile(cover_name, album_name) + if new_cover.score > current_cover.score: + folder.cover_art = cover_name + def __find_album(self, artist, album): ar = self.__find_artist(artist) al = ar.albums.select(lambda a: a.name == album).first() @@ -252,10 +295,10 @@ class Scanner: def __try_load_tag(self, path): try: return mutagen.File(path, easy = True) - except: + except mutagen.MutagenError: return None - def __try_read_tag(self, metadata, field, default = None, transform = lambda x: x[0]): + def __try_read_tag(self, metadata, field, default = None, transform = lambda x: x[0].strip()): try: value = metadata[field] if not value: diff --git a/supysonic/templates/profile.html b/supysonic/templates/profile.html index 342c6fc..4dc3cab 100644 --- a/supysonic/templates/profile.html +++ b/supysonic/templates/profile.html @@ -54,7 +54,7 @@
LastFM status
- {% if has_lastfm %} + {% if api_key != None %} {% if user.lastfm_session %}
diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 2f6d794..240f886 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -8,6 +8,7 @@ # Distributed under terms of the GNU AGPLv3 license. import logging +import os.path import time from logging.handlers import TimedRotatingFileHandler @@ -17,6 +18,7 @@ from threading import Thread, Condition, Timer from watchdog.observers import Observer from watchdog.events import PatternMatchingEventHandler +from . import covers from .db import init_database, release_database, Folder from .py23 import dict from .scanner import Scanner @@ -25,10 +27,13 @@ OP_SCAN = 1 OP_REMOVE = 2 OP_MOVE = 4 FLAG_CREATE = 8 +FLAG_COVER = 16 class SupysonicWatcherEventHandler(PatternMatchingEventHandler): def __init__(self, extensions, queue, logger): - patterns = map(lambda e: "*." + e.lower(), extensions.split()) if extensions else None + patterns = None + if extensions: + patterns = list(map(lambda e: "*." + e.lower(), extensions.split())) + list(map(lambda e: "*" + e, covers.EXTENSIONS)) super(SupysonicWatcherEventHandler, self).__init__(patterns = patterns, ignore_directories = True) self.__queue = queue @@ -37,29 +42,51 @@ class SupysonicWatcherEventHandler(PatternMatchingEventHandler): def dispatch(self, event): try: super(SupysonicWatcherEventHandler, self).dispatch(event) - except Exception as e: + except Exception as e: # pragma: nocover self.__logger.critical(e) def on_created(self, event): self.__logger.debug("File created: '%s'", event.src_path) - self.__queue.put(event.src_path, OP_SCAN | FLAG_CREATE) + + op = OP_SCAN | FLAG_CREATE + if not covers.is_valid_cover(event.src_path): + self.__queue.put(event.src_path, op) + + dirname = os.path.dirname(event.src_path) + with db_session: + folder = Folder.get(path = dirname) + if folder is None: + self.__queue.put(dirname, op | FLAG_COVER) + else: + self.__queue.put(event.src_path, op | FLAG_COVER) def on_deleted(self, event): self.__logger.debug("File deleted: '%s'", event.src_path) - self.__queue.put(event.src_path, OP_REMOVE) + + op = OP_REMOVE + _, ext = os.path.splitext(event.src_path) + if ext in covers.EXTENSIONS: + op |= FLAG_COVER + self.__queue.put(event.src_path, op) def on_modified(self, event): self.__logger.debug("File modified: '%s'", event.src_path) - self.__queue.put(event.src_path, OP_SCAN) + if not covers.is_valid_cover(event.src_path): + self.__queue.put(event.src_path, OP_SCAN) def on_moved(self, event): self.__logger.debug("File moved: '%s' -> '%s'", event.src_path, event.dest_path) - self.__queue.put(event.dest_path, OP_MOVE, src_path = event.src_path) + + op = OP_MOVE + _, ext = os.path.splitext(event.src_path) + if ext in covers.EXTENSIONS: + op |= FLAG_COVER + self.__queue.put(event.dest_path, op, src_path = event.src_path) class Event(object): def __init__(self, path, operation, **kwargs): if operation & (OP_SCAN | OP_REMOVE) == (OP_SCAN | OP_REMOVE): - raise Exception("Flags SCAN and REMOVE both set") + raise Exception("Flags SCAN and REMOVE both set") # pragma: nocover self.__path = path self.__time = time.time() @@ -68,7 +95,7 @@ class Event(object): def set(self, operation, **kwargs): if operation & (OP_SCAN | OP_REMOVE) == (OP_SCAN | OP_REMOVE): - raise Exception("Flags SCAN and REMOVE both set") + raise Exception("Flags SCAN and REMOVE both set") # pragma: nocover self.__time = time.time() if operation & OP_SCAN: @@ -113,7 +140,7 @@ class ScannerProcessingQueue(Thread): def run(self): try: self.__run() - except Exception as e: + except Exception as e: # pragma: nocover self.__logger.critical(e) raise e @@ -132,21 +159,48 @@ class ScannerProcessingQueue(Thread): item = self.__next_item() while item: - if item.operation & OP_MOVE: - self.__logger.info("Moving: '%s' -> '%s'", item.src_path, item.path) - scanner.move_file(item.src_path, item.path) - if item.operation & OP_SCAN: - self.__logger.info("Scanning: '%s'", item.path) - scanner.scan_file(item.path) - if item.operation & OP_REMOVE: - self.__logger.info("Removing: '%s'", item.path) - scanner.remove_file(item.path) + if item.operation & FLAG_COVER: + self.__process_cover_item(scanner, item) + else: + self.__process_regular_item(scanner, item) + item = self.__next_item() scanner.finish() self.__logger.debug("Freeing scanner") del scanner + def __process_regular_item(self, scanner, item): + if item.operation & OP_MOVE: + self.__logger.info("Moving: '%s' -> '%s'", item.src_path, item.path) + scanner.move_file(item.src_path, item.path) + + if item.operation & OP_SCAN: + self.__logger.info("Scanning: '%s'", item.path) + scanner.scan_file(item.path) + + if item.operation & OP_REMOVE: + self.__logger.info("Removing: '%s'", item.path) + scanner.remove_file(item.path) + + def __process_cover_item(self, scanner, item): + if item.operation & OP_SCAN: + if os.path.isdir(item.path): + self.__logger.info("Looking for covers: '%s'", item.path) + scanner.find_cover(item.path) + else: + self.__logger.info("Potentially adding cover: '%s'", item.path) + scanner.add_cover(item.path) + + if item.operation & OP_REMOVE: + self.__logger.info("Removing cover: '%s'", item.path) + scanner.find_cover(os.path.dirname(item.path)) + + if item.operation & OP_MOVE: + self.__logger.info("Moving cover: '%s' -> '%s'", item.src_path, item.path) + scanner.find_cover(os.path.dirname(item.src_path)) + scanner.add_cover(item.path) + def stop(self): self.__running = False with self.__cond: @@ -232,7 +286,7 @@ class SupysonicWatcher(object): logger.info("Starting watcher for %s", folder.path) observer.schedule(handler, folder.path, recursive = True) - try: + try: # pragma: nocover signal(SIGTERM, self.__terminate) signal(SIGINT, self.__terminate) except ValueError: @@ -254,5 +308,5 @@ class SupysonicWatcher(object): self.__running = False def __terminate(self, signum, frame): - self.stop() + self.stop() # pragma: nocover diff --git a/tests/__init__.py b/tests/__init__.py index 87a687b..80bc299 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -3,7 +3,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron +# Copyright (C) 2017-2018 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. @@ -15,6 +15,8 @@ from . import managers from . import api from . import frontend +from .issue101 import Issue101TestCase + def suite(): suite = unittest.TestSuite() @@ -22,5 +24,7 @@ def suite(): suite.addTest(managers.suite()) suite.addTest(api.suite()) suite.addTest(frontend.suite()) + suite.addTest(unittest.makeSuite(Issue101TestCase)) return suite + diff --git a/tests/api/test_media.py b/tests/api/test_media.py index ec6b5bd..26b43f7 100644 --- a/tests/api/test_media.py +++ b/tests/api/test_media.py @@ -28,7 +28,7 @@ class MediaTestCase(ApiTestBase): name = 'Root', path = os.path.abspath('tests/assets'), root = True, - has_cover_art = True # 420x420 PNG + cover_art = 'cover.jpg' ) self.folderid = folder.id diff --git a/tests/base/test_db.py b/tests/base/test_db.py index 90361d9..8421f7f 100644 --- a/tests/base/test_db.py +++ b/tests/base/test_db.py @@ -42,7 +42,7 @@ class DbTestCase(unittest.TestCase): root = False, name = 'Child folder', path = 'tests/assets', - has_cover_art = True, + cover_art = 'cover.jpg', parent = root_folder ) diff --git a/tests/base/test_watcher.py b/tests/base/test_watcher.py index 2c19253..b7d29d2 100644 --- a/tests/base/test_watcher.py +++ b/tests/base/test_watcher.py @@ -21,7 +21,7 @@ from hashlib import sha1 from pony.orm import db_session from threading import Thread -from supysonic.db import init_database, release_database, Track, Artist +from supysonic.db import init_database, release_database, Track, Artist, Folder from supysonic.managers.folder import FolderManager from supysonic.watcher import SupysonicWatcher @@ -99,14 +99,26 @@ class WatcherTestCase(WatcherTestBase): with tempfile.NamedTemporaryFile() as f: return os.path.basename(f.name) - def _temppath(self): - return os.path.join(self.__dir, self._tempname() + '.mp3') + def _temppath(self, suffix, depth = 0): + if depth > 0: + dirpath = os.path.join(self.__dir, *(self._tempname() for _ in range(depth))) + os.makedirs(dirpath) + else: + dirpath = self.__dir + return os.path.join(dirpath, self._tempname() + suffix) - def _addfile(self): - path = self._temppath() + def _addfile(self, depth = 0): + path = self._temppath('.mp3', depth) shutil.copyfile('tests/assets/folder/silence.mp3', path) return path + def _addcover(self, suffix = None, depth = 0): + suffix = '.jpg' if suffix is None else (suffix + '.jpg') + path = self._temppath(suffix, depth) + shutil.copyfile('tests/assets/cover.jpg', path) + return path + +class AudioWatcherTestCase(WatcherTestCase): @db_session def assertTrackCountEqual(self, expected): self.assertEqual(Track.select().count(), expected) @@ -163,7 +175,7 @@ class WatcherTestCase(WatcherTestBase): self.assertEqual(Track.select().count(), 1) trackid = Track.select().first().id - newpath = self._temppath() + newpath = self._temppath('.mp3') shutil.move(path, newpath) self._sleep() @@ -179,7 +191,7 @@ class WatcherTestCase(WatcherTestBase): filename = self._tempname() + '.mp3' initialpath = os.path.join(tempfile.gettempdir(), filename) shutil.copyfile('tests/assets/folder/silence.mp3', initialpath) - shutil.move(initialpath, os.path.join(self.__dir, filename)) + shutil.move(initialpath, self._temppath('.mp3')) self._sleep() self.assertTrackCountEqual(1) @@ -212,7 +224,7 @@ class WatcherTestCase(WatcherTestBase): def test_add_rename(self): path = self._addfile() - shutil.move(path, self._temppath()) + shutil.move(path, self._temppath('.mp3')) self._sleep() self.assertTrackCountEqual(1) @@ -221,7 +233,7 @@ class WatcherTestCase(WatcherTestBase): self._sleep() self.assertTrackCountEqual(1) - newpath = self._temppath() + newpath = self._temppath('.mp3') shutil.move(path, newpath) os.unlink(newpath) self._sleep() @@ -229,7 +241,7 @@ class WatcherTestCase(WatcherTestBase): def test_add_rename_delete(self): path = self._addfile() - newpath = self._temppath() + newpath = self._temppath('.mp3') shutil.move(path, newpath) os.unlink(newpath) self._sleep() @@ -240,18 +252,112 @@ class WatcherTestCase(WatcherTestBase): self._sleep() self.assertTrackCountEqual(1) - newpath = self._temppath() - finalpath = self._temppath() + newpath = self._temppath('.mp3') + finalpath = self._temppath('.mp3') shutil.move(path, newpath) shutil.move(newpath, finalpath) self._sleep() self.assertTrackCountEqual(1) +class CoverWatcherTestCase(WatcherTestCase): + def test_add_file_then_cover(self): + self._addfile() + path = self._addcover() + self._sleep() + + with db_session: + self.assertEqual(Folder.select().first().cover_art, os.path.basename(path)) + + def test_add_cover_then_file(self): + path = self._addcover() + self._addfile() + self._sleep() + + with db_session: + self.assertEqual(Folder.select().first().cover_art, os.path.basename(path)) + + def test_remove_cover(self): + self._addfile() + path = self._addcover() + self._sleep() + + os.unlink(path) + self._sleep() + + with db_session: + self.assertIsNone(Folder.select().first().cover_art) + + def test_naming_add_good(self): + bad = os.path.basename(self._addcover()) + self._sleep() + good = os.path.basename(self._addcover('cover')) + self._sleep() + + with db_session: + self.assertEqual(Folder.select().first().cover_art, good) + + def test_naming_add_bad(self): + good = os.path.basename(self._addcover('cover')) + self._sleep() + bad = os.path.basename(self._addcover()) + self._sleep() + + with db_session: + self.assertEqual(Folder.select().first().cover_art, good) + + def test_naming_remove_good(self): + bad = self._addcover() + good = self._addcover('cover') + self._sleep() + os.unlink(good) + self._sleep() + + with db_session: + self.assertEqual(Folder.select().first().cover_art, os.path.basename(bad)) + + def test_naming_remove_bad(self): + bad = self._addcover() + good = self._addcover('cover') + self._sleep() + os.unlink(bad) + self._sleep() + + with db_session: + self.assertEqual(Folder.select().first().cover_art, os.path.basename(good)) + + def test_rename(self): + path = self._addcover() + self._sleep() + newpath = self._temppath('.jpg') + shutil.move(path, newpath) + self._sleep() + + with db_session: + self.assertEqual(Folder.select().first().cover_art, os.path.basename(newpath)) + + def test_add_to_folder_without_track(self): + path = self._addcover(depth = 1) + self._sleep() + + with db_session: + self.assertFalse(Folder.exists(cover_art = os.path.basename(path))) + + def test_remove_from_folder_without_track(self): + path = self._addcover(depth = 1) + self._sleep() + os.unlink(path) + self._sleep() + + def test_add_track_to_empty_folder(self): + self._addfile(1) + self._sleep() + def suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(NothingToWatchTestCase)) - suite.addTest(unittest.makeSuite(WatcherTestCase)) + suite.addTest(unittest.makeSuite(AudioWatcherTestCase)) + suite.addTest(unittest.makeSuite(CoverWatcherTestCase)) return suite diff --git a/tests/issue101.py b/tests/issue101.py new file mode 100644 index 0000000..98b68a5 --- /dev/null +++ b/tests/issue101.py @@ -0,0 +1,56 @@ +# coding: utf-8 +# +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2018 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import os.path +import shutil +import tempfile +import unittest + +from pony.orm import db_session + +from supysonic.db import init_database, release_database +from supysonic.db import Folder +from supysonic.managers.folder import FolderManager +from supysonic.scanner import Scanner + +class Issue101TestCase(unittest.TestCase): + def setUp(self): + self.__dir = tempfile.mkdtemp() + init_database('sqlite:', True) + with db_session: + FolderManager.add('folder', self.__dir) + + def tearDown(self): + release_database() + shutil.rmtree(self.__dir) + + def test_issue(self): + firstsubdir = tempfile.mkdtemp(dir = self.__dir) + subdir = firstsubdir + for _ in range(4): + subdir = tempfile.mkdtemp(dir = subdir) + shutil.copyfile('tests/assets/folder/silence.mp3', os.path.join(subdir, 'silence.mp3')) + + scanner = Scanner() + with db_session: + folder = Folder.select(lambda f: f.root).first() + scanner.scan(folder) + scanner.finish() + + shutil.rmtree(firstsubdir) + + with db_session: + folder = Folder.select(lambda f: f.root).first() + scanner.scan(folder) + scanner.finish() + + +if __name__ == '__main__': + unittest.main() + diff --git a/travis-requirements.txt b/travis-requirements.txt new file mode 100644 index 0000000..f8a0e25 --- /dev/null +++ b/travis-requirements.txt @@ -0,0 +1,6 @@ +-e .[watcher] + +lxml +coverage +codecov +