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:
parent
96e6ba0ed7
commit
718eb0cb10
7 changed files with 2074 additions and 38 deletions
|
|
@ -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")
|
||||
|
|
|
|||
814
web_server.py
814
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=<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)
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue