mirror of
https://github.com/spl0k/supysonic.git
synced 2024-12-22 08:56:17 +00:00
parent
918cd11262
commit
405a26a20a
@ -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
|
||||
|
||||
|
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
|
||||
);
|
||||
|
@ -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():
|
||||
|
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_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 +82,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:
|
||||
@ -163,7 +163,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)
|
||||
|
||||
@ -242,7 +242,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:
|
||||
|
@ -14,6 +14,7 @@ import time
|
||||
|
||||
from pony.orm import db_session
|
||||
|
||||
from .covers import find_cover_in_folder
|
||||
from .db import Folder, Artist, Album, Track, User
|
||||
from .db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack
|
||||
from .db import RatingFolder, RatingTrack
|
||||
@ -84,7 +85,15 @@ class Scanner:
|
||||
folders = [ folder ]
|
||||
while folders:
|
||||
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
|
||||
|
||||
folder.last_scan = int(time.time())
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user