"""YouTube playlist synchronizer. Mirrors Sony.Filtr.PlaylistSynchronization.Synchronizer.YoutubeSynchronizer from .NET. Algorithm: 1. Build authenticated YouTube API client from service account's access/refresh token. 2. Fetch all current target playlist items. 3. For each source track: look up YouTube video ID via: a. ISRC cache (tblPlaylistSynchronizationTrack, ServiceType.YouTube). b. YouTube search with scoring: - Score up videos from official-looking channels. - Penalize remasters, album versions, karaoke, blacklisted titles. - Skip blacklisted channels; prefer whitelisted channels. - Cache the winning video ID for 7 days. 4. Remove duplicate playlist items. 5. Add new videos at desired positions. 6. Remove stale items (videos in target not in source). 7. Reorder remaining items to match source order. 8. Return SyncCounts. """ import asyncio import logging from datetime import datetime, timedelta from typing import Optional from google.oauth2.credentials import Credentials from sqlalchemy.ext.asyncio import AsyncSession from playlist_sync.clients import youtube_client from playlist_sync.models.enums import ServiceType, SyncError from playlist_sync.models.exceptions import PlaylistSynchronizationException from playlist_sync.models.generic_playlist import GenericPlaylist, GenericTrack from playlist_sync.models.playlist_sync_result import ( PlaylistSynchronizationResult, SyncCounts, ) from playlist_sync.models.service_account import ServiceAccount from playlist_sync.models.sync_task import PlaylistSynchronization from playlist_sync.services import flagged_channel_service, isrc_cache_service from .base import BaseSynchronizer, warn_if_source_has_duplicates logger = logging.getLogger("playlist_sync.youtube_synchronizer") _ISRC_CACHE_MAX_AGE_DAYS = 7 # Mirrors the suffix/phrase blacklists in the .NET YoutubeSynchronizer _OFFICIAL_SUFFIXES = [ "official", "officiel", "offiziell", "ufficiale", "offisiell", "oficial", "officiell", "channel", ] _BLACKLISTED_PARTIAL = ["remaster", "album", "full length"] _BLACKLISTED_EXACT = [ "single version", "radio edit", "single edit", "radio version", "radio mix", "single mix", "stereo version", "extended version", "original mix", "single remix", ] _BLACKLISTED_TITLE_WORDS = ["karaoke", "behind the scenes"] def _score_video( item: dict, channel_name: str, whitelisted: list[str], blacklisted: list[str] ) -> int: """Assign a score to a YouTube search result. Higher = better match.""" score = 0 channel_id = item.get("snippet", {}).get("channelId", "") channel_title = (item.get("snippet", {}).get("channelTitle") or "").lower() title = (item.get("snippet", {}).get("title") or "").lower() # Blacklisted channels get lowest priority if channel_id in blacklisted: return -1000 # Whitelisted channels get a big boost if channel_id in whitelisted: score += 100 # Official-looking channel name if any(suffix in channel_title for suffix in _OFFICIAL_SUFFIXES): score += 50 # Penalise blacklisted title phrases for phrase in _BLACKLISTED_EXACT: if phrase in title: score -= 20 for phrase in _BLACKLISTED_PARTIAL: if phrase in title: score -= 10 for word in _BLACKLISTED_TITLE_WORDS: if word in title: score -= 30 return score class YoutubeSynchronizer(BaseSynchronizer): """YouTube playlist synchronizer.""" async def _copy_internal( self, generic_playlist: GenericPlaylist, sync: PlaylistSynchronization, service_account: ServiceAccount, session: AsyncSession, ) -> PlaylistSynchronizationResult: logger.debug( "YoutubeSynchronizer: copying '%s' to YouTube playlist %s", generic_playlist.name, sync.to_playlist_id, ) warn_if_source_has_duplicates(generic_playlist.tracks, "YouTube", sync.id) access_token = service_account.access_token if access_token is None: return PlaylistSynchronizationResult.from_error( SyncError.Unauthorized, "Service account has no access token", ) credentials = youtube_client.build_credentials( access_token=access_token, refresh_token=getattr(service_account, "refresh_token", None), ) # 2. Fetch current target playlist items target_items = youtube_client.get_all_playlist_items( sync.to_playlist_id, credentials ) if target_items is None: raise PlaylistSynchronizationException( f"Could not fetch YouTube playlist items for {sync.to_playlist_id}", SyncError.NoTargetPlaylist, ) playlist_info = youtube_client.get_playlist_info( sync.to_playlist_id, credentials ) if playlist_info: sync.to_playlist_title = playlist_info.get("title") thumbnails = playlist_info.get("thumbnails") or {} thumb = ( thumbnails.get("maxres") or thumbnails.get("high") or thumbnails.get("default") or {} ) sync.to_playlist_image = thumb.get("url") # 3. Load whitelisted/blacklisted channels whitelisted = await flagged_channel_service.get_whitelisted_channels(session) blacklisted = await flagged_channel_service.get_blacklisted_channels(session) # Deduplicate source tracks seen_uris: set[str] = set() unique_source: list[GenericTrack] = [] for t in generic_playlist.tracks: if t.spotify_uri not in seen_uris: seen_uris.add(t.spotify_uri) unique_source.append(t) # 4. Resolve source tracks to YouTube video IDs source_sync_tracks = await self._get_track_mappings( unique_source, credentials, whitelisted, blacklisted, session ) source_video_ids = [ t["track_id"] for t in source_sync_tracks if t.get("track_id") ] source_video_id_set = set(source_video_ids) # Current target video IDs existing_video_ids = [ item.get("snippet", {}).get("resourceId", {}).get("videoId") for item in target_items ] existing_video_id_set = {vid for vid in existing_video_ids if vid} # 5. Find duplicates in target from collections import defaultdict vid_to_items: dict[str, list] = defaultdict(list) for item in target_items: vid = item.get("snippet", {}).get("resourceId", {}).get("videoId") if vid: vid_to_items[vid].append(item) duplicate_item_ids = [] for _vid, items in vid_to_items.items(): if len(items) > 1: for dup in items[1:]: duplicate_item_ids.append(dup["id"]) if duplicate_item_ids: youtube_client.delete_playlist_items(duplicate_item_ids, credentials) # Refresh target items target_items = ( youtube_client.get_all_playlist_items(sync.to_playlist_id, credentials) or [] ) existing_video_ids = [ item.get("snippet", {}).get("resourceId", {}).get("videoId") for item in target_items ] existing_video_id_set = {vid for vid in existing_video_ids if vid} # 6. Add new videos video_id_to_pos = {vid: idx for idx, vid in enumerate(source_video_ids)} videos_to_add = [ vid for vid in source_video_ids if vid not in existing_video_id_set ] for video_id in videos_to_add: desired_pos = video_id_to_pos[video_id] youtube_client.add_video_to_playlist( sync.to_playlist_id, video_id, desired_pos, credentials ) # 7. Remove stale items (in target but not in source) stale_items = [ item for item in target_items if item.get("snippet", {}).get("resourceId", {}).get("videoId") not in source_video_id_set ] stale_item_ids = [item["id"] for item in stale_items] if stale_item_ids: youtube_client.delete_playlist_items(stale_item_ids, credentials) # 8. Reorder target_items = ( youtube_client.get_all_playlist_items(sync.to_playlist_id, credentials) or [] ) reordered = list(target_items) for track in source_sync_tracks: video_id = track.get("track_id") if not video_id: continue desired_pos = video_id_to_pos.get(video_id, -1) if desired_pos < 0: continue matching = [ i for i, item in enumerate(reordered) if item.get("snippet", {}).get("resourceId", {}).get("videoId") == video_id ] if not matching: continue existing_pos = matching[0] if existing_pos != desired_pos: logger.debug( "Reordering video %s from %d to %d", video_id, existing_pos, desired_pos, ) item = reordered[existing_pos] item["snippet"]["position"] = desired_pos youtube_client.update_playlist_item(item, credentials) item_moved = reordered.pop(existing_pos) reordered.insert(desired_pos, item_moved) counts = SyncCounts( added_tracks=len(videos_to_add), deleted_duplicates=len(duplicate_item_ids), deleted_tracks=len(stale_item_ids), ) return PlaylistSynchronizationResult.from_success( counts, len(generic_playlist.tracks) ) async def _get_track_mappings( self, source_tracks: list[GenericTrack], credentials: Credentials, whitelisted: list[str], blacklisted: list[str], session: AsyncSession, ) -> list[dict]: """Resolve source tracks to YouTube video IDs, using ISRC cache + search. Mirrors: YoutubeSynchronizer.GetTrackMappings() """ cutoff = datetime.utcnow() - timedelta(days=_ISRC_CACHE_MAX_AGE_DAYS) isrcs = [t.isrc for t in source_tracks if t.isrc] cached = await isrc_cache_service.get_synchronized_tracks( isrcs, int(ServiceType.YouTube), session ) cached_fresh = [c for c in cached if c.match_date >= cutoff] cached_map = {c.isrc.upper(): c.track_id for c in cached_fresh} sem = asyncio.Semaphore(5) async def resolve(track: GenericTrack) -> Optional[dict]: """HTTP lookup only — no DB writes (session is not concurrency-safe).""" async with sem: if track.isrc and track.isrc.upper() in cached_map: return { "track_id": cached_map[track.isrc.upper()], "isrc": track.isrc, } query = f"{', '.join(track.artists)} {track.name}" search_results = youtube_client.search_videos( query, credentials, max_results=10 ) if not search_results: return None scored = [ ( item, _score_video( item, item.get("snippet", {}).get("channelTitle", ""), whitelisted, blacklisted, ), ) for item in search_results ] scored = [(item, score) for item, score in scored if score > -1000] if not scored: return None best = max(scored, key=lambda x: x[1]) video_id = best[0].get("id", {}).get("videoId") return ( {"track_id": video_id, "isrc": track.isrc, "_new_cache": True} if video_id else None ) tasks = [resolve(t) for t in source_tracks] resolved_raw = await asyncio.gather(*tasks) # Persist new cache entries sequentially — AsyncSession does not support # concurrent writes. resolved = [] for r in resolved_raw: if r is None: continue if r.pop("_new_cache", False) and r.get("isrc") and r.get("track_id"): await isrc_cache_service.save_synchronized_track( r["isrc"], int(ServiceType.YouTube), r["track_id"], session ) resolved.append(r) return resolved