feat: Add real_time_multiplier to backend

This commit is contained in:
Xoconoch
2025-08-19 21:26:14 -06:00
parent 93f8a019cc
commit cf6d367915
10 changed files with 191 additions and 39 deletions

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi import APIRouter, Request, Depends
from fastapi.responses import JSONResponse
import json
import traceback
import uuid
import time
@@ -21,7 +20,11 @@ def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
@router.get("/download/{album_id}")
async def handle_download(album_id: str, request: Request, current_user: User = Depends(require_auth_from_state)):
async def handle_download(
album_id: str,
request: Request,
current_user: User = Depends(require_auth_from_state),
):
# Retrieve essential parameters from the request.
# name = request.args.get('name')
# artist = request.args.get('artist')
@@ -38,8 +41,10 @@ async def handle_download(album_id: str, request: Request, current_user: User =
or not album_info.get("artists")
):
return JSONResponse(
content={"error": f"Could not retrieve metadata for album ID: {album_id}"},
status_code=404
content={
"error": f"Could not retrieve metadata for album ID: {album_id}"
},
status_code=404,
)
name_from_spotify = album_info.get("name")
@@ -51,15 +56,16 @@ async def handle_download(album_id: str, request: Request, current_user: User =
except Exception as e:
return JSONResponse(
content={"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"},
status_code=500
content={
"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"
},
status_code=500,
)
# Validate required parameters
if not url:
return JSONResponse(
content={"error": "Missing required parameter: url"},
status_code=400
content={"error": "Missing required parameter: url"}, status_code=400
)
# Add the task to the queue with only essential parameters
@@ -84,7 +90,7 @@ async def handle_download(album_id: str, request: Request, current_user: User =
"error": "Duplicate download detected.",
"existing_task": e.existing_task,
},
status_code=409
status_code=409,
)
except Exception as e:
# Generic error handling for other issues during task submission
@@ -116,25 +122,23 @@ async def handle_download(album_id: str, request: Request, current_user: User =
"error": f"Failed to queue album download: {str(e)}",
"task_id": error_task_id,
},
status_code=500
status_code=500,
)
return JSONResponse(
content={"task_id": task_id},
status_code=202
)
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)):
async def cancel_download(
request: Request, current_user: User = Depends(require_auth_from_state)
):
"""
Cancel a running download process by its task id.
"""
task_id = request.query_params.get("task_id")
if not task_id:
return JSONResponse(
content={"error": "Missing process id (task_id) parameter"},
status_code=400
content={"error": "Missing process id (task_id) parameter"}, status_code=400
)
# Use the queue manager's cancellation method.
@@ -145,7 +149,9 @@ async def cancel_download(request: Request, current_user: User = Depends(require
@router.get("/info")
async def get_album_info(request: Request, current_user: User = Depends(require_auth_from_state)):
async def get_album_info(
request: Request, current_user: User = Depends(require_auth_from_state)
):
"""
Retrieve Spotify album metadata given a Spotify album ID.
Expects a query parameter 'id' that contains the Spotify album ID.
@@ -153,15 +159,30 @@ async def get_album_info(request: Request, current_user: User = Depends(require_
spotify_id = request.query_params.get("id")
if not spotify_id:
return JSONResponse(
content={"error": "Missing parameter: id"},
status_code=400
)
return JSONResponse(content={"error": "Missing parameter: id"}, status_code=400)
try:
# Use the get_spotify_info function (already imported at top)
# Optional pagination params for tracks
limit_param = request.query_params.get("limit")
offset_param = request.query_params.get("offset")
limit = int(limit_param) if limit_param is not None else None
offset = int(offset_param) if offset_param is not None else None
# Fetch album metadata
album_info = get_spotify_info(spotify_id, "album")
# Fetch album tracks with pagination
album_tracks = get_spotify_info(
spotify_id, "album_tracks", limit=limit, offset=offset
)
# Merge tracks into album payload in the same shape Spotify returns on album
album_info["tracks"] = album_tracks
return JSONResponse(content=album_info, status_code=200)
except ValueError as ve:
return JSONResponse(
content={"error": f"Invalid limit/offset: {str(ve)}"}, status_code=400
)
except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()}
return JSONResponse(content=error_data, status_code=500)

View File

@@ -72,6 +72,28 @@ def validate_config(config_data: dict, watch_config: dict = None) -> tuple[bool,
if watch_config is None:
watch_config = get_watch_config_http()
# Ensure realTimeMultiplier is a valid integer in range 0..10 if provided
if "realTimeMultiplier" in config_data or "real_time_multiplier" in config_data:
key = (
"realTimeMultiplier"
if "realTimeMultiplier" in config_data
else "real_time_multiplier"
)
val = config_data.get(key)
if isinstance(val, bool):
return False, "realTimeMultiplier must be an integer between 0 and 10."
try:
ival = int(val)
except Exception:
return False, "realTimeMultiplier must be an integer between 0 and 10."
if ival < 0 or ival > 10:
return False, "realTimeMultiplier must be between 0 and 10."
# Normalize to camelCase in the working dict so save_config writes it
if key == "real_time_multiplier":
config_data["realTimeMultiplier"] = ival
else:
config_data["realTimeMultiplier"] = ival
# Check if fallback is enabled but missing required accounts
if config_data.get("fallback", False):
has_spotify = has_credentials("spotify")
@@ -169,6 +191,7 @@ def _migrate_legacy_keys_inplace(cfg: dict) -> bool:
"artist_separator": "artistSeparator",
"recursive_quality": "recursiveQuality",
"spotify_metadata": "spotifyMetadata",
"real_time_multiplier": "realTimeMultiplier",
}
modified = False
for legacy, camel in legacy_map.items():

View File

@@ -31,6 +31,7 @@ def download_album(
recursive_quality=True,
spotify_metadata=True,
_is_celery_task_execution=False, # Added to skip duplicate check from Celery task
real_time_multiplier=None,
):
if not _is_celery_task_execution:
existing_task = get_existing_task_id(
@@ -173,6 +174,7 @@ def download_album(
convert_to=convert_to,
bitrate=bitrate,
artist_separator=artist_separator,
real_time_multiplier=real_time_multiplier,
)
print(
f"DEBUG: album.py - Spotify direct download (account: {main} for blob) successful."
@@ -228,6 +230,7 @@ def download_album(
convert_to=convert_to,
bitrate=bitrate,
artist_separator=artist_separator,
real_time_multiplier=real_time_multiplier,
)
print(
f"DEBUG: album.py - Direct Spotify download (account: {main} for blob) successful."

View File

@@ -49,6 +49,7 @@ DEFAULT_MAIN_CONFIG = {
"spotifyMetadata": True,
"separateTracksByUser": False,
"watch": {},
"realTimeMultiplier": 0,
}
@@ -63,6 +64,7 @@ def _migrate_legacy_keys(cfg: dict) -> tuple[dict, bool]:
"artist_separator": "artistSeparator",
"recursive_quality": "recursiveQuality",
"spotify_metadata": "spotifyMetadata",
"real_time_multiplier": "realTimeMultiplier",
}
for legacy, camel in legacy_map.items():
if legacy in out and camel not in out:

View File

@@ -72,6 +72,9 @@ def get_config_params():
),
"separateTracksByUser": config.get("separateTracksByUser", False),
"watch": config.get("watch", {}),
"realTimeMultiplier": config.get(
"realTimeMultiplier", config.get("real_time_multiplier", 0)
),
}
except Exception as e:
logger.error(f"Error reading config for parameters: {e}")
@@ -96,6 +99,7 @@ def get_config_params():
"recursiveQuality": False,
"separateTracksByUser": False,
"watch": {},
"realTimeMultiplier": 0,
}
@@ -389,9 +393,11 @@ class CeleryDownloadQueueManager:
original_request.get("real_time"), config_params["realTime"]
),
"custom_dir_format": self._get_user_specific_dir_format(
original_request.get("custom_dir_format", config_params["customDirFormat"]),
original_request.get(
"custom_dir_format", config_params["customDirFormat"]
),
config_params.get("separateTracksByUser", False),
username
username,
),
"custom_track_format": original_request.get(
"custom_track_format", config_params["customTrackFormat"]
@@ -419,6 +425,9 @@ class CeleryDownloadQueueManager:
"retry_count": 0,
"original_request": original_request,
"created_at": time.time(),
"real_time_multiplier": original_request.get(
"real_time_multiplier", config_params.get("realTimeMultiplier", 0)
),
}
# If from_watch_job is True, ensure track_details_for_db is passed through

View File

@@ -1626,6 +1626,9 @@ def download_track(self, **task_data):
spotify_metadata = task_data.get(
"spotify_metadata", config_params.get("spotifyMetadata", True)
)
real_time_multiplier = task_data.get(
"real_time_multiplier", config_params.get("realTimeMultiplier", 0)
)
# Execute the download - service is now determined from URL
download_track_func(
@@ -1646,6 +1649,7 @@ def download_track(self, **task_data):
artist_separator=artist_separator,
spotify_metadata=spotify_metadata,
_is_celery_task_execution=True, # Skip duplicate check inside Celery task (consistency)
real_time_multiplier=real_time_multiplier,
)
return {"status": "success", "message": "Track download completed"}
@@ -1725,6 +1729,9 @@ def download_album(self, **task_data):
spotify_metadata = task_data.get(
"spotify_metadata", config_params.get("spotifyMetadata", True)
)
real_time_multiplier = task_data.get(
"real_time_multiplier", config_params.get("realTimeMultiplier", 0)
)
# Execute the download - service is now determined from URL
download_album_func(
@@ -1745,6 +1752,7 @@ def download_album(self, **task_data):
artist_separator=artist_separator,
spotify_metadata=spotify_metadata,
_is_celery_task_execution=True, # Skip duplicate check inside Celery task
real_time_multiplier=real_time_multiplier,
)
return {"status": "success", "message": "Album download completed"}
@@ -1833,6 +1841,9 @@ def download_playlist(self, **task_data):
"retry_delay_increase", config_params.get("retryDelayIncrease", 5)
)
max_retries = task_data.get("max_retries", config_params.get("maxRetries", 3))
real_time_multiplier = task_data.get(
"real_time_multiplier", config_params.get("realTimeMultiplier", 0)
)
# Execute the download - service is now determined from URL
download_playlist_func(
@@ -1856,6 +1867,7 @@ def download_playlist(self, **task_data):
artist_separator=artist_separator,
spotify_metadata=spotify_metadata,
_is_celery_task_execution=True, # Skip duplicate check inside Celery task
real_time_multiplier=real_time_multiplier,
)
return {"status": "success", "message": "Playlist download completed"}

View File

@@ -239,7 +239,7 @@ def get_spotify_info(
Args:
spotify_id: The Spotify ID of the entity
spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode)
spotify_type: The type of entity (track, album, playlist, artist, artist_discography, episode, album_tracks)
limit (int, optional): The maximum number of items to return. Used for pagination.
offset (int, optional): The index of the first item to return. Used for pagination.
@@ -255,6 +255,12 @@ def get_spotify_info(
elif spotify_type == "album":
return client.album(spotify_id)
elif spotify_type == "album_tracks":
# Fetch album's tracks with pagination support
return client.album_tracks(
spotify_id, limit=limit or 20, offset=offset or 0
)
elif spotify_type == "playlist":
# Use optimized playlist fetching
return get_playlist_full(spotify_id)
@@ -269,7 +275,10 @@ def get_spotify_info(
elif spotify_type == "artist_discography":
# Get artist's albums with pagination
albums = client.artist_albums(
spotify_id, limit=limit or 20, offset=offset or 0, include_groups="single,album,appears_on"
spotify_id,
limit=limit or 20,
offset=offset or 0,
include_groups="single,album,appears_on",
)
return albums

View File

@@ -28,6 +28,7 @@ def download_playlist(
recursive_quality=True,
spotify_metadata=True,
_is_celery_task_execution=False, # Added to skip duplicate check from Celery task
real_time_multiplier=None,
):
if not _is_celery_task_execution:
existing_task = get_existing_task_id(
@@ -175,6 +176,7 @@ def download_playlist(
convert_to=convert_to,
bitrate=bitrate,
artist_separator=artist_separator,
real_time_multiplier=real_time_multiplier,
)
print(
f"DEBUG: playlist.py - Spotify direct download (account: {main} for blob) successful."
@@ -236,6 +238,7 @@ def download_playlist(
convert_to=convert_to,
bitrate=bitrate,
artist_separator=artist_separator,
real_time_multiplier=real_time_multiplier,
)
print(
f"DEBUG: playlist.py - Direct Spotify download (account: {main} for blob) successful."

View File

@@ -29,6 +29,7 @@ def download_track(
recursive_quality=False,
spotify_metadata=True,
_is_celery_task_execution=False, # Added for consistency, not currently used for duplicate check
real_time_multiplier=None,
):
try:
# Detect URL source (Spotify or Deezer) from URL
@@ -166,6 +167,7 @@ def download_track(
convert_to=convert_to,
bitrate=bitrate,
artist_separator=artist_separator,
real_time_multiplier=real_time_multiplier,
)
print(
f"DEBUG: track.py - Spotify direct download (account: {main} for blob) successful."
@@ -222,6 +224,7 @@ def download_track(
convert_to=convert_to,
bitrate=bitrate,
artist_separator=artist_separator,
real_time_multiplier=real_time_multiplier,
)
print(
f"DEBUG: track.py - Direct Spotify download (account: {main} for blob) successful."

View File

@@ -1,5 +1,5 @@
import { Link, useParams } from "@tanstack/react-router";
import { useEffect, useState, useContext } from "react";
import { useEffect, useState, useContext, useRef, useCallback } from "react";
import apiClient from "../lib/api-client";
import { QueueContext } from "../contexts/queue-context";
import { useSettings } from "../contexts/settings-context";
@@ -10,31 +10,91 @@ import { FaArrowLeft } from "react-icons/fa";
export const Album = () => {
const { albumId } = useParams({ from: "/album/$albumId" });
const [album, setAlbum] = useState<AlbumType | null>(null);
const [tracks, setTracks] = useState<TrackType[]>([]);
const [offset, setOffset] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const context = useContext(QueueContext);
const { settings } = useSettings();
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const PAGE_SIZE = 50;
if (!context) {
throw new Error("useQueue must be used within a QueueProvider");
}
const { addItem } = context;
const totalTracks = album?.total_tracks ?? 0;
const hasMore = tracks.length < totalTracks;
// Initial load
useEffect(() => {
const fetchAlbum = async () => {
if (!albumId) return;
setIsLoading(true);
setError(null);
try {
const response = await apiClient.get(`/album/info?id=${albumId}`);
setAlbum(response.data);
const response = await apiClient.get(`/album/info?id=${albumId}&limit=${PAGE_SIZE}&offset=0`);
const data: AlbumType & { tracks: { items: TrackType[]; total?: number; limit?: number; offset?: number } } = response.data;
setAlbum(data);
setTracks(data.tracks.items || []);
setOffset((data.tracks.items || []).length);
} catch (err) {
setError("Failed to load album");
console.error("Error fetching album:", err);
} finally {
setIsLoading(false);
}
};
// reset state when albumId changes
setAlbum(null);
setTracks([]);
setOffset(0);
if (albumId) {
fetchAlbum();
}
}, [albumId]);
const loadMore = useCallback(async () => {
if (!albumId || isLoadingMore || !hasMore) return;
setIsLoadingMore(true);
try {
const response = await apiClient.get(`/album/info?id=${albumId}&limit=${PAGE_SIZE}&offset=${offset}`);
const data: AlbumType & { tracks: { items: TrackType[]; total?: number; limit?: number; offset?: number } } = response.data;
const newItems = data.tracks.items || [];
setTracks((prev) => [...prev, ...newItems]);
setOffset((prev) => prev + newItems.length);
} catch (err) {
console.error("Error fetching more tracks:", err);
} finally {
setIsLoadingMore(false);
}
}, [albumId, offset, isLoadingMore, hasMore]);
// IntersectionObserver to trigger loadMore
useEffect(() => {
if (!loadMoreRef.current) return;
const sentinel = loadMoreRef.current;
const observer = new IntersectionObserver(
(entries) => {
const first = entries[0];
if (first.isIntersecting) {
loadMore();
}
},
{ root: null, rootMargin: "200px", threshold: 0.1 }
);
observer.observe(sentinel);
return () => {
observer.unobserve(sentinel);
observer.disconnect();
};
}, [loadMore]);
const handleDownloadTrack = (track: TrackType) => {
if (!track.id) return;
toast.info(`Adding ${track.name} to queue...`);
@@ -51,7 +111,7 @@ export const Album = () => {
return <div className="text-red-500">{error}</div>;
}
if (!album) {
if (!album || isLoading) {
return <div>Loading...</div>;
}
@@ -67,7 +127,7 @@ export const Album = () => {
);
}
const hasExplicitTrack = album.tracks.items.some((track) => track.explicit);
const hasExplicitTrack = tracks.some((track) => track.explicit);
return (
<div className="space-y-4 md:space-y-6">
@@ -130,11 +190,11 @@ export const Album = () => {
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark px-1">Tracks</h2>
<div className="bg-surface-muted dark:bg-surface-muted-dark rounded-xl p-2 md:p-4 shadow-sm">
<div className="space-y-1 md:space-y-2">
{album.tracks.items.map((track, index) => {
{tracks.map((track, index) => {
if (isExplicitFilterEnabled && track.explicit) {
return (
<div
key={index}
key={`${track.id || "explicit"}-${index}`}
className="flex items-center justify-between p-3 md:p-4 bg-surface-muted dark:bg-surface-muted-dark rounded-lg opacity-50"
>
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
@@ -147,7 +207,7 @@ export const Album = () => {
}
return (
<div
key={track.id}
key={track.id || `${index}`}
className="flex items-center justify-between p-3 md:p-4 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-lg transition-colors duration-200 group"
>
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
@@ -188,6 +248,13 @@ export const Album = () => {
</div>
);
})}
<div ref={loadMoreRef} />
{isLoadingMore && (
<div className="p-3 text-center text-content-muted dark:text-content-muted-dark text-sm">Loading more...</div>
)}
{!hasMore && tracks.length > 0 && (
<div className="p-3 text-center text-content-muted dark:text-content-muted-dark text-sm">End of album</div>
)}
</div>
</div>
</div>