Skip to content

Commit 709fe11

Browse files
authored
Merge pull request #41 from Oduanir/fix/playlist-improvements
feat: fix playlist limit, add folder sync option, and refactor client
2 parents 709211a + c58428d commit 709fe11

File tree

7 files changed

+110
-23
lines changed

7 files changed

+110
-23
lines changed

backend/api/routers/playlists.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class MonitorPlaylistRequest(BaseModel):
1818
quality: Literal["LOW", "HIGH", "LOSSLESS", "HI_RES"] = "LOSSLESS"
1919
source: Literal["tidal", "listenbrainz"] = "tidal"
2020
extra_config: Optional[Dict[str, Any]] = None
21+
use_playlist_folder: bool = False
2122

2223
class DeleteFilesRequest(BaseModel):
2324
files: List[str]
@@ -57,7 +58,8 @@ async def monitor_playlist(
5758
request.frequency,
5859
request.quality,
5960
request.source,
60-
request.extra_config
61+
request.extra_config,
62+
request.use_playlist_folder
6163
)
6264

6365
# Start initial sync in background only if new

backend/api/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Settings(BaseSettings):
1717
use_musicbrainz: bool = True
1818
run_beets: bool = False
1919
embed_lyrics: bool = False
20+
group_compilations: bool = True
2021

2122
# Jellyfin Integration
2223
jellyfin_url: Optional[str] = None

backend/playlist_manager.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class MonitoredPlaylist:
3434
track_count: int = 0
3535
source: str = "tidal" # "tidal" or "listenbrainz"
3636
extra_config: Dict[str, Any] = None # e.g. { "lb_username": "...", "lb_type": "..." }
37+
use_playlist_folder: bool = False
3738

3839
class PlaylistManager:
3940
_instance = None
@@ -76,8 +77,8 @@ def get_monitored_playlists(self) -> List[Dict]:
7677
def get_playlist(self, uuid: str) -> Optional[MonitoredPlaylist]:
7778
return next((p for p in self._playlists if p.uuid == uuid), None)
7879

