1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-12-22 17:06: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.5
- 3.6 - 3.6
install: install:
- pip install -r requirements.txt - pip install -r travis-requirements.txt
- pip install lxml coverage codecov
script: coverage run setup.py test script: coverage run setup.py test
after_script: codecov after_script: codecov

View File

@ -11,7 +11,7 @@ Current supported features are:
* streaming of various audio file formats * streaming of various audio file formats
* [transcoding] * [transcoding]
* user or random playlists * 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 * starred tracks/albums and ratings
* [Last.FM][lastfm] scrobbling * [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 a standalone debug server](#as-a-standalone-debug-server)
+ [As an Apache WSGI application](#as-an-apache-wsgi-application) + [As an Apache WSGI application](#as-an-apache-wsgi-application)
+ [Other options](#other-options) + [Other options](#other-options)
+ [Docker](#docker)
* [Quickstart](#quickstart) * [Quickstart](#quickstart)
* [Watching library changes](#watching-library-changes) * [Watching library changes](#watching-library-changes)
* [Upgrading](#upgrading) * [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 _Supysonic_ can run as a standalone application (not recommended for a
"production" server) or as a WSGI application (on _Apache_ for instance). "production" server) or as a WSGI application (on _Apache_ for instance).
To install it, run:
To install it, either run:
$ python setup.py install $ 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 ### Prerequisites
You'll need these to run _Supysonic_: 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) * [Python Imaging Library](https://github.com/python-pillow/Pillow)
* [requests](http://docs.python-requests.org/) * [requests](http://docs.python-requests.org/)
* [mutagen](https://mutagen.readthedocs.io/en/latest/) * [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 if you don't want to use SQLite
(the default):
You may also need a database specific package:
* _MySQL_: `pip install pymysql` or `pip install mysqlclient` * _MySQL_: `pip install pymysql` or `pip install mysqlclient`
* _PostgreSQL_: `pip install psycopg2-binary` * _PostgreSQL_: `pip install psycopg2-binary`
@ -156,8 +166,7 @@ example of what it looks like:
<Directory /path/to/supysonic/cgi-bin> <Directory /path/to/supysonic/cgi-bin>
WSGIApplicationGroup %{GLOBAL} WSGIApplicationGroup %{GLOBAL}
WSGIPassAuthorization On WSGIPassAuthorization On
Order deny,allow Require all granted
Allow from all
</Directory> </Directory>
You might also need to run _Apache_ using the system default locale, as the one 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/ [fastcgi]: http://flask.pocoo.org/docs/deploying/fastcgi/
[cgi]: http://flask.pocoo.org/docs/deploying/cgi/ [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 ## Quickstart
To start using _Supysonic_, you'll first have to specify where your music 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 a watcher that will listen to any library change and update the database
accordingly. 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, 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 either use the old `nohup` or `screen` methods, or start it as a simple
_systemd_ unit (unit file not included). _systemd_ unit (unit file not included).
It needs some additional dependencies which can be installed with the following
command:
$ pip install -e .[watcher]
## Upgrading ## Upgrading
Some commits might introduce changes in the database schema. When that's the 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 VARCHAR(4096) NOT NULL,
path_hash BINARY(20) NOT NULL, path_hash BINARY(20) NOT NULL,
created DATETIME NOT NULL, created DATETIME NOT NULL,
has_cover_art BOOLEAN NOT NULL, cover_art VARCHAR(256),
last_scan INTEGER NOT NULL, last_scan INTEGER NOT NULL,
parent_id BINARY(16) REFERENCES folder parent_id BINARY(16) REFERENCES folder
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -5,7 +5,7 @@ CREATE TABLE folder (
path VARCHAR(4096) NOT NULL, path VARCHAR(4096) NOT NULL,
path_hash BYTEA NOT NULL, path_hash BYTEA NOT NULL,
created TIMESTAMP NOT NULL, created TIMESTAMP NOT NULL,
has_cover_art BOOLEAN NOT NULL, cover_art VARCHAR(256),
last_scan INTEGER NOT NULL, last_scan INTEGER NOT NULL,
parent_id UUID REFERENCES folder parent_id UUID REFERENCES folder
); );

View File

@ -5,7 +5,7 @@ CREATE TABLE folder (
path VARCHAR(4096) NOT NULL, path VARCHAR(4096) NOT NULL,
path_hash BLOB NOT NULL, path_hash BLOB NOT NULL,
created DATETIME NOT NULL, created DATETIME NOT NULL,
has_cover_art BOOLEAN NOT NULL, cover_art VARCHAR(256),
last_scan INTEGER NOT NULL, last_scan INTEGER NOT NULL,
parent_id CHAR(36) REFERENCES folder parent_id CHAR(36) REFERENCES folder
); );

View File

@ -14,10 +14,19 @@ import supysonic as project
from setuptools import setup from setuptools import setup
from setuptools import find_packages 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( setup(
name=project.NAME, name=project.NAME,
version=project.VERSION, version=project.VERSION,
@ -28,14 +37,14 @@ setup(
author_email=project.AUTHOR_EMAIL, author_email=project.AUTHOR_EMAIL,
url=project.URL, url=project.URL,
license=project.LICENSE, license=project.LICENSE,
packages=find_packages(), packages=find_packages(exclude=['tests*']),
install_requires=[str(x.req) for x in install_requires = reqs,
parse_requirements('requirements.txt', session=PipSession())], extras_require = extras,
scripts=['bin/supysonic-cli', 'bin/supysonic-watcher'], scripts=['bin/supysonic-cli', 'bin/supysonic-watcher'],
zip_safe=False, zip_safe=False,
include_package_data=True, include_package_data=True,
test_suite="tests.suite", test_suite='tests.suite',
tests_require = [ 'lxml' ], tests_require = [ 'lxml' ] + [ r for er in extras.values() for r in er ],
classifiers=[ classifiers=[
'Development Status :: 3 - Alpha', 'Development Status :: 3 - Alpha',
'Environment :: Console', 'Environment :: Console',

View File

@ -67,7 +67,7 @@ def album_list():
elif ltype == 'frequent': elif ltype == 'frequent':
query = query.order_by(lambda f: desc(avg(f.tracks.play_count))) query = query.order_by(lambda f: desc(avg(f.tracks.play_count)))
elif ltype == 'recent': 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': elif ltype == 'starred':
query = select(s.starred for s in StarredFolder if s.user.id == request.user.id and count(s.starred.tracks) > 0) query = select(s.starred for s in StarredFolder if s.user.id == request.user.id and count(s.starred.tracks) > 0)
elif ltype == 'alphabeticalByName': elif ltype == 'alphabeticalByName':
@ -99,7 +99,7 @@ def album_list_id3():
elif ltype == 'frequent': elif ltype == 'frequent':
query = query.order_by(lambda a: desc(avg(a.tracks.play_count))) query = query.order_by(lambda a: desc(avg(a.tracks.play_count)))
elif ltype == 'recent': 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': elif ltype == 'starred':
query = select(s.starred for s in StarredAlbum if s.user.id == request.user.id) query = select(s.starred for s in StarredAlbum if s.user.id == request.user.id)
elif ltype == 'alphabeticalByName': elif ltype == 'alphabeticalByName':

View File

@ -132,29 +132,30 @@ def download_media():
@api.route('/getCoverArt.view', methods = [ 'GET', 'POST' ]) @api.route('/getCoverArt.view', methods = [ 'GET', 'POST' ])
def cover_art(): def cover_art():
res = get_entity(Folder) 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') raise NotFound('Cover art')
cover_path = os.path.join(res.path, res.cover_art)
size = request.values.get('size') size = request.values.get('size')
if size: if size:
size = int(size) size = int(size)
else: 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')) im = Image.open(cover_path)
if size > im.size[0] and size > im.size[1]: if size > im.width and size > im.height:
return send_file(os.path.join(res.path, 'cover.jpg')) return send_file(cover_path)
size_path = os.path.join(current_app.config['WEBAPP']['cache_dir'], str(size)) 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))) path = os.path.abspath(os.path.join(size_path, str(res.id)))
if os.path.exists(path): 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): if not os.path.exists(size_path):
os.makedirs(size_path) os.makedirs(size_path)
im.thumbnail([size, size], Image.ANTIALIAS) im.thumbnail([size, size], Image.ANTIALIAS)
im.save(path, 'JPEG') im.save(path, im.format)
return send_file(path, mimetype = 'image/jpeg') return send_file(path, mimetype = 'image/' + im.format.lower())
@api.route('/getLyrics.view', methods = [ 'GET', 'POST' ]) @api.route('/getLyrics.view', methods = [ 'GET', 'POST' ])
def lyrics(): def lyrics():

View File

@ -41,7 +41,7 @@ def old_search():
if offset + count > fcount: if offset + count > fcount:
toff = max(0, offset - fcount) toff = max(0, offset - fcount)
tend = offset + count - fcount tend = offset + count - fcount
res += tracks[toff : tend] res = res[:] + tracks[toff : tend][:]
return request.formatter('searchResult', dict( return request.formatter('searchResult', dict(
totalHits = folders.count() + tracks.count(), 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 = 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): class PathMixin(object):
@classmethod @classmethod
def get(cls, *args, **kwargs): def get(cls, *args, **kwargs):
@ -59,7 +64,7 @@ class Folder(PathMixin, db.Entity):
path = Required(str, 4096) # unique path = Required(str, 4096) # unique
_path_hash = Required(buffer, column = 'path_hash') _path_hash = Required(buffer, column = 'path_hash')
created = Required(datetime, precision = 0, default = now) 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) last_scan = Required(int, default = 0)
parent = Optional(lambda: Folder, reverse = 'children', column = 'parent_id') parent = Optional(lambda: Folder, reverse = 'children', column = 'parent_id')
@ -82,7 +87,7 @@ class Folder(PathMixin, db.Entity):
if not self.root: if not self.root:
info['parent'] = str(self.parent.id) info['parent'] = str(self.parent.id)
info['artist'] = self.parent.name info['artist'] = self.parent.name
if self.has_cover_art: if self.cover_art:
info['coverArt'] = str(self.id) info['coverArt'] = str(self.id)
try: 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) not exists(f for f in Folder if f.parent == self) and not self.root)
total = 0 total = 0
while True: while True:
count = query.delete(bulk = True) count = query.delete()
total += count total += count
if not count: if not count:
return total return total
@ -140,7 +145,7 @@ class Artist(db.Entity):
@classmethod @classmethod
def prune(cls): def prune(cls):
return cls.select(lambda self: not exists(a for a in Album if a.artist == self) and \ 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): class Album(db.Entity):
_table_ = 'album' _table_ = 'album'
@ -163,7 +168,7 @@ class Album(db.Entity):
created = min(self.tracks.created).isoformat() 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: if track_with_cover is not None:
info['coverArt'] = str(track_with_cover.folder.id) info['coverArt'] = str(track_with_cover.folder.id)
@ -180,7 +185,7 @@ class Album(db.Entity):
@classmethod @classmethod
def prune(cls): 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): class Track(PathMixin, db.Entity):
_table_ = 'track' _table_ = 'track'
@ -242,7 +247,7 @@ class Track(PathMixin, db.Entity):
info['year'] = self.year info['year'] = self.year
if self.genre: if self.genre:
info['genre'] = self.genre info['genre'] = self.genre
if self.folder.has_cover_art: if self.folder.cover_art:
info['coverArt'] = str(self.folder.id) info['coverArt'] = str(self.folder.id)
try: try:

View File

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

View File

@ -54,7 +54,7 @@ class FolderManager:
Track.select(lambda t: t.root_folder == folder).delete(bulk = True) Track.select(lambda t: t.root_folder == folder).delete(bulk = True)
Album.prune() Album.prune()
Artist.prune() Artist.prune()
Folder.prune() Folder.select(lambda f: not f.root and f.path.startswith(folder.path)).delete(bulk = True)
folder.delete() folder.delete()

View File

@ -14,6 +14,7 @@ import time
from pony.orm import db_session 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 Folder, Artist, Album, Track, User
from .db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack from .db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack
from .db import RatingFolder, RatingTrack from .db import RatingFolder, RatingTrack
@ -80,11 +81,16 @@ class Scanner:
if not self.__is_valid_path(track.path): if not self.__is_valid_path(track.path):
self.remove_file(track.path) self.remove_file(track.path)
# Update cover art info # Remove deleted/moved folders and update cover art info
folders = [ folder ] folders = [ folder ]
while folders: while folders:
f = folders.pop() 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 folders += f.children
folder.last_scan = int(time.time()) folder.last_scan = int(time.time())
@ -113,27 +119,24 @@ class Scanner:
return return
tag = self.__try_load_tag(path) tag = self.__try_load_tag(path)
if not tag: if tag is None:
self.remove_file(path) self.remove_file(path)
return return
trdict = {} trdict = {}
else: else:
tag = self.__try_load_tag(path) tag = self.__try_load_tag(path)
if not tag: if tag is None:
return return
trdict = { 'path': path } trdict = { 'path': path }
artist = self.__try_read_tag(tag, 'artist') artist = self.__try_read_tag(tag, 'artist', '[unknown]')
if not artist:
return
album = self.__try_read_tag(tag, 'album', '[non-album tracks]') album = self.__try_read_tag(tag, 'album', '[non-album tracks]')
albumartist = self.__try_read_tag(tag, 'albumartist', artist) 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['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['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['year'] = self.__try_read_tag(tag, 'date', None, lambda x: int(x[0].split('-')[0]))
trdict['genre'] = self.__try_read_tag(tag, 'genre') trdict['genre'] = self.__try_read_tag(tag, 'genre')
trdict['duration'] = int(tag.info.length) trdict['duration'] = int(tag.info.length)
@ -202,6 +205,46 @@ class Scanner:
tr.folder = folder tr.folder = folder
tr.path = dst_path 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): def __find_album(self, artist, album):
ar = self.__find_artist(artist) ar = self.__find_artist(artist)
al = ar.albums.select(lambda a: a.name == album).first() al = ar.albums.select(lambda a: a.name == album).first()
@ -252,10 +295,10 @@ class Scanner:
def __try_load_tag(self, path): def __try_load_tag(self, path):
try: try:
return mutagen.File(path, easy = True) return mutagen.File(path, easy = True)
except: except mutagen.MutagenError:
return None 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: try:
value = metadata[field] value = metadata[field]
if not value: if not value:

View File

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

View File

@ -8,6 +8,7 @@
# Distributed under terms of the GNU AGPLv3 license. # Distributed under terms of the GNU AGPLv3 license.
import logging import logging
import os.path
import time import time
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
@ -17,6 +18,7 @@ from threading import Thread, Condition, Timer
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler from watchdog.events import PatternMatchingEventHandler
from . import covers
from .db import init_database, release_database, Folder from .db import init_database, release_database, Folder
from .py23 import dict from .py23 import dict
from .scanner import Scanner from .scanner import Scanner
@ -25,10 +27,13 @@ OP_SCAN = 1
OP_REMOVE = 2 OP_REMOVE = 2
OP_MOVE = 4 OP_MOVE = 4
FLAG_CREATE = 8 FLAG_CREATE = 8
FLAG_COVER = 16
class SupysonicWatcherEventHandler(PatternMatchingEventHandler): class SupysonicWatcherEventHandler(PatternMatchingEventHandler):
def __init__(self, extensions, queue, logger): 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) super(SupysonicWatcherEventHandler, self).__init__(patterns = patterns, ignore_directories = True)
self.__queue = queue self.__queue = queue
@ -37,29 +42,51 @@ class SupysonicWatcherEventHandler(PatternMatchingEventHandler):
def dispatch(self, event): def dispatch(self, event):
try: try:
super(SupysonicWatcherEventHandler, self).dispatch(event) super(SupysonicWatcherEventHandler, self).dispatch(event)
except Exception as e: except Exception as e: # pragma: nocover
self.__logger.critical(e) self.__logger.critical(e)
def on_created(self, event): def on_created(self, event):
self.__logger.debug("File created: '%s'", event.src_path) 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): def on_deleted(self, event):
self.__logger.debug("File deleted: '%s'", event.src_path) 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): def on_modified(self, event):
self.__logger.debug("File modified: '%s'", event.src_path) 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): def on_moved(self, event):
self.__logger.debug("File moved: '%s' -> '%s'", event.src_path, event.dest_path) 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): class Event(object):
def __init__(self, path, operation, **kwargs): def __init__(self, path, operation, **kwargs):
if operation & (OP_SCAN | OP_REMOVE) == (OP_SCAN | OP_REMOVE): 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.__path = path
self.__time = time.time() self.__time = time.time()
@ -68,7 +95,7 @@ class Event(object):
def set(self, operation, **kwargs): def set(self, operation, **kwargs):
if operation & (OP_SCAN | OP_REMOVE) == (OP_SCAN | OP_REMOVE): 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() self.__time = time.time()
if operation & OP_SCAN: if operation & OP_SCAN:
@ -113,7 +140,7 @@ class ScannerProcessingQueue(Thread):
def run(self): def run(self):
try: try:
self.__run() self.__run()
except Exception as e: except Exception as e: # pragma: nocover
self.__logger.critical(e) self.__logger.critical(e)
raise e raise e
@ -132,21 +159,48 @@ class ScannerProcessingQueue(Thread):
item = self.__next_item() item = self.__next_item()
while item: while item:
if item.operation & OP_MOVE: if item.operation & FLAG_COVER:
self.__logger.info("Moving: '%s' -> '%s'", item.src_path, item.path) self.__process_cover_item(scanner, item)
scanner.move_file(item.src_path, item.path) else:
if item.operation & OP_SCAN: self.__process_regular_item(scanner, item)
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)
item = self.__next_item() item = self.__next_item()
scanner.finish() scanner.finish()
self.__logger.debug("Freeing scanner") self.__logger.debug("Freeing scanner")
del 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): def stop(self):
self.__running = False self.__running = False
with self.__cond: with self.__cond:
@ -232,7 +286,7 @@ class SupysonicWatcher(object):
logger.info("Starting watcher for %s", folder.path) logger.info("Starting watcher for %s", folder.path)
observer.schedule(handler, folder.path, recursive = True) observer.schedule(handler, folder.path, recursive = True)
try: try: # pragma: nocover
signal(SIGTERM, self.__terminate) signal(SIGTERM, self.__terminate)
signal(SIGINT, self.__terminate) signal(SIGINT, self.__terminate)
except ValueError: except ValueError:
@ -254,5 +308,5 @@ class SupysonicWatcher(object):
self.__running = False self.__running = False
def __terminate(self, signum, frame): def __terminate(self, signum, frame):
self.stop() self.stop() # pragma: nocover

