""" Move 'Russian' songs from your Liked Songs into a separate playlist. Detection heuristics: 1) Cyrillic characters in track name or any artist name. 2) Artist genres contain keywords like 'russian', 'russia', 'cccp', 'cis', 'soviet', 'рус', 'ру', etc. No likes are removed. If you want to also unlike after moving, set REMOVE_FROM_LIKED=True. """ import json import re import time import unicodedata from pathlib import Path from typing import List, Dict, Iterable from dotenv import load_dotenv from spotipy import Spotify from spotipy.exceptions import SpotifyException from spotipy.oauth2 import SpotifyOAuth # Load environment variables from .env file load_dotenv() # -------------------- CONFIG -------------------- PLAYLIST_NAME = "Russian Songs (from Liked)" PLAYLIST_PUBLIC = False REMOVE_FROM_LIKED = False # set True if you want to unlike after moving BATCH_SIZE = 100 # Spotify allows 100 per add/remove call CACHE_FILE = Path(".spotify_cache.json") # Keywords to flag in artist genres (lowercased) GENRE_KEYWORDS = { "russian", "russia", "ru", "рус", "ру", "soviet", "русский", "cccp", "russkiy", "russky", "russian hip hop", "russian rap", "russian pop", "russian rock" } # Optional: add specific artist IDs you want to always treat as Russian (override) ALWAYS_RUSSIAN_ARTIST_IDS = set() # e.g., {"0Xb4bTQ9C7k2rgnAru8v9A"} # Optional: add specific artist IDs you want to exclude (never Russian) NEVER_RUSSIAN_ARTIST_IDS = set() # ------------------------------------------------ def has_cyrillic(text: str) -> bool: if not text: return False for ch in text: if "CYRILLIC" in unicodedata.name(ch, ""): return True return False def chunked(iterable: Iterable, n: int) -> Iterable[List]: buf = [] for item in iterable: buf.append(item) if len(buf) == n: yield buf buf = [] if buf: yield buf def safe_call(fn, *args, **kwargs): """Retry on rate limits (HTTP 429).""" while True: try: return fn(*args, **kwargs) except SpotifyException as e: if e.http_status == 429: retry_after = int(e.headers.get("Retry-After", "2")) time.sleep(retry_after + 1) continue raise def get_or_create_playlist(sp: Spotify, user_id: str, name: str, public: bool) -> str: # Try to find existing playlist by exact name (first page or two is enough for most users) results = safe_call(sp.current_user_playlists, limit=50) while results: for pl in results["items"]: if pl["name"] == name and pl["owner"]["id"] == user_id: return pl["id"] if results.get("next"): results = safe_call(sp.next, results) else: break # Create if not found created = safe_call(sp.user_playlist_create, user=user_id, name=name, public=public, description="Auto-collected Russian songs from Liked") return created["id"] def classify_track(sp: Spotify, track_obj: Dict, artist_cache: Dict[str, Dict]) -> tuple[bool, str]: """ Returns (True, reason) if the track is considered 'Russian' by heuristics. Returns (False, "") otherwise. """ track_name = (track_obj.get("name") or "").strip() # Heuristic 1: Cyrillic in track title if has_cyrillic(track_name): return True, f"Cyrillic in track title: '{track_name}'" artists = track_obj.get("artists") or [] artist_ids = [a["id"] for a in artists if a.get("id")] artist_names = [a.get("name") or "" for a in artists] # Heuristic 2: Cyrillic in any artist name for artist_name in artist_names: if has_cyrillic(artist_name): return True, f"Cyrillic in artist name: '{artist_name}'" # Respect explicit overrides if any(aid in ALWAYS_RUSSIAN_ARTIST_IDS for aid in artist_ids): return True, "Artist in ALWAYS_RUSSIAN_ARTIST_IDS" if any(aid in NEVER_RUSSIAN_ARTIST_IDS for aid in artist_ids): return False, "" # Heuristic 3: genre-based # Fetch artist details (cached) to_fetch = [aid for aid in artist_ids if aid and aid not in artist_cache] for chunk in chunked(to_fetch, 50): if chunk: arts = safe_call(sp.artists, chunk) for a in arts["artists"]: artist_cache[a["id"]] = a for aid in artist_ids: a = artist_cache.get(aid) if not a: continue artist_name = a.get("name", "Unknown") genres = [g.lower() for g in (a.get("genres") or [])] for genre in genres: for keyword in GENRE_KEYWORDS: # Use word boundary matching to avoid false positives # e.g., "ru" shouldn't match "ruby" or "grunge" if re.search(r'\b' + re.escape(keyword) + r'\b', genre): return True, f"Genre match: '{genre}' (artist: {artist_name}, keyword: '{keyword}')" return False, "" def load_cache() -> Dict: if not CACHE_FILE.is_file(): return {} with CACHE_FILE.open("r", encoding="utf-8") as f: return json.load(f) def save_cache(data: Dict): with CACHE_FILE.open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def get_playlist_tracks(sp: Spotify, playlist_id: str) -> set: """Get all track URIs currently in the playlist to avoid duplicates.""" track_uris = set() results = safe_call(sp.playlist_items, playlist_id, limit=100) while results: for item in results.get("items", []): track = item.get("track") if track and track.get("uri"): track_uris.add(track["uri"]) if results.get("next"): results = safe_call(sp.next, results) else: break return track_uris def main(): # Auth scope = "user-library-read playlist-modify-private playlist-modify-public user-library-modify" sp = Spotify(auth_manager=SpotifyOAuth(scope=scope)) me = safe_call(sp.current_user) user_id = me["id"] # Load cached artist data cache_data = load_cache() artist_cache = cache_data.get("artists", {}) playlist_id = get_or_create_playlist(sp, user_id, PLAYLIST_NAME, PLAYLIST_PUBLIC) print(f"Collecting Russian tracks from Liked Songs into playlist: {PLAYLIST_NAME}") print(f"Playlist ID: {playlist_id}") # Get existing playlist tracks to avoid duplicates print("Checking existing playlist contents...") existing_tracks = get_playlist_tracks(sp, playlist_id) print(f"Found {len(existing_tracks)} tracks already in playlist") # Iterate through liked tracks russian_track_uris = [] results = safe_call(sp.current_user_saved_tracks, limit=50) total = results.get("total", 0) processed = 0 while True: items = results.get("items", []) for item in items: track = item.get("track") if not track: continue processed += 1 uri = track.get("uri") # Skip if already in playlist if uri in existing_tracks: continue try: is_russian, reason = classify_track(sp, track, artist_cache) if is_russian: track_name = track.get("name", "Unknown") artists_str = ", ".join([a.get("name", "") for a in track.get("artists", [])]) print(f"✓ [{track_name}] by [{artists_str}] - {reason}") if uri: russian_track_uris.append(uri) except Exception as e: # Log and keep going tname = (track.get("name") or "Unknown").encode("utf-8", "ignore") print(f"[WARN] Failed to classify track: {tname} | {e}") if processed % 100 == 0: print(f"Processed {processed}/{total} liked tracks...") time.sleep(0.5) # Small delay every 100 tracks to avoid rate limits if results.get("next"): results = safe_call(sp.next, results) else: break print(f"Processed {processed}/{total} liked tracks total") # Add to playlist in batches if russian_track_uris: print(f"Found {len(russian_track_uris)} new Russian tracks to add") added = 0 for chunk in chunked(russian_track_uris, BATCH_SIZE): safe_call(sp.playlist_add_items, playlist_id, chunk) added += len(chunk) print(f"Added {added}/{len(russian_track_uris)} to playlist") time.sleep(0.3) # Small delay between batches # Optional: unlike after moving if REMOVE_FROM_LIKED: removed = 0 # Convert URIs to IDs ids = [uri.split(":")[-1] for uri in russian_track_uris] for chunk in chunked(ids, BATCH_SIZE): safe_call(sp.current_user_saved_tracks_delete, chunk) removed += len(chunk) print(f"Removed {removed}/{len(ids)} from Liked Songs") time.sleep(0.3) # Small delay between batches print(f"Done. Moved {len(russian_track_uris)} new tracks to '{PLAYLIST_NAME}'.") else: print("No new Russian tracks to add.") # Save updated cache save_cache({"artists": artist_cache}) if __name__ == "__main__": main()