Compare commits

...

20 Commits

Author SHA1 Message Date
dave 3342a060ee Merge pull request 'allow maxBitRate=0 as some clients set it as the default' (#2) from dpedu/maxbitrate-default into master
Gitea/pysonic/pipeline/head Something is wrong with the build of this commit Details
Reviewed-on: #2
2023-01-09 23:12:10 -08:00
dave afbf71aa08 allow maxBitRate=0 as some clients set it as the default
Gitea/pysonic/pipeline/head Something is wrong with the build of this commit Details
2023-01-09 22:49:54 -08:00
dave 092c833c4f Merge pull request 'Make sqlite open errors clearer' (#1) from dpedu/db-debug into master
Gitea/pysonic/pipeline/head This commit looks good Details
Reviewed-on: #1
2022-12-24 14:41:44 -08:00
dave e5158cfdc7 make sqlite open errors clearer
Gitea/pysonic/pipeline/head This commit looks good Details
Gitea/pysonic/pipeline/pr-master This commit looks good Details
if filesystem permissions on the directory the sqlite database database file is in are such that the app cannot list or create files, sqlite gives a vague error:

```
sqlite3.OperationalError: unable to open database file
```

whereas python's open() will give a better hint ("permission denied"). So, we try opening the database file with python first.

also, add chmods to the startup scripts to avoid this issue in the future
2022-12-24 14:33:57 -08:00
dave f0b9074391 debug print db path
Gitea/pysonic/pipeline/head This commit looks good Details
2022-12-24 13:38:33 -08:00
dave 51551f2b27 use same tracknum parser for flac as mp3
Gitea/pysonic/pipeline/head This commit looks good Details
2022-06-02 16:12:25 -07:00
dave af0cfe029d strip extra whitespace in tags
Gitea/pysonic/pipeline/head This commit looks good Details
2022-06-02 15:42:29 -07:00
dave 2de8547ab7 fix tag picking for flac as flac uses different tag names
Gitea/pysonic/pipeline/head This commit looks good Details
2022-06-02 15:17:12 -07:00
dave 6106fa9aa5 add jenkinsfile
Gitea/pysonic/pipeline/head This commit looks good Details
2022-06-01 22:28:38 -07:00
dave 5225180994 configurable transcoder timeout 2021-03-09 21:26:42 -08:00
dave 5e0e541cf9 fix suffix field 2020-10-06 16:59:23 -07:00
dave 0d2f9a9587 post-refactor fixes 2020-10-05 23:30:01 -07:00
dave da53b4e153 dir fixes 2020-10-05 23:24:11 -07:00
dave c910de0eb0 refactor out library class 2020-10-05 23:13:11 -07:00
dave bfcb528ddf readcursor -> cursor 2020-10-05 22:41:01 -07:00
dave f3d888be35 faster search 2020-10-05 22:19:48 -07:00
dave 5a7fe3a013 playcount 2020-10-05 22:12:49 -07:00
dave 0c48fc013c dockerfile touchups for kube 2020-10-05 20:12:35 -07:00
dave 64f738c5f0 fix search 2020-10-05 20:11:58 -07:00
dave 33f17887c2 fix track ordering 2020-09-23 22:57:26 -07:00
13 changed files with 604 additions and 352 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
Library/
.git/
testenv/
linuxenv/

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
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"]

68
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,68 @@
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 Normal file
View File

@ -0,0 +1,19 @@
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 Normal file
View File

@ -0,0 +1,14 @@
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/

View File

@ -1,26 +1,34 @@
import os
import logging
import subprocess
from time import time
from threading import Thread
from pysonic.library import LETTER_GROUPS
from pysonic.types import MUSIC_TYPES
from pysonic.database import LETTER_GROUPS
from pysonic.types import MUSIC_TYPES, TYPE_TO_EXTENSION
from pysonic.apilib import formatresponse, ApiResponse
import cherrypy
logging = logging.getLogger("api")
TRANSCODE_TIMEOUT = int(os.environ.get("PYSONIC_ENCODE_TIMEOUT", 5 * 60))
def extension(mime):
r = TYPE_TO_EXTENSION.get(mime)
return r
class PysonicSubsonicApi(object):
def __init__(self, db, library, options):
def __init__(self, db, options):
self.db = db
self.library = library
self.options = options
@cherrypy.expose
@formatresponse
def index(self):
response = ApiResponse()
response.add_child("totals", **self.library.db.get_stats())
response.add_child("totals", **self.db.get_stats())
return response
@cherrypy.expose
@ -46,7 +54,7 @@ class PysonicSubsonicApi(object):
def getMusicFolders_view(self, **kwargs):
response = ApiResponse()
response.add_child("musicFolders")
for folder in self.library.get_libraries():
for folder in self.db.get_libraries():
response.add_child("musicFolder", _parent="musicFolders", id=folder["id"], name=folder["name"])
return response
@ -58,7 +66,7 @@ class PysonicSubsonicApi(object):
# TODO real lastmodified date
# TODO deal with ignoredArticles
response.add_child("indexes", lastModified="1502310831000", ignoredArticles="The El La Los Las Le Les")
artists = self.library.get_artists(sortby="name", order="asc")
artists = self.db.get_artists(sortby="name", order="asc")
for letter in LETTER_GROUPS:
index = response.add_child("index", _parent="indexes", name=letter.upper())
for artist in artists:
@ -83,7 +91,7 @@ class PysonicSubsonicApi(object):
qargs.update(limit=(offset, size))
albums = self.library.get_albums(**qargs)
albums = self.db.get_albums(**qargs)
response = ApiResponse()
@ -96,10 +104,10 @@ class PysonicSubsonicApi(object):
title=album["name"],
album=album["name"],
artist=album["artistname"],
coverArt=album["coverid"]
coverArt=album["coverid"],
playCount=album["plays"],
#year=TODO
# playCount="0"
# created="2016-05-08T05:31:31.000Z"/>)
#created="2016-05-08T05:31:31.000Z"/>)
)
response.add_child("album", _parent="albumList", **album_kw)
return response
@ -108,16 +116,26 @@ class PysonicSubsonicApi(object):
@formatresponse
def getMusicDirectory_view(self, id, **kwargs):
"""
List an artist dir
List either and artist or album dir
"""
dir_id = int(id)
dirtype, dirinfo, entity = self.library.db.get_subsonic_musicdir(dirid=dir_id)
dirtype, dirinfo, entity = self.db.get_subsonic_musicdir(dirid=dir_id)
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"]:
# omit not dirs and media in browser
# if not item["isdir"] and item["type"] not in MUSIC_TYPES:
@ -127,7 +145,7 @@ class PysonicSubsonicApi(object):
if childtype == "album":
moreargs.update(name=child["name"],
isDir="true", # TODO song files in artist dir
parent=entity["id"],
parent=entity["dir"],
id=child["dir"])
if child["coverid"]:
moreargs.update(coverArt=child["coverid"])
@ -136,18 +154,31 @@ class PysonicSubsonicApi(object):
# artist=artist["name"],
# coverArt=item["coverid"],
elif childtype == "song":
moreargs.update(name=child["title"],
moreargs.update(title=child["title"],
albumId=entity["dir"],
album=entity["name"],
artistId=child["_artist"]["dir"],
artist=child["_artist"]["name"],
contentType=child["format"],
id=child["id"],
duration=child["length"],
isDir="false",
parent=entity["dir"],
# title=xxx
track=child["track"],
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"]:
moreargs.update(coverArt=entity["coverid"])
# duration="230" size="8409237" suffix="mp3" track="2" year="2005"/>
response.add_child("child", _parent="directory",
size="4096",
type="music",
@ -158,10 +189,11 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
def stream_view(self, id, maxBitRate="256", **kwargs):
maxBitRate = int(maxBitRate)
assert maxBitRate >= 32 and maxBitRate <= 320
song = self.library.get_song(int(id))
fpath = song["_fullpath"]
maxBitRate = int(maxBitRate) or 256
if maxBitRate < 32 or maxBitRate > 320:
raise cherrypy.HTTPError(400, message=f"invalid maxBitRate: {maxBitRate}. Must be between 32 and 320.")
song = self.db.get_songs(id=int(id))[0]
fpath = os.path.join(song["root"], song["file"])
media_bitrate = song.get("bitrate") / 1024 if song.get("bitrate") else 320
to_bitrate = min(maxBitRate,
self.options.max_bitrate,
@ -208,14 +240,14 @@ class PysonicSubsonicApi(object):
if proc.returncode is None or proc.returncode == 0:
logging.warning("transcoded {} in {}s".format(id, int(time() - start)))
# if completed:
# self.library.report_transcode(id, to_bitrate, length)
# self.db.report_transcode(id, to_bitrate, length)
else:
logging.error("transcode of {} exited with code {} after {}s".format(id, proc.returncode,
int(time() - start)))
def stopit(proc):
try:
proc.wait(timeout=90)
proc.wait(timeout=TRANSCODE_TIMEOUT)
except subprocess.TimeoutExpired:
logging.warning("killing timed-out transcoder")
proc.kill()
@ -228,8 +260,30 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
def getCoverArt_view(self, id, **kwargs):
cover = self.library.get_cover(id)
fpath = cover["_fullpath"]
"""
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
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 = {
'jpg': 'image/jpeg',
'png': 'image/png',
@ -246,14 +300,14 @@ class PysonicSubsonicApi(object):
break
total += len(data)
yield data
logging.info("\nSent {} bytes for {}".format(total, fpath))
logging.info("sent {} bytes for {}".format(total, fpath))
return content()
getCoverArt_view._cp_config = {'response.stream': True}
@cherrypy.expose
@formatresponse
def getArtistInfo_view(self, id, includeNotPresent="true", **kwargs):
info = self.library.get_artist_info(id)
info = self.db.get_artist_info(id)
response = ApiResponse()
response.add_child("artistInfo")
response.set_attrs("artistInfo", **info)
@ -262,7 +316,7 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
@formatresponse
def getUser_view(self, username, **kwargs):
user = {} if self.options.disable_auth else self.library.db.get_user(cherrypy.request.login)
user = {} if self.options.disable_auth else self.db.get_user(cherrypy.request.login)
response = ApiResponse()
response.add_child("user",
username=user["username"],
@ -287,19 +341,19 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
@formatresponse
def star_view(self, id, **kwargs):
self.library.set_starred(cherrypy.request.login, int(id), starred=True)
self.db.set_starred(cherrypy.request.login, int(id), starred=True)
return ApiResponse()
@cherrypy.expose
@formatresponse
def unstar_view(self, id, **kwargs):
self.library.set_starred(cherrypy.request.login, int(id), starred=False)
self.db.set_starred(cherrypy.request.login, int(id), starred=False)
return ApiResponse()
@cherrypy.expose
@formatresponse
def getStarred_view(self, **kwargs):
children = self.library.get_starred(cherrypy.request.login)
children = self.db.get_starred(cherrypy.request.login)
response = ApiResponse()
response.add_child("starred")
for item in children:
@ -321,7 +375,7 @@ class PysonicSubsonicApi(object):
"""
response = ApiResponse()
response.add_child("randomSongs")
children = self.library.db.get_songs(limit=size, sortby="random")
children = self.db.get_songs(limit=size, sortby="random")
for song in children:
moreargs = {}
if song["format"]:
@ -335,8 +389,6 @@ class PysonicSubsonicApi(object):
if song["year"]:
moreargs.update(year=song["year"])
file_extension = song["file"].split(".")[-1]
response.add_child("song",
_parent="randomSongs",
title=song["title"],
@ -346,7 +398,7 @@ class PysonicSubsonicApi(object):
isDir="false",
parent=song["albumid"],
size=song["size"],
suffix=file_extension,
suffix=extension(song["format"]),
type="music",
**moreargs)
return response
@ -356,7 +408,7 @@ class PysonicSubsonicApi(object):
def getGenres_view(self, **kwargs):
response = ApiResponse()
response.add_child("genres")
for row in self.library.db.get_genres():
for row in self.db.get_genres():
response.add_child("genre", _parent="genres", value=row["name"], songCount=420, albumCount=69)
return response
@ -384,30 +436,60 @@ class PysonicSubsonicApi(object):
query = query.replace("*", "") # TODO handle this
artists = 0
for item in self.library.get_artists():
if query in item["name"].lower():
response.add_child("artist", _parent="searchResult2", id=item["id"], name=item["name"])
artists += 1
if artists >= artistCount:
break
for item in self.db.get_artists(name_contains=query):
response.add_child("artist", _parent="searchResult2", id=item["dir"], name=item["name"])
artists += 1
if artists >= artistCount:
break
# TODO make this more efficient
albums = 0
for item in self.library.get_artists():
if query in item["name"].lower():
response.add_child("album", _parent="searchResult2", **self.render_node(item, item["metadata"], {}, {}))
albums += 1
if albums >= albumCount:
break
for album in self.db.get_albums(name_contains=query):
response.add_child("album", _parent="searchResult2",
id=album["dir"],
parent=album["artistdir"],
isDir="true",
title=album["name"],
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
songs = 0
for item in self.library.get_songs(limit=9999999, shuffle=False):
if query in item["name"].lower():
response.add_child("song", _parent="searchResult2", **self.render_node(item, item["metadata"], {}, {}))
songs += 1
if songs > songCount:
break
for song in self.db.get_songs(title_contains=query):
response.add_child("song", _parent="searchResult2",
id=song["id"],
parent=song["albumdir"],
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"],
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
@ -419,11 +501,13 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
def savePlayQueue_view(self, id, current, position, **kwargs):
print("TODO save playqueue with items {} current {} position {}".format(id, current, position))
song = self.library.get_song(int(current))
self.library.db.update_album_played(song['albumid'], time())
self.library.db.increment_album_plays(song['albumid'])
print("TODO save playqueue with items {} current {} position {}".format(id, repr(current), repr(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
# id entries are strings!
@ -432,19 +516,19 @@ class PysonicSubsonicApi(object):
def createPlaylist_view(self, name, songId, **kwargs):
if type(songId) != list:
songId = [songId]
user = self.library.db.get_user(cherrypy.request.login)
self.library.db.add_playlist(user["id"], name, 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.library.db.get_user(cherrypy.request.login)
user = self.db.get_user(cherrypy.request.login)
response = ApiResponse()
response.add_child("playlists")
for playlist in self.library.db.get_playlists(user["id"]):
for playlist in self.db.get_playlists(user["id"]):
response.add_child("playlist",
_parent="playlists",
id=playlist["id"],
@ -455,7 +539,7 @@ class PysonicSubsonicApi(object):
duration=420,
# changed="2018-04-05T23:23:38.263Z"
# created="2018-04-05T23:23:38.252Z"
# coverArt="pl-1"
coverArt="pl-{}".format(playlist["id"])
)
return response
@ -463,9 +547,10 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
@formatresponse
def getPlaylist_view(self, id, **kwargs):
user = self.library.db.get_user(cherrypy.request.login)
plinfo, songs = self.library.get_playlist(int(id))
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"],
@ -489,11 +574,11 @@ class PysonicSubsonicApi(object):
coverArt=song["albumcoverid"],
size=song["size"],
contentType=song["format"],
# suffix="mp3"
suffix=extension(song["format"]),
duration=song["length"],
bitRate=song["bitrate"] / 1024,
bitRate=song["bitrate"] / 1024 if song["bitrate"] else None, #TODO macro for this sort of logic
path=song["file"],
playCount="1",
playCount=song["plays"],
# created="2015-06-09T15:26:01.000Z"
albumId=song["albumid"],
artistId=song["artistid"],
@ -503,15 +588,16 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
@formatresponse
def updatePlaylist_view(self, playlistId, songIndexToRemove=None, songIdToAdd=None, **kwargs):
user = self.library.db.get_user(cherrypy.request.login)
plinfo, songs = self.library.get_playlist(int(playlistId))
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.library.db.remove_index_from_playlist(playlistId, songIndexToRemove)
self.db.remove_index_from_playlist(playlistId, songIndexToRemove)
elif songIdToAdd:
self.library.db.add_to_playlist(playlistId, songIdToAdd)
self.db.add_to_playlist(playlistId, songIdToAdd)
#TODO there are more modification methods
return ApiResponse()
@ -519,9 +605,9 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
@formatresponse
def deletePlaylist_view(self, id, **kwargs):
user = self.library.db.get_user(cherrypy.request.login)
plinfo, _ = self.library.get_playlist(int(id))
user = self.db.get_user(cherrypy.request.login)
plinfo = self.db.get_playlist(int(id))
assert plinfo["ownerid"] == user["id"]
self.library.delete_playlist(plinfo["id"])
self.db.delete_playlist(plinfo["id"])
return ApiResponse()

View File

@ -3,7 +3,6 @@ import logging
import cherrypy
from sqlite3 import DatabaseError
from pysonic.api import PysonicSubsonicApi
from pysonic.library import PysonicLibrary
from pysonic.database import PysonicDatabase, DuplicateRootException
@ -24,8 +23,8 @@ def main():
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("--no-rescan", action="store_true", help="don't perform simple scan on startup")
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("--deep-rescan", 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("--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")
@ -35,14 +34,14 @@ def main():
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
db = PysonicDatabase(path=args.database_path)
library = PysonicLibrary(db)
for dirname in args.dirs:
assert os.path.exists(dirname) and dirname.startswith("/"), "--dirs must be absolute paths and exist!"
dirname = os.path.abspath(dirname)
assert os.path.exists(dirname), "--dirs must be paths that exist"
try:
library.add_root_dir(dirname)
db.add_root(dirname)
except DuplicateRootException:
pass
library.update()
db.update()
for username, password in args.user:
try:
@ -54,13 +53,12 @@ def main():
# logging.warning("Artists: {}".format([i["name"] for i in library.get_artists()]))
# logging.warning("Albums: {}".format(len(library.get_albums())))
api = PysonicSubsonicApi(db, library, args)
api = PysonicSubsonicApi(db, args)
api_config = {}
if args.disable_auth:
logging.warning("starting up with auth disabled")
else:
def validate_password(realm, username, password):
print("I JUST VALIDATED {}:{} ({})".format(username, password, realm))
return True
api_config.update({'tools.auth_basic.on': True,

View File

@ -1,3 +1,4 @@
import os
import sqlite3
import logging
from hashlib import sha512
@ -5,13 +6,23 @@ from time import time
from contextlib import closing
from collections import Iterable
logging = logging.getLogger("database")
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"]
keys_in_table = ["title", "album", "artist", "type", "size"]
def dict_factory(cursor, row):
def dict_factory(c, row):
d = {}
for idx, col in enumerate(cursor.description):
for idx, col in enumerate(c.description):
d[col[0]] = row[idx]
return d
@ -25,10 +36,10 @@ class DuplicateRootException(Exception):
def hash_password(unicode_string):
return sha512(unicode_string.encode('UTF-8')).hexdigest()
return sha512(unicode_string.encode('UTF-8')).hexdigest()
def readcursor(func):
def cursor(func):
"""
Provides a cursor to the wrapped method as the first arg.
"""
@ -37,8 +48,8 @@ def readcursor(func):
if len(args) >= 2 and isinstance(args[1], sqlite3.Cursor):
return func(*args, **kwargs)
else:
with closing(self.db.cursor()) as cursor:
return func(*[self, cursor], *args[1:], **kwargs)
with closing(self.db.cursor()) as c:
return func(self, c, *args[1:], **kwargs)
return wrapped
@ -49,11 +60,20 @@ class PysonicDatabase(object):
self.db = None
self.open()
self.migrate()
self.scanner = PysonicFilesystemScanner(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.row_factory = dict_factory
def update(self):
"""
Start the library media scanner ands
"""
self.scanner.init_scan()
def migrate(self):
# Create db
queries = ["""CREATE TABLE 'libraries' (
@ -98,7 +118,8 @@ class PysonicDatabase(object):
'length' INTEGER,
'bitrate' INTEGER,
'track' INTEGER,
'year' INTEGER
'year' INTEGER,
'plays' INTEGER NOT NULL DEFAULT 0
)""",
"""CREATE TABLE 'covers' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
@ -134,31 +155,41 @@ class PysonicDatabase(object):
'value' TEXT);""",
"""INSERT INTO meta VALUES ('db_version', '1');"""]
with closing(self.db.cursor()) as cursor:
cursor.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta'")
with closing(self.db.cursor()) as c:
c.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta'")
# Initialize DB
if len(cursor.fetchall()) == 0:
logging.warning("Initializing database")
if len(c.fetchall()) == 0:
logger.warning("Initializing database")
for query in queries:
cursor.execute(query)
cursor.execute("COMMIT")
c.execute(query)
c.execute("COMMIT")
else:
# Migrate if old db exists
# cursor.execute("""UPDATE meta SET value=? WHERE key="db_version";""", (str(version), ))
# logging.warning("db schema is version {}".format(version))
# c.execute("""UPDATE meta SET value=? WHERE key="db_version";""", (str(version), ))
# logger.warning("db schema is version {}".format(version))
pass
@readcursor
def get_stats(self, cursor):
songs = cursor.execute("SELECT COUNT(*) as cnt FROM songs").fetchone()['cnt']
artists = cursor.execute("SELECT COUNT(*) as cnt FROM artists").fetchone()['cnt']
albums = cursor.execute("SELECT COUNT(*) as cnt FROM albums").fetchone()['cnt']
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": []}
@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
@readcursor
def add_root(self, cursor, path, name="Library"):
@cursor
def add_root(self, c, path, name="Library"):
"""
Add a new library root. Returns the root ID or raises on collision
:param path: normalized absolute path to add to the library
@ -166,16 +197,16 @@ class PysonicDatabase(object):
:return: int
:raises: sqlite3.IntegrityError
"""
assert path.startswith("/")
path = os.path.abspath(os.path.normpath(path))
try:
cursor.execute("INSERT INTO libraries ('name', 'path') VALUES (?, ?)", (name, path, ))
cursor.execute("COMMIT")
return cursor.lastrowid
c.execute("INSERT INTO libraries ('name', 'path') VALUES (?, ?)", (name, path, ))
c.execute("COMMIT")
return c.lastrowid
except sqlite3.IntegrityError:
raise DuplicateRootException("Root '{}' already exists".format(path))
@readcursor
def get_libraries(self, cursor, id=None):
@cursor
def get_libraries(self, c, id=None):
libs = []
q = "SELECT * FROM libraries"
params = []
@ -185,13 +216,13 @@ class PysonicDatabase(object):
params.append(id)
if conditions:
q += " WHERE " + " AND ".join(conditions)
cursor.execute(q, params)
for row in cursor:
c.execute(q, params)
for row in c:
libs.append(row)
return libs
@readcursor
def get_artists(self, cursor, id=None, dirid=None, sortby=None, order=None):
@cursor
def get_artists(self, c, id=None, dirid=None, sortby="name", order=None, name_contains=None):
assert order in ["asc", "desc", None]
artists = []
q = "SELECT * FROM artists"
@ -203,24 +234,27 @@ class PysonicDatabase(object):
if dirid:
conditions.append("dir = ?")
params.append(dirid)
if name_contains:
conditions.append("name LIKE ?")
params.append("%{}%".format(name_contains))
if conditions:
q += " WHERE " + " AND ".join(conditions)
if sortby:
q += " ORDER BY {} {}".format(sortby, order.upper() if order else "ASC")
cursor.execute(q, params)
for row in cursor:
c.execute(q, params)
for row in c:
artists.append(row)
return artists
@readcursor
def get_albums(self, cursor, id=None, artist=None, sortby=None, order=None, limit=None):
@cursor
def get_albums(self, c, id=None, artist=None, sortby="name", order=None, limit=None, name_contains=None):
"""
:param limit: int or tuple of int, int. translates directly to sql logic.
"""
if order:
order = {"asc": "ASC", "desc": "DESC"}[order]
if sortby and sortby == "random":
if sortby == "random":
sortby = "RANDOM()"
albums = []
@ -245,6 +279,9 @@ class PysonicDatabase(object):
if artist:
conditions.append("artistid = ?")
params.append(artist)
if name_contains:
conditions.append("alb.name LIKE ?")
params.append("%{}%".format(name_contains))
if conditions:
q += " WHERE " + " AND ".join(conditions)
@ -257,19 +294,19 @@ class PysonicDatabase(object):
q += " LIMIT {}".format(limit) if isinstance(limit, int) \
else " LIMIT {}, {}".format(*limit)
cursor.execute(q, params)
for row in cursor:
c.execute(q, params)
for row in c:
albums.append(row)
return albums
@readcursor
def get_songs(self, cursor, id=None, genre=None, sortby=None, order=None, limit=None):
@cursor
def get_songs(self, c, id=None, genre=None, sortby="title", order=None, limit=None, title_contains=None):
# 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?
if order:
order = {"asc": "ASC", "desc": "DESC"}[order]
if sortby and sortby == "random":
if sortby == "random":
sortby = "RANDOM()"
songs = []
@ -277,13 +314,19 @@ class PysonicDatabase(object):
q = """
SELECT
s.*,
lib.path as root,
alb.name as albumname,
alb.coverid as albumcoverid,
art.name as artistname,
g.name as genrename
g.name as genrename,
albdir.id as albumdir
FROM songs as s
INNER JOIN libraries as lib
on s.library == lib.id
INNER JOIN albums as alb
on s.albumid == alb.id
INNER JOIN dirs as albdir
on albdir.id = alb.dir
INNER JOIN artists as art
on alb.artistid = art.id
LEFT JOIN genres as g
@ -302,6 +345,9 @@ class PysonicDatabase(object):
if genre:
conditions.append("g.name = ?")
params.append(genre)
if title_contains:
conditions.append("s.title LIKE ?")
params.append("%{}%".format(title_contains))
if conditions:
q += " WHERE " + " AND ".join(conditions)
@ -313,13 +359,13 @@ class PysonicDatabase(object):
if limit:
q += " LIMIT {}".format(limit) # TODO support limit pagination
cursor.execute(q, params)
for row in cursor:
c.execute(q, params)
for row in c:
songs.append(row)
return songs
@readcursor
def get_genres(self, cursor, genre_id=None):
@cursor
def get_genres(self, c, genre_id=None):
genres = []
q = "SELECT * FROM genres"
params = []
@ -329,19 +375,24 @@ class PysonicDatabase(object):
params.append(genre_id)
if conditions:
q += " WHERE " + " AND ".join(conditions)
cursor.execute(q, params)
for row in cursor:
c.execute(q, params)
for row in c:
genres.append(row)
return genres
@readcursor
def get_cover(self, cursor, coverid):
@cursor
def get_cover(self, c, cover_id):
cover = None
for cover in cursor.execute("SELECT * FROM covers WHERE id = ?", (coverid, )):
for cover in c.execute("SELECT * FROM covers WHERE id = ?", (cover_id, )):
return cover
@readcursor
def get_subsonic_musicdir(self, cursor, dirid):
def get_cover_path(self, cover_id):
cover = self.get_cover(cover_id)
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.
Again, this bullshit exists only to serve subsonic clients. Given a directory ID it returns a dict containing:
@ -354,7 +405,7 @@ class PysonicDatabase(object):
"""
# find directory
dirinfo = None
for dirinfo in cursor.execute("SELECT * FROM dirs WHERE id = ?", (dirid, )):
for dirinfo in c.execute("SELECT * FROM dirs WHERE id = ?", (dirid, )):
pass
assert dirinfo
@ -362,7 +413,7 @@ class PysonicDatabase(object):
# see if it matches the artists or albums table
artist = None
for artist in cursor.execute("SELECT * FROM artists WHERE dir = ?", (dirid, )):
for artist in c.execute("SELECT * FROM artists WHERE dir = ?", (dirid, )):
pass
# if artist:
@ -370,7 +421,7 @@ class PysonicDatabase(object):
if artist:
ret = ("artist", dirinfo, artist)
children = []
for album in cursor.execute("SELECT * FROM albums WHERE artistid = ?", (artist["id"], )):
for album in c.execute("SELECT * FROM albums WHERE artistid = ?", (artist["id"], )):
children.append(("album", album))
ret[2]['children'] = children
return ret
@ -378,45 +429,45 @@ class PysonicDatabase(object):
# else if album:
# get child tracks
album = None
for album in cursor.execute("SELECT * FROM albums WHERE dir = ?", (dirid, )):
for album in c.execute("SELECT * FROM albums WHERE dir = ?", (dirid, )):
pass
if album:
ret = ("album", dirinfo, album)
artist_info = cursor.execute("SELECT * FROM artists WHERE id = ?", (album["artistid"], )).fetchall()[0]
artist_info = c.execute("SELECT * FROM artists WHERE id = ?", (album["artistid"], )).fetchall()[0]
children = []
for song in cursor.execute("SELECT * FROM songs WHERE albumid = ?", (album["id"], )):
for song in c.execute("SELECT * FROM songs WHERE albumid = ? ORDER BY track, title ASC;", (album["id"], )):
song["_artist"] = artist_info
children.append(("song", song))
ret[2]['children'] = children
return ret
# Playlist related
@readcursor
def add_playlist(self, cursor, ownerid, name, song_ids, public=False):
@cursor
def add_playlist(self, c, ownerid, name, song_ids, public=False):
"""
Create a playlist
"""
now = time()
cursor.execute("INSERT INTO playlists (ownerid, name, public, created, changed) VALUES (?, ?, ?, ?, ?)",
(ownerid, name, public, now, now))
plid = cursor.lastrowid
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(cursor, plid, song_id)
cursor.execute("COMMIT")
self.add_to_playlist(c, plid, song_id)
c.execute("COMMIT")
@readcursor
def add_to_playlist(self, cursor, playlist_id, song_id):
@cursor
def add_to_playlist(self, c, playlist_id, song_id):
# TODO deal with order column
cursor.execute("INSERT INTO playlist_entries (playlistid, songid) VALUES (?, ?)", (playlist_id, song_id))
c.execute("INSERT INTO playlist_entries (playlistid, songid) VALUES (?, ?)", (playlist_id, song_id))
@readcursor
def get_playlist(self, cursor, playlist_id):
return cursor.execute("SELECT * FROM playlists WHERE id=?", (playlist_id, )).fetchone()
@cursor
def get_playlist(self, c, playlist_id):
return c.execute("SELECT * FROM playlists WHERE id=?", (playlist_id, )).fetchone()
@readcursor
def get_playlist_songs(self, cursor, playlist_id):
@cursor
def get_playlist_songs(self, c, playlist_id):
songs = []
q = """
SELECT
@ -438,60 +489,66 @@ class PysonicDatabase(object):
WHERE pe.playlistid = ?
ORDER BY pe.'order' ASC;
"""
for row in cursor.execute(q, (playlist_id, )):
for row in c.execute(q, (playlist_id, )):
songs.append(row)
return songs
@readcursor
def get_playlists(self, cursor, user_id):
@cursor
def get_playlists(self, c, user_id):
playlists = []
for row in cursor.execute("SELECT * FROM playlists WHERE ownerid=? or public=1", (user_id, )):
for row in c.execute("SELECT * FROM playlists WHERE ownerid=? or public=1", (user_id, )):
playlists.append(row)
return playlists
@readcursor
def remove_index_from_playlist(self, cursor, playlist_id, index):
cursor.execute("DELETE FROM playlist_entries WHERE playlistid=? LIMIT ?, 1", (playlist_id, index, ))
cursor.execute("COMMIT")
@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")
@readcursor
def empty_playlist(self, cursor, playlist_id):
#TODO combine with # TODO combine with
cursor.execute("DELETE FROM playlist_entries WHERE playlistid=?", (playlist_id, ))
cursor.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")
@readcursor
def delete_playlist(self, cursor, playlist_id):
cursor.execute("DELETE FROM playlists WHERE id=?", (playlist_id, ))
cursor.execute("COMMIT")
@cursor
def delete_playlist(self, c, playlist_id):
c.execute("DELETE FROM playlist_entries WHERE playlistid=?", (playlist_id, ))
c.execute("DELETE FROM playlists WHERE id=?", (playlist_id, ))
c.execute("COMMIT")
@readcursor
def update_album_played(self, cursor, album_id, last_played=None):
cursor.execute("UPDATE albums SET played=? WHERE id=?", (last_played, album_id, ))
cursor.execute("COMMIT")
@cursor
def update_album_played(self, c, album_id, last_played=None):
c.execute("UPDATE albums SET played=? WHERE id=?", (last_played, album_id, ))
c.execute("COMMIT")
@readcursor
def increment_album_plays(self, cursor, album_id):
cursor.execute("UPDATE albums SET plays = plays + 1 WHERE id=?", (album_id, ))
cursor.execute("COMMIT")
@cursor
def increment_album_plays(self, c, album_id):
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
@readcursor
def add_user(self, cursor, username, password, is_admin=False):
cursor.execute("INSERT INTO users (username, password, admin) VALUES (?, ?, ?)",
(username, hash_password(password), is_admin))
cursor.execute("COMMIT")
@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")
@readcursor
def update_user(self, cursor, username, password, is_admin=False):
cursor.execute("UPDATE users SET password=?, admin=? WHERE username=?;",
(hash_password(password), is_admin, username))
cursor.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")
@readcursor
def get_user(self, cursor, user):
@cursor
def get_user(self, c, user):
try:
column = "id" if type(user) is int else "username"
return cursor.execute("SELECT * FROM users WHERE {}=?;".format(column), (user, )).fetchall()[0]
return c.execute("SELECT * FROM users WHERE {}=?;".format(column), (user, )).fetchall()[0]
except IndexError:
raise NotFoundError("User doesn't exist")

View File

@ -1,98 +0,0 @@
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
def get_playlist(self, playlist_id):
playlist_info = self.db.get_playlist(playlist_id)
songs = self.db.get_playlist_songs(playlist_id)
return (playlist_info, songs)
def delete_playlist(self, playlist_id):
self.db.empty_playlist(playlist_id)
self.db.delete_playlist(playlist_id)

View File

@ -5,7 +5,8 @@ from contextlib import closing
import mimetypes
from time import time
from threading import Thread
from pysonic.types import KNOWN_MIMES, MUSIC_TYPES, MPX_TYPES, FLAC_TYPES, WAV_TYPES, MUSIC_EXTENSIONS, IMAGE_EXTENSIONS, IMAGE_TYPES
from pysonic.types import MUSIC_TYPES, WAV_TYPES, MPX_TYPES, FLAC_TYPES, MUSIC_EXTENSIONS, IMAGE_EXTENSIONS, \
TYPE_TO_EXTENSION, UNKNOWN_MIME
from mutagen.id3 import ID3
from mutagen import MutagenError
from mutagen.id3._util import ID3NoHeaderError
@ -17,9 +18,14 @@ logging = logging.getLogger("scanner")
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):
def __init__(self, library):
self.library = library
def __init__(self, db):
self.db = db
def init_scan(self):
self.scanner = Thread(target=self.rescan, daemon=True)
@ -31,7 +37,7 @@ class PysonicFilesystemScanner(object):
"""
start = time()
logging.warning("Beginning library rescan")
for parent in self.library.db.get_libraries():
for parent in self.db.get_libraries():
logging.info("Scanning {}".format(parent["path"]))
self.scan_root(parent["id"], parent["path"])
logging.warning("Rescan complete in %ss", round(time() - start, 3))
@ -63,7 +69,7 @@ class PysonicFilesystemScanner(object):
:type path list
"""
assert path
# with closing(self.library.db.db.cursor()) as cursor:
# with closing(self.db.db.cursor()) as cursor:
parent_id = 0 # 0 indicates a top level item in the library
for name in path:
parent_id = self.create_or_get_dbdir(cursor, pid, parent_id, name)
@ -109,7 +115,7 @@ class PysonicFilesystemScanner(object):
if len(path) > 1:
album = path[-1]
with closing(self.library.db.db.cursor()) as cursor:
with closing(self.db.db.cursor()) as cursor:
artist_id, artist_dirid = self.create_or_get_artist(cursor, pid, path[0])
album_id = None
@ -147,13 +153,14 @@ class PysonicFilesystemScanner(object):
if not cursor.fetchall():
# We leave most fields blank now and return later
# 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) "
"VALUES (?, ?, ?, ?, ?)",
cursor.execute("INSERT INTO songs (library, albumid, file, size, title, format) "
"VALUES (?, ?, ?, ?, ?, ?)",
(pid,
album_id,
fpath,
os.stat(os.path.join(root_dir, fpath)).st_size,
fname, ))
fname,
guess_format(fpath)))
return True
return False
@ -226,8 +233,8 @@ class PysonicFilesystemScanner(object):
q += "ORDER BY albumid"
#TODO scraping ID3 etc from the media files can be parallelized
with closing(self.library.db.db.cursor()) as reader, \
closing(self.library.db.db.cursor()) as writer:
with closing(self.db.db.cursor()) as reader, \
closing(self.db.db.cursor()) as writer:
processed = 0 # commit batching counter
for row in reader.execute(q):
# Find meta, bail if the file was unreadable
@ -284,6 +291,7 @@ class PysonicFilesystemScanner(object):
Scan the file for metadata.
:param fpath: path to the file to scan
"""
logging.info("getting metadata from %s", fpath)
ftype, extra = mimetypes.guess_type(fpath)
if ftype in MUSIC_TYPES:
@ -307,6 +315,7 @@ class PysonicFilesystemScanner(object):
logging.error("failed to read audio information: %s", m)
return
# these fields are generic
try:
meta["length"] = int(audio.info.length)
except (ValueError, AttributeError):
@ -317,30 +326,59 @@ class PysonicFilesystemScanner(object):
# meta["kbitrate"] = int(bitrate / 1024)
except (ValueError, AttributeError):
pass
try:
meta["track"] = int(RE_NUMBERS.findall(''.join(audio['TRCK'].text))[0])
except (KeyError, IndexError):
pass
try:
meta["artist"] = ''.join(audio['TPE1'].text)
except KeyError:
pass
try:
meta["album"] = ''.join(audio['TALB'].text)
except KeyError:
pass
try:
meta["title"] = ''.join(audio['TIT2'].text)
except KeyError:
pass
try:
meta["year"] = audio['TDRC'].text[0].year
except (KeyError, IndexError):
pass
try:
meta["genre"] = audio['TCON'].text[0]
except (KeyError, IndexError):
pass
logging.info("got all media info from %s", fpath)
# these fields are format-specific
#TODO determine if having WAV_TYPES does anything at all
if ftype in MPX_TYPES or ftype in WAV_TYPES:
try:
meta["track"] = int(RE_NUMBERS.findall(''.join(audio['TRCK'].text))[0])
except (KeyError, IndexError):
pass
try:
meta["artist"] = ''.join(audio['TPE1'].text).strip()
except KeyError:
pass
try:
meta["album"] = ''.join(audio['TALB'].text).strip()
except KeyError:
pass
try:
meta["title"] = ''.join(audio['TIT2'].text).strip()
except KeyError:
pass
try:
meta["year"] = int(audio['TDRC'].text[0].year)
except (KeyError, IndexError, ValueError):
pass
try:
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

View File

@ -1,16 +1,49 @@
# known mimes
MIME_MPEG = "audio/mpeg"
KNOWN_MIMES = ["audio/mpeg", "audio/flac", "audio/x-wav", "image/jpeg", "image/png"]
MIME_FLAC = "audio/flac"
MIME_XFLAC = "audio/x-flac"
MUSIC_TYPES = ["audio/mpeg", "audio/flac", "audio/x-wav"]
MIME_XWAV = "audio/x-wav"
MPX_TYPES = ["audio/mpeg"]
MIME_JPEG = "image/jpeg"
MIME_PNG = "image/png"
MIME_GIF = "image/gif"
FLAC_TYPES = ["audio/flac"]
WAV_TYPES = ["audio/x-wav"]
# groupings of similar files by mime
KNOWN_MIMES = [MIME_MPEG, MIME_FLAC, MIME_XFLAC, MIME_XWAV, MIME_JPEG, MIME_PNG]
IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif"]
MUSIC_TYPES = [MIME_MPEG, MIME_FLAC, MIME_XFLAC, MIME_XWAV]
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"]
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

View File

@ -1,12 +1,17 @@
beautifulsoup4==4.6.0
bs4==0.0.1
cheroot==6.0.0
CherryPy==14.0.1
lxml==4.2.1
more-itertools==4.1.0
beautifulsoup4==4.11.1
cheroot==8.6.0
CherryPy==18.6.1
jaraco.classes==3.2.1
jaraco.collections==3.5.1
jaraco.context==4.1.1
jaraco.functools==3.5.0
jaraco.text==3.7.0
lxml==4.9.0
more-itertools==8.13.0
mutagen==1.40.0
portend==2.2
pysonic==0.0.1
portend==3.1.0
pytz==2018.3
six==1.11.0
tempora==1.11
soupsieve==2.3.2.post1
tempora==5.0.1
zc.lockfile==2.0

8
start.sh Normal file
View File

@ -0,0 +1,8 @@
#!/bin/bash
set -x
chmod 755 /db/.
chown -R app:app /db
exec sudo --preserve-env -Hu app pysonicd $@