From 0af1ae1d8e4af6f3d6b10d04ac5d80d523eb4963 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Sun, 24 Mar 2024 15:57:58 +0300 Subject: [PATCH] Finish documentation for all endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit + fix #193 (settings https redirect) + fix open api docs on binary + fix git error on binary + remove flask-restful hopefully, I didn't break something 😩 --- app/api/__init__.py | 23 +++-- app/api/artist.py | 3 +- app/api/colors.py | 16 +++- app/api/favorites.py | 154 +++++++++++++++--------------- app/api/getall/__init__.py | 125 +++++++++++++++++++++++- app/api/getall/resources.py | 93 ------------------ app/api/home/__init__.py | 28 ++++-- app/api/home/recents.py | 24 ----- app/api/imgserver.py | 118 +++++++++++++++-------- app/api/logger/__init__.py | 39 ++++++-- app/api/logger/tracks.py | 19 ---- app/api/lyrics.py | 45 +++++---- app/api/plugins/__init__.py | 53 +++++++--- app/api/plugins/lyrics.py | 39 +++++--- app/api/settings.py | 72 +++++++++----- app/arg_handler.py | 12 ++- app/configs.py | 2 + app/db/sqlite/plugins/__init__.py | 4 +- app/settings.py | 36 ++++++- app/utils/paths.py | 12 +++ poetry.lock | 47 +-------- pyproject.toml | 1 - 22 files changed, 547 insertions(+), 418 deletions(-) delete mode 100644 app/api/getall/resources.py delete mode 100644 app/api/home/recents.py delete mode 100644 app/api/logger/tracks.py create mode 100644 app/utils/paths.py diff --git a/app/api/__init__.py b/app/api/__init__.py index 0366e416..64c550ad 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -8,8 +8,6 @@ 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 @@ -46,6 +44,7 @@ In endpoints that request multiple lists of items, this represents the number of [MIT License](https://github.com/swing-opensource/swingmusic?tab=MIT-1-ov-file#MIT-1-ov-file) | Copyright (c) {datetime.datetime.now().year} [Mungai Njoroge](https://mungai.vercel.app) """ + def create_api(): """ Creates the Flask instance, registers modules and registers all the API blueprints. @@ -72,23 +71,23 @@ def create_api(): app.register_api(search.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) - app.register_blueprint(colors.api) - app.register_blueprint(lyrics.api) + app.register_api(favorites.api) + app.register_api(imgserver.api) + app.register_api(settings.api) + app.register_api(colors.api) + app.register_api(lyrics.api) # Plugins - app.register_blueprint(plugins.api) - app.register_blueprint(lyrics_plugin.api) + app.register_api(plugins.api) + app.register_api(lyrics_plugin.api) # Logger - app.register_blueprint(logger.api_bp) + app.register_api(logger.api) # Home - app.register_blueprint(home.api_bp) + app.register_api(home.api) # Flask Restful - app.register_blueprint(getall.api_bp) + app.register_api(getall.api) return app diff --git a/app/api/artist.py b/app/api/artist.py index 684cb6ee..a86c01dd 100644 --- a/app/api/artist.py +++ b/app/api/artist.py @@ -98,7 +98,6 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery): limit = query.limit all_albums = AlbumStore.get_albums_by_artisthash(artisthash) - # start: check for missing albums. ie. compilations and features all_tracks = TrackStore.get_tracks_by_artisthash(artisthash) @@ -163,7 +162,7 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery): if artist is None: return {"error": "Artist not found"}, 404 - if return_all is not None and return_all == "true": + if return_all: limit = len(all_albums) singles_and_eps = singles + eps diff --git a/app/api/colors.py b/app/api/colors.py index 6513f908..c52bfcf9 100644 --- a/app/api/colors.py +++ b/app/api/colors.py @@ -1,12 +1,18 @@ -from flask import Blueprint +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from app.api.apischemas import AlbumHashSchema from app.store.albums import AlbumStore as Store -api = Blueprint("colors", __name__, url_prefix="/colors") +bp_tag = Tag(name="Colors", description="Get item colors") +api = APIBlueprint("colors", __name__, url_prefix="/colors", abp_tags=[bp_tag]) -@api.route("/album/") -def get_album_color(albumhash: str): - album = Store.get_album_by_hash(albumhash) +@api.get("/album/") +def get_album_color(path: AlbumHashSchema): + """ + Get album color + """ + album = Store.get_album_by_hash(path.albumhash) msg = {"color": ""} diff --git a/app/api/favorites.py b/app/api/favorites.py index 6add5aa7..2dfe9dbe 100644 --- a/app/api/favorites.py +++ b/app/api/favorites.py @@ -1,7 +1,12 @@ from typing import List, TypeVar -from flask import Blueprint, request +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import BaseModel, Field + +from app.api.apischemas import GenericLimitSchema from app.models import FavType +from app.settings import Defaults from app.utils.bisection import use_bisection from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.serializers.track import serialize_track, serialize_tracks @@ -13,8 +18,8 @@ from app.store.tracks import TrackStore from app.store.artists import ArtistStore from app.utils.dates import timestamp_to_time_passed - -api = Blueprint("favorite", __name__, url_prefix="/") +bp_tag = Tag(name="Favorites", description="Your favorite items") +api = APIBlueprint("favorites", __name__, url_prefix="/favorites", abp_tags=[bp_tag]) T = TypeVar("T") @@ -24,18 +29,23 @@ def remove_none(items: List[T]) -> List[T]: return [i for i in items if i is not None] -@api.route("/favorite/add", methods=["POST"]) -def add_favorite(): +class FavoritesAddBody(BaseModel): + hash: str = Field( + description="The hash of the item", + min_length=Defaults.HASH_LENGTH, + max_length=Defaults.HASH_LENGTH, + example=Defaults.API_ALBUMHASH, + ) + type: str = Field(description="The type of the item", example=FavType.album) + + +@api.post("/add") +def add_favorite(body: FavoritesAddBody): """ Adds a favorite to the database. """ - data = request.get_json() - - if data is None: - return {"error": "No data provided"}, 400 - - itemhash = data.get("hash") - itemtype = data.get("type") + itemhash = body.hash + itemtype = body.type favdb.insert_one_favorite(itemtype, itemhash) @@ -45,18 +55,13 @@ def add_favorite(): return {"msg": "Added to favorites"} -@api.route("/favorite/remove", methods=["POST"]) -def remove_favorite(): +@api.post("/remove") +def remove_favorite(body: FavoritesAddBody): """ Removes a favorite from the database. """ - data = request.get_json() - - if data is None: - return {"error": "No data provided"}, 400 - - itemhash = data.get("hash") - itemtype = data.get("type") + itemhash = body.hash + itemtype = body.type favdb.delete_favorite(itemtype, itemhash) @@ -66,15 +71,12 @@ def remove_favorite(): return {"msg": "Removed from favorites"} -@api.route("/albums/favorite") -def get_favorite_albums(): - limit = request.args.get("limit") - - if limit is None: - limit = 6 - - limit = int(limit) - +@api.get("/albums") +def get_favorite_albums(query: GenericLimitSchema): + """ + Get favorite albums + """ + limit = query.limit albums = favdb.get_fav_albums() albumhashes = [a[1] for a in albums] albumhashes.reverse() @@ -90,15 +92,12 @@ def get_favorite_albums(): return {"albums": serialize_for_card_many(fav_albums[:limit])} -@api.route("/tracks/favorite") -def get_favorite_tracks(): - limit = request.args.get("limit") - - if limit is None: - limit = 6 - - limit = int(limit) - +@api.get("/tracks") +def get_favorite_tracks(query: GenericLimitSchema): + """ + Get favorite tracks + """ + limit = query.limit tracks = favdb.get_fav_tracks() trackhashes = [t[1] for t in tracks] trackhashes.reverse() @@ -113,15 +112,12 @@ def get_favorite_tracks(): return {"tracks": serialize_tracks(tracks[:limit])} -@api.route("/artists/favorite") -def get_favorite_artists(): - limit = request.args.get("limit") - - if limit is None: - limit = 6 - - limit = int(limit) - +@api.get("/artists") +def get_favorite_artists(query: GenericLimitSchema): + """ + Get favorite artists + """ + limit = query.limit artists = favdb.get_fav_artists() artisthashes = [a[1] for a in artists] artisthashes.reverse() @@ -137,27 +133,38 @@ def get_favorite_artists(): return {"artists": artists[:limit]} -@api.route("/favorites") -def get_all_favorites(): +class GetAllFavoritesQuery(BaseModel): + """ + Extending this class will give you a model with the `limit` field + """ + + track_limit: int = Field( + description="The number of tracks to return", + example=Defaults.API_CARD_LIMIT, + default=Defaults.API_CARD_LIMIT, + ) + + album_limit: int = Field( + description="The number of albums to return", + example=Defaults.API_CARD_LIMIT, + default=Defaults.API_CARD_LIMIT, + ) + + artist_limit: int = Field( + description="The number of artists to return", + example=Defaults.API_CARD_LIMIT, + default=Defaults.API_CARD_LIMIT, + ) + + +@api.get("") +def get_all_favorites(query: GetAllFavoritesQuery): """ Returns all the favorites in the database. """ - track_limit = request.args.get("track_limit") - album_limit = request.args.get("album_limit") - artist_limit = request.args.get("artist_limit") - - if track_limit is None: - track_limit = 6 - - if album_limit is None: - album_limit = 6 - - if artist_limit is None: - artist_limit = 6 - - track_limit = int(track_limit) - album_limit = int(album_limit) - artist_limit = int(artist_limit) + track_limit = query.track_limit + album_limit = query.album_limit + artist_limit = query.artist_limit # largest is x2 to accound for broken hashes if any largest = max(track_limit, album_limit, artist_limit) @@ -266,20 +273,13 @@ def get_all_favorites(): } -@api.route("/favorites/check") -def check_favorite(): +@api.get("/check") +def check_favorite(query: FavoritesAddBody): """ Checks if a favorite exists in the database. """ - itemhash = request.args.get("hash") - itemtype = request.args.get("type") - - if itemhash is None: - return {"error": "No hash provided"}, 400 - - if itemtype is None: - return {"error": "No type provided"}, 400 - + itemhash = query.hash + itemtype = query.type exists = favdb.check_is_favorite(itemhash, itemtype) return {"is_favorite": exists} diff --git a/app/api/getall/__init__.py b/app/api/getall/__init__.py index e111d98e..8a6c7385 100644 --- a/app/api/getall/__init__.py +++ b/app/api/getall/__init__.py @@ -1,10 +1,125 @@ from flask import Blueprint -from flask_restful import Api -from .resources import Albums +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import BaseModel, Field -api_bp = Blueprint("getall", __name__, url_prefix="/getall") -api = Api(api_bp) +from datetime import datetime +from app.api.apischemas import GenericLimitSchema +from app.store.albums import AlbumStore +from app.store.artists import ArtistStore + +from app.serializers.album import serialize_for_card as serialize_album +from app.serializers.artist import serialize_for_card as serialize_artist +from app.utils import format_number +from app.utils.dates import ( + create_new_date, + date_string_to_time_passed, + seconds_to_time_string, +) + +bp_tag = Tag(name="Get all", description="List all items") +api = APIBlueprint("getall", __name__, url_prefix="/getall", abp_tags=[bp_tag]) -api.add_resource(Albums, "/") +class GetAllItemsBody(GenericLimitSchema): + start: int = Field( + description="The start index of the items to return", + example=0, + default=0, + ) + sortby: str = Field( + description="The key to sort items by", + example="created_date", + default="created_date", + ) + + reverse: int = Field( + description="Reverse the sort", + example=1, + default=1, + ) + + +class GetAllItemsPath(BaseModel): + itemtype: str = Field( + description="The type of items to return (albums | artists)", + example="albums", + default="albums", + ) + + +@api.get("/") +def get_all_items(path: GetAllItemsPath, query: GetAllItemsBody): + """ + Get all items + + Used to show all albums or artists in the library + """ + is_albums = path.itemtype == "albums" + is_artists = path.itemtype == "artists" + + items = AlbumStore.albums + + if is_artists: + items = ArtistStore.artists + + start = query.start + limit = query.limit + sort = query.sortby + reverse = query.reverse == 1 + + # if sort == "": + # sort = "created_date" + + sort_is_count = sort == "count" + sort_is_duration = sort == "duration" + sort_is_create_date = sort == "created_date" + + sort_is_date = is_albums and sort == "date" + sort_is_artist = is_albums and sort == "albumartists" + + sort_is_artist_trackcount = is_artists and sort == "trackcount" + sort_is_artist_albumcount = is_artists and sort == "albumcount" + + lambda_sort = lambda x: getattr(x, sort) + if sort_is_artist: + lambda_sort = lambda x: getattr(x, sort)[0].name + + sorted_items = sorted(items, key=lambda_sort, reverse=reverse) + items = sorted_items[start : start + limit] + + album_list = [] + + for item in items: + item_dict = serialize_album(item) if is_albums else serialize_artist(item) + + if sort_is_date: + item_dict["help_text"] = item.date + + if sort_is_create_date: + date = create_new_date(datetime.fromtimestamp(item.created_date)) + timeago = date_string_to_time_passed(date) + item_dict["help_text"] = timeago + + if sort_is_count: + item_dict["help_text"] = ( + f"{format_number(item.count)} track{'' if item.count == 1 else 's'}" + ) + + if sort_is_duration: + item_dict["help_text"] = seconds_to_time_string(item.duration) + + if sort_is_artist_trackcount: + item_dict["help_text"] = ( + f"{format_number(item.trackcount)} track{'' if item.trackcount == 1 else 's'}" + ) + + if sort_is_artist_albumcount: + item_dict["help_text"] = ( + f"{format_number(item.albumcount)} album{'' if item.albumcount == 1 else 's'}" + ) + + album_list.append(item_dict) + + return {"items": album_list, "total": len(sorted_items)} diff --git a/app/api/getall/resources.py b/app/api/getall/resources.py deleted file mode 100644 index 6bbae073..00000000 --- a/app/api/getall/resources.py +++ /dev/null @@ -1,93 +0,0 @@ -from flask_restful import Resource, reqparse -from datetime import datetime -from app.store.albums import AlbumStore -from app.store.artists import ArtistStore - -from app.serializers.album import serialize_for_card as serialize_album -from app.serializers.artist import serialize_for_card as serialize_artist -from app.utils import format_number -from app.utils.dates import ( - create_new_date, - date_string_to_time_passed, - seconds_to_time_string, -) - -parser = reqparse.RequestParser() - -parser.add_argument("start", type=int, default=0, location="args") -parser.add_argument("limit", type=int, default=20, location="args") -parser.add_argument("sortby", type=str, default="created_date", location="args") -parser.add_argument("reverse", type=str, default="1", location="args") - - -class Albums(Resource): - def get(self, itemtype: str): - is_albums = itemtype == "albums" - is_artists = itemtype == "artists" - - items = AlbumStore.albums - - if is_artists: - items = ArtistStore.artists - - args = parser.parse_args() - - start = args["start"] - limit = args["limit"] - sort = args["sortby"] - reverse = args["reverse"] == "1" - - if sort == "": - sort = "created_date" - - sort_is_count = sort == "count" - sort_is_duration = sort == "duration" - sort_is_create_date = sort == "created_date" - - sort_is_date = is_albums and sort == "date" - sort_is_artist = is_albums and sort == "albumartists" - - sort_is_artist_trackcount = is_artists and sort == "trackcount" - sort_is_artist_albumcount = is_artists and sort == "albumcount" - - lambda_sort = lambda x: getattr(x, sort) - if sort_is_artist: - lambda_sort = lambda x: getattr(x, sort)[0].name - - sorted_items = sorted(items, key=lambda_sort, reverse=reverse) - items = sorted_items[start : start + limit] - - album_list = [] - - for item in items: - item_dict = serialize_album(item) if is_albums else serialize_artist(item) - - if sort_is_date: - item_dict["help_text"] = item.date - - if sort_is_create_date: - date = create_new_date(datetime.fromtimestamp(item.created_date)) - timeago = date_string_to_time_passed(date) - item_dict["help_text"] = timeago - - if sort_is_count: - item_dict[ - "help_text" - ] = f"{format_number(item.count)} track{'' if item.count == 1 else 's'}" - - if sort_is_duration: - item_dict["help_text"] = seconds_to_time_string(item.duration) - - if sort_is_artist_trackcount: - item_dict[ - "help_text" - ] = f"{format_number(item.trackcount)} track{'' if item.trackcount == 1 else 's'}" - - if sort_is_artist_albumcount: - item_dict[ - "help_text" - ] = f"{format_number(item.albumcount)} album{'' if item.albumcount == 1 else 's'}" - - album_list.append(item_dict) - - return {"items": album_list, "total": len(sorted_items)} diff --git a/app/api/home/__init__.py b/app/api/home/__init__.py index 3ba70ac7..3bac00ca 100644 --- a/app/api/home/__init__.py +++ b/app/api/home/__init__.py @@ -1,11 +1,25 @@ -from flask import Blueprint -from flask_restful import Api +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint -from .recents import RecentlyAdded, RecentlyPlayed +from app.api.apischemas import GenericLimitSchema +from app.lib.home.recentlyadded import get_recent_items +from app.lib.home.recentlyplayed import get_recently_played -api_bp = Blueprint("home", __name__, url_prefix="/home") -api = Api(api_bp) +bp_tag = Tag(name="Home", description="Homepage items") +api = APIBlueprint("home", __name__, url_prefix="/home", abp_tags=[bp_tag]) -api.add_resource(RecentlyAdded, "/recents/added") -api.add_resource(RecentlyPlayed, "/recents/played") +@api.get("/recents/added") +def get_recently_added(query: GenericLimitSchema): + """ + Get recently added + """ + return {"items": get_recent_items(query.limit)} + + +@api.get("/recents/played") +def get_recent_plays(query: GenericLimitSchema): + """ + Get recently played + """ + return {"items": get_recently_played(query.limit)} diff --git a/app/api/home/recents.py b/app/api/home/recents.py deleted file mode 100644 index c21b45a9..00000000 --- a/app/api/home/recents.py +++ /dev/null @@ -1,24 +0,0 @@ -from flask_restful import Resource, reqparse - -from app.lib.home.recentlyadded import get_recent_items -from app.lib.home.recentlyplayed import get_recently_played - -parser = reqparse.RequestParser() - -parser.add_argument("limit", type=int, required=False, default=7, location="args") - - -class RecentlyAdded(Resource): - def get(self): - args = parser.parse_args() - limit = args["limit"] - - return {"items": get_recent_items(limit)} - - -class RecentlyPlayed(Resource): - def get(self): - args = parser.parse_args() - limit = args["limit"] - - return {"items": get_recently_played(limit)} diff --git a/app/api/imgserver.py b/app/api/imgserver.py index 04e949d9..45e3d34d 100644 --- a/app/api/imgserver.py +++ b/app/api/imgserver.py @@ -1,88 +1,122 @@ from pathlib import Path +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import BaseModel, Field +from flask import send_from_directory -from flask import Blueprint, send_from_directory +from app.settings import Defaults, Paths -from app.settings import Paths - -api = Blueprint("imgserver", __name__, url_prefix="/img") - - -@api.route("/") -def hello(): - return "

