diff --git a/README.md b/README.md index 9d0accc..9fa313e 100644 --- a/README.md +++ b/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 diff --git a/schema/migration/20180521.mysql.sql b/schema/migration/20180521.mysql.sql new file mode 100644 index 0000000..285c827 --- /dev/null +++ b/schema/migration/20180521.mysql.sql @@ -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; + diff --git a/schema/migration/20180521.postgresql.sql b/schema/migration/20180521.postgresql.sql new file mode 100644 index 0000000..2c7da54 --- /dev/null +++ b/schema/migration/20180521.postgresql.sql @@ -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; + diff --git a/schema/migration/20180521.sqlite.sql b/schema/migration/20180521.sqlite.sql new file mode 100644 index 0000000..4ffbdc2 --- /dev/null +++ b/schema/migration/20180521.sqlite.sql @@ -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; + diff --git a/schema/mysql.sql b/schema/mysql.sql index 518c9c0..edc54e2 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -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; diff --git a/schema/postgresql.sql b/schema/postgresql.sql index e34a3e2..0c4f7a4 100644 --- a/schema/postgresql.sql +++ b/schema/postgresql.sql @@ -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 ); diff --git a/schema/sqlite.sql b/schema/sqlite.sql index ac076d3..5ee370d 100644 --- a/schema/sqlite.sql +++ b/schema/sqlite.sql @@ -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 ); diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 9fe30fe..8ccf426 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -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(): diff --git a/supysonic/covers.py b/supysonic/covers.py new file mode 100644 index 0000000..c6fc1ab --- /dev/null +++ b/supysonic/covers.py @@ -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] + diff --git a/supysonic/db.py b/supysonic/db.py index 6b3c56f..179b2ff 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -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: diff --git a/supysonic/scanner.py b/supysonic/scanner.py index ac6fcff..3b8ebb2 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -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()) diff --git a/tests/api/test_media.py b/tests/api/test_media.py index ec6b5bd..26b43f7 100644 --- a/tests/api/test_media.py +++ b/tests/api/test_media.py @@ -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 diff --git a/tests/base/test_db.py b/tests/base/test_db.py index 90361d9..8421f7f 100644 --- a/tests/base/test_db.py +++ b/tests/base/test_db.py @@ -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 )