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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ genuinely diverges.
## Mandatory Workflow

### After Every Code Change
1. **Bundle changes into releases** (user, 2026-10-17): a release with one changelog item is too small. A release carries 5 to 10 changelog items (user, 2026-10-23): release once the open entry reaches 10, or sooner at a natural stop once it has 5. While the version at the top of `data/changelog.yaml` has no `v<version>` git tag, it is still open: add the change to that entry and keep the version. Otherwise bump `config.py` AND `config.example.py` (`APP_VERSION` + `APP_LAST_UPDATE`) and open a new entry. Keep `APP_LAST_UPDATE` current either way.
1. **Bundle changes into releases** (user, 2026-10-17): a release with one changelog item is too small. A release carries at least 5 changelog items, with no upper limit (user, 2026-10-27: the floor only stops a release going out with one item; an open entry may grow past 10). While the version at the top of `data/changelog.yaml` has no `v<version>` git tag, it is still open: add the change to that entry and keep the version. Otherwise bump `config.py` AND `config.example.py` (`APP_VERSION` + `APP_LAST_UPDATE`) and open a new entry. Keep `APP_LAST_UPDATE` current either way.
2. Add or extend the changelog entry at top of `data/changelog.yaml`. For a real release, also translate the **recent** entry (current line onward; deep history stays English) into each per-locale `data/changelog.<locale>.yaml` (the human-translation locales in `docs/specs/i18n.md` §9 — a different delivery path from the installed UI-catalog set `services/i18n.py::available_locales()` derives, §3). Repeat `version`, `date`, and every tag verbatim — the `/changelog` route swaps the whole entry in by version, so anything omitted is dropped, not inherited (Pass 43.6). Skip for internal/no-release bumps.
3. Rebuild CSS if any `static/css/**.css` changed: `python3 build_css.py`
4. Rebuild JS if any bundled `static/js/*.js` changed: `python3 build_js.py`
Expand Down
45 changes: 36 additions & 9 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1019,11 +1019,17 @@ def _get_collector_rank_safe():
trophies module / table isn't ready yet (e.g. first boot before the
table has been populated). The sidebar badge reads this on every
render, so it must never raise."""
# inject_config runs for every render_template call in a request, so the
# rank is read once per request and reused.
if '_collector_rank' in g:
return g._collector_rank
try:
from routes.collector_trophies import get_current_rank
return get_current_rank()
rank = get_current_rank()
except Exception:
return None
rank = None
g._collector_rank = rank
return rank


# =============================================================================
Expand Down Expand Up @@ -1337,6 +1343,11 @@ def dashboard():
jtype = rj['job_type']
status = rj['status']

# A job the user may neither resume nor dismiss is not offered:
# the banner would show buttons that only answer 403.
if not _may_manage_job(jtype, params):
continue

# Determine if this job had real progress (interrupted mid-run)
# vs was just queued (no work done yet)
has_progress = progress.get('current', 0) > 0
Expand Down Expand Up @@ -1409,6 +1420,16 @@ def dashboard():
}


def _may_manage_job(job_type, params):
"""PASS-59-118 — resuming or dismissing a recoverable job needs the
rights starting it needed: the start route's role, and a per-user job
belongs to its user (admins excepted)."""
role = g.user['role']
owner = (params or {}).get('user_id')
return (role in _RESUME_ROLES.get(job_type, ('admin',))
and (owner is None or owner == g.user['id'] or role == 'admin'))


@app.route('/api/jobs/resume/<int:job_id>', methods=['POST'])
@login_required
@handle_api_errors
Expand Down Expand Up @@ -1450,12 +1471,7 @@ def resume_job(job_id):
if not handler or not hasattr(handler, 'resume_from_params'):
return api_error(f"No handler for job type '{job['job_type']}'", 400)

# PASS-59-118 — resuming needs the rights starting needed: the start
# route's role, and a per-user job belongs to its user (admins excepted).
role = g.user['role']
need = _RESUME_ROLES.get(job['job_type'], ('admin',))
owner = (job.get('params') or {}).get('user_id')
if role not in need or (owner is not None and owner != g.user['id'] and role != 'admin'):
if not _may_manage_job(job['job_type'], job.get('params')):
return api_error('You do not have permission to perform this action', 403)

resumed = handler.resume_from_params(job['params'], progress=job.get('progress'))
Expand All @@ -1471,8 +1487,19 @@ def resume_job(job_id):
@login_required
@handle_api_errors
def dismiss_job_route(job_id):
"""Dismiss a recoverable job so it no longer appears on the dashboard."""
"""Dismiss a recoverable job so it no longer appears on the dashboard.

