mirror of
https://github.com/spl0k/supysonic.git
synced 2024-12-23 01:16:18 +00:00
parent
918cd11262
commit
405a26a20a
@ -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
|
||||||
|
|
||||||
|
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
|
||||||
);
|
);
|
||||||
|
@ -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():
|
||||||
|
80
supysonic/covers.py
Normal file
80
supysonic/covers.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# 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):
|
||||||
|
file_path = os.path.join(path, f)
|
||||||
|
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]
|
||||||
|
|
@ -59,7 +59,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 +82,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:
|
||||||
@ -163,7 +163,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)
|
||||||
|
|
||||||
@ -242,7 +242,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:
|
||||||
|
@ -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
|
||||||
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
|
||||||
@ -84,7 +85,15 @@ class Scanner:
|
|||||||
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'))
|
|
||||||
|
album_name = None
|
||||||
|
track = f.tracks.select().first()
|
||||||
|
if track is not None:
|
||||||
|
album_name = track.album.name
|
||||||
|
|
||||||
|
cover = find_cover_in_folder(f.path, album_name)
|
||||||
|
f.cover_art = cover.name if cover is not None else None
|
||||||
|
|
||||||
folders += f.children
|
folders += f.children
|
||||||
|
|
||||||
folder.last_scan = int(time.time())
|
folder.last_scan = int(time.time())
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user