Compare commits
8 Commits
Author | SHA1 | Date |
---|---|---|
dave | ac9b3620e9 | |
Dave Pedu | 33e501928e | |
Dave Pedu | a3c354d4ef | |
dave | 5f3b2e471b | |
dave | 3aedfcf139 | |
dave | 3718d3b90c | |
dave | 55f48433ed | |
dave | afd5476ea8 |
|
@ -1,4 +0,0 @@
|
||||||
Library/
|
|
||||||
.git/
|
|
||||||
testenv/
|
|
||||||
linuxenv/
|
|
|
@ -1,7 +0,0 @@
|
||||||
/build
|
|
||||||
/dist
|
|
||||||
/pysonic.egg-info
|
|
||||||
/testenv
|
|
||||||
/test.db
|
|
||||||
/library
|
|
||||||
__pycache__
|
|
20
Dockerfile
20
Dockerfile
|
@ -1,20 +0,0 @@
|
||||||
FROM dockermirror:5000/ubuntu:focal
|
|
||||||
|
|
||||||
RUN apt-get update && \
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg
|
|
||||||
|
|
||||||
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y python3-pip libxml2-dev libxslt1-dev sudo sqlite3 && \
|
|
||||||
useradd --create-home --uid 1000 app
|
|
||||||
|
|
||||||
ADD requirements.txt /tmp/requirements.txt
|
|
||||||
|
|
||||||
RUN pip3 install -r /tmp/requirements.txt
|
|
||||||
|
|
||||||
ADD . /tmp/code
|
|
||||||
|
|
||||||
RUN cd /tmp/code && \
|
|
||||||
python3 setup.py install && \
|
|
||||||
mv start.sh / && \
|
|
||||||
chmod +x /start.sh
|
|
||||||
|
|
||||||
ENTRYPOINT ["/start.sh", "--database-path", "/db/pysonic.sqlite", "--dirs", "/library"]
|
|
|
@ -1,68 +0,0 @@
|
||||||
def image_name = "dpedu/pysonic"
|
|
||||||
|
|
||||||
pipeline {
|
|
||||||
agent {
|
|
||||||
kubernetes {
|
|
||||||
yaml """
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Pod
|
|
||||||
spec:
|
|
||||||
podAntiAffinity:
|
|
||||||
preferredDuringSchedulingIgnoredDuringExecution: # avoid nodes already running a jenkins job
|
|
||||||
- podAffinityTerm:
|
|
||||||
labelSelector:
|
|
||||||
matchExpressions:
|
|
||||||
- key: jenkins
|
|
||||||
operator: In
|
|
||||||
values:
|
|
||||||
- slave
|
|
||||||
topologyKey: node
|
|
||||||
containers:
|
|
||||||
- name: docker
|
|
||||||
image: docker:20-dind
|
|
||||||
args:
|
|
||||||
- "--insecure-registry"
|
|
||||||
- "dockermirror:5000"
|
|
||||||
securityContext:
|
|
||||||
privileged: true
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stages {
|
|
||||||
stage("Build image") {
|
|
||||||
steps {
|
|
||||||
container("docker") {
|
|
||||||
script {
|
|
||||||
try {
|
|
||||||
docker.withRegistry('http://dockermirror:5000') {
|
|
||||||
docker.image("ubuntu:focal").pull()
|
|
||||||
docker.image(image_name).pull() // Pull a recent version to share base layers with (?)
|
|
||||||
}
|
|
||||||
} catch (exc) {
|
|
||||||
echo "couldn't pull image, assuming we're building it for the first time"
|
|
||||||
}
|
|
||||||
docker.build(image_name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stage("Push image") {
|
|
||||||
steps {
|
|
||||||
container("docker") {
|
|
||||||
script {
|
|
||||||
docker.withRegistry('http://dockermirror:5000') {
|
|
||||||
docker.image(image_name).push("latest")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stage("Show images") {
|
|
||||||
steps {
|
|
||||||
container("docker") {
|
|
||||||
sh 'docker images'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
19
Makefile
19
Makefile
|
@ -1,19 +0,0 @@
|
||||||
BUILDARGS :=
|
|
||||||
IMAGE := dockermirror:5000/dpedu/pysonic
|
|
||||||
|
|
||||||
.PHONY: image
|
|
||||||
image:
|
|
||||||
docker build -t $(IMAGE) $(BUILDARGS) .
|
|
||||||
|
|
||||||
.PHONY: push
|
|
||||||
push: image
|
|
||||||
docker push $(IMAGE)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.PHONY: run-local
|
|
||||||
run-local:
|
|
||||||
pysonicd -d ./Library/ -u foo:bar -s ./db.sqlite --debug
|
|
14
README.md
14
README.md
|
@ -1,14 +0,0 @@
|
||||||
pysonic
|
|
||||||
=======
|
|
||||||
|
|
||||||
subsonic api drop-in replacement
|
|
||||||
|
|
||||||
running docker
|
|
||||||
--------------
|
|
||||||
|
|
||||||
* `make image`
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
* mount the sqlite database in /db/, it will be chowned automatically
|
|
||||||
* mount library in /library/
|
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.0.2"
|
__version__ = "0.0.1"
|
||||||
|
|
457
pysonic/api.py
457
pysonic/api.py
|
@ -1,36 +1,164 @@
|
||||||
import os
|
import re
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
from time import time
|
from time import time
|
||||||
|
from random import shuffle
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from pysonic.database import LETTER_GROUPS
|
|
||||||
from pysonic.types import MUSIC_TYPES, TYPE_TO_EXTENSION
|
|
||||||
from pysonic.apilib import formatresponse, ApiResponse
|
|
||||||
import cherrypy
|
import cherrypy
|
||||||
|
from collections import defaultdict
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from pysonic.library import LETTER_GROUPS
|
||||||
|
from pysonic.types import MUSIC_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
CALLBACK_RE = re.compile(r'^[a-zA-Z0-9_]+$')
|
||||||
logging = logging.getLogger("api")
|
logging = logging.getLogger("api")
|
||||||
|
|
||||||
|
|
||||||
TRANSCODE_TIMEOUT = int(os.environ.get("PYSONIC_ENCODE_TIMEOUT", 5 * 60))
|
response_formats = defaultdict(lambda: "render_xml")
|
||||||
|
response_formats["json"] = "render_json"
|
||||||
|
response_formats["jsonp"] = "render_jsonp"
|
||||||
|
|
||||||
|
response_headers = defaultdict(lambda: "text/xml; charset=utf-8")
|
||||||
|
response_headers["json"] = "application/json; charset=utf-8"
|
||||||
|
response_headers["jsonp"] = "text/javascript; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
def extension(mime):
|
def formatresponse(func):
|
||||||
r = TYPE_TO_EXTENSION.get(mime)
|
"""
|
||||||
return r
|
Decorator for rendering ApiResponse responses
|
||||||
|
"""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
response = func(*args, **kwargs)
|
||||||
|
response_format = kwargs.get("f", "xml")
|
||||||
|
callback = kwargs.get("callback", None)
|
||||||
|
cherrypy.response.headers['Content-Type'] = response_headers[response_format]
|
||||||
|
renderer = getattr(response, response_formats[response_format])
|
||||||
|
if response_format == "jsonp":
|
||||||
|
if callback is None:
|
||||||
|
return response.render_xml().encode('UTF-8') # copy original subsonic behavior
|
||||||
|
else:
|
||||||
|
return renderer(callback).encode('UTF-8')
|
||||||
|
return renderer().encode('UTF-8')
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class PysonicSubsonicApi(object):
|
class ApiResponse(object):
|
||||||
def __init__(self, db, options):
|
def __init__(self, status="ok", version="1.15.0"):
|
||||||
|
"""
|
||||||
|
ApiResponses are python data structures that can be converted to other formats. The response has a status and a
|
||||||
|
version. The response data structure is stored in self.data and follows these rules:
|
||||||
|
- self.data is a dict
|
||||||
|
- the dict's values become either child nodes or attributes, named by the key
|
||||||
|
- lists become many oner one child
|
||||||
|
- dict values are not allowed
|
||||||
|
- all other types (str, int, NoneType) are attributes
|
||||||
|
:param status:
|
||||||
|
:param version:
|
||||||
|
"""
|
||||||
|
self.status = status
|
||||||
|
self.version = version
|
||||||
|
self.data = defaultdict(lambda: list())
|
||||||
|
|
||||||
|
def add_child(self, _type, _parent="", _real_parent=None, **kwargs):
|
||||||
|
kwargs = {k: v for k, v in kwargs.items() if v or type(v) is int} # filter out empty keys (0 is ok)
|
||||||
|
parent = _real_parent if _real_parent else self.get_child(_parent)
|
||||||
|
m = defaultdict(lambda: list())
|
||||||
|
m.update(dict(kwargs))
|
||||||
|
parent[_type].append(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
def get_child(self, _path):
|
||||||
|
parent_path = _path.split(".")
|
||||||
|
parent = self.data
|
||||||
|
for item in parent_path:
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
parent = parent.get(item)[0]
|
||||||
|
return parent
|
||||||
|
|
||||||
|
def set_attrs(self, _path, **attrs):
|
||||||
|
parent = self.get_child(_path)
|
||||||
|
if type(parent) not in (dict, defaultdict):
|
||||||
|
raise Exception("wot")
|
||||||
|
parent.update(attrs)
|
||||||
|
|
||||||
|
def render_json(self):
|
||||||
|
def _flatten_json(item):
|
||||||
|
"""
|
||||||
|
Convert defaultdicts to dicts and remove lists where node has 1 or no child
|
||||||
|
"""
|
||||||
|
listed_attrs = ["folder"]
|
||||||
|
d = {}
|
||||||
|
for k, v in item.items():
|
||||||
|
if type(v) is list:
|
||||||
|
if len(v) > 1:
|
||||||
|
d[k] = []
|
||||||
|
for subitem in v:
|
||||||
|
d[k].append(_flatten_json(subitem))
|
||||||
|
elif len(v) == 1:
|
||||||
|
d[k] = _flatten_json(v[0])
|
||||||
|
else:
|
||||||
|
d[k] = {}
|
||||||
|
else:
|
||||||
|
d[k] = [v] if k in listed_attrs else v
|
||||||
|
return d
|
||||||
|
|
||||||
|
data = _flatten_json(self.data)
|
||||||
|
return json.dumps({"subsonic-response": dict(status=self.status, version=self.version, **data)}, indent=4)
|
||||||
|
|
||||||
|
def render_jsonp(self, callback):
|
||||||
|
assert CALLBACK_RE.match(callback), "Invalid callback"
|
||||||
|
return "{}({});".format(callback, self.render_json())
|
||||||
|
|
||||||
|
def render_xml(self):
|
||||||
|
text_attrs = ['largeImageUrl', 'musicBrainzId', 'smallImageUrl', 'mediumImageUrl', 'lastFmUrl', 'biography',
|
||||||
|
'folder']
|
||||||
|
selftext_attrs = ['value']
|
||||||
|
# These attributes will be placed in <hello>{{ value }}</hello> tags instead of hello="{{ value }}" on parent
|
||||||
|
doc = BeautifulSoup('', features='lxml-xml')
|
||||||
|
root = doc.new_tag("subsonic-response", xmlns="http://subsonic.org/restapi",
|
||||||
|
status=self.status,
|
||||||
|
version=self.version)
|
||||||
|
doc.append(root)
|
||||||
|
|
||||||
|
def _render_xml(node, parent):
|
||||||
|
"""
|
||||||
|
For every key in the node dict, the parent gets a new child tag with name == key
|
||||||
|
If the value is a dict, it becomes the new tag's attrs
|
||||||
|
If the value is a list, the parent gets many new tags with each dict as attrs
|
||||||
|
If the value is str int etc, parent gets attrs
|
||||||
|
"""
|
||||||
|
for key, value in node.items():
|
||||||
|
if type(value) in (dict, defaultdict):
|
||||||
|
tag = doc.new_tag(key)
|
||||||
|
parent.append(tag)
|
||||||
|
tag.attrs.update(value)
|
||||||
|
elif type(value) is list:
|
||||||
|
for item in value:
|
||||||
|
tag = doc.new_tag(key)
|
||||||
|
parent.append(tag)
|
||||||
|
_render_xml(item, tag)
|
||||||
|
else:
|
||||||
|
if key in text_attrs:
|
||||||
|
tag = doc.new_tag(key)
|
||||||
|
parent.append(tag)
|
||||||
|
tag.append(str(value))
|
||||||
|
elif key in selftext_attrs:
|
||||||
|
parent.append(str(value))
|
||||||
|
else:
|
||||||
|
parent.attrs[key] = value
|
||||||
|
_render_xml(self.data, root)
|
||||||
|
return doc.prettify()
|
||||||
|
|
||||||
|
|
||||||
|
class PysonicApi(object):
|
||||||
|
def __init__(self, db, library, options):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self.library = library
|
||||||
self.options = options
|
self.options = options
|
||||||
|
|
||||||
@cherrypy.expose
|
|
||||||
@formatresponse
|
|
||||||
def index(self):
|
|
||||||
response = ApiResponse()
|
|
||||||
response.add_child("totals", **self.db.get_stats())
|
|
||||||
return response
|
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@formatresponse
|
@formatresponse
|
||||||
def ping_view(self, **kwargs):
|
def ping_view(self, **kwargs):
|
||||||
|
@ -54,7 +182,7 @@ class PysonicSubsonicApi(object):
|
||||||
def getMusicFolders_view(self, **kwargs):
|
def getMusicFolders_view(self, **kwargs):
|
||||||
response = ApiResponse()
|
response = ApiResponse()
|
||||||
response.add_child("musicFolders")
|
response.add_child("musicFolders")
|
||||||
for folder in self.db.get_libraries():
|
for folder in self.library.get_libraries():
|
||||||
response.add_child("musicFolder", _parent="musicFolders", id=folder["id"], name=folder["name"])
|
response.add_child("musicFolder", _parent="musicFolders", id=folder["id"], name=folder["name"])
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -66,7 +194,7 @@ class PysonicSubsonicApi(object):
|
||||||
# TODO real lastmodified date
|
# TODO real lastmodified date
|
||||||
# TODO deal with ignoredArticles
|
# TODO deal with ignoredArticles
|
||||||
response.add_child("indexes", lastModified="1502310831000", ignoredArticles="The El La Los Las Le Les")
|
response.add_child("indexes", lastModified="1502310831000", ignoredArticles="The El La Los Las Le Les")
|
||||||
artists = self.db.get_artists(sortby="name", order="asc")
|
artists = self.library.get_artists(sortby="name", order="asc")
|
||||||
for letter in LETTER_GROUPS:
|
for letter in LETTER_GROUPS:
|
||||||
index = response.add_child("index", _parent="indexes", name=letter.upper())
|
index = response.add_child("index", _parent="indexes", name=letter.upper())
|
||||||
for artist in artists:
|
for artist in artists:
|
||||||
|
@ -76,7 +204,7 @@ class PysonicSubsonicApi(object):
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@formatresponse
|
@formatresponse
|
||||||
def getAlbumList_view(self, type, size=250, offset=0, **kwargs):
|
def getAlbumList_view(self, type, size=50, offset=0, **kwargs):
|
||||||
qargs = {}
|
qargs = {}
|
||||||
if type == "random":
|
if type == "random":
|
||||||
qargs.update(sortby="random")
|
qargs.update(sortby="random")
|
||||||
|
@ -84,14 +212,12 @@ class PysonicSubsonicApi(object):
|
||||||
qargs.update(sortby="name", order="asc")
|
qargs.update(sortby="name", order="asc")
|
||||||
elif type == "newest":
|
elif type == "newest":
|
||||||
qargs.update(sortby="added", order="desc")
|
qargs.update(sortby="added", order="desc")
|
||||||
elif type == "recent":
|
else:
|
||||||
qargs.update(sortby="played", order="desc")
|
raise NotImplemented()
|
||||||
elif type == "frequent":
|
|
||||||
qargs.update(sortby="plays", order="desc")
|
|
||||||
|
|
||||||
qargs.update(limit=(offset, size))
|
qargs.update(limit=(offset, size))
|
||||||
|
|
||||||
albums = self.db.get_albums(**qargs)
|
albums = self.library.get_albums(**qargs)
|
||||||
|
|
||||||
response = ApiResponse()
|
response = ApiResponse()
|
||||||
|
|
||||||
|
@ -104,10 +230,10 @@ class PysonicSubsonicApi(object):
|
||||||
title=album["name"],
|
title=album["name"],
|
||||||
album=album["name"],
|
album=album["name"],
|
||||||
artist=album["artistname"],
|
artist=album["artistname"],
|
||||||
coverArt=album["coverid"],
|
coverArt=album["coverid"]
|
||||||
playCount=album["plays"],
|
|
||||||
#year=TODO
|
#year=TODO
|
||||||
#created="2016-05-08T05:31:31.000Z"/>)
|
# playCount="0"
|
||||||
|
# created="2016-05-08T05:31:31.000Z"/>)
|
||||||
)
|
)
|
||||||
response.add_child("album", _parent="albumList", **album_kw)
|
response.add_child("album", _parent="albumList", **album_kw)
|
||||||
return response
|
return response
|
||||||
|
@ -116,26 +242,16 @@ class PysonicSubsonicApi(object):
|
||||||
@formatresponse
|
@formatresponse
|
||||||
def getMusicDirectory_view(self, id, **kwargs):
|
def getMusicDirectory_view(self, id, **kwargs):
|
||||||
"""
|
"""
|
||||||
List either and artist or album dir
|
List an artist dir
|
||||||
"""
|
"""
|
||||||
dir_id = int(id)
|
dir_id = int(id)
|
||||||
dirtype, dirinfo, entity = self.db.get_subsonic_musicdir(dirid=dir_id)
|
dirtype, dirinfo, entity = self.library.db.get_musicdir(dirid=dir_id)
|
||||||
|
|
||||||
response = ApiResponse()
|
response = ApiResponse()
|
||||||
|
response.add_child("directory")
|
||||||
|
response.set_attrs(_path="directory", name=entity['name'], id=entity['id'],
|
||||||
|
parent=dirinfo['parent'], playCount=420)
|
||||||
|
|
||||||
# artists just need this
|
|
||||||
response.add_child("directory",
|
|
||||||
name=entity['name'],
|
|
||||||
id=entity['dir'])
|
|
||||||
|
|
||||||
if dirtype == "album":
|
|
||||||
# albums can also have
|
|
||||||
# - parent (album dir id)
|
|
||||||
# - playcount
|
|
||||||
response.set_attrs(_path="directory",
|
|
||||||
parent=dirinfo["parent"],
|
|
||||||
playCount=entity["plays"])
|
|
||||||
#TODO refactor meeeeee
|
|
||||||
for childtype, child in entity["children"]:
|
for childtype, child in entity["children"]:
|
||||||
# omit not dirs and media in browser
|
# omit not dirs and media in browser
|
||||||
# if not item["isdir"] and item["type"] not in MUSIC_TYPES:
|
# if not item["isdir"] and item["type"] not in MUSIC_TYPES:
|
||||||
|
@ -145,7 +261,7 @@ class PysonicSubsonicApi(object):
|
||||||
if childtype == "album":
|
if childtype == "album":
|
||||||
moreargs.update(name=child["name"],
|
moreargs.update(name=child["name"],
|
||||||
isDir="true", # TODO song files in artist dir
|
isDir="true", # TODO song files in artist dir
|
||||||
parent=entity["dir"],
|
parent=entity["id"],
|
||||||
id=child["dir"])
|
id=child["dir"])
|
||||||
if child["coverid"]:
|
if child["coverid"]:
|
||||||
moreargs.update(coverArt=child["coverid"])
|
moreargs.update(coverArt=child["coverid"])
|
||||||
|
@ -154,31 +270,18 @@ class PysonicSubsonicApi(object):
|
||||||
# artist=artist["name"],
|
# artist=artist["name"],
|
||||||
# coverArt=item["coverid"],
|
# coverArt=item["coverid"],
|
||||||
elif childtype == "song":
|
elif childtype == "song":
|
||||||
moreargs.update(title=child["title"],
|
moreargs.update(name=child["title"],
|
||||||
albumId=entity["dir"],
|
|
||||||
album=entity["name"],
|
|
||||||
artistId=child["_artist"]["dir"],
|
|
||||||
artist=child["_artist"]["name"],
|
artist=child["_artist"]["name"],
|
||||||
contentType=child["format"],
|
contentType=child["format"],
|
||||||
id=child["id"],
|
id=child["id"],
|
||||||
duration=child["length"],
|
duration=child["length"],
|
||||||
isDir="false",
|
isDir="false",
|
||||||
parent=entity["dir"],
|
parent=entity["dir"],
|
||||||
track=child["track"],
|
# title=xxx
|
||||||
playCount=child["plays"],
|
|
||||||
#TODO suffix can be null/omitted, which causes the client to cache files wrong, while
|
|
||||||
# this isn't ideal, fixing it properly would require significant changes to the scanner.
|
|
||||||
suffix=extension(child["format"]),
|
|
||||||
path=child["file"],
|
|
||||||
# bitRate
|
|
||||||
# discNumber
|
|
||||||
# created=
|
|
||||||
# year=1999
|
|
||||||
# genre="Alternative & Punk"
|
|
||||||
)
|
)
|
||||||
if entity["coverid"]:
|
if entity["coverid"]:
|
||||||
moreargs.update(coverArt=entity["coverid"])
|
moreargs.update(coverArt=entity["coverid"])
|
||||||
|
# duration="230" size="8409237" suffix="mp3" track="2" year="2005"/>
|
||||||
response.add_child("child", _parent="directory",
|
response.add_child("child", _parent="directory",
|
||||||
size="4096",
|
size="4096",
|
||||||
type="music",
|
type="music",
|
||||||
|
@ -189,11 +292,10 @@ class PysonicSubsonicApi(object):
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def stream_view(self, id, maxBitRate="256", **kwargs):
|
def stream_view(self, id, maxBitRate="256", **kwargs):
|
||||||
maxBitRate = int(maxBitRate) or 256
|
maxBitRate = int(maxBitRate)
|
||||||
if maxBitRate < 32 or maxBitRate > 320:
|
assert maxBitRate >= 32 and maxBitRate <= 320
|
||||||
raise cherrypy.HTTPError(400, message=f"invalid maxBitRate: {maxBitRate}. Must be between 32 and 320.")
|
song = self.library.get_song(id)
|
||||||
song = self.db.get_songs(id=int(id))[0]
|
fpath = song["_fullpath"]
|
||||||
fpath = os.path.join(song["root"], song["file"])
|
|
||||||
media_bitrate = song.get("bitrate") / 1024 if song.get("bitrate") else 320
|
media_bitrate = song.get("bitrate") / 1024 if song.get("bitrate") else 320
|
||||||
to_bitrate = min(maxBitRate,
|
to_bitrate = min(maxBitRate,
|
||||||
self.options.max_bitrate,
|
self.options.max_bitrate,
|
||||||
|
@ -240,14 +342,14 @@ class PysonicSubsonicApi(object):
|
||||||
if proc.returncode is None or proc.returncode == 0:
|
if proc.returncode is None or proc.returncode == 0:
|
||||||
logging.warning("transcoded {} in {}s".format(id, int(time() - start)))
|
logging.warning("transcoded {} in {}s".format(id, int(time() - start)))
|
||||||
# if completed:
|
# if completed:
|
||||||
# self.db.report_transcode(id, to_bitrate, length)
|
# self.library.report_transcode(id, to_bitrate, length)
|
||||||
else:
|
else:
|
||||||
logging.error("transcode of {} exited with code {} after {}s".format(id, proc.returncode,
|
logging.error("transcode of {} exited with code {} after {}s".format(id, proc.returncode,
|
||||||
int(time() - start)))
|
int(time() - start)))
|
||||||
|
|
||||||
def stopit(proc):
|
def stopit(proc):
|
||||||
try:
|
try:
|
||||||
proc.wait(timeout=TRANSCODE_TIMEOUT)
|
proc.wait(timeout=90)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logging.warning("killing timed-out transcoder")
|
logging.warning("killing timed-out transcoder")
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
@ -260,30 +362,8 @@ class PysonicSubsonicApi(object):
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def getCoverArt_view(self, id, **kwargs):
|
def getCoverArt_view(self, id, **kwargs):
|
||||||
"""
|
cover = self.library.get_cover(id)
|
||||||
id is a string and if it's a number it's the album at for a...?? could be song or album either by id or directory id lol
|
fpath = cover["_fullpath"]
|
||||||
it could also be:
|
|
||||||
pl-1234 - playlist
|
|
||||||
|
|
||||||
for now, if the first character isn't a number, we error
|
|
||||||
"""
|
|
||||||
if id.startswith("pl-"): # get art from first track in playlist
|
|
||||||
playlist_id = int(id[len("pl-"):])
|
|
||||||
songs = self.db.get_playlist_songs(playlist_id)
|
|
||||||
for song in songs:
|
|
||||||
if song["albumcoverid"]:
|
|
||||||
id = song["albumcoverid"]
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise cherrypy.HTTPError(404, message=f"no art for any of the {len(songs)} tracks in playlist {playlist_id}")
|
|
||||||
elif id[0] not in "0123456789":
|
|
||||||
#TODO
|
|
||||||
print("TODO support getCoverArt id format", repr(id))
|
|
||||||
raise cherrypy.HTTPError(500, message=f"coverid format {repr(id)} not supported")
|
|
||||||
else:
|
|
||||||
id = int(id)
|
|
||||||
|
|
||||||
fpath = self.db.get_cover_path(id)
|
|
||||||
type2ct = {
|
type2ct = {
|
||||||
'jpg': 'image/jpeg',
|
'jpg': 'image/jpeg',
|
||||||
'png': 'image/png',
|
'png': 'image/png',
|
||||||
|
@ -300,14 +380,14 @@ class PysonicSubsonicApi(object):
|
||||||
break
|
break
|
||||||
total += len(data)
|
total += len(data)
|
||||||
yield data
|
yield data
|
||||||
logging.info("sent {} bytes for {}".format(total, fpath))
|
logging.info("\nSent {} bytes for {}".format(total, fpath))
|
||||||
return content()
|
return content()
|
||||||
getCoverArt_view._cp_config = {'response.stream': True}
|
getCoverArt_view._cp_config = {'response.stream': True}
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@formatresponse
|
@formatresponse
|
||||||
def getArtistInfo_view(self, id, includeNotPresent="true", **kwargs):
|
def getArtistInfo_view(self, id, includeNotPresent="true", **kwargs):
|
||||||
info = self.db.get_artist_info(id)
|
info = self.library.get_artist_info(id)
|
||||||
response = ApiResponse()
|
response = ApiResponse()
|
||||||
response.add_child("artistInfo")
|
response.add_child("artistInfo")
|
||||||
response.set_attrs("artistInfo", **info)
|
response.set_attrs("artistInfo", **info)
|
||||||
|
@ -316,7 +396,7 @@ class PysonicSubsonicApi(object):
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@formatresponse
|
@formatresponse
|
||||||
def getUser_view(self, username, **kwargs):
|
def getUser_view(self, username, **kwargs):
|
||||||
user = {} if self.options.disable_auth else self.db.get_user(cherrypy.request.login)
|
user = {} if self.options.disable_auth else self.library.db.get_user(cherrypy.request.login)
|
||||||
response = ApiResponse()
|
response = ApiResponse()
|
||||||
response.add_child("user",
|
response.add_child("user",
|
||||||
username=user["username"],
|
username=user["username"],
|
||||||
|
@ -341,19 +421,19 @@ class PysonicSubsonicApi(object):
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@formatresponse
|
@formatresponse
|
||||||
def star_view(self, id, **kwargs):
|
def star_view(self, id, **kwargs):
|
||||||
self.db.set_starred(cherrypy.request.login, int(id), starred=True)
|
self.library.set_starred(cherrypy.request.login, int(id), starred=True)
|
||||||
return ApiResponse()
|
return ApiResponse()
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@formatresponse
|
@formatresponse
|
||||||
def unstar_view(self, id, **kwargs):
|
def unstar_view(self, id, **kwargs):
|
||||||
self.db.set_starred(cherrypy.request.login, int(id), starred=False)
|
self.library.set_starred(cherrypy.request.login, int(id), starred=False)
|
||||||
return ApiResponse()
|
return ApiResponse()
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@formatresponse
|
@formatresponse
|
||||||
def getStarred_view(self, **kwargs):
|
def getStarred_view(self, **kwargs):
|
||||||
children = self.db.get_starred(cherrypy.request.login)
|
children = self.library.get_starred(cherrypy.request.login)
|
||||||
response = ApiResponse()
|
response = ApiResponse()
|
||||||
response.add_child("starred")
|
response.add_child("starred")
|
||||||
for item in children:
|
for item in children:
|
||||||
|
@ -375,7 +455,7 @@ class PysonicSubsonicApi(object):
|
||||||
"""
|
"""
|
||||||
response = ApiResponse()
|
response = ApiResponse()
|
||||||
response.add_child("randomSongs")
|
response.add_child("randomSongs")
|
||||||
children = self.db.get_songs(limit=size, sortby="random")
|
children = self.library.db.get_songs(limit=size, sortby="random")
|
||||||
for song in children:
|
for song in children:
|
||||||
moreargs = {}
|
moreargs = {}
|
||||||
if song["format"]:
|
if song["format"]:
|
||||||
|
@ -389,6 +469,8 @@ class PysonicSubsonicApi(object):
|
||||||
if song["year"]:
|
if song["year"]:
|
||||||
moreargs.update(year=song["year"])
|
moreargs.update(year=song["year"])
|
||||||
|
|
||||||
|
file_extension = song["file"].split(".")[-1]
|
||||||
|
|
||||||
response.add_child("song",
|
response.add_child("song",
|
||||||
_parent="randomSongs",
|
_parent="randomSongs",
|
||||||
title=song["title"],
|
title=song["title"],
|
||||||
|
@ -398,7 +480,7 @@ class PysonicSubsonicApi(object):
|
||||||
isDir="false",
|
isDir="false",
|
||||||
parent=song["albumid"],
|
parent=song["albumid"],
|
||||||
size=song["size"],
|
size=song["size"],
|
||||||
suffix=extension(song["format"]),
|
suffix=file_extension,
|
||||||
type="music",
|
type="music",
|
||||||
**moreargs)
|
**moreargs)
|
||||||
return response
|
return response
|
||||||
|
@ -408,7 +490,7 @@ class PysonicSubsonicApi(object):
|
||||||
def getGenres_view(self, **kwargs):
|
def getGenres_view(self, **kwargs):
|
||||||
response = ApiResponse()
|
response = ApiResponse()
|
||||||
response.add_child("genres")
|
response.add_child("genres")
|
||||||
for row in self.db.get_genres():
|
for row in self.library.db.get_genres():
|
||||||
response.add_child("genre", _parent="genres", value=row["name"], songCount=420, albumCount=69)
|
response.add_child("genre", _parent="genres", value=row["name"], songCount=420, albumCount=69)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -436,60 +518,30 @@ class PysonicSubsonicApi(object):
|
||||||
query = query.replace("*", "") # TODO handle this
|
query = query.replace("*", "") # TODO handle this
|
||||||
|
|
||||||
artists = 0
|
artists = 0
|
||||||
for item in self.db.get_artists(name_contains=query):
|
for item in self.library.get_artists():
|
||||||
response.add_child("artist", _parent="searchResult2", id=item["dir"], name=item["name"])
|
if query in item["name"].lower():
|
||||||
artists += 1
|
response.add_child("artist", _parent="searchResult2", id=item["id"], name=item["name"])
|
||||||
if artists >= artistCount:
|
artists += 1
|
||||||
break
|
if artists >= artistCount:
|
||||||
|
break
|
||||||
|
|
||||||
# TODO make this more efficient
|
# TODO make this more efficient
|
||||||
albums = 0
|
albums = 0
|
||||||
for album in self.db.get_albums(name_contains=query):
|
for item in self.library.get_artists():
|
||||||
response.add_child("album", _parent="searchResult2",
|
if query in item["name"].lower():
|
||||||
id=album["dir"],
|
response.add_child("album", _parent="searchResult2", **self.render_node(item, item["metadata"], {}, {}))
|
||||||
parent=album["artistdir"],
|
albums += 1
|
||||||
isDir="true",
|
if albums >= albumCount:
|
||||||
title=album["name"],
|
break
|
||||||
album=album["name"],
|
|
||||||
artist=album["artistname"],
|
|
||||||
coverArt=album["coverid"],
|
|
||||||
playCount=album["plays"],
|
|
||||||
#year=TODO
|
|
||||||
#created="2016-05-08T05:31:31.000Z"/>)
|
|
||||||
)
|
|
||||||
albums += 1
|
|
||||||
if albums >= albumCount:
|
|
||||||
break
|
|
||||||
|
|
||||||
# TODO make this more efficient
|
# TODO make this more efficient
|
||||||
songs = 0
|
songs = 0
|
||||||
for song in self.db.get_songs(title_contains=query):
|
for item in self.library.get_songs(limit=9999999, shuffle=False):
|
||||||
response.add_child("song", _parent="searchResult2",
|
if query in item["name"].lower():
|
||||||
id=song["id"],
|
response.add_child("song", _parent="searchResult2", **self.render_node(item, item["metadata"], {}, {}))
|
||||||
parent=song["albumdir"],
|
songs += 1
|
||||||
isDir="false",
|
if songs > songCount:
|
||||||
title=song["title"],
|
break
|
||||||
album=song["albumname"],
|
|
||||||
artist=song["artistname"],
|
|
||||||
track=song["track"],
|
|
||||||
year=song["year"],
|
|
||||||
genre=song["genrename"],
|
|
||||||
coverArt=song["albumcoverid"],
|
|
||||||
size=song["size"],
|
|
||||||
contentType=song["format"],
|
|
||||||
duration=song["length"],
|
|
||||||
bitRate=song["bitrate"],
|
|
||||||
path=song["file"],
|
|
||||||
playCount=song["plays"],
|
|
||||||
albumId=song["albumid"],
|
|
||||||
type="music",
|
|
||||||
suffix=extension(song["format"]),
|
|
||||||
# created="2012-09-17T22:35:19.000Z"
|
|
||||||
)
|
|
||||||
|
|
||||||
songs += 1
|
|
||||||
if songs > songCount:
|
|
||||||
break
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -501,113 +553,6 @@ class PysonicSubsonicApi(object):
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def savePlayQueue_view(self, id, current, position, **kwargs):
|
def savePlayQueue_view(self, id, current, position, **kwargs):
|
||||||
print("TODO save playqueue with items {} current {} position {}".format(id, repr(current), repr(position)))
|
print("TODO save playlist with items {} current {} position {}".format(id, current, position))
|
||||||
current = int(current)
|
|
||||||
song = self.db.get_songs(id=current)[0]
|
|
||||||
self.db.update_album_played(song['albumid'], time())
|
|
||||||
self.db.increment_album_plays(song['albumid'])
|
|
||||||
if int(position) == 0:
|
|
||||||
self.db.increment_track_plays(current)
|
|
||||||
# TODO save playlist with items ['378', '386', '384', '380', '383'] current 383 position 4471
|
# TODO save playlist with items ['378', '386', '384', '380', '383'] current 383 position 4471
|
||||||
# id entries are strings!
|
# id entries are strings!
|
||||||
|
|
||||||
@cherrypy.expose
|
|
||||||
@formatresponse
|
|
||||||
def createPlaylist_view(self, name, songId, **kwargs):
|
|
||||||
if type(songId) != list:
|
|
||||||
songId = [songId]
|
|
||||||
user = self.db.get_user(cherrypy.request.login)
|
|
||||||
self.db.add_playlist(user["id"], name, songId)
|
|
||||||
return ApiResponse()
|
|
||||||
#TODO the response should be the new playlist, check the cap
|
|
||||||
|
|
||||||
@cherrypy.expose
|
|
||||||
@formatresponse
|
|
||||||
def getPlaylists_view(self, **kwargs):
|
|
||||||
user = self.db.get_user(cherrypy.request.login)
|
|
||||||
|
|
||||||
response = ApiResponse()
|
|
||||||
response.add_child("playlists")
|
|
||||||
for playlist in self.db.get_playlists(user["id"]):
|
|
||||||
response.add_child("playlist",
|
|
||||||
_parent="playlists",
|
|
||||||
id=playlist["id"],
|
|
||||||
name=playlist["name"],
|
|
||||||
owner=user["username"],
|
|
||||||
public=playlist["public"],
|
|
||||||
songCount=69,
|
|
||||||
duration=420,
|
|
||||||
# changed="2018-04-05T23:23:38.263Z"
|
|
||||||
# created="2018-04-05T23:23:38.252Z"
|
|
||||||
coverArt="pl-{}".format(playlist["id"])
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
@cherrypy.expose
|
|
||||||
@formatresponse
|
|
||||||
def getPlaylist_view(self, id, **kwargs):
|
|
||||||
id = int(id)
|
|
||||||
user = self.db.get_user(cherrypy.request.login)
|
|
||||||
plinfo = self.db.get_playlist(id)
|
|
||||||
songs = self.db.get_playlist_songs(id)
|
|
||||||
response = ApiResponse()
|
|
||||||
response.add_child("playlist",
|
|
||||||
id=plinfo["id"],
|
|
||||||
name=plinfo["name"], # TODO this element should match getPlaylists_view
|
|
||||||
owner=user["username"], # TODO translate id to name
|
|
||||||
public=plinfo["public"],
|
|
||||||
songCount=69,
|
|
||||||
duration=420)
|
|
||||||
for song in songs:
|
|
||||||
response.add_child("entry",
|
|
||||||
_parent="playlist",
|
|
||||||
id=song["id"],
|
|
||||||
parent=song["albumid"], # albumid seems wrong? should be dir parent?
|
|
||||||
isDir="false",
|
|
||||||
title=song["title"],
|
|
||||||
album=song["albumname"],
|
|
||||||
artist=song["artistname"],
|
|
||||||
track=song["track"],
|
|
||||||
year=song["year"],
|
|
||||||
genre=song["genrename"],
|
|
||||||
coverArt=song["albumcoverid"],
|
|
||||||
size=song["size"],
|
|
||||||
contentType=song["format"],
|
|
||||||
suffix=extension(song["format"]),
|
|
||||||
duration=song["length"],
|
|
||||||
bitRate=song["bitrate"] / 1024 if song["bitrate"] else None, #TODO macro for this sort of logic
|
|
||||||
path=song["file"],
|
|
||||||
playCount=song["plays"],
|
|
||||||
# created="2015-06-09T15:26:01.000Z"
|
|
||||||
albumId=song["albumid"],
|
|
||||||
artistId=song["artistid"],
|
|
||||||
type="music")
|
|
||||||
return response
|
|
||||||
|
|
||||||
@cherrypy.expose
|
|
||||||
@formatresponse
|
|
||||||
def updatePlaylist_view(self, playlistId, songIndexToRemove=None, songIdToAdd=None, **kwargs):
|
|
||||||
playlistId = int(playlistId)
|
|
||||||
user = self.db.get_user(cherrypy.request.login)
|
|
||||||
plinfo = self.db.get_playlist(playlistId)
|
|
||||||
|
|
||||||
assert plinfo["ownerid"] == user["id"]
|
|
||||||
|
|
||||||
if songIndexToRemove:
|
|
||||||
self.db.remove_index_from_playlist(playlistId, songIndexToRemove)
|
|
||||||
elif songIdToAdd:
|
|
||||||
self.db.add_to_playlist(playlistId, songIdToAdd)
|
|
||||||
#TODO there are more modification methods
|
|
||||||
|
|
||||||
return ApiResponse()
|
|
||||||
|
|
||||||
@cherrypy.expose
|
|
||||||
@formatresponse
|
|
||||||
def deletePlaylist_view(self, id, **kwargs):
|
|
||||||
user = self.db.get_user(cherrypy.request.login)
|
|
||||||
plinfo = self.db.get_playlist(int(id))
|
|
||||||
assert plinfo["ownerid"] == user["id"]
|
|
||||||
|
|
||||||
self.db.delete_playlist(plinfo["id"])
|
|
||||||
return ApiResponse()
|
|
||||||
|
|
|
@ -1,143 +0,0 @@
|
||||||
from collections import defaultdict
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
import re
|
|
||||||
import cherrypy
|
|
||||||
import json
|
|
||||||
|
|
||||||
CALLBACK_RE = re.compile(r'^[a-zA-Z0-9_]+$')
|
|
||||||
|
|
||||||
response_formats = defaultdict(lambda: "render_xml")
|
|
||||||
response_formats["json"] = "render_json"
|
|
||||||
response_formats["jsonp"] = "render_jsonp"
|
|
||||||
|
|
||||||
response_headers = defaultdict(lambda: "text/xml; charset=utf-8")
|
|
||||||
response_headers["json"] = "application/json; charset=utf-8"
|
|
||||||
response_headers["jsonp"] = "text/javascript; charset=utf-8"
|
|
||||||
|
|
||||||
|
|
||||||
def formatresponse(func):
|
|
||||||
"""
|
|
||||||
Decorator for rendering ApiResponse responses based on requested response type
|
|
||||||
"""
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
response = func(*args, **kwargs)
|
|
||||||
response_format = kwargs.get("f", "xml")
|
|
||||||
callback = kwargs.get("callback", None)
|
|
||||||
cherrypy.response.headers['Content-Type'] = response_headers[response_format]
|
|
||||||
renderer = getattr(response, response_formats[response_format])
|
|
||||||
if response_format == "jsonp":
|
|
||||||
if callback is None:
|
|
||||||
return response.render_xml().encode('UTF-8') # copy original subsonic behavior
|
|
||||||
else:
|
|
||||||
return renderer(callback).encode('UTF-8')
|
|
||||||
return renderer().encode('UTF-8')
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class ApiResponse(object):
|
|
||||||
def __init__(self, status="ok", version="1.15.0"):
|
|
||||||
"""
|
|
||||||
ApiResponses are python data structures that can be converted to other formats. The response has a status and a
|
|
||||||
version. The response data structure is stored in self.data and follows these rules:
|
|
||||||
- self.data is a dict
|
|
||||||
- the dict's values become either child nodes or attributes, named by the key
|
|
||||||
- lists become many oner one child
|
|
||||||
- dict values are not allowed
|
|
||||||
- all other types (str, int, NoneType) are attributes
|
|
||||||
:param status:
|
|
||||||
:param version:
|
|
||||||
"""
|
|
||||||
self.status = status
|
|
||||||
self.version = version
|
|
||||||
self.data = defaultdict(lambda: list())
|
|
||||||
|
|
||||||
def add_child(self, _type, _parent="", _real_parent=None, **kwargs):
|
|
||||||
kwargs = {k: v for k, v in kwargs.items() if v or type(v) is int} # filter out empty keys (0 is ok)
|
|
||||||
parent = _real_parent if _real_parent else self.get_child(_parent)
|
|
||||||
m = defaultdict(lambda: list())
|
|
||||||
m.update(dict(kwargs))
|
|
||||||
parent[_type].append(m)
|
|
||||||
return m
|
|
||||||
|
|
||||||
def get_child(self, _path):
|
|
||||||
parent_path = _path.split(".")
|
|
||||||
parent = self.data
|
|
||||||
for item in parent_path:
|
|
||||||
if not item:
|
|
||||||
continue
|
|
||||||
parent = parent.get(item)[0]
|
|
||||||
return parent
|
|
||||||
|
|
||||||
def set_attrs(self, _path, **attrs):
|
|
||||||
parent = self.get_child(_path)
|
|
||||||
if type(parent) not in (dict, defaultdict):
|
|
||||||
raise Exception("wot")
|
|
||||||
parent.update(attrs)
|
|
||||||
|
|
||||||
def render_json(self):
|
|
||||||
def _flatten_json(item):
|
|
||||||
"""
|
|
||||||
Convert defaultdicts to dicts and remove lists where node has 1 or no child
|
|
||||||
"""
|
|
||||||
listed_attrs = ["folder"]
|
|
||||||
d = {}
|
|
||||||
for k, v in item.items():
|
|
||||||
if type(v) is list:
|
|
||||||
if len(v) > 1:
|
|
||||||
d[k] = []
|
|
||||||
for subitem in v:
|
|
||||||
d[k].append(_flatten_json(subitem))
|
|
||||||
elif len(v) == 1:
|
|
||||||
d[k] = _flatten_json(v[0])
|
|
||||||
else:
|
|
||||||
d[k] = {}
|
|
||||||
else:
|
|
||||||
d[k] = [v] if k in listed_attrs else v
|
|
||||||
return d
|
|
||||||
|
|
||||||
data = _flatten_json(self.data)
|
|
||||||
return json.dumps({"subsonic-response": dict(status=self.status, version=self.version, **data)}, indent=4)
|
|
||||||
|
|
||||||
def render_jsonp(self, callback):
|
|
||||||
assert CALLBACK_RE.match(callback), "Invalid callback"
|
|
||||||
return "{}({});".format(callback, self.render_json())
|
|
||||||
|
|
||||||
def render_xml(self):
|
|
||||||
text_attrs = ['largeImageUrl', 'musicBrainzId', 'smallImageUrl', 'mediumImageUrl', 'lastFmUrl', 'biography',
|
|
||||||
'folder']
|
|
||||||
selftext_attrs = ['value']
|
|
||||||
# These attributes will be placed in <hello>{{ value }}</hello> tags instead of hello="{{ value }}" on parent
|
|
||||||
doc = BeautifulSoup('', features='lxml-xml')
|
|
||||||
root = doc.new_tag("subsonic-response", xmlns="http://subsonic.org/restapi",
|
|
||||||
status=self.status,
|
|
||||||
version=self.version)
|
|
||||||
doc.append(root)
|
|
||||||
|
|
||||||
def _render_xml(node, parent):
|
|
||||||
"""
|
|
||||||
For every key in the node dict, the parent gets a new child tag with name == key
|
|
||||||
If the value is a dict, it becomes the new tag's attrs
|
|
||||||
If the value is a list, the parent gets many new tags with each dict as attrs
|
|
||||||
If the value is str int etc, parent gets attrs
|
|
||||||
"""
|
|
||||||
for key, value in node.items():
|
|
||||||
if type(value) in (dict, defaultdict):
|
|
||||||
tag = doc.new_tag(key)
|
|
||||||
parent.append(tag)
|
|
||||||
tag.attrs.update(value)
|
|
||||||
elif type(value) is list:
|
|
||||||
for item in value:
|
|
||||||
tag = doc.new_tag(key)
|
|
||||||
parent.append(tag)
|
|
||||||
_render_xml(item, tag)
|
|
||||||
else:
|
|
||||||
if key in text_attrs:
|
|
||||||
tag = doc.new_tag(key)
|
|
||||||
parent.append(tag)
|
|
||||||
tag.append(str(value))
|
|
||||||
elif key in selftext_attrs:
|
|
||||||
parent.append(str(value))
|
|
||||||
else:
|
|
||||||
parent.attrs[key] = value
|
|
||||||
_render_xml(self.data, root)
|
|
||||||
return doc.prettify()
|
|
|
@ -2,7 +2,8 @@ import os
|
||||||
import logging
|
import logging
|
||||||
import cherrypy
|
import cherrypy
|
||||||
from sqlite3 import DatabaseError
|
from sqlite3 import DatabaseError
|
||||||
from pysonic.api import PysonicSubsonicApi
|
from pysonic.api import PysonicApi
|
||||||
|
from pysonic.library import PysonicLibrary
|
||||||
from pysonic.database import PysonicDatabase, DuplicateRootException
|
from pysonic.database import PysonicDatabase, DuplicateRootException
|
||||||
|
|
||||||
|
|
||||||
|
@ -23,8 +24,8 @@ def main():
|
||||||
group = parser.add_argument_group("app options")
|
group = parser.add_argument_group("app options")
|
||||||
group.add_argument("--skip-transcode", action="store_true", help="instead of trancoding mp3s, send as-is")
|
group.add_argument("--skip-transcode", action="store_true", help="instead of trancoding mp3s, send as-is")
|
||||||
group.add_argument("--no-rescan", action="store_true", help="don't perform simple scan on startup")
|
group.add_argument("--no-rescan", action="store_true", help="don't perform simple scan on startup")
|
||||||
# group.add_argument("--deep-rescan", action="store_true", help="perform deep scan (read id3 etc)")
|
group.add_argument("--deep-rescap", action="store_true", help="perform deep scan (read id3 etc)")
|
||||||
# group.add_argument("--enable-prune", action="store_true", help="enable removal of media not found on disk")
|
group.add_argument("--enable-prune", action="store_true", help="enable removal of media not found on disk")
|
||||||
group.add_argument("--max-bitrate", type=int, default=320, help="maximum send bitrate")
|
group.add_argument("--max-bitrate", type=int, default=320, help="maximum send bitrate")
|
||||||
group.add_argument("--enable-cors", action="store_true", help="add response headers to allow cors")
|
group.add_argument("--enable-cors", action="store_true", help="add response headers to allow cors")
|
||||||
|
|
||||||
|
@ -34,14 +35,14 @@ def main():
|
||||||
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
|
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
|
||||||
|
|
||||||
db = PysonicDatabase(path=args.database_path)
|
db = PysonicDatabase(path=args.database_path)
|
||||||
|
library = PysonicLibrary(db)
|
||||||
for dirname in args.dirs:
|
for dirname in args.dirs:
|
||||||
dirname = os.path.abspath(dirname)
|
assert os.path.exists(dirname) and dirname.startswith("/"), "--dirs must be absolute paths and exist!"
|
||||||
assert os.path.exists(dirname), "--dirs must be paths that exist"
|
|
||||||
try:
|
try:
|
||||||
db.add_root(dirname)
|
library.add_root_dir(dirname)
|
||||||
except DuplicateRootException:
|
except DuplicateRootException:
|
||||||
pass
|
pass
|
||||||
db.update()
|
library.update()
|
||||||
|
|
||||||
for username, password in args.user:
|
for username, password in args.user:
|
||||||
try:
|
try:
|
||||||
|
@ -53,12 +54,13 @@ def main():
|
||||||
# logging.warning("Artists: {}".format([i["name"] for i in library.get_artists()]))
|
# logging.warning("Artists: {}".format([i["name"] for i in library.get_artists()]))
|
||||||
# logging.warning("Albums: {}".format(len(library.get_albums())))
|
# logging.warning("Albums: {}".format(len(library.get_albums())))
|
||||||
|
|
||||||
api = PysonicSubsonicApi(db, args)
|
api = PysonicApi(db, library, args)
|
||||||
api_config = {}
|
api_config = {}
|
||||||
if args.disable_auth:
|
if args.disable_auth:
|
||||||
logging.warning("starting up with auth disabled")
|
logging.warning("starting up with auth disabled")
|
||||||
else:
|
else:
|
||||||
def validate_password(realm, username, password):
|
def validate_password(realm, username, password):
|
||||||
|
print("I JUST VALIDATED {}:{} ({})".format(username, password, realm))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
api_config.update({'tools.auth_basic.on': True,
|
api_config.update({'tools.auth_basic.on': True,
|
||||||
|
|
|
@ -1,28 +1,16 @@
|
||||||
import os
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import logging
|
import logging
|
||||||
from hashlib import sha512
|
from hashlib import sha512
|
||||||
from time import time
|
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from collections import Iterable
|
|
||||||
|
|
||||||
|
|
||||||
from pysonic.scanner import PysonicFilesystemScanner
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("database")
|
|
||||||
|
|
||||||
|
|
||||||
LETTER_GROUPS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
|
|
||||||
"u", "v", "w", "xyz", "0123456789"]
|
|
||||||
|
|
||||||
|
|
||||||
|
logging = logging.getLogger("database")
|
||||||
keys_in_table = ["title", "album", "artist", "type", "size"]
|
keys_in_table = ["title", "album", "artist", "type", "size"]
|
||||||
|
|
||||||
|
|
||||||
def dict_factory(c, row):
|
def dict_factory(cursor, row):
|
||||||
d = {}
|
d = {}
|
||||||
for idx, col in enumerate(c.description):
|
for idx, col in enumerate(cursor.description):
|
||||||
d[col[0]] = row[idx]
|
d[col[0]] = row[idx]
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@ -35,21 +23,14 @@ class DuplicateRootException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def hash_password(unicode_string):
|
def readcursor(func):
|
||||||
return sha512(unicode_string.encode('UTF-8')).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def cursor(func):
|
|
||||||
"""
|
"""
|
||||||
Provides a cursor to the wrapped method as the first arg.
|
Provides a cursor to the wrapped method as the first arg
|
||||||
"""
|
"""
|
||||||
def wrapped(*args, **kwargs):
|
def wrapped(*args, **kwargs):
|
||||||
self = args[0]
|
self = args[0]
|
||||||
if len(args) >= 2 and isinstance(args[1], sqlite3.Cursor):
|
with closing(self.db.cursor()) as cursor:
|
||||||
return func(*args, **kwargs)
|
return func(*[self, cursor], *args[1:], **kwargs)
|
||||||
else:
|
|
||||||
with closing(self.db.cursor()) as c:
|
|
||||||
return func(self, c, *args[1:], **kwargs)
|
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
@ -60,20 +41,11 @@ class PysonicDatabase(object):
|
||||||
self.db = None
|
self.db = None
|
||||||
self.open()
|
self.open()
|
||||||
self.migrate()
|
self.migrate()
|
||||||
self.scanner = PysonicFilesystemScanner(self)
|
|
||||||
|
|
||||||
def open(self):
|
def open(self):
|
||||||
with open(self.path, "rb"): # sqlite doesn't give very descriptive permission errors, but this does
|
|
||||||
pass
|
|
||||||
self.db = sqlite3.connect(self.path, **self.sqlite_opts)
|
self.db = sqlite3.connect(self.path, **self.sqlite_opts)
|
||||||
self.db.row_factory = dict_factory
|
self.db.row_factory = dict_factory
|
||||||
|
|
||||||
def update(self):
|
|
||||||
"""
|
|
||||||
Start the library media scanner ands
|
|
||||||
"""
|
|
||||||
self.scanner.init_scan()
|
|
||||||
|
|
||||||
def migrate(self):
|
def migrate(self):
|
||||||
# Create db
|
# Create db
|
||||||
queries = ["""CREATE TABLE 'libraries' (
|
queries = ["""CREATE TABLE 'libraries' (
|
||||||
|
@ -102,8 +74,6 @@ class PysonicDatabase(object):
|
||||||
'dir' INTEGER,
|
'dir' INTEGER,
|
||||||
'name' TEXT,
|
'name' TEXT,
|
||||||
'added' INTEGER NOT NULL DEFAULT -1,
|
'added' INTEGER NOT NULL DEFAULT -1,
|
||||||
'played' INTEGER,
|
|
||||||
'plays' INTEGER NOT NULL DEFAULT 0,
|
|
||||||
UNIQUE (artistid, dir));""",
|
UNIQUE (artistid, dir));""",
|
||||||
"""CREATE TABLE 'songs' (
|
"""CREATE TABLE 'songs' (
|
||||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
@ -118,8 +88,7 @@ class PysonicDatabase(object):
|
||||||
'length' INTEGER,
|
'length' INTEGER,
|
||||||
'bitrate' INTEGER,
|
'bitrate' INTEGER,
|
||||||
'track' INTEGER,
|
'track' INTEGER,
|
||||||
'year' INTEGER,
|
'year' INTEGER
|
||||||
'plays' INTEGER NOT NULL DEFAULT 0
|
|
||||||
)""",
|
)""",
|
||||||
"""CREATE TABLE 'covers' (
|
"""CREATE TABLE 'covers' (
|
||||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
@ -137,59 +106,27 @@ class PysonicDatabase(object):
|
||||||
'userid' INTEGER,
|
'userid' INTEGER,
|
||||||
'songid' INTEGER,
|
'songid' INTEGER,
|
||||||
primary key ('userid', 'songid'))""",
|
primary key ('userid', 'songid'))""",
|
||||||
"""CREATE TABLE 'playlists' (
|
|
||||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
'ownerid' INTEGER,
|
|
||||||
'name' TEXT,
|
|
||||||
'public' BOOLEAN,
|
|
||||||
'created' INTEGER,
|
|
||||||
'changed' INTEGER,
|
|
||||||
'cover' INTEGER,
|
|
||||||
UNIQUE ('ownerid', 'name'))""",
|
|
||||||
"""CREATE TABLE 'playlist_entries' (
|
|
||||||
'playlistid' INTEGER,
|
|
||||||
'songid' INTEGER,
|
|
||||||
'order' FLOAT)""",
|
|
||||||
"""CREATE TABLE 'meta' (
|
"""CREATE TABLE 'meta' (
|
||||||
'key' TEXT PRIMARY KEY NOT NULL,
|
'key' TEXT PRIMARY KEY NOT NULL,
|
||||||
'value' TEXT);""",
|
'value' TEXT);""",
|
||||||
"""INSERT INTO meta VALUES ('db_version', '1');"""]
|
"""INSERT INTO meta VALUES ('db_version', '1');"""]
|
||||||
|
|
||||||
with closing(self.db.cursor()) as c:
|
with closing(self.db.cursor()) as cursor:
|
||||||
c.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta'")
|
cursor.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta'")
|
||||||
|
|
||||||
# Initialize DB
|
# Initialize DB
|
||||||
if len(c.fetchall()) == 0:
|
if len(cursor.fetchall()) == 0:
|
||||||
logger.warning("Initializing database")
|
logging.warning("Initializing database")
|
||||||
for query in queries:
|
for query in queries:
|
||||||
c.execute(query)
|
cursor.execute(query)
|
||||||
c.execute("COMMIT")
|
cursor.execute("COMMIT")
|
||||||
else:
|
else:
|
||||||
# Migrate if old db exists
|
# Migrate if old db exists
|
||||||
# c.execute("""UPDATE meta SET value=? WHERE key="db_version";""", (str(version), ))
|
# cursor.execute("""UPDATE meta SET value=? WHERE key="db_version";""", (str(version), ))
|
||||||
# logger.warning("db schema is version {}".format(version))
|
# logging.warning("db schema is version {}".format(version))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_artist_info(self, item_id):
|
def add_root(self, path, name="Library"):
|
||||||
#TODO
|
|
||||||
return {"biography": "placeholder biography",
|
|
||||||
"musicBrainzId": "playerholder",
|
|
||||||
"lastFmUrl": "https://www.last.fm/music/Placeholder",
|
|
||||||
"smallImageUrl": "",
|
|
||||||
"mediumImageUrl": "",
|
|
||||||
"largeImageUrl": "",
|
|
||||||
"similarArtists": []}
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def get_stats(self, c):
|
|
||||||
songs = c.execute("SELECT COUNT(*) as cnt FROM songs").fetchone()['cnt']
|
|
||||||
artists = c.execute("SELECT COUNT(*) as cnt FROM artists").fetchone()['cnt']
|
|
||||||
albums = c.execute("SELECT COUNT(*) as cnt FROM albums").fetchone()['cnt']
|
|
||||||
return dict(songs=songs, artists=artists, albums=albums)
|
|
||||||
|
|
||||||
# Music related
|
|
||||||
@cursor
|
|
||||||
def add_root(self, c, path, name="Library"):
|
|
||||||
"""
|
"""
|
||||||
Add a new library root. Returns the root ID or raises on collision
|
Add a new library root. Returns the root ID or raises on collision
|
||||||
:param path: normalized absolute path to add to the library
|
:param path: normalized absolute path to add to the library
|
||||||
|
@ -197,16 +134,17 @@ class PysonicDatabase(object):
|
||||||
:return: int
|
:return: int
|
||||||
:raises: sqlite3.IntegrityError
|
:raises: sqlite3.IntegrityError
|
||||||
"""
|
"""
|
||||||
path = os.path.abspath(os.path.normpath(path))
|
assert path.startswith("/")
|
||||||
try:
|
with closing(self.db.cursor()) as cursor:
|
||||||
c.execute("INSERT INTO libraries ('name', 'path') VALUES (?, ?)", (name, path, ))
|
try:
|
||||||
c.execute("COMMIT")
|
cursor.execute("INSERT INTO libraries ('name', 'path') VALUES (?, ?)", (name, path, ))
|
||||||
return c.lastrowid
|
cursor.execute("COMMIT")
|
||||||
except sqlite3.IntegrityError:
|
return cursor.lastrowid
|
||||||
raise DuplicateRootException("Root '{}' already exists".format(path))
|
except sqlite3.IntegrityError:
|
||||||
|
raise DuplicateRootException("Root '{}' already exists".format(path))
|
||||||
|
|
||||||
@cursor
|
@readcursor
|
||||||
def get_libraries(self, c, id=None):
|
def get_libraries(self, cursor, id=None):
|
||||||
libs = []
|
libs = []
|
||||||
q = "SELECT * FROM libraries"
|
q = "SELECT * FROM libraries"
|
||||||
params = []
|
params = []
|
||||||
|
@ -216,13 +154,13 @@ class PysonicDatabase(object):
|
||||||
params.append(id)
|
params.append(id)
|
||||||
if conditions:
|
if conditions:
|
||||||
q += " WHERE " + " AND ".join(conditions)
|
q += " WHERE " + " AND ".join(conditions)
|
||||||
c.execute(q, params)
|
cursor.execute(q, params)
|
||||||
for row in c:
|
for row in cursor:
|
||||||
libs.append(row)
|
libs.append(row)
|
||||||
return libs
|
return libs
|
||||||
|
|
||||||
@cursor
|
@readcursor
|
||||||
def get_artists(self, c, id=None, dirid=None, sortby="name", order=None, name_contains=None):
|
def get_artists(self, cursor, id=None, dirid=None, sortby=None, order=None):
|
||||||
assert order in ["asc", "desc", None]
|
assert order in ["asc", "desc", None]
|
||||||
artists = []
|
artists = []
|
||||||
q = "SELECT * FROM artists"
|
q = "SELECT * FROM artists"
|
||||||
|
@ -234,27 +172,24 @@ class PysonicDatabase(object):
|
||||||
if dirid:
|
if dirid:
|
||||||
conditions.append("dir = ?")
|
conditions.append("dir = ?")
|
||||||
params.append(dirid)
|
params.append(dirid)
|
||||||
if name_contains:
|
|
||||||
conditions.append("name LIKE ?")
|
|
||||||
params.append("%{}%".format(name_contains))
|
|
||||||
if conditions:
|
if conditions:
|
||||||
q += " WHERE " + " AND ".join(conditions)
|
q += " WHERE " + " AND ".join(conditions)
|
||||||
if sortby:
|
if sortby:
|
||||||
q += " ORDER BY {} {}".format(sortby, order.upper() if order else "ASC")
|
q += " ORDER BY {} {}".format(sortby, order.upper() if order else "ASC")
|
||||||
c.execute(q, params)
|
cursor.execute(q, params)
|
||||||
for row in c:
|
for row in cursor:
|
||||||
artists.append(row)
|
artists.append(row)
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
@cursor
|
@readcursor
|
||||||
def get_albums(self, c, id=None, artist=None, sortby="name", order=None, limit=None, name_contains=None):
|
def get_albums(self, cursor, id=None, artist=None, sortby=None, order=None, limit=None):
|
||||||
"""
|
"""
|
||||||
:param limit: int or tuple of int, int. translates directly to sql logic.
|
:param limit: int or tuple of int, int. translates directly to sql logic.
|
||||||
"""
|
"""
|
||||||
if order:
|
if order:
|
||||||
order = {"asc": "ASC", "desc": "DESC"}[order]
|
order = {"asc": "ASC", "desc": "DESC"}[order]
|
||||||
|
|
||||||
if sortby == "random":
|
if sortby and sortby == "random":
|
||||||
sortby = "RANDOM()"
|
sortby = "RANDOM()"
|
||||||
|
|
||||||
albums = []
|
albums = []
|
||||||
|
@ -270,6 +205,8 @@ class PysonicDatabase(object):
|
||||||
INNER JOIN dirs
|
INNER JOIN dirs
|
||||||
on dirs.id = alb.dir
|
on dirs.id = alb.dir
|
||||||
"""
|
"""
|
||||||
|
#q = "SELECT * FROM albums"
|
||||||
|
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
conditions = []
|
conditions = []
|
||||||
|
@ -279,9 +216,6 @@ class PysonicDatabase(object):
|
||||||
if artist:
|
if artist:
|
||||||
conditions.append("artistid = ?")
|
conditions.append("artistid = ?")
|
||||||
params.append(artist)
|
params.append(artist)
|
||||||
if name_contains:
|
|
||||||
conditions.append("alb.name LIKE ?")
|
|
||||||
params.append("%{}%".format(name_contains))
|
|
||||||
if conditions:
|
if conditions:
|
||||||
q += " WHERE " + " AND ".join(conditions)
|
q += " WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
|
@ -294,19 +228,19 @@ class PysonicDatabase(object):
|
||||||
q += " LIMIT {}".format(limit) if isinstance(limit, int) \
|
q += " LIMIT {}".format(limit) if isinstance(limit, int) \
|
||||||
else " LIMIT {}, {}".format(*limit)
|
else " LIMIT {}, {}".format(*limit)
|
||||||
|
|
||||||
c.execute(q, params)
|
cursor.execute(q, params)
|
||||||
for row in c:
|
for row in cursor:
|
||||||
albums.append(row)
|
albums.append(row)
|
||||||
return albums
|
return albums
|
||||||
|
|
||||||
@cursor
|
@readcursor
|
||||||
def get_songs(self, c, id=None, genre=None, sortby="title", order=None, limit=None, title_contains=None):
|
def get_songs(self, cursor, id=None, genre=None, sortby=None, order=None, limit=None):
|
||||||
# TODO make this query massively uglier by joining albums and artists so that artistid etc can be a filter
|
# TODO make this query massively uglier by joining albums and artists so that artistid etc can be a filter
|
||||||
# or maybe lookup those IDs in the library layer?
|
# or maybe lookup those IDs in the library layer?
|
||||||
if order:
|
if order:
|
||||||
order = {"asc": "ASC", "desc": "DESC"}[order]
|
order = {"asc": "ASC", "desc": "DESC"}[order]
|
||||||
|
|
||||||
if sortby == "random":
|
if sortby and sortby == "random":
|
||||||
sortby = "RANDOM()"
|
sortby = "RANDOM()"
|
||||||
|
|
||||||
songs = []
|
songs = []
|
||||||
|
@ -314,19 +248,13 @@ class PysonicDatabase(object):
|
||||||
q = """
|
q = """
|
||||||
SELECT
|
SELECT
|
||||||
s.*,
|
s.*,
|
||||||
lib.path as root,
|
|
||||||
alb.name as albumname,
|
alb.name as albumname,
|
||||||
alb.coverid as albumcoverid,
|
alb.coverid as albumcoverid,
|
||||||
art.name as artistname,
|
art.name as artistname,
|
||||||
g.name as genrename,
|
g.name as genrename
|
||||||
albdir.id as albumdir
|
|
||||||
FROM songs as s
|
FROM songs as s
|
||||||
INNER JOIN libraries as lib
|
|
||||||
on s.library == lib.id
|
|
||||||
INNER JOIN albums as alb
|
INNER JOIN albums as alb
|
||||||
on s.albumid == alb.id
|
on s.albumid == alb.id
|
||||||
INNER JOIN dirs as albdir
|
|
||||||
on albdir.id = alb.dir
|
|
||||||
INNER JOIN artists as art
|
INNER JOIN artists as art
|
||||||
on alb.artistid = art.id
|
on alb.artistid = art.id
|
||||||
LEFT JOIN genres as g
|
LEFT JOIN genres as g
|
||||||
|
@ -336,18 +264,12 @@ class PysonicDatabase(object):
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
conditions = []
|
conditions = []
|
||||||
if id and isinstance(id, int):
|
if id:
|
||||||
conditions.append("s.id = ?")
|
conditions.append("s.id = ?")
|
||||||
params.append(id)
|
params.append(id)
|
||||||
elif id and isinstance(id, Iterable):
|
|
||||||
conditions.append("s.id IN ({})".format(",".join("?" * len(id))))
|
|
||||||
params += id
|
|
||||||
if genre:
|
if genre:
|
||||||
conditions.append("g.name = ?")
|
conditions.append("g.name = ?")
|
||||||
params.append(genre)
|
params.append(genre)
|
||||||
if title_contains:
|
|
||||||
conditions.append("s.title LIKE ?")
|
|
||||||
params.append("%{}%".format(title_contains))
|
|
||||||
if conditions:
|
if conditions:
|
||||||
q += " WHERE " + " AND ".join(conditions)
|
q += " WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
|
@ -359,13 +281,13 @@ class PysonicDatabase(object):
|
||||||
if limit:
|
if limit:
|
||||||
q += " LIMIT {}".format(limit) # TODO support limit pagination
|
q += " LIMIT {}".format(limit) # TODO support limit pagination
|
||||||
|
|
||||||
c.execute(q, params)
|
cursor.execute(q, params)
|
||||||
for row in c:
|
for row in cursor:
|
||||||
songs.append(row)
|
songs.append(row)
|
||||||
return songs
|
return songs
|
||||||
|
|
||||||
@cursor
|
@readcursor
|
||||||
def get_genres(self, c, genre_id=None):
|
def get_genres(self, cursor, genre_id=None):
|
||||||
genres = []
|
genres = []
|
||||||
q = "SELECT * FROM genres"
|
q = "SELECT * FROM genres"
|
||||||
params = []
|
params = []
|
||||||
|
@ -375,24 +297,34 @@ class PysonicDatabase(object):
|
||||||
params.append(genre_id)
|
params.append(genre_id)
|
||||||
if conditions:
|
if conditions:
|
||||||
q += " WHERE " + " AND ".join(conditions)
|
q += " WHERE " + " AND ".join(conditions)
|
||||||
c.execute(q, params)
|
cursor.execute(q, params)
|
||||||
for row in c:
|
for row in cursor:
|
||||||
genres.append(row)
|
genres.append(row)
|
||||||
return genres
|
return genres
|
||||||
|
|
||||||
@cursor
|
|
||||||
def get_cover(self, c, cover_id):
|
|
||||||
|
|
||||||
|
|
||||||
|
# @readcursor
|
||||||
|
# def get_artist_by_dir(self, cursor, dirid):
|
||||||
|
# for row in cursor.execute("""
|
||||||
|
# SELECT artists.*
|
||||||
|
# FROM dirs
|
||||||
|
# INNER JOIN artists
|
||||||
|
# ON artists.dir = dirs.id
|
||||||
|
# WHERE dirs.id=?""", (dirid, )):
|
||||||
|
# return [row]
|
||||||
|
# return []
|
||||||
|
|
||||||
|
@readcursor
|
||||||
|
def get_cover(self, cursor, coverid):
|
||||||
cover = None
|
cover = None
|
||||||
for cover in c.execute("SELECT * FROM covers WHERE id = ?", (cover_id, )):
|
for cover in cursor.execute("SELECT * FROM covers WHERE id = ?", (coverid, )):
|
||||||
return cover
|
return cover
|
||||||
|
|
||||||
def get_cover_path(self, cover_id):
|
@readcursor
|
||||||
cover = self.get_cover(cover_id)
|
def get_musicdir(self, cursor, dirid):
|
||||||
library = self.get_libraries(cover["library"])[0]
|
|
||||||
return os.path.join(library["path"], cover["path"])
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def get_subsonic_musicdir(self, c, dirid):
|
|
||||||
"""
|
"""
|
||||||
The world is a harsh place.
|
The world is a harsh place.
|
||||||
Again, this bullshit exists only to serve subsonic clients. Given a directory ID it returns a dict containing:
|
Again, this bullshit exists only to serve subsonic clients. Given a directory ID it returns a dict containing:
|
||||||
|
@ -405,7 +337,7 @@ class PysonicDatabase(object):
|
||||||
"""
|
"""
|
||||||
# find directory
|
# find directory
|
||||||
dirinfo = None
|
dirinfo = None
|
||||||
for dirinfo in c.execute("SELECT * FROM dirs WHERE id = ?", (dirid, )):
|
for dirinfo in cursor.execute("SELECT * FROM dirs WHERE id = ?", (dirid, )):
|
||||||
pass
|
pass
|
||||||
assert dirinfo
|
assert dirinfo
|
||||||
|
|
||||||
|
@ -413,7 +345,7 @@ class PysonicDatabase(object):
|
||||||
|
|
||||||
# see if it matches the artists or albums table
|
# see if it matches the artists or albums table
|
||||||
artist = None
|
artist = None
|
||||||
for artist in c.execute("SELECT * FROM artists WHERE dir = ?", (dirid, )):
|
for artist in cursor.execute("SELECT * FROM artists WHERE dir = ?", (dirid, )):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# if artist:
|
# if artist:
|
||||||
|
@ -421,7 +353,7 @@ class PysonicDatabase(object):
|
||||||
if artist:
|
if artist:
|
||||||
ret = ("artist", dirinfo, artist)
|
ret = ("artist", dirinfo, artist)
|
||||||
children = []
|
children = []
|
||||||
for album in c.execute("SELECT * FROM albums WHERE artistid = ?", (artist["id"], )):
|
for album in cursor.execute("SELECT * FROM albums WHERE artistid = ?", (artist["id"], )):
|
||||||
children.append(("album", album))
|
children.append(("album", album))
|
||||||
ret[2]['children'] = children
|
ret[2]['children'] = children
|
||||||
return ret
|
return ret
|
||||||
|
@ -429,126 +361,46 @@ class PysonicDatabase(object):
|
||||||
# else if album:
|
# else if album:
|
||||||
# get child tracks
|
# get child tracks
|
||||||
album = None
|
album = None
|
||||||
for album in c.execute("SELECT * FROM albums WHERE dir = ?", (dirid, )):
|
for album in cursor.execute("SELECT * FROM albums WHERE dir = ?", (dirid, )):
|
||||||
pass
|
pass
|
||||||
if album:
|
if album:
|
||||||
ret = ("album", dirinfo, album)
|
ret = ("album", dirinfo, album)
|
||||||
|
|
||||||
artist_info = c.execute("SELECT * FROM artists WHERE id = ?", (album["artistid"], )).fetchall()[0]
|
artist_info = cursor.execute("SELECT * FROM artists WHERE id = ?", (album["artistid"], )).fetchall()[0]
|
||||||
|
|
||||||
children = []
|
children = []
|
||||||
for song in c.execute("SELECT * FROM songs WHERE albumid = ? ORDER BY track, title ASC;", (album["id"], )):
|
for song in cursor.execute("SELECT * FROM songs WHERE albumid = ?", (album["id"], )):
|
||||||
song["_artist"] = artist_info
|
song["_artist"] = artist_info
|
||||||
children.append(("song", song))
|
children.append(("song", song))
|
||||||
ret[2]['children'] = children
|
ret[2]['children'] = children
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
# Playlist related
|
|
||||||
@cursor
|
|
||||||
def add_playlist(self, c, ownerid, name, song_ids, public=False):
|
|
||||||
"""
|
|
||||||
Create a playlist
|
|
||||||
"""
|
|
||||||
now = time()
|
|
||||||
c.execute("INSERT INTO playlists (ownerid, name, public, created, changed) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
(ownerid, name, public, now, now))
|
|
||||||
plid = c.lastrowid
|
|
||||||
for song_id in song_ids:
|
|
||||||
self.add_to_playlist(c, plid, song_id)
|
|
||||||
c.execute("COMMIT")
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def add_to_playlist(self, c, playlist_id, song_id):
|
|
||||||
# TODO deal with order column
|
|
||||||
c.execute("INSERT INTO playlist_entries (playlistid, songid) VALUES (?, ?)", (playlist_id, song_id))
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def get_playlist(self, c, playlist_id):
|
|
||||||
return c.execute("SELECT * FROM playlists WHERE id=?", (playlist_id, )).fetchone()
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def get_playlist_songs(self, c, playlist_id):
|
|
||||||
songs = []
|
|
||||||
q = """
|
|
||||||
SELECT
|
|
||||||
s.*,
|
|
||||||
alb.name as albumname,
|
|
||||||
alb.coverid as albumcoverid,
|
|
||||||
art.name as artistname,
|
|
||||||
art.name as artistid,
|
|
||||||
g.name as genrename
|
|
||||||
FROM playlist_entries as pe
|
|
||||||
INNER JOIN songs as s
|
|
||||||
on pe.songid == s.id
|
|
||||||
INNER JOIN albums as alb
|
|
||||||
on s.albumid == alb.id
|
|
||||||
INNER JOIN artists as art
|
|
||||||
on alb.artistid = art.id
|
|
||||||
LEFT JOIN genres as g
|
|
||||||
on s.genre == g.id
|
|
||||||
WHERE pe.playlistid = ?
|
|
||||||
ORDER BY pe.'order' ASC;
|
|
||||||
"""
|
|
||||||
for row in c.execute(q, (playlist_id, )):
|
|
||||||
songs.append(row)
|
|
||||||
return songs
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def get_playlists(self, c, user_id):
|
|
||||||
playlists = []
|
|
||||||
for row in c.execute("SELECT * FROM playlists WHERE ownerid=? or public=1", (user_id, )):
|
|
||||||
playlists.append(row)
|
|
||||||
return playlists
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def remove_index_from_playlist(self, c, playlist_id, index):
|
|
||||||
c.execute("DELETE FROM playlist_entries WHERE playlistid=? LIMIT ?, 1", (playlist_id, index, ))
|
|
||||||
c.execute("COMMIT")
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def empty_playlist(self, c, playlist_id):
|
|
||||||
#TODO combine with delete_playlist
|
|
||||||
c.execute("DELETE FROM playlist_entries WHERE playlistid=?", (playlist_id, ))
|
|
||||||
c.execute("COMMIT")
|
|
||||||
|
|
||||||
@cursor
|
@readcursor
|
||||||
def delete_playlist(self, c, playlist_id):
|
def add_user(self, cursor, username, password, is_admin=False):
|
||||||
c.execute("DELETE FROM playlist_entries WHERE playlistid=?", (playlist_id, ))
|
cursor.execute("INSERT INTO users (username, password, admin) VALUES (?, ?, ?)",
|
||||||
c.execute("DELETE FROM playlists WHERE id=?", (playlist_id, ))
|
(username, self.hashit(password), is_admin))
|
||||||
c.execute("COMMIT")
|
cursor.execute("COMMIT")
|
||||||
|
|
||||||
@cursor
|
@readcursor
|
||||||
def update_album_played(self, c, album_id, last_played=None):
|
def update_user(self, cursor, username, password, is_admin=False):
|
||||||
c.execute("UPDATE albums SET played=? WHERE id=?", (last_played, album_id, ))
|
cursor.execute("UPDATE users SET password=?, admin=? WHERE username=?;",
|
||||||
c.execute("COMMIT")
|
(self.hashit(password), is_admin, username))
|
||||||
|
cursor.execute("COMMIT")
|
||||||
|
|
||||||
@cursor
|
@readcursor
|
||||||
def increment_album_plays(self, c, album_id):
|
def get_user(self, cursor, user):
|
||||||
c.execute("UPDATE albums SET plays = plays + 1 WHERE id=?", (album_id, ))
|
|
||||||
c.execute("COMMIT")
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def increment_track_plays(self, c, track_id):
|
|
||||||
c.execute("UPDATE songs SET plays = plays + 1 WHERE id=?", (track_id, ))
|
|
||||||
c.execute("COMMIT")
|
|
||||||
|
|
||||||
# User related
|
|
||||||
@cursor
|
|
||||||
def add_user(self, c, username, password, is_admin=False):
|
|
||||||
c.execute("INSERT INTO users (username, password, admin) VALUES (?, ?, ?)",
|
|
||||||
(username, hash_password(password), is_admin))
|
|
||||||
c.execute("COMMIT")
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def update_user(self, c, username, password, is_admin=False):
|
|
||||||
c.execute("UPDATE users SET password=?, admin=? WHERE username=?;",
|
|
||||||
(hash_password(password), is_admin, username))
|
|
||||||
c.execute("COMMIT")
|
|
||||||
|
|
||||||
@cursor
|
|
||||||
def get_user(self, c, user):
|
|
||||||
try:
|
try:
|
||||||
column = "id" if type(user) is int else "username"
|
column = "id" if type(user) is int else "username"
|
||||||
return c.execute("SELECT * FROM users WHERE {}=?;".format(column), (user, )).fetchall()[0]
|
return cursor.execute("SELECT * FROM users WHERE {}=?;".format(column), (user, )).fetchall()[0]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
raise NotFoundError("User doesn't exist")
|
raise NotFoundError("User doesn't exist")
|
||||||
|
|
||||||
|
def hashit(self, unicode_string):
|
||||||
|
return sha512(unicode_string.encode('UTF-8')).hexdigest()
|
||||||
|
|
|
@ -0,0 +1,159 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from pysonic.scanner import PysonicFilesystemScanner
|
||||||
|
from pysonic.types import MUSIC_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
LETTER_GROUPS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
|
||||||
|
"u", "v", "w", "xyz", "0123456789"]
|
||||||
|
|
||||||
|
|
||||||
|
logging = logging.getLogger("library")
|
||||||
|
|
||||||
|
|
||||||
|
def memoize(function):
|
||||||
|
memo = {}
|
||||||
|
|
||||||
|
def wrapper(*args):
|
||||||
|
if args in memo:
|
||||||
|
return memo[args]
|
||||||
|
else:
|
||||||
|
rv = function(*args)
|
||||||
|
memo[args] = rv
|
||||||
|
return rv
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class NoDataException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PysonicLibrary(object):
|
||||||
|
def __init__(self, database):
|
||||||
|
self.db = database
|
||||||
|
|
||||||
|
self.get_libraries = self.db.get_libraries
|
||||||
|
self.get_artists = self.db.get_artists
|
||||||
|
self.get_albums = self.db.get_albums
|
||||||
|
# self.get_song = self.db.get_song
|
||||||
|
# self.get_cover = self.db.get_cover
|
||||||
|
|
||||||
|
self.scanner = PysonicFilesystemScanner(self)
|
||||||
|
logging.info("library ready")
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
"""
|
||||||
|
Start the library media scanner ands
|
||||||
|
"""
|
||||||
|
self.scanner.init_scan()
|
||||||
|
|
||||||
|
def add_root_dir(self, path):
|
||||||
|
"""
|
||||||
|
The music library consists of a number of root dirs. This adds a new root
|
||||||
|
"""
|
||||||
|
path = os.path.abspath(os.path.normpath(path))
|
||||||
|
self.db.add_root(path)
|
||||||
|
|
||||||
|
# def get_artists(self, *args, **kwargs):
|
||||||
|
# artists = self.db.get_artists(*args, **kwargs)
|
||||||
|
# for item in artists:
|
||||||
|
# item["parent"] = item["libraryid"]
|
||||||
|
# return artists
|
||||||
|
|
||||||
|
# def get_albums(self, *args, **kwargs):
|
||||||
|
# albums = self.db.get_albums(*args, **kwargs)
|
||||||
|
# for item in albums:
|
||||||
|
# item["parent"] = item["artistid"]
|
||||||
|
# return albums
|
||||||
|
|
||||||
|
def get_artist_info(self, item_id):
|
||||||
|
#TODO
|
||||||
|
return {"biography": "placeholder biography",
|
||||||
|
"musicBrainzId": "playerholder",
|
||||||
|
"lastFmUrl": "https://www.last.fm/music/Placeholder",
|
||||||
|
"smallImageUrl": "",
|
||||||
|
"mediumImageUrl": "",
|
||||||
|
"largeImageUrl": "",
|
||||||
|
"similarArtists": []}
|
||||||
|
|
||||||
|
def get_cover(self, cover_id):
|
||||||
|
cover = self.db.get_cover(cover_id)
|
||||||
|
library = self.db.get_libraries(cover["library"])[0]
|
||||||
|
cover['_fullpath'] = os.path.join(library["path"], cover["path"])
|
||||||
|
return cover
|
||||||
|
|
||||||
|
def get_song(self, song_id):
|
||||||
|
song = self.db.get_songs(id=song_id)[0]
|
||||||
|
library = self.db.get_libraries(song["library"])[0]
|
||||||
|
song['_fullpath'] = os.path.join(library["path"], song["file"])
|
||||||
|
return song
|
||||||
|
|
||||||
|
# #@memoize
|
||||||
|
# def get_libraries(self):
|
||||||
|
# """
|
||||||
|
# Libraries are top-level nodes
|
||||||
|
# """
|
||||||
|
# return self.db.getnodes(-1)
|
||||||
|
|
||||||
|
# #@memoize
|
||||||
|
# def get_artists(self):
|
||||||
|
# # Assume artists are second level dirs
|
||||||
|
# return self.db.getnodes(*[item["id"] for item in self.get_libraries()])
|
||||||
|
|
||||||
|
# def get_dir(self, dirid):
|
||||||
|
# return self.db.getnode(dirid)
|
||||||
|
|
||||||
|
# def get_dir_children(self, dirid):
|
||||||
|
# return self.db.getnodes(dirid)
|
||||||
|
|
||||||
|
# #@memoize
|
||||||
|
# def get_albums(self):
|
||||||
|
# return self.db.getnodes(*[item["id"] for item in self.get_artists()])
|
||||||
|
|
||||||
|
# #@memoize
|
||||||
|
# def get_filepath(self, nodeid):
|
||||||
|
# parents = [self.db.getnode(nodeid)]
|
||||||
|
# while parents[-1]['parent'] != -1:
|
||||||
|
# parents.append(self.db.getnode(parents[-1]['parent']))
|
||||||
|
# root = parents.pop()
|
||||||
|
# parents.reverse()
|
||||||
|
# return os.path.join(root['metadata']['fspath'], *[i['name'] for i in parents])
|
||||||
|
|
||||||
|
# def get_file_metadata(self, nodeid):
|
||||||
|
# return self.db.get_metadata(nodeid)
|
||||||
|
|
||||||
|
# def get_artist_info(self, item_id):
|
||||||
|
# # artist = self.db.getnode(item_id)
|
||||||
|
# return {"biography": "placeholder biography",
|
||||||
|
# "musicBrainzId": "playerholder",
|
||||||
|
# "lastFmUrl": "https://www.last.fm/music/Placeholder",
|
||||||
|
# "smallImageUrl": "",
|
||||||
|
# "mediumImageUrl": "",
|
||||||
|
# "largeImageUrl": "",
|
||||||
|
# "similarArtists": []}
|
||||||
|
|
||||||
|
# def set_starred(self, username, node_id, starred):
|
||||||
|
# self.db.set_starred(self.db.get_user(username)["id"], node_id, starred)
|
||||||
|
|
||||||
|
# def get_stars(self, user, user_id):
|
||||||
|
# self.db.get_stars()
|
||||||
|
|
||||||
|
# def get_user(self, user):
|
||||||
|
# return self.db.get_user(user)
|
||||||
|
|
||||||
|
# def get_starred(self, username):
|
||||||
|
# return self.db.get_starred_items(self.db.get_user(username)["id"])
|
||||||
|
|
||||||
|
# def get_songs(self, limit=50, shuffle=True):
|
||||||
|
# return self.db.getnodes(types=MUSIC_TYPES, limit=limit, order="rand")
|
||||||
|
|
||||||
|
# def get_song(self, id=None):
|
||||||
|
# if id:
|
||||||
|
# return self.db.getnode(id)
|
||||||
|
# else:
|
||||||
|
# return self.db.getnodes(types=MUSIC_TYPES, limit=1, order="rand")
|
||||||
|
|
||||||
|
# def report_transcode(self, item_id, bitrate, num_bytes):
|
||||||
|
# assert type(bitrate) is int and bitrate > 0 and bitrate <= 320
|
||||||
|
# logging.info("Got transcode report of {} for item {} @ {}".format(num_bytes, item_id, bitrate))
|
||||||
|
# self.db.update_metadata(item_id, {"transcoded_{}_size".format(bitrate):int(num_bytes)})
|
|
@ -5,8 +5,7 @@ from contextlib import closing
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from time import time
|
from time import time
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from pysonic.types import MUSIC_TYPES, WAV_TYPES, MPX_TYPES, FLAC_TYPES, MUSIC_EXTENSIONS, IMAGE_EXTENSIONS, \
|
from pysonic.types import KNOWN_MIMES, MUSIC_TYPES, MPX_TYPES, FLAC_TYPES, WAV_TYPES, MUSIC_EXTENSIONS, IMAGE_EXTENSIONS, IMAGE_TYPES
|
||||||
TYPE_TO_EXTENSION, UNKNOWN_MIME
|
|
||||||
from mutagen.id3 import ID3
|
from mutagen.id3 import ID3
|
||||||
from mutagen import MutagenError
|
from mutagen import MutagenError
|
||||||
from mutagen.id3._util import ID3NoHeaderError
|
from mutagen.id3._util import ID3NoHeaderError
|
||||||
|
@ -18,14 +17,9 @@ logging = logging.getLogger("scanner")
|
||||||
RE_NUMBERS = re.compile(r'^([0-9]+)')
|
RE_NUMBERS = re.compile(r'^([0-9]+)')
|
||||||
|
|
||||||
|
|
||||||
def guess_format(fname):
|
|
||||||
ext = fname.split(".")[-1].lower()
|
|
||||||
return TYPE_TO_EXTENSION.get(ext, UNKNOWN_MIME)
|
|
||||||
|
|
||||||
|
|
||||||
class PysonicFilesystemScanner(object):
|
class PysonicFilesystemScanner(object):
|
||||||
def __init__(self, db):
|
def __init__(self, library):
|
||||||
self.db = db
|
self.library = library
|
||||||
|
|
||||||
def init_scan(self):
|
def init_scan(self):
|
||||||
self.scanner = Thread(target=self.rescan, daemon=True)
|
self.scanner = Thread(target=self.rescan, daemon=True)
|
||||||
|
@ -37,7 +31,7 @@ class PysonicFilesystemScanner(object):
|
||||||
"""
|
"""
|
||||||
start = time()
|
start = time()
|
||||||
logging.warning("Beginning library rescan")
|
logging.warning("Beginning library rescan")
|
||||||
for parent in self.db.get_libraries():
|
for parent in self.library.db.get_libraries():
|
||||||
logging.info("Scanning {}".format(parent["path"]))
|
logging.info("Scanning {}".format(parent["path"]))
|
||||||
self.scan_root(parent["id"], parent["path"])
|
self.scan_root(parent["id"], parent["path"])
|
||||||
logging.warning("Rescan complete in %ss", round(time() - start, 3))
|
logging.warning("Rescan complete in %ss", round(time() - start, 3))
|
||||||
|
@ -69,7 +63,7 @@ class PysonicFilesystemScanner(object):
|
||||||
:type path list
|
:type path list
|
||||||
"""
|
"""
|
||||||
assert path
|
assert path
|
||||||
# with closing(self.db.db.cursor()) as cursor:
|
# with closing(self.library.db.db.cursor()) as cursor:
|
||||||
parent_id = 0 # 0 indicates a top level item in the library
|
parent_id = 0 # 0 indicates a top level item in the library
|
||||||
for name in path:
|
for name in path:
|
||||||
parent_id = self.create_or_get_dbdir(cursor, pid, parent_id, name)
|
parent_id = self.create_or_get_dbdir(cursor, pid, parent_id, name)
|
||||||
|
@ -94,7 +88,6 @@ class PysonicFilesystemScanner(object):
|
||||||
- Files placed in an artist dir is an unhandled edge case TODO
|
- Files placed in an artist dir is an unhandled edge case TODO
|
||||||
- Any files with an image extension in an album dir will be assumed to be the cover regardless of naming
|
- Any files with an image extension in an album dir will be assumed to be the cover regardless of naming
|
||||||
- TODO ignore dotfiles/dirs
|
- TODO ignore dotfiles/dirs
|
||||||
TODO remove all file scanning / statting etc from paths where a db transaction is active (gather data then open)
|
|
||||||
:param pid: parent id
|
:param pid: parent id
|
||||||
:param root: library root path
|
:param root: library root path
|
||||||
:param path: scan location path, as a list of subdirs within the root
|
:param path: scan location path, as a list of subdirs within the root
|
||||||
|
@ -108,14 +101,12 @@ class PysonicFilesystemScanner(object):
|
||||||
if len(path) == 0:
|
if len(path) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info("In library %s scanning %s", pid, os.path.join(*path))
|
|
||||||
|
|
||||||
# Guess an album from the dir, if possible
|
# Guess an album from the dir, if possible
|
||||||
album = None
|
album = None
|
||||||
if len(path) > 1:
|
if len(path) > 1:
|
||||||
album = path[-1]
|
album = path[-1]
|
||||||
|
|
||||||
with closing(self.db.db.cursor()) as cursor:
|
with closing(self.library.db.db.cursor()) as cursor:
|
||||||
artist_id, artist_dirid = self.create_or_get_artist(cursor, pid, path[0])
|
artist_id, artist_dirid = self.create_or_get_artist(cursor, pid, path[0])
|
||||||
|
|
||||||
album_id = None
|
album_id = None
|
||||||
|
@ -153,14 +144,13 @@ class PysonicFilesystemScanner(object):
|
||||||
if not cursor.fetchall():
|
if not cursor.fetchall():
|
||||||
# We leave most fields blank now and return later
|
# We leave most fields blank now and return later
|
||||||
# TODO probably not here but track file sizes and mark them for rescan on change
|
# TODO probably not here but track file sizes and mark them for rescan on change
|
||||||
cursor.execute("INSERT INTO songs (library, albumid, file, size, title, format) "
|
cursor.execute("INSERT INTO songs (library, albumid, file, size, title) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
(pid,
|
(pid,
|
||||||
album_id,
|
album_id,
|
||||||
fpath,
|
fpath,
|
||||||
os.stat(os.path.join(root_dir, fpath)).st_size,
|
os.stat(os.path.join(root_dir, fpath)).st_size,
|
||||||
fname,
|
fname, ))
|
||||||
guess_format(fpath)))
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -204,7 +194,6 @@ class PysonicFilesystemScanner(object):
|
||||||
cursor.execute("INSERT INTO albums (artistid, dir, name, added) VALUES (?, ?, ?, ?)",
|
cursor.execute("INSERT INTO albums (artistid, dir, name, added) VALUES (?, ?, ?, ?)",
|
||||||
(artist_id, album_dirid, dirnames[-1], int(time())))
|
(artist_id, album_dirid, dirnames[-1], int(time())))
|
||||||
album_id = cursor.lastrowid
|
album_id = cursor.lastrowid
|
||||||
|
|
||||||
return album_id, album_dirid
|
return album_id, album_dirid
|
||||||
|
|
||||||
def split_path(self, path):
|
def split_path(self, path):
|
||||||
|
@ -232,9 +221,8 @@ class PysonicFilesystemScanner(object):
|
||||||
q += "WHERE lastscan = -1 "
|
q += "WHERE lastscan = -1 "
|
||||||
q += "ORDER BY albumid"
|
q += "ORDER BY albumid"
|
||||||
|
|
||||||
#TODO scraping ID3 etc from the media files can be parallelized
|
with closing(self.library.db.db.cursor()) as reader, \
|
||||||
with closing(self.db.db.cursor()) as reader, \
|
closing(self.library.db.db.cursor()) as writer:
|
||||||
closing(self.db.db.cursor()) as writer:
|
|
||||||
processed = 0 # commit batching counter
|
processed = 0 # commit batching counter
|
||||||
for row in reader.execute(q):
|
for row in reader.execute(q):
|
||||||
# Find meta, bail if the file was unreadable
|
# Find meta, bail if the file was unreadable
|
||||||
|
@ -262,8 +250,7 @@ class PysonicFilesystemScanner(object):
|
||||||
writer.execute("UPDATE albums SET name=? WHERE id=?", (meta["album"], row["albumid"]))
|
writer.execute("UPDATE albums SET name=? WHERE id=?", (meta["album"], row["albumid"]))
|
||||||
if "artist" in meta:
|
if "artist" in meta:
|
||||||
album = writer.execute("SELECT artistid FROM albums WHERE id=?", (row['albumid'], )).fetchone()
|
album = writer.execute("SELECT artistid FROM albums WHERE id=?", (row['albumid'], )).fetchone()
|
||||||
if album:
|
writer.execute("UPDATE artists SET name=? WHERE id=?", (meta["artist"], album["artistid"]))
|
||||||
writer.execute("UPDATE artists SET name=? WHERE id=?", (meta["artist"], album["artistid"]))
|
|
||||||
if "genre" in meta:
|
if "genre" in meta:
|
||||||
genre_name = meta["genre"].strip()
|
genre_name = meta["genre"].strip()
|
||||||
if genre_name:
|
if genre_name:
|
||||||
|
@ -291,7 +278,6 @@ class PysonicFilesystemScanner(object):
|
||||||
Scan the file for metadata.
|
Scan the file for metadata.
|
||||||
:param fpath: path to the file to scan
|
:param fpath: path to the file to scan
|
||||||
"""
|
"""
|
||||||
logging.info("getting metadata from %s", fpath)
|
|
||||||
ftype, extra = mimetypes.guess_type(fpath)
|
ftype, extra = mimetypes.guess_type(fpath)
|
||||||
|
|
||||||
if ftype in MUSIC_TYPES:
|
if ftype in MUSIC_TYPES:
|
||||||
|
@ -315,7 +301,6 @@ class PysonicFilesystemScanner(object):
|
||||||
logging.error("failed to read audio information: %s", m)
|
logging.error("failed to read audio information: %s", m)
|
||||||
return
|
return
|
||||||
|
|
||||||
# these fields are generic
|
|
||||||
try:
|
try:
|
||||||
meta["length"] = int(audio.info.length)
|
meta["length"] = int(audio.info.length)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
|
@ -326,59 +311,30 @@ class PysonicFilesystemScanner(object):
|
||||||
# meta["kbitrate"] = int(bitrate / 1024)
|
# meta["kbitrate"] = int(bitrate / 1024)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
# these fields are format-specific
|
meta["track"] = int(RE_NUMBERS.findall(''.join(audio['TRCK'].text))[0])
|
||||||
#TODO determine if having WAV_TYPES does anything at all
|
except (KeyError, IndexError):
|
||||||
if ftype in MPX_TYPES or ftype in WAV_TYPES:
|
pass
|
||||||
try:
|
try:
|
||||||
meta["track"] = int(RE_NUMBERS.findall(''.join(audio['TRCK'].text))[0])
|
meta["artist"] = ''.join(audio['TPE1'].text)
|
||||||
except (KeyError, IndexError):
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
meta["artist"] = ''.join(audio['TPE1'].text).strip()
|
meta["album"] = ''.join(audio['TALB'].text)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
meta["album"] = ''.join(audio['TALB'].text).strip()
|
meta["title"] = ''.join(audio['TIT2'].text)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
meta["title"] = ''.join(audio['TIT2'].text).strip()
|
meta["year"] = audio['TDRC'].text[0].year
|
||||||
except KeyError:
|
except (KeyError, IndexError):
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
meta["year"] = int(audio['TDRC'].text[0].year)
|
meta["genre"] = audio['TCON'].text[0]
|
||||||
except (KeyError, IndexError, ValueError):
|
except (KeyError, IndexError):
|
||||||
pass
|
pass
|
||||||
try:
|
logging.info("got all media info from %s", fpath)
|
||||||
meta["genre"] = audio['TCON'].text[0].strip()
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif ftype in FLAC_TYPES:
|
|
||||||
try:
|
|
||||||
meta["track"] = int(RE_NUMBERS.findall(audio["tracknumber"][0])[0])
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
meta["artist"] = audio["artist"][0].strip()
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
meta["album"] = audio["album"][0].strip()
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
meta["title"] = audio["title"][0].strip()
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
meta["year"] = int(audio["date"][0]) # TODO is this ever a full date?
|
|
||||||
except (KeyError, IndexError, ValueError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
meta["genre"] = audio["genre"][0].strip()
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return meta
|
return meta
|
||||||
|
|
|
@ -1,49 +1,16 @@
|
||||||
# known mimes
|
|
||||||
MIME_MPEG = "audio/mpeg"
|
|
||||||
|
|
||||||
MIME_FLAC = "audio/flac"
|
KNOWN_MIMES = ["audio/mpeg", "audio/flac", "audio/x-wav", "image/jpeg", "image/png"]
|
||||||
MIME_XFLAC = "audio/x-flac"
|
|
||||||
|
|
||||||
MIME_XWAV = "audio/x-wav"
|
MUSIC_TYPES = ["audio/mpeg", "audio/flac", "audio/x-wav"]
|
||||||
|
|
||||||
MIME_JPEG = "image/jpeg"
|
MPX_TYPES = ["audio/mpeg"]
|
||||||
MIME_PNG = "image/png"
|
|
||||||
MIME_GIF = "image/gif"
|
|
||||||
|
|
||||||
|
FLAC_TYPES = ["audio/flac"]
|
||||||
|
|
||||||
# groupings of similar files by mime
|
WAV_TYPES = ["audio/x-wav"]
|
||||||
KNOWN_MIMES = [MIME_MPEG, MIME_FLAC, MIME_XFLAC, MIME_XWAV, MIME_JPEG, MIME_PNG]
|
|
||||||
|
|
||||||
MUSIC_TYPES = [MIME_MPEG, MIME_FLAC, MIME_XFLAC, MIME_XWAV]
|
IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif"]
|
||||||
|
|
||||||
MPX_TYPES = [MIME_MPEG]
|
|
||||||
|
|
||||||
FLAC_TYPES = [MIME_FLAC, MIME_XFLAC]
|
|
||||||
|
|
||||||
WAV_TYPES = [MIME_XWAV]
|
|
||||||
|
|
||||||
IMAGE_TYPES = [MIME_JPEG, MIME_PNG, MIME_GIF]
|
|
||||||
|
|
||||||
IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif"]
|
IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif"]
|
||||||
|
|
||||||
MUSIC_EXTENSIONS = ["mp3", "flac", "wav"]
|
MUSIC_EXTENSIONS = ["mp3", "flac", "wav"]
|
||||||
|
|
||||||
TYPE_TO_EXTENSION = {
|
|
||||||
MIME_MPEG: "mp3",
|
|
||||||
MIME_FLAC: "flac",
|
|
||||||
MIME_XFLAC: "flac",
|
|
||||||
MIME_XWAV: "wav",
|
|
||||||
MIME_JPEG: "jpg",
|
|
||||||
MIME_PNG: "png",
|
|
||||||
}
|
|
||||||
|
|
||||||
EXTENSION_TO_TYPE = {
|
|
||||||
"mp3": MIME_MPEG,
|
|
||||||
"flac": MIME_FLAC,
|
|
||||||
"wav": MIME_XWAV,
|
|
||||||
"jpg": MIME_JPEG,
|
|
||||||
"png": MIME_PNG,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
UNKNOWN_MIME = None
|
|
||||||
|
|
|
@ -1,17 +1,12 @@
|
||||||
beautifulsoup4==4.11.1
|
beautifulsoup4==4.6.0
|
||||||
cheroot==8.6.0
|
bs4==0.0.1
|
||||||
CherryPy==18.6.1
|
cheroot==6.0.0
|
||||||
jaraco.classes==3.2.1
|
CherryPy==14.0.1
|
||||||
jaraco.collections==3.5.1
|
lxml==4.2.1
|
||||||
jaraco.context==4.1.1
|
more-itertools==4.1.0
|
||||||
jaraco.functools==3.5.0
|
|
||||||
jaraco.text==3.7.0
|
|
||||||
lxml==4.9.0
|
|
||||||
more-itertools==8.13.0
|
|
||||||
mutagen==1.40.0
|
mutagen==1.40.0
|
||||||
portend==3.1.0
|
portend==2.2
|
||||||
|
pysonic==0.0.1
|
||||||
pytz==2018.3
|
pytz==2018.3
|
||||||
six==1.11.0
|
six==1.11.0
|
||||||
soupsieve==2.3.2.post1
|
tempora==1.11
|
||||||
tempora==5.0.1
|
|
||||||
zc.lockfile==2.0
|
|
||||||
|
|
Loading…
Reference in New Issue