Skip to content

Game Metadata Editing & Playtime Filter#73

Merged
sam1am merged 4 commits intosam1am:mainfrom
davidp57:feat-edit-tags
Feb 27, 2026
Merged

Game Metadata Editing & Playtime Filter#73
sam1am merged 4 commits intosam1am:mainfrom
davidp57:feat-edit-tags

Conversation

@davidp57
Copy link
Contributor

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:

  • Five labels: Not played, Just tried (≤ 2h), Played (2–20h), Heavily played (> 20h), Abandoned
  • Multi-select checkboxes (same UI as the Tags filter)
  • Filters match either an explicit label set on the game, or a derived label computed from the numeric playtime_hours value provided by stores (Steam, etc.)
  • Explicit label always takes priority over the derived one

Display:

  • Game detail page shows the derived label (with color) when no explicit label is set but playtime_hours is available

Internal label value: heavily_played (replaces the previously planned completed which 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:

  • Genres override — replaces the store-provided genres with a custom list; tag input with autocomplete from existing genres
  • Playtime label — assigns an explicit playtime label to the game

Persistence across syncs:

  • genres_override and playtime_label are never overwritten by store syncs or IGDB sync — they are write-only from the edit API
  • The genre filter and Tags panel both use COALESCE(genres_override, genres), so overrides take effect everywhere immediately
  • Resetting an override to empty restores the original store/IGDB value

Entry points:

  • Library view — pencil icon (✏) on each game card; multi-select action bar button "Edit Metadata" for bulk editing
  • Game detail page — "Edit Metadata" button next to "+ Add to Collection" in the hero, and also in the Settings section

API:

  • GET /api/genres — returns all known genres (merged from genres and genres_override columns)
  • POST /api/games/bulk/edit — updates genres_override and/or playtime_label for one or more games; each field is opt-in via update_genres_override / update_playtime_label flags to avoid accidental overwrites

Filter Panel Reorganisation

The filter panel layout was restructured into two explicit columns to better use available space:

Left column Right column
Stores Tags (scrollable, taller)
Playtime Streaming / IGDB Match (side by side)
Collection
ProtonDB Tier

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_hours value:

  • The matching button is rendered with a dashed border, italic text, and 75% opacity
  • A · auto suffix is appended to its label to distinguish it from a confirmed selection
  • Clicking the button confirms the suggestion and removes the "auto" style
  • Clicking "Clear" restores the suggestion if hours are available
  • The hours hint line also shows: Store value: X.Xh → "Heavily played" suggested

This 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_label but no numeric playtime_hours (e.g. GOG/local games where only the label was set manually).

Before: games with playtime_hours = NULL always sorted to the bottom regardless of their label.

After: sort uses COALESCE(playtime_hours, sentinel_from_label) with these sentinel values:

Label Sentinel
heavily_played 1000
abandoned 50
played 11
tried 1
unplayed 0
(no label) NULL → bottom

Both the SQL ORDER BY clause 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, and playtime_hours values — 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; handleCardEditClick reads them and passes a fully populated currentData object to openEditModal(). 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

  • New DB columns: genres_override TEXT, playtime_label TEXT (added via ensure_edit_overrides() migration, non-destructive)
  • New file: web/static/js/edit_modal.js — self-contained modal (overlay on desktop, bottom sheet on mobile)
  • Service worker cache version bumped to backlogia-v2 to avoid serving stale JS
  • 15 tests in tests/test_game_edit.py covering both API endpoints — all passing

Screenshots

New filter panel

image

Edit metadata

One game at a time - in library

image #### Edit one game - in library image

Select multiple games

image

Edit multiple games

image

Edit one game - in game details

image

Edit one game - dialog

image

davidp57 and others added 3 commits February 27, 2026 14:02
- 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>
@davidp57 davidp57 marked this pull request as ready for review February 27, 2026 16:43
Copilot AI review requested due to automatic review settings February 27, 2026 16:43
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +109 to +113
"(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) + ")"
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
"current_collection": collection,
"current_protondb_tier": protondb_tier,
"current_no_igdb": no_igdb,
"current_playtime_labels": playtime_label,
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
"current_playtime_labels": playtime_label,
"current_playtime_labels": active_labels,

Copilot uses AI. Check for mistakes.
Comment on lines +1498 to +1519
<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">&le;&thinsp;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">
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<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">&le;&thinsp;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">&le;&thinsp;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">

Copilot uses AI. Check for mistakes.
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 '' }}">
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
data-playtime-hours="{{ game.playtime_hours or '' }}">
data-playtime-hours="{{ game.playtime_hours|default('', true) }}">

Copilot uses AI. Check for mistakes.
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>
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<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>

Copilot uses AI. Check for mistakes.
Comment on lines 1239 to +1243
{% 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 %}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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' }};
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const playtimeHours = {{ total_pt or 'null' }};
const playtimeHours = {{ 'null' if total_pt is none else total_pt }};

Copilot uses AI. Check for mistakes.
@sam1am
Copy link
Owner

sam1am commented Feb 27, 2026

This looks great - I would just request two changes:

  • PLAYTIME_LABELS defined in two places — Both api_metadata.py (line 559) and library.py (line 670) define the valid labels set independently. Should be a single shared constant.

  • import json as _json inside function body (api_metadata.py:592) — json is already imported at the module level in this file. The local re-import as _json is unnecessary.

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>
@davidp57
Copy link
Contributor Author

Hi @sam1am, thank you for your review.

I changed the code to correct the issues you raised.

PLAYTIME_LABELS was defined twice — once as a module-level set in
api_metadata.py and once as a local set inside library.py's route
handler. Moved it to web/utils/filters.py as the single source of truth
(a frozenset), and updated both routes to import it from there.

Also cleaned up bulk_edit_games: removed the unnecessary
import json as _json inside the function body (the module-level
import json was already available) and replaced the two _json.
references with json..

@sam1am sam1am merged commit 82c4a43 into sam1am:main Feb 27, 2026
1 check passed
@sam1am
Copy link
Owner

sam1am commented Feb 27, 2026

Merged! Thanks again for all your work - loving it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants