diff --git a/requirements.txt b/requirements.txt index 74c8a5a..9e7efac 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ fastapi==0.116.1 uvicorn[standard]==0.35.0 celery==5.5.3 -deezspot-spotizerr==2.6.0 +deezspot-spotizerr==2.7.1 httpx==0.28.1 bcrypt==4.2.1 PyJWT==2.10.1 diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py index 23d6118..97510b8 100644 --- a/routes/utils/watch/manager.py +++ b/routes/utils/watch/manager.py @@ -30,6 +30,9 @@ from routes.utils.get_info import ( ) # To fetch playlist, track, artist, and album details from routes.utils.celery_queue_manager import download_queue_manager +# Added import to fetch base formatting config +from routes.utils.celery_queue_manager import get_config_params + logger = logging.getLogger(__name__) MAIN_CONFIG_FILE_PATH = Path("./data/config/main.json") WATCH_OLD_FILE_PATH = Path("./data/config/watch.json") @@ -153,6 +156,38 @@ def construct_spotify_url(item_id, item_type="track"): return f"https://open.spotify.com/{item_type}/{item_id}" +# Helper to replace playlist placeholders in custom formats per-track +def _apply_playlist_placeholders( + base_dir_fmt: str, + base_track_fmt: str, + playlist_name: str, + playlist_position_one_based: int, + total_tracks_in_playlist: int, + pad_tracks: bool, +) -> tuple[str, str]: + try: + width = max(2, len(str(total_tracks_in_playlist))) if pad_tracks else 0 + if ( + pad_tracks + and playlist_position_one_based is not None + and playlist_position_one_based > 0 + ): + playlist_num_str = str(playlist_position_one_based).zfill(width) + else: + playlist_num_str = ( + str(playlist_position_one_based) if playlist_position_one_based else "" + ) + + dir_fmt = base_dir_fmt.replace("%playlist%", playlist_name) + track_fmt = base_track_fmt.replace("%playlist%", playlist_name).replace( + "%playlistnum%", playlist_num_str + ) + return dir_fmt, track_fmt + except Exception: + # On any error, return originals + return base_dir_fmt, base_track_fmt + + def has_playlist_changed(playlist_spotify_id: str, current_snapshot_id: str) -> bool: """ Check if a playlist has changed by comparing snapshot_id. @@ -320,6 +355,11 @@ def check_watched_playlists(specific_playlist_id: str = None): ) config = get_watch_config() use_snapshot_checking = config.get("useSnapshotIdChecking", True) + # Fetch base formatting configuration once for this run + formatting_cfg = get_config_params() + base_dir_fmt = formatting_cfg.get("customDirFormat", "%ar_album%/%album%") + base_track_fmt = formatting_cfg.get("customTrackFormat", "%tracknum%. %music%") + pad_tracks = formatting_cfg.get("tracknumPadding", True) if specific_playlist_id: playlist_obj = get_watched_playlist(specific_playlist_id) @@ -483,12 +523,17 @@ def check_watched_playlists(specific_playlist_id: str = None): current_api_track_ids = set() api_track_id_to_item_map = {} - for item in all_api_track_items: # Use all_api_track_items + api_track_position_map: dict[str, int] = {} + # Build maps for quick lookup and position within the playlist (1-based) + for idx, item in enumerate( + all_api_track_items, start=1 + ): # Use overall playlist index for numbering track = item.get("track") if track and track.get("id") and not track.get("is_local"): track_id = track["id"] current_api_track_ids.add(track_id) api_track_id_to_item_map[track_id] = item + api_track_position_map[track_id] = idx db_track_ids = get_playlist_track_ids_from_db(playlist_spotify_id) @@ -507,6 +552,19 @@ def check_watched_playlists(specific_playlist_id: str = None): continue track_to_queue = api_item["track"] + # Compute per-track formatting overrides for playlist placeholders + position_in_playlist = api_track_position_map.get(track_id) + custom_dir_format, custom_track_format = ( + _apply_playlist_placeholders( + base_dir_fmt, + base_track_fmt, + playlist_name, + position_in_playlist if position_in_playlist else 0, + api_total_tracks, + pad_tracks, + ) + ) + task_payload = { "download_type": "track", "url": construct_spotify_url(track_id, "track"), @@ -525,7 +583,9 @@ def check_watched_playlists(specific_playlist_id: str = None): "track_spotify_id": track_id, "track_item_for_db": api_item, # Pass full API item for DB update on completion }, - # "track_details_for_db" was old name, using track_item_for_db consistent with celery_tasks + # Override formats so %playlist% and %playlistnum% resolve now per track + "custom_dir_format": custom_dir_format, + "custom_track_format": custom_track_format, } try: task_id_or_none = download_queue_manager.add_task( diff --git a/spotizerr-ui/src/components/config/DownloadsTab.tsx b/spotizerr-ui/src/components/config/DownloadsTab.tsx index e320c39..bb491b8 100644 --- a/spotizerr-ui/src/components/config/DownloadsTab.tsx +++ b/spotizerr-ui/src/components/config/DownloadsTab.tsx @@ -23,6 +23,7 @@ interface DownloadSettings { spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH"; recursiveQuality: boolean; // frontend field (sent as camelCase to backend) separateTracksByUser: boolean; + realTimeMultiplier: number; } interface WatchConfig { @@ -150,7 +151,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { const missingServices: string[] = []; if (!spotifyCredentials?.length) missingServices.push("Spotify"); if (!deezerCredentials?.length) missingServices.push("Deezer"); - const error = `Download Fallback requires accounts to be configured for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`; + const error = `Download Fallback requires accounts to be configured for both Spotify and Deezer. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`; setValidationError(error); toast.error("Validation failed: " + error); return; @@ -162,6 +163,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { maxRetries: Number(data.maxRetries), retryDelaySeconds: Number(data.retryDelaySeconds), retryDelayIncrease: Number(data.retryDelayIncrease), + realTimeMultiplier: Number(data.realTimeMultiplier ?? 0), }); }; @@ -188,6 +190,26 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { + {/* Real-time Multiplier (Spotify only) */} +
+
+ + 0–10 +
+ +

