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 from fastapi.responses import JSONResponse
import json
import traceback import traceback
import uuid import uuid
import time import time
@@ -21,7 +20,11 @@ def construct_spotify_url(item_id: str, item_type: str = "track") -> str:
@router.get("/download/{album_id}") @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. # Retrieve essential parameters from the request.
# name = request.args.get('name') # name = request.args.get('name')
# artist = request.args.get('artist') # 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") or not album_info.get("artists")
): ):
return JSONResponse( return JSONResponse(
content={"error": f"Could not retrieve metadata for album ID: {album_id}"}, content={
status_code=404 "error": f"Could not retrieve metadata for album ID: {album_id}"
},
status_code=404,
) )
name_from_spotify = album_info.get("name") 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: except Exception as e:
return JSONResponse( return JSONResponse(
content={"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"}, content={
status_code=500 "error": f"Failed to fetch metadata for album {album_id}: {str(e)}"
},
status_code=500,
) )
# Validate required parameters # Validate required parameters
if not url: if not url:
return JSONResponse( return JSONResponse(
content={"error": "Missing required parameter: url"}, content={"error": "Missing required parameter: url"}, status_code=400
status_code=400
) )
# Add the task to the queue with only essential parameters # 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.", "error": "Duplicate download detected.",
"existing_task": e.existing_task, "existing_task": e.existing_task,
}, },
status_code=409 status_code=409,
) )
except Exception as e: except Exception as e:
# Generic error handling for other issues during task submission # 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)}", "error": f"Failed to queue album download: {str(e)}",
"task_id": error_task_id, "task_id": error_task_id,
}, },
status_code=500 status_code=500,
) )
return JSONResponse( return JSONResponse(content={"task_id": task_id}, status_code=202)
content={"task_id": task_id},
status_code=202
)
@router.get("/download/cancel") @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. Cancel a running download process by its task id.
""" """
task_id = request.query_params.get("task_id") task_id = request.query_params.get("task_id")
if not task_id: if not task_id:
return JSONResponse( return JSONResponse(
content={"error": "Missing process id (task_id) parameter"}, content={"error": "Missing process id (task_id) parameter"}, status_code=400
status_code=400
) )
# Use the queue manager's cancellation method. # 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") @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. Retrieve Spotify album metadata given a Spotify album ID.
Expects a query parameter 'id' that contains the 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") spotify_id = request.query_params.get("id")
if not spotify_id: if not spotify_id:
return JSONResponse( return JSONResponse(content={"error": "Missing parameter: id"}, status_code=400)
content={"error": "Missing parameter: id"},
status_code=400
)
try: 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") 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) 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: except Exception as e:
error_data = {"error": str(e), "traceback": traceback.format_exc()} error_data = {"error": str(e), "traceback": traceback.format_exc()}
return JSONResponse(content=error_data, status_code=500) 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: if watch_config is None:
watch_config = get_watch_config_http() 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 # Check if fallback is enabled but missing required accounts
if config_data.get("fallback", False): if config_data.get("fallback", False):
has_spotify = has_credentials("spotify") has_spotify = has_credentials("spotify")
@@ -169,6 +191,7 @@ def _migrate_legacy_keys_inplace(cfg: dict) -> bool:
"artist_separator": "artistSeparator", "artist_separator": "artistSeparator",
"recursive_quality": "recursiveQuality", "recursive_quality": "recursiveQuality",
"spotify_metadata": "spotifyMetadata", "spotify_metadata": "spotifyMetadata",
"real_time_multiplier": "realTimeMultiplier",
} }
modified = False modified = False
for legacy, camel in legacy_map.items(): for legacy, camel in legacy_map.items():

View File

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

View File

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

View File

