Skip to content
Merged
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
2 changes: 1 addition & 1 deletion app_alchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ def run_radio_playlists_endpoint():
---
tags:
- Alchemy
summary: Delete old '_radio' playlists, then create one playlist per enabled radio on the media server.
summary: Upsert one playlist per enabled radio (reuses existing playlist by name, preserving its server-side ID).
responses:
200:
description: Run summary.
Expand Down
29 changes: 15 additions & 14 deletions tasks/radio_manager.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import logging

from .song_alchemy import song_alchemy
from .mediaserver import create_playlist, delete_playlists_by_suffix
from .mediaserver import create_or_replace_playlist, create_playlist

logger = logging.getLogger(__name__)

RADIO_PLAYLIST_SUFFIX = '_radio'


def run_radio_playlists():
"""Generate one playlist per enabled radio (anchor + temperature + number of results).

Runs synchronously, like the sonic fingerprint cron flow: compute all
playlists first, then delete every existing playlist ending with '_radio',
then create the new ones on the media server.
Uses create_or_replace_playlist so the same server-side playlist (and ID,
where the backend allows) gets reused across runs — avoiding duplicate
playlists on online-first sync clients (e.g. Symfonium on Navidrome)
that track playlists by ID.

Falls back to create_playlist for MPD and other unsupported backends.
"""
from app_helper import get_alchemy_radios

