Add iTunes / Apple Music link import tab on Sync page

New iTunes Link tab between Deezer Link and YouTube. Accepts album,
track, and playlist URLs from music.apple.com / iTunes. Pulls the
tracklist, runs it through the same discovery -> sync -> download
pipeline as the other link tabs.

Apple Music playlists go through amp-api with a Bearer JWT scraped
from the SPA. The legacy meta-tag and inline `"token":"..."` paths
are gone in the current music.apple.com SPA, so the extractor now
walks the page's `<script src>` list (prioritising index/chunk/main
bundles), fetches up to 8 JS bundles, regex-matches JWT-shaped
strings, and base64-decodes each payload to confirm it carries
Apple media-api claims (`root_https_origin`, or `iss + iat + exp`)
before trusting it. Filters out analytics / error-reporter JWTs that
also ship in the bundle.

Tokens are cached at module scope for 6h behind a threading.Lock so
the three-worker discovery executor doesn't thunder-herd Apple on
cold start, and amp-api calls go through a single helper that on
401 invalidates the cache, refetches the page, force-refreshes the
token, and retries the request once. The playlist fetcher memoises
the page HTML for the cache-miss path so we don't refetch it for
every paginated `/tracks` page.

spotify_public discovery worker accepts the new platform shape so
iTunes Link reuses the same matching code path as Deezer Link and
Spotify-public. UI bits live in the sync-services.js iTunes Link
tab, with platform plumbing through wishlist-tools.js for the
multi-source state map.
This commit is contained in:
Broque Thomas 2026-05-25 22:32:18 -07:00
parent 96e6ba0ed7
commit 718eb0cb10
7 changed files with 2074 additions and 38 deletions

View file

@ -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")

View file

@ -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=<trackId>.
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'<meta[^>]+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'<script[^>]+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/<url_hash>', 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/<url_hash>', 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/<url_hash>', 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/<url_hash>', 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/<url_hash>', 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/<url_hash>', 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/<url_hash>', 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/<url_hash>', 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/<url_hash>', 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)

View file

@ -1006,6 +1006,9 @@
<button class="sync-tab-button" data-tab="spotify-public">
<span class="tab-icon spotify-icon"></span> Spotify Link
</button>
<button class="sync-tab-button" data-tab="itunes-link">
<span class="tab-icon import-file-icon"></span> iTunes Link
</button>
<button class="sync-tab-button" data-tab="tidal">
<span class="tab-icon tidal-icon"></span> Tidal
</button>
@ -1115,6 +1118,19 @@
</div>
</div>
<!-- iTunes Link Tab Content -->
<div class="sync-tab-content" id="itunes-link-tab-content">
<div class="youtube-input-section">
<input type="text" id="itunes-link-url-input"
placeholder="Paste iTunes or Apple Music Album/Track URL...">
<button id="itunes-link-parse-btn">Load</button>
</div>
<div class="url-history-bar" id="itunes-link-url-history" style="display:none"></div>
<div class="playlist-scroll-container" id="itunes-link-playlist-container">
<div class="playlist-placeholder">Paste an iTunes or Apple Music album/track URL above to load tracks.</div>
</div>
</div>
<!-- Beatport Tab Content -->
<div class="sync-tab-content" id="beatport-tab-content">
<!-- Beatport Nested Tabs (hidden — Browse is the only view) -->

View file

@ -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.",

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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) {