635 lines
23 KiB
Python
Executable File
635 lines
23 KiB
Python
Executable File
from fastapi import APIRouter, HTTPException, Request, Depends
|
|
from fastapi.responses import JSONResponse
|
|
import traceback
|
|
import logging # Added logging import
|
|
import uuid # For generating error task IDs
|
|
import time # For timestamps
|
|
from routes.utils.celery_queue_manager import download_queue_manager
|
|
from routes.utils.celery_tasks import (
|
|
store_task_info,
|
|
store_task_status,
|
|
ProgressState,
|
|
) # For error task creation
|
|
import threading # For playlist watch trigger
|
|
|
|
# Imports from playlist_watch.py
|
|
from routes.utils.watch.db import (
|
|
add_playlist_to_watch as add_playlist_db,
|
|
remove_playlist_from_watch as remove_playlist_db,
|
|
get_watched_playlist,
|
|
get_watched_playlists,
|
|
add_specific_tracks_to_playlist_table,
|
|
remove_specific_tracks_from_playlist_table, # Added import
|
|
)
|
|
from routes.utils.get_info import get_client, get_playlist, get_track
|
|
from routes.utils.watch.manager import (
|
|
check_watched_playlists,
|
|
get_watch_config,
|
|
) # For manual trigger & config
|
|
from routes.utils.errors import DuplicateDownloadError
|
|
|
|
# Import authentication dependencies
|
|
from routes.auth.middleware import require_auth_from_state, User
|
|
from routes.utils.celery_config import get_config_params
|
|
from routes.utils.credentials import get_spotify_blob_path
|
|
|
|
logger = logging.getLogger(__name__) # Added logger initialization
|
|
router = APIRouter()
|
|
|
|
|
|
def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
|
|
"""Construct a Spotify URL for a given item ID and type."""
|
|
return f"https://open.spotify.com/{item_type}/{item_id}"
|
|
|
|
|
|
@router.get("/download/{playlist_id}")
|
|
async def handle_download(
|
|
playlist_id: str,
|
|
request: Request,
|
|
current_user: User = Depends(require_auth_from_state),
|
|
):
|
|
# Retrieve essential parameters from the request.
|
|
# name = request.args.get('name') # Removed
|
|
# artist = request.args.get('artist') # Removed
|
|
orig_params = dict(request.query_params)
|
|
|
|
# Construct the URL from playlist_id
|
|
url = construct_spotify_url(playlist_id, "playlist")
|
|
orig_params["original_url"] = str(
|
|
request.url
|
|
) # Update original_url to the constructed one
|
|
|
|
# Fetch metadata from Spotify using optimized function
|
|
try:
|
|
from routes.utils.get_info import get_playlist_metadata
|
|
|
|
playlist_info = get_playlist_metadata(playlist_id)
|
|
if (
|
|
not playlist_info
|
|
or not playlist_info.get("name")
|
|
or not playlist_info.get("owner")
|
|
):
|
|
return JSONResponse(
|
|
content={
|
|
"error": f"Could not retrieve metadata for playlist ID: {playlist_id}"
|
|
},
|
|
status_code=404,
|
|
)
|
|
|
|
name_from_spotify = playlist_info.get("name")
|
|
# Use owner's display_name as the 'artist' for playlists
|
|
owner_info = playlist_info.get("owner", {})
|
|
artist_from_spotify = owner_info.get("display_name", "Unknown Owner")
|
|
|
|
except Exception as e:
|
|
return JSONResponse(
|
|
content={
|
|
"error": f"Failed to fetch metadata for playlist {playlist_id}: {str(e)}"
|
|
},
|
|
status_code=500,
|
|
)
|
|
|
|
# Validate required parameters
|
|
if not url: # This check might be redundant now but kept for safety
|
|
return JSONResponse(
|
|
content={"error": "Missing required parameter: url"}, status_code=400
|
|
)
|
|
|
|
try:
|
|
task_id = download_queue_manager.add_task(
|
|
{
|
|
"download_type": "playlist",
|
|
"url": url,
|
|
"name": name_from_spotify, # Use fetched name
|
|
"artist": artist_from_spotify, # Use fetched owner name as artist
|
|
"username": current_user.username,
|
|
"orig_request": orig_params,
|
|
}
|
|
)
|
|
except DuplicateDownloadError as e:
|
|
return JSONResponse(
|
|
content={
|
|
"error": "Duplicate download detected.",
|
|
"existing_task": e.existing_task,
|
|
},
|
|
status_code=409,
|
|
)
|
|
except Exception as e:
|
|
# Generic error handling for other issues during task submission
|
|
error_task_id = str(uuid.uuid4())
|
|
store_task_info(
|
|
error_task_id,
|
|
{
|
|
"download_type": "playlist",
|
|
"url": url,
|
|
"name": name_from_spotify, # Use fetched name
|
|
"artist": artist_from_spotify, # Use fetched owner name as artist
|
|
"original_request": orig_params,
|
|
"created_at": time.time(),
|
|
"is_submission_error_task": True,
|
|
},
|
|
)
|
|
store_task_status(
|
|
error_task_id,
|
|
{
|
|
"status": ProgressState.ERROR,
|
|
"error": f"Failed to queue playlist download: {str(e)}",
|
|
"timestamp": time.time(),
|
|
},
|
|
)
|
|
return JSONResponse(
|
|
content={
|
|
"error": f"Failed to queue playlist download: {str(e)}",
|
|
"task_id": error_task_id,
|
|
},
|
|
status_code=500,
|
|
)
|
|
|
|
return JSONResponse(content={"task_id": task_id}, status_code=202)
|
|
|
|
|
|
@router.get("/download/cancel")
|
|
async def cancel_download(
|
|
request: Request, current_user: User = Depends(require_auth_from_state)
|
|
):
|
|
"""
|
|
Cancel a running playlist download process by its task id.
|
|
"""
|
|
task_id = request.query_params.get("task_id")
|
|
if not task_id:
|
|
return JSONResponse(
|
|
content={"error": "Missing task id (task_id) parameter"}, status_code=400
|
|
)
|
|
|
|
# Use the queue manager's cancellation method.
|
|
result = download_queue_manager.cancel_task(task_id)
|
|
status_code = 200 if result.get("status") == "cancelled" else 404
|
|
|
|
return JSONResponse(content=result, status_code=status_code)
|
|
|
|
|
|
@router.get("/info")
|
|
async def get_playlist_info(
|
|
request: Request, current_user: User = Depends(require_auth_from_state)
|
|
):
|
|
"""
|
|
Retrieve Spotify playlist metadata given a Spotify playlist ID.
|
|
Expects a query parameter 'id' that contains the Spotify playlist ID.
|
|
Always returns the raw JSON from get_playlist with expand_items=False.
|
|
"""
|
|
spotify_id = request.query_params.get("id")
|
|
|
|
if not spotify_id:
|
|
return JSONResponse(content={"error": "Missing parameter: id"}, status_code=400)
|
|
|
|
try:
|
|
# Resolve active account's credentials blob
|
|
cfg = get_config_params() or {}
|
|
active_account = cfg.get("spotify")
|
|
if not active_account:
|
|
return JSONResponse(
|
|
content={"error": "Active Spotify account not set in configuration."},
|
|
status_code=500,
|
|
)
|
|
blob_path = get_spotify_blob_path(active_account)
|
|
if not blob_path.exists():
|
|
return JSONResponse(
|
|
content={
|
|
"error": f"Spotify credentials blob not found for account '{active_account}'"
|
|
},
|
|
status_code=500,
|
|
)
|
|
|
|
client = get_client()
|
|
try:
|
|
playlist_info = get_playlist(client, spotify_id, expand_items=False)
|
|
finally:
|
|
pass
|
|
# Ensure id field is present (librespot sometimes omits it)
|
|
if playlist_info and "id" not in playlist_info:
|
|
playlist_info["id"] = spotify_id
|
|
|
|
return JSONResponse(content=playlist_info, status_code=200)
|
|
except Exception as e:
|
|
error_data = {"error": str(e), "traceback": traceback.format_exc()}
|
|
return JSONResponse(content=error_data, status_code=500)
|
|
|
|
|
|
@router.put("/watch/{playlist_spotify_id}")
|
|
async def add_to_watchlist(
|
|
playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)
|
|
):
|
|
"""Adds a playlist to the watchlist."""
|
|
watch_config = get_watch_config()
|
|
if not watch_config.get("enabled", False):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail={"error": "Watch feature is currently disabled globally."},
|
|
)
|
|
|
|
logger.info(f"Attempting to add playlist {playlist_spotify_id} to watchlist.")
|
|
try:
|
|
# Check if already watched
|
|
if get_watched_playlist(playlist_spotify_id):
|
|
return {
|
|
"message": f"Playlist {playlist_spotify_id} is already being watched."
|
|
}
|
|
|
|
# Fetch playlist details from Spotify to populate our DB (metadata only)
|
|
# Use shared helper and add a safe fallback for missing 'id'
|
|
try:
|
|
from routes.utils.get_info import get_playlist_metadata
|
|
|
|
playlist_data = get_playlist_metadata(playlist_spotify_id) or {}
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to fetch playlist metadata for {playlist_spotify_id}: {e}",
|
|
exc_info=True,
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={
|
|
"error": f"Failed to fetch metadata for playlist {playlist_spotify_id}: {str(e)}"
|
|
},
|
|
)
|
|
|
|
# Some Librespot responses may omit 'id' even when the payload is valid.
|
|
# Fall back to the path parameter to avoid false negatives.
|
|
if playlist_data and "id" not in playlist_data:
|
|
logger.warning(
|
|
f"Playlist metadata for {playlist_spotify_id} missing 'id'. Injecting from path param. Keys: {list(playlist_data.keys())}"
|
|
)
|
|
try:
|
|
playlist_data["id"] = playlist_spotify_id
|
|
except Exception:
|
|
pass
|
|
|
|
# Validate minimal fields needed downstream and normalize shape to be resilient to client changes
|
|
if not playlist_data or not playlist_data.get("name"):
|
|
logger.error(
|
|
f"Insufficient playlist metadata for {playlist_spotify_id}. Keys present: {list(playlist_data.keys()) if isinstance(playlist_data, dict) else type(playlist_data)}"
|
|
)
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail={
|
|
"error": f"Could not fetch sufficient details for playlist {playlist_spotify_id} from Spotify."
|
|
},
|
|
)
|
|
|
|
# Ensure 'owner' is a dict with at least id/display_name to satisfy DB layer
|
|
owner = playlist_data.get("owner")
|
|
if not isinstance(owner, dict):
|
|
owner = {}
|
|
if "id" not in owner or not owner.get("id"):
|
|
owner["id"] = "unknown_owner"
|
|
if "display_name" not in owner or not owner.get("display_name"):
|
|
owner["display_name"] = owner.get("id", "Unknown Owner")
|
|
playlist_data["owner"] = owner
|
|
|
|
# Ensure 'tracks' is a dict with a numeric 'total'
|
|
tracks = playlist_data.get("tracks")
|
|
if not isinstance(tracks, dict):
|
|
tracks = {}
|
|
total = tracks.get("total")
|
|
if not isinstance(total, int):
|
|
items = tracks.get("items")
|
|
if isinstance(items, list):
|
|
total = len(items)
|
|
else:
|
|
total = 0
|
|
tracks["total"] = total
|
|
playlist_data["tracks"] = tracks
|
|
|
|
add_playlist_db(playlist_data) # This also creates the tracks table
|
|
|
|
logger.info(
|
|
f"Playlist {playlist_spotify_id} added to watchlist. Its tracks will be processed by the watch manager."
|
|
)
|
|
return {
|
|
"message": f"Playlist {playlist_spotify_id} added to watchlist. Tracks will be processed shortly."
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error adding playlist {playlist_spotify_id} to watchlist: {e}",
|
|
exc_info=True,
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": f"Could not add playlist to watchlist: {str(e)}"},
|
|
)
|
|
|
|
|
|
@router.get("/watch/{playlist_spotify_id}/status")
|
|
async def get_playlist_watch_status(
|
|
playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)
|
|
):
|
|
"""Checks if a specific playlist is being watched."""
|
|
logger.info(f"Checking watch status for playlist {playlist_spotify_id}.")
|
|
try:
|
|
playlist = get_watched_playlist(playlist_spotify_id)
|
|
if playlist:
|
|
return {"is_watched": True, "playlist_data": playlist}
|
|
else:
|
|
# Return 200 with is_watched: false, so frontend can clearly distinguish
|
|
# between "not watched" and an actual error fetching status.
|
|
return {"is_watched": False}
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error checking watch status for playlist {playlist_spotify_id}: {e}",
|
|
exc_info=True,
|
|
)
|
|
raise HTTPException(
|
|
status_code=500, detail={"error": f"Could not check watch status: {str(e)}"}
|
|
)
|
|
|
|
|
|
@router.delete("/watch/{playlist_spotify_id}")
|
|
async def remove_from_watchlist(
|
|
playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)
|
|
):
|
|
"""Removes a playlist from the watchlist."""
|
|
watch_config = get_watch_config()
|
|
if not watch_config.get("enabled", False):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail={"error": "Watch feature is currently disabled globally."},
|
|
)
|
|
|
|
logger.info(f"Attempting to remove playlist {playlist_spotify_id} from watchlist.")
|
|
try:
|
|
if not get_watched_playlist(playlist_spotify_id):
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail={
|
|
"error": f"Playlist {playlist_spotify_id} not found in watchlist."
|
|
},
|
|
)
|
|
|
|
remove_playlist_db(playlist_spotify_id)
|
|
logger.info(
|
|
f"Playlist {playlist_spotify_id} removed from watchlist successfully."
|
|
)
|
|
return {"message": f"Playlist {playlist_spotify_id} removed from watchlist."}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error removing playlist {playlist_spotify_id} from watchlist: {e}",
|
|
exc_info=True,
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": f"Could not remove playlist from watchlist: {str(e)}"},
|
|
)
|
|
|
|
|
|
@router.post("/watch/{playlist_spotify_id}/tracks")
|
|
async def mark_tracks_as_known(
|
|
playlist_spotify_id: str,
|
|
request: Request,
|
|
current_user: User = Depends(require_auth_from_state),
|
|
):
|
|
"""Fetches details for given track IDs and adds/updates them in the playlist's local DB table."""
|
|
watch_config = get_watch_config()
|
|
if not watch_config.get("enabled", False):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail={
|
|
"error": "Watch feature is currently disabled globally. Cannot mark tracks."
|
|
},
|
|
)
|
|
|
|
logger.info(
|
|
f"Attempting to mark tracks as known for playlist {playlist_spotify_id}."
|
|
)
|
|
try:
|
|
track_ids = await request.json()
|
|
if not isinstance(track_ids, list) or not all(
|
|
isinstance(tid, str) for tid in track_ids
|
|
):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"error": "Invalid request body. Expecting a JSON array of track Spotify IDs."
|
|
},
|
|
)
|
|
|
|
if not get_watched_playlist(playlist_spotify_id):
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail={
|
|
"error": f"Playlist {playlist_spotify_id} is not being watched."
|
|
},
|
|
)
|
|
|
|
fetched_tracks_details = []
|
|
client = get_client()
|
|
for track_id in track_ids:
|
|
try:
|
|
track_detail = get_track(client, track_id)
|
|
if track_detail and track_detail.get("id"):
|
|
fetched_tracks_details.append(track_detail)
|
|
else:
|
|
logger.warning(
|
|
f"Could not fetch details for track {track_id} when marking as known for playlist {playlist_spotify_id}."
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to fetch Spotify details for track {track_id}: {e}"
|
|
)
|
|
|
|
if not fetched_tracks_details:
|
|
return {
|
|
"message": "No valid track details could be fetched to mark as known.",
|
|
"processed_count": 0,
|
|
}
|
|
|
|
add_specific_tracks_to_playlist_table(
|
|
playlist_spotify_id, fetched_tracks_details
|
|
)
|
|
logger.info(
|
|
f"Successfully marked/updated {len(fetched_tracks_details)} tracks as known for playlist {playlist_spotify_id}."
|
|
)
|
|
return {
|
|
"message": f"Successfully processed {len(fetched_tracks_details)} tracks for playlist {playlist_spotify_id}."
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error marking tracks as known for playlist {playlist_spotify_id}: {e}",
|
|
exc_info=True,
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": f"Could not mark tracks as known: {str(e)}"},
|
|
)
|
|
|
|
|
|
@router.delete("/watch/{playlist_spotify_id}/tracks")
|
|
async def mark_tracks_as_missing_locally(
|
|
playlist_spotify_id: str,
|
|
request: Request,
|
|
current_user: User = Depends(require_auth_from_state),
|
|
):
|
|
"""Removes specified tracks from the playlist's local DB table."""
|
|
watch_config = get_watch_config()
|
|
if not watch_config.get("enabled", False):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail={
|
|
"error": "Watch feature is currently disabled globally. Cannot mark tracks."
|
|
},
|
|
)
|
|
|
|
logger.info(
|
|
f"Attempting to mark tracks as missing (remove locally) for playlist {playlist_spotify_id}."
|
|
)
|
|
try:
|
|
track_ids = await request.json()
|
|
if not isinstance(track_ids, list) or not all(
|
|
isinstance(tid, str) for tid in track_ids
|
|
):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"error": "Invalid request body. Expecting a JSON array of track Spotify IDs."
|
|
},
|
|
)
|
|
|
|
if not get_watched_playlist(playlist_spotify_id):
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail={
|
|
"error": f"Playlist {playlist_spotify_id} is not being watched."
|
|
},
|
|
)
|
|
|
|
deleted_count = remove_specific_tracks_from_playlist_table(
|
|
playlist_spotify_id, track_ids
|
|
)
|
|
logger.info(
|
|
f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}."
|
|
)
|
|
return {
|
|
"message": f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}."
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error marking tracks as missing (deleting locally) for playlist {playlist_spotify_id}: {e}",
|
|
exc_info=True,
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": f"Could not mark tracks as missing: {str(e)}"},
|
|
)
|
|
|
|
|
|
@router.get("/watch/list")
|
|
async def list_watched_playlists_endpoint(
|
|
current_user: User = Depends(require_auth_from_state),
|
|
):
|
|
"""Lists all playlists currently in the watchlist."""
|
|
try:
|
|
playlists = get_watched_playlists()
|
|
return playlists
|
|
except Exception as e:
|
|
logger.error(f"Error listing watched playlists: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": f"Could not list watched playlists: {str(e)}"},
|
|
)
|
|
|
|
|
|
@router.post("/watch/trigger_check")
|
|
async def trigger_playlist_check_endpoint(
|
|
current_user: User = Depends(require_auth_from_state),
|
|
):
|
|
"""Manually triggers the playlist checking mechanism for all watched playlists."""
|
|
watch_config = get_watch_config()
|
|
if not watch_config.get("enabled", False):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail={
|
|
"error": "Watch feature is currently disabled globally. Cannot trigger check."
|
|
},
|
|
)
|
|
|
|
logger.info("Manual trigger for playlist check received for all playlists.")
|
|
try:
|
|
# Run check_watched_playlists without an ID to check all
|
|
thread = threading.Thread(target=check_watched_playlists, args=(None,))
|
|
thread.start()
|
|
return {
|
|
"message": "Playlist check triggered successfully in the background for all playlists."
|
|
}
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error manually triggering playlist check for all: {e}", exc_info=True
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": f"Could not trigger playlist check for all: {str(e)}"},
|
|
)
|
|
|
|
|
|
@router.post("/watch/trigger_check/{playlist_spotify_id}")
|
|
async def trigger_specific_playlist_check_endpoint(
|
|
playlist_spotify_id: str, current_user: User = Depends(require_auth_from_state)
|
|
):
|
|
"""Manually triggers the playlist checking mechanism for a specific playlist."""
|
|
watch_config = get_watch_config()
|
|
if not watch_config.get("enabled", False):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail={
|
|
"error": "Watch feature is currently disabled globally. Cannot trigger check."
|
|
},
|
|
)
|
|
|
|
logger.info(
|
|
f"Manual trigger for specific playlist check received for ID: {playlist_spotify_id}"
|
|
)
|
|
try:
|
|
# Check if the playlist is actually in the watchlist first
|
|
watched_playlist = get_watched_playlist(playlist_spotify_id)
|
|
if not watched_playlist:
|
|
logger.warning(
|
|
f"Trigger specific check: Playlist ID {playlist_spotify_id} not found in watchlist."
|
|
)
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail={
|
|
"error": f"Playlist {playlist_spotify_id} is not in the watchlist. Add it first."
|
|
},
|
|
)
|
|
|
|
# Run check_watched_playlists with the specific ID
|
|
thread = threading.Thread(
|
|
target=check_watched_playlists, args=(playlist_spotify_id,)
|
|
)
|
|
thread.start()
|
|
logger.info(
|
|
f"Playlist check triggered in background for specific playlist ID: {playlist_spotify_id}"
|
|
)
|
|
return {
|
|
"message": f"Playlist check triggered successfully in the background for {playlist_spotify_id}."
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error manually triggering specific playlist check for {playlist_spotify_id}: {e}",
|
|
exc_info=True,
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={
|
|
"error": f"Could not trigger playlist check for {playlist_spotify_id}: {str(e)}"
|
|
},
|
|
)
|