mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-09-12 12:18:15 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
Pipfile.lock
|
||||
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# Flask specific
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Static files and media
|
||||
static/
|
||||
media/
|
||||
uploads/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
|
||||
# Local configuration
|
||||
config_local.py
|
||||
settings_local.py
|
||||
.secrets
|
||||
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
from importlib import metadata as importlib_metadata
|
||||
|
||||
try:
|
||||
__version__ = importlib_metadata.version("swingmusic")
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
# fallback to version.txt
|
||||
version_file = os.path.join(os.path.dirname(__file__), "..", "..", "version.txt")
|
||||
with open(version_file) as f:
|
||||
__version__ = f.read().strip()
|
||||
@@ -0,0 +1,93 @@
|
||||
import argparse
|
||||
import contextlib
|
||||
import multiprocessing
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
from swingmusic import settings
|
||||
from swingmusic import tools as swing_tools
|
||||
from swingmusic.logger import setup_logger
|
||||
from swingmusic.settings import AssetHandler, Metadata
|
||||
from swingmusic.start_swingmusic import start_swingmusic
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="swingmusic",
|
||||
description="Awesome Music",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-v", "--version", action="version", version=f"swingmusic v{Metadata.version}"
|
||||
)
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host to run the app on.")
|
||||
parser.add_argument(
|
||||
"--port", default=1970, help="HTTP port to run the app on.", type=int
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="If swingmusic should start in debug mode",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=settings.Paths.get_default_config_parent_dir(),
|
||||
help="The directory to setup the config folder.",
|
||||
type=pathlib.Path,
|
||||
)
|
||||
parser.add_argument("--client", help="Path to the Web UI folder.", type=pathlib.Path)
|
||||
|
||||
tools = parser.add_argument_group(title="Tools")
|
||||
tools.add_argument("--password-reset", help="Reset the password.", action="store_true")
|
||||
|
||||
|
||||
def run(*args, **kwargs):
|
||||
"""
|
||||
Swing Music entry point
|
||||
"""
|
||||
args = parser.parse_args()
|
||||
args = vars(args)
|
||||
|
||||
config_parent = args["config"]
|
||||
client_path = args["client"]
|
||||
|
||||
# INFO: Validate client path
|
||||
if client_path is not None:
|
||||
client_path = pathlib.Path(client_path).resolve()
|
||||
|
||||
if not client_path.exists():
|
||||
print(
|
||||
f"Client path {client_path} does not exist. Please provide a valid path"
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
# INFO: check if client path has index.html
|
||||
if not (client_path / "index.html").exists():
|
||||
print(
|
||||
f"Client path {client_path} does not contain an index.html file. Please provide a valid path"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
settings.Paths(config_parent=config_parent, client_dir=client_path)
|
||||
AssetHandler.copy_assets_dir()
|
||||
AssetHandler.setup_default_client()
|
||||
|
||||
setup_logger(debug=args["debug"], app_dir=settings.Paths().config_dir)
|
||||
|
||||
# handle tools
|
||||
if args["password_reset"]:
|
||||
swing_tools.handle_password_reset(config_parent)
|
||||
sys.exit(0)
|
||||
|
||||
# start swingmusic
|
||||
start_swingmusic(host=args["host"], port=args["port"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
# `python -m swingmusic` may run in environments that already selected a
|
||||
# multiprocessing context (for example test runners / embedded launchers).
|
||||
# Keep CLI startup resilient instead of crashing with RuntimeError.
|
||||
with contextlib.suppress(RuntimeError):
|
||||
multiprocessing.set_start_method("spawn")
|
||||
run()
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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)})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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]}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)}
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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!",
|
||||
}
|
||||
@@ -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())}
|
||||
@@ -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"}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,502 @@
|
||||
import datetime as dt
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from flask import Response, jsonify, request
|
||||
from flask_compress import Compress
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import (
|
||||
JWTManager,
|
||||
create_access_token,
|
||||
get_jwt,
|
||||
get_jwt_identity,
|
||||
set_access_cookies,
|
||||
verify_jwt_in_request,
|
||||
)
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.errors import RateLimitExceeded
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_openapi3 import Info, OpenAPI
|
||||
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.db.userdata import UserTable
|
||||
from swingmusic.services.setup_state import get_setup_status, is_setup_complete
|
||||
from swingmusic.settings import Metadata, Paths
|
||||
from swingmusic.utils.paths import get_client_files_extensions
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
# # # # # # # # # # # # # # # # # #
|
||||
# Grouped configuration function #
|
||||
# # # # # # # # # # # # # # # # # #
|
||||
|
||||
|
||||
def config_app(web):
|
||||
|
||||
# CORS - configurable via environment variable
|
||||
cors_origins = os.getenv("SWINGMUSIC_CORS_ORIGINS", "*")
|
||||
if cors_origins != "*":
|
||||
# Parse comma-separated list of origins
|
||||
cors_origins = [
|
||||
origin.strip() for origin in cors_origins.split(",") if origin.strip()
|
||||
]
|
||||
CORS(web, origins=cors_origins, supports_credentials=True)
|
||||
|
||||
# RESPONSE COMPRESSION
|
||||
# Only compress JSON responses
|
||||
Compress(web)
|
||||
web.config["COMPRESS_MIMETYPES"] = [
|
||||
"application/json",
|
||||
]
|
||||
|
||||
|
||||
def config_jwt(web):
|
||||
# JWT CONFIGS
|
||||
web.config["JWT_VERIFY_SUB"] = False
|
||||
web.config["JWT_SECRET_KEY"] = UserConfig().serverId
|
||||
web.config["JWT_TOKEN_LOCATION"] = ["cookies", "headers"]
|
||||
# Enable CSRF protection for cookie-based auth
|
||||
web.config["JWT_COOKIE_CSRF_PROTECT"] = True
|
||||
web.config["JWT_CSRF_IN_COOKIES"] = True
|
||||
web.config["JWT_CSRF_HEADER_NAME"] = "X-CSRF-TOKEN"
|
||||
web.config["JWT_SESSION_COOKIE"] = False
|
||||
|
||||
jwt_expiry = int(dt.timedelta(days=30).total_seconds())
|
||||
web.config["JWT_ACCESS_TOKEN_EXPIRES"] = jwt_expiry
|
||||
|
||||
jwt = JWTManager(web)
|
||||
|
||||
@jwt.user_lookup_loader
|
||||
def user_lookup_callback(_jwt_header, jwt_data):
|
||||
identity = jwt_data["sub"]
|
||||
userid = identity["id"]
|
||||
user = UserTable.get_by_id(userid)
|
||||
|
||||
if user:
|
||||
return user.todict()
|
||||
|
||||
|
||||
# Rate limiter instance - configured in build()
|
||||
limiter: Limiter | None = None
|
||||
|
||||
|
||||
def get_limiter() -> Limiter:
|
||||
"""Get the rate limiter instance."""
|
||||
global limiter
|
||||
if limiter is None:
|
||||
raise RuntimeError("Limiter not initialized. Call build() first.")
|
||||
return limiter
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiRegistration:
|
||||
module_path: str
|
||||
symbol: str
|
||||
register_as: Literal["api", "blueprint", "callable"]
|
||||
required: bool = True
|
||||
feature_flag: str | None = None
|
||||
enabled_by_default: bool = True
|
||||
|
||||
|
||||
_BOOT_REGISTRATION_STATE: dict[str, list[str]] = {
|
||||
"registered": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
|
||||
def _feature_enabled(flag: str | None, default: bool = True) -> bool:
|
||||
if flag is None:
|
||||
return True
|
||||
|
||||
value = os.getenv(flag)
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
CORE_API_REGISTRATIONS: list[ApiRegistration] = [
|
||||
ApiRegistration("swingmusic.api.auth", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.setup", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.album", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.artist", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.stream", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.search", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.folder", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.playlist", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.favorites", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.imgserver", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.settings", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.colors", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.lyrics", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.backup_and_restore", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.collections", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.scrobble", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.home", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.getall", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.spotify", "spotify_bp", "api", required=False),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.spotify_settings", "spotify_settings_bp", "api", required=False
|
||||
),
|
||||
ApiRegistration("swingmusic.api.upload", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.downloads", "api", "api", required=True),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.music_catalog", "music_catalog_bp", "blueprint", required=True
|
||||
),
|
||||
ApiRegistration("swingmusic.api.plugins", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.plugins.lyrics", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.plugins.mixes", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.dragonfly", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.recently_played", "api", "api", required=False),
|
||||
]
|
||||
|
||||
|
||||
OPTIONAL_API_REGISTRATIONS: list[ApiRegistration] = [
|
||||
ApiRegistration(
|
||||
"swingmusic.api.enhanced_search",
|
||||
"register_enhanced_search_api",
|
||||
"callable",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_ENHANCED_SEARCH",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.universal_downloader",
|
||||
"register_universal_downloader_api",
|
||||
"callable",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_UNIVERSAL_DOWNLOADER",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.update_tracking",
|
||||
"update_tracking_bp",
|
||||
"blueprint",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_UPDATE_TRACKING",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.audio_quality",
|
||||
"audio_quality_bp",
|
||||
"blueprint",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_AUDIO_QUALITY",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.advanced_ux",
|
||||
"advanced_ux_bp",
|
||||
"blueprint",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_ADVANCED_UX",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.recap",
|
||||
"recap_bp",
|
||||
"blueprint",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_RECAP",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.mobile_offline",
|
||||
"mobile_offline_bp",
|
||||
"blueprint",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_MOBILE_OFFLINE",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _register_entry(web: OpenAPI, entry: ApiRegistration):
|
||||
if not _feature_enabled(entry.feature_flag, entry.enabled_by_default):
|
||||
log.info("Skipping feature-gated API module: %s", entry.module_path)
|
||||
return
|
||||
|
||||
try:
|
||||
module = importlib.import_module(entry.module_path)
|
||||
symbol = getattr(module, entry.symbol)
|
||||
|
||||
if entry.register_as == "api":
|
||||
web.register_api(symbol)
|
||||
elif entry.register_as == "blueprint":
|
||||
web.register_blueprint(symbol)
|
||||
elif entry.register_as == "callable":
|
||||
symbol(web)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown register type: {entry.register_as}")
|
||||
|
||||
_BOOT_REGISTRATION_STATE["registered"].append(
|
||||
f"{entry.module_path}:{entry.symbol}"
|
||||
)
|
||||
except Exception as error:
|
||||
detail = f"{entry.module_path}:{entry.symbol} ({error})"
|
||||
_BOOT_REGISTRATION_STATE["failed"].append(detail)
|
||||
log.exception(
|
||||
"Failed to register API module %s.%s", entry.module_path, entry.symbol
|
||||
)
|
||||
|
||||
strict_boot = _feature_enabled("SWINGMUSIC_STRICT_BOOT", default=False)
|
||||
if entry.required and strict_boot:
|
||||
raise
|
||||
|
||||
|
||||
def load_endpoints(web: OpenAPI):
|
||||
_BOOT_REGISTRATION_STATE["registered"].clear()
|
||||
_BOOT_REGISTRATION_STATE["failed"].clear()
|
||||
|
||||
with web.app_context():
|
||||
for entry in CORE_API_REGISTRATIONS:
|
||||
_register_entry(web, entry)
|
||||
|
||||
for entry in OPTIONAL_API_REGISTRATIONS:
|
||||
_register_entry(web, entry)
|
||||
|
||||
# Keep client contracts stable even when optional modules are disabled.
|
||||
from swingmusic.api.optional_feature_fallbacks import (
|
||||
register_optional_feature_fallbacks,
|
||||
)
|
||||
|
||||
register_optional_feature_fallbacks(web)
|
||||
|
||||
|
||||
def run_boot_smoke_checks(web: OpenAPI):
|
||||
required_rules = {
|
||||
"/auth/login",
|
||||
"/auth/bootstrap/status",
|
||||
"/setup/status",
|
||||
"/api/downloads/jobs",
|
||||
"/api/catalog/search",
|
||||
}
|
||||
|
||||
current_rules = {rule.rule for rule in web.url_map.iter_rules()}
|
||||
missing_rules = sorted(required_rules - current_rules)
|
||||
|
||||
if missing_rules:
|
||||
log.error("Boot smoke check failed. Missing routes: %s", missing_rules)
|
||||
else:
|
||||
log.info("Boot smoke check passed (%s routes).", len(current_rules))
|
||||
|
||||
strict_boot = _feature_enabled("SWINGMUSIC_STRICT_BOOT", default=False)
|
||||
if strict_boot and (missing_rules or _BOOT_REGISTRATION_STATE["failed"]):
|
||||
raise RuntimeError(
|
||||
"Strict boot failed. Missing routes or API module registration failures detected."
|
||||
)
|
||||
|
||||
|
||||
# # # # # # # # # # #
|
||||
# Create App object #
|
||||
# # # # # # # # # # #
|
||||
|
||||
api_info = Info(
|
||||
title="Swing Music",
|
||||
version=f"v{Metadata.version}",
|
||||
description="The REST API exposed by your Swing Music server",
|
||||
)
|
||||
|
||||
app = OpenAPI(__name__, info=api_info, doc_prefix="/docs")
|
||||
|
||||
|
||||
def check_auth_need() -> bool:
|
||||
"""
|
||||
Check if the current request is for a static file.
|
||||
We do not need auth for index or static images of index.
|
||||
|
||||
:return: True if static file else False
|
||||
"""
|
||||
|
||||
# INFO: Routes that don't need authentication
|
||||
urls = {
|
||||
"/auth/login",
|
||||
"/auth/user",
|
||||
"/auth/users",
|
||||
"/auth/pair",
|
||||
"/auth/logout",
|
||||
"/auth/refresh",
|
||||
"/auth/bootstrap",
|
||||
"/auth/invite/accept",
|
||||
"/setup",
|
||||
"/docs",
|
||||
"/healthz",
|
||||
}
|
||||
files = {".webp", ".jpg", *get_client_files_extensions()}
|
||||
|
||||
urls = tuple(urls)
|
||||
files = tuple(files)
|
||||
|
||||
if request.path == "/" or request.path.endswith(files):
|
||||
return True
|
||||
|
||||
# if request path starts with any of the blacklisted routes, don't verify jwt
|
||||
return bool(request.path.startswith(urls))
|
||||
|
||||
|
||||
# # # # # # # # # # # # #
|
||||
# global endpoint logic #
|
||||
# # # # # # # # # # # # #
|
||||
|
||||
|
||||
@app.route("/<path:path>")
|
||||
def serve_client_files(path: str):
|
||||
"""
|
||||
Serves the static files in the client folder.
|
||||
"""
|
||||
|
||||
# Handle potential double /client path (e.g., '/client/some.js' -> '/client/client/some.js')
|
||||
# This can occur with certain proxy configurations
|
||||
if path.startswith("client/"):
|
||||
path = path[7:] # Remove duplicate 'client/' prefix
|
||||
|
||||
js_or_css = path.endswith(".js") or path.endswith(".css")
|
||||
|
||||
if not js_or_css:
|
||||
return app.send_static_file(path)
|
||||
|
||||
# INFO: Safari doesn't support gzip encoding
|
||||
# See issue: https://github.com/swingmx/swingmusic/issues/155
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
if "Safari" in user_agent and "Chrome" not in user_agent:
|
||||
return app.send_static_file(path)
|
||||
|
||||
if "gzip" in request.headers.get("Accept-Encoding", ""):
|
||||
gz_name = path + ".gz"
|
||||
gzipped_path = pathlib.Path(app.static_folder or "") / gz_name
|
||||
|
||||
if gzipped_path.exists():
|
||||
response = app.make_response(app.send_static_file(gz_name))
|
||||
response.headers["Content-Encoding"] = "gzip"
|
||||
return response
|
||||
|
||||
return app.send_static_file(path)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def serve_client():
|
||||
"""
|
||||
Serves the index.html file at `client/index.html`.
|
||||
"""
|
||||
return app.send_static_file("index.html")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
setup = get_setup_status()
|
||||
failed = list(_BOOT_REGISTRATION_STATE["failed"])
|
||||
|
||||
status_code = 200
|
||||
if failed and _feature_enabled("SWINGMUSIC_STRICT_BOOT", default=False):
|
||||
status_code = 503
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"ok": status_code == 200,
|
||||
"setup_completed": setup.get("setup_completed", False),
|
||||
"onboarding_required": setup.get("required", True),
|
||||
"registered_modules": list(_BOOT_REGISTRATION_STATE["registered"]),
|
||||
"failed_modules": failed,
|
||||
}
|
||||
),
|
||||
status_code,
|
||||
)
|
||||
|
||||
|
||||
@app.errorhandler(RateLimitExceeded)
|
||||
def handle_rate_limit(error: RateLimitExceeded):
|
||||
retry_after = getattr(error, "retry_after", None)
|
||||
response = jsonify(
|
||||
{
|
||||
"msg": "Too many requests. Please wait before trying again.",
|
||||
"error": "rate_limited",
|
||||
"retry_after": retry_after,
|
||||
}
|
||||
)
|
||||
if retry_after is not None:
|
||||
response.headers["Retry-After"] = str(retry_after)
|
||||
return response, 429
|
||||
|
||||
|
||||
def build() -> OpenAPI:
|
||||
"""
|
||||
Call this function to obtain the final flask/openapi object.
|
||||
|
||||
Do not import app directly as the static_folder can only be set
|
||||
when cli args are parsed.
|
||||
|
||||
:return: OpenApi object with all config set
|
||||
"""
|
||||
|
||||
# set late state config
|
||||
app.static_folder = Paths().client_path
|
||||
|
||||
@app.before_request
|
||||
def verify_auth():
|
||||
"""
|
||||
Verifies the JWT token before each request.
|
||||
"""
|
||||
|
||||
if check_auth_need():
|
||||
return
|
||||
|
||||
if not is_setup_complete():
|
||||
setup = get_setup_status()
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "setup_incomplete",
|
||||
"msg": "Initial setup must be completed before using product APIs.",
|
||||
"setup": setup,
|
||||
}
|
||||
),
|
||||
423,
|
||||
)
|
||||
|
||||
verify_jwt_in_request()
|
||||
|
||||
@app.after_request
|
||||
def refresh_expiring_jwt(response: Response):
|
||||
"""
|
||||
Refreshes the cookies JWT token after each request.
|
||||
"""
|
||||
|
||||
# INFO: If the request has an Authorization header, don't refresh the jwt
|
||||
# Request is probably from the mobile client or a third party
|
||||
if check_auth_need() or request.headers.get("Authorization"):
|
||||
return response
|
||||
|
||||
try:
|
||||
exp_timestamp = get_jwt()["exp"]
|
||||
until = dt.datetime.now(dt.UTC) + dt.timedelta(days=7)
|
||||
|
||||
if until.timestamp() > exp_timestamp:
|
||||
access_token = create_access_token(identity=get_jwt_identity())
|
||||
set_access_cookies(response, access_token)
|
||||
|
||||
return response
|
||||
except (RuntimeError, KeyError):
|
||||
return response
|
||||
|
||||
config_app(app)
|
||||
config_jwt(app)
|
||||
|
||||
# Initialize rate limiter
|
||||
global limiter
|
||||
rate_limit = os.getenv("SWINGMUSIC_RATE_LIMIT", "200 per hour;50 per minute")
|
||||
limiter = Limiter(
|
||||
app=app,
|
||||
key_func=get_remote_address,
|
||||
default_limits=[rate_limit],
|
||||
default_limits_exempt_when=check_auth_need,
|
||||
storage_uri="memory://",
|
||||
)
|
||||
|
||||
load_endpoints(app)
|
||||
run_boot_smoke_checks(app)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="defaultAlbumImage">
|
||||
<g id="defaultAlbumImage_2">
|
||||
<path id="Vector" d="M21 31.5C26.799 31.5 31.5 26.799 31.5 21C31.5 15.201 26.799 10.5 21 10.5C15.201 10.5 10.5 15.201 10.5 21C10.5 26.799 15.201 31.5 21 31.5Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M21 23.5C22.3807 23.5 23.5 22.3807 23.5 21C23.5 19.6193 22.3807 18.5 21 18.5C19.6193 18.5 18.5 19.6193 18.5 21C18.5 22.3807 19.6193 23.5 21 23.5Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 654 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="defaultPlaylistImage">
|
||||
<g id="defaultPlaylistImage_2">
|
||||
<g id="Group">
|
||||
<path id="Vector" d="M14.1 29.3C15.6464 29.3 16.9 28.0464 16.9 26.5C16.9 24.9536 15.6464 23.7 14.1 23.7C12.5536 23.7 11.3 24.9536 11.3 26.5C11.3 28.0464 12.5536 29.3 14.1 29.3Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round"/>
|
||||
<path id="Vector_2" d="M16.9 26.5V12.8" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
<path id="Vector_3" d="M21 24.2H29.3" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
<path id="Vector_4" d="M21 16.9H31.1" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
<path id="Vector_5" d="M21 20.5H30.2" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 737 B |
@@ -0,0 +1,165 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from dataclasses import dataclass, asdict, field, InitVar
|
||||
from swingmusic.data import ARTIST_SPLIT_IGNORE_LIST
|
||||
from swingmusic.settings import Paths, Singleton
|
||||
|
||||
|
||||
def load_artist_ignore_list_from_file(filepath: Path) -> set[str]:
|
||||
"""
|
||||
Loads artist names from a text file.
|
||||
|
||||
:params filepath: filepath to file
|
||||
:returns: Lines with content as ``set``, else empty ``set``
|
||||
"""
|
||||
if filepath.exists():
|
||||
text = filepath.read_text()
|
||||
return set([line.strip() for line in text.splitlines() if line.strip()])
|
||||
else:
|
||||
return set()
|
||||
|
||||
|
||||
def load_default_artist_ignore_list() -> set[str]:
|
||||
"""
|
||||
Loads the default artist-ignore-list from the text file.
|
||||
Returns an empty set if the file doesn't exist.
|
||||
"""
|
||||
return ARTIST_SPLIT_IGNORE_LIST
|
||||
|
||||
|
||||
def load_user_artist_ignore_list() -> set[str]:
|
||||
"""
|
||||
Loads the user-defined artist ignore list from the config directory.
|
||||
Returns an empty set if the file doesn't exist.
|
||||
"""
|
||||
user_file = Paths().config_dir / "artist_split_ignore.txt"
|
||||
if user_file.exists():
|
||||
lines = user_file.read_text().splitlines()
|
||||
return set([line.strip() for line in lines if line.strip()])
|
||||
else:
|
||||
return set()
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserConfig(metaclass=Singleton):
|
||||
_finished: bool = field(default=False, init=False) # if post init succesfully
|
||||
_config_path: InitVar[Path] = Path("")
|
||||
_artist_split_ignore_file_name: InitVar[str] = "artist_split_ignore.txt"
|
||||
# NOTE: only auth stuff are used (the others are still reading/writing to db)
|
||||
# Settings are progressively being migrated from database to config file
|
||||
# as needed for better persistence and cross-session state management
|
||||
|
||||
# auth stuff
|
||||
# NOTE: Don't expose the userId via the API
|
||||
serverId: str = ""
|
||||
usersOnLogin: bool = True
|
||||
|
||||
# lists
|
||||
rootDirs: list[str] = field(default_factory=list)
|
||||
excludeDirs: list[str] = field(default_factory=list)
|
||||
artistSeparators: set[str] = field(default_factory=lambda: {";", "/"})
|
||||
artistSplitIgnoreList: set[str] = field(
|
||||
# User-contributed ignore list: users can add entries via artist_split_ignore.txt
|
||||
# Future enhancement: could support community-sourced lists via optional sync
|
||||
default_factory=lambda: load_default_artist_ignore_list().union(
|
||||
load_user_artist_ignore_list()
|
||||
)
|
||||
)
|
||||
genreSeparators: set[str] = field(default_factory=lambda: {"/", ";", "&"})
|
||||
|
||||
# tracks
|
||||
extractFeaturedArtists: bool = True
|
||||
removeProdBy: bool = True
|
||||
removeRemasterInfo: bool = True
|
||||
|
||||
# albums
|
||||
mergeAlbums: bool = False
|
||||
cleanAlbumTitle: bool = True
|
||||
showAlbumsAsSingles: bool = False
|
||||
|
||||
# misc
|
||||
enablePeriodicScans: bool = False
|
||||
scanInterval: int = 10
|
||||
enableWatchdog: bool = False
|
||||
showPlaylistsInFolderView: bool = False
|
||||
|
||||
# plugins
|
||||
enablePlugins: bool = True
|
||||
lastfmApiKey: str = field(default_factory=lambda: os.getenv("SWINGMUSIC_LASTFM_API_KEY", ""))
|
||||
lastfmApiSecret: str = field(default_factory=lambda: os.getenv("SWINGMUSIC_LASTFM_API_SECRET", ""))
|
||||
lastfmSessionKeys: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self, _config_path, _artist_split_ignore_file_name):
|
||||
"""
|
||||
Loads the config file and sets the values to this instance
|
||||
"""
|
||||
# set config path locally to avoid writing to file
|
||||
config_path = Paths().config_file_path
|
||||
|
||||
if config_path.exists():
|
||||
config = self.load_config(config_path)
|
||||
else:
|
||||
self._config_path = config_path
|
||||
return
|
||||
|
||||
# loop through the config file and set the values
|
||||
for key, value in config.items():
|
||||
if key == "artistSplitIgnoreList":
|
||||
# Merge with default values and user file values instead of overwriting
|
||||
default_values = load_default_artist_ignore_list()
|
||||
user_values = load_user_artist_ignore_list()
|
||||
setattr(self, key, default_values.union(user_values).union(value))
|
||||
else:
|
||||
setattr(self, key, value)
|
||||
|
||||
# finally, set the config path
|
||||
self._config_path = config_path
|
||||
self._finished = True
|
||||
|
||||
def setup_config_file(self) -> None:
|
||||
"""
|
||||
Creates the config file with the default settings
|
||||
if it doesn't exist
|
||||
"""
|
||||
# if not exists, create the config file
|
||||
config = Path(self._config_path)
|
||||
if not config.exists():
|
||||
self.write_to_file(asdict(self))
|
||||
|
||||
def load_config(self, path: Path) -> dict[str, Any]:
|
||||
"""
|
||||
Reads the settings from the config file.
|
||||
Returns a dictget_root_dirs
|
||||
"""
|
||||
return json.loads(path.read_text())
|
||||
|
||||
def write_to_file(self, settings: dict[str, Any]):
|
||||
"""
|
||||
Writes the settings to the config file
|
||||
"""
|
||||
# remove internal attributes
|
||||
settings = {k: v for k, v in settings.items() if not k.startswith("_")}
|
||||
|
||||
with self._config_path.open(mode="w") as f:
|
||||
json.dump(settings, f, indent=4, default=list)
|
||||
|
||||
def __setattr__(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Writes to the config file whenever a value is set
|
||||
"""
|
||||
|
||||
# protection.
|
||||
# only write to file if post_init completed
|
||||
if not self._finished:
|
||||
super().__setattr__(key, value)
|
||||
return
|
||||
|
||||
super().__setattr__(key, value)
|
||||
|
||||
# if is internal attribute, don't write to file
|
||||
if key.startswith("_") or not self._config_path:
|
||||
return
|
||||
|
||||
self.write_to_file(asdict(self))
|
||||
@@ -0,0 +1,32 @@
|
||||
import time
|
||||
|
||||
import schedule
|
||||
|
||||
from swingmusic.crons.mixes import Mixes
|
||||
from swingmusic.lib.recipes.recents import RecentlyAdded, RecentlyPlayed
|
||||
from swingmusic.lib.recipes.topstreamed import TopArtists
|
||||
from swingmusic.utils.threading import background
|
||||
|
||||
|
||||
@background
|
||||
def start_cron_jobs():
|
||||
"""
|
||||
This is the function that triggers the cron jobs.
|
||||
"""
|
||||
# NOTE: RecentlyPlayed is not a CRON job, it's triggered here to
|
||||
# populate the values for the very first time.
|
||||
RecentlyPlayed()
|
||||
RecentlyAdded()
|
||||
|
||||
# Initialized CRON jobs
|
||||
TopArtists()
|
||||
TopArtists(duration="week")
|
||||
Mixes()
|
||||
|
||||
# Trigger all CRON jobs when the app is started.
|
||||
schedule.run_all()
|
||||
|
||||
# Run all CRON jobs on a loop.
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
time.sleep(1)
|
||||
@@ -0,0 +1,22 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import schedule
|
||||
|
||||
|
||||
class CronJob(ABC):
|
||||
"""
|
||||
A cron job that will be run on a regular interval.
|
||||
"""
|
||||
|
||||
name: str
|
||||
hours: int = 1
|
||||
|
||||
def __init__(self):
|
||||
schedule.every(self.hours).hours.do(self.run)
|
||||
|
||||
@abstractmethod
|
||||
def run(self):
|
||||
"""
|
||||
The function that will be called by the cron job.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,25 @@
|
||||
from swingmusic.crons.cron import CronJob
|
||||
from swingmusic.lib.recipes.artistmixes import ArtistMixes
|
||||
from swingmusic.lib.recipes.because import BecauseYouListened
|
||||
|
||||
|
||||
class Mixes(CronJob):
|
||||
"""
|
||||
This cron job creates mixes displayed on the homepage.
|
||||
"""
|
||||
|
||||
name: str = "mixes"
|
||||
hours: int = 12
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Creates the artist mixes
|
||||
"""
|
||||
ArtistMixes()
|
||||
|
||||
# INFO: Because you listened to artist items are generated using
|
||||
# the artist mixes, so run them after the artist mixes are created.
|
||||
BecauseYouListened()
|
||||
@@ -0,0 +1,79 @@
|
||||
ARTIST_SPLIT_IGNORE_LIST = {
|
||||
"AC/DC",
|
||||
"Sonny & Cher",
|
||||
"Bob marley & the wailers",
|
||||
"Crosby, Stills, Nash & Young",
|
||||
"Booker T. & the M.G.'s",
|
||||
"Maurice Williams & The Zodiacs",
|
||||
"Frank DeVol and His Orchestra",
|
||||
"Tommy James & the Shondells",
|
||||
"Smith & Thell",
|
||||
"Peter, Paul & Mary",
|
||||
"Simon & Garfunkel",
|
||||
"Judy & Mary",
|
||||
"Florence & The Machine",
|
||||
"Belle & Sebastian",
|
||||
"C&C Music Factory",
|
||||
"C & C Music Factory",
|
||||
"FO&O",
|
||||
"The Product G&B",
|
||||
"Big Brother and the Holding Company",
|
||||
"Tyler, The Creator",
|
||||
"Eric B. & Rakim",
|
||||
"DJ Jazzy Jeff & The Fresh Prince",
|
||||
"Rob Base & DJ E-Z Rock",
|
||||
"Nick Cave & the Bad Seeds",
|
||||
"For King + Country",
|
||||
"For King & Country",
|
||||
"Robson & Jerome",
|
||||
"Charles & Eddie",
|
||||
"Womack & Womack",
|
||||
"Pepsi & Shirlie",
|
||||
"Mel & Kim",
|
||||
"Wendy & Lisa",
|
||||
"Tony! Toni! Toné!",
|
||||
"Phillips, Craig & Dean",
|
||||
"Crosby & Nash",
|
||||
"Blood, Sweat & Tears",
|
||||
"Mike + The Mechanics",
|
||||
"Earth, Wind & Fire",
|
||||
"Emerson, Lake & Palmer",
|
||||
"Mumford & Sons",
|
||||
"Hall & Oates",
|
||||
"Tom Petty & The Heartbreakers",
|
||||
"Sly & the Family Stone",
|
||||
"KC & the Sunshine Band",
|
||||
"Huey Lewis & the News",
|
||||
"Joan Jett & the Blackhearts",
|
||||
"Echo & the Bunnymen",
|
||||
"Katrina & the Waves",
|
||||
"Gladys Knight & the Pips",
|
||||
"Martha & the Vandellas",
|
||||
"Martha Reeves and the Vandellas",
|
||||
"? and the Mysterians",
|
||||
"Kool & the Gang",
|
||||
"Josie & the Pussycats",
|
||||
"Dan + Shay",
|
||||
"Brooks & Dunn",
|
||||
"Peaches & Herb",
|
||||
"Seals & Crofts",
|
||||
"Loggins & Messina",
|
||||
"England Dan & John Ford Coley",
|
||||
"Ashford & Simpson",
|
||||
"Sam & Dave",
|
||||
"Ike & Tina Turner",
|
||||
"Captain & Tennille",
|
||||
"Hootie & the Blowfish",
|
||||
"Diana Ross & the Supremes",
|
||||
"Herb Alpert & the Tijuana Brass",
|
||||
"The Mamas & the Papas",
|
||||
"Gerry & the Pacemakers",
|
||||
"Chloe x Halle",
|
||||
"Durand Jones & The Indications",
|
||||
"St. Paul & The Broken Bones",
|
||||
"Nathaniel Rateliff & The Night Sweats",
|
||||
"Aly & AJ",
|
||||
"Maddie & Tae",
|
||||
"Nico & Vinz",
|
||||
"Yusuf / Cat Stevens",
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
delete,
|
||||
func,
|
||||
insert,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
|
||||
|
||||
from swingmusic.db.engine import DbEngine
|
||||
|
||||
|
||||
class Base(MappedAsDataclass, DeclarativeBase):
|
||||
"""
|
||||
Base class for all database models.
|
||||
|
||||
It has methods common to all tables. eg. `insert_one`, `insert_many`, `remove_all`, `remove_one`, `all`, `count`.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def execute(cls, stmt: Any, commit: bool = False):
|
||||
with DbEngine.manager(commit=commit) as session:
|
||||
result = session.execute(stmt.execution_options(yield_per=100))
|
||||
|
||||
if commit:
|
||||
session.commit()
|
||||
|
||||
yield result
|
||||
|
||||
@classmethod
|
||||
def insert_many(cls, items: list[dict[str, Any]]):
|
||||
"""
|
||||
Inserts multiple items into the database.
|
||||
"""
|
||||
return next(cls.execute(insert(cls).values(items), commit=True))
|
||||
|
||||
@classmethod
|
||||
def insert_one(cls, item: dict[str, Any]):
|
||||
"""
|
||||
Inserts a single item into the database.
|
||||
"""
|
||||
return cls.insert_many([item])
|
||||
|
||||
@classmethod
|
||||
def remove_all(cls):
|
||||
return next(cls.execute(delete(cls), commit=True))
|
||||
|
||||
@classmethod
|
||||
def remove_one(cls, id: int):
|
||||
return next(cls.execute(delete(cls).where(cls.id == id), commit=True))
|
||||
|
||||
@classmethod
|
||||
def all(cls):
|
||||
return next(cls.execute(select(cls).execution_options(yield_per=100)))
|
||||
|
||||
@classmethod
|
||||
def count(cls):
|
||||
return next(cls.execute(select(func.count()).select_from(cls))).scalar()
|
||||
|
||||
|
||||
def create_all_tables():
|
||||
"""
|
||||
Creates all the tables that build on the Base class.
|
||||
"""
|
||||
Base().metadata.create_all(DbEngine.engine)
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Native DragonflyDB Client for SwingMusic
|
||||
|
||||
Integrated as a native database service like SQLite, providing:
|
||||
- Ultra-fast caching for all services
|
||||
- Session management
|
||||
- User preferences
|
||||
- Temporary data storage
|
||||
- Real-time features
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DragonflyDBClient:
|
||||
"""
|
||||
Native DragonflyDB client integrated into SwingMusic
|
||||
Provides Redis-compatible operations with automatic fallback
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str | None = None,
|
||||
port: int | None = None,
|
||||
db: int | None = None,
|
||||
):
|
||||
self.host = host or os.environ.get("DRAGONFLYDB_HOST", "localhost")
|
||||
self.port = port or int(os.environ.get("DRAGONFLYDB_PORT", "6379"))
|
||||
self.db = db if db is not None else int(os.environ.get("DRAGONFLYDB_DB", "0"))
|
||||
self.client = None
|
||||
self.available = False
|
||||
self._connect()
|
||||
|
||||
def _connect(self):
|
||||
"""Connect to DragonflyDB with fallback handling"""
|
||||
try:
|
||||
import redis
|
||||
|
||||
self.client = redis.Redis(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
db=self.db,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=2,
|
||||
socket_timeout=2,
|
||||
retry_on_timeout=True,
|
||||
health_check_interval=30,
|
||||
)
|
||||
|
||||
# Test connection
|
||||
self.client.ping()
|
||||
self.available = True
|
||||
logger.info(f"✅ DragonflyDB connected at {self.host}:{self.port}")
|
||||
|
||||
except ImportError:
|
||||
logger.warning("❌ Redis library not installed, DragonflyDB unavailable")
|
||||
self.available = False
|
||||
except Exception as e:
|
||||
logger.warning(f"❌ DragonflyDB connection failed: {e}")
|
||||
self.available = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if DragonflyDB is available"""
|
||||
if not self.available or not self.client:
|
||||
self._connect()
|
||||
if not self.available or not self.client:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.client.ping()
|
||||
return True
|
||||
except Exception:
|
||||
self.available = False
|
||||
return False
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int | None = None) -> bool:
|
||||
"""Set a key-value pair with optional TTL"""
|
||||
if not self.is_available():
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized_value = (
|
||||
json.dumps(value) if not isinstance(value, str) else value
|
||||
)
|
||||
|
||||
if ttl:
|
||||
return self.client.setex(key, ttl, serialized_value)
|
||||
else:
|
||||
return self.client.set(key, serialized_value)
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB set failed: {e}")
|
||||
return False
|
||||
|
||||
def get(self, key: str) -> Any | None:
|
||||
"""Get a value by key"""
|
||||
if not self.is_available():
|
||||
return None
|
||||
|
||||
try:
|
||||
value = self.client.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# Try to deserialize as JSON
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB get failed: {e}")
|
||||
return None
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a key"""
|
||||
if not self.is_available():
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(self.client.delete(key))
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB delete failed: {e}")
|
||||
return False
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if key exists"""
|
||||
if not self.is_available():
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(self.client.exists(key))
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB exists failed: {e}")
|
||||
return False
|
||||
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""Set TTL for existing key"""
|
||||
if not self.is_available():
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(self.client.expire(key, ttl))
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB expire failed: {e}")
|
||||
return False
|
||||
|
||||
def ttl(self, key: str) -> int:
|
||||
"""Get TTL for key"""
|
||||
if not self.is_available():
|
||||
return -1
|
||||
|
||||
try:
|
||||
return self.client.ttl(key)
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB ttl failed: {e}")
|
||||
return -1
|
||||
|
||||
def keys(self, pattern: str = "*") -> list[str]:
|
||||
"""Get keys matching pattern"""
|
||||
if not self.is_available():
|
||||
return []
|
||||
|
||||
try:
|
||||
return self.client.keys(pattern)
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB keys failed: {e}")
|
||||
return []
|
||||
|
||||
def incr(self, key: str, amount: int = 1) -> int:
|
||||
"""Increment value by amount"""
|
||||
if not self.is_available():
|
||||
return 0
|
||||
|
||||
try:
|
||||
return self.client.incr(key, amount)
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB incr failed: {e}")
|
||||
return 0
|
||||
|
||||
def lpush(self, key: str, *values) -> int:
|
||||
"""Push values to left of list"""
|
||||
if not self.is_available():
|
||||
return 0
|
||||
|
||||
try:
|
||||
return self.client.lpush(key, *values)
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB lpush failed: {e}")
|
||||
return 0
|
||||
|
||||
def rpop(self, key: str) -> str | None:
|
||||
"""Pop value from right of list"""
|
||||
if not self.is_available():
|
||||
return None
|
||||
|
||||
try:
|
||||
return self.client.rpop(key)
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB rpop failed: {e}")
|
||||
return None
|
||||
|
||||
def lrange(self, key: str, start: int, end: int) -> list[str]:
|
||||
"""Get range of list elements"""
|
||||
if not self.is_available():
|
||||
return []
|
||||
|
||||
try:
|
||||
return self.client.lrange(key, start, end)
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB lrange failed: {e}")
|
||||
return []
|
||||
|
||||
def llen(self, key: str) -> int:
|
||||
"""Get length of list"""
|
||||
if not self.is_available():
|
||||
return 0
|
||||
|
||||
try:
|
||||
return self.client.llen(key)
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB llen failed: {e}")
|
||||
return 0
|
||||
|
||||
def lrem(self, key: str, count: int, value: str) -> int:
|
||||
"""Remove elements from list"""
|
||||
if not self.is_available():
|
||||
return 0
|
||||
|
||||
try:
|
||||
return self.client.lrem(key, count, value)
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB lrem failed: {e}")
|
||||
return 0
|
||||
|
||||
def ltrim(self, key: str, start: int, end: int) -> bool:
|
||||
"""Trim list to range"""
|
||||
if not self.is_available():
|
||||
return False
|
||||
|
||||
try:
|
||||
return self.client.ltrim(key, start, end)
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB ltrim failed: {e}")
|
||||
return False
|
||||
|
||||
def flushdb(self) -> bool:
|
||||
"""Clear all keys in current database"""
|
||||
if not self.is_available():
|
||||
return False
|
||||
|
||||
try:
|
||||
return self.client.flushdb()
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB flushdb failed: {e}")
|
||||
return False
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Ping DragonflyDB."""
|
||||
if not self.is_available():
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(self.client.ping())
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB ping failed: {e}")
|
||||
self.available = False
|
||||
return False
|
||||
|
||||
def info(self) -> dict[str, Any]:
|
||||
"""Get DragonflyDB server info"""
|
||||
if not self.is_available():
|
||||
return {}
|
||||
|
||||
try:
|
||||
info = self.client.info()
|
||||
return {
|
||||
"version": info.get("redis_version", "unknown"),
|
||||
"used_memory": info.get("used_memory", 0),
|
||||
"used_memory_human": info.get("used_memory_human", "0B"),
|
||||
"connected_clients": info.get("connected_clients", 0),
|
||||
"total_commands_processed": info.get("total_commands_processed", 0),
|
||||
"keyspace_hits": info.get("keyspace_hits", 0),
|
||||
"keyspace_misses": info.get("keyspace_misses", 0),
|
||||
"uptime_in_seconds": info.get("uptime_in_seconds", 0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"DragonflyDB info failed: {e}")
|
||||
return {}
|
||||
|
||||
def close(self):
|
||||
"""Close DragonflyDB connection"""
|
||||
if self.client:
|
||||
try:
|
||||
self.client.close()
|
||||
logger.info("DragonflyDB connection closed")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Global DragonflyDB instance (like SQLite)
|
||||
_dragonfly_client: DragonflyDBClient | None = None
|
||||
|
||||
|
||||
def get_dragonfly_client() -> DragonflyDBClient:
|
||||
"""Get the global DragonflyDB client instance"""
|
||||
global _dragonfly_client
|
||||
if _dragonfly_client is None:
|
||||
_dragonfly_client = DragonflyDBClient()
|
||||
return _dragonfly_client
|
||||
|
||||
|
||||
def init_dragonfly_if_available() -> bool:
|
||||
"""Initialize DragonflyDB if available"""
|
||||
client = get_dragonfly_client()
|
||||
return client.is_available()
|
||||
|
||||
|
||||
class DragonflyCache:
|
||||
"""High-level cache interface using DragonflyDB"""
|
||||
|
||||
def __init__(self, prefix: str = "swingmusic"):
|
||||
self.client = get_dragonfly_client()
|
||||
self.prefix = prefix
|
||||
|
||||
def _make_key(self, key: str) -> str:
|
||||
"""Create namespaced key"""
|
||||
return f"{self.prefix}:{key}"
|
||||
|
||||
def set(self, key: str, value: Any, ttl_hours: int = 12) -> bool:
|
||||
"""Set cache value with TTL in hours"""
|
||||
ttl_seconds = ttl_hours * 3600
|
||||
return self.client.set(self._make_key(key), value, ttl_seconds)
|
||||
|
||||
def get(self, key: str) -> Any | None:
|
||||
"""Get cache value"""
|
||||
return self.client.get(self._make_key(key))
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete cache value"""
|
||||
return self.client.delete(self._make_key(key))
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if cache value exists"""
|
||||
return self.client.exists(self._make_key(key))
|
||||
|
||||
def clear_all(self) -> bool:
|
||||
"""Clear all SwingMusic cache entries"""
|
||||
if not self.client.is_available():
|
||||
return False
|
||||
|
||||
keys = self.client.keys(f"{self.prefix}:*")
|
||||
if keys:
|
||||
return self.client.client.delete(*keys) > 0
|
||||
return True
|
||||
|
||||
|
||||
# Native cache instances for different purposes
|
||||
spotify_cache = DragonflyCache("spotify")
|
||||
session_cache = DragonflyCache("session")
|
||||
user_cache = DragonflyCache("user")
|
||||
temp_cache = DragonflyCache("temp")
|
||||
|
||||
|
||||
def get_spotify_cache() -> DragonflyCache:
|
||||
"""Get Spotify metadata cache"""
|
||||
return spotify_cache
|
||||
|
||||
|
||||
def get_session_cache() -> DragonflyCache:
|
||||
"""Get user session cache"""
|
||||
return session_cache
|
||||
|
||||
|
||||
def get_user_cache() -> DragonflyCache:
|
||||
"""Get user preferences cache"""
|
||||
return user_cache
|
||||
|
||||
|
||||
def get_temp_cache() -> DragonflyCache:
|
||||
"""Get temporary data cache"""
|
||||
return temp_cache
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Extended DragonflyDB Client for SwingMusic
|
||||
|
||||
Comprehensive caching system with 15+ cache services for:
|
||||
- Track metadata and persistence
|
||||
- User sessions and preferences
|
||||
- Mobile offline synchronization
|
||||
- Real-time features and analytics
|
||||
- Background job processing
|
||||
- Search and recommendations
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from swingmusic.db.dragonfly_client import DragonflyCache, get_dragonfly_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExtendedDragonflyServices:
|
||||
"""
|
||||
Extended DragonflyDB services for complete SwingMusic integration
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = get_dragonfly_client()
|
||||
|
||||
# Core performance caches
|
||||
self.track_cache = DragonflyCache("tracks")
|
||||
self.artist_cache = DragonflyCache("artists")
|
||||
self.album_cache = DragonflyCache("albums")
|
||||
|
||||
# User experience caches
|
||||
self.session_cache = DragonflyCache("sessions")
|
||||
self.user_cache = DragonflyCache("users")
|
||||
self.search_cache = DragonflyCache("search")
|
||||
self.homepage_cache = DragonflyCache("homepage")
|
||||
|
||||
# Mobile and offline caches
|
||||
self.mobile_cache = DragonflyCache("mobile")
|
||||
self.sync_cache = DragonflyCache("sync")
|
||||
self.progress_cache = DragonflyCache("progress")
|
||||
self.playlist_cache = DragonflyCache("playlists")
|
||||
|
||||
# Real-time feature caches
|
||||
self.playcount_cache = DragonflyCache("playcounts")
|
||||
self.recent_cache = DragonflyCache("recent")
|
||||
self.favorite_cache = DragonflyCache("favorites")
|
||||
self.recommendation_cache = DragonflyCache("recommendations")
|
||||
|
||||
# Background processing caches
|
||||
self.job_cache = DragonflyCache("jobs")
|
||||
self.lyrics_cache = DragonflyCache("lyrics")
|
||||
self.index_cache = DragonflyCache("index")
|
||||
self.temp_cache = DragonflyCache("temp")
|
||||
|
||||
logger.info("Extended DragonflyDB services initialized")
|
||||
|
||||
|
||||
class TrackCacheService:
|
||||
"""High-performance track caching with persistence"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = DragonflyCache("tracks")
|
||||
|
||||
def get_track(self, trackhash: str) -> dict[str, Any] | None:
|
||||
"""Get track data from cache"""
|
||||
return self.cache.get(f"track:{trackhash}")
|
||||
|
||||
def set_track(
|
||||
self, trackhash: str, track_data: dict[str, Any], ttl_hours: int = 24
|
||||
):
|
||||
"""Cache track data"""
|
||||
return self.cache.set(f"track:{trackhash}", track_data, ttl_hours)
|
||||
|
||||
def get_track_batch(self, trackhashes: list[str]) -> dict[str, Any]:
|
||||
"""Get multiple tracks from cache"""
|
||||
results = {}
|
||||
for trackhash in trackhashes:
|
||||
track = self.get_track(trackhash)
|
||||
if track:
|
||||
results[trackhash] = track
|
||||
return results
|
||||
|
||||
def set_track_batch(self, tracks: dict[str, dict[str, Any]], ttl_hours: int = 24):
|
||||
"""Cache multiple tracks"""
|
||||
success_count = 0
|
||||
for trackhash, track_data in tracks.items():
|
||||
if self.set_track(trackhash, track_data, ttl_hours):
|
||||
success_count += 1
|
||||
return success_count
|
||||
|
||||
def invalidate_track(self, trackhash: str):
|
||||
"""Remove track from cache"""
|
||||
return self.cache.delete(f"track:{trackhash}")
|
||||
|
||||
def get_stats(self) -> dict[str, Any]:
|
||||
"""Get track cache statistics"""
|
||||
keys = self.cache.client.keys("tracks:track:*")
|
||||
return {
|
||||
"total_tracks": len(keys),
|
||||
"memory_usage": self.cache.client.info().get(
|
||||
"used_memory_human", "Unknown"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class UserSessionService:
|
||||
"""Ultra-fast user session management"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = DragonflyCache("sessions")
|
||||
# Backward compatibility for older call sites.
|
||||
self.session_cache = self.cache
|
||||
|
||||
def create_session(
|
||||
self, session_token: str, user_data: dict[str, Any], ttl_hours: int = 24
|
||||
):
|
||||
"""Create user session"""
|
||||
return self.cache.set(f"session:{session_token}", user_data, ttl_hours)
|
||||
|
||||
def set_user_session(
|
||||
self, userid: int, user_data: dict[str, Any], ttl_seconds: int = 24 * 3600
|
||||
):
|
||||
"""Store latest session payload by user id for quick lookups."""
|
||||
ttl_hours = max(1, int(ttl_seconds // 3600))
|
||||
return self.cache.set(f"user_session:{userid}", user_data, ttl_hours)
|
||||
|
||||
def get_user_session(self, userid: int) -> dict[str, Any] | None:
|
||||
"""Get latest session payload for a user id."""
|
||||
return self.cache.get(f"user_session:{userid}")
|
||||
|
||||
def get_session(self, session_token: str) -> dict[str, Any] | None:
|
||||
"""Get user session"""
|
||||
return self.cache.get(f"session:{session_token}")
|
||||
|
||||
def refresh_session(self, session_token: str, ttl_hours: int = 24):
|
||||
"""Refresh session TTL"""
|
||||
return self.cache.expire(f"session:{session_token}", ttl_hours * 3600)
|
||||
|
||||
def invalidate_session(self, session_token: str):
|
||||
"""Invalidate user session"""
|
||||
return self.cache.delete(f"session:{session_token}")
|
||||
|
||||
def invalidate_user_session(self, userid: int):
|
||||
"""Invalidate latest session payload for a user id."""
|
||||
return self.cache.delete(f"user_session:{userid}")
|
||||
|
||||
def get_user_sessions(self, userid: int) -> list[str]:
|
||||
"""Get all active sessions for user"""
|
||||
pattern = "session:*"
|
||||
keys = self.cache.client.keys(pattern)
|
||||
user_sessions = []
|
||||
|
||||
for key in keys:
|
||||
session_data = self.cache.get(key.replace("session:", ""))
|
||||
if session_data and session_data.get("userid") == userid:
|
||||
user_sessions.append(key)
|
||||
|
||||
return user_sessions
|
||||
|
||||
|
||||
class MobileSyncService:
|
||||
"""Reliable mobile offline synchronization"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = DragonflyCache("mobile")
|
||||
|
||||
def queue_sync_action(self, userid: int, action: dict[str, Any]):
|
||||
"""Queue a sync action for mobile device"""
|
||||
queue_key = f"sync_queue:user:{userid}"
|
||||
return self.cache.client.lpush(queue_key, json.dumps(action))
|
||||
|
||||
def get_sync_actions(self, userid: int, count: int = 10) -> list[dict[str, Any]]:
|
||||
"""Get pending sync actions for user"""
|
||||
queue_key = f"sync_queue:user:{userid}"
|
||||
actions_data = self.cache.client.lrange(queue_key, 0, count - 1)
|
||||
|
||||
actions = []
|
||||
for action_data in actions_data:
|
||||
try:
|
||||
actions.append(json.loads(action_data))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return actions
|
||||
|
||||
def mark_sync_completed(self, userid: int, action_id: str):
|
||||
"""Mark sync action as completed"""
|
||||
# Remove from queue
|
||||
queue_key = f"sync_queue:user:{userid}"
|
||||
return self.cache.client.lrem(queue_key, 1, action_id)
|
||||
|
||||
def set_sync_state(self, userid: int, device_id: str, state: dict[str, Any]):
|
||||
"""Set device sync state"""
|
||||
state_key = f"sync_state:user:{userid}:device:{device_id}"
|
||||
return self.cache.set(state_key, state, ttl_hours=24)
|
||||
|
||||
def get_sync_state(self, userid: int, device_id: str) -> dict[str, Any] | None:
|
||||
"""Get device sync state"""
|
||||
state_key = f"sync_state:user:{userid}:device:{device_id}"
|
||||
return self.cache.get(state_key)
|
||||
|
||||
|
||||
class RealTimeFeaturesService:
|
||||
"""Real-time features like play counts and favorites"""
|
||||
|
||||
def __init__(self):
|
||||
self.playcount_cache = DragonflyCache("playcounts")
|
||||
self.recent_cache = DragonflyCache("recent")
|
||||
self.favorite_cache = DragonflyCache("favorites")
|
||||
|
||||
def increment_playcount(self, trackhash: str, userid: int | None = None):
|
||||
"""Increment track play count"""
|
||||
key = f"plays:{trackhash}"
|
||||
if userid:
|
||||
key = f"plays:user:{userid}:track:{trackhash}"
|
||||
|
||||
return self.playcount_cache.client.incr(key)
|
||||
|
||||
def get_playcount(self, trackhash: str, userid: int | None = None) -> int:
|
||||
"""Get track play count"""
|
||||
key = f"plays:{trackhash}"
|
||||
if userid:
|
||||
key = f"plays:user:{userid}:track:{trackhash}"
|
||||
|
||||
count = self.playcount_cache.client.get(key)
|
||||
return int(count) if count else 0
|
||||
|
||||
def add_to_recently_played(self, userid: int, trackhash: str, limit: int = 50):
|
||||
"""Add track to recently played list"""
|
||||
key = f"recent:user:{userid}"
|
||||
|
||||
# Add to beginning of list
|
||||
self.recent_cache.client.lpush(key, trackhash)
|
||||
|
||||
# Remove duplicates
|
||||
self.recent_cache.client.lrem(key, 1, trackhash)
|
||||
|
||||
# Add back to beginning
|
||||
self.recent_cache.client.lpush(key, trackhash)
|
||||
|
||||
# Limit list size
|
||||
self.recent_cache.client.ltrim(key, 0, limit - 1)
|
||||
|
||||
# Set TTL
|
||||
self.recent_cache.client.expire(key, 7 * 24 * 3600) # 7 days
|
||||
|
||||
def get_recently_played(self, userid: int, limit: int = 50) -> list[str]:
|
||||
"""Get recently played tracks for user"""
|
||||
key = f"recent:user:{userid}"
|
||||
return self.recent_cache.client.lrange(key, 0, limit - 1)
|
||||
|
||||
def toggle_favorite(self, userid: int, trackhash: str) -> bool:
|
||||
"""Toggle favorite status for track"""
|
||||
key = f"fav:user:{userid}:track:{trackhash}"
|
||||
|
||||
current = self.favorite_cache.client.get(key)
|
||||
if current:
|
||||
# Remove favorite
|
||||
self.favorite_cache.client.delete(key)
|
||||
return False
|
||||
else:
|
||||
# Add favorite
|
||||
self.favorite_cache.client.set(key, True, ttl_hours=24 * 30) # 30 days
|
||||
return True
|
||||
|
||||
def is_favorite(self, userid: int, trackhash: str) -> bool:
|
||||
"""Check if track is favorited by user"""
|
||||
key = f"fav:user:{userid}:track:{trackhash}"
|
||||
return bool(self.favorite_cache.client.get(key))
|
||||
|
||||
def get_user_favorites(self, userid: int) -> list[str]:
|
||||
"""Get all favorite tracks for user"""
|
||||
pattern = f"fav:user:{userid}:track:*"
|
||||
keys = self.favorite_cache.client.keys(pattern)
|
||||
|
||||
favorites = []
|
||||
for key in keys:
|
||||
trackhash = key.split(":")[-1]
|
||||
favorites.append(trackhash)
|
||||
|
||||
return favorites
|
||||
|
||||
|
||||
class SearchCacheService:
|
||||
"""High-performance search results caching"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = DragonflyCache("search")
|
||||
|
||||
def cache_search_results(
|
||||
self, query: str, results: dict[str, Any], ttl_hours: int = 6
|
||||
):
|
||||
"""Cache search results"""
|
||||
query_hash = hash(query) # Simple hash for key
|
||||
return self.cache.set(f"results:{query_hash}", results, ttl_hours)
|
||||
|
||||
def get_search_results(self, query: str) -> dict[str, Any] | None:
|
||||
"""Get cached search results"""
|
||||
query_hash = hash(query)
|
||||
return self.cache.get(f"results:{query_hash}")
|
||||
|
||||
def cache_suggestions(
|
||||
self, query_type: str, suggestions: list[str], ttl_hours: int = 12
|
||||
):
|
||||
"""Cache search suggestions"""
|
||||
return self.cache.set(f"suggestions:{query_type}", suggestions, ttl_hours)
|
||||
|
||||
def get_suggestions(self, query_type: str) -> list[str]:
|
||||
"""Get cached search suggestions"""
|
||||
suggestions = self.cache.get(f"suggestions:{query_type}")
|
||||
return suggestions if suggestions else []
|
||||
|
||||
def invalidate_search_cache(self, pattern: str = "*"):
|
||||
"""Invalidate search cache"""
|
||||
keys = self.cache.client.keys(f"search:{pattern}")
|
||||
if keys:
|
||||
return self.cache.client.delete(*keys)
|
||||
return True
|
||||
|
||||
|
||||
class JobQueueService:
|
||||
"""High-performance background job processing"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = DragonflyCache("jobs")
|
||||
|
||||
def enqueue_job(self, queue: str, job_data: dict[str, Any]):
|
||||
"""Add job to queue"""
|
||||
job_json = json.dumps(job_data)
|
||||
return self.cache.client.lpush(f"queue:{queue}", job_json)
|
||||
|
||||
def dequeue_job(self, queue: str) -> dict[str, Any] | None:
|
||||
"""Get next job from queue"""
|
||||
job_json = self.cache.client.rpop(f"queue:{queue}")
|
||||
if job_json:
|
||||
try:
|
||||
return json.loads(job_json)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_queue_size(self, queue: str) -> int:
|
||||
"""Get number of jobs in queue"""
|
||||
return self.cache.client.llen(f"queue:{queue}")
|
||||
|
||||
def peek_jobs(self, queue: str, count: int = 10) -> list[dict[str, Any]]:
|
||||
"""Peek at jobs in queue without removing them"""
|
||||
jobs_data = self.cache.client.lrange(f"queue:{queue}", 0, count - 1)
|
||||
|
||||
jobs = []
|
||||
for job_data in jobs_data:
|
||||
try:
|
||||
jobs.append(json.loads(job_data))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return jobs
|
||||
|
||||
def clear_queue(self, queue: str):
|
||||
"""Clear all jobs from queue"""
|
||||
return self.cache.client.delete(f"queue:{queue}")
|
||||
|
||||
|
||||
# Global service instances
|
||||
_track_cache_service: TrackCacheService | None = None
|
||||
_user_session_service: UserSessionService | None = None
|
||||
_mobile_sync_service: MobileSyncService | None = None
|
||||
_realtime_service: RealTimeFeaturesService | None = None
|
||||
_search_cache_service: SearchCacheService | None = None
|
||||
_job_queue_service: JobQueueService | None = None
|
||||
|
||||
|
||||
def get_track_cache_service() -> TrackCacheService:
|
||||
"""Get track cache service instance"""
|
||||
global _track_cache_service
|
||||
if _track_cache_service is None:
|
||||
_track_cache_service = TrackCacheService()
|
||||
return _track_cache_service
|
||||
|
||||
|
||||
def get_user_session_service() -> UserSessionService:
|
||||
"""Get user session service instance"""
|
||||
global _user_session_service
|
||||
if _user_session_service is None:
|
||||
_user_session_service = UserSessionService()
|
||||
return _user_session_service
|
||||
|
||||
|
||||
def get_mobile_sync_service() -> MobileSyncService:
|
||||
"""Get mobile sync service instance"""
|
||||
global _mobile_sync_service
|
||||
if _mobile_sync_service is None:
|
||||
_mobile_sync_service = MobileSyncService()
|
||||
return _mobile_sync_service
|
||||
|
||||
|
||||
def get_realtime_service() -> RealTimeFeaturesService:
|
||||
"""Get real-time features service instance"""
|
||||
global _realtime_service
|
||||
if _realtime_service is None:
|
||||
_realtime_service = RealTimeFeaturesService()
|
||||
return _realtime_service
|
||||
|
||||
|
||||
def get_search_cache_service() -> SearchCacheService:
|
||||
"""Get search cache service instance"""
|
||||
global _search_cache_service
|
||||
if _search_cache_service is None:
|
||||
_search_cache_service = SearchCacheService()
|
||||
return _search_cache_service
|
||||
|
||||
|
||||
def get_job_queue_service() -> JobQueueService:
|
||||
"""Get job queue service instance"""
|
||||
global _job_queue_service
|
||||
if _job_queue_service is None:
|
||||
_job_queue_service = JobQueueService()
|
||||
return _job_queue_service
|
||||
|
||||
|
||||
def get_all_dragonfly_services() -> dict[str, Any]:
|
||||
"""Get all DragonflyDB services for monitoring"""
|
||||
return {
|
||||
"track_cache": get_track_cache_service(),
|
||||
"user_sessions": get_user_session_service(),
|
||||
"mobile_sync": get_mobile_sync_service(),
|
||||
"realtime": get_realtime_service(),
|
||||
"search_cache": get_search_cache_service(),
|
||||
"job_queue": get_job_queue_service(),
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import Engine, create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from swingmusic.settings import Paths
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA cache_size=10000")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA temp_store=FILE")
|
||||
cursor.execute("PRAGMA mmap_size=0")
|
||||
cursor.close()
|
||||
|
||||
|
||||
class classproperty(property):
|
||||
"""
|
||||
A class property decorator.
|
||||
"""
|
||||
|
||||
def __get__(self, owner_self, owner_cls):
|
||||
if self.fget:
|
||||
return self.fget(owner_cls)
|
||||
|
||||
|
||||
class DbEngine:
|
||||
"""
|
||||
The database engine instance.
|
||||
"""
|
||||
|
||||
_engine: Engine | None = None
|
||||
|
||||
@classproperty
|
||||
def engine(cls) -> Engine:
|
||||
if not cls._engine:
|
||||
cls._engine = create_engine(
|
||||
f"sqlite+pysqlite:///{Paths().app_db_path}",
|
||||
echo=False,
|
||||
max_overflow=20,
|
||||
pool_size=10,
|
||||
)
|
||||
|
||||
return cls._engine
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def manager(cls, commit: bool = False):
|
||||
"""
|
||||
This context manager manages access to the database.
|
||||
|
||||
When the context manager is entered, it returns a session object that can be used to execute SQL statements.
|
||||
|
||||
If the `commit` parameter is set to `True`, the context manager will commit the transaction when it exits.
|
||||
"""
|
||||
Session = sessionmaker(cls.engine)
|
||||
|
||||
try:
|
||||
with Session() as session:
|
||||
yield session
|
||||
|
||||
if commit:
|
||||
session.commit()
|
||||
# yield session.execution_options(preserve_rowcount=True, yield_per=100)
|
||||
# yield conn.execution_options(preserve_rowcount=True, yield_per=100)
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
raise e
|
||||
finally:
|
||||
if commit:
|
||||
session.commit()
|
||||
|
||||
session.close()
|
||||
# del conn
|
||||
# cls.engine.clear_compiled_cache()
|
||||
@@ -0,0 +1,79 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Integer, String, delete, select
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.db import Base
|
||||
from swingmusic.db.engine import DbEngine
|
||||
from swingmusic.db.utils import track_to_dataclass, tracks_to_dataclasses
|
||||
|
||||
|
||||
class TrackTable(Base):
|
||||
__tablename__ = "track"
|
||||
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
album: Mapped[str] = mapped_column(String())
|
||||
albumartists: Mapped[str] = mapped_column(String())
|
||||
albumhash: Mapped[str] = mapped_column(String(), index=True)
|
||||
artists: Mapped[str] = mapped_column(String())
|
||||
bitrate: Mapped[int] = mapped_column(Integer())
|
||||
copyright: Mapped[str | None] = mapped_column(String())
|
||||
date: Mapped[int] = mapped_column(Integer(), nullable=True)
|
||||
disc: Mapped[int] = mapped_column(Integer())
|
||||
duration: Mapped[int] = mapped_column(Integer())
|
||||
filepath: Mapped[str] = mapped_column(String(), index=True, unique=True)
|
||||
folder: Mapped[str] = mapped_column(String(), index=True)
|
||||
genres: Mapped[str | None] = mapped_column(String())
|
||||
last_mod: Mapped[float] = mapped_column(Integer())
|
||||
title: Mapped[str] = mapped_column(String())
|
||||
track: Mapped[int] = mapped_column(Integer())
|
||||
trackhash: Mapped[str] = mapped_column(String(), index=True)
|
||||
lastplayed: Mapped[int] = mapped_column(Integer(), default=0)
|
||||
playcount: Mapped[int] = mapped_column(Integer(), default=0)
|
||||
playduration: Mapped[int] = mapped_column(Integer(), default=0)
|
||||
extra: Mapped[dict[str, Any] | None] = mapped_column(JSON(), default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
with DbEngine.manager() as conn:
|
||||
config = UserConfig()
|
||||
result = conn.execute(select(cls).execution_options(yield_per=100))
|
||||
|
||||
for i in result.scalars():
|
||||
d = i.__dict__
|
||||
del d["_sa_instance_state"]
|
||||
|
||||
yield track_to_dataclass(d, config)
|
||||
|
||||
@classmethod
|
||||
def get_tracks_by_filepaths(cls, filepaths: list[str]):
|
||||
with DbEngine.manager() as conn:
|
||||
result = conn.execute(
|
||||
select(TrackTable)
|
||||
.where(TrackTable.filepath.in_(filepaths))
|
||||
.order_by(TrackTable.last_mod)
|
||||
)
|
||||
return tracks_to_dataclasses(result.fetchall())
|
||||
|
||||
@classmethod
|
||||
def get_tracks_in_path(cls, path: str):
|
||||
with DbEngine.manager() as conn:
|
||||
result = conn.execute(
|
||||
select(TrackTable)
|
||||
.where(TrackTable.filepath.contains(path))
|
||||
.order_by(TrackTable.last_mod)
|
||||
)
|
||||
|
||||
clean = []
|
||||
for row in result.fetchall():
|
||||
d = row[0].__dict__
|
||||
del d["_sa_instance_state"]
|
||||
clean.append(d)
|
||||
|
||||
return tracks_to_dataclasses(clean)
|
||||
|
||||
@classmethod
|
||||
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
|
||||
with DbEngine.manager(commit=True) as conn:
|
||||
conn.execute(delete(TrackTable).where(TrackTable.filepath.in_(filepaths)))
|
||||
@@ -0,0 +1,33 @@
|
||||
from sqlalchemy import Integer, insert, select, update
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from swingmusic.db import Base
|
||||
from swingmusic.db.engine import DbEngine
|
||||
|
||||
|
||||
class MigrationTable(Base):
|
||||
__tablename__ = "dbmigration"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
version: Mapped[int] = mapped_column(Integer())
|
||||
|
||||
@classmethod
|
||||
def set_version(cls, version: int):
|
||||
with DbEngine.manager(commit=True) as conn:
|
||||
result = conn.execute(
|
||||
update(cls).where(cls.id == 1).values(version=version)
|
||||
)
|
||||
|
||||
if result.rowcount == 0:
|
||||
conn.execute(insert(cls).values(id=1, version=version))
|
||||
|
||||
@classmethod
|
||||
def get_version(cls):
|
||||
with DbEngine.manager() as conn:
|
||||
result = conn.execute(select(cls.version).where(cls.id == 1))
|
||||
result = result.fetchone()
|
||||
|
||||
if result:
|
||||
return result[0]
|
||||
|
||||
return -1
|
||||
@@ -0,0 +1,745 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
and_,
|
||||
delete,
|
||||
select,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from swingmusic.db import Base
|
||||
|
||||
|
||||
class LibraryFileTable(Base):
|
||||
__tablename__ = "library_file"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
trackhash: Mapped[str] = mapped_column(String(), unique=True, index=True)
|
||||
filepath: Mapped[str] = mapped_column(String(), unique=True, index=True)
|
||||
codec: Mapped[str] = mapped_column(String(), default="unknown")
|
||||
quality: Mapped[str] = mapped_column(String(), default="unknown")
|
||||
bitrate: Mapped[int] = mapped_column(Integer(), default=0)
|
||||
source: Mapped[str] = mapped_column(String(), default="local")
|
||||
checksum: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
|
||||
created_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
updated_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(JSON(), default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def get_by_trackhash(cls, trackhash: str):
|
||||
result = cls.execute(select(cls).where(cls.trackhash == trackhash))
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def upsert_from_local_track(
|
||||
cls,
|
||||
*,
|
||||
trackhash: str,
|
||||
filepath: str,
|
||||
bitrate: int,
|
||||
codec: str,
|
||||
quality: str,
|
||||
source: str = "local",
|
||||
):
|
||||
now = int(time.time())
|
||||
row = cls.get_by_trackhash(trackhash)
|
||||
|
||||
if row:
|
||||
next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where(cls.id == row.id)
|
||||
.values(
|
||||
filepath=filepath,
|
||||
bitrate=bitrate,
|
||||
codec=codec,
|
||||
quality=quality,
|
||||
source=source,
|
||||
updated_at=now,
|
||||
),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
return cls.get_by_trackhash(trackhash)
|
||||
|
||||
cls.insert_one(
|
||||
{
|
||||
"trackhash": trackhash,
|
||||
"filepath": filepath,
|
||||
"bitrate": bitrate,
|
||||
"codec": codec,
|
||||
"quality": quality,
|
||||
"source": source,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"extra": {},
|
||||
}
|
||||
)
|
||||
return cls.get_by_trackhash(trackhash)
|
||||
|
||||
|
||||
class DownloadJobTable(Base):
|
||||
__tablename__ = "download_job"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[int] = mapped_column(
|
||||
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
|
||||
)
|
||||
trackhash: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, index=True, default=None
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
|
||||
artist: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
|
||||
album: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
|
||||
item_type: Mapped[str] = mapped_column(String(), default="track")
|
||||
source_url: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, index=True, default=None
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(), default="spotify", index=True)
|
||||
provider: Mapped[str] = mapped_column(String(), default="spotify")
|
||||
codec: Mapped[str] = mapped_column(String(), default="mp3")
|
||||
quality: Mapped[str] = mapped_column(String(), default="high")
|
||||
target_path: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, default=None
|
||||
)
|
||||
state: Mapped[str] = mapped_column(String(), default="queued", index=True)
|
||||
progress: Mapped[float] = mapped_column(Float(), default=0.0)
|
||||
error: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
|
||||
retry_count: Mapped[int] = mapped_column(Integer(), default=0)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON(), default_factory=dict)
|
||||
created_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
updated_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
started_at: Mapped[int | None] = mapped_column(
|
||||
Integer(), nullable=True, default=None
|
||||
)
|
||||
finished_at: Mapped[int | None] = mapped_column(
|
||||
Integer(), nullable=True, default=None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def enqueue(cls, payload: dict[str, Any]):
|
||||
now = int(time.time())
|
||||
values = {
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"state": "queued",
|
||||
"progress": 0.0,
|
||||
**payload,
|
||||
}
|
||||
result = cls.insert_one(values)
|
||||
return result.lastrowid
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, job_id: int):
|
||||
result = cls.execute(select(cls).where(cls.id == job_id))
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def get_queued_job(cls):
|
||||
result = cls.execute(
|
||||
select(cls)
|
||||
.where(cls.state == "queued")
|
||||
.order_by(cls.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def update_job(cls, job_id: int, values: dict[str, Any]):
|
||||
values = {**values, "updated_at": int(time.time())}
|
||||
return next(
|
||||
cls.execute(update(cls).where(cls.id == job_id).values(values), commit=True)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def list_for_user(cls, userid: int, states: list[str] | set[str] | None = None):
|
||||
query = select(cls).where(cls.userid == userid).order_by(cls.created_at.desc())
|
||||
if states:
|
||||
query = query.where(cls.state.in_(list(states)))
|
||||
|
||||
result = cls.execute(query)
|
||||
return list(next(result).scalars())
|
||||
|
||||
@classmethod
|
||||
def delete_for_user(
|
||||
cls, userid: int, states: list[str] | set[str] | None = None
|
||||
) -> int:
|
||||
statement = delete(cls).where(cls.userid == userid)
|
||||
if states:
|
||||
statement = statement.where(cls.state.in_(list(states)))
|
||||
|
||||
result = next(cls.execute(statement, commit=True))
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
class TrackedPlaylistTable(Base):
|
||||
__tablename__ = "tracked_playlist"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"userid",
|
||||
"service",
|
||||
"playlist_id",
|
||||
name="uq_tracked_playlist_user_service",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[int] = mapped_column(
|
||||
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
|
||||
)
|
||||
source_url: Mapped[str] = mapped_column(String(), index=True)
|
||||
playlist_id: Mapped[str] = mapped_column(String(), index=True)
|
||||
service: Mapped[str] = mapped_column(String(), default="spotify", index=True)
|
||||
title: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
|
||||
owner_name: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, default=None
|
||||
)
|
||||
quality: Mapped[str] = mapped_column(String(), default="lossless")
|
||||
codec: Mapped[str] = mapped_column(String(), default="flac")
|
||||
auto_sync: Mapped[bool] = mapped_column(Boolean(), default=True, index=True)
|
||||
sync_interval_seconds: Mapped[int] = mapped_column(Integer(), default=900)
|
||||
next_sync_at: Mapped[int] = mapped_column(
|
||||
Integer(), default=lambda: int(time.time())
|
||||
)
|
||||
last_sync_at: Mapped[int | None] = mapped_column(
|
||||
Integer(), nullable=True, default=None
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(), default="active", index=True)
|
||||
snapshot_track_ids: Mapped[list[str]] = mapped_column(JSON(), default_factory=list)
|
||||
snapshot_hash: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, default=None
|
||||
)
|
||||
last_result: Mapped[dict[str, Any]] = mapped_column(JSON(), default_factory=dict)
|
||||
last_error: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, default=None
|
||||
)
|
||||
created_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
updated_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(JSON(), default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, tracked_id: int, userid: int | None = None):
|
||||
statement = select(cls).where(cls.id == tracked_id)
|
||||
if userid is not None:
|
||||
statement = statement.where(cls.userid == userid)
|
||||
result = cls.execute(statement)
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def get_by_source(cls, *, userid: int, service: str, playlist_id: str):
|
||||
result = cls.execute(
|
||||
select(cls).where(
|
||||
and_(
|
||||
cls.userid == userid,
|
||||
cls.service == service,
|
||||
cls.playlist_id == playlist_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def list_for_user(cls, userid: int, include_deleted: bool = False):
|
||||
statement = (
|
||||
select(cls).where(cls.userid == userid).order_by(cls.created_at.desc())
|
||||
)
|
||||
if not include_deleted:
|
||||
statement = statement.where(cls.status != "deleted")
|
||||
result = cls.execute(statement)
|
||||
return list(next(result).scalars())
|
||||
|
||||
@classmethod
|
||||
def upsert(
|
||||
cls,
|
||||
*,
|
||||
userid: int,
|
||||
service: str,
|
||||
playlist_id: str,
|
||||
source_url: str,
|
||||
values: dict[str, Any] | None = None,
|
||||
):
|
||||
now = int(time.time())
|
||||
row = cls.get_by_source(userid=userid, service=service, playlist_id=playlist_id)
|
||||
payload: dict[str, Any] = {
|
||||
"userid": userid,
|
||||
"service": service,
|
||||
"playlist_id": playlist_id,
|
||||
"source_url": source_url,
|
||||
}
|
||||
if values:
|
||||
payload.update(values)
|
||||
|
||||
if row:
|
||||
next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where(cls.id == row.id)
|
||||
.values(
|
||||
{
|
||||
**payload,
|
||||
"updated_at": now,
|
||||
}
|
||||
),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
return cls.get_by_id(row.id)
|
||||
|
||||
cls.insert_one(
|
||||
{
|
||||
**payload,
|
||||
"status": payload.get("status", "active"),
|
||||
"auto_sync": bool(payload.get("auto_sync", True)),
|
||||
"sync_interval_seconds": int(payload.get("sync_interval_seconds", 900)),
|
||||
"next_sync_at": int(payload.get("next_sync_at", now)),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"snapshot_track_ids": payload.get("snapshot_track_ids", []),
|
||||
"last_result": payload.get("last_result", {}),
|
||||
"extra": payload.get("extra", {}),
|
||||
}
|
||||
)
|
||||
return cls.get_by_source(
|
||||
userid=userid, service=service, playlist_id=playlist_id
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_row(cls, tracked_id: int, values: dict[str, Any]):
|
||||
next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where(cls.id == tracked_id)
|
||||
.values({**values, "updated_at": int(time.time())}),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
return cls.get_by_id(tracked_id)
|
||||
|
||||
@classmethod
|
||||
def due_for_sync(cls, *, now_ts: int | None = None, limit: int = 50):
|
||||
now_ts = int(now_ts or time.time())
|
||||
result = cls.execute(
|
||||
select(cls)
|
||||
.where(cls.auto_sync.is_(True))
|
||||
.where(cls.status.in_(["active", "failed", "syncing"]))
|
||||
.where(cls.next_sync_at <= now_ts)
|
||||
.order_by(cls.next_sync_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(next(result).scalars())
|
||||
|
||||
|
||||
class UserLibraryTrackTable(Base):
|
||||
__tablename__ = "user_library_track"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("userid", "trackhash", name="uq_user_track_projection"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[int] = mapped_column(
|
||||
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
|
||||
)
|
||||
trackhash: Mapped[str] = mapped_column(String(), index=True)
|
||||
file_id: Mapped[int] = mapped_column(
|
||||
Integer(),
|
||||
ForeignKey("library_file.id", ondelete="set null"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(), default="missing", index=True)
|
||||
source_url: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, default=None
|
||||
)
|
||||
download_job_id: Mapped[int | None] = mapped_column(
|
||||
Integer(),
|
||||
ForeignKey("download_job.id", ondelete="set null"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
error: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
|
||||
created_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
updated_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(JSON(), default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def get_user_track(cls, userid: int, trackhash: str):
|
||||
result = cls.execute(
|
||||
select(cls).where(and_(cls.userid == userid, cls.trackhash == trackhash))
|
||||
)
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def upsert_status(
|
||||
cls,
|
||||
*,
|
||||
userid: int,
|
||||
trackhash: str,
|
||||
status: str,
|
||||
file_id: int | None = None,
|
||||
download_job_id: int | None = None,
|
||||
source_url: str | None = None,
|
||||
error: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
):
|
||||
now = int(time.time())
|
||||
row = cls.get_user_track(userid, trackhash)
|
||||
|
||||
values: dict[str, Any] = {
|
||||
"status": status,
|
||||
"updated_at": now,
|
||||
"file_id": file_id,
|
||||
"download_job_id": download_job_id,
|
||||
"source_url": source_url,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
if extra is not None:
|
||||
values["extra"] = extra
|
||||
|
||||
if row:
|
||||
next(
|
||||
cls.execute(
|
||||
update(cls).where(cls.id == row.id).values(values), commit=True
|
||||
)
|
||||
)
|
||||
return cls.get_user_track(userid, trackhash)
|
||||
|
||||
cls.insert_one(
|
||||
{
|
||||
"userid": userid,
|
||||
"trackhash": trackhash,
|
||||
"status": status,
|
||||
"file_id": file_id,
|
||||
"download_job_id": download_job_id,
|
||||
"source_url": source_url,
|
||||
"error": error,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"extra": extra or {},
|
||||
}
|
||||
)
|
||||
return cls.get_user_track(userid, trackhash)
|
||||
|
||||
@classmethod
|
||||
def get_status_map(cls, userid: int, trackhashes: set[str] | list[str]):
|
||||
if not trackhashes:
|
||||
return {}
|
||||
|
||||
result = cls.execute(
|
||||
select(cls).where(
|
||||
and_(cls.userid == userid, cls.trackhash.in_(set(trackhashes)))
|
||||
)
|
||||
)
|
||||
rows = list(next(result).scalars())
|
||||
return {row.trackhash: row for row in rows}
|
||||
|
||||
|
||||
class UserRootDirOwnershipTable(Base):
|
||||
__tablename__ = "user_root_dir_ownership"
|
||||
__table_args__ = (UniqueConstraint("userid", "path", name="uq_user_root_path"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
userid: Mapped[int] = mapped_column(
|
||||
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
|
||||
)
|
||||
path: Mapped[str] = mapped_column(String(), index=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
|
||||
@classmethod
|
||||
def assign_paths(cls, userid: int, paths: list[str]):
|
||||
existing_result = cls.execute(select(cls.path).where(cls.userid == userid))
|
||||
existing = {row[0] for row in next(existing_result).all()}
|
||||
|
||||
for path in paths:
|
||||
if path in existing:
|
||||
continue
|
||||
cls.insert_one(
|
||||
{"userid": userid, "path": path, "created_at": int(time.time())}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_paths(cls, userid: int) -> list[str]:
|
||||
result = cls.execute(select(cls.path).where(cls.userid == userid))
|
||||
paths = [row for row in next(result).scalars().all() if row]
|
||||
return list(dict.fromkeys(paths))
|
||||
|
||||
@classmethod
|
||||
def replace_paths(cls, userid: int, paths: list[str]):
|
||||
cleaned = [path.strip() for path in paths if path and path.strip()]
|
||||
cleaned = list(dict.fromkeys(cleaned))
|
||||
|
||||
next(cls.execute(delete(cls).where(cls.userid == userid), commit=True))
|
||||
if not cleaned:
|
||||
return
|
||||
|
||||
now = int(time.time())
|
||||
cls.insert_many(
|
||||
[{"userid": userid, "path": path, "created_at": now} for path in cleaned]
|
||||
)
|
||||
|
||||
|
||||
class SetupStateTable(Base):
|
||||
__tablename__ = "setup_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
owner_userid: Mapped[int | None] = mapped_column(
|
||||
Integer(),
|
||||
ForeignKey("user.id", ondelete="set null"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
primary_music_dir: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, default=None
|
||||
)
|
||||
setup_completed: Mapped[bool] = mapped_column(Boolean(), default=False)
|
||||
index_state: Mapped[str] = mapped_column(String(), default="idle")
|
||||
index_progress: Mapped[float] = mapped_column(Float(), default=0.0)
|
||||
index_message: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, default=None
|
||||
)
|
||||
created_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
updated_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(JSON(), default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def get_singleton(cls):
|
||||
result = cls.execute(select(cls).where(cls.id == 1))
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def ensure_singleton(cls):
|
||||
row = cls.get_singleton()
|
||||
if row:
|
||||
return row
|
||||
|
||||
cls.insert_one(
|
||||
{
|
||||
"id": 1,
|
||||
"setup_completed": False,
|
||||
"index_state": "idle",
|
||||
"index_progress": 0.0,
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
"extra": {},
|
||||
}
|
||||
)
|
||||
return cls.get_singleton()
|
||||
|
||||
@classmethod
|
||||
def update_state(cls, values: dict[str, Any]):
|
||||
cls.ensure_singleton()
|
||||
next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where(cls.id == 1)
|
||||
.values(
|
||||
{
|
||||
**values,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
return cls.get_singleton()
|
||||
|
||||
@classmethod
|
||||
def mark_index_progress(
|
||||
cls,
|
||||
*,
|
||||
state: str,
|
||||
progress: float,
|
||||
message: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
):
|
||||
values: dict[str, Any] = {
|
||||
"index_state": state,
|
||||
"index_progress": max(0.0, min(float(progress), 100.0)),
|
||||
"index_message": message,
|
||||
}
|
||||
if extra is not None:
|
||||
values["extra"] = extra
|
||||
return cls.update_state(values)
|
||||
|
||||
|
||||
class LyricsStatusTable(Base):
|
||||
__tablename__ = "lyrics_status"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
trackhash: Mapped[str] = mapped_column(String(), unique=True, index=True)
|
||||
filepath: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, index=True, default=None
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(), default="pending", index=True)
|
||||
source: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
|
||||
has_embedded: Mapped[bool] = mapped_column(Boolean(), default=False)
|
||||
has_lrc: Mapped[bool] = mapped_column(Boolean(), default=False)
|
||||
last_error: Mapped[str | None] = mapped_column(
|
||||
String(), nullable=True, default=None
|
||||
)
|
||||
attempts: Mapped[int] = mapped_column(Integer(), default=0)
|
||||
created_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
updated_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(JSON(), default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def get_by_trackhash(cls, trackhash: str):
|
||||
result = cls.execute(select(cls).where(cls.trackhash == trackhash))
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def upsert(
|
||||
cls,
|
||||
*,
|
||||
trackhash: str,
|
||||
filepath: str | None = None,
|
||||
status: str,
|
||||
source: str | None = None,
|
||||
has_embedded: bool | None = None,
|
||||
has_lrc: bool | None = None,
|
||||
last_error: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
increment_attempt: bool = False,
|
||||
):
|
||||
now = int(time.time())
|
||||
row = cls.get_by_trackhash(trackhash)
|
||||
values: dict[str, Any] = {
|
||||
"status": status,
|
||||
"source": source,
|
||||
"last_error": last_error,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
if filepath is not None:
|
||||
values["filepath"] = filepath
|
||||
if has_embedded is not None:
|
||||
values["has_embedded"] = bool(has_embedded)
|
||||
if has_lrc is not None:
|
||||
values["has_lrc"] = bool(has_lrc)
|
||||
if extra is not None:
|
||||
values["extra"] = extra
|
||||
|
||||
if row:
|
||||
if increment_attempt:
|
||||
values["attempts"] = int(row.attempts or 0) + 1
|
||||
next(
|
||||
cls.execute(
|
||||
update(cls).where(cls.id == row.id).values(values), commit=True
|
||||
)
|
||||
)
|
||||
return cls.get_by_trackhash(trackhash)
|
||||
|
||||
cls.insert_one(
|
||||
{
|
||||
"trackhash": trackhash,
|
||||
"filepath": filepath,
|
||||
"status": status,
|
||||
"source": source,
|
||||
"has_embedded": bool(has_embedded)
|
||||
if has_embedded is not None
|
||||
else False,
|
||||
"has_lrc": bool(has_lrc) if has_lrc is not None else False,
|
||||
"last_error": last_error,
|
||||
"attempts": 1 if increment_attempt else 0,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"extra": extra or {},
|
||||
}
|
||||
)
|
||||
return cls.get_by_trackhash(trackhash)
|
||||
|
||||
|
||||
class InviteTokenTable(Base):
|
||||
__tablename__ = "invite_token"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
token: Mapped[str] = mapped_column(String(), unique=True, index=True)
|
||||
created_by: Mapped[int | None] = mapped_column(
|
||||
Integer(),
|
||||
ForeignKey("user.id", ondelete="set null"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
used_by: Mapped[int | None] = mapped_column(
|
||||
Integer(),
|
||||
ForeignKey("user.id", ondelete="set null"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
roles: Mapped[list[str]] = mapped_column(JSON(), default_factory=lambda: ["user"])
|
||||
active: Mapped[bool] = mapped_column(Boolean(), default=True)
|
||||
expires_at: Mapped[int | None] = mapped_column(
|
||||
Integer(), nullable=True, default=None
|
||||
)
|
||||
created_at: Mapped[int] = mapped_column(Integer(), default=lambda: int(time.time()))
|
||||
used_at: Mapped[int | None] = mapped_column(Integer(), nullable=True, default=None)
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(JSON(), default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def create_token(
|
||||
cls,
|
||||
*,
|
||||
created_by: int | None,
|
||||
roles: list[str] | None = None,
|
||||
expires_in_seconds: int = 7 * 24 * 3600,
|
||||
extra: dict[str, Any] | None = None,
|
||||
):
|
||||
token = secrets.token_urlsafe(24)
|
||||
now = int(time.time())
|
||||
expires_at = now + expires_in_seconds if expires_in_seconds > 0 else None
|
||||
|
||||
cls.insert_one(
|
||||
{
|
||||
"token": token,
|
||||
"created_by": created_by,
|
||||
"roles": roles or ["user"],
|
||||
"active": True,
|
||||
"expires_at": expires_at,
|
||||
"created_at": now,
|
||||
"extra": extra or {},
|
||||
}
|
||||
)
|
||||
|
||||
result = cls.execute(select(cls).where(cls.token == token))
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def get_valid_token(cls, token: str):
|
||||
now = int(time.time())
|
||||
result = cls.execute(select(cls).where(cls.token == token))
|
||||
row = next(result).scalar()
|
||||
|
||||
if not row or not row.active:
|
||||
return None
|
||||
|
||||
if row.expires_at is not None and row.expires_at < now:
|
||||
cls.consume_token(token, used_by=None, deactivate_only=True)
|
||||
return None
|
||||
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
def consume_token(
|
||||
cls, token: str, used_by: int | None, deactivate_only: bool = False
|
||||
):
|
||||
values: dict[str, Any] = {"active": False, "used_at": int(time.time())}
|
||||
if not deactivate_only:
|
||||
values["used_by"] = used_by
|
||||
|
||||
next(
|
||||
cls.execute(
|
||||
update(cls).where(cls.token == token).values(values), commit=True
|
||||
)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
This module contains the functions to interact with the SQLite database.
|
||||
"""
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Reads and saves the latest database migrations version.
|
||||
"""
|
||||
|
||||
from swingmusic.db.sqlite.utils import SQLiteManager
|
||||
|
||||
|
||||
class MigrationManager:
|
||||
@staticmethod
|
||||
def get_index() -> int:
|
||||
"""
|
||||
Returns the latest databases migrations index.
|
||||
"""
|
||||
sql = "SELECT * FROM dbmigrations"
|
||||
with SQLiteManager() as cur:
|
||||
cur.execute(sql)
|
||||
ver = int(cur.fetchone()[1])
|
||||
cur.close()
|
||||
|
||||
return ver
|
||||
|
||||
# 👇 Setters 👇
|
||||
@staticmethod
|
||||
def set_index(version: int):
|
||||
"""
|
||||
Updates the databases migrations index.
|
||||
"""
|
||||
sql = "UPDATE dbmigrations SET version = ? WHERE id = 1"
|
||||
with SQLiteManager() as cur:
|
||||
cur.execute(sql, (version,))
|
||||
cur.close()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Helper functions for use with the SQLite database.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from sqlite3 import Connection, Cursor
|
||||
|
||||
from swingmusic import settings
|
||||
from swingmusic.models import Album, Playlist, Track
|
||||
|
||||
|
||||
def tuple_to_track(track: tuple):
|
||||
"""
|
||||
Takes a tuple and returns a Track object
|
||||
"""
|
||||
return Track(*track[1:]) # rowid is removed from the tuple
|
||||
|
||||
|
||||
def tuples_to_tracks(tracks: list[tuple]):
|
||||
"""
|
||||
Takes a list of tuples and returns a generator that yields a Track object for each tuple
|
||||
"""
|
||||
for track in tracks:
|
||||
yield tuple_to_track(track)
|
||||
|
||||
|
||||
def tuple_to_album(album: tuple):
|
||||
"""
|
||||
Takes a tuple and returns an Album object
|
||||
"""
|
||||
return Album(*album[1:]) # rowid is removed from the tuple
|
||||
|
||||
|
||||
def tuples_to_albums(albums: list[tuple]):
|
||||
"""
|
||||
Takes a list of tuples and returns a generator that yields an album object for each tuple
|
||||
"""
|
||||
for album in albums:
|
||||
yield tuple_to_album(album)
|
||||
|
||||
|
||||
def tuple_to_playlist(playlist: tuple):
|
||||
"""
|
||||
Takes a tuple and returns a Playlist object
|
||||
"""
|
||||
return Playlist(*playlist)
|
||||
|
||||
|
||||
def tuples_to_playlists(playlists: list[tuple]):
|
||||
"""
|
||||
Takes a list of tuples and returns a list of Playlist objects
|
||||
"""
|
||||
for playlist in playlists:
|
||||
yield tuple_to_playlist(playlist)
|
||||
|
||||
|
||||
class SQLiteManager:
|
||||
"""
|
||||
This is a context manager that handles the connection and cursor
|
||||
for you. It also commits and closes the connection when you're done.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: Connection | None = None,
|
||||
userdata_db=False,
|
||||
test_db_path: str = None,
|
||||
) -> None:
|
||||
"""
|
||||
When a connection is passed in, don't close the connection, because it's
|
||||
a connection to the search database [in memory db].
|
||||
"""
|
||||
self.conn = conn
|
||||
self.CLOSE_CONN = True
|
||||
self.userdata_db = userdata_db
|
||||
self.test_db_path = test_db_path
|
||||
|
||||
if conn:
|
||||
self.conn = conn
|
||||
self.CLOSE_CONN = False
|
||||
|
||||
def __enter__(self) -> Cursor:
|
||||
if self.conn is not None:
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("PRAGMA foreign_keys = ON")
|
||||
return cur
|
||||
|
||||
db_path = self.test_db_path or settings.Paths().app_db_path
|
||||
|
||||
if self.userdata_db:
|
||||
db_path = settings.Paths().userdata_db_path
|
||||
|
||||
self.conn = sqlite3.connect(
|
||||
db_path,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("PRAGMA foreign_keys = ON")
|
||||
return cur
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
trial_count = 0
|
||||
|
||||
while trial_count < 10:
|
||||
try:
|
||||
self.conn.commit()
|
||||
|
||||
if self.CLOSE_CONN:
|
||||
self.conn.close()
|
||||
|
||||
return
|
||||
except sqlite3.OperationalError:
|
||||
trial_count += 1
|
||||
time.sleep(3)
|
||||
|
||||
self.conn.close()
|
||||
@@ -0,0 +1,835 @@
|
||||
import datetime
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
and_,
|
||||
delete,
|
||||
func,
|
||||
insert,
|
||||
select,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from swingmusic.db import Base
|
||||
from swingmusic.db.engine import DbEngine
|
||||
from swingmusic.db.utils import (
|
||||
favorite_to_dataclass,
|
||||
favorites_to_dataclass,
|
||||
playlist_to_dataclass,
|
||||
plugin_to_dataclass,
|
||||
similar_artist_to_dataclass,
|
||||
tracklog_to_dataclass,
|
||||
user_to_dataclass,
|
||||
)
|
||||
from swingmusic.models.mix import Mix
|
||||
from swingmusic.utils.auth import get_current_userid, hash_password
|
||||
|
||||
|
||||
class UserTable(Base):
|
||||
__tablename__ = "user"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
image: Mapped[str] = mapped_column(String(), nullable=True)
|
||||
password: Mapped[str] = mapped_column(String())
|
||||
username: Mapped[str] = mapped_column(String(), index=True)
|
||||
roles: Mapped[list[str]] = mapped_column(JSON(), default_factory=lambda: [])
|
||||
password_change_required: Mapped[bool] = mapped_column(Boolean(), default=False)
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON(), nullable=True, default_factory=dict
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
result = cls.execute(select(cls))
|
||||
|
||||
for i in next(result).scalars():
|
||||
yield user_to_dataclass(i)
|
||||
|
||||
@classmethod
|
||||
def insert_default_user(cls):
|
||||
user = {
|
||||
"username": "admin",
|
||||
"password": hash_password("admin"),
|
||||
"roles": ["admin"],
|
||||
"password_change_required": True,
|
||||
}
|
||||
|
||||
return cls.insert_one(user)
|
||||
|
||||
@classmethod
|
||||
def insert_guest_user(cls):
|
||||
user = {
|
||||
"username": "guest",
|
||||
"password": hash_password("guest"),
|
||||
"roles": ["guest"],
|
||||
"password_change_required": True,
|
||||
}
|
||||
|
||||
return cls.insert_one(user)
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, id: int):
|
||||
result = cls.execute(select(cls).where(cls.id == id))
|
||||
res = next(result).scalar()
|
||||
|
||||
if res:
|
||||
return user_to_dataclass(res)
|
||||
|
||||
@classmethod
|
||||
def get_by_username(cls, username: str):
|
||||
res = cls.execute(select(cls).where(cls.username == username))
|
||||
res = next(res).scalar()
|
||||
|
||||
if res:
|
||||
return user_to_dataclass(res)
|
||||
|
||||
@classmethod
|
||||
def update_one(cls, user: dict[str, Any]):
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls).where(cls.id == user["id"]).values(user), commit=True
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def remove_by_username(cls, username: str):
|
||||
return next(
|
||||
cls.execute(delete(cls).where(cls.username == username), commit=True)
|
||||
)
|
||||
|
||||
|
||||
class PluginTable(Base):
|
||||
__tablename__ = "plugin"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(), unique=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean())
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON())
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(JSON(), nullable=True)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
result = cls.execute(select(cls))
|
||||
|
||||
for i in next(result).scalars():
|
||||
yield plugin_to_dataclass(i)
|
||||
|
||||
@classmethod
|
||||
def activate(cls, name: str, value: bool):
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls).where(cls.name == name).values(active=value), commit=True
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_by_name(cls, name: str):
|
||||
result = cls.execute(select(cls).where(cls.name == name))
|
||||
res = next(result).scalar()
|
||||
|
||||
if res:
|
||||
return plugin_to_dataclass(res)
|
||||
|
||||
@classmethod
|
||||
def update_settings(cls, name: str, settings: dict[str, Any]):
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls).where(cls.name == name).values(settings=settings),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SimilarArtistTable(Base):
|
||||
__tablename__ = "notlastfm_similar_artists"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer(), primary_key=True)
|
||||
artisthash: Mapped[str] = mapped_column(String(), index=True)
|
||||
similar_artists: Mapped[dict[str, str]] = mapped_column(JSON())
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
result = cls.execute(select(cls).execution_options(yield_per=100))
|
||||
|
||||
for i in next(result).scalars():
|
||||
yield similar_artist_to_dataclass(i)
|
||||
|
||||
@classmethod
|
||||
def exists(cls, artisthash: str):
|
||||
"""
|
||||
Check whether an artisthash exists in the database.
|
||||
"""
|
||||
|
||||
with DbEngine.manager() as conn:
|
||||
result = conn.execute(
|
||||
select(cls.artisthash)
|
||||
.where(cls.artisthash == artisthash)
|
||||
.execution_options(yield_per=100)
|
||||
)
|
||||
|
||||
return len(result.scalars().all()) > 0
|
||||
|
||||
@classmethod
|
||||
def get_by_hash(cls, artisthash: str):
|
||||
"""
|
||||
Get a single artist by hash.
|
||||
"""
|
||||
result = cls.execute(select(cls).where(cls.artisthash == artisthash))
|
||||
res = next(result).scalar()
|
||||
|
||||
if res:
|
||||
return similar_artist_to_dataclass(res)
|
||||
|
||||
|
||||
class FavoritesTable(Base):
|
||||
__tablename__ = "favorite"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
hash: Mapped[str] = mapped_column(String(), unique=True)
|
||||
type: Mapped[str] = mapped_column(String(), index=True)
|
||||
timestamp: Mapped[int] = mapped_column(Integer(), index=True)
|
||||
userid: Mapped[int] = mapped_column(
|
||||
Integer(), ForeignKey("user.id", ondelete="cascade"), default=1, index=True
|
||||
)
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON(), nullable=True, default_factory=dict
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _normalize_item_hash(cls, raw_hash: str, item_type: str) -> str:
|
||||
"""
|
||||
Normalize legacy and scoped favorite hash formats to plain item hash.
|
||||
Accepted formats:
|
||||
- <hash>
|
||||
- <type>_<hash>
|
||||
- u<userid>:<type>_<hash>
|
||||
"""
|
||||
normalized = str(raw_hash or "").strip()
|
||||
item_type = str(item_type or "").strip()
|
||||
|
||||
if normalized.startswith("u") and ":" in normalized:
|
||||
user_prefix, remainder = normalized.split(":", 1)
|
||||
if user_prefix[1:].isdigit():
|
||||
normalized = remainder
|
||||
|
||||
type_prefix = f"{item_type}_"
|
||||
if item_type and normalized.startswith(type_prefix):
|
||||
normalized = normalized[len(type_prefix) :]
|
||||
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _hash_candidates(
|
||||
cls,
|
||||
*,
|
||||
hash_value: str,
|
||||
item_type: str,
|
||||
userid: int | None = None,
|
||||
) -> set[str]:
|
||||
canonical = cls._normalize_item_hash(hash_value, item_type)
|
||||
candidates = {canonical}
|
||||
|
||||
if item_type:
|
||||
candidates.add(f"{item_type}_{canonical}")
|
||||
if userid is not None:
|
||||
candidates.add(f"u{int(userid)}:{item_type}_{canonical}")
|
||||
|
||||
return {candidate for candidate in candidates if candidate}
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, with_user: bool = True):
|
||||
with DbEngine.manager() as conn:
|
||||
if with_user:
|
||||
result = conn.execute(
|
||||
select(cls).where(cls.userid == get_current_userid())
|
||||
)
|
||||
else:
|
||||
result = conn.execute(select(cls))
|
||||
|
||||
for i in result.scalars():
|
||||
yield favorite_to_dataclass(i)
|
||||
|
||||
@classmethod
|
||||
def insert_item(cls, item: dict[str, Any]):
|
||||
item_type = str(item.get("type") or "").strip()
|
||||
canonical_hash = cls._normalize_item_hash(item.get("hash", ""), item_type)
|
||||
userid = int(item.get("userid") or get_current_userid())
|
||||
|
||||
if cls.check_exists(canonical_hash, item_type, userid=userid):
|
||||
return None
|
||||
|
||||
# Scope favorites per user while keeping backward compatibility
|
||||
# with legacy `type_hash` entries.
|
||||
item["hash"] = f"u{userid}:{item_type}_{canonical_hash}"
|
||||
|
||||
if item.get("timestamp") is None:
|
||||
item["timestamp"] = int(datetime.datetime.now().timestamp())
|
||||
|
||||
item["userid"] = userid
|
||||
|
||||
return next(cls.execute(insert(cls).values(item), commit=True))
|
||||
|
||||
@classmethod
|
||||
def remove_item(cls, item: dict[str, Any]):
|
||||
userid = int(item.get("userid") or get_current_userid())
|
||||
candidates = cls._hash_candidates(
|
||||
hash_value=str(item.get("hash") or ""),
|
||||
item_type=str(item.get("type") or ""),
|
||||
userid=userid,
|
||||
)
|
||||
return next(
|
||||
cls.execute(
|
||||
delete(cls).where(and_(cls.userid == userid, cls.hash.in_(candidates))),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def check_exists(cls, hash: str, type: str, userid: int | None = None):
|
||||
userid = int(userid or get_current_userid())
|
||||
candidates = cls._hash_candidates(
|
||||
hash_value=hash,
|
||||
item_type=type,
|
||||
userid=userid,
|
||||
)
|
||||
result = cls.execute(
|
||||
select(cls).where(and_(cls.userid == userid, cls.hash.in_(candidates)))
|
||||
)
|
||||
|
||||
return next(result).scalar() is not None
|
||||
|
||||
@classmethod
|
||||
def get_by_hash(cls, hash: str, type: str, userid: int | None = None):
|
||||
userid = int(userid or get_current_userid())
|
||||
candidates = cls._hash_candidates(
|
||||
hash_value=hash,
|
||||
item_type=type,
|
||||
userid=userid,
|
||||
)
|
||||
result = cls.execute(
|
||||
select(cls).where(and_(cls.userid == userid, cls.hash.in_(candidates)))
|
||||
)
|
||||
|
||||
return next(result).scalars().all()
|
||||
|
||||
@classmethod
|
||||
def get_all_of_type(cls, type: str, start: int, limit: int):
|
||||
result = cls.execute(
|
||||
select(cls)
|
||||
# .select_from(join(table, cls, field == cls.hash))
|
||||
.where(and_(cls.type == type, cls.userid == get_current_userid()))
|
||||
.order_by(cls.timestamp.desc())
|
||||
.offset(start)
|
||||
# INFO: If start is 0, fetch all so we can get the total count
|
||||
.limit(limit if start != 0 else None)
|
||||
)
|
||||
|
||||
res = next(result).scalars().all()
|
||||
|
||||
if start == 0:
|
||||
# if limit == -1, return all
|
||||
if limit == -1:
|
||||
limit = len(res)
|
||||
|
||||
return res[:limit], len(res)
|
||||
|
||||
return res, -1
|
||||
|
||||
@classmethod
|
||||
def get_fav_tracks(cls, start: int, limit: int):
|
||||
result, total = cls.get_all_of_type("track", start, limit)
|
||||
return favorites_to_dataclass(result), total
|
||||
|
||||
@classmethod
|
||||
def get_fav_albums(cls, start: int, limit: int):
|
||||
result, total = cls.get_all_of_type("album", start, limit)
|
||||
return favorites_to_dataclass(result), total
|
||||
|
||||
@classmethod
|
||||
def get_fav_artists(cls, start: int, limit: int):
|
||||
result, total = cls.get_all_of_type("artist", start, limit)
|
||||
return favorites_to_dataclass(result), total
|
||||
|
||||
@classmethod
|
||||
def count_favs_in_period(cls, start_time: int, end_time: int):
|
||||
result = cls.execute(
|
||||
select(func.count(cls.id))
|
||||
.where(cls.userid == get_current_userid())
|
||||
.where(and_(cls.timestamp >= start_time, cls.timestamp <= end_time))
|
||||
)
|
||||
|
||||
res = next(result).scalar()
|
||||
|
||||
if res:
|
||||
return res
|
||||
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def count_tracks(cls):
|
||||
result = cls.execute(
|
||||
select(func.count(cls.id)).where(
|
||||
and_(cls.type == "track", cls.userid == get_current_userid())
|
||||
)
|
||||
)
|
||||
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def get_last_trackhash(cls):
|
||||
result = cls.execute(
|
||||
select(cls.hash)
|
||||
.where(and_(cls.type == "track", cls.userid == get_current_userid()))
|
||||
.order_by(cls.timestamp.desc())
|
||||
)
|
||||
|
||||
db_hash = next(result).scalar()
|
||||
if not db_hash:
|
||||
return None
|
||||
|
||||
return cls._normalize_item_hash(db_hash, "track")
|
||||
|
||||
|
||||
class ScrobbleTable(Base):
|
||||
__tablename__ = "scrobble"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
trackhash: Mapped[str] = mapped_column(String(), index=True)
|
||||
duration: Mapped[int] = mapped_column(Integer())
|
||||
timestamp: Mapped[int] = mapped_column(Integer())
|
||||
source: Mapped[str] = mapped_column(String())
|
||||
userid: Mapped[int] = mapped_column(
|
||||
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
|
||||
)
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON(), nullable=True, default_factory=dict
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def add(cls, item: dict[str, Any]):
|
||||
if item.get("userid") is None:
|
||||
item["userid"] = get_current_userid()
|
||||
|
||||
return cls.insert_one(item)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, start: int, limit: int | None = None, userid: int | None = None):
|
||||
result = cls.execute(
|
||||
select(cls)
|
||||
.where(cls.userid == (userid if userid else get_current_userid()))
|
||||
.order_by(cls.timestamp.desc())
|
||||
.offset(start)
|
||||
.limit(limit)
|
||||
.execution_options(yield_per=100)
|
||||
)
|
||||
|
||||
for i in next(result).scalars():
|
||||
yield tracklog_to_dataclass(i)
|
||||
|
||||
@classmethod
|
||||
def get_all_in_period(cls, start_time: int, end_time: int, userid: int | None):
|
||||
# UserId will be None if function is called from the API
|
||||
# In that case, we use the request userid
|
||||
if userid is None:
|
||||
userid = get_current_userid()
|
||||
|
||||
result = cls.execute(
|
||||
select(cls)
|
||||
.where(cls.userid == userid)
|
||||
.where(and_(cls.timestamp >= start_time, cls.timestamp <= end_time))
|
||||
.order_by(cls.timestamp.desc())
|
||||
.execution_options(yield_per=100)
|
||||
)
|
||||
|
||||
for i in next(result).scalars():
|
||||
yield tracklog_to_dataclass(i)
|
||||
|
||||
@classmethod
|
||||
def get_last_entry(cls, userid: int):
|
||||
result = cls.execute(
|
||||
select(cls).where(cls.userid == userid).order_by(cls.timestamp.desc())
|
||||
)
|
||||
res = next(result).scalar()
|
||||
|
||||
if res:
|
||||
return tracklog_to_dataclass(res)
|
||||
|
||||
|
||||
class PlaylistTable(Base):
|
||||
__tablename__ = "playlist"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(), index=True)
|
||||
last_updated: Mapped[int] = mapped_column(Integer())
|
||||
image: Mapped[str] = mapped_column(String(), nullable=True)
|
||||
userid: Mapped[int] = mapped_column(
|
||||
Integer(), ForeignKey("user.id", ondelete="cascade")
|
||||
)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON())
|
||||
trackhashes: Mapped[list[str]] = mapped_column(JSON(), default_factory=list)
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON(), nullable=True, default_factory=dict
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, current_user: bool = True):
|
||||
if current_user:
|
||||
result = cls.execute(
|
||||
select(cls)
|
||||
.where(cls.userid == get_current_userid())
|
||||
.execution_options(yield_per=100)
|
||||
)
|
||||
else:
|
||||
result = cls.execute(select(cls).execution_options(yield_per=100))
|
||||
|
||||
for i in next(result).scalars():
|
||||
yield playlist_to_dataclass(i)
|
||||
|
||||
@classmethod
|
||||
def add_one(cls, playlist: dict[str, Any]):
|
||||
playlist["userid"] = get_current_userid()
|
||||
result = cls.insert_one(playlist)
|
||||
|
||||
return result.lastrowid
|
||||
|
||||
@classmethod
|
||||
def check_exists_by_name(cls, name: str):
|
||||
result = cls.execute(
|
||||
select(cls).where((cls.name == name) & (cls.userid == get_current_userid()))
|
||||
)
|
||||
return next(result).scalar() is not None
|
||||
|
||||
@classmethod
|
||||
def append_to_playlist(cls, id: int, trackhashes: list[str]):
|
||||
dbtrackhashes = cls.get_trackhashes(id) or []
|
||||
trackhashes = list(set(dbtrackhashes).union(set(trackhashes)))
|
||||
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where((cls.id == id) & (cls.userid == get_current_userid()))
|
||||
.values(trackhashes=trackhashes),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_trackhashes(cls, id: int):
|
||||
result = cls.execute(
|
||||
select(cls.trackhashes).where(
|
||||
(cls.id == id) & (cls.userid == get_current_userid())
|
||||
)
|
||||
)
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def remove_from_playlist(cls, id: int, trackhashes: list[dict[str, Any]]):
|
||||
# INFO: Get db trackhashes
|
||||
dbtrackhashes = cls.get_trackhashes(id)
|
||||
if dbtrackhashes:
|
||||
for item in trackhashes:
|
||||
if dbtrackhashes.index(item["trackhash"]) == item["index"]:
|
||||
dbtrackhashes.remove(item["trackhash"])
|
||||
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where((cls.id == id) & (cls.userid == get_current_userid()))
|
||||
.values(trackhashes=dbtrackhashes),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, id: int):
|
||||
result = cls.execute(
|
||||
select(cls).where((cls.id == id) & (cls.userid == get_current_userid()))
|
||||
)
|
||||
result = next(result).scalar()
|
||||
|
||||
if result:
|
||||
return playlist_to_dataclass(result)
|
||||
|
||||
@classmethod
|
||||
def update_one(cls, id: int, playlist: dict[str, Any]):
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where((cls.id == id) & (cls.userid == get_current_userid()))
|
||||
.values(playlist),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_settings(cls, id: int, settings: dict[str, Any]):
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where((cls.id == id) & (cls.userid == get_current_userid()))
|
||||
.values(settings=settings),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def remove_image(cls, id: int):
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where((cls.id == id) & (cls.userid == get_current_userid()))
|
||||
.values(image=None),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class LibDataTable(Base):
|
||||
__tablename__ = "artistdata"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
itemhash: Mapped[str] = mapped_column(String(), unique=True, index=True)
|
||||
itemtype: Mapped[str] = mapped_column(String())
|
||||
color: Mapped[str] = mapped_column(String(), nullable=True)
|
||||
bio: Mapped[str] = mapped_column(String(), nullable=True)
|
||||
info: Mapped[dict[str, Any]] = mapped_column(JSON(), nullable=True)
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON(), nullable=True, default_factory=dict
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_one(cls, hash: str, data: dict[str, Any]):
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls).where(cls.itemhash == hash).values(data), commit=True
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find_one(cls, hash: str, type: Literal["album", "artist"]):
|
||||
result = cls.execute(
|
||||
select(cls).where((cls.itemhash == type + hash) & (cls.itemtype == type))
|
||||
)
|
||||
return next(result).scalar()
|
||||
|
||||
@classmethod
|
||||
def get_all_colors(cls, type: str) -> Iterable[dict[str, str]]:
|
||||
result = cls.execute(select(cls).where(cls.itemtype == type))
|
||||
|
||||
for i in next(result).scalars():
|
||||
yield {"itemhash": i.itemhash.replace(type, ""), "color": i.color}
|
||||
|
||||
|
||||
class MixTable(Base):
|
||||
__tablename__ = "mix"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
mixid: Mapped[str] = mapped_column(String(), index=True)
|
||||
title: Mapped[str] = mapped_column(String())
|
||||
description: Mapped[str] = mapped_column(String())
|
||||
timestamp: Mapped[int] = mapped_column(Integer())
|
||||
sourcehash: Mapped[str] = mapped_column(String(), unique=True, index=True)
|
||||
userid: Mapped[int] = mapped_column(
|
||||
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
|
||||
)
|
||||
saved: Mapped[bool] = mapped_column(Boolean(), default=False)
|
||||
tracks: Mapped[list[str]] = mapped_column(JSON(), default_factory=list)
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON(), nullable=True, default_factory=dict
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, with_userid: bool = False):
|
||||
if with_userid:
|
||||
result = cls.execute(
|
||||
select(cls)
|
||||
.where(cls.userid == get_current_userid())
|
||||
.order_by(cls.timestamp.desc())
|
||||
)
|
||||
else:
|
||||
result = cls.execute(select(cls).order_by(cls.timestamp.desc()))
|
||||
|
||||
for i in next(result).scalars():
|
||||
yield Mix.mix_to_dataclass(i)
|
||||
|
||||
@classmethod
|
||||
def get_by_sourcehash(cls, sourcehash: str):
|
||||
result = cls.execute(
|
||||
select(cls).where(
|
||||
and_(cls.sourcehash == sourcehash, cls.userid == get_current_userid())
|
||||
)
|
||||
)
|
||||
|
||||
res = next(result).scalar()
|
||||
|
||||
if res:
|
||||
return Mix.mix_to_dataclass(res)
|
||||
|
||||
@classmethod
|
||||
def get_by_mixid(cls, mixid: str):
|
||||
result = cls.execute(
|
||||
select(cls).where(
|
||||
and_(cls.mixid == mixid, cls.userid == get_current_userid())
|
||||
)
|
||||
)
|
||||
res = next(result).scalar()
|
||||
|
||||
if res:
|
||||
return Mix.mix_to_dataclass(res)
|
||||
|
||||
@classmethod
|
||||
def insert_one(cls, mix: Mix):
|
||||
mixdict = asdict(mix)
|
||||
mixdict["mixid"] = mix.id
|
||||
del mixdict["id"]
|
||||
|
||||
return next(cls.execute(insert(cls).values(mixdict), commit=True))
|
||||
|
||||
@classmethod
|
||||
def update_one(cls, mixid: str, mix: Mix):
|
||||
mixdict = asdict(mix)
|
||||
mixdict["mixid"] = mix.id
|
||||
del mixdict["id"]
|
||||
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where(
|
||||
and_(
|
||||
cls.mixid == mixid,
|
||||
cls.sourcehash == mix.sourcehash,
|
||||
cls.userid == get_current_userid(),
|
||||
)
|
||||
)
|
||||
.values(mixdict),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def save_artist_mix(cls, sourcehash: str):
|
||||
"""
|
||||
Toggles the saved status of an artist mix.
|
||||
"""
|
||||
|
||||
mix = cls.get_by_sourcehash(sourcehash)
|
||||
|
||||
if not mix:
|
||||
return False
|
||||
|
||||
mix.saved = not mix.saved
|
||||
cls.update_one(mix.id, mix)
|
||||
|
||||
return mix.saved
|
||||
|
||||
@classmethod
|
||||
def get_saved_track_mixes(cls):
|
||||
"""
|
||||
Return all mixes that have the extra.trackmix_saved set to True.
|
||||
"""
|
||||
|
||||
result = cls.execute(
|
||||
select(cls).where(
|
||||
and_(cls.extra.c.trackmix_saved, cls.userid == get_current_userid())
|
||||
)
|
||||
)
|
||||
# return Mix.mixes_to_dataclasses(result.fetchall())
|
||||
|
||||
for i in next(result).scalars():
|
||||
yield Mix.mix_to_dataclass(i)
|
||||
|
||||
@classmethod
|
||||
def save_track_mix(cls, sourcehash: str):
|
||||
"""
|
||||
Toggles the property extra.trackmix_saved to True.
|
||||
"""
|
||||
|
||||
mix = cls.get_by_sourcehash(sourcehash)
|
||||
if not mix:
|
||||
return False
|
||||
|
||||
mix.extra["trackmix_saved"] = not mix.extra.get("trackmix_saved", False)
|
||||
cls.update_one(mix.id, mix)
|
||||
|
||||
return mix.extra["trackmix_saved"]
|
||||
|
||||
|
||||
class CollectionTable(Base):
|
||||
# INFO: table name was kept as page to avoid breaking existing data
|
||||
__tablename__ = "page"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(), index=True)
|
||||
userid: Mapped[int] = mapped_column(
|
||||
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
|
||||
)
|
||||
items: Mapped[list[dict[str, Any]]] = mapped_column(JSON(), default_factory=list)
|
||||
extra: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON(), nullable=True, default_factory=dict
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls, entry: Any) -> dict[str, Any]:
|
||||
d = entry.__dict__
|
||||
del d["_sa_instance_state"]
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
result = cls.execute(select(cls).where(cls.userid == get_current_userid()))
|
||||
|
||||
for i in next(result).scalars():
|
||||
yield cls.to_dict(i)
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, id: int):
|
||||
result = cls.execute(
|
||||
select(cls).where(and_(cls.id == id, cls.userid == get_current_userid()))
|
||||
)
|
||||
res = next(result).scalar()
|
||||
|
||||
if res:
|
||||
return cls.to_dict(res)
|
||||
|
||||
@classmethod
|
||||
def delete_by_id(cls, id: int):
|
||||
return next(
|
||||
cls.execute(
|
||||
delete(cls).where(
|
||||
and_(cls.id == id, cls.userid == get_current_userid())
|
||||
),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_items(cls, id: int, items: list[dict[str, Any]]):
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where(and_(cls.id == id, cls.userid == get_current_userid()))
|
||||
.values(items=items),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_one(cls, payload: dict[str, Any]):
|
||||
return next(
|
||||
cls.execute(
|
||||
update(cls)
|
||||
.where(
|
||||
and_(cls.id == payload["id"], cls.userid == get_current_userid())
|
||||
)
|
||||
.values(payload),
|
||||
commit=True,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
from typing import Any
|
||||
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.models import Album as AlbumModel
|
||||
from swingmusic.models import Artist as ArtistModel
|
||||
from swingmusic.models import Track as TrackModel
|
||||
from swingmusic.models.favorite import Favorite
|
||||
from swingmusic.models.lastfm import SimilarArtist
|
||||
from swingmusic.models.logger import TrackLog
|
||||
from swingmusic.models.playlist import Playlist
|
||||
from swingmusic.models.plugins import Plugin
|
||||
from swingmusic.models.user import User
|
||||
|
||||
|
||||
def row_to_dict(row: Any):
|
||||
d = row.__dict__
|
||||
del d["_sa_instance_state"]
|
||||
return d
|
||||
|
||||
|
||||
def track_to_dataclass(track: dict, config: UserConfig):
|
||||
return TrackModel(**track, config=config)
|
||||
|
||||
|
||||
def tracks_to_dataclasses(tracks: Any):
|
||||
return [track_to_dataclass(track, UserConfig()) for track in tracks]
|
||||
|
||||
|
||||
def album_to_dataclass(album: Any):
|
||||
return AlbumModel(**album._asdict())
|
||||
|
||||
|
||||
def albums_to_dataclasses(albums: Any):
|
||||
return [album_to_dataclass(album) for album in albums]
|
||||
|
||||
|
||||
def artist_to_dataclass(artist: Any):
|
||||
return ArtistModel(**artist._asdict())
|
||||
|
||||
|
||||
def artists_to_dataclasses(artists: Any):
|
||||
return [artist_to_dataclass(artist) for artist in artists]
|
||||
|
||||
|
||||
# SECTION: User data helpers
|
||||
def similar_artist_to_dataclass(entry: Any):
|
||||
entry_dict = row_to_dict(entry)
|
||||
del entry_dict["id"]
|
||||
|
||||
return SimilarArtist(**entry_dict)
|
||||
|
||||
|
||||
def similar_artists_to_dataclass(entries: Any):
|
||||
return [similar_artist_to_dataclass(entry) for entry in entries]
|
||||
|
||||
|
||||
def favorite_to_dataclass(entry: Any):
|
||||
entry_dict = row_to_dict(entry)
|
||||
del entry_dict["id"]
|
||||
|
||||
return Favorite(**entry_dict)
|
||||
|
||||
|
||||
def favorites_to_dataclass(entries: Any):
|
||||
return [favorite_to_dataclass(entry) for entry in entries]
|
||||
|
||||
|
||||
def user_to_dataclass(entry: Any):
|
||||
return User(**row_to_dict(entry))
|
||||
|
||||
|
||||
# def user_to_dataclasses(entries: Any):
|
||||
# return [user_to_dataclass(entry) for entry in entries]
|
||||
|
||||
|
||||
def plugin_to_dataclass(entry: Any):
|
||||
entry_dict = row_to_dict(entry)
|
||||
del entry_dict["id"]
|
||||
return Plugin(**entry_dict)
|
||||
|
||||
|
||||
def plugin_to_dataclasses(entries: Any):
|
||||
return [plugin_to_dataclass(entry) for entry in entries]
|
||||
|
||||
|
||||
def tracklog_to_dataclass(entry: Any):
|
||||
return TrackLog(**row_to_dict(entry))
|
||||
|
||||
|
||||
def tracklog_to_dataclasses(entries: Any):
|
||||
return [tracklog_to_dataclass(entry) for entry in entries]
|
||||
|
||||
|
||||
def playlist_to_dataclass(entry: Any):
|
||||
entry_dict = row_to_dict(entry)
|
||||
return Playlist(**entry_dict)
|
||||
|
||||
|
||||
def playlists_to_dataclasses(entries: Any):
|
||||
return [playlist_to_dataclass(entry) for entry in entries]
|
||||
@@ -0,0 +1,67 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AlbumVersionEnum(Enum):
|
||||
"""
|
||||
Enum that registers supported album versions.
|
||||
"""
|
||||
|
||||
Explicit = ("explicit",)
|
||||
_360_AUDIO = ("360 audio",)
|
||||
|
||||
ANNIVERSARY_EDITION = ("anniversary",)
|
||||
DIAMOND_EDITION = ("diamond",)
|
||||
Centennial_EDITION = ("centennial",)
|
||||
GOLDEN_EDITION = ("gold",)
|
||||
PLATINUM_EDITION = ("platinum",)
|
||||
SILVER_EDITION = ("silver",)
|
||||
ULTIMATE_EDITION = ("ultimate",)
|
||||
|
||||
EXPANDED = ("expanded",)
|
||||
EXTENDED = ("extended",)
|
||||
|
||||
DELUXE = ("deluxe",)
|
||||
SUPER_DELUXE = ("super deluxe",)
|
||||
COMPLETE = ("complete",)
|
||||
|
||||
LEGACY_EDITION = ("legacy",)
|
||||
SPECIAL_EDITION = ("special",)
|
||||
COLLECTORS_EDITION = ("collector",)
|
||||
ARCHIVE_EDITION = ("archive",)
|
||||
|
||||
Acoustic = ("acoustic",)
|
||||
instrumental = ("instrumental",)
|
||||
DOUBLE_DISC = ("double disc", "double disk")
|
||||
Unplugged = ("unplugged",)
|
||||
|
||||
SUMMER_EDITION = ("summer",)
|
||||
WINTER_EDITION = ("winter",)
|
||||
SPRING_EDITION = ("spring",)
|
||||
FALL_EDITION = ("fall",)
|
||||
|
||||
BONUS_EDITION = ("bonus",)
|
||||
BONUS_TRACK = ("bonus track",)
|
||||
|
||||
ORIGINAL = ("original", " og ", "og ")
|
||||
INTL_VERSION = ("international",)
|
||||
UK_VERSION = ("uk version",)
|
||||
US_VERSION = ("us version",)
|
||||
PARENTAL_ADVISORY = ("PA version",)
|
||||
|
||||
Limited_EDITION = ("limited",)
|
||||
|
||||
MONO = ("mono",)
|
||||
STEREO = ("stereo",)
|
||||
|
||||
HI_RES = ("Hi-Res",)
|
||||
RE_MIX = ("re-mix",)
|
||||
RE_RECORDED = ("re-recorded", "rerecorded")
|
||||
REISSUE = ("reissue",)
|
||||
REMASTERED = ("remaster",)
|
||||
|
||||
|
||||
def get_all_keywords():
|
||||
"""
|
||||
Returns a joint string of all album versions.
|
||||
"""
|
||||
return "|".join("|".join(i.value) for i in AlbumVersionEnum)
|
||||
@@ -0,0 +1,90 @@
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Jsoni:
|
||||
_configpath: str = ""
|
||||
_init_complete: bool = False
|
||||
|
||||
# @property
|
||||
# def _configpath(self):
|
||||
# """
|
||||
# The path to the config file
|
||||
# """
|
||||
# return None
|
||||
|
||||
@property
|
||||
def _config_as_dict(self):
|
||||
all_keys = asdict(self)
|
||||
# print("all_keys: ", all_keys)
|
||||
# remove internal attributes (starting with __)
|
||||
return {k: v for k, v in all_keys.items() if not k.startswith("_")}
|
||||
|
||||
def create_file(self):
|
||||
# if not exists, create the config file
|
||||
if not os.path.exists(self._configpath):
|
||||
print("creating file")
|
||||
self.write_to_file(self._config_as_dict)
|
||||
|
||||
def write_to_file(self, settings: dict[str, Any]):
|
||||
print("writing to file")
|
||||
print("settings: ", settings)
|
||||
with open(self._configpath, "w") as f:
|
||||
json.dump(settings, f, indent=4)
|
||||
|
||||
def __setattr__(self, name: str, value: Any):
|
||||
if not self._init_complete:
|
||||
print("setting local attr", "name: ", name, ", value: ", value)
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
|
||||
# if is internal attribute, set to instance
|
||||
# but don't write to file
|
||||
super().__setattr__(name, value)
|
||||
if name.startswith("_"):
|
||||
print("setting local internal attr", "name: ", name, ", value: ", value)
|
||||
return
|
||||
|
||||
print("writing attr", "name: ", name, ", value: ", value)
|
||||
self.write_to_file(self._config_as_dict)
|
||||
|
||||
def load_config(self):
|
||||
with open(self._configpath) as f:
|
||||
settings: dict[str, Any] = json.load(f)
|
||||
|
||||
for key, value in settings.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def __post_init__(self):
|
||||
if not self._configpath:
|
||||
raise AttributeError(
|
||||
f"{self.__class__.__name__}: self._configpath is not set"
|
||||
)
|
||||
|
||||
print("self: ", self)
|
||||
self.create_file()
|
||||
self.load_config()
|
||||
self._init_complete = True
|
||||
print("init complete!!!!")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyConfig(Jsoni):
|
||||
age: int = 30
|
||||
name: str = "John"
|
||||
# _configpath: str = "notconfig.json"
|
||||
|
||||
@property
|
||||
def _configpath(self):
|
||||
return "notconfig.json"
|
||||
|
||||
|
||||
config = MyConfig("notconfig.json")
|
||||
print("config.name: ", config.name)
|
||||
config.age = 45
|
||||
print("config.name: ", config.name)
|
||||
# config.create_file()
|
||||
# config.name = "Jane"
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Logger module
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
import logging.config
|
||||
import logging.handlers
|
||||
from pathlib import Path
|
||||
|
||||
LOG_RECORD_BUILTIN_ATTRS = {
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"module",
|
||||
"msecs",
|
||||
"message",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"thread",
|
||||
"threadName",
|
||||
"taskName",
|
||||
}
|
||||
|
||||
|
||||
class JsonFormat(logging.Formatter):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
fmt_keys: dict[str, str] | None = None,
|
||||
):
|
||||
|
||||
super().__init__()
|
||||
self.fmt_keys = fmt_keys or {}
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
message = self._prepare_log_dict(record)
|
||||
return json.dumps(message, default=str)
|
||||
|
||||
def _prepare_log_dict(self, record: logging.LogRecord):
|
||||
always_fields = {
|
||||
"args": record.args,
|
||||
"name": record.name,
|
||||
"line": record.lineno,
|
||||
"message": record.getMessage(),
|
||||
"timestamp": dt.datetime.fromtimestamp(
|
||||
record.created, tz=dt.UTC
|
||||
).isoformat(),
|
||||
"who": record.name,
|
||||
}
|
||||
|
||||
if record.exc_info is not None:
|
||||
always_fields["exc_info"] = self.formatException(record.exc_info)
|
||||
|
||||
if record.stack_info is not None:
|
||||
always_fields["stack_info"] = self.formatStack(record.stack_info)
|
||||
|
||||
message = {}
|
||||
|
||||
for key, val in self.fmt_keys.items():
|
||||
if (msg_val := always_fields.pop(val, None)) is not None:
|
||||
message[key] = msg_val
|
||||
else:
|
||||
message[key] = getattr(record, val)
|
||||
|
||||
message.update(always_fields)
|
||||
|
||||
for key, val in record.__dict__.items():
|
||||
if key not in LOG_RECORD_BUILTIN_ATTRS:
|
||||
message[key] = val
|
||||
|
||||
return message
|
||||
|
||||
|
||||
class CustomFormatter(logging.Formatter):
|
||||
"""
|
||||
Custom log formatter
|
||||
"""
|
||||
|
||||
grey = "\033[92m"
|
||||
yellow = "\x1b[33;20m"
|
||||
red = "\033[41m"
|
||||
bold_red = "\x1b[31;1m"
|
||||
reset = "\x1b[0m"
|
||||
# format_ = "[%(asctime)s] %(name)s %(levelname)s %(message)s (%(filename)s:%(lineno)d)"
|
||||
format_ = "[%(asctime)s] [%(levelname)s] %(message)s (%(filename)s:%(lineno)d)"
|
||||
# format_ = "%(message)s"
|
||||
|
||||
FORMATS = {
|
||||
logging.DEBUG: grey + format_ + reset,
|
||||
logging.INFO: grey + format_ + reset,
|
||||
logging.WARNING: yellow + format_ + reset,
|
||||
logging.ERROR: red + format_ + reset,
|
||||
logging.CRITICAL: bold_red + format_ + reset,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
fmt_keys: dict[str, str] | None = None,
|
||||
):
|
||||
|
||||
super().__init__()
|
||||
self.fmt_keys = fmt_keys or {}
|
||||
|
||||
def format(self, record):
|
||||
log_fmt = self.FORMATS.get(record.levelno)
|
||||
# record.exc_info = None
|
||||
# record.exc_text = None
|
||||
self._style = logging.PercentStyle(log_fmt)
|
||||
self._fmt = self._style._fmt
|
||||
|
||||
self.datefmt = "%H:%M:%S"
|
||||
return super().format(record)
|
||||
|
||||
def formatException(self, e):
|
||||
# Exceptions are logged to file but not shown on CLI to avoid cluttering output.
|
||||
# Non-terminal exceptions are handled gracefully - the user doesn't need to be
|
||||
# informed as they don't affect normal operation.
|
||||
return ""
|
||||
|
||||
def formatStack(self, stack_info):
|
||||
return ""
|
||||
|
||||
|
||||
CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": JsonFormat,
|
||||
"fmt_keys": {
|
||||
"level": "levelname",
|
||||
"message": "message",
|
||||
"timestamp": "timestamp",
|
||||
"logger": "name",
|
||||
"module": "module",
|
||||
"function": "funcName",
|
||||
"line": "lineno",
|
||||
},
|
||||
},
|
||||
"custom": {
|
||||
"()": CustomFormatter,
|
||||
"fmt_keys": {
|
||||
"level": "levelname",
|
||||
"message": "message",
|
||||
"timestamp": "timestamp",
|
||||
"logger": "name",
|
||||
"module": "module",
|
||||
"function": "funcName",
|
||||
"line": "lineno",
|
||||
},
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"stdout": {
|
||||
"class": "logging.StreamHandler",
|
||||
"level": "INFO",
|
||||
"formatter": "custom",
|
||||
"stream": "ext://sys.stderr",
|
||||
},
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"level": "DEBUG",
|
||||
"formatter": "json",
|
||||
"maxBytes": 5 * 1024 * 1024, # 5 MB
|
||||
"backupCount": 5,
|
||||
},
|
||||
"remote": {
|
||||
"class": "logging.handlers.SocketHandler",
|
||||
"level": "DEBUG",
|
||||
"formatter": "json",
|
||||
"host": "127.0.0.2",
|
||||
"port": "19996",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"swingmusic": {
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
"handlers": ["stdout", "file"],
|
||||
},
|
||||
"waitress": {
|
||||
"level": "ERROR",
|
||||
"propagate": False,
|
||||
"handlers": ["stdout", "file"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Always expose a usable logger object, even before setup_logger runs.
|
||||
log = logging.getLogger("swingmusic")
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> logging.Logger:
|
||||
"""
|
||||
Returns a configured logger instance.
|
||||
Falls back to stdlib logger if setup_logger has not run yet.
|
||||
"""
|
||||
if name:
|
||||
return logging.getLogger(name)
|
||||
return log or logging.getLogger("swingmusic")
|
||||
|
||||
|
||||
def debug(message, *args, **kwargs):
|
||||
get_logger().debug(message, *args, **kwargs)
|
||||
|
||||
|
||||
def info(message, *args, **kwargs):
|
||||
get_logger().info(message, *args, **kwargs)
|
||||
|
||||
|
||||
def warning(message, *args, **kwargs):
|
||||
get_logger().warning(message, *args, **kwargs)
|
||||
|
||||
|
||||
def error(message, *args, **kwargs):
|
||||
get_logger().error(message, *args, **kwargs)
|
||||
|
||||
|
||||
def critical(message, *args, **kwargs):
|
||||
get_logger().critical(message, *args, **kwargs)
|
||||
|
||||
|
||||
def exception(message, *args, **kwargs):
|
||||
get_logger().exception(message, *args, **kwargs)
|
||||
|
||||
|
||||
def setup_logger(app_dir: Path, debug=False):
|
||||
"""
|
||||
setup logger
|
||||
needs to be called at the beginning and at least once
|
||||
|
||||
:param app_dir: logging directory
|
||||
:param debug: When True Loglevel is set to DEBUG and enable Socket log
|
||||
"""
|
||||
|
||||
if Path.home().resolve().as_posix() == app_dir.resolve().as_posix():
|
||||
app_name = ".swingmusic"
|
||||
else:
|
||||
app_name = "swingmusic"
|
||||
|
||||
log_dir = Path(app_dir) / app_name / "logs"
|
||||
if not log_dir.exists():
|
||||
log_dir.mkdir(parents=True)
|
||||
|
||||
CONFIG["handlers"]["file"]["filename"] = log_dir / "log.jsonl"
|
||||
|
||||
# enable socket log
|
||||
if debug:
|
||||
logging.warning("YOU ARE IN DEBUG MODE.")
|
||||
for key in CONFIG["loggers"]:
|
||||
CONFIG["loggers"][key]["handlers"].append("remote")
|
||||
CONFIG["loggers"][key]["level"] = "DEBUG"
|
||||
|
||||
logging.config.dictConfig(CONFIG)
|
||||
|
||||
global log
|
||||
log = logging.getLogger("swingmusic")
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Migrations module.
|
||||
|
||||
Discovers migration classes from explicitly registered modules and applies
|
||||
pending migrations in deterministic order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
from types import ModuleType
|
||||
|
||||
from swingmusic.db.metadata import MigrationTable
|
||||
from swingmusic.migrations.base import Migration
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_MIGRATION_MODULES = [
|
||||
"swingmusic.migrations.production_setup_migration",
|
||||
]
|
||||
|
||||
OPTIONAL_MIGRATION_MODULES = [
|
||||
(
|
||||
"SWINGMUSIC_ENABLE_UPDATE_TRACKING_MIGRATIONS",
|
||||
"swingmusic.migrations.update_tracking_migration",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_all_migrations(module: ModuleType) -> list[type[Migration]]:
|
||||
"""
|
||||
Extract all enabled migration classes from a module.
|
||||
"""
|
||||
|
||||
def predicate(obj):
|
||||
return (
|
||||
inspect.isclass(obj)
|
||||
and issubclass(obj, Migration)
|
||||
and obj.enabled
|
||||
and obj is not Migration
|
||||
and obj.__module__ == module.__name__
|
||||
)
|
||||
|
||||
return [obj for _, obj in inspect.getmembers(module, predicate)]
|
||||
|
||||
|
||||
def _load_migration_modules() -> list[ModuleType]:
|
||||
modules: list[ModuleType] = []
|
||||
|
||||
for module_path in DEFAULT_MIGRATION_MODULES:
|
||||
modules.append(importlib.import_module(module_path))
|
||||
|
||||
for flag, module_path in OPTIONAL_MIGRATION_MODULES:
|
||||
enabled = os.getenv(flag, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
modules.append(importlib.import_module(module_path))
|
||||
except Exception as error:
|
||||
log.exception(
|
||||
"Failed to import optional migration module %s: %s", module_path, error
|
||||
)
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def apply_migrations():
|
||||
"""
|
||||
Applies pending migrations and records the migration index.
|
||||
"""
|
||||
modules = _load_migration_modules()
|
||||
migrations = [
|
||||
migration for module in modules for migration in get_all_migrations(module)
|
||||
]
|
||||
migrations.sort(key=lambda migration: migration.__name__)
|
||||
|
||||
current_index = MigrationTable.get_version()
|
||||
if current_index < 0:
|
||||
current_index = 0
|
||||
|
||||
if current_index > len(migrations):
|
||||
log.warning(
|
||||
"Migration index %s exceeds known migrations %s. Clamping index.",
|
||||
current_index,
|
||||
len(migrations),
|
||||
)
|
||||
current_index = len(migrations)
|
||||
|
||||
to_apply = migrations[current_index:]
|
||||
for migration in to_apply:
|
||||
migration.migrate()
|
||||
log.info("Applied migration: %s", migration.__name__)
|
||||
|
||||
MigrationTable.set_version(len(migrations))
|
||||
@@ -0,0 +1,13 @@
|
||||
class Migration:
|
||||
"""
|
||||
Base migration class.
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
@staticmethod
|
||||
def migrate():
|
||||
"""
|
||||
Code to run when migrating, override this method.
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.db.libdata import TrackTable
|
||||
from swingmusic.db.production import (
|
||||
LyricsStatusTable,
|
||||
SetupStateTable,
|
||||
TrackedPlaylistTable,
|
||||
UserRootDirOwnershipTable,
|
||||
)
|
||||
from swingmusic.db.userdata import UserTable
|
||||
from swingmusic.migrations.base import Migration
|
||||
from swingmusic.services.library_projection import get_owner_user, sync_owner_projection
|
||||
|
||||
|
||||
class Migration001EnsureSetupState(Migration):
|
||||
@staticmethod
|
||||
def migrate():
|
||||
SetupStateTable.ensure_singleton()
|
||||
|
||||
|
||||
class Migration002SyncOwnerProjection(Migration):
|
||||
@staticmethod
|
||||
def migrate():
|
||||
owner = get_owner_user()
|
||||
if not owner:
|
||||
return
|
||||
sync_owner_projection(owner.id)
|
||||
|
||||
|
||||
class Migration003BackfillLyricsStatus(Migration):
|
||||
@staticmethod
|
||||
def migrate():
|
||||
for track in TrackTable.get_all():
|
||||
filepath = track.filepath
|
||||
if not filepath:
|
||||
continue
|
||||
|
||||
track_path = Path(filepath)
|
||||
has_lrc = (
|
||||
track_path.with_suffix(".lrc").exists()
|
||||
or track_path.with_suffix(".elrc").exists()
|
||||
)
|
||||
has_embedded = bool((track.extra or {}).get("lyrics"))
|
||||
|
||||
if has_embedded:
|
||||
status = "embedded"
|
||||
source = "tags"
|
||||
elif has_lrc:
|
||||
status = "lrc"
|
||||
source = "lrc"
|
||||
else:
|
||||
status = "missing"
|
||||
source = None
|
||||
|
||||
LyricsStatusTable.upsert(
|
||||
trackhash=track.trackhash,
|
||||
filepath=filepath,
|
||||
status=status,
|
||||
source=source,
|
||||
has_embedded=has_embedded,
|
||||
has_lrc=has_lrc,
|
||||
last_error=None,
|
||||
extra={"migration": "backfill"},
|
||||
increment_attempt=False,
|
||||
)
|
||||
|
||||
|
||||
class Migration004BackfillUserRootOwnership(Migration):
|
||||
@staticmethod
|
||||
def migrate():
|
||||
config_roots = UserConfig().rootDirs or []
|
||||
if config_roots:
|
||||
primary_root = config_roots[0]
|
||||
if primary_root == "$home":
|
||||
base_root = os.path.join(os.path.expanduser("~"), "Music")
|
||||
else:
|
||||
base_root = os.path.expanduser(primary_root)
|
||||
else:
|
||||
base_root = os.path.join(os.path.expanduser("~"), "Music")
|
||||
|
||||
for user in UserTable.get_all():
|
||||
if UserRootDirOwnershipTable.get_paths(user.id):
|
||||
continue
|
||||
|
||||
if "owner" in user.roles or "admin" in user.roles:
|
||||
UserRootDirOwnershipTable.assign_paths(user.id, config_roots)
|
||||
continue
|
||||
|
||||
safe_username = (
|
||||
re.sub(r"[^\w\-. ]", "", user.username or "").strip()
|
||||
or f"user-{user.id}"
|
||||
)
|
||||
user_root = os.path.join(base_root, "SwingMusic Users", safe_username)
|
||||
os.makedirs(user_root, exist_ok=True)
|
||||
UserRootDirOwnershipTable.assign_paths(user.id, [user_root])
|
||||
|
||||
|
||||
class Migration005NormalizeTrackedPlaylists(Migration):
|
||||
@staticmethod
|
||||
def migrate():
|
||||
now = int(time.time())
|
||||
for row in TrackedPlaylistTable.all().scalars():
|
||||
interval = max(120, int(row.sync_interval_seconds or 900))
|
||||
update_payload = {}
|
||||
|
||||
if int(row.sync_interval_seconds or 0) != interval:
|
||||
update_payload["sync_interval_seconds"] = interval
|
||||
|
||||
if not row.next_sync_at:
|
||||
update_payload["next_sync_at"] = int(
|
||||
row.updated_at or row.created_at or now
|
||||
)
|
||||
|
||||
if row.status not in {"active", "syncing", "failed", "paused", "deleted"}:
|
||||
update_payload["status"] = "active"
|
||||
|
||||
if row.snapshot_track_ids is None:
|
||||
update_payload["snapshot_track_ids"] = []
|
||||
|
||||
if row.last_result is None:
|
||||
update_payload["last_result"] = {}
|
||||
|
||||
if update_payload:
|
||||
TrackedPlaylistTable.update_row(row.id, update_payload)
|
||||
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
Migration for Update Tracking System Tables
|
||||
|
||||
This migration creates all the necessary tables for the artist update
|
||||
tracking system, including follows, releases, notifications, and preferences.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from swingmusic.db import db
|
||||
from swingmusic.migrations.base import Migration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Migration001UpdateTracking(Migration):
|
||||
"""
|
||||
Create tables for the update tracking system
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def migrate():
|
||||
"""
|
||||
Create all update tracking tables
|
||||
"""
|
||||
logger.info("Starting update tracking migration")
|
||||
|
||||
try:
|
||||
# Create artist_follows table
|
||||
logger.info("Creating artist_follows table")
|
||||
db.session.execute("""
|
||||
CREATE TABLE IF NOT EXISTS artist_follows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
artist_id TEXT NOT NULL UNIQUE,
|
||||
artist_name TEXT NOT NULL,
|
||||
follow_level TEXT NOT NULL DEFAULT 'followed',
|
||||
auto_download_new_releases BOOLEAN DEFAULT FALSE,
|
||||
preferred_quality TEXT DEFAULT 'flac',
|
||||
notification_preferences TEXT DEFAULT '{}',
|
||||
follow_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_check_date DATETIME NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create release_updates table
|
||||
logger.info("Creating release_updates table")
|
||||
db.session.execute("""
|
||||
CREATE TABLE IF NOT EXISTS release_updates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
release_id TEXT NOT NULL UNIQUE,
|
||||
artist_id TEXT NOT NULL,
|
||||
artist_name TEXT NOT NULL,
|
||||
release_title TEXT NOT NULL,
|
||||
release_type TEXT NOT NULL,
|
||||
release_date DATE NOT NULL,
|
||||
spotify_url TEXT NOT NULL,
|
||||
cover_image_url TEXT NULL,
|
||||
total_tracks INTEGER NOT NULL,
|
||||
popularity INTEGER DEFAULT 0,
|
||||
explicit BOOLEAN DEFAULT FALSE,
|
||||
discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at DATETIME NULL,
|
||||
download_status TEXT DEFAULT 'pending',
|
||||
auto_downloaded BOOLEAN DEFAULT FALSE,
|
||||
notification_sent BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
""")
|
||||
|
||||
# Create update_notifications table
|
||||
logger.info("Creating update_notifications table")
|
||||
db.session.execute("""
|
||||
CREATE TABLE IF NOT EXISTS update_notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
notification_type TEXT NOT NULL,
|
||||
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
opened_at DATETIME NULL,
|
||||
action_taken TEXT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (release_id) REFERENCES release_updates (release_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create update_monitoring_preferences table
|
||||
logger.info("Creating update_monitoring_preferences table")
|
||||
db.session.execute("""
|
||||
CREATE TABLE IF NOT EXISTS update_monitoring_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
enable_artist_monitoring BOOLEAN DEFAULT TRUE,
|
||||
check_frequency TEXT DEFAULT 'daily',
|
||||
auto_download_favorites BOOLEAN DEFAULT FALSE,
|
||||
auto_download_followed BOOLEAN DEFAULT FALSE,
|
||||
max_auto_downloads_per_week INTEGER DEFAULT 5,
|
||||
quality_preference TEXT DEFAULT 'flac',
|
||||
storage_limit_mb INTEGER DEFAULT 10240,
|
||||
notification_channels TEXT DEFAULT '{}',
|
||||
exclude_explicit BOOLEAN DEFAULT FALSE,
|
||||
preferred_release_types TEXT DEFAULT '["album", "ep", "single"]',
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create download_tasks table
|
||||
logger.info("Creating download_tasks table")
|
||||
db.session.execute("""
|
||||
CREATE TABLE IF NOT EXISTS download_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
release_id TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
track_title TEXT NOT NULL,
|
||||
artist_name TEXT NOT NULL,
|
||||
album_name TEXT NOT NULL,
|
||||
spotify_url TEXT NOT NULL,
|
||||
quality_preference TEXT DEFAULT 'flac',
|
||||
status TEXT DEFAULT 'pending',
|
||||
priority TEXT DEFAULT 'normal',
|
||||
progress INTEGER DEFAULT 0,
|
||||
file_path TEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
auto_downloaded BOOLEAN DEFAULT FALSE,
|
||||
added_to_library BOOLEAN DEFAULT FALSE,
|
||||
FOREIGN KEY (release_id) REFERENCES release_updates (release_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create artist_follow_history table
|
||||
logger.info("Creating artist_follow_history table")
|
||||
db.session.execute("""
|
||||
CREATE TABLE IF NOT EXISTS artist_follow_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
artist_id TEXT NOT NULL,
|
||||
artist_name TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
old_level TEXT NULL,
|
||||
new_level TEXT NULL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create release_update_history table
|
||||
logger.info("Creating release_update_history table")
|
||||
db.session.execute("""
|
||||
CREATE TABLE IF NOT EXISTS release_update_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
release_id TEXT NOT NULL,
|
||||
artist_id TEXT NOT NULL,
|
||||
artist_name TEXT NOT NULL,
|
||||
release_title TEXT NOT NULL,
|
||||
release_type TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Create update_tracking_stats table
|
||||
logger.info("Creating update_tracking_stats table")
|
||||
db.session.execute("""
|
||||
CREATE TABLE IF NOT EXISTS update_tracking_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
stat_date DATE NOT NULL,
|
||||
total_followed_artists INTEGER DEFAULT 0,
|
||||
new_releases_discovered INTEGER DEFAULT 0,
|
||||
auto_downloads_completed INTEGER DEFAULT 0,
|
||||
manual_downloads_completed INTEGER DEFAULT 0,
|
||||
notifications_sent INTEGER DEFAULT 0,
|
||||
notifications_opened INTEGER DEFAULT 0,
|
||||
storage_used_mb INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
UNIQUE(user_id, stat_date)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for better performance
|
||||
logger.info("Creating indexes")
|
||||
|
||||
# Indexes for artist_follows
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_follows_user_id ON artist_follows(user_id)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_follows_artist_id ON artist_follows(artist_id)
|
||||
""")
|
||||
|
||||
# Indexes for release_updates
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_release_updates_artist_id ON release_updates(artist_id)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_release_updates_release_date ON release_updates(release_date)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_release_updates_discovered_at ON release_updates(discovered_at)
|
||||
""")
|
||||
|
||||
# Indexes for update_notifications
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_update_notifications_user_id ON update_notifications(user_id)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_update_notifications_release_id ON update_notifications(release_id)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_update_notifications_sent_at ON update_notifications(sent_at)
|
||||
""")
|
||||
|
||||
# Indexes for download_tasks
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_download_tasks_release_id ON download_tasks(release_id)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_download_tasks_status ON download_tasks(status)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_download_tasks_priority ON download_tasks(priority)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_download_tasks_created_at ON download_tasks(created_at)
|
||||
""")
|
||||
|
||||
# Indexes for history tables
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_follow_history_user_id ON artist_follow_history(user_id)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_follow_history_timestamp ON artist_follow_history(timestamp)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_release_update_history_release_id ON release_update_history(release_id)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_release_update_history_timestamp ON release_update_history(timestamp)
|
||||
""")
|
||||
|
||||
# Indexes for stats
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_update_tracking_stats_user_id ON update_tracking_stats(user_id)
|
||||
""")
|
||||
db.session.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_update_tracking_stats_stat_date ON update_tracking_stats(stat_date)
|
||||
""")
|
||||
|
||||
# Commit the transaction
|
||||
db.session.commit()
|
||||
logger.info("Update tracking migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during update tracking migration: {e}")
|
||||
db.session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class Migration002UpdateTrackingTriggers(Migration):
|
||||
"""
|
||||
Create triggers for update tracking system
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def migrate():
|
||||
"""
|
||||
Create triggers for automatic history tracking
|
||||
"""
|
||||
logger.info("Creating update tracking triggers")
|
||||
|
||||
try:
|
||||
# Trigger for artist follow history
|
||||
db.session.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS artist_follow_history_insert
|
||||
AFTER INSERT ON artist_follows
|
||||
BEGIN
|
||||
INSERT INTO artist_follow_history
|
||||
(user_id, artist_id, artist_name, action, new_level, timestamp)
|
||||
VALUES
|
||||
(NEW.user_id, NEW.artist_id, NEW.artist_name, 'follow', NEW.follow_level, CURRENT_TIMESTAMP);
|
||||
END
|
||||
""")
|
||||
|
||||
# Trigger for artist unfollow history
|
||||
db.session.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS artist_follow_history_delete
|
||||
AFTER DELETE ON artist_follows
|
||||
BEGIN
|
||||
INSERT INTO artist_follow_history
|
||||
(user_id, artist_id, artist_name, action, old_level, timestamp)
|
||||
VALUES
|
||||
(OLD.user_id, OLD.artist_id, OLD.artist_name, 'unfollow', OLD.follow_level, CURRENT_TIMESTAMP);
|
||||
END
|
||||
""")
|
||||
|
||||
# Trigger for artist follow level change
|
||||
db.session.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS artist_follow_history_update
|
||||
AFTER UPDATE ON artist_follows
|
||||
WHEN OLD.follow_level != NEW.follow_level
|
||||
BEGIN
|
||||
INSERT INTO artist_follow_history
|
||||
(user_id, artist_id, artist_name, action, old_level, new_level, timestamp)
|
||||
VALUES
|
||||
(NEW.user_id, NEW.artist_id, NEW.artist_name, 'level_change', OLD.follow_level, NEW.follow_level, CURRENT_TIMESTAMP);
|
||||
END
|
||||
""")
|
||||
|
||||
# Trigger for release update discovery
|
||||
db.session.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS release_update_discovered
|
||||
AFTER INSERT ON release_updates
|
||||
BEGIN
|
||||
INSERT INTO release_update_history
|
||||
(release_id, artist_id, artist_name, release_title, release_type, action, timestamp)
|
||||
VALUES
|
||||
(NEW.release_id, NEW.artist_id, NEW.artist_name, NEW.release_title, NEW.release_type, 'discovered', CURRENT_TIMESTAMP);
|
||||
END
|
||||
""")
|
||||
|
||||
# Trigger for release update download completion
|
||||
db.session.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS release_update_downloaded
|
||||
AFTER UPDATE ON release_updates
|
||||
WHEN OLD.download_status != 'completed' AND NEW.download_status = 'completed'
|
||||
BEGIN
|
||||
INSERT INTO release_update_history
|
||||
(release_id, artist_id, artist_name, release_title, release_type, action, timestamp, metadata)
|
||||
VALUES
|
||||
(NEW.release_id, NEW.artist_id, NEW.artist_name, NEW.release_title, NEW.release_type, 'downloaded', CURRENT_TIMESTAMP,
|
||||
json_object('auto_downloaded', NEW.auto_downloaded));
|
||||
END
|
||||
""")
|
||||
|
||||
db.session.commit()
|
||||
logger.info("Update tracking triggers created successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating update tracking triggers: {e}")
|
||||
db.session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,16 @@
|
||||
from swingmusic.models.album import Album
|
||||
from swingmusic.models.artist import Artist, ArtistMinimal
|
||||
from swingmusic.models.enums import FavType
|
||||
from swingmusic.models.folder import Folder
|
||||
from swingmusic.models.playlist import Playlist
|
||||
from swingmusic.models.track import Track
|
||||
|
||||
__all__ = [
|
||||
"Album",
|
||||
"Track",
|
||||
"Artist",
|
||||
"ArtistMinimal",
|
||||
"Playlist",
|
||||
"Folder",
|
||||
"FavType",
|
||||
]
|
||||
@@ -0,0 +1,189 @@
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
|
||||
from swingmusic.models.track import Track
|
||||
from swingmusic.utils.auth import get_current_userid
|
||||
from swingmusic.utils.hashing import create_hash
|
||||
from swingmusic.utils.parsers import get_base_title_and_versions
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Album:
|
||||
"""
|
||||
Creates an album object
|
||||
"""
|
||||
|
||||
albumartists: list[dict[str, str]]
|
||||
albumhash: str
|
||||
artisthashes: list[str]
|
||||
base_title: str
|
||||
color: str
|
||||
created_date: int
|
||||
date: int
|
||||
duration: int
|
||||
genres: list[dict[str, str]]
|
||||
genrehashes: list[str]
|
||||
og_title: str
|
||||
title: str
|
||||
trackcount: int
|
||||
lastplayed: int
|
||||
playcount: int
|
||||
playduration: int
|
||||
extra: dict
|
||||
pathhash: str = ""
|
||||
|
||||
id: int = -1
|
||||
type: str = "album"
|
||||
image: str = ""
|
||||
_score: float = 0
|
||||
versions: list[str] = dataclasses.field(default_factory=list)
|
||||
fav_userids: list[int] = dataclasses.field(default_factory=list)
|
||||
weakhash: str = ""
|
||||
|
||||
@property
|
||||
def is_favorite(self):
|
||||
return get_current_userid() in self.fav_userids
|
||||
|
||||
def toggle_favorite_user(self, userid: int):
|
||||
"""
|
||||
Adds or removes the given user from the list of users
|
||||
who have favorited the album.
|
||||
"""
|
||||
if userid in self.fav_userids:
|
||||
self.fav_userids.remove(userid)
|
||||
else:
|
||||
self.fav_userids.append(userid)
|
||||
|
||||
def __post_init__(self):
|
||||
self.image = self.albumhash + ".webp" + "?pathhash=" + self.pathhash
|
||||
self.populate_versions()
|
||||
self.weakhash = create_hash(
|
||||
self.og_title, ",".join(a["name"] for a in self.albumartists)
|
||||
)
|
||||
|
||||
def populate_versions(self):
|
||||
_, self.versions = get_base_title_and_versions(self.og_title, get_versions=True)
|
||||
|
||||
if "super_deluxe" in self.versions:
|
||||
self.versions.remove("deluxe")
|
||||
|
||||
# at this point, we should know the type of album
|
||||
if "original" in self.versions and self.type == "soundtrack":
|
||||
self.versions.remove("original")
|
||||
|
||||
self.versions = [v.replace("_", " ") for v in self.versions]
|
||||
|
||||
def check_type(self, tracks: list[Track], singleTrackAsSingle: bool):
|
||||
"""
|
||||
Runs all the checks to determine the type of album.
|
||||
"""
|
||||
if self.is_single(tracks, singleTrackAsSingle):
|
||||
self.type = "single"
|
||||
return
|
||||
|
||||
if self.is_soundtrack():
|
||||
self.type = "soundtrack"
|
||||
return
|
||||
|
||||
if self.is_live_album():
|
||||
self.type = "live album"
|
||||
return
|
||||
|
||||
if self.is_compilation():
|
||||
self.type = "compilation"
|
||||
return
|
||||
|
||||
if self.is_ep():
|
||||
self.type = "ep"
|
||||
return
|
||||
|
||||
self.type = "album"
|
||||
|
||||
def is_soundtrack(self) -> bool:
|
||||
"""
|
||||
Checks if the album is a soundtrack.
|
||||
"""
|
||||
title = self.og_title.lower()
|
||||
|
||||
keywords = ["motion picture", "soundtrack"]
|
||||
for keyword in keywords:
|
||||
if keyword in title:
|
||||
return True
|
||||
|
||||
# if og_title ends with "the album"
|
||||
return bool(len(title) > 10 and title.endswith("the album"))
|
||||
|
||||
def is_compilation(self) -> bool:
|
||||
"""
|
||||
Checks if the album is a compilation.
|
||||
"""
|
||||
artists = [a["name"] for a in self.albumartists]
|
||||
artists = "".join(artists).lower()
|
||||
|
||||
if "various artists" in artists:
|
||||
return True
|
||||
|
||||
substrings = {
|
||||
"the essential",
|
||||
"best of",
|
||||
"greatest hits",
|
||||
"#1 hits",
|
||||
"number ones",
|
||||
"super hits",
|
||||
"collection",
|
||||
"anthology",
|
||||
"great hits",
|
||||
"biggest hits",
|
||||
"the hits",
|
||||
"the ultimate",
|
||||
"compilation",
|
||||
}
|
||||
|
||||
return any(substring in self.title.lower() for substring in substrings)
|
||||
|
||||
def is_live_album(self):
|
||||
"""
|
||||
Checks if the album is a live album.
|
||||
"""
|
||||
keywords = ["live from", "live at", "live in", "live on", "mtv unplugged"]
|
||||
return any(keyword in self.og_title.lower() for keyword in keywords)
|
||||
|
||||
def is_ep(self) -> bool:
|
||||
"""
|
||||
Checks if the album is an EP.
|
||||
An EP typically has 4-6 tracks, but we also check the title.
|
||||
"""
|
||||
# Check title suffix first
|
||||
if self.title.strip().endswith(" EP"):
|
||||
return True
|
||||
|
||||
# EPs typically have 4-6 tracks (industry standard)
|
||||
# This helps identify EPs that don't have "EP" in the title
|
||||
return 4 <= self.trackcount <= 6
|
||||
|
||||
def is_single(self, tracks: list[Track], singleTrackAsSingle: bool):
|
||||
"""
|
||||
Checks if the album is a single.
|
||||
"""
|
||||
keywords = ["single version", "- single"]
|
||||
|
||||
# show_albums_as_singles = get_flag(SessionVarKeys.SHOW_ALBUMS_AS_SINGLES)
|
||||
|
||||
for keyword in keywords:
|
||||
if keyword in self.og_title.lower():
|
||||
return True
|
||||
|
||||
# Config is read once at startup, not in loop - performance is acceptable
|
||||
if singleTrackAsSingle and self.trackcount == 1:
|
||||
return True
|
||||
|
||||
if (
|
||||
len(tracks) == 1
|
||||
and (
|
||||
create_hash(tracks[0].title) == create_hash(self.title)
|
||||
or create_hash(tracks[0].title) == create_hash(self.og_title)
|
||||
) # if they have the same title
|
||||
# Track and disc numbers checks are not necessary - if there's only
|
||||
# one track and titles match, it's a single regardless of track/disc numbers
|
||||
):
|
||||
return True
|
||||
@@ -0,0 +1,77 @@
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
|
||||
from swingmusic.utils.auth import get_current_userid
|
||||
from swingmusic.utils.hashing import create_hash
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ArtistMinimal:
|
||||
"""
|
||||
ArtistMinimal class
|
||||
"""
|
||||
|
||||
name: str
|
||||
artisthash: str = ""
|
||||
image: str = ""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.artisthash = create_hash(self.name, decode=True)
|
||||
self.image = self.artisthash + ".webp"
|
||||
|
||||
# hack to override all the variations from unreleased files (sorry guys!)
|
||||
if self.artisthash == "5a37d5315e":
|
||||
self.name = "Juice WRLD"
|
||||
|
||||
def to_json(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"artisthash": self.artisthash,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Artist:
|
||||
"""
|
||||
Artist class
|
||||
"""
|
||||
|
||||
name: str
|
||||
albumcount: int
|
||||
artisthash: str
|
||||
created_date: int
|
||||
date: int
|
||||
duration: int
|
||||
genres: list[dict[str, str]]
|
||||
genrehashes: list[str]
|
||||
name: str
|
||||
trackcount: int
|
||||
lastplayed: int
|
||||
playcount: int
|
||||
playduration: int
|
||||
extra: dict
|
||||
|
||||
id: int = -1
|
||||
image: str = ""
|
||||
_score: float = 0
|
||||
|
||||
color: str = ""
|
||||
fav_userids: list[int] = dataclasses.field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_favorite(self):
|
||||
return get_current_userid() in self.fav_userids
|
||||
|
||||
def toggle_favorite_user(self, userid: int):
|
||||
"""
|
||||
Adds or removes the given user from the list of users
|
||||
who have favorited this artist.
|
||||
"""
|
||||
if userid in self.fav_userids:
|
||||
self.fav_userids.remove(userid)
|
||||
else:
|
||||
self.fav_userids.append(userid)
|
||||
|
||||
def __post_init__(self):
|
||||
self.image = self.artisthash + ".webp"
|
||||
@@ -0,0 +1,6 @@
|
||||
class FavType:
|
||||
"""Favorite types enum"""
|
||||
|
||||
track = "track"
|
||||
album = "album"
|
||||
artist = "artist"
|
||||
@@ -0,0 +1,27 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
@dataclass
|
||||
class Favorite:
|
||||
hash: str
|
||||
type: Literal["album", "track", "artist"]
|
||||
timestamp: int
|
||||
userid: int
|
||||
extra: dict[str, Any]
|
||||
|
||||
def __post_init__(self):
|
||||
raw_hash = str(self.hash or "")
|
||||
|
||||
# Scoped format: u<userid>:<type>_<hash>
|
||||
if raw_hash.startswith("u") and ":" in raw_hash:
|
||||
user_prefix, remainder = raw_hash.split(":", 1)
|
||||
if user_prefix[1:].isdigit():
|
||||
raw_hash = remainder
|
||||
|
||||
# Legacy format: <type>_<hash>
|
||||
type_prefix = f"{self.type}_"
|
||||
if raw_hash.startswith(type_prefix):
|
||||
raw_hash = raw_hash[len(type_prefix) :]
|
||||
|
||||
self.hash = raw_hash
|
||||
@@ -0,0 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Folder:
|
||||
name: str
|
||||
path: str
|
||||
is_sym: bool = False
|
||||
trackcount: int = 0
|
||||
@@ -0,0 +1,26 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimilarArtistEntry:
|
||||
artisthash: str
|
||||
name: str
|
||||
weight: float
|
||||
scrobbles: int
|
||||
listeners: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimilarArtist:
|
||||
artisthash: str
|
||||
similar_artists: list[SimilarArtistEntry]
|
||||
|
||||
def get_artist_hash_set(self) -> set[str]:
|
||||
"""
|
||||
Returns a set of similar artists.
|
||||
"""
|
||||
if not self.similar_artists:
|
||||
return set()
|
||||
|
||||
# INFO:
|
||||
return {a["artisthash"] for a in self.similar_artists}
|
||||
@@ -0,0 +1,46 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackLog:
|
||||
"""
|
||||
Track play logger model
|
||||
"""
|
||||
|
||||
id: int
|
||||
trackhash: str
|
||||
duration: int
|
||||
timestamp: int
|
||||
source: str
|
||||
"""
|
||||
The full source string, eg. "al:123456"
|
||||
"""
|
||||
userid: int
|
||||
extra: dict[str, Any]
|
||||
|
||||
type = "track"
|
||||
type_src = None
|
||||
"""
|
||||
The source identifier string, eg. albumhash, artisthash, etc.
|
||||
"""
|
||||
|
||||
def __post_init__(self):
|
||||
prefix_map = {
|
||||
"mix:": "mix",
|
||||
"al:": "album",
|
||||
"ar:": "artist",
|
||||
"fo:": "folder",
|
||||
"pl:": "playlist",
|
||||
"favorite": "favorite",
|
||||
}
|
||||
|
||||
for prefix, srctype in prefix_map.items():
|
||||
if self.source.startswith(prefix):
|
||||
try:
|
||||
self.type_src = self.source.split(":", 1)[1]
|
||||
except IndexError:
|
||||
self.type_src = None
|
||||
|
||||
self.type = srctype
|
||||
break
|
||||
@@ -0,0 +1,72 @@
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from swingmusic.db.utils import row_to_dict
|
||||
from swingmusic.serializers.track import serialize_tracks
|
||||
from swingmusic.store.tracks import TrackStore
|
||||
from swingmusic.utils.dates import seconds_to_time_string, timestamp_to_time_passed
|
||||
from swingmusic.utils.hashing import create_hash
|
||||
|
||||
|
||||
@dataclass
|
||||
class Mix:
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
tracks: list[str]
|
||||
sourcehash: str
|
||||
userid: int
|
||||
"""
|
||||
A hash of the tracks used to generate the mix.
|
||||
"""
|
||||
|
||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
||||
extra: dict = field(default_factory=dict)
|
||||
saved: bool = False
|
||||
|
||||
def to_full_dict(self):
|
||||
tracks = TrackStore.get_tracks_by_trackhashes(self.tracks)[:40]
|
||||
serialized_tracks = serialize_tracks(tracks)
|
||||
|
||||
_dict = asdict(self)
|
||||
_dict["tracks"] = serialized_tracks
|
||||
|
||||
# if not self.extra.get("image"):
|
||||
# _dict["images"] = get_first_4_images(tracks)
|
||||
|
||||
_dict["duration"] = seconds_to_time_string(sum(t.duration for t in tracks))
|
||||
_dict["trackcount"] = len(tracks)
|
||||
|
||||
del _dict["extra"]["albums"]
|
||||
del _dict["extra"]["artists"]
|
||||
|
||||
return _dict
|
||||
|
||||
def to_dict(self, convert_timestamp: bool = False):
|
||||
item = asdict(self)
|
||||
item["trackshash"] = create_hash(*self.tracks[:40])
|
||||
item["type"] = "mix"
|
||||
|
||||
if convert_timestamp:
|
||||
item["time"] = timestamp_to_time_passed(item["timestamp"])
|
||||
|
||||
del item["tracks"]
|
||||
|
||||
del item["extra"]["albums"]
|
||||
del item["extra"]["artists"]
|
||||
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
def mix_to_dataclass(cls, entry: Any):
|
||||
entry_dict = row_to_dict(entry)
|
||||
|
||||
entry_dict["id"] = entry_dict["mixid"]
|
||||
del entry_dict["mixid"]
|
||||
|
||||
return Mix(**entry_dict)
|
||||
|
||||
@classmethod
|
||||
def mixes_to_dataclasses(cls, entries: Any):
|
||||
return [cls.mix_to_dataclass(entry) for entry in entries]
|
||||
@@ -0,0 +1,51 @@
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from swingmusic import settings
|
||||
from swingmusic.utils.auth import get_current_userid
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Playlist:
|
||||
"""Creates playlist objects"""
|
||||
|
||||
id: int | str
|
||||
image: str | None
|
||||
last_updated: str
|
||||
name: str
|
||||
settings: dict
|
||||
trackhashes: list[str] = dataclasses.field(default_factory=list)
|
||||
extra: dict[str, Any] = dataclasses.field(default_factory=dict)
|
||||
|
||||
_last_updated: str = ""
|
||||
userid: int | None = None
|
||||
thumb: str = ""
|
||||
count: int = 0
|
||||
duration: int = 0
|
||||
has_image: bool = False
|
||||
images: list[dict[str, str]] = dataclasses.field(default_factory=list)
|
||||
pinned: bool = False
|
||||
_score: float = 0
|
||||
|
||||
def __post_init__(self):
|
||||
self.count = len(self.trackhashes)
|
||||
|
||||
if self.userid is None:
|
||||
self.userid = get_current_userid()
|
||||
|
||||
self.pinned = self.settings.get("pinned", False)
|
||||
self.has_image = (settings.Paths().playlist_img_path / str(self.image)).exists()
|
||||
|
||||
if self.image is not None:
|
||||
self.thumb = "thumb_" + self.image
|
||||
else:
|
||||
self.image = "None"
|
||||
self.thumb = "None"
|
||||
|
||||
def clear_lists(self):
|
||||
"""
|
||||
Removes data from lists to make it lighter for sending
|
||||
over the API.
|
||||
"""
|
||||
self.trackhashes = []
|
||||
@@ -0,0 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Plugin:
|
||||
name: str
|
||||
active: bool
|
||||
settings: dict
|
||||
extra: dict
|
||||
@@ -0,0 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatItem:
|
||||
cssclass: str
|
||||
text: str
|
||||
value: str | int
|
||||
image: str | None = None
|
||||
@@ -0,0 +1,228 @@
|
||||
from dataclasses import asdict, dataclass, field
|
||||
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.utils.auth import get_current_userid
|
||||
from swingmusic.utils.hashing import create_hash
|
||||
from swingmusic.utils.parsers import (
|
||||
clean_title,
|
||||
get_base_title_and_versions,
|
||||
parse_feat_from_title,
|
||||
remove_prod,
|
||||
split_artists,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Track:
|
||||
"""
|
||||
Track class
|
||||
"""
|
||||
|
||||
id: int
|
||||
album: str
|
||||
albumartists: list[dict[str, str]]
|
||||
albumhash: str
|
||||
artists: list[dict[str, str]]
|
||||
bitrate: int
|
||||
copyright: str
|
||||
date: int
|
||||
disc: int
|
||||
duration: int
|
||||
filepath: str
|
||||
folder: str
|
||||
genres: str | list[dict[str, str]]
|
||||
last_mod: int
|
||||
title: str
|
||||
track: int
|
||||
trackhash: str
|
||||
extra: dict
|
||||
lastplayed: int
|
||||
playcount: int
|
||||
playduration: int
|
||||
|
||||
config: UserConfig
|
||||
og_album: str = ""
|
||||
og_title: str = ""
|
||||
artisthashes: list[str] = field(default_factory=list)
|
||||
genrehashes: list[str] = field(default_factory=list)
|
||||
weakhash: str = ""
|
||||
|
||||
_pos: int = 0
|
||||
_ati: str = ""
|
||||
image: str = ""
|
||||
_score: float = 0
|
||||
explicit: bool = False
|
||||
fav_userids: list[int] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_favorite(self):
|
||||
return get_current_userid() in self.fav_userids
|
||||
|
||||
@property
|
||||
def pathhash(self):
|
||||
return create_hash(self.folder)
|
||||
|
||||
def toggle_favorite_user(self, userid: int):
|
||||
"""
|
||||
Toggles the favorite status of the track for a given user.
|
||||
|
||||
Args:
|
||||
userid (int): The ID of the user toggling the favorite status.
|
||||
"""
|
||||
if userid in self.fav_userids:
|
||||
self.fav_userids.remove(userid)
|
||||
else:
|
||||
self.fav_userids.append(userid)
|
||||
|
||||
def __post_init__(self):
|
||||
"""
|
||||
Performs post-initialization processing on the track object.
|
||||
This includes setting original values, processing artists and genres,
|
||||
and removing duplicate artists.
|
||||
"""
|
||||
self.og_title = self.title
|
||||
self.og_album = self.album
|
||||
self.folder = self.folder + "/"
|
||||
self.weakhash = create_hash(self.title, self.artists)
|
||||
|
||||
explicit_tag = self.extra.get("explicit", ["0"])
|
||||
if isinstance(explicit_tag, list):
|
||||
explicit_tag = explicit_tag[0] if explicit_tag else "0"
|
||||
if isinstance(explicit_tag, str):
|
||||
explicit_tag = explicit_tag.lower()
|
||||
self.explicit = explicit_tag in ("1", "yes", "true")
|
||||
else:
|
||||
self.explicit = bool(explicit_tag)
|
||||
|
||||
self.image = self.albumhash + ".webp" + "?pathhash=" + self.pathhash
|
||||
# self.extra = {
|
||||
# "disc_total": self.extra.get("disc_total", 0),
|
||||
# "track_total": self.extra.get("track_total", 0),
|
||||
# "samplerate": self.extra.get("samplerate", -1),
|
||||
# }
|
||||
|
||||
self.split_artists()
|
||||
self.map_with_config()
|
||||
self.process_genres()
|
||||
|
||||
# Remove duplicates from artists and albumartists
|
||||
seen_artists = set()
|
||||
self.artists = [
|
||||
d
|
||||
for d in self.artists
|
||||
if tuple(d.items()) not in seen_artists
|
||||
and not seen_artists.add(tuple(d.items()))
|
||||
]
|
||||
|
||||
seen_albumartists = set()
|
||||
self.albumartists = [
|
||||
d
|
||||
for d in self.albumartists
|
||||
if tuple(d.items()) not in seen_albumartists
|
||||
and not seen_albumartists.add(tuple(d.items()))
|
||||
]
|
||||
|
||||
self.recreate_trackhash()
|
||||
self.config = None
|
||||
|
||||
def split_artists(self):
|
||||
"""
|
||||
Splits the artists and albumartists based on the given separators,
|
||||
and updates the artisthashes.
|
||||
"""
|
||||
|
||||
def split(artists: str):
|
||||
return [
|
||||
{"name": a, "artisthash": create_hash(a, decode=True)}
|
||||
for a in split_artists(artists, config=self.config)
|
||||
]
|
||||
|
||||
self.artists = split(self.artists)
|
||||
self.albumartists = split(self.albumartists)
|
||||
self.artisthashes = [a["artisthash"] for a in self.artists]
|
||||
|
||||
def map_with_config(self):
|
||||
"""
|
||||
Applies various transformations to the track's title and album
|
||||
based on the user's configuration settings.
|
||||
"""
|
||||
new_title = self.title
|
||||
|
||||
# Extract featured artists
|
||||
if self.config.extractFeaturedArtists:
|
||||
feat, new_title = parse_feat_from_title(self.title, self.config)
|
||||
feat = [
|
||||
{"name": f, "artisthash": create_hash(f, decode=True)} for f in feat
|
||||
]
|
||||
feat = [f for f in feat if f["artisthash"] not in self.artisthashes]
|
||||
self.artists.extend(feat)
|
||||
self.artisthashes.extend([f["artisthash"] for f in feat])
|
||||
|
||||
# Update album title for singles
|
||||
# ie. album: "Title (feat. Artist)"
|
||||
# title: "Title (feat. Artist)"
|
||||
# becomes: album: "Title", title: "Title"
|
||||
if self.og_album == self.og_title:
|
||||
self.album = new_title
|
||||
|
||||
# Clean track title
|
||||
if self.config.removeProdBy:
|
||||
new_title = remove_prod(new_title)
|
||||
|
||||
# if self.title == new_title:
|
||||
# self.album = new_title
|
||||
|
||||
if self.config.removeRemasterInfo:
|
||||
new_title = clean_title(new_title)
|
||||
|
||||
self.title = new_title
|
||||
|
||||
# Clean album title
|
||||
if self.config.cleanAlbumTitle:
|
||||
self.album, _ = get_base_title_and_versions(self.album, get_versions=False)
|
||||
|
||||
if self.config.mergeAlbums:
|
||||
self.albumhash = create_hash(
|
||||
self.album, *(a["name"] for a in self.albumartists)
|
||||
)
|
||||
|
||||
def process_genres(self):
|
||||
"""
|
||||
Processes and standardizes the genre information for the track.
|
||||
"""
|
||||
if self.genres:
|
||||
src_genres: str = self.genres
|
||||
|
||||
src_genres = src_genres.lower()
|
||||
# separators = {"/", ";", "&"}
|
||||
separators = set(self.config.genreSeparators)
|
||||
|
||||
contains_rnb = "r&b" in src_genres
|
||||
contains_rock = "rock & roll" in src_genres
|
||||
|
||||
if contains_rnb:
|
||||
src_genres = src_genres.replace("r&b", "RnB")
|
||||
|
||||
if contains_rock:
|
||||
src_genres = src_genres.replace("rock & roll", "rock")
|
||||
|
||||
for s in separators:
|
||||
src_genres = src_genres.replace(s, ",")
|
||||
|
||||
genres_list: list[str] = src_genres.split(",")
|
||||
self.genres = [
|
||||
{"name": g.strip(), "genrehash": create_hash(g.strip())}
|
||||
for g in genres_list
|
||||
]
|
||||
self.genrehashes = [g["genrehash"] for g in self.genres]
|
||||
|
||||
def recreate_trackhash(self):
|
||||
"""
|
||||
Recreates the trackhash based on the current title, album, and artist information.
|
||||
"""
|
||||
self.trackhash = create_hash(
|
||||
self.title, self.album, *(artist["name"] for artist in self.artists)
|
||||
)
|
||||
|
||||
def copy(self):
|
||||
return Track(**{**asdict(self), "config": UserConfig()})
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Update Tracking Database Models
|
||||
|
||||
This module contains the database models for the artist update tracking system,
|
||||
including artist follows, release updates, notifications, and user preferences.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from swingmusic.db.base import Base
|
||||
|
||||
|
||||
class ArtistFollow(Base):
|
||||
"""
|
||||
Represents a user following an artist for update tracking
|
||||
"""
|
||||
|
||||
__tablename__ = "artist_follows"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
artist_id = Column(String(100), nullable=False, unique=True) # Spotify artist ID
|
||||
artist_name = Column(String(255), nullable=False)
|
||||
follow_level = Column(
|
||||
String(20), nullable=False, default="followed"
|
||||
) # 'favorite', 'followed', 'casual'
|
||||
auto_download_new_releases = Column(Boolean, default=False)
|
||||
preferred_quality = Column(String(20), default="flac")
|
||||
notification_preferences = Column(
|
||||
JSON, default=dict
|
||||
) # {in_app: true, push: false, email: false}
|
||||
follow_date = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
last_check_date = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="artist_follows")
|
||||
release_updates = relationship("ReleaseUpdate", back_populates="artist_follow")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ArtistFollow(user_id={self.user_id}, artist='{self.artist_name}')>"
|
||||
|
||||
|
||||
class ReleaseUpdate(Base):
|
||||
"""
|
||||
Represents a new release discovered from a followed artist
|
||||
"""
|
||||
|
||||
__tablename__ = "release_updates"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
release_id = Column(String(100), nullable=False, unique=True) # Spotify release ID
|
||||
artist_id = Column(String(100), nullable=False) # Spotify artist ID
|
||||
artist_name = Column(String(255), nullable=False)
|
||||
release_title = Column(String(255), nullable=False)
|
||||
release_type = Column(
|
||||
String(20), nullable=False
|
||||
) # 'album', 'single', 'ep', 'compilation'
|
||||
release_date = Column(Date, nullable=False)
|
||||
spotify_url = Column(Text, nullable=False)
|
||||
cover_image_url = Column(Text, nullable=True)
|
||||
total_tracks = Column(Integer, nullable=False)
|
||||
popularity = Column(Integer, default=0)
|
||||
explicit = Column(Boolean, default=False)
|
||||
discovered_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
processed_at = Column(DateTime, nullable=True)
|
||||
download_status = Column(
|
||||
String(20), default="pending"
|
||||
) # 'pending', 'queued', 'downloading', 'completed', 'failed'
|
||||
auto_downloaded = Column(Boolean, default=False)
|
||||
notification_sent = Column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
artist_follow = relationship("ArtistFollow", back_populates="release_updates")
|
||||
download_tasks = relationship("DownloadTask", back_populates="release_update")
|
||||
notifications = relationship("UpdateNotification", back_populates="release_update")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ReleaseUpdate(title='{self.release_title}', artist='{self.artist_name}')>"
|
||||
|
||||
|
||||
class UpdateNotification(Base):
|
||||
"""
|
||||
Represents notifications sent to users about new releases
|
||||
"""
|
||||
|
||||
__tablename__ = "update_notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
release_id = Column(
|
||||
String(100), ForeignKey("release_updates.release_id"), nullable=False
|
||||
)
|
||||
notification_type = Column(
|
||||
String(50), nullable=False
|
||||
) # 'new_release', 'artist_update', 'back_in_stock'
|
||||
sent_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
opened_at = Column(DateTime, nullable=True)
|
||||
action_taken = Column(
|
||||
String(50), nullable=True
|
||||
) # 'downloaded', 'played', 'dismissed'
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
release_update = relationship("ReleaseUpdate", back_populates="notifications")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UpdateNotification(user_id={self.user_id}, type='{self.notification_type}')>"
|
||||
|
||||
|
||||
class UpdateMonitoringPreferences(Base):
|
||||
"""
|
||||
User preferences for update monitoring
|
||||
"""
|
||||
|
||||
__tablename__ = "update_monitoring_preferences"
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id"), primary_key=True)
|
||||
enable_artist_monitoring = Column(Boolean, default=True)
|
||||
check_frequency = Column(String(20), default="daily") # 'hourly', 'daily', 'weekly'
|
||||
auto_download_favorites = Column(Boolean, default=False)
|
||||
auto_download_followed = Column(Boolean, default=False)
|
||||
max_auto_downloads_per_week = Column(Integer, default=5)
|
||||
quality_preference = Column(String(20), default="flac")
|
||||
storage_limit_mb = Column(Integer, default=10240)
|
||||
notification_channels = Column(
|
||||
JSON, default=dict
|
||||
) # {in_app: true, push: false, email: false, discord: false}
|
||||
exclude_explicit = Column(Boolean, default=False)
|
||||
preferred_release_types = Column(JSON, default=list) # ['album', 'ep', 'single']
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="update_preferences")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UpdateMonitoringPreferences(user_id={self.user_id})>"
|
||||
|
||||
|
||||
class DownloadTask(Base):
|
||||
"""
|
||||
Represents download tasks created from release updates
|
||||
"""
|
||||
|
||||
__tablename__ = "download_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
release_id = Column(
|
||||
String(100), ForeignKey("release_updates.release_id"), nullable=False
|
||||
)
|
||||
track_id = Column(String(100), nullable=False) # Spotify track ID
|
||||
track_title = Column(String(255), nullable=False)
|
||||
artist_name = Column(String(255), nullable=False)
|
||||
album_name = Column(String(255), nullable=False)
|
||||
spotify_url = Column(Text, nullable=False)
|
||||
quality_preference = Column(String(20), default="flac")
|
||||
status = Column(
|
||||
String(20), default="pending"
|
||||
) # 'pending', 'queued', 'downloading', 'completed', 'failed'
|
||||
priority = Column(String(20), default="normal") # 'low', 'normal', 'high', 'urgent'
|
||||
progress = Column(Integer, default=0) # 0-100
|
||||
file_path = Column(Text, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
auto_downloaded = Column(Boolean, default=False)
|
||||
added_to_library = Column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
release_update = relationship("ReleaseUpdate", back_populates="download_tasks")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DownloadTask(track='{self.track_title}', status='{self.status}')>"
|
||||
|
||||
|
||||
class ArtistFollowHistory(Base):
|
||||
"""
|
||||
Historical tracking of artist follows for analytics
|
||||
"""
|
||||
|
||||
__tablename__ = "artist_follow_history"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
artist_id = Column(String(100), nullable=False)
|
||||
artist_name = Column(String(255), nullable=False)
|
||||
action = Column(String(20), nullable=False) # 'follow', 'unfollow', 'level_change'
|
||||
old_level = Column(String(20), nullable=True)
|
||||
new_level = Column(String(20), nullable=True)
|
||||
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ArtistFollowHistory(user_id={self.user_id}, action='{self.action}')>"
|
||||
|
||||
|
||||
class ReleaseUpdateHistory(Base):
|
||||
"""
|
||||
Historical tracking of release updates for analytics
|
||||
"""
|
||||
|
||||
__tablename__ = "release_update_history"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
release_id = Column(String(100), nullable=False)
|
||||
artist_id = Column(String(100), nullable=False)
|
||||
artist_name = Column(String(255), nullable=False)
|
||||
release_title = Column(String(255), nullable=False)
|
||||
release_type = Column(String(20), nullable=False)
|
||||
action = Column(
|
||||
String(20), nullable=False
|
||||
) # 'discovered', 'downloaded', 'notification_sent', 'completed'
|
||||
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
metadata = Column(JSON, nullable=True) # Additional data about the action
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ReleaseUpdateHistory(release='{self.release_title}', action='{self.action}')>"
|
||||
|
||||
|
||||
class UpdateTrackingStats(Base):
|
||||
"""
|
||||
Aggregated statistics for update tracking
|
||||
"""
|
||||
|
||||
__tablename__ = "update_tracking_stats"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
stat_date = Column(Date, nullable=False)
|
||||
total_followed_artists = Column(Integer, default=0)
|
||||
new_releases_discovered = Column(Integer, default=0)
|
||||
auto_downloads_completed = Column(Integer, default=0)
|
||||
manual_downloads_completed = Column(Integer, default=0)
|
||||
notifications_sent = Column(Integer, default=0)
|
||||
notifications_opened = Column(Integer, default=0)
|
||||
storage_used_mb = Column(Integer, default=0)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UpdateTrackingStats(user_id={self.user_id}, date={self.stat_date})>"
|
||||
|
||||
|
||||
# Update the User model to include the new relationships
|
||||
# This would need to be added to the User model in user.py:
|
||||
#
|
||||
# from swingmusic.models.update_tracking import ArtistFollow, UpdateMonitoringPreferences
|
||||
#
|
||||
# class User(Base):
|
||||
# # ... existing fields ...
|
||||
#
|
||||
# # Update tracking relationships
|
||||
# artist_follows = relationship("ArtistFollow", back_populates="user")
|
||||
# update_preferences = relationship("UpdateMonitoringPreferences", back_populates="user", uselist=False)
|
||||
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class User:
|
||||
id: int
|
||||
image: str
|
||||
password: str
|
||||
username: str
|
||||
roles: list[str]
|
||||
extra: dict[str, str] = field(default_factory=dict)
|
||||
password_change_required: bool = False
|
||||
|
||||
# NOTE: roles: ['admin', 'user', 'curator']
|
||||
roles: list[str] = field(default_factory=lambda: ["user"])
|
||||
|
||||
def todict(self):
|
||||
this_dict = asdict(self)
|
||||
del this_dict["password"]
|
||||
|
||||
if type(this_dict["roles"]) is str:
|
||||
# INFO: this is an attempt to fix string roles!
|
||||
try:
|
||||
this_dict["roles"] = json.loads(this_dict["roles"])
|
||||
except json.JSONDecodeError:
|
||||
this_dict["roles"] = []
|
||||
|
||||
return this_dict
|
||||
|
||||
def todict_simplified(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
"firstname": self.extra["firstname"] if self.extra else "",
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
This module contains functions for the server
|
||||
"""
|
||||
|
||||
|
||||
# @background
|
||||
# def run_periodic_scans():
|
||||
# """
|
||||
# Runs periodic scans.
|
||||
|
||||
# Periodic scans are checks that run every few minutes
|
||||
# in the background to do stuff like:
|
||||
# - checking for new music
|
||||
# - delete deleted entries
|
||||
# - downloading artist images, and other data.
|
||||
# """
|
||||
# # ValidateAlbumThumbs()
|
||||
# # ValidatePlaylistThumbs()
|
||||
|
||||
# while UserConfig().enablePeriodicScans:
|
||||
|
||||
# try:
|
||||
# except PopulateCancelledError:
|
||||
# log.error("'run_periodic_scans': Periodic scan cancelled.")
|
||||
# pass
|
||||
|
||||
# time.sleep(UserConfig().scanInterval)
|
||||
@@ -0,0 +1,29 @@
|
||||
class Plugin:
|
||||
"""
|
||||
Class that all plugins should inherit from
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, description: str) -> None:
|
||||
self.enabled = False
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
def set_active(self, state: bool):
|
||||
self.enabled = state
|
||||
|
||||
|
||||
def plugin_method(func):
|
||||
"""
|
||||
A decorator that prevents execution if the plugin is disabled.
|
||||
Should be used on all plugin methods
|
||||
"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
plugin: Plugin = args[0]
|
||||
|
||||
if plugin.enabled:
|
||||
return func(*args, **kwargs)
|
||||
else:
|
||||
return
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,157 @@
|
||||
import contextlib
|
||||
import json
|
||||
import time
|
||||
from hashlib import md5
|
||||
from typing import Any
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import requests
|
||||
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.logger import log
|
||||
from swingmusic.models.track import Track
|
||||
from swingmusic.plugins import Plugin, plugin_method
|
||||
from swingmusic.settings import Paths
|
||||
from swingmusic.utils.threading import background
|
||||
|
||||
|
||||
class LastFmPlugin(Plugin):
|
||||
"""
|
||||
Last.fm scrobbler plugin.
|
||||
"""
|
||||
|
||||
UPLOADING_DUMPS = False
|
||||
|
||||
def __init__(self, current_userid: int):
|
||||
self.config = UserConfig()
|
||||
self.current_userid = current_userid
|
||||
super().__init__("lastfm", "Last.fm scrobbler")
|
||||
self.set_active(
|
||||
bool(
|
||||
self.config.lastfmApiKey
|
||||
and self.config.lastfmApiSecret
|
||||
and self.config.lastfmSessionKeys.get(str(self.current_userid))
|
||||
)
|
||||
)
|
||||
|
||||
def get_api_signature(self, data: dict[str, Any]) -> str:
|
||||
params = dict(data.items())
|
||||
|
||||
signature = "".join(f"{k}{v}" for k, v in sorted(params.items()))
|
||||
signature += self.config.lastfmApiSecret
|
||||
|
||||
return md5(signature.encode("utf-8")).hexdigest()
|
||||
|
||||
def post(self, data: dict[str, Any], useSessionKey: bool = True):
|
||||
url = "http://ws.audioscrobbler.com/2.0/?format=json"
|
||||
data["api_key"] = self.config.lastfmApiKey
|
||||
if useSessionKey:
|
||||
data["sk"] = self.config.lastfmSessionKeys.get(str(self.current_userid))
|
||||
|
||||
data["api_sig"] = self.get_api_signature(data)
|
||||
|
||||
final_url = (
|
||||
url + "&" + "&".join(f"{k}={quote_plus(str(v))}" for k, v in data.items())
|
||||
)
|
||||
|
||||
return requests.post(final_url)
|
||||
|
||||
def get_session_key(self, token: str):
|
||||
data = {
|
||||
"method": "auth.getSession",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
try:
|
||||
res = self.post(data, useSessionKey=False)
|
||||
return res.json()["session"]["key"]
|
||||
except Exception as e:
|
||||
print("get_session_key error", e)
|
||||
return None
|
||||
|
||||
@plugin_method
|
||||
@background
|
||||
def scrobble(self, track: Track, timestamp: int):
|
||||
data = {
|
||||
"method": "track.scrobble",
|
||||
"artist": track.artists[0]["name"],
|
||||
"track": track.title,
|
||||
"timestamp": timestamp,
|
||||
"album": track.album,
|
||||
"albumArtist": track.albumartists[0]["name"],
|
||||
}
|
||||
|
||||
success = self.post_scrobble_data({**data})
|
||||
|
||||
if not success:
|
||||
self.dump_scrobble(data)
|
||||
else:
|
||||
self.upload_dumps()
|
||||
|
||||
return success
|
||||
|
||||
def post_scrobble_data(self, data: dict[str, Any]):
|
||||
"""
|
||||
Uploads the scrobble data and handles the
|
||||
response from the lastfm scrobble endpoint.
|
||||
"""
|
||||
try:
|
||||
res = self.post(data)
|
||||
except Exception as e:
|
||||
log.warn("scrobble response error" + str(e))
|
||||
return False
|
||||
|
||||
try:
|
||||
res_json: dict[str, Any] = res.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return False
|
||||
|
||||
if res_json.get("error"):
|
||||
log.error("LASTFM: scrobble error" + str(res_json))
|
||||
|
||||
if res_json["error"] == 9:
|
||||
log.error("LAST.FM: Invalid session key")
|
||||
# Invalid session key
|
||||
with contextlib.suppress(KeyError):
|
||||
self.config.lastfmSessionKeys.pop(str(self.current_userid))
|
||||
|
||||
self.config.lastfmSessionKeys = self.config.lastfmSessionKeys
|
||||
return False
|
||||
|
||||
return res_json.get("scrobbles", {}).get("@attr", {}).get("accepted") == 1
|
||||
|
||||
# SECTION: Persistence
|
||||
def dump_scrobble(self, data: dict[str, Any]):
|
||||
"""
|
||||
Dumps the scrobble data to a file in the lastfm plugin directory.
|
||||
"""
|
||||
dump_dir = Paths().plugins_path / "lastfm"
|
||||
if not dump_dir.exists():
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
path = dump_dir / f"{int(time.time())}.json"
|
||||
path.write_text(json.dumps(data))
|
||||
|
||||
def upload_dumps(self):
|
||||
"""
|
||||
Uploads the scrobble dumps to the lastfm api.
|
||||
"""
|
||||
if self.UPLOADING_DUMPS:
|
||||
return
|
||||
|
||||
self.UPLOADING_DUMPS = True
|
||||
dump_dir = Paths().plugins_path / "lastfm"
|
||||
|
||||
if not dump_dir.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
for file in dump_dir.iterdir():
|
||||
with open(file) as f:
|
||||
data = json.load(f)
|
||||
success = self.post_scrobble_data(data)
|
||||
|
||||
if success:
|
||||
file.unlink()
|
||||
finally:
|
||||
self.UPLOADING_DUMPS = False
|
||||
@@ -0,0 +1,383 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from unidecode import unidecode
|
||||
|
||||
from swingmusic.db.userdata import PluginTable
|
||||
from swingmusic.plugins import Plugin, plugin_method
|
||||
from swingmusic.settings import Paths
|
||||
|
||||
|
||||
class LRCProvider:
|
||||
"""Base class for synced (LRC format) lyrics providers."""
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session.headers.update(
|
||||
{
|
||||
"user-agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def get_lrc_by_id(self, track_id: str) -> str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_lrc(self, title: str, artist: str, album: str = "") -> list[dict]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LRCLibProvider(LRCProvider):
|
||||
"""LRCLIB-first provider (SpotiFLAC-style exact->fallback search strategy)."""
|
||||
|
||||
ROOT_URL = "https://lrclib.net/api"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._by_id_cache: dict[str, str] = {}
|
||||
|
||||
def _get_json(self, endpoint: str, params: dict) -> dict | list | None:
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{self.ROOT_URL}/{endpoint}",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
if not response.ok:
|
||||
return None
|
||||
return response.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _entry_to_lrc(self, entry: dict) -> str | None:
|
||||
synced = (entry.get("syncedLyrics") or "").strip()
|
||||
plain = (entry.get("plainLyrics") or "").strip()
|
||||
|
||||
if synced:
|
||||
return synced
|
||||
if plain:
|
||||
return plain
|
||||
return None
|
||||
|
||||
def _to_result(self, entry: dict) -> dict | None:
|
||||
lrc = self._entry_to_lrc(entry)
|
||||
if not lrc:
|
||||
return None
|
||||
|
||||
track_id = str(entry.get("id") or "")
|
||||
provider_track_id = f"lrclib:{track_id}" if track_id else f"lrclib:{hash(lrc)}"
|
||||
self._by_id_cache[provider_track_id] = lrc
|
||||
|
||||
return {
|
||||
"track_id": provider_track_id,
|
||||
"title": entry.get("trackName", ""),
|
||||
"artist": entry.get("artistName", ""),
|
||||
"album": entry.get("albumName", ""),
|
||||
"image": None,
|
||||
"provider": "lrclib",
|
||||
"lrc": lrc,
|
||||
}
|
||||
|
||||
def get_lrc_by_id(self, track_id: str) -> str | None:
|
||||
return self._by_id_cache.get(track_id)
|
||||
|
||||
def get_lrc(self, title: str, artist: str, album: str = "") -> list[dict]:
|
||||
if not title or not artist:
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
|
||||
# 1) Exact lookup including album when available.
|
||||
if album:
|
||||
exact_with_album = self._get_json(
|
||||
"get",
|
||||
{
|
||||
"artist_name": artist,
|
||||
"track_name": title,
|
||||
"album_name": album,
|
||||
},
|
||||
)
|
||||
if isinstance(exact_with_album, dict):
|
||||
result = self._to_result(exact_with_album)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
# 2) Exact lookup without album.
|
||||
if not results:
|
||||
exact = self._get_json(
|
||||
"get",
|
||||
{
|
||||
"artist_name": artist,
|
||||
"track_name": title,
|
||||
},
|
||||
)
|
||||
if isinstance(exact, dict):
|
||||
result = self._to_result(exact)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
# 3) Search fallback.
|
||||
if not results:
|
||||
search_data = self._get_json(
|
||||
"search",
|
||||
{
|
||||
"artist_name": artist,
|
||||
"track_name": title,
|
||||
},
|
||||
)
|
||||
if isinstance(search_data, list):
|
||||
for entry in search_data:
|
||||
result = self._to_result(entry)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class MusixmatchProvider(LRCProvider):
|
||||
"""Musixmatch provider class."""
|
||||
|
||||
ROOT_URL = "https://apic-desktop.musixmatch.com/ws/1.1/"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.token = None
|
||||
self.session.headers.update(
|
||||
{
|
||||
"authority": "apic-desktop.musixmatch.com",
|
||||
"cookie": "AWSELBCORS=0; AWSELB=0",
|
||||
}
|
||||
)
|
||||
|
||||
def _get(self, action: str, query: list[tuple]):
|
||||
if action != "token.get" and self.token is None:
|
||||
self._get_token()
|
||||
|
||||
query.append(("app_id", "web-desktop-app-v1.0"))
|
||||
if self.token is not None:
|
||||
query.append(("usertoken", self.token))
|
||||
|
||||
t = str(int(time.time() * 1000))
|
||||
query.append(("t", t))
|
||||
|
||||
try:
|
||||
url = self.ROOT_URL + action
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
try:
|
||||
response = self.session.get(url, params=query, timeout=10)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if response is not None and response.ok:
|
||||
return response
|
||||
|
||||
return None
|
||||
|
||||
def _get_token(self):
|
||||
plugin_path = Paths().lyrics_plugins_path
|
||||
token_path = plugin_path / "token.json"
|
||||
|
||||
current_time = int(time.time())
|
||||
|
||||
if token_path.exists():
|
||||
with token_path.open(mode="r", encoding="utf-8") as token_file:
|
||||
cached_token_data: dict = json.load(token_file)
|
||||
|
||||
cached_token = cached_token_data.get("token")
|
||||
expiration_time = cached_token_data.get("expiration_time")
|
||||
|
||||
if cached_token and expiration_time and current_time < expiration_time:
|
||||
self.token = cached_token
|
||||
return
|
||||
|
||||
res = self._get("token.get", [("user_language", "en")])
|
||||
|
||||
if res is None:
|
||||
return
|
||||
|
||||
res = res.json()
|
||||
if res["message"]["header"]["status_code"] == 401:
|
||||
time.sleep(13)
|
||||
return self._get_token()
|
||||
|
||||
new_token = res["message"]["body"]["user_token"]
|
||||
expiration_time = current_time + 600
|
||||
|
||||
self.token = new_token
|
||||
token_data = {"token": new_token, "expiration_time": expiration_time}
|
||||
|
||||
plugin_path.mkdir(parents=True, exist_ok=True)
|
||||
with token_path.open("w", encoding="utf-8") as token_file:
|
||||
json.dump(token_data, token_file)
|
||||
|
||||
def get_lrc_by_id(self, track_id: str) -> str | None:
|
||||
res = self._get(
|
||||
"track.subtitle.get",
|
||||
[("track_id", track_id), ("subtitle_format", "lrc")],
|
||||
)
|
||||
|
||||
try:
|
||||
res = res.json()
|
||||
body = res["message"]["body"]
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
if not body:
|
||||
return None
|
||||
|
||||
return body["subtitle"]["subtitle_body"]
|
||||
|
||||
def get_lrc(self, title: str, artist: str, album: str = "") -> list[dict]:
|
||||
res = self._get(
|
||||
"track.search",
|
||||
[
|
||||
("q_track", title),
|
||||
("q_artist", artist),
|
||||
("page_size", "5"),
|
||||
("page", "1"),
|
||||
("f_has_lyrics", "1"),
|
||||
("s_track_rating", "desc"),
|
||||
("quorum_factor", "1.0"),
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
body = res.json()["message"]["body"]
|
||||
except AttributeError:
|
||||
return []
|
||||
|
||||
try:
|
||||
tracks = body["track_list"]
|
||||
except TypeError:
|
||||
return []
|
||||
|
||||
if not tracks:
|
||||
decoded = unidecode(artist)
|
||||
if decoded == artist:
|
||||
return []
|
||||
return self.get_lrc(title, decoded, album)
|
||||
|
||||
return [
|
||||
{
|
||||
"track_id": str(t["track"]["track_id"]),
|
||||
"title": t["track"]["track_name"],
|
||||
"artist": t["track"]["artist_name"],
|
||||
"album": t["track"]["album_name"],
|
||||
"image": t["track"]["album_coverart_100x100"],
|
||||
"provider": "musixmatch",
|
||||
}
|
||||
for t in tracks
|
||||
]
|
||||
|
||||
|
||||
class Lyrics(Plugin):
|
||||
def __init__(self) -> None:
|
||||
plugin = PluginTable.get_by_name("lyrics_finder")
|
||||
if not plugin:
|
||||
return
|
||||
|
||||
super().__init__(plugin.name, "Lyrics finder")
|
||||
|
||||
self.providers: list[LRCProvider] = [LRCLibProvider(), MusixmatchProvider()]
|
||||
self._search_cache: dict[str, str] = {}
|
||||
|
||||
self.set_active(bool(int(plugin.active)))
|
||||
|
||||
@staticmethod
|
||||
def _write_lrc(path: str, lrc: str) -> None:
|
||||
output = Path(path).with_suffix(".lrc")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(lrc, encoding="utf-8")
|
||||
|
||||
@plugin_method
|
||||
def search_lyrics_by_title_and_artist(self, title: str, artist: str):
|
||||
album = ""
|
||||
results: list[dict] = []
|
||||
seen = set()
|
||||
|
||||
for provider in self.providers:
|
||||
try:
|
||||
provider_results = provider.get_lrc(title, artist, album)
|
||||
except TypeError:
|
||||
provider_results = provider.get_lrc(title, artist) # type: ignore[misc]
|
||||
|
||||
for item in provider_results:
|
||||
if not item:
|
||||
continue
|
||||
|
||||
dedupe_key = (
|
||||
(item.get("title") or "").strip().lower(),
|
||||
(item.get("artist") or "").strip().lower(),
|
||||
(item.get("album") or "").strip().lower(),
|
||||
)
|
||||
if dedupe_key in seen:
|
||||
continue
|
||||
|
||||
seen.add(dedupe_key)
|
||||
|
||||
track_id = str(item.get("track_id", "")).strip()
|
||||
lrc = item.get("lrc")
|
||||
if track_id and lrc:
|
||||
self._search_cache[track_id] = lrc
|
||||
|
||||
results.append(item)
|
||||
|
||||
return results
|
||||
|
||||
@plugin_method
|
||||
def download_lyrics(self, trackid: str, path: str):
|
||||
lrc = self._search_cache.get(trackid)
|
||||
|
||||
if not lrc:
|
||||
for provider in self.providers:
|
||||
lrc = provider.get_lrc_by_id(trackid)
|
||||
if lrc:
|
||||
break
|
||||
|
||||
if lrc is None:
|
||||
return None
|
||||
if len(lrc.replace("\n", "").strip()) < 1:
|
||||
return None
|
||||
|
||||
self._write_lrc(path, lrc)
|
||||
return lrc
|
||||
|
||||
@plugin_method
|
||||
def download_lyrics_by_metadata(
|
||||
self,
|
||||
title: str,
|
||||
artist: str,
|
||||
path: str,
|
||||
album: str = "",
|
||||
):
|
||||
if not title or not artist:
|
||||
return None
|
||||
|
||||
for provider in self.providers:
|
||||
try:
|
||||
provider_results = provider.get_lrc(title, artist, album)
|
||||
except TypeError:
|
||||
provider_results = provider.get_lrc(title, artist) # type: ignore[misc]
|
||||
|
||||
for item in provider_results:
|
||||
lrc = item.get("lrc")
|
||||
if not lrc:
|
||||
track_id = str(item.get("track_id", ""))
|
||||
if track_id:
|
||||
lrc = provider.get_lrc_by_id(track_id)
|
||||
|
||||
if not lrc or len(lrc.replace("\n", "").strip()) < 1:
|
||||
continue
|
||||
|
||||
self._write_lrc(path, lrc)
|
||||
return lrc
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,698 @@
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from gettext import ngettext
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from swingmusic.db.userdata import MixTable
|
||||
from swingmusic.models.artist import Artist
|
||||
from swingmusic.models.mix import Mix
|
||||
from swingmusic.models.track import Track
|
||||
from swingmusic.plugins import Plugin, plugin_method
|
||||
from swingmusic.settings import Paths
|
||||
from swingmusic.store.albums import AlbumStore
|
||||
from swingmusic.store.artists import ArtistStore
|
||||
from swingmusic.store.tracks import TrackStore
|
||||
from swingmusic.utils.dates import get_date_range, get_duration_ago
|
||||
from swingmusic.utils.hashing import create_hash
|
||||
from swingmusic.utils.mixes import balance_mix
|
||||
from swingmusic.utils.stats import get_artists_in_period
|
||||
|
||||
|
||||
class MixAlreadyExists(Exception):
|
||||
"""
|
||||
Raised when a mix with the same sourcehash already exists.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MixesPlugin(Plugin):
|
||||
MAX_TRACKS_TO_FETCH = 5
|
||||
MIN_TRACK_MIX_LENGTH = 15
|
||||
MIN_ARTISTS_PER_MIX = 4
|
||||
MIX_TRACKS_LENGTH = 40
|
||||
|
||||
MIN_DAY_LISTEN_DURATION = 3 * 60 # 3 minutes
|
||||
MIN_WEEK_LISTEN_DURATION = 10 * 60 # 10 minutes
|
||||
MIN_MONTH_LISTEN_DURATION = 20 * 60 # 20 minutes
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("mixes", "Mixes")
|
||||
self.server = "https://smcloud.mungaist.com"
|
||||
# self.server = "http://localhost:1956"
|
||||
|
||||
# server_online = self.ping_server()
|
||||
self.set_active(True)
|
||||
|
||||
def ping_server(self):
|
||||
max_retries = 3
|
||||
retry_delay = 2 # seconds
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
requests.get(self.server, timeout=10)
|
||||
return True
|
||||
except Exception:
|
||||
print(
|
||||
f"Failed to connect to the recommendation server (attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(retry_delay)
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
@plugin_method
|
||||
def get_track_mix_data(self, tracks: list[Track], with_help: bool = False):
|
||||
"""
|
||||
Given a list of tracks, creates a mix by fetching data from the
|
||||
Swing Music Cloud recommendation server.
|
||||
|
||||
The server returns a list of weak trackhashes. We use these to fetch
|
||||
the matching track data from our library database. Found tracks are
|
||||
then balanced and returned as the final mix tracklist.
|
||||
|
||||
:param with_help: Whether to include the help flag in the query.
|
||||
The flag tells the server to find more data using other tracks from the same album.
|
||||
"""
|
||||
queries = [
|
||||
{
|
||||
"title": track.title,
|
||||
"artists": [a["name"] for a in track.artists],
|
||||
"album": track.og_album,
|
||||
"with_help": with_help,
|
||||
}
|
||||
for track in tracks
|
||||
]
|
||||
|
||||
try:
|
||||
response = requests.post(f"{self.server}/radio", json=queries, timeout=30)
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
|
||||
print("Failed to connect to recommendation server")
|
||||
return [], [], []
|
||||
|
||||
try:
|
||||
results = response.json()
|
||||
except json.JSONDecodeError:
|
||||
print("Failed to decode JSON response from recommendation server")
|
||||
return [], [], []
|
||||
|
||||
trackhashes: list[str] = results.get("tracks", [])
|
||||
|
||||
trackmatches = TrackStore.get_flat_list()
|
||||
trackmatches = [t for t in trackmatches if t.weakhash in trackhashes]
|
||||
|
||||
# filter out duplicates of the same weakhash
|
||||
# group by weakhash and pick the one with the highest bitrate
|
||||
grouped: dict[str, list[Track]] = {}
|
||||
for track in trackmatches:
|
||||
grouped.setdefault(track.weakhash, []).append(track)
|
||||
|
||||
trackmatches = [
|
||||
max(group, key=lambda x: x.bitrate) for group in grouped.values()
|
||||
]
|
||||
|
||||
# sort by trackhash order
|
||||
trackmatches = sorted(trackmatches, key=lambda x: trackhashes.index(x.weakhash))
|
||||
|
||||
# if the mix is short, try to fill it up with tracks
|
||||
# from album and artist data from the cloud!
|
||||
|
||||
# Create as many filler tracks as possible
|
||||
# Then the mix length will be controlled in the Mix model
|
||||
# if len(trackmatches) < self.TRACK_MIX_LENGTH:
|
||||
if True:
|
||||
filler_tracks = self.fallback_create_artist_mix(
|
||||
similar_artists=results.get("artists", []),
|
||||
similar_albums=results.get("albums", []),
|
||||
omit_trackhashes={t.weakhash for t in trackmatches},
|
||||
# limit=self.TRACK_MIX_LENGTH - len(trackmatches),
|
||||
)
|
||||
trackmatches.extend(filler_tracks)
|
||||
|
||||
# try to balance the mix
|
||||
trackmatches = balance_mix(trackmatches)
|
||||
return trackmatches, results.get("albums", []), results.get("artists", [])
|
||||
|
||||
# @plugin_method
|
||||
# def get_artist_mix(self, artisthash: str):
|
||||
# """
|
||||
# Given an artisthash, creates an artist mix using the
|
||||
# self.MAX_TRACKS_TO_FETCH most listened to tracks.
|
||||
|
||||
# Returns a tuple of the mix and the sourcehash.
|
||||
# """
|
||||
# artist = ArtistStore.artistmap[artisthash]
|
||||
# tracks = TrackStore.get_tracks_by_trackhashes(artist.trackhashes)
|
||||
|
||||
# tracks = sorted(tracks, key=lambda x: x.playduration, reverse=True)
|
||||
# sourcetracks = tracks[: self.MAX_TRACKS_TO_FETCH]
|
||||
# sourcehash = create_hash(*[t.trackhash for t in sourcetracks])
|
||||
|
||||
# if MixTable.get_by_sourcehash(sourcehash):
|
||||
# raise MixAlreadyExists()
|
||||
|
||||
# tracks, albums, artists = self.get_track_mix(tracks[: self.MAX_TRACKS_TO_FETCH])
|
||||
# return (tracks, albums, artists, sourcehash)
|
||||
|
||||
@plugin_method
|
||||
def create_artist_mixes(self, userid: int):
|
||||
"""
|
||||
Creates artist mixes for a given userid.
|
||||
"""
|
||||
mixes: list[Mix] = []
|
||||
indexed = set()
|
||||
|
||||
today_start, today_end = get_date_range(duration="day")
|
||||
last_2_days_start = get_duration_ago("day", 2)
|
||||
last_7_days_start = get_duration_ago("week")
|
||||
last_1_month_start = get_duration_ago("month")
|
||||
|
||||
artists = {
|
||||
"today": {
|
||||
"max": 4,
|
||||
"artists": get_artists_in_period(today_start, today_end, userid),
|
||||
"created": 0,
|
||||
},
|
||||
"last_2_days": {
|
||||
"max": 3,
|
||||
"artists": get_artists_in_period(
|
||||
last_2_days_start, time.time(), userid
|
||||
),
|
||||
"created": 0,
|
||||
},
|
||||
"last_7_days": {
|
||||
"max": 4,
|
||||
"artists": get_artists_in_period(
|
||||
last_7_days_start, time.time(), userid
|
||||
),
|
||||
"created": 0,
|
||||
},
|
||||
"last_1_month": {
|
||||
"max": 4,
|
||||
"artists": get_artists_in_period(
|
||||
last_1_month_start, time.time(), userid
|
||||
),
|
||||
"created": 0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, period in enumerate(artists.values()):
|
||||
# if previous period has less than its max
|
||||
# add the difference to this period's limit
|
||||
limit = period["max"]
|
||||
|
||||
if i > 0:
|
||||
previous_period = artists[list(artists.keys())[i - 1]]
|
||||
if previous_period["created"] < previous_period["max"]:
|
||||
limit += previous_period["max"] - previous_period["created"]
|
||||
|
||||
for artist in period["artists"]:
|
||||
if period["created"] >= limit:
|
||||
break
|
||||
|
||||
if artist["artisthash"] in indexed:
|
||||
continue
|
||||
|
||||
# INFO: track['tracks'] is a dict of trackhashes and their counts
|
||||
# get the trackhashes sorted by count
|
||||
trackhashes = sorted(
|
||||
artist["tracks"], key=lambda x: artist["tracks"][x], reverse=True
|
||||
)
|
||||
|
||||
mix = self.create_artist_mix(
|
||||
artist, trackhashes[: self.MAX_TRACKS_TO_FETCH], userid=userid
|
||||
)
|
||||
|
||||
if mix:
|
||||
mixes.append(mix)
|
||||
indexed.add(artist["artisthash"])
|
||||
period["created"] += 1
|
||||
|
||||
return mixes
|
||||
|
||||
@classmethod
|
||||
def get_mix_description(cls, tracks: list[Track], artishash: str):
|
||||
"""
|
||||
Constructs a description for a mix by putting together the first n=4
|
||||
artists in the mix tracklist.
|
||||
"""
|
||||
first_4_artists = []
|
||||
indexed = set()
|
||||
|
||||
for track in tracks:
|
||||
if len(first_4_artists) < 4 and (
|
||||
track.artists[0]["artisthash"] != artishash
|
||||
and track.artists[0]["artisthash"] not in indexed
|
||||
):
|
||||
first_4_artists.append(track.artists[0])
|
||||
indexed.add(track.artists[0]["artisthash"])
|
||||
|
||||
if len(first_4_artists) == 4:
|
||||
return f"Featuring {', '.join(a['name'] for a in first_4_artists)} and more"
|
||||
|
||||
if len(first_4_artists) > 0:
|
||||
return f"Featuring {', '.join(a['name'] for a in first_4_artists)}"
|
||||
|
||||
return f"Featuring {tracks[0].artists[0]['name']}"
|
||||
|
||||
def create_artist_mix(
|
||||
self, artist: dict[str, str], trackhashes: list[str], userid: int
|
||||
):
|
||||
"""
|
||||
Given an artist dict, creates an artist mix.
|
||||
"""
|
||||
_artist = ArtistStore.artistmap.get(artist["artisthash"])
|
||||
|
||||
if not _artist:
|
||||
return None
|
||||
|
||||
tracks = TrackStore.get_tracks_by_trackhashes(trackhashes)
|
||||
# tracks = sorted(tracks, key=lambda x: x.playduration, reverse=True)
|
||||
# sourcetracks = tracks[: self.MAX_TRACKS_TO_FETCH]
|
||||
|
||||
# INFO: Sort the trackhashes when creating the sourcehash
|
||||
ordered_source_trackhashes = sorted(
|
||||
trackhashes, key=lambda x: trackhashes.index(x)
|
||||
)
|
||||
legacy_sourcehash = create_hash(*ordered_source_trackhashes)
|
||||
|
||||
# Scope mix identity per user AND per artist to avoid collisions.
|
||||
# Including artisthash ensures different artists with similar top tracks
|
||||
# generate unique mixes instead of colliding.
|
||||
sourcehash = create_hash(
|
||||
f"user:{userid}",
|
||||
f"artist:{artist['artisthash']}",
|
||||
*ordered_source_trackhashes,
|
||||
)
|
||||
|
||||
db_mix = MixTable.get_by_sourcehash(sourcehash) or MixTable.get_by_sourcehash(
|
||||
legacy_sourcehash
|
||||
)
|
||||
if db_mix:
|
||||
return db_mix
|
||||
|
||||
mix_tracks, albums, artists = self.get_track_mix_data(tracks)
|
||||
|
||||
if len(mix_tracks) < self.MIN_TRACK_MIX_LENGTH:
|
||||
return None
|
||||
|
||||
# INFO: Dump mixes with no variety
|
||||
if len({t.artisthashes[0] for t in mix_tracks}) < self.MIN_ARTISTS_PER_MIX:
|
||||
return None
|
||||
|
||||
# try downloading artist image
|
||||
mix_image = {"image": _artist.artist.image, "color": _artist.artist.color}
|
||||
image = self.download_artist_image(_artist.artist)
|
||||
|
||||
if image:
|
||||
mix_image["image"] = image
|
||||
|
||||
mix = Mix(
|
||||
# the a prefix indicates that this is an artist mix
|
||||
id=f"a{userid}{artist['artisthash']}",
|
||||
title=artist["artist"] + " Radio",
|
||||
description=self.get_mix_description(mix_tracks, artist["artisthash"]),
|
||||
tracks=[t.trackhash for t in mix_tracks],
|
||||
sourcehash=sourcehash,
|
||||
userid=userid,
|
||||
extra={
|
||||
"type": "artist",
|
||||
"artisthash": artist["artisthash"],
|
||||
"sourcetracks": trackhashes,
|
||||
"legacy_sourcehash": legacy_sourcehash,
|
||||
"image": mix_image,
|
||||
# NOTE: Save the similar albums and artists
|
||||
# Related to the source tracks that were used to create the mix
|
||||
# Will be useful when generating other homepage entries
|
||||
"albums": albums,
|
||||
"artists": artists,
|
||||
},
|
||||
timestamp=int(time.time()),
|
||||
)
|
||||
|
||||
MixTable.insert_one(mix)
|
||||
return mix
|
||||
|
||||
def download_artist_image(self, artist: Artist):
|
||||
try:
|
||||
res = requests.get(
|
||||
f"{self.server}/mix/image?artist={quote(artist.name)}&type=Artist"
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
return None
|
||||
|
||||
if res.status_code == 200:
|
||||
filename = f"{artist.artisthash}_{int(time.time())}.webp"
|
||||
path = Paths().md_mixes_img_path / filename
|
||||
|
||||
image = Image.open(BytesIO(res.content))
|
||||
aspect_ratio = image.width / image.height
|
||||
|
||||
# resize to 512px
|
||||
md_width = 512
|
||||
md_height = int(md_width / aspect_ratio)
|
||||
|
||||
image = image.resize((md_width, md_height), Image.LANCZOS)
|
||||
image.save(path, "webp")
|
||||
|
||||
# resize to 256px
|
||||
sm_width = 256
|
||||
sm_height = int(sm_width / aspect_ratio)
|
||||
|
||||
image = image.resize((sm_width, sm_height), Image.LANCZOS)
|
||||
small_path = Paths().sm_mixes_img_path / filename
|
||||
image.save(small_path, "webp")
|
||||
|
||||
return filename
|
||||
|
||||
return None
|
||||
|
||||
def fallback_create_artist_mix(
|
||||
self,
|
||||
# artist: dict[str, str],
|
||||
similar_albums: list[str],
|
||||
similar_artists: list[str],
|
||||
omit_trackhashes: set[str],
|
||||
limit: int = 99,
|
||||
):
|
||||
"""
|
||||
Creates an artist mix by selecting random tracks from similar albums and artists.
|
||||
|
||||
This is used when:
|
||||
- The Swing Music recommendation server is down.
|
||||
- The artist has less than self.MIN_TRACK_MIX_LENGTH tracks from the cloud mix.
|
||||
- When we need to dilute the mix to balance the artist distribution.
|
||||
|
||||
:param similar_albums: A list of similar album weakhashes to select tracks from.
|
||||
:param similar_artists: A list of similar artist hashes to select tracks from.
|
||||
:param omit_trackhashes: A set of trackhashes to omit from the new tracklist.
|
||||
:param limit: The maximum number of tracks to select.
|
||||
"""
|
||||
|
||||
mixtracks = []
|
||||
albummatches = (
|
||||
a
|
||||
for a in AlbumStore.albummap.values()
|
||||
if a.album.weakhash in similar_albums
|
||||
)
|
||||
|
||||
for match in albummatches:
|
||||
if len(mixtracks) >= limit:
|
||||
return mixtracks
|
||||
|
||||
albumtracks = [
|
||||
t
|
||||
for t in TrackStore.get_tracks_by_trackhashes(match.trackhashes)
|
||||
if t.weakhash not in omit_trackhashes
|
||||
]
|
||||
|
||||
if len(albumtracks) == 0:
|
||||
continue
|
||||
|
||||
sample = random.sample(albumtracks, k=1)
|
||||
mixtracks.extend(sample)
|
||||
|
||||
artistmatches = (
|
||||
a
|
||||
for a in ArtistStore.artistmap.values()
|
||||
if a.artist.artisthash in similar_artists
|
||||
)
|
||||
|
||||
for match in artistmatches:
|
||||
if len(mixtracks) >= limit:
|
||||
return mixtracks
|
||||
|
||||
artisttracks = [
|
||||
t
|
||||
for t in TrackStore.get_tracks_by_trackhashes(match.trackhashes)
|
||||
if t.weakhash not in omit_trackhashes
|
||||
]
|
||||
|
||||
if len(artisttracks) == 0:
|
||||
continue
|
||||
|
||||
sample = random.sample(artisttracks, k=1)
|
||||
mixtracks.extend(sample)
|
||||
|
||||
return mixtracks
|
||||
|
||||
def get_mix_from_lastfm_data(self, artisthash: str, limit: int = 40) -> list[Track]:
|
||||
"""
|
||||
Creates a mix from the locally available lastfm similar artists data.
|
||||
|
||||
The resulting mix is definitely expected to be of lower quality than
|
||||
the cloud-based recommendation, but provides a fallback when offline
|
||||
or when the recommendation server is unavailable.
|
||||
|
||||
:param artisthash: The hash of the artist to create a mix for
|
||||
:param limit: Maximum number of tracks to include in the mix
|
||||
:return: List of tracks for the mix
|
||||
"""
|
||||
from swingmusic.db.userdata import SimilarArtistTable
|
||||
|
||||
# Get the similar artists data from the database
|
||||
similar_data = SimilarArtistTable.get_by_artisthash(artisthash)
|
||||
|
||||
if not similar_data or not similar_data.get("similar_artists"):
|
||||
return []
|
||||
|
||||
# Get the source artist to include their tracks
|
||||
source_artist = ArtistStore.artistmap.get(artisthash)
|
||||
mixtracks = []
|
||||
|
||||
if source_artist:
|
||||
# Add some tracks from the source artist first
|
||||
source_tracks = TrackStore.get_tracks_by_trackhashes(
|
||||
source_artist.trackhashes[:5]
|
||||
)
|
||||
mixtracks.extend(source_tracks)
|
||||
|
||||
# Get similar artist hashes sorted by weight (similarity score)
|
||||
similar_entries = sorted(
|
||||
similar_data["similar_artists"],
|
||||
key=lambda x: x.get("weight", 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Collect tracks from similar artists
|
||||
seen_trackhashes = {t.trackhash for t in mixtracks}
|
||||
omit_trackhashes = {t.weakhash for t in mixtracks}
|
||||
|
||||
for entry in similar_entries:
|
||||
if len(mixtracks) >= limit:
|
||||
break
|
||||
|
||||
similar_artisthash = entry.get("artisthash")
|
||||
if not similar_artisthash:
|
||||
continue
|
||||
|
||||
similar_artist = ArtistStore.artistmap.get(similar_artisthash)
|
||||
if not similar_artist:
|
||||
continue
|
||||
|
||||
# Get tracks from this similar artist
|
||||
artist_tracks = [
|
||||
t
|
||||
for t in TrackStore.get_tracks_by_trackhashes(
|
||||
similar_artist.trackhashes
|
||||
)
|
||||
if t.weakhash not in omit_trackhashes
|
||||
and t.trackhash not in seen_trackhashes
|
||||
]
|
||||
|
||||
if artist_tracks:
|
||||
# Add 1-3 random tracks from this artist
|
||||
num_tracks = min(len(artist_tracks), random.randint(1, 3))
|
||||
sample = random.sample(artist_tracks, k=num_tracks)
|
||||
mixtracks.extend(sample)
|
||||
seen_trackhashes.update(t.trackhash for t in sample)
|
||||
omit_trackhashes.update(t.weakhash for t in sample)
|
||||
|
||||
# Balance the mix to ensure variety
|
||||
if mixtracks:
|
||||
mixtracks = balance_mix(mixtracks)
|
||||
|
||||
return mixtracks
|
||||
|
||||
@classmethod
|
||||
def get_track_mix(cls, mix: Mix):
|
||||
"""
|
||||
Given a mix, returns the excess tracks as a custom mix.
|
||||
"""
|
||||
|
||||
# INFO: If the mix can't have more than 20 tracks, return None
|
||||
if len(mix.tracks) <= cls.MIX_TRACKS_LENGTH + 20:
|
||||
return None
|
||||
|
||||
og_track = TrackStore.trackhashmap.get(mix.tracks[0])
|
||||
|
||||
if not og_track:
|
||||
return None
|
||||
|
||||
og_track = og_track.get_best()
|
||||
tracks = [og_track] + TrackStore.get_tracks_by_trackhashes(
|
||||
mix.tracks[cls.MIX_TRACKS_LENGTH :]
|
||||
)
|
||||
|
||||
trackmix = Mix(
|
||||
id=f"t{mix.userid}{mix.extra['artisthash']}",
|
||||
title=og_track.title,
|
||||
description=cls.get_mix_description(tracks, mix.extra["artisthash"]),
|
||||
tracks=[t.trackhash for t in tracks],
|
||||
sourcehash=create_hash(*[t.trackhash for t in tracks]),
|
||||
userid=mix.userid,
|
||||
extra={
|
||||
"type": "track",
|
||||
"og_sourcehash": mix.sourcehash,
|
||||
"images": cls.get_custom_mix_images(tracks),
|
||||
"artists": None,
|
||||
"albums": None,
|
||||
},
|
||||
)
|
||||
trackmix.timestamp = mix.timestamp
|
||||
|
||||
# INFO: Write track mix save state
|
||||
if mix.extra.get("trackmix_saved"):
|
||||
trackmix.saved = True
|
||||
|
||||
return trackmix
|
||||
|
||||
@classmethod
|
||||
def get_custom_mix_images(cls, tracks: list[Track]):
|
||||
first_album = tracks[0].albumhash
|
||||
first_img = {
|
||||
"image": first_album + ".webp",
|
||||
"type": "album",
|
||||
"color": AlbumStore.albummap[first_album].album.color,
|
||||
}
|
||||
|
||||
seen = set()
|
||||
images = [first_img]
|
||||
|
||||
for track in tracks[1:]:
|
||||
artisthash = track.artists[0]["artisthash"]
|
||||
|
||||
if artisthash in seen:
|
||||
continue
|
||||
|
||||
artist = ArtistStore.artistmap.get(artisthash)
|
||||
|
||||
if not artist:
|
||||
continue
|
||||
|
||||
seen.add(artisthash)
|
||||
|
||||
image = {
|
||||
"image": artisthash + ".webp",
|
||||
"type": "artist",
|
||||
"color": artist.artist.color,
|
||||
}
|
||||
|
||||
images.append(image)
|
||||
|
||||
if len(images) == 3:
|
||||
break
|
||||
|
||||
return images
|
||||
|
||||
@staticmethod
|
||||
def get_because_items(mixes: list[Mix]):
|
||||
"""
|
||||
Given a list of mixes, returns a list of artists that are similar to the
|
||||
artists in the mixes.
|
||||
"""
|
||||
artists: dict[str, list[dict[str, str | int]]] = {}
|
||||
albums: dict[str, list[dict[str, str | int]]] = {}
|
||||
|
||||
pivot_artist = None
|
||||
pivot_artist_index = None
|
||||
|
||||
# Get pivot artist
|
||||
for index, mix in enumerate(mixes):
|
||||
artist = ArtistStore.artistmap.get(mix.extra["artisthash"])
|
||||
if not artist:
|
||||
continue
|
||||
|
||||
pivot_artist = artist.artist
|
||||
pivot_artist_index = index
|
||||
break
|
||||
|
||||
if not pivot_artist:
|
||||
return None, None
|
||||
|
||||
for mix in mixes[pivot_artist_index:]:
|
||||
mix_artisthash = mix.extra["artisthash"]
|
||||
artists.setdefault(mix_artisthash, [])
|
||||
albums.setdefault(mix_artisthash, [])
|
||||
|
||||
for artisthash in mix.extra["artists"]:
|
||||
artist = ArtistStore.artistmap.get(artisthash)
|
||||
|
||||
if not artist:
|
||||
continue
|
||||
|
||||
artists[mix_artisthash].append(
|
||||
{
|
||||
"type": "artist",
|
||||
"trackcount": artist.artist.trackcount,
|
||||
"hash": artisthash,
|
||||
"help_text": str(artist.artist.trackcount)
|
||||
+ ngettext(" track", " tracks", artist.artist.trackcount),
|
||||
}
|
||||
)
|
||||
|
||||
for albumhash in mix.extra["albums"]:
|
||||
album = AlbumStore.albummap.get(albumhash)
|
||||
|
||||
if not album:
|
||||
continue
|
||||
|
||||
albums[mix_artisthash].append(
|
||||
{
|
||||
"type": "album",
|
||||
"trackcount": album.album.trackcount,
|
||||
"hash": albumhash,
|
||||
"help_text": str(album.album.trackcount)
|
||||
+ ngettext(" track", " tracks", album.album.trackcount),
|
||||
}
|
||||
)
|
||||
|
||||
# INFO: Sort artists by trackcount
|
||||
artists[mix_artisthash] = sorted(
|
||||
artists[mix_artisthash],
|
||||
key=lambda x: x["trackcount"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# INFO: Sort albums by trackcount
|
||||
albums[mix_artisthash] = sorted(
|
||||
albums[mix_artisthash],
|
||||
key=lambda x: x["trackcount"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
because_you_listened_to_artist = {
|
||||
"title": "Because you listened to " + pivot_artist.name,
|
||||
"items": albums[pivot_artist.artisthash][:15],
|
||||
}
|
||||
|
||||
# Flatten list of artists and remove duplicates by artisthash
|
||||
|
||||
# for artist_list in artists.values():
|
||||
# for artist in artist_list:
|
||||
# if artist["hash"] not in seen:
|
||||
# all_artists.append(artist)
|
||||
# seen.add(artist["hash"])
|
||||
|
||||
artists_you_might_like = {
|
||||
"title": "Artists you might like",
|
||||
"items": artists[pivot_artist.artisthash][:15],
|
||||
}
|
||||
|
||||
return because_you_listened_to_artist, artists_you_might_like
|
||||
@@ -0,0 +1,23 @@
|
||||
import contextlib
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from swingmusic.db.userdata import PluginTable
|
||||
|
||||
|
||||
def register_plugins():
|
||||
with contextlib.suppress(IntegrityError):
|
||||
PluginTable.insert_one(
|
||||
{
|
||||
"name": "lyrics_finder",
|
||||
"active": True,
|
||||
"settings": {
|
||||
"auto_download": True,
|
||||
"overide_unsynced": True,
|
||||
"provider_order": ["lrclib", "musixmatch"],
|
||||
},
|
||||
"extra": {
|
||||
"description": "Find lyrics from the internet",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
All network requests are defined here.
|
||||
"""
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Requests related to artists
|
||||
"""
|
||||
|
||||
import urllib.parse
|
||||
|
||||
import requests
|
||||
from requests import ConnectionError, HTTPError, ReadTimeout
|
||||
|
||||
from swingmusic.models.lastfm import SimilarArtistEntry
|
||||
from swingmusic.utils.hashing import create_hash
|
||||
|
||||
|
||||
def fetch_similar_artists(name: str):
|
||||
"""
|
||||
Fetches similar artists from Last.fm
|
||||
"""
|
||||
url = f"https://kerve.last.fm/kerve/similarartists?artist={urllib.parse.quote_plus(name, safe='')}&autocorrect=1&tracks=1&image_size=large&limit=250&format=json"
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
except (ConnectionError, ReadTimeout, HTTPError):
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
|
||||
try:
|
||||
artists = data["results"]["artist"]
|
||||
except KeyError:
|
||||
return []
|
||||
|
||||
return [
|
||||
SimilarArtistEntry(
|
||||
**{
|
||||
"artisthash": create_hash(artist["name"]),
|
||||
"name": artist["name"],
|
||||
"weight": artist["weight"],
|
||||
"listeners": int(artist["listeners"]),
|
||||
"scrobbles": int(artist["scrobbles"]),
|
||||
}
|
||||
)
|
||||
for artist in artists
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
from dataclasses import asdict
|
||||
|
||||
from swingmusic.models import Album
|
||||
|
||||
|
||||
def album_serializer(album: Album, to_remove: set[str]) -> dict:
|
||||
try:
|
||||
album_dict = asdict(album)
|
||||
except TypeError:
|
||||
return {}
|
||||
|
||||
to_remove.update(key for key in album_dict if key.startswith("is_"))
|
||||
for key in to_remove:
|
||||
album_dict.pop(key, None)
|
||||
|
||||
# remove artist images
|
||||
for artist in album_dict["albumartists"]:
|
||||
artist.pop("image", None)
|
||||
|
||||
album_dict["type"] = "album"
|
||||
return album_dict
|
||||
|
||||
|
||||
def serialize_for_card(album: Album):
|
||||
props_to_remove = {
|
||||
"duration",
|
||||
"count",
|
||||
"artisthashes",
|
||||
"albumartists_hashes",
|
||||
"created_date",
|
||||
"og_title",
|
||||
"base_title",
|
||||
"genres",
|
||||
"playcount",
|
||||
"trackcount",
|
||||
"type",
|
||||
"playduration",
|
||||
"genrehashes",
|
||||
"fav_userids",
|
||||
"extra",
|
||||
"id",
|
||||
"lastplayed",
|
||||
"weakhash",
|
||||
}
|
||||
|
||||
return album_serializer(album, props_to_remove)
|
||||
|
||||
|
||||
def serialize_for_card_many(albums: list[Album]):
|
||||
return [serialize_for_card(a) for a in albums]
|
||||
@@ -0,0 +1,43 @@
|
||||
from dataclasses import asdict
|
||||
|
||||
from swingmusic.models.artist import Artist
|
||||
|
||||
|
||||
def serialize_for_card(artist: Artist, include: set[str] = None):
|
||||
if include is None:
|
||||
include = set()
|
||||
try:
|
||||
artist_dict = asdict(artist)
|
||||
except TypeError:
|
||||
return {}
|
||||
|
||||
props_to_remove = {
|
||||
"is_favorite",
|
||||
"trackcount",
|
||||
"duration",
|
||||
"albumcount",
|
||||
"playcount",
|
||||
"playduration",
|
||||
"lastplayed",
|
||||
"id",
|
||||
"genres",
|
||||
"genrehashes",
|
||||
"extra",
|
||||
"created_date",
|
||||
"date",
|
||||
"fav_userids",
|
||||
"_score",
|
||||
}
|
||||
|
||||
if include:
|
||||
props_to_remove = props_to_remove - include
|
||||
|
||||
for key in props_to_remove:
|
||||
artist_dict.pop(key, None)
|
||||
|
||||
artist_dict["type"] = "artist"
|
||||
return artist_dict
|
||||
|
||||
|
||||
def serialize_for_cards(artists: list[Artist]):
|
||||
return [serialize_for_card(a) for a in artists]
|
||||
@@ -0,0 +1,16 @@
|
||||
from dataclasses import asdict
|
||||
|
||||
from swingmusic.models.playlist import Playlist
|
||||
|
||||
|
||||
def serialize_for_card(playlist: Playlist, to_remove=None):
|
||||
if to_remove is None:
|
||||
to_remove = set()
|
||||
p_dict = asdict(playlist)
|
||||
|
||||
props = {"trackhashes"}.union(to_remove)
|
||||
|
||||
for key in props:
|
||||
p_dict.pop(key, None)
|
||||
|
||||
return p_dict
|
||||
@@ -0,0 +1,71 @@
|
||||
from dataclasses import asdict
|
||||
|
||||
from swingmusic.models.track import Track
|
||||
|
||||
|
||||
def serialize_track(
|
||||
track: Track, to_remove: set = None, remove_disc: bool = True
|
||||
) -> dict:
|
||||
"""
|
||||
Convert `Track` to dict
|
||||
|
||||
:params track: Track to convert
|
||||
:params to_remove: custom tags which should also be removed from Track.
|
||||
:params remove_disc:
|
||||
"""
|
||||
if to_remove is None:
|
||||
to_remove = set()
|
||||
album_dict = asdict(track)
|
||||
album_keys = (key for key in album_dict if key.startswith(("is_", "_")))
|
||||
# add all keys from album_dict starting with "is_" or "_"
|
||||
|
||||
props = {
|
||||
"date",
|
||||
"genre",
|
||||
"last_mod",
|
||||
"og_title",
|
||||
"og_album",
|
||||
"copyright",
|
||||
"config",
|
||||
"artist_hashes",
|
||||
"created_date",
|
||||
"fav_userids",
|
||||
"playcount",
|
||||
"genrehashes",
|
||||
"id",
|
||||
"lastplayed",
|
||||
"playduration",
|
||||
"genres",
|
||||
*to_remove,
|
||||
*album_keys,
|
||||
}
|
||||
|
||||
if remove_disc:
|
||||
props.add("disc")
|
||||
props.add("track")
|
||||
|
||||
for key in props:
|
||||
album_dict.pop(key, None)
|
||||
|
||||
# remove images
|
||||
for key in ["artists", "albumartists"]:
|
||||
for artist in album_dict[key]:
|
||||
artist.pop("image", None)
|
||||
|
||||
# is_favorite @property is not included in `asdict`
|
||||
album_dict["is_favorite"] = track.is_favorite
|
||||
|
||||
return album_dict
|
||||
|
||||
|
||||
def serialize_tracks(
|
||||
tracks: list[Track], _remove: set = None, remove_disc=True
|
||||
) -> list[dict]:
|
||||
"""
|
||||
wrapper for iterable type with Tracks.
|
||||
convert Tracks to dict and return as list[dict]
|
||||
"""
|
||||
|
||||
if _remove is None:
|
||||
_remove = set()
|
||||
return [serialize_track(t, _remove, remove_disc) for t in tracks]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user