View File

@ -3,7 +3,7 @@
# This file is part of Supysonic. # This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API. # 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 # 2017 Óscar García Amor
# #
# Distributed under terms of the GNU AGPLv3 license. # Distributed under terms of the GNU AGPLv3 license.
@ -15,6 +15,8 @@ from . import managers
from . import api from . import api
from . import frontend from . import frontend
from .issue101 import Issue101TestCase
def suite(): def suite():
suite = unittest.TestSuite() suite = unittest.TestSuite()
@ -22,5 +24,7 @@ def suite():
suite.addTest(managers.suite()) suite.addTest(managers.suite())
suite.addTest(api.suite()) suite.addTest(api.suite())
suite.addTest(frontend.suite()) suite.addTest(frontend.suite())
suite.addTest(unittest.makeSuite(Issue101TestCase))
return suite return suite

View File

@ -28,7 +28,7 @@ class MediaTestCase(ApiTestBase):
name = 'Root', name = 'Root',
path = os.path.abspath('tests/assets'), path = os.path.abspath('tests/assets'),
root = True, root = True,
has_cover_art = True # 420x420 PNG cover_art = 'cover.jpg'
) )
self.folderid = folder.id self.folderid = folder.id

View File

@ -42,7 +42,7 @@ class DbTestCase(unittest.TestCase):
root = False, root = False,
name = 'Child folder', name = 'Child folder',
path = 'tests/assets', path = 'tests/assets',
has_cover_art = True, cover_art = 'cover.jpg',
parent = root_folder parent = root_folder
) )

