add docs for playlist endpoints

+ limit recently added tracks to 100
This commit is contained in:
mungai-njoroge
2024-03-10 19:44:12 +03:00
committed by Mungai Njoroge
parent 4edb3a5e7a
commit ae031014a9
8 changed files with 207 additions and 166 deletions
+1
View File
@@ -1,6 +1,7 @@
# What's New? # What's New?
- Hovering on recent favorite item will show how long ago it was ♥ed - 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 # Development
- API documentation on /openapi - API documentation on /openapi
+4 -2
View File
@@ -8,6 +8,8 @@ from flask_compress import Compress
from flask_openapi3 import Info from flask_openapi3 import Info
from flask_openapi3 import OpenAPI from flask_openapi3 import OpenAPI
from pydantic import BaseModel, Field
from flask_openapi3 import FileStorage
from app.settings import Keys from app.settings import Keys
from .plugins import lyrics as lyrics_plugin from .plugins import lyrics as lyrics_plugin
@@ -68,8 +70,8 @@ def create_api():
app.register_api(artist.api) app.register_api(artist.api)
app.register_api(send_file.api) app.register_api(send_file.api)
app.register_api(search.api) app.register_api(search.api)
app.register_blueprint(folder.api) app.register_api(folder.api)
app.register_blueprint(playlist.api) app.register_api(playlist.api)
app.register_blueprint(favorites.api) app.register_blueprint(favorites.api)
app.register_blueprint(imgserver.api) app.register_blueprint(imgserver.api)
app.register_blueprint(settings.api) app.register_blueprint(settings.api)
+6 -3
View File
@@ -26,7 +26,7 @@ api = APIBlueprint("artist", __name__, url_prefix="/artist", abp_tags=[bp_tag])
@api.get("/<string:artisthash>") @api.get("/<string:artisthash>")
def get_artist(path: ArtistHashSchema, query: TrackLimitSchema): def get_artist(path: ArtistHashSchema, query: TrackLimitSchema):
""" """
Get artist data. Get artist
Returns artist data, tracks and genres for the given artisthash. Returns artist data, tracks and genres for the given artisthash.
""" """
@@ -89,6 +89,9 @@ class GetArtistAlbumsQuery(AlbumLimitSchema):
@api.get("/<artisthash>/albums") @api.get("/<artisthash>/albums")
def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery): def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
"""
Get artist albums.
"""
return_all = query.all return_all = query.all
artisthash = path.artisthash artisthash = path.artisthash
@@ -177,7 +180,7 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
@api.get("/<artisthash>/tracks") @api.get("/<artisthash>/tracks")
def get_all_artist_tracks(path: ArtistHashSchema): def get_all_artist_tracks(path: ArtistHashSchema):
""" """
Get all artist tracks Get artist tracks
Returns all artists by a given artist. Returns all artists by a given artist.
""" """
@@ -189,7 +192,7 @@ def get_all_artist_tracks(path: ArtistHashSchema):
@api.get("/<artisthash>/similar") @api.get("/<artisthash>/similar")
def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema): def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema):
""" """
Returns similar artists. Get similar artists.
""" """
limit = query.limit limit = query.limit
+59 -37
View File
@@ -1,11 +1,14 @@
""" """
Contains all the folder routes. Contains all the folder routes.
""" """
import os import os
from pathlib import Path from pathlib import Path
import psutil 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 showinfm import show_in_file_manager
from app import settings 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.store.tracks import TrackStore as store
from app.utils.wintools import is_windows, win_replace_slash 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"]) class FolderTree(BaseModel):
def get_folder_tree(): 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. Returns a list of all the folders and tracks in the given folder.
""" """
data = request.get_json() req_dir = body.folder
req_dir = "$home" tracks_only = body.tracks_only
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"
root_dirs = db.get_root_dirs() root_dirs = db.get_root_dirs()
root_dirs.sort() root_dirs.sort()
@@ -92,18 +95,23 @@ def get_all_drives(is_win: bool = False):
return drives return drives
@api.route("/folder/dir-browser", methods=["POST"]) class DirBrowserBody(BaseModel):
def list_folders(): folder: str = Field(
""" "$root",
Returns a list of all the folders in the given folder. description="The folder to list directories from",
""" )
data = request.get_json()
is_win = is_windows()
try:
req_dir: str = data["folder"] @api.post("/folder/dir-browser")
except KeyError: def list_folders(body: DirBrowserBody):
req_dir = "$root" """
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": if req_dir == "$root":
return { return {
@@ -131,26 +139,40 @@ def list_folders():
} }
@api.route("/folder/show-in-files") class FolderOpenInFileManagerQuery(BaseModel):
def open_in_file_manager(): path: str = Field(
path = request.args.get("path") 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} return {"success": True}
@api.route("/folder/tracks/all") class GetTracksInPathQuery(BaseModel):
def get_tracks_in_path(): path: str = Field(
path = request.args.get("path") 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 = sorted(tracks, key=lambda i: i.last_mod)
tracks = (serialize_track(t) for t in tracks if Path(t.filepath).exists()) tracks = (serialize_track(t) for t in tracks if Path(t.filepath).exists())
+118 -110
View File
@@ -1,12 +1,15 @@
""" """
All playlist-related routes. All playlist-related routes.
""" """
import json import json
from datetime import datetime from datetime import datetime
import pathlib import pathlib
from flask import Blueprint, request
from PIL import UnidentifiedImageError, Image 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 import models
from app.db.sqlite.playlists import SQLitePlaylistMethods 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.utils.remove_duplicates import remove_duplicates
from app.settings import Paths 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 PL = SQLitePlaylistMethods
@api.route("/playlists", methods=["GET"]) class SendAllPlaylistsQuery(BaseModel):
def send_all_playlists(): no_images: bool = Field(False, description="Whether to include images")
@api.get("", methods=["GET"])
def send_all_playlists(query: SendAllPlaylistsQuery):
""" """
Gets all the playlists. Gets all the playlists.
""" """
no_images = request.args.get("no_images", False)
playlists = PL.get_all_playlists() playlists = PL.get_all_playlists()
playlists = list(playlists) playlists = list(playlists)
for playlist in playlists: for playlist in playlists:
if not no_images: if not query.no_images:
playlist.images = playlistlib.get_first_4_images( playlist.images = playlistlib.get_first_4_images(
trackhashes=playlist.trackhashes trackhashes=playlist.trackhashes
) )
@@ -69,22 +75,23 @@ def insert_playlist(name: str, image: str = None):
return PL.insert_one_playlist(playlist) return PL.insert_one_playlist(playlist)
@api.route("/playlist/new", methods=["POST"]) class CreatePlaylistBody(BaseModel):
def create_playlist(): 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. Creates a new playlist. Accepts POST method with a JSON body.
""" """
data = request.get_json() existing_playlist_count = PL.count_playlist_by_name(body.name)
if data is None:
return {"error": "Playlist name not provided"}, 400
existing_playlist_count = PL.count_playlist_by_name(data["name"])
if existing_playlist_count > 0: if existing_playlist_count > 0:
return {"error": "Playlist already exists"}, 409 return {"error": "Playlist already exists"}, 409
playlist = insert_playlist(data["name"]) playlist = insert_playlist(body.name)
if playlist is None: if playlist is None:
return {"error": "Playlist could not be created"}, 500 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] return [t.trackhash for t in tracks]
@api.route("/playlist/<playlist_id>/add", methods=["POST"]) class PlaylistIDPath(BaseModel):
def add_item_to_playlist(playlist_id: str): # 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("/<playlistid>/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() itemtype = body.itemtype
itemhash = body.itemhash
if data is None: playlist_id = path.playlistid
return {"error": "Track hash not provided"}, 400
try:
itemtype = data["itemtype"]
except KeyError:
itemtype = None
try:
itemhash: str = data["itemhash"]
except KeyError:
itemhash = None
if itemtype == "tracks": if itemtype == "tracks":
trackhashes = itemhash.split(",") trackhashes = itemhash.split(",")
@@ -158,13 +170,17 @@ def add_item_to_playlist(playlist_id: str):
return {"msg": "Done"}, 200 return {"msg": "Done"}, 200
@api.route("/playlist/<playlistid>") class GetPlaylistQuery(BaseModel):
def get_playlist(playlistid: str): no_tracks: bool = Field(False, description="Whether to include tracks")
@api.get("/<playlistid>")
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 = query.no_tracks
no_tracks = no_tracks == "true" playlistid = path.playlistid
is_recently_added = playlistid == "recentlyadded" is_recently_added = playlistid == "recentlyadded"
@@ -201,31 +217,40 @@ def get_playlist(playlistid: str):
return {"info": playlist, "tracks": tracks if not no_tracks else []} return {"info": playlist, "tracks": tracks if not no_tracks else []}
@api.route("/playlist/<playlistid>/update", methods=["PUT"]) class UpdatePlaylistForm(BaseModel):
def update_playlist_info(playlistid: str): image: FileStorage = Field(None, description="The image file")
if playlistid is None: name: str = Field(..., description="The name of the playlist")
return {"error": "Playlist ID not provided"}, 400 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("/<playlistid>/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: if db_playlist is None:
return {"error": "Playlist not found"}, 404 return {"error": "Playlist not found"}, 404
image = None image = form.image
if "image" in request.files: if form.image:
image = request.files["image"] image = form.image
data = request.form settings = json.loads(form.settings)
settings = json.loads(data.get("settings"))
settings["has_gif"] = False settings["has_gif"] = False
playlist = { playlist = {
"id": int(playlistid), "id": playlistid,
"image": db_playlist.image, "image": db_playlist.image,
"last_updated": create_new_date(), "last_updated": create_new_date(),
"name": str(data.get("name")).strip(), "name": str(form.name).strip(),
"settings": settings, "settings": settings,
"trackhashes": json.dumps([]), "trackhashes": json.dumps([]),
} }
@@ -247,7 +272,7 @@ def update_playlist_info(playlistid: str):
p_tuple = (*playlist.values(),) p_tuple = (*playlist.values(),)
PL.update_playlist(int(playlistid), playlist) PL.update_playlist(playlistid, playlist)
playlist = models.Playlist(*p_tuple) playlist = models.Playlist(*p_tuple)
playlist.last_updated = date_string_to_time_passed(playlist.last_updated) playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
@@ -257,12 +282,12 @@ def update_playlist_info(playlistid: str):
} }
@api.route("/playlist/<playlistid>/pin_unpin", methods=["GET"]) @api.post("/<playlistid>/pin_unpin")
def pin_unpin_playlist(playlistid: str): 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: if playlist is None:
return {"error": "Playlist not found"}, 404 return {"error": "Playlist not found"}, 404
@@ -274,23 +299,22 @@ def pin_unpin_playlist(playlistid: str):
except KeyError: except KeyError:
settings["pinned"] = True settings["pinned"] = True
PL.update_settings(int(playlistid), settings) PL.update_settings(path.playlistid, settings)
return {"msg": "Done"}, 200 return {"msg": "Done"}, 200
@api.route("/playlist/<playlistid>/remove-img", methods=["GET"]) @api.delete("/<playlistid>/remove-img")
def remove_playlist_image(playlistid: str): def remove_playlist_image(path: PlaylistIDPath):
""" """
Removes the playlist image. Clear playlist image.
""" """
pid = int(playlistid) playlist = PL.get_playlist_by_id(path.playlistid)
playlist = PL.get_playlist_by_id(pid)
if playlist is None: if playlist is None:
return {"error": "Playlist not found"}, 404 return {"error": "Playlist not found"}, 404
PL.remove_banner(pid) PL.remove_banner(path.playlistid)
playlist.image = None playlist.image = None
playlist.thumb = None playlist.thumb = None
@@ -303,78 +327,62 @@ def remove_playlist_image(playlistid: str):
return {"playlist": playlist}, 200 return {"playlist": playlist}, 200
@api.route("/playlist/delete", methods=["POST"]) @api.delete("/<playlistid>/delete", methods=["DELETE"])
def remove_playlist(): def remove_playlist(path: PlaylistIDPath):
""" """
Deletes a playlist by ID. Delete playlist
""" """
message = {"error": "Playlist ID not provided"} PL.delete_playlist(path.playlistid)
data = request.get_json()
if data is None:
return message, 400
try:
pid = data["pid"]
except KeyError:
return message, 400
PL.delete_playlist(pid)
return {"msg": "Done"}, 200 return {"msg": "Done"}, 200
@api.route("/playlist/<pid>/remove-tracks", methods=["POST"]) class RemoveTracksFromPlaylistBody(BaseModel):
def remove_tracks_from_playlist(pid: int): tracks: list[dict] = Field(..., description="A list of trackhashes to remove")
data = request.get_json()
if data is None:
return {"error": "Track index not provided"}, 400
@api.post("/<playlistid>/remove-tracks")
def remove_tracks_from_playlist(
path: PlaylistIDPath, body: RemoveTracksFromPlaylistBody
):
"""
Remove track from playlist
"""
# A track looks like this:
# { # {
# trackhash: str; # trackhash: str;
# index: int; # index: int;
# } # }
tracks = data["tracks"] PL.remove_tracks_from_playlist(path.playlistid, body.tracks)
PL.remove_tracks_from_playlist(pid, tracks)
return {"msg": "Done"}, 200 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 return PL.count_playlist_by_name(name) > 0
@api.route("/playlist/save-item", methods=["POST"]) class SavePlaylistAsItemBody(BaseModel):
def save_item_as_playlist(): itemtype: str = Field(..., description="The type of item", example="tracks")
data = request.get_json() playlist_name: str = Field(..., description="The name of the playlist")
msg = {"error": "'itemtype', 'playlist_name' and 'itemhash' not provided"}, 400 itemhash: str = Field(..., description="The hash of the item to save")
if data is None:
return msg
try: @api.post("/save-item")
playlist_name = data["playlist_name"] def save_item_as_playlist(body: SavePlaylistAsItemBody):
except KeyError: """
playlist_name = None 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 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": if itemtype == "tracks":
trackhashes = itemhash.split(",") trackhashes = itemhash.split(",")
elif itemtype == "folder": elif itemtype == "folder":
+9 -5
View File
@@ -3,8 +3,8 @@ Contains all the search routes.
""" """
from flask import request from flask import request
from pydantic import BaseModel, Field
from unidecode import unidecode from unidecode import unidecode
from pydantic import BaseModel, Field
from flask_openapi3 import Tag from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint from flask_openapi3 import APIBlueprint
@@ -75,7 +75,7 @@ class SearchQuery(BaseModel):
@api.get("/tracks") @api.get("/tracks")
def search_tracks(query: SearchQuery): def search_tracks(query: SearchQuery):
""" """
Searches for tracks Search tracks
""" """
query = query.q query = query.q
@@ -92,7 +92,7 @@ def search_tracks(query: SearchQuery):
@api.get("/albums") @api.get("/albums")
def search_albums(query: SearchQuery): def search_albums(query: SearchQuery):
""" """
Searches for albums. Search albums.
""" """
query = query.q query = query.q
@@ -109,7 +109,7 @@ def search_albums(query: SearchQuery):
@api.get("/artists") @api.get("/artists")
def search_artists(query: SearchQuery): def search_artists(query: SearchQuery):
""" """
Searches for artists. Search artists.
""" """
query = query.q query = query.q
@@ -134,7 +134,9 @@ class TopResultsQuery(SearchQuery):
@api.get("/top") @api.get("/top")
def get_top_results(query: TopResultsQuery): 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 query = query.q
@@ -156,6 +158,8 @@ class SearchLoadMoreQuery(SearchQuery):
@api.get("/loadmore") @api.get("/loadmore")
def search_load_more(query: SearchLoadMoreQuery): def search_load_more(query: SearchLoadMoreQuery):
""" """
Load more
Returns more songs, albums or artists from a search query. Returns more songs, albums or artists from a search query.
NOTE: You must first initiate a search using the `/search` endpoint. NOTE: You must first initiate a search using the `/search` endpoint.
+3 -5
View File
@@ -12,7 +12,7 @@ from app.serializers.artist import serialize_for_card
from itertools import groupby 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_albums = set()
older_artists = set() older_artists = set()
@@ -216,8 +216,6 @@ def get_recent_items(limit: int = 7):
return recent_items 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) tracks = sorted(TrackStore.tracks, key=lambda t: t.created_date, reverse=True)
timestamp = timestamp_from_days_ago(cutoff_days) return tracks[:limit]
return [t for t in tracks if t.created_date > timestamp]
+7 -4
View File
@@ -1,6 +1,7 @@
""" """
This library contains all the functions related to playlists. This library contains all the functions related to playlists.
""" """
import os import os
import random import random
import string import string
@@ -62,7 +63,7 @@ def create_gif_thumbnail(image: Any, img_path: str):
def save_p_image( 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: ) -> str:
""" """
Saves a playlist banner image and returns the filepath. 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)) random_str = "".join(random.choices(string.ascii_letters + string.digits, k=5))
if not filename: 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) 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) return duplicate_images(images)
def get_recently_added_playlist(cutoff: int = 14): def get_recently_added_playlist(limit: int = 100):
playlist = Playlist( playlist = Playlist(
id="recentlyadded", id="recentlyadded",
name="Recently Added", name="Recently Added",
@@ -144,8 +145,10 @@ def get_recently_added_playlist(cutoff: int = 14):
trackhashes=[], trackhashes=[],
) )
tracks = get_recent_tracks(cutoff) tracks = get_recent_tracks(limit=limit)
try: try:
# Create date to show as last updated
date = datetime.fromtimestamp(tracks[0].created_date) date = datetime.fromtimestamp(tracks[0].created_date)
except IndexError: except IndexError:
return playlist, [] return playlist, []