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 subprocess
from time import time
from threading import Thread
from pysonic.database import LETTER_GROUPS
from pysonic.types import MUSIC_TYPES, TYPE_TO_EXTENSION
from pysonic.library import LETTER_GROUPS
from pysonic.types import MUSIC_TYPES
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, options):
def __init__(self, db, library, options):
self.db = db
self.library = library
self.options = options
@cherrypy.expose
@formatresponse
def index(self):
response = ApiResponse()
response.add_child("totals", **self.db.get_stats())
response.add_child("totals", **self.library.db.get_stats())
return response
@cherrypy.expose
@ -54,7 +46,7 @@ class PysonicSubsonicApi(object):
def getMusicFolders_view(self, **kwargs):
response = ApiResponse()
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"])
return response
@ -66,7 +58,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.db.get_artists(sortby="name", order="asc")
artists = self.library.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:
@ -91,7 +83,7 @@ class PysonicSubsonicApi(object):
qargs.update(limit=(offset, size))
albums = self.db.get_albums(**qargs)
albums = self.library.get_albums(**qargs)
response = ApiResponse()
@ -104,10 +96,10 @@ class PysonicSubsonicApi(object):
title=album["name"],
album=album["name"],
artist=album["artistname"],
coverArt=album["coverid"],
playCount=album["plays"],
coverArt=album["coverid"]
#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)
return response
@ -116,26 +108,16 @@ class PysonicSubsonicApi(object):
@formatresponse
def getMusicDirectory_view(self, id, **kwargs):
"""
List either and artist or album dir
List an artist dir
"""
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.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:
@ -145,7 +127,7 @@ class PysonicSubsonicApi(object):
if childtype == "album":
moreargs.update(name=child["name"],
isDir="true", # TODO song files in artist dir
parent=entity["dir"],
parent=entity["id"],
id=child["dir"])
if child["coverid"]:
moreargs.update(coverArt=child["coverid"])
@ -154,31 +136,18 @@ class PysonicSubsonicApi(object):
# artist=artist["name"],
# coverArt=item["coverid"],
elif childtype == "song":
moreargs.update(title=child["title"],
albumId=entity["dir"],
album=entity["name"],
artistId=child["_artist"]["dir"],
moreargs.update(name=child["title"],
artist=child["_artist"]["name"],
contentType=child["format"],
id=child["id"],
duration=child["length"],
isDir="false",
parent=entity["dir"],
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"
# title=xxx
)
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",
@ -189,11 +158,10 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
def stream_view(self, id, maxBitRate="256", **kwargs):
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"])
maxBitRate = int(maxBitRate)
assert maxBitRate >= 32 and maxBitRate <= 320
song = self.library.get_song(int(id))
fpath = song["_fullpath"]
media_bitrate = song.get("bitrate") / 1024 if song.get("bitrate") else 320
to_bitrate = min(maxBitRate,
self.options.max_bitrate,
@ -240,14 +208,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.db.report_transcode(id, to_bitrate, length)
# self.library.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=TRANSCODE_TIMEOUT)
proc.wait(timeout=90)
except subprocess.TimeoutExpired:
logging.warning("killing timed-out transcoder")
proc.kill()
@ -260,30 +228,8 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
def getCoverArt_view(self, id, **kwargs):
"""
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)
cover = self.library.get_cover(id)
fpath = cover["_fullpath"]
type2ct = {
'jpg': 'image/jpeg',
'png': 'image/png',
@ -300,14 +246,14 @@ class PysonicSubsonicApi(object):
break
total += len(data)
yield data
logging.info("sent {} bytes for {}".format(total, fpath))
logging.info("\nSent {} 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.db.get_artist_info(id)
info = self.library.get_artist_info(id)
response = ApiResponse()
response.add_child("artistInfo")
response.set_attrs("artistInfo", **info)
@ -316,7 +262,7 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
@formatresponse
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.add_child("user",
username=user["username"],
@ -329,7 +275,7 @@ class PysonicSubsonicApi(object):
playlistRole="true",
coverArtRole="false",
commentRole="false",
podcastRole="false",
podcastRole="true",
streamRole="true",
jukeboxRole="false",
shareRole="true",
@ -341,19 +287,19 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
@formatresponse
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()
@cherrypy.expose
@formatresponse
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()
@cherrypy.expose
@formatresponse
def getStarred_view(self, **kwargs):
children = self.db.get_starred(cherrypy.request.login)
children = self.library.get_starred(cherrypy.request.login)
response = ApiResponse()
response.add_child("starred")
for item in children:
@ -375,7 +321,7 @@ class PysonicSubsonicApi(object):
"""
response = ApiResponse()
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:
moreargs = {}
if song["format"]:
@ -389,6 +335,8 @@ 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"],
@ -398,7 +346,7 @@ class PysonicSubsonicApi(object):
isDir="false",
parent=song["albumid"],
size=song["size"],
suffix=extension(song["format"]),
suffix=file_extension,
type="music",
**moreargs)
return response
@ -408,7 +356,7 @@ class PysonicSubsonicApi(object):
def getGenres_view(self, **kwargs):
response = ApiResponse()
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)
return response
@ -436,60 +384,30 @@ class PysonicSubsonicApi(object):
query = query.replace("*", "") # TODO handle this
artists = 0
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
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
# TODO make this more efficient
albums = 0
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
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
# TODO make this more efficient
songs = 0
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
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
return response
@ -501,13 +419,11 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
def savePlayQueue_view(self, id, current, position, **kwargs):
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)
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'])
# TODO save playlist with items ['378', '386', '384', '380', '383'] current 383 position 4471
# id entries are strings!
@ -516,19 +432,19 @@ class PysonicSubsonicApi(object):
def createPlaylist_view(self, name, songId, **kwargs):
if type(songId) != list:
songId = [songId]
user = self.db.get_user(cherrypy.request.login)
self.db.add_playlist(user["id"], name, songId)
user = self.library.db.get_user(cherrypy.request.login)
self.library.db.add_playlist(user["id"], name, songId)
return ApiResponse()
#TODO the response should be the new playlist, check the cap
@cherrypy.expose
@formatresponse
def getPlaylists_view(self, **kwargs):
user = self.db.get_user(cherrypy.request.login)
user = self.library.db.get_user(cherrypy.request.login)
response = ApiResponse()
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",
_parent="playlists",
id=playlist["id"],
@ -539,7 +455,7 @@ class PysonicSubsonicApi(object):
duration=420,
# changed="2018-04-05T23:23:38.263Z"
# created="2018-04-05T23:23:38.252Z"
coverArt="pl-{}".format(playlist["id"])
# coverArt="pl-1"
)
return response
@ -547,10 +463,9 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
@formatresponse
def getPlaylist_view(self, id, **kwargs):
id = int(id)
user = self.db.get_user(cherrypy.request.login)
plinfo = self.db.get_playlist(id)
songs = self.db.get_playlist_songs(id)
user = self.library.db.get_user(cherrypy.request.login)
plinfo, songs = self.library.get_playlist(int(id))
response = ApiResponse()
response.add_child("playlist",
id=plinfo["id"],
@ -574,11 +489,11 @@ class PysonicSubsonicApi(object):
coverArt=song["albumcoverid"],
size=song["size"],
contentType=song["format"],
suffix=extension(song["format"]),
# suffix="mp3"
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"],
playCount=song["plays"],
playCount="1",
# created="2015-06-09T15:26:01.000Z"
albumId=song["albumid"],
artistId=song["artistid"],
@ -588,16 +503,15 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
@formatresponse
def updatePlaylist_view(self, playlistId, songIndexToRemove=None, songIdToAdd=None, **kwargs):
playlistId = int(playlistId)
user = self.db.get_user(cherrypy.request.login)
plinfo = self.db.get_playlist(playlistId)
user = self.library.db.get_user(cherrypy.request.login)
plinfo, songs = self.library.get_playlist(int(playlistId))
assert plinfo["ownerid"] == user["id"]
if songIndexToRemove:
self.db.remove_index_from_playlist(playlistId, songIndexToRemove)
self.library.db.remove_index_from_playlist(playlistId, songIndexToRemove)
elif songIdToAdd:
self.db.add_to_playlist(playlistId, songIdToAdd)
self.library.db.add_to_playlist(playlistId, songIdToAdd)
#TODO there are more modification methods
return ApiResponse()
@ -605,9 +519,59 @@ class PysonicSubsonicApi(object):
@cherrypy.expose
@formatresponse
def deletePlaylist_view(self, id, **kwargs):
user = self.db.get_user(cherrypy.request.login)
plinfo = self.db.get_playlist(int(id))
user = self.library.db.get_user(cherrypy.request.login)
plinfo, _ = self.library.get_playlist(int(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()

View File

@ -3,6 +3,7 @@ import logging
import cherrypy
from sqlite3 import DatabaseError
from pysonic.api import PysonicSubsonicApi
from pysonic.library import PysonicLibrary
from pysonic.database import PysonicDatabase, DuplicateRootException
@ -23,8 +24,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-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("--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("--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")
@ -34,14 +35,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:
dirname = os.path.abspath(dirname)
assert os.path.exists(dirname), "--dirs must be paths that exist"
assert os.path.exists(dirname) and dirname.startswith("/"), "--dirs must be absolute paths and exist!"
try:
db.add_root(dirname)
library.add_root_dir(dirname)
except DuplicateRootException:
pass
db.update()
library.update()
for username, password in args.user:
try:
@ -53,12 +54,13 @@ 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, args)
api = PysonicSubsonicApi(db, library, 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,28 +1,19 @@
import os
import sqlite3
import logging
from hashlib import sha512
from time import time
from contextlib import closing
from collections import Iterable
from pysonic.schema import table_quers
from pysonic.scanner import PysonicFilesystemScanner
logger = logging.getLogger("database")
LETTER_GROUPS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
"u", "v", "w", "xyz", "0123456789"]
logging = logging.getLogger("database")
keys_in_table = ["title", "album", "artist", "type", "size"]
def dict_factory(c, row):
def dict_factory(cursor, row):
d = {}
for idx, col in enumerate(c.description):
for idx, col in enumerate(cursor.description):
d[col[0]] = row[idx]
return d
@ -36,10 +27,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 cursor(func):
def readcursor(func):
"""
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):
return func(*args, **kwargs)
else:
with closing(self.db.cursor()) as c:
return func(self, c, *args[1:], **kwargs)
with closing(self.db.cursor()) as cursor:
return func(*[self, cursor], *args[1:], **kwargs)
return wrapped
@ -60,136 +51,38 @@ 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' (
'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,
'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'")
with closing(self.db.cursor()) as cursor:
cursor.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta'")
# Initialize DB
if len(c.fetchall()) == 0:
logger.warning("Initializing database")
for query in queries:
c.execute(query)
c.execute("COMMIT")
if len(cursor.fetchall()) == 0:
logging.warning("Initializing database")
for query in table_quers:
print(query)
cursor.execute(query)
cursor.execute("COMMIT")
else:
# Migrate if old db exists
# c.execute("""UPDATE meta SET value=? WHERE key="db_version";""", (str(version), ))
# logger.warning("db schema is version {}".format(version))
# cursor.execute("""UPDATE meta SET value=? WHERE key="db_version";""", (str(version), ))
# logging.warning("db schema is version {}".format(version))
pass
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']
@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']
return dict(songs=songs, artists=artists, albums=albums)
# Music related
@cursor
def add_root(self, c, path, name="Library"):
@readcursor
def add_root(self, cursor, 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
@ -197,16 +90,16 @@ class PysonicDatabase(object):
:return: int
:raises: sqlite3.IntegrityError
"""
path = os.path.abspath(os.path.normpath(path))
assert path.startswith("/")
try:
c.execute("INSERT INTO libraries ('name', 'path') VALUES (?, ?)", (name, path, ))
c.execute("COMMIT")
return c.lastrowid
cursor.execute("INSERT INTO libraries ('name', 'path') VALUES (?, ?)", (name, path, ))
cursor.execute("COMMIT")
return cursor.lastrowid
except sqlite3.IntegrityError:
raise DuplicateRootException("Root '{}' already exists".format(path))
@cursor
def get_libraries(self, c, id=None):
@readcursor
def get_libraries(self, cursor, id=None):
libs = []
q = "SELECT * FROM libraries"
params = []
@ -216,13 +109,13 @@ class PysonicDatabase(object):
params.append(id)
if conditions:
q += " WHERE " + " AND ".join(conditions)
c.execute(q, params)
for row in c:
cursor.execute(q, params)
for row in cursor:
libs.append(row)
return libs
@cursor
def get_artists(self, c, id=None, dirid=None, sortby="name", order=None, name_contains=None):
@readcursor
def get_artists(self, cursor, id=None, dirid=None, sortby=None, order=None):
assert order in ["asc", "desc", None]
artists = []
q = "SELECT * FROM artists"
@ -234,27 +127,24 @@ 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")
c.execute(q, params)
for row in c:
cursor.execute(q, params)
for row in cursor:
artists.append(row)
return artists
@cursor
def get_albums(self, c, id=None, artist=None, sortby="name", order=None, limit=None, name_contains=None):
@readcursor
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.
"""
if order:
order = {"asc": "ASC", "desc": "DESC"}[order]
if sortby == "random":
if sortby and sortby == "random":
sortby = "RANDOM()"
albums = []
@ -279,9 +169,6 @@ 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)
@ -294,19 +181,19 @@ class PysonicDatabase(object):
q += " LIMIT {}".format(limit) if isinstance(limit, int) \
else " LIMIT {}, {}".format(*limit)
c.execute(q, params)
for row in c:
cursor.execute(q, params)
for row in cursor:
albums.append(row)
return albums
@cursor
def get_songs(self, c, id=None, genre=None, sortby="title", order=None, limit=None, title_contains=None):
@readcursor
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
# or maybe lookup those IDs in the library layer?
if order:
order = {"asc": "ASC", "desc": "DESC"}[order]
if sortby == "random":
if sortby and sortby == "random":
sortby = "RANDOM()"
songs = []
@ -314,19 +201,13 @@ 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,
albdir.id as albumdir
g.name as genrename
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
@ -345,9 +226,6 @@ 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)
@ -359,13 +237,13 @@ class PysonicDatabase(object):
if limit:
q += " LIMIT {}".format(limit) # TODO support limit pagination
c.execute(q, params)
for row in c:
cursor.execute(q, params)
for row in cursor:
songs.append(row)
return songs
@cursor
def get_genres(self, c, genre_id=None):
@readcursor
def get_genres(self, cursor, genre_id=None):
genres = []
q = "SELECT * FROM genres"
params = []
@ -375,24 +253,19 @@ class PysonicDatabase(object):
params.append(genre_id)
if conditions:
q += " WHERE " + " AND ".join(conditions)
c.execute(q, params)
for row in c:
cursor.execute(q, params)
for row in cursor:
genres.append(row)
return genres
@cursor
def get_cover(self, c, cover_id):
@readcursor
def get_cover(self, cursor, coverid):
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
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):
@readcursor
def get_subsonic_musicdir(self, cursor, 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:
@ -405,7 +278,7 @@ class PysonicDatabase(object):
"""
# find directory
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
assert dirinfo
@ -413,7 +286,7 @@ class PysonicDatabase(object):
# see if it matches the artists or albums table
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
# if artist:
@ -421,7 +294,7 @@ class PysonicDatabase(object):
if artist:
ret = ("artist", dirinfo, artist)
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))
ret[2]['children'] = children
return ret
@ -429,45 +302,45 @@ class PysonicDatabase(object):
# else if album:
# get child tracks
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
if 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 = []
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
children.append(("song", song))
ret[2]['children'] = children
return ret
# Playlist related
@cursor
def add_playlist(self, c, ownerid, name, song_ids, public=False):
@readcursor
def add_playlist(self, cursor, ownerid, name, song_ids, public=False):
"""
Create a playlist
"""
now = time()
c.execute("INSERT INTO playlists (ownerid, name, public, created, changed) VALUES (?, ?, ?, ?, ?)",
(ownerid, name, public, now, now))
plid = c.lastrowid
cursor.execute("INSERT INTO playlists (ownerid, name, public, created, changed) VALUES (?, ?, ?, ?, ?)",
(ownerid, name, public, now, now))
plid = cursor.lastrowid
for song_id in song_ids:
self.add_to_playlist(c, plid, song_id)
c.execute("COMMIT")
self.add_to_playlist(cursor, plid, song_id)
cursor.execute("COMMIT")
@cursor
def add_to_playlist(self, c, playlist_id, song_id):
@readcursor
def add_to_playlist(self, cursor, playlist_id, song_id):
# 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
def get_playlist(self, c, playlist_id):
return c.execute("SELECT * FROM playlists WHERE id=?", (playlist_id, )).fetchone()
@readcursor
def get_playlist(self, cursor, playlist_id):
return cursor.execute("SELECT * FROM playlists WHERE id=?", (playlist_id, )).fetchone()
@cursor
def get_playlist_songs(self, c, playlist_id):
@readcursor
def get_playlist_songs(self, cursor, playlist_id):
songs = []
q = """
SELECT
@ -489,66 +362,133 @@ class PysonicDatabase(object):
WHERE pe.playlistid = ?
ORDER BY pe.'order' ASC;
"""
for row in c.execute(q, (playlist_id, )):
for row in cursor.execute(q, (playlist_id, )):
songs.append(row)
return songs
@cursor
def get_playlists(self, c, user_id):
@readcursor
def get_playlists(self, cursor, user_id):
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)
return playlists
@cursor
def remove_index_from_playlist(self, c, playlist_id, index):
c.execute("DELETE FROM playlist_entries WHERE playlistid=? LIMIT ?, 1", (playlist_id, index, ))
c.execute("COMMIT")
@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 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 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 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 delete_playlist(self, cursor, playlist_id):
cursor.execute("DELETE FROM playlists WHERE id=?", (playlist_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 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 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")
@readcursor
def increment_album_plays(self, cursor, album_id):
cursor.execute("UPDATE albums SET plays = plays + 1 WHERE id=?", (album_id, ))
cursor.execute("COMMIT")
# User related
@cursor
def add_user(self, c, username, password, is_admin=False):
c.execute("INSERT INTO users (username, password, admin) VALUES (?, ?, ?)",
(username, hash_password(password), is_admin))
c.execute("COMMIT")
@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 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 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 get_user(self, c, user):
@readcursor
def get_user(self, cursor, user):
try:
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:
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
from time import time
from threading import Thread
from pysonic.types import MUSIC_TYPES, WAV_TYPES, MPX_TYPES, FLAC_TYPES, MUSIC_EXTENSIONS, IMAGE_EXTENSIONS, \
TYPE_TO_EXTENSION, UNKNOWN_MIME
from pysonic.types import KNOWN_MIMES, MUSIC_TYPES, MPX_TYPES, FLAC_TYPES, WAV_TYPES, MUSIC_EXTENSIONS, IMAGE_EXTENSIONS, IMAGE_TYPES
from mutagen.id3 import ID3
from mutagen import MutagenError
from mutagen.id3._util import ID3NoHeaderError
@ -18,14 +17,9 @@ 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, db):
self.db = db
def __init__(self, library):
self.library = library
def init_scan(self):
self.scanner = Thread(target=self.rescan, daemon=True)
@ -37,7 +31,7 @@ class PysonicFilesystemScanner(object):
"""
start = time()
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"]))
self.scan_root(parent["id"], parent["path"])
logging.warning("Rescan complete in %ss", round(time() - start, 3))
@ -69,7 +63,7 @@ class PysonicFilesystemScanner(object):
:type path list
"""
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
for name in path:
parent_id = self.create_or_get_dbdir(cursor, pid, parent_id, name)
@ -115,7 +109,7 @@ class PysonicFilesystemScanner(object):
if len(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])
album_id = None
@ -153,14 +147,13 @@ 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, format) "
"VALUES (?, ?, ?, ?, ?, ?)",
cursor.execute("INSERT INTO songs (library, albumid, file, size, title) "
"VALUES (?, ?, ?, ?, ?)",
(pid,
album_id,
fpath,
os.stat(os.path.join(root_dir, fpath)).st_size,
fname,
guess_format(fpath)))
fname, ))
return True
return False
@ -233,8 +226,8 @@ class PysonicFilesystemScanner(object):
q += "ORDER BY albumid"
#TODO scraping ID3 etc from the media files can be parallelized
with closing(self.db.db.cursor()) as reader, \
closing(self.db.db.cursor()) as writer:
with closing(self.library.db.db.cursor()) as reader, \
closing(self.library.db.db.cursor()) as writer:
processed = 0 # commit batching counter
for row in reader.execute(q):
# Find meta, bail if the file was unreadable
@ -291,7 +284,6 @@ 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:
@ -315,7 +307,6 @@ 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):
@ -326,59 +317,30 @@ class PysonicFilesystemScanner(object):
# meta["kbitrate"] = int(bitrate / 1024)
except (ValueError, AttributeError):
pass
# 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
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)
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"
MIME_XFLAC = "audio/x-flac"
KNOWN_MIMES = ["audio/mpeg", "audio/flac", "audio/x-wav", "image/jpeg", "image/png"]
MIME_XWAV = "audio/x-wav"
MUSIC_TYPES = ["audio/mpeg", "audio/flac", "audio/x-wav"]
MIME_JPEG = "image/jpeg"
MIME_PNG = "image/png"
MIME_GIF = "image/gif"
MPX_TYPES = ["audio/mpeg"]
FLAC_TYPES = ["audio/flac"]
# groupings of similar files by mime
KNOWN_MIMES = [MIME_MPEG, MIME_FLAC, MIME_XFLAC, MIME_XWAV, MIME_JPEG, MIME_PNG]
WAV_TYPES = ["audio/x-wav"]
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_TYPES = ["image/jpeg", "image/png", "image/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,17 +1,17 @@
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==3.1.0
backports.functools-lru-cache==1.5
beautifulsoup4==4.6.0
certifi==2018.1.18
chardet==3.0.4
cheroot==6.2.0
CherryPy==14.0.1
feedparser==5.2.1
idna==2.6
lxml==3.8.0
more-itertools==4.1.0
mutagen==1.38
portend==2.2
pytz==2018.3
requests==2.18.4
six==1.11.0
soupsieve==2.3.2.post1
tempora==5.0.1
zc.lockfile==2.0
tempora==1.11
urllib3==1.22

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