diff --git a/backend/api/routers/playlists.py b/backend/api/routers/playlists.py index f25ff90..7dca4b9 100644 --- a/backend/api/routers/playlists.py +++ b/backend/api/routers/playlists.py @@ -18,6 +18,7 @@ class MonitorPlaylistRequest(BaseModel): quality: Literal["LOW", "HIGH", "LOSSLESS", "HI_RES"] = "LOSSLESS" source: Literal["tidal", "listenbrainz"] = "tidal" extra_config: Optional[Dict[str, Any]] = None + use_playlist_folder: bool = False class DeleteFilesRequest(BaseModel): files: List[str] @@ -57,7 +58,8 @@ async def monitor_playlist( request.frequency, request.quality, request.source, - request.extra_config + request.extra_config, + request.use_playlist_folder ) # Start initial sync in background only if new diff --git a/backend/api/settings.py b/backend/api/settings.py index 5c68cc3..9160472 100644 --- a/backend/api/settings.py +++ b/backend/api/settings.py @@ -17,6 +17,7 @@ class Settings(BaseSettings): use_musicbrainz: bool = True run_beets: bool = False embed_lyrics: bool = False + group_compilations: bool = True # Jellyfin Integration jellyfin_url: Optional[str] = None diff --git a/backend/playlist_manager.py b/backend/playlist_manager.py index e0685ec..17e33cc 100644 --- a/backend/playlist_manager.py +++ b/backend/playlist_manager.py @@ -34,6 +34,7 @@ class MonitoredPlaylist: track_count: int = 0 source: str = "tidal" # "tidal" or "listenbrainz" extra_config: Dict[str, Any] = None # e.g. { "lb_username": "...", "lb_type": "..." } + use_playlist_folder: bool = False class PlaylistManager: _instance = None @@ -76,8 +77,8 @@ def get_monitored_playlists(self) -> List[Dict]: def get_playlist(self, uuid: str) -> Optional[MonitoredPlaylist]: return next((p for p in self._playlists if p.uuid == uuid), None) - def add_monitored_playlist(self, uuid: str, name: str, frequency: str = "manual", quality: str = "LOSSLESS", source: str = "tidal", extra_config: Dict = None) -> tuple[MonitoredPlaylist, bool]: - logger.info(f"Adding/Updating playlist: {uuid} - {name} (Freq: {frequency}, Qual: {quality}, Source: {source})") + def add_monitored_playlist(self, uuid: str, name: str, frequency: str = "manual", quality: str = "LOSSLESS", source: str = "tidal", extra_config: Dict = None, use_playlist_folder: bool = False) -> tuple[MonitoredPlaylist, bool]: + logger.info(f"Adding/Updating playlist: {uuid} - {name} (Freq: {frequency}, Qual: {quality}, Source: {source}, Folder: {use_playlist_folder})") # Check if exists existing = self.get_playlist(uuid) if existing: @@ -86,6 +87,7 @@ def add_monitored_playlist(self, uuid: str, name: str, frequency: str = "manual" existing.quality = quality existing.source = source existing.extra_config = extra_config + existing.use_playlist_folder = use_playlist_folder # Start sync immediately? No, caller decides. self._save_state() logger.info(f"Playlist {uuid} updated. Current list size: {len(self._playlists)}") @@ -103,7 +105,8 @@ def add_monitored_playlist(self, uuid: str, name: str, frequency: str = "manual" sync_frequency=frequency, quality=quality, source=source, - extra_config=extra_config + extra_config=extra_config, + use_playlist_folder=use_playlist_folder ) self._playlists.append(playlist) self._save_state() @@ -249,6 +252,16 @@ async def _process_playlist_items(self, playlist: MonitoredPlaylist, raw_items: m3u8_lines = ["#EXTM3U", f"# Source: {playlist.source}"] items_to_download = [] + org_template = settings.organization_template + group_compilations = settings.group_compilations + + if playlist.use_playlist_folder: + safe_pl_name = sanitize_path_component(playlist.name) + # Use 'tidaloader_playlists' explicitly to match PLAYLISTS_DIR logic + # This makes the path relative to DOWNLOAD_DIR be: tidaloader_playlists/PlaylistName/Track - Title + org_template = f"tidaloader_playlists/{safe_pl_name}/{{TrackNumber}} - {{Title}}" + group_compilations = False + for i, item in enumerate(raw_items): # Robust extraction logic mirrored from search.py track = item.get('item', item) if isinstance(item, dict) else item @@ -293,7 +306,7 @@ async def _process_playlist_items(self, playlist: MonitoredPlaylist, raw_items: # Check FLAC (Common default for lossless) metadata['file_ext'] = '.flac' - rel_flac = get_output_relative_path(metadata) + rel_flac = get_output_relative_path(metadata, template=org_template, group_compilations=group_compilations) if (DOWNLOAD_DIR / rel_flac).exists(): logger.info(f"Found existing file (FLAC): {rel_flac}") found_rel_path = rel_flac @@ -301,21 +314,21 @@ async def _process_playlist_items(self, playlist: MonitoredPlaylist, raw_items: # logger.debug(f"File not found at: {DOWNLOAD_DIR / rel_flac}") # Check M4A metadata['file_ext'] = '.m4a' - rel_m4a = get_output_relative_path(metadata) + rel_m4a = get_output_relative_path(metadata, template=org_template, group_compilations=group_compilations) if (DOWNLOAD_DIR / rel_m4a).exists(): logger.info(f"Found existing file (M4A): {rel_m4a}") found_rel_path = rel_m4a else: # Check MP3 metadata['file_ext'] = '.mp3' - rel_mp3 = get_output_relative_path(metadata) + rel_mp3 = get_output_relative_path(metadata, template=org_template, group_compilations=group_compilations) if (DOWNLOAD_DIR / rel_mp3).exists(): logger.info(f"Found existing file (MP3): {rel_mp3}") found_rel_path = rel_mp3 # Check OPUS else: metadata['file_ext'] = '.opus' - rel_opus = get_output_relative_path(metadata) + rel_opus = get_output_relative_path(metadata, template=org_template, group_compilations=group_compilations) if (DOWNLOAD_DIR / rel_opus).exists(): logger.info(f"Found existing file (OPUS): {rel_opus}") found_rel_path = rel_opus @@ -346,8 +359,8 @@ async def _process_playlist_items(self, playlist: MonitoredPlaylist, raw_items: tidal_artist_id=str(artist_data.get('id')) if artist_data.get('id') else None, tidal_album_id=str(album_data.get('id')) if album_data.get('id') else None, auto_clean=True, - organization_template=settings.organization_template, - group_compilations=settings.group_compilations, + organization_template=org_template, + group_compilations=group_compilations, run_beets=settings.run_beets, embed_lyrics=settings.embed_lyrics )) @@ -357,7 +370,7 @@ async def _process_playlist_items(self, playlist: MonitoredPlaylist, raw_items: target_ext = '.m4a' metadata['file_ext'] = target_ext - predicted_path = get_output_relative_path(metadata) + predicted_path = get_output_relative_path(metadata, template=org_template, group_compilations=group_compilations) duration = track.get('duration', -1) m3u8_lines.append(f"#EXTINF:{duration},{artist_name} - {title}") diff --git a/backend/tidal_client.py b/backend/tidal_client.py index 39ffaf8..90c6397 100644 --- a/backend/tidal_client.py +++ b/backend/tidal_client.py @@ -279,7 +279,57 @@ def get_playlist(self, playlist_id: str) -> Optional[Dict]: return self._make_request("/playlist/", {"id": playlist_id}, operation="get_playlist") def get_playlist_tracks(self, playlist_id: str) -> Optional[Dict]: - return self._make_request("/playlist/", {"id": playlist_id}, operation="get_playlist_tracks") + all_items = [] + offset = 0 + limit = 100 + base_response = None + + while True: + params = {"id": playlist_id, "offset": offset, "limit": limit} + data = self._make_request("/playlist/", params, operation="get_playlist_tracks") + + if not data: + break + + # Store first page metadata + if not base_response and isinstance(data, dict): + base_response = data + + # Extract items + items = [] + if isinstance(data, list): + items = data + elif isinstance(data, dict): + items = data.get('items', []) + # Fallback for nested tracks structure + if not items and 'tracks' in data: + tracks = data['tracks'] + items = tracks.get('items', []) if isinstance(tracks, dict) else tracks + + if not items: + break + + all_items.extend(items) + + if len(items) < limit or len(all_items) >= 10000: + if len(all_items) >= 10000: + logger.warning(f"Playlist {playlist_id} truncated at 10000 tracks") + break + + offset += limit + + # Return merged result + if base_response: + # Update item counts and list + base_response['items'] = all_items + base_response['totalNumberOfItems'] = len(all_items) + # Ensure nested tracks count is updated if it exists + if 'tracks' in base_response and isinstance(base_response['tracks'], dict): + base_response['tracks']['items'] = all_items + base_response['tracks']['totalNumberOfItems'] = len(all_items) + return base_response + + return {'items': all_items, 'totalNumberOfItems': len(all_items)} def get_artist_albums(self, artist_id: int) -> Optional[Dict]: return self._make_request(f"/artist/{artist_id}/albums", operation="get_artist_albums") diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 8d35a66..7e19418 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -386,8 +386,8 @@ class ApiClient { return this.get("/playlists/monitored"); } - monitorPlaylist(uuid, name, frequency, quality, source = "tidal", extra_config = null) { - return this.post("/playlists/monitor", { uuid, name, frequency, quality, source, extra_config }); + monitorPlaylist(uuid, name, frequency, quality, source = "tidal", extra_config = null, use_playlist_folder = false) { + return this.post("/playlists/monitor", { uuid, name, frequency, quality, source, extra_config, use_playlist_folder }); } removeMonitoredPlaylist(uuid) { diff --git a/frontend/src/components/TidalPlaylists/MonitoredList.jsx b/frontend/src/components/TidalPlaylists/MonitoredList.jsx index ed53f93..d688cf6 100644 --- a/frontend/src/components/TidalPlaylists/MonitoredList.jsx +++ b/frontend/src/components/TidalPlaylists/MonitoredList.jsx @@ -111,7 +111,10 @@ export function MonitoredList() { playlist.uuid, playlist.name, newFreq, - playlist.quality + playlist.quality, + playlist.source, + playlist.extra_config, + playlist.use_playlist_folder ).then(() => { addToast("Frequency updated", "success"); fetchPlaylists(); // Refresh to be sure diff --git a/frontend/src/components/TidalPlaylists/PlaylistSearch.jsx b/frontend/src/components/TidalPlaylists/PlaylistSearch.jsx index 2237868..6eb1ab6 100644 --- a/frontend/src/components/TidalPlaylists/PlaylistSearch.jsx +++ b/frontend/src/components/TidalPlaylists/PlaylistSearch.jsx @@ -8,15 +8,15 @@ function extractPlaylistUuid(input) { if (!input) return null; const trimmed = input.trim(); - + const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; - + if (uuidPattern.test(trimmed)) { return trimmed; } - + const urlMatch = trimmed.match(/tidal\.com\/(?:browse\/)?playlist\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i); if (urlMatch) { return urlMatch[1]; @@ -30,16 +30,16 @@ export function PlaylistSearch({ onSyncStarted }) { const [loading, setLoading] = useState(false); const [results, setResults] = useState([]); const [officialOnly, setOfficialOnly] = useState(false); - const [selectedPlaylist, setSelectedPlaylist] = useState(null); + const [selectedPlaylist, setSelectedPlaylist] = useState(null); const addToast = useToastStore((state) => state.addToast); const handleSearch = async () => { if (!query.trim()) return; - + const extractedUuid = extractPlaylistUuid(query); if (extractedUuid) { - + setLoading(true); setResults([]); try { @@ -85,7 +85,7 @@ export function PlaylistSearch({ onSyncStarted }) { const [monitoredUuids, setMonitoredUuids] = useState(new Set()); useEffect(() => { - + api.getMonitoredPlaylists().then(list => { setMonitoredUuids(new Set(list.map(p => p.uuid))); }).catch(err => console.error("Failed to load monitored status", err)); @@ -330,6 +330,7 @@ function PlaylistCoverImage({ cover, title }) { function MonitorModal({ playlist, onClose, onSuccess }) { const [frequency, setFrequency] = useState("manual"); const [quality, setQuality] = useState("LOSSLESS"); + const [usePlaylistFolder, setUsePlaylistFolder] = useState(false); const [submitting, setSubmitting] = useState(false); const addToast = useToastStore((state) => state.addToast); @@ -338,7 +339,8 @@ function MonitorModal({ playlist, onClose, onSuccess }) { setSubmitting(true); try { const uuid = playlist.uuid || playlist.id; - await api.monitorPlaylist(uuid, playlist.title, frequency, quality); + // monitorPlaylist(uuid, name, frequency, quality, source, extra_config, use_playlist_folder) + await api.monitorPlaylist(uuid, playlist.title, frequency, quality, "tidal", null, usePlaylistFolder); addToast(`Started monitoring "${playlist.title}"`, "success"); onSuccess(); } catch (e) { @@ -391,6 +393,22 @@ function MonitorModal({ playlist, onClose, onSuccess }) { +
+ Creates a standalone folder "{playlist.title}" containing all tracks. + Useful for keeping files together, but duplicates tracks if they already exist in library. +
+