first commit

This commit is contained in:
Tomas Dvorak
2026-04-13 17:46:58 +02:00
commit 6e8fedf534
234 changed files with 53808 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
"""
Swing Music API package.
The package intentionally avoids eager imports so a broken or optional API
module cannot crash process boot.
"""
from __future__ import annotations
import importlib
_MODULES = {
"album": "swingmusic.api.album",
"artist": "swingmusic.api.artist",
"collections": "swingmusic.api.collections",
"colors": "swingmusic.api.colors",
"favorites": "swingmusic.api.favorites",
"folder": "swingmusic.api.folder",
"imgserver": "swingmusic.api.imgserver",
"playlist": "swingmusic.api.playlist",
"search": "swingmusic.api.search",
"settings": "swingmusic.api.settings",
"lyrics": "swingmusic.api.lyrics",
"plugins": "swingmusic.api.plugins",
"scrobble": "swingmusic.api.scrobble",
"home": "swingmusic.api.home",
"getall": "swingmusic.api.getall",
"auth": "swingmusic.api.auth",
"stream": "swingmusic.api.stream",
"backup_and_restore": "swingmusic.api.backup_and_restore",
"spotify": "swingmusic.api.spotify",
"spotify_settings": "swingmusic.api.spotify_settings",
"enhanced_search": "swingmusic.api.enhanced_search",
"universal_downloader": "swingmusic.api.universal_downloader",
"music_catalog": "swingmusic.api.music_catalog",
"upload": "swingmusic.api.upload",
"downloads": "swingmusic.api.downloads",
"setup": "swingmusic.api.setup",
"plugins_lyrics": "swingmusic.api.plugins.lyrics",
"plugins_mixes": "swingmusic.api.plugins.mixes",
"dragonfly": "swingmusic.api.dragonfly",
}
def __getattr__(name: str):
module_path = _MODULES.get(name)
if module_path is None:
raise AttributeError(f"module 'swingmusic.api' has no attribute '{name}'")
module = importlib.import_module(module_path)
globals()[name] = module
return module
__all__ = sorted(_MODULES.keys())
+190
View File
@@ -0,0 +1,190 @@
"""Advanced UX endpoints backed by local stores and lightweight persistence."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from swingmusic.services.advanced_ux_store import advanced_ux_store
from swingmusic.utils.auth import get_current_userid
advanced_ux_bp = Blueprint("advanced_ux", __name__, url_prefix="/api/ux")
def _user_id() -> int:
return int(get_current_userid())
def _safe_limit(value, default: int = 10, max_value: int = 100) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
parsed = default
return max(1, min(parsed, max_value))
@advanced_ux_bp.get("/search/suggestions")
def search_suggestions():
query = str(request.args.get("q") or "")
context = str(request.args.get("context") or "general")
limit = _safe_limit(request.args.get("limit"), default=10, max_value=50)
suggestions = advanced_ux_store.search_suggestions(
query=query, context=context, limit=limit
)
return jsonify(
{
"enabled": True,
"suggestions": suggestions,
"query": query,
"context": context,
"total_count": len(suggestions),
}
)
@advanced_ux_bp.get("/discovery/recommendations")
def discovery_recommendations():
recommendation_type = str(request.args.get("type") or "mixed")
limit = _safe_limit(request.args.get("limit"), default=20, max_value=100)
recommendations = advanced_ux_store.get_recommendations(recommendation_type, limit)
return jsonify(
{
"enabled": True,
"recommendations": recommendations,
"type": recommendation_type,
"total_count": len(recommendations),
}
)
@advanced_ux_bp.get("/contextual/suggestions")
def contextual_suggestions():
track_id = str(request.args.get("track_id") or "")
context_type = str(request.args.get("context_type") or "similar")
limit = _safe_limit(request.args.get("limit"), default=10, max_value=50)
suggestions = advanced_ux_store.get_contextual_suggestions(
track_id, context_type, limit
)
return jsonify(
{
"enabled": True,
"suggestions": suggestions,
"track_id": track_id,
"context_type": context_type,
"total_count": len(suggestions),
}
)
@advanced_ux_bp.get("/download/suggestions")
def download_suggestions():
query = str(request.args.get("q") or "")
limit = _safe_limit(request.args.get("limit"), default=15, max_value=50)
suggestions = advanced_ux_store.get_download_suggestions(query=query, limit=limit)
return jsonify(
{
"enabled": True,
"suggestions": suggestions,
"query": query,
"total_count": len(suggestions),
}
)
@advanced_ux_bp.get("/search/filters")
def search_filters():
filters = advanced_ux_store.get_search_filters()
return jsonify(
{
"enabled": True,
"filters": filters,
"total_count": len(filters),
}
)
@advanced_ux_bp.post("/behavior/track")
def behavior_track():
payload = request.get_json(silent=True) or {}
event_type = str(payload.get("type") or "unknown")
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
advanced_ux_store.track_behavior(_user_id(), event_type, data)
return jsonify({"enabled": True, "message": "Behavior event tracked"})
@advanced_ux_bp.get("/behavior/profile")
def behavior_profile():
profile = advanced_ux_store.get_behavior_profile(_user_id())
return jsonify({"enabled": True, "profile": profile})
@advanced_ux_bp.get("/trending/content")
def trending_content():
item_type = str(request.args.get("type") or "mixed")
timeframe = str(request.args.get("timeframe") or "week")
limit = _safe_limit(request.args.get("limit"), default=20, max_value=100)
trending = advanced_ux_store.get_trending(
item_type=item_type, timeframe=timeframe, limit=limit
)
return jsonify(
{
"enabled": True,
"trending": trending,
"type": item_type,
"timeframe": timeframe,
"total_count": len(trending),
}
)
@advanced_ux_bp.post("/search/advanced")
def advanced_search():
payload = request.get_json(silent=True) or {}
result = advanced_ux_store.advanced_search(payload)
result["enabled"] = True
return jsonify(result)
@advanced_ux_bp.get("/suggestions/quick")
def quick_suggestions():
suggestion_type = str(request.args.get("type") or "search")
limit = _safe_limit(request.args.get("limit"), default=5, max_value=30)
suggestions = advanced_ux_store.quick_suggestions(
suggestion_type=suggestion_type, limit=limit
)
return jsonify(
{
"enabled": True,
"suggestions": suggestions,
"type": suggestion_type,
"total_count": len(suggestions),
}
)
@advanced_ux_bp.get("/personalization/preferences")
def get_personalization_preferences():
prefs = advanced_ux_store.get_preferences(_user_id())
return jsonify({"enabled": True, "preferences": prefs})
@advanced_ux_bp.put("/personalization/preferences")
def update_personalization_preferences():
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
payload = {}
prefs = advanced_ux_store.update_preferences(_user_id(), payload)
return jsonify(
{
"enabled": True,
"message": "Preferences updated",
"preferences": prefs,
}
)
+286
View File
@@ -0,0 +1,286 @@
"""
Contains all the album routes.
"""
import json
import logging
import random
from dataclasses import asdict, replace
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.api.apischemas import (
AlbumHashSchema,
AlbumLimitSchema,
ArtistHashSchema,
)
from swingmusic.config import UserConfig
# DragonflyDB integration for album caching
from swingmusic.db.dragonfly_client import get_dragonfly_client
from swingmusic.db.userdata import SimilarArtistTable
from swingmusic.lib.albumslib import sort_by_track_no
from swingmusic.models.album import Album
from swingmusic.serializers.album import serialize_for_card_many
from swingmusic.serializers.track import serialize_tracks
from swingmusic.services.user_library_scope import (
filter_trackhashes_for_user,
get_available_trackhashes,
)
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.hashing import create_hash
from swingmusic.utils.stats import get_track_group_stats
logger = logging.getLogger(__name__)
bp_tag = Tag(name="Album", description="Single album")
api = APIBlueprint("album", __name__, url_prefix="/album", abp_tags=[bp_tag])
class GetAlbumVersionsBody(BaseModel):
og_album_title: str = Field(
description="The original album title (album.og_title)",
)
albumhash: str = Field(
description="The album hash of the album to exclude from the results.",
)
class GetMoreFromArtistsBody(AlbumLimitSchema):
albumartists: list = Field(
description="The artist hashes to get more albums from",
)
base_title: str = Field(
description="The base title of the album to exclude from the results.",
)
class GetAlbumInfoBody(AlbumHashSchema, AlbumLimitSchema):
pass
# NOTE: Don't use "/" as it will cause redirects (failure)
@api.post("")
def get_album_tracks_and_info(body: GetAlbumInfoBody):
"""
Get album and tracks
Returns album info and tracks for the given albumhash.
"""
albumhash = body.albumhash
# Try DragonflyDB cache first
cache = get_dragonfly_client()
cache_key = f"albums:{albumhash}:{body.limit}"
if cache.is_available():
try:
cached = cache.get(cache_key)
if cached:
logger.debug(f"Cache hit for album {albumhash}")
return json.loads(cached)
except Exception:
pass # Cache miss is fine
albumentry = AlbumStore.albummap.get(albumhash)
if albumentry is None:
return {"error": "Album not found"}, 404
album = replace(albumentry.album)
visible_trackhashes = filter_trackhashes_for_user(albumentry.trackhashes)
if not visible_trackhashes:
return {"error": "Album not found"}, 404
tracks = TrackStore.get_tracks_by_trackhashes(visible_trackhashes)
album.trackcount = len(tracks)
album.duration = sum(t.duration for t in tracks)
album.check_type(
tracks=tracks, singleTrackAsSingle=UserConfig().showAlbumsAsSingles
)
track_total = sum({int(t.extra.get("track_total", 1) or 1) for t in tracks})
avg_bitrate = sum(t.bitrate for t in tracks) // (len(tracks) or 1)
more_from_data = GetMoreFromArtistsBody(
albumartists=[a["artisthash"] for a in album.albumartists],
albumlimit=body.limit,
base_title=album.base_title,
)
other_versions_data = GetAlbumVersionsBody(
albumhash=albumhash,
og_album_title=album.og_title,
)
more_from_albums = get_more_from_artist(more_from_data)
other_versions = get_album_versions(other_versions_data)
result = {
"stats": get_track_group_stats(tracks, is_album=True),
"info": {
**asdict(album),
"is_favorite": album.is_favorite,
},
"extra": {
# INFO: track_total is the sum of a set of track_total values from each track
# ASSUMPTIONS
# 1. All the tracks have the correct track totals
# 2. Tracks with the same track total are from the same disc
"track_total": track_total,
"avg_bitrate": avg_bitrate,
},
"copyright": tracks[0].copyright,
"tracks": serialize_tracks(tracks, remove_disc=False),
"more_from": more_from_albums,
"other_versions": other_versions,
}
# Cache the result for 10 minutes
if cache.is_available():
import contextlib
with contextlib.suppress(Exception):
cache.set(cache_key, json.dumps(result, default=str), ex=600)
return result
@api.get("/<albumhash>/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.
"""
entry = AlbumStore.albummap.get(path.albumhash)
if not entry:
return []
visible_trackhashes = filter_trackhashes_for_user(entry.trackhashes)
tracks = TrackStore.get_tracks_by_trackhashes(visible_trackhashes)
tracks = sort_by_track_no(tracks)
return serialize_tracks(tracks)
@api.post("/from-artist")
def get_more_from_artist(body: GetMoreFromArtistsBody):
"""
Get more from artist
Returns more albums from the given artist hashes.
"""
albumartists = body.albumartists
limit = body.limit
base_title = body.base_title
available_trackhashes = get_available_trackhashes()
all_albums: dict[str, list[Album]] = {}
for artisthash in albumartists:
albums = AlbumStore.get_albums_by_artisthash(artisthash)
all_albums[artisthash] = [
album
for album in albums
if set(AlbumStore.albummap.get(album.albumhash).trackhashes).intersection(
available_trackhashes
)
]
seen_hashes = set()
for artisthash, albums in all_albums.items():
albums = [
a
for a in albums
# INFO: filter out albums added to other artists
if a.albumhash not in seen_hashes
and artisthash in a.artisthashes
# INFO: filter out albums with the same base title
and create_hash(a.base_title) != create_hash(base_title)
]
all_albums[artisthash] = serialize_for_card_many(
[a for a in albums if create_hash(a.base_title) != create_hash(base_title)][
:limit
]
)
# INFO: record albums added to other artists
seen_hashes.update([a.albumhash for a in albums][:limit])
return all_albums
@api.post("/other-versions")
def get_album_versions(body: GetAlbumVersionsBody):
"""
Get other versions
Returns other versions of the given album.
"""
albumhash = body.albumhash
album = AlbumStore.albummap.get(albumhash)
if not album:
return []
artisthash = album.album.artisthashes[0]
albums = AlbumStore.get_albums_by_artisthash(artisthash)
available_trackhashes = get_available_trackhashes()
basetitle = album.basetitle
albums = [
a
for a in albums
if a.og_title != album.album.og_title
if a.base_title == basetitle
and artisthash in {a["artisthash"] for a in a.albumartists}
and set(AlbumStore.albummap.get(a.albumhash).trackhashes).intersection(
available_trackhashes
)
]
return serialize_for_card_many(albums)
class GetSimilarAlbumsQuery(ArtistHashSchema, AlbumLimitSchema):
pass
@api.get("/similar")
def get_similar_albums(query: GetSimilarAlbumsQuery):
"""
Get similar albums
Returns similar albums to the given album.
"""
artisthash = query.artisthash
limit = query.limit
similar_artists = SimilarArtistTable.get_by_hash(artisthash)
if similar_artists is None:
return []
artisthashes = similar_artists.get_artist_hash_set()
del similar_artists
artists = ArtistStore.get_artists_by_hashes(artisthashes)
albums = AlbumStore.get_albums_by_artisthashes([a.artisthash for a in artists])
available_trackhashes = get_available_trackhashes()
albums = [
album
for album in albums
if set(AlbumStore.albummap.get(album.albumhash).trackhashes).intersection(
available_trackhashes
)
]
sample = random.sample(albums, min(len(albums), limit))
return serialize_for_card_many(sample[:limit])
+112
View File
@@ -0,0 +1,112 @@
"""
Reusable Pydantic basic schemas for the API
"""
from pydantic import BaseModel, Field
from swingmusic.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",
json_schema_extra={
"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",
json_schema_extra={
"example": Defaults.API_ARTISTHASH,
},
min_length=Defaults.HASH_LENGTH,
max_length=Defaults.HASH_LENGTH,
)
class TrackHashSchema(BaseModel):
"""
Extending this class will give you a model with the `trackhash` field
"""
trackhash: str = Field(
description="The track hash",
json_schema_extra={
"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
"""
limit: int = Field(
description="The number of items to return",
json_schema_extra={
"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",
json_schema_extra={
"example": Defaults.API_CARD_LIMIT,
},
default=5,
alias="tracklimit",
)
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",
json_schema_extra={
"example": Defaults.API_CARD_LIMIT,
},
default=Defaults.API_CARD_LIMIT,
alias="albumlimit",
)
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",
json_schema_extra={
"example": Defaults.API_CARD_LIMIT,
},
default=Defaults.API_CARD_LIMIT,
alias="artistlimit",
)
+255
View File
@@ -0,0 +1,255 @@
"""
Contains all the artist(s) routes.
"""
import math
import random
from dataclasses import replace
from datetime import datetime
from itertools import groupby
from typing import Any
from flask_openapi3 import APIBlueprint, Tag
from pydantic import Field
from swingmusic.api.apischemas import (
AlbumLimitSchema,
ArtistHashSchema,
ArtistLimitSchema,
TrackLimitSchema,
)
from swingmusic.config import UserConfig
from swingmusic.db.userdata import SimilarArtistTable
from swingmusic.lib.sortlib import sort_tracks
from swingmusic.serializers.album import serialize_for_card_many
from swingmusic.serializers.artist import serialize_for_card, serialize_for_cards
from swingmusic.serializers.track import serialize_track
from swingmusic.services.user_library_scope import (
filter_trackhashes_for_user,
get_available_trackhashes,
)
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.stats import get_track_group_stats
bp_tag = Tag(name="Artist", description="Single artist")
api = APIBlueprint("artist", __name__, url_prefix="/artist", abp_tags=[bp_tag])
class GetArtistAlbumsQuery(AlbumLimitSchema):
all: bool = Field(
description="Whether to ignore albumlimit and return all albums", default=False
)
class GetArtistQuery(TrackLimitSchema, GetArtistAlbumsQuery):
albumlimit: int = Field(7, description="The number of albums to return")
@api.get("/<string:artisthash>")
def get_artist(path: ArtistHashSchema, query: GetArtistQuery):
"""
Get artist
Returns artist data, tracks and genres for the given artisthash.
"""
artisthash = path.artisthash
limit = query.limit
entry = ArtistStore.artistmap.get(artisthash)
if entry is None:
return {"error": "Artist not found"}, 404
visible_trackhashes = filter_trackhashes_for_user(entry.trackhashes)
if not visible_trackhashes:
return {"error": "Artist not found"}, 404
tracks = TrackStore.get_tracks_by_trackhashes(visible_trackhashes)
tracks = sort_tracks(tracks, key="playcount", reverse=True)
tcount = len(tracks)
artist = entry.artist
if artist.albumcount == 0 and tcount < 10:
limit = tcount
try:
year = datetime.fromtimestamp(artist.date).year
except ValueError:
year = 0
genres = [*artist.genres]
decade = None
if year:
decade = math.floor(year / 10) * 10
decade = str(decade)[2:] + "s"
if decade:
genres.insert(0, {"name": decade, "genrehash": decade})
stats = get_track_group_stats(tracks)
duration = sum(t.duration for t in tracks) if tracks else 0
tracks = tracks[:limit] if (limit and limit != -1) else tracks
tracks = [
{
**serialize_track(t),
"help_text": (
"unplayed"
if t.playcount == 0
else f"{t.playcount} play{'' if t.playcount == 1 else 's'}"
),
}
for t in tracks
]
query.limit = query.albumlimit
albums = get_artist_albums(path, query)
return {
"artist": {
**serialize_for_card(artist),
"duration": duration,
"trackcount": tcount,
"albumcount": artist.albumcount,
"genres": genres,
"is_favorite": artist.is_favorite,
},
"tracks": tracks,
"albums": albums,
"stats": stats,
}
@api.get("/<artisthash>/albums")
def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
"""
Get artist albums.
"""
return_all = query.all
artisthash = path.artisthash
limit = query.limit
entry = ArtistStore.artistmap.get(artisthash)
if entry is None:
return {"error": "Artist not found"}, 404
visible_trackhashes = set(filter_trackhashes_for_user(entry.trackhashes))
if not visible_trackhashes:
return {"error": "Artist not found"}, 404
albums = AlbumStore.get_albums_by_hashes(entry.albumhashes)
tracks = TrackStore.get_tracks_by_trackhashes(visible_trackhashes)
missing_albumhashes = {
t.albumhash for t in tracks if t.albumhash not in {a.albumhash for a in albums}
}
albums.extend(AlbumStore.get_albums_by_hashes(missing_albumhashes))
albumdict = {a.albumhash: replace(a) for a in albums}
config = UserConfig()
albumgroups = groupby(tracks, key=lambda t: t.albumhash)
for albumhash, tracks in albumgroups:
album = albumdict.get(albumhash)
if album:
album.check_type(list(tracks), config.showAlbumsAsSingles)
albums = [
album
for album in albumdict.values()
if set(AlbumStore.albummap.get(album.albumhash).trackhashes).intersection(
visible_trackhashes
)
]
all_albums = sorted(albums, key=lambda a: a.date, reverse=True)
res: dict[str, Any] = {
"albums": [],
"appearances": [],
"compilations": [],
"singles_and_eps": [],
}
for album in all_albums:
if album.type == "single" or album.type == "ep":
res["singles_and_eps"].append(album)
elif album.type == "compilation":
res["compilations"].append(album)
elif (
album.albumhash in missing_albumhashes
or artisthash not in album.artisthashes
):
res["appearances"].append(album)
else:
res["albums"].append(album)
if return_all:
limit = len(all_albums)
# loop through the res dict and serialize the albums
for key, value in res.items():
res[key] = serialize_for_card_many(value[:limit])
res["artistname"] = entry.artist.name
return res
@api.get("/<artisthash>/tracks")
def get_all_artist_tracks(path: ArtistHashSchema):
"""
Get artist tracks
Returns all artists by a given artist.
"""
entry = ArtistStore.artistmap.get(path.artisthash)
if entry is None:
return []
visible_trackhashes = filter_trackhashes_for_user(entry.trackhashes)
tracks = TrackStore.get_tracks_by_trackhashes(visible_trackhashes)
tracks = sort_tracks(tracks, key="playcount", reverse=True)
tracks = [
{
**serialize_track(t),
"help_text": (
"unplayed"
if t.playcount == 0
else f"{t.playcount} play{'' if t.playcount == 1 else 's'}"
),
}
for t in tracks
]
return tracks
@api.get("/<artisthash>/similar")
def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema):
"""
Get similar artists.
"""
limit = query.limit
result = SimilarArtistTable.get_by_hash(path.artisthash)
if result is None:
return []
similar = ArtistStore.get_artists_by_hashes(result.get_artist_hash_set())
available_trackhashes = get_available_trackhashes()
similar = [
artist
for artist in similar
if set(ArtistStore.artistmap.get(artist.artisthash).trackhashes).intersection(
available_trackhashes
)
]
if len(similar) > limit:
similar = random.sample(similar, min(limit, len(similar)))
return serialize_for_cards(similar[:limit])
+102
View File
@@ -0,0 +1,102 @@
"""Audio quality endpoints for settings, presets and environment hints."""
from __future__ import annotations
import json
from flask import Blueprint, jsonify, request
from swingmusic.services.audio_quality_store import audio_quality_store
from swingmusic.utils.auth import get_current_userid
audio_quality_bp = Blueprint("audio_quality", __name__, url_prefix="/api/audio-quality")
def _user_id() -> int:
return int(get_current_userid())
def _error(message: str, status: int = 400):
return jsonify({"error": message}), status
@audio_quality_bp.get("/settings")
def get_quality_settings():
settings = audio_quality_store.get_settings(_user_id())
return jsonify({"enabled": True, "settings": settings})
@audio_quality_bp.post("/settings")
def update_quality_settings():
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
return _error("Request body must be an object")
settings = audio_quality_store.update_settings(_user_id(), data)
return jsonify(
{
"message": "Audio quality settings updated successfully",
"settings": settings,
}
)
@audio_quality_bp.get("/optimal-streaming")
def get_optimal_streaming_quality():
context_raw = request.args.get("context")
context = {}
if context_raw:
try:
decoded = json.loads(context_raw)
if isinstance(decoded, dict):
context = decoded
except json.JSONDecodeError:
context = {}
optimal_quality = audio_quality_store.get_optimal_streaming_quality(
_user_id(), context
)
return jsonify({"optimal_quality": optimal_quality, "context": context})
@audio_quality_bp.post("/apply-preset")
def apply_preset():
data = request.get_json(silent=True) or {}
preset_name = str(data.get("preset_name") or "").strip()
if not preset_name:
return _error("preset_name is required")
settings, ok = audio_quality_store.apply_preset(_user_id(), preset_name)
if not ok:
return _error("Invalid preset_name", 404)
return jsonify(
{
"message": "Preset applied successfully",
"preset_name": preset_name,
"settings": settings,
}
)
@audio_quality_bp.get("/quality-presets")
def get_quality_presets():
return jsonify({"presets": audio_quality_store.get_presets()})
@audio_quality_bp.get("/formats")
def get_supported_formats():
return jsonify({"formats": audio_quality_store.get_supported_formats()})
@audio_quality_bp.get("/network/status")
def get_network_status():
return jsonify({"network_status": audio_quality_store.get_network_status()})
@audio_quality_bp.get("/device/info")
def get_device_info():
user_agent = request.headers.get("User-Agent", "")
return jsonify({"device_info": audio_quality_store.get_device_info(user_agent)})
+708
View File
@@ -0,0 +1,708 @@
import os
import secrets
import sqlite3
import threading
import time
from functools import wraps
from flask import current_app, jsonify, request
from flask_jwt_extended import (
create_access_token,
create_refresh_token,
current_user,
get_jwt_identity,
jwt_required,
set_access_cookies,
)
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.config import UserConfig
# DragonflyDB integration for fast session caching
from swingmusic.db.dragonfly_extended_client import get_user_session_service
from swingmusic.db.production import UserRootDirOwnershipTable
from swingmusic.db.userdata import UserTable
from swingmusic.services.production_readiness import (
accept_invite_token,
create_invite_token,
default_user_root_dir,
get_bootstrap_status,
)
from swingmusic.services.setup_state import bootstrap_setup, get_setup_status
from swingmusic.store.homepage import HomepageStore
from swingmusic.utils.auth import check_password, hash_password
bp_tag = Tag(name="Auth", description="Authentication stuff")
api = APIBlueprint("auth", __name__, url_prefix="/auth", abp_tags=[bp_tag])
def get_limiter():
"""Get the rate limiter from app context."""
# Prefer the global limiter initialized in app_builder.build().
# flask-limiter v4 may store a set in current_app.extensions["limiter"],
# so resolve defensively across versions.
try:
from swingmusic.app_builder import limiter as app_limiter
if app_limiter is not None and hasattr(app_limiter, "limit"):
return app_limiter
except Exception:
pass
ext = current_app.extensions.get("limiter")
if ext is None:
return None
if hasattr(ext, "limit"):
return ext
if isinstance(ext, set):
for candidate in ext:
if hasattr(candidate, "limit"):
return candidate
return None
def rate_limit(limit: str):
"""
Decorator to apply rate limiting to an endpoint.
Falls back gracefully if limiter is not available.
"""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
limiter = get_limiter()
if limiter:
# Apply rate limit using the limiter's decorator
return limiter.limit(limit)(fn)(*args, **kwargs)
return fn(*args, **kwargs)
return wrapper
return decorator
def admin_required():
"""
Decorator to require admin role
"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
if "admin" not in current_user["roles"]:
return {"msg": "Only admins can do that!"}, 403
return fn(*args, **kwargs)
return decorator
return wrapper
def create_new_token(user: dict):
"""
Create a new token response
"""
access_token = create_access_token(identity=user)
max_age: int = current_app.config.get("JWT_ACCESS_TOKEN_EXPIRES")
return {
"msg": f"Logged in as {user['username']}",
"accesstoken": access_token,
"refreshtoken": create_refresh_token(identity=user),
"maxage": max_age,
"password_change_required": user.get("password_change_required", False),
}
class PairTokenStore:
def __init__(self, *, ttl_seconds: int = 300, max_codes: int = 2048):
self.ttl_seconds = max(30, ttl_seconds)
self.max_codes = max(128, max_codes)
self._codes: dict[str, dict] = {}
self._lock = threading.Lock()
def _cleanup_locked(self):
now = time.time()
expired = [
code
for code, payload in self._codes.items()
if payload.get("expires_at", 0) <= now
]
for code in expired:
self._codes.pop(code, None)
if len(self._codes) <= self.max_codes:
return
ordered = sorted(
self._codes.items(),
key=lambda item: item[1].get("created_at", 0),
)
drop_count = len(self._codes) - self.max_codes
for code, _ in ordered[:drop_count]:
self._codes.pop(code, None)
def issue(self, token_payload: dict, user_identity: dict | None = None):
code_alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
with self._lock:
self._cleanup_locked()
code = None
for _ in range(32):
candidate = "".join(secrets.choice(code_alphabet) for _ in range(6))
if candidate not in self._codes:
code = candidate
break
if not code:
raise RuntimeError("Unable to allocate a unique pairing code")
now = time.time()
expires_at = now + self.ttl_seconds
self._codes[code] = {
"created_at": now,
"expires_at": expires_at,
"payload": token_payload,
"user_id": (
int(user_identity["id"])
if isinstance(user_identity, dict) and user_identity.get("id")
else None
),
}
return code, int(expires_at)
def consume(self, raw_code: str | None):
code = (raw_code or "").strip().upper()
if not code:
return None
with self._lock:
self._cleanup_locked()
payload = self._codes.pop(code, None)
if not payload:
return None
if payload.get("expires_at", 0) <= time.time():
return None
return payload.get("payload")
pair_token_store = PairTokenStore(
ttl_seconds=int(os.getenv("SWINGMUSIC_PAIR_CODE_TTL_SECONDS", "300")),
max_codes=int(os.getenv("SWINGMUSIC_PAIR_CODE_MAX_ACTIVE", "2048")),
)
class LoginBody(BaseModel):
username: str = Field(description="The username", example="user0")
password: str = Field(description="The password", example="password0")
@api.post("/login")
@rate_limit("30 per minute")
def login(body: LoginBody):
"""
Authenticate using username and password
"""
user = UserTable.get_by_username(body.username)
if user is None:
return {"msg": "User not found"}, 404
password_ok = check_password(body.password, user.password)
if not password_ok:
return {"msg": "Hehe! invalid password"}, 401
res = create_new_token(user.todict())
token = res["accesstoken"]
age = res["maxage"]
res = jsonify(res)
set_access_cookies(res, token, max_age=age)
# Cache user session in DragonflyDB for fast lookups
session_service = get_user_session_service()
if session_service.cache.client.is_available():
import contextlib
with contextlib.suppress(Exception):
session_service.create_session(
token,
user.todict(),
ttl_hours=max(1, int(age // 3600)),
)
session_service.set_user_session(user.id, user.todict(), ttl_seconds=age)
return res
@api.get("/bootstrap/status")
@jwt_required(optional=True)
def bootstrap_status():
"""
Returns owner-bootstrap state for first-run provisioning.
"""
legacy = get_bootstrap_status()
setup = get_setup_status()
return {
**legacy,
**setup,
}
class BootstrapOwnerBody(BaseModel):
username: str = Field(description="Owner username")
password: str = Field(description="Owner password")
root_dirs: list[str] = Field(
default_factory=list, description="Initial root directories"
)
@api.post("/bootstrap/owner")
@rate_limit("5 per minute")
def bootstrap_owner(body: BootstrapOwnerBody):
"""
Creates the first owner account when no users exist.
"""
try:
owner = bootstrap_setup(
username=body.username,
password=body.password,
root_dirs=body.root_dirs,
)
except ValueError as error:
return {"msg": str(error)}, 400
res = create_new_token(owner.todict())
token = res["accesstoken"]
age = res["maxage"]
response = jsonify(res)
set_access_cookies(response, token, max_age=age)
return response
class InviteCreateBody(BaseModel):
roles: list[str] = Field(
default_factory=lambda: ["user"], description="Roles for invited account"
)
expires_in_seconds: int = Field(
default=7 * 24 * 3600, description="Invite validity in seconds"
)
@api.post("/invite/create")
@admin_required()
def create_invite(body: InviteCreateBody):
"""
Create an invite token for onboarding additional users.
"""
invite = create_invite_token(
created_by=current_user["id"],
roles=body.roles,
expires_in_seconds=body.expires_in_seconds,
)
return {
"token": invite.token,
"expires_at": invite.expires_at,
"roles": invite.roles,
}
class InviteAcceptBody(BaseModel):
token: str = Field(description="Invite token")
username: str = Field(description="New username")
password: str = Field(description="New user password")
@api.post("/invite/accept")
@rate_limit("5 per minute")
def accept_invite(body: InviteAcceptBody):
"""
Accept an invite token and create a user account.
"""
try:
user = accept_invite_token(
token=body.token,
username=body.username,
password=body.password,
)
except ValueError as error:
return {"msg": str(error)}, 400
res = create_new_token(user.todict())
token = res["accesstoken"]
age = res["maxage"]
response = jsonify(res)
set_access_cookies(response, token, max_age=age)
return response
@api.get("/getpaircode")
@jwt_required()
def get_pair():
"""
Get a new pair code to log in to thee Swing Music mobile app
"""
user_identity = get_jwt_identity()
if not isinstance(user_identity, dict) or user_identity.get("id") is None:
return {"msg": "Unauthorized"}, 401
token_payload = create_new_token(user_identity)
code, expires_at = pair_token_store.issue(token_payload, user_identity)
server_url = request.headers.get("Origin", "").strip()
if not server_url:
server_url = request.host_url.rstrip("/")
else:
server_url = server_url.rstrip("/")
return {
"code": code,
"expires_at": expires_at,
"ttl_seconds": pair_token_store.ttl_seconds,
"server_url": server_url,
# Keep payload contract explicit for mobile/desktop clients.
# Format: "<server_url>|<pair_code>"
"qr_payload": f"{server_url}|{code}",
}
class PairDeviceQuery(BaseModel):
code: str = Field("", description="The code")
@api.get("/pair")
@jwt_required(optional=True)
@rate_limit("20 per minute")
def pair_with_code(query: PairDeviceQuery):
"""
Get an access token by sending a pair code. NOTE: A code can only be used once!
"""
token = pair_token_store.consume(query.code)
if token:
return token
return {"msg": "Invalid or expired code"}, 400
@api.post("/refresh")
@jwt_required(refresh=True)
def refresh():
"""
Refresh an access token by sending a refresh token in the Authorization header
>>> Headers:
>>> Authorization: Bearer <refresh_token>
Won't work with cookies!!!
"""
user = get_jwt_identity()
return create_new_token(user)
class UpdateProfileBody(BaseModel):
id: int = Field(0, description="The user id")
email: str = Field("", description="The email")
username: str = Field("", description="The username", example="user0")
password: str = Field("", description="The password", example="password0")
roles: list[str] = Field(None, description="The roles")
@api.put("/profile/update")
def update_profile(body: UpdateProfileBody):
"""
Update user profile
"""
user = {
"id": body.id,
"username": body.username,
"password": body.password,
"roles": body.roles,
}
# prevent updating guest
if current_user["username"] == "guest" or user["username"] == "guest":
return {"msg": "Cannot update guest user"}, 400
# if not id, update self
if not user["id"]:
user["id"] = current_user["id"]
if body.roles is not None:
# only admins can update roles
if "admin" not in current_user["roles"]:
return {"msg": "Only admins can update roles"}, 403
all_users = list(UserTable.get_all())
if "admin" not in body.roles:
# check if we're removing the last admin
admins = [user for user in all_users if "admin" in user.roles]
if len(admins) == 1 and admins[0].id == user["id"]:
return {"msg": "Cannot remove the only admin"}, 400
# guest roles cannot be updated
_user = [u for u in all_users if u.id == user["id"]][0]
if "guest" in _user.roles:
return {"msg": "Cannot update guest user"}, 400
if user["password"]:
user["password"] = hash_password(user["password"])
# remove empty values
clean_user = {k: v for k, v in user.items() if v}
# finally, convert roles to json string
# doing it here to prevent deleting roles from clean user
# when body.roles is an empty list
if body.roles is not None:
clean_user["roles"] = body.roles
try:
# return authdb.update_user(clean_user)
UserTable.update_one(clean_user)
return UserTable.get_by_id(user["id"]).todict()
except sqlite3.IntegrityError:
return {"msg": "Username already exists"}, 400
@api.post("/profile/create")
@admin_required()
def create_user(body: UpdateProfileBody):
"""
Create a new user
"""
if not body.username or not body.password:
return {"msg": "Username and password are required"}, 400
user = {
"username": body.username,
"password": hash_password(body.password),
"roles": [],
}
# check if user already exists
if UserTable.get_by_username(user["username"]):
return {"msg": "Username already exists"}, 400
UserTable.insert_one(user)
user = UserTable.get_by_username(user["username"])
if user:
user_root = default_user_root_dir(user.username)
os.makedirs(user_root, exist_ok=True)
UserRootDirOwnershipTable.assign_paths(user.id, [user_root])
HomepageStore.entries["recently_played"].add_new_user(user.id)
return user.todict()
return {
"msg": "Failed to create user",
}, 500
@api.post("/profile/guest/create")
@admin_required()
def create_guest_user():
"""
Create a guest user
"""
# check if guest user already exists
guest_user = UserTable.get_by_username("guest")
if guest_user:
return {
"msg": "Guest user already exists",
}, 400
UserTable.insert_guest_user()
user = UserTable.get_by_username("guest")
if user:
# Guest user is isolated too, but kept under a deterministic root.
user_root = default_user_root_dir(user.username)
os.makedirs(user_root, exist_ok=True)
UserRootDirOwnershipTable.assign_paths(user.id, [user_root])
HomepageStore.entries["recently_played"].add_new_user(user.id)
return {
"msg": "Guest user created",
}
return {
"msg": "Failed to create guest user",
}, 500
class DeleteUseBody(BaseModel):
username: str = Field("", description="The username")
class ChangePasswordBody(BaseModel):
current_password: str = Field(description="Current password")
new_password: str = Field(description="New password")
@api.post("/password/change")
@jwt_required()
@rate_limit("5 per minute")
def change_password(body: ChangePasswordBody):
"""
Change the current user's password. Required when password_change_required is True.
"""
user_id = current_user["id"]
user = UserTable.get_by_id(user_id)
if not user:
return {"msg": "User not found"}, 404
# Verify current password
if not check_password(body.current_password, user.password):
return {"msg": "Current password is incorrect"}, 401
# Validate new password
if len(body.new_password) < 8:
return {"msg": "Password must be at least 8 characters"}, 400
if body.current_password == body.new_password:
return {"msg": "New password must be different from current password"}, 400
# Update password and clear the change required flag
updated_user = {
"id": user_id,
"password": hash_password(body.new_password),
"password_change_required": False,
}
UserTable.update_one(updated_user)
return {"msg": "Password changed successfully", "password_change_required": False}
@api.delete("/profile/delete")
@admin_required()
def delete_user(body: DeleteUseBody):
"""
Delete a user by username
"""
# prevent admin from deleting themselves
if body.username == current_user["username"]:
return {"msg": "Sorry! you cannot delete yourselfu"}, 400
# prevent deleting the only admin
users = UserTable.get_all()
admins = [user for user in users if "admin" in user.roles]
if len(admins) == 1 and admins[0].username == body.username:
return {"msg": "Cannot delete the only admin"}, 400
UserTable.remove_by_username(body.username)
return {"msg": f"User {body.username} deleted"}
@api.get("/logout")
@jwt_required(optional=True)
def logout():
"""
Log out and clear the access token cookie
"""
# Invalidate session in DragonflyDB
if current_user:
session_service = get_user_session_service()
if session_service.cache.client.is_available():
import contextlib
with contextlib.suppress(Exception):
session_service.invalidate_user_session(current_user["id"])
res = jsonify({"msg": "Logged out"})
res.delete_cookie("access_token_cookie")
return res
class GetAllUsersQuery(BaseModel):
simplified: bool = Field(
False, description="Whether to return simplified user data"
)
@api.get("/users")
@jwt_required(optional=True)
def get_all_users(query: GetAllUsersQuery):
"""
Get all users (if you're an admin, you will also receive accounts settings)
"""
config = UserConfig()
settings = {
"enableGuest": False,
"usersOnLogin": config.usersOnLogin,
}
res = {
"settings": {},
"users": [],
}
users = list(UserTable.get_all())
is_admin = current_user and "admin" in current_user["roles"]
settings["enableGuest"] = [
user for user in users if user.username == "guest"
].__len__() > 0
# if user is admin, also return settings
if is_admin:
res = {
"settings": settings,
}
# if is normal user, return empty response
elif current_user or (
not current_user
and not settings["usersOnLogin"]
and not settings["enableGuest"]
):
return res
# remove guest user
# if not settings["enableGuest"]:
# users = [user for user in users if user.username != "guest"]
if not settings["usersOnLogin"]:
users = [user for user in users if user.username == "guest"]
# reverse list to show latest users first
users = reversed(users)
# bring admins to the front
users = sorted(users, key=lambda x: "admin" in x.roles, reverse=True)
# bring current user to index 0
if current_user:
users = sorted(
users,
key=lambda x: x.username == current_user["username"],
reverse=True,
)
if query.simplified:
res["users"] = [user.todict_simplified() for user in users]
else:
res["users"] = [user.todict() for user in users]
return res
@api.get("/user")
@jwt_required(optional=True)
def get_logged_in_user():
"""
Get logged in user
"""
if get_jwt_identity() is None:
return {"authenticated": False}
user = dict(current_user)
user["authenticated"] = True
return user
+352
View File
@@ -0,0 +1,352 @@
import contextlib
import json
import shutil
from dataclasses import asdict
from pathlib import Path
from time import time
import sqlalchemy.exc
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.api.auth import admin_required
from swingmusic.db.userdata import (
CollectionTable,
FavoritesTable,
PlaylistTable,
ScrobbleTable,
)
from swingmusic.lib.index import index_everything
from swingmusic.settings import Paths
from swingmusic.utils.dates import timestamp_to_time_passed
bp_tag = Tag(name="Backup and Restore", description="Backup and Restore")
api = APIBlueprint(
"backup_and_restore", __name__, url_prefix="/backup", abp_tags=[bp_tag]
)
@api.post("/create")
@admin_required()
def backup():
"""
Create a backup file of your favorites, playlists, scrobble data, and collections.
"""
backup_name = f"backup.{int(time())}"
backup_dir = Path("~").expanduser() / "swingmusic.backup" / backup_name
backup_dir.mkdir(parents=True, exist_ok=True)
backup_file = backup_dir / "data.json"
img_folder = backup_dir / "images"
img_folder_created = img_folder.exists()
favorites = FavoritesTable.get_all(with_user=True)
favorites = [asdict(entry) for entry in favorites]
scrobbles = ScrobbleTable.get_all(start=0)
scrobbles = [asdict(entry) for entry in scrobbles]
for scrobble in scrobbles:
del scrobble["id"]
# SECTION: Playlists
playlists = PlaylistTable.get_all()
playlist_dicts = []
for entry in playlists:
playlist = asdict(entry)
for key in [
"id",
"_last_updated",
"has_image",
"images",
"duration",
"count",
"pinned",
"thumb",
]:
del playlist[key]
playlist_dicts.append(playlist)
# copy images
img_path = Path(Paths().playlist_img_path) / str(playlist["image"])
if img_path.exists():
if not img_folder_created:
img_folder.mkdir(parents=True)
img_folder_created = True
shutil.copy(img_path, img_folder / playlist["image"])
# !SECTION
# SECTION: Collections
collections_list = list(CollectionTable.get_all())
collections_dicts = []
for collection in collections_list:
# Remove auto-generated id field
collection_copy = collection.copy()
if "id" in collection_copy:
del collection_copy["id"]
collections_dicts.append(collection_copy)
# !SECTION
data = {
"favorites": favorites,
"scrobbles": scrobbles,
"playlists": playlist_dicts,
"collections": collections_dicts,
}
with open(backup_file, "w") as f:
json.dump(data, f, indent=4)
return {
"name": backup_name,
"date": timestamp_to_time_passed(int(backup_name.split(".")[1])),
"scrobbles": len(scrobbles),
"favorites": len(favorites),
"playlists": len(playlist_dicts),
"collections": len(collections_dicts),
}, 200
class RestoreBackup:
"""
Handles restoration of backup data including favorites, playlists,
scrobbles, and collections.
Note: Mixes (plugin-generated playlists) are not currently backed up
as they can be regenerated from the plugin. Future enhancement could
include caching mix configurations for faster restoration.
"""
def __init__(self, backup_dir: Path):
self.backup_dir = backup_dir
self.backup_file = backup_dir / "data.json"
with open(self.backup_file) as f:
self.data = json.load(f)
# Progress tracking for UX feedback
self.progress = {
"favorites": 0,
"playlists": 0,
"scrobbles": 0,
"collections": 0,
}
self.restore_favorites(self.data["favorites"])
self.restore_playlists(self.data["playlists"])
self.restore_scrobbles(self.data["scrobbles"])
self.restore_collections(self.data.get("collections", []))
def restore(self):
pass
def restore_favorites(self, favorites: list[dict]):
existing_favorites = FavoritesTable.get_all(with_user=True)
existing_hashes = {(fav.type, fav.hash) for fav in existing_favorites}
for fav in favorites:
fav_type = str(fav.get("type") or "").strip()
if not fav_type:
continue
canonical_hash = FavoritesTable._normalize_item_hash(
str(fav.get("hash") or ""),
fav_type,
)
if not canonical_hash:
continue
key = (fav_type, canonical_hash)
if key in existing_hashes:
continue
payload = {
"hash": canonical_hash,
"type": fav_type,
"extra": fav.get("extra", {})
if isinstance(fav.get("extra"), dict)
else {},
}
if fav.get("timestamp") is not None:
payload["timestamp"] = int(fav["timestamp"])
try:
FavoritesTable.insert_item(payload)
existing_hashes.add(key)
except sqlalchemy.exc.IntegrityError:
print("Integrity error, skipping favorite")
print(payload)
def restore_playlists(self, playlists: list[dict]):
existing_playlists = PlaylistTable.get_all()
existing_names = {playlist.name for playlist in existing_playlists}
new_playlists = [
playlist for playlist in playlists if playlist["name"] not in existing_names
]
for playlist in new_playlists:
try:
if playlist.get("_score") is not None:
del playlist["_score"]
PlaylistTable.add_one(playlist)
except sqlalchemy.exc.IntegrityError:
print("Integrity error, skipping playlist:")
print(playlist)
def restore_scrobbles(self, scrobbles: list[dict]):
existing_scrobbles = ScrobbleTable.get_all(0)
existing_hashes = {
f"{scrobble.trackhash}.{scrobble.timestamp}"
for scrobble in existing_scrobbles
}
new_scrobbles = [
scrobble
for scrobble in scrobbles
if f"{scrobble['trackhash']}.{scrobble['timestamp']}" not in existing_hashes
]
for scrobble in new_scrobbles:
try:
ScrobbleTable.add(scrobble)
except sqlalchemy.exc.IntegrityError:
print("Integrity error, skipping scrobble:")
print(scrobble)
def restore_collections(self, collections: list[dict]):
existing_collections = list(CollectionTable.get_all())
existing_names = {collection["name"] for collection in existing_collections}
new_collections = [
collection
for collection in collections
if collection["name"] not in existing_names
]
for collection in new_collections:
try:
# Ensure userid is set for the collection
if collection.get("userid") is None:
from swingmusic.utils.auth import get_current_userid
collection["userid"] = get_current_userid()
CollectionTable.insert_one(collection)
except sqlalchemy.exc.IntegrityError:
print("Integrity error, skipping collection:")
print(collection)
class RestoreBackupBody(BaseModel):
backup_dir: str | None = Field(
default=None,
description="The name of the backup directory to restore from. If not provided, all backups will be restored.",
example="backup.1234567890",
)
@api.post("/restore")
@admin_required()
def restore(body: RestoreBackupBody):
"""
Restore your favorites, playlists, scrobble data, and collections from a specified backup or all backups.
"""
backup_base_dir = Path("~").expanduser() / "swingmusic.backup"
backups = []
if body.backup_dir:
# Restore from a specific backup
specified_backup_dir = backup_base_dir / body.backup_dir
if not specified_backup_dir.exists() or not specified_backup_dir.is_dir():
return {"msg": f"Backup '{body.backup_dir}' not found"}, 404
restore_backup = RestoreBackup(specified_backup_dir)
restore_backup.restore()
backups.append(body.backup_dir)
else:
# Restore from all backups
try:
backup_dirs = [d for d in backup_base_dir.iterdir() if d.is_dir()]
except FileNotFoundError:
backup_dirs = []
if not backup_dirs:
return {"msg": "No backups found"}, 404
for backup_dir in sorted(backup_dirs, key=lambda x: x.name, reverse=True):
restore_backup = RestoreBackup(backup_dir)
restore_backup.restore()
backups.append(backup_dir.name)
index_everything()
return {"msg": "Restored successfully", "backups": backups}, 200
@api.get("/list")
@admin_required()
def list_backups():
"""
List all backups with detailed information.
"""
backup_dir = Path("~").expanduser() / "swingmusic.backup"
backups = []
entries = []
try:
paths = [p for p in backup_dir.iterdir() if p.is_dir()]
except FileNotFoundError:
paths = []
for path in paths:
with contextlib.suppress(IndexError, ValueError):
entries.append({"path": path, "timestamp": int(path.name.split(".")[1])})
entries = sorted(entries, key=lambda x: x["timestamp"], reverse=True)
for entry in entries:
backup_info = {
"name": entry["path"].name,
"date": timestamp_to_time_passed(entry["timestamp"]),
}
# Read the JSON file and count items
json_file: Path = entry["path"] / "data.json"
if json_file.exists():
with json_file.open("r") as f:
data = json.load(f)
backup_info["scrobbles"] = len(data.get("scrobbles", []))
backup_info["favorites"] = len(data.get("favorites", []))
backup_info["playlists"] = len(data.get("playlists", []))
backup_info["collections"] = len(data.get("collections", []))
else:
backup_info["scrobbles"] = 0
backup_info["favorites"] = 0
backup_info["playlists"] = 0
backup_info["collections"] = 0
backups.append(backup_info)
return {"backups": backups}, 200
class DeleteBackupBody(BaseModel):
backup_dir: str = Field(
..., description="The name of the backup directory to delete."
)
@api.delete("/delete")
@admin_required()
def delete_backup(body: DeleteBackupBody):
"""
Delete a backup.
"""
backup_dir = Path("~").expanduser() / "swingmusic.backup"
backup_dir = backup_dir / body.backup_dir
if not backup_dir.exists() or not backup_dir.is_dir():
return {"msg": f"Backup '{body.backup_dir}' not found"}, 404
shutil.rmtree(backup_dir)
return {"msg": f"Backup '{body.backup_dir}' deleted"}, 200
+185
View File
@@ -0,0 +1,185 @@
"""
Contains all the collection routes.
"""
from typing import Any
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.db.userdata import CollectionTable
from swingmusic.lib.pagelib import (
recover_page_items,
remove_page_items,
validate_page_items,
)
from swingmusic.utils.auth import get_current_userid
bp_tag = Tag(name="Collections", description="Collections")
api = APIBlueprint(
"collections", __name__, url_prefix="/collections", abp_tags=[bp_tag]
)
class CreateCollectionBody(BaseModel):
name: str = Field(description="The name of the collection")
description: str = Field(description="The description of the collection")
items: list[dict[str, Any]] = Field(
description="The items to add to the collection",
json_schema_extra={"example": [{"type": "album", "hash": "1234567890"}]},
)
@api.post("")
def create_collection(body: CreateCollectionBody):
"""
Create a new collection.
"""
items = validate_page_items(body.items, existing=[])
if len(items) == 0:
return {"error": "No items to add"}, 400
payload = {
"name": body.name,
"items": items,
"userid": get_current_userid(),
"extra": {
"description": body.description,
},
}
CollectionTable.insert_one(payload)
return {"message": "collection created"}, 201
@api.get("")
def get_collections():
"""
Get all collections.
"""
return list(CollectionTable.get_all())
class AddCollectionItemBody(BaseModel):
item: dict[str, Any] = Field(
description="The item to add to the collection",
json_schema_extra={"example": {"type": "album", "hash": "1234567890"}},
)
class AddCollectionItemPath(BaseModel):
collection_id: int = Field(
description="The ID of the collection to add items to",
json_schema_extra={"example": 1},
)
@api.post("/<int:collection_id>/items")
def add_collection_item(path: AddCollectionItemPath, body: AddCollectionItemBody):
"""
Add an item to a collection.
"""
collection = CollectionTable.get_by_id(path.collection_id)
if collection is None:
return {"error": "Collection not found"}, 404
new_items = validate_page_items([body.item], existing=collection["items"])
if len(new_items) == 0:
return {"error": "items already in collection"}, 400
collection["items"].extend(new_items)
CollectionTable.update_items(collection["id"], collection["items"])
return {"message": "Items added to collection"}
class RemoveCollectionItemBody(BaseModel):
item: dict[str, Any] = Field(
description="The item to remove from the collection",
json_schema_extra={"example": {"type": "album", "hash": "1234567890"}},
)
class RemoveCollectionItemPath(BaseModel):
collection_id: int = Field(
description="The ID of the collection to remove items from"
)
@api.delete("/<int:collection_id>/items")
def remove_collection_item(
path: RemoveCollectionItemPath, body: RemoveCollectionItemBody
):
"""
Remove an item from a collection.
"""
collection = CollectionTable.get_by_id(path.collection_id)
if collection is None:
return {"error": "Collection not found"}, 404
remaining = remove_page_items(collection["items"], body.item)
CollectionTable.update_items(collection["id"], remaining)
return {"message": "Item removed from collection"}
class GetCollectionBody(BaseModel):
collection_id: int = Field(description="The ID of the collection to get")
@api.get("/<int:collection_id>")
def get_collection(path: GetCollectionBody):
"""
Get a collection.
"""
collection = CollectionTable.get_by_id(path.collection_id)
if not collection:
return {"error": "Collection not found"}, 404
items = recover_page_items(collection["items"])
return {
"id": collection["id"],
"name": collection["name"],
"items": items,
"extra": collection["extra"],
}
class UpdateCollectionBody(BaseModel):
name: str = Field(description="The name of the collection")
description: str = Field(
description="The description of the collection", default=""
)
@api.put("/<int:collection_id>")
def update_collection(path: GetCollectionBody, body: UpdateCollectionBody):
"""
Update a collection.
"""
payload = {
"id": path.collection_id,
"name": body.name,
"extra": {"description": body.description},
}
CollectionTable.update_one(payload)
return payload
class DeleteCollectionPath(BaseModel):
collection_id: int = Field(description="The ID of the collection to delete")
@api.delete("/<int:collection_id>")
def delete_collection(path: DeleteCollectionPath):
"""
Delete a collection.
"""
CollectionTable.delete_by_id(path.collection_id)
return {"message": "Collection deleted"}
+22
View File
@@ -0,0 +1,22 @@
from flask_openapi3 import APIBlueprint, Tag
from swingmusic.api.apischemas import AlbumHashSchema
from swingmusic.store.albums import AlbumStore as Store
bp_tag = Tag(name="Colors", description="Get item colors")
api = APIBlueprint("colors", __name__, url_prefix="/colors", abp_tags=[bp_tag])
@api.get("/album/<albumhash>")
def get_album_color(path: AlbumHashSchema):
"""
Get album color
"""
album = Store.get_album_by_hash(path.albumhash)
msg = {"color": ""}
if album is None or len(album.colors) == 0:
return msg, 404
return {"color": album.colors[0]}
+486
View File
@@ -0,0 +1,486 @@
from __future__ import annotations
import os
from pathlib import Path
from flask_jwt_extended import get_jwt_identity
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.config import UserConfig
from swingmusic.db.production import UserRootDirOwnershipTable
from swingmusic.services.download_jobs import download_job_manager
from swingmusic.services.library_projection import (
get_track_availability,
get_track_availability_map,
import_existing_track,
list_import_candidates,
)
from swingmusic.services.playlist_tracking import playlist_tracking_service
from swingmusic.services.user_library_scope import get_user_root_dirs
from swingmusic.utils.auth import get_current_userid
bp_tag = Tag(name="Downloads", description="Unified download jobs and import flow")
api = APIBlueprint(
"downloads", __name__, url_prefix="/api/downloads", abp_tags=[bp_tag]
)
class JobsQuery(BaseModel):
limit: int = Field(default=200, description="Maximum number of jobs to return")
class HistoryQuery(BaseModel):
limit: int = Field(default=100, description="Maximum history items")
offset: int = Field(default=0, description="History offset")
class CreateDownloadJobBody(BaseModel):
source_url: str | None = Field(default=None, description="Original source URL")
source: str = Field(default="spotify", description="Source provider")
quality: str = Field(default="high", description="Requested quality")
codec: str | None = Field(default=None, description="Codec hint")
trackhash: str | None = Field(default=None, description="Track hash")
title: str | None = Field(default=None, description="Track title")
artist: str | None = Field(default=None, description="Track artist")
album: str | None = Field(default=None, description="Track album")
item_type: str = Field(default="track", description="Item type")
target_path: str | None = Field(
default=None, description="Optional destination path"
)
payload: dict = Field(default_factory=dict, description="Extra provider payload")
class JobPath(BaseModel):
job_id: int
class ImportCandidatesBody(BaseModel):
trackhash: str = Field(description="Trackhash to query import candidates for")
class ImportConfirmBody(BaseModel):
trackhash: str = Field(description="Trackhash to import")
source_userid: int | None = Field(
default=None, description="Specific source user ID"
)
class AvailabilityBody(BaseModel):
trackhashes: list[str] = Field(default_factory=list)
class TrackPlaylistBody(BaseModel):
source_url: str = Field(
description="Trackable playlist URL (Spotify and supported providers)"
)
quality: str | None = Field(default="lossless", description="Requested quality")
codec: str | None = Field(default="flac", description="Requested codec")
auto_sync: bool = Field(default=True, description="Enable periodic sync")
sync_interval_seconds: int = Field(
default=900, description="Sync cadence in seconds"
)
sync_now: bool = Field(default=True, description="Run immediate sync")
class TrackedPlaylistPath(BaseModel):
tracked_id: int
class TrackedPlaylistsQuery(BaseModel):
playlist_id: str | None = Field(
default=None, description="Filter by Spotify playlist ID"
)
class ToggleAutoSyncBody(BaseModel):
enabled: bool = Field(default=True, description="Whether auto sync is enabled")
class StorageRootsBody(BaseModel):
root_dirs: list[str] = Field(
default_factory=list, description="Root directories for current user"
)
def _current_userid() -> int:
try:
identity = get_jwt_identity()
if isinstance(identity, dict) and identity.get("id") is not None:
return int(identity["id"])
except Exception:
pass
return get_current_userid()
def _normalize_root_path(value: str) -> str:
if value == "$home":
return "$home"
return Path(value).expanduser().resolve().as_posix().rstrip("/")
def _allowed_root_bases() -> list[Path]:
bases: list[Path] = []
for root in UserConfig().rootDirs or []:
if root == "$home":
bases.append(Path.home().resolve())
else:
bases.append(Path(root).expanduser().resolve())
return bases
def _validate_user_roots(root_dirs: list[str]) -> list[str]:
normalized = [
_normalize_root_path(path.strip())
for path in root_dirs
if path and path.strip()
]
normalized = list(dict.fromkeys(normalized))
configured_bases = _allowed_root_bases()
configured_raw = UserConfig().rootDirs or []
if not configured_bases:
return normalized
for root in normalized:
if root == "$home":
if "$home" not in configured_raw:
raise ValueError(
"$home is not allowed because it is not configured as a server root"
)
continue
candidate = Path(root).expanduser().resolve()
valid = False
for base in configured_bases:
if candidate == base or base in candidate.parents:
valid = True
break
if not valid:
raise ValueError(
"User root directories must be inside configured library roots"
)
return normalized
@api.get("/jobs")
def list_download_jobs(query: JobsQuery):
userid = _current_userid()
limit = max(1, min(int(query.limit or 200), 500))
jobs = download_job_manager.list_jobs(userid, limit=limit)
return {
"jobs": jobs,
"total": len(jobs),
}
@api.get("/queue")
def get_download_queue(query: JobsQuery):
userid = _current_userid()
limit = max(1, min(int(query.limit or 200), 500))
jobs = download_job_manager.list_jobs(userid, limit=limit)
pending = [job for job in jobs if job["state"] == "queued"]
active = [job for job in jobs if job["state"] == "downloading"]
queued = [job for job in jobs if job["state"] in {"queued", "downloading"}]
history = [
job for job in jobs if job["state"] in {"completed", "failed", "cancelled"}
]
return {
"queue_length": len(pending),
"active_downloads": len(active),
"queue": queued,
"pending": pending,
"active": active,
"history": history,
}
@api.get("/status")
def get_download_status(query: JobsQuery):
userid = _current_userid()
limit = max(1, min(int(query.limit or 500), 2000))
jobs = download_job_manager.list_jobs(userid, limit=limit)
counts = {
"queued": 0,
"downloading": 0,
"completed": 0,
"failed": 0,
"cancelled": 0,
}
for job in jobs:
state = job.get("state")
if state in counts:
counts[state] += 1
return {
"counts": counts,
"total": len(jobs),
}
@api.get("/history")
def get_download_history(query: HistoryQuery):
userid = _current_userid()
limit = max(1, min(int(query.limit or 100), 500))
offset = max(0, int(query.offset or 0))
jobs = download_job_manager.list_jobs(userid, limit=2000)
history = [
job for job in jobs if job["state"] in {"completed", "failed", "cancelled"}
]
sliced = history[offset : offset + limit]
return {
"history": sliced,
"total": len(history),
"limit": limit,
"offset": offset,
}
@api.post("/history/clear")
def clear_download_history():
userid = _current_userid()
deleted = download_job_manager.clear_history(userid)
return {
"success": True,
"deleted": deleted,
}
@api.post("/jobs")
def create_download_job(body: CreateDownloadJobBody):
userid = _current_userid()
job_id = download_job_manager.enqueue(
userid=userid,
source_url=body.source_url,
source=body.source,
quality=body.quality,
codec=body.codec,
trackhash=body.trackhash,
title=body.title,
artist=body.artist,
album=body.album,
item_type=body.item_type,
target_path=body.target_path,
payload=body.payload,
)
job = download_job_manager.get_job(job_id, userid=userid)
return {
"success": True,
"job_id": job_id,
"job": job,
}, 201
@api.get("/jobs/<job_id>")
def get_download_job(path: JobPath):
userid = _current_userid()
job = download_job_manager.get_job(path.job_id, userid=userid)
if not job:
return {"error": "Job not found"}, 404
return job
@api.post("/jobs/<job_id>/cancel")
def cancel_download_job(path: JobPath):
userid = _current_userid()
success = download_job_manager.cancel(path.job_id, userid)
if not success:
return {"success": False, "error": "Unable to cancel job"}, 400
return {"success": True}
@api.post("/jobs/<job_id>/retry")
def retry_download_job(path: JobPath):
userid = _current_userid()
success = download_job_manager.retry(path.job_id, userid)
if not success:
return {"success": False, "error": "Unable to retry job"}, 400
return {"success": True}
@api.post("/imports/candidates")
def get_import_candidates(body: ImportCandidatesBody):
userid = _current_userid()
candidates = list_import_candidates(body.trackhash, userid=userid)
availability = get_track_availability(body.trackhash, userid=userid)
return {
"trackhash": body.trackhash,
"availability": availability,
"candidates": candidates,
}
@api.post("/imports/confirm")
def confirm_import(body: ImportConfirmBody):
userid = _current_userid()
imported = import_existing_track(
body.trackhash,
userid=userid,
source_userid=body.source_userid,
)
availability = get_track_availability(body.trackhash, userid=userid)
if not imported:
return {
"success": False,
"error": "No import candidate available",
"availability": availability,
}, 404
return {
"success": True,
"availability": availability,
}
@api.post("/tracks/availability")
def get_tracks_availability(body: AvailabilityBody):
userid = _current_userid()
availability = get_track_availability_map(body.trackhashes, userid=userid)
return {
"availability": availability,
}
@api.post("/playlists/track")
def track_playlist(body: TrackPlaylistBody):
userid = _current_userid()
try:
payload = playlist_tracking_service.track_playlist(
userid=userid,
source_url=body.source_url,
quality=body.quality,
codec=body.codec,
auto_sync=body.auto_sync,
sync_interval_seconds=body.sync_interval_seconds,
sync_now=body.sync_now,
)
except ValueError as error:
return {"success": False, "error": str(error)}, 400
except Exception as error:
return {"success": False, "error": f"Failed to track playlist: {error}"}, 500
return {
"success": True,
**payload,
}, 201
@api.get("/playlists/tracked")
def list_tracked_playlists(query: TrackedPlaylistsQuery):
userid = _current_userid()
items = playlist_tracking_service.list_tracked_playlists(userid)
if query.playlist_id:
filtered = [
item for item in items if item.get("playlist_id") == query.playlist_id
]
else:
filtered = items
return {
"tracked_playlists": filtered,
"total": len(filtered),
}
@api.post("/playlists/<tracked_id>/sync")
def sync_tracked_playlist(path: TrackedPlaylistPath):
userid = _current_userid()
result = playlist_tracking_service.sync_tracked_playlist(
path.tracked_id, userid=userid, force=True
)
if not result.get("success"):
if result.get("message") == "Tracked playlist not found":
return {"success": False, **result}, 404
return {"success": False, **result}, 400
tracked = playlist_tracking_service.get_tracked_playlist(path.tracked_id, userid)
return {
"success": True,
"result": result,
"tracked": tracked,
}
@api.post("/playlists/<tracked_id>/auto-sync")
def toggle_playlist_auto_sync(path: TrackedPlaylistPath, body: ToggleAutoSyncBody):
userid = _current_userid()
tracked = playlist_tracking_service.set_auto_sync(
path.tracked_id, userid=userid, enabled=body.enabled
)
if not tracked:
return {"success": False, "error": "Tracked playlist not found"}, 404
return {
"success": True,
"tracked": tracked,
}
@api.delete("/playlists/<tracked_id>")
def delete_tracked_playlist(path: TrackedPlaylistPath):
userid = _current_userid()
deleted = playlist_tracking_service.untrack_playlist(path.tracked_id, userid=userid)
if not deleted:
return {"success": False, "error": "Tracked playlist not found"}, 404
return {
"success": True,
}
@api.get("/storage/roots")
def get_storage_roots():
userid = _current_userid()
configured_roots = UserConfig().rootDirs or []
owned_roots = UserRootDirOwnershipTable.get_paths(userid)
effective = get_user_root_dirs(userid)
return {
"configured_roots": configured_roots,
"owned_roots": owned_roots,
"effective_roots": effective,
}
@api.post("/storage/roots")
def set_storage_roots(body: StorageRootsBody):
userid = _current_userid()
try:
normalized = _validate_user_roots(body.root_dirs)
except ValueError as error:
return {"success": False, "error": str(error)}, 400
for root in normalized:
if root == "$home":
continue
os.makedirs(root, exist_ok=True)
UserRootDirOwnershipTable.replace_paths(userid, normalized)
return {
"success": True,
"owned_roots": UserRootDirOwnershipTable.get_paths(userid),
"effective_roots": get_user_root_dirs(userid),
}
+275
View File
@@ -0,0 +1,275 @@
"""
DragonflyDB health check and monitoring endpoints.
"""
from flask_openapi3 import APIBlueprint, Tag
from swingmusic.db.dragonfly_client import get_dragonfly_client
from swingmusic.db.dragonfly_extended_client import (
get_all_dragonfly_services,
get_job_queue_service,
get_realtime_service,
get_search_cache_service,
get_track_cache_service,
get_user_session_service,
)
tag = Tag(name="DragonflyDB", description="DragonflyDB cache monitoring")
api = APIBlueprint("dragonfly", __name__, url_prefix="/dragonfly", abp_tags=[tag])
@api.get("/health")
def health_check():
"""
Check DragonflyDB connection health.
Returns basic connectivity status and response time.
"""
client = get_dragonfly_client()
if not client.is_available():
return {
"status": "unavailable",
"connected": False,
"message": "DragonflyDB is not available or not configured",
}, 503
try:
# Measure ping response time
import time
start = time.time()
pong = client.ping()
latency_ms = round((time.time() - start) * 1000, 2)
return {
"status": "healthy",
"connected": True,
"latency_ms": latency_ms,
"ping": pong,
}
except Exception as e:
return {
"status": "error",
"connected": False,
"message": str(e),
}, 503
@api.get("/stats")
def get_stats():
"""
Get DragonflyDB statistics and memory usage.
Returns detailed information about cache usage, memory, and performance.
"""
client = get_dragonfly_client()
if not client.is_available():
return {"error": "DragonflyDB is not available"}, 503
try:
info = client.info()
# Extract relevant stats
stats = {
"memory": {
"used_memory": info.get("used_memory_human", "Unknown"),
"used_memory_peak": info.get("used_memory_peak_human", "Unknown"),
"used_memory_rss": info.get("used_memory_rss_human", "Unknown"),
"memory_fragmentation_ratio": info.get("mem_fragmentation_ratio", 0),
},
"clients": {
"connected_clients": info.get("connected_clients", 0),
"blocked_clients": info.get("blocked_clients", 0),
},
"stats": {
"total_connections_received": info.get("total_connections_received", 0),
"total_commands_processed": info.get("total_commands_processed", 0),
"instantaneous_ops_per_sec": info.get("instantaneous_ops_per_sec", 0),
"keyspace_hits": info.get("keyspace_hits", 0),
"keyspace_misses": info.get("keyspace_misses", 0),
"hit_rate": _calculate_hit_rate(
info.get("keyspace_hits", 0), info.get("keyspace_misses", 0)
),
},
"cpu": {
"used_cpu_sys": info.get("used_cpu_sys", 0),
"used_cpu_user": info.get("used_cpu_user", 0),
},
"uptime_seconds": info.get("uptime_in_seconds", 0),
"version": info.get(
"dragonfly_version", info.get("redis_version", "Unknown")
),
}
return stats
except Exception as e:
return {"error": str(e)}, 500
@api.get("/services")
def get_services_status():
"""
Get status of all DragonflyDB cache services.
Returns information about each cache namespace and its usage.
"""
client = get_dragonfly_client()
if not client.is_available():
return {"error": "DragonflyDB is not available"}, 503
get_all_dragonfly_services()
service_stats = {}
# Track cache stats
track_service = get_track_cache_service()
track_keys = client.keys("tracks:*")
service_stats["track_cache"] = {
"available": track_service.cache.client.is_available(),
"cached_tracks": len(track_keys),
}
# Search cache stats
search_service = get_search_cache_service()
search_keys = client.keys("search:*")
service_stats["search_cache"] = {
"available": search_service.cache.client.is_available(),
"cached_searches": len(search_keys),
}
# Session cache stats
session_service = get_user_session_service()
session_keys = client.keys("sessions:*")
service_stats["session_cache"] = {
"available": session_service.cache.client.is_available(),
"active_sessions": len(session_keys),
}
# Realtime features stats
realtime_service = get_realtime_service()
playcount_keys = client.keys("playcounts:*")
recent_keys = client.keys("recent:*")
favorite_keys = client.keys("favorites:*")
service_stats["realtime_features"] = {
"available": realtime_service.playcount_cache.client.is_available(),
"playcount_entries": len(playcount_keys),
"recent_lists": len(recent_keys),
"favorite_entries": len(favorite_keys),
}
# Job queue stats
job_service = get_job_queue_service()
download_queue_size = job_service.get_queue_size("downloads")
service_stats["job_queue"] = {
"available": job_service.cache.client.is_available(),
"download_queue_size": download_queue_size,
}
return {
"services": service_stats,
"total_keys": len(client.keys("*")),
}
@api.get("/keys")
def get_key_stats():
"""
Get statistics about cached keys by namespace.
Returns count of keys in each cache namespace.
"""
client = get_dragonfly_client()
if not client.is_available():
return {"error": "DragonflyDB is not available"}, 503
namespaces = [
"tracks",
"artists",
"albums",
"sessions",
"users",
"search",
"homepage",
"mobile",
"sync",
"progress",
"playlists",
"playcounts",
"recent",
"favorites",
"recommendations",
"jobs",
"lyrics",
"index",
"temp",
]
key_stats = {}
total = 0
for namespace in namespaces:
keys = client.keys(f"{namespace}:*")
count = len(keys)
key_stats[namespace] = count
total += count
key_stats["total"] = total
return key_stats
@api.post("/clear/<namespace>")
def clear_namespace(namespace: str):
"""
Clear all keys in a specific cache namespace.
Use with caution - this will remove all cached data for the namespace.
"""
client = get_dragonfly_client()
if not client.is_available():
return {"error": "DragonflyDB is not available"}, 503
# Validate namespace to prevent accidental data loss
allowed_namespaces = [
"search",
"homepage",
"temp",
"recommendations",
"index",
]
if namespace not in allowed_namespaces:
return {
"error": f"Cannot clear namespace '{namespace}'. Allowed namespaces: {allowed_namespaces}"
}, 400
try:
keys = client.keys(f"{namespace}:*")
if keys:
deleted = client.delete(*keys)
return {
"success": True,
"namespace": namespace,
"keys_deleted": deleted,
}
return {
"success": True,
"namespace": namespace,
"keys_deleted": 0,
"message": "No keys found in namespace",
}
except Exception as e:
return {"error": str(e)}, 500
def _calculate_hit_rate(hits: int, misses: int) -> float:
"""Calculate cache hit rate percentage"""
total = hits + misses
if total == 0:
return 0.0
return round((hits / total) * 100, 2)
+513
View File
@@ -0,0 +1,513 @@
"""
Enhanced Search API for SwingMusic
Integrates global music catalog search with existing local search
"""
import asyncio
import logging
from typing import Any
from flask import Blueprint, jsonify, request
from swingmusic.api.search import search_items as local_search
from swingmusic.db.spotify import UserCatalogPreferencesTable
from swingmusic.services.music_catalog import music_catalog_service
logger = logging.getLogger(__name__)
# Create blueprint
enhanced_search_bp = Blueprint("enhanced_search", __name__, url_prefix="/api/search")
@enhanced_search_bp.route("/global", methods=["POST"])
def global_search():
"""
Search across global music catalog (Spotify)
Request body:
{
"query": "search query",
"type": "all|tracks|albums|artists|playlists",
"limit": 20,
"user_id": 1
}
"""
try:
data = request.get_json()
if not data or not data.get("query"):
return jsonify({"error": "Search query is required"}), 400
query = data["query"].strip()
search_type = data.get("type", "all")
limit = min(data.get("limit", 20), 50) # Cap at 50
user_id = data.get("user_id")
# Get user preferences if available
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_search_results)
# Run async search
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, search_type, limit)
)
finally:
loop.close()
# Filter based on user preferences
if user_prefs and not user_prefs.show_explicit:
result.tracks = [track for track in result.tracks if not track.explicit]
result.albums = [album for album in result.albums if not album.explicit]
# Convert to dict for JSON response
response_data = {
"query": result.query,
"total": result.total,
"tracks": [_catalog_item_to_dict(track) for track in result.tracks],
"albums": [_catalog_item_to_dict(album) for album in result.albums],
"artists": [_catalog_item_to_dict(artist) for artist in result.artists],
"playlists": [
_catalog_item_to_dict(playlist) for playlist in result.playlists
],
"source": "global_catalog",
"cache_info": {
"from_cache": False, # Cache detection would require tracking query timestamps
"expires_at": None,
},
}
return jsonify(response_data)
except Exception as e:
logger.error(f"Error in global search: {e}")
return jsonify({"error": "Search failed"}), 500
@enhanced_search_bp.route("/combined", methods=["POST"])
def combined_search():
"""
Search both local library and global catalog
Request body:
{
"query": "search query",
"include_local": true,
"include_global": true,
"type": "all|tracks|albums|artists",
"limit": 20,
"user_id": 1
}
"""
try:
data = request.get_json()
if not data or not data.get("query"):
return jsonify({"error": "Search query is required"}), 400
query = data["query"].strip()
include_local = data.get("include_local", True)
include_global = data.get("include_global", True)
search_type = data.get("type", "all")
limit = min(data.get("limit", 20), 50)
user_id = data.get("user_id")
results = {
"query": query,
"local": {"tracks": [], "albums": [], "artists": []},
"global": {"tracks": [], "albums": [], "artists": [], "playlists": []},
"total": 0,
}
# Search local library
if include_local:
try:
# Use existing local search
local_results = local_search(query, search_type)
results["local"] = (
local_results
if local_results
else {"tracks": [], "albums": [], "artists": []}
)
except Exception as e:
logger.error(f"Error in local search: {e}")
# Search global catalog
if include_global:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
global_results = loop.run_until_complete(
music_catalog_service.search_global_catalog(
query, search_type, limit
)
)
# Filter based on user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
if not user_prefs.show_explicit:
global_results.tracks = [
track
for track in global_results.tracks
if not track.explicit
]
global_results.albums = [
album
for album in global_results.albums
if not album.explicit
]
results["global"] = {
"tracks": [
_catalog_item_to_dict(track) for track in global_results.tracks
],
"albums": [
_catalog_item_to_dict(album) for album in global_results.albums
],
"artists": [
_catalog_item_to_dict(artist)
for artist in global_results.artists
],
"playlists": [
_catalog_item_to_dict(playlist)
for playlist in global_results.playlists
],
}
finally:
loop.close()
# Calculate total
results["total"] = (
len(results["local"].get("tracks", []))
+ len(results["local"].get("albums", []))
+ len(results["local"].get("artists", []))
+ len(results["global"].get("tracks", []))
+ len(results["global"].get("albums", []))
+ len(results["global"].get("artists", []))
+ len(results["global"].get("playlists", []))
)
return jsonify(results)
except Exception as e:
logger.error(f"Error in combined search: {e}")
return jsonify({"error": "Search failed"}), 500
@enhanced_search_bp.route("/suggestions", methods=["GET"])
def search_suggestions():
"""
Get search suggestions based on query and user preferences
Query parameters:
- q: search query
- type: tracks|albums|artists|all
- limit: number of suggestions (default 10)
- user_id: user ID for preferences
"""
try:
query = request.args.get("q", "").strip()
if not query or len(query) < 2:
return jsonify({"suggestions": []})
search_type = request.args.get("type", "all")
limit = min(int(request.args.get("limit", 10)), 20)
user_id = request.args.get("user_id")
# Get user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_search_results)
# Search cached items for fast suggestions
item_types = None
if search_type != "all":
item_types = [search_type]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# For suggestions, search both cache and live
suggestions = []
# Search cached items first (fast)
from swingmusic.db.spotify import GlobalCatalogCacheTable
cached_items = GlobalCatalogCacheTable.search_cached(
query, item_types, limit
)
for item in cached_items:
if user_prefs and not user_prefs.show_explicit and item.explicit:
continue
suggestion = {
"id": item.spotify_id,
"type": item.item_type,
"title": item.title,
"artist": item.artist,
"album": item.album,
"image_url": item.image_url,
"popularity": item.popularity,
"source": "cache",
}
suggestions.append(suggestion)
# If we need more suggestions, search global catalog
if len(suggestions) < limit:
remaining = limit - len(suggestions)
global_results = loop.run_until_complete(
music_catalog_service.search_global_catalog(
query, search_type, remaining
)
)
for track in global_results.tracks[:remaining]:
if user_prefs and not user_prefs.show_explicit and track.explicit:
continue
suggestion = {
"id": track.spotify_id,
"type": "track",
"title": track.title,
"artist": track.artist,
"album": track.album,
"image_url": track.image_url,
"popularity": track.popularity,
"source": "global",
}
suggestions.append(suggestion)
return jsonify({"suggestions": suggestions[:limit]})
finally:
loop.close()
except Exception as e:
logger.error(f"Error in search suggestions: {e}")
return jsonify({"suggestions": []})
@enhanced_search_bp.route("/artist/<artist_id>", methods=["GET"])
def get_artist_info(artist_id: str):
"""
Get comprehensive artist information including top tracks and albums
Path parameters:
- artist_id: Spotify artist ID
Query parameters:
- user_id: user ID for preferences
"""
try:
user_id = request.args.get("user_id")
# Get user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
artist_info = loop.run_until_complete(
music_catalog_service.get_artist_info(artist_id)
)
if not artist_info:
return jsonify({"error": "Artist not found"}), 404
# Filter based on user preferences
if user_prefs and not user_prefs.show_explicit:
artist_info.top_tracks = [
track
for track in artist_info.top_tracks or []
if not track.explicit
]
artist_info.albums = [
album for album in artist_info.albums or [] if not album.explicit
]
response_data = {
"spotify_id": artist_info.spotify_id,
"name": artist_info.name,
"image_url": artist_info.image_url,
"followers": artist_info.followers,
"popularity": artist_info.popularity,
"genres": artist_info.genres or [],
"top_tracks": [
_catalog_item_to_dict(track)
for track in (artist_info.top_tracks or [])
],
"albums": [
_catalog_item_to_dict(album) for album in (artist_info.albums or [])
],
"related_artists": artist_info.related_artists or [],
}
return jsonify(response_data)
finally:
loop.close()
except Exception as e:
logger.error(f"Error getting artist info: {e}")
return jsonify({"error": "Failed to get artist info"}), 500
@enhanced_search_bp.route("/album/<album_id>", methods=["GET"])
def get_album_details(album_id: str):
"""
Get detailed album information with tracklist
Path parameters:
- album_id: Spotify album ID
Query parameters:
- user_id: user ID for preferences
"""
try:
user_id = request.args.get("user_id")
# Get user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
album = loop.run_until_complete(
music_catalog_service.get_album_details(album_id)
)
if not album:
return jsonify({"error": "Album not found"}), 404
# Filter based on user preferences
if user_prefs and not user_prefs.show_explicit and album.explicit:
return jsonify({"error": "Explicit content filtered"}), 403
response_data = _catalog_item_to_dict(album)
# Add tracklist if available in data
if album.data and "tracks" in album.data:
response_data["tracks"] = [
_catalog_item_to_dict(track) for track in album.data["tracks"]
]
return jsonify(response_data)
finally:
loop.close()
except Exception as e:
logger.error(f"Error getting album details: {e}")
return jsonify({"error": "Failed to get album details"}), 500
@enhanced_search_bp.route("/preferences/<int:user_id>", methods=["GET", "POST"])
def user_preferences(user_id: int):
"""Get or update user catalog search preferences"""
try:
if request.method == "GET":
prefs = UserCatalogPreferencesTable.get_or_create(user_id)
return jsonify(
{
"user_id": prefs.user_id,
"show_explicit": prefs.show_explicit,
"default_quality": prefs.default_quality,
"auto_download": prefs.auto_download,
"show_suggestions": prefs.show_suggestions,
"preferred_genres": prefs.preferred_genres or [],
"excluded_genres": prefs.excluded_genres or [],
"max_search_results": prefs.max_search_results,
"cache_ttl_preference": prefs.cache_ttl_preference,
}
)
elif request.method == "POST":
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
# Update only provided fields
update_data = {}
allowed_fields = [
"show_explicit",
"default_quality",
"auto_download",
"show_suggestions",
"preferred_genres",
"excluded_genres",
"max_search_results",
"cache_ttl_preference",
]
for field in allowed_fields:
if field in data:
update_data[field] = data[field]
if update_data:
UserCatalogPreferencesTable.update_preferences(user_id, update_data)
return jsonify({"message": "Preferences updated successfully"})
except Exception as e:
logger.error(f"Error handling user preferences: {e}")
return jsonify({"error": "Failed to handle preferences"}), 500
def _catalog_item_to_dict(item) -> dict[str, Any]:
"""Convert CatalogItem to dictionary for JSON response"""
if hasattr(item, "__dict__"):
# It's a dataclass instance
return {
"spotify_id": item.spotify_id,
"type": item.item_type.value
if hasattr(item.item_type, "value")
else str(item.item_type),
"title": item.title,
"artist": item.artist,
"album": item.album,
"duration_ms": item.duration_ms,
"popularity": item.popularity,
"preview_url": item.preview_url,
"image_url": item.image_url,
"release_date": item.release_date,
"explicit": item.explicit,
"data": item.data,
}
else:
# It's likely a database model
return {
"spotify_id": getattr(item, "spotify_id", None),
"type": getattr(item, "item_type", None),
"title": getattr(item, "title", None),
"artist": getattr(item, "artist", None),
"album": getattr(item, "album", None),
"duration_ms": getattr(item, "duration_ms", None),
"popularity": getattr(item, "popularity", None),
"preview_url": getattr(item, "preview_url", None),
"image_url": getattr(item, "image_url", None),
"release_date": getattr(item, "release_date", None),
"explicit": getattr(item, "explicit", False),
"data": getattr(item, "data", None),
}
def register_enhanced_search_api(app):
"""Register enhanced search API with Flask app"""
app.register_blueprint(enhanced_search_bp)
logger.info("Enhanced search API registered")
+331
View File
@@ -0,0 +1,331 @@
from typing import TypeVar
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.api.apischemas import GenericLimitSchema
# DragonflyDB integration for instant favorite status caching
from swingmusic.db.dragonfly_extended_client import get_realtime_service
from swingmusic.db.userdata import FavoritesTable
from swingmusic.lib.extras import get_extra_info
from swingmusic.models import FavType
from swingmusic.serializers.album import serialize_for_card, serialize_for_card_many
from swingmusic.serializers.artist import (
serialize_for_card as serialize_artist,
)
from swingmusic.serializers.artist import (
serialize_for_cards,
)
from swingmusic.serializers.track import serialize_track, serialize_tracks
from swingmusic.services.user_library_scope import (
get_available_trackhashes,
get_visible_albums,
get_visible_artists,
)
from swingmusic.settings import Defaults
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.dates import timestamp_to_time_passed
bp_tag = Tag(name="Favorites", description="Your favorite items")
api = APIBlueprint("favorites", __name__, url_prefix="/favorites", abp_tags=[bp_tag])
T = TypeVar("T")
def remove_none(items: list[T]) -> list[T]:
return [i for i in items if i is not None]
class FavoritesAddBody(BaseModel):
hash: str = Field(
description="The hash of the item",
min_length=Defaults.HASH_LENGTH,
max_length=Defaults.HASH_LENGTH,
)
type: str = Field(description="The type of the item")
def toggle_fav(type: str, hash: str):
"""
Toggles a favorite item.
"""
if type == FavType.track:
entry = TrackStore.trackhashmap.get(hash)
if entry is not None:
entry.toggle_favorite_user()
elif type == FavType.album:
entry = AlbumStore.albummap.get(hash)
if entry is not None:
entry.toggle_favorite_user()
elif type == FavType.artist:
entry = ArtistStore.artistmap.get(hash)
if entry is not None:
entry.toggle_favorite_user()
return {"msg": "Added to favorites"}
@api.post("/add")
def toggle_favorite(body: FavoritesAddBody):
"""
Adds a favorite to the database.
"""
extra = get_extra_info(body.hash, body.type)
userid = get_current_userid()
try:
FavoritesTable.insert_item(
{"hash": body.hash, "type": body.type, "extra": extra}
)
except Exception as e:
print(e)
return {"msg": "Failed! An error occured"}, 500
toggle_fav(body.type, body.hash)
# Update DragonflyDB favorite cache for instant status checks
realtime = get_realtime_service()
if realtime.favorite_cache.client.is_available() and body.type == FavType.track:
import contextlib
with contextlib.suppress(Exception):
realtime.toggle_favorite(userid, body.hash)
return {"msg": "Added to favorites"}
@api.post("/remove")
def remove_favorite(body: FavoritesAddBody):
"""
Removes a favorite from the database.
"""
userid = get_current_userid()
try:
FavoritesTable.remove_item({"hash": body.hash, "type": body.type})
except Exception as e:
print(e)
return {"msg": "Failed! An error occured"}, 500
toggle_fav(body.type, body.hash)
# Update DragonflyDB favorite cache for instant status checks
realtime = get_realtime_service()
if realtime.favorite_cache.client.is_available() and body.type == FavType.track:
import contextlib
with contextlib.suppress(Exception):
realtime.toggle_favorite(userid, body.hash)
return {"msg": "Removed from favorites"}
class GetAllOfTypeQuery(GenericLimitSchema):
"""
Extending this class will give you a model with the `limit` field
"""
start: int = Field(
description="Where to start from",
default=Defaults.API_CARD_LIMIT,
)
@api.get("/albums")
def get_favorite_albums(query: GetAllOfTypeQuery):
"""
Get favorite albums
Note: Only the first request will return the total number of favorites.
Others will return -1
"""
fav_albums, total = FavoritesTable.get_fav_albums(query.start, query.limit)
albums = AlbumStore.get_albums_by_hashes(a.hash for a in fav_albums)
visible_albums = {album.albumhash for album in get_visible_albums()}
albums = [album for album in albums if album.albumhash in visible_albums]
return {"albums": serialize_for_card_many(albums), "total": total}
@api.get("/tracks")
def get_favorite_tracks(query: GetAllOfTypeQuery):
"""
Get favorite tracks
Note: Only the first request will return the total number of favorites.
Others will return -1
"""
tracks, total = FavoritesTable.get_fav_tracks(query.start, query.limit)
available_trackhashes = get_available_trackhashes()
tracks = TrackStore.get_tracks_by_trackhashes(
[t.hash for t in tracks if t.hash in available_trackhashes]
)
return {"tracks": serialize_tracks(tracks), "total": total}
@api.get("/artists")
def get_favorite_artists(query: GetAllOfTypeQuery):
"""
Get favorite artists
Note: Only the first request will return the total number of favorites.
Others will return -1
"""
artists, total = FavoritesTable.get_fav_artists(
start=query.start,
limit=query.limit,
)
artists = ArtistStore.get_artists_by_hashes(a.hash for a in artists)
visible_artists = {artist.artisthash for artist in get_visible_artists()}
artists = [artist for artist in artists if artist.artisthash in visible_artists]
return {"artists": [serialize_artist(a) for a in artists], "total": total}
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",
default=Defaults.API_CARD_LIMIT,
)
album_limit: int = Field(
description="The number of albums to return",
default=Defaults.API_CARD_LIMIT,
)
artist_limit: int = Field(
description="The number of artists to return",
default=Defaults.API_CARD_LIMIT,
)
@api.get("")
def get_all_favorites(query: GetAllFavoritesQuery):
"""
Returns all the favorites in the database.
"""
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)
favs = FavoritesTable.get_all(with_user=True)
favs = sorted(favs, key=lambda x: x.timestamp, reverse=True)
tracks = []
albums = []
artists = []
track_master_hash = get_available_trackhashes()
album_master_hash = {album.albumhash for album in get_visible_albums()}
artist_master_hash = {artist.artisthash for artist in get_visible_artists()}
# INFO: Filter out invalid hashes (file not found or tags edited)
for fav in favs:
hash = fav.hash
type = fav.type
if type == FavType.track:
tracks.append(hash) if hash in track_master_hash else None
if type == FavType.artist:
artists.append(hash) if hash in artist_master_hash else None
if type == FavType.album:
albums.append(hash) if hash in album_master_hash else None
count = {
"tracks": len(tracks),
"albums": len(albums),
"artists": len(artists),
}
tracks = TrackStore.get_tracks_by_trackhashes(tracks[:track_limit])
albums = AlbumStore.get_albums_by_hashes(albums[:album_limit])
artists = ArtistStore.get_artists_by_hashes(artists[:artist_limit])
recents = []
for fav in favs:
if len(recents) >= largest:
break
if fav.type == FavType.album:
album = next((a for a in albums if a.albumhash == fav.hash), None)
if album is None:
continue
album = serialize_for_card(album)
album["help_text"] = "album"
album["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append(
{
"type": "album",
"item": album,
}
)
if fav.type == FavType.artist:
artist = next((a for a in artists if a.artisthash == fav.hash), None)
if artist is None:
continue
artist = serialize_artist(artist)
artist["help_text"] = "artist"
artist["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append(
{
"type": "artist",
"item": artist,
}
)
if fav.type == FavType.track:
track = next((t for t in tracks if t.trackhash == fav.hash), None)
if track is None:
continue
track = serialize_track(track)
track["help_text"] = "track"
track["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append({"type": "track", "item": track})
return {
"recents": recents[:album_limit],
"tracks": serialize_tracks(tracks[:track_limit]),
"albums": serialize_for_card_many(albums[:album_limit]),
"artists": serialize_for_cards(artists[:artist_limit]),
"count": count,
}
@api.get("/check")
def check_favorite(query: FavoritesAddBody):
"""
Checks if a favorite exists in the database.
"""
itemhash = query.hash
itemtype = query.type
return {"is_favorite": FavoritesTable.check_exists(itemhash, itemtype)}
+395
View File
@@ -0,0 +1,395 @@
"""
Contains all the folder routes.
"""
import os
import pathlib
from datetime import datetime
from dataclasses import replace
from pathlib import Path
import psutil
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from showinfm import show_in_file_manager
from swingmusic import settings
from swingmusic.api.auth import admin_required
from swingmusic.config import UserConfig
from swingmusic.db.libdata import TrackTable
from swingmusic.db.userdata import FavoritesTable, PlaylistTable
from swingmusic.lib.folderslib import get_files_and_dirs, get_folders
from swingmusic.serializers.track import serialize_track, serialize_tracks
from swingmusic.services.user_library_scope import (
count_visible_tracks_in_paths,
get_available_trackhashes,
get_user_root_dirs,
is_path_within_user_roots,
)
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.wintools import is_windows
tag = Tag(name="Folders", description="Get folders and tracks in a directory")
api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag])
def is_path_within_root_dirs(filepath: str, userid: int | None = None) -> bool:
"""
Check if a filepath is within one of the configured root directories.
Prevents directory traversal attacks.
"""
return is_path_within_user_roots(filepath, userid=userid)
class FolderTree(BaseModel):
folder: str = Field("$home", description="The folder to things from")
sorttracksby: str = Field(
"default",
description="""The field to sort tracks by. Options: [
"default",
"album",
"albumartists",
"artists",
"bitrate",
"date",
"disc",
"duration",
"last_mod",
"lastplayed",
"playduration",
"playcount",
"title",
]""",
)
tracksort_reverse: bool = Field(
False,
description="Whether to reverse the sort order of the tracks",
)
sortfoldersby: str = Field(
"lastmod",
description="""The field to sort folders by.
Options: [
"default",
"name",
"lastmod",
"trackcount",
]
""",
)
foldersort_reverse: bool = Field(
False,
description="Whether to reverse the sort order of the folders",
)
start: int = Field(0, description="The start index")
limit: int = Field(50, description="The max number of items to return")
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.
"""
userid = get_current_userid()
og_req_dir = body.folder
req_dir = body.folder
tracks_only = body.tracks_only
config = UserConfig()
root_dirs = get_user_root_dirs(userid)
if req_dir == "$home" and "$home" in root_dirs:
req_dir = settings.Paths().USER_HOME_DIR.as_posix()
if req_dir == "$home":
folders = get_folders(root_dirs)
folder_paths = [folder.path for folder in folders]
user_counts = count_visible_tracks_in_paths(folder_paths, userid=userid)
visible_folders = []
for folder in folders:
key = Path(folder.path).resolve().as_posix().rstrip("/")
visible_folders.append(replace(folder, trackcount=user_counts.get(key, 0)))
return {
"folders": visible_folders,
"tracks": [],
"path": req_dir,
"total": 0,
}
if req_dir.startswith("$playlist"):
splits = req_dir.split("/")
if len(splits) == 2:
pid = splits[1]
playlist = PlaylistTable.get_by_id(int(pid))
available_trackhashes = get_available_trackhashes(userid)
tracks = TrackStore.get_tracks_by_trackhashes(
playlist.trackhashes[
body.start : body.start + body.limit if body.limit != -1 else None
]
)
tracks = [
track for track in tracks if track.trackhash in available_trackhashes
]
return {
"path": f"$playlist/{playlist.name}",
"folders": [],
"tracks": serialize_tracks(tracks),
}
playlists = PlaylistTable.get_all()
playlists = sorted(
playlists,
key=lambda p: datetime.strptime(p.last_updated, "%Y-%m-%d %H:%M:%S"),
reverse=True,
)
available_trackhashes = get_available_trackhashes(userid)
return {
"path": req_dir,
"folders": [
{
"name": p.name,
"path": f"$playlist/{p.id}",
"trackcount": len(
[
trackhash
for trackhash in p.trackhashes
if trackhash in available_trackhashes
]
),
}
for p in playlists
],
"tracks": [],
}
if req_dir == "$favorites":
tracks, total = FavoritesTable.get_fav_tracks(body.start, body.limit)
available_trackhashes = get_available_trackhashes(userid)
tracks = TrackStore.get_tracks_by_trackhashes(
[t.hash for t in tracks if t.hash in available_trackhashes]
)
return {
"tracks": serialize_tracks(tracks),
"folders": [],
"path": req_dir,
}
# Resolve path to prevent directory traversal attacks
resolved_path = pathlib.Path(req_dir).resolve()
# Validate path is within configured root directories
if not is_path_within_root_dirs(str(resolved_path), userid=userid):
return {
"folders": [],
"tracks": [],
"error": "Path not within allowed directories",
}, 403
if not resolved_path.exists() or not resolved_path.is_dir():
return {
"folders": [],
"tracks": [],
"error": "Invalid directory",
}, 400
results = get_files_and_dirs(
resolved_path,
start=body.start,
limit=body.limit,
tracks_only=tracks_only,
tracksortby=body.sorttracksby,
foldersortby=body.sortfoldersby,
tracksort_reverse=body.tracksort_reverse,
foldersort_reverse=body.foldersort_reverse,
)
# Enforce per-user projection on file-backed track results.
available_trackhashes = get_available_trackhashes(userid)
results["tracks"] = [
track
for track in results.get("tracks", [])
if track.get("trackhash") in available_trackhashes
]
# Recompute folder counts from visible tracks only for this user.
folder_paths = [folder.path for folder in results.get("folders", [])]
user_counts = count_visible_tracks_in_paths(folder_paths, userid=userid)
visible_folders = []
for folder in results.get("folders", []):
key = Path(folder.path).resolve().as_posix().rstrip("/")
visible_folders.append(replace(folder, trackcount=user_counts.get(key, 0)))
results["folders"] = visible_folders
if og_req_dir == "$home" and config.showPlaylistsInFolderView:
# Get all playlists and return them as a list of folders
playlists_item = {
"name": "Playlists",
"path": "$playlists",
"trackcount": sum(p.count for p in PlaylistTable.get_all()),
}
favorites_item = {
"name": "Favorites",
"path": "$favorites",
"trackcount": FavoritesTable.get_fav_tracks(0, -1)[1],
}
results["folders"].insert(0, playlists_item)
results["folders"].insert(0, favorites_item)
return results
def get_all_drives(is_win: bool = False):
"""
Returns a list of all the drives on a Windows machine.
"""
drives_ = psutil.disk_partitions(all=True)
drives = [Path(d.mountpoint).as_posix() for d in drives_]
if is_win:
return drives
else:
remove = (
"/boot",
"/tmp",
"/snap",
"/var",
"/sys",
"/proc",
"/etc",
"/run",
"/dev",
)
drives = [d for d in drives if not d.startswith(remove)]
return drives
class DirBrowserBody(BaseModel):
folder: str = Field(
"$root",
description="The folder to list directories from",
)
@api.post("/dir-browser")
@admin_required()
def list_folders(body: DirBrowserBody):
"""
List folders
Returns a list of all the folders in the given folder.
Used when selecting root dirs. Admin only.
"""
req_dir = body.folder
is_win = is_windows()
if req_dir == "$root":
return {
"folders": [{"name": d, "path": d} for d in get_all_drives(is_win=is_win)]
}
# Resolve path to prevent directory traversal attacks
req_dir = pathlib.Path(req_dir).resolve()
if not req_dir.exists() or not req_dir.is_dir():
return {"folders": [], "error": "Invalid directory"}, 400
try:
entries = os.scandir(req_dir)
except PermissionError:
return {"folders": []}
# only get dirs and remove hidden dirs
dirs = []
for entry in entries:
entry = pathlib.Path(entry)
name = entry.name
if name.startswith("$"):
continue
if name.startswith("."):
continue
if entry.is_dir():
dirs.append({"name": name, "path": entry.resolve().as_posix()})
return {
"folders": sorted(dirs, key=lambda i: i["name"]),
}
class FolderOpenInFileManagerQuery(BaseModel):
path: str = Field(
description="The path to open in the file manager",
)
@api.get("/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.
Path must be within configured root directories.
"""
# Resolve path to prevent directory traversal
resolved_path = Path(query.path).resolve()
# Validate path is within root directories
userid = get_current_userid()
if not is_path_within_root_dirs(query.path, userid=userid):
return {"success": False, "error": "Path not within allowed directories"}, 403
if not resolved_path.exists():
return {"success": False, "error": "Path does not exist"}, 404
show_in_file_manager(str(resolved_path))
return {"success": True}
class GetTracksInPathQuery(BaseModel):
path: str = Field(
description="The path to get tracks from",
)
@api.get("/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.
"""
userid = get_current_userid()
# Resolve path to prevent directory traversal
resolved_path = Path(query.path).resolve()
# Validate path is within root directories
if not is_path_within_root_dirs(str(resolved_path), userid=userid):
return {"tracks": [], "error": "Path not within allowed directories"}, 403
available_trackhashes = get_available_trackhashes(userid)
tracks = TrackTable.get_tracks_in_path(str(resolved_path))
tracks = (
serialize_track(t)
for t in tracks
if Path(t.filepath).exists() and t.trackhash in available_trackhashes
)
return {
"tracks": list(tracks)[:300],
}
+160
View File
@@ -0,0 +1,160 @@
from datetime import datetime
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.api.apischemas import GenericLimitSchema
from swingmusic.serializers.album import serialize_for_card as serialize_album
from swingmusic.serializers.artist import serialize_for_card as serialize_artist
from swingmusic.services.user_library_scope import (
get_visible_albums,
get_visible_artists,
)
from swingmusic.utils import format_number
from swingmusic.utils.dates import (
create_new_date,
date_string_to_time_passed,
seconds_to_time_string,
timestamp_to_time_passed,
)
bp_tag = Tag(name="Get all", description="List all items")
api = APIBlueprint("getall", __name__, url_prefix="/getall", abp_tags=[bp_tag])
class GetAllItemsQuery(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: str = 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: GetAllItemsQuery):
"""
Get all items
Used to show all albums or artists in the library
Sort keys:
-
Both albums and artists: `duration`, `created_date`, `playcount`, `playduration`, `lastplayed`, `trackcount`
Albums only: `title`, `albumartists`, `date`
Artists only: `name`, `albumcount`
"""
is_albums = path.itemtype == "albums"
is_artists = path.itemtype == "artists"
if is_albums:
items = get_visible_albums()
elif is_artists:
items = get_visible_artists()
else:
return {"items": [], "total": 0}
total = len(items)
start = query.start
limit = query.limit
sort = query.sortby
reverse = query.reverse == "1"
sort_is_count = sort == "trackcount"
sort_is_duration = sort == "duration"
sort_is_create_date = sort == "created_date"
sort_is_playcount = sort == "playcount"
sort_is_playduration = sort == "playduration"
sort_is_lastplayed = sort == "lastplayed"
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"
def lambda_sort(x):
return getattr(x, sort)
def lambda_sort_casefold(x):
return getattr(x, sort).casefold()
if sort_is_artist:
def lambda_sort(x):
return getattr(x, sort)[0]["name"].casefold()
try:
sorted_items = sorted(items, key=lambda_sort_casefold, reverse=reverse)
except AttributeError:
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"] = datetime.fromtimestamp(item.date).year
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.trackcount)} track{'' if item.trackcount == 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'}"
)
if sort_is_playcount:
item_dict["help_text"] = (
f"{format_number(item.playcount)} play{'' if item.playcount == 1 else 's'}"
)
if sort_is_lastplayed:
if item.playduration == 0:
item_dict["help_text"] = "Never played"
else:
item_dict["help_text"] = timestamp_to_time_passed(item.lastplayed)
if sort_is_playduration:
item_dict["help_text"] = seconds_to_time_string(item.playduration)
album_list.append(item_dict)
return {"items": album_list, "total": total}
+92
View File
@@ -0,0 +1,92 @@
import logging
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.api.apischemas import GenericLimitSchema
# DragonflyDB integration for homepage caching
from swingmusic.db.dragonfly_client import DragonflyCache
from swingmusic.lib.home.get_recently_played import get_recently_played
from swingmusic.lib.home.recentlyadded import get_recently_added_items
from swingmusic.store.homepage import HomepageStore
from swingmusic.utils.auth import get_current_userid
logger = logging.getLogger(__name__)
bp_tag = Tag(name="Home", description="Homepage items")
api = APIBlueprint("home", __name__, url_prefix="/nothome", abp_tags=[bp_tag])
# Homepage cache with 5-minute TTL (homepage content changes frequently)
homepage_cache = DragonflyCache("homepage")
def _get_homepage_cache_key(userid: int, limit: int) -> str:
"""Generate cache key for homepage items"""
return f"items:user:{userid}:limit:{limit}"
def _try_get_cached_homepage(userid: int, limit: int) -> list | None:
"""Try to get cached homepage items"""
if not homepage_cache.client.is_available():
return None
cache_key = _get_homepage_cache_key(userid, limit)
cached = homepage_cache.get(cache_key)
if cached:
logger.debug(f"Homepage cache hit for user {userid}")
return cached
return None
def _cache_homepage_items(userid: int, limit: int, items: list, ttl_minutes: int = 5):
"""Cache homepage items with short TTL"""
if not homepage_cache.client.is_available():
return
cache_key = _get_homepage_cache_key(userid, limit)
ttl_seconds = ttl_minutes * 60
homepage_cache.client.set(cache_key, items, ttl_seconds)
logger.debug(f"Cached homepage for user {userid} for {ttl_minutes} minutes")
@api.get("/recents/added")
def get_recently_added(query: GenericLimitSchema):
"""
Get recently added
"""
return {"items": get_recently_added_items(query.limit)}
@api.get("/recents/played")
def get_recent_plays(query: GenericLimitSchema):
"""
Get recently played
"""
return {"items": get_recently_played(query.limit)}
class HomepageItem(BaseModel):
limit: int = Field(
default=9, description="The max number of items per group to return"
)
@api.get("/")
def homepage_items(query: HomepageItem):
userid = get_current_userid()
# Try to get cached homepage first
cached = _try_get_cached_homepage(userid, query.limit)
if cached:
return cached
# Generate fresh homepage items
items = HomepageStore.get_homepage_items(limit=query.limit)
# Cache for 5 minutes (short TTL since homepage changes with plays)
_cache_homepage_items(userid, query.limit, items, ttl_minutes=5)
return items
+258
View File
@@ -0,0 +1,258 @@
from pathlib import Path
from flask import send_from_directory
from flask_openapi3 import APIBlueprint, Tag
from PIL import Image
from pydantic import BaseModel, Field
from swingmusic.settings import Defaults, Paths
from swingmusic.store.albums import AlbumStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.threading import background
bp_tag = Tag(
name="Images", description="Image filenames are constructured as '{itemhash}.webp'"
)
api = APIBlueprint("imgserver", __name__, url_prefix="/img", abp_tags=[bp_tag])
@background
def cache_thumbnails(filepath: Path, trackhash: str):
"""
Resizes the image and stores it in the cache directory.
"""
image = Image.open(filepath)
path = Path(Paths().image_cache_path)
aspect_ratio = image.width / image.height
sizes = {
"xsmall": 64,
"small": 96,
"medium": 256,
"large": 512,
}
for size, width in sizes.items():
width = min(width, image.width)
height = int(width / aspect_ratio)
resized_path = path / size / (trackhash + ".webp")
resized_path.parent.mkdir(parents=True, exist_ok=True)
image.resize((width, height)).save(resized_path, format="webp")
def find_thumbnail(albumhash: str, pathhash: str):
# entry = TrackStore.trackhashmap.get(albumhash)
entry = AlbumStore.albummap.get(albumhash)
if entry is None:
return None, None, ""
track_file = None
tracks = TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
for track in tracks:
if track.pathhash == pathhash:
track_file = track
break
if track_file is None:
return None, None, ""
folder = Path(track_file.folder)
# INFO: Check if the folder has image files
extensions = [".jpg", ".jpeg", ".png", ".webp"]
hierarchy = ["cover", "front", "back", "folder", "album", "artwork"]
images: list[Path] = []
for item in folder.iterdir():
if item.suffix in extensions:
images.append(item)
if len(images) == 0:
return None, None, ""
# INFO: Check if the folder has image files in the hierarchy
for item in hierarchy:
for image in images:
if image.name.lower().startswith(item.lower()):
return image.parent, image.name, track_file.albumhash
# INFO: If no image falls in the hierarchy, return the first image
first_image = images[0]
return first_image.parent, first_image.name, track_file.albumhash
def send_fallback_img(filename: str = "default.webp"):
"""
Returns the fallback image from the assets folder.
"""
folder = Paths().assets_path
img = Path(folder) / filename
if not img.exists():
return "", 404
return send_from_directory(folder, filename)
def send_file_or_fallback(
folder: str, filename: str, fallback: str = "default.webp", pathhash: str = ""
):
"""
Returns the file from the folder or the fallback image.
"""
fpath = Path(folder) / filename
if fpath.exists():
return send_from_directory(folder, filename)
if pathhash != "":
# INFO: Check if the image is in the cache
cache_path = Paths().image_cache_path / fpath.parent.name / filename
if cache_path.exists():
return send_from_directory(cache_path.parent, cache_path.name)
# INFO: Find the thumbnail
parent, file, albumhash = find_thumbnail(
filename.replace(".webp", ""), pathhash
)
# INFO: Cache and send the thumbnail
if file is not None and parent is not None:
cache_thumbnails(parent / file, albumhash)
return send_from_directory(parent, file)
return send_fallback_img(fallback)
class ImagePath(BaseModel):
imgpath: str = Field(
description="The image filename",
example=Defaults.API_ALBUMHASH + ".webp",
)
class ImageQuery(BaseModel):
pathhash: str = Field(
description="The path hash used to find the thumbnail",
default="",
)
# @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():
# return send_from_directory(folder, path.imgpath)
# return send_fallback_img()
# TRACK THUMBNAILS
@api.get("/thumbnail/<imgpath>")
def send_lg_thumbnail(path: ImagePath, query: ImageQuery):
"""
Get large thumbnail (500 x 500)
"""
folder = Paths().lg_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
@api.get("/thumbnail/xsmall/<imgpath>")
def send_xsm_thumbnail(path: ImagePath, query: ImageQuery):
"""
Get extra small thumbnail (64px)
"""
folder = Paths().xsm_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
@api.get("/thumbnail/small/<imgpath>")
def send_sm_thumbnail(path: ImagePath, query: ImageQuery):
"""
Get small thumbnail (96px)
"""
folder = Paths().sm_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
@api.get("/thumbnail/medium/<imgpath>")
def send_md_thumbnail(path: ImagePath, query: ImageQuery):
"""
Get medium thumbnail (256px)
"""
folder = Paths().md_thumb_path
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
# ARTISTS
@api.get("/artist/<imgpath>")
def send_lg_artist_image(path: ImagePath):
"""
Get large artist image (500 x 500)
"""
folder = Paths().lg_artist_img_path
return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
@api.get("/artist/small/<imgpath>")
def send_sm_artist_image(path: ImagePath):
"""
Get small artist image (128)
"""
folder = Paths().sm_artist_img_path
return send_file_or_fallback(str(folder), path.imgpath, "artist.webp")
@api.get("/artist/medium/<imgpath>")
def send_md_artist_image(path: ImagePath):
"""
Get medium artist image (256px)
"""
folder = Paths().md_artist_img_path
return send_file_or_fallback(folder, path.imgpath, "artist.webp")
# PLAYLISTS
class PlaylistImagePath(BaseModel):
imgpath: str = Field(
description="The image path",
example="1.webp",
)
@api.get("/playlist/<imgpath>")
def send_playlist_image(path: PlaylistImagePath):
"""
Get playlist image
Images are constructed as '{playlist_id}.webp'
"""
folder = Paths().playlist_img_path
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
# MIXES
@api.get("/mix/medium/<imgpath>")
def send_md_mix_image(path: ImagePath):
"""
Get medium mix image
"""
folder = Paths().md_mixes_img_path
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
@api.get("/mix/small/<imgpath>")
def send_sm_mix_image(path: ImagePath):
"""
Get small mix image
"""
folder = Paths().sm_mixes_img_path
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
+174
View File
@@ -0,0 +1,174 @@
import json
import logging
from flask_openapi3 import APIBlueprint, Tag
from pydantic import Field
from swingmusic.api.apischemas import TrackHashSchema
# DragonflyDB integration for lyrics caching
from swingmusic.db.dragonfly_client import get_dragonfly_client
from swingmusic.lib.lyrics import (
Lyrics as Lyrics_class,
)
from swingmusic.lib.lyrics import (
get_lyrics_file,
get_lyrics_from_duplicates,
get_lyrics_from_tags,
)
from swingmusic.plugins.lyrics import Lyrics
from swingmusic.store.tracks import TrackStore
logger = logging.getLogger(__name__)
bp_tag = Tag(name="Lyrics", description="Get lyrics")
api = APIBlueprint("lyrics", __name__, url_prefix="/lyrics", abp_tags=[bp_tag])
class SendLyricsBody(TrackHashSchema):
filepath: str = Field(description="The path to the file")
@api.post("")
def send_lyrics(body: SendLyricsBody):
"""
Returns the lyrics for a track
"""
# 1. try to get lyrics by .lrc / .elrc file
# 2. try to get lyrics by extra key
# 3. try to get by duplicates
# 4. iter plugins
filepath = body.filepath
trackhash = body.trackhash
# Try DragonflyDB cache first
cache = get_dragonfly_client()
cache_key = f"lyrics:{trackhash}"
if cache.is_available():
try:
cached = cache.get(cache_key)
if cached:
logger.debug(f"Cache hit for lyrics {trackhash}")
return json.loads(cached)
except Exception:
pass # Cache miss is fine
# get copyright first
copyright = ""
if entry := TrackStore.trackhashmap.get(trackhash, None):
for track in entry.tracks:
copyright = track.copyright
if copyright:
break
lyrics = get_lyrics_file(filepath)
if not lyrics:
lyrics = get_lyrics_from_tags(trackhash) # type: ignore
if not lyrics:
lyrics = get_lyrics_from_duplicates(filepath, trackhash)
# check lyrics plugins
if not lyrics:
try:
# Get track metadata for plugin search
entry = TrackStore.trackhashmap.get(trackhash, None)
if entry and len(entry.tracks) > 0:
track = entry.tracks[0] # Use first track for metadata
title = getattr(track, "title", "") or ""
artist = ""
if hasattr(track, "artists") and track.artists:
artist = (
track.artists[0].name
if hasattr(track.artists[0], "name")
else str(track.artists[0])
)
album = ""
if hasattr(track, "album") and track.album:
album = (
track.album.name
if hasattr(track.album, "name")
else str(track.album)
)
# Only proceed if we have basic metadata
if title and artist:
# Initialize lyrics plugin
lyrics_plugin = Lyrics()
if lyrics_plugin.enabled:
# LRCLIB-first metadata retrieval with provider fallback.
lrc_content = lyrics_plugin.download_lyrics_by_metadata(
title=title,
artist=artist,
path=filepath,
album=album,
)
# Fallback to provider search result track IDs when metadata fetch fails.
if not lrc_content:
search_results = (
lyrics_plugin.search_lyrics_by_title_and_artist(
title, artist
)
)
if search_results and len(search_results) > 0:
perfect_match = search_results[0]
if album:
for result in search_results:
result_title = result.get("title", "").lower()
result_album = result.get("album", "").lower()
if (
result_title == title.lower()
and result_album == album.lower()
):
perfect_match = result
break
track_id = perfect_match.get("track_id")
if track_id:
lrc_content = lyrics_plugin.download_lyrics(
track_id, filepath
)
if lrc_content and len(lrc_content.strip()) > 0:
lyrics = Lyrics_class(lrc_content)
except Exception:
# Log error but don't break the lyrics fetching process
# In production, you might want to log this error
pass
if not lyrics:
return {"error": "No lyrics found"}
if lyrics.is_synced:
text = lyrics.format_synced_lyrics()
else:
text = lyrics.format_unsynced_lyrics()
result = {"lyrics": text, "synced": lyrics.is_synced, "copyright": copyright}
# Cache lyrics for 24 hours (lyrics rarely change)
if cache.is_available():
import contextlib
with contextlib.suppress(Exception):
cache.set(cache_key, json.dumps(result), ex=86400)
return result, 200
@api.post("/check")
def check_lyrics(body: SendLyricsBody):
"""
Checks if lyrics file or tag exists for a track
"""
result = send_lyrics(body)
if "error" in result:
return {"exists": False}
else:
return {"exists": True}, 200
+322
View File
@@ -0,0 +1,322 @@
"""Mobile offline sync API."""
from __future__ import annotations
from typing import Any
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from swingmusic.services.mobile_offline_service import mobile_offline_service
from swingmusic.utils.auth import get_current_userid
mobile_offline_bp = Blueprint(
"mobile_offline", __name__, url_prefix="/api/mobile-offline"
)
def _ok(payload: dict[str, Any], status: int = 200):
return payload, status
def _fail(message: str, status: int = 400):
return {"error": message}, status
@mobile_offline_bp.post("/devices/register")
@jwt_required()
def register_device():
body = request.get_json(silent=True) or {}
userid = get_current_userid()
try:
device = mobile_offline_service.register_device(userid, body)
except Exception as error:
return _fail(f"Failed to register device: {error}", 500)
return _ok({"device": device}, 201)
@mobile_offline_bp.get("/devices")
@jwt_required()
def get_devices():
userid = get_current_userid()
devices = mobile_offline_service.list_devices(userid)
return _ok({"devices": devices, "total_count": len(devices)})
@mobile_offline_bp.get("/devices/<device_id>")
@jwt_required()
def get_device(device_id: str):
userid = get_current_userid()
device = mobile_offline_service.get_device(userid, device_id)
if not device:
return _fail("Device not found", 404)
return _ok({"device": device})
@mobile_offline_bp.put("/devices/<device_id>/settings")
@jwt_required()
def update_device_settings(device_id: str):
body = request.get_json(silent=True) or {}
userid = get_current_userid()
success = mobile_offline_service.update_device_settings(userid, device_id, body)
if not success:
return _fail("Device not found", 404)
return _ok({"success": True})
@mobile_offline_bp.get("/devices/<device_id>/offline-library")
@jwt_required()
def get_offline_library(device_id: str):
userid = get_current_userid()
try:
payload = mobile_offline_service.get_offline_library(userid, device_id)
except ValueError as error:
return _fail(str(error), 404)
except Exception as error:
return _fail(f"Failed to get offline library: {error}", 500)
return _ok({"offline_library": payload})
@mobile_offline_bp.post("/devices/<device_id>/add-tracks")
@jwt_required()
def add_tracks_to_offline(device_id: str):
body = request.get_json(silent=True) or {}
userid = get_current_userid()
track_items = body.get("tracks") or body.get("track_ids") or []
if not isinstance(track_items, list) or not track_items:
return _fail("tracks or track_ids must be a non-empty list", 400)
quality = body.get("quality")
collection = body.get("collection")
try:
queue_items = mobile_offline_service.add_to_offline_library(
userid,
device_id,
track_items,
quality=quality,
collection=collection,
)
except ValueError as error:
return _fail(str(error), 404)
except Exception as error:
return _fail(f"Failed to add tracks: {error}", 500)
return _ok(
{
"success": True,
"queue_items": queue_items,
"added_count": len(queue_items),
}
)
@mobile_offline_bp.post("/devices/<device_id>/sync-playlist/<playlist_id>")
@jwt_required()
def sync_playlist_offline(device_id: str, playlist_id: str):
body = request.get_json(silent=True) or {}
userid = get_current_userid()
try:
queue_items = mobile_offline_service.sync_playlist_offline(
userid,
device_id,
playlist_id,
quality=body.get("quality"),
)
except ValueError as error:
return _fail(str(error), 400)
except Exception as error:
return _fail(f"Failed to sync playlist: {error}", 500)
return _ok(
{"success": True, "queue_items": queue_items, "added_count": len(queue_items)}
)
@mobile_offline_bp.post("/devices/<device_id>/sync-collection")
@jwt_required()
def sync_collection_offline(device_id: str):
body = request.get_json(silent=True) or {}
userid = get_current_userid()
collection_type = str(body.get("collection_type") or "").strip().lower()
collection_id = str(body.get("collection_id") or "").strip()
quality = body.get("quality")
if collection_type not in {"album", "artist", "playlist"}:
return _fail("collection_type must be one of: album, artist, playlist", 400)
if not collection_id:
return _fail("collection_id is required", 400)
trackhashes = mobile_offline_service.tracks_for_collection(
collection_type=collection_type,
collection_id=collection_id,
)
if not trackhashes:
return _fail("No tracks found for collection", 404)
try:
queue_items = mobile_offline_service.add_to_offline_library(
userid,
device_id,
trackhashes,
quality=quality,
collection=f"{collection_type}:{collection_id}",
)
except ValueError as error:
return _fail(str(error), 404)
except Exception as error:
return _fail(f"Failed to sync collection: {error}", 500)
return _ok(
{"success": True, "queue_items": queue_items, "added_count": len(queue_items)}
)
@mobile_offline_bp.post("/devices/<device_id>/remove-tracks")
@jwt_required()
def remove_tracks_from_offline(device_id: str):
body = request.get_json(silent=True) or {}
userid = get_current_userid()
trackhashes = body.get("trackhashes") or body.get("track_ids") or []
if not isinstance(trackhashes, list) or not trackhashes:
return _fail("trackhashes or track_ids must be a non-empty list", 400)
success = mobile_offline_service.remove_from_offline_library(
userid, device_id, trackhashes
)
if not success:
return _fail("Device not found", 404)
return _ok({"success": True, "removed_count": len(trackhashes)})
@mobile_offline_bp.get("/devices/<device_id>/sync-progress")
@jwt_required()
def get_sync_progress(device_id: str):
userid = get_current_userid()
try:
progress = mobile_offline_service.get_sync_progress(userid, device_id)
except ValueError as error:
return _fail(str(error), 404)
except Exception as error:
return _fail(f"Failed to fetch sync progress: {error}", 500)
return _ok({"sync_progress": progress})
@mobile_offline_bp.post("/devices/<device_id>/force-sync")
@jwt_required()
def force_sync_now(device_id: str):
userid = get_current_userid()
success = mobile_offline_service.force_sync_now(userid, device_id)
if not success:
return _fail("Device not found", 404)
return _ok({"success": True})
@mobile_offline_bp.get("/devices/<device_id>/storage-info")
@jwt_required()
def get_storage_info(device_id: str):
userid = get_current_userid()
try:
usage = mobile_offline_service.get_storage_usage(userid, device_id)
except ValueError as error:
return _fail(str(error), 404)
except Exception as error:
return _fail(f"Failed to get storage info: {error}", 500)
usage_percentage = 0.0
if usage.total_capacity > 0:
usage_percentage = round((usage.used_space / usage.total_capacity) * 100.0, 2)
return _ok(
{
"storage_info": {
"total_capacity": usage.total_capacity,
"used_space": usage.used_space,
"available_space": usage.available_space,
"usage_percentage": usage_percentage,
"offline_tracks_count": usage.offline_tracks_count,
"offline_tracks_size": usage.offline_tracks_size,
"other_data_size": usage.other_data_size,
"quality_breakdown": usage.quality_breakdown,
"needs_cleanup": usage_percentage >= 90.0,
}
}
)
@mobile_offline_bp.post("/devices/<device_id>/cleanup")
@jwt_required()
def cleanup_storage(device_id: str):
body = request.get_json(silent=True) or {}
userid = get_current_userid()
strategy = str(body.get("strategy") or "least_played")
if strategy not in {"least_played", "oldest", "all"}:
return _fail("strategy must be one of: least_played, oldest, all", 400)
free_space_bytes = int(body.get("free_space_bytes") or 0)
freed = mobile_offline_service.cleanup_device_content(
userid,
device_id,
strategy=strategy,
free_space_bytes=free_space_bytes,
)
return _ok({"success": True, "freed_space": freed, "strategy": strategy})
@mobile_offline_bp.post("/devices/<device_id>/events/batch")
@jwt_required()
def append_events(device_id: str):
body = request.get_json(silent=True) or {}
userid = get_current_userid()
events = body.get("events")
if not isinstance(events, list):
return _fail("events must be a list", 400)
try:
result = mobile_offline_service.append_events(userid, device_id, events)
except ValueError as error:
return _fail(str(error), 404)
except Exception as error:
return _fail(f"Failed to append events: {error}", 500)
mark_synced = body.get("mark_synced")
if isinstance(mark_synced, list):
mobile_offline_service.mark_events_synced(userid, device_id, mark_synced)
return _ok({"success": True, **result})
@mobile_offline_bp.post("/devices/<device_id>/events/mark-synced")
@jwt_required()
def mark_events_synced(device_id: str):
body = request.get_json(silent=True) or {}
userid = get_current_userid()
event_ids = body.get("event_ids")
if event_ids is not None and not isinstance(event_ids, list):
return _fail("event_ids must be a list", 400)
updated = mobile_offline_service.mark_events_synced(userid, device_id, event_ids)
return _ok({"success": True, "updated": updated})
@mobile_offline_bp.get("/quality-presets")
@jwt_required()
def get_quality_presets():
return _ok({"quality_presets": mobile_offline_service.quality_presets()})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,514 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request
fallback_ux_bp = Blueprint("fallback_ux", __name__, url_prefix="/api/ux")
fallback_updates_bp = Blueprint("fallback_updates", __name__, url_prefix="/api/updates")
fallback_audio_quality_bp = Blueprint(
"fallback_audio_quality", __name__, url_prefix="/api/audio-quality"
)
fallback_recap_bp = Blueprint("fallback_recap", __name__, url_prefix="/api/recap")
fallback_settings_bp = Blueprint(
"fallback_settings", __name__, url_prefix="/api/settings"
)
DEFAULT_AUDIO_SETTINGS = {
"streaming_quality": "high",
"adaptive_quality": True,
"network_aware_quality": True,
"device_specific_quality": True,
"download_format": "flac",
"download_sample_rate": "44.1kHz",
"download_bit_depth": "16bit",
"enable_loudness_normalization": True,
"target_loudness": -14.0,
"enable_adaptive_eq": False,
"enable_spatial_audio_processing": False,
"spatial_audio_format": "stereo",
"enable_crossfade": False,
"crossfade_duration": 2.0,
"enable_gapless_playback": True,
"enable_replaygain": True,
}
DEFAULT_UPDATE_SETTINGS = {
"enableArtistMonitoring": False,
"autoDownloadFavorites": False,
"enableNotifications": False,
"checkFrequency": "daily",
"qualityPreference": "flac",
"excludeExplicit": False,
}
DEFAULT_UD_SETTINGS = {
"defaultQuality": "high",
"autoAddToLibrary": True,
"maxConcurrentDownloads": 3,
}
def _disabled_payload(feature: str, **payload):
return {"enabled": False, "feature": feature, **payload}
@fallback_ux_bp.get("/search/suggestions")
def fallback_ux_search_suggestions():
query = request.args.get("q", "")
context = request.args.get("context", "general")
return jsonify(
_disabled_payload(
"advanced_ux",
suggestions=[],
query=query,
context=context,
total_count=0,
)
)
@fallback_ux_bp.get("/discovery/recommendations")
def fallback_ux_discovery():
recommendation_type = request.args.get("type", "mixed")
return jsonify(
_disabled_payload(
"advanced_ux",
recommendations=[],
type=recommendation_type,
total_count=0,
)
)
@fallback_ux_bp.get("/contextual/suggestions")
def fallback_ux_contextual():
track_id = request.args.get("track_id")
context_type = request.args.get("context_type", "similar")
return jsonify(
_disabled_payload(
"advanced_ux",
suggestions=[],
track_id=track_id,
context_type=context_type,
total_count=0,
)
)
@fallback_ux_bp.get("/download/suggestions")
def fallback_ux_download_suggestions():
query = request.args.get("q", "")
return jsonify(
_disabled_payload(
"advanced_ux",
suggestions=[],
query=query,
total_count=0,
)
)
@fallback_ux_bp.get("/search/filters")
def fallback_ux_filters():
return jsonify(_disabled_payload("advanced_ux", filters=[], total_count=0))
@fallback_ux_bp.post("/behavior/track")
def fallback_ux_track_behavior():
return jsonify(
_disabled_payload("advanced_ux", message="Behavior tracking skipped")
)
@fallback_ux_bp.get("/behavior/profile")
def fallback_ux_behavior_profile():
profile = {
"user_id": None,
"favorite_genres": [],
"favorite_artists": [],
"listening_patterns": {},
"download_preferences": {},
"interaction_patterns": {},
"last_updated": None,
"search_history_count": 0,
"recent_searches": [],
}
return jsonify(_disabled_payload("advanced_ux", profile=profile))
@fallback_ux_bp.get("/trending/content")
def fallback_ux_trending():
return jsonify(
_disabled_payload(
"advanced_ux",
trending=[],
type=request.args.get("type", "mixed"),
timeframe=request.args.get("timeframe", "week"),
total_count=0,
)
)
@fallback_ux_bp.post("/search/advanced")
def fallback_ux_advanced_search():
payload = request.get_json(silent=True) or {}
return jsonify(
_disabled_payload(
"advanced_ux",
query=payload.get("query", ""),
results={
"tracks": [],
"albums": [],
"artists": [],
"playlists": [],
},
)
)
@fallback_ux_bp.get("/suggestions/quick")
def fallback_ux_quick_suggestions():
return jsonify(
_disabled_payload(
"advanced_ux",
suggestions=[],
type=request.args.get("type", "search"),
total_count=0,
)
)
@fallback_ux_bp.get("/personalization/preferences")
def fallback_ux_get_preferences():
return jsonify(
_disabled_payload(
"advanced_ux",
preferences={"enable_personalization": False},
)
)
@fallback_ux_bp.put("/personalization/preferences")
def fallback_ux_update_preferences():
payload = request.get_json(silent=True) or {}
return jsonify(
_disabled_payload(
"advanced_ux",
message="Preferences saved in fallback mode",
preferences=payload,
)
)
@fallback_updates_bp.get("/stats")
def fallback_updates_stats():
stats = {
"followedArtists": 0,
"newReleases": 0,
"pendingDownloads": 0,
"unreadNotifications": 0,
}
return jsonify(_disabled_payload("update_tracking", stats=stats))
@fallback_updates_bp.get("/recent")
def fallback_updates_recent():
limit = request.args.get("limit", 20, type=int)
offset = request.args.get("offset", 0, type=int)
return jsonify(
_disabled_payload(
"update_tracking",
updates=[],
limit=limit,
offset=offset,
total=0,
)
)
@fallback_updates_bp.get("/followed-artists")
def fallback_updates_followed_artists():
return jsonify(
_disabled_payload(
"update_tracking",
artists=[],
limit=50,
offset=0,
total=0,
)
)
@fallback_updates_bp.get("/settings")
def fallback_updates_get_settings():
return jsonify(_disabled_payload("update_tracking", **DEFAULT_UPDATE_SETTINGS))
@fallback_updates_bp.post("/settings")
def fallback_updates_set_settings():
payload = request.get_json(silent=True) or {}
merged = {**DEFAULT_UPDATE_SETTINGS, **payload}
return jsonify(
_disabled_payload(
"update_tracking",
message="Settings saved in fallback mode",
settings=merged,
)
)
@fallback_updates_bp.get("/search/artists")
def fallback_updates_search_artists():
return jsonify(
_disabled_payload(
"update_tracking",
artists=[],
query=request.args.get("q", ""),
)
)
@fallback_updates_bp.post("/follow-artist")
def fallback_updates_follow_artist():
payload = request.get_json(silent=True) or {}
return jsonify(
_disabled_payload(
"update_tracking",
message="Artist follow stored in fallback mode",
artist_id=payload.get("artist_id"),
)
)
@fallback_updates_bp.post("/unfollow-artist")
def fallback_updates_unfollow_artist():
payload = request.get_json(silent=True) or {}
return jsonify(
_disabled_payload(
"update_tracking",
message="Artist unfollow stored in fallback mode",
artist_id=payload.get("artist_id"),
)
)
@fallback_updates_bp.get("/artist/<artist_id>/follow-status")
def fallback_updates_follow_status(artist_id: str):
return jsonify(
_disabled_payload(
"update_tracking",
is_following=False,
artist_id=artist_id,
follow_level="followed",
auto_download_new_releases=False,
preferred_quality="flac",
)
)
@fallback_updates_bp.post("/artist/<artist_id>")
def fallback_updates_update_artist(artist_id: str):
payload = request.get_json(silent=True) or {}
return jsonify(
_disabled_payload(
"update_tracking",
message="Artist preferences saved in fallback mode",
artist_id=artist_id,
settings=payload,
)
)
@fallback_updates_bp.post("/auto-download/<release_id>")
def fallback_updates_auto_download(release_id: str):
return jsonify(
_disabled_payload(
"update_tracking",
message="Download queued in fallback mode",
release_id=release_id,
)
)
@fallback_updates_bp.post("/release/<release_id>/mark-read")
def fallback_updates_mark_read(release_id: str):
return jsonify(
_disabled_payload(
"update_tracking",
message="Marked as read",
release_id=release_id,
)
)
@fallback_updates_bp.post("/notifications/mark-all-read")
def fallback_updates_mark_all_read():
return jsonify(
_disabled_payload(
"update_tracking",
message="All notifications marked as read",
)
)
@fallback_updates_bp.get("/export/followed-artists")
def fallback_updates_export_followed_artists():
return jsonify(_disabled_payload("update_tracking", followed_artists=[]))
@fallback_audio_quality_bp.get("/settings")
def fallback_audio_get_settings():
return jsonify(_disabled_payload("audio_quality", settings=DEFAULT_AUDIO_SETTINGS))
@fallback_audio_quality_bp.post("/settings")
def fallback_audio_set_settings():
payload = request.get_json(silent=True) or {}
merged = {**DEFAULT_AUDIO_SETTINGS, **payload}
return jsonify(
_disabled_payload(
"audio_quality",
message="Audio quality settings saved in fallback mode",
settings=merged,
)
)
@fallback_audio_quality_bp.get("/network/status")
def fallback_audio_network_status():
return jsonify(
_disabled_payload(
"audio_quality",
network_status={"speed": 0, "quality": "unknown"},
)
)
@fallback_audio_quality_bp.get("/device/info")
def fallback_audio_device_info():
return jsonify(
_disabled_payload(
"audio_quality",
device_info={"type": "unknown"},
)
)
@fallback_audio_quality_bp.post("/apply-preset")
def fallback_audio_apply_preset():
payload = request.get_json(silent=True) or {}
return jsonify(
_disabled_payload(
"audio_quality",
message="Preset applied in fallback mode",
preset_name=payload.get("preset_name"),
settings=DEFAULT_AUDIO_SETTINGS,
)
)
@fallback_recap_bp.get("/available-years")
def fallback_recap_available_years():
return jsonify(_disabled_payload("recap", available_years=[], total_recaps=0))
@fallback_recap_bp.get("/summary/<int:year>")
def fallback_recap_summary(year: int):
return jsonify(_disabled_payload("recap", recap=None, year=year))
@fallback_recap_bp.get("/details/<int:year>")
def fallback_recap_details(year: int):
return jsonify(_disabled_payload("recap", recap=None, year=year))
@fallback_recap_bp.post("/generate/<int:year>")
def fallback_recap_generate(year: int):
return jsonify(
_disabled_payload(
"recap",
message="Recap generation is unavailable",
year=year,
)
)
@fallback_recap_bp.post("/video/<int:year>")
def fallback_recap_video(year: int):
return jsonify(
_disabled_payload(
"recap",
message="Recap video generation is unavailable",
year=year,
)
)
@fallback_recap_bp.post("/share/<int:year>")
def fallback_recap_share(year: int):
return jsonify(
_disabled_payload(
"recap",
message="Share links are unavailable",
year=year,
share_url=None,
)
)
@fallback_recap_bp.get("/shared/<token>")
def fallback_recap_shared(token: str):
return jsonify(_disabled_payload("recap", recap=None, token=token))
@fallback_recap_bp.get("/compare/<int:year1>/<int:year2>")
def fallback_recap_compare(year1: int, year2: int):
return jsonify(_disabled_payload("recap", comparison=None, years=[year1, year2]))
@fallback_settings_bp.get("/universal-downloader")
def fallback_universal_downloader_get():
return jsonify(
_disabled_payload(
"universal_downloader_settings",
success=True,
settings=DEFAULT_UD_SETTINGS,
)
)
@fallback_settings_bp.post("/universal-downloader")
def fallback_universal_downloader_post():
payload = request.get_json(silent=True) or {}
merged = {**DEFAULT_UD_SETTINGS, **payload}
return jsonify(
_disabled_payload(
"universal_downloader_settings",
success=True,
settings=merged,
message="Settings saved in fallback mode",
)
)
def _has_route(app, route: str) -> bool:
return any(rule.rule == route for rule in app.url_map.iter_rules())
def register_optional_feature_fallbacks(app):
if not _has_route(app, "/api/ux/search/suggestions"):
app.register_blueprint(fallback_ux_bp)
if not _has_route(app, "/api/updates/stats"):
app.register_blueprint(fallback_updates_bp)
if not _has_route(app, "/api/audio-quality/settings"):
app.register_blueprint(fallback_audio_quality_bp)
if not _has_route(app, "/api/recap/available-years"):
app.register_blueprint(fallback_recap_bp)
if not _has_route(app, "/api/settings/universal-downloader"):
app.register_blueprint(fallback_settings_bp)
+546
View File
@@ -0,0 +1,546 @@
"""
All playlist-related routes.
"""
import json
import logging
import pathlib
from datetime import datetime
from typing import Any
from flask_openapi3 import APIBlueprint, Tag
from flask_openapi3 import FileStorage as _FileStorage
from PIL import Image, UnidentifiedImageError
from pydantic import BaseModel, Field, GetCoreSchemaHandler
from pydantic_core import core_schema
from swingmusic import models
from swingmusic.api.apischemas import GenericLimitSchema
# DragonflyDB integration for playlist caching
from swingmusic.db.dragonfly_client import get_dragonfly_client
from swingmusic.db.userdata import PlaylistTable
from swingmusic.lib import playlistlib
from swingmusic.lib.albumslib import sort_by_track_no
from swingmusic.lib.home.recentlyadded import get_recently_added_playlist
from swingmusic.lib.home.recentlyplayed import get_recently_played_playlist
from swingmusic.lib.sortlib import sort_tracks
from swingmusic.models.playlist import Playlist
from swingmusic.serializers.playlist import serialize_for_card
from swingmusic.serializers.track import serialize_tracks
from swingmusic.services.user_library_scope import (
filter_trackhashes_for_user,
get_available_trackhashes,
)
from swingmusic.settings import Paths
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.dates import create_new_date, date_string_to_time_passed
logger = logging.getLogger(__name__)
tag = Tag(name="Playlists", description="Get and manage playlists")
api = APIBlueprint("playlists", __name__, url_prefix="/playlists", abp_tags=[tag])
def insert_playlist(name: str, image: str = None):
playlist = {
"image": image,
"last_updated": create_new_date(),
"name": name,
"trackhashes": [],
"settings": {
"has_gif": False,
"banner_pos": 50,
"square_img": bool(image),
"pinned": False,
},
}
rowid = PlaylistTable.add_one(playlist)
if rowid:
playlist["id"] = rowid
return Playlist(**playlist)
return None
def get_path_trackhashes(
path: str, tracksortby: str, reverse: bool, userid: int | None = None
):
"""
Returns a list of trackhashes in a folder.
"""
tracks = TrackStore.get_tracks_in_path(path)
tracks = sort_tracks(tracks, key=tracksortby, reverse=reverse)
return filter_trackhashes_for_user([t.trackhash for t in tracks], userid=userid)
def get_album_trackhashes(albumhash: str, userid: int | None = None):
"""
Returns a list of trackhashes in an album.
"""
tracks = TrackStore.get_tracks_by_albumhash(albumhash)
tracks = sort_by_track_no(tracks)
return filter_trackhashes_for_user([t.trackhash for t in tracks], userid=userid)
def get_artist_trackhashes(artisthash: str, userid: int | None = None):
"""
Returns a list of trackhashes for an artist.
"""
tracks = TrackStore.get_tracks_by_artisthash(artisthash)
tracks = sort_tracks(tracks, key="playcount", reverse=True)
return filter_trackhashes_for_user([t.trackhash for t in tracks], userid=userid)
def format_custom_playlist(playlist: models.Playlist, tracks: list[models.Track]):
playlist.duration = sum(t.duration for t in tracks)
playlist.count = len(tracks)
return {
"info": serialize_for_card(playlist),
"tracks": serialize_tracks(tracks),
}
class SendAllPlaylistsQuery(BaseModel):
no_images: bool = Field(False, description="Whether to include images")
@api.get("")
def send_all_playlists(query: SendAllPlaylistsQuery):
"""
Gets all the playlists.
"""
playlists = PlaylistTable.get_all()
playlists = sorted(
playlists,
key=lambda p: datetime.strptime(p.last_updated, "%Y-%m-%d %H:%M:%S"),
reverse=True,
)
available_trackhashes = get_available_trackhashes(get_current_userid())
for playlist in playlists:
visible_trackhashes = [
trackhash
for trackhash in playlist.trackhashes
if trackhash in available_trackhashes
]
playlist.count = len(visible_trackhashes)
if not playlist.has_image:
playlist.images = playlistlib.get_first_4_images(
trackhashes=visible_trackhashes
)
playlist.clear_lists()
# playlists.sort(
# key=lambda p: datetime.strptime(p.last_updated, "%Y-%m-%d %H:%M:%S"),
# reverse=True,
# )
return {"data": playlists}
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.
"""
exists = PlaylistTable.check_exists_by_name(body.name)
if exists:
return {"error": "Playlist already exists"}, 409
playlist = insert_playlist(body.name)
if playlist is None:
return {"error": "Playlist could not be created"}, 500
return {"playlist": playlist}, 201
class PlaylistIDPath(BaseModel):
# INFO: playlistid string examples: "recentlyadded"
playlistid: 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"],
)
sortoptions: dict = Field(
default=None,
description="The sort options for the tracks",
)
itemhash: str = Field(..., description="The hash of the item to add")
@api.post("/<playlistid>/add")
def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody):
"""
Add to playlist.
If itemtype is not "tracks", itemhash is expected to be a folder, album or artist hash.
"""
itemtype = body.itemtype
itemhash = body.itemhash
playlist_id = int(path.playlistid)
sortoptions = body.sortoptions
userid = get_current_userid()
if itemtype == "tracks":
trackhashes = itemhash.split(",")
trackhashes = filter_trackhashes_for_user(trackhashes, userid=userid)
if len(trackhashes) == 1 and trackhashes[0] in PlaylistTable.get_trackhashes(
playlist_id
):
return {"msg": "Track already exists in playlist"}, 409
elif itemtype == "folder":
trackhashes = get_path_trackhashes(
itemhash,
sortoptions.get("tracksortby") or "default",
sortoptions.get("tracksortreverse") or False,
userid=userid,
)
elif itemtype == "album":
trackhashes = get_album_trackhashes(itemhash, userid=userid)
elif itemtype == "artist":
trackhashes = get_artist_trackhashes(itemhash, userid=userid)
else:
trackhashes = []
PlaylistTable.append_to_playlist(playlist_id, trackhashes)
return {"msg": "Done"}, 200
class GetPlaylistQuery(GenericLimitSchema):
no_tracks: bool = Field(False, description="Whether to include tracks")
start: int = Field(0, description="The start index of the tracks")
@api.get("/<playlistid>")
def get_playlist(path: PlaylistIDPath, query: GetPlaylistQuery):
"""
Get playlist by id
"""
no_tracks = query.no_tracks
playlistid = path.playlistid
custom_playlists = [
{"name": "recentlyadded", "handler": get_recently_added_playlist},
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
]
is_custom = playlistid in {p["name"] for p in custom_playlists}
if is_custom:
if query.start != 0:
return {
"tracks": [],
}
handler = next(
p["handler"] for p in custom_playlists if p["name"] == playlistid
)
playlist, tracks = handler()
return format_custom_playlist(playlist, tracks)
# Try DragonflyDB cache first for regular playlists
cache = get_dragonfly_client()
cache_key = f"playlists:{playlistid}:{query.start}:{query.limit}"
if cache.is_available():
try:
cached = cache.get(cache_key)
if cached:
result = json.loads(cached)
logger.debug(f"Cache hit for playlist {playlistid}")
return result
except Exception:
pass # Cache miss is fine
playlist = PlaylistTable.get_by_id(int(playlistid))
if playlist is None:
return {"msg": "Playlist not found"}, 404
if query.limit == -1:
query.limit = len(playlist.trackhashes) - 1
available_trackhashes = get_available_trackhashes(get_current_userid())
scoped_trackhashes = [
trackhash
for trackhash in playlist.trackhashes
if trackhash in available_trackhashes
]
tracks = TrackStore.get_tracks_by_trackhashes(
scoped_trackhashes[query.start : query.start + query.limit]
)
duration = sum(t.duration for t in tracks)
playlist._last_updated = date_string_to_time_passed(playlist.last_updated)
playlist.duration = duration
playlist.images = playlistlib.get_first_4_images(tracks)
playlist.clear_lists()
result = {
"info": playlist,
"tracks": serialize_tracks(tracks) if not no_tracks else [],
}
# Cache the result for 5 minutes
if cache.is_available():
import contextlib
with contextlib.suppress(Exception):
cache.set(cache_key, json.dumps(result, default=str), ex=300)
return result
class FileStorage(_FileStorage):
@classmethod
def __get_pydantic_core_schema__(
cls, _source: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
return core_schema.with_info_plain_validator_function(cls.validate)
class UpdatePlaylistForm(BaseModel):
image: FileStorage = Field(description="The image file")
name: str = Field(..., description="The name of the playlist")
settings: str = Field(
...,
description="The settings of the playlist",
json_schema_extra={
"example": '{"has_gif": false, "banner_pos": 50, "square_img": false, "pinned": false}'
},
)
@api.put("/<playlistid>/update", methods=["PUT"])
def update_playlist_info(path: PlaylistIDPath, form: UpdatePlaylistForm):
"""
Update playlist
"""
playlistid = path.playlistid
db_playlist = PlaylistTable.get_by_id(playlistid)
if db_playlist is None:
return {"error": "Playlist not found"}, 404
image = form.image
if form.image:
image = form.image
settings = json.loads(form.settings)
settings["has_gif"] = False
playlist = {
"id": int(playlistid),
"image": db_playlist.image,
"last_updated": create_new_date(),
"name": str(form.name).strip(),
"settings": settings,
}
if image:
try:
pil_image = Image.open(image)
content_type = image.content_type
playlist["image"] = playlistlib.save_p_image(
pil_image, playlistid, content_type
)
if image.content_type == "image/gif":
playlist["settings"]["has_gif"] = True
except UnidentifiedImageError:
return {"error": "Failed: Invalid image"}, 400
p_tuple = (*playlist.values(),)
PlaylistTable.update_one(playlistid, playlist)
playlistlib.cleanup_playlist_images()
playlist = models.Playlist(*p_tuple)
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
return {
"data": playlist,
}
@api.post("/<playlistid>/pin_unpin")
def pin_unpin_playlist(path: PlaylistIDPath):
"""
Pin playlist.
"""
playlist = PlaylistTable.get_by_id(path.playlistid)
if playlist is None:
return {"error": "Playlist not found"}, 404
settings = playlist.settings
try:
settings["pinned"] = not settings["pinned"]
except KeyError:
settings["pinned"] = True
PlaylistTable.update_settings(path.playlistid, settings)
return {"msg": "Done"}, 200
@api.delete("/<playlistid>/remove-img")
def remove_playlist_image(path: PlaylistIDPath):
"""
Clear playlist image.
"""
playlist = PlaylistTable.get_by_id(path.playlistid)
if playlist is None:
return {"error": "Playlist not found"}, 404
PlaylistTable.remove_image(path.playlistid)
playlist.image = None
playlist.thumb = None
playlist.settings["has_gif"] = False
playlist.has_image = False
available_trackhashes = get_available_trackhashes(get_current_userid())
visible_trackhashes = [
trackhash
for trackhash in playlist.trackhashes
if trackhash in available_trackhashes
]
playlist.images = playlistlib.get_first_4_images(trackhashes=visible_trackhashes)
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
return {"playlist": playlist}, 200
@api.delete("/<playlistid>/delete", methods=["DELETE"])
def remove_playlist(path: PlaylistIDPath):
"""
Delete playlist
"""
PlaylistTable.remove_one(path.playlistid)
playlistlib.cleanup_playlist_images()
return {"msg": "Done"}, 200
class RemoveTracksFromPlaylistBody(BaseModel):
tracks: list[dict] = Field(..., description="A list of trackhashes to remove")
@api.post("/<playlistid>/remove-tracks")
def remove_tracks_from_playlist(
path: PlaylistIDPath, body: RemoveTracksFromPlaylistBody
):
"""
Remove track from playlist
"""
# A track looks like this:
# {
# trackhash: str;
# index: int;
# }
PlaylistTable.remove_from_playlist(path.playlistid, body.tracks)
return {"msg": "Done"}, 200
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")
sortoptions: dict = Field(
default={},
description="The sort options for the tracks",
)
@api.post("/save-item")
def save_item_as_playlist(body: SavePlaylistAsItemBody):
"""
Save as playlist
Saves a track, album, artist or folder as a playlist
"""
itemtype = body.itemtype
playlist_name = body.playlist_name
itemhash = body.itemhash
sortoptions = body.sortoptions
userid = get_current_userid()
if PlaylistTable.check_exists_by_name(playlist_name):
return {"error": "Playlist already exists"}, 409
if itemtype == "tracks":
trackhashes = filter_trackhashes_for_user(itemhash.split(","), userid=userid)
elif itemtype == "folder":
trackhashes = get_path_trackhashes(
itemhash,
sortoptions.get("tracksortby") or "default",
sortoptions.get("tracksortreverse") or False,
userid=userid,
)
elif itemtype == "album":
trackhashes = get_album_trackhashes(itemhash, userid=userid)
elif itemtype == "artist":
trackhashes = get_artist_trackhashes(itemhash, userid=userid)
else:
trackhashes = []
if len(trackhashes) == 0:
return {"error": "No tracks founds"}, 404
image = (
itemhash + ".webp" if itemtype != "folder" and itemtype != "tracks" else None
)
playlist = insert_playlist(playlist_name, image)
if playlist is None:
return {"error": "Playlist could not be created"}, 500
# save image
if itemtype != "folder" and itemtype != "tracks":
filename = itemhash + ".webp"
base_path = (
Paths().lg_artist_img_path
if itemtype == "artist"
else Paths().lg_thumb_path()
)
img_path = pathlib.Path(base_path + "/" + filename)
if img_path.exists():
img = Image.open(img_path)
playlistlib.save_p_image(
img, str(playlist.id), "image/webp", filename=filename
)
PlaylistTable.append_to_playlist(playlist.id, trackhashes)
playlist.count = len(trackhashes)
images = playlistlib.get_first_4_images(trackhashes=trackhashes)
playlist.images = [img["image"] for img in images]
return {"playlist": playlist}, 201
+116
View File
@@ -0,0 +1,116 @@
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.api.auth import admin_required
from swingmusic.config import UserConfig
from swingmusic.db.userdata import PluginTable
from swingmusic.plugins.lastfm import LastFmPlugin
from swingmusic.utils.auth import get_current_userid
bp_tag = Tag(name="Plugins", description="Manage plugins")
api = APIBlueprint("plugins", __name__, url_prefix="/plugins", abp_tags=[bp_tag])
@api.get("/")
def get_all_plugins():
"""
List all plugins
"""
plugins = PluginTable.get_all()
return {"plugins": plugins}
class PluginBody(BaseModel):
plugin: str = Field(description="The plugin name", example="lyrics")
class PluginActivateBody(PluginBody):
active: bool = Field(
description="New plugin active state", example=False, default=False
)
@api.post("/setactive")
@admin_required()
def activate_deactivate_plugin(body: PluginActivateBody):
"""
Activate/Deactivate plugin
"""
name = body.plugin
if name == "lyrics_finder" and not body.active:
# Lyrics retrieval is production-required and cannot be disabled.
return {"error": "lyrics_finder is always enabled"}, 400
PluginTable.activate(name, body.active)
return {"message": "OK"}, 200
class PluginSettingsBody(PluginBody):
settings: dict = Field(
description="The new plugin settings", example={"key": "value"}
)
@api.post("/settings")
@admin_required()
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
if plugin == "lyrics_finder":
# Keep lyrics automation on regardless of client payload.
settings = {
**settings,
"auto_download": True,
"overide_unsynced": True,
}
PluginTable.activate(plugin, True)
PluginTable.update_settings(plugin, settings)
plugin = PluginTable.get_by_name(plugin)
return {"status": "success", "settings": plugin.settings}
class LastFmSessionBody(BaseModel):
token: str = Field(description="The token to use to create the session")
@api.post("/lastfm/session/create")
def create_lastfm_session(body: LastFmSessionBody):
"""
Create a Last.fm session
"""
if not body.token:
return {"error": "Missing token"}, 400
lastfm = LastFmPlugin(current_userid=get_current_userid())
session_key = lastfm.get_session_key(body.token)
if session_key:
config = UserConfig()
current_user = get_current_userid()
config.lastfmSessionKeys[str(current_user)] = session_key
config.lastfmSessionKeys = config.lastfmSessionKeys
return {"status": "success", "session_key": session_key}
@api.post("/lastfm/session/delete")
def delete_lastfm_session():
"""
Delete the Last.fm session
"""
config = UserConfig()
current_user = get_current_userid()
config.lastfmSessionKeys[str(current_user)] = ""
config.lastfmSessionKeys = config.lastfmSessionKeys
return {"status": "success"}
+73
View File
@@ -0,0 +1,73 @@
from flask_openapi3 import APIBlueprint, Tag
from pydantic import Field
from swingmusic.api.apischemas import TrackHashSchema
from swingmusic.lib.lyrics import Lyrics as Lyrics_class
from swingmusic.plugins.lyrics import Lyrics
from swingmusic.settings import Defaults
from swingmusic.utils.hashing import create_hash
bp_tag = Tag(name="Lyrics Plugin", description="Musixmatch lyrics plugin")
api = APIBlueprint(
"lyricsplugin", __name__, url_prefix="/plugins/lyrics", abp_tags=[bp_tag]
)
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",
)
@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:
return {"trackhash": trackhash, "lyrics": None}
perfect_match = data[0]
for track in data:
i_title = track["title"]
i_album = track["album"]
if create_hash(i_title) == create_hash(title) and create_hash(
i_album
) == create_hash(album):
perfect_match = track
track_id = perfect_match["track_id"]
lrc = finder.download_lyrics(track_id, filepath)
if lrc is not None:
lyrics = Lyrics_class(lrc)
if lyrics.is_synced:
formatted_lyrics = lyrics.format_synced_lyrics()
else:
formatted_lyrics = lyrics.format_unsynced_lyrics()
return {
"trackhash": trackhash,
"lyrics": formatted_lyrics,
"synced": lyrics.is_synced,
}, 200
return {"trackhash": trackhash, "lyrics": None, "synced": False}, 200
+107
View File
@@ -0,0 +1,107 @@
from typing import Literal
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.db.userdata import MixTable
from swingmusic.plugins.mixes import MixesPlugin
from swingmusic.store.homepage import HomepageStore
bp_tag = Tag(name="Mixes Plugin", description="Mixes plugin hehe")
api = APIBlueprint(
"mixesplugin", __name__, url_prefix="/plugins/mixes", abp_tags=[bp_tag]
)
class GetMixesBody(BaseModel):
mixtype: Literal["artists", "tracks"] = Field(description="The type of mix")
@api.get("/<mixtype>")
def get_artist_mixes(path: GetMixesBody):
srcmixes = MixTable.get_all(with_userid=True)
mixes = []
if path.mixtype == "artists":
mixes = [mix.to_dict(convert_timestamp=True) for mix in srcmixes]
elif path.mixtype == "tracks":
plugin = MixesPlugin()
for mix in srcmixes:
custom_mix = plugin.get_track_mix(mix)
if custom_mix:
mixes.append(custom_mix.to_dict(convert_timestamp=True))
seen_mixids = set()
# filter duplicates by trackshash
final_mixes = []
for mix in mixes:
# INFO: Ignore duplicates for artist mixes
if mix["id"] in seen_mixids and path.mixtype == "tracks":
continue
final_mixes.append(mix)
seen_mixids.add(mix["id"])
return final_mixes
class MixQuery(BaseModel):
mixid: str = Field(description="The mix id")
sourcehash: str = Field(description="The sourcehash of the mix")
@api.get("/")
def get_mix(query: MixQuery):
mixtype = ""
match query.mixid[0]:
case "a":
mixtype = "artist_mixes"
case "t":
mixtype = "custom_mixes"
case _:
return {"msg": "Invalid mix ID"}, 400
# INFO: Check if the mix is already in the homepage store
mix = HomepageStore.get_mix(mixtype, query.mixid)
if mix and mix["sourcehash"] == query.sourcehash:
return mix, 200
# INF0: Get the mix from the db
mix = MixTable.get_by_sourcehash(query.sourcehash)
if not mix:
return {"msg": "Mix not found"}, 404
if mixtype == "custom_mixes":
mix = MixesPlugin.get_track_mix(mix)
if not mix:
return {"msg": "Mix not found"}, 404
return mix.to_full_dict(), 200
class SaveMixRequest(BaseModel):
mixid: str = Field(description="The id of the mix")
type: str = Field(description="The type of mix")
sourcehash: str = Field(description="The sourcehash of the mix")
@api.post("/save")
def save_mix(body: SaveMixRequest):
mix_type = body.type
mix_sourcehash = body.sourcehash
if mix_type == "artist":
state = MixTable.save_artist_mix(mix_sourcehash)
elif mix_type == "track":
state = MixTable.save_track_mix(mix_sourcehash)
mix = HomepageStore.find_mix(body.mixid)
if mix:
mix.saved = state
return {"msg": "Mixes saved"}, 200
+141
View File
@@ -0,0 +1,141 @@
"""Year-in-review recap endpoints."""
from __future__ import annotations
import datetime as dt
from flask import Blueprint, jsonify, request
from swingmusic.services.recap_store import recap_store
from swingmusic.utils.auth import get_current_userid
recap_bp = Blueprint("recap", __name__, url_prefix="/api/recap")
def _user_id() -> int:
return int(get_current_userid())
def _error(message: str, status: int = 400):
return jsonify({"error": message, "message": message}), status
def _validate_year(year: int) -> bool:
now_year = dt.datetime.now(dt.UTC).year
return 2000 <= int(year) <= now_year + 1
@recap_bp.get("/available-years")
def available_years():
years = recap_store.get_available_years(_user_id())
return jsonify({"available_years": years, "total_recaps": len(years)})
@recap_bp.get("/summary/<int:year>")
def summary(year: int):
if not _validate_year(year):
return _error("Invalid year")
recap = recap_store.get_summary(_user_id(), year)
return jsonify({"year": year, "recap": recap})
@recap_bp.get("/details/<int:year>")
def details(year: int):
if not _validate_year(year):
return _error("Invalid year")
recap = recap_store.get_recap(_user_id(), year, generate_if_missing=False)
return jsonify({"year": year, "recap": recap})
@recap_bp.post("/generate/<int:year>")
def generate(year: int):
if not _validate_year(year):
return _error("Invalid year")
recap = recap_store.generate_recap(_user_id(), year)
if not recap:
return _error("No listening data available for this year", 404)
return jsonify(
{
"message": "Recap generated successfully",
"year": year,
"recap": recap,
}
)
@recap_bp.post("/video/<int:year>")
def generate_video(year: int):
if not _validate_year(year):
return _error("Invalid year")
recap = recap_store.get_recap(_user_id(), year, generate_if_missing=True)
if not recap:
return _error("No listening data available for this year", 404)
options = request.get_json(silent=True) or {}
return jsonify(
{
"message": "Video generation queued",
"year": year,
"video_status": "queued",
"options": options,
}
)
@recap_bp.post("/share/<int:year>")
def share(year: int):
if not _validate_year(year):
return _error("Invalid year")
payload = request.get_json(silent=True) or {}
include_personal_data = bool(
payload.get("includePersonalData", payload.get("include_personal_data", False))
)
try:
expires_in_days = int(
payload.get("expiresInDays", payload.get("expires_in_days", 30))
)
except (TypeError, ValueError):
expires_in_days = 30
share_data = recap_store.create_share_link(
user_id=_user_id(),
year=year,
include_personal_data=include_personal_data,
expires_in_days=expires_in_days,
)
if not share_data:
return _error("Unable to create share link", 404)
return jsonify(share_data)
@recap_bp.get("/shared/<token>")
def shared(token: str):
shared_recap = recap_store.get_shared_recap(token)
if not shared_recap:
return _error("Shared recap not found or expired", 404)
return jsonify(shared_recap)
@recap_bp.get("/compare/<int:year1>/<int:year2>")
def compare(year1: int, year2: int):
if not _validate_year(year1) or not _validate_year(year2):
return _error("Invalid year")
if year1 == year2:
return _error("Year values must be different")
comparison = recap_store.compare_years(_user_id(), year1, year2)
if not comparison:
return _error("Comparison unavailable for selected years", 404)
return jsonify({"years": [year1, year2], "comparison": comparison})
+50
View File
@@ -0,0 +1,50 @@
"""
Recently Played API endpoints.
"""
from flask_openapi3 import APIBlueprint, Tag
from swingmusic.api.apischemas import GenericLimitSchema
from swingmusic.services.recently_played_buffer import get_recently_played_buffer
from swingmusic.utils.auth import get_current_userid
tag = Tag(name="Recently Played", description="Recently played tracks")
api = APIBlueprint(
"recently_played", __name__, url_prefix="/recently-played", abp_tags=[tag]
)
class RecentlyPlayedQuery(GenericLimitSchema):
pass
@api.get("")
def get_recently_played(query: RecentlyPlayedQuery):
"""
Get recently played tracks for the current user.
Returns tracks from the DragonflyDB buffer for instant access.
"""
userid = get_current_userid()
limit = query.limit if query.limit > 0 else 20
buffer = get_recently_played_buffer()
tracks = buffer.get_recent_tracks(userid, limit=limit)
return {"tracks": tracks}
@api.delete("")
def clear_recently_played():
"""
Clear the recently played buffer for the current user.
"""
userid = get_current_userid()
buffer = get_recently_played_buffer()
success = buffer.clear_buffer(userid)
if success:
return {"success": True, "message": "Recently played history cleared"}
else:
return {"success": False, "message": "Failed to clear history"}, 500
+507
View File
@@ -0,0 +1,507 @@
import locale
import logging
from gettext import ngettext
from typing import Literal
import pendulum
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.api.apischemas import TrackHashSchema
from swingmusic.config import UserConfig
# DragonflyDB integration for real-time features
from swingmusic.db.dragonfly_extended_client import get_realtime_service
from swingmusic.db.userdata import FavoritesTable, ScrobbleTable
from swingmusic.lib.extras import get_extra_info
from swingmusic.lib.recipes.recents import RecentlyPlayed
from swingmusic.models.album import Album
from swingmusic.models.stats import StatItem
from swingmusic.models.track import Track
from swingmusic.plugins.lastfm import LastFmPlugin
from swingmusic.serializers.album import serialize_for_card as serialize_for_album_card
from swingmusic.serializers.artist import serialize_for_card
from swingmusic.serializers.track import serialize_track
from swingmusic.services.recently_played_buffer import get_recently_played_buffer
from swingmusic.services.user_library_scope import get_available_trackhashes
from swingmusic.settings import Defaults
from swingmusic.store.albums import AlbumStore
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.dates import (
get_date_range,
get_duration_in_seconds,
seconds_to_time_string,
)
from swingmusic.utils.stats import (
calculate_album_trend,
calculate_artist_trend,
calculate_new_albums,
calculate_new_artists,
calculate_scrobble_trend,
calculate_track_trend,
get_albums_in_period,
get_artists_in_period,
get_tracks_in_period,
)
logger = logging.getLogger(__name__)
bp_tag = Tag(name="Logger", description="Log item plays")
api = APIBlueprint("logger", __name__, url_prefix="/logger", abp_tags=[bp_tag])
class LogTrackBody(TrackHashSchema):
timestamp: int = Field(description="The timestamp of the track")
duration: int = Field(description="The duration of the track in seconds")
source: str = Field(
description="The play source of the track",
json_schema_extra={
"examples": [
f"al:{Defaults.API_ALBUMHASH}",
f"tr:{Defaults.API_TRACKHASH}",
f"ar:{Defaults.API_ARTISTHASH}",
]
},
)
def format_date(start: float, end: float):
return f"{pendulum.from_timestamp(start).format('MMM D, YYYY')} - {pendulum.from_timestamp(end).format('MMM D, YYYY')}"
@api.post("/track/log")
def log_track(body: LogTrackBody):
"""
Log a track play to the database.
"""
timestamp = body.timestamp
duration = body.duration
if not timestamp or duration < 5:
return {"msg": "Invalid entry."}, 400
trackentry = TrackStore.trackhashmap.get(body.trackhash)
if trackentry is None:
return {"msg": "Track not found."}, 404
scrobble_data = dict(body)
# REVIEW: Do we need to store the extra info in the database?
# OR .... can we just write it to the backup file on demand?
scrobble_data["extra"] = get_extra_info(body.trackhash, "track")
ScrobbleTable.add(scrobble_data)
# NOTE: Update the recently played homepage for this userid
RecentlyPlayed(userid=scrobble_data["userid"])
# Update play data on the in-memory stores
track = trackentry.tracks[0]
album = AlbumStore.albummap.get(track.albumhash)
if album:
album.increment_playcount(duration, timestamp)
for hash in track.artisthashes:
artist = ArtistStore.artistmap.get(hash)
if artist:
artist.increment_playcount(duration, timestamp)
trackentry.increment_playcount(duration, timestamp)
track = trackentry.tracks[0]
# Update DragonflyDB real-time features (non-blocking, fast)
realtime = get_realtime_service()
if realtime.playcount_cache.client.is_available():
try:
userid = get_current_userid()
# Increment global playcount for track
realtime.increment_playcount(body.trackhash)
# Increment user-specific playcount
realtime.increment_playcount(body.trackhash, userid=userid)
# Add to recently played list
realtime.add_to_recently_played(userid, body.trackhash)
logger.debug(f"Updated real-time play stats for track {body.trackhash}")
except Exception as e:
logger.debug(f"Failed to update real-time stats: {e}")
# Update recently played buffer for instant access
recently_played = get_recently_played_buffer()
if recently_played.client.is_available():
try:
userid = get_current_userid()
track = trackentry.tracks[0]
recently_played.add_track(
userid,
{
"trackhash": track.trackhash,
"title": track.title,
"artist": track.artists[0] if track.artists else "Unknown Artist",
"album": track.album,
"albumhash": track.albumhash,
"duration": track.duration,
"image": track.image,
},
)
except Exception as e:
logger.debug(f"Failed to update recently played buffer: {e}")
lastfm = LastFmPlugin(current_userid=get_current_userid())
if (
lastfm.enabled
and track.duration > 30
and body.duration >= min(track.duration / 2, 240)
# SEE: https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble
):
lastfm.scrobble(trackentry.tracks[0], timestamp)
return {"msg": "recorded"}, 201
class ChartItemsQuery(BaseModel):
duration: Literal["week", "month", "year", "alltime"] = Field(
"year",
description="Duration to fetch data for",
)
limit: int = Field(10, description="Number of top tracks to return")
order_by: Literal["playcount", "playduration"] = Field(
"playduration", description="Property to order by"
)
# SECTION: STATS
def get_help_text(
playcount: int, playduration: int, order_by: Literal["playcount", "playduration"]
):
"""
Get the help text given the playcount and playduration.
"""
if order_by == "playcount":
if playcount == 0:
return "unplayed"
return f"{playcount} play{'' if playcount == 1 else 's'}"
if order_by == "playduration":
return seconds_to_time_string(playduration)
# DISCLAIMER: Code beyond this point was partially written by Claude 3.5 Sonnet in Cursor.
# The stats functions are organized by type (tracks, artists, albums) and follow
# a consistent pattern for calculating trends and aggregating play data.
@api.get("/top-tracks")
def get_top_tracks(query: ChartItemsQuery):
"""
Get the top N tracks played within a given duration.
"""
start_time, end_time = get_date_range(query.duration)
previous_start_time = start_time - get_duration_in_seconds(query.duration)
current_period_tracks, current_period_scrobbles, duration = get_tracks_in_period(
start_time, end_time
)
previous_period_tracks, previous_period_scrobbles, _ = get_tracks_in_period(
previous_start_time, start_time
)
scrobble_trend = (
"rising"
if current_period_scrobbles > previous_period_scrobbles
else (
"falling"
if current_period_scrobbles < previous_period_scrobbles
else "stable"
)
)
sorted_tracks = sort_tracks(current_period_tracks, query.order_by)
top_tracks = sorted_tracks[: query.limit]
response = []
for track in top_tracks:
trend = calculate_track_trend(
track, current_period_tracks, previous_period_tracks
)
track = {
**serialize_track(track),
"trend": trend,
"help_text": get_help_text(
track.playcount, track.playduration, query.order_by
),
}
response.append(track)
return {
"tracks": response,
"scrobbles": {
"text": f"{current_period_scrobbles} total play{'' if current_period_scrobbles == 1 else 's'} ({seconds_to_time_string(duration)})",
"trend": scrobble_trend,
"dates": format_date(start_time, end_time),
},
}, 200
def sort_tracks(tracks: list[Track], order_by: Literal["playcount", "playduration"]):
return sorted(tracks, key=lambda x: getattr(x, order_by), reverse=True)
@api.get("/top-artists")
def get_top_artists(query: ChartItemsQuery):
"""
Get the top N artists played within a given duration.
"""
start_time, end_time = get_date_range(query.duration)
previous_start_time = start_time - get_duration_in_seconds(query.duration)
current_period_artists = get_artists_in_period(start_time, end_time)
previous_period_artists = get_artists_in_period(previous_start_time, start_time)
new_artists = calculate_new_artists(current_period_artists, start_time)
scrobble_trend = calculate_scrobble_trend(
len(current_period_artists), len(previous_period_artists)
)
sorted_artists = sort_artists(current_period_artists, query.order_by)
top_artists = sorted_artists[: query.limit]
response = []
for artist in top_artists:
trend = calculate_artist_trend(
artist, current_period_artists, previous_period_artists
)
db_artist = ArtistStore.get_artist_by_hash(artist["artisthash"])
if db_artist is None:
continue
artist = {
**serialize_for_card(db_artist),
"trend": trend,
"help_text": get_help_text(
artist["playcount"], artist["playduration"], query.order_by
),
"extra": {
"playcount": artist["playcount"],
},
}
response.append(artist)
return {
"artists": response,
"scrobbles": {
"text": f"{new_artists} {'new' if query.duration != 'alltime' else ''} {ngettext('artist', 'artists', new_artists)}",
"trend": scrobble_trend,
"dates": format_date(start_time, end_time),
},
}, 200
def sort_artists(artists, order_by):
return sorted(artists, key=lambda x: x[order_by], reverse=True)
@api.get("/top-albums")
def get_top_albums(query: ChartItemsQuery):
"""
Get the top N albums played within a given duration.
"""
start_time, end_time = get_date_range(query.duration)
previous_start_time = start_time - get_duration_in_seconds(query.duration)
current_period_albums = get_albums_in_period(start_time, end_time)
previous_period_albums = get_albums_in_period(previous_start_time, start_time)
new_albums = calculate_new_albums(current_period_albums, previous_period_albums)
scrobble_trend = calculate_scrobble_trend(
len(current_period_albums), len(previous_period_albums)
)
sorted_albums = sort_albums(current_period_albums, query.order_by)
top_albums = sorted_albums[: query.limit]
response = []
for album in top_albums:
trend = calculate_album_trend(
album, current_period_albums, previous_period_albums
)
album = {
**serialize_for_album_card(album),
"trend": trend,
"help_text": get_help_text(
album.playcount, album.playduration, query.order_by
),
}
response.append(album)
return {
"albums": response,
"scrobbles": {
"text": f"{new_albums} new album{'' if new_albums == 1 else 's'} played",
"trend": scrobble_trend,
"dates": format_date(start_time, end_time),
},
}, 200
def sort_albums(albums: list[Album], order_by: Literal["playcount", "playduration"]):
return sorted(albums, key=lambda x: getattr(x, order_by), reverse=True)
@api.get("/stats")
def get_stats():
"""
Get the stats for the user.
"""
period = "week"
start_time, end_time = get_date_range(period)
said_period = period
match period:
case "week":
said_period = "this week"
case "month":
said_period = "this month"
case "year":
said_period = "this year"
case "alltime":
said_period = "all time"
count = len(get_available_trackhashes(get_current_userid()))
total_tracks = StatItem(
"trackcount",
"in your library",
locale.format_string("%d", count, grouping=True)
+ " "
+ ngettext("track", "tracks", count),
)
tracks, playcount, playduration = get_tracks_in_period(start_time, end_time)
playcount = StatItem(
"streams",
said_period,
f"{playcount} track {ngettext('play', 'plays', playcount)}",
)
playduration = StatItem(
"playtime",
said_period,
f"{seconds_to_time_string(playduration)} listened",
)
tracks = sorted(tracks, key=lambda t: t.playduration, reverse=True)
# Find the top track from the last 7 days
top_track = StatItem(
"toptrack",
f"Top track {said_period}",
(
tracks[0].title + " - " + tracks[0].artists[0]["name"]
if len(tracks) > 0
else ""
),
(tracks[0].image if len(tracks) > 0 else None),
)
fav_count = FavoritesTable.count_favs_in_period(start_time, end_time)
favorites = StatItem(
"favorites",
said_period,
f"{fav_count} {'new' if period != 'alltime' else ''} favorite{'' if fav_count == 1 else 's'}",
)
return {
"stats": [
top_track,
playcount,
playduration,
favorites,
total_tracks,
],
"dates": format_date(start_time, end_time),
}, 200
class LastFmConnectBody(BaseModel):
token: str = Field(description="Last.fm auth token")
@api.get("/lastfm/status")
def get_lastfm_status():
"""
Get user-scoped Last.fm integration status.
"""
userid = get_current_userid()
config = UserConfig()
session_key = config.lastfmSessionKeys.get(str(userid), "")
plugin = LastFmPlugin(current_userid=userid)
return {
"connected": bool(session_key),
"session_key_set": bool(session_key),
"enabled": bool(plugin.enabled),
"userid": userid,
}
@api.post("/lastfm/connect")
def connect_lastfm(body: LastFmConnectBody):
"""
Connect Last.fm for current user.
"""
if not body.token:
return {"error": "Missing token"}, 400
userid = get_current_userid()
lastfm = LastFmPlugin(current_userid=userid)
session_key = lastfm.get_session_key(body.token)
if not session_key:
return {"error": "Failed to create Last.fm session"}, 400
config = UserConfig()
config.lastfmSessionKeys[str(userid)] = session_key
config.lastfmSessionKeys = config.lastfmSessionKeys
return {
"connected": True,
"session_key_set": True,
}
@api.post("/lastfm/disconnect")
def disconnect_lastfm():
"""
Disconnect Last.fm for current user.
"""
userid = get_current_userid()
config = UserConfig()
config.lastfmSessionKeys[str(userid)] = ""
config.lastfmSessionKeys = config.lastfmSessionKeys
return {
"connected": False,
"session_key_set": False,
}
@api.post("/lastfm/sync")
def sync_lastfm_status():
"""
Returns lightweight sync capability status for current user.
"""
userid = get_current_userid()
config = UserConfig()
connected = bool(config.lastfmSessionKeys.get(str(userid), ""))
scrobbles = list(ScrobbleTable.get_all(0, 1, userid=userid))
return {
"connected": connected,
"can_sync": connected and len(scrobbles) > 0,
"latest_local_scrobble": scrobbles[0].timestamp if scrobbles else None,
}
+274
View File
@@ -0,0 +1,274 @@
"""
Contains all the search routes.
"""
import hashlib
import logging
from typing import Any, Literal
from flask_openapi3 import APIBlueprint, Tag
from pydantic import Field
from unidecode import unidecode
from swingmusic import models
from swingmusic.api.apischemas import GenericLimitSchema
# DragonflyDB integration for search caching
from swingmusic.db.dragonfly_extended_client import get_search_cache_service
from swingmusic.lib import searchlib
from swingmusic.serializers.artist import serialize_for_cards
from swingmusic.services.user_library_scope import (
get_available_trackhashes,
get_visible_albums,
get_visible_artists,
)
from swingmusic.settings import Defaults
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.auth import get_current_userid
logger = logging.getLogger(__name__)
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
"""
class SearchQuery(GenericLimitSchema):
q: str = Field(
description="The search query",
json_schema_extra={"example": "Fleetwood Mac"},
)
start: int = Field(description="The index to start from", default=0)
limit: int = Field(
description="The number of items to return", default=SEARCH_COUNT
)
class TopResultsQuery(SearchQuery):
limit: int = Field(
description="The number of items to return", default=Defaults.API_CARD_LIMIT
)
class SearchLoadMoreQuery(SearchQuery):
itemtype: Literal["tracks", "albums", "artists"] = Field(
description="The type of search",
json_schema_extra={"example": "tracks"},
)
class Search:
def __init__(self, query: str) -> None:
self.tracks: list[models.Track] = []
self.query = unidecode(query)
def search_tracks(self):
"""
Calls :class:`SearchTracks` which returns the tracks that fuzzily match
the search terms. Then adds them to the `SearchResults` store.
"""
self.tracks = TrackStore.get_flat_list()
return searchlib.TopResults().search(self.query, tracks_only=True)
def search_artists(self):
"""Calls :class:`SearchArtists` which returns the artists that fuzzily match
the search term. Then adds them to the `SearchResults` store.
"""
artists = searchlib.SearchArtists(self.query)()
return serialize_for_cards(artists)
def search_albums(self):
"""Calls :class:`SearchAlbums` which returns the albums that fuzzily match
the search term. Then adds them to the `SearchResults` store.
"""
return searchlib.TopResults().search(self.query, albums_only=True)
def get_top_results(
self,
limit: int,
):
finder = searchlib.TopResults()
return finder.search(self.query, limit=limit)
def _get_visible_hash_sets(userid: int):
return {
"tracks": get_available_trackhashes(userid),
"albums": {album.albumhash for album in get_visible_albums(userid)},
"artists": {artist.artisthash for artist in get_visible_artists(userid)},
}
def _filter_track_items(items: list[dict], allowed_trackhashes: set[str]) -> list[dict]:
return [item for item in items if item.get("trackhash") in allowed_trackhashes]
def _filter_album_items(items: list[dict], allowed_albumhashes: set[str]) -> list[dict]:
return [item for item in items if item.get("albumhash") in allowed_albumhashes]
def _filter_artist_items(
items: list[dict], allowed_artisthashes: set[str]
) -> list[dict]:
return [item for item in items if item.get("artisthash") in allowed_artisthashes]
def _is_top_result_visible(top_result: dict, visible: dict[str, set[str]]) -> bool:
item_type = (top_result.get("type") or "").lower()
if item_type == "track":
return top_result.get("trackhash") in visible["tracks"]
if item_type == "album":
return top_result.get("albumhash") in visible["albums"]
if item_type == "artist":
return top_result.get("artisthash") in visible["artists"]
return False
def _fallback_top_result(results: dict) -> dict | None:
for key in ("tracks", "albums", "artists"):
items = results.get(key) or []
if items:
top = dict(items[0])
if "type" not in top:
top["type"] = key[:-1]
return top
return None
def _get_cache_key(query: str, item_type: str, userid: int) -> str:
"""Generate a cache key for search results"""
normalized = unidecode(query).lower().strip()
hash_input = f"{normalized}:{item_type}:{userid}"
return hashlib.md5(hash_input.encode()).hexdigest()
def _try_get_cached_results(query: str, item_type: str, userid: int) -> dict | None:
"""Try to get cached search results from DragonflyDB"""
cache = get_search_cache_service()
if not cache.cache.client.is_available():
return None
cache_key = _get_cache_key(query, item_type, userid)
cached = cache.get_search_results(cache_key)
if cached:
logger.debug(f"Search cache hit for '{query}' ({item_type})")
return cached
return None
def _cache_search_results(
query: str, item_type: str, userid: int, results: dict, ttl_hours: int = 1
):
"""Cache search results in DragonflyDB"""
cache = get_search_cache_service()
if not cache.cache.client.is_available():
return
cache_key = _get_cache_key(query, item_type, userid)
cache.cache_search_results(cache_key, results, ttl_hours=ttl_hours)
logger.debug(f"Cached search results for '{query}' ({item_type})")
@api.get("/top")
def get_top_results(query: TopResultsQuery):
"""
Get top results
Returns the top results for the given query.
"""
if not query.q:
return {"error": "No query provided"}, 400
userid = get_current_userid()
# Try to get cached results first
cached = _try_get_cached_results(query.q, "top", userid)
if cached:
return cached
visible = _get_visible_hash_sets(userid)
results = Search(query.q).get_top_results(limit=query.limit)
if not isinstance(results, dict):
return results
results["tracks"] = _filter_track_items(
results.get("tracks") or [], visible["tracks"]
)
results["albums"] = _filter_album_items(
results.get("albums") or [], visible["albums"]
)
results["artists"] = _filter_artist_items(
results.get("artists") or [], visible["artists"]
)
top_result = results.get("top_result")
if (
top_result
and not _is_top_result_visible(top_result, visible)
or top_result is None
):
results["top_result"] = _fallback_top_result(results)
# Cache the results for 1 hour (search results change frequently)
_cache_search_results(query.q, "top", userid, results, ttl_hours=1)
return results
@api.get("/")
def search_items(query: SearchLoadMoreQuery):
"""
Find tracks, albums or artists from a search query.
"""
userid = get_current_userid()
# Try to get cached results first
cached = _try_get_cached_results(query.q, query.itemtype, userid)
if cached:
# Apply pagination to cached results
results = cached.get("results", [])
return {
"results": results[query.start : query.start + query.limit],
"more": len(results) > query.start + query.limit,
}
results: Any = []
visible = _get_visible_hash_sets(userid)
match query.itemtype:
case "tracks":
results = Search(query.q).search_tracks()
results = _filter_track_items(results, visible["tracks"])
case "albums":
results = Search(query.q).search_albums()
results = _filter_album_items(results, visible["albums"])
case "artists":
results = Search(query.q).search_artists()
results = _filter_artist_items(results, visible["artists"])
case _:
return {
"error": "Invalid item type. Valid types are 'tracks', 'albums' and 'artists'"
}, 400
# Cache the full results for 1 hour
_cache_search_results(
query.q, query.itemtype, userid, {"results": results}, ttl_hours=1
)
return {
"results": results[query.start : query.start + query.limit],
"more": len(results) > query.start + query.limit,
}
# Note: Generators are not used here because:
# 1. Results are already materialized (loaded from store)
# 2. Pagination requires knowing total count for "more" flag
# 3. Filtering operations need full list access
+178
View File
@@ -0,0 +1,178 @@
import contextlib
from dataclasses import asdict
from typing import Any
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.api.auth import admin_required
from swingmusic.config import UserConfig
from swingmusic.db.userdata import PluginTable
from swingmusic.lib.index import index_everything
from swingmusic.services.setup_state import trigger_initial_index
from swingmusic.settings import Metadata
from swingmusic.utils.auth import get_current_userid
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]):
"""Returns child directories in a list, given a parent directory"""
return [_dir for _dir in children if _dir.startswith(parent) and _dir != parent]
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")
@admin_required()
def add_root_dirs(body: AddRootDirsBody):
"""
Add custom root directories to the database.
"""
new_dirs = body.new_dirs
removed_dirs = body.removed
config = UserConfig()
db_dirs = config.rootDirs
home = "$home"
db_home = any(d == home for d in db_dirs) # if $home is in db
incoming_home = any(d == home for d in new_dirs) # if $home is in incoming
# handle $home case
if db_home and incoming_home:
return {"msg": "Not changed!"}, 304
# if $home is the current root dir or the incoming root dir
# is $home, remove all root dirs
if db_home or incoming_home:
config.rootDirs = []
if incoming_home:
config.rootDirs = [home]
trigger_initial_index(force=True)
return {"root_dirs": [home]}
# ---
for _dir in new_dirs:
children = get_child_dirs(_dir, db_dirs)
removed_dirs.extend(children)
for _dir in removed_dirs:
with contextlib.suppress(ValueError):
db_dirs.remove(_dir)
db_dirs.extend(new_dirs)
config.rootDirs = [dir_ for dir_ in db_dirs if dir_ != home]
trigger_initial_index(force=True)
return {"root_dirs": config.rootDirs}
@api.get("/get-root-dirs")
def get_root_dirs():
"""
Get root directories
"""
return {"dirs": UserConfig().rootDirs}
@api.get("")
def get_all_settings():
"""
Get all settings
"""
config = asdict(UserConfig())
# Convert sets to lists for JSON serialization
for key, value in config.items():
if isinstance(value, set):
config[key] = sorted(value)
config["plugins"] = list(PluginTable.get_all())
config["version"] = Metadata.version
if config["version"] == "0.0.0":
# fallback to version.txt (useful for docker builds)
config["version"] = open("version.txt").read().strip()
# only return lastfmSessionKey for the current user
current_user = get_current_userid()
config["lastfmSessionKey"] = config["lastfmSessionKeys"].get(str(current_user), "")
del config["lastfmSessionKeys"]
return config
class SetSettingBody(BaseModel):
key: str = Field(
description="The setting key",
example="artist_separators",
)
value: Any = Field(
description="The setting value",
example=",",
)
@api.get("/trigger-scan")
def trigger_scan():
"""
Triggers scan for new music
"""
queued = trigger_initial_index(force=True)
return {"msg": "Scan triggered!", "queued": queued}
class UpdateConfigBody(BaseModel):
key: str = Field(
description="The setting key",
example="usersOnLogin",
)
value: Any = Field(
description="The setting value",
example=False,
)
@api.put("/update")
@admin_required()
def update_config(body: UpdateConfigBody):
"""
Update the config file
"""
config = UserConfig()
if body.key == "artistSeparators":
body.value = body.value.split(",")
setattr(config, body.key, body.value)
# INFO: Rebuild stores when these settings are updated
reset_stores_lists = {
"artistSeparators",
"artistSplitIgnoreList",
"removeProdBy",
"removeRemasterInfo",
"mergeAlbums",
"cleanAlbumTitle",
"showAlbumsAsSingles",
}
if body.key in reset_stores_lists:
index_everything()
return {
"msg": "Config updated!",
}
+189
View File
@@ -0,0 +1,189 @@
from __future__ import annotations
import os
import pathlib
from pathlib import Path
import psutil
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.services.setup_state import (
bootstrap_setup,
configure_primary_directory,
get_setup_status,
trigger_initial_index,
)
from swingmusic.utils.wintools import is_windows
bp_tag = Tag(name="Setup", description="First-run setup and onboarding state")
api = APIBlueprint("setup", __name__, url_prefix="/setup", abp_tags=[bp_tag])
class SetupBootstrapBody(BaseModel):
username: str = Field(description="Owner username for first boot")
password: str = Field(description="Owner password for first boot")
root_dirs: list[str] = Field(
default_factory=list,
description="Initial primary music directories",
)
class SetupDirectoryBody(BaseModel):
root_dirs: list[str] = Field(
default_factory=list,
description="Primary music directories to use for indexing",
)
class SetupIndexStartBody(BaseModel):
force: bool = Field(
default=False,
description="Force queueing a new initial index run",
)
class SetupDirBrowserBody(BaseModel):
folder: str = Field(
"$root",
description="The folder to list directories from during first-run setup",
)
def _setup_root_drives(is_win: bool = False):
drives = [Path(d.mountpoint).as_posix() for d in psutil.disk_partitions(all=True)]
if is_win:
return drives
hidden_roots = (
"/boot",
"/tmp",
"/snap",
"/var",
"/sys",
"/proc",
"/etc",
"/run",
"/dev",
)
return [drive for drive in drives if not drive.startswith(hidden_roots)]
@api.get("/status")
def setup_status():
return get_setup_status()
@api.post("/bootstrap")
def setup_bootstrap(body: SetupBootstrapBody):
try:
owner = bootstrap_setup(
username=body.username,
password=body.password,
root_dirs=body.root_dirs,
)
return {
"success": True,
"owner": {
"id": owner.id,
"username": owner.username,
},
"setup": get_setup_status(),
}
except ValueError as error:
return {"success": False, "error": str(error)}, 400
@api.post("/directory")
def setup_directory(body: SetupDirectoryBody):
status = get_setup_status()
if status["setup_completed"]:
return {
"success": False,
"error": "Setup is already completed.",
"setup": status,
}, 400
if not status["owner_created"]:
return {
"success": False,
"error": "Create the owner account before configuring directories.",
"setup": status,
}, 400
try:
queued = configure_primary_directory(root_dirs=body.root_dirs)
except ValueError as error:
return {"success": False, "error": str(error)}, 400
return {
"success": True,
"queued": queued,
"setup": get_setup_status(),
}
@api.get("/index-progress")
def setup_index_progress():
status = get_setup_status()
return {
"index_state": status["index_state"],
"index_progress": status["index_progress"],
"index_message": status["index_message"],
"initial_index_completed": status["initial_index_completed"],
}
@api.post("/index/start")
def setup_index_start(body: SetupIndexStartBody):
status = get_setup_status()
if not status["owner_created"] or not status["directory_configured"]:
return {
"queued": False,
"error": "Owner account and primary music directory are required before indexing.",
"setup": status,
}, 400
queued = trigger_initial_index(force=body.force)
status = get_setup_status()
return {
"queued": queued,
"setup": status,
}
@api.post("/dir-browser")
def setup_dir_browser(body: SetupDirBrowserBody):
status = get_setup_status()
if status["setup_completed"]:
return {"folders": [], "error": "Setup is already completed."}, 403
req_dir = body.folder
if req_dir == "$root":
roots = _setup_root_drives(is_win=is_windows())
if "/music" not in roots and Path("/music").exists():
roots.insert(0, "/music")
return {"folders": [{"name": root, "path": root} for root in roots]}
req_path = pathlib.Path(req_dir).resolve()
if not req_path.exists() or not req_path.is_dir():
return {"folders": [], "error": "Invalid directory"}, 400
dirs = []
try:
with os.scandir(req_path) as entries:
for entry in entries:
entry_path = pathlib.Path(entry)
name = entry_path.name
if name.startswith("$") or name.startswith("."):
continue
if entry_path.is_dir():
dirs.append({"name": name, "path": entry_path.resolve().as_posix()})
except PermissionError:
return {"folders": []}
return {"folders": sorted(dirs, key=lambda item: item["name"].lower())}
+211
View File
@@ -0,0 +1,211 @@
"""Spotify downloader API backed by the unified durable download job pipeline."""
from __future__ import annotations
import asyncio
from flask import jsonify, request
from flask_jwt_extended import get_jwt_identity
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from swingmusic.services.spotify_downloader import DownloadSource, spotify_downloader
from swingmusic.utils.auth import get_current_userid
spotify_bp = APIBlueprint(
"spotify",
import_name="spotify",
url_prefix="/api/spotify",
)
class SpotifyURLRequest(BaseModel):
url: str = Field(..., description="Spotify URL (track, album, playlist, artist)")
quality: str | None = Field(default="flac", description="Audio quality")
output_dir: str | None = Field(default=None, description="Output directory")
def _current_userid() -> int:
try:
identity = get_jwt_identity()
if isinstance(identity, dict) and identity.get("id") is not None:
return int(identity["id"])
except Exception:
pass
return get_current_userid()
@spotify_bp.post("/metadata", summary="Get Spotify metadata")
def get_metadata(body: SpotifyURLRequest):
try:
metadata = asyncio.run(spotify_downloader.get_metadata(body.url))
if not metadata:
return jsonify({"error": "Invalid Spotify URL", "success": False}), 400
return jsonify(
{
"success": True,
"metadata": {
"spotify_id": metadata.spotify_id,
"item_type": metadata.item_type,
"title": metadata.title,
"artist": metadata.artist,
"album": metadata.album,
"duration_ms": metadata.duration_ms,
"image_url": metadata.image_url,
"release_date": metadata.release_date,
"track_number": metadata.track_number,
"total_tracks": metadata.total_tracks,
"is_explicit": metadata.is_explicit,
"preview_url": metadata.preview_url,
},
}
)
except Exception as error:
return jsonify({"error": str(error), "success": False}), 500
@spotify_bp.post("/download", summary="Add Spotify URL to queue")
def download_from_url(body: SpotifyURLRequest):
userid = _current_userid()
item_id = spotify_downloader.add_download(
spotify_url=body.url,
output_dir=body.output_dir,
quality=body.quality,
userid=userid,
)
if not item_id:
return jsonify({"error": "Failed to add download", "success": False}), 400
return jsonify(
{
"success": True,
"message": "Download added to queue",
"item_id": item_id,
}
)
@spotify_bp.get("/queue", summary="Get queue status")
def get_queue_status():
userid = _current_userid()
status = spotify_downloader.get_queue_status(userid)
return jsonify({"success": True, "data": status})
@spotify_bp.post("/cancel/<item_id>", summary="Cancel download")
def cancel_download(item_id: str):
userid = _current_userid()
success = spotify_downloader.cancel_download(item_id, userid=userid)
if not success:
return jsonify(
{"success": False, "message": "Download not found or cannot be cancelled"}
), 404
return jsonify({"success": True, "message": "Download cancelled successfully"})
@spotify_bp.post("/retry/<item_id>", summary="Retry failed download")
def retry_download(item_id: str):
userid = _current_userid()
success = spotify_downloader.retry_download(item_id, userid=userid)
if not success:
return jsonify(
{"success": False, "message": "Download not found or cannot be retried"}
), 404
return jsonify({"success": True, "message": "Download retry added to queue"})
@spotify_bp.get("/sources", summary="Get download sources")
def get_download_sources():
sources = [
{
"name": source.value,
"display_name": source.value.replace("_", " ").title(),
"enabled": True,
"priority": index,
}
for index, source in enumerate(DownloadSource)
]
return jsonify({"success": True, "sources": sources})
@spotify_bp.get("/qualities", summary="Get audio qualities")
def get_audio_qualities():
return jsonify(
{
"success": True,
"qualities": [
{
"id": "flac",
"name": "FLAC",
"description": "Lossless audio quality",
"extension": "flac",
"bitrate": "Lossless",
},
{
"id": "mp3_320",
"name": "MP3 320kbps",
"description": "High quality MP3",
"extension": "mp3",
"bitrate": "320 kbps",
},
{
"id": "mp3_128",
"name": "MP3 128kbps",
"description": "Standard quality MP3",
"extension": "mp3",
"bitrate": "128 kbps",
},
],
}
)
@spotify_bp.get("/history", summary="Get download history")
def get_download_history():
userid = _current_userid()
page = int(request.args.get("page", 1))
limit = int(request.args.get("limit", 50))
status_filter = request.args.get("status", None)
status = spotify_downloader.get_queue_status(userid)
history = status.get("history", [])
if status_filter:
history = [item for item in history if item.get("state") == status_filter]
total = len(history)
start = max(0, (page - 1) * limit)
end = start + limit
items = history[start:end]
return jsonify(
{
"success": True,
"data": {
"items": items,
"pagination": {
"page": page,
"limit": limit,
"total": total,
"pages": (total + limit - 1) // limit,
},
},
}
)
@spotify_bp.delete("/clear-history", summary="Clear download history")
def clear_download_history():
# Durable history is kept in DB for reliability; expose as no-op success for backward compatibility.
return jsonify(
{"success": True, "message": "History retention is managed automatically"}
)
+355
View File
@@ -0,0 +1,355 @@
"""
Spotify Downloader Settings API endpoints
"""
from typing import Any
from flask import jsonify
from flask_jwt_extended import get_jwt_identity
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from swingmusic import logger
from swingmusic.config import UserConfig
from swingmusic.services.download_jobs import download_job_manager
from swingmusic.utils.auth import get_current_userid
spotify_settings_bp = APIBlueprint(
"spotify_settings",
import_name="spotify_settings",
url_prefix="/api/settings/spotify",
)
def _current_userid() -> int:
try:
identity = get_jwt_identity()
if isinstance(identity, dict) and identity.get("id") is not None:
return int(identity["id"])
except Exception:
pass
return get_current_userid()
class SpotifySettingsRequest(BaseModel):
defaultQuality: str = Field("flac", description="Default download quality")
downloadFolder: str | None = Field(None, description="Download folder path")
autoAddToLibrary: bool = Field(True, description="Auto-add downloads to library")
maxConcurrentDownloads: int = Field(3, description="Max concurrent downloads")
sources: list | None = Field(None, description="Download sources configuration")
maxRetryAttempts: int = Field(3, description="Max retry attempts")
cleanupHistoryDays: int = Field(30, description="Auto-cleanup history days")
showExplicitWarning: bool = Field(True, description="Show explicit content warning")
class SpotifySettingsResponse(BaseModel):
success: bool
settings: dict[str, Any] | None = None
message: str | None = None
# Default settings
DEFAULT_SETTINGS = {
"defaultQuality": "flac",
"downloadFolder": "",
"autoAddToLibrary": True,
"maxConcurrentDownloads": 3,
"sources": [
{
"name": "tidal",
"display_name": "Tidal",
"enabled": True,
"priority": 1,
"config": {
"quality_preference": ["lossless", "high", "normal"],
"formats": ["flac", "mp3"],
},
},
{
"name": "qobuz",
"display_name": "Qobuz",
"enabled": True,
"priority": 2,
"config": {
"quality_preference": ["lossless", "high", "normal"],
"formats": ["flac", "mp3"],
},
},
{
"name": "amazon",
"display_name": "Amazon Music",
"enabled": False,
"priority": 3,
"config": {
"quality_preference": ["high", "normal"],
"formats": ["mp3", "aac"],
},
},
],
"maxRetryAttempts": 3,
"cleanupHistoryDays": 30,
"showExplicitWarning": True,
}
def get_spotify_settings():
"""Get Spotify downloader settings from config"""
try:
config = UserConfig()
spotify_settings = (
config.spotify_downloads if hasattr(config, "spotify_downloads") else {}
)
# Merge with defaults
settings = {**DEFAULT_SETTINGS}
settings.update(spotify_settings)
return settings
except Exception as e:
logger.error(f"Error loading Spotify settings: {e}")
return DEFAULT_SETTINGS
def save_spotify_settings(settings_data: dict):
"""Save Spotify downloader settings to config"""
try:
config = UserConfig()
# Update only provided settings
current_settings = get_spotify_settings()
current_settings.update(settings_data)
# Save to config
config.spotify_downloads = current_settings
config.save()
logger.info("Spotify settings saved successfully")
return True
except Exception as e:
logger.error(f"Error saving Spotify settings: {e}")
return False
@spotify_settings_bp.get("/", summary="Get Spotify downloader settings")
def get_settings():
"""
Get current Spotify downloader settings
Returns all Spotify downloader configuration including:
- Default quality settings
- Download folder configuration
- Source priorities and enablement
- Advanced options
"""
try:
settings = get_spotify_settings()
return jsonify({"success": True, "settings": settings})
except Exception as e:
logger.error(f"Error getting Spotify settings: {e}")
return jsonify({"success": False, "message": str(e)}), 500
@spotify_settings_bp.post("/", summary="Update Spotify downloader settings")
def update_settings(body: SpotifySettingsRequest):
"""
Update Spotify downloader settings
- **defaultQuality**: Default download quality (flac, mp3_320, mp3_128)
- **downloadFolder**: Custom download folder path
- **autoAddToLibrary**: Whether to auto-add downloads to library
- **maxConcurrentDownloads**: Maximum concurrent downloads (1-10)
- **sources**: Download sources configuration
- **maxRetryAttempts**: Maximum retry attempts for failed downloads
- **cleanupHistoryDays**: Days to keep download history (0 = disabled)
- **showExplicitWarning**: Show warning for explicit content
Updates the Spotify downloader configuration and saves to user settings.
"""
try:
# Validate inputs
if body.defaultQuality not in ["flac", "mp3_320", "mp3_128"]:
return jsonify(
{"success": False, "message": "Invalid quality setting"}
), 400
if not 1 <= body.maxConcurrentDownloads <= 10:
return jsonify(
{
"success": False,
"message": "Max concurrent downloads must be between 1 and 10",
}
), 400
if not 0 <= body.maxRetryAttempts <= 10:
return jsonify(
{
"success": False,
"message": "Max retry attempts must be between 0 and 10",
}
), 400
if not 0 <= body.cleanupHistoryDays <= 365:
return jsonify(
{"success": False, "message": "Cleanup days must be between 0 and 365"}
), 400
# Prepare settings data
settings_data = {
"defaultQuality": body.defaultQuality,
"downloadFolder": body.downloadFolder,
"autoAddToLibrary": body.autoAddToLibrary,
"maxConcurrentDownloads": body.maxConcurrentDownloads,
"sources": body.sources,
"maxRetryAttempts": body.maxRetryAttempts,
"cleanupHistoryDays": body.cleanupHistoryDays,
"showExplicitWarning": body.showExplicitWarning,
}
# Remove None values
settings_data = {k: v for k, v in settings_data.items() if v is not None}
# Save settings
if save_spotify_settings(settings_data):
return jsonify({"success": True, "message": "Settings saved successfully"})
else:
return jsonify(
{"success": False, "message": "Failed to save settings"}
), 500
except Exception as e:
logger.error(f"Error updating Spotify settings: {e}")
return jsonify({"success": False, "message": str(e)}), 500
@spotify_settings_bp.post("/reset", summary="Reset Spotify settings to defaults")
def reset_settings():
"""
Reset all Spotify downloader settings to default values
Resets all Spotify downloader configuration to factory defaults.
"""
try:
if save_spotify_settings(DEFAULT_SETTINGS):
return jsonify(
{
"success": True,
"message": "Settings reset to defaults",
"settings": DEFAULT_SETTINGS,
}
)
else:
return jsonify(
{"success": False, "message": "Failed to reset settings"}
), 500
except Exception as e:
logger.error(f"Error resetting Spotify settings: {e}")
return jsonify({"success": False, "message": str(e)}), 500
@spotify_settings_bp.delete("/queue", summary="Clear download queue")
def clear_queue():
"""
Clear pending/active download jobs for current user.
"""
try:
userid = _current_userid()
cancelled = download_job_manager.clear_queue(userid)
return jsonify(
{
"success": True,
"cancelled_jobs": cancelled,
"message": f"Cleared queue ({cancelled} job(s) cancelled)",
}
)
except Exception as e:
logger.error(f"Error clearing download queue: {e}")
return jsonify({"success": False, "message": str(e)}), 500
@spotify_settings_bp.delete("/history", summary="Clear download history")
def clear_history():
"""
Clear completed/failed/cancelled download history for current user.
"""
try:
userid = _current_userid()
deleted = download_job_manager.clear_history(userid)
return jsonify(
{
"success": True,
"deleted_jobs": deleted,
"message": f"Download history cleared ({deleted} job(s) removed)",
}
)
except Exception as e:
logger.error(f"Error clearing download history: {e}")
return jsonify({"success": False, "message": str(e)}), 500
@spotify_settings_bp.get("/sources", summary="Get available download sources")
def get_available_sources():
"""
Get list of available download sources
Returns information about supported download sources and their capabilities.
"""
try:
sources = [
{
"name": "tidal",
"display_name": "Tidal",
"description": "High-quality FLAC downloads from Tidal",
"quality_options": ["lossless", "high", "normal"],
"formats": ["flac", "mp3"],
"available": True,
"requires_auth": False,
"max_quality": "lossless",
},
{
"name": "qobuz",
"display_name": "Qobuz",
"description": "Alternative high-quality source with extensive catalog",
"quality_options": ["lossless", "high", "normal"],
"formats": ["flac", "mp3"],
"available": True,
"requires_auth": True,
"max_quality": "lossless",
},
{
"name": "amazon",
"display_name": "Amazon Music",
"description": "Fallback source with wide availability",
"quality_options": ["high", "normal"],
"formats": ["mp3", "aac"],
"available": False, # Disabled by default
"requires_auth": True,
"max_quality": "high",
},
]
return jsonify({"success": True, "sources": sources})
except Exception as e:
logger.error(f"Error getting available sources: {e}")
return jsonify({"success": False, "message": str(e)}), 500
# Error handlers
@spotify_settings_bp.errorhandler(400)
def bad_request(error):
return jsonify(
{"error": "Bad request", "message": str(error), "success": False}
), 400
@spotify_settings_bp.errorhandler(500)
def internal_error(error):
return jsonify(
{"error": "Internal server error", "message": str(error), "success": False}
), 500
+613
View File
@@ -0,0 +1,613 @@
"""
Contains all the track routes with iOS compatibility enhancements.
"""
import hashlib
import os
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Literal
from flask import Response, request, send_from_directory
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from swingmusic.api.apischemas import TrackHashSchema
from swingmusic.lib.trackslib import get_silence_paddings
from swingmusic.lib.transcoder import start_transcoding
from swingmusic.services.ios_audio_compatibility import ios_audio_manager
from swingmusic.services.user_library_scope import (
get_available_trackhashes,
is_path_within_user_roots,
)
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.files import guess_mime_type
bp_tag = Tag(name="File", description="Audio files")
api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag])
class TransCodeStore:
map: dict[str, str] = {}
@classmethod
def add_file(cls, trackhash: str, filepath: str):
cls.map[trackhash] = filepath
@classmethod
def remove_file(cls, trackhash: str):
del cls.map[trackhash]
@classmethod
def find(cls, trackhash: str):
return cls.map.get(trackhash)
class SendTrackFileQuery(BaseModel):
filepath: str = Field(description="The filepath to play (if available)")
quality: str = Field(
"original",
description="The quality of the audio file. Options: original, 1411, 1024, 512, 320, 256, 128, 96",
)
container: Literal["mp3", "aac", "flac", "webm", "ogg"] = Field(
"mp3",
description="The container format of the audio file. Options: mp3, aac, flac, webm, ogg",
)
TRANSCODE_CODEC_ARGS = {
"mp3": ["-c:a", "libmp3lame"],
"aac": ["-c:a", "aac"],
"webm": ["-c:a", "libopus"],
"ogg": ["-c:a", "libvorbis"],
"flac": ["-c:a", "flac"],
}
TRANSCODE_CACHE_DIR = Path(tempfile.gettempdir()) / "swingmusic_transcodes"
def _parse_requested_bitrate(quality: str) -> int | None:
normalized = (quality or "").strip().lower()
if not normalized or normalized == "original":
return None
try:
bitrate = int(normalized)
except ValueError:
return None
return max(64, min(1411, bitrate))
def _requested_ios_quality(quality: str) -> str:
requested = _parse_requested_bitrate(quality)
if requested is None:
return "lossless"
if requested <= 128:
return "low"
if requested <= 256:
return "medium"
if requested <= 512:
return "high"
return "lossless"
def _ensure_transcoded_variant(
*,
source_path: str,
quality: str,
container: str,
) -> str:
bitrate = _parse_requested_bitrate(quality)
if bitrate is None:
return source_path
output_container = container if container in TRANSCODE_CODEC_ARGS else "mp3"
if output_container != "flac":
bitrate = min(320, bitrate)
source = Path(source_path).resolve()
TRANSCODE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
source_stamp = source.stat().st_mtime_ns
cache_key = f"{source}::{source_stamp}::{output_container}::{bitrate}"
out_name = (
f"{hashlib.sha1(cache_key.encode('utf-8')).hexdigest()}.{output_container}"
)
out_path = TRANSCODE_CACHE_DIR / out_name
if out_path.exists() and out_path.stat().st_size > 0:
return str(out_path)
command = [
"ffmpeg",
"-y",
"-i",
str(source),
"-vn",
"-map_metadata",
"0",
]
command.extend(TRANSCODE_CODEC_ARGS[output_container])
if output_container != "flac":
command.extend(["-b:a", f"{bitrate}k"])
command.append(str(out_path))
process = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
if process.returncode != 0 or not out_path.exists():
if out_path.exists():
out_path.unlink(missing_ok=True)
raise RuntimeError(
f"Transcoding failed for {source_path} ({quality}/{output_container}): "
f"{process.stderr[-400:]}"
)
return str(out_path)
def _resolve_track_for_user(
*,
requested_trackhash: str,
filepath: str,
userid: int,
):
msg = {"msg": "File Not Found"}
available_trackhashes = get_available_trackhashes(userid)
if requested_trackhash not in available_trackhashes:
return None, msg, 404
if filepath:
# prevent path traversal
if "/../" in filepath:
return (
None,
{"msg": "Invalid filepath", "error": "Path traversal detected"},
400,
)
requested_filepath = Path(filepath).resolve()
if not is_path_within_user_roots(str(requested_filepath), userid=userid):
return (
None,
{
"msg": "Invalid filepath",
"error": "File not inside root directories",
},
403,
)
tracks = TrackStore.get_tracks_by_filepaths([filepath])
if len(tracks) > 0 and os.path.exists(tracks[0].filepath):
for track in tracks:
if (
os.path.exists(track.filepath)
and track.trackhash == requested_trackhash
):
return track, None, None
group = TrackStore.trackhashmap.get(requested_trackhash)
if group is not None:
tracks = sorted(group.tracks, key=lambda x: x.bitrate, reverse=True)
for track in tracks:
if os.path.exists(track.filepath):
return track, None, None
return None, msg, 404
@api.get("/<trackhash>/legacy")
def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery):
"""
Get a playable audio file without Range support (iOS compatible)
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
Automatically handles iOS compatibility by transcoding to supported formats when needed.
NOTE: Does not support range requests or transcoding beyond iOS compatibility.
"""
requested_trackhash = path.trackhash.strip()
filepath = query.filepath.strip()
userid = get_current_userid()
track, error_payload, error_status = _resolve_track_for_user(
requested_trackhash=requested_trackhash,
filepath=filepath,
userid=userid,
)
if track is not None:
selected_path = track.filepath
selected_quality = (query.quality or "original").strip().lower()
selected_container = (query.container or "mp3").strip().lower()
# Honor requested streaming quality for mobile data saver mode.
if selected_quality != "original":
try:
selected_path = _ensure_transcoded_variant(
source_path=track.filepath,
quality=selected_quality,
container=selected_container,
)
except Exception:
selected_path = track.filepath
# Detect iOS capabilities and handle compatibility
user_agent = request.headers.get("User-Agent", "")
ios_capabilities = ios_audio_manager.detect_ios_capabilities(user_agent)
# Create iOS-compatible audio source
audio_source = ios_audio_manager.create_ios_audio_source(
selected_path,
ios_capabilities,
quality=_requested_ios_quality(query.quality),
)
# Use the potentially transcoded file path
final_file_path = audio_source["file_path"]
audio_type = audio_source["mime_type"]
# Add iOS compatibility headers
response = send_from_directory(
Path(final_file_path).parent,
Path(final_file_path).name,
mimetype=audio_type,
conditional=True,
as_attachment=False,
)
# Add iOS-specific headers
if ios_capabilities.is_ios:
response.headers["Accept-Ranges"] = "bytes"
response.headers["Cache-Control"] = "public, max-age=3600"
# Add transcoding info if applicable
if audio_source["needs_transcoding"]:
response.headers["X-iOS-Transcoded"] = "true"
response.headers["X-iOS-Original-Format"] = guess_mime_type(
selected_path
)
response.headers["X-iOS-Target-Format"] = audio_source["format"]
response.headers["X-Requested-Quality"] = query.quality
response.headers["X-Requested-Container"] = query.container
return response
return error_payload, error_status
@api.get("/<trackhash>/ios")
def send_track_file_ios(path: TrackHashSchema, query: SendTrackFileQuery):
"""
Get a playable audio file optimized for iOS devices
Returns a playable audio file optimized for iOS compatibility with automatic transcoding.
Supports FLAC to ALAC/AAC conversion and proper MIME types for iOS Safari and other browsers.
iOS Features:
- Automatic FLAC to ALAC/AAC transcoding
- Proper MP4 container formatting
- iOS-compatible MIME types
- Optimized bitrate for mobile streaming
- Caching for transcoded files
"""
requested_trackhash = path.trackhash.strip()
filepath = query.filepath.strip()
userid = get_current_userid()
track, error_payload, error_status = _resolve_track_for_user(
requested_trackhash=requested_trackhash,
filepath=filepath,
userid=userid,
)
if track is not None:
# Detect iOS capabilities
user_agent = request.headers.get("User-Agent", "")
ios_capabilities = ios_audio_manager.detect_ios_capabilities(user_agent)
# Determine quality based on query parameter or device capabilities
quality_map = {
"original": "lossless",
"1411": "lossless",
"1024": "lossless",
"512": "high",
"320": "high",
"256": "high",
"128": "medium",
"96": "low",
}
quality = quality_map.get(query.quality, "high")
# Create iOS-optimized audio source
audio_source = ios_audio_manager.create_ios_audio_source(
track.filepath, ios_capabilities, quality=quality
)
# Use the potentially transcoded file path
final_file_path = audio_source["file_path"]
audio_type = audio_source["mime_type"]
# Create response with iOS-specific optimizations
response = send_from_directory(
Path(final_file_path).parent,
Path(final_file_path).name,
mimetype=audio_type,
conditional=True,
as_attachment=False, # Stream inline for iOS
)
# iOS-specific headers for optimal playback
response.headers["Accept-Ranges"] = "bytes"
response.headers["Cache-Control"] = "public, max-age=7200" # 2 hours
response.headers["X-Content-Type-Options"] = "nosniff"
# Add iOS compatibility information
if ios_capabilities.is_ios:
response.headers["X-iOS-Optimized"] = "true"
response.headers["X-iOS-Device"] = (
"iPhone"
if "iPhone" in user_agent
else "iPad"
if "iPad" in user_agent
else "iPod"
)
# Add transcoding information
if audio_source["needs_transcoding"]:
response.headers["X-iOS-Transcoded"] = "true"
response.headers["X-iOS-Original-Format"] = guess_mime_type(
track.filepath
)
response.headers["X-iOS-Target-Format"] = audio_source["format"]
response.headers["X-iOS-Quality"] = quality
else:
response.headers["X-iOS-Transcoded"] = "false"
response.headers["X-iOS-Native-Format"] = "true"
return response
return error_payload, error_status
# @api.get("/<trackhash>")
# def send_track_file(path: TrackHashSchema, query: SendTrackFileQuery):
# """
# Get a playable audio file with Range headers support
# Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
# Transcoding can be done by sending the quality and container query parameters.
# **NOTES:**
# - Transcoded streams report incorrect duration during playback (idk why! FFMPEG gurus we need your help here).
# - The quality parameter is the desired bitrate in kbps.
# - The mp3 container is the best container for upto 320kbps (and has better duration reporting). The flac container allows for higher bitrates but it produces dramatically larger files (when transcoding from lossy formats).
# - You can get the transcoded bitrate by checking the X-Transcoded-Bitrate header on the first request's response.
# """
# trackhash = path.trackhash
# filepath = query.filepath
# # If filepath is provided, try to send that
# track = None
# tracks = TrackStore.get_tracks_by_filepaths([filepath])
# if len(tracks) > 0 and os.path.exists(filepath):
# track = tracks[0]
# else:
# res = TrackStore.trackhashmap.get(trackhash)
# # When finding by trackhash, sort by bitrate
# # and get the first track that exists
# if res is not None:
# tracks = sorted(res.tracks, key=lambda x: x.bitrate, reverse=True)
# for t in tracks:
# if os.path.exists(t.filepath):
# track = t
# break
# if track is not None:
# if query.quality == "original":
# return send_file_as_chunks(track.filepath)
# # prevent requesting over transcoding
# max_bitrate = track.bitrate
# requested_bitrate = int(query.quality)
# if query.container != "flac":
# # drop to 320 for non-flac containers
# requested_bitrate = min(320, requested_bitrate)
# quality = f"{min(max_bitrate, requested_bitrate)}k"
# return transcode_and_stream(trackhash, track.filepath, quality, query.container)
# return {"msg": "File Not Found"}, 404
def transcode_and_stream(trackhash: str, filepath: str, bitrate: str, container: str):
"""
Initiates transcoding and returns the first chunk of the transcoded file.
The other chunks are streamed on subsequent requests and are rerouted to `send_file_as_chunks`.
"""
temp_file = TransCodeStore.find(trackhash)
if temp_file is not None:
return send_file_as_chunks(temp_file)
format_params = {
"mp3": ["-c:a", "libmp3lame"],
"aac": ["-c:a", "aac"],
"webm": ["-c:a", "libopus"],
"ogg": ["-c:a", "libvorbis"],
"flac": ["-c:a", "flac"],
"wav": ["-c:a", "pcm_s16le"],
}
# Create a temporary file
format = f".{container}" if container in format_params else ".flac"
container_args = (
format_params[container]
if container in format_params
else format_params["flac"]
)
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=format)
temp_filename = temp_file.name
temp_file.close()
TransCodeStore.add_file(trackhash, temp_filename)
start_transcoding(filepath, temp_filename, bitrate, container_args)
chunk_size = 1024 * 512 # 0.5MB
file_size = os.path.getsize(filepath)
def generate():
# Poll for the output file
while (
not os.path.exists(temp_filename)
or os.path.getsize(temp_filename) < chunk_size
):
print(f"Waiting for transcoding to complete... filename: {temp_filename}")
time.sleep(0.1) # Wait for 100ms before checking again
with open(temp_filename, "rb") as file:
file.seek(0)
return file.read(chunk_size)
audio_type = guess_mime_type(temp_filename)
response = Response(
generate(),
206,
mimetype=audio_type,
content_type=audio_type,
direct_passthrough=True,
)
response.headers.add("Content-Range", f"bytes {0}-{chunk_size}/{file_size}")
response.headers.add("Accept-Ranges", "bytes")
response.headers.add("X-Transcoded-Bitrate", bitrate)
return response
def send_file_as_chunks(filepath: str) -> Response:
"""
Returns a Response object that streams the file in chunks.
"""
# NOTE: +1 makes sure the last byte is included in the range.
# NOTE: -1 is used to convert the end index to a 0-based index.
chunk_size = 1024 * 512 # 0.5MB
# Get file size
file_size = os.path.getsize(filepath)
start = 0
end = chunk_size
# Read range header
range_header = request.headers.get("Range")
if range_header:
start = get_start_range(range_header)
# If start + chunk_size is greater than file_size,
# set end to file_size - 1
_end = start + chunk_size - 1
end = file_size - 1 if _end > file_size else _end
def generate_chunks():
with open(filepath, "rb") as file:
file.seek(start)
remaining_bytes = end - start + 1
retry_count = 0
max_retries = 10 # 5 * 100ms = 500ms total wait time
while remaining_bytes > 0 or retry_count < max_retries:
if retry_count == max_retries:
print("💚 sending final chunk! ...")
pos = file.tell()
chunk = file.read(os.path.getsize(filepath) - pos)
return chunk, pos, True
if remaining_bytes < chunk_size:
time.sleep(0.25)
retry_count += 1
remaining_bytes = os.path.getsize(filepath) - file.tell()
continue
chunk = file.read(min(chunk_size, remaining_bytes))
if chunk:
remaining_bytes -= len(chunk)
return chunk, file.tell(), False
else:
# If no data is read, wait for 100ms before retrying
time.sleep(0.25)
retry_count += 1
# update remaining bytes
remaining_bytes = os.path.getsize(filepath) - file.tell()
print(f"▶ Remaining bytes: {remaining_bytes}")
return None, 0, True
data, position, is_final = generate_chunks()
audio_type = guess_mime_type(filepath)
response = Response(
response=data,
status=206, # Partial Content status code
mimetype=audio_type,
content_type=audio_type,
direct_passthrough=True,
)
bytes_to_add = chunk_size if not is_final else 0
response.headers.add(
"Content-Range",
f"bytes {start}-{position}/{os.path.getsize(filepath) + bytes_to_add}",
)
response.headers.add("Access-Control-Expose-Headers", "Content-Range")
response.headers.add("Accept-Ranges", "bytes")
return response
def get_start_range(range_header: str):
try:
range_start, range_end = range_header.strip().split("=")[1].split("-")
return int(range_start)
except ValueError:
return 0
class GetAudioSilenceBody(BaseModel):
ending_file: str = Field(description="The ending file's path")
starting_file: str = Field(description="The beginning file's path")
@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
return get_silence_paddings(ending_file, starting_file)
+402
View File
@@ -0,0 +1,402 @@
"""Unified multi-service downloader API backed by durable download jobs."""
from __future__ import annotations
import asyncio
from collections import Counter, defaultdict
from flask import Blueprint, jsonify, request
from flask_jwt_extended import get_jwt_identity
from swingmusic.services.download_jobs import download_job_manager
from swingmusic.services.spotify_downloader import spotify_downloader
from swingmusic.services.universal_url_parser import universal_url_parser
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.hashing import create_hash
universal_downloader_bp = Blueprint(
"universal_downloader", __name__, url_prefix="/api/universal"
)
def _current_userid() -> int:
try:
identity = get_jwt_identity()
if isinstance(identity, dict) and identity.get("id") is not None:
return int(identity["id"])
except Exception:
pass
return get_current_userid()
def _quality_to_job(quality: str | None) -> tuple[str, str]:
quality = (quality or "high").lower()
mapping = {
"lossless": ("lossless", "flac"),
"high": ("high", "mp3"),
"medium": ("medium", "mp3"),
"low": ("low", "mp3"),
}
return mapping.get(quality, ("high", "mp3"))
def _serialize_jobs(jobs: list[dict]) -> list[dict]:
serialized = []
for job in jobs:
payload = job.get("payload") or {}
serialized.append(
{
"id": str(job.get("id")),
"url": job.get("source_url"),
"title": job.get("title") or payload.get("title"),
"artist": job.get("artist") or payload.get("artist"),
"album": job.get("album") or payload.get("album"),
"service": job.get("source") or payload.get("service") or "generic",
"item_type": job.get("item_type")
or payload.get("item_type")
or "track",
"quality": job.get("quality") or "high",
"status": job.get("state"),
"state": job.get("state"),
"progress": job.get("progress") or 0,
"error_message": job.get("error"),
"file_path": job.get("target_path"),
"created_at": job.get("created_at"),
"started_at": job.get("started_at"),
"finished_at": job.get("finished_at"),
}
)
return serialized
@universal_downloader_bp.route("/download", methods=["POST"])
def add_download():
data = request.get_json() or {}
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "URL is required"}), 400
parsed = universal_url_parser.parse_url(url)
if not parsed:
return jsonify({"error": "Unsupported URL format"}), 400
quality, codec = _quality_to_job(data.get("quality"))
output_dir = data.get("output_dir")
userid = _current_userid()
title = None
artist = None
album = None
trackhash = None
if parsed.service.value == "spotify":
metadata = asyncio.run(spotify_downloader.get_metadata(url))
if metadata:
title = metadata.title
artist = metadata.artist
album = metadata.album
if metadata.item_type == "track" and title and artist:
trackhash = create_hash(title, album or "", artist)
job_id = download_job_manager.enqueue(
userid=userid,
source_url=url,
source=parsed.service.value,
quality=quality,
codec=codec,
trackhash=trackhash,
title=title,
artist=artist,
album=album,
item_type=parsed.item_type,
target_path=output_dir,
payload={
"service": parsed.service.value,
"item_type": parsed.item_type,
"service_id": parsed.id,
"metadata": parsed.metadata,
},
)
return jsonify(
{
"success": True,
"item_id": str(job_id),
"service": parsed.service.value,
"item_type": parsed.item_type,
"message": f"Added to download queue from {parsed.service.value}",
}
)
@universal_downloader_bp.route("/metadata", methods=["POST"])
def get_metadata():
data = request.get_json() or {}
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "URL is required"}), 400
parsed = universal_url_parser.parse_url(url)
if not parsed:
return jsonify({"error": "Unsupported URL format"}), 400
if parsed.service.value == "spotify":
metadata = asyncio.run(spotify_downloader.get_metadata(url))
if metadata:
return jsonify(
{
"success": True,
"service": "spotify",
"service_id": metadata.spotify_id,
"item_type": metadata.item_type,
"title": metadata.title,
"artist": metadata.artist,
"album": metadata.album,
"duration_ms": metadata.duration_ms,
"image_url": metadata.image_url,
"release_date": metadata.release_date,
"explicit": metadata.is_explicit,
"preview_url": metadata.preview_url,
"original_url": url,
}
)
return jsonify(
{
"success": True,
"service": parsed.service.value,
"service_id": parsed.id,
"item_type": parsed.item_type,
"title": f"{parsed.service.value.title()} {parsed.item_type.title()}",
"artist": "Unknown Artist",
"album": "",
"duration_ms": None,
"image_url": None,
"release_date": None,
"explicit": False,
"preview_url": None,
"original_url": url,
}
)
@universal_downloader_bp.route("/queue", methods=["GET"])
def get_queue_status():
userid = _current_userid()
jobs = download_job_manager.list_jobs(userid, limit=500)
queued = [job for job in jobs if job["state"] in {"queued", "downloading"}]
active = [job for job in jobs if job["state"] == "downloading"]
history = [
job for job in jobs if job["state"] in {"completed", "failed", "cancelled"}
]
return jsonify(
{
"queue_length": len([job for job in jobs if job["state"] == "queued"]),
"active_downloads": len(active),
"queue": _serialize_jobs(queued),
"pending": _serialize_jobs(
[job for job in jobs if job["state"] == "queued"]
),
"active": _serialize_jobs(active),
"history": _serialize_jobs(history),
}
)
@universal_downloader_bp.route("/queue/<item_id>/cancel", methods=["POST"])
def cancel_download(item_id: str):
userid = _current_userid()
try:
success = download_job_manager.cancel(int(item_id), userid)
except ValueError:
success = False
if success:
return jsonify({"success": True, "message": "Download cancelled"})
return jsonify({"error": "Download not found or cannot be cancelled"}), 404
@universal_downloader_bp.route("/queue/<item_id>/retry", methods=["POST"])
def retry_download(item_id: str):
userid = _current_userid()
try:
success = download_job_manager.retry(int(item_id), userid)
except ValueError:
success = False
if success:
return jsonify({"success": True, "message": "Download retry added to queue"})
return jsonify({"error": "Download not found or cannot be retried"}), 404
@universal_downloader_bp.route("/history", methods=["GET"])
def get_download_history():
limit = min(int(request.args.get("limit", 100)), 500)
offset = int(request.args.get("offset", 0))
userid = _current_userid()
jobs = download_job_manager.list_jobs(userid, limit=1000)
history = [
job for job in jobs if job["state"] in {"completed", "failed", "cancelled"}
]
sliced = history[offset : offset + limit]
return jsonify(
{
"downloads": _serialize_jobs(sliced),
"total": len(history),
"limit": limit,
"offset": offset,
}
)
@universal_downloader_bp.route("/services", methods=["GET"])
def get_supported_services():
services = universal_url_parser.get_supported_services()
return jsonify({"services": services, "total": len(services)})
@universal_downloader_bp.route("/services/<service_name>/enable", methods=["POST"])
def enable_service(service_name: str):
return jsonify({"success": True, "message": f"{service_name} service enabled"})
@universal_downloader_bp.route("/services/<service_name>/disable", methods=["POST"])
def disable_service(service_name: str):
return jsonify({"success": True, "message": f"{service_name} service disabled"})
@universal_downloader_bp.route(
"/services/<service_name>/config", methods=["GET", "POST"]
)
def service_config(service_name: str):
if request.method == "GET":
return jsonify(
{
"service": service_name,
"display_name": service_name.replace("_", " ").title(),
"enabled": True,
"priority": 0,
"supported_types": [],
"features": ["metadata", "download"],
"config": {},
}
)
return jsonify({"success": True, "message": "Service configuration updated"})
@universal_downloader_bp.route("/validate-url", methods=["POST"])
def validate_url():
data = request.get_json() or {}
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "URL is required"}), 400
parsed = universal_url_parser.parse_url(url)
if parsed:
return jsonify(
{
"valid": True,
"service": parsed.service.value,
"item_type": parsed.item_type,
"id": parsed.id,
"metadata": parsed.metadata,
}
)
return jsonify({"valid": False, "error": "Unsupported URL format"})
@universal_downloader_bp.route("/statistics", methods=["GET"])
def get_statistics():
userid = _current_userid()
jobs = download_job_manager.list_jobs(userid, limit=1000)
stats: dict[str, dict[str, int]] = defaultdict(dict)
grouped = defaultdict(Counter)
for job in jobs:
source = job.get("source") or "generic"
state = job.get("state") or "unknown"
grouped[source][state] += 1
for source, counts in grouped.items():
stats[source] = dict(counts)
return jsonify({"statistics": stats})
@universal_downloader_bp.route("/batch", methods=["POST"])
def batch_download():
data = request.get_json() or {}
urls = data.get("urls") or []
if not isinstance(urls, list) or len(urls) == 0:
return jsonify({"error": "URLs array is required"}), 400
quality = data.get("quality")
output_dir = data.get("output_dir")
results = []
for url in urls:
value = (url or "").strip()
if not value:
continue
parsed = universal_url_parser.parse_url(value)
if not parsed:
results.append(
{"url": value, "success": False, "error": "Unsupported URL format"}
)
continue
quality_name, codec = _quality_to_job(quality)
userid = _current_userid()
job_id = download_job_manager.enqueue(
userid=userid,
source_url=value,
source=parsed.service.value,
quality=quality_name,
codec=codec,
item_type=parsed.item_type,
target_path=output_dir,
payload={
"service": parsed.service.value,
"item_type": parsed.item_type,
"service_id": parsed.id,
"metadata": parsed.metadata,
},
)
results.append(
{
"url": value,
"success": True,
"item_id": str(job_id),
"service": parsed.service.value,
"item_type": parsed.item_type,
}
)
successful = sum(1 for item in results if item["success"])
failed = len(results) - successful
return jsonify(
{
"total": len(results),
"successful": successful,
"failed": failed,
"results": results,
}
)
def register_universal_downloader_api(app):
app.register_blueprint(universal_downloader_bp)
+326
View File
@@ -0,0 +1,326 @@
"""
Update Tracking API Endpoints
Provides stable endpoints for following artists, update preferences,
recent release updates, and dashboard statistics.
"""
from __future__ import annotations
import csv
import io
import logging
from typing import Any
from flask import Blueprint, Response, jsonify, request
from swingmusic.services.update_tracker import (
VALID_CHECK_FREQUENCIES,
VALID_FOLLOW_LEVELS,
VALID_QUALITY_VALUES,
VALID_RELEASE_TYPES,
update_tracker,
)
from swingmusic.utils.auth import get_current_userid
logger = logging.getLogger(__name__)
update_tracking_bp = Blueprint("update_tracking", __name__, url_prefix="/api/updates")
def _error(message: str, status: int = 400):
return jsonify({"error": message}), status
def _user_id() -> int:
return int(get_current_userid())
def _safe_limit(value: Any, default: int, max_value: int) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
parsed = default
return max(0, min(parsed, max_value))
@update_tracking_bp.post("/follow-artist")
def follow_artist():
data = request.get_json(silent=True) or {}
artist_id = str(data.get("artist_id") or "").strip()
if not artist_id:
return _error("artist_id is required")
payload = {
"user_id": _user_id(),
"artist_id": artist_id,
"artist_name": str(data.get("artist_name") or artist_id),
"follow_level": str(data.get("follow_level") or "followed"),
"auto_download": bool(data.get("auto_download", False)),
"preferred_quality": str(data.get("preferred_quality") or "flac"),
"notification_preferences": data.get("notification_preferences"),
"image": data.get("image"),
}
if payload["follow_level"] not in VALID_FOLLOW_LEVELS:
return _error("Invalid follow_level")
if payload["preferred_quality"] not in VALID_QUALITY_VALUES:
return _error("Invalid preferred_quality")
success = update_tracker.follow_artist(payload)
if not success:
return _error("Failed to follow artist", 500)
return jsonify(
{
"message": "Artist followed successfully",
"artist_id": artist_id,
}
)
@update_tracking_bp.post("/unfollow-artist")
def unfollow_artist():
data = request.get_json(silent=True) or {}
artist_id = str(data.get("artist_id") or "").strip()
if not artist_id:
return _error("artist_id is required")
success = update_tracker.unfollow_artist(_user_id(), artist_id)
if not success:
return _error("Artist not followed", 404)
return jsonify(
{
"message": "Artist unfollowed successfully",
"artist_id": artist_id,
}
)
@update_tracking_bp.get("/recent")
def get_recent_updates():
limit = _safe_limit(request.args.get("limit"), default=20, max_value=100)
offset = _safe_limit(request.args.get("offset"), default=0, max_value=100000)
release_type = request.args.get("release_type")
unread_only = str(request.args.get("unread_only", "false")).lower() == "true"
if release_type and release_type not in VALID_RELEASE_TYPES:
return _error("Invalid release_type")
updates = update_tracker.get_user_updates(
user_id=_user_id(),
limit=limit,
offset=offset,
release_type=release_type,
unread_only=unread_only,
)
return jsonify(
{
"updates": updates,
"limit": limit,
"offset": offset,
"total": len(updates),
}
)
@update_tracking_bp.get("/settings")
def get_settings():
return jsonify(update_tracker.get_user_settings(_user_id()))
@update_tracking_bp.post("/settings")
def update_settings():
data = request.get_json(silent=True) or {}
check_frequency = data.get("checkFrequency", data.get("check_frequency"))
if check_frequency and check_frequency not in VALID_CHECK_FREQUENCIES:
return _error("Invalid checkFrequency")
quality_preference = data.get("qualityPreference", data.get("quality_preference"))
if quality_preference and quality_preference not in VALID_QUALITY_VALUES:
return _error("Invalid qualityPreference")
if not update_tracker.update_user_settings(_user_id(), data):
return _error("Failed to update settings", 500)
return jsonify(
{
"message": "Settings updated successfully",
"settings": update_tracker.get_user_settings(_user_id()),
}
)
@update_tracking_bp.post("/auto-download/<release_id>")
def auto_download_release(release_id: str):
if not update_tracker.auto_download_release(_user_id(), release_id):
return _error("Release not found", 404)
return jsonify(
{
"message": "Download queued successfully",
"release_id": release_id,
}
)
@update_tracking_bp.get("/stats")
def get_update_stats():
stats = update_tracker.get_user_stats(_user_id())
return jsonify({"stats": stats})
@update_tracking_bp.get("/followed-artists")
def get_followed_artists():
limit = _safe_limit(request.args.get("limit"), default=50, max_value=200)
offset = _safe_limit(request.args.get("offset"), default=0, max_value=100000)
follow_level = request.args.get("follow_level")
if follow_level and follow_level not in VALID_FOLLOW_LEVELS:
return _error("Invalid follow_level")
artists = update_tracker.get_followed_artists(
user_id=_user_id(),
limit=limit,
offset=offset,
follow_level=follow_level,
)
return jsonify(
{
"artists": artists,
"limit": limit,
"offset": offset,
"total": len(artists),
}
)
@update_tracking_bp.get("/artist/<artist_id>/follow-status")
def get_artist_follow_status(artist_id: str):
status = update_tracker.get_artist_follow_status(_user_id(), artist_id)
if status:
return jsonify(status)
return jsonify(
{
"is_following": False,
"artist_id": artist_id,
"follow_level": "followed",
"auto_download_new_releases": False,
"preferred_quality": "flac",
}
)
@update_tracking_bp.route("/artist/<artist_id>", methods=["POST", "PUT"])
def update_artist_follow(artist_id: str):
data = request.get_json(silent=True) or {}
follow_level = data.get("follow_level")
if follow_level and follow_level not in VALID_FOLLOW_LEVELS:
return _error("Invalid follow_level")
preferred_quality = data.get("preferred_quality")
if preferred_quality and preferred_quality not in VALID_QUALITY_VALUES:
return _error("Invalid preferred_quality")
success = update_tracker.update_artist_follow(_user_id(), artist_id, data)
if not success:
return _error("Failed to update artist", 500)
return jsonify(
{
"message": "Artist follow settings updated",
"artist_id": artist_id,
"settings": data,
}
)
@update_tracking_bp.get("/search/artists")
def search_artists():
query = str(request.args.get("q") or "").strip()
limit = _safe_limit(request.args.get("limit"), default=20, max_value=100)
artists = update_tracker.search_artists(query, _user_id(), limit=limit)
return jsonify(
{
"artists": artists,
"query": query,
}
)
@update_tracking_bp.post("/release/<release_id>/mark-read")
def mark_release_read(release_id: str):
if not update_tracker.mark_release_read(_user_id(), release_id):
return _error("Failed to mark release as read", 500)
return jsonify(
{
"message": "Marked release as read",
"release_id": release_id,
}
)
@update_tracking_bp.post("/notifications/mark-all-read")
def mark_all_read():
count = update_tracker.mark_all_notifications_read(_user_id())
return jsonify(
{
"message": "All notifications marked as read",
"updated": count,
}
)
@update_tracking_bp.get("/export/followed-artists")
def export_followed_artists():
export_format = str(request.args.get("format") or "json").lower()
artists = update_tracker.export_followed_artists(_user_id())
if export_format == "csv":
output = io.StringIO()
writer = csv.DictWriter(
output,
fieldnames=[
"artist_id",
"artist_name",
"follow_level",
"auto_download",
"preferred_quality",
"follow_date",
],
)
writer.writeheader()
writer.writerows(artists)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={
"Content-Disposition": "attachment; filename=followed_artists.csv",
},
)
return jsonify({"followed_artists": artists})
@update_tracking_bp.errorhandler(404)
def not_found(_error):
return jsonify({"error": "Endpoint not found"}), 404
@update_tracking_bp.errorhandler(500)
def internal_error(_error):
return jsonify({"error": "Internal server error"}), 500
+401
View File
@@ -0,0 +1,401 @@
"""
Contains all the file upload routes for manual music upload functionality.
"""
import os
from pathlib import Path
from flask import jsonify, request
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from werkzeug.utils import secure_filename
from swingmusic.api.auth import admin_required
from swingmusic.config import UserConfig
tag = Tag(name="Upload", description="Manual music file upload functionality")
api = APIBlueprint("upload", __name__, url_prefix="/upload", abp_tags=[tag])
# Allowed audio file extensions
ALLOWED_EXTENSIONS = {
"mp3",
"flac",
"wav",
"aac",
"m4a",
"ogg",
"wma",
"opus",
"aiff",
"au",
"ra",
"3gp",
"amr",
"awb",
"dct",
"dvf",
"m4p",
"mmf",
"mpc",
"msv",
"nmf",
"nsf",
"qcp",
"rm",
"sln",
"vox",
"wv",
}
# Maximum file size (100MB)
MAX_FILE_SIZE = 100 * 1024 * 1024
def is_allowed_file(filename: str) -> bool:
"""Check if file has an allowed audio extension."""
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
def is_path_within_root_dirs(filepath: str) -> bool:
"""
Check if a filepath is within one of the configured root directories.
Prevents directory traversal attacks.
"""
config = UserConfig()
resolved_path = Path(filepath).resolve()
for root_dir in config.rootDirs:
if root_dir == "$home":
root_path = Path.home().resolve()
else:
root_path = Path(root_dir).resolve()
# Check if resolved_path is the root or a child of root
if resolved_path == root_path or root_path in resolved_path.parents:
return True
return False
def _default_upload_dir(config: UserConfig) -> Path:
"""Resolve the default upload directory from user configuration."""
if hasattr(config, "uploadDir") and config.uploadDir:
return Path(config.uploadDir).expanduser()
if config.rootDirs:
first_root = config.rootDirs[0]
if first_root == "$home":
return Path.home() / "Music"
return Path(first_root).expanduser()
return Path.home() / "Music"
def resolve_upload_directory(target_dir: str | None = None) -> Path:
"""
Resolve and validate upload directory.
If target_dir is provided, it must resolve within configured root directories.
"""
config = UserConfig()
if target_dir:
target_dir = target_dir.strip()
if target_dir:
if target_dir == "$home":
upload_dir = _default_upload_dir(config).resolve()
else:
upload_dir = Path(target_dir).expanduser().resolve()
if not is_path_within_root_dirs(str(upload_dir)):
raise ValueError(
"Target upload directory must be inside configured library folders"
)
upload_dir.mkdir(parents=True, exist_ok=True)
return upload_dir
upload_dir = _default_upload_dir(config).resolve()
upload_dir.mkdir(parents=True, exist_ok=True)
return upload_dir
class UploadResponse(BaseModel):
success: bool = Field(description="Whether the upload was successful")
message: str = Field(description="Status message")
track_id: str | None = Field(None, description="ID of the added track")
filename: str | None = Field(None, description="Name of the uploaded file")
class BatchUploadResponse(BaseModel):
success: bool = Field(description="Whether the batch upload was successful")
message: str = Field(description="Status message")
uploaded_files: list[UploadResponse] = Field(description="List of upload results")
failed_files: list[str] = Field(description="List of failed files")
@api.post("/single")
@admin_required()
def upload_single_file():
"""
Upload a single music file
Uploads a single music file to the configured music folder and adds it to the library.
Supports drag-and-drop and file selection.
"""
try:
if "file" not in request.files:
return jsonify({"success": False, "message": "No file provided"}), 400
file = request.files["file"]
if file.filename == "":
return jsonify({"success": False, "message": "No file selected"}), 400
# Check file extension
if not is_allowed_file(file.filename):
return jsonify(
{
"success": False,
"message": f"File type not allowed. Supported formats: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
}
), 400
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
return jsonify(
{
"success": False,
"message": f"File too large. Maximum size is {MAX_FILE_SIZE // (1024 * 1024)}MB",
}
), 400
target_dir = request.form.get("target_dir")
try:
upload_dir = resolve_upload_directory(target_dir)
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 400
# Secure the filename and create full path
filename = secure_filename(file.filename)
file_path = upload_dir / filename
# Handle filename conflicts
counter = 1
original_filename = filename
while file_path.exists():
name, ext = os.path.splitext(original_filename)
filename = f"{name}_{counter}{ext}"
file_path = upload_dir / filename
counter += 1
# Save the file
file.save(file_path)
# Extract metadata and add to library
try:
# This would trigger a library rescan for the specific file
# For now, we'll return the file info and let the frontend handle the refresh
track_info = {
"filepath": str(file_path),
"filename": filename,
"size": file_size,
}
return jsonify(
{
"success": True,
"message": f"File '{filename}' uploaded successfully",
"filename": filename,
"filepath": str(file_path),
"track_info": track_info,
}
)
except Exception as e:
# If metadata extraction fails, still return success for the upload
return jsonify(
{
"success": True,
"message": f"File '{filename}' uploaded successfully (metadata extraction failed)",
"filename": filename,
"filepath": str(file_path),
"warning": f"Metadata extraction failed: {str(e)}",
}
)
except Exception as e:
return jsonify({"success": False, "message": f"Upload failed: {str(e)}"}), 500
@api.post("/batch")
@admin_required()
def upload_multiple_files():
"""
Upload multiple music files
Uploads multiple music files to the configured music folder and adds them to the library.
Supports drag-and-drop of multiple files.
"""
try:
if "files" not in request.files:
return jsonify({"success": False, "message": "No files provided"}), 400
files = request.files.getlist("files")
if not files:
return jsonify({"success": False, "message": "No files selected"}), 400
uploaded_files = []
failed_files = []
target_dir = request.form.get("target_dir")
try:
upload_dir = resolve_upload_directory(target_dir)
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 400
for file in files:
if file.filename == "":
continue
try:
# Check file extension
if not is_allowed_file(file.filename):
failed_files.append(f"{file.filename} - File type not allowed")
continue
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
failed_files.append(f"{file.filename} - File too large")
continue
# Secure filename and handle conflicts
filename = secure_filename(file.filename)
file_path = upload_dir / filename
counter = 1
original_filename = filename
while file_path.exists():
name, ext = os.path.splitext(original_filename)
filename = f"{name}_{counter}{ext}"
file_path = upload_dir / filename
counter += 1
# Save the file
file.save(file_path)
uploaded_files.append(
{
"success": True,
"message": f"File '{filename}' uploaded successfully",
"filename": filename,
"filepath": str(file_path),
"size": file_size,
}
)
except Exception as e:
failed_files.append(f"{file.filename} - {str(e)}")
total_files = len(uploaded_files) + len(failed_files)
success_count = len(uploaded_files)
return jsonify(
{
"success": len(uploaded_files) > 0,
"message": f"Uploaded {success_count} of {total_files} files",
"uploaded_files": uploaded_files,
"failed_files": failed_files,
}
)
except Exception as e:
return jsonify(
{"success": False, "message": f"Batch upload failed: {str(e)}"}
), 500
@api.get("/config")
def get_upload_config():
"""
Get upload configuration
Returns the current upload configuration including allowed file types,
maximum file size, and upload directory.
"""
upload_dir = str(resolve_upload_directory())
return jsonify(
{
"allowed_extensions": sorted(ALLOWED_EXTENSIONS),
"max_file_size": MAX_FILE_SIZE,
"max_file_size_mb": MAX_FILE_SIZE // (1024 * 1024),
"upload_directory": upload_dir,
"supported_formats": [
{"ext": ext, "description": get_format_description(ext)}
for ext in sorted(ALLOWED_EXTENSIONS)
],
}
)
def get_format_description(extension: str) -> str:
"""Get a user-friendly description for a file format."""
descriptions = {
"mp3": "MP3 Audio",
"flac": "FLAC Lossless Audio",
"wav": "WAV Audio",
"aac": "AAC Audio",
"m4a": "M4A Audio",
"ogg": "OGG Vorbis Audio",
"wma": "WMA Audio",
"opus": "Opus Audio",
"aiff": "AIFF Audio",
"au": "AU Audio",
"ra": "RealAudio",
"3gp": "3GP Audio",
"amr": "AMR Audio",
"awb": "AWB Audio",
"dct": "DCT Audio",
"dvf": "DVF Audio",
"m4p": "M4P Audio",
"mmf": "MMF Audio",
"mpc": "MPC Audio",
"msv": "MSV Audio",
"nmf": "NMF Audio",
"nsf": "NSF Audio",
"qcp": "QCP Audio",
"rm": "RealMedia Audio",
"sln": "SLN Audio",
"vox": "VOX Audio",
"wv": "WavPack Audio",
}
return descriptions.get(extension.lower(), f"{extension.upper()} Audio")
@api.post("/rescan")
@admin_required()
def trigger_library_rescan():
"""
Trigger library rescan
Triggers a library rescan to detect newly uploaded files.
"""
try:
# This would integrate with the existing library scanning system
# For now, return a success response
return jsonify(
{"success": True, "message": "Library rescan triggered successfully"}
)
except Exception as e:
return jsonify(
{"success": False, "message": f"Failed to trigger library rescan: {str(e)}"}
), 500