Game Metadata Editing & Playtime Filter#73
Conversation
- Add bulk edit API (POST /api/games/bulk/edit) for genres_override and playtime_label fields - Add GET /api/genres endpoint returning merged genres list - Add edit_modal.js: shared modal for library and game detail pages - Add pencil button on game cards and 'Edit Metadata' in action bar - Add playtime filter in library panel with checkbox labels - SQL filter supports numeric playtime_hours fallback when no explicit label is set (unplayed/tried/played/heavily_played thresholds) - game_detail.html shows derived label from playtime_hours when no explicit label is defined - Reorganize filter panel: two explicit columns, playtime and collection/protondb on left, tags + streaming/igdb on right - Rename 'completed' label to 'heavily_played' across code, DB, templates, tests and documentation - Add DB migration in ensure_edit_overrides() for new columns - Add tests: 15 tests covering bulk edit API and genres endpoint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add auto-suggested playtime label in edit modal: derived label shown with dashed border, italic, and '· auto' suffix when no explicit label is set; suggestion updates reactively with hours hint text - Pre-populate edit modal from library card (genres_override, genres, playtime_label, playtime_hours) via data-* attributes; single-card action-bar edit also pre-populates - Fix genre filter and tag counts to use COALESCE(genres_override, genres) so manual genre overrides are reflected everywhere immediately - Fix 'Most Played' sort to account for manual playtime_label when playtime_hours is NULL; uses sentinel values per label in both the SQL ORDER BY and the Python post-grouping sort - Add visible 'Edit Metadata' button in game detail hero (next to '+ Add to Collection') - Bump service worker cache to backlogia-v2 to clear stale JS - Add Metadata Overrides section to docs/configuration.md - Fix docs/project-info.md: Flask -> FastAPI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds user-editable metadata overrides (genres override + explicit playtime label) and introduces a multi-select playtime filter in the library, including UI updates, new API endpoints, DB migrations, and tests/documentation updates.
Changes:
- Add playtime label filtering in the library filter panel (including URL/localStorage persistence) and derived label display on game details.
- Add a shared “Edit Metadata” modal for single-game and bulk editing, backed by new API endpoints and DB columns.
- Fix “Most Played” sorting to account for manual playtime labels when numeric playtime is missing.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| web/templates/index.html | Filter panel re-layout; playtime filter UI; per-card and bulk “Edit Metadata” entry points; edit modal wiring. |
| web/templates/game_detail.html | Derived/manual playtime label display; genres override display; “Edit Metadata” entry points; edit modal wiring. |
| web/static/sw.js | Bumps service worker cache version to pick up new static assets. |
| web/static/js/edit_modal.js | New shared edit modal implementation (genres override + playtime label) with autocomplete and bulk save. |
| web/routes/library.py | Adds playtime_label filtering; makes genre filtering/counts prefer genres_override; updates playtime sort. |
| web/routes/api_metadata.py | Adds POST /api/games/bulk/edit for persisting overrides. |
| web/routes/api_games.py | Adds GET /api/genres for autocomplete (merges genres + genres_override; excludes hidden). |
| web/main.py | Runs new migration helper during app DB init. |
| web/database.py | Adds ensure_edit_overrides() migration for new columns. |
| tests/test_game_edit.py | Tests for /api/genres and /api/games/bulk/edit. |
| tests/conftest.py | Introduces shared pytest fixtures and in-memory schema for API tests. |
| requirements-dev.txt | Adds pytest/httpx dev dependencies. |
| docs/project-info.md | Updates backend tech description to FastAPI. |
| docs/configuration.md | Documents metadata override behavior and UI entry points. |
| .gitignore | Ignores local dev/AI tool config directories/files. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "(playtime_label IS NULL AND playtime_hours > 20))" | ||
| ) | ||
| else: # abandoned – explicit label only | ||
| label_conditions.append(f"playtime_label = '{lbl}'") | ||
| query += " AND (" + " OR ".join(label_conditions) + ")" |
There was a problem hiding this comment.
This branch interpolates lbl directly into SQL (playtime_label = '{lbl}'). While lbl is currently validated, this breaks the parameterized-query pattern used elsewhere and is easy to regress into SQL injection if the validation changes. Prefer adding a playtime_label = ? condition and pushing lbl into params.
| "current_collection": collection, | ||
| "current_protondb_tier": protondb_tier, | ||
| "current_no_igdb": no_igdb, | ||
| "current_playtime_labels": playtime_label, |
There was a problem hiding this comment.
active_labels filters invalid playtime_label query values for the SQL, but current_playtime_labels is set from the unfiltered playtime_label list. That can leave the UI showing/persisting a filter that isn’t actually applied. Consider passing active_labels to the template instead (and/or normalizing playtime_label before storing/using it).
| "current_playtime_labels": playtime_label, | |
| "current_playtime_labels": active_labels, |
| <label class="filter-tag-option filter-pt-unplayed"> | ||
| <input type="checkbox" value="unplayed" {% if 'unplayed' in current_playtime_labels %}checked{% endif %}> | ||
| <span class="tag-checkbox"></span> | ||
| <span class="tag-label">Not played</span> | ||
| </label> | ||
| <label class="filter-tag-option filter-pt-tried"> | ||
| <input type="checkbox" value="tried" {% if 'tried' in current_playtime_labels %}checked{% endif %}> | ||
| <span class="tag-checkbox"></span> | ||
| <span class="tag-label">Just tried</span> | ||
| <span class="tag-count">≤ 2h</span> | ||
| </label> | ||
| <label class="filter-tag-option filter-pt-played"> | ||
| <input type="checkbox" value="played" {% if 'played' in current_playtime_labels %}checked{% endif %}> | ||
| <span class="tag-checkbox"></span> | ||
| <span class="tag-label">Played</span> | ||
| </label> | ||
| <label class="filter-tag-option filter-pt-heavily-played"> | ||
| <input type="checkbox" value="heavily_played" {% if 'heavily_played' in current_playtime_labels %}checked{% endif %}> | ||
| <span class="tag-checkbox"></span> | ||
| <span class="tag-label">Heavily played</span> | ||
| </label> | ||
| <label class="filter-tag-option filter-pt-abandoned"> |
There was a problem hiding this comment.
The Playtime filter options reuse the .filter-tag-option class, but filterPanelTags() hides all .filter-tag-option elements based on the tag search input. This will cause the Tags search box to also hide Playtime options. Consider scoping filterPanelTags() to #filter-tags only, or using a distinct class for playtime options.
| <label class="filter-tag-option filter-pt-unplayed"> | |
| <input type="checkbox" value="unplayed" {% if 'unplayed' in current_playtime_labels %}checked{% endif %}> | |
| <span class="tag-checkbox"></span> | |
| <span class="tag-label">Not played</span> | |
| </label> | |
| <label class="filter-tag-option filter-pt-tried"> | |
| <input type="checkbox" value="tried" {% if 'tried' in current_playtime_labels %}checked{% endif %}> | |
| <span class="tag-checkbox"></span> | |
| <span class="tag-label">Just tried</span> | |
| <span class="tag-count">≤ 2h</span> | |
| </label> | |
| <label class="filter-tag-option filter-pt-played"> | |
| <input type="checkbox" value="played" {% if 'played' in current_playtime_labels %}checked{% endif %}> | |
| <span class="tag-checkbox"></span> | |
| <span class="tag-label">Played</span> | |
| </label> | |
| <label class="filter-tag-option filter-pt-heavily-played"> | |
| <input type="checkbox" value="heavily_played" {% if 'heavily_played' in current_playtime_labels %}checked{% endif %}> | |
| <span class="tag-checkbox"></span> | |
| <span class="tag-label">Heavily played</span> | |
| </label> | |
| <label class="filter-tag-option filter-pt-abandoned"> | |
| <label class="filter-playtime-option filter-pt-unplayed"> | |
| <input type="checkbox" value="unplayed" {% if 'unplayed' in current_playtime_labels %}checked{% endif %}> | |
| <span class="tag-checkbox"></span> | |
| <span class="tag-label">Not played</span> | |
| </label> | |
| <label class="filter-playtime-option filter-pt-tried"> | |
| <input type="checkbox" value="tried" {% if 'tried' in current_playtime_labels %}checked{% endif %}> | |
| <span class="tag-checkbox"></span> | |
| <span class="tag-label">Just tried</span> | |
| <span class="tag-count">≤ 2h</span> | |
| </label> | |
| <label class="filter-playtime-option filter-pt-played"> | |
| <input type="checkbox" value="played" {% if 'played' in current_playtime_labels %}checked{% endif %}> | |
| <span class="tag-checkbox"></span> | |
| <span class="tag-label">Played</span> | |
| </label> | |
| <label class="filter-playtime-option filter-pt-heavily-played"> | |
| <input type="checkbox" value="heavily_played" {% if 'heavily_played' in current_playtime_labels %}checked{% endif %}> | |
| <span class="tag-checkbox"></span> | |
| <span class="tag-label">Heavily played</span> | |
| </label> | |
| <label class="filter-playtime-option filter-pt-abandoned"> |
| data-genres-override="{{ game.genres_override or '' }}" | ||
| data-genres="{{ game.genres or '' }}" | ||
| data-playtime-label="{{ game.playtime_label or '' }}" | ||
| data-playtime-hours="{{ game.playtime_hours or '' }}"> |
There was a problem hiding this comment.
data-playtime-hours uses {{ game.playtime_hours or '' }}, which turns a legitimate 0-hour value into an empty string. That prevents the edit modal from pre-populating playtime_hours=0 and breaks the “Not played” auto-suggestion for zero-hour games. Use an explicit is not none check (or default filter) so 0 is preserved.
| data-playtime-hours="{{ game.playtime_hours or '' }}"> | |
| data-playtime-hours="{{ game.playtime_hours|default('', true) }}"> |
| data-playtime-label="{{ game.playtime_label or '' }}" | ||
| data-playtime-hours="{{ game.playtime_hours or '' }}"> | ||
| <div class="game-checkbox" onclick="toggleGameSelection(event, this)"></div> | ||
| <button class="card-edit-btn" onclick="handleCardEditClick(event, this)" title="Edit metadata">✏</button> |
There was a problem hiding this comment.
The edit pencil <button> is nested inside the clickable <a class="game-card">, which is invalid HTML (interactive inside interactive) and can create accessibility and click/keyboard issues. Consider moving the edit control outside the anchor or refactoring the card so only one interactive element wraps the content.
| <button class="card-edit-btn" onclick="handleCardEditClick(event, this)" title="Edit metadata">✏</button> | |
| <div class="card-edit-btn" | |
| role="button" | |
| tabindex="0" | |
| onclick="handleCardEditClick(event, this)" | |
| onkeydown="if (event.key === 'Enter' || event.key === ' ') { handleCardEditClick(event, this); }" | |
| title="Edit metadata">✏</div> |
| {% set total_playtime = store_info|selectattr('playtime_hours')|map(attribute='playtime_hours')|sum %} | ||
| {% if game.playtime_label %} | ||
| {% if total_playtime %} | ||
| <span class="meta-item">{{ total_playtime|round(1) }} hours played</span> | ||
| <span class="meta-item" style="color:#888;">{{ total_playtime|round(1) }}h</span> | ||
| {% endif %} |
There was a problem hiding this comment.
total_playtime is computed via store_info|selectattr('playtime_hours')|...|sum, which filters out falsy values (including 0). That makes 0 hours indistinguishable from “no playtime data” and can produce incorrect derived label/display. Consider summing all non-NULL values (including 0) and separately tracking whether any store has a playtime_hours value before showing a derived label.
| const genresRawStr = {{ game.genres|tojson }}; | ||
| const playtimeLabel = {{ game.playtime_label|tojson }}; | ||
| {% set total_pt = store_info|selectattr('playtime_hours')|map(attribute='playtime_hours')|sum %} | ||
| const playtimeHours = {{ total_pt or 'null' }}; |
There was a problem hiding this comment.
const playtimeHours = {{ total_pt or 'null' }} will emit null when total_pt is 0, so the edit modal won’t show 0h or derive the “Not played” suggestion. Use a is not none check (or emit 0 explicitly) so zero is preserved.
| const playtimeHours = {{ total_pt or 'null' }}; | |
| const playtimeHours = {{ 'null' if total_pt is none else total_pt }}; |
|
This looks great - I would just request two changes:
|
Deduplicate the valid playtime labels set that was defined independently in api_metadata.py and library.py. Now exported as a frozenset from web/utils/filters.py and imported by both routes. Also remove the spurious 'import json as _json' inside bulk_edit_games: the module-level 'import json' was already available; update the two call sites to use json.dumps directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Hi @sam1am, thank you for your review. I changed the code to correct the issues you raised.
Also cleaned up |
|
Merged! Thanks again for all your work - loving it! |
Overview
Two new features: bulk editing of game metadata directly from the library, and a playtime filter in the filter panel.
Feature 1 — Playtime Filter
Games can now be filtered by playtime status in the library filter panel.
How it works:
Not played,Just tried(≤ 2h),Played(2–20h),Heavily played(> 20h),Abandonedplaytime_hoursvalue provided by stores (Steam, etc.)Display:
playtime_hoursis availableInternal label value:
heavily_played(replaces the previously plannedcompletedwhich was ambiguous)Feature 2 — Bulk Metadata Editing
Games can have two metadata fields overridden directly from the UI, without re-syncing from stores.
Editable fields:
Persistence across syncs:
genres_overrideandplaytime_labelare never overwritten by store syncs or IGDB sync — they are write-only from the edit APICOALESCE(genres_override, genres), so overrides take effect everywhere immediatelyEntry points:
API:
GET /api/genres— returns all known genres (merged fromgenresandgenres_overridecolumns)POST /api/games/bulk/edit— updatesgenres_overrideand/orplaytime_labelfor one or more games; each field is opt-in viaupdate_genres_override/update_playtime_labelflags to avoid accidental overwritesFilter Panel Reorganisation
The filter panel layout was restructured into two explicit columns to better use available space:
Edit Modal — Auto-suggested Playtime Label
When opening the edit modal on a game that has no explicit playtime label set, the UI now visually suggests the label that would be auto-derived from the store's
playtime_hoursvalue:· autosuffix is appended to its label to distinguish it from a confirmed selectionStore value: X.Xh → "Heavily played" suggestedThis helps users understand what the current effective label is before they override it.
Playtime Sort Fix
The "Most Played" sort now correctly accounts for games that have a manual
playtime_labelbut no numericplaytime_hours(e.g. GOG/local games where only the label was set manually).Before: games with
playtime_hours = NULLalways sorted to the bottom regardless of their label.After: sort uses
COALESCE(playtime_hours, sentinel_from_label)with these sentinel values:heavily_playedabandonedplayedtriedunplayedBoth the SQL
ORDER BYclause and the Python post-grouping sort (used when IGDB grouping is active) apply the same logic.Edit Modal Pre-population Fix (Library View)
When opening the edit modal from the library (pencil icon on a card, or action bar with a single card selected), the modal now pre-populates the current
genres_override,genres,playtime_label, andplaytime_hoursvalues — identical behaviour to the game detail page.Before: card edit always opened with an empty form (no current data shown).
After: the card element carries the game's metadata as
data-*attributes;handleCardEditClickreads them and passes a fully populatedcurrentDataobject toopenEditModal(). When the action bar "Edit Metadata" is used with exactly one card selected, the same pre-population applies; multi-card selection still opens the mixed/empty form.Technical Notes
genres_override TEXT,playtime_label TEXT(added viaensure_edit_overrides()migration, non-destructive)web/static/js/edit_modal.js— self-contained modal (overlay on desktop, bottom sheet on mobile)backlogia-v2to avoid serving stale JStests/test_game_edit.pycovering both API endpoints — all passingScreenshots
New filter panel
Edit metadata
One game at a time - in library
Select multiple games
Edit multiple games
Edit one game - in game details
Edit one game - dialog