mirror of
https://github.com/spl0k/supysonic.git
synced 2024-12-22 17:06:17 +00:00
Merge remote-tracking branch 'TaizoSimpson/master'
This commit is contained in:
commit
889b83b761
@ -131,11 +131,28 @@ def download_media():
|
||||
|
||||
@api.route('/getCoverArt.view', methods = [ 'GET', 'POST' ])
|
||||
def cover_art():
|
||||
eid = request.values['id']
|
||||
if Folder.exists(id=eid):
|
||||
res = get_entity(Folder)
|
||||
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)
|
||||
elif Track.exists(id=eid):
|
||||
embed_cache = os.path.join(current_app.config['WEBAPP']['cache_dir'], 'embeded_art')
|
||||
cover_path = os.path.join(embed_cache, eid)
|
||||
if not os.path.exists(cover_path):
|
||||
res = get_entity(Track)
|
||||
art = res.extract_cover_art()
|
||||
if not art:
|
||||
raise NotFound('Cover art')
|
||||
#Art found, save to cache
|
||||
if not os.path.exists(embed_cache):
|
||||
os.makedirs(embed_cache)
|
||||
with open(cover_path, 'wb') as cover_file:
|
||||
cover_file.write(art)
|
||||
else:
|
||||
raise NotFound('Entity')
|
||||
|
||||
size = request.values.get('size')
|
||||
if size:
|
||||
size = int(size)
|
||||
@ -147,7 +164,7 @@ def cover_art():
|
||||
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)))
|
||||
path = os.path.abspath(os.path.join(size_path, eid))
|
||||
if os.path.exists(path):
|
||||
return send_file(path, mimetype = 'image/' + im.format.lower())
|
||||
if not os.path.exists(size_path):
|
||||
@ -215,4 +232,3 @@ def read_file_as_unicode(path):
|
||||
# Fallback to ASCII
|
||||
current_app.logger.debug('Reading file {} with ascii encoding'.format(path))
|
||||
return unicode(open(path, 'r').read())
|
||||
|
||||
|
@ -7,8 +7,10 @@
|
||||
#
|
||||
# Distributed under terms of the GNU AGPLv3 license.
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import mimetypes
|
||||
import mutagen
|
||||
import os.path
|
||||
import pkg_resources
|
||||
import time
|
||||
@ -29,7 +31,7 @@ try:
|
||||
except ImportError:
|
||||
from urlparse import urlparse, parse_qsl
|
||||
|
||||
SCHEMA_VERSION = '20180829'
|
||||
SCHEMA_VERSION = '20181010'
|
||||
|
||||
def now():
|
||||
return datetime.now().replace(microsecond = 0)
|
||||
@ -101,6 +103,11 @@ class Folder(PathMixin, db.Entity):
|
||||
info['artist'] = self.parent.name
|
||||
if self.cover_art:
|
||||
info['coverArt'] = str(self.id)
|
||||
else:
|
||||
for track in self.tracks:
|
||||
if track.has_art:
|
||||
info['coverArt'] = str(track.id)
|
||||
break
|
||||
|
||||
try:
|
||||
starred = StarredFolder[user.id, self.id]
|
||||
@ -183,6 +190,10 @@ class Album(db.Entity):
|
||||
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)
|
||||
else:
|
||||
track_with_cover = self.tracks.select(lambda t: t.has_art).first()
|
||||
if track_with_cover is not None:
|
||||
info['coverArt'] = str(track_with_cover.id)
|
||||
|
||||
try:
|
||||
starred = StarredAlbum[user.id, self.id]
|
||||
@ -209,6 +220,7 @@ class Track(PathMixin, db.Entity):
|
||||
year = Optional(int)
|
||||
genre = Optional(str, nullable = True)
|
||||
duration = Required(int)
|
||||
has_art = Required(bool, default=False)
|
||||
|
||||
album = Required(Album, column = 'album_id')
|
||||
artist = Required(Artist, column = 'artist_id')
|
||||
@ -259,7 +271,9 @@ class Track(PathMixin, db.Entity):
|
||||
info['year'] = self.year
|
||||
if self.genre:
|
||||
info['genre'] = self.genre
|
||||
if self.folder.cover_art:
|
||||
if self.has_art:
|
||||
info['coverArt'] = str(self.id)
|
||||
elif self.folder.cover_art:
|
||||
info['coverArt'] = str(self.folder.id)
|
||||
|
||||
try:
|
||||
@ -294,6 +308,23 @@ class Track(PathMixin, db.Entity):
|
||||
def sort_key(self):
|
||||
return (self.album.artist.name + self.album.name + ("%02i" % self.disc) + ("%02i" % self.number) + self.title).lower()
|
||||
|
||||
def extract_cover_art(self):
|
||||
return Track._extract_cover_art(self.path)
|
||||
|
||||
@staticmethod
|
||||
def _extract_cover_art(path):
|
||||
if os.path.exists(path):
|
||||
metadata = mutagen.File(path)
|
||||
if metadata:
|
||||
if isinstance(metadata.tags, mutagen.id3.ID3Tags) and len(metadata.tags.getall('APIC')) > 0:
|
||||
return metadata.tags.getall('APIC')[0].data
|
||||
elif isinstance(metadata, mutagen.flac.FLAC) and len(metadata.pictures):
|
||||
return metadata.pictures[0].data
|
||||
elif isinstance(metadata.tags, mutagen._vorbis.VCommentDict) and 'METADATA_BLOCK_PICTURE' in metadata.tags and len(metadata.tags['METADATA_BLOCK_PICTURE']) > 0:
|
||||
picture = mutagen.flac.Picture(base64.b64decode(metadata.tags['METADATA_BLOCK_PICTURE'][0]))
|
||||
return picture.data
|
||||
return None
|
||||
|
||||
class User(db.Entity):
|
||||
_table_ = 'user'
|
||||
|
||||
|
@ -140,6 +140,7 @@ class Scanner:
|
||||
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)
|
||||
trdict['has_art'] = bool(Track._extract_cover_art(path))
|
||||
|
||||
trdict['bitrate'] = (tag.info.bitrate if hasattr(tag.info, 'bitrate') else int(os.path.getsize(path) * 8 / tag.info.length)) // 1000
|
||||
trdict['content_type'] = mimetypes.guess_type(path, False)[0] or 'application/octet-stream'
|
||||
|
6
supysonic/schema/migration/mysql/20181010.sql
Normal file
6
supysonic/schema/migration/mysql/20181010.sql
Normal file
@ -0,0 +1,6 @@
|
||||
START TRANSACTION;
|
||||
|
||||
ALTER TABLE track ADD has_art BOOLEAN DEFAULT false NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
|
6
supysonic/schema/migration/postgres/20181010.sql
Normal file
6
supysonic/schema/migration/postgres/20181010.sql
Normal file
@ -0,0 +1,6 @@
|
||||
START TRANSACTION;
|
||||
|
||||
ALTER TABLE track ADD has_art BOOLEAN DEFAULT false NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
|
3
supysonic/schema/migration/sqlite/20181010.sql
Normal file
3
supysonic/schema/migration/sqlite/20181010.sql
Normal file
@ -0,0 +1,3 @@
|
||||
ALTER TABLE track ADD has_art BOOLEAN DEFAULT false NOT NULL;
|
||||
|
||||
COMMIT;
|
@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS track (
|
||||
year INTEGER,
|
||||
genre VARCHAR(256),
|
||||
duration INTEGER NOT NULL,
|
||||
has_art BOOLEAN NOT NULL DEFAULT false,
|
||||
album_id BINARY(16) NOT NULL REFERENCES album,
|
||||
artist_id BINARY(16) NOT NULL REFERENCES artist,
|
||||
bitrate INTEGER NOT NULL,
|
||||
|
@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS track (
|
||||
year INTEGER,
|
||||
genre VARCHAR(256),
|
||||
duration INTEGER NOT NULL,
|
||||
has_art BOOLEAN NOT NULL DEFAULT false,
|
||||
album_id UUID NOT NULL REFERENCES album,
|
||||
artist_id UUID NOT NULL REFERENCES artist,
|
||||
bitrate INTEGER NOT NULL,
|
||||
|
@ -31,6 +31,7 @@ CREATE TABLE IF NOT EXISTS track (
|
||||
year INTEGER,
|
||||
genre VARCHAR(256),
|
||||
duration INTEGER NOT NULL,
|
||||
has_art BOOLEAN NOT NULL DEFAULT false,
|
||||
album_id CHAR(36) NOT NULL REFERENCES album,
|
||||
artist_id CHAR(36) NOT NULL REFERENCES artist,
|
||||
bitrate INTEGER NOT NULL,
|
||||
|
@ -51,6 +51,24 @@ class MediaTestCase(ApiTestBase):
|
||||
)
|
||||
self.trackid = track.id
|
||||
|
||||
self.formats = [('mp3','mpeg'), ('flac','flac'), ('ogg','ogg')]
|
||||
for i in range(len(self.formats)):
|
||||
track_embeded_art = Track(
|
||||
title = '[silence]',
|
||||
number = 1,
|
||||
disc = 1,
|
||||
artist = artist,
|
||||
album = album,
|
||||
path = os.path.abspath('tests/assets/formats/silence.{0}'.format(self.formats[i][0])),
|
||||
root_folder = folder,
|
||||
folder = folder,
|
||||
duration = 2,
|
||||
bitrate = 320,
|
||||
content_type = 'audio/{0}'.format(self.formats[i][1]),
|
||||
last_modification = 0
|
||||
)
|
||||
self.formats[i] = track_embeded_art.id
|
||||
|
||||
def test_stream(self):
|
||||
self._make_request('stream', error = 10)
|
||||
self._make_request('stream', { 'id': 'string' }, error = 0)
|
||||
@ -121,6 +139,15 @@ class MediaTestCase(ApiTestBase):
|
||||
|
||||
# TODO test non square covers
|
||||
|
||||
# Test extracting cover art from embeded media
|
||||
for args['id'] in self.formats:
|
||||
rv = self.client.get('/rest/getCoverArt.view', query_string = args)
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(rv.mimetype, 'image/png')
|
||||
im = Image.open(BytesIO(rv.data))
|
||||
self.assertEqual(im.format, 'PNG')
|
||||
self.assertEqual(im.size, (120, 120))
|
||||
|
||||
def test_get_lyrics(self):
|
||||
self._make_request('getLyrics', error = 10)
|
||||
self._make_request('getLyrics', { 'artist': 'artist' }, error = 10)
|
||||
|
Binary file not shown.
BIN
tests/assets/formats/silence.flac
Normal file
BIN
tests/assets/formats/silence.flac
Normal file
Binary file not shown.
1
tests/assets/formats/silence.mp3
Symbolic link
1
tests/assets/formats/silence.mp3
Symbolic link
@ -0,0 +1 @@
|
||||
../folder/silence.mp3
|
BIN
tests/assets/formats/silence.ogg
Normal file
BIN
tests/assets/formats/silence.ogg
Normal file
Binary file not shown.
@ -46,10 +46,17 @@ class DbTestCase(unittest.TestCase):
|
||||
parent = root_folder
|
||||
)
|
||||
|
||||
return root_folder, child_folder
|
||||
child_2 = db.Folder(
|
||||
root = False,
|
||||
name = 'Child folder (No Art)',
|
||||
path = 'tests/formats',
|
||||
parent = root_folder
|
||||
)
|
||||
|
||||
return root_folder, child_folder, child_2
|
||||
|
||||
def create_some_tracks(self, artist = None, album = None):
|
||||
root, child = self.create_some_folders()
|
||||
root, child, child_2 = self.create_some_folders()
|
||||
|
||||
if not artist:
|
||||
artist = db.Artist(name = 'Test artist')
|
||||
@ -64,9 +71,10 @@ class DbTestCase(unittest.TestCase):
|
||||
disc = 1,
|
||||
number = 1,
|
||||
duration = 3,
|
||||
has_art = True,
|
||||
bitrate = 320,
|
||||
path = 'tests/assets/empty',
|
||||
content_type = 'audio/mpeg',
|
||||
path = 'tests/assets/formats/silence.ogg',
|
||||
content_type = 'audio/ogg',
|
||||
last_modification = 1234,
|
||||
root_folder = root,
|
||||
folder = child
|
||||
@ -89,6 +97,25 @@ class DbTestCase(unittest.TestCase):
|
||||
|
||||
return track1, track2
|
||||
|
||||
def create_track_in(self, folder, root, artist = None, album = None, has_art = True):
|
||||
artist = artist or db.Artist(name = 'Snazzy Artist')
|
||||
album = album or db.Album(artist = artist, name = 'Rockin\' Album')
|
||||
return db.Track(
|
||||
title = 'Nifty Number',
|
||||
album = album,
|
||||
artist = artist,
|
||||
disc = 1,
|
||||
number = 1,
|
||||
duration = 5,
|
||||
has_art = has_art,
|
||||
bitrate = 96,
|
||||
path = 'tests/assets/formats/silence.flac',
|
||||
content_type = 'audio/flac',
|
||||
last_modification = 1234,
|
||||
root_folder = root,
|
||||
folder = folder
|
||||
)
|
||||
|
||||
def create_user(self, name = 'Test User'):
|
||||
return db.User(
|
||||
name = name,
|
||||
@ -107,7 +134,8 @@ class DbTestCase(unittest.TestCase):
|
||||
|
||||
@db_session
|
||||
def test_folder_base(self):
|
||||
root_folder, child_folder = self.create_some_folders()
|
||||
root_folder, child_folder, child_noart = self.create_some_folders()
|
||||
track_embededart = self.create_track_in(child_noart, root_folder)
|
||||
|
||||
MockUser = namedtuple('User', [ 'id' ])
|
||||
user = MockUser(uuid.uuid4())
|
||||
@ -132,9 +160,13 @@ class DbTestCase(unittest.TestCase):
|
||||
self.assertEqual(child['artist'], root_folder.name)
|
||||
self.assertEqual(child['coverArt'], child['id'])
|
||||
|
||||
noart = child_noart.as_subsonic_child(user)
|
||||
self.assertIn('coverArt', noart)
|
||||
self.assertEqual(noart['coverArt'], str(track_embededart.id))
|
||||
|
||||
@db_session
|
||||
def test_folder_annotation(self):
|
||||
root_folder, child_folder = self.create_some_folders()
|
||||
root_folder, child_folder, _ = self.create_some_folders()
|
||||
|
||||
user = self.create_user()
|
||||
star = db.StarredFolder(
|
||||
@ -202,7 +234,8 @@ class DbTestCase(unittest.TestCase):
|
||||
# No tracks, shouldn't be stored under normal circumstances
|
||||
self.assertRaises(ValueError, album.as_subsonic_album, user)
|
||||
|
||||
self.create_some_tracks(artist, album)
|
||||
root_folder, folder_art, folder_noart = self.create_some_folders()
|
||||
track1 = self.create_track_in(root_folder, folder_noart, artist = artist, album = album)
|
||||
|
||||
album_dict = album.as_subsonic_album(user)
|
||||
self.assertIsInstance(album_dict, dict)
|
||||
@ -214,11 +247,13 @@ class DbTestCase(unittest.TestCase):
|
||||
self.assertIn('duration', album_dict)
|
||||
self.assertIn('created', album_dict)
|
||||
self.assertIn('starred', album_dict)
|
||||
self.assertIn('coverArt', album_dict)
|
||||
self.assertEqual(album_dict['name'], album.name)
|
||||
self.assertEqual(album_dict['artist'], artist.name)
|
||||
self.assertEqual(album_dict['artistId'], str(artist.id))
|
||||
self.assertEqual(album_dict['songCount'], 2)
|
||||
self.assertEqual(album_dict['duration'], 8)
|
||||
self.assertEqual(album_dict['songCount'], 1)
|
||||
self.assertEqual(album_dict['duration'], 5)
|
||||
self.assertEqual(album_dict['coverArt'], str(track1.id))
|
||||
self.assertRegex(album_dict['created'], date_regex)
|
||||
self.assertRegex(album_dict['starred'], date_regex)
|
||||
|
||||
@ -237,6 +272,11 @@ class DbTestCase(unittest.TestCase):
|
||||
self.assertIn('isDir', track1_dict)
|
||||
self.assertIn('title', track1_dict)
|
||||
self.assertFalse(track1_dict['isDir'])
|
||||
self.assertIn('coverArt', track1_dict)
|
||||
self.assertEqual(track1_dict['coverArt'], track1_dict['id'])
|
||||
|
||||
track2_dict = track2.as_subsonic_child(user, None)
|
||||
self.assertEqual(track2_dict['coverArt'], track2_dict['parent'])
|
||||
# ... we'll test the rest against the API XSD.
|
||||
|
||||
@db_session
|
||||
|
@ -26,7 +26,7 @@ class ScannerTestCase(unittest.TestCase):
|
||||
db.init_database('sqlite:')
|
||||
|
||||
with db_session:
|
||||
folder = FolderManager.add('folder', os.path.abspath('tests/assets'))
|
||||
folder = FolderManager.add('folder', os.path.abspath('tests/assets/folder'))
|
||||
self.assertIsNotNone(folder)
|
||||
self.folderid = folder.id
|
||||
|
||||
@ -126,7 +126,7 @@ class ScannerTestCase(unittest.TestCase):
|
||||
self.assertEqual(db.Track.select().count(), 1)
|
||||
|
||||
track = db.Track.select().first()
|
||||
new_path = os.path.abspath(os.path.join(os.path.dirname(track.path), '..', 'silence.mp3'))
|
||||
new_path = track.path.replace('silence','silence_moved')
|
||||
self.scanner.move_file(track.path, new_path)
|
||||
commit()
|
||||
self.assertEqual(db.Track.select().count(), 1)
|
||||
|
Loading…
Reference in New Issue
Block a user