Discord report (CJFC, 2026-04-26): syncing a Spotify playlist to the
server overwrote anything manually added to the server-side playlist.
The fix adds a per-sync mode picker next to the Sync button on the
playlist details modal — Replace (default, current delete-recreate
behavior) or Append only (preserves existing tracks, only adds new
ones). Useful when the source platform caps playlist size and the
user is manually building beyond it on the server.
Implementation:
* New `append_to_playlist(name, tracks)` method on Plex / Jellyfin /
Navidrome clients. Each uses the server's NATIVE append API:
- Plex: `existing_playlist.addItems(new_tracks)`
- Jellyfin: `POST /Playlists/<id>/Items?Ids=...&UserId=...`
- Navidrome: Subsonic `updatePlaylist?songIdToAdd=...`
Falls back to `create_playlist` when the playlist doesn't exist
yet (first sync). No delete-recreate, no backup playlist created
(preserves playlist creation date + metadata + non-soulsync-managed
tracks).
* Dedup-by-server-native-id (ratingKey for Plex, GUID for Jellyfin,
song-id for Navidrome) — never re-adds a track already on the
playlist. Server-native identity, not fuzzy title+artist match,
so it can't false-collide.
* `sync_service.sync_playlist` accepts `sync_mode='replace'|'append'`
kwarg. Single if/else branch dispatches to `append_to_playlist` or
`update_playlist`. Threaded through `core/discovery/sync.run_sync_task`
and the `/api/sync/start` HTTP handler. Validation on the API rejects
unknown mode strings (defaults to 'replace').
* Frontend: per-playlist `<select id="sync-mode-${id}">` rendered next
to the Sync button in both modal renderers (sync-spotify.js for
Spotify playlists, sync-services.js for Deezer ARL playlists).
`startPlaylistSync` reads the select at click time; missing select
(other callers like discover.js) defaults to 'replace' so backward
compat preserved without per-call-site updates.
* SoulSync standalone has no playlist methods at all and the modal
hides the Sync button entirely on it via `_isSoulsyncStandalone` —
dispatch never reaches that path, no defensive fallback needed.
15 new tests pin per-server append behavior:
- missing playlist → create_playlist delegation
- dedup filtering (existing IDs skipped, only new tracks added)
- empty new-track set short-circuits without API call
- failure paths return False without raising
- contract listing (KNOWN_PER_SERVER_METHODS includes
'append_to_playlist'; Plex / Jellyfin / Navidrome all implement)
Plus tests/discovery/test_discovery_sync.py fake `sync_playlist`
fixture got `sync_mode='replace'` default to match the new signature
(was breaking after the kwarg add; now passing).
WHATS_NEW entry under new '2.6.0' block (hidden by
`_getLatestWhatsNewVersion` until next release bump).
Closes CJFC discord request.
503 lines
25 KiB
Python
503 lines
25 KiB
Python
"""Background worker for the playlist sync task.
|
|
|
|
`run_sync_task(playlist_id, playlist_name, tracks_json, automation_id, profile_id,
|
|
playlist_image_url, deps)` is the function `sync_executor.submit(...)` invokes
|
|
to drive the entire playlist-sync workflow:
|
|
|
|
1. Convert frontend JSON tracks → SpotifyTrack/SpotifyPlaylist objects.
|
|
2. Normalize artist/album shapes for downstream wishlist parity.
|
|
3. Wire a progress_callback that updates `sync_states` + automation card.
|
|
4. Patch sync_service for database-only fallback when no media server is connected.
|
|
5. `run_async(sync_service.sync_playlist(...))` and capture the result.
|
|
6. Update sync_states to 'finished', push playlist poster image to Plex/Jellyfin/Emby,
|
|
record sync history (with re-sync vs new-sync branching), emit
|
|
`playlist_synced` event for automation engine, and persist sync status with a
|
|
tracks_hash for smart-skip on the next scheduled sync.
|
|
7. On exception → mark error in sync_states + automation; finally clear progress
|
|
callback + drop `_original_tracks_map` from sync_service.
|
|
|
|
Lifted verbatim from web_server.py. Wide dependency surface (sync_service,
|
|
sync_states, plex/jellyfin clients, automation engine, multiple helper funcs)
|
|
all injected via `SyncDeps`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable
|
|
|
|
from core.spotify_client import Playlist as SpotifyPlaylist, Track as SpotifyTrack
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class SyncDeps:
|
|
"""Bundle of cross-cutting deps the sync worker needs."""
|
|
config_manager: Any
|
|
sync_service: Any
|
|
media_server_engine: Any
|
|
automation_engine: Any
|
|
run_async: Callable[..., Any]
|
|
record_sync_history_start: Callable
|
|
update_automation_progress: Callable
|
|
update_and_save_sync_status: Callable
|
|
sync_states: dict
|
|
sync_lock: Any # threading.Lock
|
|
|
|
|
|
def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', deps: SyncDeps = None, sync_mode: str = 'replace'):
|
|
"""The actual sync function that runs in the background thread."""
|
|
sync_states = deps.sync_states
|
|
sync_lock = deps.sync_lock
|
|
sync_service = deps.sync_service
|
|
|
|
task_start_time = time.time()
|
|
logger.info(f"[TIMING] _run_sync_task STARTED for playlist '{playlist_name}' at {time.strftime('%H:%M:%S')}")
|
|
logger.info(f"Received {len(tracks_json)} tracks from frontend")
|
|
|
|
# Record sync history start (skip for re-syncs triggered from history)
|
|
_is_resync = playlist_id.startswith('resync_')
|
|
_resync_entry_id = None
|
|
sync_batch_id = f"sync_{playlist_id}_{int(time.time())}"
|
|
if _is_resync:
|
|
# Extract the original entry ID from resync_{entryId}_{timestamp}
|
|
try:
|
|
_resync_entry_id = int(playlist_id.split('_')[1])
|
|
except (IndexError, ValueError):
|
|
pass
|
|
else:
|
|
deps.record_sync_history_start(
|
|
batch_id=sync_batch_id,
|
|
playlist_id=playlist_id,
|
|
playlist_name=playlist_name,
|
|
tracks=tracks_json,
|
|
is_album_download=False,
|
|
album_context=None,
|
|
artist_context=None,
|
|
playlist_folder_mode=False,
|
|
source_page='sync'
|
|
)
|
|
|
|
try:
|
|
# Recreate a Playlist object from the JSON data sent by the frontend
|
|
# This avoids needing to re-fetch it from Spotify
|
|
logger.info("Converting JSON tracks to SpotifyTrack objects...")
|
|
|
|
# Store original track data with full album objects (for wishlist with cover art)
|
|
# Normalize formats for wishlist: album must be dict {'name': ...}, artists must be [{'name': ...}]
|
|
# Important: copy data — don't mutate tracks_json since SpotifyTrack expects List[str] artists
|
|
original_tracks_map = {}
|
|
for t in tracks_json:
|
|
track_id = t.get('id', '')
|
|
if track_id:
|
|
normalized = dict(t)
|
|
# Normalize album to dict format, preserving images and metadata
|
|
raw_album = normalized.get('album', '')
|
|
if isinstance(raw_album, str):
|
|
normalized['album'] = {
|
|
'name': raw_album or normalized.get('name', 'Unknown Album'),
|
|
'images': [], 'album_type': 'single', 'total_tracks': 1, 'release_date': ''
|
|
}
|
|
elif not isinstance(raw_album, dict):
|
|
normalized['album'] = {
|
|
'name': str(raw_album) if raw_album else normalized.get('name', 'Unknown Album'),
|
|
'images': [], 'album_type': 'single', 'total_tracks': 1, 'release_date': ''
|
|
}
|
|
else:
|
|
# Dict — ensure required keys exist
|
|
raw_album.setdefault('name', 'Unknown Album')
|
|
raw_album.setdefault('images', [])
|
|
# Normalize artists to list of dicts
|
|
raw_artists = normalized.get('artists', [])
|
|
if raw_artists and isinstance(raw_artists[0], str):
|
|
normalized['artists'] = [{'name': a} for a in raw_artists]
|
|
original_tracks_map[track_id] = normalized
|
|
|
|
tracks = []
|
|
for i, t in enumerate(tracks_json):
|
|
# Handle album field - extract name if it's a dictionary
|
|
raw_album = t.get('album', '')
|
|
if isinstance(raw_album, dict) and 'name' in raw_album:
|
|
album_name = raw_album['name']
|
|
elif isinstance(raw_album, str):
|
|
album_name = raw_album
|
|
else:
|
|
album_name = str(raw_album)
|
|
|
|
# Extract image URL from album data if available
|
|
_track_image = ''
|
|
if isinstance(raw_album, dict):
|
|
_imgs = raw_album.get('images', [])
|
|
if _imgs and isinstance(_imgs, list) and len(_imgs) > 0:
|
|
_track_image = _imgs[0].get('url', '') if isinstance(_imgs[0], dict) else ''
|
|
if not _track_image:
|
|
_track_image = t.get('image_url', '')
|
|
|
|
# Create SpotifyTrack objects with proper default values for missing fields
|
|
track = SpotifyTrack(
|
|
id=t.get('id', ''),
|
|
name=t.get('name', ''),
|
|
artists=t.get('artists', []),
|
|
album=album_name,
|
|
duration_ms=t.get('duration_ms', 0),
|
|
popularity=t.get('popularity', 0),
|
|
preview_url=t.get('preview_url'),
|
|
external_urls=t.get('external_urls'),
|
|
image_url=_track_image or None
|
|
)
|
|
tracks.append(track)
|
|
if i < 3: # Log first 3 tracks for debugging
|
|
logger.info(f" Track {i+1}: '{track.name}' by {track.artists}")
|
|
|
|
logger.info(f"Created {len(tracks)} SpotifyTrack objects")
|
|
|
|
playlist = SpotifyPlaylist(
|
|
id=playlist_id,
|
|
name=playlist_name,
|
|
description=None, # Not needed for sync
|
|
owner="web_user", # Placeholder
|
|
public=False, # Default
|
|
collaborative=False, # Default
|
|
tracks=tracks,
|
|
total_tracks=len(tracks)
|
|
)
|
|
logger.info(f"Created SpotifyPlaylist object: '{playlist.name}' with {playlist.total_tracks} tracks")
|
|
|
|
first_callback_time = [None] # Use list to allow modification in nested function
|
|
|
|
def progress_callback(progress):
|
|
"""Callback to update the shared state."""
|
|
if first_callback_time[0] is None:
|
|
first_callback_time[0] = time.time()
|
|
first_callback_duration = (first_callback_time[0] - task_start_time) * 1000
|
|
logger.info(f"⏱️ [TIMING] FIRST progress callback at {time.strftime('%H:%M:%S')} (took {first_callback_duration:.1f}ms from start)")
|
|
|
|
logger.info(f"PROGRESS CALLBACK: {progress.current_step} - {progress.current_track}")
|
|
logger.error(f" Progress: {progress.progress}% ({progress.matched_tracks}/{progress.total_tracks} matched, {progress.failed_tracks} failed)")
|
|
|
|
with sync_lock:
|
|
sync_states[playlist_id] = {
|
|
"status": "syncing",
|
|
"progress": progress.__dict__ # Convert dataclass to dict
|
|
}
|
|
logger.info(f" Updated sync_states for {playlist_id}")
|
|
|
|
# Update automation progress card
|
|
if automation_id:
|
|
step = getattr(progress, 'current_step', '')
|
|
track = getattr(progress, 'current_track', '')
|
|
pct = getattr(progress, 'progress', 0)
|
|
matched = getattr(progress, 'matched_tracks', 0)
|
|
failed = getattr(progress, 'failed_tracks', 0)
|
|
total = getattr(progress, 'total_tracks', 0)
|
|
log_type = 'success' if 'matched' in step.lower() or 'found' in step.lower() else 'info'
|
|
if 'not found' in step.lower() or 'failed' in step.lower():
|
|
log_type = 'error'
|
|
deps.update_automation_progress(automation_id, progress=pct,
|
|
phase=f'Syncing: {step}',
|
|
processed=matched + failed, total=total,
|
|
current_item=track,
|
|
log_line=f'{track} — {step}' if track else step, log_type=log_type)
|
|
|
|
except Exception as setup_error:
|
|
logger.error(f"SETUP ERROR in _run_sync_task: {setup_error}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
with sync_lock:
|
|
sync_states[playlist_id] = {
|
|
"status": "error",
|
|
"error": f"Setup error: {str(setup_error)}"
|
|
}
|
|
if automation_id:
|
|
deps.update_automation_progress(automation_id, status='error', progress=100,
|
|
phase='Error', log_line=f'Setup error: {str(setup_error)}', log_type='error')
|
|
return
|
|
|
|
try:
|
|
logger.info("Setting up sync service...")
|
|
logger.info(f" sync_service available: {sync_service is not None}")
|
|
|
|
if sync_service is None:
|
|
raise Exception("sync_service is None - not initialized properly")
|
|
|
|
# Check sync service components
|
|
logger.info(f" spotify_client: {sync_service.spotify_client is not None}")
|
|
_ms_engine = getattr(sync_service, '_engine', None)
|
|
logger.info(f" plex_client: {(_ms_engine.client('plex') if _ms_engine else None) is not None}")
|
|
logger.info(f" jellyfin_client: {(_ms_engine.client('jellyfin') if _ms_engine else None) is not None}")
|
|
|
|
# Check media server connection before starting
|
|
from config.settings import config_manager
|
|
active_server = config_manager.get_active_media_server()
|
|
logger.info(f" Active media server: {active_server}")
|
|
|
|
media_client, server_type = sync_service._get_active_media_client()
|
|
logger.info(f" Media client available: {media_client is not None}")
|
|
|
|
if media_client:
|
|
is_connected = media_client.is_connected()
|
|
logger.info(f" Media client connected: {is_connected}")
|
|
|
|
# Check database access
|
|
try:
|
|
from database.music_database import MusicDatabase
|
|
db = MusicDatabase()
|
|
logger.debug(f" Database initialized: {db is not None}")
|
|
except Exception as db_error:
|
|
logger.error(f" Database initialization failed: {db_error}")
|
|
|
|
logger.info("Attaching progress callback...")
|
|
# Attach the progress callback
|
|
sync_service.set_progress_callback(progress_callback, playlist.name)
|
|
logger.info(f"Progress callback attached for playlist: {playlist.name}")
|
|
|
|
# CRITICAL FIX: Add database-only fallback for web context
|
|
# If media client is not connected, patch the sync service to use database-only matching
|
|
if media_client is None or not media_client.is_connected():
|
|
logger.info("Media client not connected - patching sync service for database-only matching")
|
|
|
|
# Store original method
|
|
original_find_track = sync_service._find_track_in_media_server
|
|
|
|
# Create database-only replacement method
|
|
async def database_only_find_track(spotify_track):
|
|
logger.info(f"Database-only search for: '{spotify_track.name}' by {spotify_track.artists}")
|
|
try:
|
|
from database.music_database import MusicDatabase
|
|
from config.settings import config_manager
|
|
|
|
db = MusicDatabase()
|
|
active_server = config_manager.get_active_media_server()
|
|
original_title = spotify_track.name
|
|
spotify_id = getattr(spotify_track, 'id', '') or ''
|
|
|
|
# --- Sync match cache fast-path ---
|
|
if spotify_id:
|
|
try:
|
|
cached = db.read_sync_match_cache(spotify_id, active_server)
|
|
if cached:
|
|
db_track_check = db.get_track_by_id(cached['server_track_id'])
|
|
if db_track_check:
|
|
class DatabaseTrackCached:
|
|
def __init__(self, db_t):
|
|
self.ratingKey = db_t.id
|
|
self.title = db_t.title
|
|
self.id = db_t.id
|
|
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
|
|
return DatabaseTrackCached(db_track_check), cached['confidence']
|
|
logger.warning(f"Sync cache stale for '{original_title}' — track gone")
|
|
except Exception as e:
|
|
logger.debug("sync match cache fast-path failed: %s", e)
|
|
# --- End cache fast-path ---
|
|
|
|
# Try each artist (same logic as original)
|
|
for artist in spotify_track.artists:
|
|
# Extract artist name from both string and dict formats
|
|
if isinstance(artist, str):
|
|
artist_name = artist
|
|
elif isinstance(artist, dict) and 'name' in artist:
|
|
artist_name = artist['name']
|
|
else:
|
|
artist_name = str(artist)
|
|
|
|
db_track, confidence = db.check_track_exists(
|
|
original_title, artist_name,
|
|
confidence_threshold=0.80,
|
|
server_source=active_server
|
|
)
|
|
|
|
if db_track and confidence >= 0.80:
|
|
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
|
|
|
# Save to sync match cache
|
|
if spotify_id:
|
|
try:
|
|
from core.matching_engine import MusicMatchingEngine
|
|
me = MusicMatchingEngine()
|
|
db.save_sync_match_cache(
|
|
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
|
|
active_server, db_track.id, db_track.title, confidence
|
|
)
|
|
except Exception as e:
|
|
logger.debug("save sync match cache failed: %s", e)
|
|
|
|
# Create mock track object for playlist creation
|
|
class DatabaseTrackMock:
|
|
def __init__(self, db_track):
|
|
self.ratingKey = db_track.id
|
|
self.title = db_track.title
|
|
self.id = db_track.id
|
|
|
|
return DatabaseTrackMock(db_track), confidence
|
|
|
|
logger.warning(f"No database match found for: '{original_title}'")
|
|
return None, 0.0
|
|
|
|
except Exception as e:
|
|
logger.error(f"Database search error: {e}")
|
|
return None, 0.0
|
|
|
|
# Patch the method
|
|
sync_service._find_track_in_media_server = database_only_find_track
|
|
logger.info("Patched sync service to use database-only matching")
|
|
|
|
sync_start_time = time.time()
|
|
setup_duration = (sync_start_time - task_start_time) * 1000
|
|
logger.info(f"⏱️ [TIMING] Setup completed at {time.strftime('%H:%M:%S')} (took {setup_duration:.1f}ms)")
|
|
logger.info("Starting actual sync process with run_async()...")
|
|
|
|
# Attach original tracks map to sync_service for wishlist with album images
|
|
sync_service._original_tracks_map = original_tracks_map
|
|
|
|
# Wing It mode — skip wishlist for unmatched tracks
|
|
with sync_lock:
|
|
is_wing_it = sync_states.get(playlist_id, {}).get('wing_it', False)
|
|
sync_service._skip_wishlist = is_wing_it
|
|
|
|
# Run the sync (this is a blocking call within this thread)
|
|
result = deps.run_async(sync_service.sync_playlist(playlist, download_missing=False, profile_id=profile_id, sync_mode=sync_mode))
|
|
|
|
# Clear progress callback immediately to prevent race condition where a
|
|
# late-firing progress callback overwrites the "finished" state below
|
|
if sync_service:
|
|
sync_service.clear_progress_callback(playlist.name)
|
|
|
|
sync_duration = (time.time() - sync_start_time) * 1000
|
|
total_duration = (time.time() - task_start_time) * 1000
|
|
logger.info(f"⏱️ [TIMING] Sync completed at {time.strftime('%H:%M:%S')} (sync: {sync_duration:.1f}ms, total: {total_duration:.1f}ms)")
|
|
logger.info(f"Sync process completed! Result type: {type(result)}")
|
|
logger.info(f" Result details: matched={getattr(result, 'matched_tracks', 'N/A')}, total={getattr(result, 'total_tracks', 'N/A')}")
|
|
|
|
# Update final state on completion
|
|
# Convert result to JSON-serializable dict (datetime/errors can't be emitted via SocketIO)
|
|
# Exclude match_details (large) but include a summary of unmatched tracks
|
|
result_dict = {
|
|
k: (v.isoformat() if hasattr(v, 'isoformat') else v)
|
|
for k, v in result.__dict__.items()
|
|
if k != 'match_details'
|
|
}
|
|
# Include unmatched track names so the frontend can show which tracks failed
|
|
match_details = getattr(result, 'match_details', None)
|
|
if match_details:
|
|
unmatched_summary = [
|
|
{'name': d.get('name', ''), 'artist': d.get('artist', ''), 'image_url': d.get('image_url', '')}
|
|
for d in match_details if d.get('status') == 'not_found'
|
|
]
|
|
if unmatched_summary:
|
|
result_dict['unmatched_tracks'] = unmatched_summary
|
|
with sync_lock:
|
|
sync_states[playlist_id] = {
|
|
"status": "finished",
|
|
"progress": result_dict,
|
|
"result": result_dict
|
|
}
|
|
logger.info(f"Sync finished for {playlist_id} - state updated")
|
|
|
|
# Set playlist poster image if available (Plex, Jellyfin, Emby)
|
|
_synced = getattr(result, 'synced_tracks', 0)
|
|
logger.info(f"[PLAYLIST IMAGE] image_url={playlist_image_url!r}, synced_tracks={_synced}")
|
|
if playlist_image_url and _synced > 0:
|
|
try:
|
|
active_server = deps.config_manager.get_active_media_server()
|
|
logger.info(f"[PLAYLIST IMAGE] active_server={active_server}")
|
|
_engine = deps.media_server_engine
|
|
if active_server == 'plex' and _engine and _engine.client('plex'):
|
|
ok = _engine.client('plex').set_playlist_image(playlist_name, playlist_image_url)
|
|
logger.info(f"[PLAYLIST IMAGE] Plex upload result: {ok}")
|
|
elif active_server in ('jellyfin', 'emby') and _engine and _engine.client('jellyfin'):
|
|
ok = _engine.client('jellyfin').set_playlist_image(playlist_name, playlist_image_url)
|
|
logger.info(f"[PLAYLIST IMAGE] Jellyfin upload result: {ok}")
|
|
# Navidrome doesn't support custom playlist images
|
|
except Exception as img_err:
|
|
logger.error(f"[PLAYLIST IMAGE] Exception: {img_err}")
|
|
|
|
# Record sync history completion with per-track data
|
|
try:
|
|
matched = getattr(result, 'matched_tracks', 0)
|
|
failed = getattr(result, 'failed_tracks', 0)
|
|
synced = getattr(result, 'synced_tracks', 0)
|
|
db = MusicDatabase()
|
|
target_batch_id = sync_batch_id
|
|
if _is_resync and _resync_entry_id:
|
|
db.refresh_sync_history_entry(_resync_entry_id, matched, synced, failed)
|
|
# For re-sync, get the batch_id from the original entry
|
|
try:
|
|
entry = db.get_sync_history_entry(_resync_entry_id)
|
|
if entry:
|
|
target_batch_id = entry.get('batch_id', sync_batch_id)
|
|
except Exception as e:
|
|
logger.debug("resync history lookup failed: %s", e)
|
|
else:
|
|
db.update_sync_history_completion(sync_batch_id, matched, synced, failed)
|
|
|
|
# Save per-track match details from sync service
|
|
match_details = getattr(result, 'match_details', None)
|
|
if match_details:
|
|
try:
|
|
track_results_json = json.dumps(match_details, default=str)
|
|
saved = db.update_sync_history_track_results(target_batch_id, track_results_json)
|
|
logger.info(f"[Sync History] Saved {len(match_details)} track results for batch {target_batch_id} (saved={saved})")
|
|
except Exception as json_err:
|
|
logger.error(f"[Sync History] Failed to serialize track results: {json_err}")
|
|
else:
|
|
logger.warning(f"[Sync History] No match_details on SyncResult for batch {target_batch_id}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to record sync history completion: {e}")
|
|
|
|
if automation_id:
|
|
matched = getattr(result, 'matched_tracks', 0)
|
|
total = getattr(result, 'total_tracks', 0)
|
|
failed = getattr(result, 'failed_tracks', 0)
|
|
deps.update_automation_progress(automation_id, status='finished', progress=100,
|
|
phase='Sync complete',
|
|
log_line=f'Done: {matched}/{total} matched, {failed} failed', log_type='success')
|
|
|
|
# Emit playlist_synced event for automation engine
|
|
try:
|
|
if deps.automation_engine:
|
|
deps.automation_engine.emit('playlist_synced', {
|
|
'playlist_name': playlist_name,
|
|
'total_tracks': str(getattr(result, 'total_tracks', 0)),
|
|
'matched_tracks': str(getattr(result, 'matched_tracks', 0)),
|
|
'synced_tracks': str(getattr(result, 'synced_tracks', 0)),
|
|
'failed_tracks': str(getattr(result, 'failed_tracks', 0)),
|
|
})
|
|
except Exception as e:
|
|
logger.debug("playlist_synced emit failed: %s", e)
|
|
|
|
# Save sync status with match counts and track hash for smart-skip on next scheduled sync
|
|
import hashlib as _hl
|
|
_track_ids_str = ','.join(sorted(t.get('id', '') for t in tracks_json))
|
|
_tracks_hash = _hl.md5(_track_ids_str.encode()).hexdigest()
|
|
snapshot_id = getattr(playlist, 'snapshot_id', None)
|
|
deps.update_and_save_sync_status(playlist_id, playlist_name, playlist.owner, snapshot_id,
|
|
matched_tracks=getattr(result, 'matched_tracks', 0),
|
|
total_tracks=getattr(result, 'total_tracks', 0),
|
|
discovered_tracks=len(tracks_json),
|
|
tracks_hash=_tracks_hash)
|
|
|
|
except Exception as e:
|
|
logger.error(f"SYNC FAILED for {playlist_id}: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
with sync_lock:
|
|
sync_states[playlist_id] = {
|
|
"status": "error",
|
|
"error": str(e)
|
|
}
|
|
if automation_id:
|
|
deps.update_automation_progress(automation_id, status='error', progress=100,
|
|
phase='Error', log_line=f'Sync failed: {str(e)}', log_type='error')
|
|
finally:
|
|
logger.info(f"Cleaning up progress callback for {playlist.name}")
|
|
# Clean up the callback
|
|
if sync_service:
|
|
sync_service.clear_progress_callback(playlist.name)
|
|
# Clean up original tracks map
|
|
if hasattr(sync_service, '_original_tracks_map'):
|
|
del sync_service._original_tracks_map
|
|
logger.info(f"Cleanup completed for {playlist_id}")
|