1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-12-22 08:56:17 +00:00

Merge branch 'master' into issue90

This commit is contained in:
spl0k 2018-09-08 15:37:08 +02:00
commit 1a15b95155
26 changed files with 534 additions and 96 deletions

View File

@ -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

View File

@ -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:
<Directory /path/to/supysonic/cgi-bin>
WSGIApplicationGroup %{GLOBAL}
WSGIPassAuthorization On
Order deny,allow
Allow from all
Require all granted
</Directory>
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

View File

@ -1,6 +0,0 @@
flask>=0.11
pony>=0.7.2
Pillow
requests>=1.0.0
mutagen>=1.33
watchdog>=0.8.0

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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
);

View File

@ -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
);

View File

@ -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',

View File

@ -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':

View File

@ -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():

View File

@ -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(),

84
supysonic/covers.py Normal file
View File

@ -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]

View File

@ -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:

View File

@ -58,7 +58,7 @@ def user_index():
@frontend.route('/user/<uid>')
@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/<uid>', methods = [ 'POST' ])
@me_or_uuid

View File

@ -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()

View File

@ -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:

View File

@ -54,7 +54,7 @@
<label class="sr-only" for="lastfm">LastFM status</label>
<div class="input-group">
<div class="input-group-addon">LastFM status</div>
{% if has_lastfm %}
{% if api_key != None %}
{% if user.lastfm_session %}
<input type="text" class="form-control" id="lastfm" placeholder="{% if user.lastfm_status %}Linked{% else %}Invalid session{% endif %}" readonly>
<div class="input-group-btn">

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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

56
tests/issue101.py Normal file
View File

@ -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()

6
travis-requirements.txt Normal file
View File

@ -0,0 +1,6 @@
-e .[watcher]
lxml
coverage
codecov