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
14 changes: 9 additions & 5 deletions locale/themerr.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""

Expand Down Expand Up @@ -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 ""

Expand Down
26 changes: 26 additions & 0 deletions src/common/server_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/themerr/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
126 changes: 126 additions & 0 deletions tests/functional/test_server_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
34 changes: 33 additions & 1 deletion tests/web/dashboard.test.mjs
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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);
});
4 changes: 4 additions & 0 deletions web/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
13 changes: 13 additions & 0 deletions web/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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())) {
Expand Down
1 change: 1 addition & 0 deletions web/templates/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ <h2>{{ _('Your library deserves a soundtrack.') if not servers else _('Getting t
{% elif item.theme_status == 'missing' %}<span class="status-badge neutral"><i data-lucide="circle-x"></i>{{ _('Missing from ThemerrDB') }}</span>
{% elif item.theme_status == 'unresolved' %}<span class="status-badge warning"><i data-lucide="circle-help"></i>{{ _('TMDB ID unavailable') if item.source_id else _('External ID unavailable') }}</span>
{% else %}<span class="status-badge pending"><i data-lucide="clock"></i>{{ _('Theme not installed yet') }}</span>{% endif %}
{% if (attention or item.theme_status == 'pending') and section.server_id != 'default' %}<button type="button" class="retry-button" data-retry-item data-server-id="{{ section.server_id }}" data-item-id="{{ item.rating_key }}"><i data-lucide="refresh-cw" aria-hidden="true"></i>{{ _('Retry') }}<span class="visually-hidden"> {{ item.title }}</span></button>{% endif %}
{% if failure_reason %}<div class="failure-reason">{% if item.theme %}<strong><i data-lucide="triangle-alert"></i>{{ _('Outdated from ThemerrDB') }}</strong>{% endif %}<span>{{ failure_reason }}</span></div>{% endif %}
</td>
<td class="contribution-cell">{% if item.issue_url and (item.issue_action != 'edit' or item.show_edit) %}<a class="contribute-link" href="{{ item.issue_url }}" target="_blank" rel="noopener noreferrer">{{ _('Edit') if item.issue_action == 'edit' else _('Add') }}<i data-lucide="arrow-up-right"></i></a>{% else %}<span class="muted">—</span>{% endif %}</td>
Expand Down
Loading