mirror of
https://github.com/spl0k/supysonic.git
synced 2024-11-14 14:12:17 +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
|
||||
import subprocess
|
||||
import shlex
|
||||
import mutagen
|
||||
import fnmatch
|
||||
import mimetypes
|
||||
from mediafile import MediaFile
|
||||
import mutagen
|
||||
|
||||
import config, scanner
|
||||
from web import app
|
||||
@ -174,6 +175,8 @@ def download_media():
|
||||
|
||||
@app.route('/rest/getCoverArt.view', methods = [ 'GET', 'POST' ])
|
||||
def cover_art():
|
||||
|
||||
# Speed up the file transfer
|
||||
@after_this_request
|
||||
def add_header(response):
|
||||
if 'X-Sendfile' in response.headers:
|
||||
@ -184,23 +187,37 @@ def cover_art():
|
||||
app.logger.debug('X-Accel-Redirect: ' + xsendfile + redirect)
|
||||
return response
|
||||
|
||||
# retrieve folder from database
|
||||
status, res = get_entity(request, Folder)
|
||||
|
||||
if not status:
|
||||
return res
|
||||
|
||||
# Check the folder id given for jpgs
|
||||
app.logger.debug('Cover Art Check: ' + res.path + '/*.jp*g')
|
||||
|
||||
coverfile = os.listdir(res.path)
|
||||
coverfile = fnmatch.filter(coverfile, '*.jp*g')
|
||||
app.logger.debug('Found Images: ' + str(coverfile))
|
||||
|
||||
# when there is not a jpeg in the folder check files for embedded art
|
||||
if not coverfile:
|
||||
app.logger.debug('No Art Found!')
|
||||
res.has_cover_art = False
|
||||
session.commit()
|
||||
app.logger.debug('No Art Found in Folder, Checking Files!')
|
||||
|
||||
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
|
||||
|
||||
# pick the first image found
|
||||
# TODO: prefer cover
|
||||
coverfile = coverfile[0]
|
||||
|
||||
size = request.args.get('size')
|
||||
|
6
db.py
6
db.py
@ -124,7 +124,7 @@ class Folder(database.Model):
|
||||
|
||||
@hybrid_property
|
||||
def name(self):
|
||||
return self.path[self.path.rfind(os.sep) + 1:]
|
||||
return os.path.basename(self.path)
|
||||
|
||||
def get_children(self):
|
||||
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))
|
||||
artist_id = Column(UUID, ForeignKey('artist.id'))
|
||||
tracks = relationship('Track', backref = 'album', cascade="delete")
|
||||
year = Column(String(32))
|
||||
|
||||
def as_subsonic_album(self, user):
|
||||
info = {
|
||||
@ -195,7 +196,8 @@ class Album(database.Model):
|
||||
'artistId': str(self.artist_id),
|
||||
'songCount': len(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:
|
||||
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 time
|
||||
import mutagen
|
||||
import datetime
|
||||
from mediafile import MediaFile
|
||||
import config
|
||||
import math
|
||||
import sys, traceback
|
||||
@ -43,7 +44,7 @@ class Scanner:
|
||||
|
||||
for root, subfolders, files in os.walk(root_folder.path, topdown=False):
|
||||
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)
|
||||
|
||||
for f in files:
|
||||
@ -54,10 +55,8 @@ class Scanner:
|
||||
except:
|
||||
app.logger.error('Problem adding file: ' + os.path.join(root,f))
|
||||
app.logger.error(traceback.print_exc())
|
||||
sys.exit(0)
|
||||
self.__session.rollback()
|
||||
pass
|
||||
|
||||
self.__session.add_all(self.__folders.values())
|
||||
self.__session.add_all(self.__tracks.values())
|
||||
root_folder.last_scan = int(time.time())
|
||||
self.__session.commit()
|
||||
@ -94,6 +93,7 @@ class Scanner:
|
||||
tr = self.__tracks[path]
|
||||
|
||||
app.logger.debug('Existing File: ' + path)
|
||||
|
||||
if not tr.last_modification:
|
||||
tr.last_modification = curmtime
|
||||
|
||||
@ -104,16 +104,14 @@ class Scanner:
|
||||
app.logger.debug('\tFile modified, updating tag')
|
||||
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))
|
||||
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:
|
||||
app.logger.debug('Scanning File: ' + path + '\n\tReading tag')
|
||||
tag = self.__try_load_tag(path)
|
||||
if not tag:
|
||||
app.logger.debug('\tProblem reading tag')
|
||||
|
||||
try:
|
||||
mf = MediaFile(path)
|
||||
except:
|
||||
app.logger.error('Problem reading file: ' + path)
|
||||
app.logger.error(traceback.print_exc())
|
||||
return False
|
||||
|
||||
tr = db.Track(path = path, folder = self.__find_folder(root))
|
||||
@ -122,21 +120,32 @@ class Scanner:
|
||||
self.__added_tracks += 1
|
||||
|
||||
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
|
||||
tr.album = self.__find_album(self.__try_read_tag(tag, 'artist', 'Unknown'), self.__try_read_tag(tag, 'album', 'Unknown'))
|
||||
# read in file tags
|
||||
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
|
||||
|
||||
def __find_album(self, artist, album):
|
||||
def __find_album(self, artist, album, yr):
|
||||
# TODO : DB specific issues with single column name primary key
|
||||
# for instance, case sensitivity and trailing spaces
|
||||
artist = artist.rstrip()
|
||||
@ -157,7 +166,7 @@ class Scanner:
|
||||
return al[album]
|
||||
else:
|
||||
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):
|
||||
|
||||
@ -168,23 +177,6 @@ class Scanner:
|
||||
self.__folders[path] = db.Folder(path = 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):
|
||||
track.album.tracks.remove(track)
|
||||
track.folder.tracks.remove(track)
|
||||
|
@ -9,6 +9,6 @@
|
||||
touch-reload = /tmp/supysonic.reload
|
||||
enable-threads = true
|
||||
processes = 8
|
||||
harakiri = 120
|
||||
harakiri = 280
|
||||
daemonize = uwsgi.log
|
||||
close-on-exec = true
|
||||
|
Loading…
Reference in New Issue
Block a user