mirror of
https://github.com/spl0k/supysonic.git
synced 2024-12-23 01:16:18 +00:00
Merge branch 'master' into issue90
This commit is contained in:
commit
1a15b95155
@ -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
|
||||||
|
46
README.md
46
README.md
@ -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
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
flask>=0.11
|
|
||||||
pony>=0.7.2
|
|
||||||
Pillow
|
|
||||||
requests>=1.0.0
|
|
||||||
mutagen>=1.33
|
|
||||||
watchdog>=0.8.0
|
|
12
schema/migration/20180521.mysql.sql
Normal file
12
schema/migration/20180521.mysql.sql
Normal 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;
|
||||||
|
|
12
schema/migration/20180521.postgresql.sql
Normal file
12
schema/migration/20180521.postgresql.sql
Normal 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;
|
||||||
|
|
27
schema/migration/20180521.sqlite.sql
Normal file
27
schema/migration/20180521.sqlite.sql
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
23
setup.py
23
setup.py
@ -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',
|
||||||
|
@ -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':
|
||||||
|
@ -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():
|
||||||
|
@ -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
84
supysonic/covers.py
Normal 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]
|
||||||
|
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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">
|
||||||
|
@ -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)
|
||||||
|
if not covers.is_valid_cover(event.src_path):
|
||||||
self.__queue.put(event.src_path, OP_SCAN)
|
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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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
56
tests/issue101.py
Normal 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
6
travis-requirements.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-e .[watcher]
|
||||||
|
|
||||||
|
lxml
|
||||||
|
coverage
|
||||||
|
codecov
|
||||||
|
|
Loading…
Reference in New Issue
Block a user