@@ -72,6 +72,9 @@ def get_config_params():
), ),
"separateTracksByUser": config.get("separateTracksByUser", False), "separateTracksByUser": config.get("separateTracksByUser", False),
"watch": config.get("watch", {}), "watch": config.get("watch", {}),
"realTimeMultiplier": config.get(
"realTimeMultiplier", config.get("real_time_multiplier", 0)
),
} }
except Exception as e: except Exception as e:
logger.error(f"Error reading config for parameters: {e}") logger.error(f"Error reading config for parameters: {e}")
@@ -96,6 +99,7 @@ def get_config_params():
"recursiveQuality": False, "recursiveQuality": False,
"separateTracksByUser": False, "separateTracksByUser": False,
"watch": {}, "watch": {},
"realTimeMultiplier": 0,
} }
@@ -389,9 +393,11 @@ class CeleryDownloadQueueManager:
original_request.get("real_time"), config_params["realTime"] original_request.get("real_time"), config_params["realTime"]
), ),
"custom_dir_format": self._get_user_specific_dir_format( "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), config_params.get("separateTracksByUser", False),
username username,
), ),
"custom_track_format": original_request.get( "custom_track_format": original_request.get(
"custom_track_format", config_params["customTrackFormat"] "custom_track_format", config_params["customTrackFormat"]
@@ -419,6 +425,9 @@ class CeleryDownloadQueueManager:
"retry_count": 0, "retry_count": 0,
"original_request": original_request, "original_request": original_request,
"created_at": time.time(), "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 # 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 = task_data.get(
"spotify_metadata", config_params.get("spotifyMetadata", True) "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 # Execute the download - service is now determined from URL
download_track_func( download_track_func(
@@ -1646,6 +1649,7 @@ def download_track(self, **task_data):
artist_separator=artist_separator, artist_separator=artist_separator,
spotify_metadata=spotify_metadata, spotify_metadata=spotify_metadata,
_is_celery_task_execution=True, # Skip duplicate check inside Celery task (consistency) _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"} return {"status": "success", "message": "Track download completed"}
@@ -1725,6 +1729,9 @@ def download_album(self, **task_data):
spotify_metadata = task_data.get( spotify_metadata = task_data.get(
"spotify_metadata", config_params.get("spotifyMetadata", True) "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 # Execute the download - service is now determined from URL
download_album_func( download_album_func(
@@ -1745,6 +1752,7 @@ def download_album(self, **task_data):
artist_separator=artist_separator, artist_separator=artist_separator,
spotify_metadata=spotify_metadata, spotify_metadata=spotify_metadata,
_is_celery_task_execution=True, # Skip duplicate check inside Celery task _is_celery_task_execution=True, # Skip duplicate check inside Celery task
real_time_multiplier=real_time_multiplier,
) )
return {"status": "success", "message": "Album download completed"} 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) "retry_delay_increase", config_params.get("retryDelayIncrease", 5)
) )
max_retries = task_data.get("max_retries", config_params.get("maxRetries", 3)) 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 # Execute the download - service is now determined from URL
download_playlist_func( download_playlist_func(
@@ -1856,6 +1867,7 @@ def download_playlist(self, **task_data):
artist_separator=artist_separator, artist_separator=artist_separator,
spotify_metadata=spotify_metadata, spotify_metadata=spotify_metadata,
_is_celery_task_execution=True, # Skip duplicate check inside Celery task _is_celery_task_execution=True, # Skip duplicate check inside Celery task
real_time_multiplier=real_time_multiplier,
) )
return {"status": "success", "message": "Playlist download completed"} return {"status": "success", "message": "Playlist download completed"}

View File

@@ -239,7 +239,7 @@ def get_spotify_info(
Args: Args:
spotify_id: The Spotify ID of the entity 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. 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. 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": elif spotify_type == "album":
return client.album(spotify_id) 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": elif spotify_type == "playlist":
# Use optimized playlist fetching # Use optimized playlist fetching
return get_playlist_full(spotify_id) return get_playlist_full(spotify_id)
@@ -269,7 +275,10 @@ def get_spotify_info(
elif spotify_type == "artist_discography": elif spotify_type == "artist_discography":
# Get artist's albums with pagination # Get artist's albums with pagination
albums = client.artist_albums( 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 return albums

View File

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

View File

@@ -29,6 +29,7 @@ def download_track(
recursive_quality=False, recursive_quality=False,
spotify_metadata=True, spotify_metadata=True,
_is_celery_task_execution=False, # Added for consistency, not currently used for duplicate check _is_celery_task_execution=False, # Added for consistency, not currently used for duplicate check
real_time_multiplier=None,
): ):
try: try:
# Detect URL source (Spotify or Deezer) from URL # Detect URL source (Spotify or Deezer) from URL
@@ -166,6 +167,7 @@ def download_track(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate, bitrate=bitrate,
artist_separator=artist_separator, artist_separator=artist_separator,
real_time_multiplier=real_time_multiplier,
) )
print( print(
f"DEBUG: track.py - Spotify direct download (account: {main} for blob) successful." f"DEBUG: track.py - Spotify direct download (account: {main} for blob) successful."
@@ -222,6 +224,7 @@ def download_track(
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate, bitrate=bitrate,
artist_separator=artist_separator, artist_separator=artist_separator,
real_time_multiplier=real_time_multiplier,
) )
print( print(
f"DEBUG: track.py - Direct Spotify download (account: {main} for blob) successful." 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 { 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 apiClient from "../lib/api-client";
import { QueueContext } from "../contexts/queue-context"; import { QueueContext } from "../contexts/queue-context";
import { useSettings } from "../contexts/settings-context"; import { useSettings } from "../contexts/settings-context";
@@ -10,31 +10,91 @@ import { FaArrowLeft } from "react-icons/fa";
export const Album = () => { export const Album = () => {
const { albumId } = useParams({ from: "/album/$albumId" }); const { albumId } = useParams({ from: "/album/$albumId" });
const [album, setAlbum] = useState<AlbumType | null>(null); 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 [error, setError] = useState<string | null>(null);
const context = useContext(QueueContext); const context = useContext(QueueContext);
const { settings } = useSettings(); const { settings } = useSettings();
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const PAGE_SIZE = 50;
if (!context) { if (!context) {
throw new Error("useQueue must be used within a QueueProvider"); throw new Error("useQueue must be used within a QueueProvider");
} }
const { addItem } = context; const { addItem } = context;
const totalTracks = album?.total_tracks ?? 0;
const hasMore = tracks.length < totalTracks;
// Initial load
useEffect(() => { useEffect(() => {
const fetchAlbum = async () => { const fetchAlbum = async () => {
if (!albumId) return;
setIsLoading(true);
setError(null);
try { try {
const response = await apiClient.get(`/album/info?id=${albumId}`); const response = await apiClient.get(`/album/info?id=${albumId}&limit=${PAGE_SIZE}&offset=0`);
setAlbum(response.data); 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) { } catch (err) {
setError("Failed to load album"); setError("Failed to load album");
console.error("Error fetching album:", err); console.error("Error fetching album:", err);
} finally {
setIsLoading(false);
} }
}; };
// reset state when albumId changes
setAlbum(null);
setTracks([]);
setOffset(0);
if (albumId) { if (albumId) {
fetchAlbum(); fetchAlbum();
} }
}, [albumId]); }, [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) => { const handleDownloadTrack = (track: TrackType) => {
if (!track.id) return; if (!track.id) return;
toast.info(`Adding ${track.name} to queue...`); toast.info(`Adding ${track.name} to queue...`);
@@ -51,7 +111,7 @@ export const Album = () => {
return <div className="text-red-500">{error}</div>; return <div className="text-red-500">{error}</div>;
} }
if (!album) { if (!album || isLoading) {
return <div>Loading...</div>; 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 ( return (
<div className="space-y-4 md:space-y-6"> <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> <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="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"> <div className="space-y-1 md:space-y-2">
{album.tracks.items.map((track, index) => { {tracks.map((track, index) => {
if (isExplicitFilterEnabled && track.explicit) { if (isExplicitFilterEnabled && track.explicit) {
return ( return (
<div <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" 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"> <div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
@@ -147,7 +207,7 @@ export const Album = () => {
} }
return ( return (
<div <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" 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"> <div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
@@ -188,6 +248,13 @@ export const Album = () => {
</div> </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> </div>
</div> </div>