Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions core/automation/handlers/personalized_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) ->

try:
kinds_config = config.get('kinds') or []

manager = deps.build_personalized_manager()

if not isinstance(kinds_config, list) or not kinds_config:
deps.state.set_pipeline_running(False)
return {
Expand All @@ -70,13 +73,11 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) ->
refresh_first = bool(config.get('refresh_first', False))
skip_wishlist = bool(config.get('skip_wishlist', False))

manager = deps.build_personalized_manager()

deps.update_progress(
automation_id,
progress=2,
phase=f'Personalized pipeline: {len(kinds_config)} playlist(s)',
log_line=f'Starting pipeline for {len(kinds_config)} playlist(s)',
phase=f'Auto-playlist pipeline: {len(kinds_config)} playlist(s)',
log_line=f'Starting auto-playlist pipeline for {len(kinds_config)} playlist(s)',
log_type='info',
)

Expand All @@ -91,19 +92,19 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) ->
)

profile_id = deps.get_current_profile_id()
playload_payloads = _build_payloads_for_kinds(
payloads = _build_payloads_for_kinds(
deps, manager, kinds_config, profile_id,
automation_id=automation_id,
refresh_first=refresh_first,
)

if not playload_payloads:
if not payloads:
deps.state.set_pipeline_running(False)
deps.update_progress(
automation_id,
status='finished', progress=100,
phase='No playlists to sync',
log_line='No personalized playlists had tracks to sync',
log_line='No auto-playlists had tracks to sync',
log_type='warning',
)
return {
Expand All @@ -118,15 +119,15 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) ->
automation_id,
progress=50,
phase='Phase 1/2: Snapshot complete',
log_line=f'Phase 1 done: {len(playload_payloads)} playlist(s) ready to sync',
log_line=f'Phase 1 done: {len(payloads)} playlist(s) ready to sync',
log_type='success',
)

# ── PHASE 2: SYNC + WISHLIST (shared helper) ────────────────
sync_summary = run_sync_and_wishlist(
deps,
automation_id,
playload_payloads,
payloads,
sync_one_fn=lambda pl: _sync_personalized_playlist(deps, pl),
sync_id_for_fn=lambda pl: pl['sync_id'],
skip_wishlist=skip_wishlist,
Expand All @@ -144,15 +145,15 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) ->
automation_id,
status='finished', progress=100,
phase='Pipeline complete',
log_line=f'Personalized pipeline finished in {duration // 60}m {duration % 60}s',
log_line=f'Auto-playlist pipeline finished in {duration // 60}m {duration % 60}s',
log_type='success',
)

deps.state.set_pipeline_running(False)
return {
'status': 'completed',
'_manages_own_progress': True,
'playlists_synced': str(len(playload_payloads)),
'playlists_synced': str(len(payloads)),
'tracks_synced': str(sync_summary['synced']),
'sync_skipped': str(sync_summary['skipped']),
'wishlist_queued': str(sync_summary['wishlist_queued']),
Expand All @@ -165,7 +166,7 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) ->
automation_id,
status='error', progress=100,
phase='Pipeline error',
log_line=f'Personalized pipeline failed: {e}',
log_line=f'Auto-playlist pipeline failed: {e}',
log_type='error',
)
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
Expand Down
98 changes: 98 additions & 0 deletions core/automation_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ def ensure_system_automations(self):
self._fix_airing_automation_schedule()
self._fix_deep_scan_schedules()
self._fix_wishlist_processor_rename()
self._migrate_auto_refresh_to_automations()

def _fix_video_scan_default(self):
"""Remove the obsolete standalone 'Scan Video Library' SYSTEM automation — it's
Expand Down Expand Up @@ -710,6 +711,103 @@ def _fix_deep_scan_schedules(self):
except Exception:
logger.exception("deep-scan schedule migration failed for %s", action_type)

def _migrate_auto_refresh_to_automations(self):
"""One-time migration: convert legacy ``auto_refresh``/``refresh_interval_hours``
stored in ``config_json.extra`` into proper per-playlist automation rows.

Each playlist with auto_refresh=True becomes its own automation row with
action_type='personalized_pipeline', owned_by='auto_playlist', and the
correct profile_id. Removes the old system 'Auto-Playlist Refresh' row."""
try:
# Remove the old system automation if it still exists
old_system = self.db.get_system_automation_by_action('personalized_pipeline')
if old_system:
self.db.update_automation(old_system['id'], is_system=0)
self.db.delete_automation(old_system['id'])
logger.info("Removed legacy system 'Auto-Playlist Refresh' automation (id=%s)",
old_system.get('id'))

# Find all personalized playlists with auto_refresh in their extra config
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id, profile_id, kind, variant, name, config_json "
"FROM personalized_playlists"
)
rows = [dict(r) for r in cursor.fetchall()]

migrated = 0
existing_pipeline = self.db.get_automations_by_action('personalized_pipeline') or []
for row in rows:
try:
cfg = json.loads(row.get('config_json') or '{}')
extra = cfg.get('extra') or {}
if not extra.get('auto_refresh'):
continue

# Skip if automation already exists for this playlist
already_exists = False
for auto in existing_pipeline:
if auto.get('owned_by') != 'auto_playlist':
continue
if auto.get('profile_id') != row['profile_id']:
continue
ac = json.loads(auto.get('action_config') or '{}')
kinds = ac.get('kinds') or []
if any(k.get('kind') == row['kind']
and k.get('variant', '') == (row.get('variant') or '')
for k in kinds):
already_exists = True
break
if already_exists:
continue

interval_hours = max(1, int(extra.get('refresh_interval_hours', 24)))
kind_display = row['kind'].replace('_', ' ').title()
variant_display = f" ({row['variant']})" if row.get('variant') else ''

from core.personalized.api import _interval_to_trigger
trigger_type, trigger_config = _interval_to_trigger(interval_hours)

action_config = {
'kinds': [{'kind': row['kind'], 'variant': row.get('variant') or ''}]
}

aid = self.db.create_automation(
name=f"Auto-Refresh: {kind_display}{variant_display}",
trigger_type=trigger_type,
trigger_config=json.dumps(trigger_config),
action_type='personalized_pipeline',
action_config=json.dumps(action_config),
profile_id=row['profile_id'],
owned_by='auto_playlist',
)
if aid:
migrated += 1
logger.info("Migrated auto_refresh playlist '%s' → automation id=%s",
row['name'], aid)

# Clean up legacy auto_refresh from config_json.extra
del extra['auto_refresh']
if 'refresh_interval_hours' in extra:
del extra['refresh_interval_hours']
cfg['extra'] = extra
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE personalized_playlists SET config_json = ? WHERE id = ?",
(json.dumps(cfg), row['id']),
)
conn.commit()

except Exception:
logger.exception("Failed to migrate playlist id=%s", row.get('id'))

if migrated:
logger.info("Auto-refresh migration: converted %d playlists to automation rows", migrated)
except Exception:
logger.exception("auto_refresh → automation migration failed")

def get_system_automation_next_run_seconds(self, action_type):
"""Get seconds until next run for a system automation. Returns 0 if not found or disabled."""
auto = self.db.get_system_automation_by_action(action_type)
Expand Down
Loading