From c910de0eb01b536c9d2bace56aa7b1a6b32f6b41 Mon Sep 17 00:00:00 2001 From: dave Date: Mon, 5 Oct 2020 23:06:07 -0700 Subject: [PATCH] refactor out library class --- pysonic/api.py | 84 +++++++++++++++++++------------------- pysonic/daemon.py | 8 ++-- pysonic/database.py | 42 +++++++++++++++++-- pysonic/library.py | 98 --------------------------------------------- pysonic/scanner.py | 14 +++---- 5 files changed, 90 insertions(+), 156 deletions(-) diff --git a/pysonic/api.py b/pysonic/api.py index d6daeb9..289ca56 100644 --- a/pysonic/api.py +++ b/pysonic/api.py @@ -2,7 +2,7 @@ import logging import subprocess from time import time from threading import Thread -from pysonic.library import LETTER_GROUPS +from pysonic.database import LETTER_GROUPS from pysonic.types import MUSIC_TYPES from pysonic.apilib import formatresponse, ApiResponse import cherrypy @@ -11,16 +11,15 @@ logging = logging.getLogger("api") class PysonicSubsonicApi(object): - def __init__(self, db, library, options): + def __init__(self, db, options): self.db = db - self.library = library self.options = options @cherrypy.expose @formatresponse def index(self): response = ApiResponse() - response.add_child("totals", **self.library.db.get_stats()) + response.add_child("totals", **self.db.get_stats()) return response @cherrypy.expose @@ -46,7 +45,7 @@ class PysonicSubsonicApi(object): def getMusicFolders_view(self, **kwargs): response = ApiResponse() response.add_child("musicFolders") - for folder in self.library.get_libraries(): + for folder in self.db.get_libraries(): response.add_child("musicFolder", _parent="musicFolders", id=folder["id"], name=folder["name"]) return response @@ -58,7 +57,7 @@ class PysonicSubsonicApi(object): # TODO real lastmodified date # TODO deal with ignoredArticles response.add_child("indexes", lastModified="1502310831000", ignoredArticles="The El La Los Las Le Les") - artists = self.library.get_artists(sortby="name", order="asc") + artists = self.db.get_artists(sortby="name", order="asc") for letter in LETTER_GROUPS: index = response.add_child("index", _parent="indexes", name=letter.upper()) for artist in artists: @@ -83,7 +82,7 @@ class PysonicSubsonicApi(object): qargs.update(limit=(offset, size)) - albums = self.library.get_albums(**qargs) + albums = self.db.get_albums(**qargs) response = ApiResponse() @@ -111,7 +110,7 @@ class PysonicSubsonicApi(object): List either and artist or album dir """ dir_id = int(id) - dirtype, dirinfo, entity = self.library.db.get_subsonic_musicdir(dirid=dir_id) + dirtype, dirinfo, entity = self.db.get_subsonic_musicdir(dirid=dir_id) response = ApiResponse() @@ -171,7 +170,7 @@ class PysonicSubsonicApi(object): def stream_view(self, id, maxBitRate="256", **kwargs): maxBitRate = int(maxBitRate) assert maxBitRate >= 32 and maxBitRate <= 320 - song = self.library.get_song(int(id)) + song = self.db.get_songs(id=int(id))[0] fpath = song["_fullpath"] media_bitrate = song.get("bitrate") / 1024 if song.get("bitrate") else 320 to_bitrate = min(maxBitRate, @@ -219,7 +218,7 @@ class PysonicSubsonicApi(object): if proc.returncode is None or proc.returncode == 0: logging.warning("transcoded {} in {}s".format(id, int(time() - start))) # if completed: - # self.library.report_transcode(id, to_bitrate, length) + # self.db.report_transcode(id, to_bitrate, length) else: logging.error("transcode of {} exited with code {} after {}s".format(id, proc.returncode, int(time() - start))) @@ -248,7 +247,7 @@ class PysonicSubsonicApi(object): """ if id.startswith("pl-"): # get art from first track in playlist playlist_id = int(id[len("pl-"):]) - _, songs = self.library.get_playlist(playlist_id) + songs = self.db.get_playlist_songs(playlist_id) for song in songs: if song["albumcoverid"]: id = song["albumcoverid"] @@ -262,8 +261,7 @@ class PysonicSubsonicApi(object): else: id = int(id) - cover = self.library.get_cover(id) - fpath = cover["_fullpath"] + fpath = self.db.get_cover_path(id) type2ct = { 'jpg': 'image/jpeg', 'png': 'image/png', @@ -280,14 +278,14 @@ class PysonicSubsonicApi(object): break total += len(data) yield data - logging.info("\nSent {} bytes for {}".format(total, fpath)) + logging.info("sent {} bytes for {}".format(total, fpath)) return content() getCoverArt_view._cp_config = {'response.stream': True} @cherrypy.expose @formatresponse def getArtistInfo_view(self, id, includeNotPresent="true", **kwargs): - info = self.library.get_artist_info(id) + info = self.db.get_artist_info(id) response = ApiResponse() response.add_child("artistInfo") response.set_attrs("artistInfo", **info) @@ -296,7 +294,7 @@ class PysonicSubsonicApi(object): @cherrypy.expose @formatresponse def getUser_view(self, username, **kwargs): - user = {} if self.options.disable_auth else self.library.db.get_user(cherrypy.request.login) + user = {} if self.options.disable_auth else self.db.get_user(cherrypy.request.login) response = ApiResponse() response.add_child("user", username=user["username"], @@ -321,19 +319,19 @@ class PysonicSubsonicApi(object): @cherrypy.expose @formatresponse def star_view(self, id, **kwargs): - self.library.set_starred(cherrypy.request.login, int(id), starred=True) + self.db.set_starred(cherrypy.request.login, int(id), starred=True) return ApiResponse() @cherrypy.expose @formatresponse def unstar_view(self, id, **kwargs): - self.library.set_starred(cherrypy.request.login, int(id), starred=False) + self.db.set_starred(cherrypy.request.login, int(id), starred=False) return ApiResponse() @cherrypy.expose @formatresponse def getStarred_view(self, **kwargs): - children = self.library.get_starred(cherrypy.request.login) + children = self.db.get_starred(cherrypy.request.login) response = ApiResponse() response.add_child("starred") for item in children: @@ -355,7 +353,7 @@ class PysonicSubsonicApi(object): """ response = ApiResponse() response.add_child("randomSongs") - children = self.library.db.get_songs(limit=size, sortby="random") + children = self.db.get_songs(limit=size, sortby="random") for song in children: moreargs = {} if song["format"]: @@ -390,7 +388,7 @@ class PysonicSubsonicApi(object): def getGenres_view(self, **kwargs): response = ApiResponse() response.add_child("genres") - for row in self.library.db.get_genres(): + for row in self.db.get_genres(): response.add_child("genre", _parent="genres", value=row["name"], songCount=420, albumCount=69) return response @@ -418,7 +416,7 @@ class PysonicSubsonicApi(object): query = query.replace("*", "") # TODO handle this artists = 0 - for item in self.library.get_artists(name_contains=query): + for item in self.db.get_artists(name_contains=query): response.add_child("artist", _parent="searchResult2", id=item["id"], name=item["name"]) artists += 1 if artists >= artistCount: @@ -426,7 +424,7 @@ class PysonicSubsonicApi(object): # TODO make this more efficient albums = 0 - for album in self.library.get_albums(name_contains=query): + for album in self.db.get_albums(name_contains=query): response.add_child("album", _parent="searchResult2", id=album["dir"], parent=album["artistdir"], @@ -445,7 +443,7 @@ class PysonicSubsonicApi(object): # TODO make this more efficient songs = 0 - for song in self.library.db.get_songs(title_contains=query): + for song in self.db.get_songs(title_contains=query): response.add_child("song", _parent="searchResult2", id=song["id"], parent=song["albumdir"], @@ -485,11 +483,11 @@ class PysonicSubsonicApi(object): 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.library.get_song(current) - self.library.db.update_album_played(song['albumid'], time()) - self.library.db.increment_album_plays(song['albumid']) + 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.library.db.increment_track_plays(current) + self.db.increment_track_plays(current) # TODO save playlist with items ['378', '386', '384', '380', '383'] current 383 position 4471 # id entries are strings! @@ -498,19 +496,19 @@ class PysonicSubsonicApi(object): def createPlaylist_view(self, name, songId, **kwargs): if type(songId) != list: songId = [songId] - user = self.library.db.get_user(cherrypy.request.login) - self.library.db.add_playlist(user["id"], name, songId) + user = self.db.get_user(cherrypy.request.login) + self.db.add_playlist(user["id"], name, songId) return ApiResponse() #TODO the response should be the new playlist, check the cap @cherrypy.expose @formatresponse def getPlaylists_view(self, **kwargs): - user = self.library.db.get_user(cherrypy.request.login) + user = self.db.get_user(cherrypy.request.login) response = ApiResponse() response.add_child("playlists") - for playlist in self.library.db.get_playlists(user["id"]): + for playlist in self.db.get_playlists(user["id"]): response.add_child("playlist", _parent="playlists", id=playlist["id"], @@ -529,9 +527,10 @@ class PysonicSubsonicApi(object): @cherrypy.expose @formatresponse def getPlaylist_view(self, id, **kwargs): - user = self.library.db.get_user(cherrypy.request.login) - plinfo, songs = self.library.get_playlist(int(id)) - + id = int(id) + user = self.db.get_user(cherrypy.request.login) + plinfo = self.db.get_playlist(id) + songs = self.db.get_playlist_songs(id) response = ApiResponse() response.add_child("playlist", id=plinfo["id"], @@ -569,15 +568,16 @@ class PysonicSubsonicApi(object): @cherrypy.expose @formatresponse def updatePlaylist_view(self, playlistId, songIndexToRemove=None, songIdToAdd=None, **kwargs): - user = self.library.db.get_user(cherrypy.request.login) - plinfo, songs = self.library.get_playlist(int(playlistId)) + playlistId = int(playlistId) + user = self.db.get_user(cherrypy.request.login) + plinfo = self.db.get_playlist(playlistId) assert plinfo["ownerid"] == user["id"] if songIndexToRemove: - self.library.db.remove_index_from_playlist(playlistId, songIndexToRemove) + self.db.remove_index_from_playlist(playlistId, songIndexToRemove) elif songIdToAdd: - self.library.db.add_to_playlist(playlistId, songIdToAdd) + self.db.add_to_playlist(playlistId, songIdToAdd) #TODO there are more modification methods return ApiResponse() @@ -585,9 +585,9 @@ class PysonicSubsonicApi(object): @cherrypy.expose @formatresponse def deletePlaylist_view(self, id, **kwargs): - user = self.library.db.get_user(cherrypy.request.login) - plinfo, _ = self.library.get_playlist(int(id)) + user = self.db.get_user(cherrypy.request.login) + plinfo = self.db.get_playlist(int(id)) assert plinfo["ownerid"] == user["id"] - self.library.delete_playlist(plinfo["id"]) + self.db.delete_playlist(plinfo["id"]) return ApiResponse() diff --git a/pysonic/daemon.py b/pysonic/daemon.py index 72a33e5..31d9448 100644 --- a/pysonic/daemon.py +++ b/pysonic/daemon.py @@ -3,7 +3,6 @@ import logging import cherrypy from sqlite3 import DatabaseError from pysonic.api import PysonicSubsonicApi -from pysonic.library import PysonicLibrary from pysonic.database import PysonicDatabase, DuplicateRootException @@ -35,15 +34,14 @@ def main(): format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s") db = PysonicDatabase(path=args.database_path) - library = PysonicLibrary(db) for dirname in args.dirs: dirname = os.path.abspath(dirname) assert os.path.exists(dirname), "--dirs must be paths that exist" try: - library.add_root_dir(dirname) + db.add_root(dirname) except DuplicateRootException: pass - library.update() + db.update() for username, password in args.user: try: @@ -55,7 +53,7 @@ def main(): # logging.warning("Artists: {}".format([i["name"] for i in library.get_artists()])) # logging.warning("Albums: {}".format(len(library.get_albums()))) - api = PysonicSubsonicApi(db, library, args) + api = PysonicSubsonicApi(db, args) api_config = {} if args.disable_auth: logging.warning("starting up with auth disabled") diff --git a/pysonic/database.py b/pysonic/database.py index 69e4257..c26e396 100644 --- a/pysonic/database.py +++ b/pysonic/database.py @@ -1,3 +1,4 @@ +import os import sqlite3 import logging from hashlib import sha512 @@ -5,7 +6,17 @@ from time import time from contextlib import closing from collections import Iterable + +from pysonic.scanner import PysonicFilesystemScanner + + logging = 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"] @@ -49,11 +60,18 @@ class PysonicDatabase(object): self.db = None self.open() self.migrate() + self.scanner = PysonicFilesystemScanner(self) def open(self): 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' ( @@ -150,6 +168,16 @@ class PysonicDatabase(object): # 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'] @@ -167,7 +195,7 @@ class PysonicDatabase(object): :return: int :raises: sqlite3.IntegrityError """ - assert path.startswith("/") + path = os.path.abspath(os.path.normpath(path)) try: c.execute("INSERT INTO libraries ('name', 'path') VALUES (?, ?)", (name, path, )) c.execute("COMMIT") @@ -348,11 +376,16 @@ class PysonicDatabase(object): return genres @cursor - def get_cover(self, c, coverid): + def get_cover(self, c, cover_id): cover = None - for cover in c.execute("SELECT * FROM covers WHERE id = ?", (coverid, )): + for cover in c.execute("SELECT * FROM covers WHERE id = ?", (cover_id, )): 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): """ @@ -469,12 +502,13 @@ class PysonicDatabase(object): @cursor def empty_playlist(self, c, playlist_id): - #TODO combine with ?? + #TODO combine with delete_playlist c.execute("DELETE FROM playlist_entries WHERE playlistid=?", (playlist_id, )) c.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") diff --git a/pysonic/library.py b/pysonic/library.py index 92c8d28..e69de29 100644 --- a/pysonic/library.py +++ b/pysonic/library.py @@ -1,98 +0,0 @@ -import os -import logging -from pysonic.scanner import PysonicFilesystemScanner -from pysonic.types import MUSIC_TYPES - - -LETTER_GROUPS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", - "u", "v", "w", "xyz", "0123456789"] - - -logging = logging.getLogger("library") - - -def memoize(function): - memo = {} - - def wrapper(*args): - if args in memo: - return memo[args] - else: - rv = function(*args) - memo[args] = rv - return rv - return wrapper - - -class NoDataException(Exception): - pass - - -class PysonicLibrary(object): - def __init__(self, database): - self.db = database - - self.get_libraries = self.db.get_libraries - self.get_artists = self.db.get_artists - self.get_albums = self.db.get_albums - # self.get_song = self.db.get_song - # self.get_cover = self.db.get_cover - - self.scanner = PysonicFilesystemScanner(self) - logging.info("library ready") - - def update(self): - """ - Start the library media scanner ands - """ - self.scanner.init_scan() - - def add_root_dir(self, path): - """ - The music library consists of a number of root dirs. This adds a new root - """ - path = os.path.abspath(os.path.normpath(path)) - self.db.add_root(path) - - # def get_artists(self, *args, **kwargs): - # artists = self.db.get_artists(*args, **kwargs) - # for item in artists: - # item["parent"] = item["libraryid"] - # return artists - - # def get_albums(self, *args, **kwargs): - # albums = self.db.get_albums(*args, **kwargs) - # for item in albums: - # item["parent"] = item["artistid"] - # return albums - - def get_artist_info(self, item_id): - #TODO - return {"biography": "placeholder biography", - "musicBrainzId": "playerholder", - "lastFmUrl": "https://www.last.fm/music/Placeholder", - "smallImageUrl": "", - "mediumImageUrl": "", - "largeImageUrl": "", - "similarArtists": []} - - def get_cover(self, cover_id): - cover = self.db.get_cover(cover_id) - library = self.db.get_libraries(cover["library"])[0] - cover['_fullpath'] = os.path.join(library["path"], cover["path"]) - return cover - - def get_song(self, song_id): - song = self.db.get_songs(id=song_id)[0] - library = self.db.get_libraries(song["library"])[0] - song['_fullpath'] = os.path.join(library["path"], song["file"]) - return song - - def get_playlist(self, playlist_id): - playlist_info = self.db.get_playlist(playlist_id) - songs = self.db.get_playlist_songs(playlist_id) - return (playlist_info, songs) - - def delete_playlist(self, playlist_id): - self.db.empty_playlist(playlist_id) - self.db.delete_playlist(playlist_id) diff --git a/pysonic/scanner.py b/pysonic/scanner.py index add1b0c..de43b20 100644 --- a/pysonic/scanner.py +++ b/pysonic/scanner.py @@ -18,8 +18,8 @@ RE_NUMBERS = re.compile(r'^([0-9]+)') class PysonicFilesystemScanner(object): - def __init__(self, library): - self.library = library + def __init__(self, db): + self.db = db def init_scan(self): self.scanner = Thread(target=self.rescan, daemon=True) @@ -31,7 +31,7 @@ class PysonicFilesystemScanner(object): """ start = time() logging.warning("Beginning library rescan") - for parent in self.library.db.get_libraries(): + for parent in self.db.get_libraries(): logging.info("Scanning {}".format(parent["path"])) self.scan_root(parent["id"], parent["path"]) logging.warning("Rescan complete in %ss", round(time() - start, 3)) @@ -63,7 +63,7 @@ class PysonicFilesystemScanner(object): :type path list """ assert path - # with closing(self.library.db.db.cursor()) as cursor: + # with closing(self.db.db.cursor()) as cursor: parent_id = 0 # 0 indicates a top level item in the library for name in path: parent_id = self.create_or_get_dbdir(cursor, pid, parent_id, name) @@ -109,7 +109,7 @@ class PysonicFilesystemScanner(object): if len(path) > 1: album = path[-1] - with closing(self.library.db.db.cursor()) as cursor: + with closing(self.db.db.cursor()) as cursor: artist_id, artist_dirid = self.create_or_get_artist(cursor, pid, path[0]) album_id = None @@ -226,8 +226,8 @@ class PysonicFilesystemScanner(object): q += "ORDER BY albumid" #TODO scraping ID3 etc from the media files can be parallelized - with closing(self.library.db.db.cursor()) as reader, \ - closing(self.library.db.db.cursor()) as writer: + with closing(self.db.db.cursor()) as reader, \ + closing(self.db.db.cursor()) as writer: processed = 0 # commit batching counter for row in reader.execute(q): # Find meta, bail if the file was unreadable