diff --git a/README.md b/README.md index 18f224f..91b4c64 100644 --- a/README.md +++ b/README.md @@ -56,19 +56,10 @@ but not both. ### Prerequisites -You'll need these to run _Supysonic_: +You'll need Python 3.5 or later to run _Supysonic_. -* Python >= 3.5 -* [Flask](http://flask.pocoo.org/) -* [PonyORM](https://ponyorm.com/) -* [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) -* [zipstream](https://github.com/allanlei/python-zipstream) - -All the dependencies will automatically be installed by the -installation command above. +All the dependencies will automatically be installed by the installation +command above. You may also need a database specific package if you don't want to use SQLite (the default): diff --git a/setup.py b/setup.py index 6e58aed..738b92d 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ reqs = [ "pony>=0.7.6", "Pillow", "requests>=1.0.0", - "mutagen>=1.33", + "mediafile", "watchdog>=0.8.0", "zipstream", ] diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 7f8b673..cc29aab 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -3,21 +3,22 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # 2018-2019 Carey 'pR0Ps' Metcalfe # # Distributed under terms of the GNU AGPLv3 license. +import hashlib +import io +import json import logging +import mediafile import mimetypes import os.path import requests import shlex import subprocess import uuid -import io -import hashlib -import json import zlib from flask import request, Response, send_file @@ -30,7 +31,6 @@ from zipstream import ZipFile from .. import scanner from ..cache import CacheMiss -from ..covers import get_embedded_cover from ..db import Track, Album, Artist, Folder, User, ClientPrefs, now from . import api, get_entity, get_entity_id @@ -267,8 +267,9 @@ def cover_art(): cover_path = cache.get(cache_key) except CacheMiss: res = get_entity(Track) - art = get_embedded_cover(res.path) - if not art: + try: + art = mediafile.MediaFile(res.path).art + except mediafile.UnreadableFileError: raise NotFound("Cover art") cover_path = cache.set(cache_key, art) else: diff --git a/supysonic/api/radio.py b/supysonic/api/radio.py index c8525d2..65e7fc3 100644 --- a/supysonic/api/radio.py +++ b/supysonic/api/radio.py @@ -17,9 +17,7 @@ from .exceptions import Forbidden, MissingParameter, NotFound @api.route("/getInternetRadioStations.view", methods=["GET", "POST"]) def get_radio_stations(): - query = RadioStation.select().sort_by( - RadioStation.name - ) + query = RadioStation.select().sort_by(RadioStation.name) return request.formatter( "internetRadioStations", dict(internetRadioStation=[p.as_subsonic_station() for p in query]), @@ -31,7 +29,9 @@ def create_radio_station(): if not request.user.admin: raise Forbidden() - stream_url, name, homepage_url = map(request.values.get, ["streamUrl", "name", "homepageUrl"]) + stream_url, name, homepage_url = map( + request.values.get, ["streamUrl", "name", "homepageUrl"] + ) if stream_url and name: RadioStation(stream_url=stream_url, name=name, homepage_url=homepage_url) @@ -48,7 +48,9 @@ def update_radio_station(): res = get_entity(RadioStation) - stream_url, name, homepage_url = map(request.values.get, ["streamUrl", "name", "homepageUrl"]) + stream_url, name, homepage_url = map( + request.values.get, ["streamUrl", "name", "homepageUrl"] + ) if stream_url and name: res.stream_url = stream_url res.name = name @@ -70,4 +72,3 @@ def delete_radio_station(): res.delete() return request.formatter.empty - diff --git a/supysonic/covers.py b/supysonic/covers.py index e749f9d..3c1ed38 100644 --- a/supysonic/covers.py +++ b/supysonic/covers.py @@ -11,11 +11,6 @@ import os.path import re import warnings -from base64 import b64decode -from mutagen import File, FileType -from mutagen.easyid3 import EasyID3 -from mutagen.flac import FLAC, Picture -from mutagen._vorbis import VCommentDict from PIL import Image from os import scandir @@ -90,48 +85,3 @@ def find_cover_in_folder(path, album_name=None): return candidates[0] return sorted(candidates, key=lambda c: c.score, reverse=True)[0] - - -def get_embedded_cover(path): - if not isinstance(path, str): # pragma: nocover - raise TypeError("Expecting string, got " + str(type(path))) - - if not os.path.exists(path): - return None - - metadata = File(path, easy=True) - if not metadata: - return None - - if isinstance(metadata.tags, EasyID3): - picture = metadata["pictures"][0] - elif isinstance(metadata, FLAC): - picture = metadata.pictures[0] - elif isinstance(metadata.tags, VCommentDict): - picture = Picture(b64decode(metadata.tags["METADATA_BLOCK_PICTURE"][0])) - else: - return None - - return picture.data - - -def has_embedded_cover(metadata): - if not isinstance(metadata, FileType): # pragma: nocover - raise TypeError("Expecting mutagen.FileType, got " + str(type(metadata))) - - pictures = [] - if isinstance(metadata.tags, EasyID3): - pictures = metadata.get("pictures", []) - elif isinstance(metadata, FLAC): - pictures = metadata.pictures - elif isinstance(metadata.tags, VCommentDict): - pictures = metadata.tags.get("METADATA_BLOCK_PICTURE", []) - - return len(pictures) > 0 - - -def _get_id3_apic(id3, key): - return id3.getall("APIC") - - -EasyID3.RegisterKey("pictures", _get_id3_apic) diff --git a/supysonic/scanner.py b/supysonic/scanner.py index d8cb7d0..c3f6a04 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -3,13 +3,13 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import logging import os, os.path -import mutagen +import mediafile import time from datetime import datetime @@ -17,7 +17,7 @@ from pony.orm import db_session from queue import Queue, Empty as QueueEmpty from threading import Thread, Event -from .covers import find_cover_in_folder, has_embedded_cover, CoverFile +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 @@ -233,32 +233,19 @@ class Scanner(Thread): trdict = {"path": path} - artist = self.__try_read_tag(tag, "artist", "[unknown]")[:255] - album = self.__try_read_tag(tag, "album", "[non-album tracks]")[:255] - albumartist = self.__try_read_tag(tag, "albumartist", artist)[:255] + artist = (self.__sanitize_str(tag.artist) or "[unknown]")[:255] + album = (self.__sanitize_str(tag.album) or "[non-album tracks]")[:255] + albumartist = (self.__sanitize_str(tag.albumartist) or artist)[:255] - trdict["disc"] = self.__try_read_tag( - tag, "discnumber", 1, lambda x: int(x.split("/")[0]) - ) - trdict["number"] = self.__try_read_tag( - tag, "tracknumber", 1, lambda x: int(x.split("/")[0]) - ) - trdict["title"] = self.__try_read_tag(tag, "title", basename)[:255] - trdict["year"] = self.__try_read_tag( - tag, "date", None, lambda x: int(x.split("-")[0]) - ) - trdict["genre"] = self.__try_read_tag(tag, "genre") - trdict["duration"] = int(tag.info.length) - trdict["has_art"] = has_embedded_cover(tag) + trdict["disc"] = tag.disc or 1 + trdict["number"] = tag.track or 1 + trdict["title"] = (self.__sanitize_str(tag.title) or basename)[:255] + trdict["year"] = tag.year + trdict["genre"] = tag.genre + trdict["duration"] = int(tag.length) + trdict["has_art"] = bool(tag.images) - trdict["bitrate"] = ( - int( - tag.info.bitrate - if hasattr(tag.info, "bitrate") - else size * 8 / tag.info.length - ) - // 1000 - ) + trdict["bitrate"] = tag.bitrate trdict["last_modification"] = mtime tralbum = self.__find_album(albumartist, album) @@ -431,25 +418,14 @@ class Scanner(Thread): def __try_load_tag(self, path): try: - return mutagen.File(path, easy=True) - except mutagen.MutagenError: + return mediafile.MediaFile(path) + except mediafile.UnreadableFileError: return None - def __try_read_tag(self, metadata, field, default=None, transform=None): - try: - value = metadata[field][0] - value = value.replace("\x00", "").strip() - - if not value: - return default - if transform: - value = transform(value) - return value if value else default - # KeyError: missing tag - # IndexError: tag is present but doesn't have any value - # ValueError: tag can't be transformed to correct type - except (KeyError, IndexError, ValueError): - return default + def __sanitize_str(self, value): + if value is None: + return None + return value.replace("\x00", "").strip() def stats(self): return self.__stats