From fb65ac9931641eaac2c76dd45959bff17eaa44f8 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Sun, 3 Mar 2024 22:28:24 +0300 Subject: [PATCH 01/12] add open API specs --- app/api/__init__.py | 20 ++++-- app/api/album.py | 140 +++++++++++++++++++++++++----------------- app/settings.py | 7 +++ poetry.lock | 144 +++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 5 files changed, 253 insertions(+), 59 deletions(-) diff --git a/app/api/__init__.py b/app/api/__init__.py index 609c2131..257e54bd 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -2,10 +2,13 @@ This module combines all API blueprints into a single Flask app instance. """ -from flask import Flask -from flask_compress import Compress from flask_cors import CORS +from flask_compress import Compress +from flask_openapi3 import Info +from flask_openapi3 import OpenAPI + +from app.settings import Keys from .plugins import lyrics as lyrics_plugin from app.api import ( album, @@ -30,7 +33,16 @@ def create_api(): """ Creates the Flask instance, registers modules and registers all the API blueprints. """ - app = Flask(__name__) + api_info = Info( + title=f"Swing Music", + version=f"v{Keys.SWINGMUSIC_APP_VERSION}", + license={"name": "MIT", "url": "https://github.com/swing-opensource/swingmusic?tab=MIT-1-ov-file#MIT-1-ov-file"}, + contact={"name": "Mungai Njoroge", "url": "https://mungai.vercel.app", "email": "geoffreymungai45@gmail.com"}, + description="The REST API exposed by your Swing Music server", + ) + + app = OpenAPI(__name__, info=api_info) + CORS(app, origins="*") Compress(app) @@ -39,7 +51,7 @@ def create_api(): ] with app.app_context(): - app.register_blueprint(album.api) + app.register_api(album.api) app.register_blueprint(artist.api) app.register_blueprint(send_file.api) app.register_blueprint(search.api) diff --git a/app/api/album.py b/app/api/album.py index 5f2111d6..91ae14e7 100644 --- a/app/api/album.py +++ b/app/api/album.py @@ -2,9 +2,12 @@ Contains all the album routes. """ +from operator import length_hint import random -from flask import Blueprint, request +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import BaseModel, Field from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb @@ -13,6 +16,7 @@ from app.lib.albumslib import sort_by_track_no from app.models import FavType, Track from app.serializers.album import serialize_for_card from app.serializers.track import serialize_track +from app.settings import Defaults from app.store.albums import AlbumStore from app.store.tracks import TrackStore from app.utils.hashing import create_hash @@ -20,25 +24,25 @@ from app.utils.hashing import create_hash get_albums_by_albumartist = adb.get_albums_by_albumartist check_is_fav = favdb.check_is_favorite -api = Blueprint("album", __name__, url_prefix="") +book_tag = Tag(name="Album", description="Single album") +api = APIBlueprint("album", __name__, url_prefix="", abp_tags=[book_tag]) -@api.route("/album", methods=["POST"]) -def get_album_tracks_and_info(): +class GetAlbumBody(BaseModel): + albumhash: str = Field( + description="The hash of the album to get", + example="49e4819273", + min_length=Defaults.HASH_LENGTH, + max_length=Defaults.HASH_LENGTH, + ) + + +@api.post("/album", summary="Get album") +def get_album_tracks_and_info(body: GetAlbumBody): """ - Returns all the tracks in the given album + Returns album info and tracks for the given albumhash. """ - - data = request.get_json() - error_msg = {"msg": "No hash provided"} - - if data is None: - return error_msg, 400 - - try: - albumhash: str = data["albumhash"] - except KeyError: - return error_msg, 400 + albumhash = body.albumhash error_msg = {"error": "Album not created yet."} album = AlbumStore.get_album_by_hash(albumhash) @@ -81,28 +85,48 @@ def get_album_tracks_and_info(): "info": album, } +class GetAlbumTracksQuery(BaseModel): + albumhash: str = Field( + description="The hash of the album", + example="49e4819273", + min_length=Defaults.HASH_LENGTH, + max_length=Defaults.HASH_LENGTH, + ) -@api.route("/album//tracks", methods=["GET"]) -def get_album_tracks(albumhash: str): +@api.get("/album//tracks", summary="Get album tracks") +def get_album_tracks(query: GetAlbumTracksQuery): """ Returns all the tracks in the given album, sorted by disc and track number. """ - tracks = TrackStore.get_tracks_by_albumhash(albumhash) + tracks = TrackStore.get_tracks_by_albumhash(query.albumhash) tracks = sort_by_track_no(tracks) return {"tracks": tracks} +class GetMoreFromArtistsBody(BaseModel): + albumartists: str = Field( + description="The artist hashes to get more albums from", + example=Defaults.API_ARTISTHASH + ) + limit: int = Field( + description="The maximum number of albums to return per artist", + example=7, + default=7, + ) + base_title: str = Field( + description="The base title of the album to exclude from the results.", + example=Defaults.API_ALBUMNAME, + default=None, + ) -@api.route("/album/from-artist", methods=["POST"]) -def get_artist_albums(): - data = request.get_json() - - if data is None: - return {"msg": "No albumartist provided"} - - albumartists: str = data["albumartists"] - limit: int = data.get("limit") - base_title: str = data.get("base_title") +@api.post("/album/from-artist", summary="More from artist") +def get_more_from_artist(body: GetMoreFromArtistsBody): + """ + Returns more albums from the given artist hashes. + """ + albumartists = body.albumartists + limit = body.limit + base_title = body.base_title albumartists: list[str] = albumartists.split(",") @@ -128,20 +152,28 @@ def get_artist_albums(): return {"data": albums} -@api.route("/album/versions", methods=["POST"]) -def get_album_versions(): +class GetAlbumVersionsBody(BaseModel): + og_album_title: str = Field( + description="The original album title (album.og_title)", + example=Defaults.API_ALBUMNAME, + ) + base_title: str = Field( + description="The base title of the album to exclude from the results.", + example=Defaults.API_ALBUMNAME, + ) + artisthash: str = Field( + description="The artist hash", + example=Defaults.API_ARTISTHASH, + ) + +@api.post("/album/versions", summary="Get other versions") +def get_album_versions(body: GetAlbumVersionsBody): """ Returns other versions of the given album. """ - - data = request.get_json() - - if data is None: - return {"msg": "No albumartist provided"} - - og_album_title: str = data["og_album_title"] - base_title: str = data["base_title"] - artisthash: str = data["artisthash"] + og_album_title = body.og_album_title + base_title = body.base_title + artisthash = body.artisthash albums = AlbumStore.get_albums_by_artisthash(artisthash) @@ -158,24 +190,24 @@ def get_album_versions(): return {"data": albums} +class GetSimilarAlbumsQuery(BaseModel): + artisthash: str = Field( + description="The artist hash", + example=Defaults.API_ARTISTHASH, + ) + limit: int = Field( + description="The maximum number of albums to return", + example=Defaults.API_CARD_LIMIT, + default=Defaults.API_CARD_LIMIT, + ) -@api.route("/album/similar", methods=["GET"]) -def get_similar_albums(): +@api.get("/album/similar", summary="Get similar albums") +def get_similar_albums(query: GetSimilarAlbumsQuery): """ Returns similar albums to the given album. """ - data = request.args - - if data is None: - return {"msg": "No artisthash provided"} - - artisthash: str = data["artisthash"] - limit: int = data.get("limit") - - if limit is None: - limit = 6 - - limit = int(limit) + artisthash = query.artisthash + limit = query.limit similar_artists = lastfmdb.get_similar_artists_for(artisthash) diff --git a/app/settings.py b/app/settings.py index 5a63d64b..37203b46 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,6 +1,7 @@ """ Contains default configs """ + import os import sys from typing import Any @@ -96,6 +97,12 @@ class Defaults: """ The size of extracted images in pixels """ + HASH_LENGTH = 10 + API_ALBUMHASH = "c5bcec6cb3" + API_ARTISTHASH = "fc6f0acac5" + API_ALBUMNAME = "Rumours" + + API_CARD_LIMIT = 6 FILES = ["flac", "mp3", "wav", "m4a", "ogg", "wma", "opus", "alac", "aiff"] diff --git a/poetry.lock b/poetry.lock index 21864ea3..3e3e5443 100644 --- a/poetry.lock +++ b/poetry.lock @@ -25,6 +25,17 @@ files = [ [package.extras] dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"] +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + [[package]] name = "astroid" version = "2.15.8" @@ -560,6 +571,27 @@ files = [ Flask = ">=0.9" Six = "*" +[[package]] +name = "flask-openapi3" +version = "3.0.2" +description = "Generate REST API and OpenAPI documentation for your Flask project." +optional = false +python-versions = ">=3.8" +files = [ + {file = "flask_openapi3-3.0.2-py3-none-any.whl", hash = "sha256:e39359bf07da47d9abf30d4b370feaac4035bd805f03518ce65016958796c636"}, + {file = "flask_openapi3-3.0.2.tar.gz", hash = "sha256:92e6a308c5b13692ad8aea04ee951ffbfabc6853b20bd3b678c2569dcb781916"}, +] + +[package.dependencies] +flask = ">=2.0" +pydantic = ">=2.4" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] +email = ["email-validator"] +yaml = ["pyyaml"] + [[package]] name = "flask-restful" version = "0.3.10" @@ -1447,6 +1479,116 @@ files = [ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] +[[package]] +name = "pydantic" +version = "2.6.3" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.6.3-py3-none-any.whl", hash = "sha256:72c6034df47f46ccdf81869fddb81aade68056003900a8724a4f160700016a2a"}, + {file = "pydantic-2.6.3.tar.gz", hash = "sha256:e07805c4c7f5c6826e33a1d4c9d47950d7eaf34868e2690f8594d2e30241f11f"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.16.3" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.16.3" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"}, + {file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"}, + {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"}, + {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"}, + {file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"}, + {file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"}, + {file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"}, + {file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"}, + {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"}, + {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"}, + {file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"}, + {file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"}, + {file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"}, + {file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"}, + {file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"}, + {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"}, + {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"}, + {file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"}, + {file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"}, + {file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"}, + {file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"}, + {file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"}, + {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"}, + {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"}, + {file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"}, + {file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"}, + {file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"}, + {file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"}, + {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"}, + {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"}, + {file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"}, + {file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"}, + {file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pyinstaller" version = "5.13.2" @@ -2371,4 +2513,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.12" -content-hash = "a4c52a13b0952ab8848381800032d35994c17d39ad6f884f509c7f6d848a5ce9" +content-hash = "7969ca61599f24a005909514cf11d35dd11b6f82f5958bb84c271d8156399755" diff --git a/pyproject.toml b/pyproject.toml index 8ade65d2..e7179d36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ locust = "^2.20.1" waitress = "^2.1.2" watchdog = "^4.0.0" pendulum = "^3.0.0" +flask-openapi3 = "^3.0.2" [tool.poetry.dev-dependencies] pylint = "^2.15.5" From 9e50eb43951315d8c355d1bb9f3fe2c14369903c Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Mon, 4 Mar 2024 00:06:40 +0300 Subject: [PATCH 02/12] remove response keys --- app/api/album.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/api/album.py b/app/api/album.py index 91ae14e7..eef04953 100644 --- a/app/api/album.py +++ b/app/api/album.py @@ -2,7 +2,6 @@ Contains all the album routes. """ -from operator import length_hint import random from flask_openapi3 import Tag @@ -101,7 +100,7 @@ def get_album_tracks(query: GetAlbumTracksQuery): tracks = TrackStore.get_tracks_by_albumhash(query.albumhash) tracks = sort_by_track_no(tracks) - return {"tracks": tracks} + return tracks class GetMoreFromArtistsBody(BaseModel): albumartists: str = Field( @@ -149,7 +148,7 @@ def get_more_from_artist(body: GetMoreFromArtistsBody): if len(a["albums"]) > 0 ] - return {"data": albums} + return albums class GetAlbumVersionsBody(BaseModel): @@ -166,7 +165,7 @@ class GetAlbumVersionsBody(BaseModel): example=Defaults.API_ARTISTHASH, ) -@api.post("/album/versions", summary="Get other versions") +@api.post("/album/other-versions", summary="Get other versions") def get_album_versions(body: GetAlbumVersionsBody): """ Returns other versions of the given album. @@ -188,7 +187,7 @@ def get_album_versions(body: GetAlbumVersionsBody): tracks = TrackStore.get_tracks_by_albumhash(a.albumhash) a.get_date_from_tracks(tracks) - return {"data": albums} + return albums class GetSimilarAlbumsQuery(BaseModel): artisthash: str = Field( @@ -229,4 +228,4 @@ def get_similar_albums(query: GetSimilarAlbumsQuery): except ValueError: pass - return {"albums": [serialize_for_card(a) for a in albums[:limit]]} + return [serialize_for_card(a) for a in albums[:limit]] From 7d064a8562964c50fa4fa7eebe0076abf4b2ce40 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Mon, 4 Mar 2024 01:31:46 +0300 Subject: [PATCH 03/12] add api docs for artist routes + extract hash and limit schemas --- app/api/__init__.py | 20 ++++++++--- app/api/album.py | 81 +++++++++++++++++------------------------ app/api/apischemas.py | 83 +++++++++++++++++++++++++++++++++++++++++++ app/api/artist.py | 60 ++++++++++++++++--------------- 4 files changed, 163 insertions(+), 81 deletions(-) create mode 100644 app/api/apischemas.py diff --git a/app/api/__init__.py b/app/api/__init__.py index 257e54bd..ebf4806c 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -2,6 +2,7 @@ This module combines all API blueprints into a single Flask app instance. """ +import datetime from flask_cors import CORS from flask_compress import Compress @@ -28,6 +29,19 @@ from app.api import ( getall, ) +open_api_description = f""" +The REST API exposed by your Swing Music server + +### Definition of terms: + +#### 1. `limit`: The number of items to return. + +In endpoints that request multiple lists of items, this represents the number of items to return for each list. + +--- + +[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(): """ @@ -36,9 +50,7 @@ def create_api(): api_info = Info( title=f"Swing Music", version=f"v{Keys.SWINGMUSIC_APP_VERSION}", - license={"name": "MIT", "url": "https://github.com/swing-opensource/swingmusic?tab=MIT-1-ov-file#MIT-1-ov-file"}, - contact={"name": "Mungai Njoroge", "url": "https://mungai.vercel.app", "email": "geoffreymungai45@gmail.com"}, - description="The REST API exposed by your Swing Music server", + description=open_api_description, ) app = OpenAPI(__name__, info=api_info) @@ -52,7 +64,7 @@ def create_api(): with app.app_context(): app.register_api(album.api) - app.register_blueprint(artist.api) + app.register_api(artist.api) app.register_blueprint(send_file.api) app.register_blueprint(search.api) app.register_blueprint(folder.api) diff --git a/app/api/album.py b/app/api/album.py index eef04953..c2f77dfb 100644 --- a/app/api/album.py +++ b/app/api/album.py @@ -7,6 +7,7 @@ import random from flask_openapi3 import Tag from flask_openapi3 import APIBlueprint from pydantic import BaseModel, Field +from app.api.apischemas import AlbumHashSchema, AlbumLimitSchema, ArtistHashSchema from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb @@ -23,22 +24,15 @@ from app.utils.hashing import create_hash get_albums_by_albumartist = adb.get_albums_by_albumartist check_is_fav = favdb.check_is_favorite -book_tag = Tag(name="Album", description="Single album") -api = APIBlueprint("album", __name__, url_prefix="", abp_tags=[book_tag]) +bp_tag = Tag(name="Album", description="Single album") +api = APIBlueprint("album", __name__, url_prefix="", abp_tags=[bp_tag]) -class GetAlbumBody(BaseModel): - albumhash: str = Field( - description="The hash of the album to get", - example="49e4819273", - min_length=Defaults.HASH_LENGTH, - max_length=Defaults.HASH_LENGTH, - ) - - -@api.post("/album", summary="Get album") -def get_album_tracks_and_info(body: GetAlbumBody): +@api.post("/album") +def get_album_tracks_and_info(body: AlbumHashSchema): """ + Get album and tracks + Returns album info and tracks for the given albumhash. """ albumhash = body.albumhash @@ -84,43 +78,39 @@ def get_album_tracks_and_info(body: GetAlbumBody): "info": album, } -class GetAlbumTracksQuery(BaseModel): - albumhash: str = Field( - description="The hash of the album", - example="49e4819273", - min_length=Defaults.HASH_LENGTH, - max_length=Defaults.HASH_LENGTH, - ) -@api.get("/album//tracks", summary="Get album tracks") -def get_album_tracks(query: GetAlbumTracksQuery): +@api.get("/album//tracks") +def get_album_tracks(path: AlbumHashSchema): """ + Get album tracks + Returns all the tracks in the given album, sorted by disc and track number. + NOTE: No album info is returned. """ - tracks = TrackStore.get_tracks_by_albumhash(query.albumhash) + tracks = TrackStore.get_tracks_by_albumhash(path.albumhash) tracks = sort_by_track_no(tracks) return tracks -class GetMoreFromArtistsBody(BaseModel): + +class GetMoreFromArtistsBody(AlbumLimitSchema): albumartists: str = Field( description="The artist hashes to get more albums from", - example=Defaults.API_ARTISTHASH - ) - limit: int = Field( - description="The maximum number of albums to return per artist", - example=7, - default=7, + example=Defaults.API_ARTISTHASH, ) + base_title: str = Field( description="The base title of the album to exclude from the results.", example=Defaults.API_ALBUMNAME, default=None, ) -@api.post("/album/from-artist", summary="More from artist") + +@api.post("/album/from-artist") def get_more_from_artist(body: GetMoreFromArtistsBody): """ + Get more from artist + Returns more albums from the given artist hashes. """ albumartists = body.albumartists @@ -151,7 +141,7 @@ def get_more_from_artist(body: GetMoreFromArtistsBody): return albums -class GetAlbumVersionsBody(BaseModel): +class GetAlbumVersionsBody(ArtistHashSchema): og_album_title: str = Field( description="The original album title (album.og_title)", example=Defaults.API_ALBUMNAME, @@ -160,14 +150,13 @@ class GetAlbumVersionsBody(BaseModel): description="The base title of the album to exclude from the results.", example=Defaults.API_ALBUMNAME, ) - artisthash: str = Field( - description="The artist hash", - example=Defaults.API_ARTISTHASH, - ) -@api.post("/album/other-versions", summary="Get other versions") + +@api.post("/album/other-versions") def get_album_versions(body: GetAlbumVersionsBody): """ + Get other versions + Returns other versions of the given album. """ og_album_title = body.og_album_title @@ -189,20 +178,16 @@ def get_album_versions(body: GetAlbumVersionsBody): return albums -class GetSimilarAlbumsQuery(BaseModel): - artisthash: str = Field( - description="The artist hash", - example=Defaults.API_ARTISTHASH, - ) - limit: int = Field( - description="The maximum number of albums to return", - example=Defaults.API_CARD_LIMIT, - default=Defaults.API_CARD_LIMIT, - ) -@api.get("/album/similar", summary="Get similar albums") +class GetSimilarAlbumsQuery(ArtistHashSchema, AlbumLimitSchema): + pass + + +@api.get("/album/similar") def get_similar_albums(query: GetSimilarAlbumsQuery): """ + Get similar albums + Returns similar albums to the given album. """ artisthash = query.artisthash diff --git a/app/api/apischemas.py b/app/api/apischemas.py new file mode 100644 index 00000000..a4031491 --- /dev/null +++ b/app/api/apischemas.py @@ -0,0 +1,83 @@ +""" +Reusable Pydantic basic schemas for the API +""" + +from pydantic import BaseModel, Field + +from app.settings import Defaults + + + +class AlbumHashSchema(BaseModel): + """ + Extending this class will give you a model with the `albumhash` field + """ + + albumhash: str = Field( + description="The album hash", + example=Defaults.API_ALBUMHASH, + min_length=Defaults.HASH_LENGTH, + max_length=Defaults.HASH_LENGTH, + ) + + +class ArtistHashSchema(BaseModel): + """ + Extending this class will give you a model with the `artisthash` field + """ + + artisthash: str = Field( + description="The artist hash", + example=Defaults.API_ARTISTHASH, + min_length=Defaults.HASH_LENGTH, + max_length=Defaults.HASH_LENGTH, + ) + + +class GenericLimitSchema(BaseModel): + """ + Extending this class will give you a model with the `limit` field + """ + + limit: int = Field( + description="The number of items to return", + example=Defaults.API_CARD_LIMIT, + default=Defaults.API_CARD_LIMIT + ) + + +# INFO: The following 3 classes are duplicated to specify the type of items +class TrackLimitSchema(BaseModel): + """ + Extending this class will give you a model with the `limit` field + """ + + limit: int = Field( + description="The number of tracks to return", + example=Defaults.API_CARD_LIMIT, + default=Defaults.API_CARD_LIMIT + ) + + +class AlbumLimitSchema(BaseModel): + """ + Extending this class will give you a model with the `limit` field + """ + + limit: int = Field( + description="The number of albums to return", + example=Defaults.API_CARD_LIMIT, + default=Defaults.API_CARD_LIMIT + ) + + +class ArtistLimitSchema(BaseModel): + """ + Extending this class will give you a model with the `limit` field + """ + + limit: int = Field( + description="The number of artists to return", + example=Defaults.API_CARD_LIMIT, + default=Defaults.API_CARD_LIMIT + ) diff --git a/app/api/artist.py b/app/api/artist.py index 235653c9..e26a014c 100644 --- a/app/api/artist.py +++ b/app/api/artist.py @@ -1,11 +1,15 @@ """ Contains all the artist(s) routes. """ + import math import random from datetime import datetime -from flask import Blueprint, request +from flask import request +from flask_openapi3 import APIBlueprint, Tag +from pydantic import BaseModel, Field +from app.api.apischemas import AlbumLimitSchema, ArtistHashSchema, ArtistLimitSchema, TrackLimitSchema from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as fmdb @@ -16,20 +20,19 @@ from app.store.albums import AlbumStore from app.store.artists import ArtistStore from app.store.tracks import TrackStore -api = Blueprint("artist", __name__, url_prefix="/") +bp_tag = Tag(name="Artist", description="Single artist") +api = APIBlueprint("artist", __name__, url_prefix="/", abp_tags=[bp_tag]) -@api.route("/artist/", methods=["GET"]) -def get_artist(artisthash: str): +@api.get("/artist/") +def get_artist(path: ArtistHashSchema, query: TrackLimitSchema): """ Get artist data. + + Returns artist data, tracks and genres for the given artisthash. """ - limit = request.args.get("limit") - - if limit is None: - limit = 6 - - limit = int(limit) + artisthash = path.artisthash + limit = query.limit artist = ArtistStore.get_artist_by_hash(artisthash) @@ -79,16 +82,18 @@ def get_artist(artisthash: str): } -@api.route("/artist//albums", methods=["GET"]) -def get_artist_albums(artisthash: str): - limit = request.args.get("limit") +class GetArtistAlbumsQuery(AlbumLimitSchema): + all: bool = Field( + description="Whether to ignore limit and return all albums", default=False + ) - if limit is None: - limit = 6 - return_all = request.args.get("all") +@api.get("/artist//albums") +def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery): + return_all = query.all + artisthash = path.artisthash - limit = int(limit) + limit = query.limit all_albums = AlbumStore.get_albums_by_artisthash(artisthash) @@ -170,29 +175,26 @@ def get_artist_albums(artisthash: str): } -@api.route("/artist//tracks", methods=["GET"]) -def get_all_artist_tracks(artisthash: str): +@api.get("/artist//tracks") +def get_all_artist_tracks(path: ArtistHashSchema): """ + Get all artist tracks + Returns all artists by a given artist. """ - tracks = TrackStore.get_tracks_by_artisthash(artisthash) + tracks = TrackStore.get_tracks_by_artisthash(path.artisthash) return {"tracks": serialize_tracks(tracks)} -@api.route("/artist//similar", methods=["GET"]) -def get_similar_artists(artisthash: str): +@api.get("/artist//similar") +def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema): """ Returns similar artists. """ - limit = request.args.get("limit") + limit = query.limit - if limit is None: - limit = 6 - - limit = int(limit) - - artist = ArtistStore.get_artist_by_hash(artisthash) + artist = ArtistStore.get_artist_by_hash(path.artisthash) if artist is None: return {"error": "Artist not found"}, 404 From fb635ff35f17efbd209f7a28f86acd602dfd7ac5 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Mon, 4 Mar 2024 02:22:25 +0300 Subject: [PATCH 04/12] add api docs for streaming routes + add trackhash schema --- app/api/__init__.py | 3 ++- app/api/album.py | 29 +++++++++++----------- app/api/apischemas.py | 22 ++++++++++++---- app/api/artist.py | 13 +++++----- app/api/send_file.py | 58 ++++++++++++++++++++++++++++++++----------- app/lib/trackslib.py | 6 ++--- app/settings.py | 1 + 7 files changed, 87 insertions(+), 45 deletions(-) diff --git a/app/api/__init__.py b/app/api/__init__.py index ebf4806c..b72c0ec0 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -29,6 +29,7 @@ from app.api import ( getall, ) +# TODO: Move this description to a separate file open_api_description = f""" The REST API exposed by your Swing Music server @@ -65,7 +66,7 @@ def create_api(): with app.app_context(): app.register_api(album.api) app.register_api(artist.api) - app.register_blueprint(send_file.api) + app.register_api(send_file.api) app.register_blueprint(search.api) app.register_blueprint(folder.api) app.register_blueprint(playlist.api) diff --git a/app/api/album.py b/app/api/album.py index c2f77dfb..75882b18 100644 --- a/app/api/album.py +++ b/app/api/album.py @@ -4,31 +4,32 @@ Contains all the album routes. import random +from pydantic import Field from flask_openapi3 import Tag from flask_openapi3 import APIBlueprint -from pydantic import BaseModel, Field from app.api.apischemas import AlbumHashSchema, AlbumLimitSchema, ArtistHashSchema -from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb -from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb -from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb -from app.lib.albumslib import sort_by_track_no -from app.models import FavType, Track -from app.serializers.album import serialize_for_card -from app.serializers.track import serialize_track from app.settings import Defaults +from app.models import FavType, Track from app.store.albums import AlbumStore from app.store.tracks import TrackStore from app.utils.hashing import create_hash +from app.lib.albumslib import sort_by_track_no +from app.serializers.album import serialize_for_card +from app.serializers.track import serialize_track +from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb +from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb +from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb get_albums_by_albumartist = adb.get_albums_by_albumartist check_is_fav = favdb.check_is_favorite bp_tag = Tag(name="Album", description="Single album") -api = APIBlueprint("album", __name__, url_prefix="", abp_tags=[bp_tag]) +api = APIBlueprint("album", __name__, url_prefix="/album", abp_tags=[bp_tag]) -@api.post("/album") +# NOTE: Don't use "/" as it will cause redirects (failure) +@api.post("") def get_album_tracks_and_info(body: AlbumHashSchema): """ Get album and tracks @@ -79,7 +80,7 @@ def get_album_tracks_and_info(body: AlbumHashSchema): } -@api.get("/album//tracks") +@api.get("//tracks") def get_album_tracks(path: AlbumHashSchema): """ Get album tracks @@ -106,7 +107,7 @@ class GetMoreFromArtistsBody(AlbumLimitSchema): ) -@api.post("/album/from-artist") +@api.post("/from-artist") def get_more_from_artist(body: GetMoreFromArtistsBody): """ Get more from artist @@ -152,7 +153,7 @@ class GetAlbumVersionsBody(ArtistHashSchema): ) -@api.post("/album/other-versions") +@api.post("/other-versions") def get_album_versions(body: GetAlbumVersionsBody): """ Get other versions @@ -183,7 +184,7 @@ class GetSimilarAlbumsQuery(ArtistHashSchema, AlbumLimitSchema): pass -@api.get("/album/similar") +@api.get("/similar") def get_similar_albums(query: GetSimilarAlbumsQuery): """ Get similar albums diff --git a/app/api/apischemas.py b/app/api/apischemas.py index a4031491..eb024a90 100644 --- a/app/api/apischemas.py +++ b/app/api/apischemas.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, Field from app.settings import Defaults - class AlbumHashSchema(BaseModel): """ Extending this class will give you a model with the `albumhash` field @@ -34,6 +33,19 @@ class ArtistHashSchema(BaseModel): ) +class TrackHashSchema(BaseModel): + """ + Extending this class will give you a model with the `trackhash` field + """ + + trackhash: str = Field( + description="The track hash", + example=Defaults.API_TRACKHASH, + min_length=Defaults.HASH_LENGTH, + max_length=Defaults.HASH_LENGTH, + ) + + class GenericLimitSchema(BaseModel): """ Extending this class will give you a model with the `limit` field @@ -42,7 +54,7 @@ class GenericLimitSchema(BaseModel): limit: int = Field( description="The number of items to return", example=Defaults.API_CARD_LIMIT, - default=Defaults.API_CARD_LIMIT + default=Defaults.API_CARD_LIMIT, ) @@ -55,7 +67,7 @@ class TrackLimitSchema(BaseModel): limit: int = Field( description="The number of tracks to return", example=Defaults.API_CARD_LIMIT, - default=Defaults.API_CARD_LIMIT + default=Defaults.API_CARD_LIMIT, ) @@ -67,7 +79,7 @@ class AlbumLimitSchema(BaseModel): limit: int = Field( description="The number of albums to return", example=Defaults.API_CARD_LIMIT, - default=Defaults.API_CARD_LIMIT + default=Defaults.API_CARD_LIMIT, ) @@ -79,5 +91,5 @@ class ArtistLimitSchema(BaseModel): limit: int = Field( description="The number of artists to return", example=Defaults.API_CARD_LIMIT, - default=Defaults.API_CARD_LIMIT + default=Defaults.API_CARD_LIMIT, ) diff --git a/app/api/artist.py b/app/api/artist.py index e26a014c..a1309f1c 100644 --- a/app/api/artist.py +++ b/app/api/artist.py @@ -6,9 +6,8 @@ import math import random from datetime import datetime -from flask import request from flask_openapi3 import APIBlueprint, Tag -from pydantic import BaseModel, Field +from pydantic import Field from app.api.apischemas import AlbumLimitSchema, ArtistHashSchema, ArtistLimitSchema, TrackLimitSchema from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb @@ -21,10 +20,10 @@ from app.store.artists import ArtistStore from app.store.tracks import TrackStore bp_tag = Tag(name="Artist", description="Single artist") -api = APIBlueprint("artist", __name__, url_prefix="/", abp_tags=[bp_tag]) +api = APIBlueprint("artist", __name__, url_prefix="/artist", abp_tags=[bp_tag]) -@api.get("/artist/") +@api.get("/") def get_artist(path: ArtistHashSchema, query: TrackLimitSchema): """ Get artist data. @@ -88,7 +87,7 @@ class GetArtistAlbumsQuery(AlbumLimitSchema): ) -@api.get("/artist//albums") +@api.get("//albums") def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery): return_all = query.all artisthash = path.artisthash @@ -175,7 +174,7 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery): } -@api.get("/artist//tracks") +@api.get("//tracks") def get_all_artist_tracks(path: ArtistHashSchema): """ Get all artist tracks @@ -187,7 +186,7 @@ def get_all_artist_tracks(path: ArtistHashSchema): return {"tracks": serialize_tracks(tracks)} -@api.get("/artist//similar") +@api.get("//similar") def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema): """ Returns similar artists. diff --git a/app/api/send_file.py b/app/api/send_file.py index 94f3a4c5..e07f32a7 100644 --- a/app/api/send_file.py +++ b/app/api/send_file.py @@ -1,30 +1,43 @@ """ Contains all the track routes. """ + import os from flask import Blueprint, send_file, request +from flask_openapi3 import APIBlueprint, Tag +from pydantic import BaseModel, Field +from app.api.apischemas import TrackHashSchema from app.lib.trackslib import get_silence_paddings from app.store.tracks import TrackStore -api = Blueprint("track", __name__, url_prefix="/") +bp_tag = Tag(name="File", description="Single artist") +api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag]) -@api.route("/file/") -def send_track_file(trackhash: str): +class SendTrackFileQuery(BaseModel): + filepath: str = Field( + description="The filepath to play (if available)", default=None + ) + + +@api.get("/") +def send_track_file(path: TrackHashSchema, query: SendTrackFileQuery): """ - Returns an audio file that matches the passed id to the client. - Falls back to track hash if id is not found. + Get file + + Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found. """ + trackhash = path.trackhash + filepath = query.filepath msg = {"msg": "File Not Found"} def get_mime(filename: str) -> str: ext = filename.rsplit(".", maxsplit=1)[-1] return f"audio/{ext}" - filepath = request.args.get("filepath") - + # If filepath is provide, try to send that if filepath is not None: try: track = TrackStore.get_tracks_by_filepaths([filepath])[0] @@ -37,9 +50,7 @@ def send_track_file(trackhash: str): audio_type = get_mime(filepath) return send_file(filepath, mimetype=audio_type) - if trackhash is None: - return msg, 404 - + # Else, find file by trackhash tracks = TrackStore.get_tracks_by_trackhashes([trackhash]) for track in tracks: @@ -56,11 +67,28 @@ def send_track_file(trackhash: str): return msg, 404 -@api.route("/file/silence", methods=["POST"]) -def get_audio_silence(): - data = request.get_json() - ending_file = data.get("end", None) # ending file's filepath - starting_file = data.get("start", None) # starting file's filepath +class GetAudioSilenceBody(BaseModel): + ending_file: str = Field( + description="The ending file's path", + example="/home/cwilvx/Music/Made in Kenya/Sol generation/Bensoul - Salama.mp3", + ) + starting_file: str = Field( + description="The beginning file's path", + example="/home/cwilvx/Music/Tidal/Albums/Bensoul - Qwarantunes/Bensoul - Peddi.m4a", + ) + + +@api.post("/silence") +def get_audio_silence(body: GetAudioSilenceBody): + """ + Get silence paddings + + Returns the duration of silence at the end of the current ending track and the duration of silence at the beginning of the next track. + + NOTE: Durations are in milliseconds. + """ + ending_file = body.ending_file # ending file's filepath + starting_file = body.starting_file # starting file's filepath if ending_file is None or starting_file is None: return {"msg": "No filepath provided"}, 400 diff --git a/app/lib/trackslib.py b/app/lib/trackslib.py index d12662f8..a685c0bd 100644 --- a/app/lib/trackslib.py +++ b/app/lib/trackslib.py @@ -60,7 +60,7 @@ def get_silence_paddings(ending_file: str, starting_file: str): """ Returns the ending silence of a track and the starting silence of the next. """ - silence = {"start": 0, "end": 0} + silence = {"starting_file": 0, "ending_file": 0} ending_thread = None starting_thread = None @@ -77,9 +77,9 @@ def get_silence_paddings(ending_file: str, starting_file: str): starting_thread.start() if ending_thread: - silence["end"] = ending_thread.join() + silence["ending_file"] = ending_thread.join() if starting_thread: - silence["start"] = starting_thread.join() + silence["starting_file"] = starting_thread.join() return silence diff --git a/app/settings.py b/app/settings.py index 37203b46..e141576f 100644 --- a/app/settings.py +++ b/app/settings.py @@ -100,6 +100,7 @@ class Defaults: HASH_LENGTH = 10 API_ALBUMHASH = "c5bcec6cb3" API_ARTISTHASH = "fc6f0acac5" + API_TRACKHASH = "0853280a12" API_ALBUMNAME = "Rumours" API_CARD_LIMIT = 6 From 766eb388b23ec5c811f2f27d5c8c0f6f86e1abc9 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Sun, 10 Mar 2024 17:21:22 +0300 Subject: [PATCH 05/12] add timestamp to favorite entries + convert useBisection into a function --- app/api/artist.py | 4 +-- app/api/favorites.py | 34 +++++++++++++-------- app/db/sqlite/favorite.py | 6 ++-- app/db/sqlite/queries.py | 3 +- app/migrations/__init__.py | 5 ++- app/migrations/v1_4_9/__init__.py | 34 +++++++++++++++++++++ app/store/artists.py | 6 ++-- app/store/tracks.py | 4 +-- app/utils/bisection.py | 51 +++++++++++++------------------ 9 files changed, 95 insertions(+), 52 deletions(-) create mode 100644 app/migrations/v1_4_9/__init__.py diff --git a/app/api/artist.py b/app/api/artist.py index a1309f1c..eb425f9e 100644 --- a/app/api/artist.py +++ b/app/api/artist.py @@ -183,7 +183,7 @@ def get_all_artist_tracks(path: ArtistHashSchema): """ tracks = TrackStore.get_tracks_by_artisthash(path.artisthash) - return {"tracks": serialize_tracks(tracks)} + return serialize_tracks(tracks) @api.get("//similar") @@ -208,7 +208,7 @@ def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema): if len(similar) > limit: similar = random.sample(similar, limit) - return {"artists": similar[:limit]} + return similar[:limit] # TODO: Rewrite this file using generators where possible diff --git a/app/api/favorites.py b/app/api/favorites.py index e1ec0db4..6add5aa7 100644 --- a/app/api/favorites.py +++ b/app/api/favorites.py @@ -1,21 +1,26 @@ +from typing import List, TypeVar from flask import Blueprint, request -from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.models import FavType -from app.serializers.album import serialize_for_card, serialize_for_card_many -from app.serializers.artist import serialize_for_card as serialize_artist +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 -from app.utils.bisection import UseBisection +from app.serializers.artist import serialize_for_card as serialize_artist +from app.serializers.album import serialize_for_card, serialize_for_card_many -from app.store.artists import ArtistStore from app.store.albums import AlbumStore 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="/") -def remove_none(items: list): +T = TypeVar("T") + + +def remove_none(items: List[T]) -> List[T]: return [i for i in items if i is not None] @@ -76,7 +81,7 @@ def get_favorite_albums(): src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash) - fav_albums = UseBisection(src_albums, "albumhash", albumhashes)() + fav_albums = use_bisection(src_albums, "albumhash", albumhashes) fav_albums = remove_none(fav_albums) if limit == 0: @@ -99,7 +104,7 @@ def get_favorite_tracks(): trackhashes.reverse() src_tracks = sorted(TrackStore.tracks, key=lambda x: x.trackhash) - tracks = UseBisection(src_tracks, "trackhash", trackhashes)() + tracks = use_bisection(src_tracks, "trackhash", trackhashes) tracks = remove_none(tracks) if limit == 0: @@ -123,7 +128,7 @@ def get_favorite_artists(): src_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash) - artists = UseBisection(src_artists, "artisthash", artisthashes)() + artists = use_bisection(src_artists, "artisthash", artisthashes) artists = remove_none(artists) if limit == 0: @@ -169,6 +174,7 @@ def get_all_favorites(): artist_master_hash = set(a.artisthash for a in ArtistStore.artists) for fav in favs: + # INFO: hash is [1], type is [2], timestamp is [3] hash = fav[1] if fav[2] == FavType.track: tracks.append(hash) if hash in track_master_hash else None @@ -189,9 +195,9 @@ def get_all_favorites(): src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash) src_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash) - tracks = UseBisection(src_tracks, "trackhash", tracks, limit=track_limit)() - albums = UseBisection(src_albums, "albumhash", albums, limit=album_limit)() - artists = UseBisection(src_artists, "artisthash", artists, limit=artist_limit)() + tracks = use_bisection(src_tracks, "trackhash", tracks, limit=track_limit) + albums = use_bisection(src_albums, "albumhash", albums, limit=album_limit) + artists = use_bisection(src_artists, "artisthash", artists, limit=artist_limit) tracks = remove_none(tracks) albums = remove_none(albums) @@ -201,6 +207,7 @@ def get_all_favorites(): # first_n = favs for fav in favs: + # INFO: hash is [1], type is [2], timestamp is [3] if len(recents) >= largest: break @@ -212,6 +219,7 @@ def get_all_favorites(): album = serialize_for_card(album) album["help_text"] = "album" + album["time"] = timestamp_to_time_passed(fav[3]) recents.append( { @@ -228,6 +236,7 @@ def get_all_favorites(): artist = serialize_artist(artist) artist["help_text"] = "artist" + artist["time"] = timestamp_to_time_passed(fav[3]) recents.append( { @@ -244,6 +253,7 @@ def get_all_favorites(): track = serialize_track(track) track["help_text"] = "track" + track["time"] = timestamp_to_time_passed(fav[3]) recents.append({"type": "track", "item": track}) diff --git a/app/db/sqlite/favorite.py b/app/db/sqlite/favorite.py index fb1ef911..162400b3 100644 --- a/app/db/sqlite/favorite.py +++ b/app/db/sqlite/favorite.py @@ -1,3 +1,4 @@ +from datetime import datetime from app.models import FavType from .utils import SQLiteManager @@ -26,9 +27,10 @@ class SQLiteFavoriteMethods: if cls.check_is_favorite(fav_hash, fav_type): return - sql = """INSERT INTO favorites(type, hash) VALUES(?,?)""" + sql = """INSERT INTO favorites(type, hash, timestamp) VALUES(?,?,?)""" + current_timestamp = datetime.now().timestamp() with SQLiteManager(userdata_db=True) as cur: - cur.execute(sql, (fav_type, fav_hash)) + cur.execute(sql, (fav_type, fav_hash, current_timestamp)) cur.close() @classmethod diff --git a/app/db/sqlite/queries.py b/app/db/sqlite/queries.py index c286a004..96b8b5b1 100644 --- a/app/db/sqlite/queries.py +++ b/app/db/sqlite/queries.py @@ -15,7 +15,8 @@ CREATE TABLE IF NOT EXISTS playlists ( CREATE TABLE IF NOT EXISTS favorites ( id integer PRIMARY KEY, hash text not null, - type text not null + type text not null, + timestamp integer not null default 0 ); CREATE TABLE IF NOT EXISTS settings ( diff --git a/app/migrations/__init__.py b/app/migrations/__init__.py index 7bc87b80..8aaf1f5e 100644 --- a/app/migrations/__init__.py +++ b/app/migrations/__init__.py @@ -13,7 +13,7 @@ PS: Fuck that! Do what you want. from app.db.sqlite.migrations import MigrationManager from app.logger import log -from app.migrations import v1_3_0 +from app.migrations import v1_3_0, v1_4_9 from app.migrations.base import Migration migrations: list[list[Migration]] = [ @@ -26,6 +26,9 @@ migrations: list[list[Migration]] = [ v1_3_0.MovePlaylistsAndFavoritesTo10BitHashes, v1_3_0.RemoveAllTracks, v1_3_0.UpdateAppSettingsTable, + ], + [ + v1_4_9.AddTimestampToFavoritesTable ] ] diff --git a/app/migrations/v1_4_9/__init__.py b/app/migrations/v1_4_9/__init__.py new file mode 100644 index 00000000..4ebd0eae --- /dev/null +++ b/app/migrations/v1_4_9/__init__.py @@ -0,0 +1,34 @@ +from app.db.sqlite.utils import SQLiteManager +from app.migrations.base import Migration + + +class AddTimestampToFavoritesTable(Migration): + """ + Adds a timestamp column to the favorites table. + """ + + @staticmethod + def migrate(): + # INFO: add timestamp column with automatic current timestamp + sql = f"ALTER TABLE favorites ADD COLUMN timestamp INTEGER NOT NULL DEFAULT 0" + + # INFO: execute the sql + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql) + + # INFO: Update the timestamp column with the current timestamp + cur.execute("UPDATE favorites SET timestamp = strftime('%s', 'now')") + cur.close() + + +class MoveHashesToSha1(Migration): + """ + Moves the 10 bit item hashes from sha256 to sha1 which is + faster and more lenient on less powerful devices. + + Thanks to [@tcsenpai](https:github.com/tcsenpai) for the contribution. + """ + pass + + # INFO: Apparentlly, every single table is affected by this migration. + # NOTE: Use generators to avoid memory issues. \ No newline at end of file diff --git a/app/store/artists.py b/app/store/artists.py index f640c169..84bcb242 100644 --- a/app/store/artists.py +++ b/app/store/artists.py @@ -3,7 +3,7 @@ import json from app.db.sqlite.artistcolors import SQLiteArtistMethods as ardb from app.lib.artistlib import get_all_artists from app.models import Artist -from app.utils.bisection import UseBisection +from app.utils.bisection import use_bisection from app.utils.customlist import CustomList from app.utils.progressbar import tqdm @@ -72,7 +72,7 @@ class ArtistStore: """ artists = sorted(cls.artists, key=lambda x: x.artisthash) try: - artist = UseBisection(artists, "artisthash", [artisthash])()[0] + artist = use_bisection(artists, "artisthash", [artisthash])[0] return artist except IndexError: return None @@ -83,7 +83,7 @@ class ArtistStore: Returns artists by their hashes. """ artists = sorted(cls.artists, key=lambda x: x.artisthash) - artists = UseBisection(artists, "artisthash", artisthashes)() + artists = use_bisection(artists, "artisthash", artisthashes) return [a for a in artists if a is not None] @classmethod diff --git a/app/store/tracks.py b/app/store/tracks.py index 7d8625bb..6b56a829 100644 --- a/app/store/tracks.py +++ b/app/store/tracks.py @@ -3,7 +3,7 @@ from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.db.sqlite.tracks import SQLiteTrackMethods as tdb from app.models import Track -from app.utils.bisection import UseBisection +from app.utils.bisection import use_bisection from app.utils.customlist import CustomList from app.utils.remove_duplicates import remove_duplicates @@ -153,7 +153,7 @@ class TrackStore: Returns all tracks matching the given paths. """ tracks = sorted(cls.tracks, key=lambda x: x.filepath) - tracks = UseBisection(tracks, "filepath", paths)() + tracks = use_bisection(tracks, "filepath", paths) return [track for track in tracks if track is not None] @classmethod diff --git a/app/utils/bisection.py b/app/utils/bisection.py index 2941f10a..ef88e1d6 100644 --- a/app/utils/bisection.py +++ b/app/utils/bisection.py @@ -1,52 +1,45 @@ -from app.models.track import Track +from typing import List, Optional, TypeVar +T = TypeVar("T") -class UseBisection: +def use_bisection( + source: List[T], key: str, queries: List[str], limit: int = -1 +) -> List[Optional[T]]: """ Uses bisection to find a list of items in another list. - returns a list of found items with `None` items being not found - items. + Returns a list of found items with `None` items being not found items. """ - def __init__( - self, source: list, search_from: str, queries: list[str], limit=-1 - ) -> None: - self.source_list = source - self.queries_list = queries - self.attr = search_from - self.limit = limit - - def find(self, query: str): + def find(query: str): left = 0 - right = len(self.source_list) - 1 + right = len(source) - 1 while left <= right: mid = (left + right) // 2 - if self.source_list[mid].__getattribute__(self.attr) == query: - return self.source_list[mid] - elif self.source_list[mid].__getattribute__(self.attr) > query: + if source[mid].__getattribute__(key) == query: + return source[mid] + elif source[mid].__getattribute__(key) > query: right = mid - 1 else: left = mid + 1 return None - def __call__(self): - if len(self.source_list) == 0: - return [] + if len(source) == 0: + return [] - results: list[Track] = [] + results = [] - for query in self.queries_list: - res = self.find(query) + for query in queries: + res = find(query) - if res is None: - continue + if res is None: + continue - results.append(res) + results.append(res) - if self.limit != -1 and len(results) >= self.limit: - break + if limit != -1 and len(results) >= limit: + break - return results + return results From 4edb3a5e7aa1328ebcce1bce2a160017de276e4a Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Sun, 10 Mar 2024 17:40:35 +0300 Subject: [PATCH 06/12] add docs for search endpoints --- .github/changelog.md | 27 ++------------- app/api/__init__.py | 2 +- app/api/search.py | 78 ++++++++++++++++++++++++++------------------ app/api/send_file.py | 2 +- app/settings.py | 1 + 5 files changed, 51 insertions(+), 59 deletions(-) diff --git a/.github/changelog.md b/.github/changelog.md index d424a67d..1d087f45 100644 --- a/.github/changelog.md +++ b/.github/changelog.md @@ -1,29 +1,6 @@ # What's New? -- New opt-in alternate (no sidebar) layout -- Added search bar to the top bar (all layouts) -- Move browse options to homepage bottom -- Move to a stronger WSGI server ([waitress](https://docs.pylonsproject.org/projects/waitress/en/stable/)) -- Proper ARM64 and other platforms support -- A Proper timezone fix. Thanks to @tcsenpai on #170 -- Hovering over a recently played/added item on the homepage will reveal how long ago -- Recently added items will not have a cutoff -- The exhaustive list of web client stuff can be found on [commit 4211ccc](https://github.com/swing-opensource/swingmusic-client/commit/4211ccc685e3d33dbf008cbb6c77542baf0130dc) in the client repo. - -# Bug fixes & Enhancements - -- Lyrics plugin now works when Swing Music is auto started (tested on Ubuntu) -- Track not being removed from queue -- Playlist list page moving out of bounds -- Save queue as playlist not working -- Keyboard shortcuts not working in first try -- Fix recently added items not filling row -- Fix recently added items order +- Hovering on recent favorite item will show how long ago it was ♥ed # Development - -- WIP code base documentation to `.github/docs`. Contributions are welcome! -- Bump watchdog to v4 - -> [!TIP] -> Plans for a mobile are underway. The development will be led by @EricGacoki \ No newline at end of file +- API documentation on /openapi \ No newline at end of file diff --git a/app/api/__init__.py b/app/api/__init__.py index b72c0ec0..9cc6db1f 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -67,7 +67,7 @@ def create_api(): app.register_api(album.api) app.register_api(artist.api) app.register_api(send_file.api) - app.register_blueprint(search.api) + app.register_api(search.api) app.register_blueprint(folder.api) app.register_blueprint(playlist.api) app.register_blueprint(favorites.api) diff --git a/app/api/search.py b/app/api/search.py index f554c549..3b55a82b 100644 --- a/app/api/search.py +++ b/app/api/search.py @@ -2,14 +2,19 @@ Contains all the search routes. """ -from flask import Blueprint, request +from flask import request +from pydantic import BaseModel, Field from unidecode import unidecode +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint from app import models from app.lib import searchlib +from app.settings import Defaults from app.store.tracks import TrackStore -api = Blueprint("search", __name__, url_prefix="/") +tag = Tag(name="Search", description="Search for tracks, albums and artists") +api = APIBlueprint("search", __name__, url_prefix="/search", abp_tags=[tag]) SEARCH_COUNT = 30 """The max amount of items to return per request""" @@ -63,18 +68,19 @@ class Search: return finder.search(self.query, in_quotes=in_quotes, limit=limit) -@api.route("/search/tracks", methods=["GET"]) -def search_tracks(): +class SearchQuery(BaseModel): + q: str = Field(description="The search query", example=Defaults.API_ARTISTNAME) + + +@api.get("/tracks") +def search_tracks(query: SearchQuery): """ - Searches for tracks that match the search query. + Searches for tracks """ - query = request.args.get("q") + query = query.q in_quotes = query_in_quotes(query) - if not query: - return {"error": "No query provided"}, 400 - tracks = Search(query).search_tracks(in_quotes) return { @@ -83,18 +89,15 @@ def search_tracks(): } -@api.route("/search/albums", methods=["GET"]) -def search_albums(): +@api.get("/albums") +def search_albums(query: SearchQuery): """ Searches for albums. """ - query = request.args.get("q") + query = query.q in_quotes = query_in_quotes(query) - if not query: - return {"error": "No query provided"}, 400 - albums = Search(query).search_albums(in_quotes) return { @@ -103,13 +106,13 @@ def search_albums(): } -@api.route("/search/artists", methods=["GET"]) -def search_artists(): +@api.get("/artists") +def search_artists(query: SearchQuery): """ Searches for artists. """ - query = request.args.get("q") + query = query.q if not query: return {"error": "No query provided"}, 400 @@ -122,15 +125,20 @@ def search_artists(): } -@api.route("/search/top", methods=["GET"]) -def get_top_results(): +class TopResultsQuery(SearchQuery): + limit: int = Field( + description="The number of items to return", default=Defaults.API_CARD_LIMIT + ) + + +@api.get("/top") +def get_top_results(query: TopResultsQuery): """ Returns the top results for the search query. """ - query = request.args.get("q") - limit = request.args.get("limit", "6") - limit = int(limit) + query = query.q + limit = query.limit in_quotes = query_in_quotes(query) @@ -140,32 +148,38 @@ def get_top_results(): return Search(query).get_top_results(in_quotes=in_quotes, limit=limit) -@api.route("/search/loadmore") -def search_load_more(): +class SearchLoadMoreQuery(SearchQuery): + type: str = Field(description="The type of search", example="tracks") + index: int = Field(description="The index to start from", default=0) + + +@api.get("/loadmore") +def search_load_more(query: SearchLoadMoreQuery): """ Returns more songs, albums or artists from a search query. + + NOTE: You must first initiate a search using the `/search` endpoint. """ - query = request.args.get("q") + query = query.q + item_type = query.type + index = query.index in_quotes = query_in_quotes(query) - s_type = request.args.get("type") - index = int(request.args.get("index") or 0) - - if s_type == "tracks": + if item_type == "tracks": t = Search(query).search_tracks(in_quotes) return { "tracks": t[index : index + SEARCH_COUNT], "more": len(t) > index + SEARCH_COUNT, } - elif s_type == "albums": + elif item_type == "albums": a = Search(query).search_albums(in_quotes) return { "albums": a[index : index + SEARCH_COUNT], "more": len(a) > index + SEARCH_COUNT, } - elif s_type == "artists": + elif item_type == "artists": a = Search(query).search_artists() return { "artists": a[index : index + SEARCH_COUNT], diff --git a/app/api/send_file.py b/app/api/send_file.py index e07f32a7..bbcd7c05 100644 --- a/app/api/send_file.py +++ b/app/api/send_file.py @@ -12,7 +12,7 @@ from app.lib.trackslib import get_silence_paddings from app.store.tracks import TrackStore -bp_tag = Tag(name="File", description="Single artist") +bp_tag = Tag(name="File", description="Audio files") api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag]) diff --git a/app/settings.py b/app/settings.py index e141576f..5021e62d 100644 --- a/app/settings.py +++ b/app/settings.py @@ -102,6 +102,7 @@ class Defaults: API_ARTISTHASH = "fc6f0acac5" API_TRACKHASH = "0853280a12" API_ALBUMNAME = "Rumours" + API_ARTISTNAME = "girl in red" API_CARD_LIMIT = 6 From ae031014a90ea0792ee1a76f06fb2472f0da73a2 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Sun, 10 Mar 2024 19:44:12 +0300 Subject: [PATCH 07/12] 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, [] From 99ec11565c54ec69a9322b68707c73e7f87eeee6 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Mon, 11 Mar 2024 11:33:59 +0300 Subject: [PATCH 08/12] fix migrations deleting all playlists --- .TODO.md | 13 +++++++++++++ app/api/playlist.py | 3 +-- app/api/search.py | 3 +-- app/migrations/__init__.py | 9 +++------ 4 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 .TODO.md diff --git a/.TODO.md b/.TODO.md new file mode 100644 index 00000000..d508ea0d --- /dev/null +++ b/.TODO.md @@ -0,0 +1,13 @@ +# TODO +A list of things to do. Feel free to grab one of this and work on it. + +## Clean up old playlist images +When playlists are deleted or their images updated, the old +images are not deleted. + +Write a function to: +- read all images in the playlist image directory and checks unlinked images by comparing the list with the .image property of each property +- delete unlinked images + +The function can run on app start up, before periodic checks + diff --git a/app/api/playlist.py b/app/api/playlist.py index 0247901b..91461fdf 100644 --- a/app/api/playlist.py +++ b/app/api/playlist.py @@ -26,12 +26,11 @@ api = APIBlueprint("playlists", __name__, url_prefix="/playlists", abp_tags=[tag PL = SQLitePlaylistMethods - class SendAllPlaylistsQuery(BaseModel): no_images: bool = Field(False, description="Whether to include images") -@api.get("", methods=["GET"]) +@api.get("") def send_all_playlists(query: SendAllPlaylistsQuery): """ Gets all the playlists. diff --git a/app/api/search.py b/app/api/search.py index cff4cee1..871c3dcf 100644 --- a/app/api/search.py +++ b/app/api/search.py @@ -138,9 +138,8 @@ def get_top_results(query: TopResultsQuery): Returns the top results for the given query. """ - - query = query.q limit = query.limit + query = query.q in_quotes = query_in_quotes(query) diff --git a/app/migrations/__init__.py b/app/migrations/__init__.py index 8aaf1f5e..c35baf71 100644 --- a/app/migrations/__init__.py +++ b/app/migrations/__init__.py @@ -10,7 +10,6 @@ ONLY MODIFY OLD MIGRATIONS FOR BUG FIXES OR ENHANCEMENTS ONLY PS: Fuck that! Do what you want. """ - from app.db.sqlite.migrations import MigrationManager from app.logger import log from app.migrations import v1_3_0, v1_4_9 @@ -27,9 +26,7 @@ migrations: list[list[Migration]] = [ v1_3_0.RemoveAllTracks, v1_3_0.UpdateAppSettingsTable, ], - [ - v1_4_9.AddTimestampToFavoritesTable - ] + [v1_4_9.AddTimestampToFavoritesTable], ] @@ -41,8 +38,8 @@ def apply_migrations(): version = MigrationManager.get_version() if version != len(migrations): - # run migrations after the previous migration version - for migration in migrations[(version - 1) :]: + # INFO: Apply new migrations + for migration in migrations[version:]: for m in migration: try: m.migrate() From 0af1ae1d8e4af6f3d6b10d04ac5d80d523eb4963 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Sun, 24 Mar 2024 15:57:58 +0300 Subject: [PATCH 09/12] 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" From ed6732e7fda21c5d8eee70b4abf76da53b305dc7 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Sun, 24 Mar 2024 16:09:42 +0300 Subject: [PATCH 10/12] add requirements.txt + attempt to remove poetry from distribution --- .github/workflows/release.yml | 22 +++++++++-- .gitignore | 2 +- Dockerfile | 9 +++-- requirements.txt | 70 +++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 requirements.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5714c47f..932ecc2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,15 +62,29 @@ jobs: uses: actions/setup-python@v2 with: python-version: "3.10.11" - - name: Install Poetry + - name: Create virtualenv run: | - pip install poetry + python -m venv .venv + - name: Activate virtualenv (linux) + if: matrix.os == 'ubuntu-20.04' + run: | + source .venv/bin/activate + - name: Activate virtualenv (windows) + if: matrix.os == 'windows-2019' + run: | + .venv\Scripts\Activate - name: Install dependencies run: | - python -m poetry install + pip install -r requirements.txt + # - name: Install Poetry + # run: | + # pip install poetry + # - name: Install dependencies + # run: | + # python -m poetry install - name: Build server run: | - python -m poetry run python manage.py --build + python manage.py --build env: SWINGMUSIC_APP_VERSION: ${{ inputs.tag }} - name: Verify Linux build success diff --git a/.gitignore b/.gitignore index 0e5621e6..7b09bc4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # local env files .env.local .env.*.local - +venv # Editor directories and files .idea diff --git a/Dockerfile b/Dockerfile index 770c92a8..f9f73093 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,11 +28,14 @@ VOLUME /music VOLUME /config -RUN pip install poetry +# RUN pip install poetry +RUN python -m venv /venv -RUN poetry config virtualenvs.create false +# RUN poetry config virtualenvs.create false +RUN . /venv/bin/activate -RUN poetry install +# RUN poetry install +RUN pip install -r requirements.txt RUN apt-get update && apt-get install -y ffmpeg libavcodec-extra diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..51b16ecf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,70 @@ +altgraph==0.17.4 +annotated-types==0.6.0 +astroid==2.15.8 +attrs==23.1.0 +black==22.12.0 +blinker==1.6.3 +Brotli==1.1.0 +certifi==2023.7.22 +charset-normalizer==3.3.0 +click==8.1.7 +colorgram.py==1.2.0 +ConfigArgParse==1.7 +dill==0.3.7 +Flask==2.3.3 +Flask-BasicAuth==0.2.0 +Flask-Compress==1.14 +Flask-Cors==3.0.10 +flask-openapi3==3.0.2 +gevent==23.9.1 +geventhttpclient==2.0.11 +greenlet==3.0.3 +hypothesis==6.87.3 +idna==3.4 +iniconfig==2.0.0 +isort==5.12.0 +itsdangerous==2.1.2 +Jinja2==3.1.2 +lazy-object-proxy==1.9.0 +locust==2.20.1 +MarkupSafe==2.1.3 +mccabe==0.7.0 +msgpack==1.0.7 +mypy-extensions==1.0.0 +packaging==23.2 +pathspec==0.11.2 +pendulum==3.0.0 +Pillow==9.5.0 +platformdirs==3.11.0 +pluggy==1.3.0 +psutil==5.9.5 +pydantic==2.6.3 +pydantic_core==2.16.3 +pyinstaller==5.13.2 +pyinstaller-hooks-contrib==2023.9 +pylint==2.17.7 +pytest==7.4.2 +python-dateutil==2.8.2 +pyxdg==0.28 +pyzmq==25.1.2 +rapidfuzz==2.15.2 +requests==2.31.0 +roundrobin==0.0.4 +setproctitle==1.3.3 +show-in-file-manager==1.1.4 +six==1.16.0 +sortedcontainers==2.4.0 +tabulate==0.9.0 +tinytag==1.9.0 +tomlkit==0.12.1 +tqdm==4.66.1 +typing_extensions==4.8.0 +tzdata==2024.1 +Unidecode==1.3.7 +urllib3==2.0.6 +waitress==2.1.2 +watchdog==4.0.0 +Werkzeug==3.0.0 +wrapt==1.15.0 +zope.event==5.0 +zope.interface==6.1 From 21ba84c03496b5ea646a026235f0969296d01ca4 Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Sun, 24 Mar 2024 19:14:29 +0300 Subject: [PATCH 11/12] remove poetry from dockerfile --- Dockerfile | 3 ++- app/api/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index f9f73093..9c1f920f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,4 +39,5 @@ RUN pip install -r requirements.txt RUN apt-get update && apt-get install -y ffmpeg libavcodec-extra -ENTRYPOINT ["poetry", "run", "python", "manage.py", "--host", "0.0.0.0", "--config", "/config"] \ No newline at end of file +# ENTRYPOINT ["poetry", "run", "python", "manage.py", "--host", "0.0.0.0", "--config", "/config"] +ENTRYPOINT ["python", "manage.py", "--host", "0.0.0.0", "--config", "/config"] \ No newline at end of file diff --git a/app/api/__init__.py b/app/api/__init__.py index 64c550ad..206bc91e 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -55,7 +55,7 @@ def create_api(): description=open_api_description, ) - app = OpenAPI(__name__, info=api_info) + app = OpenAPI(__name__, info=api_info, doc_prefix="/docs") CORS(app, origins="*") Compress(app) From 7842a700577d37baedc17c7094c9bf0c03e1b78c Mon Sep 17 00:00:00 2001 From: mungai-njoroge Date: Tue, 26 Mar 2024 14:28:12 +0300 Subject: [PATCH 12/12] fix: adding root dirs --- .github/contributing.md | 4 +++- .github/workflows/release.yml | 2 +- README.md | 19 +++++++++++++------ app/api/folder.py | 19 +++++++++++-------- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/contributing.md b/.github/contributing.md index 32ebd8ab..31be9f64 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -79,7 +79,9 @@ yarn dev You can see the client at http://localhost:5173. -> The client is hardcoded to hook into the server on port `1980` (to allow the another server instance to be running on the default port). You can follow the instructions above to set up the server in that port, or you can change the port in `swingmusic-client/config.ts`. Don't forget to change it back when in the PR. +> The client is hardcoded to hook into the server on port `1980` (to allow the another server instance to be running on the default port). +> +> You can follow the instructions above to set up the server in that port, or you can change the port in `swingmusic-client/config.ts`. Don't forget to change it back when in the PR. ## Where can I go for help? diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 932ecc2d..dabc0d07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -183,6 +183,6 @@ jobs: tags: ghcr.io/${{github.repository}}:${{format('v{0}', inputs.tag)}}, ${{env.LATEST_TAG}} labels: org.opencontainers.image.title=Docker build-args: | - client_tag=v${{inputs.tag}} + app_version=${{inputs.tag}} env: LATEST_TAG: ${{ inputs.is_latest == 'true' && format('ghcr.io/{0}:latest', github.repository) || '' }} diff --git a/README.md b/README.md index 57f7efc7..d9bf414e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,15 @@ Swing Music is a beautiful, self-hosted music player for your local audio files. Swing Music is available as pre-compiled binaries for Windows and Linux. Just download the latest release from the [downloads page](https://swingmusic.vercel.app/downloads) and launch it. -For Linux, you need to make the file executable first. +[FFmpeg](https://ffmpeg.org/) is needed for the audio silence skip feature, so you need to install it first. On windows, you can follows [this tutorial](https://phoenixnap.com/kb/ffmpeg-windows) to install FFmpeg. + +On Linux, you can install FFmpeg using: + +```sh +sudo apt-get install ffmpeg libavcodec-extra -y +``` + +Then make the file executable first. ```bash chmod a+x ./swingmusic @@ -35,10 +43,10 @@ swingmusic --port 1980 ### Options -Options are flags that can be passed when starting the app in the terminal to tweak runtime settings or perform tasks. You can use the `-h` flag to see all supported options. +Options are flags that can be passed when starting the app in the terminal to tweak runtime settings or perform tasks. You can use the `-h` flag to see all supported options. > [!TIP] -> You can read more about options in [the docs](https://swingmusic.vercel.app/guide/getting-started.html#options). +> You can read more about options in [the docs](https://swingmusic.vercel.app/guide/getting-started.html#options). ### Docker @@ -80,10 +88,9 @@ services: ### Contributing -Swing Music is looking for contributors. If you're interested, please join us at the [Swing Music Community](https://t.me/+9n61PFcgKhozZDE0) group on Telegram. For more information, take a look at https://github.com/swing-opensource/swingmusic/issues/186. - -[Contributing Guidelines](.github/contributing.md). +Swing Music is looking for contributors. If you're interested, please join us at the [Swing Music Community](https://t.me/+9n61PFcgKhozZDE0) group on Telegram. For more information, take a look at https://github.com/swing-opensource/swingmusic/issues/186. +[**CONTRIBUTING GUIDELINES**](.github/contributing.md). ### License diff --git a/app/api/folder.py b/app/api/folder.py index 29aed260..7b2b5843 100644 --- a/app/api/folder.py +++ b/app/api/folder.py @@ -47,12 +47,15 @@ def get_folder_tree(body: FolderTree): pass if req_dir == "$home": - folders = get_folders(root_dirs) + if len(root_dirs) == 1: + req_dir = root_dirs[0] + else: + folders = get_folders(root_dirs) - return { - "folders": folders, - "tracks": [], - } + return { + "folders": folders, + "tracks": [], + } if is_windows(): # Trailing slash needed when drive letters are passed, @@ -102,7 +105,7 @@ class DirBrowserBody(BaseModel): ) -@api.post("/folder/dir-browser") +@api.post("/dir-browser") def list_folders(body: DirBrowserBody): """ List folders @@ -145,7 +148,7 @@ class FolderOpenInFileManagerQuery(BaseModel): ) -@api.get("/folder/show-in-files") +@api.get("/show-in-files") def open_in_file_manager(query: FolderOpenInFileManagerQuery): """ Open in file manager @@ -163,7 +166,7 @@ class GetTracksInPathQuery(BaseModel): ) -@api.get("/folder/tracks/all") +@api.get("/tracks/all") def get_tracks_in_path(query: GetTracksInPathQuery): """ Get tracks in path