mirror of
https://github.com/spl0k/supysonic.git
synced 2024-11-14 22:22:18 +00:00
use mediafile library from beets to read tags and coverart from songs
This commit is contained in:
parent
8570884e6e
commit
f313ff5369
25
api/media.py
25
api/media.py
@ -5,9 +5,10 @@ import os.path
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
import subprocess
|
import subprocess
|
||||||
import shlex
|
import shlex
|
||||||
import mutagen
|
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from mediafile import MediaFile
|
||||||
|
import mutagen
|
||||||
|
|
||||||
import config, scanner
|
import config, scanner
|
||||||
from web import app
|
from web import app
|
||||||
@ -174,6 +175,8 @@ def download_media():
|
|||||||
|
|
||||||
@app.route('/rest/getCoverArt.view', methods = [ 'GET', 'POST' ])
|
@app.route('/rest/getCoverArt.view', methods = [ 'GET', 'POST' ])
|
||||||
def cover_art():
|
def cover_art():
|
||||||
|
|
||||||
|
# Speed up the file transfer
|
||||||
@after_this_request
|
@after_this_request
|
||||||
def add_header(response):
|
def add_header(response):
|
||||||
if 'X-Sendfile' in response.headers:
|
if 'X-Sendfile' in response.headers:
|
||||||
@ -184,23 +187,37 @@ def cover_art():
|
|||||||
app.logger.debug('X-Accel-Redirect: ' + xsendfile + redirect)
|
app.logger.debug('X-Accel-Redirect: ' + xsendfile + redirect)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# retrieve folder from database
|
||||||
status, res = get_entity(request, Folder)
|
status, res = get_entity(request, Folder)
|
||||||
|
|
||||||
if not status:
|
if not status:
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# Check the folder id given for jpgs
|
||||||
app.logger.debug('Cover Art Check: ' + res.path + '/*.jp*g')
|
app.logger.debug('Cover Art Check: ' + res.path + '/*.jp*g')
|
||||||
|
|
||||||
coverfile = os.listdir(res.path)
|
coverfile = os.listdir(res.path)
|
||||||
coverfile = fnmatch.filter(coverfile, '*.jp*g')
|
coverfile = fnmatch.filter(coverfile, '*.jp*g')
|
||||||
app.logger.debug('Found Images: ' + str(coverfile))
|
app.logger.debug('Found Images: ' + str(coverfile))
|
||||||
|
|
||||||
|
# when there is not a jpeg in the folder check files for embedded art
|
||||||
if not coverfile:
|
if not coverfile:
|
||||||
app.logger.debug('No Art Found!')
|
app.logger.debug('No Art Found in Folder, Checking Files!')
|
||||||
res.has_cover_art = False
|
|
||||||
session.commit()
|
for tr in res.tracks:
|
||||||
|
app.logger.debug('Checking ' + tr.path + ' For Artwork')
|
||||||
|
try:
|
||||||
|
mf = MediaFile(tr.path)
|
||||||
|
coverfile = getattr(mf, 'art')
|
||||||
|
if coverfile is not None:
|
||||||
|
return coverfile
|
||||||
|
except:
|
||||||
|
app.logger.debug('Problem reading embedded art')
|
||||||
|
|
||||||
return request.error_formatter(70, 'Cover art not found'), 404
|
return request.error_formatter(70, 'Cover art not found'), 404
|
||||||
|
|
||||||
|
# pick the first image found
|
||||||
|
# TODO: prefer cover
|
||||||
coverfile = coverfile[0]
|
coverfile = coverfile[0]
|
||||||
|
|
||||||
size = request.args.get('size')
|
size = request.args.get('size')
|
||||||
|
6
db.py
6
db.py
@ -124,7 +124,7 @@ class Folder(database.Model):
|
|||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def name(self):
|
def name(self):
|
||||||
return self.path[self.path.rfind(os.sep) + 1:]
|
return os.path.basename(self.path)
|
||||||
|
|
||||||
def get_children(self):
|
def get_children(self):
|
||||||
return Folder.query.filter(Folder.path.like(self.path + '/%%')).filter(~Folder.path.like(self.path + '/%%/%%'))
|
return Folder.query.filter(Folder.path.like(self.path + '/%%')).filter(~Folder.path.like(self.path + '/%%/%%'))
|
||||||
@ -186,6 +186,7 @@ class Album(database.Model):
|
|||||||
name = Column(String(255))
|
name = Column(String(255))
|
||||||
artist_id = Column(UUID, ForeignKey('artist.id'))
|
artist_id = Column(UUID, ForeignKey('artist.id'))
|
||||||
tracks = relationship('Track', backref = 'album', cascade="delete")
|
tracks = relationship('Track', backref = 'album', cascade="delete")
|
||||||
|
year = Column(String(32))
|
||||||
|
|
||||||
def as_subsonic_album(self, user):
|
def as_subsonic_album(self, user):
|
||||||
info = {
|
info = {
|
||||||
@ -195,7 +196,8 @@ class Album(database.Model):
|
|||||||
'artistId': str(self.artist_id),
|
'artistId': str(self.artist_id),
|
||||||
'songCount': len(self.tracks),
|
'songCount': len(self.tracks),
|
||||||
'duration': sum(map(lambda t: t.duration, self.tracks)),
|
'duration': sum(map(lambda t: t.duration, self.tracks)),
|
||||||
'created': min(map(lambda t: t.created, self.tracks)).isoformat()
|
'created': min(map(lambda t: t.created, self.tracks)).isoformat(),
|
||||||
|
'year': self.year
|
||||||
}
|
}
|
||||||
if self.tracks[0].folder.has_cover_art:
|
if self.tracks[0].folder.has_cover_art:
|
||||||
info['coverArt'] = str(self.tracks[0].folder_id)
|
info['coverArt'] = str(self.tracks[0].folder_id)
|
||||||
|
178
enumeration.py
Executable file
178
enumeration.py
Executable file
@ -0,0 +1,178 @@
|
|||||||
|
# This file is part of beets.
|
||||||
|
# Copyright 2013, Adrian Sampson.
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
# a copy of this software and associated documentation files (the
|
||||||
|
# "Software"), to deal in the Software without restriction, including
|
||||||
|
# without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
# permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
# the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be
|
||||||
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
"""A metaclass for enumerated types that really are types.
|
||||||
|
|
||||||
|
You can create enumerations with `enum(values, [name])` and they work
|
||||||
|
how you would expect them to.
|
||||||
|
|
||||||
|
>>> from enumeration import enum
|
||||||
|
>>> Direction = enum('north east south west', name='Direction')
|
||||||
|
>>> Direction.west
|
||||||
|
Direction.west
|
||||||
|
>>> Direction.west == Direction.west
|
||||||
|
True
|
||||||
|
>>> Direction.west == Direction.east
|
||||||
|
False
|
||||||
|
>>> isinstance(Direction.west, Direction)
|
||||||
|
True
|
||||||
|
>>> Direction[3]
|
||||||
|
Direction.west
|
||||||
|
>>> Direction['west']
|
||||||
|
Direction.west
|
||||||
|
>>> Direction.west.name
|
||||||
|
'west'
|
||||||
|
>>> Direction.north < Direction.west
|
||||||
|
True
|
||||||
|
|
||||||
|
Enumerations are classes; their instances represent the possible values
|
||||||
|
of the enumeration. Because Python classes must have names, you may
|
||||||
|
provide a `name` parameter to `enum`; if you don't, a meaningless one
|
||||||
|
will be chosen for you.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
class Enumeration(type):
|
||||||
|
"""A metaclass whose classes are enumerations.
|
||||||
|
|
||||||
|
The `values` attribute of the class is used to populate the
|
||||||
|
enumeration. Values may either be a list of enumerated names or a
|
||||||
|
string containing a space-separated list of names. When the class
|
||||||
|
is created, it is instantiated for each name value in `values`.
|
||||||
|
Each such instance is the name of the enumerated item as the sole
|
||||||
|
argument.
|
||||||
|
|
||||||
|
The `Enumerated` class is a good choice for a superclass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(cls, name, bases, dic):
|
||||||
|
super(Enumeration, cls).__init__(name, bases, dic)
|
||||||
|
|
||||||
|
if 'values' not in dic:
|
||||||
|
# Do nothing if no values are provided (i.e., with
|
||||||
|
# Enumerated itself).
|
||||||
|
return
|
||||||
|
|
||||||
|
# May be called with a single string, in which case we split on
|
||||||
|
# whitespace for convenience.
|
||||||
|
values = dic['values']
|
||||||
|
if isinstance(values, basestring):
|
||||||
|
values = values.split()
|
||||||
|
|
||||||
|
# Create the Enumerated instances for each value. We have to use
|
||||||
|
# super's __setattr__ here because we disallow setattr below.
|
||||||
|
super(Enumeration, cls).__setattr__('_items_dict', {})
|
||||||
|
super(Enumeration, cls).__setattr__('_items_list', [])
|
||||||
|
for value in values:
|
||||||
|
item = cls(value, len(cls._items_list))
|
||||||
|
cls._items_dict[value] = item
|
||||||
|
cls._items_list.append(item)
|
||||||
|
|
||||||
|
def __getattr__(cls, key):
|
||||||
|
try:
|
||||||
|
return cls._items_dict[key]
|
||||||
|
except KeyError:
|
||||||
|
raise AttributeError("enumeration '" + cls.__name__ +
|
||||||
|
"' has no item '" + key + "'")
|
||||||
|
|
||||||
|
def __setattr__(cls, key, val):
|
||||||
|
raise TypeError("enumerations do not support attribute assignment")
|
||||||
|
|
||||||
|
def __getitem__(cls, key):
|
||||||
|
if isinstance(key, int):
|
||||||
|
return cls._items_list[key]
|
||||||
|
else:
|
||||||
|
return getattr(cls, key)
|
||||||
|
|
||||||
|
def __len__(cls):
|
||||||
|
return len(cls._items_list)
|
||||||
|
|
||||||
|
def __iter__(cls):
|
||||||
|
return iter(cls._items_list)
|
||||||
|
|
||||||
|
def __nonzero__(cls):
|
||||||
|
# Ensures that __len__ doesn't get called before __init__ by
|
||||||
|
# pydoc.
|
||||||
|
return True
|
||||||
|
|
||||||
|
class Enumerated(object):
|
||||||
|
"""An item in an enumeration.
|
||||||
|
|
||||||
|
Contains instance methods inherited by enumerated objects. The
|
||||||
|
metaclass is preset to `Enumeration` for your convenience.
|
||||||
|
|
||||||
|
Instance attributes:
|
||||||
|
name -- The name of the item.
|
||||||
|
index -- The index of the item in its enumeration.
|
||||||
|
|
||||||
|
>>> from enumeration import Enumerated
|
||||||
|
>>> class Garment(Enumerated):
|
||||||
|
... values = 'hat glove belt poncho lederhosen suspenders'
|
||||||
|
... def wear(self):
|
||||||
|
... print('now wearing a ' + self.name)
|
||||||
|
...
|
||||||
|
>>> Garment.poncho.wear()
|
||||||
|
now wearing a poncho
|
||||||
|
"""
|
||||||
|
|
||||||
|
__metaclass__ = Enumeration
|
||||||
|
|
||||||
|
def __init__(self, name, index):
|
||||||
|
self.name = name
|
||||||
|
self.index = index
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return type(self).__name__ + '.' + self.name
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return str(self)
|
||||||
|
|
||||||
|
def __cmp__(self, other):
|
||||||
|
if type(self) is type(other):
|
||||||
|
# Note that we're assuming that the items are direct
|
||||||
|
# instances of the same Enumeration (i.e., no fancy
|
||||||
|
# subclassing), which is probably okay.
|
||||||
|
return cmp(self.index, other.index)
|
||||||
|
else:
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def enum(*values, **kwargs):
|
||||||
|
"""Shorthand for creating a new Enumeration class.
|
||||||
|
|
||||||
|
Call with enumeration values as a list, a space-delimited string, or
|
||||||
|
just an argument list. To give the class a name, pass it as the
|
||||||
|
`name` keyword argument. Otherwise, a name will be chosen for you.
|
||||||
|
|
||||||
|
The following are all equivalent:
|
||||||
|
|
||||||
|
enum('pinkie ring middle index thumb')
|
||||||
|
enum('pinkie', 'ring', 'middle', 'index', 'thumb')
|
||||||
|
enum(['pinkie', 'ring', 'middle', 'index', 'thumb'])
|
||||||
|
"""
|
||||||
|
|
||||||
|
if ('name' not in kwargs) or kwargs['name'] is None:
|
||||||
|
# Create a probably-unique name. It doesn't really have to be
|
||||||
|
# unique, but getting distinct names each time helps with
|
||||||
|
# identification in debugging.
|
||||||
|
name = 'Enumeration' + hex(random.randint(0,0xfffffff))[2:].upper()
|
||||||
|
else:
|
||||||
|
name = kwargs['name']
|
||||||
|
|
||||||
|
if len(values) == 1:
|
||||||
|
# If there's only one value, we have a couple of alternate calling
|
||||||
|
# styles.
|
||||||
|
if isinstance(values[0], basestring) or hasattr(values[0], '__iter__'):
|
||||||
|
values = values[0]
|
||||||
|
|
||||||
|
return type(name, (Enumerated,), {'values': values})
|
1414
mediafile.py
Executable file
1414
mediafile.py
Executable file
File diff suppressed because it is too large
Load Diff
74
scanner.py
74
scanner.py
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import os, os.path
|
import os, os.path
|
||||||
import time
|
import time
|
||||||
import mutagen
|
import datetime
|
||||||
|
from mediafile import MediaFile
|
||||||
import config
|
import config
|
||||||
import math
|
import math
|
||||||
import sys, traceback
|
import sys, traceback
|
||||||
@ -43,7 +44,7 @@ class Scanner:
|
|||||||
|
|
||||||
for root, subfolders, files in os.walk(root_folder.path, topdown=False):
|
for root, subfolders, files in os.walk(root_folder.path, topdown=False):
|
||||||
if(root not in self.__folders):
|
if(root not in self.__folders):
|
||||||
app.logger.debug('Adding folder (empty): ' + root)
|
app.logger.debug('Adding folder: ' + root)
|
||||||
self.__folders[root] = db.Folder(path = root)
|
self.__folders[root] = db.Folder(path = root)
|
||||||
|
|
||||||
for f in files:
|
for f in files:
|
||||||
@ -54,10 +55,8 @@ class Scanner:
|
|||||||
except:
|
except:
|
||||||
app.logger.error('Problem adding file: ' + os.path.join(root,f))
|
app.logger.error('Problem adding file: ' + os.path.join(root,f))
|
||||||
app.logger.error(traceback.print_exc())
|
app.logger.error(traceback.print_exc())
|
||||||
sys.exit(0)
|
pass
|
||||||
self.__session.rollback()
|
|
||||||
|
|
||||||
self.__session.add_all(self.__folders.values())
|
|
||||||
self.__session.add_all(self.__tracks.values())
|
self.__session.add_all(self.__tracks.values())
|
||||||
root_folder.last_scan = int(time.time())
|
root_folder.last_scan = int(time.time())
|
||||||
self.__session.commit()
|
self.__session.commit()
|
||||||
@ -94,6 +93,7 @@ class Scanner:
|
|||||||
tr = self.__tracks[path]
|
tr = self.__tracks[path]
|
||||||
|
|
||||||
app.logger.debug('Existing File: ' + path)
|
app.logger.debug('Existing File: ' + path)
|
||||||
|
|
||||||
if not tr.last_modification:
|
if not tr.last_modification:
|
||||||
tr.last_modification = curmtime
|
tr.last_modification = curmtime
|
||||||
|
|
||||||
@ -104,16 +104,14 @@ class Scanner:
|
|||||||
app.logger.debug('\tFile modified, updating tag')
|
app.logger.debug('\tFile modified, updating tag')
|
||||||
app.logger.debug('\tcurmtime %s / last_mod %s', curmtime, tr.last_modification)
|
app.logger.debug('\tcurmtime %s / last_mod %s', curmtime, tr.last_modification)
|
||||||
app.logger.debug('\t\t%s Seconds Newer\n\t\t', str(curmtime - tr.last_modification))
|
app.logger.debug('\t\t%s Seconds Newer\n\t\t', str(curmtime - tr.last_modification))
|
||||||
tag = self.__try_load_tag(path)
|
|
||||||
if not tag:
|
|
||||||
app.logger.debug('\tError retrieving tags, removing track from DB')
|
|
||||||
self.__remove_track(tr)
|
|
||||||
return False
|
|
||||||
else:
|
else:
|
||||||
app.logger.debug('Scanning File: ' + path + '\n\tReading tag')
|
app.logger.debug('Scanning File: ' + path + '\n\tReading tag')
|
||||||
tag = self.__try_load_tag(path)
|
|
||||||
if not tag:
|
try:
|
||||||
app.logger.debug('\tProblem reading tag')
|
mf = MediaFile(path)
|
||||||
|
except:
|
||||||
|
app.logger.error('Problem reading file: ' + path)
|
||||||
|
app.logger.error(traceback.print_exc())
|
||||||
return False
|
return False
|
||||||
|
|
||||||
tr = db.Track(path = path, folder = self.__find_folder(root))
|
tr = db.Track(path = path, folder = self.__find_folder(root))
|
||||||
@ -122,21 +120,32 @@ class Scanner:
|
|||||||
self.__added_tracks += 1
|
self.__added_tracks += 1
|
||||||
|
|
||||||
tr.last_modification = curmtime
|
tr.last_modification = curmtime
|
||||||
tr.disc = self.__try_read_tag(tag, 'discnumber', 1, lambda x: int(x[0].split('/')[0]))
|
|
||||||
tr.number = self.__try_read_tag(tag, 'tracknumber', 1, lambda x: int(x[0].split('/')[0]))
|
|
||||||
tr.title = self.__try_read_tag(tag, 'title', '')
|
|
||||||
tr.year = self.__try_read_tag(tag, 'date', None, lambda x: int(x[0].split('-')[0]))
|
|
||||||
tr.genre = self.__try_read_tag(tag, 'genre')
|
|
||||||
tr.duration = int(tag.info.length)
|
|
||||||
|
|
||||||
# TODO: use album artist if available, then artist, then unknown
|
# read in file tags
|
||||||
tr.album = self.__find_album(self.__try_read_tag(tag, 'artist', 'Unknown'), self.__try_read_tag(tag, 'album', 'Unknown'))
|
tr.disc = getattr(mf, 'disc')
|
||||||
|
tr.number = getattr(mf, 'track')
|
||||||
|
tr.title = getattr(mf, 'title')
|
||||||
|
tr.year = getattr(mf, 'year')
|
||||||
|
tr.genre = getattr(mf, 'genre')
|
||||||
|
tr.artist = getattr(mf, 'artist')
|
||||||
|
tr.bitrate = getattr(mf, 'bitrate')/1000
|
||||||
|
tr.duration = getattr(mf, 'length')
|
||||||
|
|
||||||
tr.bitrate = (tag.info.bitrate if hasattr(tag.info, 'bitrate') else int(os.path.getsize(path) * 8 / tag.info.length)) / 1000
|
albumartist = getattr(mf, 'albumartist')
|
||||||
|
if (albumartist == u''):
|
||||||
|
# Use folder name two levels up if no albumartist tag found
|
||||||
|
# Assumes structure main -> artist -> album -> song.file
|
||||||
|
# That way the songs in compilations will show up in the same album
|
||||||
|
albumartist = os.path.basename(os.path.dirname(tr.folder.path))
|
||||||
|
|
||||||
|
tr.created = datetime.datetime.fromtimestamp(curmtime)
|
||||||
|
|
||||||
|
# album year is the same as year of first track found from album, might be inaccurate
|
||||||
|
tr.album = self.__find_album(albumartist, getattr(mf, 'album'), tr.year)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def __find_album(self, artist, album):
|
def __find_album(self, artist, album, yr):
|
||||||
# TODO : DB specific issues with single column name primary key
|
# TODO : DB specific issues with single column name primary key
|
||||||
# for instance, case sensitivity and trailing spaces
|
# for instance, case sensitivity and trailing spaces
|
||||||
artist = artist.rstrip()
|
artist = artist.rstrip()
|
||||||
@ -157,7 +166,7 @@ class Scanner:
|
|||||||
return al[album]
|
return al[album]
|
||||||
else:
|
else:
|
||||||
self.__added_albums += 1
|
self.__added_albums += 1
|
||||||
return db.Album(name = album, artist = ar)
|
return db.Album(name = album, artist = ar, year = yr)
|
||||||
|
|
||||||
def __find_folder(self, path):
|
def __find_folder(self, path):
|
||||||
|
|
||||||
@ -168,23 +177,6 @@ class Scanner:
|
|||||||
self.__folders[path] = db.Folder(path = path)
|
self.__folders[path] = db.Folder(path = path)
|
||||||
return self.__folders[path]
|
return self.__folders[path]
|
||||||
|
|
||||||
def __try_load_tag(self, path):
|
|
||||||
try:
|
|
||||||
return mutagen.File(path, easy = True)
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def __try_read_tag(self, metadata, field, default = None, transform = lambda x: x[0]):
|
|
||||||
try:
|
|
||||||
value = metadata[field]
|
|
||||||
if not value:
|
|
||||||
return default
|
|
||||||
if transform:
|
|
||||||
value = transform(value)
|
|
||||||
return value if value else default
|
|
||||||
except:
|
|
||||||
return default
|
|
||||||
|
|
||||||
def __remove_track(self, track):
|
def __remove_track(self, track):
|
||||||
track.album.tracks.remove(track)
|
track.album.tracks.remove(track)
|
||||||
track.folder.tracks.remove(track)
|
track.folder.tracks.remove(track)
|
||||||
|
@ -9,6 +9,6 @@
|
|||||||
touch-reload = /tmp/supysonic.reload
|
touch-reload = /tmp/supysonic.reload
|
||||||
enable-threads = true
|
enable-threads = true
|
||||||
processes = 8
|
processes = 8
|
||||||
harakiri = 120
|
harakiri = 280
|
||||||
daemonize = uwsgi.log
|
daemonize = uwsgi.log
|
||||||
close-on-exec = true
|
close-on-exec = true
|
||||||
|
Loading…
Reference in New Issue
Block a user