diff --git a/TODO.md b/TODO.md index 6edebdd1..11178148 100644 --- a/TODO.md +++ b/TODO.md @@ -43,10 +43,11 @@ - New table to hold similar artist entries - Create 2 way relationships, such that if an artist A is similar to another B with a certain weight, then artist B is similar to A with the same weight, unless overwritten. +- Clean up tempfiles after transcoding # Bug fixes - Duplicates on search - Audio stops on ending - Show users on account settings when logged in as admin and show users on login is disabled. -- Save both filepath and trackhash in favorites and playlists \ No newline at end of file +- Save both filepath and trackhash in favorites and playlists diff --git a/app/api/__init__.py b/app/api/__init__.py index b883ca7a..f0d474f0 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -31,6 +31,7 @@ from app.api import ( getall, auth, stream, + backup_and_restore ) # TODO: Move this description to a separate file @@ -107,6 +108,7 @@ def create_api(): app.register_api(settings.api) app.register_api(colors.api) app.register_api(lyrics.api) + app.register_api(backup_and_restore.api) # Plugins app.register_api(plugins.api) diff --git a/app/api/backup_and_restore.py b/app/api/backup_and_restore.py new file mode 100644 index 00000000..69762c64 --- /dev/null +++ b/app/api/backup_and_restore.py @@ -0,0 +1,78 @@ +from dataclasses import asdict +import json +from pathlib import Path +import shutil +from time import time +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from app.api.auth import admin_required + +from app.db.userdata import FavoritesTable, PlaylistTable, ScrobbleTable +from app.settings import Paths + +bp_tag = Tag(name="Backup and Restore", description="Backup and Restore") +api = APIBlueprint("backup_and_restore", __name__, url_prefix="/", abp_tags=[bp_tag]) + + +@api.post("/backup") +@admin_required() +def backup(): + """ + Create a backup file of your favorites, playlists and scrobble data. + """ + backup_dir = Path(Paths.get_app_dir()) / "backup" + backup_dir.mkdir(parents=True, exist_ok=True) + backup_name = f"backup.{int(time())}" + backup_file = backup_dir / f"{backup_name}.json" + + # INFO: Image folder for playlist images + img_folder = backup_dir / "images" / backup_name + img_folder.mkdir(parents=True, exist_ok=True) + + favorites = FavoritesTable.get_all() + favorites = [asdict(entry) for entry in favorites] + + scrobbles = ScrobbleTable.get_all(start=0) + scrobbles = [asdict(entry) for entry in scrobbles] + + # SECTION: Playlists + playlists = PlaylistTable.get_all() + playlist_dicts = [] + + for entry in playlists: + playlist = asdict(entry) + for key in ["_last_updated", "has_image", "images", "duration", "count"]: + del playlist[key] + + playlist_dicts.append(playlist) + + # copy images + if playlist["thumb"]: + img_path = Path(Paths.get_playlist_img_path()) / playlist["thumb"] + shutil.copy(img_path, img_folder / playlist["thumb"]) + + # !SECTION + + data = { + "favorites": favorites, + "scrobbles": scrobbles, + "playlists": playlist_dicts, + } + + with open(backup_file, "w") as f: + json.dump(data, f, indent=4) + + return { + "msg": "Backup created", + "data_path": str(backup_file), + "images_path": str(img_folder), + }, 200 + + +@api.post("/restore") +@admin_required() +def restore(): + """ + Restore your favorites, playlists and scrobble data from a backup file. + """ + return {"msg": "Restore"} diff --git a/app/api/favorites.py b/app/api/favorites.py index 7e8aaaae..c23594a5 100644 --- a/app/api/favorites.py +++ b/app/api/favorites.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field from app.api.apischemas import GenericLimitSchema from app.db.libdata import TrackTable from app.db.userdata import FavoritesTable +from app.lib.extras import get_extra_info from app.models import FavType from app.settings import Defaults @@ -71,9 +72,12 @@ def toggle_favorite(body: FavoritesAddBody): """ Adds a favorite to the database. """ + extra = get_extra_info(body.hash, body.type) try: - FavoritesTable.insert_item({"hash": body.hash, "type": body.type}) + FavoritesTable.insert_item( + {"hash": body.hash, "type": body.type, "extra": extra} + ) except: return {"msg": "Failed! An error occured"}, 500 diff --git a/app/api/scrobble/__init__.py b/app/api/scrobble/__init__.py index 36400c2b..46ac1338 100644 --- a/app/api/scrobble/__init__.py +++ b/app/api/scrobble/__init__.py @@ -4,6 +4,7 @@ from pydantic import Field from app.api.apischemas import TrackHashSchema from app.db.userdata import ScrobbleTable +from app.lib.extras import get_extra_info from app.settings import Defaults from app.store.albums import AlbumStore from app.store.artists import ArtistStore @@ -39,7 +40,9 @@ def log_track(body: LogTrackBody): if trackentry is None: return {"msg": "Track not found."}, 404 - ScrobbleTable.add(dict(body)) + scrobble_data = dict(body) + scrobble_data["extra"] = get_extra_info(body.trackhash, "track") + ScrobbleTable.add(scrobble_data) # Update play data on the in-memory stores track = trackentry.tracks[0] diff --git a/app/api/stream.py b/app/api/stream.py index 69bb6240..a49a1ef8 100644 --- a/app/api/stream.py +++ b/app/api/stream.py @@ -247,23 +247,11 @@ def send_file_as_chunks(filepath: str) -> Response: while remaining_bytes > 0 or retry_count < max_retries: if retry_count == max_retries: print("💚 sending final chunk! ...") - return ( - file.read(os.path.getsize(filepath) - file.tell()), - file.tell(), - True, - ) - print("\n\n") - print(f"file: {filepath}") - print(f"start: {start}") - print(f"end: {end}") - print(f"filesize: {os.path.getsize(filepath)}") - print(f"⭐ (O) Remaining bytes: {remaining_bytes}") - print(f"⭐ Remaining bytes: {remaining_bytes}") - print(f"⭐ Cursor position: {file.tell()}") - # Read the chunk size or all the remaining bytes - print(f"💚 remaining_bytes: {remaining_bytes}") - print(f"💚 retry_count: {retry_count}") + pos = file.tell() + chunk = file.read(os.path.getsize(filepath) - pos) + + return chunk, pos, True if remaining_bytes < chunk_size: time.sleep(0.25) @@ -303,7 +291,6 @@ def send_file_as_chunks(filepath: str) -> Response: f"bytes {start}-{position}/{os.path.getsize(filepath) + bytes_to_add}", ) response.headers.add("Accept-Ranges", "bytes") - response.headers.add("Content-Length", str(len(data or []))) return response diff --git a/app/db/userdata.py b/app/db/userdata.py index 2d33ff1e..c1debdd8 100644 --- a/app/db/userdata.py +++ b/app/db/userdata.py @@ -266,7 +266,7 @@ class ScrobbleTable(Base): return cls.insert_one(item) @classmethod - def get_all(cls, start: int, limit: int | None): + def get_all(cls, start: int, limit: int | None = None): result = cls.execute( select(cls) .where(cls.userid == get_current_userid()) diff --git a/app/lib/extras.py b/app/lib/extras.py new file mode 100644 index 00000000..70ac58af --- /dev/null +++ b/app/lib/extras.py @@ -0,0 +1,37 @@ +from typing import Any +from app.store.albums import AlbumStore +from app.store.artists import ArtistStore +from app.store.tracks import TrackStore + + +def get_extra_info(hash: str, type: str): + """ + Generates extra info for a track, album or artist, which will be stored + in the database (in favorites, playlists and scrobble data) for backup and restore. + + The extra info contains all the fields needed to reconstruct the itemhash. The track contains an additional filepath field which can be used to locate the file when restoring. + """ + extra: dict[str, Any] = {} + + if type == "track": + trackentry = TrackStore.trackhashmap.get(hash) + if trackentry is not None: + track = trackentry.get_best() + + extra["filepath"] = track.filepath + extra["title"] = track.title + extra["artists"] = [a["name"] for a in track.artists] + extra["album"] = track.albumhash + + elif type == "album": + album = AlbumStore.get_album_by_hash(hash) + if album is not None: + extra["albumartists"] = [a["name"] for a in album.albumartists] + extra["title"] = album.title + + elif type == "artist": + artist = ArtistStore.get_artist_by_hash(hash) + if artist is not None: + extra["name"] = artist.name + + return extra