Image Server

" +bp_tag = Tag( + name="Images", description="Image filenames are constructured as '{itemhash}.webp'" +) +api = APIBlueprint("imgserver", __name__, url_prefix="/img", abp_tags=[bp_tag]) def send_fallback_img(filename: str = "default.webp"): - path = Paths.get_assets_path() - img = Path(path) / filename + folder = Paths.get_assets_path() + img = Path(folder) / filename if not img.exists(): return "", 404 - return send_from_directory(path, filename) + return send_from_directory(folder, filename) -@api.route("/t/o/") -def send_original_thumbnail(imgpath: str): - path = Paths.get_original_thumb_path() - fpath = Path(path) / imgpath +class ImagePath(BaseModel): + imgpath: str = Field( + description="The image filename", + example=Defaults.API_ALBUMHASH + ".webp", + ) + + +@api.get("/t/o/") +def send_original_thumbnail(path: ImagePath): + """ + Get original thumbnail + """ + folder = Paths.get_original_thumb_path() + fpath = Path(folder) / path.imgpath if fpath.exists(): - return send_from_directory(path, imgpath) + return send_from_directory(folder, path.imgpath) return send_fallback_img() -@api.route("/t/") -def send_lg_thumbnail(imgpath: str): - path = Paths.get_lg_thumb_path() - fpath = Path(path) / imgpath +@api.get("/t/") +def send_lg_thumbnail(path: ImagePath): + """ + Get large thumbnail (500 x 500) + """ + folder = Paths.get_lg_thumb_path() + fpath = Path(folder) / path.imgpath if fpath.exists(): - return send_from_directory(path, imgpath) + return send_from_directory(folder, path.imgpath) return send_fallback_img() -@api.route("/t/s/") -def send_sm_thumbnail(imgpath: str): - path = Paths.get_sm_thumb_path() - fpath = Path(path) / imgpath +@api.get("/t/s/") +def send_sm_thumbnail(path: ImagePath): + """ + Get small thumbnail (64 x 64) + """ + folder = Paths.get_sm_thumb_path() + fpath = Path(folder) / path.imgpath if fpath.exists(): - return send_from_directory(path, imgpath) + return send_from_directory(folder, path.imgpath) return send_fallback_img() -@api.route("/a/") -def send_lg_artist_image(imgpath: str): - path = Paths.get_artist_img_lg_path() - fpath = Path(path) / imgpath +@api.get("/a/") +def send_lg_artist_image(path: ImagePath): + """ + Get large artist image (500 x 500) + """ + folder = Paths.get_artist_img_lg_path() + fpath = Path(folder) / path.imgpath if fpath.exists(): - return send_from_directory(path, imgpath) + return send_from_directory(folder, path.imgpath) return send_fallback_img("artist.webp") -@api.route("/a/s/") -def send_sm_artist_image(imgpath: str): - path = Paths.get_artist_img_sm_path() - fpath = Path(path) / imgpath +@api.get("/a/s/") +def send_sm_artist_image(path: ImagePath): + """ + Get small artist image (64 x 64) + """ + folder = Paths.get_artist_img_sm_path() + fpath = Path(folder) / path.imgpath if fpath.exists(): - return send_from_directory(path, imgpath) + return send_from_directory(folder, path.imgpath) return send_fallback_img("artist.webp") -@api.route("/p/") -def send_playlist_image(imgpath: str): - path = Paths.get_playlist_img_path() - fpath = Path(path) / imgpath +class PlaylistImagePath(BaseModel): + imgpath: str = Field( + description="The image path", + example="1.webp", + ) + + +@api.get("/p/") +def send_playlist_image(path: PlaylistImagePath): + """ + Get playlist image + + Images are constructed as '{playlist_id}.webp' + """ + folder = Paths.get_playlist_img_path() + fpath = Path(folder) / path.imgpath if fpath.exists(): - return send_from_directory(path, imgpath) + return send_from_directory(folder, path.imgpath) return send_fallback_img("playlist.svg") diff --git a/app/api/logger/__init__.py b/app/api/logger/__init__.py index 033bfd3d..546451e6 100644 --- a/app/api/logger/__init__.py +++ b/app/api/logger/__init__.py @@ -1,11 +1,38 @@ -from flask import Blueprint -from flask_restful import Api +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import Field +from app.api.apischemas import TrackHashSchema -from app.api.logger.tracks import LogTrack +from app.db.sqlite.logger.tracks import SQLiteTrackLogger as db +from app.settings import Defaults + +bp_tag = Tag(name="Logger", description="Log item plays") +api = APIBlueprint("logger", __name__, url_prefix="/logger", abp_tags=[bp_tag]) -api_bp = Blueprint("logger", __name__, url_prefix="/logger") -api = Api(api_bp) +class LogTrackBody(TrackHashSchema): + timestamp: int = Field(description="The timestamp of the track", example=1622217600) + duration: int = Field( + description="The duration of the track in seconds", example=300 + ) + source: str = Field( + description="The play source of the track", + example=f"al:{Defaults.API_ALBUMHASH}", + ) -api.add_resource(LogTrack, "/track/log") +@api.post("/track/log") +def log_track(body: LogTrackBody): + """ + Log a track play to the database. + """ + trackhash = body.trackhash + timestamp = body.timestamp + duration = body.duration + source = body.source + + last_row = db.insert_track( + trackhash=trackhash, timestamp=timestamp, duration=duration, source=source + ) + + return {"last_row": last_row} diff --git a/app/api/logger/tracks.py b/app/api/logger/tracks.py deleted file mode 100644 index f6ed2894..00000000 --- a/app/api/logger/tracks.py +++ /dev/null @@ -1,19 +0,0 @@ -from flask_restful import Resource, reqparse -from app.db.sqlite.logger.tracks import SQLiteTrackLogger as db - -parser = reqparse.RequestParser() -parser.add_argument("trackhash", type=str, required=True) -parser.add_argument("timestamp", type=int, required=True) -parser.add_argument("duration", type=int, required=True) -parser.add_argument("source", type=str, required=True) - - -class LogTrack(Resource): - def post(self): - args = parser.parse_args(strict=True) - - last_row = db.insert_track( - args["trackhash"], args["duration"], args["source"], args["timestamp"] - ) - - return {"last_row": last_row} diff --git a/app/api/lyrics.py b/app/api/lyrics.py index 7e86ed48..8d1cd261 100644 --- a/app/api/lyrics.py +++ b/app/api/lyrics.py @@ -1,5 +1,8 @@ -from flask import Blueprint, request +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import Field +from app.api.apischemas import TrackHashSchema from app.lib.lyrics import ( get_lyrics, check_lyrics_file, @@ -7,21 +10,24 @@ from app.lib.lyrics import ( get_lyrics_from_tags, ) -api = Blueprint("lyrics", __name__, url_prefix="") +bp_tag = Tag(name="Lyrics", description="Get lyrics") +api = APIBlueprint("lyrics", __name__, url_prefix="/lyrics", abp_tags=[bp_tag]) -@api.route("/lyrics", methods=["POST"]) -def send_lyrics(): +class SendLyricsBody(TrackHashSchema): + filepath: str = Field( + description="The path to the file", + example="/path/to/file.mp3", + ) + + +@api.post("") +def send_lyrics(body: SendLyricsBody): """ Returns the lyrics for a track """ - data = request.get_json() - - filepath = data.get("filepath", None) - trackhash = data.get("trackhash", None) - - if filepath is None or trackhash is None: - return {"error": "No filepath or trackhash provided"}, 400 + filepath = body.filepath + trackhash = body.trackhash is_synced = True lyrics, copyright = get_lyrics(filepath) @@ -38,15 +44,13 @@ def send_lyrics(): return {"lyrics": lyrics, "synced": is_synced, "copyright": copyright}, 200 -@api.route("/lyrics/check", methods=["POST"]) -def check_lyrics(): - data = request.get_json() - - filepath = data.get("filepath", None) - trackhash = data.get("trackhash", None) - - if filepath is None or trackhash is None: - return {"error": "No filepath or trackhash provided"}, 400 +@api.post("/check") +def check_lyrics(body: SendLyricsBody): + """ + Checks if lyrics exist for a track + """ + filepath = body.filepath + trackhash = body.trackhash exists = check_lyrics_file(filepath, trackhash) @@ -56,4 +60,3 @@ def check_lyrics(): exists = get_lyrics_from_tags(filepath, just_check=True) return {"exists": exists}, 200 - diff --git a/app/api/plugins/__init__.py b/app/api/plugins/__init__.py index b4661c60..6fffe4ab 100644 --- a/app/api/plugins/__init__.py +++ b/app/api/plugins/__init__.py @@ -1,37 +1,60 @@ from flask import Blueprint, request +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import BaseModel, Field from app.db.sqlite.plugins import PluginsMethods - -api = Blueprint("plugins", __name__, url_prefix="/plugins") +bp_tag = Tag(name="Plugins", description="Manage plugins") +api = APIBlueprint("plugins", __name__, url_prefix="/plugins", abp_tags=[bp_tag]) -@api.route("/", methods=["GET"]) +@api.get("/") def get_all_plugins(): + """ + List all plugins + """ plugins = PluginsMethods.get_all_plugins() return {"plugins": plugins} -@api.route("/setactive", methods=["GET"]) -def activate_deactivate_plugin(): - name = request.args.get("plugin", None) - state = request.args.get("state", None) +class PluginBody(BaseModel): + plugin: str = Field(description="The plugin name", example="lyrics") - if not name or not state: - return {"error": "Missing plugin or state"}, 400 - PluginsMethods.plugin_set_active(name, int(state)) +class PluginActivateBody(PluginBody): + active: bool = Field( + description="New plugin active state", example=False, default=False + ) + + +@api.post("/setactive") +def activate_deactivate_plugin(body: PluginActivateBody): + """ + Activate/Deactivate plugin + """ + name = body.plugin + active = 1 if body.active else 0 + + PluginsMethods.plugin_set_active(name, active) return {"message": "OK"}, 200 -@api.route("/settings", methods=["POST"]) -def update_plugin_settings(): - data = request.get_json() +class PluginSettingsBody(PluginBody): + settings: dict = Field( + description="The new plugin settings", example={"key": "value"} + ) - plugin = data.get("plugin", None) - settings = data.get("settings", None) + +@api.post("/settings") +def update_plugin_settings(body: PluginSettingsBody): + """ + Update plugin settings + """ + plugin = body.plugin + settings = body.settings if not plugin or not settings: return {"error": "Missing plugin or settings"}, 400 diff --git a/app/api/plugins/lyrics.py b/app/api/plugins/lyrics.py index e1d01fbc..3c5873e3 100644 --- a/app/api/plugins/lyrics.py +++ b/app/api/plugins/lyrics.py @@ -1,24 +1,41 @@ -from flask import Blueprint, request +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import Field +from app.api.apischemas import TrackHashSchema from app.lib.lyrics import format_synced_lyrics from app.plugins.lyrics import Lyrics +from app.settings import Defaults from app.utils.hashing import create_hash -api = Blueprint("lyricsplugin", __name__, url_prefix="/plugins/lyrics") +bp_tag = Tag(name="Lyrics Plugin", description="Musixmatch lyrics plugin") +api = APIBlueprint( + "lyricsplugin", __name__, url_prefix="/plugins/lyrics", abp_tags=[bp_tag] +) -@api.route("/search", methods=["POST"]) -def search_lyrics(): - data = request.get_json() +class LyricsSearchBody(TrackHashSchema): + title: str = Field(description="The track title ", example=Defaults.API_TRACKNAME) + artist: str = Field(description="The track artist ", example=Defaults.API_ARTISTNAME) + album: str = Field(description="The track track album ", example=Defaults.API_ALBUMNAME) + filepath: str = Field( + description="Track filepath to save the lyrics file relative to", + example="/home/cwilvx/temp/crazy song.mp3", + ) - trackhash = data.get("trackhash", "") - title = data.get("title", "") - artist = data.get("artist", "") - album = data.get("album", "") - filepath = data.get("filepath", None) + +@api.post("/search") +def search_lyrics(body: LyricsSearchBody): + """ + Search for lyrics by title and artist + """ + title = body.title + artist = body.artist + album = body.album + filepath = body.filepath + trackhash = body.trackhash finder = Lyrics() - data = finder.search_lyrics_by_title_and_artist(title, artist) if not data: diff --git a/app/api/settings.py b/app/api/settings.py index 92ccdf2c..9d387ac9 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -1,4 +1,8 @@ -from flask import Blueprint, request +from typing import Any +from flask import request +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import BaseModel, Field from app.db.sqlite.plugins import PluginsMethods as pdb from app.db.sqlite.settings import SettingsSQLMethods as sdb @@ -12,7 +16,8 @@ from app.store.tracks import TrackStore from app.utils.generators import get_random_str from app.utils.threading import background -api = Blueprint("settings", __name__, url_prefix="") +bp_tag = Tag(name="Settings", description="Customize stuff") +api = APIBlueprint("settings", __name__, url_prefix="/notsettings", abp_tags=[bp_tag]) def get_child_dirs(parent: str, children: list[str]): @@ -77,23 +82,24 @@ def finalize(new_: list[str], removed_: list[str], db_dirs_: list[str]): rebuild_store(db_dirs_) -@api.route("/settings/add-root-dirs", methods=["POST"]) -def add_root_dirs(): +class AddRootDirsBody(BaseModel): + new_dirs: list[str] = Field( + description="The new directories to add", + example=["/home/user/Music", "/home/user/Downloads"], + ) + removed: list[str] = Field( + description="The directories to remove", + example=["/home/user/Downloads"], + ) + + +@api.post("/add-root-dirs") +def add_root_dirs(body: AddRootDirsBody): """ Add custom root directories to the database. """ - msg = {"msg": "Failed! No directories were given."} - - data = request.get_json() - - if data is None: - return msg, 400 - - try: - new_dirs: list[str] = data["new_dirs"] - removed_dirs: list[str] = data["removed"] - except KeyError: - return msg, 400 + new_dirs = body.new_dirs + removed_dirs = body.removed db_dirs = sdb.get_root_dirs() _h = "$home" @@ -132,10 +138,10 @@ def add_root_dirs(): return {"root_dirs": db_dirs} -@api.route("/settings/get-root-dirs", methods=["GET"]) +@api.get("/get-root-dirs") def get_root_dirs(): """ - Get custom root directories from the database. + Get root directories """ dirs = sdb.get_root_dirs() @@ -154,10 +160,10 @@ mapp = { } -@api.route("/settings/", methods=["GET"]) +@api.get("") def get_all_settings(): """ - Get all settings from the database. + Get all settings """ settings = sdb.get_all_settings() @@ -195,10 +201,24 @@ def reload_all_for_set_setting(): reload_everything(get_random_str()) -@api.route("/settings/set", methods=["POST"]) -def set_setting(): - key = request.get_json().get("key") - value = request.get_json().get("value") +class SetSettingBody(BaseModel): + key: str = Field( + description="The setting key", + example="artist_separators", + ) + value: Any = Field( + description="The setting value", + example=",", + ) + + +@api.post("/set") +def set_setting(body: SetSettingBody): + """ + Set a setting. + """ + key = body.key + value = body.value if key is None or value is None or key == "root_dirs": return {"msg": "Invalid arguments!"}, 400 @@ -235,10 +255,10 @@ def run_populate(): populate.Populate(instance_key=get_random_str()) -@api.route("/settings/trigger-scan", methods=["GET"]) +@api.get("/trigger-scan") def trigger_scan(): """ - Triggers a scan. + Triggers scan for new music """ run_populate() diff --git a/app/arg_handler.py b/app/arg_handler.py index befecb28..64367343 100644 --- a/app/arg_handler.py +++ b/app/arg_handler.py @@ -1,6 +1,7 @@ """ Handles arguments passed to the program. """ + import os.path import sys @@ -9,6 +10,7 @@ import PyInstaller.__main__ as bundler from app import settings from app.logger import log from app.print_help import HELP_MESSAGE +from app.utils.paths import getFlaskOpenApiPath from app.utils.xdg_utils import get_xdg_config_dir from app.utils.wintools import is_windows @@ -45,6 +47,8 @@ class HandleArgs: config_keys = [ "SWINGMUSIC_APP_VERSION", + "GIT_LATEST_COMMIT_HASH", + "GIT_CURRENT_BRANCH", ] lines = [] @@ -65,6 +69,8 @@ class HandleArgs: _s = ";" if is_windows() else ":" + flask_openapi_path = getFlaskOpenApiPath() + bundler.run( [ "manage.py", @@ -74,6 +80,7 @@ class HandleArgs: "--clean", f"--add-data=assets{_s}assets", f"--add-data=client{_s}client", + f"--add-data={flask_openapi_path}/templates/static{_s}flask_openapi3/templates/static", f"--icon=assets/logo-fill.light.ico", "-y", ] @@ -176,5 +183,8 @@ class HandleArgs: @staticmethod def handle_version(): if any((a in ARGS for a in ALLARGS.version)): - print(settings.Keys.SWINGMUSIC_APP_VERSION) + print(f"VERSION: v{settings.Keys.SWINGMUSIC_APP_VERSION}") + print( + f"COMMIT#: {settings.Keys.GIT_CURRENT_BRANCH}/{settings.Keys.GIT_LATEST_COMMIT_HASH}" + ) sys.exit(0) diff --git a/app/configs.py b/app/configs.py index 66240f60..4008bc30 100644 --- a/app/configs.py +++ b/app/configs.py @@ -1 +1,3 @@ SWINGMUSIC_APP_VERSION = "" +GIT_LATEST_COMMIT_HASH = "" +GIT_CURRENT_BRANCH = "" diff --git a/app/db/sqlite/plugins/__init__.py b/app/db/sqlite/plugins/__init__.py index 12f6efe7..2cfd2345 100644 --- a/app/db/sqlite/plugins/__init__.py +++ b/app/db/sqlite/plugins/__init__.py @@ -66,9 +66,9 @@ class PluginsMethods: return [] @classmethod - def plugin_set_active(cls, name: str, state: int): + def plugin_set_active(cls, name: str, active: int): with SQLiteManager(userdata_db=True) as cur: - cur.execute("UPDATE plugins SET active=? WHERE name=?", (state, name)) + cur.execute("UPDATE plugins SET active=? WHERE name=?", (active, name)) cur.close() @classmethod diff --git a/app/settings.py b/app/settings.py index 5021e62d..5cddc750 100644 --- a/app/settings.py +++ b/app/settings.py @@ -3,6 +3,7 @@ Contains default configs """ import os +import subprocess import sys from typing import Any @@ -103,6 +104,7 @@ class Defaults: API_TRACKHASH = "0853280a12" API_ALBUMNAME = "Rumours" API_ARTISTNAME = "girl in red" + API_TRACKNAME = "Apartment 402" API_CARD_LIMIT = 6 @@ -242,21 +244,49 @@ class TCOLOR: # credits: https://stackoverflow.com/a/287944 +def getLatestCommitHash(): + """ + Returns the latest git commit hash for the current branch + """ + + try: + hash = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"]) + return hash.decode("utf-8").strip() + except: + return "" + + +def getCurrentBranch(): + """ + Returns the current git branch + """ + + try: + branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"]) + return branch.decode("utf-8").strip() + except: + return "" + + class Keys: SWINGMUSIC_APP_VERSION = os.environ.get("SWINGMUSIC_APP_VERSION") + GIT_LATEST_COMMIT_HASH = "" + GIT_CURRENT_BRANCH = "" @classmethod def load(cls): if IS_BUILD: cls.SWINGMUSIC_APP_VERSION = configs.SWINGMUSIC_APP_VERSION + cls.GIT_LATEST_COMMIT_HASH = configs.GIT_LATEST_COMMIT_HASH + cls.GIT_CURRENT_BRANCH = configs.GIT_CURRENT_BRANCH + else: + cls.GIT_LATEST_COMMIT_HASH = getLatestCommitHash() + cls.GIT_CURRENT_BRANCH = getCurrentBranch() cls.verify_keys() @classmethod def verify_keys(cls): - # if not cls.LASTFM_API_KEY: - # print("ERROR: LASTFM_API_KEY not set in environment") - # sys.exit(0) pass @classmethod diff --git a/app/utils/paths.py b/app/utils/paths.py new file mode 100644 index 00000000..fe6bcfb4 --- /dev/null +++ b/app/utils/paths.py @@ -0,0 +1,12 @@ +import sys + + +def getFlaskOpenApiPath(): + """ + Used to retrieve the path to the flask_openapi3 package + + See: https://github.com/luolingchun/flask-openapi3/issues/147 + """ + site_packages_path = [p for p in sys.path if "site-packages" in p][0] + + return f"{site_packages_path}/flask_openapi3" diff --git a/poetry.lock b/poetry.lock index 3e3e5443..0b9fa9b7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,20 +11,6 @@ files = [ {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, ] -[[package]] -name = "aniso8601" -version = "9.0.1" -description = "A library for parsing ISO 8601 strings." -optional = false -python-versions = "*" -files = [ - {file = "aniso8601-9.0.1-py2.py3-none-any.whl", hash = "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f"}, - {file = "aniso8601-9.0.1.tar.gz", hash = "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973"}, -] - -[package.extras] -dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"] - [[package]] name = "annotated-types" version = "0.6.0" @@ -592,26 +578,6 @@ dotenv = ["python-dotenv"] email = ["email-validator"] yaml = ["pyyaml"] -[[package]] -name = "flask-restful" -version = "0.3.10" -description = "Simple framework for creating REST APIs" -optional = false -python-versions = "*" -files = [ - {file = "Flask-RESTful-0.3.10.tar.gz", hash = "sha256:fe4af2ef0027df8f9b4f797aba20c5566801b6ade995ac63b588abf1a59cec37"}, - {file = "Flask_RESTful-0.3.10-py2.py3-none-any.whl", hash = "sha256:1cf93c535172f112e080b0d4503a8d15f93a48c88bdd36dd87269bdaf405051b"}, -] - -[package.dependencies] -aniso8601 = ">=0.82" -Flask = ">=0.8" -pytz = "*" -six = ">=1.3.0" - -[package.extras] -docs = ["sphinx"] - [[package]] name = "gevent" version = "23.9.1" @@ -1697,17 +1663,6 @@ files = [ [package.dependencies] six = ">=1.5" -[[package]] -name = "pytz" -version = "2023.3.post1" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, - {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, -] - [[package]] name = "pywin32" version = "306" @@ -2513,4 +2468,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.12" -content-hash = "7969ca61599f24a005909514cf11d35dd11b6f82f5958bb84c271d8156399755" +content-hash = "feb13f92b7b3a909fcb851860a405b96579feac0e2dde7681ed0e9c381c4f6cd" diff --git a/pyproject.toml b/pyproject.toml index e7179d36..005eaed5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ show-in-file-manager = "^1.1.4" flask-compress = "^1.13" tabulate = "^0.9.0" setproctitle = "^1.3.2" -flask-restful = "^0.3.10" locust = "^2.20.1" waitress = "^2.1.2" watchdog = "^4.0.0"