Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/api/routers/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 23 additions & 10 deletions backend/playlist_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)}")
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -293,29 +306,29 @@ 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
else:
# 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
Expand Down Expand Up @@ -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
))
Expand All @@ -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}")
Expand Down
52 changes: 51 additions & 1 deletion backend/tidal_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/TidalPlaylists/MonitoredList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 26 additions & 8 deletions frontend/src/components/TidalPlaylists/PlaylistSearch.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);

Expand All @@ -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) {
Expand Down Expand Up @@ -391,6 +393,22 @@ function MonitorModal({ playlist, onClose, onSuccess }) {
</select>
</div>

<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={usePlaylistFolder}
onChange={(e) => setUsePlaylistFolder(e.target.checked)}
class="rounded border-border text-primary focus:ring-primary h-4 w-4"
/>
<span class="text-sm font-medium text-text">Download tracks to playlist folder</span>
</label>
<p class="text-xs text-text-muted mt-1 ml-6">
Creates a standalone folder "{playlist.title}" containing all tracks.
Useful for keeping files together, but duplicates tracks if they already exist in library.
</p>
</div>

<div class="flex gap-3 justify-end mt-6">
<button
type="button"
Expand Down