Compare commits

..

3 Commits

Author SHA1 Message Date
dave 122addbfa9 some things 2018-09-21 13:49:30 -07:00
dave 30c641fbea podcast downloader features 2018-04-09 22:01:00 -07:00
dave c8a9ae89e1 basic podcast browsing apis 2018-04-07 16:26:27 -07:00
15 changed files with 792 additions and 684 deletions

View File

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

View File

@ -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"]

68
Jenkinsfile vendored
View File

@ -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'
}
}
}
}
}

View File

@ -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

View File

@ -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/

View File

@ -1,34 +1,26 @@
import os
import logging import logging
import subprocess import subprocess
from time import time from time import time
from threading import Thread from threading import Thread
from pysonic.database import LETTER_GROUPS from pysonic.library import LETTER_GROUPS
from pysonic.types import MUSIC_TYPES, TYPE_TO_EXTENSION from pysonic.types import MUSIC_TYPES
from pysonic.apilib import formatresponse, ApiResponse from pysonic.apilib import formatresponse, ApiResponse
import cherrypy import cherrypy
logging = logging.getLogger("api") 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): class PysonicSubsonicApi(object):
def __init__(self, db, options): def __init__(self, db, library, options):
self.db = db self.db = db
self.library = library
self.options = options self.options = options
@cherrypy.expose @cherrypy.expose
@formatresponse @formatresponse
def index(self): def index(self):
response = ApiResponse() response = ApiResponse()
response.add_child("totals", **self.db.get_stats()) response.add_child("totals", **self.library.db.get_stats())
return response return response
@cherrypy.expose @cherrypy.expose
@ -54,7 +46,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 +58,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:
@ -91,7 +83,7 @@ class PysonicSubsonicApi(object):
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 +96,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 +108,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_subsonic_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 +127,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 +136,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 +158,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(int(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 +208,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 +228,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 +246,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 +262,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"],
@ -329,7 +275,7 @@ class PysonicSubsonicApi(object):
playlistRole="true", playlistRole="true",
coverArtRole="false", coverArtRole="false",
commentRole="false", commentRole="false",
podcastRole="false", podcastRole="true",
streamRole="true", streamRole="true",
jukeboxRole="false", jukeboxRole="false",
shareRole="true", shareRole="true",
@ -341,19 +287,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 +321,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 +335,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 +346,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 +356,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 +384,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,13 +419,11 @@ 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 playqueue with items {} current {} position {}".format(id, current, position))
current = int(current)
song = self.db.get_songs(id=current)[0] song = self.library.get_song(int(current))
self.db.update_album_played(song['albumid'], time()) self.library.db.update_album_played(song['albumid'], time())
self.db.increment_album_plays(song['albumid']) self.library.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!
@ -516,19 +432,19 @@ class PysonicSubsonicApi(object):
def createPlaylist_view(self, name, songId, **kwargs): def createPlaylist_view(self, name, songId, **kwargs):
if type(songId) != list: if type(songId) != list:
songId = [songId] songId = [songId]
user = self.db.get_user(cherrypy.request.login) user = self.library.db.get_user(cherrypy.request.login)
self.db.add_playlist(user["id"], name, songId) self.library.db.add_playlist(user["id"], name, songId)
return ApiResponse() return ApiResponse()
#TODO the response should be the new playlist, check the cap #TODO the response should be the new playlist, check the cap
@cherrypy.expose @cherrypy.expose
@formatresponse @formatresponse
def getPlaylists_view(self, **kwargs): def getPlaylists_view(self, **kwargs):
user = self.db.get_user(cherrypy.request.login) user = self.library.db.get_user(cherrypy.request.login)
response = ApiResponse() response = ApiResponse()
response.add_child("playlists") response.add_child("playlists")
for playlist in self.db.get_playlists(user["id"]): for playlist in self.library.db.get_playlists(user["id"]):
response.add_child("playlist", response.add_child("playlist",
_parent="playlists", _parent="playlists",
id=playlist["id"], id=playlist["id"],
@ -539,7 +455,7 @@ class PysonicSubsonicApi(object):
duration=420, duration=420,
# changed="2018-04-05T23:23:38.263Z" # changed="2018-04-05T23:23:38.263Z"
# created="2018-04-05T23:23:38.252Z" # created="2018-04-05T23:23:38.252Z"
coverArt="pl-{}".format(playlist["id"]) # coverArt="pl-1"
) )
return response return response
@ -547,10 +463,9 @@ class PysonicSubsonicApi(object):
@cherrypy.expose @cherrypy.expose
@formatresponse @formatresponse
def getPlaylist_view(self, id, **kwargs): def getPlaylist_view(self, id, **kwargs):
id = int(id) user = self.library.db.get_user(cherrypy.request.login)
user = self.db.get_user(cherrypy.request.login) plinfo, songs = self.library.get_playlist(int(id))
plinfo = self.db.get_playlist(id)
songs = self.db.get_playlist_songs(id)
response = ApiResponse() response = ApiResponse()
response.add_child("playlist", response.add_child("playlist",
id=plinfo["id"], id=plinfo["id"],
@ -574,11 +489,11 @@ class PysonicSubsonicApi(object):
coverArt=song["albumcoverid"], coverArt=song["albumcoverid"],
size=song["size"], size=song["size"],
contentType=song["format"], contentType=song["format"],
suffix=extension(song["format"]), # suffix="mp3"
duration=song["length"], duration=song["length"],
bitRate=song["bitrate"] / 1024 if song["bitrate"] else None, #TODO macro for this sort of logic bitRate=song["bitrate"] / 1024,
path=song["file"], path=song["file"],
playCount=song["plays"], playCount="1",
# created="2015-06-09T15:26:01.000Z" # created="2015-06-09T15:26:01.000Z"
albumId=song["albumid"], albumId=song["albumid"],
artistId=song["artistid"], artistId=song["artistid"],
@ -588,16 +503,15 @@ class PysonicSubsonicApi(object):
@cherrypy.expose @cherrypy.expose
@formatresponse @formatresponse
def updatePlaylist_view(self, playlistId, songIndexToRemove=None, songIdToAdd=None, **kwargs): def updatePlaylist_view(self, playlistId, songIndexToRemove=None, songIdToAdd=None, **kwargs):
playlistId = int(playlistId) user = self.library.db.get_user(cherrypy.request.login)
user = self.db.get_user(cherrypy.request.login) plinfo, songs = self.library.get_playlist(int(playlistId))
plinfo = self.db.get_playlist(playlistId)
assert plinfo["ownerid"] == user["id"] assert plinfo["ownerid"] == user["id"]
if songIndexToRemove: if songIndexToRemove:
self.db.remove_index_from_playlist(playlistId, songIndexToRemove) self.library.db.remove_index_from_playlist(playlistId, songIndexToRemove)
elif songIdToAdd: elif songIdToAdd:
self.db.add_to_playlist(playlistId, songIdToAdd) self.library.db.add_to_playlist(playlistId, songIdToAdd)
#TODO there are more modification methods #TODO there are more modification methods
return ApiResponse() return ApiResponse()
@ -605,9 +519,59 @@ class PysonicSubsonicApi(object):
@cherrypy.expose @cherrypy.expose
@formatresponse @formatresponse
def deletePlaylist_view(self, id, **kwargs): def deletePlaylist_view(self, id, **kwargs):
user = self.db.get_user(cherrypy.request.login) user = self.library.db.get_user(cherrypy.request.login)
plinfo = self.db.get_playlist(int(id)) plinfo, _ = self.library.get_playlist(int(id))
assert plinfo["ownerid"] == user["id"] assert plinfo["ownerid"] == user["id"]
self.db.delete_playlist(plinfo["id"]) self.library.delete_playlist(plinfo["id"])
return ApiResponse()
#
#
#
#
# Podcast related endpoints
@cherrypy.expose
@formatresponse
def getPodcasts_view(self, id=None, includeEpisodes=True, **kwargs):
#TODO implement includeEpisodes properly
response = ApiResponse()
response.add_child("podcasts")
for podcast in self.library.get_podcasts():
node = response.add_child("channel",
_parent="podcasts",
id=podcast["id"],
title=podcast["title"],
url=podcast["url"],
description=podcast["description"],
# coverArt="pl-1"
# originalImageUrl="",
status="completed" # or "downloading"
)
if includeEpisodes:
for episode in self.library.db.get_podcast_episodes(podcast_id=podcast['id']):
response.add_child("episode",
_real_parent=node, # what the actual fuck does this do
isDir="false",
title=episode["title"],
id=episode["id"],
duration="420",
description=episode["description"],
status=episode["status"]
)
# publishDate="2018-03-29T01:00:00.000Z"/>
return response
@cherrypy.expose
@formatresponse
def createPodcastChannel_view(self, url, **kwargs):
self.library.db.add_postcast(url)
return ApiResponse()
@cherrypy.expose
@formatresponse
def refreshPodcasts_view(self, **kwargs):
return ApiResponse() return ApiResponse()

View File

@ -3,6 +3,7 @@ import logging
import cherrypy import cherrypy
from sqlite3 import DatabaseError from sqlite3 import DatabaseError
from pysonic.api import PysonicSubsonicApi from pysonic.api import PysonicSubsonicApi
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 = PysonicSubsonicApi(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,

View File

@ -1,28 +1,19 @@
import os
import sqlite3 import sqlite3
import logging import logging
from hashlib import sha512 from hashlib import sha512
from time import time from time import time
from contextlib import closing from contextlib import closing
from collections import Iterable from collections import Iterable
from pysonic.schema import table_quers
from pysonic.scanner import PysonicFilesystemScanner logging = logging.getLogger("database")
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"] 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
@ -36,10 +27,10 @@ class DuplicateRootException(Exception):
def hash_password(unicode_string): def hash_password(unicode_string):
return sha512(unicode_string.encode('UTF-8')).hexdigest() return sha512(unicode_string.encode('UTF-8')).hexdigest()
def cursor(func): def readcursor(func):
""" """
Provides a cursor to the wrapped method as the first arg. Provides a cursor to the wrapped method as the first arg.
""" """
@ -48,8 +39,8 @@ def cursor(func):
if len(args) >= 2 and isinstance(args[1], sqlite3.Cursor): if len(args) >= 2 and isinstance(args[1], sqlite3.Cursor):
return func(*args, **kwargs) return func(*args, **kwargs)
else: else:
with closing(self.db.cursor()) as c: with closing(self.db.cursor()) as cursor:
return func(self, c, *args[1:], **kwargs) return func(*[self, cursor], *args[1:], **kwargs)
return wrapped return wrapped
@ -60,136 +51,38 @@ 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' ( with closing(self.db.cursor()) as cursor:
'id' INTEGER PRIMARY KEY AUTOINCREMENT, cursor.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta'")
'name' TEXT,
'path' TEXT UNIQUE);""",
"""CREATE TABLE 'dirs' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'library' INTEGER,
'parent' INTEGER,
'name' TEXT,
UNIQUE(parent, name)
)""",
"""CREATE TABLE 'genres' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'name' TEXT UNIQUE)""",
"""CREATE TABLE 'artists' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'libraryid' INTEGER,
'dir' INTEGER UNIQUE,
'name' TEXT)""",
"""CREATE TABLE 'albums' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'artistid' INTEGER,
'coverid' INTEGER,
'dir' INTEGER,
'name' TEXT,
'added' INTEGER NOT NULL DEFAULT -1,
'played' INTEGER,
'plays' INTEGER NOT NULL DEFAULT 0,
UNIQUE (artistid, dir));""",
"""CREATE TABLE 'songs' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'library' INTEGER,
'albumid' BOOLEAN,
'genre' INTEGER DEFAULT NULL,
'file' TEXT UNIQUE, -- path from the library root
'size' INTEGER NOT NULL DEFAULT -1,
'title' TEXT NOT NULL,
'lastscan' INTEGER NOT NULL DEFAULT -1,
'format' TEXT,
'length' INTEGER,
'bitrate' INTEGER,
'track' INTEGER,
'year' INTEGER,
'plays' INTEGER NOT NULL DEFAULT 0
)""",
"""CREATE TABLE 'covers' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'library' INTEGER,
'type' TEXT,
'size' TEXT,
'path' TEXT UNIQUE);""",
"""CREATE TABLE 'users' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
'username' TEXT UNIQUE NOT NULL,
'password' TEXT NOT NULL,
'admin' BOOLEAN DEFAULT 0,
'email' TEXT)""",
"""CREATE TABLE 'stars' (
'userid' INTEGER,
'songid' INTEGER,
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' (
'key' TEXT PRIMARY KEY NOT NULL,
'value' TEXT);""",
"""INSERT INTO meta VALUES ('db_version', '1');"""]
with closing(self.db.cursor()) as c:
c.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 table_quers:
c.execute(query) print(query)
c.execute("COMMIT") cursor.execute(query)
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): @readcursor
#TODO def get_stats(self, cursor):
return {"biography": "placeholder biography", songs = cursor.execute("SELECT COUNT(*) as cnt FROM songs").fetchone()['cnt']
"musicBrainzId": "playerholder", artists = cursor.execute("SELECT COUNT(*) as cnt FROM artists").fetchone()['cnt']
"lastFmUrl": "https://www.last.fm/music/Placeholder", albums = cursor.execute("SELECT COUNT(*) as cnt FROM albums").fetchone()['cnt']
"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) return dict(songs=songs, artists=artists, albums=albums)
# Music related # Music related
@cursor @readcursor
def add_root(self, c, path, name="Library"): def add_root(self, cursor, 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 +90,16 @@ 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: try:
c.execute("INSERT INTO libraries ('name', 'path') VALUES (?, ?)", (name, path, )) cursor.execute("INSERT INTO libraries ('name', 'path') VALUES (?, ?)", (name, path, ))
c.execute("COMMIT") cursor.execute("COMMIT")
return c.lastrowid return cursor.lastrowid
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
raise DuplicateRootException("Root '{}' already exists".format(path)) 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 +109,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 +127,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 = []
@ -279,9 +169,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 +181,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 +201,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
@ -345,9 +226,6 @@ class PysonicDatabase(object):
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 +237,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 +253,19 @@ 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 @readcursor
def get_cover(self, c, cover_id): 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_subsonic_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 +278,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 +286,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 +294,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,45 +302,45 @@ 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 # Playlist related
@cursor @readcursor
def add_playlist(self, c, ownerid, name, song_ids, public=False): def add_playlist(self, cursor, ownerid, name, song_ids, public=False):
""" """
Create a playlist Create a playlist
""" """
now = time() now = time()
c.execute("INSERT INTO playlists (ownerid, name, public, created, changed) VALUES (?, ?, ?, ?, ?)", cursor.execute("INSERT INTO playlists (ownerid, name, public, created, changed) VALUES (?, ?, ?, ?, ?)",
(ownerid, name, public, now, now)) (ownerid, name, public, now, now))
plid = c.lastrowid plid = cursor.lastrowid
for song_id in song_ids: for song_id in song_ids:
self.add_to_playlist(c, plid, song_id) self.add_to_playlist(cursor, plid, song_id)
c.execute("COMMIT") cursor.execute("COMMIT")
@cursor @readcursor
def add_to_playlist(self, c, playlist_id, song_id): def add_to_playlist(self, cursor, playlist_id, song_id):
# TODO deal with order column # TODO deal with order column
c.execute("INSERT INTO playlist_entries (playlistid, songid) VALUES (?, ?)", (playlist_id, song_id)) cursor.execute("INSERT INTO playlist_entries (playlistid, songid) VALUES (?, ?)", (playlist_id, song_id))
@cursor @readcursor
def get_playlist(self, c, playlist_id): def get_playlist(self, cursor, playlist_id):
return c.execute("SELECT * FROM playlists WHERE id=?", (playlist_id, )).fetchone() return cursor.execute("SELECT * FROM playlists WHERE id=?", (playlist_id, )).fetchone()
@cursor @readcursor
def get_playlist_songs(self, c, playlist_id): def get_playlist_songs(self, cursor, playlist_id):
songs = [] songs = []
q = """ q = """
SELECT SELECT
@ -489,66 +362,133 @@ class PysonicDatabase(object):
WHERE pe.playlistid = ? WHERE pe.playlistid = ?
ORDER BY pe.'order' ASC; ORDER BY pe.'order' ASC;
""" """
for row in c.execute(q, (playlist_id, )): for row in cursor.execute(q, (playlist_id, )):
songs.append(row) songs.append(row)
return songs return songs
@cursor @readcursor
def get_playlists(self, c, user_id): def get_playlists(self, cursor, user_id):
playlists = [] playlists = []
for row in c.execute("SELECT * FROM playlists WHERE ownerid=? or public=1", (user_id, )): for row in cursor.execute("SELECT * FROM playlists WHERE ownerid=? or public=1", (user_id, )):
playlists.append(row) playlists.append(row)
return playlists return playlists
@cursor @readcursor
def remove_index_from_playlist(self, c, playlist_id, index): def remove_index_from_playlist(self, cursor, playlist_id, index):
c.execute("DELETE FROM playlist_entries WHERE playlistid=? LIMIT ?, 1", (playlist_id, index, )) cursor.execute("DELETE FROM playlist_entries WHERE playlistid=? LIMIT ?, 1", (playlist_id, index, ))
c.execute("COMMIT") cursor.execute("COMMIT")
@cursor @readcursor
def empty_playlist(self, c, playlist_id): def empty_playlist(self, cursor, playlist_id):
#TODO combine with delete_playlist #TODO combine with # TODO combine with
c.execute("DELETE FROM playlist_entries WHERE playlistid=?", (playlist_id, )) cursor.execute("DELETE FROM playlist_entries WHERE playlistid=?", (playlist_id, ))
c.execute("COMMIT") cursor.execute("COMMIT")
@cursor @readcursor
def delete_playlist(self, c, playlist_id): def delete_playlist(self, cursor, playlist_id):
c.execute("DELETE FROM playlist_entries WHERE playlistid=?", (playlist_id, )) cursor.execute("DELETE FROM playlists WHERE id=?", (playlist_id, ))
c.execute("DELETE FROM playlists WHERE id=?", (playlist_id, )) cursor.execute("COMMIT")
c.execute("COMMIT")
@cursor @readcursor
def update_album_played(self, c, album_id, last_played=None): def update_album_played(self, cursor, album_id, last_played=None):
c.execute("UPDATE albums SET played=? WHERE id=?", (last_played, album_id, )) cursor.execute("UPDATE albums SET played=? WHERE id=?", (last_played, album_id, ))
c.execute("COMMIT") cursor.execute("COMMIT")
@cursor @readcursor
def increment_album_plays(self, c, album_id): def increment_album_plays(self, cursor, album_id):
c.execute("UPDATE albums SET plays = plays + 1 WHERE id=?", (album_id, )) cursor.execute("UPDATE albums SET plays = plays + 1 WHERE id=?", (album_id, ))
c.execute("COMMIT") cursor.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 # User related
@cursor @readcursor
def add_user(self, c, username, password, is_admin=False): def add_user(self, cursor, username, password, is_admin=False):
c.execute("INSERT INTO users (username, password, admin) VALUES (?, ?, ?)", cursor.execute("INSERT INTO users (username, password, admin) VALUES (?, ?, ?)",
(username, hash_password(password), is_admin)) (username, hash_password(password), is_admin))
c.execute("COMMIT") cursor.execute("COMMIT")
@cursor @readcursor
def update_user(self, c, username, password, is_admin=False): def update_user(self, cursor, username, password, is_admin=False):
c.execute("UPDATE users SET password=?, admin=? WHERE username=?;", cursor.execute("UPDATE users SET password=?, admin=? WHERE username=?;",
(hash_password(password), is_admin, username)) (hash_password(password), is_admin, username))
c.execute("COMMIT") cursor.execute("COMMIT")
@cursor @readcursor
def get_user(self, c, user): def get_user(self, cursor, 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")
#
# Podcast related
@readcursor
def get_podcasts(self, cursor):
podcasts = []
for row in cursor.execute("SELECT * FROM podcasts ORDER BY title ASC"): #TODO order by newest episode
podcasts.append(row)
return podcasts
@readcursor
def add_postcast(self, cursor, url, title=None):
cursor.execute("INSERT INTO podcasts (title, url) VALUES (?, ?)",
(title if title else url, url, ))
cursor.execute("COMMIT")
@readcursor
def get_podcast_episodes(self, cursor, episode_id=None, podcast_id=None, title=None, status=None,
sortby="pe.date", order="desc", limit=None):
q = """
SELECT
pe.*
FROM podcast_episodes as pe
INNER JOIN podcasts as p
on pe.podcastid == p.id
"""
episodes = []
params = []
conditions = []
if episode_id:
conditions.append("pe.id = ?")
params.append(episode_id)
if podcast_id:
conditions.append("p.id = ?")
params.append(podcast_id)
if title:
conditions.append("pe.title = ?")
params.append(title)
if status:
conditions.append("pe.status = ?")
params.append(status)
if conditions:
q += " WHERE " + " AND ".join(conditions)
if sortby:
q += " ORDER BY {}".format(sortby)
if order:
q += " {}".format(order)
if order:
order = {"asc": "ASC", "desc": "DESC"}[order]
if limit:
q += " LIMIT {}".format(limit)
cursor.execute(q, params)
for row in cursor:
episodes.append(row)
return episodes
@readcursor
def add_podcast_episode(self, cursor, podcast_id, date, title, description, url, mime):
cursor.execute("INSERT INTO podcast_episodes (podcastid, date, title, description, url, format, status) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(podcast_id, date, title, description, url, mime, "new", ))
cursor.execute("COMMIT")
return cursor.lastrowid
@readcursor
def set_podcast_episode_status(self, cursor, episode_id, status):
assert status in ["new", "skipped", "downloading", "completed"]
cursor.execute("UPDATE podcast_episodes SET status=? WHERE id=?", (status, episode_id, ))
cursor.execute("COMMIT")

View File

@ -0,0 +1,101 @@
import os
import logging
from pysonic.scanner import PysonicFilesystemScanner
from pysonic.types import MUSIC_TYPES
from pysonic.podcast import PodcastManager
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.podcastmgr = PodcastManager(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.get_podcasts = self.db.get_podcasts
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)

194
pysonic/podcast.py Normal file
View File

@ -0,0 +1,194 @@
from threading import Thread, Timer
from concurrent.futures import ThreadPoolExecutor
from queue import Queue
import shutil
import logging
import os
import requests
import feedparser
import time
class PodcastSettings(object):
"""seconds between updating podcasts"""
refresh_interval = 3 #60 * 60
"""how many seconds to wait after initialization to start refreshing podcasts"""
startup_delay = 30
"""how many podcasts can be scanned at once"""
scan_threads = 4
"""root path of downloaded podcasts"""
path = "podcasts"
"""how many of the most recent episodes to download"""
download_episodes = 2
class PodcastManager(Thread):
def __init__(self, db):
super().__init__()
self.daemon = True
self.db = db
self.settings = PodcastSettings
self.q = Queue()
self.start()
def run(self):
"""
In a loop forever, query for podcasts in need of scanning for new episodes. Wait for a scan being requested (aka
a queue item) as the signal to begin scanning.
"""
self.schedule_rescan()
while True:
self.q.get()
self.refresh_podcasts()
def interval_scan(self):
"""
Schedule the next automated rescan. Request a scan be executed.
"""
self.request_rescan()
#self.schedule_rescan()
def schedule_rescan(self):
"""
Call the next interval scan later
"""
t = Timer(self.settings.refresh_interval, self.interval_scan)
t.daemon = True
t.start()
def request_rescan(self):
"""
Add an item to the queue
"""
self.q.put(None)
def refresh_podcasts(self):
"""
Refresh all the podcasts
"""
logging.info("rescanning podcasts")
# If any episodes are marked as "downloading", it's a lie and left over from before the crash
# TODO this should happen earlier than the scan
for entry in self.db.get_podcast_episodes(status="downloading"):
self.db.set_podcast_episode_status(entry['id'], "new")
futures = []
# TODO the TPE doesn't die as a daemon thread :|
with ThreadPoolExecutor(max_workers=self.settings.scan_threads) as pool:
for item in self.db.get_podcasts():
futures.append(pool.submit(self.refresh_podcast, item, ))
for item in futures:
e = item.exception()
if e:
raise e
# for item in self.db.get_podcasts():
# self.refresh_podcast(item)
logging.info("podcast refresh complete")
#TODO all episodes in 'new' status change to 'skipped'
def refresh_podcast(self, podcast):
"""
Refresh all metadata and episodes of a single podcast
"""
logging.info("updating podcast %s '%s' ", podcast['id'], podcast['title'])
feed = self.get_feed(podcast['url'])
for entry in feed['entries']:
self.refresh_podcast_entry(podcast['id'], entry)
self.refresh_podcast_episodes(podcast['id'])
#TODO update the feed's description
# self.udpate_feed_meta(feed['feed'])
# 'image': {'href': 'http://sysadministrivia.com/images/1.jpg',
# 'link': 'http://sysadministrivia.com/',
# 'links': [{'href': 'http://sysadministrivia.com/',
# 'rel': 'alternate',
# 'type': 'text/html'}],
# 'title': 'The Sysadministrivia Podcast',
# 'title_detail': {'base': '',
# 'language': 'en',
# 'type': 'text/plain',
# 'value': 'The Sysadministrivia Podcast'}},
# 'link': 'http://sysadministrivia.com/',
# 'subtitle': 'We podcast all things system administration/engineering/infosec, '
# 'with a strong focus on GNU/Linux. We use F/OSS software whenever '
# 'possible in the production of these podcasts. Please be sure to '
# 'view our show notes on the site!',
# 'title': 'The Sysadministrivia Podcast',
def refresh_podcast_episodes(self, podcast_id):
"""
Check that the most recent X episodes are downloaded. Start downloads if not.
"""
for entry in self.db.get_podcast_episodes(podcast_id=podcast_id, limit=self.settings.download_episodes):
if entry["status"] == "new":
self.download_episode(entry)
def download_episode(self, episode):
"""
Download the episode:
- mark status as downloading
- clean up any tmp files from previous failures
- create the dir
- stream the url to temp file
- rename the temp file to final location
- mark episode as downloaded
"""
self.db.set_podcast_episode_status(episode['id'], "downloading")
ep_dir = os.path.join(self.settings.path, str(episode['podcastid']))
ep_path = os.path.join(ep_dir, "{}.mp3".format(episode['id']))
ep_tmppath = os.path.join(ep_dir, ".{}.mp3".format(episode['id']))
os.makedirs(ep_dir, exist_ok=True)
if os.path.exists(ep_path):
os.unlink(ep_path) # previous failed downloads
if os.path.exists(ep_tmppath):
os.unlink(ep_tmppath) # previous failed downloads
logging.info("fetching %s", episode['url'])
r = requests.get(episode['url'], stream=True)
r.raise_for_status()
with open(ep_tmppath, 'wb') as f:
shutil.copyfileobj(r.raw, f)
os.rename(ep_tmppath, ep_path)
# TODO verify or update MIME from that of the url
self.db.set_podcast_episode_status(episode['id'], "completed")
def get_feed(self, rss_url):
"""
Download the given URL and return a parsed feed
"""
feed_body = requests.get(rss_url, timeout=30)
return feedparser.parse(feed_body.text)
def refresh_podcast_entry(self, podcast_id, entry):
"""
Update the database for the given podcast entry. Add it to the database if it doesn't exist. Note: we use the
episode TITLE as the uniqueness check against the database
"""
existing = self.db.get_podcast_episodes(podcast_id=podcast_id, title=entry['title'])
if existing:
return
# find media file url
url = None
mime = None
for link in entry['links']:
if link['type'] in ["audio/mpeg", "audio/mp3"]: # TODO more formats
url = link['href']
mime = link['type']
break
if not url:
logging.warning("could not find url for episode in podcast %s", podcast_id)
return
# create entry
ep_id = self.db.add_podcast_episode(podcast_id,
time.mktime(entry['published_parsed']),
entry['title'],
entry['summary'],
url,
mime)
logging.info("added episode %s '%s'", ep_id, entry['title'])

View File

@ -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)
@ -115,7 +109,7 @@ class PysonicFilesystemScanner(object):
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 +147,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
@ -233,8 +226,8 @@ class PysonicFilesystemScanner(object):
q += "ORDER BY albumid" q += "ORDER BY albumid"
#TODO scraping ID3 etc from the media files can be parallelized #TODO scraping ID3 etc from the media files can be parallelized
with closing(self.db.db.cursor()) as reader, \ with closing(self.library.db.db.cursor()) as reader, \
closing(self.db.db.cursor()) as writer: closing(self.library.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
@ -291,7 +284,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 +307,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 +317,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

111
pysonic/schema.py Normal file
View File

@ -0,0 +1,111 @@
table_quers = ["""CREATE TABLE 'libraries' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'name' TEXT,
'path' TEXT UNIQUE);""",
"""CREATE TABLE 'dirs' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'library' INTEGER,
'parent' INTEGER,
'name' TEXT,
UNIQUE(parent, name)
)""",
"""CREATE TABLE 'genres' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'name' TEXT UNIQUE)""",
"""CREATE TABLE 'artists' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'libraryid' INTEGER,
'dir' INTEGER UNIQUE,
'name' TEXT)""",
"""CREATE TABLE 'albums' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'artistid' INTEGER,
'coverid' INTEGER,
'dir' INTEGER,
'name' TEXT,
'added' INTEGER NOT NULL DEFAULT -1,
'played' INTEGER,
'plays' INTEGER NOT NULL DEFAULT 0,
UNIQUE (artistid, dir));""",
"""CREATE TABLE 'songs' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'library' INTEGER,
'albumid' BOOLEAN,
'genre' INTEGER DEFAULT NULL,
'file' TEXT UNIQUE, -- path from the library root
'size' INTEGER NOT NULL DEFAULT -1,
'title' TEXT NOT NULL,
'lastscan' INTEGER NOT NULL DEFAULT -1,
'format' TEXT,
'length' INTEGER,
'bitrate' INTEGER,
'track' INTEGER,
'year' INTEGER
)""",
"""CREATE TABLE 'covers' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'library' INTEGER,
'type' TEXT,
'size' TEXT,
'path' TEXT UNIQUE);""",
"""CREATE TABLE 'users' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
'username' TEXT UNIQUE NOT NULL,
'password' TEXT NOT NULL,
'admin' BOOLEAN DEFAULT 0,
'email' TEXT)""",
"""CREATE TABLE 'stars' (
'userid' INTEGER,
'songid' INTEGER,
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 'podcasts' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'lastscan' INTEGER NOT NULL DEFAULT 0,
'interval' INTEGER NOT NULL DEFAULT 60,
'url' TEXT UNIQUE,
'title' TEXT NOT NULL,
'description' TEXT,
'cover' INTEGER,
'rss_cover' TEXT,
'status' TEXT)""",
"""CREATE TABLE 'podcast_episodes' (
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
'podcastid' INTEGER,
'date' INTEGER,
'title' TEXT NOT NULL,
'description' TEXT,
'url' TEXT,
'format' TEXT,
'status' TEXT,
UNIQUE('podcastid', 'title'))""",
"""CREATE TABLE 'meta' (
'key' TEXT PRIMARY KEY NOT NULL,
'value' TEXT);""",
"""INSERT INTO meta VALUES ('db_version', '1');"""]

View File

@ -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

View File

@ -1,17 +1,17 @@
beautifulsoup4==4.11.1 backports.functools-lru-cache==1.5
cheroot==8.6.0 beautifulsoup4==4.6.0
CherryPy==18.6.1 certifi==2018.1.18
jaraco.classes==3.2.1 chardet==3.0.4
jaraco.collections==3.5.1 cheroot==6.2.0
jaraco.context==4.1.1 CherryPy==14.0.1
jaraco.functools==3.5.0 feedparser==5.2.1
jaraco.text==3.7.0 idna==2.6
lxml==4.9.0 lxml==3.8.0
more-itertools==8.13.0 more-itertools==4.1.0
mutagen==1.40.0 mutagen==1.38
portend==3.1.0 portend==2.2
pytz==2018.3 pytz==2018.3
requests==2.18.4
six==1.11.0 six==1.11.0
soupsieve==2.3.2.post1 tempora==1.11
tempora==5.0.1 urllib3==1.22
zc.lockfile==2.0

View File

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