From ae031014a90ea0792ee1a76f06fb2472f0da73a2 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Sun, 10 Mar 2024 19:44:12 +0300 Subject: [PATCH] add docs for playlist endpoints + limit recently added tracks to 100 --- .github/changelog.md | 1 + app/api/__init__.py | 6 +- app/api/artist.py | 9 +- app/api/folder.py | 96 ++++++++------ app/api/playlist.py | 228 ++++++++++++++++++---------------- app/api/search.py | 14 ++- app/lib/home/recentlyadded.py | 8 +- app/lib/playlistlib.py | 11 +- 8 files changed, 207 insertions(+), 166 deletions(-) diff --git a/.github/changelog.md b/.github/changelog.md index 1d087f45..1d00e7f1 100644 --- a/.github/changelog.md +++ b/.github/changelog.md @@ -1,6 +1,7 @@ # What's New? - Hovering on recent favorite item will show how long ago it was ♥ed +- Recently added playlist returns a max of 100 tracks, but without a cutoff period # Development - API documentation on /openapi \ No newline at end of file diff --git a/app/api/__init__.py b/app/api/__init__.py index 9cc6db1f..0366e416 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -8,6 +8,8 @@ from flask_compress import Compress from flask_openapi3 import Info from flask_openapi3 import OpenAPI +from pydantic import BaseModel, Field +from flask_openapi3 import FileStorage from app.settings import Keys from .plugins import lyrics as lyrics_plugin @@ -68,8 +70,8 @@ def create_api(): app.register_api(artist.api) app.register_api(send_file.api) app.register_api(search.api) - app.register_blueprint(folder.api) - app.register_blueprint(playlist.api) + app.register_api(folder.api) + app.register_api(playlist.api) app.register_blueprint(favorites.api) app.register_blueprint(imgserver.api) app.register_blueprint(settings.api) diff --git a/app/api/artist.py b/app/api/artist.py index eb425f9e..684cb6ee 100644 --- a/app/api/artist.py +++ b/app/api/artist.py @@ -26,7 +26,7 @@ api = APIBlueprint("artist", __name__, url_prefix="/artist", abp_tags=[bp_tag]) @api.get("/") def get_artist(path: ArtistHashSchema, query: TrackLimitSchema): """ - Get artist data. + Get artist Returns artist data, tracks and genres for the given artisthash. """ @@ -89,6 +89,9 @@ class GetArtistAlbumsQuery(AlbumLimitSchema): @api.get("//albums") def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery): + """ + Get artist albums. + """ return_all = query.all artisthash = path.artisthash @@ -177,7 +180,7 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery): @api.get("//tracks") def get_all_artist_tracks(path: ArtistHashSchema): """ - Get all artist tracks + Get artist tracks Returns all artists by a given artist. """ @@ -189,7 +192,7 @@ def get_all_artist_tracks(path: ArtistHashSchema): @api.get("//similar") def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema): """ - Returns similar artists. + Get similar artists. """ limit = query.limit diff --git a/app/api/folder.py b/app/api/folder.py index 42c7da0c..29aed260 100644 --- a/app/api/folder.py +++ b/app/api/folder.py @@ -1,11 +1,14 @@ """ Contains all the folder routes. """ + import os from pathlib import Path import psutil -from flask import Blueprint, request +from pydantic import BaseModel, Field +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint from showinfm import show_in_file_manager from app import settings @@ -15,24 +18,24 @@ from app.serializers.track import serialize_track from app.store.tracks import TrackStore as store from app.utils.wintools import is_windows, win_replace_slash -api = Blueprint("folder", __name__, url_prefix="") +tag = Tag(name="Folders", description="Get folders and tracks in a directory") +api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag]) -@api.route("/folder", methods=["POST"]) -def get_folder_tree(): +class FolderTree(BaseModel): + folder: str = Field("$home", description="The folder to things from") + tracks_only: bool = Field(False, description="Whether to only get tracks") + + +@api.post("") +def get_folder_tree(body: FolderTree): """ + Get folder + Returns a list of all the folders and tracks in the given folder. """ - data = request.get_json() - req_dir = "$home" - - tracks_only = False - if data is not None: - try: - req_dir: str = data["folder"] - tracks_only: bool = data["tracks_only"] - except KeyError: - req_dir = "$home" + req_dir = body.folder + tracks_only = body.tracks_only root_dirs = db.get_root_dirs() root_dirs.sort() @@ -92,18 +95,23 @@ def get_all_drives(is_win: bool = False): return drives -@api.route("/folder/dir-browser", methods=["POST"]) -def list_folders(): - """ - Returns a list of all the folders in the given folder. - """ - data = request.get_json() - is_win = is_windows() +class DirBrowserBody(BaseModel): + folder: str = Field( + "$root", + description="The folder to list directories from", + ) - try: - req_dir: str = data["folder"] - except KeyError: - req_dir = "$root" + +@api.post("/folder/dir-browser") +def list_folders(body: DirBrowserBody): + """ + List folders + + Returns a list of all the folders in the given folder. + Used when selecting root dirs. + """ + req_dir = body.folder + is_win = is_windows() if req_dir == "$root": return { @@ -131,26 +139,40 @@ def list_folders(): } -@api.route("/folder/show-in-files") -def open_in_file_manager(): - path = request.args.get("path") +class FolderOpenInFileManagerQuery(BaseModel): + path: str = Field( + description="The path to open in the file manager", + ) - if path is None: - return {"error": "No path provided."}, 400 - show_in_file_manager(path) +@api.get("/folder/show-in-files") +def open_in_file_manager(query: FolderOpenInFileManagerQuery): + """ + Open in file manager + + Opens the given path in the file manager on the host machine. + """ + show_in_file_manager(query.path) return {"success": True} -@api.route("/folder/tracks/all") -def get_tracks_in_path(): - path = request.args.get("path") +class GetTracksInPathQuery(BaseModel): + path: str = Field( + description="The path to get tracks from", + ) - if path is None: - return {"error": "No path provided."}, 400 - tracks = store.get_tracks_in_path(path) +@api.get("/folder/tracks/all") +def get_tracks_in_path(query: GetTracksInPathQuery): + """ + Get tracks in path + + Gets all (or a max of 300) tracks from the given path and its subdirectories. + + Used when adding tracks to the queue. + """ + tracks = store.get_tracks_in_path(query.path) tracks = sorted(tracks, key=lambda i: i.last_mod) tracks = (serialize_track(t) for t in tracks if Path(t.filepath).exists()) diff --git a/app/api/playlist.py b/app/api/playlist.py index f4ec7c4b..0247901b 100644 --- a/app/api/playlist.py +++ b/app/api/playlist.py @@ -1,12 +1,15 @@ """ All playlist-related routes. """ + import json from datetime import datetime import pathlib -from flask import Blueprint, request from PIL import UnidentifiedImageError, Image +from pydantic import BaseModel, Field +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint, FileStorage from app import models from app.db.sqlite.playlists import SQLitePlaylistMethods @@ -18,23 +21,26 @@ from app.utils.dates import create_new_date, date_string_to_time_passed from app.utils.remove_duplicates import remove_duplicates from app.settings import Paths -api = Blueprint("playlist", __name__, url_prefix="/") +tag = Tag(name="Playlists", description="Get and manage playlists") +api = APIBlueprint("playlists", __name__, url_prefix="/playlists", abp_tags=[tag]) PL = SQLitePlaylistMethods -@api.route("/playlists", methods=["GET"]) -def send_all_playlists(): +class SendAllPlaylistsQuery(BaseModel): + no_images: bool = Field(False, description="Whether to include images") + + +@api.get("", methods=["GET"]) +def send_all_playlists(query: SendAllPlaylistsQuery): """ Gets all the playlists. """ - no_images = request.args.get("no_images", False) - playlists = PL.get_all_playlists() playlists = list(playlists) for playlist in playlists: - if not no_images: + if not query.no_images: playlist.images = playlistlib.get_first_4_images( trackhashes=playlist.trackhashes ) @@ -69,22 +75,23 @@ def insert_playlist(name: str, image: str = None): return PL.insert_one_playlist(playlist) -@api.route("/playlist/new", methods=["POST"]) -def create_playlist(): +class CreatePlaylistBody(BaseModel): + name: str = Field(..., description="The name of the playlist") + + +@api.post("/new") +def create_playlist(body: CreatePlaylistBody): """ + New playlist + Creates a new playlist. Accepts POST method with a JSON body. """ - data = request.get_json() - - if data is None: - return {"error": "Playlist name not provided"}, 400 - - existing_playlist_count = PL.count_playlist_by_name(data["name"]) + existing_playlist_count = PL.count_playlist_by_name(body.name) if existing_playlist_count > 0: return {"error": "Playlist already exists"}, 409 - playlist = insert_playlist(data["name"]) + playlist = insert_playlist(body.name) if playlist is None: return {"error": "Playlist could not be created"}, 500 @@ -119,25 +126,30 @@ def get_artist_trackhashes(artisthash: str): return [t.trackhash for t in tracks] -@api.route("/playlist//add", methods=["POST"]) -def add_item_to_playlist(playlist_id: str): +class PlaylistIDPath(BaseModel): + # INFO: playlistid string examples: "recentlyadded" + playlistid: int | str = Field(..., description="The ID of the playlist") + + +class AddItemToPlaylistBody(BaseModel): + itemtype: str = Field( + default="tracks", + description="The type of item to add", + examples=["tracks", "folder", "album", "artist"], + ) + itemhash: str = Field(..., description="The hash of the item to add") + + +@api.post("//add") +def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody): """ - Takes a playlist ID and a track hash, and adds the track to the playlist + Add to playlist. + + If itemtype is not "tracks", itemhash is expected to be a folder, album or artist hash. """ - data = request.get_json() - - if data is None: - return {"error": "Track hash not provided"}, 400 - - try: - itemtype = data["itemtype"] - except KeyError: - itemtype = None - - try: - itemhash: str = data["itemhash"] - except KeyError: - itemhash = None + itemtype = body.itemtype + itemhash = body.itemhash + playlist_id = path.playlistid if itemtype == "tracks": trackhashes = itemhash.split(",") @@ -158,13 +170,17 @@ def add_item_to_playlist(playlist_id: str): return {"msg": "Done"}, 200 -@api.route("/playlist/") -def get_playlist(playlistid: str): +class GetPlaylistQuery(BaseModel): + no_tracks: bool = Field(False, description="Whether to include tracks") + + +@api.get("/") +def get_playlist(path: PlaylistIDPath, query: GetPlaylistQuery): """ - Gets a playlist by id, and if it exists, it gets all the tracks in the playlist and returns them. + Get playlist by id """ - no_tracks = request.args.get("no_tracks", "false") - no_tracks = no_tracks == "true" + no_tracks = query.no_tracks + playlistid = path.playlistid is_recently_added = playlistid == "recentlyadded" @@ -201,31 +217,40 @@ def get_playlist(playlistid: str): return {"info": playlist, "tracks": tracks if not no_tracks else []} -@api.route("/playlist//update", methods=["PUT"]) -def update_playlist_info(playlistid: str): - if playlistid is None: - return {"error": "Playlist ID not provided"}, 400 +class UpdatePlaylistForm(BaseModel): + image: FileStorage = Field(None, description="The image file") + name: str = Field(..., description="The name of the playlist") + settings: str = Field( + ..., + description="The settings of the playlist", + example='{"has_gif": false, "banner_pos": 50, "square_img": false, "pinned": false}', + ) - db_playlist = PL.get_playlist_by_id(int(playlistid)) + +@api.put("//update", methods=["PUT"]) +def update_playlist_info(path: PlaylistIDPath, form: UpdatePlaylistForm): + """ + Update playlist + """ + playlistid = path.playlistid + db_playlist = PL.get_playlist_by_id(playlistid) if db_playlist is None: return {"error": "Playlist not found"}, 404 - image = None + image = form.image - if "image" in request.files: - image = request.files["image"] + if form.image: + image = form.image - data = request.form - - settings = json.loads(data.get("settings")) + settings = json.loads(form.settings) settings["has_gif"] = False playlist = { - "id": int(playlistid), + "id": playlistid, "image": db_playlist.image, "last_updated": create_new_date(), - "name": str(data.get("name")).strip(), + "name": str(form.name).strip(), "settings": settings, "trackhashes": json.dumps([]), } @@ -247,7 +272,7 @@ def update_playlist_info(playlistid: str): p_tuple = (*playlist.values(),) - PL.update_playlist(int(playlistid), playlist) + PL.update_playlist(playlistid, playlist) playlist = models.Playlist(*p_tuple) playlist.last_updated = date_string_to_time_passed(playlist.last_updated) @@ -257,12 +282,12 @@ def update_playlist_info(playlistid: str): } -@api.route("/playlist//pin_unpin", methods=["GET"]) -def pin_unpin_playlist(playlistid: str): +@api.post("//pin_unpin") +def pin_unpin_playlist(path: PlaylistIDPath): """ - Pins or unpins a playlist. + Pin playlist. """ - playlist = PL.get_playlist_by_id(int(playlistid)) + playlist = PL.get_playlist_by_id(path.playlistid) if playlist is None: return {"error": "Playlist not found"}, 404 @@ -274,23 +299,22 @@ def pin_unpin_playlist(playlistid: str): except KeyError: settings["pinned"] = True - PL.update_settings(int(playlistid), settings) + PL.update_settings(path.playlistid, settings) return {"msg": "Done"}, 200 -@api.route("/playlist//remove-img", methods=["GET"]) -def remove_playlist_image(playlistid: str): +@api.delete("//remove-img") +def remove_playlist_image(path: PlaylistIDPath): """ - Removes the playlist image. + Clear playlist image. """ - pid = int(playlistid) - playlist = PL.get_playlist_by_id(pid) + playlist = PL.get_playlist_by_id(path.playlistid) if playlist is None: return {"error": "Playlist not found"}, 404 - PL.remove_banner(pid) + PL.remove_banner(path.playlistid) playlist.image = None playlist.thumb = None @@ -303,78 +327,62 @@ def remove_playlist_image(playlistid: str): return {"playlist": playlist}, 200 -@api.route("/playlist/delete", methods=["POST"]) -def remove_playlist(): +@api.delete("//delete", methods=["DELETE"]) +def remove_playlist(path: PlaylistIDPath): """ - Deletes a playlist by ID. + Delete playlist """ - message = {"error": "Playlist ID not provided"} - data = request.get_json() - - if data is None: - return message, 400 - - try: - pid = data["pid"] - except KeyError: - return message, 400 - - PL.delete_playlist(pid) + PL.delete_playlist(path.playlistid) return {"msg": "Done"}, 200 -@api.route("/playlist//remove-tracks", methods=["POST"]) -def remove_tracks_from_playlist(pid: int): - data = request.get_json() +class RemoveTracksFromPlaylistBody(BaseModel): + tracks: list[dict] = Field(..., description="A list of trackhashes to remove") - if data is None: - return {"error": "Track index not provided"}, 400 +@api.post("//remove-tracks") +def remove_tracks_from_playlist( + path: PlaylistIDPath, body: RemoveTracksFromPlaylistBody +): + """ + Remove track from playlist + """ + # A track looks like this: # { # trackhash: str; # index: int; # } - tracks = data["tracks"] - PL.remove_tracks_from_playlist(pid, tracks) + PL.remove_tracks_from_playlist(path.playlistid, body.tracks) return {"msg": "Done"}, 200 -def playlist_exists(name: str) -> bool: +def playlist_name_exists(name: str) -> bool: return PL.count_playlist_by_name(name) > 0 -@api.route("/playlist/save-item", methods=["POST"]) -def save_item_as_playlist(): - data = request.get_json() - msg = {"error": "'itemtype', 'playlist_name' and 'itemhash' not provided"}, 400 +class SavePlaylistAsItemBody(BaseModel): + itemtype: str = Field(..., description="The type of item", example="tracks") + playlist_name: str = Field(..., description="The name of the playlist") + itemhash: str = Field(..., description="The hash of the item to save") - if data is None: - return msg - try: - playlist_name = data["playlist_name"] - except KeyError: - playlist_name = None +@api.post("/save-item") +def save_item_as_playlist(body: SavePlaylistAsItemBody): + """ + Save as playlist - if playlist_exists(playlist_name): + Saves a track, album, artist or folder as a playlist + """ + itemtype = body.itemtype + playlist_name = body.playlist_name + itemhash = body.itemhash + + if playlist_name_exists(playlist_name): return {"error": "Playlist already exists"}, 409 - try: - itemtype = data["itemtype"] - except KeyError: - itemtype = None - - try: - itemhash: str = data["itemhash"] - except KeyError: - itemhash = None - - if itemtype is None or playlist_name is None or itemhash is None: - return msg - if itemtype == "tracks": trackhashes = itemhash.split(",") elif itemtype == "folder": diff --git a/app/api/search.py b/app/api/search.py index 3b55a82b..cff4cee1 100644 --- a/app/api/search.py +++ b/app/api/search.py @@ -3,8 +3,8 @@ Contains all the search routes. """ from flask import request -from pydantic import BaseModel, Field from unidecode import unidecode +from pydantic import BaseModel, Field from flask_openapi3 import Tag from flask_openapi3 import APIBlueprint @@ -75,7 +75,7 @@ class SearchQuery(BaseModel): @api.get("/tracks") def search_tracks(query: SearchQuery): """ - Searches for tracks + Search tracks """ query = query.q @@ -92,7 +92,7 @@ def search_tracks(query: SearchQuery): @api.get("/albums") def search_albums(query: SearchQuery): """ - Searches for albums. + Search albums. """ query = query.q @@ -109,7 +109,7 @@ def search_albums(query: SearchQuery): @api.get("/artists") def search_artists(query: SearchQuery): """ - Searches for artists. + Search artists. """ query = query.q @@ -134,7 +134,9 @@ class TopResultsQuery(SearchQuery): @api.get("/top") def get_top_results(query: TopResultsQuery): """ - Returns the top results for the search query. + Get top results + + Returns the top results for the given query. """ query = query.q @@ -156,6 +158,8 @@ class SearchLoadMoreQuery(SearchQuery): @api.get("/loadmore") def search_load_more(query: SearchLoadMoreQuery): """ + Load more + Returns more songs, albums or artists from a search query. NOTE: You must first initiate a search using the `/search` endpoint. diff --git a/app/lib/home/recentlyadded.py b/app/lib/home/recentlyadded.py index cedddb07..4e7b5e39 100644 --- a/app/lib/home/recentlyadded.py +++ b/app/lib/home/recentlyadded.py @@ -12,7 +12,7 @@ from app.serializers.artist import serialize_for_card from itertools import groupby -from app.utils.dates import timestamp_from_days_ago, timestamp_to_time_passed +from app.utils.dates import timestamp_to_time_passed older_albums = set() older_artists = set() @@ -216,8 +216,6 @@ def get_recent_items(limit: int = 7): return recent_items -def get_recent_tracks(cutoff_days: int): +def get_recent_tracks(limit: int): tracks = sorted(TrackStore.tracks, key=lambda t: t.created_date, reverse=True) - timestamp = timestamp_from_days_ago(cutoff_days) - - return [t for t in tracks if t.created_date > timestamp] + return tracks[:limit] \ No newline at end of file diff --git a/app/lib/playlistlib.py b/app/lib/playlistlib.py index f6a5a506..fc08e76e 100644 --- a/app/lib/playlistlib.py +++ b/app/lib/playlistlib.py @@ -1,6 +1,7 @@ """ This library contains all the functions related to playlists. """ + import os import random import string @@ -62,7 +63,7 @@ def create_gif_thumbnail(image: Any, img_path: str): def save_p_image( - img: Image, pid: str, content_type: str = None, filename: str = None + img: Image, pid: int, content_type: str = None, filename: str = None ) -> str: """ Saves a playlist banner image and returns the filepath. @@ -72,7 +73,7 @@ def save_p_image( random_str = "".join(random.choices(string.ascii_letters + string.digits, k=5)) if not filename: - filename = pid + str(random_str) + ".webp" + filename = str(pid) + str(random_str) + ".webp" full_img_path = os.path.join(settings.Paths.get_playlist_img_path(), filename) @@ -134,7 +135,7 @@ def get_first_4_images( return duplicate_images(images) -def get_recently_added_playlist(cutoff: int = 14): +def get_recently_added_playlist(limit: int = 100): playlist = Playlist( id="recentlyadded", name="Recently Added", @@ -144,8 +145,10 @@ def get_recently_added_playlist(cutoff: int = 14): trackhashes=[], ) - tracks = get_recent_tracks(cutoff) + tracks = get_recent_tracks(limit=limit) + try: + # Create date to show as last updated date = datetime.fromtimestamp(tracks[0].created_date) except IndexError: return playlist, []