+ Controls how fast Spotify real-time downloads go. Only affects Spotify downloads; ignored for Deezer. +

+
diff --git a/spotizerr-ui/src/contexts/SettingsProvider.tsx b/spotizerr-ui/src/contexts/SettingsProvider.tsx index 127f5e8..10b328e 100644 --- a/spotizerr-ui/src/contexts/SettingsProvider.tsx +++ b/spotizerr-ui/src/contexts/SettingsProvider.tsx @@ -62,6 +62,7 @@ export type FlatAppSettings = { compilation: string; artistSeparator: string; spotifyMetadata: boolean; + realTimeMultiplier: number; }; const defaultSettings: FlatAppSettings = { @@ -102,6 +103,7 @@ const defaultSettings: FlatAppSettings = { watch: { enabled: false, }, + realTimeMultiplier: 0, }; interface FetchedCamelCaseSettings { @@ -129,6 +131,7 @@ const fetchSettings = async (): Promise => { ...(camelData as unknown as FlatAppSettings), // Ensure required frontend-only fields exist recursiveQuality: Boolean((camelData as any).recursiveQuality ?? false), + realTimeMultiplier: Number((camelData as any).realTimeMultiplier ?? 0), }; return withDefaults; diff --git a/spotizerr-ui/src/contexts/settings-context.ts b/spotizerr-ui/src/contexts/settings-context.ts index e0c6011..8e9d534 100644 --- a/spotizerr-ui/src/contexts/settings-context.ts +++ b/spotizerr-ui/src/contexts/settings-context.ts @@ -40,6 +40,7 @@ export interface AppSettings { // Add other watch properties from the old type if they still exist in the API response }; // Add other root-level properties from the API if they exist + realTimeMultiplier: number; } export interface SettingsContextType {