The same rights as resuming it: dismissing throws the job away.
"""
from services.jobs.base import dismiss_job
row = query("SELECT job_type, params FROM job_queue WHERE id = ?", (job_id,), one=True)
if row:
try:
params = json.loads(row['params']) if row['params'] else {}
except (TypeError, ValueError):
params = {}
if not _may_manage_job(row['job_type'], params if isinstance(params, dict) else {}):
return api_error('You do not have permission to perform this action', 403)
dismiss_job(job_id)
return api_success()

Expand Down
2 changes: 1 addition & 1 deletion config.example.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
# Application metadata
APP_NAME = "RetroDB"
APP_VERSION = "3.33.0"
APP_LAST_UPDATE = "2026-10-23"
APP_LAST_UPDATE = "2026-10-27"
APP_DESCRIPTION = "Retro Gaming ROM Library Manager"

# Supported image extensions for boxart/screenshots
Expand Down
62 changes: 61 additions & 1 deletion data/changelog.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
- version: 3.33.0
date: 2026-10-23
date: 2026-10-27
tags:
- type: feature
label: Get RetroArch from inside RetroDB
- type: fix
label: Next disk works for games played in RetroDB
- type: fix
label: Game cards line up on a phone and after resizing the window
- type: fix
label: Scraped ratings and player counts are more accurate
- type: fix
label: ScreenScraper keeps its details when it is the main source
- type: fix
label: Clear scraped data puts the file name back as the title
- type: fix
label: RetroAchievements refresh for one console works again
- type: fix
label: Screenshot duplicate finder works in the downloaded app
- type: fix
label: ScreenScraper passwords are hidden, and the last admin stays admin
- type: fix
label: Interrupted jobs are only offered to people allowed to run them
- type: performance
label: Faster trophies, library imports and maintenance tools
body: |
<ul>
<li><strong>Get RetroArch from inside RetroDB.</strong> Settings &gt;
Expand All @@ -20,6 +38,48 @@
own RetroArch with no setup. RetroArch listens for RetroDB's
commands only while such a game runs, and on Windows and macOS
while any game runs.</li>
<li><strong>Game cards line up on a phone and after resizing the
window.</strong> On a phone the Library and console pages showed one
card and then a long empty gap; cards now stack one under another.
Making the window narrower or wider re-arranges the cards instead of
leaving them in the old columns.</li>
<li><strong>Scraped ratings and player counts are more accurate.</strong>
Age ratings from IGDB for CERO, USK, GRAC, ClassInd and ACB are now
read correctly, so a game rated CERO Z or USK 18 gets that rating.
TheGamesDB, RAWG, IGDB and ES-DE no longer guess "1 player" when
they don't know; an ES-DE import no longer overwrites a player count
or game modes you set. TheGamesDB no longer saves a number in place
of a publisher or developer name after a failed lookup.</li>
<li><strong>ScreenScraper keeps its details when it is the main
source.</strong> Its title, description, date, developer, publisher
and players are kept on every scrape, not only the first few
minutes after a search. Downloaded videos and manuals get a proper
file name.</li>
<li><strong>Clear scraped data puts the file name back as the
title.</strong> It cleared the details but left the scraped
title.</li>
<li><strong>RetroAchievements refresh for one console works
again.</strong> It failed with an error every time.</li>
<li><strong>Screenshot duplicate finder works in the downloaded
app.</strong> It found no screenshots there, and deleting a
duplicate left the file on disk.</li>
<li><strong>ScreenScraper passwords are hidden, and the last admin
stays admin.</strong> Settings now shows your ScreenScraper
passwords masked, like your other keys. The only admin account can
no longer be switched to editor or turned off, which would have
left nobody able to manage RetroDB.</li>
<li><strong>Interrupted jobs are only offered to people allowed to run
them.</strong> The dashboard's interrupted-job banner now shows a
job only to someone allowed to start it, for example a bulk scrape
only to editors and admins. Other accounts no longer see it and can
no longer dismiss it.</li>
<li><strong>Faster trophies, library imports and maintenance
tools.</strong> The Trophies page, Steam, Xbox and PlayStation
library imports, collector trophies, Calculate storage and the
unused-media scan all do far less work on a large library. Job
progress pop-ups keep updating when the server is busy, and the
HowLongToBeat and alternate-title jobs no longer hold up other
changes while they run.</li>
</ul>
- version: 3.32.0
date: 2026-10-23
Expand Down
6 changes: 4 additions & 2 deletions docs/specs/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,10 @@ Anything not persisted in the last progress-tick window (≤ 10 items or
2. `sweep_old_job_history()` — deletes terminal rows older than retention
(§10).
3. The dashboard reads `get_recoverable_jobs()` and offers a banner.
`BulkScrapeJob` deduplicates by `(system_id, scrape_mode)` and
auto-dismisses losers (`base.py:606`).
`BulkScrapeJob` deduplicates single-system scrapes by
`(system_id, scrape_mode)` and auto-dismisses losers (`base.py:606`).
Multi-system scrapes (`system_id` None) are distinct selections and are
never deduplicated.
4. User clicks "Resume" → route calls `<job>.resume_from_params(params,
progress)` which restarts the worker at `progress['current']`. **Seven
classes implement resume**: `BulkScrapeJob`, `RASyncJob`, `RARefreshJob`,
Expand Down
12 changes: 11 additions & 1 deletion install_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,22 @@ def start_install(self):

self.install_btn.configure(state='disabled', bg=TEXT_MUTED, cursor='arrow')

thread = threading.Thread(target=self._install_worker, daemon=True)
thread = threading.Thread(target=self._run_install_worker, daemon=True)
thread.start()
self._poll_queue()

# ── Install worker (background thread) ───────────────────────────────

def _run_install_worker(self):
"""Run the worker so an unexpected error (an unwritable config copy or
directory, say) still reaches the UI. Uncaught, it ended the thread
before 'done' was queued, and the window sat with Install disabled."""
try:
self._install_worker()
except Exception as e:
self._log(f'Installer error: {e}', 'error')
self._finish(False)

def _install_worker(self):
base_dir = self.base_dir
errors = []
Expand Down
Loading
Loading