View File

@ -21,7 +21,7 @@ from hashlib import sha1
from pony.orm import db_session from pony.orm import db_session
from threading import Thread 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.managers.folder import FolderManager
from supysonic.watcher import SupysonicWatcher from supysonic.watcher import SupysonicWatcher
@ -99,14 +99,26 @@ class WatcherTestCase(WatcherTestBase):
with tempfile.NamedTemporaryFile() as f: with tempfile.NamedTemporaryFile() as f:
return os.path.basename(f.name) return os.path.basename(f.name)
def _temppath(self): def _temppath(self, suffix, depth = 0):
return os.path.join(self.__dir, self._tempname() + '.mp3') 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): def _addfile(self, depth = 0):
path = self._temppath() path = self._temppath('.mp3', depth)
shutil.copyfile('tests/assets/folder/silence.mp3', path) shutil.copyfile('tests/assets/folder/silence.mp3', path)
return 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 @db_session
def assertTrackCountEqual(self, expected): def assertTrackCountEqual(self, expected):
self.assertEqual(Track.select().count(), expected) self.assertEqual(Track.select().count(), expected)
@ -163,7 +175,7 @@ class WatcherTestCase(WatcherTestBase):
self.assertEqual(Track.select().count(), 1) self.assertEqual(Track.select().count(), 1)
trackid = Track.select().first().id trackid = Track.select().first().id
newpath = self._temppath() newpath = self._temppath('.mp3')
shutil.move(path, newpath) shutil.move(path, newpath)
self._sleep() self._sleep()
@ -179,7 +191,7 @@ class WatcherTestCase(WatcherTestBase):
filename = self._tempname() + '.mp3' filename = self._tempname() + '.mp3'
initialpath = os.path.join(tempfile.gettempdir(), filename) initialpath = os.path.join(tempfile.gettempdir(), filename)
shutil.copyfile('tests/assets/folder/silence.mp3', initialpath) 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._sleep()
self.assertTrackCountEqual(1) self.assertTrackCountEqual(1)
@ -212,7 +224,7 @@ class WatcherTestCase(WatcherTestBase):
def test_add_rename(self): def test_add_rename(self):
path = self._addfile() path = self._addfile()
shutil.move(path, self._temppath()) shutil.move(path, self._temppath('.mp3'))
self._sleep() self._sleep()
self.assertTrackCountEqual(1) self.assertTrackCountEqual(1)
@ -221,7 +233,7 @@ class WatcherTestCase(WatcherTestBase):
self._sleep() self._sleep()
self.assertTrackCountEqual(1) self.assertTrackCountEqual(1)
newpath = self._temppath() newpath = self._temppath('.mp3')
shutil.move(path, newpath) shutil.move(path, newpath)
os.unlink(newpath) os.unlink(newpath)
self._sleep() self._sleep()
@ -229,7 +241,7 @@ class WatcherTestCase(WatcherTestBase):
def test_add_rename_delete(self): def test_add_rename_delete(self):
path = self._addfile() path = self._addfile()
newpath = self._temppath() newpath = self._temppath('.mp3')
shutil.move(path, newpath) shutil.move(path, newpath)
os.unlink(newpath) os.unlink(newpath)
self._sleep() self._sleep()
@ -240,18 +252,112 @@ class WatcherTestCase(WatcherTestBase):
self._sleep() self._sleep()
self.assertTrackCountEqual(1) self.assertTrackCountEqual(1)
newpath = self._temppath() newpath = self._temppath('.mp3')
finalpath = self._temppath() finalpath = self._temppath('.mp3')
shutil.move(path, newpath) shutil.move(path, newpath)
shutil.move(newpath, finalpath) shutil.move(newpath, finalpath)
self._sleep() self._sleep()
self.assertTrackCountEqual(1) 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(): def suite():
suite = unittest.TestSuite() suite = unittest.TestSuite()
suite.addTest(unittest.makeSuite(NothingToWatchTestCase)) suite.addTest(unittest.makeSuite(NothingToWatchTestCase))
suite.addTest(unittest.makeSuite(WatcherTestCase)) suite.addTest(unittest.makeSuite(AudioWatcherTestCase))
suite.addTest(unittest.makeSuite(CoverWatcherTestCase))
return suite 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