diff --git a/core/discovery/spotify_public.py b/core/discovery/spotify_public.py index 6df4732b..1fd86d1d 100644 --- a/core/discovery/spotify_public.py +++ b/core/discovery/spotify_public.py @@ -55,13 +55,17 @@ class SpotifyPublicDiscoveryDeps: search_spotify_for_tidal_track: Callable build_discovery_wing_it_stub: Callable add_activity_item: Callable + source_label: str = "Spotify Public" + activity_label: str = "Spotify Link" + original_track_key: str = "spotify_public_track" def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDeps): """Background worker for Spotify Public discovery process (Spotify preferred, iTunes fallback)""" _ew_state = {} try: - _ew_state = deps.pause_enrichment_workers('Spotify Public discovery') + worker_label = f"{deps.source_label} discovery" + _ew_state = deps.pause_enrichment_workers(worker_label) state = deps.spotify_public_discovery_states[url_hash] playlist = state['playlist'] @@ -74,7 +78,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe if not use_spotify: itunes_client_instance = deps.get_metadata_fallback_client() - logger.info(f"Starting Spotify Public discovery for: {playlist['name']} (using {discovery_source.upper()})") + logger.info(f"Starting {deps.source_label} discovery for: {playlist['name']} (using {discovery_source.upper()})") # Store discovery source in state for frontend state['discovery_source'] = discovery_source @@ -126,7 +130,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe cached_album = cached_album.get('name', '') result = { - 'spotify_public_track': { + deps.original_track_key: { 'id': track_id, 'name': track_name, 'artists': track_artists or [], @@ -170,7 +174,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe # Create result entry result = { - 'spotify_public_track': { + deps.original_track_key: { 'id': track_id, 'name': track_name, 'artists': track_artists or [], @@ -270,7 +274,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe # Auto Wing It fallback for unmatched tracks if result['status_class'] == 'not-found': - sp_t = result.get('spotify_public_track', {}) + sp_t = result.get(deps.original_track_key, {}) stub = deps.build_discovery_wing_it_stub( sp_t.get('name', ''), ', '.join(sp_t.get('artists', [])), @@ -299,7 +303,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe logger.error(f"Error processing track {i+1}: {e}") # Add error result result = { - 'spotify_public_track': { + deps.original_track_key: { 'name': sp_track.get('name', 'Unknown'), 'artists': sp_track.get('artists', []), }, @@ -324,14 +328,14 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe # Add activity for discovery completion source_label = discovery_source.upper() - deps.add_activity_item("", f"Spotify Link Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now") + deps.add_activity_item("", f"{deps.activity_label} Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now") - logger.info(f"Spotify Public discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found") + logger.info(f"{deps.source_label} discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found") except Exception as e: - logger.error(f"Error in Spotify Public discovery worker: {e}") + logger.error(f"Error in {deps.source_label} discovery worker: {e}") if url_hash in deps.spotify_public_discovery_states: deps.spotify_public_discovery_states[url_hash]['phase'] = 'error' deps.spotify_public_discovery_states[url_hash]['status'] = f'error: {str(e)}' finally: - deps.resume_enrichment_workers(_ew_state, 'Spotify Public discovery') + deps.resume_enrichment_workers(_ew_state, f"{deps.source_label} discovery") diff --git a/web_server.py b/web_server.py index e4eea0c6..59fd23ef 100644 --- a/web_server.py +++ b/web_server.py @@ -18558,6 +18558,15 @@ def start_missing_tracks_process(playlist_id): spotify_public_discovery_states[sp_url_hash]['converted_spotify_playlist_id'] = playlist_id logger.info(f"Linked Spotify Public playlist {sp_url_hash} to download process {batch_id} (converted ID: {playlist_id})") + # Link iTunes Link playlist to download process if this is an iTunes Link playlist + if playlist_id.startswith('itunes_link_'): + itunes_url_hash = playlist_id.replace('itunes_link_', '') + if itunes_url_hash in itunes_link_discovery_states: + itunes_link_discovery_states[itunes_url_hash]['download_process_id'] = batch_id + itunes_link_discovery_states[itunes_url_hash]['phase'] = 'downloading' + itunes_link_discovery_states[itunes_url_hash]['converted_spotify_playlist_id'] = playlist_id + logger.info(f"Linked iTunes Link playlist {itunes_url_hash} to download process {batch_id} (converted ID: {playlist_id})") + # Link Deezer playlist to download process if this is a Deezer playlist if playlist_id.startswith('deezer_'): deezer_playlist_id = playlist_id.replace('deezer_', '') @@ -22530,6 +22539,361 @@ def cancel_qobuz_sync(playlist_id): spotify_public_discovery_states = {} # Key: url_hash, Value: discovery state spotify_public_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="spotify_public_discovery") +# Global state for iTunes/Apple Music link imports +itunes_link_discovery_states = {} # Key: url_hash, Value: discovery state +itunes_link_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="itunes_link_discovery") +_apple_music_token_cache = {'token': None, 'fetched_at': 0} +_apple_music_token_lock = threading.Lock() +_APPLE_MUSIC_TOKEN_TTL = 6 * 60 * 60 # seconds +_APPLE_MUSIC_JWT_RE = re.compile(r'eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+') +_APPLE_MUSIC_BUNDLE_SCRAPE_CAP = 8 + +def _parse_itunes_link_url(url): + """Return {'type': 'album'|'track'|'playlist', 'id': str} for supported Apple links.""" + import re + from urllib.parse import parse_qs, urlparse + + raw = (url or '').strip() + if not raw: + return None + + uri_match = re.match(r'^(?:itunes|applemusic):(album|track|playlist):([A-Za-z0-9._-]+)$', raw, re.IGNORECASE) + if uri_match: + return {'type': uri_match.group(1).lower(), 'id': uri_match.group(2), 'country': 'us'} + + parsed = urlparse(raw) + host = (parsed.netloc or '').lower() + path = parsed.path or '' + query = parse_qs(parsed.query or '') + + if 'itunes.apple.com' not in host and 'music.apple.com' not in host: + return None + + # Apple Music track links are album URLs with ?i=. + track_id = (query.get('i') or [None])[0] + if track_id and str(track_id).isdigit(): + return {'type': 'track', 'id': str(track_id), 'country': _apple_music_country_from_path(path)} + + song_match = re.search(r'/song(?:/[^/]+)?/(\d+)', path) + if song_match: + return {'type': 'track', 'id': song_match.group(1), 'country': _apple_music_country_from_path(path)} + + album_match = re.search(r'/album(?:/[^/]+)?/(\d+)', path) + if album_match: + return {'type': 'album', 'id': album_match.group(1), 'country': _apple_music_country_from_path(path)} + + playlist_match = re.search(r'/playlist(?:/[^/]+)?/(pl\.[A-Za-z0-9._-]+)', path) + if playlist_match: + return {'type': 'playlist', 'id': playlist_match.group(1), 'country': _apple_music_country_from_path(path)} + + return None + + +def _apple_music_country_from_path(path): + import re + match = re.match(r'^/([a-z]{2})(?:/|$)', path or '', re.IGNORECASE) + return match.group(1).lower() if match else 'us' + + +def _itunes_album_image_url(album_data): + images = album_data.get('images') or [] + if images and isinstance(images[0], dict): + return images[0].get('url', '') + return '' + + +def _itunes_track_to_link_track(track_data, fallback_album=None): + artists = track_data.get('artists') or [] + if artists and isinstance(artists[0], dict): + artists = [a.get('name', '') for a in artists if a.get('name')] + elif isinstance(artists, str): + artists = [artists] + + album = track_data.get('album') or fallback_album or {} + if isinstance(album, str): + album = {'name': album, 'images': []} + + return { + 'id': str(track_data.get('id') or track_data.get('trackId') or ''), + 'name': track_data.get('name') or track_data.get('trackName') or '', + 'artists': artists, + 'album': album, + 'duration_ms': track_data.get('duration_ms') or track_data.get('trackTimeMillis') or 0, + 'explicit': track_data.get('explicit') or track_data.get('trackExplicitness') == 'explicit', + 'track_number': track_data.get('track_number') or track_data.get('trackNumber') or 0, + 'disc_number': track_data.get('disc_number') or track_data.get('discNumber') or 1, + 'external_urls': track_data.get('external_urls') or {'itunes': track_data.get('trackViewUrl', '')}, + '_source': 'itunes' + } + + +def _apple_music_artwork_images(artwork): + if not isinstance(artwork, dict): + return [] + template = artwork.get('url') or '' + if not template: + return [] + sizes = [600, 300, 100] + images = [] + for size in sizes: + url = template.replace('{w}', str(size)).replace('{h}', str(size)) + url = url.replace('{f}', 'jpg').replace('{c}', '') + images.append({'url': url, 'height': size, 'width': size}) + return images + + +def _apple_music_song_to_link_track(song): + attrs = (song or {}).get('attributes') or {} + album_images = _apple_music_artwork_images(attrs.get('artwork')) + album = { + 'id': attrs.get('albumId') or '', + 'name': attrs.get('albumName') or '', + 'images': album_images, + 'release_date': attrs.get('releaseDate') or '', + 'album_type': 'album', + } + return { + 'id': str((song or {}).get('id') or ''), + 'name': attrs.get('name') or '', + 'artists': [attrs.get('artistName') or 'Unknown Artist'], + 'album': album, + 'duration_ms': attrs.get('durationInMillis') or 0, + 'explicit': attrs.get('contentRating') == 'explicit', + 'track_number': attrs.get('trackNumber') or 0, + 'disc_number': attrs.get('discNumber') or 1, + 'external_urls': {'itunes': attrs.get('url') or ''}, + '_source': 'itunes' + } + + +def _looks_like_apple_music_token(token): + """Decode JWT payload and confirm Apple media-api claims before trusting.""" + import base64 + import json + + if not token or token.count('.') != 2: + return False + try: + payload_b64 = token.split('.')[1] + padding = '=' * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding)) + except Exception: + return False + # Apple media-api tokens carry root_https_origin (distinctive) or are + # Apple-issued JWTs with iss + iat + exp claims. + if payload.get('root_https_origin'): + return True + if payload.get('iss') and payload.get('iat') and payload.get('exp'): + return True + return False + + +def _extract_apple_music_web_token(html_text, session=None): + import html + import json + from urllib.parse import unquote, urljoin + + if not html_text: + return None + + meta_match = re.search( + r']+name=["\']desktop-music-app/config/environment["\'][^>]+content=["\']([^"\']+)["\']', + html_text, + re.IGNORECASE, + ) + if meta_match: + try: + raw = html.unescape(meta_match.group(1)) + data = json.loads(unquote(raw)) + token = ((data.get('MEDIA_API') or {}).get('token') + or (data.get('media-api') or {}).get('token')) + if token and _looks_like_apple_music_token(token): + return token + except Exception as e: + logger.debug(f"Apple Music token meta parse failed: {e}") + + inline_match = re.search(r'"token"\s*:\s*"(' + _APPLE_MUSIC_JWT_RE.pattern + r')"', html_text) + if inline_match and _looks_like_apple_music_token(inline_match.group(1)): + return inline_match.group(1) + + if session is None: + return None + + script_srcs = re.findall( + r']+src=["\']([^"\']+\.js)["\']', + html_text, + re.IGNORECASE, + ) + prioritized = sorted( + script_srcs, + key=lambda s: 0 if re.search(r'(index|chunk|main|app)[^/]*\.js$', s, re.IGNORECASE) else 1, + ) + attempted = 0 + for src in prioritized: + if attempted >= _APPLE_MUSIC_BUNDLE_SCRAPE_CAP: + break + attempted += 1 + try: + js_url = urljoin('https://music.apple.com/', src) + js_resp = session.get(js_url, timeout=15) + js_resp.raise_for_status() + for match in _APPLE_MUSIC_JWT_RE.finditer(js_resp.text): + candidate = match.group(0) + if _looks_like_apple_music_token(candidate): + return candidate + except Exception as e: + logger.debug(f"Apple Music bundle scrape failed for {src}: {e}") + continue + return None + + +def _get_apple_music_web_token(session, page_text, force_refresh=False): + with _apple_music_token_lock: + if not force_refresh: + cached = _apple_music_token_cache.get('token') + fetched_at = _apple_music_token_cache.get('fetched_at', 0) + if cached and (time.time() - fetched_at) < _APPLE_MUSIC_TOKEN_TTL: + return cached + token = _extract_apple_music_web_token(page_text, session=session) + if token: + _apple_music_token_cache['token'] = token + _apple_music_token_cache['fetched_at'] = time.time() + elif force_refresh: + _apple_music_token_cache['token'] = None + _apple_music_token_cache['fetched_at'] = 0 + return token + + +def _invalidate_apple_music_token(): + with _apple_music_token_lock: + _apple_music_token_cache['token'] = None + _apple_music_token_cache['fetched_at'] = 0 + + +def _apple_music_amp_get(session, page_text_provider, web_headers, page_url, api_url, params): + """GET amp-api with current cached token; on 401 invalidate + refetch token + retry once.""" + def build_headers(tok): + return { + 'Accept': 'application/json', + 'Origin': 'https://music.apple.com', + 'Referer': page_url, + 'User-Agent': web_headers['User-Agent'], + 'Authorization': f"Bearer {tok}", + } + + token = _get_apple_music_web_token(session, page_text_provider()) + if not token: + raise ValueError("Could not read Apple Music web token") + resp = session.get(api_url, headers=build_headers(token), params=params, timeout=20) + if resp.status_code == 401: + _invalidate_apple_music_token() + page = session.get(page_url, headers=web_headers, timeout=20) + page.raise_for_status() + token = _get_apple_music_web_token(session, page.text, force_refresh=True) + if not token: + raise ValueError("Could not read Apple Music web token") + resp = session.get(api_url, headers=build_headers(token), params=params, timeout=20) + resp.raise_for_status() + return resp + + +def _fetch_apple_music_playlist(url, playlist_id, country): + import requests + from urllib.parse import urljoin + + session = requests.Session() + web_headers = { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', + 'Accept-Language': 'en-US,en;q=0.9', + } + cached_page_text = {'value': None} + + def page_text_provider(): + if cached_page_text['value'] is None: + page = session.get(url, headers=web_headers, timeout=20) + page.raise_for_status() + cached_page_text['value'] = page.text + return cached_page_text['value'] + + country = (country or 'us').lower() + api_url = f"https://amp-api.music.apple.com/v1/catalog/{country}/playlists/{playlist_id}" + playlist_resp = _apple_music_amp_get(session, page_text_provider, web_headers, url, api_url, {'l': 'en-US'}) + playlist_payload = playlist_resp.json() + playlist_item = (playlist_payload.get('data') or [{}])[0] + playlist_attrs = playlist_item.get('attributes') or {} + + tracks = [] + tracks_url = f"{api_url}/tracks" + params = {'l': 'en-US'} + while tracks_url: + tracks_resp = _apple_music_amp_get(session, page_text_provider, web_headers, url, tracks_url, params) + tracks_payload = tracks_resp.json() + tracks.extend(_apple_music_song_to_link_track(song) for song in tracks_payload.get('data') or []) + next_path = tracks_payload.get('next') + tracks_url = urljoin('https://amp-api.music.apple.com', next_path) if next_path else None + params = None + + images = _apple_music_artwork_images(playlist_attrs.get('artwork')) + return { + 'id': playlist_id, + 'type': 'playlist', + 'name': playlist_attrs.get('name') or 'Apple Music Playlist', + 'subtitle': playlist_attrs.get('curatorName') or playlist_attrs.get('editorialNotes', {}).get('standard') or 'Apple Music', + 'url': url, + 'track_count': len(tracks), + 'image_url': images[0]['url'] if images else '', + 'tracks': tracks, + } + + +def _build_itunes_link_state(response_data): + return { + 'playlist': response_data, + 'phase': 'fresh', + 'status': 'fresh', + 'discovery_progress': 0, + 'spotify_matches': 0, + 'spotify_total': len(response_data.get('tracks') or []), + 'discovery_results': [], + 'sync_playlist_id': None, + 'converted_spotify_playlist_id': None, + 'download_process_id': None, + 'created_at': time.time(), + 'last_accessed': time.time(), + 'discovery_future': None, + 'sync_progress': {} + } + + +def _convert_link_results_to_spotify_tracks(discovery_results, label): + spotify_tracks = [] + for result in discovery_results: + if result.get('spotify_data'): + spotify_data = result['spotify_data'] + track = { + 'id': spotify_data['id'], + 'name': spotify_data['name'], + 'artists': spotify_data['artists'], + 'album': spotify_data['album'], + 'duration_ms': spotify_data.get('duration_ms', 0) + } + if spotify_data.get('track_number'): + track['track_number'] = spotify_data['track_number'] + if spotify_data.get('disc_number'): + track['disc_number'] = spotify_data['disc_number'] + spotify_tracks.append(track) + elif result.get('spotify_track') and result.get('status_class') == 'found': + spotify_tracks.append({ + 'id': result.get('spotify_id', 'unknown'), + 'name': result.get('spotify_track', 'Unknown Track'), + 'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'], + 'album': result.get('spotify_album', 'Unknown Album'), + 'duration_ms': 0 + }) + logger.info(f"Converted {len(spotify_tracks)} {label} matches to Spotify tracks for sync") + return spotify_tracks + @app.route('/api/spotify/parse-public', methods=['POST']) def parse_spotify_public_endpoint(): """Parse a public Spotify playlist or album URL without API auth""" @@ -23144,6 +23508,453 @@ def cancel_spotify_public_sync(url_hash): return jsonify({"error": str(e)}), 500 +# =================================================================== +# ITUNES LINK DISCOVERY API ENDPOINTS +# =================================================================== + +@app.route('/api/itunes-link/parse', methods=['POST']) +def parse_itunes_link_endpoint(): + """Parse an iTunes/Apple Music album or track URL into a virtual playlist.""" + try: + data = request.get_json() + url = data.get('url', '').strip() + + if not url: + return jsonify({"error": "iTunes URL is required"}), 400 + + parsed = _parse_itunes_link_url(url) + if not parsed: + return jsonify({"error": "Invalid iTunes or Apple Music link. Album and track links are supported."}), 400 + + client = _get_itunes_client() + tracks = [] + image_url = '' + subtitle = 'iTunes' + name = '' + + if parsed['type'] == 'playlist': + response_data = _fetch_apple_music_playlist(url, parsed['id'], parsed.get('country') or 'us') + tracks = response_data.get('tracks') or [] + image_url = response_data.get('image_url', '') + subtitle = response_data.get('subtitle', 'Apple Music') + name = response_data.get('name', '') + elif parsed['type'] == 'album': + album = client.get_album(parsed['id'], include_tracks=True) + if not album: + return jsonify({"error": "iTunes album not found"}), 404 + album_tracks = ((album.get('tracks') or {}).get('items') or []) + tracks = [_itunes_track_to_link_track(t, fallback_album={ + 'id': album.get('id'), + 'name': album.get('name', ''), + 'images': album.get('images') or [], + 'release_date': album.get('release_date', ''), + 'album_type': album.get('album_type', 'album') + }) for t in album_tracks] + name = album.get('name', '') + artists = album.get('artists') or [] + subtitle = ', '.join(a.get('name', '') for a in artists if isinstance(a, dict)) or 'iTunes' + image_url = _itunes_album_image_url(album) + else: + track = client.get_track_details(parsed['id']) + if not track: + return jsonify({"error": "iTunes track not found"}), 404 + tracks = [_itunes_track_to_link_track(track)] + name = track.get('name', '') + subtitle = ', '.join(track.get('artists') or []) or 'iTunes' + album = track.get('album') or {} + if isinstance(album, dict): + name = f"{track.get('name', '')} - {subtitle}".strip(' -') + + if not tracks: + return jsonify({"error": "No tracks found for this iTunes link"}), 400 + + import hashlib + canonical = f"itunes:{parsed['type']}:{parsed['id']}" + url_hash = hashlib.md5(canonical.encode()).hexdigest()[:12] + + if parsed['type'] == 'playlist': + response_data['url_hash'] = url_hash + response_data['track_count'] = len(tracks) + else: + response_data = { + 'id': parsed['id'], + 'type': parsed['type'], + 'name': name or f"iTunes {parsed['type'].title()}", + 'subtitle': subtitle, + 'url': url, + 'url_hash': url_hash, + 'track_count': len(tracks), + 'image_url': image_url, + 'tracks': tracks + } + + if url_hash not in itunes_link_discovery_states: + itunes_link_discovery_states[url_hash] = _build_itunes_link_state(response_data) + else: + itunes_link_discovery_states[url_hash]['playlist'] = response_data + itunes_link_discovery_states[url_hash]['last_accessed'] = time.time() + + logger.info(f"iTunes {parsed['type']} parsed: {response_data['name']} ({len(tracks)} tracks)") + return jsonify(response_data) + + except Exception as e: + logger.error(f"Error parsing iTunes link: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/discovery/start/', methods=['POST']) +def start_itunes_link_discovery(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes link not found. Please load the URL first."}), 404 + + state = itunes_link_discovery_states[url_hash] + if state['phase'] == 'discovering': + return jsonify({"error": "Discovery already in progress"}), 400 + + if not state.get('playlist'): + return jsonify({"error": "iTunes link data missing. Please load the URL again."}), 404 + + state['phase'] = 'discovering' + state['status'] = 'discovering' + state['last_accessed'] = time.time() + state['discovery_results'] = [] + state['discovery_progress'] = 0 + state['spotify_matches'] = 0 + + playlist_name = state['playlist']['name'] + track_count = len(state['playlist']['tracks']) + add_activity_item("", "iTunes Link Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now") + + future = itunes_link_discovery_executor.submit(_run_itunes_link_discovery_worker, url_hash) + state['discovery_future'] = future + + logger.info(f"Started discovery for iTunes Link: {playlist_name}") + return jsonify({"success": True, "message": "Discovery started"}) + + except Exception as e: + logger.error(f"Error starting iTunes Link discovery: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/discovery/status/', methods=['GET']) +def get_itunes_link_discovery_status(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link discovery not found"}), 404 + state = itunes_link_discovery_states[url_hash] + state['last_accessed'] = time.time() + return jsonify({ + 'phase': state['phase'], + 'status': state['status'], + 'progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'results': state['discovery_results'], + 'complete': state['phase'] == 'discovered' + }) + except Exception as e: + logger.error(f"Error getting iTunes Link discovery status: {e}") + return jsonify({"error": str(e)}), 500 + + +def _update_itunes_link_discovery_result(identifier, track_index, spotify_track): + state = itunes_link_discovery_states.get(identifier) + if not state: + return None, ('Discovery state not found', 404) + if track_index >= len(state['discovery_results']): + return None, ('Invalid track index', 400) + + result = state['discovery_results'][track_index] + old_status = result.get('status') + result['status'] = 'Found' + result['status_class'] = 'found' + result['spotify_track'] = spotify_track['name'] + result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists']) + result['spotify_album'] = spotify_track['album'] + result['spotify_id'] = spotify_track['id'] + result['duration'] = '0:00' + duration_ms = spotify_track.get('duration_ms', 0) + if duration_ms: + result['duration'] = f"{duration_ms // 60000}:{(duration_ms % 60000) // 1000:02d}" + result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track) + result['wing_it_fallback'] = False + result['manual_match'] = True + if old_status not in ('found', 'Found'): + state['spotify_matches'] = state.get('spotify_matches', 0) + 1 + return result, None + + +@app.route('/api/itunes-link/discovery/update_match', methods=['POST']) +def update_itunes_link_discovery_match(): + try: + data = request.get_json() + identifier = data.get('identifier') + track_index = data.get('track_index') + spotify_track = data.get('spotify_track') + + if not identifier or track_index is None or not spotify_track: + return jsonify({'error': 'Missing required fields'}), 400 + + result, error = _update_itunes_link_discovery_result(identifier, track_index, spotify_track) + if error: + message, status = error + return jsonify({'error': message}), status + + try: + original_track = result.get('itunes_link_track', {}) + original_name = original_track.get('name', spotify_track['name']) + original_artists = original_track.get('artists', []) + original_artist = original_artists[0] if original_artists else '' + cache_key = _get_discovery_cache_key(original_name, original_artist) + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, + result['spotify_data'], original_name, original_artist + ) + except Exception as cache_err: + logger.error(f"Error saving iTunes Link manual fix to discovery cache: {cache_err}") + + return jsonify({'success': True, 'result': result}) + + except Exception as e: + logger.error(f"Error updating iTunes Link discovery match: {e}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/itunes-link/playlists/states', methods=['GET']) +def get_itunes_link_playlist_states(): + try: + current_time = time.time() + states = [] + for url_hash, state in itunes_link_discovery_states.items(): + state['last_accessed'] = current_time + states.append({ + 'playlist_id': url_hash, + 'phase': state['phase'], + 'status': state['status'], + 'discovery_progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'discovery_results': state['discovery_results'], + 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), + 'download_process_id': state.get('download_process_id'), + 'last_accessed': state['last_accessed'] + }) + return jsonify({"states": states}) + except Exception as e: + logger.error(f"Error getting iTunes Link playlist states: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/state/', methods=['GET']) +def get_itunes_link_playlist_state(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + state['last_accessed'] = time.time() + return jsonify({ + 'playlist_id': url_hash, + 'playlist': state['playlist'], + 'phase': state['phase'], + 'status': state['status'], + 'discovery_progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'discovery_results': state['discovery_results'], + 'sync_playlist_id': state.get('sync_playlist_id'), + 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), + 'download_process_id': state.get('download_process_id'), + 'sync_progress': state.get('sync_progress', {}), + 'last_accessed': state['last_accessed'] + }) + except Exception as e: + logger.error(f"Error getting iTunes Link state: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/reset/', methods=['POST']) +def reset_itunes_link_playlist(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + if state.get('discovery_future'): + state['discovery_future'].cancel() + state.update({ + 'phase': 'fresh', + 'status': 'fresh', + 'discovery_results': [], + 'discovery_progress': 0, + 'spotify_matches': 0, + 'sync_playlist_id': None, + 'converted_spotify_playlist_id': None, + 'download_process_id': None, + 'sync_progress': {}, + 'discovery_future': None, + 'last_accessed': time.time() + }) + return jsonify({"success": True, "message": "iTunes Link reset to fresh phase"}) + except Exception as e: + logger.error(f"Error resetting iTunes Link: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/delete/', methods=['POST']) +def delete_itunes_link_playlist(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + if state.get('discovery_future'): + state['discovery_future'].cancel() + del itunes_link_discovery_states[url_hash] + return jsonify({"success": True, "message": "iTunes Link deleted"}) + except Exception as e: + logger.error(f"Error deleting iTunes Link: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/update_phase/', methods=['POST']) +def update_itunes_link_playlist_phase(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + data = request.get_json() + new_phase = data.get('phase') if data else None + valid_phases = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] + if new_phase not in valid_phases: + return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400 + state = itunes_link_discovery_states[url_hash] + old_phase = state.get('phase', 'unknown') + state['phase'] = new_phase + state['last_accessed'] = time.time() + if 'download_process_id' in data: + state['download_process_id'] = data['download_process_id'] + if 'converted_spotify_playlist_id' in data: + state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id'] + return jsonify({"success": True, "old_phase": old_phase, "new_phase": new_phase}) + except Exception as e: + logger.error(f"Error updating iTunes Link phase: {e}") + return jsonify({"error": str(e)}), 500 + + +def _build_itunes_link_discovery_deps(): + return _discovery_spotify_public.SpotifyPublicDiscoveryDeps( + spotify_public_discovery_states=itunes_link_discovery_states, + spotify_client=spotify_client, + pause_enrichment_workers=_pause_enrichment_workers, + resume_enrichment_workers=_resume_enrichment_workers, + get_active_discovery_source=_get_active_discovery_source, + get_metadata_fallback_client=_get_metadata_fallback_client, + get_discovery_cache_key=_get_discovery_cache_key, + get_database=get_database, + validate_discovery_cache_artist=_validate_discovery_cache_artist, + search_spotify_for_tidal_track=_search_spotify_for_tidal_track, + build_discovery_wing_it_stub=_build_discovery_wing_it_stub, + add_activity_item=add_activity_item, + source_label="iTunes Link", + activity_label="iTunes Link", + original_track_key="itunes_link_track", + ) + + +def _run_itunes_link_discovery_worker(url_hash): + return _discovery_spotify_public.run_spotify_public_discovery_worker( + url_hash, _build_itunes_link_discovery_deps() + ) + + +@app.route('/api/itunes-link/sync/start/', methods=['POST']) +def start_itunes_link_sync(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + state['last_accessed'] = time.time() + if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']: + return jsonify({"error": "iTunes Link not ready for sync"}), 400 + + spotify_tracks = _convert_link_results_to_spotify_tracks(state['discovery_results'], 'iTunes Link') + if not spotify_tracks: + return jsonify({"error": "No Spotify matches found for sync"}), 400 + + sync_playlist_id = f"itunes_link_{url_hash}" + playlist_name = state['playlist']['name'] + add_activity_item("", "iTunes Link Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") + state['phase'] = 'syncing' + state['sync_playlist_id'] = sync_playlist_id + state['sync_progress'] = {} + + with sync_lock: + sync_states[sync_playlist_id] = {"status": "starting", "progress": {}} + + playlist_image_url = state['playlist'].get('image_url', '') + future = sync_executor.submit(_run_sync_task, sync_playlist_id, playlist_name, spotify_tracks, None, get_current_profile_id(), playlist_image_url) + active_sync_workers[sync_playlist_id] = future + return jsonify({"success": True, "sync_playlist_id": sync_playlist_id}) + + except Exception as e: + logger.error(f"Error starting iTunes Link sync: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/sync/status/', methods=['GET']) +def get_itunes_link_sync_status(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + state['last_accessed'] = time.time() + sync_playlist_id = state.get('sync_playlist_id') + if not sync_playlist_id: + return jsonify({"error": "No sync in progress"}), 404 + + with sync_lock: + sync_state = sync_states.get(sync_playlist_id, {}) + response = { + 'phase': state['phase'], + 'sync_status': sync_state.get('status', 'unknown'), + 'progress': sync_state.get('progress', {}), + 'complete': sync_state.get('status') == 'finished', + 'error': sync_state.get('error') + } + if sync_state.get('status') == 'finished': + state['phase'] = 'sync_complete' + state['sync_progress'] = sync_state.get('progress', {}) + add_activity_item("", "Sync Complete", f"iTunes Link '{state['playlist']['name']}' synced successfully", "Now") + elif sync_state.get('status') == 'error': + state['phase'] = 'discovered' + add_activity_item("", "Sync Failed", f"iTunes Link '{state['playlist']['name']}' sync failed", "Now") + return jsonify(response) + except Exception as e: + logger.error(f"Error getting iTunes Link sync status: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/itunes-link/sync/cancel/', methods=['POST']) +def cancel_itunes_link_sync(url_hash): + try: + if url_hash not in itunes_link_discovery_states: + return jsonify({"error": "iTunes Link not found"}), 404 + state = itunes_link_discovery_states[url_hash] + state['last_accessed'] = time.time() + sync_playlist_id = state.get('sync_playlist_id') + if sync_playlist_id: + with sync_lock: + sync_states[sync_playlist_id] = {"status": "cancelled"} + if sync_playlist_id in active_sync_workers: + del active_sync_workers[sync_playlist_id] + state['phase'] = 'discovered' + state['sync_playlist_id'] = None + state['sync_progress'] = {} + return jsonify({"success": True, "message": "iTunes Link sync cancelled"}) + except Exception as e: + logger.error(f"Error cancelling iTunes Link sync: {e}") + return jsonify({"error": str(e)}), 500 + + # =================================================================== # YOUTUBE PLAYLIST API ENDPOINTS # =================================================================== @@ -23317,6 +24128,7 @@ def get_youtube_discovery_status(url_hash): @app.route('/api/tidal/discovery/unmatch', methods=['POST']) @app.route('/api/deezer/discovery/unmatch', methods=['POST']) @app.route('/api/spotify-public/discovery/unmatch', methods=['POST']) +@app.route('/api/itunes-link/discovery/unmatch', methods=['POST']) @app.route('/api/beatport/discovery/unmatch', methods=['POST']) @app.route('/api/listenbrainz/discovery/unmatch', methods=['POST']) def unmatch_discovery_track(): @@ -23334,6 +24146,7 @@ def unmatch_discovery_track(): or tidal_discovery_states.get(identifier) or deezer_discovery_states.get(identifier) or spotify_public_discovery_states.get(identifier) + or itunes_link_discovery_states.get(identifier) or beatport_chart_states.get(identifier) or listenbrainz_playlist_states.get(identifier)) @@ -35598,6 +36411,7 @@ def _emit_discovery_progress_loop(): 'beatport': lambda: beatport_chart_states, 'listenbrainz': lambda: listenbrainz_playlist_states, 'spotify_public': lambda: spotify_public_discovery_states, + 'itunes_link': lambda: itunes_link_discovery_states, } while not globals().get('IS_SHUTTING_DOWN', False): socketio.sleep(1) diff --git a/webui/index.html b/webui/index.html index 6aa70bc3..65b3108e 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1006,6 +1006,9 @@ + @@ -1115,6 +1118,19 @@ + + +
diff --git a/webui/static/helper.js b/webui/static/helper.js index 576046ca..c1f6f3cf 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.6.2': [ { date: 'May 24, 2026 — 2.6.2 release' }, + { title: 'iTunes / Apple Music link import', desc: 'new iTunes Link tab on the Sync page, between Deezer Link and YouTube. paste an Apple Music album, track, or playlist URL and SoulSync pulls the tracklist, runs it through the same discovery → sync → download flow as the other link tabs. handles the new Apple Music SPA token shape — token gets scraped from the JS bundle on first use and cached for 6 hours, with a 401 retry that refetches if Apple rotates mid-session.' }, { title: 'Auto-Sync: bulk schedule by source + custom interval columns', desc: 'each source group in the sidebar gets a Bulk button — opens a popover that lets you schedule every playlist in that group at a chosen interval in one click (1h / 2h / 4h / 8h / 12h / 16h / 24h / 48h / 72h / weekly) or "Custom interval…" for an arbitrary number of hours. also includes "Unschedule all" to clear a source\'s schedules. on the board itself, a schedule with a non-standard interval (e.g. 6h or 36h, created via Automations page or the custom prompt) now renders as its own dashed-border column instead of disappearing because it didn\'t match a hardcoded bucket.' }, { title: 'Auto-Sync manager: filter, failure indicators, history filters + load more', desc: 'a handful of UX additions on the Playlist Auto-Sync modal. sidebar gets a "Filter playlists…" search input so you can narrow down a long mirrored-playlist list. scheduled cards on the board now show a red ! badge when the last three pipeline runs all failed (yellow ⚠ if at least one of the last few failed) so chronically broken schedules surface visually instead of getting buried in the run-history tab. run history tab title shows a red error count when there are failed runs in the loaded window. the history tab itself gains All / Errors / Completed filter pills, a "Load more" footer that pulls another 50 entries, and a "Run pipeline again" button inside the expanded detail panel so you can re-trigger a specific playlist without leaving the modal. also dropped the "Discovered: completed" pill — `tracks_discovered` was a status string, not a count, and the same data is already in the before/after stats grid above.' }, { title: 'Auto-Sync manager redesigned to match the rest of the app', desc: 'the Auto-Sync modal got a top-to-bottom restyle so it stops feeling like it lives in a different app. modal shell now uses the standard SoulSync gradient + accent-tinted border + 20px radius, the KPI summary became inset stat tiles, the live pipeline monitor is auto-fill instead of a fixed 4-col grid, tabs are now underline-style instead of pill buttons, and the schedule board sidebar/columns use the dense dark-card pattern that Automations + Library pages already use. modal also fills more of the screen since there was a lot of unused real estate. run history cards got the same treatment — slim horizontal row matching `.automation-card` (status dot + name + flow chips + meta row), and the expanded panel uses the same stats-grid / log-section structure as the Automations run-history modal.' }, @@ -3518,6 +3519,17 @@ const WHATS_NEW = { // Section shape: { title, description, features: [bullet strings], // usage_note?: 'optional hint shown at the bottom' } const VERSION_MODAL_SECTIONS = [ + { + title: "iTunes / Apple Music Link Import", + description: "new iTunes Link tab on the Sync page. paste an Apple Music album, track, or playlist URL and SoulSync pulls the tracklist, runs it through discovery, and lets you sync or download like any other link source.", + features: [ + "new iTunes Link tab on the Sync page, sitting between Deezer Link and YouTube", + "accepts album, track, and playlist URLs from music.apple.com or iTunes", + "tracklist runs through the same discovery → sync → download pipeline as Deezer Link / YouTube", + "handles the current Apple Music SPA token flow — token is scraped from the JS bundle on first use, cached for 6 hours, and refetched automatically on 401 if Apple rotates", + ], + usage_note: "Sync → iTunes Link → paste URL → Load", + }, { title: "Qobuz Playlist Sync", description: "Qobuz joins Tidal and Deezer as a first-class playlist sync source on the Sync page. Browse your Qobuz playlists and Favorite Tracks, run them through the same discovery flow as Tidal, sync the resulting Spotify-matched tracks, and queue downloads — same multi-step pipeline you already know.", diff --git a/webui/static/style.css b/webui/static/style.css index 0ff70c1c..02169e75 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -13193,6 +13193,12 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: 0 4px 15px rgba(29, 185, 84, 0.3); } +.sync-tab-button[data-tab="itunes-link"].active { + background: #fa586a; + color: #fff; + box-shadow: 0 4px 15px rgba(250, 88, 106, 0.3); +} + /* Server tab — prominent first tab */ .sync-tab-server { flex: 1.4 !important; @@ -17092,6 +17098,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(29, 185, 84, 0.3); } +#itunes-link-url-history .url-history-pill:hover { + border-color: rgba(250, 88, 106, 0.3); +} + /* Playlist URL input section (YouTube, Deezer) */ .youtube-input-section { display: flex; @@ -17111,7 +17121,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { #youtube-url-input, #deezer-url-input, -#spotify-public-url-input { +#spotify-public-url-input, +#itunes-link-url-input { flex: 1; background: transparent; border: none; @@ -17125,14 +17136,16 @@ body.helper-mode-active #dashboard-activity-feed:hover { #youtube-url-input::placeholder, #deezer-url-input::placeholder, -#spotify-public-url-input::placeholder { +#spotify-public-url-input::placeholder, +#itunes-link-url-input::placeholder { color: rgba(255, 255, 255, 0.3); font-weight: 400; } #youtube-parse-btn, #deezer-parse-btn, -#spotify-public-parse-btn { +#spotify-public-parse-btn, +#itunes-link-parse-btn { flex-shrink: 0; padding: 10px 22px; border: none; @@ -17182,7 +17195,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { #youtube-parse-btn:disabled, #deezer-parse-btn:disabled, -#spotify-public-parse-btn:disabled { +#spotify-public-parse-btn:disabled, +#itunes-link-parse-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; @@ -17206,22 +17220,44 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: 0 2px 8px rgba(29, 185, 84, 0.2); } +#itunes-link-parse-btn { + background: linear-gradient(135deg, #fa586a, #ff7a59); + color: #fff; + box-shadow: 0 2px 8px rgba(250, 88, 106, 0.2); +} + #spotify-public-parse-btn:hover { background: linear-gradient(135deg, #1ed760, #2de86e); box-shadow: 0 4px 16px rgba(29, 185, 84, 0.3); transform: translateY(-1px); } +#itunes-link-parse-btn:hover { + background: linear-gradient(135deg, #ff6a7a, #ff8b6c); + box-shadow: 0 4px 16px rgba(250, 88, 106, 0.3); + transform: translateY(-1px); +} + #spotify-public-parse-btn:active { transform: translateY(0); box-shadow: 0 1px 4px rgba(29, 185, 84, 0.2); } +#itunes-link-parse-btn:active { + transform: translateY(0); + box-shadow: 0 1px 4px rgba(250, 88, 106, 0.2); +} + #spotify-public-tab-content .youtube-input-section:focus-within { border-color: rgba(29, 185, 84, 0.25); box-shadow: 0 0 16px rgba(29, 185, 84, 0.08); } +#itunes-link-tab-content .youtube-input-section:focus-within { + border-color: rgba(250, 88, 106, 0.25); + box-shadow: 0 0 16px rgba(250, 88, 106, 0.08); +} + /* Right Sidebar */ .sync-sidebar { gap: 20px; diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 41ab7761..71b2d1ed 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -1365,14 +1365,15 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, virtualPlaylistId.startsWith('qobuz_') ? 'Qobuz' : virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' : - virtualPlaylistId.startsWith('spotify:') ? 'Spotify' : - virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : - virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : - virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : - virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : - virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : - virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : - 'YouTube'; + virtualPlaylistId.startsWith('itunes_link_') ? 'iTunes' : + virtualPlaylistId.startsWith('spotify:') ? 'Spotify' : + virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : + virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : + virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : + virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : + virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : + virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : + 'YouTube'; const heroContext = { type: 'playlist', @@ -3934,6 +3935,22 @@ function initializeSyncPage() { }); } + // Logic for iTunes Link parse button + const itunesLinkParseBtn = document.getElementById('itunes-link-parse-btn'); + if (itunesLinkParseBtn) { + itunesLinkParseBtn.addEventListener('click', parseITunesLinkUrl); + } + + // Logic for iTunes Link URL input (Enter key support) + const itunesLinkUrlInput = document.getElementById('itunes-link-url-input'); + if (itunesLinkUrlInput) { + itunesLinkUrlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + parseITunesLinkUrl(); + } + }); + } + // Logic for Beatport Top 100 button const beatportTop100Btn = document.getElementById('beatport-top100-btn'); if (beatportTop100Btn) { @@ -7587,6 +7604,1031 @@ async function startSpotifyPublicDownloadMissing(urlHash) { } } + +// =============================== +// ITUNES LINK FUNCTIONALITY +// =============================== + +let itunesLinkPlaylists = []; // Array of loaded iTunes Link playlist objects +let itunesLinkPlaylistStates = {}; // Key: url_hash, Value: state dict + +async function parseITunesLinkUrl() { + const urlInput = document.getElementById('itunes-link-url-input'); + const url = urlInput.value.trim(); + + if (!url) { + showToast('Please enter a iTunes URL', 'error'); + return; + } + + // Basic URL validation + const lowerUrl = url.toLowerCase(); + if (!lowerUrl.includes('itunes.apple.com') && !lowerUrl.includes('music.apple.com') && + !lowerUrl.startsWith('itunes:album:') && !lowerUrl.startsWith('itunes:track:') && + !lowerUrl.startsWith('itunes:playlist:') && !lowerUrl.startsWith('applemusic:album:') && + !lowerUrl.startsWith('applemusic:track:') && !lowerUrl.startsWith('applemusic:playlist:')) { + showToast('Please enter a valid iTunes or Apple Music URL', 'error'); + return; + } + + // Check if already loaded + if (_isUrlAlreadyLoaded('itunes-link', url)) { + showToast('This playlist is already loaded', 'info'); + urlInput.value = ''; + return; + } + + const parseBtn = document.getElementById('itunes-link-parse-btn'); + if (parseBtn) { + parseBtn.disabled = true; + parseBtn.textContent = 'Loading...'; + } + + try { + console.log('🎵 Parsing public iTunes URL:', url); + + const response = await fetch('/api/itunes-link/parse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error: ${result.error}`, 'error'); + return; + } + + // Check if already loaded + if (itunesLinkPlaylists.find(p => String(p.url_hash) === String(result.url_hash))) { + showToast('This playlist is already loaded', 'info'); + urlInput.value = ''; + return; + } + + console.log(`✅ iTunes ${result.type} parsed: ${result.name} (${result.track_count} tracks)`); + + itunesLinkPlaylists.push(result); + + // Auto-mirror + if (result.tracks && result.tracks.length > 0) { + mirrorPlaylist('itunes_link', result.url_hash, result.name, result.tracks.map(t => ({ + track_name: t.name || '', + artist_name: Array.isArray(t.artists) ? t.artists.map(a => (typeof a === 'object' && a !== null) ? a.name : a).filter(Boolean).join(', ') : '', + album_name: t.album?.name || '', + duration_ms: t.duration_ms || 0, + source_track_id: t.id || '' + })), { owner: result.subtitle || '', image_url: '', description: result.url || '' }); + } + + // Save to URL history + saveUrlHistory('itunes-link', url, result.name); + + renderITunesLinkPlaylists(); + await loadITunesLinkPlaylistStatesFromBackend(); + + urlInput.value = ''; + showToast(`Loaded: ${result.name} (${result.track_count} tracks)`, 'success'); + console.log(`🎵 Loaded iTunes link: ${result.name}`); + + } catch (error) { + console.error('❌ Error parsing iTunes URL:', error); + showToast(`Error parsing iTunes URL: ${error.message}`, 'error'); + } finally { + if (parseBtn) { + parseBtn.disabled = false; + parseBtn.textContent = 'Load'; + } + } +} + +function renderITunesLinkPlaylists() { + const container = document.getElementById('itunes-link-playlist-container'); + if (itunesLinkPlaylists.length === 0) { + container.innerHTML = `
Paste an iTunes or Apple Music album/track URL above to load tracks.
`; + return; + } + + container.innerHTML = itunesLinkPlaylists.map(p => { + if (!itunesLinkPlaylistStates[p.url_hash]) { + itunesLinkPlaylistStates[p.url_hash] = { + phase: 'fresh', + playlist: p + }; + } + return createITunesLinkCard(p); + }).join(''); + + // Add click handlers to cards + itunesLinkPlaylists.forEach(p => { + const card = document.getElementById(`itunes-link-card-${p.url_hash}`); + if (card) { + card.addEventListener('click', () => handleITunesLinkCardClick(p.url_hash)); + } + }); +} + +function createITunesLinkCard(playlist) { + const state = itunesLinkPlaylistStates[playlist.url_hash]; + const phase = state ? state.phase : 'fresh'; + const isAlbum = playlist.type === 'album'; + const isPlaylist = playlist.type === 'playlist'; + + let buttonText = getActionButtonText(phase); + let phaseText = getPhaseText(phase); + let phaseColor = getPhaseColor(phase); + + return ` + + `; +} + +async function handleITunesLinkCardClick(urlHash) { + const state = itunesLinkPlaylistStates[urlHash]; + if (!state) { + console.error(`No state found for iTunes Link playlist: ${urlHash}`); + showToast('Playlist state not found - try refreshing the page', 'error'); + return; + } + + if (!state.playlist) { + console.error(`No playlist data found for iTunes Link playlist: ${urlHash}`); + showToast('Playlist data missing - try refreshing the page', 'error'); + return; + } + + if (!state.phase) { + state.phase = 'fresh'; + } + + console.log(`🎵 [Card Click] iTunes Link card clicked: ${urlHash}, Phase: ${state.phase}`); + + if (state.phase === 'fresh') { + console.log(`🎵 Using pre-loaded iTunes Link playlist data for: ${state.playlist.name}`); + openITunesLinkDiscoveryModal(urlHash, state.playlist); + + } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { + console.log(`🎵 [Card Click] Opening iTunes Link discovery modal for ${state.phase} phase`); + + if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) { + try { + const stateResponse = await fetch(`/api/itunes-link/state/${urlHash}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results) { + state.discovery_results = fullState.discovery_results; + state.spotify_matches = fullState.spotify_matches || state.spotify_matches; + state.discovery_progress = fullState.discovery_progress || state.discovery_progress; + itunesLinkPlaylistStates[urlHash] = { ...itunesLinkPlaylistStates[urlHash], ...state }; + console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`); + } + } + } catch (error) { + console.error(`Failed to fetch discovery results from backend: ${error}`); + } + } + + openITunesLinkDiscoveryModal(urlHash, state.playlist); + } else if (state.phase === 'downloading' || state.phase === 'download_complete') { + if (state.convertedSpotifyPlaylistId) { + if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) { + const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId]; + if (process.modalElement) { + process.modalElement.style.display = 'flex'; + } else { + await rehydrateITunesLinkDownloadModal(urlHash, state); + } + } else { + await rehydrateITunesLinkDownloadModal(urlHash, state); + } + } else { + if (state.discovery_results && state.discovery_results.length > 0) { + openITunesLinkDiscoveryModal(urlHash, state.playlist); + } else { + showToast('Unable to open download modal - missing playlist data', 'error'); + } + } + } +} + +async function rehydrateITunesLinkDownloadModal(urlHash, state) { + try { + if (!state || !state.playlist) { + showToast('Cannot open download modal - invalid playlist data', 'error'); + return; + } + + const spotifyTracks = state.discovery_results + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + const virtualPlaylistId = state.convertedSpotifyPlaylistId || `itunes_link_${urlHash}`; + await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks); + + if (state.download_process_id) { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = state.download_process_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + startModalDownloadPolling(virtualPlaylistId); + } + } + } else { + showToast('No Spotify tracks found for download', 'error'); + } + } catch (error) { + console.error(`Error rehydrating iTunes Link download modal: ${error}`); + } +} + +async function openITunesLinkDiscoveryModal(urlHash, playlistData) { + console.log(`🎵 Opening iTunes Link discovery modal (reusing YouTube modal): ${playlistData.name}`); + + const fakeUrlHash = `ituneslink_${urlHash}`; + + const cardState = itunesLinkPlaylistStates[urlHash]; + const isAlreadyDiscovered = cardState && (cardState.phase === 'discovered' || cardState.phase === 'syncing' || cardState.phase === 'sync_complete'); + const isCurrentlyDiscovering = cardState && cardState.phase === 'discovering'; + + let transformedResults = []; + let actualMatches = 0; + if (isAlreadyDiscovered && cardState.discovery_results) { + transformedResults = cardState.discovery_results.map((result, index) => { + const isFound = result.status === 'found' || + result.status === '✅ Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; + if (isFound) actualMatches++; + + return { + index: index, + yt_track: result.itunes_link_track ? result.itunes_link_track.name : 'Unknown', + yt_artist: result.itunes_link_track ? (result.itunes_link_track.artists ? result.itunes_link_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', + status_class: isFound ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists ? + (Array.isArray(result.spotify_data.artists) + ? result.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-' + : result.spotify_data.artists) + : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + spotify_id: result.spotify_id, + manual_match: result.manual_match + }; + }); + console.log(`🎵 iTunes Link modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); + } + + // Normalize artist objects to strings for the discovery modal table + const normalizedTracks = playlistData.tracks.map(t => ({ + ...t, + artists: Array.isArray(t.artists) + ? t.artists.map(a => typeof a === 'object' ? a.name : a) + : t.artists + })); + + const modalPhase = cardState ? cardState.phase : 'fresh'; + youtubePlaylistStates[fakeUrlHash] = { + phase: modalPhase, + playlist: { + name: playlistData.name, + tracks: normalizedTracks + }, + is_itunes_link_playlist: true, + itunes_link_playlist_id: urlHash, + discovery_progress: isAlreadyDiscovered ? 100 : 0, + spotify_matches: isAlreadyDiscovered ? actualMatches : 0, + spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, + spotify_total: playlistData.tracks.length, + discovery_results: transformedResults, + discoveryResults: transformedResults, + discoveryProgress: isAlreadyDiscovered ? 100 : 0 + }; + + if (!isAlreadyDiscovered && !isCurrentlyDiscovering) { + try { + console.log(`🔍 Starting iTunes Link discovery for: ${playlistData.name}`); + + const response = await fetch(`/api/itunes-link/discovery/start/${urlHash}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + console.error('Error starting iTunes Link discovery:', result.error); + showToast(`Error starting discovery: ${result.error}`, 'error'); + return; + } + + console.log('iTunes Link discovery started, beginning polling...'); + + itunesLinkPlaylistStates[urlHash].phase = 'discovering'; + updateITunesLinkCardPhase(urlHash, 'discovering'); + youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; + + startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash); + + } catch (error) { + console.error('Error starting iTunes Link discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + } + } else if (isCurrentlyDiscovering) { + console.log(`🔄 Resuming iTunes Link discovery polling for: ${playlistData.name}`); + startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash); + } else if (cardState && cardState.phase === 'syncing') { + console.log(`🔄 Resuming iTunes Link sync polling for: ${playlistData.name}`); + startITunesLinkSyncPolling(fakeUrlHash); + } else { + console.log('Using existing results - no need to re-discover'); + } + + openYouTubeDiscoveryModal(fakeUrlHash); +} + +function startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash) { + console.log(`🔄 Starting iTunes Link discovery polling for: ${urlHash}`); + + if (activeYouTubePollers[fakeUrlHash]) { + clearInterval(activeYouTubePollers[fakeUrlHash]); + } + + // WebSocket subscription + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [urlHash] }); + _discoveryProgressCallbacks[urlHash] = (data) => { + if (data.error) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + return; + } + const transformed = { + progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total, + complete: data.complete, + results: (data.results || []).map((r, i) => { + const isWingIt = r.wing_it_fallback || r.status_class === 'wing-it'; + const isFound = !isWingIt && (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track); + return { + index: i, yt_track: r.itunes_link_track ? r.itunes_link_track.name : 'Unknown', + yt_artist: r.itunes_link_track ? (r.itunes_link_track.artists ? r.itunes_link_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isWingIt ? '🎯 Wing It' : (isFound ? '✅ Found' : '❌ Not Found'), + status_class: isWingIt ? 'wing-it' : (isFound ? 'found' : 'not-found'), + spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), + spotify_artist: r.spotify_data && r.spotify_data.artists + ? (Array.isArray(r.spotify_data.artists) + ? (r.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-') + : r.spotify_data.artists) + : (r.spotify_artist || '-'), + spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'), + spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match, + wing_it_fallback: isWingIt + }; + }) + }; + const st = youtubePlaylistStates[fakeUrlHash]; + if (st) { + st.discovery_progress = data.progress; st.discoveryProgress = data.progress; + st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches; + st.discovery_results = data.results; st.discoveryResults = transformed.results; + st.phase = data.phase; + updateYouTubeDiscoveryModal(fakeUrlHash, transformed); + } + if (itunesLinkPlaylistStates[urlHash]) { + itunesLinkPlaylistStates[urlHash].phase = data.phase; + itunesLinkPlaylistStates[urlHash].discovery_results = data.results; + itunesLinkPlaylistStates[urlHash].spotify_matches = data.spotify_matches; + itunesLinkPlaylistStates[urlHash].discovery_progress = data.progress; + updateITunesLinkCardPhase(urlHash, data.phase); + } + updateITunesLinkCardProgress(urlHash, data); + if (data.complete) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + } + }; + } + + const pollInterval = setInterval(async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/itunes-link/discovery/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling iTunes Link discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + return; + } + + const transformedStatus = { + progress: status.progress, + spotify_matches: status.spotify_matches, + spotify_total: status.spotify_total, + complete: status.complete, + results: status.results.map((result, index) => { + const isFound = result.status === 'found' || + result.status === '✅ Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; + + return { + index: index, + yt_track: result.itunes_link_track ? result.itunes_link_track.name : 'Unknown', + yt_artist: result.itunes_link_track ? (result.itunes_link_track.artists ? result.itunes_link_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', + status_class: isFound ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists + ? (Array.isArray(result.spotify_data.artists) + ? (result.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-') + : result.spotify_data.artists) + : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + spotify_id: result.spotify_id, + manual_match: result.manual_match + }; + }) + }; + + const state = youtubePlaylistStates[fakeUrlHash]; + if (state) { + state.discovery_progress = status.progress; + state.discoveryProgress = status.progress; + state.spotify_matches = status.spotify_matches; + state.spotifyMatches = status.spotify_matches; + state.discovery_results = status.results; + state.discoveryResults = transformedStatus.results; + state.phase = status.phase; + + updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus); + + if (itunesLinkPlaylistStates[urlHash]) { + itunesLinkPlaylistStates[urlHash].phase = status.phase; + itunesLinkPlaylistStates[urlHash].discovery_results = status.results; + itunesLinkPlaylistStates[urlHash].spotify_matches = status.spotify_matches; + itunesLinkPlaylistStates[urlHash].discovery_progress = status.progress; + updateITunesLinkCardPhase(urlHash, status.phase); + } + + updateITunesLinkCardProgress(urlHash, status); + + console.log(`🔄 iTunes Link discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); + } + + if (status.complete) { + console.log(`iTunes Link discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + + } catch (error) { + console.error('Error polling iTunes Link discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + }, 1000); + + activeYouTubePollers[fakeUrlHash] = pollInterval; +} + +async function loadITunesLinkPlaylistStatesFromBackend() { + try { + console.log('🎵 Loading iTunes Link playlist states from backend...'); + + const response = await fetch('/api/itunes-link/playlists/states'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch iTunes Link playlist states'); + } + + const data = await response.json(); + const states = data.states || []; + + console.log(`🎵 Found ${states.length} stored iTunes Link playlist states in backend`); + + if (states.length === 0) return; + + for (const stateInfo of states) { + await applyITunesLinkPlaylistState(stateInfo); + } + + // Rehydrate download modals for playlists in downloading/download_complete phases + for (const stateInfo of states) { + if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') && + stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) { + + const convertedPlaylistId = stateInfo.converted_spotify_playlist_id; + + if (!activeDownloadProcesses[convertedPlaylistId]) { + console.log(`Rehydrating download modal for iTunes Link playlist: ${stateInfo.playlist_id}`); + try { + const playlistData = itunesLinkPlaylists.find(p => String(p.url_hash) === String(stateInfo.playlist_id)); + if (!playlistData) continue; + + const spotifyTracks = itunesLinkPlaylistStates[stateInfo.playlist_id]?.discovery_results + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + await openDownloadMissingModalForTidal( + convertedPlaylistId, + playlistData.name, + spotifyTracks + ); + + const process = activeDownloadProcesses[convertedPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = stateInfo.download_process_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + startModalDownloadPolling(convertedPlaylistId); + } + } + } catch (error) { + console.error(`Error rehydrating iTunes Link download modal for ${stateInfo.playlist_id}:`, error); + } + } + } + } + + console.log('iTunes Link playlist states loaded and applied'); + + } catch (error) { + console.error('Error loading iTunes Link playlist states:', error); + } +} + +async function applyITunesLinkPlaylistState(stateInfo) { + const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo; + + try { + console.log(`🎵 Applying saved state for iTunes Link playlist: ${playlist_id}, Phase: ${phase}`); + + const playlistData = itunesLinkPlaylists.find(p => String(p.url_hash) === String(playlist_id)); + if (!playlistData) { + console.warn(`Playlist data not found for state ${playlist_id} - skipping`); + return; + } + + if (!itunesLinkPlaylistStates[playlist_id]) { + itunesLinkPlaylistStates[playlist_id] = { + playlist: playlistData, + phase: 'fresh' + }; + } + + itunesLinkPlaylistStates[playlist_id].phase = phase; + itunesLinkPlaylistStates[playlist_id].discovery_progress = discovery_progress; + itunesLinkPlaylistStates[playlist_id].spotify_matches = spotify_matches; + itunesLinkPlaylistStates[playlist_id].discovery_results = discovery_results; + itunesLinkPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id; + itunesLinkPlaylistStates[playlist_id].download_process_id = download_process_id; + itunesLinkPlaylistStates[playlist_id].playlist = playlistData; + + if (phase !== 'fresh' && phase !== 'discovering') { + try { + const stateResponse = await fetch(`/api/itunes-link/state/${playlist_id}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results && itunesLinkPlaylistStates[playlist_id]) { + itunesLinkPlaylistStates[playlist_id].discovery_results = fullState.discovery_results; + itunesLinkPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress; + itunesLinkPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches; + itunesLinkPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; + itunesLinkPlaylistStates[playlist_id].download_process_id = fullState.download_process_id; + } + } + } catch (error) { + console.warn(`Error fetching full discovery results for iTunes Link playlist ${playlistData.name}:`, error.message); + } + } + + updateITunesLinkCardPhase(playlist_id, phase); + + if (phase === 'discovered' && itunesLinkPlaylistStates[playlist_id]) { + const progressInfo = { + spotify_total: playlistData.track_count || playlistData.tracks?.length || 0, + spotify_matches: itunesLinkPlaylistStates[playlist_id].spotify_matches || 0 + }; + updateITunesLinkCardProgress(playlist_id, progressInfo); + } + + if (phase === 'discovering') { + const fakeUrlHash = `ituneslink_${playlist_id}`; + startITunesLinkDiscoveryPolling(fakeUrlHash, playlist_id); + } else if (phase === 'syncing') { + const fakeUrlHash = `ituneslink_${playlist_id}`; + startITunesLinkSyncPolling(fakeUrlHash); + } + + } catch (error) { + console.error(`Error applying iTunes Link playlist state for ${playlist_id}:`, error); + } +} + +function updateITunesLinkCardPhase(urlHash, phase) { + const state = itunesLinkPlaylistStates[urlHash]; + if (!state) return; + + state.phase = phase; + + const card = document.getElementById(`itunes-link-card-${urlHash}`); + if (card) { + const newCardHtml = createITunesLinkCard(state.playlist); + card.outerHTML = newCardHtml; + + const newCard = document.getElementById(`itunes-link-card-${urlHash}`); + if (newCard) { + newCard.addEventListener('click', () => handleITunesLinkCardClick(urlHash)); + } + + if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { + setTimeout(() => { + updateITunesLinkCardSyncProgress(urlHash, state.lastSyncProgress); + }, 0); + } + } +} + +function updateITunesLinkCardProgress(urlHash, progress) { + const state = itunesLinkPlaylistStates[urlHash]; + if (!state) return; + + const card = document.getElementById(`itunes-link-card-${urlHash}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + if (!progressElement) return; + + progressElement.classList.remove('hidden'); + + const total = progress.spotify_total || 0; + const matches = progress.spotify_matches || 0; + + if (total > 0) { + progressElement.innerHTML = ` +
+ ✓ ${matches} + / + ♪ ${total} +
+ `; + } +} + +// =============================== +// SPOTIFY PUBLIC SYNC FUNCTIONALITY +// =============================== + +async function startITunesLinkPlaylistSync(urlHash) { + try { + console.log('🎵 Starting iTunes Link playlist sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_itunes_link_playlist) { + console.error('Invalid iTunes Link playlist state for sync'); + return; + } + + const playlistId = state.itunes_link_playlist_id; + const response = await fetch(`/api/itunes-link/sync/start/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error starting sync: ${result.error}`, 'error'); + return; + } + + const syncPlaylistId = result.sync_playlist_id; + if (state) state.syncPlaylistId = syncPlaylistId; + + updateITunesLinkCardPhase(playlistId, 'syncing'); + updateITunesLinkModalButtons(urlHash, 'syncing'); + + startITunesLinkSyncPolling(urlHash, syncPlaylistId); + + showToast('iTunes Link playlist sync started!', 'success'); + + } catch (error) { + console.error('Error starting iTunes Link sync:', error); + showToast(`Error starting sync: ${error.message}`, 'error'); + } +} + +function startITunesLinkSyncPolling(urlHash, syncPlaylistId) { + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + const state = youtubePlaylistStates[urlHash]; + const playlistId = state.itunes_link_playlist_id; + + syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId); + + // WebSocket subscription + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateITunesLinkCardSyncProgress(playlistId, progress); + updateITunesLinkModalSyncProgress(urlHash, progress); + + if (data.status === 'finished') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + if (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateITunesLinkCardPhase(playlistId, 'sync_complete'); + updateITunesLinkModalButtons(urlHash, 'sync_complete'); + showToast('iTunes Link playlist sync complete!', 'success'); + } else if (data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + if (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateITunesLinkCardPhase(playlistId, 'discovered'); + updateITunesLinkModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + const pollFunction = async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/itunes-link/sync/status/${playlistId}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling iTunes Link sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + updateITunesLinkCardSyncProgress(playlistId, status.progress); + updateITunesLinkModalSyncProgress(urlHash, status.progress); + + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateITunesLinkCardPhase(playlistId, 'sync_complete'); + updateITunesLinkModalButtons(urlHash, 'sync_complete'); + showToast('iTunes Link playlist sync complete!', 'success'); + } else if (status.sync_status === 'error') { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateITunesLinkCardPhase(playlistId, 'discovered'); + updateITunesLinkModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); + } + } catch (error) { + console.error('Error polling iTunes Link sync:', error); + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + } + }; + + if (!socketConnected) pollFunction(); + + const pollInterval = setInterval(pollFunction, 1000); + activeYouTubePollers[urlHash] = pollInterval; +} + +async function cancelITunesLinkSync(urlHash) { + try { + console.log('Cancelling iTunes Link sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_itunes_link_playlist) { + console.error('Invalid iTunes Link playlist state'); + return; + } + + const playlistId = state.itunes_link_playlist_id; + const response = await fetch(`/api/itunes-link/sync/cancel/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error cancelling sync: ${result.error}`, 'error'); + return; + } + + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + const syncId = state && state.syncPlaylistId; + if (syncId && _syncProgressCallbacks[syncId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] }); + delete _syncProgressCallbacks[syncId]; + } + + updateITunesLinkCardPhase(playlistId, 'discovered'); + updateITunesLinkModalButtons(urlHash, 'discovered'); + + showToast('iTunes Link sync cancelled', 'info'); + + } catch (error) { + console.error('Error cancelling iTunes Link sync:', error); + showToast(`Error cancelling sync: ${error.message}`, 'error'); + } +} + +function updateITunesLinkCardSyncProgress(urlHash, progress) { + const state = itunesLinkPlaylistStates[urlHash]; + if (!state || !state.playlist || !progress) return; + + state.lastSyncProgress = progress; + + const card = document.getElementById(`itunes-link-card-${urlHash}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + + let statusCounterHTML = ''; + if (progress && progress.total_tracks > 0) { + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const processed = matched + failed; + const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + statusCounterHTML = ` +
+ ♪ ${total} + / + ✓ ${matched} + / + ✗ ${failed} + (${percentage}%) +
+ `; + } + + if (statusCounterHTML) { + progressElement.innerHTML = statusCounterHTML; + } +} + +function updateITunesLinkModalSyncProgress(urlHash, progress) { + const statusDisplay = document.getElementById(`itunes-link-sync-status-${urlHash}`); + if (!statusDisplay || !progress) return; + + const totalEl = document.getElementById(`itunes-link-total-${urlHash}`); + const matchedEl = document.getElementById(`itunes-link-matched-${urlHash}`); + const failedEl = document.getElementById(`itunes-link-failed-${urlHash}`); + const percentageEl = document.getElementById(`itunes-link-percentage-${urlHash}`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + + if (total > 0) { + const processed = matched + failed; + const percentage = Math.round((processed / total) * 100); + if (percentageEl) percentageEl.textContent = percentage; + } +} + +function updateITunesLinkModalButtons(urlHash, phase) { + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (!modal) return; + + const footerLeft = modal.querySelector('.modal-footer-left'); + if (footerLeft) { + footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + } +} + +async function startITunesLinkDownloadMissing(urlHash) { + try { + console.log('🔍 Starting download missing tracks for iTunes Link playlist:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_itunes_link_playlist) { + console.error('Invalid iTunes Link playlist state for download'); + return; + } + + const discoveryResults = state.discoveryResults || state.discovery_results; + + if (!discoveryResults) { + showToast('No discovery results available for download', 'error'); + return; + } + + const spotifyTracks = []; + for (const result of discoveryResults) { + if (result.spotify_data) { + spotifyTracks.push(result.spotify_data); + } else if (result.spotify_track && result.status_class === 'found') { + const albumData = result.spotify_album || 'Unknown Album'; + const albumObject = typeof albumData === 'object' && albumData !== null + ? albumData + : { + name: typeof albumData === 'string' ? albumData : 'Unknown Album', + album_type: 'album', + images: [] + }; + + spotifyTracks.push({ + id: result.spotify_id || 'unknown', + name: result.spotify_track || 'Unknown Track', + artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], + album: albumObject, + duration_ms: 0 + }); + } + } + + if (spotifyTracks.length === 0) { + showToast('No Spotify matches found for download', 'error'); + return; + } + + const realUrlHash = state.itunes_link_playlist_id; + const virtualPlaylistId = `itunes_link_${realUrlHash}`; + const playlistName = state.playlist.name; + + state.convertedSpotifyPlaylistId = virtualPlaylistId; + + // Sync convertedSpotifyPlaylistId to itunesLinkPlaylistStates for card click routing + if (realUrlHash && itunesLinkPlaylistStates[realUrlHash]) { + itunesLinkPlaylistStates[realUrlHash].convertedSpotifyPlaylistId = virtualPlaylistId; + } + + const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (discoveryModal) { + discoveryModal.classList.add('hidden'); + } + + await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); + + } catch (error) { + console.error('Error starting iTunes Link download missing:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + + // =============================== // URL HISTORY (Saved playlist URLs) // =============================== @@ -7595,7 +8637,8 @@ const URL_HISTORY_MAX = 10; const URL_HISTORY_SOURCES = { youtube: { key: 'soulsync-url-history-youtube', icon: '▶', inputId: 'youtube-url-input', containerId: 'youtube-url-history', loadFn: () => parseYouTubePlaylist() }, deezer: { key: 'soulsync-url-history-deezer', icon: '🎵', inputId: 'deezer-url-input', containerId: 'deezer-url-history', loadFn: () => loadDeezerPlaylist() }, - 'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: '🎧', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() } + 'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: '🎧', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() }, + 'itunes-link': { key: 'soulsync-url-history-itunes-link', icon: '♪', inputId: 'itunes-link-url-input', containerId: 'itunes-link-url-history', loadFn: () => parseITunesLinkUrl() } }; function getUrlHistory(source) { @@ -7704,10 +8747,34 @@ function _isUrlAlreadyLoaded(source, url) { if (spId && spotifyPublicPlaylists.some(p => p.id === spId)) return true; // Fallback: direct URL comparison return spotifyPublicPlaylists.some(p => p.url === url); + } else if (source === 'itunes-link') { + const parsed = extractITunesLinkId(url); + if (parsed && itunesLinkPlaylists.some(p => p.id === parsed.id && p.type === parsed.type)) return true; + return itunesLinkPlaylists.some(p => p.url === url); } return false; } +function extractITunesLinkId(url) { + try { + const raw = (url || '').trim(); + const uriMatch = raw.match(/^(?:itunes|applemusic):(album|track|playlist):([A-Za-z0-9._-]+)$/i); + if (uriMatch) return { type: uriMatch[1].toLowerCase(), id: uriMatch[2] }; + const parsed = new URL(raw); + const trackId = parsed.searchParams.get('i'); + if (trackId && /^\d+$/.test(trackId)) return { type: 'track', id: trackId }; + const songMatch = parsed.pathname.match(/\/song(?:\/[^/]+)?\/(\d+)/); + if (songMatch) return { type: 'track', id: songMatch[1] }; + const albumMatch = parsed.pathname.match(/\/album(?:\/[^/]+)?\/(\d+)/); + if (albumMatch) return { type: 'album', id: albumMatch[1] }; + const playlistMatch = parsed.pathname.match(/\/playlist(?:\/[^/]+)?\/(pl\.[A-Za-z0-9._-]+)/); + if (playlistMatch) return { type: 'playlist', id: playlistMatch[1] }; + } catch (e) { + return null; + } + return null; +} + function initUrlHistories() { for (const source of Object.keys(URL_HISTORY_SOURCES)) { renderUrlHistory(source); @@ -8229,6 +9296,8 @@ function openYouTubeDiscoveryModal(urlHash) { startDeezerSyncPolling(urlHash); } else if (state.is_spotify_public_playlist) { startSpotifyPublicSyncPolling(urlHash); + } else if (state.is_itunes_link_playlist) { + startITunesLinkSyncPolling(urlHash); } else if (state.is_beatport_playlist) { startBeatportSyncPolling(urlHash); } else if (state.is_listenbrainz_playlist) { @@ -8243,13 +9312,15 @@ function openYouTubeDiscoveryModal(urlHash) { const isQobuz = state.is_qobuz_playlist; const isDeezer = state.is_deezer_playlist; const isSpotifyPublic = state.is_spotify_public_playlist; + const isITunesLink = state.is_itunes_link_playlist; const isBeatport = state.is_beatport_playlist; const isListenBrainz = state.is_listenbrainz_playlist; const isMirrored = state.is_mirrored_playlist; const isLastfmRadio = typeof urlHash === 'string' && urlHash.startsWith('lastfm_radio_'); const modalTitle = isMirrored ? '🎵 Mirrored Playlist Discovery' : isSpotifyPublic ? '🎵 Spotify Playlist Discovery' : - isDeezer ? '🎵 Deezer Playlist Discovery' : + isITunesLink ? '🎵 iTunes Link Discovery' : + isDeezer ? '🎵 Deezer Playlist Discovery' : isTidal ? '🎵 Tidal Playlist Discovery' : isQobuz ? '🎵 Qobuz Playlist Discovery' : isBeatport ? '🎵 Beatport Chart Discovery' : @@ -8258,7 +9329,8 @@ function openYouTubeDiscoveryModal(urlHash) { '🎵 YouTube Playlist Discovery'; const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') : isSpotifyPublic ? 'Spotify' : - isDeezer ? 'Deezer' : + isITunesLink ? 'iTunes' : + isDeezer ? 'Deezer' : isTidal ? 'Tidal' : isQobuz ? 'Qobuz' : isBeatport ? 'Beatport' : @@ -8272,7 +9344,7 @@ function openYouTubeDiscoveryModal(urlHash) { @@ -8445,6 +9517,7 @@ function getModalActionButtons(urlHash, phase, state = null) { const isQobuz = state && state.is_qobuz_playlist; const isDeezer = state && state.is_deezer_playlist; const isSpotifyPublic = state && state.is_spotify_public_playlist; + const isITunesLink = state && state.is_itunes_link_playlist; const isBeatport = state && state.is_beatport_playlist; const isListenBrainz = state && state.is_listenbrainz_playlist; @@ -8492,6 +9565,8 @@ function getModalActionButtons(urlHash, phase, state = null) { buttons += ``; } else if (isSpotifyPublic) { buttons += ``; + } else if (isITunesLink) { + buttons += ``; } else if (isBeatport) { buttons += ``; } else { @@ -8511,6 +9586,8 @@ function getModalActionButtons(urlHash, phase, state = null) { buttons += ``; } else if (isSpotifyPublic) { buttons += ``; + } else if (isITunesLink) { + buttons += ``; } else if (isBeatport) { buttons += ``; } else { @@ -8530,7 +9607,7 @@ function getModalActionButtons(urlHash, phase, state = null) { // Rediscover button — reset and re-run discovery (only for sources with reset endpoints) if (isBeatport) { buttons += ``; - } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic) { + } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic && !isITunesLink) { buttons += ``; } @@ -8608,6 +9685,18 @@ function getModalActionButtons(urlHash, phase, state = null) { (0%)
`; + } else if (isITunesLink) { + return ` + + + `; } else if (isBeatport) { return ` @@ -8647,6 +9736,8 @@ function getModalActionButtons(urlHash, phase, state = null) { syncCompleteButtons += ``; } else if (isSpotifyPublic) { syncCompleteButtons += ``; + } else if (isITunesLink) { + syncCompleteButtons += ``; } else if (isBeatport) { syncCompleteButtons += ``; } else { @@ -8664,6 +9755,8 @@ function getModalActionButtons(urlHash, phase, state = null) { syncCompleteButtons += ``; } else if (isSpotifyPublic) { syncCompleteButtons += ``; + } else if (isITunesLink) { + syncCompleteButtons += ``; } else if (isBeatport) { syncCompleteButtons += ``; } else { @@ -8674,7 +9767,7 @@ function getModalActionButtons(urlHash, phase, state = null) { // Rediscover button (only for sources with reset endpoints) if (isBeatport) { syncCompleteButtons += ``; - } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic) { + } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic && !isITunesLink) { syncCompleteButtons += ``; } @@ -8698,6 +9791,8 @@ function getModalActionButtons(urlHash, phase, state = null) { dlCompleteButtons += ``; } else if (isSpotifyPublic) { dlCompleteButtons += ``; + } else if (isITunesLink) { + dlCompleteButtons += ``; } else if (isBeatport) { dlCompleteButtons += ``; } else { @@ -8716,6 +9811,8 @@ function getModalActionButtons(urlHash, phase, state = null) { dlCompleteButtons += ``; } else if (isSpotifyPublic) { dlCompleteButtons += ``; + } else if (isITunesLink) { + dlCompleteButtons += ``; } else if (isBeatport) { dlCompleteButtons += ``; } else { @@ -8726,7 +9823,7 @@ function getModalActionButtons(urlHash, phase, state = null) { // Rediscover button (only for sources with reset endpoints) if (isBeatport) { dlCompleteButtons += ``; - } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic) { + } else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic && !isITunesLink) { dlCompleteButtons += ``; } @@ -8737,8 +9834,8 @@ function getModalActionButtons(urlHash, phase, state = null) { } } -function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false, isLastfmRadio = false, isQobuz = false) { - const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isDeezer ? 'Deezer' : (isLastfmRadio ? 'Last.fm Radio' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isQobuz ? 'Qobuz' : (isTidal ? 'Tidal' : 'YouTube'))))))); +function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false, isLastfmRadio = false, isQobuz = false, isITunesLink = false) { + const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isITunesLink ? 'iTunes' : (isDeezer ? 'Deezer' : (isLastfmRadio ? 'Last.fm Radio' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isQobuz ? 'Qobuz' : (isTidal ? 'Tidal' : 'YouTube')))))))); switch (phase) { case 'fresh': return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`; @@ -8773,10 +9870,11 @@ function generateTableRowsFromState(state, urlHash) { const isQobuz = state.is_qobuz_playlist; const isDeezer = state.is_deezer_playlist; const isSpotifyPublic = state.is_spotify_public_playlist; + const isITunesLink = state.is_itunes_link_playlist; const isBeatport = state.is_beatport_playlist; const isListenBrainz = state.is_listenbrainz_playlist; const isMirrored = state.is_mirrored_playlist; - const platform = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'spotify_public' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isQobuz ? 'qobuz' : (isBeatport ? 'beatport' : 'youtube')))))); + const platform = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'spotify_public' : (isITunesLink ? 'itunes_link' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isQobuz ? 'qobuz' : (isBeatport ? 'beatport' : 'youtube'))))))); // Support both camelCase and snake_case const discoveryResults = state.discoveryResults || state.discovery_results; @@ -8938,11 +10036,12 @@ function updateYouTubeDiscoveryModal(urlHash, status) { const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; const platform = state?.is_mirrored_playlist ? 'mirrored' : (state?.is_spotify_public_playlist ? 'spotify_public' : + (state?.is_itunes_link_playlist ? 'itunes_link' : (state?.is_deezer_playlist ? 'deezer' : (state?.is_listenbrainz_playlist ? 'listenbrainz' : (state?.is_tidal_playlist ? 'tidal' : (state?.is_qobuz_playlist ? 'qobuz' : - (state?.is_beatport_playlist ? 'beatport' : 'youtube')))))); + (state?.is_beatport_playlist ? 'beatport' : 'youtube'))))))); actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform); } }); @@ -9016,11 +10115,12 @@ function closeYouTubeDiscoveryModal(urlHash) { const isQobuz = state.is_qobuz_playlist; const isDeezer = state.is_deezer_playlist; const isSpotifyPublic = state.is_spotify_public_playlist; + const isITunesLink = state.is_itunes_link_playlist; const isBeatport = state.is_beatport_playlist; // Reset to 'discovered' phase if modal is closed after completion (like Tidal does) if (state.phase === 'sync_complete' || state.phase === 'download_complete') { - console.log(`🧹 [Modal Close] Resetting ${isSpotifyPublic ? 'Spotify Public' : (isDeezer ? 'Deezer' : (isQobuz ? 'Qobuz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'))))} state after completion`); + console.log(`🧹 [Modal Close] Resetting ${isSpotifyPublic ? 'Spotify Public' : (isITunesLink ? 'iTunes Link' : (isDeezer ? 'Deezer' : (isQobuz ? 'Qobuz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))))} state after completion`); if (isSpotifyPublic) { // Spotify Public: Extract url_hash and reset state @@ -9052,6 +10152,35 @@ function closeYouTubeDiscoveryModal(urlHash) { console.warn('Error updating backend Spotify Public phase:', error); } } + } else if (isITunesLink) { + const itunesUrlHash = state.itunes_link_playlist_id || null; + if (itunesUrlHash && itunesLinkPlaylistStates[itunesUrlHash]) { + const preservedData = { + playlist: itunesLinkPlaylistStates[itunesUrlHash].playlist, + discovery_results: itunesLinkPlaylistStates[itunesUrlHash].discovery_results, + spotify_matches: itunesLinkPlaylistStates[itunesUrlHash].spotify_matches, + discovery_progress: itunesLinkPlaylistStates[itunesUrlHash].discovery_progress, + convertedSpotifyPlaylistId: itunesLinkPlaylistStates[itunesUrlHash].convertedSpotifyPlaylistId + }; + + delete itunesLinkPlaylistStates[itunesUrlHash].download_process_id; + delete itunesLinkPlaylistStates[itunesUrlHash].phase; + + Object.assign(itunesLinkPlaylistStates[itunesUrlHash], preservedData); + itunesLinkPlaylistStates[itunesUrlHash].phase = 'discovered'; + + updateITunesLinkCardPhase(itunesUrlHash, 'discovered'); + + try { + fetch(`/api/itunes-link/update_phase/${itunesUrlHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('Error updating backend iTunes Link phase:', error); + } + } } else if (isDeezer) { // Deezer: Extract playlist ID and reset Deezer state const deezerPlaylistId = state.deezer_playlist_id || null; @@ -9394,7 +10523,7 @@ function updateYouTubeCardSyncProgress(urlHash, progress) { function updateYouTubeModalSyncProgress(urlHash, progress) { // Try all source-specific element ID prefixes - const prefixes = ['youtube', 'listenbrainz', 'tidal', 'deezer', 'spotify-public', 'beatport']; + const prefixes = ['youtube', 'listenbrainz', 'tidal', 'deezer', 'spotify-public', 'itunes-link', 'beatport']; let statusDisplay = null; let prefix = 'youtube'; for (const p of prefixes) { diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index a1fbc4b3..94e9cd3a 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -396,13 +396,16 @@ async function selectDiscoveryFixTrack(track) { // For Spotify Public, backend expects the url_hash const state = youtubePlaylistStates[identifier]; backendIdentifier = state?.spotify_public_playlist_id || identifier; + } else if (platform === 'itunes_link') { + const state = youtubePlaylistStates[identifier]; + backendIdentifier = state?.itunes_link_playlist_id || identifier; } else if (platform === 'beatport') { // For Beatport, backend expects url_hash (same as identifier) backendIdentifier = identifier; } // Mirrored playlists route through the YouTube endpoint (which already handles mirrored_ prefixes) - const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : platform); + const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : (platform === 'itunes_link' ? 'itunes-link' : platform)); const requestBody = { identifier: backendIdentifier, @@ -459,6 +462,8 @@ async function selectDiscoveryFixTrack(track) { state = youtubePlaylistStates[identifier]; } else if (platform === 'spotify_public') { state = youtubePlaylistStates[identifier]; + } else if (platform === 'itunes_link') { + state = youtubePlaylistStates[identifier]; } // Support both camelCase and snake_case @@ -561,6 +566,17 @@ async function selectDiscoveryFixTrack(track) { }); } } + + if (platform === 'itunes_link' && state.itunes_link_playlist_id) { + const itunesState = itunesLinkPlaylistStates?.[state.itunes_link_playlist_id]; + if (itunesState) { + itunesState.spotifyMatches = state.spotifyMatches; + updateITunesLinkCardProgress(state.itunes_link_playlist_id, { + spotify_matches: state.spotifyMatches, + spotify_total: spotify_total + }); + } + } } // Update UI - refresh the table row @@ -623,10 +639,19 @@ function updateDiscoveryModalSingleRow(platform, identifier, trackIndex) { } async function unmatchDiscoveryTrack(platform, identifier, trackIndex) { + const uiState = (typeof youtubePlaylistStates !== 'undefined' ? youtubePlaylistStates[identifier] : null) + || (typeof listenbrainzPlaylistStates !== 'undefined' ? listenbrainzPlaylistStates[identifier] : null); + const backendIdentifier = platform === 'spotify_public' + ? (uiState?.spotify_public_playlist_id || identifier) + : platform === 'itunes_link' + ? (uiState?.itunes_link_playlist_id || identifier) + : identifier; + // Determine the correct API base for this platform const apiBase = platform === 'tidal' ? '/api/tidal' : platform === 'deezer' ? '/api/deezer' - : platform === 'spotify-public' ? '/api/spotify-public' + : (platform === 'spotify-public' || platform === 'spotify_public') ? '/api/spotify-public' + : (platform === 'itunes-link' || platform === 'itunes_link') ? '/api/itunes-link' : platform === 'beatport' ? '/api/beatport' : platform === 'listenbrainz' ? '/api/listenbrainz' : '/api/youtube'; @@ -635,7 +660,7 @@ async function unmatchDiscoveryTrack(platform, identifier, trackIndex) { const response = await fetch(`${apiBase}/discovery/unmatch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identifier, track_index: trackIndex }) + body: JSON.stringify({ identifier: backendIdentifier, track_index: trackIndex }) }); const data = await response.json(); if (data.success) {