79-
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]:
80-
logger.info(f"Adding/Updating playlist: {uuid} - {name} (Freq: {frequency}, Qual: {quality}, Source: {source})")
80+
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]:
81+
logger.info(f"Adding/Updating playlist: {uuid} - {name} (Freq: {frequency}, Qual: {quality}, Source: {source}, Folder: {use_playlist_folder})")
8182
# Check if exists
8283
existing = self.get_playlist(uuid)
8384
if existing:
@@ -86,6 +87,7 @@ def add_monitored_playlist(self, uuid: str, name: str, frequency: str = "manual"
8687
existing.quality = quality
8788
existing.source = source
8889
existing.extra_config = extra_config
90+
existing.use_playlist_folder = use_playlist_folder
8991
# Start sync immediately? No, caller decides.
9092
self._save_state()
9193
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"
103105
sync_frequency=frequency,
104106
quality=quality,
105107
source=source,
106-
extra_config=extra_config
108+
extra_config=extra_config,
109+
use_playlist_folder=use_playlist_folder
107110
)
108111
self._playlists.append(playlist)
109112
self._save_state()
@@ -249,6 +252,16 @@ async def _process_playlist_items(self, playlist: MonitoredPlaylist, raw_items:
249252
m3u8_lines = ["#EXTM3U", f"# Source: {playlist.source}"]
250253
items_to_download = []
251254

255+
org_template = settings.organization_template
256+
group_compilations = settings.group_compilations
257+
258+
if playlist.use_playlist_folder:
259+
safe_pl_name = sanitize_path_component(playlist.name)
260+
# Use 'tidaloader_playlists' explicitly to match PLAYLISTS_DIR logic
261+
# This makes the path relative to DOWNLOAD_DIR be: tidaloader_playlists/PlaylistName/Track - Title
262+
org_template = f"tidaloader_playlists/{safe_pl_name}/{{TrackNumber}} - {{Title}}"
263+
group_compilations = False
264+
252265
for i, item in enumerate(raw_items):
253266
# Robust extraction logic mirrored from search.py
254267
track = item.get('item', item) if isinstance(item, dict) else item
@@ -293,29 +306,29 @@ async def _process_playlist_items(self, playlist: MonitoredPlaylist, raw_items:
293306

294307
# Check FLAC (Common default for lossless)
295308
metadata['file_ext'] = '.flac'
296-
rel_flac = get_output_relative_path(metadata)
309+
rel_flac = get_output_relative_path(metadata, template=org_template, group_compilations=group_compilations)
297310
if (DOWNLOAD_DIR / rel_flac).exists():
298311
logger.info(f"Found existing file (FLAC): {rel_flac}")
299312
found_rel_path = rel_flac
300313
else:
301314
# logger.debug(f"File not found at: {DOWNLOAD_DIR / rel_flac}")
302315
# Check M4A
303316
metadata['file_ext'] = '.m4a'
304-
rel_m4a = get_output_relative_path(metadata)
317+
rel_m4a = get_output_relative_path(metadata, template=org_template, group_compilations=group_compilations)
305318
if (DOWNLOAD_DIR / rel_m4a).exists():
306319
logger.info(f"Found existing file (M4A): {rel_m4a}")
307320
found_rel_path = rel_m4a
308321
else:
309322
# Check MP3
310323
metadata['file_ext'] = '.mp3'
311-
rel_mp3 = get_output_relative_path(metadata)
324+
rel_mp3 = get_output_relative_path(metadata, template=org_template, group_compilations=group_compilations)
312325
if (DOWNLOAD_DIR / rel_mp3).exists():
313326
logger.info(f"Found existing file (MP3): {rel_mp3}")
314327
found_rel_path = rel_mp3
315328
# Check OPUS
316329
else:
317330
metadata['file_ext'] = '.opus'
318-
rel_opus = get_output_relative_path(metadata)
331+
rel_opus = get_output_relative_path(metadata, template=org_template, group_compilations=group_compilations)
319332
if (DOWNLOAD_DIR / rel_opus).exists():
320333
logger.info(f"Found existing file (OPUS): {rel_opus}")
321334
found_rel_path = rel_opus
@@ -346,8 +359,8 @@ async def _process_playlist_items(self, playlist: MonitoredPlaylist, raw_items:
346359
tidal_artist_id=str(artist_data.get('id')) if artist_data.get('id') else None,
347360
tidal_album_id=str(album_data.get('id')) if album_data.get('id') else None,
348361
auto_clean=True,
349-
organization_template=settings.organization_template,
350-
group_compilations=settings.group_compilations,
362+
organization_template=org_template,
363+
group_compilations=group_compilations,
351364
run_beets=settings.run_beets,
352365
embed_lyrics=settings.embed_lyrics
353366
))
@@ -357,7 +370,7 @@ async def _process_playlist_items(self, playlist: MonitoredPlaylist, raw_items:
357370
target_ext = '.m4a'
358371

359372
metadata['file_ext'] = target_ext
360-
predicted_path = get_output_relative_path(metadata)
373+
predicted_path = get_output_relative_path(metadata, template=org_template, group_compilations=group_compilations)
361374

362375
duration = track.get('duration', -1)
363376
m3u8_lines.append(f"#EXTINF:{duration},{artist_name} - {title}")

backend/tidal_client.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,57 @@ def get_playlist(self, playlist_id: str) -> Optional[Dict]:
279279
return self._make_request("/playlist/", {"id": playlist_id}, operation="get_playlist")
280280

281281
def get_playlist_tracks(self, playlist_id: str) -> Optional[Dict]:
282-
return self._make_request("/playlist/", {"id": playlist_id}, operation="get_playlist_tracks")
282+
all_items = []
283+
offset = 0
284+
limit = 100
285+
base_response = None
286+
287+
while True:
288+
params = {"id": playlist_id, "offset": offset, "limit": limit}
289+
data = self._make_request("/playlist/", params, operation="get_playlist_tracks")
290+
291+
if not data:
292+
break
293+
294+
# Store first page metadata
295+
if not base_response and isinstance(data, dict):
296+
base_response = data
297+
298+
# Extract items
299+
items = []
300+
if isinstance(data, list):
301+
items = data
302+
elif isinstance(data, dict):
303+
items = data.get('items', [])
304+
# Fallback for nested tracks structure
305+
if not items and 'tracks' in data:
306+
tracks = data['tracks']
307+
items = tracks.get('items', []) if isinstance(tracks, dict) else tracks
308+
309+
if not items:
310+
break
311+
312+
all_items.extend(items)
313+
314+
if len(items) < limit or len(all_items) >= 10000:
315+
if len(all_items) >= 10000:
316+
logger.warning(f"Playlist {playlist_id} truncated at 10000 tracks")
317+
break
318+
319+
offset += limit
320+
321+
# Return merged result
322+
if base_response:
323+
# Update item counts and list
324+
base_response['items'] = all_items
325+
base_response['totalNumberOfItems'] = len(all_items)
326+
# Ensure nested tracks count is updated if it exists
327+
if 'tracks' in base_response and isinstance(base_response['tracks'], dict):
328+
base_response['tracks']['items'] = all_items
329+
base_response['tracks']['totalNumberOfItems'] = len(all_items)
330+
return base_response
331+
332+
return {'items': all_items, 'totalNumberOfItems': len(all_items)}
283333

284334
def get_artist_albums(self, artist_id: int) -> Optional[Dict]:
285335
return self._make_request(f"/artist/{artist_id}/albums", operation="get_artist_albums")

frontend/src/api/client.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,8 +386,8 @@ class ApiClient {
386386
return this.get("/playlists/monitored");
387387
}
388388

389-
monitorPlaylist(uuid, name, frequency, quality, source = "tidal", extra_config = null) {
390-
return this.post("/playlists/monitor", { uuid, name, frequency, quality, source, extra_config });
389+
monitorPlaylist(uuid, name, frequency, quality, source = "tidal", extra_config = null, use_playlist_folder = false) {
390+
return this.post("/playlists/monitor", { uuid, name, frequency, quality, source, extra_config, use_playlist_folder });
391391
}
392392

393393
removeMonitoredPlaylist(uuid) {

frontend/src/components/TidalPlaylists/MonitoredList.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ export function MonitoredList() {
111111
playlist.uuid,
112112
playlist.name,
113113
newFreq,
114-
playlist.quality
114+
playlist.quality,
115+
playlist.source,
116+
playlist.extra_config,
117+
playlist.use_playlist_folder
115118
).then(() => {
116119
addToast("Frequency updated", "success");
117120
fetchPlaylists(); // Refresh to be sure

frontend/src/components/TidalPlaylists/PlaylistSearch.jsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ function extractPlaylistUuid(input) {
88
if (!input) return null;
99
const trimmed = input.trim();
1010

11-
11+
1212
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
1313

14-
14+
1515
if (uuidPattern.test(trimmed)) {
1616
return trimmed;
1717
}
1818

19-
19+
2020
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);
2121
if (urlMatch) {
2222
return urlMatch[1];
@@ -30,16 +30,16 @@ export function PlaylistSearch({ onSyncStarted }) {
3030
const [loading, setLoading] = useState(false);
3131
const [results, setResults] = useState([]);
3232
const [officialOnly, setOfficialOnly] = useState(false);
33-
const [selectedPlaylist, setSelectedPlaylist] = useState(null);
33+
const [selectedPlaylist, setSelectedPlaylist] = useState(null);
3434
const addToast = useToastStore((state) => state.addToast);
3535

3636
const handleSearch = async () => {
3737
if (!query.trim()) return;
3838

39-
39+
4040
const extractedUuid = extractPlaylistUuid(query);
4141
if (extractedUuid) {
42-
42+
4343
setLoading(true);
4444
setResults([]);
4545
try {
@@ -85,7 +85,7 @@ export function PlaylistSearch({ onSyncStarted }) {
8585
const [monitoredUuids, setMonitoredUuids] = useState(new Set());
8686

8787
useEffect(() => {
88-
88+
8989
api.getMonitoredPlaylists().then(list => {
9090
setMonitoredUuids(new Set(list.map(p => p.uuid)));
9191
}).catch(err => console.error("Failed to load monitored status", err));
@@ -330,6 +330,7 @@ function PlaylistCoverImage({ cover, title }) {
330330
function MonitorModal({ playlist, onClose, onSuccess }) {
331331
const [frequency, setFrequency] = useState("manual");
332332
const [quality, setQuality] = useState("LOSSLESS");
333+
const [usePlaylistFolder, setUsePlaylistFolder] = useState(false);
333334
const [submitting, setSubmitting] = useState(false);
334335
const addToast = useToastStore((state) => state.addToast);
335336

@@ -338,7 +339,8 @@ function MonitorModal({ playlist, onClose, onSuccess }) {
338339
setSubmitting(true);
339340
try {
340341
const uuid = playlist.uuid || playlist.id;
341-
await api.monitorPlaylist(uuid, playlist.title, frequency, quality);
342+
// monitorPlaylist(uuid, name, frequency, quality, source, extra_config, use_playlist_folder)
343+
await api.monitorPlaylist(uuid, playlist.title, frequency, quality, "tidal", null, usePlaylistFolder);
342344
addToast(`Started monitoring "${playlist.title}"`, "success");
343345
onSuccess();
344346
} catch (e) {
@@ -391,6 +393,22 @@ function MonitorModal({ playlist, onClose, onSuccess }) {
391393
</select>
392394
</div>
393395

396+
<div>
397+
<label class="flex items-center gap-2 cursor-pointer">
398+
<input
399+
type="checkbox"
400+
checked={usePlaylistFolder}
401+
onChange={(e) => setUsePlaylistFolder(e.target.checked)}
402+
class="rounded border-border text-primary focus:ring-primary h-4 w-4"
403+
/>
404+
<span class="text-sm font-medium text-text">Download tracks to playlist folder</span>
405+
</label>
406+
<p class="text-xs text-text-muted mt-1 ml-6">
407+
Creates a standalone folder "{playlist.title}" containing all tracks.
408+
Useful for keeping files together, but duplicates tracks if they already exist in library.
409+
</p>
410+
</div>
411+
394412
<div class="flex gap-3 justify-end mt-6">
395413
<button
396414
type="button"

0 commit comments

Comments
 (0)