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