From 848b0b9366b9bde973780ef2efa2c9c7db0e2cbd Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:09:22 -0600 Subject: [PATCH 01/17] Fix indentation for playlist_name parameter --- octogen/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/octogen/main.py b/octogen/main.py index bc35b9b..8aaaa9b 100644 --- a/octogen/main.py +++ b/octogen/main.py @@ -1051,7 +1051,7 @@ def run(self) -> None: top_genres=top_genres, favorited_songs=favorited_songs, low_rated_songs=low_rated_songs, - playlist_name=playlist_name # NEW + playlist_name=playlist_name ) if hybrid_songs: self.create_playlist(playlist_name, hybrid_songs, max_songs=30) From 6517dca1d409260b46280d451cbd857df81aa270 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:52:07 -0600 Subject: [PATCH 02/17] fix: return full song pool from hybrid mix + fix ListenBrainz validation filter Bug 1 (main.py): _generate_hybrid_daily_mix was slicing songs[:30] before passing to _process_recommendations. Since _is_duplicate() both checks AND adds to self.processed_songs, later playlists with overlapping genres would have all 30 of their songs already flagged as duplicates -> 0 songs added. Fix: return the full pool so _process_recommendations can iterate past duplicates and still fill max_songs=30. Bug 2 (listenbrainz.py): get_created_for_you_playlists() was filtering for top-level 'id' and 'name' keys, but the ListenBrainz API returns JSPF format where each playlist is wrapped as {"playlist": {"title": ..., "identifier": ...}}. No playlists ever passed the filter -> always 0 playlists found. Fix: validate against the correct nested structure. --- octogen/api/listenbrainz.py | 11 ++++++++--- octogen/main.py | 6 +++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/octogen/api/listenbrainz.py b/octogen/api/listenbrainz.py index 4666451..496e95a 100644 --- a/octogen/api/listenbrainz.py +++ b/octogen/api/listenbrainz.py @@ -68,9 +68,14 @@ def get_created_for_you_playlists(self, count: int = 25, offset: int = 0) -> Lis playlists = response["playlists"] - # Add minimal validation: only include playlists with 'id' and 'name' - required = {"id", "name"} - playlists = [p for p in playlists if all(k in p for k in required)] + # Validate: the ListenBrainz API returns JSPF format where each item is + # {"playlist": {"title": ..., "identifier": ..., "track": [...], ...}} + # The old filter checked for top-level 'id' and 'name' keys which don't + # exist in JSPF format, causing ALL playlists to be silently dropped. + playlists = [ + p for p in playlists + if p.get("playlist", {}).get("title") and p.get("playlist", {}).get("identifier") + ] # DEBUG if playlists: diff --git a/octogen/main.py b/octogen/main.py index 8aaaa9b..979990f 100644 --- a/octogen/main.py +++ b/octogen/main.py @@ -763,7 +763,11 @@ def _generate_hybrid_daily_mix( logger.info(f"🤖 {label}: Got {len(llm_songs)} songs from LLM") logger.info(f"🎵 {label}: Total {len(songs)} songs (AudioMuse: {audiomuse_actual_count}, LLM: {len(llm_songs)})") - return songs[:30] # Ensure we return exactly 30 songs + # Return the full pool so _process_recommendations can iterate past cross-playlist + # duplicates and still find enough unique songs to fill max_songs. + # DO NOT slice here — slicing to 30 caused later playlists to get 0 songs because + # all 30 candidates had already been marked as processed by earlier playlists. + return songs def _generate_llm_songs_for_daily_mix( self, From dd74489584eb407e53a6e6b0d9f701fda79aba5a Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:48:56 -0600 Subject: [PATCH 03/17] refactor: strict-fill _process_recommendations + AudioMuse dedup - Rewrote _process_recommendations with per-playlist dedup (Set), up to 3 download rounds, similar-song check, and a 5x candidate pool so every playlist can independently reach max_songs even when earlier playlists already consumed popular tracks. - Fixed AudioMuse prompt loop: now accumulates unique songs across prompt variants instead of overwriting with each attempt, ensuring the full songs_per_mix quota is filled from variant results. --- octogen/main.py | 228 +++++++++++++++++++++++++++--------------------- 1 file changed, 130 insertions(+), 98 deletions(-) diff --git a/octogen/main.py b/octogen/main.py index 979990f..b4ea5db 100644 --- a/octogen/main.py +++ b/octogen/main.py @@ -562,97 +562,120 @@ def _process_recommendations( recommendations: List[Dict], max_songs: int = 100, ) -> List[str]: - """Process recommendations with batched scanning.""" - song_ids: List[str] = [] - needs_download = [] - total = min(len(recommendations), max_songs) - - logger.info("Processing playlist '%s': %d songs to check", playlist_name, total) - - # Phase 1: Check library and collect songs that need downloading + """Process recommendations with strict-fill and per-playlist dedup. + + Keeps scanning/downloading in up to 3 rounds until len(song_ids) == max_songs + or all candidates are exhausted. Dedup is scoped per-playlist so every + playlist can independently fill to max_songs. + """ + MAX_ROUNDS = 3 + # Give ourselves a generous candidate pool (5x target) to survive skips/failures + max_candidates = min(len(recommendations), max(50, max_songs * 5)) + song_ids: List[str] = [] - needs_download = [] - + playlist_seen: Set[Tuple[str, str]] = set() # per-playlist dedup + download_attempted: Set[Tuple[str, str]] = set() idx = 0 - added = 0 - recommendation_count = len(recommendations) - # Fill up to max_songs, keep scanning recommendations until you have enough unique/eligible tracks - while added < max_songs and idx < recommendation_count: - rec = recommendations[idx] - idx += 1 - - artist = (rec.get("artist") or "").strip() - title = (rec.get("title") or "").strip() - - if not artist or not title: - continue - - if self._is_duplicate(artist, title): - logger.debug("Skipping duplicate: %s - %s", artist, title) - self.stats["songs_skipped_duplicate"] += 1 - continue - - # Log progress based on 'added', not idx - if added % 10 == 0 or added == 0 or added + 1 == max_songs: - logger.info(" [%s] Checking library: %d/%d", playlist_name, added + 1, max_songs) - - song_id = self.nd.search_song(artist, title) - if song_id: - if not self._check_and_skip_low_rating(song_id, artist, title): - song_ids.append(song_id) - self.stats["songs_found"] += 1 - added += 1 - else: - needs_download.append((artist, title)) - added += 1 - - # Continue as you already do, with downloads for needs_download, batch scanning, etc. - - # Phase 2: Batch download all missing songs - if needs_download and not self.dry_run: - logger.info(" [%s] Downloading %d missing songs in batch...", playlist_name, len(needs_download)) - - downloaded_count = 0 - for idx, (artist, title) in enumerate(needs_download, 1): - if idx % 5 == 0 or idx == 1 or idx == len(needs_download): - logger.info(" [%s] Download progress: %d/%d", playlist_name, idx, len(needs_download)) - - success, _result = self.octo.search_and_trigger_download(artist, title) - if success: - downloaded_count += 1 - - if downloaded_count > 0: - # Single scan for all downloads - logger.info(" [%s] Waiting for downloads to settle...", playlist_name) - wait_time = self.download_delay * min(downloaded_count, 5) # Scale wait time, max 5x - time.sleep(wait_time) - - logger.info(" [%s] Triggering library scan...", playlist_name) - self.nd.trigger_scan() - self.nd.wait_for_scan() - time.sleep(self.post_scan_delay) - - # Phase 3: Re-search for downloaded songs - logger.info(" [%s] Checking for downloaded songs...", playlist_name) - for artist, title in needs_download: - song_id = self.nd.search_song(artist, title) - if song_id: - if not self._check_and_skip_low_rating(song_id, artist, title): + + def seen_key(a: str, t: str) -> Tuple[str, str]: + return (a.lower().strip(), t.lower().strip()) + + logger.info("Processing playlist '%s': %d songs to check (target=%d, max_candidates=%d)", + playlist_name, len(recommendations), max_songs, max_candidates) + + for round_num in range(1, MAX_ROUNDS + 1): + needs_download: List[Tuple[str, str]] = [] + + # Phase 1: scan candidates until we have enough confirmed + pending to hit target + while idx < max_candidates and (len(song_ids) + len(needs_download)) < max_songs: + rec = recommendations[idx] + idx += 1 + + artist = (rec.get("artist") or "").strip() + title = (rec.get("title") or "").strip() + if not artist or not title: + continue + + k = seen_key(artist, title) + if k in playlist_seen: + self.stats["songs_skipped_duplicate"] += 1 + continue + playlist_seen.add(k) + + checked = len(song_ids) + len(needs_download) + 1 + if checked % 10 == 1 or checked == max_songs: + logger.info(" [%s] Checking library: %d/%d", playlist_name, checked, max_songs) + + song_id = self.nd.search_song(artist, title) + if song_id: + if not self._check_and_skip_low_rating(song_id, artist, title): + song_ids.append(song_id) + self.stats["songs_found"] += 1 + else: + similar_song_id = self.nd.check_for_similar_song(artist, title) + if similar_song_id: + if not self._check_and_skip_low_rating(similar_song_id, artist, title): + song_ids.append(similar_song_id) + self.stats["songs_found"] += 1 + self.stats["duplicates_prevented"] += 1 + elif not self.dry_run and k not in download_attempted: + needs_download.append((artist, title)) + download_attempted.add(k) + + if len(song_ids) >= max_songs: + break + + # Phase 2: batch download everything collected this round + if needs_download and not self.dry_run: + logger.info(" [%s] Round %d/%d: Downloading %d missing songs in batch...", + playlist_name, round_num, MAX_ROUNDS, len(needs_download)) + + downloaded_count = 0 + for d_idx, (artist, title) in enumerate(needs_download, 1): + if d_idx % 5 == 0 or d_idx == 1 or d_idx == len(needs_download): + logger.info(" [%s] Download progress: %d/%d", playlist_name, d_idx, len(needs_download)) + success, _result = self.octo.search_and_trigger_download(artist, title) + if success: + downloaded_count += 1 + + if downloaded_count > 0: + logger.info(" [%s] Waiting for downloads to settle...", playlist_name) + wait_time = self.download_delay * min(downloaded_count, 5) + time.sleep(wait_time) + + logger.info(" [%s] Triggering library scan...", playlist_name) + self.nd.trigger_scan() + self.nd.wait_for_scan() + time.sleep(self.post_scan_delay) + + logger.info(" [%s] Checking for downloaded songs...", playlist_name) + for artist, title in needs_download: + if len(song_ids) >= max_songs: + break + song_id = self.nd.search_song(artist, title) + if song_id and not self._check_and_skip_low_rating(song_id, artist, title): song_ids.append(song_id) self.stats["songs_downloaded"] += 1 - else: - self.stats["songs_failed"] += 1 - else: - logger.warning(" [%s] All %d download attempts failed", playlist_name, len(needs_download)) - self.stats["songs_failed"] += len(needs_download) - - elif needs_download and self.dry_run: - logger.info(" [%s] [DRY RUN] Would download %d songs", playlist_name, len(needs_download)) - - logger.info(" [%s] Complete: %d/%d songs added to playlist", - playlist_name, len(song_ids), total) - - return song_ids + elif not song_id: + self.stats["songs_failed"] += 1 + else: + logger.warning(" [%s] All %d download attempts failed", playlist_name, len(needs_download)) + self.stats["songs_failed"] += len(needs_download) + + elif needs_download and self.dry_run: + logger.info(" [%s] [DRY RUN] Would download %d songs", playlist_name, len(needs_download)) + + if not needs_download or idx >= max_candidates: + break # nothing left to try + + if len(song_ids) < max_songs: + logger.warning(" [%s] Underfilled: %d/%d songs (pool exhausted after %d candidates)", + playlist_name, len(song_ids), max_songs, idx) + else: + logger.info(" [%s] Complete: %d/%d songs added to playlist", + playlist_name, len(song_ids), max_songs) + + return song_ids[:max_songs] @@ -716,21 +739,30 @@ def _generate_hybrid_daily_mix( if genre_focus: prompt_variants.append(f"{genre_focus} music") # genre only prompt_variants.append(f"{genre_focus}") # genre only, no "music" - audiomuse_songs = [] logger.debug(f"AudioMuse prompt attempts: {prompt_variants}") + audiomuse_collected: List[Dict] = [] + audiomuse_seen: Set[Tuple[str, str]] = set() for prompt in prompt_variants: - logger.debug(f"AudioMuse request: '{prompt}'") - audiomuse_songs = self.audiomuse_client.generate_playlist( + remaining = audiomuse_songs_count - len(audiomuse_collected) + if remaining <= 0: + break + logger.debug(f"AudioMuse request: '{prompt}' (need {remaining} more)") + batch = self.audiomuse_client.generate_playlist( user_request=prompt, - num_songs=audiomuse_songs_count - ) - if len(audiomuse_songs) >= 3: # threshold; adjust as needed - logger.info(f"AudioMuse prompt '{prompt}' yielded {len(audiomuse_songs)} songs") + num_songs=remaining + ) or [] + logger.info(f"AudioMuse prompt '{prompt}' yielded {len(batch)} songs") + for s in batch: + a = (s.get("artist") or "").strip() + t = (s.get("title") or "").strip() + k = (a.lower(), t.lower()) + if a and t and k not in audiomuse_seen: + audiomuse_seen.add(k) + audiomuse_collected.append({"artist": a, "title": t}) + if len(audiomuse_collected) >= audiomuse_songs_count: break - # Convert AudioMuse format to Octogen format - for song in audiomuse_songs: - songs.append({"artist": song.get('artist', ''), "title": song.get('title', '')}) - audiomuse_actual_count = len(songs) + songs.extend(audiomuse_collected) + audiomuse_actual_count = len(audiomuse_collected) label = f"Daily Mix {mix_number}" if mix_number in [1,2,3,4,5,6] else playlist_name logger.info(f"📻 {label}: Got {audiomuse_actual_count} songs from AudioMuse-AI") if audiomuse_actual_count < audiomuse_songs_count: From 665390b31e280b5e6e512c0c974e37ef59d82ea7 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:53:17 -0600 Subject: [PATCH 04/17] Add mbid retrieval in recommendation processing --- octogen/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/octogen/main.py b/octogen/main.py index b4ea5db..3074853 100644 --- a/octogen/main.py +++ b/octogen/main.py @@ -494,7 +494,8 @@ def _process_single_recommendation(self, rec: Dict) -> Optional[str]: """Process a single recommendation and return song ID if successful.""" artist = (rec.get("artist") or "").strip() title = (rec.get("title") or "").strip() - + mbid = rec.get("mbid") + if not artist or not title: return None From 02f50e34bfa194f02077a26cb872a05855bfe564 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:57:09 -0600 Subject: [PATCH 05/17] Enhance search_song with optional mbid parameter Added optional mbid parameter to search_song method for MusicBrainz ID lookup. --- octogen/api/navidrome.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/octogen/api/navidrome.py b/octogen/api/navidrome.py index 8e6e248..1e6e041 100644 --- a/octogen/api/navidrome.py +++ b/octogen/api/navidrome.py @@ -400,7 +400,7 @@ def _calculate_match_score(self, search_artist: str, search_title: str, title_ratio = difflib.SequenceMatcher(None, search_title, result_title).ratio() return (artist_ratio * 0.5) + (title_ratio * 0.5) - def search_song(self, artist: str, title: str) -> Optional[str]: + def search_song(self, artist: str, title: str, mbid: str = None) -> Optional[str]: """Search for a song with fuzzy matching and version detection. Args: @@ -410,8 +410,22 @@ def search_song(self, artist: str, title: str) -> Optional[str]: Returns: Song ID if found, None otherwise """ - - # Normalize search terms + if mbid: + response = self._request("search3", { + "query": mbid, + "songCount": 5, + "artistCount": 0, + "albumCount": 0 + }) + if response: + for song in response.get("searchResult3", {}).get("song", []): + if song.get("musicBrainzId") == mbid: + logger.debug("MBID exact match: %s - %s", + song.get("artist"), song.get("title")) + return song["id"] + logger.debug("MBID lookup missed for %s, falling through to fuzzy", mbid) + + # Step 1: Normalize search terms search_artist_norm = self._normalize_for_comparison(artist, preserve_version=False) search_title_norm = self._normalize_for_comparison(title, preserve_version=False) search_version = self._has_version_marker(title) From 25f0e29f24027d3898d415cc9bf1effdcaeadef9 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:07:04 -0600 Subject: [PATCH 06/17] Add MBID support and improve logging in main.py --- octogen/main.py | 158 ++++++++++++++++++++++++++++-------------------- 1 file changed, 92 insertions(+), 66 deletions(-) diff --git a/octogen/main.py b/octogen/main.py index 3074853..ff21377 100644 --- a/octogen/main.py +++ b/octogen/main.py @@ -507,7 +507,7 @@ def _process_single_recommendation(self, rec: Dict) -> Optional[str]: # Priority 2: Search library thoroughly with fuzzy matching logger.debug("Checking library for: %s - %s", artist, title) - song_id = self.nd.search_song(artist, title) + song_id = self.nd.search_song(artist, title, mbid=mbid) if song_id: if self._check_and_skip_low_rating(song_id, artist, title): @@ -564,50 +564,58 @@ def _process_recommendations( max_songs: int = 100, ) -> List[str]: """Process recommendations with strict-fill and per-playlist dedup. - + Keeps scanning/downloading in up to 3 rounds until len(song_ids) == max_songs - or all candidates are exhausted. Dedup is scoped per-playlist so every + or all candidates are exhausted. Dedup is scoped per-playlist so every playlist can independently fill to max_songs. """ MAX_ROUNDS = 3 + # Give ourselves a generous candidate pool (5x target) to survive skips/failures max_candidates = min(len(recommendations), max(50, max_songs * 5)) - + song_ids: List[str] = [] - playlist_seen: Set[Tuple[str, str]] = set() # per-playlist dedup + playlist_seen: Set[Tuple[str, str]] = set() # per-playlist dedup download_attempted: Set[Tuple[str, str]] = set() + idx = 0 - + def seen_key(a: str, t: str) -> Tuple[str, str]: return (a.lower().strip(), t.lower().strip()) - - logger.info("Processing playlist '%s': %d songs to check (target=%d, max_candidates=%d)", - playlist_name, len(recommendations), max_songs, max_candidates) - + + logger.info( + "Processing playlist '%s': %d songs to check (target=%d, max_candidates=%d)", + playlist_name, len(recommendations), max_songs, max_candidates + ) + for round_num in range(1, MAX_ROUNDS + 1): - needs_download: List[Tuple[str, str]] = [] - + needs_download: List[Tuple[str, str, Optional[str]]] = [] + # Phase 1: scan candidates until we have enough confirmed + pending to hit target while idx < max_candidates and (len(song_ids) + len(needs_download)) < max_songs: rec = recommendations[idx] idx += 1 - + artist = (rec.get("artist") or "").strip() title = (rec.get("title") or "").strip() + mbid = rec.get("mbid") # <-- NEW + if not artist or not title: continue - + k = seen_key(artist, title) if k in playlist_seen: self.stats["songs_skipped_duplicate"] += 1 continue playlist_seen.add(k) - + checked = len(song_ids) + len(needs_download) + 1 if checked % 10 == 1 or checked == max_songs: - logger.info(" [%s] Checking library: %d/%d", playlist_name, checked, max_songs) - - song_id = self.nd.search_song(artist, title) + logger.info(" [%s] Checking library: %d/%d", playlist_name, checked, max_songs) + + # MBID-aware lookup (falls back internally if mbid is None or misses) + song_id = self.nd.search_song(artist, title, mbid=mbid) + if song_id: if not self._check_and_skip_low_rating(song_id, artist, title): song_ids.append(song_id) @@ -620,62 +628,72 @@ def seen_key(a: str, t: str) -> Tuple[str, str]: self.stats["songs_found"] += 1 self.stats["duplicates_prevented"] += 1 elif not self.dry_run and k not in download_attempted: - needs_download.append((artist, title)) + needs_download.append((artist, title, mbid)) download_attempted.add(k) - - if len(song_ids) >= max_songs: - break - + + if len(song_ids) >= max_songs: + break + # Phase 2: batch download everything collected this round if needs_download and not self.dry_run: - logger.info(" [%s] Round %d/%d: Downloading %d missing songs in batch...", - playlist_name, round_num, MAX_ROUNDS, len(needs_download)) - + logger.info( + " [%s] Round %d/%d: Downloading %d missing songs in batch...", + playlist_name, round_num, MAX_ROUNDS, len(needs_download) + ) + downloaded_count = 0 - for d_idx, (artist, title) in enumerate(needs_download, 1): + for d_idx, (artist, title, _mbid) in enumerate(needs_download, 1): if d_idx % 5 == 0 or d_idx == 1 or d_idx == len(needs_download): - logger.info(" [%s] Download progress: %d/%d", playlist_name, d_idx, len(needs_download)) + logger.info(" [%s] Download progress: %d/%d", playlist_name, d_idx, len(needs_download)) + success, _result = self.octo.search_and_trigger_download(artist, title) if success: downloaded_count += 1 - + if downloaded_count > 0: - logger.info(" [%s] Waiting for downloads to settle...", playlist_name) + logger.info(" [%s] Waiting for downloads to settle...", playlist_name) wait_time = self.download_delay * min(downloaded_count, 5) time.sleep(wait_time) - - logger.info(" [%s] Triggering library scan...", playlist_name) + + logger.info(" [%s] Triggering library scan...", playlist_name) self.nd.trigger_scan() self.nd.wait_for_scan() time.sleep(self.post_scan_delay) - - logger.info(" [%s] Checking for downloaded songs...", playlist_name) - for artist, title in needs_download: + + logger.info(" [%s] Checking for downloaded songs...", playlist_name) + for artist, title, mbid in needs_download: if len(song_ids) >= max_songs: break - song_id = self.nd.search_song(artist, title) + + # MBID-aware re-check after scan + song_id = self.nd.search_song(artist, title, mbid=mbid) + if song_id and not self._check_and_skip_low_rating(song_id, artist, title): song_ids.append(song_id) self.stats["songs_downloaded"] += 1 elif not song_id: self.stats["songs_failed"] += 1 else: - logger.warning(" [%s] All %d download attempts failed", playlist_name, len(needs_download)) + logger.warning(" [%s] All %d download attempts failed", playlist_name, len(needs_download)) self.stats["songs_failed"] += len(needs_download) - + elif needs_download and self.dry_run: - logger.info(" [%s] [DRY RUN] Would download %d songs", playlist_name, len(needs_download)) - + logger.info(" [%s] [DRY RUN] Would download %d songs", playlist_name, len(needs_download)) + if not needs_download or idx >= max_candidates: break # nothing left to try - + if len(song_ids) < max_songs: - logger.warning(" [%s] Underfilled: %d/%d songs (pool exhausted after %d candidates)", - playlist_name, len(song_ids), max_songs, idx) + logger.warning( + " [%s] Underfilled: %d/%d songs (pool exhausted after %d candidates)", + playlist_name, len(song_ids), max_songs, idx + ) else: - logger.info(" [%s] Complete: %d/%d songs added to playlist", - playlist_name, len(song_ids), max_songs) - + logger.info( + " [%s] Complete: %d/%d songs added to playlist", + playlist_name, len(song_ids), max_songs + ) + return song_ids[:max_songs] @@ -1146,15 +1164,16 @@ def run(self) -> None: if should_generate_regular and self.listenbrainz: logger.info("Creating ListenBrainz 'Created For You' playlists...") + try: playlists_before = self.stats["playlists_created"] + lb_playlists = self.listenbrainz.get_created_for_you_playlists() - for lb_playlist in lb_playlists: # The data is nested inside a "playlist" key playlist_data = lb_playlist.get("playlist", {}) playlist_name = playlist_data.get("title", "Unknown") - + # Get the identifier from the nested structure playlist_mbid = None if "identifier" in playlist_data: @@ -1164,71 +1183,77 @@ def run(self) -> None: playlist_mbid = identifier.split("/")[-1] elif isinstance(identifier, list) and len(identifier) > 0: playlist_mbid = identifier[0].split("/")[-1] - + if not playlist_mbid: logger.error("Cannot find playlist ID for: %s", playlist_name) continue - + # Determine if this is current week or last week renamed_playlist = None should_process = True - + if "Weekly Exploration" in playlist_name and "week of" in playlist_name: try: # Extract the date string date_part = playlist_name.split("week of ")[1].split()[0] # Gets "2026-02-09" playlist_date = datetime.strptime(date_part, "%Y-%m-%d") - + # Calculate start of current week (Monday) today = datetime.now() start_of_this_week = today - timedelta(days=today.weekday()) start_of_last_week = start_of_this_week - timedelta(days=7) - + # Compare dates (ignoring time) playlist_week_start = playlist_date.replace(hour=0, minute=0, second=0, microsecond=0) this_week_start = start_of_this_week.replace(hour=0, minute=0, second=0, microsecond=0) last_week_start = start_of_last_week.replace(hour=0, minute=0, second=0, microsecond=0) - + if playlist_week_start == this_week_start: renamed_playlist = "LB: Weekly Exploration" elif playlist_week_start == last_week_start: renamed_playlist = "LB: Last Week's Exploration" else: # Older than 2 weeks - skip - logger.info("Skipping old Weekly Exploration: %s (keeping only last 2 weeks)", playlist_name) + logger.info( + "Skipping old Weekly Exploration: %s (keeping only last 2 weeks)", + playlist_name + ) should_process = False - except Exception as e: + + except Exception: logger.warning("Could not parse date from playlist: %s", playlist_name) renamed_playlist = f"LB: {playlist_name}" + else: # Non-weekly playlists (Daily Jams, etc.) renamed_playlist = f"LB: {playlist_name}" - + if not should_process: continue - + logger.info("Processing: %s -> %s (MBID: %s)", playlist_name, renamed_playlist, playlist_mbid) + tracks = self.listenbrainz.get_playlist_tracks(playlist_mbid) - + # Process songs with download support (limit to 50) - found_ids = [] + found_ids: List[str] = [] for track in tracks[:50]: - # Use the same processing as AI recommendations (includes download) song_id = self._process_single_recommendation(track) if song_id: found_ids.append(song_id) - + if found_ids: - self.nd.create_playlist(renamed_playlist, found_ids) - + if self.nd.create_playlist(renamed_playlist, found_ids): + self.stats["playlists_created"] += 1 + playlists_created = self.stats["playlists_created"] - playlists_before - self.service_tracker.record( "listenbrainz", success=True, playlists=playlists_created ) logger.info("ListenBrainz service succeeded: %d playlists", playlists_created) + except Exception as e: self.service_tracker.record( "listenbrainz", @@ -1236,12 +1261,13 @@ def run(self) -> None: reason=str(e)[:100] ) logger.warning("ListenBrainz service failed: %s", e) + # Record successful regular playlist generation if should_generate_regular: record_regular_playlist_generation(BASE_DIR) - # Time-Period Playlist Generation (NEW FEATURE) + # Time-Period Playlist Generation try: from octogen.scheduler.timeofday import ( should_generate_period_playlist_now, From 2424ac8586773c273c5994e63e87e1176b5ede1a Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:09:05 -0600 Subject: [PATCH 07/17] Enhance MBID search with artist and title validation --- octogen/api/navidrome.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/octogen/api/navidrome.py b/octogen/api/navidrome.py index 1e6e041..a057f4e 100644 --- a/octogen/api/navidrome.py +++ b/octogen/api/navidrome.py @@ -411,19 +411,21 @@ def search_song(self, artist: str, title: str, mbid: str = None) -> Optional[str Song ID if found, None otherwise """ if mbid: - response = self._request("search3", { - "query": mbid, - "songCount": 5, + # Try a targeted artist+title search first, then validate MBID on the results + mbid_check_response = self._request("search3", { + "query": f'"{artist}" "{title}"', + "songCount": 10, "artistCount": 0, "albumCount": 0 }) - if response: - for song in response.get("searchResult3", {}).get("song", []): + if mbid_check_response: + for song in mbid_check_response.get("searchResult3", {}).get("song", []): if song.get("musicBrainzId") == mbid: - logger.debug("MBID exact match: %s - %s", + logger.debug("MBID exact match: %s - %s", song.get("artist"), song.get("title")) return song["id"] logger.debug("MBID lookup missed for %s, falling through to fuzzy", mbid) + # Step 1: Normalize search terms search_artist_norm = self._normalize_for_comparison(artist, preserve_version=False) From 9dc48340495774c80bbffdb0b7b59145ca0ae760 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:11:13 -0600 Subject: [PATCH 08/17] Fix syntax error in recommendations appending --- octogen/api/lastfm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/octogen/api/lastfm.py b/octogen/api/lastfm.py index a8a2cf7..63bec92 100644 --- a/octogen/api/lastfm.py +++ b/octogen/api/lastfm.py @@ -98,7 +98,8 @@ def get_recommended_tracks(self, limit: int = 50) -> List[Dict]: for track in tracks_response["toptracks"].get("track", []): recommendations.append({ "artist": track["artist"]["name"], - "title": track["name"] + "title": track["name"], + "mbid": mbid, }) if len(recommendations) >= limit: break From 14ea7f8dc533b02a7155d7ca254650cdf4c226e4 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:13:31 -0600 Subject: [PATCH 09/17] Add mbid parameter to search_song method --- octogen/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/octogen/main.py b/octogen/main.py index ff21377..6950717 100644 --- a/octogen/main.py +++ b/octogen/main.py @@ -546,7 +546,7 @@ def _process_single_recommendation(self, rec: Dict) -> Optional[str]: self.nd.wait_for_scan() time.sleep(self.post_scan_delay) - song_id = self.nd.search_song(artist, title) + song_id = self.nd.search_song(artist, title, mbid=mbid) if song_id: if self._check_and_skip_low_rating(song_id, artist, title): From 8a6e66e18cbcf8d7416ac132144f827492c75549 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:26:51 -0600 Subject: [PATCH 10/17] Enhance LLM song request with buffer for failures Added a buffer to the number of LLM songs requested to account for version mismatches and download failures. --- octogen/main.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/octogen/main.py b/octogen/main.py index 6950717..764d0a9 100644 --- a/octogen/main.py +++ b/octogen/main.py @@ -791,10 +791,13 @@ def _generate_hybrid_daily_mix( # If AudioMuse returned fewer songs, request more from LLM to reach target num_llm_songs = llm_songs_count if self.audiomuse_client else 30 if self.audiomuse_client and audiomuse_actual_count < audiomuse_songs_count: - # Request extra LLM songs to compensate - shortfall = audiomuse_songs_count - audiomuse_actual_count - num_llm_songs = llm_songs_count + shortfall - logger.info(f"🔄 AudioMuse returned {audiomuse_actual_count}/{audiomuse_songs_count} songs, requesting {num_llm_songs} from LLM") + # Request extra LLM songs to compensate, plus a buffer for version + # mismatches and download failures + shortfall = audiomuse_songs_count - audiomuse_actual_count + buffer = max(15, int((shortfall + llm_songs_count) * 0.5)) + num_llm_songs = llm_songs_count + shortfall + buffer + logger.info(f"🔄 AudioMuse returned {audiomuse_actual_count}/{audiomuse_songs_count} songs, " + f"requesting {num_llm_songs} from LLM (includes {buffer} song buffer)") logger.debug(f"Requesting {num_llm_songs} songs from LLM for Daily Mix {mix_number}") # We'll use the AI engine to generate just the LLM portion From a571927d4e4bf15f2211c48b855c6f060b1d473a Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:29:33 -0600 Subject: [PATCH 11/17] Fix mbid assignment in Last.fm recommendations --- octogen/api/lastfm.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/octogen/api/lastfm.py b/octogen/api/lastfm.py index 63bec92..e04daba 100644 --- a/octogen/api/lastfm.py +++ b/octogen/api/lastfm.py @@ -96,13 +96,18 @@ def get_recommended_tracks(self, limit: int = 50) -> List[Dict]: continue for track in tracks_response["toptracks"].get("track", []): + raw_mbid = track.get("mbid", "") + track_mbid = raw_mbid if raw_mbid else None + recommendations.append({ "artist": track["artist"]["name"], "title": track["name"], - "mbid": mbid, + "mbid": track_mbid, }) + if len(recommendations) >= limit: break + if len(recommendations) >= limit: break From 4322eab3dd31d084387cc6cf10517a3192adf807 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:46:16 -0600 Subject: [PATCH 12/17] Implement filtering for unexpected playlists Added filtering for unexpected playlists returned by AI. --- octogen/ai/engine.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/octogen/ai/engine.py b/octogen/ai/engine.py index 285ff19..1fd28b7 100644 --- a/octogen/ai/engine.py +++ b/octogen/ai/engine.py @@ -696,6 +696,20 @@ def generate_all_playlists( ] all_playlists[playlist_name] = valid_songs + EXPECTED_PLAYLISTS = { + "Discovery", "Daily Mix 1", "Daily Mix 2", "Daily Mix 3", + "Daily Mix 4", "Daily Mix 5", "Daily Mix 6", + "Chill Vibes", "Workout Energy", "Focus Flow", "Drive Time" + } + + # Filter out any hallucinated extra playlists + unexpected = [k for k in all_playlists if k not in EXPECTED_PLAYLISTS] + if unexpected: + logger.warning("AI returned unexpected playlists (filtered): %s", unexpected) + for k in unexpected: + del all_playlists[k] + + self.response_cache = all_playlists self._record_ai_call() total = sum(len(songs) for songs in all_playlists.values()) From 32d92b0d380a68c62ccdde609514996de4c44223 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:12:26 -0600 Subject: [PATCH 13/17] Change default PERF_DOWNLOAD_DELAY to 10 seconds Updated the default value for PERF_DOWNLOAD_DELAY from 6 to 10 seconds. --- ENV_VARS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ENV_VARS.md b/ENV_VARS.md index 954fc8e..d1b8789 100644 --- a/ENV_VARS.md +++ b/ENV_VARS.md @@ -769,7 +769,7 @@ PERF_SCAN_TIMEOUT=60 ### PERF_DOWNLOAD_DELAY **Description**: Delay between downloads (seconds) -**Default**: `6` +**Default**: `10` **Range**: `1` to `30` **Example**: ```bash From 30ae1e7f7704c5a13915c7e37d74e1886bacad9b Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:14:53 -0600 Subject: [PATCH 14/17] Adjust performance download and post-scan delays Updated performance parameters for download and post-scan delays. --- octogen/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/octogen/main.py b/octogen/main.py index 764d0a9..50daaab 100644 --- a/octogen/main.py +++ b/octogen/main.py @@ -214,8 +214,8 @@ def _load_config_from_env(self) -> dict: "album_batch_size": self._get_env_int("PERF_ALBUM_BATCH_SIZE", 500), "max_albums_scan": self._get_env_int("PERF_MAX_ALBUMS_SCAN", 10000), "scan_timeout": self._get_env_int("PERF_SCAN_TIMEOUT", 60), - "download_delay_seconds": self._get_env_int("PERF_DOWNLOAD_DELAY", 6), - "post_scan_delay_seconds": self._get_env_int("PERF_POST_SCAN_DELAY", 10) + "download_delay_seconds": self._get_env_int("PERF_DOWNLOAD_DELAY", 10), + "post_scan_delay_seconds": self._get_env_int("PERF_POST_SCAN_DELAY", 30) }, "lastfm": { "enabled": self._get_env_bool("LASTFM_ENABLED", False), From 1eb75fe0d2b235c5717b381e81b2a2e1b7f5a5e4 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:15:55 -0600 Subject: [PATCH 15/17] Increase post scan delay to 30 seconds --- octogen/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/octogen/config.py b/octogen/config.py index cc7c69f..fbd7424 100644 --- a/octogen/config.py +++ b/octogen/config.py @@ -86,7 +86,7 @@ def load_config_from_env() -> Dict: "max_albums_scan": int(os.getenv("MAX_ALBUMS_SCAN", "10000")), "scan_timeout": int(os.getenv("SCAN_TIMEOUT", "60")), "download_delay_seconds": int(os.getenv("DOWNLOAD_DELAY_SECONDS", "10")), - "post_scan_delay_seconds": int(os.getenv("POST_SCAN_DELAY_SECONDS", "3")), + "post_scan_delay_seconds": int(os.getenv("POST_SCAN_DELAY_SECONDS", "30")), "download_batch_size": int(os.getenv("DOWNLOAD_BATCH_SIZE", "5")), "download_concurrency": int(os.getenv("DOWNLOAD_CONCURRENCY", "3")), }, From 175595c632f1b08ecc9b386052df8660c015ae74 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:37:07 -0600 Subject: [PATCH 16/17] Update docker-compose.yml Update env variables --- docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 31ee134..2b61762 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,7 +53,7 @@ services: # ============================================================================ # Cron expression for automatic scheduling (e.g., "0 2 * * *" for daily at 2 AM) # Leave unset or use "manual" to disable scheduling - SCHEDULE_CRON: ${SCHEDULE_CRON:-0 2,6,12,16,22 * * *} + SCHEDULE_CRON: ${SCHEDULE_CRON:-0 2,4,10,16,22 * * *} # Timezone for scheduled runs (IANA timezone name) TZ: ${TZ:-America/Chicago} @@ -132,8 +132,8 @@ services: # Time period boundaries (24-hour format) TIMEOFDAY_MORNING_START: ${TIMEOFDAY_MORNING_START:-4} - TIMEOFDAY_MORNING_END: ${TIMEOFDAY_MORNING_END:-12} - TIMEOFDAY_AFTERNOON_START: ${TIMEOFDAY_AFTERNOON_START:-12} + TIMEOFDAY_MORNING_END: ${TIMEOFDAY_MORNING_END:-10} + TIMEOFDAY_AFTERNOON_START: ${TIMEOFDAY_AFTERNOON_START:-10} TIMEOFDAY_AFTERNOON_END: ${TIMEOFDAY_AFTERNOON_END:-16} TIMEOFDAY_EVENING_START: ${TIMEOFDAY_EVENING_START:-16} TIMEOFDAY_EVENING_END: ${TIMEOFDAY_EVENING_END:-22} From b8bc1c5326d20f95376eb9ff9936e113500e5930 Mon Sep 17 00:00:00 2001 From: Blueion <128919662+Blueion76@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:37:59 -0600 Subject: [PATCH 17/17] Update .env.example --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index d92ec6b..59f5546 100644 --- a/.env.example +++ b/.env.example @@ -184,8 +184,8 @@ TIMEOFDAY_ENABLED=true # Time period boundaries (24-hour format) TIMEOFDAY_MORNING_START=4 -TIMEOFDAY_MORNING_END=12 -TIMEOFDAY_AFTERNOON_START=12 +TIMEOFDAY_MORNING_END=10 +TIMEOFDAY_AFTERNOON_START=10 TIMEOFDAY_AFTERNOON_END=16 TIMEOFDAY_EVENING_START=16 TIMEOFDAY_EVENING_END=22