diff --git a/locale/themerr.po b/locale/themerr.po index 3e318445..aace9039 100644 --- a/locale/themerr.po +++ b/locale/themerr.po @@ -905,7 +905,7 @@ msgstr "" msgid "ID" msgstr "" -#: web/templates/activity.html:8 web/templates/home.html:80 +#: web/templates/activity.html:8 web/templates/home.html:81 msgid "Outdated from ThemerrDB" msgstr "" @@ -1453,19 +1453,23 @@ msgstr "" msgid "Theme not installed yet" msgstr "" -#: web/templates/home.html:82 +#: web/templates/home.html:80 +msgid "Retry" +msgstr "" + +#: web/templates/home.html:83 msgid "Edit" msgstr "" -#: web/templates/home.html:82 +#: web/templates/home.html:83 msgid "Add" msgstr "" -#: web/templates/home.html:89 +#: web/templates/home.html:90 msgid "No titles match." msgstr "" -#: web/templates/home.html:89 +#: web/templates/home.html:90 msgid "Try another search or reset your filters." msgstr "" diff --git a/src/common/server_ui.py b/src/common/server_ui.py index 6ac820ff..686e65a0 100644 --- a/src/common/server_ui.py +++ b/src/common/server_ui.py @@ -257,6 +257,32 @@ def refresh(payload=Depends(_payload)): ) +@router.api_route('/api/tasks/retry', methods=['POST'], name='server_ui.retry', response_model=None) +def retry(payload=Depends(_payload)): + """Queue one cached library item for another theme attempt. + + Returns + ------- + Response + Dispatch confirmation, or a fixed client error when the item cannot be retried. + """ + from themerr import mcp_tools + server_id = payload.get('server_id') + item_id = payload.get('item_id') + if not all(isinstance(value, str) and value for value in ( + server_id, + item_id, + )): + return JSONResponse({'message': 'Select an item to retry.'}, status_code=400) + try: + result = mcp_tools.retry_items(server_id, [item_id]) + except mcp_tools.ToolInputError as error: + return JSONResponse({'message': str(error)}, status_code=400) + message = 'Retry queued. Follow progress in Activity.' if result['queued_item_ids'] \ + else 'This item is already queued.' + return JSONResponse({'message': message}, status_code=202) + + @router.api_route('/api/themerrdb', methods=['GET', 'HEAD'], name='server_ui.database_status', response_model=None) def database_status(): """Return the latest successful ThemerrDB Pages deployment from the hourly cache. diff --git a/src/themerr/mcp_tools.py b/src/themerr/mcp_tools.py index ac8b05da..2b4737d7 100644 --- a/src/themerr/mcp_tools.py +++ b/src/themerr/mcp_tools.py @@ -317,7 +317,7 @@ def retry_items(server_id, item_ids): queued.append(item_id) else: existing.append(item_id) - logger.get_logger(__name__).info('MCP retry requested for server %s: %d queued, %d already active', + logger.get_logger(__name__).info('Retry requested for server %s: %d queued, %d already active', record['id'], len(queued), len(existing)) return { 'queued_item_ids': queued, diff --git a/tests/functional/test_server_ui.py b/tests/functional/test_server_ui.py index dc279f23..4a17f6c4 100644 --- a/tests/functional/test_server_ui.py +++ b/tests/functional/test_server_ui.py @@ -450,3 +450,129 @@ def test_contribution_actions_reflect_video_issues(client, reason, action, visib assert b'title="Movie" aria-hidden="true"' in rendered assert b'data-lucide="film"' in rendered assert b'data-lucide="play"' in rendered + + +def _retry_body(server_id='one', item_id='42'): + return { + 'server_id': server_id, + 'item_id': item_id, + } + + +def test_retry_button_only_shows_for_pending_and_attention_items(client): + statuses = { + 'a': ( + 'themerr', + 'complete', + ), + 'b': ( + None, + 'pending', + ), + 'c': ( + None, + 'failed', + ), + 'd': ( + None, + 'missing', + ), + 'e': ( + None, + 'unresolved', + ), + 'f': ( + 'themerr', + 'complete', + ), + } + for identifier, (provider, status) in statuses.items(): + save_server(identifier) + data = snapshot(f'Title {identifier}', provider) + data['1']['items'][0]['theme_status'] = status + with storage.server_scope(identifier): + storage.replace_dashboard(data) + with storage.server_scope('f'): + storage.set_error(42, 'Theme is outdated') + page = client.get('/').text + shown = set(re.findall(r'data-retry-item data-server-id="([^"]+)" data-item-id="42"', page)) + assert shown == { + 'b', + 'c', + 'e', + 'f', + } + + +def test_retry_queues_a_cached_item(client, monkeypatch): + save_server('one') + with storage.server_scope('one'): + storage.replace_dashboard(snapshot('Pending')) + enqueue = Mock(return_value=True) + monkeypatch.setattr(processing, 'enqueue', enqueue) + response = client.post( + '/api/tasks/retry', + json=_retry_body(), + headers=headers(client), + ) + assert response.status_code == 202 + assert response.json()['message'] == 'Retry queued. Follow progress in Activity.' + enqueue.assert_called_once_with('42') + + enqueue.return_value = False + response = client.post( + '/api/tasks/retry', + json=_retry_body(), + headers=headers(client), + ) + assert response.status_code == 202 + assert response.json()['message'] == 'This item is already queued.' + + +@pytest.mark.parametrize('body', [ + [], + {}, + { + 'server_id': 'one', + }, + _retry_body(item_id=''), + _retry_body(item_id=42), + _retry_body(server_id='missing'), + _retry_body(item_id='99'), +]) +def test_retry_rejects_invalid_or_unknown_items(client, monkeypatch, body): + save_server('one') + with storage.server_scope('one'): + storage.replace_dashboard(snapshot('Pending')) + enqueue = Mock(return_value=True) + monkeypatch.setattr(processing, 'enqueue', enqueue) + response = client.post('/api/tasks/retry', json=body, headers=headers(client)) + assert response.status_code == 400 + enqueue.assert_not_called() + + +def test_retry_respects_disabled_updates_servers_ignored_libraries_and_csrf(client, configured, monkeypatch): + save_server('one') + with storage.server_scope('one'): + storage.replace_dashboard(snapshot('Pending')) + enqueue = Mock(return_value=True) + monkeypatch.setattr(processing, 'enqueue', enqueue) + body = _retry_body() + assert client.post('/api/tasks/retry', json=body).status_code == 400 + + configured['Themerr']['BOOL_THEMERR_ENABLED'] = False + assert client.post('/api/tasks/retry', json=body, headers=headers(client)).status_code == 400 + configured['Themerr']['BOOL_THEMERR_ENABLED'] = True + + with Session(storage.engine()) as session: + session.get(servers.ServerRecord, 'one').ignored_libraries = '1' + session.commit() + assert client.post('/api/tasks/retry', json=body, headers=headers(client)).status_code == 400 + + with Session(storage.engine()) as session: + record = session.get(servers.ServerRecord, 'one') + record.ignored_libraries = '' + record.enabled = False + session.commit() + assert client.post('/api/tasks/retry', json=body, headers=headers(client)).status_code == 400 + enqueue.assert_not_called() diff --git a/tests/web/dashboard.test.mjs b/tests/web/dashboard.test.mjs index 8f86790c..e5a6e20b 100644 --- a/tests/web/dashboard.test.mjs +++ b/tests/web/dashboard.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { matchesFilters, initDashboard, waitForRefresh } from '../../web/js/dashboard.js'; +import { matchesFilters, initDashboard, retryItem, waitForRefresh } from '../../web/js/dashboard.js'; test('library filters distinguish the same title on separate servers and handle Unicode search', () => { const media = { title: 'Léon: The Professional', server: 'a', type: 'movie', theme: true, attention: false }; @@ -65,3 +65,35 @@ test('refresh waits for its own job and reports failures or long-running work', assert.equal(await waitForRefresh('requested', async () => ({ jobs: [] }), async () => {}), 'running'); }); + +test('retry posts the selected server item and reports the result', async () => { + const calls = []; + const messages = []; + const message = 'Retry queued. Follow progress in Activity.'; + const button = { + dataset: { + serverId: 'server-1', + itemId: '42', + }, + }; + const result = await retryItem(button, async (path, options) => { + calls.push([ + path, + options, + ]); + return { message }; + }, text => messages.push(text)); + assert.deepEqual(calls, [ + [ + '/api/tasks/retry', + { + body: { + server_id: 'server-1', + item_id: '42', + }, + }, + ], + ]); + assert.deepEqual(messages, [message]); + assert.equal(result.message, message); +}); diff --git a/web/css/custom.css b/web/css/custom.css index 10e11404..ce807dd9 100644 --- a/web/css/custom.css +++ b/web/css/custom.css @@ -200,6 +200,10 @@ details[open] > summary .details-chevron { transform: rotate(180deg); } .failure-reason { margin-top: 7px; max-width: 330px; font-size: 10px; color: var(--danger); overflow-wrap: anywhere; } .failure-reason strong { display: flex; align-items: center; gap: 5px; margin-bottom: 4px; color: var(--warning); font-weight: 500; } .failure-reason .lucide { width: 12px; height: 12px; } +.retry-button { display: inline-flex; align-items: center; gap: 6px; margin: 3px 0 3px 7px; padding: 4px 7px; background: transparent; color: var(--ink-soft); border: 1px solid var(--line); border-radius: 6px; font-size: 10px; font-weight: 500; line-height: inherit; white-space: nowrap; } +.retry-button:hover:not(:disabled) { color: var(--accent); background: var(--surface-raised); } +.retry-button:disabled { opacity: .6; cursor: progress; } +.retry-button .lucide { width: 13px; height: 13px; stroke-width: 2; } .contribute-link { display: inline-flex; align-items: center; gap: 5px; color: var(--ink-soft); font-size: 11px; } .contribute-link:hover { color: var(--accent); } .contribute-link .lucide { width: 13px; height: 13px; } diff --git a/web/js/dashboard.js b/web/js/dashboard.js index e86bdfe2..e3b04f17 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -68,6 +68,17 @@ export async function waitForRefresh(id, query, delay) { return 'running'; } +export async function retryItem(button, request = api, notify = toast) { + const result = await request('/api/tasks/retry', { + body: { + server_id: button.dataset.serverId, + item_id: button.dataset.itemId, + }, + }); + notify(result.message); + return result; +} + async function refreshLibraries(button) { const result = await api('/api/tasks/refresh', { body: { scan: button.dataset.scan === 'true' } }); toast(result.message); @@ -125,6 +136,8 @@ export function initNavigation() { export function initPageControls() { document.querySelectorAll('[data-refresh]').forEach(button => button.addEventListener('click', () => busy(button, () => refreshLibraries(button)))); + document.querySelectorAll('[data-retry-item]').forEach(button => button.addEventListener('click', + () => busy(button, () => retryItem(button)))); document.querySelectorAll('time').forEach(element => { const date = new Date(element.textContent); if (!Number.isNaN(date.valueOf())) { diff --git a/web/templates/home.html b/web/templates/home.html index 46f2a72b..c96129ba 100644 --- a/web/templates/home.html +++ b/web/templates/home.html @@ -77,6 +77,7 @@