Finish documentation for all endpoints

+ 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 😩
This commit is contained in:
mungai-njoroge
2024-03-24 15:57:58 +03:00
committed by Mungai Njoroge
parent 99ec11565c
commit 0af1ae1d8e
22 changed files with 547 additions and 418 deletions
+11 -12
View File
@@ -8,8 +8,6 @@ from flask_compress import Compress
from flask_openapi3 import Info from flask_openapi3 import Info
from flask_openapi3 import OpenAPI from flask_openapi3 import OpenAPI
from pydantic import BaseModel, Field
from flask_openapi3 import FileStorage
from app.settings import Keys from app.settings import Keys
from .plugins import lyrics as lyrics_plugin from .plugins import lyrics as lyrics_plugin
@@ -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) [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(): def create_api():
""" """
Creates the Flask instance, registers modules and registers all the API blueprints. 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(search.api)
app.register_api(folder.api) app.register_api(folder.api)
app.register_api(playlist.api) app.register_api(playlist.api)
app.register_blueprint(favorites.api) app.register_api(favorites.api)
app.register_blueprint(imgserver.api) app.register_api(imgserver.api)
app.register_blueprint(settings.api) app.register_api(settings.api)
app.register_blueprint(colors.api) app.register_api(colors.api)
app.register_blueprint(lyrics.api) app.register_api(lyrics.api)
# Plugins # Plugins
app.register_blueprint(plugins.api) app.register_api(plugins.api)
app.register_blueprint(lyrics_plugin.api) app.register_api(lyrics_plugin.api)
# Logger # Logger
app.register_blueprint(logger.api_bp) app.register_api(logger.api)
# Home # Home
app.register_blueprint(home.api_bp) app.register_api(home.api)
# Flask Restful # Flask Restful
app.register_blueprint(getall.api_bp) app.register_api(getall.api)
return app return app
+1 -2
View File
@@ -98,7 +98,6 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
limit = query.limit limit = query.limit
all_albums = AlbumStore.get_albums_by_artisthash(artisthash) all_albums = AlbumStore.get_albums_by_artisthash(artisthash)
# start: check for missing albums. ie. compilations and features # start: check for missing albums. ie. compilations and features
all_tracks = TrackStore.get_tracks_by_artisthash(artisthash) all_tracks = TrackStore.get_tracks_by_artisthash(artisthash)
@@ -163,7 +162,7 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
if artist is None: if artist is None:
return {"error": "Artist not found"}, 404 return {"error": "Artist not found"}, 404
if return_all is not None and return_all == "true": if return_all:
limit = len(all_albums) limit = len(all_albums)
singles_and_eps = singles + eps singles_and_eps = singles + eps
+11 -5
View File
@@ -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 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/<albumhash>") @api.get("/album/<albumhash>")
def get_album_color(albumhash: str): def get_album_color(path: AlbumHashSchema):
album = Store.get_album_by_hash(albumhash) """
Get album color
"""
album = Store.get_album_by_hash(path.albumhash)
msg = {"color": ""} msg = {"color": ""}
+77 -77
View File
@@ -1,7 +1,12 @@
from typing import List, TypeVar 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.models import FavType
from app.settings import Defaults
from app.utils.bisection import use_bisection from app.utils.bisection import use_bisection
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.serializers.track import serialize_track, serialize_tracks 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.store.artists import ArtistStore
from app.utils.dates import timestamp_to_time_passed from app.utils.dates import timestamp_to_time_passed
bp_tag = Tag(name="Favorites", description="Your favorite items")
api = Blueprint("favorite", __name__, url_prefix="/") api = APIBlueprint("favorites", __name__, url_prefix="/favorites", abp_tags=[bp_tag])
T = TypeVar("T") 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] return [i for i in items if i is not None]
@api.route("/favorite/add", methods=["POST"]) class FavoritesAddBody(BaseModel):
def add_favorite(): 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. Adds a favorite to the database.
""" """
data = request.get_json() itemhash = body.hash
itemtype = body.type
if data is None:
return {"error": "No data provided"}, 400
itemhash = data.get("hash")
itemtype = data.get("type")
favdb.insert_one_favorite(itemtype, itemhash) favdb.insert_one_favorite(itemtype, itemhash)
@@ -45,18 +55,13 @@ def add_favorite():
return {"msg": "Added to favorites"} return {"msg": "Added to favorites"}
@api.route("/favorite/remove", methods=["POST"]) @api.post("/remove")
def remove_favorite(): def remove_favorite(body: FavoritesAddBody):
""" """
Removes a favorite from the database. Removes a favorite from the database.
""" """
data = request.get_json() itemhash = body.hash
itemtype = body.type
if data is None:
return {"error": "No data provided"}, 400
itemhash = data.get("hash")
itemtype = data.get("type")
favdb.delete_favorite(itemtype, itemhash) favdb.delete_favorite(itemtype, itemhash)
@@ -66,15 +71,12 @@ def remove_favorite():
return {"msg": "Removed from favorites"} return {"msg": "Removed from favorites"}
@api.route("/albums/favorite") @api.get("/albums")
def get_favorite_albums(): def get_favorite_albums(query: GenericLimitSchema):
limit = request.args.get("limit") """
Get favorite albums
if limit is None: """
limit = 6 limit = query.limit
limit = int(limit)
albums = favdb.get_fav_albums() albums = favdb.get_fav_albums()
albumhashes = [a[1] for a in albums] albumhashes = [a[1] for a in albums]
albumhashes.reverse() albumhashes.reverse()
@@ -90,15 +92,12 @@ def get_favorite_albums():
return {"albums": serialize_for_card_many(fav_albums[:limit])} return {"albums": serialize_for_card_many(fav_albums[:limit])}
@api.route("/tracks/favorite") @api.get("/tracks")
def get_favorite_tracks(): def get_favorite_tracks(query: GenericLimitSchema):
limit = request.args.get("limit") """
Get favorite tracks
if limit is None: """
limit = 6 limit = query.limit
limit = int(limit)
tracks = favdb.get_fav_tracks() tracks = favdb.get_fav_tracks()
trackhashes = [t[1] for t in tracks] trackhashes = [t[1] for t in tracks]
trackhashes.reverse() trackhashes.reverse()
@@ -113,15 +112,12 @@ def get_favorite_tracks():
return {"tracks": serialize_tracks(tracks[:limit])} return {"tracks": serialize_tracks(tracks[:limit])}
@api.route("/artists/favorite") @api.get("/artists")
def get_favorite_artists(): def get_favorite_artists(query: GenericLimitSchema):
limit = request.args.get("limit") """
Get favorite artists
if limit is None: """
limit = 6 limit = query.limit
limit = int(limit)
artists = favdb.get_fav_artists() artists = favdb.get_fav_artists()
artisthashes = [a[1] for a in artists] artisthashes = [a[1] for a in artists]
artisthashes.reverse() artisthashes.reverse()
@@ -137,27 +133,38 @@ def get_favorite_artists():
return {"artists": artists[:limit]} return {"artists": artists[:limit]}
@api.route("/favorites") class GetAllFavoritesQuery(BaseModel):
def get_all_favorites(): """
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. Returns all the favorites in the database.
""" """
track_limit = request.args.get("track_limit") track_limit = query.track_limit
album_limit = request.args.get("album_limit") album_limit = query.album_limit
artist_limit = request.args.get("artist_limit") artist_limit = query.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)
# largest is x2 to accound for broken hashes if any # largest is x2 to accound for broken hashes if any
largest = max(track_limit, album_limit, artist_limit) largest = max(track_limit, album_limit, artist_limit)
@@ -266,20 +273,13 @@ def get_all_favorites():
} }
@api.route("/favorites/check") @api.get("/check")
def check_favorite(): def check_favorite(query: FavoritesAddBody):
""" """
Checks if a favorite exists in the database. Checks if a favorite exists in the database.
""" """
itemhash = request.args.get("hash") itemhash = query.hash
itemtype = request.args.get("type") itemtype = query.type
if itemhash is None:
return {"error": "No hash provided"}, 400
if itemtype is None:
return {"error": "No type provided"}, 400
exists = favdb.check_is_favorite(itemhash, itemtype) exists = favdb.check_is_favorite(itemhash, itemtype)
return {"is_favorite": exists} return {"is_favorite": exists}
+120 -5
View File
@@ -1,10 +1,125 @@
from flask import Blueprint 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") from datetime import datetime
api = Api(api_bp) 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, "/<itemtype>") 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("/<itemtype>")
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)}
-93
View File
@@ -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)}
+21 -7
View File
@@ -1,11 +1,25 @@
from flask import Blueprint from flask_openapi3 import Tag
from flask_restful import Api 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") bp_tag = Tag(name="Home", description="Homepage items")
api = Api(api_bp) api = APIBlueprint("home", __name__, url_prefix="/home", abp_tags=[bp_tag])
api.add_resource(RecentlyAdded, "/recents/added") @api.get("/recents/added")
api.add_resource(RecentlyPlayed, "/recents/played") 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)}
-24
View File
@@ -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)}
+76 -42
View File
@@ -1,88 +1,122 @@
from pathlib import Path 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 bp_tag = Tag(
name="Images", description="Image filenames are constructured as '{itemhash}.webp'"
api = Blueprint("imgserver", __name__, url_prefix="/img") )
api = APIBlueprint("imgserver", __name__, url_prefix="/img", abp_tags=[bp_tag])
@api.route("/")
def hello():
return "<h1>Image Server</h1>"
def send_fallback_img(filename: str = "default.webp"): def send_fallback_img(filename: str = "default.webp"):
path = Paths.get_assets_path() folder = Paths.get_assets_path()
img = Path(path) / filename img = Path(folder) / filename
if not img.exists(): if not img.exists():
return "", 404 return "", 404
return send_from_directory(path, filename) return send_from_directory(folder, filename)
@api.route("/t/o/<imgpath>") class ImagePath(BaseModel):
def send_original_thumbnail(imgpath: str): imgpath: str = Field(
path = Paths.get_original_thumb_path() description="The image filename",
fpath = Path(path) / imgpath example=Defaults.API_ALBUMHASH + ".webp",
)
@api.get("/t/o/<imgpath>")
def send_original_thumbnail(path: ImagePath):
"""
Get original thumbnail
"""
folder = Paths.get_original_thumb_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img() return send_fallback_img()
@api.route("/t/<imgpath>") @api.get("/t/<imgpath>")
def send_lg_thumbnail(imgpath: str): def send_lg_thumbnail(path: ImagePath):
path = Paths.get_lg_thumb_path() """
fpath = Path(path) / imgpath Get large thumbnail (500 x 500)
"""
folder = Paths.get_lg_thumb_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img() return send_fallback_img()
@api.route("/t/s/<imgpath>") @api.get("/t/s/<imgpath>")
def send_sm_thumbnail(imgpath: str): def send_sm_thumbnail(path: ImagePath):
path = Paths.get_sm_thumb_path() """
fpath = Path(path) / imgpath Get small thumbnail (64 x 64)
"""
folder = Paths.get_sm_thumb_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img() return send_fallback_img()
@api.route("/a/<imgpath>") @api.get("/a/<imgpath>")
def send_lg_artist_image(imgpath: str): def send_lg_artist_image(path: ImagePath):
path = Paths.get_artist_img_lg_path() """
fpath = Path(path) / imgpath Get large artist image (500 x 500)
"""
folder = Paths.get_artist_img_lg_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img("artist.webp") return send_fallback_img("artist.webp")
@api.route("/a/s/<imgpath>") @api.get("/a/s/<imgpath>")
def send_sm_artist_image(imgpath: str): def send_sm_artist_image(path: ImagePath):
path = Paths.get_artist_img_sm_path() """
fpath = Path(path) / imgpath Get small artist image (64 x 64)
"""
folder = Paths.get_artist_img_sm_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img("artist.webp") return send_fallback_img("artist.webp")
@api.route("/p/<imgpath>") class PlaylistImagePath(BaseModel):
def send_playlist_image(imgpath: str): imgpath: str = Field(
path = Paths.get_playlist_img_path() description="The image path",
fpath = Path(path) / imgpath example="1.webp",
)
@api.get("/p/<imgpath>")
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(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img("playlist.svg") return send_fallback_img("playlist.svg")
+33 -6
View File
@@ -1,11 +1,38 @@
from flask import Blueprint from flask_openapi3 import Tag
from flask_restful import Api 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") class LogTrackBody(TrackHashSchema):
api = Api(api_bp) 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}
-19
View File
@@ -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}
+24 -21
View File
@@ -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 ( from app.lib.lyrics import (
get_lyrics, get_lyrics,
check_lyrics_file, check_lyrics_file,
@@ -7,21 +10,24 @@ from app.lib.lyrics import (
get_lyrics_from_tags, 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"]) class SendLyricsBody(TrackHashSchema):
def send_lyrics(): 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 Returns the lyrics for a track
""" """
data = request.get_json() filepath = body.filepath
trackhash = body.trackhash
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
is_synced = True is_synced = True
lyrics, copyright = get_lyrics(filepath) lyrics, copyright = get_lyrics(filepath)
@@ -38,15 +44,13 @@ def send_lyrics():
return {"lyrics": lyrics, "synced": is_synced, "copyright": copyright}, 200 return {"lyrics": lyrics, "synced": is_synced, "copyright": copyright}, 200
@api.route("/lyrics/check", methods=["POST"]) @api.post("/check")
def check_lyrics(): def check_lyrics(body: SendLyricsBody):
data = request.get_json() """
Checks if lyrics exist for a track
filepath = data.get("filepath", None) """
trackhash = data.get("trackhash", None) filepath = body.filepath
trackhash = body.trackhash
if filepath is None or trackhash is None:
return {"error": "No filepath or trackhash provided"}, 400
exists = check_lyrics_file(filepath, trackhash) exists = check_lyrics_file(filepath, trackhash)
@@ -56,4 +60,3 @@ def check_lyrics():
exists = get_lyrics_from_tags(filepath, just_check=True) exists = get_lyrics_from_tags(filepath, just_check=True)
return {"exists": exists}, 200 return {"exists": exists}, 200
+38 -15
View File
@@ -1,37 +1,60 @@
from flask import Blueprint, request 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 from app.db.sqlite.plugins import PluginsMethods
bp_tag = Tag(name="Plugins", description="Manage plugins")
api = Blueprint("plugins", __name__, url_prefix="/plugins") api = APIBlueprint("plugins", __name__, url_prefix="/plugins", abp_tags=[bp_tag])
@api.route("/", methods=["GET"]) @api.get("/")
def get_all_plugins(): def get_all_plugins():
"""
List all plugins
"""
plugins = PluginsMethods.get_all_plugins() plugins = PluginsMethods.get_all_plugins()
return {"plugins": plugins} return {"plugins": plugins}
@api.route("/setactive", methods=["GET"]) class PluginBody(BaseModel):
def activate_deactivate_plugin(): plugin: str = Field(description="The plugin name", example="lyrics")
name = request.args.get("plugin", None)
state = request.args.get("state", None)
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 return {"message": "OK"}, 200
@api.route("/settings", methods=["POST"]) class PluginSettingsBody(PluginBody):
def update_plugin_settings(): settings: dict = Field(
data = request.get_json() 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: if not plugin or not settings:
return {"error": "Missing plugin or settings"}, 400 return {"error": "Missing plugin or settings"}, 400
+28 -11
View File
@@ -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.lib.lyrics import format_synced_lyrics
from app.plugins.lyrics import Lyrics from app.plugins.lyrics import Lyrics
from app.settings import Defaults
from app.utils.hashing import create_hash 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"]) class LyricsSearchBody(TrackHashSchema):
def search_lyrics(): title: str = Field(description="The track title ", example=Defaults.API_TRACKNAME)
data = request.get_json() 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", "") @api.post("/search")
artist = data.get("artist", "") def search_lyrics(body: LyricsSearchBody):
album = data.get("album", "") """
filepath = data.get("filepath", None) Search for lyrics by title and artist
"""
title = body.title
artist = body.artist
album = body.album
filepath = body.filepath
trackhash = body.trackhash
finder = Lyrics() finder = Lyrics()
data = finder.search_lyrics_by_title_and_artist(title, artist) data = finder.search_lyrics_by_title_and_artist(title, artist)
if not data: if not data:
+46 -26
View File
@@ -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.plugins import PluginsMethods as pdb
from app.db.sqlite.settings import SettingsSQLMethods as sdb 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.generators import get_random_str
from app.utils.threading import background 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]): 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_) rebuild_store(db_dirs_)
@api.route("/settings/add-root-dirs", methods=["POST"]) class AddRootDirsBody(BaseModel):
def add_root_dirs(): 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. Add custom root directories to the database.
""" """
msg = {"msg": "Failed! No directories were given."} new_dirs = body.new_dirs
removed_dirs = body.removed
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
db_dirs = sdb.get_root_dirs() db_dirs = sdb.get_root_dirs()
_h = "$home" _h = "$home"
@@ -132,10 +138,10 @@ def add_root_dirs():
return {"root_dirs": db_dirs} return {"root_dirs": db_dirs}
@api.route("/settings/get-root-dirs", methods=["GET"]) @api.get("/get-root-dirs")
def get_root_dirs(): def get_root_dirs():
""" """
Get custom root directories from the database. Get root directories
""" """
dirs = sdb.get_root_dirs() dirs = sdb.get_root_dirs()
@@ -154,10 +160,10 @@ mapp = {
} }
@api.route("/settings/", methods=["GET"]) @api.get("")
def get_all_settings(): def get_all_settings():
""" """
Get all settings from the database. Get all settings
""" """
settings = sdb.get_all_settings() settings = sdb.get_all_settings()
@@ -195,10 +201,24 @@ def reload_all_for_set_setting():
reload_everything(get_random_str()) reload_everything(get_random_str())
@api.route("/settings/set", methods=["POST"]) class SetSettingBody(BaseModel):
def set_setting(): key: str = Field(
key = request.get_json().get("key") description="The setting key",
value = request.get_json().get("value") 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": if key is None or value is None or key == "root_dirs":
return {"msg": "Invalid arguments!"}, 400 return {"msg": "Invalid arguments!"}, 400
@@ -235,10 +255,10 @@ def run_populate():
populate.Populate(instance_key=get_random_str()) populate.Populate(instance_key=get_random_str())
@api.route("/settings/trigger-scan", methods=["GET"]) @api.get("/trigger-scan")
def trigger_scan(): def trigger_scan():
""" """
Triggers a scan. Triggers scan for new music
""" """
run_populate() run_populate()
+11 -1
View File
@@ -1,6 +1,7 @@
""" """
Handles arguments passed to the program. Handles arguments passed to the program.
""" """
import os.path import os.path
import sys import sys
@@ -9,6 +10,7 @@ import PyInstaller.__main__ as bundler
from app import settings from app import settings
from app.logger import log from app.logger import log
from app.print_help import HELP_MESSAGE 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.xdg_utils import get_xdg_config_dir
from app.utils.wintools import is_windows from app.utils.wintools import is_windows
@@ -45,6 +47,8 @@ class HandleArgs:
config_keys = [ config_keys = [
"SWINGMUSIC_APP_VERSION", "SWINGMUSIC_APP_VERSION",
"GIT_LATEST_COMMIT_HASH",
"GIT_CURRENT_BRANCH",
] ]
lines = [] lines = []
@@ -65,6 +69,8 @@ class HandleArgs:
_s = ";" if is_windows() else ":" _s = ";" if is_windows() else ":"
flask_openapi_path = getFlaskOpenApiPath()
bundler.run( bundler.run(
[ [
"manage.py", "manage.py",
@@ -74,6 +80,7 @@ class HandleArgs:
"--clean", "--clean",
f"--add-data=assets{_s}assets", f"--add-data=assets{_s}assets",
f"--add-data=client{_s}client", 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", f"--icon=assets/logo-fill.light.ico",
"-y", "-y",
] ]
@@ -176,5 +183,8 @@ class HandleArgs:
@staticmethod @staticmethod
def handle_version(): def handle_version():
if any((a in ARGS for a in ALLARGS.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) sys.exit(0)
+2
View File
@@ -1 +1,3 @@
SWINGMUSIC_APP_VERSION = "" SWINGMUSIC_APP_VERSION = ""
GIT_LATEST_COMMIT_HASH = ""
GIT_CURRENT_BRANCH = ""
+2 -2
View File
@@ -66,9 +66,9 @@ class PluginsMethods:
return [] return []
@classmethod @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: 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() cur.close()
@classmethod @classmethod
+33 -3
View File
@@ -3,6 +3,7 @@ Contains default configs
""" """
import os import os
import subprocess
import sys import sys
from typing import Any from typing import Any
@@ -103,6 +104,7 @@ class Defaults:
API_TRACKHASH = "0853280a12" API_TRACKHASH = "0853280a12"
API_ALBUMNAME = "Rumours" API_ALBUMNAME = "Rumours"
API_ARTISTNAME = "girl in red" API_ARTISTNAME = "girl in red"
API_TRACKNAME = "Apartment 402"
API_CARD_LIMIT = 6 API_CARD_LIMIT = 6
@@ -242,21 +244,49 @@ class TCOLOR:
# credits: https://stackoverflow.com/a/287944 # 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: class Keys:
SWINGMUSIC_APP_VERSION = os.environ.get("SWINGMUSIC_APP_VERSION") SWINGMUSIC_APP_VERSION = os.environ.get("SWINGMUSIC_APP_VERSION")
GIT_LATEST_COMMIT_HASH = "<unset>"
GIT_CURRENT_BRANCH = "<unset>"
@classmethod @classmethod
def load(cls): def load(cls):
if IS_BUILD: if IS_BUILD:
cls.SWINGMUSIC_APP_VERSION = configs.SWINGMUSIC_APP_VERSION 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() cls.verify_keys()
@classmethod @classmethod
def verify_keys(cls): def verify_keys(cls):
# if not cls.LASTFM_API_KEY:
# print("ERROR: LASTFM_API_KEY not set in environment")
# sys.exit(0)
pass pass
@classmethod @classmethod
+12
View File
@@ -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"
Generated
+1 -46
View File
@@ -11,20 +11,6 @@ files = [
{file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, {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]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.6.0" version = "0.6.0"
@@ -592,26 +578,6 @@ dotenv = ["python-dotenv"]
email = ["email-validator"] email = ["email-validator"]
yaml = ["pyyaml"] 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]] [[package]]
name = "gevent" name = "gevent"
version = "23.9.1" version = "23.9.1"
@@ -1697,17 +1663,6 @@ files = [
[package.dependencies] [package.dependencies]
six = ">=1.5" 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]] [[package]]
name = "pywin32" name = "pywin32"
version = "306" version = "306"
@@ -2513,4 +2468,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<3.12" python-versions = ">=3.10,<3.12"
content-hash = "7969ca61599f24a005909514cf11d35dd11b6f82f5958bb84c271d8156399755" content-hash = "feb13f92b7b3a909fcb851860a405b96579feac0e2dde7681ed0e9c381c4f6cd"
-1
View File
@@ -20,7 +20,6 @@ show-in-file-manager = "^1.1.4"
flask-compress = "^1.13" flask-compress = "^1.13"
tabulate = "^0.9.0" tabulate = "^0.9.0"
setproctitle = "^1.3.2" setproctitle = "^1.3.2"
flask-restful = "^0.3.10"
locust = "^2.20.1" locust = "^2.20.1"
waitress = "^2.1.2" waitress = "^2.1.2"
watchdog = "^4.0.0" watchdog = "^4.0.0"