Expand All @@ -23,7 +24,7 @@ def run_radio_playlists():
generated = []
failed = []
for radio in radios:
playlist_name = f"{radio['name']}{RADIO_PLAYLIST_SUFFIX}"
playlist_name = radio['name']
Comment thread
NeptuneHub marked this conversation as resolved.
try:
outcome = song_alchemy(
add_items=[{'type': 'anchor', 'id': radio['anchor_id']}],
Expand All @@ -40,19 +41,19 @@ def run_radio_playlists():
failed.append(playlist_name)
logger.exception(f"Radio '{radio['name']}' failed; skipping playlist creation.")

try:
delete_playlists_by_suffix(RADIO_PLAYLIST_SUFFIX)
except Exception:
logger.exception(f"Failed to delete old '{RADIO_PLAYLIST_SUFFIX}' playlists; continuing with playlist creation.")

created = 0
for playlist_name, item_ids in generated:
try:
create_playlist(playlist_name, item_ids)
try:
create_or_replace_playlist(playlist_name, item_ids)
except NotImplementedError:
# MPD or unsupported backend: fall back to plain create.
create_playlist(playlist_name, item_ids)
created += 1
logger.info(f"Radio playlist '{playlist_name}' upserted with {len(item_ids)} tracks.")
except Exception:
failed.append(playlist_name)
logger.exception(f"Failed to create playlist '{playlist_name}' on the media server.")
logger.exception(f"Failed to upsert playlist '{playlist_name}' on the media server.")

summary = {
"message": f"Created {created} radio playlist(s) from {len(radios)} enabled radio(s).",
Expand Down
4 changes: 2 additions & 2 deletions templates/alchemy.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
<section>
<header class="page-header">
<h1>AudioMuse-AI - Song Alchemy</h1>
<p>Select tracks or artists to Include or Exclude boost favorites with Include and remove unwanted flavors with Exclude.</p>
<p>Select tracks or artists to Include or Exclude, boost favorites with Include and remove unwanted flavors with Exclude.</p>
Comment thread
NeptuneHub marked this conversation as resolved.
<div class="alchemy-tabs" style="margin-top:1rem; display:flex; gap:0.5rem; justify-content:flex-end;">
<button type="button" id="tab-alchemy" class="tab-btn active">Alchemy</button>
<button type="button" id="tab-anchors" class="tab-btn">Anchors</button>
Expand Down Expand Up @@ -214,7 +214,7 @@ <h2>Saved Anchors</h2>
<h2>Radios</h2>
<button type="button" id="radio-back-to-alchemy-btn" class="btn btn-ghost btn-sm">← Back to Alchemy</button>
</div>
<p style="font-size:0.9rem; color:var(--text-muted); margin-top:0.3rem;">A radio is a saved anchor plus a temperature and a number of results. "Create Radio Playlists" deletes every old <em>_radio</em> playlist on the media server and creates a fresh one per enabled radio, named after its anchor.</p>
<p style="font-size:0.9rem; color:var(--text-muted); margin-top:0.3rem;">A radio is a saved anchor plus a temperature and a number of results.</p>
<div class="responsive-table-wrapper" style="margin-top: 0.7rem;">
<table>
<thead><tr><th style="text-align:center;">Enabled</th><th>Name</th><th>Temp (τ)</th><th title="Number of results">Results</th><th></th></tr></thead>
Expand Down
2 changes: 1 addition & 1 deletion templates/cron.html
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ <h1>AudioMuse-AI - Scheduled Tasks</h1>
Radio Playlists
<span class="info-tooltip" tabindex="0">
<span class="info-icon"></span>
<span class="tooltip-text">Schedule automatic creation of the Radio playlists defined in the Alchemy page (Radio tab). Old "_radio" playlists are deleted and recreated for every enabled radio. Uses cron notation: 5 fields (minute hour day month weekday). Example: "0 3 * * 6" = 3:00 AM every Saturday.</span>
<span class="tooltip-text">Schedule automatic creation of the Radio playlists defined in the Alchemy page (Radio tab). Each radio's existing playlist is updated in-place (preserving its server-side ID) to avoid duplicates on sync clients. Uses cron notation: 5 fields (minute hour day month weekday). Example: "0 3 * * 6" = 3:00 AM every Saturday.</span>
</span>
</label>
<input id="alchemy-radio-cron" type="text" style="width:100%;" placeholder="cron expression (e.g. 0 3 * * 6)">
Expand Down
39 changes: 16 additions & 23 deletions tests/unit/test_app_alchemy_radio.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,9 @@ def _radio(self, radio_id, anchor_id, name, temperature=1.0, n_results=100, enab
'temperature': temperature, 'n_results': n_results, 'enabled': enabled}

@patch('app_helper.get_alchemy_radios')
@patch('tasks.radio_manager.create_playlist')
@patch('tasks.radio_manager.delete_playlists_by_suffix')
@patch('tasks.radio_manager.create_or_replace_playlist')
@patch('tasks.radio_manager.song_alchemy')
def test_creates_playlists_for_enabled_radios_only(self, mock_alchemy, mock_delete, mock_create,
def test_creates_playlists_for_enabled_radios_only(self, mock_alchemy, mock_upsert,
mock_get_radios):
from tasks.radio_manager import run_radio_playlists

Expand All @@ -183,36 +182,32 @@ def test_creates_playlists_for_enabled_radios_only(self, mock_alchemy, mock_dele

mock_alchemy.assert_called_once_with(
add_items=[{'type': 'anchor', 'id': 10}], n_results=30, temperature=0.5)
mock_delete.assert_called_once_with('_radio')
mock_create.assert_called_once_with('Chill_radio', ['a', 'b'])
mock_upsert.assert_called_once_with('Chill', ['a', 'b'])
assert summary['playlists_created'] == 1
assert summary['radios_enabled'] == 1

@patch('app_helper.get_alchemy_radios')
@patch('tasks.radio_manager.create_playlist')
@patch('tasks.radio_manager.delete_playlists_by_suffix')
@patch('tasks.radio_manager.create_or_replace_playlist')
@patch('tasks.radio_manager.song_alchemy')
def test_deletes_old_radio_playlists_after_generation(self, mock_alchemy, mock_delete, mock_create,
mock_get_radios):
def test_upserts_after_generation(self, mock_alchemy, mock_upsert,
mock_get_radios):
from tasks.radio_manager import run_radio_playlists

mock_get_radios.return_value = [self._radio(1, 10, 'Chill')]
mock_alchemy.return_value = {'results': [{'item_id': 'a'}]}
order_tracker = MagicMock()
order_tracker.attach_mock(mock_alchemy, 'alchemy')
order_tracker.attach_mock(mock_delete, 'delete')
order_tracker.attach_mock(mock_create, 'create')
order_tracker.attach_mock(mock_upsert, 'upsert')

run_radio_playlists()

names = [c[0] for c in order_tracker.mock_calls]
assert names.index('alchemy') < names.index('delete') < names.index('create')
assert names.index('alchemy') < names.index('upsert')

@patch('app_helper.get_alchemy_radios')
@patch('tasks.radio_manager.create_playlist')
@patch('tasks.radio_manager.delete_playlists_by_suffix')
@patch('tasks.radio_manager.create_or_replace_playlist')
@patch('tasks.radio_manager.song_alchemy')
def test_one_failing_radio_does_not_block_others(self, mock_alchemy, mock_delete, mock_create,
def test_one_failing_radio_does_not_block_others(self, mock_alchemy, mock_upsert,
mock_get_radios):
from tasks.radio_manager import run_radio_playlists

Expand All @@ -224,15 +219,14 @@ def test_one_failing_radio_does_not_block_others(self, mock_alchemy, mock_delete

summary = run_radio_playlists()

mock_create.assert_called_once_with('Chill_radio', ['x'])
mock_upsert.assert_called_once_with('Chill', ['x'])
assert summary['playlists_created'] == 1
assert summary['failed'] == ['Broken_radio']
assert summary['failed'] == ['Broken']

@patch('app_helper.get_alchemy_radios')
@patch('tasks.radio_manager.create_playlist')
@patch('tasks.radio_manager.delete_playlists_by_suffix')
@patch('tasks.radio_manager.create_or_replace_playlist')
@patch('tasks.radio_manager.song_alchemy')
def test_radio_with_no_results_creates_no_playlist(self, mock_alchemy, mock_delete, mock_create,
def test_radio_with_no_results_creates_no_playlist(self, mock_alchemy, mock_upsert,
mock_get_radios):
from tasks.radio_manager import run_radio_playlists

Expand All @@ -241,10 +235,9 @@ def test_radio_with_no_results_creates_no_playlist(self, mock_alchemy, mock_dele

summary = run_radio_playlists()

mock_delete.assert_called_once_with('_radio')
mock_create.assert_not_called()
mock_upsert.assert_not_called()
assert summary['playlists_created'] == 0
assert summary['failed'] == ['Empty_radio']
assert summary['failed'] == ['Empty']


class TestDeletePlaylistsBySuffix:
Expand Down
Loading