diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28d1f22..96208a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,9 @@ jobs: jq . /tmp/omakade-vulnerabilities.json test "$(jq length /tmp/omakade-vulnerabilities.json)" -eq 0 - name: Scan package SBOM + # Arch advisories are a baseline for ARM64, not complete Arch Linux ARM coverage. + env: + GRYPE_DISTRO: arch:rolling uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 with: sbom: omakade-${{ env.VERSION }}-${{ matrix.arch }}.spdx.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7d18a..5a3edc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 1.8.0 + +- Add optional Home, persistent Up Next, and suggestions from the local library. + Improve Home wheel scrolling during background metadata updates. + Put game shelves before shortcuts and add direct Play beside Details. +- Filter by genre, decade, and platform, including saved filters. +- Show regional release dates, title evidence, genres, credits, and descriptions. + Preserve manual identity choices and leave ambiguous matches correctable. +- Bring matching and cover selection together under Game & Artwork. Preserve + existing portraits during refresh and cache maintenance, and recover covers + through verified aliases. Keep Done visible while the panel scrolls. +- Prevent overlapping desktop library captions after returning from a game. + Keep existing cards stable during unchanged startup console scans. +- Improve popup keyboard navigation, controller focus, and narrow details layouts. + Restore the original Home action after closing details. Show immediate launch + feedback, suppress repeated presses briefly, and keep launch errors visible. + Limit the rating-count tooltip to the rating and put credits before regional details. +- Add optional local session recording for configured emulator process profiles. + Show recorder status and separate imported time from recorded time. New installs + opt in; existing recording preferences and history are preserved. + Attribution requires a recognizable game path in process arguments. Internal + emulator game changes and wrapper handoffs still need adapter-specific testing. +- Back up explicit metadata choices, recorded sessions, baselines, and preferences + in archive format 2. Format 1 remains readable. Emulator saves are excluded. +- Report persistence failures and protect referenced artwork during cache cleanup. +- Keep ROM Folders on the Sources overview. Fixes #40. + ## 1.7.0 Omakade 1.7 brings console libraries, more ways to organize your games, and a diff --git a/CMakeLists.txt b/CMakeLists.txt index a9699e2..354e5b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.24) -project(Omakade VERSION 1.7.0 LANGUAGES C CXX) +project(Omakade VERSION 1.8.0 LANGUAGES C CXX) find_package(PkgConfig REQUIRED) find_package(OpenSSL REQUIRED COMPONENTS Crypto) @@ -57,6 +57,43 @@ if(OMAKADE_IDLE_INHIBIT AND NOT TARGET Qt6::GuiPrivate) endif() qt_standard_project_setup(REQUIRES 6.8) +# Play session tracking, shared by the Omakade window and the omakade-sessiond +# recorder. Kept separate so the daemon links only Qt Core, Sql, and Network. +add_library(omakade_tracking STATIC + src/tracking/AppNotify.cpp + src/tracking/AppNotify.h + src/tracking/PlaySessionStore.cpp + src/tracking/PlaySessionStore.h + src/tracking/ProcFs.cpp + src/tracking/ProcFs.h + src/tracking/ProcessMatcher.cpp + src/tracking/ProcessMatcher.h + src/tracking/SessionDatabase.cpp + src/tracking/SessionDatabase.h + src/tracking/SessionRecorder.cpp + src/tracking/SessionRecorder.h +) + +target_include_directories(omakade_tracking PUBLIC src) +target_link_libraries(omakade_tracking PUBLIC + Qt6::Core + Qt6::Network + Qt6::Sql +) + +if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") + target_compile_options(omakade_tracking PRIVATE -Wall -Wextra -Wpedantic) +endif() + +qt_add_executable(omakade-sessiond src/sessiond/main.cpp) +target_compile_definitions(omakade-sessiond PRIVATE + OMAKADE_SESSIOND_PROFILES="${CMAKE_INSTALL_FULL_DATADIR}/omakade/sessiond-profiles.json" +) +target_link_libraries(omakade-sessiond PRIVATE omakade_tracking) +if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") + target_compile_options(omakade-sessiond PRIVATE -Wall -Wextra -Wpedantic) +endif() + add_library(omakade_core STATIC src/backup/BackupArchive.cpp src/backup/BackupArchive.h @@ -92,6 +129,8 @@ add_library(omakade_core STATIC src/input/ControllerInput.h src/input/CouchCursorManager.cpp src/input/CouchCursorManager.h + src/library/HomeModel.cpp + src/library/HomeModel.h src/library/LibraryFilterModel.cpp src/library/LibraryFilterModel.h src/library/DatabaseTuning.h @@ -187,6 +226,7 @@ add_library(omakade_core STATIC target_include_directories(omakade_core PUBLIC src) target_link_libraries(omakade_core PUBLIC + omakade_tracking Qt6::Quick PkgConfig::SDL3 PkgConfig::LIBSECRET @@ -232,6 +272,7 @@ qt_add_qml_module(omakade qml/components/FieldClearButton.qml qml/components/CoverSizeControl.qml qml/components/CoverArtwork.qml + qml/components/LaunchFeedback.qml qml/components/GameCard.qml qml/components/CouchLibraryView.qml qml/components/CouchKeyboard.qml @@ -239,6 +280,8 @@ qt_add_qml_module(omakade qml/components/GameMetadataEditor.qml qml/components/SettingsPanel.qml qml/components/GlassButton.qml + qml/components/ActionMenu.qml + qml/components/MenuAction.qml qml/components/LibraryView.qml qml/screens/ArtworkEditor.qml qml/screens/RestoreStartup.qml @@ -246,6 +289,7 @@ qt_add_qml_module(omakade qml/screens/SavedFiltersEditor.qml qml/screens/BulkOrganizationEditor.qml qml/screens/ManualGameEditor.qml + qml/screens/HomeScreen.qml qml/screens/GameDetails.qml ) @@ -294,6 +338,15 @@ install(TARGETS omakade BUNDLE DESTINATION . RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) +install(TARGETS omakade-sessiond + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) +install(FILES resources/sessiond-profiles.json + DESTINATION ${CMAKE_INSTALL_DATADIR}/omakade +) +install(FILES packaging/omakade-sessiond.service + DESTINATION ${CMAKE_INSTALL_LIBDIR}/systemd/user +) install(FILES packaging/io.github.tsouth89.Omakade.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications ) @@ -313,6 +366,10 @@ install(FILES README.md PRIVACY.md SUPPORT.md CHANGELOG.md docs/COMPATIBILITY.md DESTINATION ${CMAKE_INSTALL_DATADIR}/doc/omakade ) +install(FILES docs/RECORDING-COVERAGE.md + DESTINATION ${CMAKE_INSTALL_DATADIR}/doc/omakade/docs +) + include(CTest) if(BUILD_TESTING) add_subdirectory(tests) diff --git a/PLAN.md b/PLAN.md index c2339cf..f4904db 100644 --- a/PLAN.md +++ b/PLAN.md @@ -872,13 +872,51 @@ It preserves role IDs and names across the existing nine game models. - Automatic fuzzy merging across stores - Emulator installation and ROM scraping - Plugin marketplace or third-party executable plugins -- Background daemon +- General background daemons beyond the play session recorder - Mobile companion - Cross-device sync Each item needs a separate product decision. None should enter incidentally while building the library. +## Play session tracking + +Omakade shows playtime per game, but every emulator keeps its own counter in its +own format and several keep none at all. A small recorder closes that gap. + +### Shipped + +- `omakade-sessiond`, a per-user systemd service shipped with the package, + polls the process table every few seconds and attributes sessions by matching + a known emulator binary in the process table with a game image path on its + command line. This covers Omakade launches, terminal launches, and wrapper + scripts, including emulators Omakade has no source for, as long as the game + path is on the command line. +- Sessions land in `play_sessions` in the library database with a periodic + heartbeat. A recorder restart reconciles dead processes at their last + heartbeat so a crash never invents play time, and elapsed time comes from the + monotonic clock so suspended time is not billed. +- Sources merge their imported playtime with recorded sessions as + max(imported, baseline + sessions). New baselines include zero and subtract + already recorded sessions conservatively, since a late import may include them. + Existing baselines are preserved. Gaps in recording can leave the imported + total ahead until observed time catches up; exact overlap reconciliation is + still future work. +- A Settings toggle (on by default) controls both the display and the recorder, + which reads the same config key. When a session for an emulator whose own + playtime is written on exit ends, the recorder asks the running Omakade + window to rescan that source so its import stops going stale. + +### Later + +- Attribute sessions for games loaded from an emulator's own file picker, where + the command line carries no path: window-title matching through the Hyprland + IPC first, then per-emulator recents and log adapters. +- Pause the clock while the emulator window is unfocused, matching how + Ryujinx excludes paused time from its own counter. +- A settings view for recorded sessions per game. + + ## Decisions to settle before M0 implementation Recommended defaults are listed first: diff --git a/PRIVACY.md b/PRIVACY.md index d3e1b6a..7492bca 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -26,6 +26,10 @@ Omakade retains: choices, provider IDs, ratings, and popularity scores in the same database - Owned Steam App IDs, titles, and account playtime after an explicit library sync in the same database +- Play sessions recorded by `omakade-sessiond` in the same database: game + paths, start and end times, and accumulated seconds. The recorder only reads + the local process table and never sends anything anywhere; sessions never + leave the machine. - Steam ID, RetroAchievements username, public IGDB client ID, cache limit, and reduced-motion preference, console-view overrides, and cover sizes in `$XDG_CONFIG_HOME/omakade/config.toml` @@ -58,6 +62,9 @@ A SteamGridDB API key is stored through Secret Service under `io.github.tsouth89.Omakade.SteamGridDB`. It is never written to config, the database, logs, or process arguments. +Recording is off for new configurations until enabled in Settings. Existing saved +choices are preserved. Disabling recording retains recorded history locally. + ## Backup and restore Export creates a local archive at the path you choose. It includes personal diff --git a/README.md b/README.md index b7b920c..362f412 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ into one quiet, cover-focused home that follows the active Omarchy theme. ## Features -Omakade 1.7.0 includes: +The 1.8.0 candidate includes: - Native and Flatpak Steam, Lutris, Heroic, Faugus, RetroArch, PCSX2, Ryujinx, Cemu, shadPS4, and Dolphin discovery, plus direct GOG installation @@ -30,6 +30,9 @@ Omakade 1.7.0 includes: - Console cards for cartridge and disc systems, with a per-system choice between cards and library tiles, per-game pinning, and ROM folder scanning for EmuDeck-style layouts +- Optional local session recording from supported emulator process arguments +- Optional Home, persistent Up Next, and local discovery suggestions +- Genre, decade, and platform filters with saved-filter persistence - One-click details and delegated launching through the owning platform - Omarchy palette, font, transparency, and live theme updates - Search, favorites, hidden games, sorting, and source filters that combine, @@ -40,7 +43,9 @@ Omakade 1.7.0 includes: - Local Steam achievements plus optional Web API enrichment - Optional RetroAchievements progress for supported RetroArch systems - Optional Steam owned-library sync with installed and ready-to-install views -- Optional IGDB ratings, popularity sorting, and game-length estimates +- Optional IGDB ratings, popularity sorting, and game-length estimates, plus + release dates, original platform, genres, credits, and a background + paragraph on game details - SteamGridDB portrait covers with per-game identification and artwork choices - Adjustable cover size and per-console grouping preferences - Local, downloaded, and user-selected cover, hero, and logo artwork @@ -102,23 +107,23 @@ verify the package, and install it. If Omakade is already installed, `pacman -U` upgrades it in place without removing your settings or library data: ```bash -curl -fLO https://github.com/btsouth/omakade/releases/download/v1.7.0/omakade-1.7.0-1-x86_64.pkg.tar.zst -curl -fLO https://github.com/btsouth/omakade/releases/download/v1.7.0/SHA256SUMS +curl -fLO https://github.com/btsouth/omakade/releases/download/v1.8.0/omakade-1.8.0-1-x86_64.pkg.tar.zst +curl -fLO https://github.com/btsouth/omakade/releases/download/v1.8.0/SHA256SUMS sha256sum -c SHA256SUMS --ignore-missing -sudo pacman -U ./omakade-1.7.0-1-x86_64.pkg.tar.zst +sudo pacman -U ./omakade-1.8.0-1-x86_64.pkg.tar.zst ``` ### Install or upgrade from a browser download 1. Open the [latest release](https://github.com/btsouth/omakade/releases/latest). -2. Under **Assets**, download `omakade-1.7.0-1-x86_64.pkg.tar.zst` (or - `omakade-1.7.0-1-aarch64.pkg.tar.zst` for ARM64) and `SHA256SUMS` into the same folder. +2. Under **Assets**, download `omakade-1.8.0-1-x86_64.pkg.tar.zst` (or + `omakade-1.8.0-1-aarch64.pkg.tar.zst` for ARM64) and `SHA256SUMS` into the same folder. 3. Open a terminal in that folder and run the commands below. On ARM64, replace `x86_64` with `aarch64` in the package filename: ```bash sha256sum -c SHA256SUMS --ignore-missing -sudo pacman -U ./omakade-1.7.0-1-x86_64.pkg.tar.zst +sudo pacman -U ./omakade-1.8.0-1-x86_64.pkg.tar.zst ``` Launch Omakade from the application launcher or run `omakade` in a terminal. @@ -303,12 +308,45 @@ the preferred launch mode. Its cursor hides during controller or keyboard use, returns on mouse movement, and remains visible in Desktop Mode. `Ctrl+M` toggles reduced motion and `Ctrl+D` opens settings and source diagnostics. +## Track play sessions + +Every emulator keeps its own playtime in its own format, and some keep none at +all. Omakade ships an optional recorder. Turn on **Record Playtime** in Settings, +then enable its service: + +```bash +systemctl --user enable --now omakade-sessiond +``` + +The recorder watches the process table and attributes sessions by the game path +on an emulator's command line. Profiles include RetroArch, Dolphin, PCSX2, Cemu, +Ryujinx, shadPS4, and yuzu-family forks like Eden. Attribution requires a +recognizable game path in those arguments. Internal game changes and wrapper +handoffs need adapter-specific validation; profile coverage is not runtime acceptance. Emulators that +count their own time retain their imported totals. Omakade takes the larger of +the imported total and its baseline plus recorded time. Recovery preserves committed time and +excludes unobserved downtime. Late imports are treated conservatively as including +already recorded sessions; gaps in tracking can delay visible increases. Existing +history is not rewritten automatically. + +New installations require opting in. Existing saved choices are preserved, and +older configuration files without this setting retain their previous enabled +default. Settings reports whether the recorder is running separately from whether +recording is enabled. The recorder continues after Omakade closes; paused emulator +time counts. Switching recording off preserves history and displays imported time. +Game details separates imported emulator time from Omakade's recorded total. + +Loading a game from inside an emulator's own file picker is not counted yet, +because the command line carries no path then. See the +[recording coverage notes](docs/RECORDING-COVERAGE.md) for validation limits. + ## Local data - Library: `~/.local/share/omakade/library.sqlite3` - Settings: `~/.config/omakade/config.toml` - Downloaded artwork: `~/.cache/omakade/` - Selected custom artwork: `~/.local/share/omakade/artwork/` +- Play sessions: `play_sessions` and `play_baselines` tables in the library Core library discovery, local achievements, artwork, search, organization, controller navigation, and launching require no Steam API key or network diff --git a/docs/BACKUP-FORMAT.md b/docs/BACKUP-FORMAT.md index ac7806c..f455d16 100644 --- a/docs/BACKUP-FORMAT.md +++ b/docs/BACKUP-FORMAT.md @@ -7,13 +7,13 @@ controls are integrated. Released-database migration is covered by a fixture generated from the frozen v1.6.0 core. Release and maintainer acceptance checks remain part of the completion plan. -## Version 1 archive +## Archive envelope A ZIP archive contains `manifest.json` and referenced custom artwork. The manifest has exactly these fields: - `format`: `omakade-backup` -- `version`: `1` +- `version`: `2` for new exports; versions `1` and `2` are readable - `createdAt`: ISO timestamp - `library`: allowlisted personal-data tables, represented as arrays of records - `settings`: allowlisted core application preferences @@ -36,6 +36,51 @@ Deflate compression. Unexpected paths and non-regular Unix file entries are rejected. Reads fail without replacing the caller's previous payload. Writes use a temporary archive and an atomic file replacement with owner-only access. +## Version 2 coverage + +Older builds reject version 2 rather than partially importing it. + +### Added in version 2 + +- Favorites, hidden choices, organization, collections, links, preferred installations, + launch activity, manual games, saved filters, and owned custom artwork. +- Explicit IGDB identifications and disabled automatic matching. Descriptions, ratings, + automatic matches, and downloaded cache paths are regenerated from providers. +- Recorded play sessions with stable IDs, plus imported-playtime baselines. Active sessions + export as closed snapshots at the last recorded heartbeat. Process IDs are not restored. +- Source preferences, ROM folders, console layouts, cover sizes, playtime tracking, library + sorting, and other allowlisted library preferences. + +ROMs, emulator saves, save states, account credentials, account-service identifiers, and +Sunshine publishing choices are excluded. This is not emulator save-file backup/versioning. +Paths remain paths on the original machine. Restore does not relocate ROMs automatically. + + +### History and compatibility rules + +Merge imports personal choices while retaining unrelated records. For play history it imports +only game paths with neither local sessions nor a baseline. It leaves existing history for +that path untouched, including when the archive contains additional sessions. This deliberately +avoids adding archived sessions to an imported baseline that may already include them. + +Replace restores archived history and baselines together. Older archives that omit history +or identifications cannot clear those categories. Preferences introduced in version 2 remain +unchanged when absent from an older archive; older core preferences retain their original +replacement/default behavior. + +Both history tables are required together. Validation rejects duplicate session IDs, open +sessions, unsupported baseline versions, inconsistent timestamps, and combined durations beyond +the exact JSON integer range. Restore runs in a database +transaction and checks the recorder's ownership lock before touching history. A running recorder +blocks restore with a retry message. For the packaged service, stop `omakade-sessiond.service` +before applying the queued restore and start it again afterward. Turning tracking off alone does +not stop that service. No service was stopped during development or automated testing. + +The recovery archive includes history and identifications. Isolated subprocess tests interrupt +restore and undo at their checkpoints, then verify recovery, including the recorded game paths. +Pending writes that have not reached the database cannot appear in an archive. Storage failures +are reported; in-memory retries cannot survive the recorder or app exiting. + ## Personal data The archive includes favorites and hidden state, completion states, tags, console pins, @@ -72,8 +117,8 @@ reject archives containing the new field. All cached personal choices are included, including undiscovered or disconnected entries. A snapshot uses a separate read-only SQLite transaction. Legacy cover records gain empty hero/logo slots in the archive. Missing or invalid custom -artwork fails export with a repair/reset message instead of silently discarding -that choice. Missing native game files do not invalidate a structurally valid +artwork is handled explicitly: missing references are omitted from the archive; +invalid image bytes fail export with a repair/reset message. Missing native game files do not invalidate a structurally valid manual entry, and reading an archive never launches one. ## Database import and settings behavior @@ -95,12 +140,14 @@ other groups retain their remaining members when at least two remain, with one primary and a valid preference. An imported saved filter with a conflicting name gets a deterministic `restored` suffix. Replaying the same import is idempotent. -Replacement clears personal tables and resets cached legacy favorite/hidden -flags before importing the archive. It leaves cached game records and game files +Replacement clears covered personal tables and resets cached legacy favorite/hidden +flags before importing the archive. Version 1 omissions cannot clear the newly +covered identification and play-history categories. It leaves cached game records and game files intact. Manual entries with missing executable paths stay available for repair. `AppSettings::applyBackupSettings` applies the core allowlist in one file save. Merge retains unspecified core preferences; replacement uses their defaults. +Preferences introduced in version 2 remain unchanged when absent from an older archive. Account-service identifiers and Sunshine publishing choices remain unchanged in both modes. Failed saves restore the prior in-memory core settings. This method alone does not make a database-plus-settings restore atomic; the coordinator @@ -216,3 +263,11 @@ the pre-migration archive through BackupRecovery again preserves personal data and image bytes, retains the original custom-art file, leaves account/cache rows local, and keeps them out of portable personal data. This validates database migration, not game launching or hardware compatibility. + +### Saved-filter state version 2 + +Saved filters now record genre, release decade, platform, and console scope in addition to the +original ten fields. This nested state version is independent of the archive version. Readers +accept nested versions 1 and 2; applying version 1 clears the newer criteria. Both library and +archive validation use SavedFilterRules. Older builds reject the unsupported nested state rather +than restoring a broader query. Automatic metadata remains regenerable and excluded from backups. diff --git a/docs/COMPLETION-PLAN.md b/docs/COMPLETION-PLAN.md index fe1f68b..c2c6b9d 100644 --- a/docs/COMPLETION-PLAN.md +++ b/docs/COMPLETION-PLAN.md @@ -1,5 +1,10 @@ # Omakade completion plan +> Historical review and implementation record. For the reconciled September 8 +> candidate, push authorization, and remaining acceptance gates, see +> [PUBLICATION-CANDIDATE.md](PUBLICATION-CANDIDATE.md). Earlier local-only status +> and test counts below describe their original snapshots. + Created September 5, 2026. Baseline public release: 1.6.0. Implementation worktree: `/home/bts/Projects/omakade-completion`, branch diff --git a/docs/DISCOVERY-EXPANSION.md b/docs/DISCOVERY-EXPANSION.md new file mode 100644 index 0000000..cd0135c --- /dev/null +++ b/docs/DISCOVERY-EXPANSION.md @@ -0,0 +1,102 @@ +# Discovery expansion, local candidate + +The first increment adds genre, release-decade, and platform filters to the existing library. +It does not install or publish a release. + +## Behavior + +- Desktop has three filter buttons; Couch Browse has corresponding scrollable categories. +- Criteria combine with existing search, source, availability, favorites, and organization filters. + Console-card counts use matching members, so a console with no matching games disappears. +- Genre comes from confirmed cached IGDB metadata. Ambiguous or rejected identities do not supply + genre filters. Decade uses the catalog year, falling back to the source's year. Regional dates + remain installation-specific on game details. Unknown values do not match a selected criterion. +- Platform groups emulated systems by Omakade's console catalog; non-console installations use PC. +- No additional network request is needed to filter. New metadata updates the results and choices. +- Saved-filter state version 2 records genre, decade, platform, and console scope. Legacy version 1 + still loads and clears newer criteria. Unsupported states fail without altering the current view. +- Library and backup validation share SavedFilterRules. Archive round trips preserve the criteria; + older clients reject the new saved-filter state rather than silently broadening its results. + +## Validation + +The core regression covers combined criteria, live metadata changes, uncertain matches, empty +results, restart, legacy saved views, console-scope restoration, and archive round trips. Desktop +UI tests select and clear a decade with keyboard events at 600x800 and 1280x720. Couch navigation +reaches the release-decade category through the scrolling list and applies a value. + +Final development build and all 134 CTest checks passed in 52.07 seconds, including the new +metadata regression, desktop picker tests, and expanded couch-navigation checks. Reviewed the +600x800 picker screenshot. Logs are in build/quality-sweep/discovery-build.log and +discovery-checks.log. Tests use private XDG/TMP directories, offscreen software rendering, and +disabled session DBus. + +Physical-controller acceptance remains separate. No installed application data is used by tests. + +## Home and Up next, second increment + +- Optional Home is accessible from desktop and Couch Mode. Library remains the startup view. +- Continue playing shows up to eight available, non-hidden games using existing launch activity. +- Up next stores up to 100 installation identities, with add, remove, and manual ordering. + Add games from their details or Continue playing. Selecting an entry opens game details. +- Linked installations appear once. Removing a linked queue entry removes its queued members. + Hidden entries remain stored but are omitted. Disconnected or disabled-source entries stay + visible as unavailable, keeping their title and place in the queue. +- Returning from details restores the library's filters and Home focus. Cover placeholders + appear when artwork is missing. Home refreshes only while active, avoiding a startup scan. +- Queue writes are transactional. Backups include queue order. Merge preserves existing order + and appends new identities; exceeding 100 entries rejects the restore. Restoring an older + backup without a queue preserves the current queue. + +Storage tests exercise restart, links, source availability, hidden entries, failed writes, filter +restoration, and backup round trips. Render tests exercise keyboard opening and removal in both +views at 600x800 and 1280x720. Physical-controller acceptance remains a separate local check. +Home render fixtures emit a Qt DelegateModel cancellation warning during initial setup; their +opening, filter restoration, and queue navigation assertions pass. + +Final Home candidate: development build and all 138 CTest checks passed. Evidence is in +`build/quality-sweep/home-build.log` and `home-checks.log`. Screenshots for desktop and Couch +Mode were reviewed at narrow and standard sizes. The installed application is unchanged. + +## Cover flashing correction + +Background metadata updates previously invalidated the entire library layout, briefly replacing +all visible covers with placeholders. Data-only updates now refresh filtering incrementally; +structural console changes retain the bulk rebuild path. The active sort role is registered so +rating, popularity, recent activity, and playtime can update without unconditional invalidation. + +The regression reproduced 12 layout invalidations for 12 metadata updates before the fix and +zero afterward. It also checks live rating reordering and metadata-filter membership changes. +Evidence is in `build/quality-sweep/flashing/`. + +## Detail backgrounds and broad-search matching + +Automatic IGDB backgrounds now use still screenshots with usable dimensions and a landscape or +near-square shape. Artwork scans and promotional illustrations are not used automatically. +Selection ranks usable resolution with a stable image-ID tie break. IGDB's `1080p` fit preset +preserves source framing; its screenshot presets crop to a wide rectangle. Details fits the full +image into a restrained backdrop and no longer enlarges portrait covers as a fallback. Missing, +legacy, rejected, or ambiguous automatic images leave the theme background. User and launcher +backgrounds retain priority. Payload version 6 refreshes old choices through the existing queue. + +A full broad-search page or multiple normalized matches now gets one exact title/alias lookup +before becoming ambiguous. Super Mario World's captured 20-result page contains sequels and +hacks; the exact lookup returns ID 1070 alone. Matching version 5 retries old ambiguous results. +True exact-query ties and truncated exact results still require identification. There are no +individual game exceptions, and existing portraits and manual matches remain preserved. + +Regression fixtures are offline. Read-only live catalog queries confirmed the Mario World case +and supplied Zelda/Punch-Out screenshot examples for local render review. No credentials are +stored in those fixtures. Six rendering checks cover legacy rejection, screenshots, and custom +backgrounds at 600x800 and 1920x1080. Logs are `hero-build.log`, `hero-focused.log`, and +`hero-checks.log` under `build/quality-sweep/`. + +Final combined candidate: all 144 CTest checks passed in 55.72 seconds. Zelda and Punch-Out +were rendered using their live-provider screenshot candidates and inspected locally. + +Provider reference: https://api-docs.igdb.com/#images + +## Later increments + +Game-length filtering waits until the current Steam-focused insights service provides consistent +library-wide values. Save-file versioning and RomM remain later, separately validated projects. diff --git a/docs/FEATURE-QUALITY-PLAN.md b/docs/FEATURE-QUALITY-PLAN.md new file mode 100644 index 0000000..68b7d60 --- /dev/null +++ b/docs/FEATURE-QUALITY-PLAN.md @@ -0,0 +1,232 @@ +# Omakade feature quality and expansion plan + +> Historical review and implementation record. For the reconciled September 8 +> candidate, push authorization, and remaining acceptance gates, see +> [PUBLICATION-CANDIDATE.md](PUBLICATION-CANDIDATE.md). Earlier local-only status +> and test counts below describe their original snapshots. + +Reviewed September 8, 2026 against `033ca9e62d2dcaa2911f1136825789b1fd9501e5` +on `codex/port-playtime-game-info`. This is a plan and bounded code review, not a +release approval or a completed product audit. No application behavior or installed +data was changed during this review. + +## Local implementation status + +A first candidate is being prepared in `/home/bts/Projects/omakade-quality-local` +on `codex/feature-quality-local`. It fixes live recovery, zero and late initial +baseline capture, UTC date handling, first-match platform selection, successful +provider-field replacement, SQL connection cleanup and daemon ownership. It also +adds the metadata year beside the title and a readable About section with keyboard +expand/collapse. See `LOCAL-CANDIDATE.md` for validation and remaining work. + +The original findings below describe the reviewed baseline, not an assertion that +these defects remain in the candidate. Exact reconciliation across recording gaps, +legacy history corrections, broader process attribution and database error handling +remain separate work. No installed data is migrated by this development run. + +## Assessment + +The recent work adds useful foundations. Metadata reuses the existing provider +queue, preserves explicitly selected identities, and refreshes older payloads by +version. Tracking separates process matching, persistence, and elapsed-time handling, +uses PID plus process start time, and supplies a controllable clock for tests. + +The tests do not establish release confidence for the new behavior. The restart +test covers a dead process, not adoption of a live session. The merge test checks +the formula without exercising an imported counter advancing through the first +recorded session. The richer metadata test checks parsing, not acceptance, +persistence, refresh, or the rendered details screen. Fix these gaps before adding +features that depend on their data. The assessment concerns the code, irrespective +of which model authored it. + +## What exists and what to build on + +| Area | Current implementation | Next useful improvement | +| --- | --- | --- | +| Identification | `GameMetadata`: platform-aware matching, manual search/choice/rejection, stored provider identity, artwork selection, background queue | Make match state, correction and refresh reliable and understandable; do not recreate the matcher | +| Game details | Ratings, popularity, game-length data, new release/genre/credits/summary block | Correct dates and platform semantics, persistent refresh tests, readable desktop and couch layouts | +| Playtime | `src/tracking`, six emulator model integrations, imported counters, daemon and settings toggle | Correct accounting and recovery, verified attribution, recorder status, inspectable history | +| Library discovery | `LibraryFilterModel`: Recent mode, recent/playtime/rating/popularity sorting, favorites, completion filters, collections, tags, saved filters and random selection | Reuse these models for an optional home screen and intentional play queue | +| Launch preferences | Linked installations and preferred installation selection; platform-specific delegated launching | Explain what will launch and why it is unavailable; scope per-game profiles after an adapter review | +| Backup | Explicit personal-data tables, settings and artwork export/restore | Define preservation of manual metadata choices and session history; these are outside the current table allowlist | +| Remote collection | No RomM source in the reviewed code | Optional RomM adapter after the local experience is reliable | +| Saves | Existing backup concerns Omakade data, not emulator save files | Separate save discovery/backup project only after source-specific restore requirements are established | + +## Findings to fix first + +### P1: live-session recovery overwrites recorded duration + +`src/tracking/SessionRecorder.cpp`, `recover`, initializes an adopted session's +`elapsedMs` to zero. `updateProgress` and `endSession` replace the stored total. +Consequently the first post-restart write loses the pre-restart duration. + +Confirmed with the current rebuilt tracking library and an in-memory database: +120 recorded seconds, recovery of a still-live PID/start-time pair, then 30 more +seconds produced **30 seconds instead of 150**. + +Preserve committed elapsed time when adopting a session; do not count unobserved +downtime. Cover live and dead processes, PID reuse, repeated restart, next heartbeat, +normal exit, and switching recording off. Inject liveness for deterministic tests. + +### P1: baseline capture can double-count the first session + +`src/tracking/SessionDatabase.cpp:173` declines to capture zero imported time. +`RyujinxGameModel::load` and the PCSX2/RetroArch equivalents capture baselines on +load. After an emulator first writes its own time, that total becomes the baseline +even though the recorder already contains the same session. + +Confirmed using a temporary database: initial imported time 0, one 600-second +session, then imported time 600 produced **1200 seconds instead of 600**. + +Capturing zero is necessary but not sufficient. Define a reconciliation policy for +late discovery, recorder-disabled periods, resets of emulator counters, and stale +imports. A fixed `max(imported, baseline + tracked)` also can hide newly recorded +time until tracking catches up after an unrecorded interval. Keep imported and +observed evidence separate and test a chronological sequence of snapshots. Do not +silently rewrite existing history when overlap cannot be established. + +### P2: release dates depend on the local timezone + +`GameMetadata::parseMatches` converts epoch seconds to a local date. The current +fixture timestamp is July 28, 1997 UTC and displays July 27 in America/Louisville. +The existing test only checks the year. + +Use UTC calendar dates and format at presentation time. Label IGDB's first release +as such; it is not necessarily the release date of the selected platform/edition. +Test exact dates in positive and negative offsets, missing values, and year edges. + +### P2: platform text is computed before the current platform is assigned + +In `GameMetadata::acceptMatch` (lines 984 and 995), `platformText` reads the old +payload's platform before assigning `m_active.system`. A newly matched console +game can therefore show the provider's whole platform list instead of its selected +console. This is a code-path finding; a full accept/persist/UI reproduction remains +to be added. + +Derive the selected platform from the current game first. Distinguish that platform +from other supported platforms; never call the whole list the original platform. + +### P2: successful refresh retains removed provider fields + +`acceptMatch` preserves the old payload for the same IGDB identity and overwrites +new fields only when nonempty. A successful refresh with an absent summary, credits, +or genres keeps stale values. Define replacement semantics for provider-owned +fields while preserving user choices. Failed requests should retain cached data. +Test both cases separately, including changing the selected match. + +### Further review and verification required + +- Process matching returns the first argument with a recognized extension and + stores it verbatim. Verify relative paths, symlinks, Flatpak paths, argument + syntax, playlists, AppImages and title changes inside an emulator. Match the + library's identity consistently; avoid silently attributing ambiguous activity. +- Profiles list more emulators than the UI integrates. Recording a path does not + establish that its total appears in Omakade. Publish a verified coverage matrix. +- The daemon has no explicit single-recorder guard. Two instances could duplicate + sessions or interfere during reconciliation. Add ownership and lifecycle tests. +- Several database writes ignore errors. Add failure reporting and recovery tests + for locked/unwritable storage and schema initialization failures. +- `PlaySessionStore` does not explicitly close/remove its named Qt SQL connection. + The focused test emits a duplicate-connection warning. Fix lifecycle cleanup. +- Existing backup allowlists omit `play_sessions`, `play_baselines` and + `game_metadata`. Decide which user-owned history and identity overrides belong + in the archive. Keep re-downloadable metadata distinct from irreplaceable choices. +- The five-second polling interval and thirty-second heartbeat impose observation + limits. Show approximate process runtime honestly; foreground focus alone is + not proof of play or pause. Do not automatically exclude unfocused couch play. + +## Delivery sequence + +### 1. Correctness candidate + +Fix the two P1 accounting defects, dates, platform selection, provider refresh +semantics, connection cleanup and recorder ownership. Add regressions that fail +on this baseline. Define migration behavior before touching existing session data. + +Acceptance: exact expected totals through recovery and import timelines; no +cross-game attribution; correct dates and selected console on first identification; +cached details survive offline failures; successful refresh replaces provider data. +Run the full relevant suite after fixes, plus package upgrade and daemon lifecycle +checks in isolation. Local maintainer validation is required before publication. + +### 2. Finish metadata and details + +- Keep the existing identify/correct/reject actions. Present clear matched, + uncertain, unmatched, unavailable and refreshing states without making people + interpret internal queue messages. +- Show the selected game's platform, first release, genres, credits and background + with clear provenance. Test sparse data and long names/summaries. +- Make corrections durable across restart, rescans and provider refresh. Add a + deliberate per-game refresh and retry path where the current UI lacks one. +- Add useful genre/year filtering only after fields have model roles and stable + persistence. Extend saved-filter serialization and migration at the same time. +- Verify controller reachability and scrolling in small desktop and 1080p couch + layouts. Capture populated fixtures, not only empty metadata placeholders. + +Acceptance: identify, correct, refresh, disconnect, restart and return using only a +controller. Long details remain legible and the primary launch action stays easy +to reach. No additional provider until a measured matching gap justifies one. + +### 3. Make play history trustworthy and visible + +Add recorder status and an explanation of supported attribution, recent sessions +per game, provenance of imported versus observed time, and explicit correction or +deletion with confirmation where appropriate. Decide backup/export coverage first. +Measure idle daemon cost and refresh-query cost against a large history fixture. + +Acceptance: a real launch and exit for each supported emulator path; terminal and +Omakade launches; suspend/resume; recorder restart while playing; tracking toggle; +emulator UI launches marked unsupported until actually verified. A running service +alone does not count as successful session attribution. + +### 4. Improve the daily library experience + +Prototype an optional home screen using existing recent activity and organization +models. Start with Continue playing and a small user-controlled Up next queue. +Continue launches the preferred installation; it does not promise save-state resume. +Use stable identities across linked installations, exclude hidden games, handle +missing storage explicitly, and preserve controller focus as activity updates. + +Do not ship several rows that repeat the same covers or a new ranking system +without a clear benefit. Compare startup, search and controller navigation with the +current library at the same size. Keep the library directly accessible and let the +user choose their startup view. Add suggestions only when the underlying data can +explain why a game was suggested. + +### 5. Bounded expansion after local acceptance + +**RomM:** begin with a read-only adapter investigation against a pinned API version +and test server. Establish authentication, pagination, provider/platform identity, +and offline cache behavior. Then prototype one selected download with progress, +cancel/retry, space checks, atomic completion and local-emulator launch. Preserve +remote provenance and prevent duplicate local entries. No bulk synchronization or +save sync in the first candidate. Reference: https://romm.app/ + +**Per-game setup:** inventory existing launch options and preferred-installation +behavior per source. Pilot a small reversible profile for one emulator, with +preview/reset and a clear configuration owner. Keep launcher-managed settings +with their launcher; validate against real commands before generalizing. + +**Saves:** defer implementation. Start with verified save-location adapters and +read-only discovery, then a versioned backup/restore drill for one emulator. Live +saves, multiple profiles, conflicts and partial restores must be resolved before +cross-device sync. This is separate from Omakade's current personal-data backup. + +## Evidence and limits + +Rebuilt `omakade_core_tests` and `omakade_tracking` against the reviewed source. +Ran six focused test functions covering the new matcher, recorder, merge and +metadata behavior: all six passed, plus QtTest setup/cleanup. The independent +temporary-database probes still reproduced both accounting errors. A Qt conversion +probe confirmed numeric platform IDs do convert through `toStringList`; that +suspected issue is not a finding. + +Local evidence: `build/feature-audit-2026-09-08/probe.cpp`, `probe-results.txt`, +and `focused-tests.txt`. Probe writes used temporary/in-memory databases; focused +tests used isolated XDG config/data/cache directories. No daemon was launched or +stopped. This review did not run the full suite, render the new screen, call live +metadata APIs, test a RomM server, or prove real emulator attribution. Those remain +explicit candidate gates, not implied by the passing focused tests. + +Recommended first implementation scope: phase 1, then phases 2 and 3. Reassess the +home screen with reliable real data before committing to the expansion projects. diff --git a/docs/HOME-LOCAL-CANDIDATE.md b/docs/HOME-LOCAL-CANDIDATE.md new file mode 100644 index 0000000..c3db286 --- /dev/null +++ b/docs/HOME-LOCAL-CANDIDATE.md @@ -0,0 +1,43 @@ +# Home redesign, local candidate + +> Historical review and implementation record. For the reconciled September 8 +> candidate, push authorization, and remaining acceptance gates, see +> [PUBLICATION-CANDIDATE.md](PUBLICATION-CANDIDATE.md). Earlier local-only status +> and test counts below describe their original snapshots. + +Home now uses a featured recent game, portrait tiles, an Up next shelf, and suggestions from the user's own library. Quick access opens consoles, sources, collections, saved views, favorites, backlog, or all games. View all on Continue playing opens the complete recent library. + +Suggestions use available, non-hidden games outside the recent list and queue. Backlog and favorites take precedence, followed by genre overlap with recent games, unplayed games, and older games. Completed and abandoned games are excluded. Every suggestion includes its reason. Ordering is deterministic and linked installations are deduplicated. No new provider or network integration is involved. + +Game tiles retain their instances when artwork or play history updates. Queue entries keep their saved identities and unavailable entries remain removable. Queue actions live in the shared keyboard/controller menu. Home opens game details through the existing preferred-installation path and restores the previous library filters on return. Quick access deliberately starts a fresh view; saved views apply their stored filters. + +## Verification + +- Isolated unit coverage for suggestion exclusions, priority, stability, shortcut counts, and disabled sources. +- Keyboard traversal across game tiles, scrolling focused tiles into view, header/featured navigation, opening details and returning, queue removal, and clearing stale searches through quick access. +- Home navigation renders at desktop and couch sizes; overview renders at 600x800, 1280x720, and 2048x1152, plus an empty-library render. +- Full application test results and installed artifact identity are recorded with the local candidate. + +## Maintainer checks + +1. Open Home full screen and tiled. Check real cover art, long titles, and reading size. +2. Use arrows/D-pad to move between the header, featured game, shortcuts, and tile sections. Use Enter/confirm and Escape/Back. +3. Open a game and return. Add a suggestion to Up next, reorder it, and remove it through Queue actions. +4. Try console, collection, and saved-view shortcuts after leaving a search active in Library. +5. Review whether the suggestions are useful for your library. Physical controller feel and actual artwork acceptance remain manual checks. + +Everything remains local. No publication is authorized. + +## Delayed-opening layout fix + +The first candidate's demo previews opened Home before the first frame. Opening it later from the running library reproduced a Grid polish loop and collapsed all its sections. Shelves now calculate tile positions and total height from available width and item count, without circular implicit-size dependencies. + +Eight additional regression cases open Home after startup, resize it, update the queue, leave, and reopen it. They check non-overlapping sections and tiles at four desktop/couch sizes and fail on layout-loop warnings. The original broken layout was reproduced before applying this fix. + +## Mouse wheel scrolling + +Home now uses a velocity-preserving SmoothedAnimation on a separate wheel-position property, accumulates repeated input, and reverses from the current position rather than finishing an old target. Pixel deltas remain direct. Reduced motion keeps immediate scrolling. Navigation reveal, scrollbar dragging, resizing, and content-size changes cancel pending animation. + +Four wheel-input regression cases cover narrow/wide windows with reduced motion on and off, accumulated input, reversal, pixel deltas, bounds, and navigation taking over. Existing library wheel tests remain part of the full suite. + +The maintainer's 2026-09-08 16:09 recording showed abrupt motion with the initial stop/restart easing. That implementation was replaced with continuous retargeting. Two additional cases send six wheel events spaced 60 ms apart, checking that individual events do not jump the current position and that movement is not lost. Motion feel still requires the maintainer's mouse and display; passing input checks alone is not visual acceptance. diff --git a/docs/LOCAL-CANDIDATE.md b/docs/LOCAL-CANDIDATE.md new file mode 100644 index 0000000..89a8d56 --- /dev/null +++ b/docs/LOCAL-CANDIDATE.md @@ -0,0 +1,76 @@ +# Local feature quality candidate + +> Historical review and implementation record. For the reconciled September 8 +> candidate, push authorization, and remaining acceptance gates, see +> [PUBLICATION-CANDIDATE.md](PUBLICATION-CANDIDATE.md). Earlier local-only status +> and test counts below describe their original snapshots. + +Branch: `codex/feature-quality-local` +Base: `033ca9e62d2dcaa2911f1136825789b1fd9501e5` +Worktree: `/home/bts/Projects/omakade-quality-local` + +## Changes + +- Preserve committed session duration through live recorder restarts. Recovery + excludes unobserved downtime and closes mismatched survivors at their heartbeat. +- Capture zero playtime baselines and conservatively subtract already recorded + time on late first imports. Preserve existing baselines and session rows. +- Close Qt SQL connections and prevent multiple new recorder instances from + owning the same database, including stale-lock recovery after process exit. +- Use UTC release dates, the selected console on first matching, and replacement + semantics for successful provider refreshes. Failed requests keep cached data. + Metadata payload version 3 requests a refresh of older identified entries when + credentials are available; manual identity choices stay selected. +- Show metadata release year beside the title, with an About section for first + release, platform, genres, credits and a plain-text description. Long descriptions + expand/collapse with keyboard activation and have larger couch typography. + +## Verification + +Validation is recorded in `build/quality-evidence/`. The regression baseline +failed in the expected places: live restart 30 rather than 150 seconds; first +session 1200 rather than 600 seconds; release date July 27 rather than July 28. +The fixed focused regressions pass, including late first import, repeated recovery, +metadata persistence/offline handling and duplicate daemon ownership. + +The full 130-check suite passed after the typography/navigation changes. The final +focus-scroll adjustment is covered by the subsequent details/navigation test run. +Metadata persistence/date checks also passed under UTC, Pacific/Kiritimati and +America/Los_Angeles. Desktop and couch screenshots were inspected. +The first suite attempt failed at GTK display initialization; the offscreen test +configuration requires `QT_QPA_PLATFORMTHEME=generic` and `QT_STYLE_OVERRIDE=Fusion` +on this desktop. It is not an application regression. + +All tests use isolated config/data/cache/runtime paths. Daemon lifecycle tests use +an empty private profile set so no real emulator is recorded and no app rescan is +sent. Installed binary hashes and the existing recorder PID remained unchanged. +No installation, service restart, push, tag, PR or release was performed. + +## Remaining limitations + +- Existing totals that were already lost or double-counted are not repaired. +- Imported counters and observed sessions lack enough information for exact + historical overlap reconciliation. Late imports conservatively assume recorded + time is included; recording gaps can delay visible increases. No broad claim of + exact playtime accounting is made. +- Emulator file-picker launches, relative/sandbox path attribution, session editing, + history backup, richer database failure handling and real emulator validation + remain future work. +- Metadata requests were tested with fixtures, not live IGDB credentials. Release + dates still use the existing English display format. Real desktop/controller + acceptance, package lifecycle validation and release approval remain open. +- Save-file backups, RomM integration, home screen and genre/year browsing filters + are not implemented in this candidate. + +## Manual test checklist for later + +1. In a test profile, identify a console game. Check year, selected console, first + release, genre, description and credits. Reopen and confirm they persist. +2. Correct a match, refresh, then disconnect the network. Confirm the chosen game + and cached details stay intact. +3. In desktop and couch layouts, expand/collapse a long description, navigate to + metadata controls and back, then return to Play. Check readability from the couch. +4. In an isolated emulator fixture, record a first session and restart the recorder + while playing. Compare duration before/after and after emulator exit. +5. Only after acceptance, prepare a versioned package candidate and validate its + upgrade/service lifecycle before requesting publication approval. diff --git a/docs/LOCAL-QUALITY-REVIEW.md b/docs/LOCAL-QUALITY-REVIEW.md new file mode 100644 index 0000000..bdbce14 --- /dev/null +++ b/docs/LOCAL-QUALITY-REVIEW.md @@ -0,0 +1,33 @@ +# Local quality review, September 8, 2026 + +> Historical review and implementation record. For the reconciled September 8 +> candidate, push authorization, and remaining acceptance gates, see +> [PUBLICATION-CANDIDATE.md](PUBLICATION-CANDIDATE.md). Earlier local-only status +> and test counts below describe their original snapshots. + +Reviewed the pending changes against installed cover candidate f49b390 (1.7.1-3). +Everything remains local. No pushes, tags, releases, or remote writes. + +## Changes reviewed and corrected + +- Preserve downloaded portraits when source artwork arrives; expose missing RetroArch cover files as missing artwork. These fixes are already installed separately. +- Prioritize the selected game ahead of visible and background metadata requests. Older payloads can acquire descriptions and artwork without waiting for the normal refresh cycle. +- Explicit refresh bypasses the in-memory provider response cache. Retry is disabled when IGDB is not connected. +- Put game information above organization controls. Correct controller links to follow the new order, with a fallback when organization controls are hidden. +- Use provider image IDs to construct background URLs. Retain cover artwork as a fallback while the background is unavailable, and reduce background opacity for text readability. +- Keep the real-data render fixture consistent across the information and rating sections. + +## Validation + +- 130/130 CTest checks pass in private configuration, data, cache, runtime, and temporary directories. Tests do not contact the installed app instance. +- Regressions cover selected-game priority against another visible game, explicit refresh cache eviction, source-cover preservation, and missing cover files. +- The captured Mario Odyssey IGDB response and local artwork render successfully. This is an offscreen fixture, not acceptance of the live installed UI. +- Evidence: build/odyssey-audit/review-checks.log, review-render.log, and review-odyssey.png. + +## Remaining work + +- Regional title identification is not implemented. Fetch and preserve IGDB aliases, localizations, and platform/region release information before changing match decisions. +- The existing matcher still resolves some same-title candidates by rating count. That requires a separate identity review; this candidate does not claim to fix it. +- No title-specific Final Fantasy rule is present. +- Background selection currently takes the first usable provider artwork, then a screenshot. Further image selection and visual polish remain possible. +- The richer detail-page candidate has not replaced the installed cover-only candidate. diff --git a/docs/NAVIGATION-LOCAL-CANDIDATE.md b/docs/NAVIGATION-LOCAL-CANDIDATE.md new file mode 100644 index 0000000..7dffe29 --- /dev/null +++ b/docs/NAVIGATION-LOCAL-CANDIDATE.md @@ -0,0 +1,41 @@ +# Local navigation candidate + +> Historical review and implementation record. For the reconciled September 8 +> candidate, push authorization, and remaining acceptance gates, see +> [PUBLICATION-CANDIDATE.md](PUBLICATION-CANDIDATE.md). Earlier local-only status +> and test counts below describe their original snapshots. + +The navigation overhaul is implemented and tested locally. Nothing has been pushed or published. + +## Changes + +- Library controls are grouped into Sources, Filters, Sort, View and More. Active filter chips can be cleared individually, and Hidden games is inside Filters. +- Sources retain additive selection. Saved views, organization, manual entry, collection management and scanning remain available through More. +- Details uses Play, Favorite, Up next and Manage. Other names is collapsed by default while all identification evidence remains stored. +- Settings has separate categories for sources, library/launching, appearance, controls, connections, streaming, backup/storage and help. Narrow windows use a category picker instead of rows of tabs. +- Home and Couch Mode separate navigation from browsing controls. Home exposes Search and Settings. Couch Clear filters does not reset search, source, console or sorting; Reset browsing retains the full reset. +- Menus and editors return focus to their caller. Desktop menus close when changing mode. Back and Tab stay within the active surface. + +## Evidence + +The final source candidate passed all 159 CTests in 63.63 seconds using isolated application state and no physical controller input. The build and whitespace checks passed. Logs and screenshots are in `build/quality-evidence/navigation-final/` and `build/dev/tests/`. + +Coverage includes menu opening/reopening, forward/reverse Tab, directional traversal, multi-source selection, nested filter and sizing menus, editor cancellation, linked-installation defaults, metadata/alias disclosure, Home return state, desktop/couch switching, narrow Settings categories, and 4K menu rendering. Existing source, backup, startup-recovery, launch and library core tests also passed. + +The local installer records the exact source commit and binary SHA-256 in the versioned candidate's `candidate.json`. It retains the previous executable and desktop entry. The system package is not replaced. + +## Maintainer check + +1. Use the controller to open Sources and combine two sources. Apply a genre/decade filter, remove one chip, and verify Back returns through the picker and menu without losing the selected game. +2. Open a familiar game. Traverse Play, Favorite, Up next and Manage. Open and cancel artwork/identification, and check the same game remains selected. +3. Check FFIII/FFVI regional information and expand/collapse Other names. Check a long title and a game with sparse metadata. +4. Visit Settings categories, edit a field without saving, change categories and return. Check Back, Tab/Shift+Tab and the couch keyboard. +5. Switch desktop/couch, browse Home, disconnect/reconnect the controller, then launch a game and return to Omakade. + +Physical-controller feel, emulator-return behavior and real-library visual acceptance remain manual checks. This candidate does not add save-file versioning, RomM or new metadata matching rules. + +## Tiled layout and keyboard follow-up + +Game actions now have equal widths and consistent gaps in each responsive layout. Shared action popups handle arrow and Tab keys within the modal, with Enter activation and Escape returning focus. Removed duplicate detail arrow handling that could skip a control. Installation choices return Down to Play. + +Validation: all 159 isolated tests passed (63.67 seconds), including actual keyboard events for Sources, Filters, Sort and both Tab directions in More, controller paths at four window sizes, and equal action widths. Visually inspected 900x720 and 1256x836 detail renders. Physical controller acceptance remains with the maintainer. diff --git a/docs/NAVIGATION-REDESIGN-PLAN.md b/docs/NAVIGATION-REDESIGN-PLAN.md new file mode 100644 index 0000000..fd14ebf --- /dev/null +++ b/docs/NAVIGATION-REDESIGN-PLAN.md @@ -0,0 +1,289 @@ +# Navigation and menus redesign + +> Historical review and implementation record. For the reconciled September 8 +> candidate, push authorization, and remaining acceptance gates, see +> [PUBLICATION-CANDIDATE.md](PUBLICATION-CANDIDATE.md). Earlier local-only status +> and test counts below describe their original snapshots. + +Status: implementation complete for local acceptance, publication not authorized. Reviewed against `a0ebc14eaf7e4a4f7247c1a8b543323be7c88c3f` on September 8, 2026. The implementation notes below distinguish the first checkpoint from the final rollout. Installation provenance is recorded with the versioned local candidate. + +## Outcome + +Make browsing and playing immediately understandable, put occasional actions in predictable places, and allow new capabilities without adding another row of buttons. Preserve every existing capability, saved preference, library selection, and controller workflow. + +Use three levels consistently: + +1. **Destinations:** Home and Library. +2. **Context controls:** filters and sorting in Library; Play and game actions in Details. +3. **Configuration:** Settings, with categories and focused subpages. + +Home remains optional and Library remains the default. No new empty destinations for planned features. + +## Current code and pressure points + +| Surface | Current implementation | Change needed | +| --- | --- | --- | +| Application and library header | `qml/Main.qml`: browsing presets, individual source buttons, availability, sorting, console layout, cover size, rescan, organization, saved filters, metadata filters, Home, Settings and Couch controls spread across rows | Separate navigation from query controls and maintenance actions | +| Details | `qml/screens/GameDetails.qml`: action grid plus artwork/link buttons under the cover, separate external link, permanently expanded alias text | Establish action priority and progressively disclose less-used information | +| Settings | `qml/components/SettingsPanel.qml`: Sources, Library, Connections, Controls & streaming, About & storage | Give unrelated tasks separate categories without widening the tab row | +| Home | `qml/screens/HomeScreen.qml`: Continue playing and Up next with identity-based focus restoration | Share the application shell while preserving queue behavior and focus | +| Editors and overlays | Separate artwork, manual game, saved filter, bulk organization, backup, startup restore, link and collection dialogs | Consistent headers, cancellation, focus return and scrolling | +| Input routing | `Main.qml` combines active-surface selection, explicit targets, spatial fallback, registered shortcuts and controller routing | Make behavior an explicit contract; migrate incrementally rather than replacing it all | + +The existing navigation review in `NAVIGATION-REVIEW.md` remains relevant. Automated navigation coverage does not establish physical-controller acceptance. + +## Application shell + +Wide layout: + +```text +OMAKADE Home Library Search games Couch Settings +Library / Super Nintendo Sources Filters (3) Sort View More +[Installed ×] [1990s ×] [Adventure ×] 84 games · Clear filters +``` + +This is a hierarchy sketch, not an exact pixel layout. Actual chips show only supported, active filters. + +- The first row belongs to the app. The second belongs to the current screen. +- Search explicitly means game search. From Home it opens Library search, retaining a route back to Home. In Library it updates the current query. A visible clear action restores the previous browsing context where applicable. +- Details keep a prominent Back control and a compact shared header. Search or navigation away from Details must preserve a return location rather than destroying the caller's state. +- Couch Mode stays a direct, labeled toggle. Changing it preserves the current destination and selected game. +- Settings opens at its last category during the session; closing returns to the exact invoking control when it still exists. +- Scanning/update status uses one compact status area with access to existing progress and cancellation. Do not make every background job another header button. +- At narrow widths, use a deliberate compact header and a labeled Browse menu for less frequent context controls. Never let an arbitrary Flow become four or five header rows. Preserve direct Search, Back, Play and Filters access where relevant. +- Desktop and couch share destinations, labels and actions. Couch gets larger targets, fewer simultaneous controls and persistent input hints, not a separate information architecture. + +## Library + +### Browsing controls + +| Existing control | Proposed home | +| --- | --- | +| All, Favorites, Recent | A compact browsing selector, default All games | +| Hidden | Visibility option in Filters, with a conspicuous active indicator | +| Individual source buttons and Emulated | Searchable Sources selector; preserve current filter semantics | +| Console portals and current console title | Existing cards plus a breadcrumb with Back to library | +| Installed, All games, Ready to install | Availability group in Filters | +| Status, collection, tag, genre, decade, platform | Named groups in one Filters panel | +| Saved filters | Saved views within Filters, also accessible from the browsing selector | +| Clear and Clear metadata filters | Individual removable chips, group reset inside Filters, one explicit Clear filters action | +| Title, recent, playtime, rating, popularity | Sort menu with current selection and direction if supported | +| Cover size and console-versus-games view | View menu | +| Organize | Select games action entering a dedicated selection toolbar | +| Pick a game | Library More menu, with an optional direct shortcut preserved if already supported | +| Rescan | Library More menu; source-specific rescan stays in source settings | +| Add manual game | Library More > Add game; source settings may link to the same editor | +| Collection management | Library collection management, linked from the collection filter | + +Filters should apply immediately, matching current behavior, with Reset and Done rather than an ambiguous Apply/Cancel pair. Opening or closing the panel does not change filters. Search and source scope must have separately understandable reset behavior; Clear filters must not silently navigate out of a console or erase search. + +Show a compact active-filter summary outside the panel. Long sets can collapse into a count with an accessible expansion. Distinguish no games found, no games installed, no metadata for selected filters, disabled sources and a scan in progress. Each state gets a relevant existing recovery action. + +Selection mode replaces context controls with selection count, organization actions and Done. Sorting/filtering must not silently apply bulk edits to games the user never selected. Keep existing selection semantics explicit and tested. + +Returning from Details restores query, source, console, filters, sort, scroll and game identity. If a game disappears, focus moves to the nearest remaining item, then the empty-state action. Background updates must not move focus or reload unaffected covers. + +## Home + +- Keep Continue playing and Up next as the initial sections. +- Queue removal and reordering belong to a game action menu or focused queue controls, not the global toolbar. +- Retain unavailable queue entries with a useful explanation. Do not silently remove them. +- Preserve section-qualified focus identity when a game appears in both sections. +- Opening Details and returning restores the originating section and card. +- Future sections must earn their space through usable data. Do not add placeholder recommendations or game-length promises. + +## Game details + +Primary actions: + +```text +Play Favorite Up next Manage +``` + +Use explicit selected states for Favorite and Up next. Play remains the default initial focus. When multiple installations or an unavailable preferred installation require a choice, expose the launch choice beside Play rather than burying the reason it cannot launch. + +### Manage menu + +| Group | Existing actions to preserve | +| --- | --- | +| Launch and installations | Manage in launcher, select/prefer installation, link/unlink installation | +| Identity and artwork | Identify game, select artwork, reset custom artwork, existing metadata refresh/rejection actions | +| Library placement | Hide/unhide, show separately in library where currently supported | +| Manual entry | Edit/remove manual entry when applicable | + +Do not conflate “show separately in library” with navigation back to Library. Verify the current pin action and give it a label that explains its actual effect. Hide, unlink, remove entry, and delete files must never share an ambiguous Remove label. Preserve existing confirmations and add one where an irreversible action requires it. + +The cover stays visible, but maintenance buttons no longer need to sit permanently underneath it. PCGamingWiki and other available outbound destinations move to a labeled Links section/menu. A link should identify when it opens an external application/browser. + +### Information order + +1. Title, cover, platform, relevant release year, rating and primary actions. +2. About the game: description, developer/publisher and concise release information. +3. Personal organization: status, tags and collections. +4. Play activity, installation information, achievements and other available insights. + +Keep section order consistent; omit unsupported empty sections rather than presenting empty tabs. Long descriptions retain Read more. Background artwork stays decorative, stable and subordinate to legible content. + +### Regional names without the wall of text + +- Keep all provider names and regional evidence in the data used for matching. +- Show the ROM region and relevant regional/platform release date when supported. Clearly label a fallback date. +- Show a short catalog-title explanation only when there is a meaningful difference from the local title. Filename region/revision decorations alone should not trigger one. +- If provider evidence establishes a regional relationship, explain it concisely. Do not infer a country from arbitrary alias text or special-case a franchise. +- Put the complete list behind **Other names (N)**, collapsed by default. Deduplicate display equivalents; retain provider labels and full stored evidence. +- Do not truncate names irretrievably. Expanded content wraps and participates in normal page scrolling; avoid a nested scrolling trap. +- Expanding preserves focus on the disclosure. Collapsing or switching games must not leave focus inside hidden content. Reset expansion on a different game. +- Where evidence is ambiguous, show the available facts without claiming a regional equivalence. This presentation change must not change matching decisions. + +## Settings + +Use a category sidebar on wide screens and a category list with drill-in pages on narrow screens. Couch uses the same hierarchy with larger rows. Avoid adding more horizontal tabs. + +| Category | Contents | +| --- | --- | +| Library & Sources | Enabled sources, source status/rescan, ROM/GOG folders, manual-game entry point, console layout overrides, standalone emulator preference, launch/auto-close behavior, playtime tracking | +| Appearance | Cover size, default console presentation, reduced motion and existing display options | +| Controls | Controller status, current input help, couch/desktop mode; only expose remapping if it exists | +| Connections | Steam, IGDB/Twitch, SteamGridDB, RetroAchievements setup, test/disconnect, metadata update/stop | +| Streaming | Existing Sunshine/Moonlight export options, app-list update and restart | +| Backup & Storage | Existing personal-data backup/restore, cache budget, downloaded artwork maintenance and storage locations | +| About & Troubleshooting | Version/build provenance, existing diagnostics, project and issue links, useful configuration paths | + +View-menu controls and Settings entries must manipulate the same preferences. One canonical configuration page owns each source/connection; other entry points deep-link to it. Collection management moves to Library, with a transitional settings link if needed. + +Keep connection configuration distinct from a successful connection test. Display only status the backend actually knows. Preserve drafts according to an explicit Save/Cancel contract; navigation must not silently save credentials or discard unsubmitted changes. Keep secrets out of diagnostics. + +Separate cache cleanup from user artwork and personal data. Explain the exact scope of each existing clear action. Backup & Storage must say that current backups cover Omakade data, not imply emulator save protection. + +Settings search is optional later. A sensible category structure is the first deliverable; there is no need to build a search index now. + +## Shared menus, editors and input contract + +Apply the same contract to Settings, Filters, artwork, metadata identification, manual games, saved views, bulk organization, linking, collection deletion, backup/restore and startup recovery. + +- Every surface has a title, visible close/back affordance, deterministic initial focus and a remembered invoker. +- Back/Escape closes the innermost surface first. It must not also navigate the page beneath it or exit the app in the same event. +- Subpages return to their parent before closing the enclosing surface. +- Unsaved editors distinguish Save, Cancel and navigation away. Reuse existing validated semantics; confirm any necessary changes before implementation. +- Tab/Shift+Tab cycle through usable controls within the active modal. Arrows follow logical groups and never land on invisible, disabled or clipped targets. +- Preserve current controller bindings and glyphs. Confirm activates the focused control; Back follows the same hierarchy as Escape. Do not introduce required new bindings during the layout migration. +- Text fields and the onscreen keyboard own text-editing input while active. Directional editing must not also navigate the page underneath. +- Mouse movement alone must not steal keyboard/controller focus. Switching input mode updates hints without resetting selection. +- Background model changes preserve focus by stable identity rather than row number. If an action disappears, choose a documented adjacent fallback. +- Onscreen-keyboard closure returns to its text field; menu closure returns to its button; editor closure returns to its caller. +- Focus remains visible above sticky headers and inside scrolling panels. Long labels, large UI scale and narrow windows must not hide the only way out. +- Launch failure returns to an actionable state. Emulator return, controller reconnection and couch-mode changes preserve context. + +## Implementation boundaries + +Start with small reusable QML components: application header, context toolbar, action menu, settings category shell and disclosure section. Reuse `GlassButton`, `TextEntry`, existing backend calls and current models. + +Give actions stable IDs, labels, enabled/visible conditions and handlers. Share action definitions where desktop and couch invoke the same behavior. This should be a small explicit model, not a general plugin framework. + +Preserve current object names where tests or focus restoration depend on them. Update tests to express the new interaction path when a control legitimately moves behind a menu, rather than forcing hidden controls visible. + +Document caller focus identity and return-state snapshots at surface boundaries. Consolidate active-surface handling incrementally as surfaces migrate; do not replace all overlay booleans and input routing in the first patch. + +This work should not change metadata matching, source discovery, save formats, cache selection, launch commands or queue persistence. Any discovered defect in those systems gets a separate fix and evidence. + +## Space for future capabilities + +| Future capability | Natural location | Condition before exposing it | +| --- | --- | --- | +| Save versions and restore | Game Details > Saves; global policy in Backup & Storage | Reliable emulator/source adapters, safe snapshots and tested restores | +| RomM integration | Library source with one linked configuration page | Implemented authentication, identity and availability behavior | +| Per-game launch profiles | Details > Manage > Launch settings | Supported backend and clear override/reset behavior | +| More background jobs | Existing status area opening a task panel | Enough concurrent work to justify the panel | +| Game-length discovery | Filters and optional Home sections | Reliable library-wide data and clear unknown-value handling | +| Browser/remote play | Explicit Play option on compatible games | Implemented runtime, input and save behavior; not a current promise | + +Add top-level destinations only for substantial independent workflows. A new integration normally adds a source or settings page, not another permanent header button. + +## Delivery sequence and regression gates + +Each phase is a small local candidate with its own rollback point. Do not change all navigation surfaces at once. + +1. **Baseline and layout preview.** Inventory action handlers and current desktop/couch entry points, capture representative screens, and document focus/Back expectations. Verify every item in the relocation tables against enabled and disabled states. Preview wide and narrow layouts before wiring new behavior. +2. **Shared header and Library.** Introduce the shell, grouped controls and active-filter summary while reusing existing filter/sort handlers. Preserve console, saved-view, bulk-selection and Home return state. This is the recommended first implementation slice. +3. **Details.** Group actions into Manage; simplify visible release information and collapse aliases. Preserve installation choice, identity/artwork editors, organization, achievements and focus across Read more/disclosures. +4. **Settings.** Move existing sections into the category shell, one category at a time. Preserve source configuration, credential drafts, streaming controls and backup paths. Add links rather than duplicate configuration logic. +5. **Editor consistency.** Align remaining modal headers, buttons, focus return and text entry. Include startup recovery, not only dialogs reachable from the normal Library screen. +6. **Whole-app acceptance.** Test cross-screen journeys, physical controller behavior and visual fit on the exact local candidate before treating the redesign as ready. + +Automated checks should include: + +- Existing relevant CTests plus build/QML validation for each changed surface. The previously reported 144-test baseline is historical evidence, not a test run for this document. +- An action inventory proving every moved command remains reachable and invokes the correct handler. +- Keyboard and virtual-controller coverage for open, traverse, activate, cancel and return on each new menu/surface. +- At least 600x800 desktop and 1280x720 couch layouts, plus wide screens, long titles, long translations/aliases and large UI scale. +- Return-state tests through Home, Library, console portals, Details, Settings and nested editors. +- Empty/offline/unavailable states, metadata updates while focused, filtered-out selections, hidden games and multiple installations. +- Draft handling, backup restore choices, repeated Back events, onscreen keyboard, reduced motion and source-specific disabled controls. +- No renewed flashing, blanket cover reloads, scroll jumps or noticeable input delay during background updates. + +Keep automated execution isolated from the installed app: private IPC and XDG/config/data/cache/runtime paths, test controller state and appropriate offscreen software rendering. Do not send synthetic controller events to the live library or launch real games from tests. + +Physical acceptance on the exact candidate remains necessary: + +1. Browse with D-pad and stick, including held input and rapid direction changes. +2. Search with the couch keyboard; clear text; close it and resume browsing. +3. Apply filters, enter a console, open a game, use Manage, return and verify position. +4. Reorder/remove Up next entries and return from a game appearing in both Home sections. +5. Visit every Settings category and nested editor; cancel drafts and use repeated Back. +6. Switch mouse/keyboard/controller and couch/desktop without losing focus. +7. Disconnect/reconnect the controller; launch and return from an emulator; verify launch-failure recovery. +8. Inspect the appearance with sparse metadata, long aliases, missing artwork and multiple installations. + +Done means the actions are easier to find, all existing capabilities remain reachable, return state is reliable, automated checks pass, and the maintainer accepts the physical-controller candidate. It does not mean merely fitting the buttons onto fewer rows. Publication remains separately gated by explicit maintainer approval. + + +## Second review and first local implementation + +The second code review found requirements that need explicit protection: + +- Desktop sources support multi-selection. The eventual Sources selector needs checkboxes or an equivalent additive action, not only the single-choice behavior currently used by the couch browser. +- Couch Clear filters currently also resets sorting, search and console scope. Preserve that behavior until there are separately labeled Clear filters and Reset browsing commands, with migration tests. Do not silently change it while relocating controls. +- Applying a saved view intentionally updates browsing state; canceling its editor should restore the invoker. Those are different return behaviors. +- Metadata filter choices, hidden-state controls and source buttons differ between demo fixtures and a configured library. Test both, including unavailable actions. +- The shared shell cannot simply be copied onto Home and Details: their current focus containers and caller snapshots need an explicit migration. The first patch is the desktop Library header, not a claim of app-wide completion. + +First local slice: + +- Home and Library sit with Settings and Couch in the application row. +- Search and browsing presets sit together below application navigation; search has its own row below 720 pixels. +- Library More contains Organize, Saved filters and Rescan, using a reusable action-menu component. +- Organization and metadata filter buttons share one responsive strip. Their data handlers are unchanged. +- Canceling a library editor opened from More restores focus to More. Applying a saved view retains the existing return-to-results behavior. +- Controller tests now enter More, traverse its actions, cancel it, open both editors and cancel back to the invoking button. A 600x800 case supplements the existing tiled and wide cases. + +Remaining: grouped Sources/Filters/Sort/View controls and active chips, the shared shell on Home/Details and couch, detail actions and alias disclosure, Settings categories, and the remaining editor migration. These remain staged work, not completed features. + + +### Validation for the first slice + +- Build passed. Full isolated CTest suite: 145/145 passed in 62.05 seconds. +- After adding the mode-transition guard and render fixture, all four navigation cases passed again in 3.55 seconds. These exercise forward/reverse Tab, directional traversal, repeated menu opening, both editor handoffs, cancellation, selection retention and closing the desktop menu on entry to Couch Mode. +- Inspected rendered 600x800 Library/menu and 1600x900 Library previews. The header and menu fit; the source strip still requires the planned selector redesign. +- Testing caught and fixed a reopened popup starting after its previously focused action. Initial focus now explicitly selects the first enabled action, with Close as fallback. +- Evidence is in `build/quality-evidence/navigation/`: build and test logs plus preview PNGs. Fixtures use synthetic games and isolated application state. +- No installed binary, user library, settings or remote repository was changed. Physical controller and live-library acceptance remain outstanding. + + +## Final local rollout + +The planned navigation overhaul is implemented for local acceptance. Earlier checkpoint notes above describe intermediate states, not remaining work. + +- Library: Home/Library navigation, separate search/presets, Sources, Filters, Sort, View and More. Hidden games lives in Filters with a visible active chip. Metadata/status/availability chips can be removed individually. Clearing these filters preserves source, console, search and sort context. +- Sources: the existing enabled-source handlers and additive selection are retained in a wrapping panel. A separate source search is unnecessary for the current short list; the panel can scroll if more sources are added. +- More: Pick a game, Add a game, Organize, Saved filters, Manage collections and Rescan. Collection management deep-links to its existing canonical implementation. Bulk organization keeps its tested editor rather than introducing a second selection implementation. +- Details: Play, Favorite, Up next and Manage. Launcher, visibility, pinning, manual entry, default installation, artwork and linking actions are grouped in Manage. Installation choices and unavailable-default guidance remain immediately visible when needed. External links sit below About. +- Regional information: region/date and meaningful catalog-title differences remain visible. Other names are collapsed, with the complete provider evidence retained. Filename decorations do not by themselves cause a redundant catalog-name line. There are no game-specific matching rules in this change. +- Settings: Sources, Library & launching, Appearance, Controls, Connections, Streaming, Backup & storage, About & help. Wide screens use a sidebar; narrow screens use a category list. Existing fields stay mounted so switching categories does not silently discard drafts or save them. +- Home: Library, Search, Settings and mode switching are directly available. Desktop and couch share command meanings without forcing identical geometry. Couch navigation and browsing controls occupy separate rows. +- Input: reusable menus register as the active navigation surface; Back closes the innermost menu and restores its invoker. Value pickers return to Filters, sizing returns to View, and editor cancellation restores the initiating control. Mode changes close desktop menus. Global search/settings shortcuts cannot focus controls beneath unrelated editors. +- Couch: additive source selection is supported, and Clear filters is separate from Reset browsing. The latter preserves its original complete-reset behavior with an accurate label. +- Long filter labels are bounded with ellipsis and full accessible text; pointer users can read truncated labels in a tooltip. Menus scale for couch displays and scroll when needed. + +Future save versions, RomM, launch profiles and background-task expansion remain future capabilities. They have designated locations, not empty menu placeholders or new product promises. + +Acceptance still requires a physical controller and normal emulator launches. Automated fixtures do not replace that check. All publishing restrictions remain in force. diff --git a/docs/NAVIGATION-REVIEW.md b/docs/NAVIGATION-REVIEW.md new file mode 100644 index 0000000..0b13455 --- /dev/null +++ b/docs/NAVIGATION-REVIEW.md @@ -0,0 +1,48 @@ +# Navigation review, September 8, 2026 + +> Historical review and implementation record. For the reconciled September 8 +> candidate, push authorization, and remaining acceptance gates, see +> [PUBLICATION-CANDIDATE.md](PUBLICATION-CANDIDATE.md). Earlier local-only status +> and test counts below describe their original snapshots. + +The app does not yet have an exhaustive navigation guarantee. Navigation combines explicit +links, spatial fallback, keyboard focus order, modal selection, and controller input routing. +Layout changes can affect these paths differently. + +## Findings and changes + +- Detail render tests used DemoMode, which hid status, tags, and collection controls. The new + fixtures explicitly show those controls without enabling live data or hardware access. +- An injected stale explicit target reproduced focus escaping the detail screen. The shared + focus router now requires explicit and preferred targets to belong to the active container. +- Detail tests now traverse the registered Tab and Shift+Tab shortcut routes, require status, + tags, collections, and metadata controls to be visited, require the cycle to return, and check + usable focus and vertical window bounds. Raw window key injection bypasses Qt's platform + shortcut dispatcher, so these tests activate the registered shortcuts directly. +- Arrow tests verify Read More to organization and back, in expanded and collapsed states. +- Additional detail fixtures cover 600x800 desktop and 1280x720 couch layouts. + +## Existing coverage reviewed + +| Surface | Automated coverage | Limit | +| --- | --- | --- | +| Library and console portals | Desktop/couch navigation, toolbar paths, filtering/back, large libraries, stale selection | Selected fixture states | +| Game details | Actions, description expansion, organization, metadata, both Tab routes, focus containment | Synthetic data; not every provider/state | +| Settings and text entry | Settings paths, editor fields, clear actions, on-screen keyboard reachability | Selected sections and field states | +| Artwork/manual editors | Entry, editing, controller text entry, close | Selected fixture data | +| Saved filters and bulk organization | Scrolling, field entry, selected actions and close | Selected layouts and data | +| Backup/restore | Preview, navigation to choices, cancel, confirmation | Isolated fixture storage only | +| Input routing | Virtual controller mapping and focus ownership checks | Not physical hardware end to end | + +## Remaining acceptance + +A physical-controller pass is still needed for D-pad/stick repeat, confirm/back, reconnection, +focus after returning from an emulator, and mouse-to-controller switching. Also check actual +Tab/Shift+Tab platform dispatch, text editing, and dialog focus restoration with the installed +app. These checks must not be represented as completed by the automated suite. + +Validation: all 132 CTest cases passed in isolated directories. The injected out-of-screen +explicit target failed before the router fix and passed afterward. + +All work remains local. The installed build has not been replaced by this navigation candidate. +Evidence is under build/odyssey-audit/navigation-*.log. diff --git a/docs/PUBLICATION-CANDIDATE.md b/docs/PUBLICATION-CANDIDATE.md new file mode 100644 index 0000000..47919f4 --- /dev/null +++ b/docs/PUBLICATION-CANDIDATE.md @@ -0,0 +1,59 @@ +# Publication candidate + +The maintainer authorized pushing reviewed work through btsouth on September 8. +The candidate branch is `codex/feature-quality-local`, draft PR #43. Publication, +tags, release assets, and main remain subject to RELEASING.md and exact-candidate +maintainer acceptance. Version 1.8.0 package links remain prospective until assets +exist. Do not merge this branch before release assets are available. + +## Scope and preservation + +Home and Up Next, discovery filters, regional details, portrait preservation, +Game & Artwork, navigation fixes, session recording, and backup format 2 are in +this candidate. Follow-ups fix rating tooltips, overlapping Recent captions, +startup artwork refresh, and Home launch feedback. + +Recorder settings distinguish the saved preference from a running daemon. +Details identify imported and recorded emulator time. New configurations require +opting in; existing preferences and historical totals are preserved. Full-queue +checks cover the 100-entry limit and navigation. See RECORDING-COVERAGE.md for +what automated recording fixtures establish and what still needs real hardware. + +Other worktrees, package archives, checkpoint refs, and deferred PR #33 remain +preserved. The reconciled ancestry and previous evidence are retained in +[the candidate history](PUBLICATION-HISTORY-2026-09-08.md). + +## Verification + +Record the final commit, local checks, package workflow, and CI results in PR #43 +and the local `build/quality-evidence/` manifest. Earlier candidate test counts do +not establish verification of subsequent edits. + +Package scanning supplies `GRYPE_DISTRO=arch:rolling` so Arch advisories can be +matched using [Grype’s Arch support](https://oss.anchore.com/docs/capabilities/all-os/). +This is a baseline for ARM64, not complete Arch Linux ARM advisory +coverage. Inspect the scan diagnostics and database status for each candidate. +The optional GitHub AI findings job lacks a Copilot license; it is not a completed +review. Regular build, tests, and CodeQL are separate checks. + +Local verification for this follow-up passed: release build, 213/213 isolated +CTest cases (87.34 seconds), two SBOM generator tests, final staged smoke, +desktop entry, and AppStream validation. Narrow and couch recorder renders were +inspected. The full queue fixture verifies traversal, directional movement, +reorder/removal focus, and the model's unavailable-source recovery. The existing +navigation implementation passed once the fixture waited for layout. + +## Maintainer acceptance still needed + +1. Home scrolling, full Up Next queue, and Play/Details focus return. +2. Keyboard and physical controller: Sources/Filters, Back, and launcher return. +3. Narrow and couch details, tooltips, Other Names, and Game & Artwork controls. +4. Recorder preference/status and imported versus recorded totals. +5. Real launcher return and short-session accounting when game tests resume. +6. Backup preview and restore using disposable data with its recorder stopped. +7. Exact ARM64 package on supported hardware. + +Do not restart the paused Z-A diagnostics or change emulator settings as part of +release preparation. Process attribution cannot observe all internal game changes; +provider metadata and artwork coverage remain incomplete. Automated fixtures and +package lifecycle checks do not replace the manual gates above. diff --git a/docs/PUBLICATION-HISTORY-2026-09-08.md b/docs/PUBLICATION-HISTORY-2026-09-08.md new file mode 100644 index 0000000..773239f --- /dev/null +++ b/docs/PUBLICATION-HISTORY-2026-09-08.md @@ -0,0 +1,185 @@ +# September 8 candidate history + +Historical evidence only. See PUBLICATION-CANDIDATE.md for the current gates. + +The maintainer authorized pushing the reviewed candidate through btsouth on +September 8. This supersedes earlier local-only instructions in historical +review documents. Tags, public release assets, release publication, and merging +remain gated on exact-candidate acceptance and the checks in RELEASING.md. + +The proposed version is 1.8.0 because Home, discovery filters, session recording, +and backup format 2 exceed the original 1.7.1 patch scope. README package links +are prospective until release assets exist; this branch must not reach main first. + +## Reconciliation + +Start: d33f84955024cfe1d5c7302b4578970e903fae47, based on GitHub main +b1c8311177eb8809ef4f382aa0054d14aa95c1e4. Preserve the existing 30-commit +ancestry, including the cover-preservation merge. + +- The port-playtime-game-info, cover-preservation-local, and release-1.7.1 + worktree tips are ancestors of this candidate. +- The untracked feature plan in steam-launcher is an older copy of the tracked + plan. Its original content is retained in the candidate. +- The old 1.7.1 source archive and cover-local package directory remain untouched. + They are older artifacts, not publication inputs. +- The console-portals branch contains earlier development history superseded by + the released console implementation and subsequent ported metadata/session work. + It remains preserved; do not overwrite newer source with that old snapshot. +- PR #33 and its review branches remain separate deferred TV helper work. +- T3 checkpoint refs remain untouched. + +## Current implementation + +Home and Up Next, metadata discovery filters, regional title/date evidence, +portrait preservation, unified Game & Artwork, popup navigation, precise session +accounting, and personal backup format 2 are implemented. Historical plans may +still describe their pre-implementation findings. The changelog describes the +current scope; BACKUP-FORMAT.md defines archive coverage and compatibility. + +This follow-up bounds rating hover to the rating text, uses the app's Qt Quick +Controls tooltip with a nearby anchor, and places developer/publisher credits +before the entire regional-information group. No new focusable control is added. +Other tooltip handlers are attached to their own title/button labels, with no +second rating-count tooltip attached to a combined information row. + +## Acceptance still needed + +Automated fixtures do not establish physical-controller or emulator acceptance. +The earlier Home wheel behavior was accepted; the final candidate still needs: + +1. Home wheel scrolling during metadata refresh. +2. Keyboard and pad Sources/Filters, Back, and focus after emulator return. +3. Narrow and couch details; hover platform/date/rating; expand Other Names. +4. Game & Artwork scroll, Done, select, reopen, and reset. +5. Known regional titles and previously missing NES covers. +6. A real emulator launch/return and short-session accounting. +7. Backup preview and restore on disposable data with the recorder stopped. + +Process-argument attribution cannot observe every internal emulator game change. +Provider aliases and artwork tagging are incomplete. Referenced artwork can keep +cache size above its soft limit. Older copied covers may lack a Current badge. +ARM64 runtime acceptance and disposable package lifecycle checks remain release +gates even when the local x86_64 suite passes. + +Validation and candidate hashes are recorded in the PR and local evidence folder +`build/quality-evidence/publication/`; earlier test counts are historical. + +## Local validation + +- Release configure/build passed; the complete isolated suite passed 201/201 + in 75.23 seconds, including the 12 new tooltip cases and late-cover navigation. +- Narrow and couch tooltip screenshots were inspected. Credits precede regional + details; platform/date hover and missing ratings do not activate the tooltip. +- Empty staged install inspected: app, recorder, profiles, service, metadata, + icons, licenses, and documentation only. Isolated staged smoke passed. +- Desktop and AppStream validation passed. SBOM generator tests passed 2/2. +- Core tests include disposable restore/migration, session recovery, and daemon + duplicate-owner/restart checks. No live library restore was performed. + +The maintainer also launched Z-A and returned using Super+W on the previous +installed d33f849 build. Ryujinx's log confirms F5 paused emulation; its process +exited after window closure and the recorder closed a 130-second session. +Paused time remains counted while the emulator runs. This observation is useful +runtime evidence for that installation, not acceptance of this final candidate. + +## Library return follow-up + +The maintainer reported overlapping captions in Recent immediately after returning +from Z-A. An isolated sequence that hides the library, updates filters/layout, +and returns reproduced overlapping delegates. Forcing layout on visibility alone +did not resolve it; disabling the desktop GridView reuse pool did. Normal viewport +caching remains enabled and Home is unchanged. Regression fixtures cover repeated +hide/update/return, scrolling, window and cover-size changes, plus recording a +launch into Recent while Details hides the grid. + +The initial GitHub AI findings job failed because the account lacks a Copilot +license, before performing a review. This is separate from the regular CodeQL +checks. Do not count that failed job as a completed review. + +Follow-up validation: release build and 203/203 isolated CTests passed in 80.31 +seconds. This includes both library regressions, tooltip checks, Home wheel +checks, and thousand-game startup/navigation. A fresh staged install and isolated +smoke passed. The final fix only disables desktop delegate reuse; the ineffective +visibility relayout workaround was removed. Human acceptance of the new return +behavior is still pending. + +## Home polish follow-up + +The maintainer requested the focused pre-release polish pass. Home now removes +its introductory slogan and puts Quick access after the game shelves. The +featured game has separate Play and Details actions. Play uses the existing +preferred-installation launch path and opens details as the return surface. +Unavailable featured games retain Details as the navigation fallback. + +Home regression fixtures cover directional movement from Play to Details, +return focus, queue actions, filter restoration, and desktop/Couch layouts. +The new candidate needs a fresh manual check; acceptance of an earlier binary +and its CI/package results do not apply to this follow-up automatically. + +Manual acceptance: +- Home: Play, Details, Up Next, and Quick access with keyboard and controller. +- Details: launch prominence, narrow layout, artwork dialog, and Back focus. +- Library: Recent labels after returning, empty filters, Sources and Filters. +- Couch: readable controls and every dialog usable without a mouse. + +Emulator diagnostics remain stopped. Physical controller, emulator-return, +ARM64 hardware, and recorder/backup acceptance gates remain open. + +Validation for this follow-up: Release build passed; all 203 isolated CTests +passed in 79.52 seconds; fresh staged installation and isolated smoke passed. +Inspected refreshed Home renders at 600x800 and 1280x720. All 22 Home fixtures +passed, including desktop/Couch navigation, delayed layout, and wheel behavior. +Local logs: build/home-polish-{check,full-check,install,smoke}.log. + +## Startup artwork follow-up + +Reviewed screenrecording-2026-09-08_19-54-14.mp4. Cached covers appear, then most +cards revert to placeholders before the covers return. ConsolePortalModel was +emitting an all-fields data change even when a source rescan changed nothing. +That notification reaches LibraryFilterModel and invalidates the entire grid. +The model now emits only the actual changed fields on the affected console rows. +Unchanged scans emit nothing, preserving the existing cards and artwork. + +The regression repeats an unchanged ROM rescan three times. It failed before +this change with three unnecessary notifications. It requires no portal +notifications, library layout changes, or library resets after the fix. +This addresses a confirmed redraw trigger; the user's normal startup still +needs visual acceptance. No emulator tests or cache clearing were performed. + +Startup follow-up validation: Release build, 203/203 isolated CTests in 79.40 +seconds, fresh staged installation, and isolated smoke passed. Evidence is in +build/startup-{before,full-check,install,smoke}.log and +build/quality-evidence/startup-recording/contact.png. + +## Navigation and launch feedback follow-up + +The maintainer confirmed startup looked much better on c989a73 and requested +navigation and launch feedback polish. Home now restores the original Play or +Details action, and preserves card-action focus through Home refreshes. + +Play shows Opening before calling the existing launcher. A request snapshots +the chosen installation, dispatches after a 50 ms feedback frame, and suppresses +repeated presses until failure or a two-second post-dispatch cooldown. This is +launch-request feedback, not a claim that the game reached its title screen. +Errors remain beside Play, with keyboard/controller focus retained for retry. +Successful launch activity resolves the installation against the full library, +so changing filters during dispatch cannot record the wrong row or lose the +activity just because that game is no longer visible. + +Regression coverage checks duplicate suppression, immutable request identity, +failure/retry, cooldown expiry, launch recording through empty filters and linked +installations, Home Details focus return, and narrow/Couch error rendering. +No emulator was launched. Real launcher handoff and physical-controller +acceptance still require the maintainer's check of the final candidate. + +Validation: Release build passed; 205/205 isolated CTests passed in 80.27 seconds. +Fresh staged installation, isolated smoke, desktop entry and AppStream validation +passed. Inspected launch-error renders at 600x800 desktop and 1280x720 Couch. +Evidence: build/launch-nav-{focused,full,install,smoke}.log and +build/release/tests/launch-feedback-{desktop,couch}.png. + +Manual checklist for this follow-up: Home Details then Back returns to Details; +Home Play then Back returns to Play; keyboard/controller focus remains visible +through menus and dialogs; when normal game testing resumes, confirm one launch +per repeated press and readable feedback on an unavailable installation. diff --git a/docs/QUALITY-SWEEP.md b/docs/QUALITY-SWEEP.md new file mode 100644 index 0000000..edd1625 --- /dev/null +++ b/docs/QUALITY-SWEEP.md @@ -0,0 +1,142 @@ +# Omakade quality sweep, September 8, 2026 + +> Historical review and implementation record. For the reconciled September 8 +> candidate, push authorization, and remaining acceptance gates, see +> [PUBLICATION-CANDIDATE.md](PUBLICATION-CANDIDATE.md). Earlier local-only status +> and test counts below describe their original snapshots. + +Risk-based review of the local candidate after e3e2a20. This covers critical paths across the +app, not a claim that every line, device, provider, or failure state has been exhaustively tested. +All changes and evidence stay local. The installed application remains 1.7.1-3. + +## Fixed in this sweep + +| Priority | Finding | Change and evidence | +| --- | --- | --- | +| P1 | A new game reported for the same PID/start time inherited the previous active session. | Close the previous game at the observation boundary and begin a new session. Regression reproduced 90 seconds charged to game A instead of A=60, B=30; now passes. | +| P2 | Process discovery included readable processes owned by other users. | Limit discovery to the effective user. Regression requires the current process to appear and every surviving returned process to have the expected owner. | +| P2 | Many settings setters ignored save failure, leaving no indication that changes might disappear after restart. | Central save-failure signal and a visible toast. Regression forces a write failure with an occupied destination, checks notification, then verifies recovery and persistence. Live settings still apply in memory; this does not make every setter transactional. | +| P2 | Invalid JSON preserved cached metadata but did not set the selected game's error state. | Preserve the cached description and expose a refresh error on the detail page. Regression verifies both. | + +The session fix only separates games when the matcher reports a changed path. It cannot detect +an internal emulator game change that is invisible in the process arguments. + +## Follow-up fixes + +| Concern | Local change | +| --- | --- | +| Ambiguous identity | Removed popularity-based tie-breaking. Older automatic matches are rechecked; manual IDs stay protected. Exact provider aliases and localization names are recognized on the correct platform, with one bounded fallback query. Truncated result sets require review. | +| Lost regional context | Preserve the local title and original ROM filename alongside the provider title, aliases, localization region names/identifiers, and edition information. Candidate buttons show edition and ID. No game-specific numbering rules or country inference from alias comments. | +| Portrait changes | Keep the existing portrait when the catalog ID changes. Ambiguous identities cannot trigger new automatic artwork selection. | +| Backup coverage | Version 2 includes explicit IGDB choices, recorded sessions with stable IDs, baselines, and newer library preferences. Rating/popularity sorting no longer prevents export. Version 1 remains readable. See BACKUP-FORMAT.md for merge, replacement, recorder-lock, and exclusion rules. | +| Source cache starvation | Steam, RetroArch, Battle.net, and metadata portraits share the same eviction policy: protect referenced files and remove unused files first. A source cannot remove another source's files. The configured size is a soft target while artwork remains referenced. | +| Failed metadata writes | Return failure, stop the dependent workflow, preserve the committed payload, and retain the unsaved choice for an explicit retry. Pending writes are excluded from background refresh and protected from portrait pruning. | +| Failed session writes | Check schema preparation, progress, and closure writes. Keep failed closures with their original time boundary and retry at the normal flush interval. Report failure through daemon logging and the app's local notification channel. | +| Failed artwork writes | Steam and RetroArch share a transactional batch writer that retains pending changes on failure. Battle.net reports a failed update before replacing its model's saved path. | +| Short play sessions | Propagate precise seconds through sources, linked games, and console portals. Display minutes and sort by precise duration. Preserve the existing maximum-across-linked-installations behavior. | +| Duplicated policies | Extract shared cache eviction and artwork persistence helpers. Other scan policies and the large main.cpp test harness remain future maintenance work. | + +## Remaining limits and acceptance + +- Provider evidence is incomplete. Regional dates now prefer explicit ROM tags on the known + platform, with labeled platform/catalog fallbacks. Local titles remain unchanged; provider names + and localization evidence explain differences. Alias coverage cannot guarantee every ROM is matched. + Uncertain results require identification; descriptions for an uncertain cached ID remain visibly + flagged until confirmed. IGDB field references: https://api-docs.igdb.com/#alternative-name and + https://api-docs.igdb.com/#game-localization. +- Cache limits are soft while files remain referenced. This avoids blank cards and download churn, + but a strict global least-recently-used coordinator with visible-only protection remains future work. +- Play-history merge intentionally preserves existing history for an already tracked game path. + Restore requires stopping the recorder. Emulator saves and save states remain excluded. +- Pending writes exist in memory and can be lost if storage stays unavailable until exit. A failed + initial session insert is reported; the recorder resumes recording when a later poll can insert. + This is not a guarantee of complete tracking during a storage outage. +- Process arguments are still the observation source. Loading or closing a title inside an emulator + can be invisible. Wrapper handoff, game exit, and idle protection need adapter-specific runtime + testing; no broad process-descendant heuristic was added without that evidence. +- Physical controller repeat/reconnect, actual platform Tab dispatch, focus after emulator return, + and mixed mouse/controller use still need the manual pass in NAVIGATION-REVIEW.md. Automated + offscreen checks do not establish hardware acceptance. + +## Manual candidate checklist + +1. Confirm a known match and an ambiguous regional/edition match. Retry a selection, restart, + and confirm the chosen ID and artwork remain. Verify Paperboy and other NES portraits. +2. Check sub-hour playtime display and sorting, then a normal emulator session and exit. +3. Export a version 2 archive. Inspect its preview and exclusions. Test merge/replacement on + a disposable library with the recorder stopped, including an older archive. +4. Traverse library, details, organization, metadata, backup, settings, and dialogs with keyboard + and a physical pad. Check cancel/back, reconnect, and returning from a real emulator. + +## Subsystems inspected + +- Scanning and identity: ROM folder normalization, representative emulator/Steam import paths, + incomplete-scan guards, cached-library loading, and metadata candidate selection. +- Persistence and organization: settings serialization, linked-game transactions, bulk personal + state writes, saved filters, and metadata writes. +- Tracking and launch: process discovery/matching, session transitions/recovery, detached launch + tracking, path/argument construction, and idle-inhibition connection. +- Artwork and metadata networking: provider refresh states, response limits/timeouts, portrait + preservation, and cache pruning. +- Backup/restore: archive bounds, validation, snapshot transactions, table/settings coverage, + recovery journal checks, and atomic output paths. No live restore was performed. +- UI/navigation/theme: latest navigation changes and coverage, notification handling, duration + presentation, and theme watcher handling. Full UI tests ran again after these changes. + +Existing strengths include incomplete-scan preservation in inspected model paths, transactions +for linked and bulk personal data, bounded provider responses, atomic backup output, and isolated +backup recovery tests. These are specific observations, not whole-subsystem safety guarantees. + +## Verification + +- 132/132 CTest cases passed, including 11 new core regressions and expanded history/identity + interruption-recovery fixtures; private XDG and temporary + directories, offscreen rendering, disabled session DBus, and no live-app IPC/controller access. +- The installed SQLite database passed a read-only `quick_check`. +- Of 1,478 metadata records inspected, no referenced portrait file was missing at audit time. + This does not prove every source cover or every game identity is correct. +- A read-only live IGDB probe accepted the expanded fields and alias query: searching Starwing + on SNES returned Star Fox (ID 8581), with two aliases and two localizations. This verifies one + real provider response, not universal catalog coverage. +- Evidence: `build/quality-sweep/session-before.log`, `checks.log`, and `build.log`. +- No commits, tags, releases, messages, or assets were published. No candidate was installed. + +Follow-up evidence: build/quality-sweep/remaining-targeted.log, remaining-checks.log, and +remaining-build.log. These tests use private storage and disabled live-app IPC/controller access. +No follow-up candidate has been installed or published. + +## Regional details follow-up + +- Query and retain IGDB release date rows, including platform, territory, year, and provider date + text. Preserve partial dates such as a year or month instead of inventing a day. +- Extract recognized parenthesized region, language, and revision tags from the selected ROM + filename. Unknown tags remain in the original filename. Language does not imply country, and + multiple regions do not silently become one preferred region. +- Derive the selected installation's date at display time: earliest matching platform/region row, + then earliest platform row, then existing catalog date. Each fallback is labeled. SNES/Super + Famicom and NES/Famicom share the established platform families; remakes on other platforms + remain separate. The library's cached year remains the catalog year. +- Show catalog title, localized names and provider alias comments above the description. Suppress + acronym/capitalization/alternative-spelling noise in that display only. Do not rewrite the local + title or infer regional equivalence from description prose. Candidate buttons include territories + for release rows on the searched platform family. +- Payload version 5 refreshes older descriptions to acquire these fields while preserving manual + IDs and portraits. Region/date display is derived per installation rather than persisted as one + shared region for all installations. +- Captured real provider fixtures cover FF3 SNES versus FF3 Famicom, ambiguous FF2 SNES, + Starwing/Star Fox, and Paperboy NES portrait preservation. See tests/fixtures/regional-metadata. + Provider data is evidence, not an independent historical audit. Tests also cover missing-region, + multiple-region, different-platform, partial-date, and restart behavior. +- Detail rendering/navigation fixtures include regional evidence in desktop, couch, and narrow + layouts. Physical controller reconnect, mixed input, and real emulator return still require + local human acceptance; automated virtual input cannot establish those behaviors. + +Visual review also found the couch controller hints parented inside the content area. They now +anchor to the detail screen's reserved footer space. The navigation fixture checks that the +scroll viewport ends above the hints, including the 720p expanded-description case. + +Final regional candidate validation: development build succeeded; 132/132 CTest checks passed +in 50.97 seconds using private XDG/TMP directories, disabled session DBus, and offscreen software +rendering. Core tests: 182 passed, 0 failed, 1 skipped. Both regional regressions passed. Reviewed +600x800 desktop and 1280x720 couch screenshots. Evidence: build/quality-sweep/regional-build.log +and build/quality-sweep/regional-final-checks.log. No install or publication performed. diff --git a/docs/RECORDING-COVERAGE.md b/docs/RECORDING-COVERAGE.md new file mode 100644 index 0000000..a4b2830 --- /dev/null +++ b/docs/RECORDING-COVERAGE.md @@ -0,0 +1,31 @@ +# Recording coverage + +Profiles recognize emulator processes and game paths in command-line arguments. +A listed profile is not evidence that every launcher, wrapper, or internal game +change works. Recording does not inject code, control emulators, or detect pauses. + +| Area | Automated evidence | Remaining acceptance | +| --- | --- | --- | +| Ryujinx and Eden | Direct ROM arguments, paths with spaces, missing-path rejection | Exact candidate launch, return, and short-session accounting | +| Other shipped profiles | Shared matcher and profile parsing | Real launch arguments for RetroArch, PCSX2, Dolphin, Cemu, shadPS4 and other listed binaries | +| Session accounting | Monotonic duration, game changes in supplied snapshots, restart recovery, baseline overlap, write failure | Compare displayed time with a real short session | +| Recorder ownership | Private daemon start, duplicate rejection, termination and restart | Installed service behavior on supported hosts | +| Imported totals | Source parser and baseline fixtures | Updated emulator formats and unusual library layouts | + +The earlier local Ryujinx launch/return produced a closed session. That is +historical evidence, not acceptance of the current candidate. Game tests remain +paused at the maintainer's request. The Z-A freezes have not been attributed to +Omakade, LSFG, or focus changes. + +New configurations default to recording off. Existing explicit choices persist; +legacy configurations without the key retain the previous enabled default. +The preference controls recording and whether recorded totals contribute to the +display. Turning it off keeps the stored history. Daemon status is checked against +the owner of this library's recorder lock and the live process executable. It +reports a running process, not a guarantee that a particular game is recognized. + +Paused emulator time counts while its process remains matched. Imported and +recorded totals may overlap. Displayed time is the larger of the imported total +and the captured baseline plus recorded time. Loading games internally without a +recognizable command-line path is not covered. ARM64 hardware acceptance remains +separate from cross-architecture build and package checks. diff --git a/packaging/io.github.tsouth89.Omakade.metainfo.xml b/packaging/io.github.tsouth89.Omakade.metainfo.xml index a3176bb..a81d99c 100644 --- a/packaging/io.github.tsouth89.Omakade.metainfo.xml +++ b/packaging/io.github.tsouth89.Omakade.metainfo.xml @@ -26,6 +26,11 @@ + + +

Optional Home and Up Next, regional game details, unified artwork selection, session recording, and expanded personal-data backups.

+
+

Console libraries, redesigned settings, personal backups, and optional ratings and portrait covers.

diff --git a/packaging/omakade-sessiond.service b/packaging/omakade-sessiond.service new file mode 100644 index 0000000..ac3ab6e --- /dev/null +++ b/packaging/omakade-sessiond.service @@ -0,0 +1,11 @@ +[Unit] +Description=Omakade play session recorder +Documentation=https://github.com/btsouth/omakade + +[Service] +ExecStart=/usr/bin/omakade-sessiond +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/qml/Main.qml b/qml/Main.qml index 23839e4..57b8954 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -8,9 +8,14 @@ import "screens" ApplicationWindow { id: root + property var activeActionMenu: null property bool randomSelection: false property bool backupEditorOpen: false property bool bulkOrganizationOpen: false + property bool homeOpen: false + property var homeLibraryState: null + property string homeReturnIdentity: "" + property string homeReturnAction: "" property bool savedFiltersOpen: false property bool artworkEditorOpen: false property bool manualEditorOpen: false @@ -30,6 +35,17 @@ ApplicationWindow { root.refreshSelected(root.selectedGame.source, root.selectedGame.runner || "", root.selectedGame.appId) } } + Connections { + target: SessionRecorderStatus + function onTotalsChanged() { + if (!root.detailOpen) return + const chosen = root.launchIdentity(root.selectedInstallation) + if (root.refreshSelected(root.selectedGame.source, root.selectedGame.runner || "", root.selectedGame.appId)) { + for (const installation of root.selectedInstallations) + if (root.launchIdentity(installation) === chosen) root.selectedInstallation = installation + } + } + } property bool diagnosticsOpen: false property bool linkDialogOpen: false property bool collectionDeleteOpen: false @@ -70,25 +86,25 @@ ApplicationWindow { readonly property int ownedGameCount: SteamAccount ? SteamAccount.ownedGameCount : OwnedGameCountOverride - // Right from the end of the source row continues along the toolbar. - readonly property Item sourceRowNextButton: - randomGameButton.visible && randomGameButton.enabled ? randomGameButton - : consoleGamesButton.visible && consoleGamesButton.enabled ? consoleGamesButton : sortButton - readonly property Item sourceRowEndButton: - manualSourceButton.visible ? manualSourceButton - : dolphinSourceButton.visible && dolphinSourceButton.enabled ? dolphinSourceButton - : cemuSourceButton.visible && cemuSourceButton.enabled ? cemuSourceButton - : shadps4SourceButton.visible && shadps4SourceButton.enabled ? shadps4SourceButton - : ryujinxSourceButton.visible && ryujinxSourceButton.enabled ? ryujinxSourceButton - : pcsx2SourceButton.visible && pcsx2SourceButton.enabled ? pcsx2SourceButton - : retroArchSourceButton.visible && retroArchSourceButton.enabled ? retroArchSourceButton - : faugusSourceButton.visible && faugusSourceButton.enabled ? faugusSourceButton - : gogSourceButton.visible && gogSourceButton.enabled ? gogSourceButton - : heroicSourceButton.visible && heroicSourceButton.enabled ? heroicSourceButton - : lutrisSourceButton.visible && lutrisSourceButton.enabled ? lutrisSourceButton - : battleNetSourceButton.visible && battleNetSourceButton.enabled ? battleNetSourceButton - : steamSourceButton.visible && steamSourceButton.enabled ? steamSourceButton - : allSourcesButton + readonly property var activeLibraryFilters: { + const result = [] + for (const field of [ + {key: "completionFilter", label: "Status"}, {key: "collectionFilter", label: "Collection"}, + {key: "tagFilter", label: "Tag"}, {key: "genreFilter", label: "Genre"}, + {key: "decadeFilter", label: "Decade"}, {key: "platformFilter", label: "Platform"}]) { + const value = Library[field.key] + if (value) result.push({key: field.key, label: field.label + ": " + value, empty: ""}) + } + if (Library.mode === 3) result.push({key: "mode", label: "Hidden games", empty: 0}) + if (Library.availability !== 0) result.push({key: "availability", label: Library.availability === 1 ? "All owned games" : "Ready to install", empty: 0}) + return result + } + function clearContextFilters() { + if (Library.mode === 3) Library.mode = 0 + Library.completionFilter = ""; Library.collectionFilter = ""; Library.tagFilter = "" + Library.genreFilter = ""; Library.decadeFilter = ""; Library.platformFilter = "" + Library.availability = 0 + } function isWithin(item, container) { while (item) { @@ -100,7 +116,10 @@ ApplicationWindow { return false } + property bool returnToFilters: false function openFilterPicker(kind, values) { + returnToFilters = libraryFilters.opened + if (libraryFilters.opened) libraryFilters.close() filterPickerKind = kind filterPickerValues = values filterPickerOpen = true @@ -109,6 +128,9 @@ ApplicationWindow { function filterPickerCurrent() { return filterPickerKind === "status" ? Library.completionFilter : filterPickerKind === "collection" ? Library.collectionFilter + : filterPickerKind === "genre" ? Library.genreFilter + : filterPickerKind === "decade" ? Library.decadeFilter + : filterPickerKind === "platform" ? Library.platformFilter : Library.tagFilter } @@ -117,6 +139,12 @@ ApplicationWindow { Library.completionFilter = value } else if (filterPickerKind === "collection") { Library.collectionFilter = value + } else if (filterPickerKind === "genre") { + Library.genreFilter = value + } else if (filterPickerKind === "decade") { + Library.decadeFilter = value + } else if (filterPickerKind === "platform") { + Library.platformFilter = value } else { Library.tagFilter = value } @@ -125,6 +153,7 @@ ApplicationWindow { } function navigationContainer() { + if (activeActionMenu && activeActionMenu.opened) return activeActionMenu.contentItem if (coverSizePopup.opened) return coverSizePopup.contentItem if (couchTextEntryOpen) { return null @@ -149,6 +178,7 @@ ApplicationWindow { if (detailOpen && detailsLoader.item) { return detailsLoader.item } + if (homeOpen) return homeScreen return null } @@ -162,7 +192,8 @@ ApplicationWindow { if (!container) { return } - if (preferred && preferred.visible && preferred.enabled) { + if (preferred && root.isWithin(preferred, container) + && preferred.visible && preferred.enabled) { preferred.forceActiveFocus(forward ? Qt.TabFocusReason : Qt.BacktabFocusReason) revealNavigationItem(container, preferred) @@ -204,6 +235,7 @@ ApplicationWindow { const current = root.activeFocusItem if (container === backupEditor && backupEditor.navigate(current, key)) return true if (container === bulkOrganizationEditor && bulkOrganizationEditor.navigate(current, key)) return true + if (container === homeScreen && homeScreen.navigate(current, key)) return true if (container === savedFiltersEditor && savedFiltersEditor.navigate(current, key)) return true if (!root.isWithin(current, container)) { root.focusWithin(container, true) @@ -220,7 +252,8 @@ ApplicationWindow { && hops < 24; ++hops) { explicitTarget = explicitTarget[targetProperty] } - if (explicitTarget && explicitTarget.visible && explicitTarget.enabled) { + if (explicitTarget && root.isWithin(explicitTarget, container) + && explicitTarget.visible && explicitTarget.enabled) { explicitTarget.forceActiveFocus(Qt.TabFocusReason) root.revealNavigationItem(container, explicitTarget) return true @@ -340,6 +373,13 @@ ApplicationWindow { } } + function openLibrarySearch() { + if (root.activeActionMenu && root.activeActionMenu.opened) root.activeActionMenu.close() + root.homeOpen = false + if (root.couchMode) couchLibraryView.openSearch() + else Qt.callLater(searchField.forceActiveFocus) + } + function toggleLibraryControls() { if (root.couchTextEntryOpen || couchLibraryView.searchOpen) return if (root.navigationContainer() !== null) { @@ -374,8 +414,13 @@ ApplicationWindow { } function revealNavigationItem(container, item) { - if (container === bulkOrganizationEditor) { + if (root.activeActionMenu && container === root.activeActionMenu.contentItem) { + const scroll = container.navigationScrollView || container + if (root.isWithin(item, scroll)) root.revealInScrollView(scroll, item) + } else if (container === bulkOrganizationEditor) { bulkOrganizationEditor.reveal(item) + } else if (container === homeScreen) { + homeScreen.reveal(item) } else if (container === savedFiltersEditor) { savedFiltersEditor.reveal(item) } else if (container === settingsOverlay) { @@ -561,10 +606,12 @@ ApplicationWindow { function closeDetails() { detailOpen = false + if (homeLibraryState !== null) { Library.applyFilterState(homeLibraryState); homeLibraryState = null } Qt.callLater(root.focusLibrary) } function focusLibrary() { + if (root.homeOpen) { homeScreen.restoreIdentity(root.homeReturnIdentity, root.homeReturnAction); return } if (root.couchMode) { couchLibraryView.focusGrid() } else { @@ -589,10 +636,11 @@ ApplicationWindow { if (root.couchMode === enabled) { return } + root.returnToViewMenu = false + if (coverSizePopup.opened) coverSizePopup.close() + if (activeActionMenu && activeActionMenu.opened) activeActionMenu.close() if (!enabled) { - if (coverSizePopup.opened) { - coverSizePopup.close() - } else if (root.couchTextEntryOpen) { + if (root.couchTextEntryOpen) { root.closeCouchTextEntry(false) } if (couchLibraryView.searchOpen) { @@ -630,6 +678,11 @@ ApplicationWindow { setCouchMode(!root.couchMode) } + Connections { + target: Preferences + function onSaveFailed(message) { root.showToast(message) } + } + function showToast(message) { toast.message = message toastTimer.restart() @@ -646,6 +699,9 @@ ApplicationWindow { readonly property bool organizationFiltersActive: Library.completionFilter !== "" || Library.collectionFilter !== "" || Library.tagFilter !== "" + || Library.genreFilter !== "" + || Library.decadeFilter !== "" + || Library.platformFilter !== "" // Names the search or filter that produced an empty library, or returns "" when the // library itself is empty. @@ -653,11 +709,14 @@ ApplicationWindow { if (Library.searchText !== "") { return "No games match \"" + Library.searchText + "\"" } - const active = [Library.completionFilter, Library.collectionFilter, Library.tagFilter] + const active = [Library.completionFilter, Library.collectionFilter, Library.tagFilter, Library.genreFilter, Library.decadeFilter, Library.platformFilter] .filter(value => value !== "").length if (active > 1) { return "No games match these filters" } + if (Library.genreFilter || Library.decadeFilter || Library.platformFilter) { + return "No games match these filters" + } if (Library.completionFilter !== "") { return "No games marked " + Library.completionFilter.toUpperCase() } @@ -674,6 +733,9 @@ ApplicationWindow { Library.completionFilter = "" Library.collectionFilter = "" Library.tagFilter = "" + Library.genreFilter = "" + Library.decadeFilter = "" + Library.platformFilter = "" searchField.clear() libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 libraryView.focusGrid() @@ -703,28 +765,44 @@ ApplicationWindow { } } + readonly property bool launchMatchesSelection: launchFeedback.request.gameKey === launchIdentity(selectedGame) + && launchIdentity(launchFeedback.request.installation || {}) === launchIdentity(selectedInstallation) + + function launchIdentity(game) { + return JSON.stringify([game.source || "", game.runner || "", game.appId || ""]) + } + + LaunchFeedback { + id: launchFeedback + objectName: "launchFeedback" + onDispatchRequested: request => root.dispatchLaunch(request) + } + function playSelected() { - if (DemoMode) { - showToast("Demo games cannot be launched") - } else if (selectedInstallation.installed === false) { - if (Launcher.install(selectedInstallation.source, selectedInstallation.appId)) { - showToast("Opening Steam to install " + selectedGame.title) - } else { - showToast(Launcher.lastError) - } - } else if (Launcher.launch(selectedInstallation.source, selectedInstallation.appId, - selectedInstallation.flatpak || false, - selectedInstallation.runner || "", - selectedInstallation.installPath || "", - selectedInstallation.launchTarget || "")) { - Library.recordLaunch(selectedIndex, selectedInstallation.source, - selectedInstallation.runner || "", selectedInstallation.appId) - showToast("Opening " + selectedGame.title + " in " + selectedInstallation.source) - if (Preferences.closeAfterLaunch) { - Qt.callLater(Qt.quit) - } - } else { - showToast(Launcher.lastError) + if (launchFeedback.pending) { showToast(launchFeedback.message); return } + launchFeedback.begin({gameKey: launchIdentity(selectedGame), + title: selectedGame.title, installation: selectedInstallation}) + } + + function dispatchLaunch(request) { + const choice = request.installation + const installing = choice.installed === false + let okay = false + if (!DemoMode) { + okay = installing ? Launcher.install(choice.source, choice.appId) + : Launcher.launch(choice.source, choice.appId, choice.flatpak || false, + choice.runner || "", choice.installPath || "", choice.launchTarget || "") + } + const message = okay + ? (installing ? "Opening Steam to install " : "Opening ") + request.title + + (installing ? "" : " in " + choice.source) + : (DemoMode ? "Demo games cannot be launched" : Launcher.lastError || "Could not open this game. Try again.") + launchFeedback.finish(okay, message) + showToast(message) + if (okay && !installing) { + // Filters or selection may have changed during the feedback frame. + Library.recordLaunchByIdentity(choice.source, choice.runner || "", choice.appId) + if (Preferences.closeAfterLaunch) Qt.callLater(Qt.quit) } } @@ -795,7 +873,20 @@ ApplicationWindow { onAccepted: Preferences.addRomFolder(selectedFolder, root.romFolderSystems[root.romFolderSystemIndex].id) } + property var libraryEditorInvoker: null + function dismissLibraryEditor(kind) { + if (kind === "bulk") { + Library.clearSelection() + root.bulkOrganizationOpen = false + } else root.savedFiltersOpen = false + const invoker = root.libraryEditorInvoker + root.libraryEditorInvoker = null + if (invoker && invoker.visible && invoker.enabled) root.restoreFocus(invoker) + else Qt.callLater(root.focusCurrentSurface) + } + function openBulkOrganization() { + root.libraryEditorInvoker = root.activeFocusItem Library.clearSelection() root.bulkOrganizationOpen = true Qt.callLater(bulkOrganizationEditor.focusEditor) @@ -807,15 +898,12 @@ ApplicationWindow { z: 87 visible: root.bulkOrganizationOpen couchMode: root.couchMode - onDismissed: { - Library.clearSelection() - root.bulkOrganizationOpen = false - Qt.callLater(root.focusCurrentSurface) - } + onDismissed: root.dismissLibraryEditor("bulk") onTextEntryRequested: (target, title) => root.openCouchTextEntry(target, title, false, "") } function openSavedFilters() { + root.libraryEditorInvoker = root.activeFocusItem root.savedFiltersOpen = true Qt.callLater(savedFiltersEditor.focusEditor) } @@ -829,6 +917,7 @@ ApplicationWindow { couchLibraryView.currentIndex = index couchLibraryView.refreshCurrentGame() root.savedFiltersOpen = false + root.libraryEditorInvoker = null if (Library.savedFilterMessage) root.showToast(Library.savedFilterMessage) Qt.callLater(root.focusCurrentSurface) } @@ -840,11 +929,21 @@ ApplicationWindow { visible: root.savedFiltersOpen couchMode: root.couchMode onApplyRequested: id => root.applySavedFilter(id) - onDismissed: { root.savedFiltersOpen = false; Qt.callLater(root.focusCurrentSurface) } + onDismissed: root.dismissLibraryEditor("saved") onTextEntryRequested: (target, title) => root.openCouchTextEntry(target, title, false, "") } + property var editorInvokers: ({}) + function rememberEditor(kind) { editorInvokers[kind] = root.activeFocusItem } + function dismissEditor(kind) { + root[kind + "EditorOpen"] = false + const invoker = editorInvokers[kind] + delete editorInvokers[kind] + if (invoker && invoker.visible && invoker.enabled) root.restoreFocus(invoker) + else Qt.callLater(root.focusCurrentSurface) + } function openBackupEditor() { + rememberEditor("backup") backupEditorOpen = true Qt.callLater(backupEditor.focusEditor) } @@ -860,11 +959,12 @@ ApplicationWindow { z: 89 visible: root.backupEditorOpen couchMode: root.couchMode - onDismissed: { root.backupEditorOpen = false; Qt.callLater(root.focusCurrentSurface) } + onDismissed: root.dismissEditor("backup") onTextEntryRequested: (target, title) => root.openCouchTextEntry(target, title, false, "") } function editArtwork() { + rememberEditor("artwork") artworkEditor.message = "" root.artworkEditorOpen = true Qt.callLater(artworkEditor.focusEditor) @@ -878,15 +978,13 @@ ApplicationWindow { game: root.selectedGame gameRow: root.selectedIndex couchMode: root.couchMode - onDismissed: { - root.artworkEditorOpen = false - Qt.callLater(root.focusCurrentSurface) - } + onDismissed: root.dismissEditor("artwork") onArtworkChanged: root.refreshAfterOrganization() onTextEntryRequested: (target, title) => root.openCouchTextEntry(target, title, false, "") } function editManualGame(id) { + rememberEditor("manual") manualEditorOpen = true manualEditor.loadDraft(id ? ManualLibrary.get(id) : {}) } @@ -899,23 +997,19 @@ ApplicationWindow { visible: root.manualEditorOpen couchMode: root.couchMode onTextEntryRequested: (target, title) => root.openCouchTextEntry(target, title, false, "") - onDismissed: { - root.manualEditorOpen = false - Qt.callLater(root.focusCurrentSurface) - } + onDismissed: root.dismissEditor("manual") onSaved: function(id) { root.manualEditorOpen = false root.diagnosticsOpen = false if (manualEditor.entryId === "") root.clearLibraryFilters() const row = Library.indexOf("Manual", "", id) if (row >= 0) root.openGame(row) - else { root.detailOpen = false; Qt.callLater(root.focusLibrary) } + else root.closeDetails() root.showToast("Manual game saved") } onRemoved: { root.manualEditorOpen = false - root.detailOpen = false - Qt.callLater(root.focusCurrentSurface) + root.closeDetails() root.showToast("Removed from Omakade. Game files were kept.") } } @@ -958,10 +1052,10 @@ ApplicationWindow { Shortcut { sequence: "Ctrl+F" - enabled: !root.couchTextEntryOpen && !root.couchMode && !root.detailOpen && !root.diagnosticsOpen - && !root.linkDialogOpen - && !root.collectionDeleteOpen - onActivated: searchField.forceActiveFocus() + enabled: !root.couchTextEntryOpen && !root.detailOpen + && (root.navigationContainer() === null || root.navigationContainer() === homeScreen + || (root.activeActionMenu && root.activeActionMenu.opened)) + onActivated: root.openLibrarySearch() } Shortcut { sequence: "F11" @@ -978,7 +1072,11 @@ ApplicationWindow { Shortcut { sequence: "Ctrl+D" enabled: !root.couchTextEntryOpen && !couchLibraryView.searchOpen && !root.linkDialogOpen && !root.collectionDeleteOpen - onActivated: root.diagnosticsOpen = !root.diagnosticsOpen + && !root.backupEditorOpen && !root.manualEditorOpen && !root.artworkEditorOpen && !root.bulkOrganizationOpen && !root.savedFiltersOpen + onActivated: { + if (root.activeActionMenu && root.activeActionMenu.opened) root.activeActionMenu.close() + root.diagnosticsOpen = !root.diagnosticsOpen + } } Shortcut { sequence: "F6" @@ -986,11 +1084,13 @@ ApplicationWindow { onActivated: root.toggleLibraryControls() } Shortcut { + objectName: "navigationTabForward" sequence: "Tab" enabled: root.navigationContainer() !== null onActivated: root.focusWithin(root.navigationContainer(), true) } Shortcut { + objectName: "navigationTabBackward" sequence: "Shift+Tab" enabled: root.navigationContainer() !== null onActivated: root.focusWithin(root.navigationContainer(), false) @@ -1018,25 +1118,22 @@ ApplicationWindow { Shortcut { sequence: "Escape" onActivated: { - if (coverSizePopup.opened) { + if (activeActionMenu && activeActionMenu.opened) { + activeActionMenu.close() + } else if (coverSizePopup.opened) { coverSizePopup.close() } else if (root.couchTextEntryOpen) { root.closeCouchTextEntry(false) } else if (root.backupEditorOpen) { backupEditor.dismiss() } else if (root.bulkOrganizationOpen) { - Library.clearSelection() - root.bulkOrganizationOpen = false - Qt.callLater(root.focusCurrentSurface) + root.dismissLibraryEditor("bulk") } else if (root.savedFiltersOpen) { - root.savedFiltersOpen = false - Qt.callLater(root.focusCurrentSurface) + root.dismissLibraryEditor("saved") } else if (root.artworkEditorOpen) { - root.artworkEditorOpen = false - Qt.callLater(root.focusCurrentSurface) + root.dismissEditor("artwork") } else if (root.manualEditorOpen) { - root.manualEditorOpen = false - Qt.callLater(root.focusCurrentSurface) + root.dismissEditor("manual") } else if (root.filterPickerOpen) { root.filterPickerOpen = false } else if (root.couchMode && couchLibraryView.searchOpen) { @@ -1056,6 +1153,9 @@ ApplicationWindow { detailsLoader.item.closeCollectionEditor() } else if (root.detailOpen) { root.closeDetails() + } else if (root.homeOpen) { + root.homeOpen = false + Qt.callLater(root.focusLibrary) } else if (root.stepBackFilter()) { if (!root.couchMode) { libraryView.focusGrid() @@ -1139,7 +1239,7 @@ ApplicationWindow { anchors.fill: parent opacity: root.detailOpen ? 0 : 1 scale: root.detailOpen ? 0.985 : 1 - visible: !root.couchMode && opacity > 0 + visible: !root.homeOpen && !root.couchMode && opacity > 0 enabled: !root.couchMode && !root.detailOpen // Arrow keys move between the filters and toolbar controls, and Down with nothing @@ -1213,8 +1313,41 @@ ApplicationWindow { } } + GlassButton { + objectName: "openHomeButton" + text: "HOME"; compact: true + onClicked: { root.homeOpen = true; Qt.callLater(homeScreen.focusHome) } + } + GlassButton { + objectName: "libraryDestinationButton" + text: "LIBRARY"; compact: true; selected: true + onClicked: libraryView.focusGrid() + } Item { Layout.fillWidth: true } + GlassButton { + id: settingsButton + objectName: "settingsButton" + text: "SETTINGS" + compact: true + onClicked: root.diagnosticsOpen = true + } + + GlassButton { + id: couchModeButton + objectName: "couchModeButton" + text: "COUCH" + compact: true + onClicked: root.setCouchMode(true) + } + } + + GridLayout { + objectName: "libraryQueryBar" + Layout.fillWidth: true + columns: root.width < 720 ? 1 : 2 + columnSpacing: 12 + rowSpacing: 8 Row { spacing: 5 visible: root.width >= 1040 @@ -1222,7 +1355,7 @@ ApplicationWindow { GlassButton { id: allModeButton objectName: "allModeButton" - property Item controllerDownTarget: root.sourceRowEndButton + property Item controllerDownTarget: sourcesMenuButton text: "ALL" compact: true selected: Library.mode === 0 @@ -1233,7 +1366,7 @@ ApplicationWindow { GlassButton { id: favoritesModeButton objectName: "favoritesModeButton" - property Item controllerDownTarget: root.sourceRowEndButton + property Item controllerDownTarget: sourcesMenuButton text: "FAVORITES" compact: true selected: Library.mode === 1 @@ -1244,7 +1377,7 @@ ApplicationWindow { GlassButton { id: recentModeButton objectName: "recentModeButton" - property Item controllerDownTarget: root.sourceRowEndButton + property Item controllerDownTarget: sourcesMenuButton text: "RECENT" compact: true selected: Library.mode === 2 @@ -1252,26 +1385,48 @@ ApplicationWindow { Library.mode = 2 } } + + } + RowLayout { + Layout.fillWidth: true + visible: root.width < 1040 + spacing: 6 GlassButton { - id: hiddenModeButton - objectName: "hiddenModeButton" - property Item controllerDownTarget: root.sourceRowEndButton - text: "HIDDEN" + id: narrowAllModeButton + objectName: "narrowAllModeButton" + text: "ALL" compact: true - visible: !DemoMode - selected: Library.mode === 3 + selected: Library.mode === 0 onClicked: { - Library.mode = 3 + Library.mode = 0 } } - } + GlassButton { + text: "FAVORITES" + compact: true + selected: Library.mode === 1 + onClicked: { + Library.mode = 1 + } + } + GlassButton { + text: "RECENT" + compact: true + selected: Library.mode === 2 + onClicked: { + Library.mode = 2 + } + } + + } TextField { id: searchField objectName: "searchField" property bool controllerNavigation: TextEntry.keyboardNeeded - Layout.preferredWidth: root.width < 900 ? 150 : Math.min(300, root.width * 0.26) - Layout.minimumWidth: root.width < 900 ? 150 : 190 + Layout.fillWidth: true + Layout.preferredWidth: 220 + Layout.minimumWidth: 140 Layout.preferredHeight: 38 placeholderText: "Search games" color: Theme.foreground @@ -1282,12 +1437,13 @@ ApplicationWindow { rightPadding: searchFieldClear.visible ? searchFieldClear.reservedWidth : 12 selectByMouse: true focus: false + property Item controllerUpTarget: root.width < 720 ? narrowAllModeButton : null property Item controllerRightTarget: searchFieldClear.visible ? searchFieldClear : null FieldClearButton { id: searchFieldClear; field: searchField } Keys.onReturnPressed: event => root.handleCouchTextEntry(event, searchField, "SEARCH GAMES", false, "Search games") Keys.onEnterPressed: event => root.handleCouchTextEntry(event, searchField, "SEARCH GAMES", false, "Search games") Accessible.name: "Search games" - Accessible.description: "Filter the installed game library" + Accessible.description: "Search the current game library" onTextChanged: { Library.searchText = text @@ -1324,617 +1480,74 @@ ApplicationWindow { font.pixelSize: 15 } } - - GlassButton { - objectName: "bulkOrganizationButton" - text: "ORGANIZE" - compact: true - onClicked: root.openBulkOrganization() - } - GlassButton { - objectName: "savedFiltersButton" - text: "SAVED FILTERS" - compact: true - onClicked: root.openSavedFilters() - } - GlassButton { - id: settingsButton - objectName: "settingsButton" - text: "SETTINGS" - compact: true - onClicked: root.diagnosticsOpen = true - } - - GlassButton { - id: couchModeButton - objectName: "couchModeButton" - text: "COUCH" - compact: true - onClicked: root.setCouchMode(true) - } - } - - RowLayout { - Layout.fillWidth: true - visible: root.width < 1040 - spacing: 6 - GlassButton { - id: narrowAllModeButton - objectName: "narrowAllModeButton" - text: "ALL" - compact: true - selected: Library.mode === 0 - onClicked: { - Library.mode = 0 - } - } - GlassButton { - text: "FAVORITES" - compact: true - selected: Library.mode === 1 - onClicked: { - Library.mode = 1 - } - } - GlassButton { - text: "RECENT" - compact: true - selected: Library.mode === 2 - onClicked: { - Library.mode = 2 - } - } - GlassButton { - id: narrowHiddenModeButton - objectName: "narrowHiddenModeButton" - property Item controllerDownTarget: root.sourceRowEndButton - text: "HIDDEN" - compact: true - visible: !DemoMode - selected: Library.mode === 3 - onClicked: { - Library.mode = 3 - } - } - Item { Layout.fillWidth: true } } - RowLayout { + Flow { Layout.fillWidth: true - spacing: 12 - - Flickable { - id: sourceFlickable - objectName: "sourceFlickable" - Layout.fillWidth: true - Layout.minimumWidth: 80 - Layout.preferredHeight: sourceButtonsRow.implicitHeight - visible: !DemoMode - clip: true - contentWidth: sourceButtonsRow.implicitWidth - contentHeight: sourceButtonsRow.implicitHeight - boundsBehavior: Flickable.StopAtBounds - - function reveal(item) { - if (!item || !root.isWithin(item, sourceButtonsRow) - || contentWidth <= width) { - return - } - const position = item.mapToItem(sourceButtonsRow, 0, 0) - const margin = 5 - if (position.x < contentX + margin) { - contentX = Math.max(0, position.x - margin) - } else if (position.x + item.width > contentX + width - margin) { - contentX = Math.min(contentWidth - width, - position.x + item.width - width + margin) - } - } - - Connections { - target: root - function onActiveFocusItemChanged() { - sourceFlickable.reveal(root.activeFocusItem) - } - } - - Row { - id: sourceButtonsRow - spacing: 5 - GlassButton { - id: allSourcesButton - objectName: "allSourcesButton" - property Item controllerDownTarget: root.ownedGameCount > 0 - ? installedAvailabilityButton - : statusFilterButton - text: "ALL SOURCES" - compact: true - selected: Library.sourceFilters.length === 0 - onClicked: { - Library.sourceFilters = [] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: emulatedSourcesButton - objectName: "emulatedSourcesButton" - property Item controllerLeftTarget: allSourcesButton - property Item controllerRightTarget: steamSourceButton - property Item controllerDownTarget: statusFilterButton - text: "EMULATED" - compact: true - property string sourceName: "Emulated" - selected: Library.emulatorSources.every(source => Library.sourceFilters.indexOf(source) >= 0) - onClicked: { - Library.sourceFilters = Library.emulatorSources - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSources(Library.emulatorSources) - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: steamSourceButton - objectName: "steamSourceButton" - text: "STEAM" - compact: true - visible: Preferences.steamEnabled - property string sourceName: "Steam" - selected: Library.sourceFilters.indexOf("Steam") >= 0 - onClicked: { - Library.sourceFilters = ["Steam"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("Steam") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: battleNetSourceButton - objectName: "battleNetSourceButton" - text: "BATTLE.NET" - compact: true - visible: Preferences.battleNetEnabled - property string sourceName: "Battle.net" - selected: Library.sourceFilters.indexOf("Battle.net") >= 0 - onClicked: { - Library.sourceFilters = ["Battle.net"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("Battle.net") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: lutrisSourceButton - objectName: "lutrisSourceButton" - text: "LUTRIS" - compact: true - visible: Preferences.lutrisEnabled - property string sourceName: "Lutris" - selected: Library.sourceFilters.indexOf("Lutris") >= 0 - onClicked: { - Library.sourceFilters = ["Lutris"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("Lutris") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: heroicSourceButton - objectName: "heroicSourceButton" - text: "HEROIC" - compact: true - visible: Preferences.heroicEnabled - property string sourceName: "Heroic" - selected: Library.sourceFilters.indexOf("Heroic") >= 0 - onClicked: { - Library.sourceFilters = ["Heroic"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("Heroic") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: gogSourceButton - objectName: "gogSourceButton" - text: "GOG" - compact: true - visible: Preferences.gogEnabled - property string sourceName: "GOG" - selected: Library.sourceFilters.indexOf("GOG") >= 0 - onClicked: { - Library.sourceFilters = ["GOG"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("GOG") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: faugusSourceButton - objectName: "faugusSourceButton" - text: "FAUGUS" - compact: true - visible: Preferences.faugusEnabled - property string sourceName: "Faugus" - selected: Library.sourceFilters.indexOf("Faugus") >= 0 - onClicked: { - Library.sourceFilters = ["Faugus"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("Faugus") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: retroArchSourceButton - objectName: "retroArchSourceButton" - property Item controllerRightTarget: pcsx2SourceButton - text: "RETROARCH" - compact: true - visible: Preferences.retroArchEnabled - property string sourceName: "RetroArch" - selected: Library.sourceFilters.indexOf("RetroArch") >= 0 - onClicked: { - Library.sourceFilters = ["RetroArch"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("RetroArch") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: pcsx2SourceButton - objectName: "pcsx2SourceButton" - property Item controllerLeftTarget: retroArchSourceButton - property Item controllerRightTarget: ryujinxSourceButton - property Item controllerDownTarget: statusFilterButton - text: "PCSX2" - compact: true - visible: Preferences.pcsx2Enabled - property string sourceName: "PCSX2" - selected: Library.sourceFilters.indexOf("PCSX2") >= 0 - onClicked: { - Library.sourceFilters = ["PCSX2"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("PCSX2") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: ryujinxSourceButton - objectName: "ryujinxSourceButton" - property Item controllerLeftTarget: pcsx2SourceButton - property Item controllerRightTarget: shadps4SourceButton - property Item controllerDownTarget: statusFilterButton - text: "RYUJINX" - compact: true - visible: Preferences.ryujinxEnabled - property string sourceName: "Ryujinx" - selected: Library.sourceFilters.indexOf("Ryujinx") >= 0 - onClicked: { - Library.sourceFilters = ["Ryujinx"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("Ryujinx") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: shadps4SourceButton - objectName: "shadps4SourceButton" - property Item controllerLeftTarget: ryujinxSourceButton - property Item controllerRightTarget: cemuSourceButton - property Item controllerDownTarget: statusFilterButton - text: "SHADPS4" - compact: true - visible: Preferences.shadps4Enabled - property string sourceName: "shadPS4" - selected: Library.sourceFilters.indexOf("shadPS4") >= 0 - onClicked: { - Library.sourceFilters = ["shadPS4"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("shadPS4") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: cemuSourceButton - objectName: "cemuSourceButton" - property Item controllerLeftTarget: shadps4SourceButton - property Item controllerRightTarget: dolphinSourceButton - property Item controllerDownTarget: statusFilterButton - text: "CEMU" - compact: true - visible: Preferences.cemuEnabled - property string sourceName: "Cemu" - selected: Library.sourceFilters.indexOf("Cemu") >= 0 - onClicked: { - Library.sourceFilters = ["Cemu"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("Cemu") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: dolphinSourceButton - objectName: "dolphinSourceButton" - property Item controllerLeftTarget: cemuSourceButton - property Item controllerRightTarget: manualSourceButton - property Item controllerDownTarget: statusFilterButton - text: "DOLPHIN" - compact: true - visible: Preferences.dolphinEnabled - property string sourceName: "Dolphin" - selected: Library.sourceFilters.indexOf("Dolphin") >= 0 - onClicked: { - Library.sourceFilters = ["Dolphin"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("Dolphin") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - GlassButton { - id: manualSourceButton - objectName: "manualSourceButton" - property Item controllerLeftTarget: dolphinSourceButton - property Item controllerRightTarget: root.sourceRowNextButton - property Item controllerDownTarget: statusFilterButton - text: "MANUAL" - compact: true - visible: ManualLibrary.count > 0 - property string sourceName: "Manual" - selected: Library.sourceFilters.indexOf("Manual") >= 0 - onClicked: { - Library.sourceFilters = ["Manual"] - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - onSecondaryClicked: { - Library.toggleSource("Manual") - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } - } - } - } - - Text { - visible: root.width >= 1100 - text: Library.consoleTitle.length > 0 - ? "LIBRARY / " + Library.consoleTitle.toUpperCase() - : Library.mode === 1 ? "FAVORITES" : Library.mode === 2 ? "RECENTLY PLAYED" : Library.mode === 3 ? "HIDDEN" : "YOUR LIBRARY" - color: Theme.foreground - font.family: Theme.fontFamily - font.pixelSize: 11 - font.weight: Font.DemiBold - font.letterSpacing: 0.7 - } - Text { - visible: root.width >= 1100 - text: libraryView.count - + (DemoMode ? " GAMES" - : Library.availability === 0 ? " INSTALLED" - : Library.availability === 2 ? " READY TO INSTALL" - : " GAMES") - color: Theme.mutedText - font.family: Theme.fontFamily - font.pixelSize: 9 - } - Text { - visible: root.width >= 1100 && root.libraryScanning - text: "SYNCING" - color: Theme.accent - font.family: Theme.fontFamily - font.pixelSize: 9 - font.weight: Font.DemiBold - } - Item { Layout.fillWidth: true } + spacing: 8 GlassButton { - id: randomGameButton - objectName: "randomGameButton" - property Item controllerLeftTarget: root.width < 1040 - ? root.sourceRowEndButton - : hiddenModeButton - property Item controllerRightTarget: consoleGamesButton.visible && consoleGamesButton.enabled - ? consoleGamesButton : sortButton - compact: true - text: "PICK A GAME" - onClicked: root.pickRandomGame() + id: sourcesMenuButton; objectName: "sourcesMenuButton" + compact: true; text: Library.sourceFilters.length ? "SOURCES (" + Library.sourceFilters.length + ")" : "SOURCES" + selected: Library.sourceFilters.length > 0 + onClicked: librarySources.open() } GlassButton { - id: consoleGamesButton - objectName: "consoleGamesButton" - // Every console system follows this view unless explicitly overridden. - visible: Library.hasConsoleCards || Library.expandConsoles - property Item controllerLeftTarget: randomGameButton - property Item controllerRightTarget: sortButton - compact: true - selected: Library.expandConsoles - text: Library.expandConsoles ? "CONSOLE VIEW: GAMES" : "CONSOLE VIEW: CONSOLES" - onClicked: { - Library.expandConsoles = !Library.expandConsoles - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } + id: filtersMenuButton; objectName: "filtersMenuButton" + compact: true; text: root.activeLibraryFilters.length ? "FILTERS (" + root.activeLibraryFilters.length + ")" : "FILTERS" + selected: root.activeLibraryFilters.length > 0 + onClicked: libraryFilters.open() } GlassButton { id: sortButton + property Item controllerLeftTarget: filtersMenuButton + property Item controllerRightTarget: viewMenuButton objectName: "sortButton" - property Item controllerLeftTarget: consoleGamesButton.visible ? consoleGamesButton - : randomGameButton - property Item controllerRightTarget: coverSizeButton compact: true text: Library.sortMode === 0 ? "SORT: TITLE" : Library.sortMode === 1 ? "SORT: RECENT" : Library.sortMode === 2 ? "SORT: PLAYTIME" : Library.sortMode === 3 ? "SORT: RATING" : "SORT: POPULARITY" - onClicked: Library.sortMode = (Library.sortMode + 1) % 5 + onClicked: librarySort.open() } GlassButton { - id: coverSizeButton - property Item controllerDownTarget: statusFilterButton.visible ? statusFilterButton : libraryView.navigationTarget - objectName: "coverSizeButton" - property Item controllerLeftTarget: sortButton - property Item controllerRightTarget: rescanButton - property Item controllerUpTarget: settingsButton - compact: true; text: "COVER SIZE" - onClicked: coverSizePopup.open() + id: viewMenuButton; objectName: "viewMenuButton" + text: "VIEW"; compact: true + onClicked: libraryViewMenu.open() } GlassButton { - id: rescanButton - objectName: "rescanButton" - property Item controllerLeftTarget: coverSizeButton + id: libraryMoreButton + property Item controllerLeftTarget: viewMenuButton property Item controllerUpTarget: settingsButton - compact: true - text: root.libraryScanning ? "SCANNING" : "RESCAN" - enabled: !root.libraryScanning - onClicked: root.rescanLibraries() + objectName: "libraryMoreButton" + text: "MORE"; compact: true + onClicked: libraryActions.open() } Text { - readonly property bool sourceChipFocused: root.activeFocusItem - && root.activeFocusItem.sourceName !== undefined - text: sourceChipFocused - ? (Controller.connected - ? Controller.primaryGlyph + " SELECT · " + Controller.favoriteGlyph + " ADD / REMOVE · " + Controller.backGlyph + " BACK" - : "ENTER SELECT · SHIFT+ENTER ADD / REMOVE") - : Controller.connected - ? Controller.primaryGlyph + " OPEN · " + Controller.favoriteGlyph + " FAVORITE · " + Controller.toolbarGlyph + " CONTROLS · " + Controller.backGlyph + " BACK" - : "ENTER OPEN · F FAVORITE · F6 CONTROLS" - color: root.alpha(Theme.foreground, 0.42) - font.family: Theme.fontFamily - font.pixelSize: 8 - // The hints are a fixed-width string; on tiled windows they - // starve the source chips, so they only appear with room to spare. - visible: root.width >= 1560 + text: root.libraryScanning ? "SCANNING…" : libraryView.count + " GAMES" + color: Theme.mutedText; font.family: Theme.fontFamily + font.pixelSize: 11 + height: 34; verticalAlignment: Text.AlignVCenter } - } - RowLayout { - Layout.fillWidth: true - visible: !DemoMode && root.ownedGameCount > 0 - spacing: 6 - - Text { - text: "AVAILABILITY" - color: Theme.mutedText - font.family: Theme.fontFamily - font.pixelSize: 9 - font.weight: Font.DemiBold - } - GlassButton { - id: installedAvailabilityButton - objectName: "installedAvailabilityButton" - compact: true - text: "INSTALLED" - selected: Library.availability === 0 - onClicked: { - Library.availability = 0 - } - } - GlassButton { - compact: true - text: "ALL GAMES" - selected: Library.availability === 1 - onClicked: { - Library.availability = 1 - } - } - GlassButton { - id: readyAvailabilityButton - objectName: "readyAvailabilityButton" - property Item controllerDownTarget: statusFilterButton - compact: true - text: "READY TO INSTALL" - selected: Library.availability === 2 - onClicked: { - Library.availability = 2 - } - } - Item { Layout.fillWidth: true } } - RowLayout { + Flow { Layout.fillWidth: true - visible: !DemoMode spacing: 6 - - Text { - text: "ORGANIZE" - color: Theme.mutedText - font.family: Theme.fontFamily - font.pixelSize: 9 - font.weight: Font.DemiBold - } - GlassButton { - id: statusFilterButton - objectName: "statusFilterButton" - property Item controllerDownTarget: libraryView.focusTarget - compact: true - text: root.filterLabel("STATUS", Library.completionFilter) - selected: Library.completionFilter !== "" - onClicked: root.openFilterPicker("status", - ["backlog", "playing", "completed", "abandoned"]) - } - GlassButton { - id: collectionFilterButton - objectName: "collectionFilterButton" - property Item controllerDownTarget: libraryView.focusTarget - compact: true - text: root.filterLabel("COLLECTION", Library.collectionFilter, - Library.collectionNames) - selected: Library.collectionFilter !== "" - onClicked: { - if (Library.collectionNames.length === 0) { - root.showToast("No collections yet. Open a game and use + New Collection.") - return - } - root.openFilterPicker("collection", Library.collectionNames) - } - } - GlassButton { - id: tagFilterButton - objectName: "tagFilterButton" - property Item controllerDownTarget: libraryView.focusTarget - compact: true - text: root.filterLabel("TAG", Library.tagFilter, Library.tagNames) - selected: Library.tagFilter !== "" - onClicked: { - if (Library.tagNames.length === 0) { - root.showToast("No tags yet. Open a game and add tags under Organize.") - return - } - root.openFilterPicker("tag", Library.tagNames) + visible: root.activeLibraryFilters.length > 0 + Repeater { + model: root.activeLibraryFilters + GlassButton { + required property var modelData + compact: true + maximumLabelWidth: Math.max(80, librarySurface.width - 100) + text: modelData.label + " ×" + Accessible.name: "Remove " + modelData.label + " filter" + onClicked: { Library[modelData.key] = modelData.empty; Qt.callLater(filtersMenuButton.forceActiveFocus) } } } GlassButton { - compact: true - visible: Library.completionFilter !== "" || Library.collectionFilter !== "" - || Library.tagFilter !== "" - text: "CLEAR" - onClicked: { - Library.completionFilter = "" - Library.collectionFilter = "" - Library.tagFilter = "" - libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 - } + text: "CLEAR FILTERS"; compact: true + onClicked: { root.clearContextFilters(); filtersMenuButton.forceActiveFocus() } } - Item { Layout.fillWidth: true } } - RowLayout { Layout.fillWidth: true visible: Library.consoleTitle.length > 0 @@ -2043,11 +1656,62 @@ ApplicationWindow { } } + Binding { target: Home; property: "active"; value: root.homeOpen } + HomeScreen { + id: homeScreen + objectName: "homeScreen" + launchBusy: launchFeedback.pending && launchFeedback.request.gameKey === root.launchIdentity(homeScreen.featured) + anchors.fill: parent + visible: root.homeOpen && !root.detailOpen + couchMode: root.couchMode + onLibraryRequested: { root.homeOpen = false; Qt.callLater(root.focusLibrary) } + onBrowseRequested: (kind, value) => { + if (kind === "saved") { + if (Library.applySavedFilter(value)) { + root.homeOpen = false + Qt.callLater(root.focusLibrary) + } else root.showToast(Library.savedFilterMessage || "Could not open saved view") + return + } + root.clearLibraryFilters() + Library.searchText = "" + Library.sourceFilters = kind === "source" ? [value] : [] + Library.consoleFilter = kind === "console" ? value : "" + Library.showHidden = false + Library.mode = kind === "favorites" ? 1 : kind === "recent" ? 2 : 0 + if (kind === "recent") Library.sortMode = 1 + Library.availability = 0 + Library.completionFilter = kind === "backlog" ? "backlog" : "" + Library.collectionFilter = kind === "collection" ? value : "" + root.homeOpen = false + Qt.callLater(root.focusLibrary) + } + function selectHomeGame(game, action) { + root.homeReturnAction = action || "tile" + root.homeReturnIdentity = homeScreen.focusKey(game) + root.homeLibraryState = Library.filterState() + const row = Library.revealGame(game.source, game.runner || "", game.appId) + if (row >= 0) { + root.openGame(row) + return true + } + Library.applyFilterState(root.homeLibraryState) + root.homeLibraryState = null + root.showToast("This game is no longer available") + return false + } + onGameRequested: (game, action) => selectHomeGame(game, action) + onPlayRequested: game => { + if (launchFeedback.pending) root.showToast(launchFeedback.message) + else if (selectHomeGame(game, "play")) root.playSelected() + } + } + CouchLibraryView { id: couchLibraryView objectName: "couchLibrary" anchors.fill: parent - visible: root.couchMode && !root.detailOpen + visible: !root.homeOpen && root.couchMode && !root.detailOpen enabled: visible && root.navigationContainer() === null libraryModel: Library scanning: root.libraryScanning @@ -2062,6 +1726,7 @@ ApplicationWindow { onSavedFiltersRequested: root.openSavedFilters() onRandomRequested: root.pickRandomGame() onSettingsRequested: root.diagnosticsOpen = true + onHomeRequested: { root.homeOpen = true; Qt.callLater(homeScreen.focusHome) } onDesktopRequested: root.setCouchMode(false) onCoverRequested: function(source, appId) { if (source === "Steam" && SteamLibrary) { @@ -2078,14 +1743,11 @@ ApplicationWindow { Loader { id: detailsLoader + onLoaded: Qt.callLater(function() { + if (root.detailOpen && detailsLoader.item) detailsLoader.item.focusPrimary() + }) anchors.fill: parent active: root.detailOpen - Keys.onPressed: function(event) { - if (item && !root.linkDialogOpen && !root.diagnosticsOpen - && !root.collectionDeleteOpen) { - root.handleArrowKey(item, event) - } - } opacity: root.detailOpen ? 1 : 0 asynchronous: false @@ -2096,10 +1758,13 @@ ApplicationWindow { sourceComponent: GameDetails { game: root.selectedGame + launchBusy: launchFeedback.pending && root.launchMatchesSelection + launchMessage: root.launchMatchesSelection ? launchFeedback.message : "" + launchFailed: launchFeedback.failed installations: root.selectedInstallations selectedInstallation: root.selectedInstallation couchMode: root.couchMode - navigationEnabled: !root.backupEditorOpen && !root.bulkOrganizationOpen && !root.savedFiltersOpen && !root.artworkEditorOpen && !root.manualEditorOpen && !root.linkDialogOpen && !root.diagnosticsOpen + navigationEnabled: !root.activeActionMenu && !root.backupEditorOpen && !root.bulkOrganizationOpen && !root.savedFiltersOpen && !root.artworkEditorOpen && !root.manualEditorOpen && !root.linkDialogOpen && !root.diagnosticsOpen && !root.collectionDeleteOpen onBackRequested: root.closeDetails() onFavoriteRequested: { @@ -2399,6 +2064,10 @@ ApplicationWindow { root.focusWithin(filterPickerOverlay, true) } }) + } else if (root.returnToFilters) { + root.returnToFilters = false + previousFocus = null + Qt.callLater(libraryFilters.open) } else if (previousFocus) { root.restoreFocus(previousFocus) previousFocus = null @@ -2430,7 +2099,7 @@ ApplicationWindow { Text { text: root.filterPickerKind === "status" ? "FILTER BY STATUS" : root.filterPickerKind === "collection" ? "FILTER BY COLLECTION" - : "FILTER BY TAG" + : "FILTER BY " + root.filterPickerKind.toUpperCase() color: Theme.brightForeground font.family: Theme.fontFamily font.pixelSize: 13 @@ -2443,6 +2112,15 @@ ApplicationWindow { onClicked: root.filterPickerOpen = false } } + Text { + Layout.fillWidth: true + visible: root.filterPickerKind === "genre" || root.filterPickerKind === "decade" + text: "Uses available game metadata. Games without a matching value are excluded." + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: 11 + wrapMode: Text.Wrap + } ListView { id: pickerList Layout.fillWidth: true @@ -2461,7 +2139,7 @@ ApplicationWindow { text: modelData === "" ? (root.filterPickerKind === "status" ? "ANY STATUS" : root.filterPickerKind === "collection" ? "ALL COLLECTIONS" - : "ALL TAGS") + : "ANY " + root.filterPickerKind.toUpperCase()) : modelData.toUpperCase() onClicked: root.applyFilterPick(modelData) } @@ -2509,6 +2187,544 @@ ApplicationWindow { interval: 2400 } + ActionMenu { + id: librarySources + objectName: "librarySources" + host: root + anchorItem: sourcesMenuButton + title: "SOURCES" + width: Math.min(540, root.width - 48) + initialFocus: allSourcesButton + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: "Select a source. Shift+Enter or the controller favorite button adds or removes a source." + color: Theme.mutedText + font.family: Theme.fontFamily + } + Flow { + id: sourceButtonsRow + Layout.fillWidth: true + spacing: 6 + GlassButton { + id: allSourcesButton + objectName: "allSourcesButton" + text: "ALL SOURCES" + compact: true + selected: Library.sourceFilters.length === 0 + onClicked: { + Library.sourceFilters = [] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: emulatedSourcesButton + objectName: "emulatedSourcesButton" + text: "EMULATED" + compact: true + property string sourceName: "Emulated" + selected: Library.emulatorSources.every(source => Library.sourceFilters.indexOf(source) >= 0) + onClicked: { + Library.sourceFilters = Library.emulatorSources + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSources(Library.emulatorSources) + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: steamSourceButton + objectName: "steamSourceButton" + text: "STEAM" + compact: true + visible: Preferences.steamEnabled + property string sourceName: "Steam" + selected: Library.sourceFilters.indexOf("Steam") >= 0 + onClicked: { + Library.sourceFilters = ["Steam"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("Steam") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: battleNetSourceButton + objectName: "battleNetSourceButton" + text: "BATTLE.NET" + compact: true + visible: Preferences.battleNetEnabled + property string sourceName: "Battle.net" + selected: Library.sourceFilters.indexOf("Battle.net") >= 0 + onClicked: { + Library.sourceFilters = ["Battle.net"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("Battle.net") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: lutrisSourceButton + objectName: "lutrisSourceButton" + text: "LUTRIS" + compact: true + visible: Preferences.lutrisEnabled + property string sourceName: "Lutris" + selected: Library.sourceFilters.indexOf("Lutris") >= 0 + onClicked: { + Library.sourceFilters = ["Lutris"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("Lutris") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: heroicSourceButton + objectName: "heroicSourceButton" + text: "HEROIC" + compact: true + visible: Preferences.heroicEnabled + property string sourceName: "Heroic" + selected: Library.sourceFilters.indexOf("Heroic") >= 0 + onClicked: { + Library.sourceFilters = ["Heroic"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("Heroic") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: gogSourceButton + objectName: "gogSourceButton" + text: "GOG" + compact: true + visible: Preferences.gogEnabled + property string sourceName: "GOG" + selected: Library.sourceFilters.indexOf("GOG") >= 0 + onClicked: { + Library.sourceFilters = ["GOG"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("GOG") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: faugusSourceButton + objectName: "faugusSourceButton" + text: "FAUGUS" + compact: true + visible: Preferences.faugusEnabled + property string sourceName: "Faugus" + selected: Library.sourceFilters.indexOf("Faugus") >= 0 + onClicked: { + Library.sourceFilters = ["Faugus"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("Faugus") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: retroArchSourceButton + objectName: "retroArchSourceButton" + text: "RETROARCH" + compact: true + visible: Preferences.retroArchEnabled + property string sourceName: "RetroArch" + selected: Library.sourceFilters.indexOf("RetroArch") >= 0 + onClicked: { + Library.sourceFilters = ["RetroArch"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("RetroArch") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: pcsx2SourceButton + objectName: "pcsx2SourceButton" + text: "PCSX2" + compact: true + visible: Preferences.pcsx2Enabled + property string sourceName: "PCSX2" + selected: Library.sourceFilters.indexOf("PCSX2") >= 0 + onClicked: { + Library.sourceFilters = ["PCSX2"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("PCSX2") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: ryujinxSourceButton + objectName: "ryujinxSourceButton" + text: "RYUJINX" + compact: true + visible: Preferences.ryujinxEnabled + property string sourceName: "Ryujinx" + selected: Library.sourceFilters.indexOf("Ryujinx") >= 0 + onClicked: { + Library.sourceFilters = ["Ryujinx"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("Ryujinx") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: shadps4SourceButton + objectName: "shadps4SourceButton" + text: "SHADPS4" + compact: true + visible: Preferences.shadps4Enabled + property string sourceName: "shadPS4" + selected: Library.sourceFilters.indexOf("shadPS4") >= 0 + onClicked: { + Library.sourceFilters = ["shadPS4"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("shadPS4") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: cemuSourceButton + objectName: "cemuSourceButton" + text: "CEMU" + compact: true + visible: Preferences.cemuEnabled + property string sourceName: "Cemu" + selected: Library.sourceFilters.indexOf("Cemu") >= 0 + onClicked: { + Library.sourceFilters = ["Cemu"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("Cemu") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: dolphinSourceButton + objectName: "dolphinSourceButton" + text: "DOLPHIN" + compact: true + visible: Preferences.dolphinEnabled + property string sourceName: "Dolphin" + selected: Library.sourceFilters.indexOf("Dolphin") >= 0 + onClicked: { + Library.sourceFilters = ["Dolphin"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("Dolphin") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + GlassButton { + id: manualSourceButton + objectName: "manualSourceButton" + text: "MANUAL" + compact: true + visible: ManualLibrary.count > 0 + property string sourceName: "Manual" + selected: Library.sourceFilters.indexOf("Manual") >= 0 + onClicked: { + Library.sourceFilters = ["Manual"] + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + onSecondaryClicked: { + Library.toggleSource("Manual") + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + } + } + ActionMenu { + id: libraryFilters + objectName: "libraryFilters" + host: root + anchorItem: filtersMenuButton + title: "FILTER LIBRARY" + width: Math.min(540, root.width - 48) + initialFocus: !DemoMode && root.ownedGameCount > 0 ? installedAvailabilityButton : statusFilterButton + MenuAction { + id: hiddenModeButton + objectName: "hiddenModeButton" + visible: !DemoMode + compact: true + text: "HIDDEN GAMES" + selected: Library.mode === 3 + onClicked: Library.mode = Library.mode === 3 ? 0 : 3 + } + RowLayout { + Layout.fillWidth: true + visible: !DemoMode && root.ownedGameCount > 0 + spacing: 6 + + Text { + text: "AVAILABILITY" + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: 9 + font.weight: Font.DemiBold + } + GlassButton { + id: installedAvailabilityButton + objectName: "installedAvailabilityButton" + compact: true + text: "INSTALLED" + selected: Library.availability === 0 + onClicked: { + Library.availability = 0 + } + } + GlassButton { + compact: true + text: "ALL GAMES" + selected: Library.availability === 1 + onClicked: { + Library.availability = 1 + } + } + GlassButton { + id: readyAvailabilityButton + objectName: "readyAvailabilityButton" + compact: true + text: "READY TO INSTALL" + selected: Library.availability === 2 + onClicked: { + Library.availability = 2 + } + } + Item { + Layout.fillWidth: true + } + } + Flow { + Layout.fillWidth: true + spacing: 6 + + GlassButton { + id: statusFilterButton + visible: !DemoMode + objectName: "statusFilterButton" + maximumLabelWidth: Math.max(80, libraryFilters.width - 80) + compact: true + text: root.filterLabel("STATUS", Library.completionFilter) + selected: Library.completionFilter !== "" + onClicked: root.openFilterPicker("status", ["backlog", "playing", "completed", "abandoned"]) + } + GlassButton { + id: collectionFilterButton + visible: !DemoMode + objectName: "collectionFilterButton" + maximumLabelWidth: Math.max(80, libraryFilters.width - 80) + compact: true + text: root.filterLabel("COLLECTION", Library.collectionFilter, Library.collectionNames) + selected: Library.collectionFilter !== "" + onClicked: { + if (Library.collectionNames.length === 0) { + root.showToast("No collections yet. Open a game and use + New Collection.") + return + } + root.openFilterPicker("collection", Library.collectionNames) + } + } + GlassButton { + id: tagFilterButton + visible: !DemoMode + objectName: "tagFilterButton" + maximumLabelWidth: Math.max(80, libraryFilters.width - 80) + compact: true + text: root.filterLabel("TAG", Library.tagFilter, Library.tagNames) + selected: Library.tagFilter !== "" + onClicked: { + if (Library.tagNames.length === 0) { + root.showToast("No tags yet. Open a game and add tags under Organize.") + return + } + root.openFilterPicker("tag", Library.tagNames) + } + } + GlassButton { + compact: true + visible: Library.completionFilter !== "" || Library.collectionFilter !== "" || Library.tagFilter !== "" + text: "CLEAR" + onClicked: { + Library.completionFilter = "" + Library.collectionFilter = "" + Library.tagFilter = "" + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + + GlassButton { + objectName: "genreFilterButton" + maximumLabelWidth: Math.max(80, libraryFilters.width - 80) + compact: true + text: root.filterLabel("GENRE", Library.genreFilter) + selected: Library.genreFilter !== "" + onClicked: root.openFilterPicker("genre", Library.genreNames) + } + GlassButton { + objectName: "decadeFilterButton" + maximumLabelWidth: Math.max(80, libraryFilters.width - 80) + compact: true + text: root.filterLabel("DECADE", Library.decadeFilter) + selected: Library.decadeFilter !== "" + onClicked: root.openFilterPicker("decade", Library.decadeNames) + } + GlassButton { + objectName: "platformFilterButton" + maximumLabelWidth: Math.max(80, libraryFilters.width - 80) + compact: true + text: root.filterLabel("PLATFORM", Library.platformFilter) + selected: Library.platformFilter !== "" + onClicked: root.openFilterPicker("platform", Library.platformNames) + } + GlassButton { + compact: true + visible: Library.genreFilter !== "" || Library.decadeFilter !== "" || Library.platformFilter !== "" + text: "CLEAR METADATA FILTERS" + onClicked: { + Library.genreFilter = "" + Library.decadeFilter = "" + Library.platformFilter = "" + } + } + } + } + ActionMenu { + id: librarySort + objectName: "librarySort" + host: root + anchorItem: sortButton + title: "SORT GAMES" + Repeater { + model: ["TITLE", "RECENTLY PLAYED", "PLAYTIME", "RATING", "POPULARITY"] + MenuAction { + required property int index + required property string modelData + Layout.fillWidth: true + compact: true + text: modelData + selected: Library.sortMode === index + onClicked: { + Library.sortMode = index + librarySort.close() + } + } + } + } + ActionMenu { + id: libraryViewMenu + objectName: "libraryViewMenu" + host: root + anchorItem: viewMenuButton + title: "LIBRARY VIEW" + MenuAction { + id: consoleGamesButton + objectName: "consoleGamesButton" + // Every console system follows this view unless explicitly overridden. + visible: Library.hasConsoleCards || Library.expandConsoles + compact: true + selected: Library.expandConsoles + text: Library.expandConsoles ? "CONSOLE VIEW: GAMES" : "CONSOLE VIEW: CONSOLES" + onClicked: { + Library.expandConsoles = !Library.expandConsoles + libraryView.currentIndex = Library.rowCount() > 0 ? 0 : -1 + } + } + MenuAction { + id: coverSizeButton + objectName: "coverSizeButton" + compact: true + text: "COVER SIZE" + onClicked: { + root.returnToViewMenu = true + libraryViewMenu.invoke(coverSizePopup.open) + } + } + } + ActionMenu { + id: libraryActions + objectName: "libraryActions" + host: root + anchorItem: libraryMoreButton + title: "LIBRARY ACTIONS" + initialFocus: randomGameButton + MenuAction { + objectName: "libraryAddGameButton" + visible: !DemoMode + Layout.fillWidth: true + compact: true + text: "ADD A GAME" + onClicked: libraryActions.invoke(function () { + root.editManualGame("") + }) + } + MenuAction { + objectName: "libraryCollectionsButton" + visible: !DemoMode + Layout.fillWidth: true + compact: true + text: "MANAGE COLLECTIONS" + onClicked: libraryActions.invoke(function () { + root.diagnosticsOpen = true + settingsOverlay.focusCollections() + }) + } + + MenuAction { + id: randomGameButton + objectName: "randomGameButton" + compact: true + text: "PICK A GAME" + onClicked: libraryActions.invoke(root.pickRandomGame) + } + MenuAction { + objectName: "bulkOrganizationButton" + text: "ORGANIZE" + Layout.fillWidth: true + compact: true + onClicked: libraryActions.invoke(root.openBulkOrganization) + } + MenuAction { + objectName: "savedFiltersButton" + text: "SAVED FILTERS" + Layout.fillWidth: true + compact: true + onClicked: libraryActions.invoke(root.openSavedFilters) + } + MenuAction { + id: rescanButton + objectName: "rescanButton" + Layout.fillWidth: true + compact: true + text: root.libraryScanning ? "SCANNING" : "RESCAN" + enabled: !root.libraryScanning + onClicked: libraryActions.invoke(root.rescanLibraries) + } + } + + property bool returnToViewMenu: false Popup { id: coverSizePopup objectName: "coverSizePopup" @@ -2528,7 +2744,10 @@ ApplicationWindow { Text { text: libraryView.columns + " PER ROW"; color: Theme.mutedText; font.family: Theme.fontFamily; font.pixelSize: 11 } } onOpened: libraryCoverSize.focusSlider() - onClosed: coverSizeButton.forceActiveFocus(Qt.TabFocusReason) + onClosed: { + if (root.returnToViewMenu && !root.couchMode) Qt.callLater(libraryViewMenu.open) + root.returnToViewMenu = false + } } SettingsPanel { @@ -2720,6 +2939,7 @@ ApplicationWindow { focused.secondaryClicked() return } + if (root.activeActionMenu && root.activeActionMenu.opened) return if (root.detailOpen && !root.diagnosticsOpen && !root.linkDialogOpen && !root.collectionDeleteOpen) { Library.toggleFavorite(root.selectedIndex) diff --git a/qml/components/ActionMenu.qml b/qml/components/ActionMenu.qml new file mode 100644 index 0000000..0e52a76 --- /dev/null +++ b/qml/components/ActionMenu.qml @@ -0,0 +1,159 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Popup { + id: menu + required property var host + required property Item anchorItem + property bool showCloseButton: true + property bool fixedHeader: false + property string doneObjectName: "actionMenuDoneButton" + property Item headerDownTarget: null + readonly property Item doneControl: headerDone + property string title: "ACTIONS" + property Item initialFocus: null + default property alias actions: actionColumn.data + + parent: Overlay.overlay + width: Math.min(320 * (host.couchMode ? Math.max(1, Math.min(2.4, host.height / 900)) : 1), host.width - 48) + height: Math.min(implicitHeight, host.height - 48) + padding: 16 + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + // Close and restore the invoker before another editor captures its return focus. + function invoke(action) { + close() + Qt.callLater(action) + } + + function handleMenuKey(event) { + if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) { + host.focusWithin(menu.contentItem, + event.key !== Qt.Key_Backtab && !(event.modifiers & Qt.ShiftModifier)) + event.accepted = true + } else host.handleArrowKey(menu.contentItem, event) + } + function positionWithinWindow() { + if (fixedHeader) { + x = Math.max(24, (host.width - width) / 2) + y = Math.max(24, (host.height - height) / 2) + } else { + const position = anchorItem.mapToItem(Overlay.overlay, 0, anchorItem.height) + x = Math.max(24, Math.min(host.width - width - 24, position.x)) + y = Math.max(24, Math.min(host.height - height - 24, position.y + 8)) + } + } + onWidthChanged: if (visible) positionWithinWindow() + onHeightChanged: if (visible) positionWithinWindow() + Connections { + target: menu.host + function onWidthChanged() { if (menu.visible) menu.positionWithinWindow() } + function onHeightChanged() { if (menu.visible) menu.positionWithinWindow() } + } + onAboutToShow: { + if (host.activeActionMenu && host.activeActionMenu !== menu) + host.activeActionMenu.close() + host.activeActionMenu = menu + positionWithinWindow() + } + onOpened: { + // A reopened popup may restore its old child focus before this signal. + // Start at the first enabled action, not the item after that old child. + if (initialFocus && initialFocus.visible && initialFocus.enabled) { + host.focusWithin(contentItem, true, initialFocus) + return + } + for (const action of actionColumn.children) { + if (action.visible && action.enabled && action.activeFocusOnTab) { + host.focusWithin(contentItem, true, action) + return + } + } + host.focusWithin(contentItem, true, closeButton) + } + onClosed: { + if (host.activeActionMenu === menu) + host.activeActionMenu = null + if (anchorItem.visible && anchorItem.enabled) + anchorItem.forceActiveFocus(Qt.TabFocusReason) + } + + background: Rectangle { + color: Theme.background + radius: Math.max(8, Theme.cornerRadius) + border.color: Theme.mutedText + } + contentItem: ColumnLayout { + property var navigationScrollView: actionScroll + spacing: menu.fixedHeader ? 12 : 0 + // Modal popups block the window shortcuts. Handle navigation inside + // the popup so physical keyboard input follows the controller path. + Keys.priority: Keys.BeforeItem + Keys.onPressed: event => menu.handleMenuKey(event) + RowLayout { + Layout.fillWidth: true + visible: menu.fixedHeader + Text { + Layout.fillWidth: true + text: menu.title + wrapMode: Text.Wrap + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: 12 + } + GlassButton { + id: headerDone + objectName: menu.doneObjectName + compact: true + text: "DONE" + property Item controllerDownTarget: menu.headerDownTarget + onClicked: menu.close() + } + } + ScrollView { + id: actionScroll + Keys.priority: Keys.BeforeItem + Keys.onPressed: event => menu.handleMenuKey(event) + objectName: "actionMenuScroll" + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 0 + rightPadding: 12 + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + implicitHeight: menuColumn.implicitHeight + contentWidth: availableWidth + clip: true + ColumnLayout { + id: menuColumn + width: actionScroll.availableWidth + spacing: 8 + Text { + Layout.fillWidth: true + visible: !menu.fixedHeader + text: menu.title + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: 11 + wrapMode: Text.Wrap + } + ColumnLayout { + id: actionColumn + Layout.fillWidth: true + spacing: 6 + } + MenuAction { + id: closeButton + objectName: "actionMenuCloseButton" + visible: menu.showCloseButton + Layout.fillWidth: true + text: "CLOSE" + compact: true + onClicked: menu.close() + } + } + } + } +} diff --git a/qml/components/CouchBrowsePanel.qml b/qml/components/CouchBrowsePanel.qml index 09a43b7..3c831e7 100644 --- a/qml/components/CouchBrowsePanel.qml +++ b/qml/components/CouchBrowsePanel.qml @@ -16,7 +16,10 @@ FocusScope { { label: "CONSOLES", kind: "consoles" }, { label: "STATUS", kind: "status" }, { label: "COLLECTION", kind: "collection" }, - { label: "TAG", kind: "tag" } + { label: "TAG", kind: "tag" }, + { label: "GENRE", kind: "genre" }, + { label: "RELEASE DECADE", kind: "decade" }, + { label: "PLATFORM", kind: "platform" } ] readonly property real uiScale: Math.max(1, Math.min(2, Math.min(width / 1920, @@ -80,8 +83,11 @@ FocusScope { ] } const names = kind === "collection" ? libraryModel.collectionNames - : libraryModel.tagNames - const values = [{ label: kind === "collection" ? "ANY COLLECTION" : "ANY TAG", + : kind === "genre" ? libraryModel.genreNames + : kind === "decade" ? libraryModel.decadeNames + : kind === "platform" ? libraryModel.platformNames + : libraryModel.tagNames + const values = [{ label: "ANY " + kind.toUpperCase(), value: "" }] for (let index = 0; index < names.length; ++index) { values.push({ label: names[index].toUpperCase(), value: names[index] }) @@ -103,6 +109,9 @@ FocusScope { : kind === "consoles" ? libraryModel.expandConsoles : kind === "status" ? libraryModel.completionFilter : kind === "collection" ? libraryModel.collectionFilter + : kind === "genre" ? libraryModel.genreFilter + : kind === "decade" ? libraryModel.decadeFilter + : kind === "platform" ? libraryModel.platformFilter : libraryModel.tagFilter for (let index = 0; index < optionModel.length; ++index) { if (optionModel[index].value === selectedValue) { @@ -116,10 +125,15 @@ FocusScope { return kind === "mode" ? libraryModel.mode === value : kind === "sort" ? libraryModel.sortMode === value : kind === "availability" ? libraryModel.availability === value - : kind === "source" ? libraryModel.sourceFilter === value + : kind === "source" ? (value === "" ? libraryModel.sourceFilters.length === 0 + : value === "Emulated" ? libraryModel.emulatorSources.every(source => libraryModel.sourceFilters.indexOf(source) >= 0) + : libraryModel.sourceFilters.indexOf(value) >= 0) : kind === "consoles" ? libraryModel.expandConsoles === value : kind === "status" ? libraryModel.completionFilter === value : kind === "collection" ? libraryModel.collectionFilter === value + : kind === "genre" ? libraryModel.genreFilter === value + : kind === "decade" ? libraryModel.decadeFilter === value + : kind === "platform" ? libraryModel.platformFilter === value : libraryModel.tagFilter === value } @@ -136,10 +150,33 @@ FocusScope { else if (kind === "consoles") libraryModel.expandConsoles = value else if (kind === "status") libraryModel.completionFilter = value else if (kind === "collection") libraryModel.collectionFilter = value + else if (kind === "genre") libraryModel.genreFilter = value + else if (kind === "decade") libraryModel.decadeFilter = value + else if (kind === "platform") libraryModel.platformFilter = value else libraryModel.tagFilter = value filtersChanged() } + function clearContextFilters() { + libraryModel.availability = 0 + libraryModel.completionFilter = ""; libraryModel.collectionFilter = ""; libraryModel.tagFilter = "" + libraryModel.genreFilter = ""; libraryModel.decadeFilter = ""; libraryModel.platformFilter = "" + filtersChanged(); rebuildOptions() + } + function toggleSourceOption(index) { + if (categories[categoryIndex].kind !== "source" || index < 0 || index >= optionModel.length) return + const value = optionModel[index].value + if (value === "") libraryModel.sourceFilters = [] + else if (value === "Emulated") libraryModel.toggleSources(libraryModel.emulatorSources) + else libraryModel.toggleSource(value) + filtersChanged() + } + Connections { + target: Controller + function onFavoriteRequested() { + if (root.visible && optionList.activeFocus) root.toggleSourceOption(optionList.currentIndex) + } + } function clearFilters() { libraryModel.mode = 0 libraryModel.sortMode = 0 @@ -149,6 +186,9 @@ FocusScope { libraryModel.completionFilter = "" libraryModel.collectionFilter = "" libraryModel.tagFilter = "" + libraryModel.genreFilter = "" + libraryModel.decadeFilter = "" + libraryModel.platformFilter = "" libraryModel.searchText = "" filtersChanged() rebuildOptions() @@ -164,6 +204,7 @@ FocusScope { Connections { target: root.libraryModel + function onMetadataOptionsChanged() { root.rebuildOptions() } function onOrganizationNamesChanged() { root.rebuildOptions() } function onSourceFilterChanged() { if (root.categories[root.categoryIndex].kind === "source") root.rebuildOptions() @@ -184,7 +225,7 @@ FocusScope { anchors.bottomMargin: 44 * root.uiScale spacing: 24 * root.uiScale - RowLayout { + ColumnLayout { Layout.fillWidth: true ColumnLayout { @@ -205,6 +246,9 @@ FocusScope { } } + Flow { + Layout.fillWidth: true + spacing: 8 GlassButton { id: organizeButton text: "ORGANIZE" @@ -236,7 +280,7 @@ FocusScope { GlassButton { id: clearButton KeyNavigation.left: randomButton - text: "CLEAR ALL" + text: "RESET BROWSING" onClicked: root.clearFilters() KeyNavigation.right: doneButton KeyNavigation.down: categoryList @@ -249,6 +293,11 @@ FocusScope { KeyNavigation.left: clearButton KeyNavigation.down: optionList } + GlassButton { + text: "CLEAR FILTERS"; compact: true + onClicked: root.clearContextFilters() + } + } } Rectangle { @@ -363,11 +412,13 @@ FocusScope { } } Keys.onReturnPressed: function(event) { - root.applyOption(currentIndex) + if (event.modifiers & (Qt.ShiftModifier | Qt.ControlModifier)) root.toggleSourceOption(currentIndex) + else root.applyOption(currentIndex) event.accepted = true } Keys.onEnterPressed: function(event) { - root.applyOption(currentIndex) + if (event.modifiers & (Qt.ShiftModifier | Qt.ControlModifier)) root.toggleSourceOption(currentIndex) + else root.applyOption(currentIndex) event.accepted = true } diff --git a/qml/components/CouchLibraryView.qml b/qml/components/CouchLibraryView.qml index aed23d5..2f2649e 100644 --- a/qml/components/CouchLibraryView.qml +++ b/qml/components/CouchLibraryView.qml @@ -46,6 +46,7 @@ FocusScope { signal savedFiltersRequested() signal randomRequested() signal settingsRequested() + signal homeRequested() signal desktopRequested() signal coverRequested(string source, string appId) @@ -313,7 +314,7 @@ FocusScope { border.color: root.alpha(Theme.foreground, 0.12) } - RowLayout { + ColumnLayout { id: topBar anchors.top: parent.top anchors.left: parent.left @@ -323,46 +324,82 @@ FocusScope { anchors.rightMargin: 54 * root.uiScale spacing: 12 * root.uiScale - Row { - spacing: 12 * root.uiScale - Layout.alignment: Qt.AlignVCenter - - Image { - width: 42 * root.uiScale - height: width - source: "qrc:/icons/resources/icons/io.github.tsouth89.Omakade.svg" - sourceSize: Qt.size(96, 96) - Accessible.ignored: true - } - - Column { - anchors.verticalCenter: parent.verticalCenter - spacing: 0 + RowLayout { + Layout.fillWidth: true + Row { + spacing: 12 * root.uiScale + Layout.alignment: Qt.AlignVCenter - Text { - text: "OMAKADE" - color: Theme.brightForeground - font.family: Theme.fontFamily - font.pixelSize: 18 * root.uiScale - font.weight: Font.Bold - font.letterSpacing: 2 + Image { + width: 42 * root.uiScale + height: width + source: "qrc:/icons/resources/icons/io.github.tsouth89.Omakade.svg" + sourceSize: Qt.size(96, 96) + Accessible.ignored: true } - Text { - text: "COUCH MODE" - color: Theme.accent - font.family: Theme.fontFamily - font.pixelSize: 9 * root.uiScale - font.weight: Font.DemiBold - font.letterSpacing: 1.4 + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Text { + text: "OMAKADE" + color: Theme.brightForeground + font.family: Theme.fontFamily + font.pixelSize: 18 * root.uiScale + font.weight: Font.Bold + font.letterSpacing: 2 + } + Text { + text: "COUCH MODE" + color: Theme.accent + font.family: Theme.fontFamily + font.pixelSize: 9 * root.uiScale + font.weight: Font.DemiBold + font.letterSpacing: 1.4 + } } } - } - Item { Layout.fillWidth: true } + Item { + Layout.fillWidth: true + } - Row { + GlassButton { + id: homeButton + objectName: "couchHomeButton" + text: "HOME" + compact: true + onClicked: root.homeRequested() + KeyNavigation.left: desktopButton + KeyNavigation.down: root.detailView ? favoriteButton : gameGrid + } + GlassButton { + id: settingsButton + objectName: "couchSettingsButton" + text: "SETTINGS" + compact: true + displayScale: Math.max(1, root.uiScale * 1.18) + onClicked: root.settingsRequested() + KeyNavigation.left: filtersButton + KeyNavigation.right: desktopButton + KeyNavigation.down: root.detailView ? favoriteButton : gameGrid + } + GlassButton { + id: desktopButton + objectName: "couchDesktopButton" + text: "DESKTOP" + compact: true + displayScale: Math.max(1, root.uiScale * 1.18) + onClicked: root.desktopRequested() + KeyNavigation.left: settingsButton + KeyNavigation.right: homeButton + KeyNavigation.down: root.detailView ? favoriteButton : gameGrid + } + } + Flow { + Layout.fillWidth: true spacing: 7 * root.uiScale - Layout.alignment: Qt.AlignVCenter GlassButton { id: consoleButton @@ -387,11 +424,8 @@ FocusScope { GlassButton { id: showButton objectName: "couchShowButton" - text: "SHOW: " + (root.libraryModel.mode === 1 ? "FAVORITES" - : root.libraryModel.mode === 2 ? "RECENT" : "ALL") - Accessible.name: "Showing " + (root.libraryModel.mode === 1 ? "favorites" - : root.libraryModel.mode === 2 ? "recently played" - : "all games") + text: "SHOW: " + (root.libraryModel.mode === 1 ? "FAVORITES" : root.libraryModel.mode === 2 ? "RECENT" : "ALL") + Accessible.name: "Showing " + (root.libraryModel.mode === 1 ? "favorites" : root.libraryModel.mode === 2 ? "recently played" : "all games") compact: true displayScale: Math.max(1, root.uiScale * 1.18) selected: root.libraryModel.mode !== 0 @@ -463,11 +497,7 @@ FocusScope { GlassButton { id: searchButton objectName: "couchSearchButton" - text: root.libraryModel.searchText.length > 0 - ? "SEARCH · " - + root.libraryModel.searchText.substring(0, 12).toUpperCase() - + (root.libraryModel.searchText.length > 12 ? "…" : "") - : "SEARCH" + text: root.libraryModel.searchText.length > 0 ? "SEARCH · " + root.libraryModel.searchText.substring(0, 12).toUpperCase() + (root.libraryModel.searchText.length > 12 ? "…" : "") : "SEARCH" compact: true displayScale: Math.max(1, root.uiScale * 1.18) onClicked: root.openSearch() @@ -486,27 +516,6 @@ FocusScope { KeyNavigation.right: settingsButton KeyNavigation.down: root.detailView ? favoriteButton : gameGrid } - GlassButton { - id: settingsButton - objectName: "couchSettingsButton" - text: "SETTINGS" - compact: true - displayScale: Math.max(1, root.uiScale * 1.18) - onClicked: root.settingsRequested() - KeyNavigation.left: filtersButton - KeyNavigation.right: desktopButton - KeyNavigation.down: root.detailView ? favoriteButton : gameGrid - } - GlassButton { - id: desktopButton - objectName: "couchDesktopButton" - text: "DESKTOP" - compact: true - displayScale: Math.max(1, root.uiScale * 1.18) - onClicked: root.desktopRequested() - KeyNavigation.left: settingsButton - KeyNavigation.down: root.detailView ? favoriteButton : gameGrid - } } } diff --git a/qml/components/GameCard.qml b/qml/components/GameCard.qml index 7837f2c..c65217d 100644 --- a/qml/components/GameCard.qml +++ b/qml/components/GameCard.qml @@ -7,6 +7,7 @@ FocusScope { required property string title required property string subtitle required property int hours + property string playtimeText: hours + "h" // IGDB score out of 100. Below zero means this game has no rating, and the card // then shows nothing rather than a placeholder. required property int rating @@ -28,7 +29,7 @@ FocusScope { activeFocusOnTab: true Accessible.name: title - Accessible.description: subtitle + ", " + hours + " hours played" + Accessible.description: subtitle + ", " + playtimeText + " played" + (rating >= 0 ? ", rated " + rating + " out of 100" : "") Accessible.role: Accessible.ListItem @@ -279,7 +280,7 @@ FocusScope { } Text { id: subtitleHours - text: root.hours + "h" + text: root.playtimeText color: Theme.mutedText font.family: Theme.fontFamily font.pixelSize: 10 diff --git a/qml/components/GameMetadataEditor.qml b/qml/components/GameMetadataEditor.qml index 2bc714f..2776f50 100644 --- a/qml/components/GameMetadataEditor.qml +++ b/qml/components/GameMetadataEditor.qml @@ -4,10 +4,31 @@ import QtQuick.Layouts ColumnLayout { id: root + property var entry: Metadata ? Metadata.current : ({}) required property var game property bool couchMode: false property real uiScale: 1 property bool editing: false + property bool panelMode: false + property Item externalDone: null + readonly property Item headerControl: externalDone || artworkButton + readonly property Item firstBodyControl: identifyButton.visible ? identifyButton : changeMatchButton.visible ? changeMatchButton : choosePortraitButton + property var coverChoices: Metadata ? Metadata.covers : [] + property int coverGeneration: 0 + function coverAt(index) { + const generation = coverGeneration + return coverRepeater.itemAt(index) + } + property bool matchControlsOpen: false + property bool coverControlsOpen: false + signal localArtworkRequested() + signal connectionsRequested() + property bool autoCoverPending: false + function loadCoverChoices() { + if (!autoCoverPending || !editing || !Metadata || Metadata.busy || !Metadata.hasGridKey) return + autoCoverPending = false + Metadata.findCovers() + } signal textEntryRequested(var target, string title, bool password, string placeholder) // The details page navigates by an explicit controller chain, and a section left out of it // is unreachable however plainly it is on screen: arrow keys follow the chain in preference @@ -16,62 +37,99 @@ ColumnLayout { // way out; the page wires them to whatever sits either side. property Item previousSection: null property Item nextSection: null - readonly property Item firstControl: artworkButton + readonly property Item firstControl: firstBodyControl readonly property Item lastControl: !root.editing ? artworkButton : coverSearchButton.visible && coverSearchButton.enabled ? coverSearchButton - : artworkButton + : root.headerControl Layout.fillWidth: true spacing: 10 visible: Metadata !== null && !game.isPortal readonly property string gameKey: game.metadataKey || "" - onGameKeyChanged: { editing = false; if (Metadata) Metadata.inspect(game) } + onGameKeyChanged: { matchControlsOpen = false; coverControlsOpen = false; editing = false; if (Metadata) Metadata.inspect(game) } Component.onCompleted: if (Metadata) Metadata.inspect(game) // Identifying a game by hand takes precedence over the background pass, which would // otherwise hold the service busy and leave every control here disabled. - onEditingChanged: if (Metadata) Metadata.setEditing(editing) + onEditingChanged: { + if (Metadata) Metadata.setEditing(editing) + autoCoverPending = editing && root.entry.igdbId > 0 + Qt.callLater(loadCoverChoices) + } Component.onDestruction: if (Metadata) Metadata.setEditing(false) Connections { target: Metadata + function onChanged() { root.loadCoverChoices() } function onPortraitSelected(key) { if (key !== root.gameKey) return root.editing = false - artworkButton.forceActiveFocus() + if (!root.panelMode) artworkButton.forceActiveFocus() } } RowLayout { + visible: !root.panelMode Layout.fillWidth: true - Text { Layout.fillWidth: true; text: "RATING & COVER ART"; color: Theme.brightForeground; font.family: Theme.fontFamily; font.pixelSize: 12 * root.uiScale } + Text { Layout.fillWidth: true; text: root.panelMode ? "CURRENT GAME" : "RATING & COVER ART"; color: Theme.brightForeground; font.family: Theme.fontFamily; font.pixelSize: 12 * root.uiScale } GlassButton { id: artworkButton - objectName: "metadataArtworkButton" + objectName: root.externalDone ? "metadataInlineDoneButton" : "metadataArtworkButton" + visible: !root.externalDone compact: true text: root.editing ? "DONE" : "IDENTIFY / ARTWORK" property Item controllerUpTarget: root.previousSection // Expanded, down goes into the section rather than past it. Collapsed, there is // nothing inside to reach, so it goes on to whatever follows. - property Item controllerDownTarget: root.editing ? identifyButton : root.nextSection + property Item controllerDownTarget: root.editing ? (identifyButton.visible ? identifyButton : choosePortraitButton) : root.nextSection onClicked: root.editing = !root.editing } } Text { Layout.fillWidth: true - text: !Metadata ? "" : Metadata.current.rating >= 0 - ? "IGDB " + Metadata.current.rating + " / 100 · " + Metadata.current.ratingCount + " ratings" + visible: !root.panelMode + text: !Metadata ? "" : root.entry.rating >= 0 + ? "IGDB " + root.entry.rating + " / 100 · " + root.entry.ratingCount + " ratings" : "No rating available" color: Theme.foreground; font.family: Theme.fontFamily; font.pixelSize: 12 * root.uiScale } ColumnLayout { Layout.fillWidth: true; spacing: 10; visible: root.editing - Text { - Layout.fillWidth: true; wrapMode: Text.Wrap - text: Metadata ? (Metadata.current.title || root.game.title) + (Metadata.current.year ? " (" + Metadata.current.year + ")" : "") + " · " + (Metadata.current.matchStatus || "Not identified") : "" - color: Theme.mutedText; font.family: Theme.fontFamily; font.pixelSize: 11 * root.uiScale + RowLayout { + Layout.fillWidth: true + spacing: 14 * root.uiScale + Image { + Layout.preferredWidth: 48 * root.uiScale + Layout.preferredHeight: 72 * root.uiScale + source: root.game.coverPath || root.entry.portrait || "" + visible: source.toString() !== "" + asynchronous: true + fillMode: Image.PreserveAspectFit + sourceSize.width: 96 + } + ColumnLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true; wrapMode: Text.Wrap + text: root.entry.title || root.game.title || "" + color: Theme.brightForeground; font.family: Theme.fontFamily; font.pixelSize: 14 * root.uiScale + } + Text { + Layout.fillWidth: true; wrapMode: Text.Wrap + text: (root.entry.year ? root.entry.year + " · " : "") + (root.entry.matchStatus || "Not identified") + color: Theme.mutedText; font.family: Theme.fontFamily; font.pixelSize: 11 * root.uiScale + } + } + GlassButton { + id: changeMatchButton + compact: true + text: root.matchControlsOpen ? "HIDE SEARCH" : "CHANGE MATCH" + visible: root.entry.igdbId > 0 + onClicked: root.matchControlsOpen = !root.matchControlsOpen + } } RowLayout { + visible: root.matchControlsOpen || !(root.entry.igdbId > 0) Layout.fillWidth: true TextField { - id: titleSearch; objectName: "metadataTitleField"; Layout.fillWidth: true; text: root.game.title || "" + id: titleSearch; objectName: "metadataTitleField"; Layout.fillWidth: true; text: root.entry.title || root.game.title || "" placeholderTextColor: Theme.mutedText background: Rectangle { radius: Math.max(5, Theme.cornerRadius) @@ -93,11 +151,11 @@ ColumnLayout { id: identifyButton objectName: "metadataIdentifyButton" compact: true - text: "SEARCH IGDB" - property Item controllerUpTarget: artworkButton - property Item controllerDownTarget: rejectButton - enabled: Metadata && !Metadata.busy && Insights && Insights.configured - onClicked: Metadata.search(titleSearch.text) + text: Insights && Insights.configured ? "SEARCH IGDB" : "CONNECT IGDB" + property Item controllerUpTarget: root.headerControl + property Item controllerDownTarget: rejectButton.visible ? rejectButton : choosePortraitButton + enabled: Metadata && !Metadata.busy + onClicked: Insights && Insights.configured ? Metadata.search(titleSearch.text) : root.connectionsRequested() } } Flow { @@ -106,9 +164,10 @@ ColumnLayout { id: rejectButton objectName: "metadataRejectButton" compact: true - text: "NOT THIS GAME" - property Item controllerUpTarget: identifyButton - property Item controllerDownTarget: coverSearchButton + visible: root.matchControlsOpen + text: "REMOVE MATCH" + property Item controllerUpTarget: identifyButton.visible ? identifyButton : root.headerControl + property Item controllerDownTarget: coverSearchButton.visible ? coverSearchButton : customImagesButton property Item controllerRightTarget: choosePortraitButton enabled: Metadata && !Metadata.busy onClicked: Metadata.rejectMatch() @@ -117,34 +176,52 @@ ColumnLayout { id: choosePortraitButton objectName: "metadataChoosePortraitButton" compact: true - text: "CHOOSE PORTRAIT" - property Item controllerUpTarget: identifyButton - property Item controllerDownTarget: coverSearchButton + text: Metadata && !Metadata.hasGridKey ? "CONNECT COVER SERVICE" : root.coverChoices.length ? "REFRESH COVERS" : "FIND COVERS" + property Item controllerUpTarget: identifyButton.visible ? identifyButton : root.headerControl + property Item controllerDownTarget: coverSearchButton.visible ? coverSearchButton : customImagesButton property Item controllerLeftTarget: rejectButton property Item controllerRightTarget: clearCoverButton - enabled: Metadata && Metadata.hasGridKey && !Metadata.busy - onClicked: Metadata.findCovers() + enabled: Metadata && !Metadata.busy + onClicked: Metadata.hasGridKey ? Metadata.findCovers() : root.connectionsRequested() } GlassButton { id: clearCoverButton objectName: "metadataClearCoverButton" compact: true - text: "CLEAR COVER" - property Item controllerUpTarget: identifyButton - property Item controllerDownTarget: coverSearchButton + visible: root.coverControlsOpen + text: "RESET COVER MATCH" + property Item controllerUpTarget: identifyButton.visible ? identifyButton : root.headerControl + property Item controllerDownTarget: coverSearchButton.visible ? coverSearchButton : customImagesButton property Item controllerLeftTarget: choosePortraitButton enabled: Metadata && Metadata.hasGridKey && !Metadata.busy onClicked: Metadata.clearGridSelection() } } + Flow { + Layout.fillWidth: true + spacing: 8 + GlassButton { + compact: true + visible: Metadata && Metadata.hasGridKey + text: root.coverControlsOpen ? "HIDE COVER SEARCH" : "SEARCH BY TITLE" + onClicked: root.coverControlsOpen = !root.coverControlsOpen + } + GlassButton { + id: customImagesButton + compact: true + text: "CUSTOM IMAGES…" + visible: root.panelMode + onClicked: root.localArtworkRequested() + } + } // The two catalogues do not always agree on a name: SteamGridDB files Dragon Quest V // under Hand of the Heavenly Bride while IGDB gives its Japanese title, and nothing // automatic bridges that. The name to search for can be typed here instead. RowLayout { Layout.fillWidth: true - visible: Metadata && Metadata.hasGridKey + visible: root.coverControlsOpen && Metadata && Metadata.hasGridKey TextField { - id: coverSearch; objectName: "metadataCoverField"; Layout.fillWidth: true; text: root.game.title || "" + id: coverSearch; objectName: "metadataCoverField"; Layout.fillWidth: true; text: root.entry.title || root.game.title || "" placeholderText: "Search SteamGridDB by name" placeholderTextColor: Theme.mutedText background: Rectangle { @@ -183,26 +260,85 @@ ColumnLayout { required property int index Layout.fillWidth: true; compact: true text: modelData.title + (modelData.year ? " · " + modelData.year : "") + + (modelData.edition ? " · " + modelData.edition : "") + + (modelData.releaseRegions && modelData.releaseRegions.length + ? " · " + modelData.releaseRegions.join(", ") : "") + + (modelData.id ? " · ID " + modelData.id : "") enabled: Metadata && !Metadata.busy onClicked: { Metadata.chooseMatch(index); Metadata.chooseGridGame(index) } } } Flow { - Layout.fillWidth: true; spacing: 12 + id: coverGrid + Layout.fillWidth: true + spacing: 10 * root.uiScale + readonly property int columns: Math.max(2, Math.floor((width + spacing) / (128 * root.uiScale + spacing))) Repeater { - model: Metadata ? Metadata.covers : [] - Column { + id: coverRepeater + onItemAdded: Qt.callLater(function() { root.coverGeneration++ }) + onItemRemoved: Qt.callLater(function() { root.coverGeneration++ }) + model: root.coverChoices + GlassButton { + id: tile required property var modelData required property int index - spacing: 6; width: 120 * root.uiScale - Image { width: parent.width; height: width * 1.5; source: modelData.url; asynchronous: true; fillMode: Image.PreserveAspectFit; sourceSize.width: 180 } - GlassButton { width: parent.width; compact: true; text: "USE COVER"; enabled: Metadata && !Metadata.busy; onClicked: Metadata.chooseCover(index) } + objectName: "metadataCoverTile" + index + width: Math.floor((coverGrid.width - (coverGrid.columns - 1) * coverGrid.spacing) / coverGrid.columns) + height: width * 1.5 + 8 + padding: 4 + leftPadding: 4 + rightPadding: 4 + topPadding: 4 + bottomPadding: 4 + text: "Use cover " + (index + 1) + (modelData.author ? " by " + modelData.author : "") + Accessible.name: text + (currentCover ? ", current cover" : "") + readonly property bool currentCover: Number(root.entry.gridCoverId) === Number(modelData.id) + && [root.entry.portrait, root.entry.selectedCoverPath].filter(Boolean).some(function(path) { + return String(path).replace(/^file:\/\//, "") === String(root.game.coverPath || "").replace(/^file:\/\//, "") + }) + property Item controllerLeftTarget: index % coverGrid.columns ? root.coverAt(index - 1) : null + property Item controllerRightTarget: index % coverGrid.columns < coverGrid.columns - 1 ? root.coverAt(index + 1) : null + property Item controllerUpTarget: index >= coverGrid.columns ? root.coverAt(index - coverGrid.columns) : customImagesButton + property Item controllerDownTarget: index + coverGrid.columns < coverRepeater.count ? root.coverAt(index + coverGrid.columns) : null + enabled: Metadata && !Metadata.busy + onClicked: Metadata.chooseCover(index) + contentItem: Item { + Image { + id: coverImage + anchors.fill: parent + source: tile.modelData.url + asynchronous: true + fillMode: Image.PreserveAspectFit + sourceSize.width: 300 + } + Text { + anchors.centerIn: parent + width: parent.width - 12 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + visible: coverImage.status === Image.Error + text: "Preview unavailable" + color: Theme.mutedText; font.family: Theme.fontFamily; font.pixelSize: 11 * root.uiScale + } + Rectangle { + visible: tile.currentCover + anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom + height: 26 * root.uiScale + color: Theme.background + Text { + anchors.centerIn: parent + text: "CURRENT" + color: Theme.accent; font.family: Theme.fontFamily; font.pixelSize: 11 * root.uiScale + } + } + } } } } Text { Layout.fillWidth: true; wrapMode: Text.Wrap - text: "Ratings from IGDB. Portraits from SteamGridDB. Your custom cover always takes priority. Connections are managed in Settings." + text: "Covers from SteamGridDB · Select a cover to apply it" + visible: root.coverChoices.length > 0 color: Theme.mutedText; font.family: Theme.fontFamily; font.pixelSize: 10 * root.uiScale } } diff --git a/qml/components/GlassButton.qml b/qml/components/GlassButton.qml index 17870d7..e50d631 100644 --- a/qml/components/GlassButton.qml +++ b/qml/components/GlassButton.qml @@ -4,6 +4,7 @@ import QtQuick.Controls Button { id: root + property real maximumLabelWidth: Infinity property string iconText: "" property bool primary: false property bool selected: false @@ -62,6 +63,10 @@ Button { } } + ToolTip.visible: hovered && buttonLabel.truncated + ToolTip.text: text + ToolTip.delay: 500 + background: Rectangle { radius: Math.max(4, Theme.cornerRadius) color: root.down @@ -106,6 +111,9 @@ Button { } Text { + id: buttonLabel + width: Math.min(implicitWidth, root.maximumLabelWidth) + elide: Text.ElideRight text: root.text color: root.enabled ? (root.primary ? Theme.brightForeground : Theme.foreground) : root.alpha(Theme.foreground, 0.35) diff --git a/qml/components/LaunchFeedback.qml b/qml/components/LaunchFeedback.qml new file mode 100644 index 0000000..d0536d1 --- /dev/null +++ b/qml/components/LaunchFeedback.qml @@ -0,0 +1,42 @@ +import QtQuick + +QtObject { + id: root + property var request: ({}) + property bool pending: false + property bool failed: false + property string message: "" + signal dispatchRequested(var request) + + function begin(value) { + if (pending) return false + // Selection can change before dispatch. Keep the installation the user chose. + request = JSON.parse(JSON.stringify(value)) + failed = false + message = "Opening " + request.title + "..." + pending = true + dispatch.start() + return true + } + + function finish(success, text) { + dispatch.stop() + cooldown.stop() + pending = success + failed = !success + message = text + if (success) cooldown.start() + else pending = false + } + + // Give the pressed button and status text a frame to appear before starting + // the external launcher. Repeated keyboard/controller presses are ignored. + property Timer dispatch: Timer { + interval: 50 + onTriggered: root.dispatchRequested(root.request) + } + property Timer cooldown: Timer { + interval: 2000 + onTriggered: { root.pending = false; root.message = "" } + } +} diff --git a/qml/components/LibraryView.qml b/qml/components/LibraryView.qml index 1a14fd7..e2ecac2 100644 --- a/qml/components/LibraryView.qml +++ b/qml/components/LibraryView.qml @@ -85,7 +85,9 @@ Item { highlightFollowsCurrentItem: true highlightMoveDuration: 110 cacheBuffer: height * 0.25 - reuseItems: true + // Reused delegates can retain stale caption positions after hidden + // Recent updates. Keep normal viewport caching, without the reuse pool. + reuseItems: false focus: true property real wheelTargetY: contentY // Filtering can move the first row without moving retained delegates. @@ -193,6 +195,7 @@ Item { required property string title required property string subtitle required property int hours + required property string playtimeText required property int rating required property int progress required property bool favorite @@ -238,6 +241,7 @@ Item { subtitle: Library.consoleFilter.length > 0 && delegateRoot.source.length > 0 ? delegateRoot.source : delegateRoot.subtitle hours: delegateRoot.hours + playtimeText: delegateRoot.playtimeText rating: delegateRoot.rating progress: delegateRoot.progress favorite: delegateRoot.favorite diff --git a/qml/components/MenuAction.qml b/qml/components/MenuAction.qml new file mode 100644 index 0000000..b07c6f6 --- /dev/null +++ b/qml/components/MenuAction.qml @@ -0,0 +1,12 @@ +import QtQuick +import QtQuick.Layouts + +// Vertical menu actions share a full-width hit target and a stable label column. +// Source/filter chips inside a Flow keep their content-sized GlassButton style. +GlassButton { + Layout.fillWidth: true + Layout.minimumWidth: 0 + compact: true + implicitWidth: 120 * displayScale + maximumLabelWidth: Math.max(0, width - leftPadding - rightPadding) +} diff --git a/qml/components/SettingsPanel.qml b/qml/components/SettingsPanel.qml index 4537c34..065a585 100644 --- a/qml/components/SettingsPanel.qml +++ b/qml/components/SettingsPanel.qml @@ -5,9 +5,8 @@ import QtQuick.Layouts Rectangle { id: settingsOverlay objectName: "settingsOverlay" - component ConnectionButton: GlassButton { + component WrappingButton: GlassButton { id: connectionButton - readonly property bool connectionStatusButton: true Layout.fillWidth: true compact: true implicitWidth: 100 @@ -24,17 +23,19 @@ import QtQuick.Layouts verticalAlignment: Text.AlignVCenter } } - function reveal(item) { if (host.isWithin(item, settingsScroll)) host.revealInScrollView(settingsScroll, item) } - // Every connection row reports the same three states from the service that owns it. - // Credentials are stored in the keyring, so "connected" means Omakade holds what the - // provider needs, and a provider that answered with a problem says so instead. + component ConnectionButton: WrappingButton { readonly property bool connectionStatusButton: true } + function reveal(item) { + if (host.isWithin(item, settingsScroll)) host.revealInScrollView(settingsScroll, item) + else if (host.isWithin(item, categoryList)) host.revealInScrollView(categoryList, item) + } + // Configuration and a successful provider request are different states. readonly property var connectionProblems: ["invalid-key", "private", "rate-limited", "unsupported", "error"] function connectionLabel(name, ready, state) { if (!ready) return name + " · NOT CONNECTED" if (state && settingsOverlay.connectionProblems.indexOf(state) >= 0) return name + " · CHECK SETTINGS" - return name + " · CONNECTED" + return name + " · CONFIGURED" } // Main.qml owns the GOG folder actions, but the field lives here. function focusGogFolderField() { @@ -43,6 +44,10 @@ import QtQuick.Layouts gogLibraryPathField.forceActiveFocus() settingsOverlay.reveal(gogLibraryPathField) } + function focusCollections() { + chooseSection(1) + Qt.callLater(function() { host.revealInScrollView(settingsScroll, collectionsHeading) }) + } required property var host property int libraryCount: 0 property int section: 0 @@ -50,7 +55,22 @@ import QtQuick.Layouts property bool availableSources: false property string sourceSearch: "" property string sourceDetail: "" - readonly property var sections: ["Sources", "Library", "Connections", "Controls & streaming", "About & storage"] + property bool categoriesOpen: false + readonly property var sections: [ + {label: "Sources", section: 0}, {label: "Library & launching", section: 1}, + {label: "Appearance", section: 5}, {label: "Controls", section: 3}, + {label: "Connections", section: 2}, {label: "Streaming", section: 6}, + {label: "Backup & storage", section: 4}, {label: "About & help", section: 7} + ] + function sectionLabel() { + for (const item of sections) if (item.section === section) return item.label + return "Settings" + } + function chooseSection(value) { + categoriesOpen = false + section = value + pageChanged() + } function pageChanged() { Qt.callLater(function() { settingsScroll.contentItem.contentY = 0 @@ -58,6 +78,7 @@ import QtQuick.Layouts }) } function back() { + if (categoriesOpen) { categoriesOpen = false; compactSections.forceActiveFocus(); return } if (section === 0 && sourceDetail) { sourceDetail = ""; pageChanged(); return } if (section === 2 && connection >= 0) { connection = -1; pageChanged(); return } host.diagnosticsOpen = false @@ -71,6 +92,7 @@ import QtQuick.Layouts color: host.alpha(Theme.darkerBackground, 0.72) onVisibleChanged: { if (visible) { + if (SessionRecorderStatus) SessionRecorderStatus.refreshRecorderStatus() previousFocus = host.activeFocusItem Qt.callLater(function() { host.focusWithin(settingsOverlay, true) }) } else if (previousFocus) { @@ -118,37 +140,59 @@ import QtQuick.Layouts spacing: 8 Repeater { model: settingsOverlay.sections - GlassButton { + WrappingButton { required property int index - required property string modelData - objectName: "settingsSection" + index + required property var modelData + objectName: "settingsSection" + modelData.section Layout.fillWidth: true; compact: true - text: modelData.toUpperCase(); selected: settingsOverlay.section === index - onClicked: settingsOverlay.section = index + text: modelData.label.toUpperCase(); selected: settingsOverlay.section === modelData.section + onClicked: settingsOverlay.chooseSection(modelData.section) property Item controllerRightTarget: null Keys.onRightPressed: event => { host.focusWithin(settingsScroll, true); event.accepted = true } property Item controllerUpTarget: index === 0 ? closeSettings : null } } } - Flow { + GlassButton { id: compactSections + objectName: "settingsCategoryButton" visible: !sectionNavigation.visible - anchors.left: parent.left; anchors.right: parent.right; anchors.top: settingsHeader.bottom; anchors.margins: 20 - spacing: 6 - Repeater { - model: settingsOverlay.sections - GlassButton { - required property int index - required property string modelData - compact: true; text: modelData.toUpperCase(); selected: settingsOverlay.section === index - onClicked: settingsOverlay.section = index - Accessible.name: modelData + " settings" + anchors.left: parent.left; anchors.right: parent.right; anchors.top: settingsHeader.bottom + anchors.margins: 20 + text: settingsOverlay.categoriesOpen ? "BACK TO SETTINGS" : settingsOverlay.sectionLabel().toUpperCase() + " · CHANGE CATEGORY" + compact: true + onClicked: { + settingsOverlay.categoriesOpen = !settingsOverlay.categoriesOpen + if (settingsOverlay.categoriesOpen) Qt.callLater(function() { host.focusWithin(categoryList, true) }) + else settingsOverlay.pageChanged() + } + } + ScrollView { + id: categoryList + visible: !sectionNavigation.visible && settingsOverlay.categoriesOpen + anchors.left: parent.left; anchors.right: parent.right + anchors.top: compactSections.bottom; anchors.bottom: parent.bottom + anchors.margins: 20 + contentWidth: availableWidth + ColumnLayout { + width: categoryList.availableWidth + spacing: 8 + Repeater { + model: settingsOverlay.sections + WrappingButton { + required property var modelData + objectName: "compactSettingsSection" + modelData.section + text: modelData.label.toUpperCase() + selected: settingsOverlay.section === modelData.section + onClicked: settingsOverlay.chooseSection(modelData.section) + } } } } + ScrollView { id: settingsScroll + visible: sectionNavigation.visible || !settingsOverlay.categoriesOpen objectName: "settingsScroll" readonly property real navigationContentY: contentItem ? contentItem.contentY : 0 anchors.left: sectionNavigation.visible ? sectionNavigation.right : parent.left @@ -462,7 +506,7 @@ import QtQuick.Layouts model: Preferences.romFolders RowLayout { required property int index - required property string modelData + required property var modelData Layout.fillWidth: true spacing: 8 Text { @@ -524,7 +568,7 @@ import QtQuick.Layouts Repeater { model: Preferences.gogLibraryPaths ColumnLayout { - required property string modelData + required property var modelData required property int index Layout.fillWidth: true RowLayout { @@ -623,13 +667,6 @@ import QtQuick.Layouts Layout.fillWidth: true spacing: 14 visible: settingsOverlay.section === 1 - CoverSizeControl { Layout.fillWidth: true; uiScale: settingsPanel.uiScale } - CoverSizeControl { Layout.fillWidth: true; couch: true; uiScale: settingsPanel.uiScale } - RowLayout { - Layout.fillWidth: true - Text { text: "CONSOLE VIEW"; color: Theme.foreground; font.family: Theme.fontFamily; Layout.fillWidth: true } - GlassButton { compact: true; text: Preferences.expandConsoles ? "GAMES" : "CONSOLES"; onClicked: Preferences.expandConsoles = !Preferences.expandConsoles } - } ColumnLayout { Layout.fillWidth: true visible: true @@ -704,6 +741,7 @@ import QtQuick.Layouts } } Text { + id: collectionsHeading text: "LIBRARY COLLECTIONS" color: Theme.brightForeground font.family: Theme.fontFamily @@ -720,7 +758,7 @@ import QtQuick.Layouts Repeater { model: Library.collectionNames RowLayout { - required property string modelData + required property var modelData Layout.fillWidth: true Text { Layout.fillWidth: true @@ -740,20 +778,52 @@ import QtQuick.Layouts } } } - Flow { Layout.fillWidth: true; spacing: 8 + Flow { + Layout.fillWidth: true + spacing: 8 GlassButton { - Layout.fillWidth: true - compact: true - text: Preferences.reducedMotion ? "MOTION OFF" : "MOTION ON" - selected: Preferences.reducedMotion - onClicked: Preferences.reducedMotion = !Preferences.reducedMotion - } GlassButton { - Layout.fillWidth: true compact: true text: "AUTO-CLOSE: " + (Preferences.closeAfterLaunch ? "ON" : "OFF") selected: Preferences.closeAfterLaunch onClicked: Preferences.closeAfterLaunch = !Preferences.closeAfterLaunch - } } + } + GlassButton { + compact: true + text: "RECORD PLAYTIME: " + (Preferences.trackPlaySessions ? "ON" : "OFF") + selected: Preferences.trackPlaySessions + onClicked: Preferences.trackPlaySessions = !Preferences.trackPlaySessions + } + } + Text { + objectName: "recorderStatusText" + Layout.fillWidth: true + text: !SessionRecorderStatus ? "Recorder status is unavailable in this preview." + : !SessionRecorderStatus.storageAvailable ? "Playtime storage is unavailable." + : SessionRecorderStatus.recorderRunning + ? "Recorder running. " + (Preferences.trackPlaySessions ? "Session detection is enabled." : "Recording is switched off.") + : "Recorder not running. " + (Preferences.trackPlaySessions ? "Recording is enabled, but new sessions will not be recorded." : "Recording is switched off.") + color: Theme.brightForeground + font.family: Theme.fontFamily + font.pixelSize: 12 * settingsPanel.uiScale + wrapMode: Text.Wrap + } + Text { + Layout.fillWidth: true + text: "Recording runs separately from Omakade and continues when this window closes. Paused emulator time counts. Imported and recorded totals can overlap; they are not simply added together. Switching recording off keeps your history and displays imported time." + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: 11 * settingsPanel.uiScale + wrapMode: Text.Wrap + } + Text { + Layout.fillWidth: true + visible: !!SessionRecorderStatus && !SessionRecorderStatus.recorderRunning && Preferences.trackPlaySessions + text: "Start the recorder in a terminal: systemctl --user enable --now omakade-sessiond" + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: 11 * settingsPanel.uiScale + wrapMode: Text.Wrap + } } ColumnLayout { Layout.fillWidth: true @@ -1197,6 +1267,30 @@ import QtQuick.Layouts visible: settingsOverlay.section === 3 Text { Layout.fillWidth: true; text: Controller.connected ? "CONTROLLER · " + Controller.name : "CONTROLLER · NOT CONNECTED"; color: Theme.foreground; font.family: Theme.fontFamily; font.pixelSize: 12 * settingsPanel.uiScale } GlassButton { compact: true; text: host.couchMode ? "SWITCH TO DESKTOP" : "SWITCH TO COUCH MODE"; onClicked: host.setCouchMode(!host.couchMode) } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + visible: settingsOverlay.section === 5 + CoverSizeControl { Layout.fillWidth: true; uiScale: settingsPanel.uiScale } + CoverSizeControl { Layout.fillWidth: true; couch: true; uiScale: settingsPanel.uiScale } + RowLayout { + Layout.fillWidth: true + Text { text: "CONSOLE VIEW"; color: Theme.foreground; font.family: Theme.fontFamily; Layout.fillWidth: true } + GlassButton { compact: true; text: Preferences.expandConsoles ? "GAMES" : "CONSOLES"; onClicked: Preferences.expandConsoles = !Preferences.expandConsoles } + } +GlassButton { + Layout.fillWidth: true + compact: true + text: Preferences.reducedMotion ? "MOTION OFF" : "MOTION ON" + selected: Preferences.reducedMotion + onClicked: Preferences.reducedMotion = !Preferences.reducedMotion + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + visible: settingsOverlay.section === 6 Text { text: "STREAM WITH SUNSHINE AND MOONLIGHT" color: Theme.brightForeground @@ -1273,19 +1367,12 @@ import QtQuick.Layouts onClicked: Sunshine.restartSunshine() } } + } ColumnLayout { Layout.fillWidth: true spacing: 14 - visible: settingsOverlay.section === 4 - GlassButton { compact: true; text: "CLEAR DOWNLOADED PORTRAITS"; enabled: Metadata && !Metadata.busy; onClicked: Metadata.clearPortraitCache() } - GlassButton { - objectName: "backupSettingsButton" - compact: true - text: "BACKUP & RESTORE" - enabled: Backups.available - onClicked: host.openBackupEditor() - } + visible: settingsOverlay.section === 7 Repeater { model: [ { label: "LIBRARY", value: settingsOverlay.libraryCount + " visible games" }, @@ -1314,7 +1401,40 @@ import QtQuick.Layouts elide: Text.ElideMiddle } } + } RowLayout { + Layout.topMargin: 8 + spacing: 8 + GlassButton { + compact: true + text: "PROJECT" + onClicked: Qt.openUrlExternally("https://github.com/btsouth/omakade") + } + GlassButton { + compact: true + text: "REPORT ISSUE" + onClicked: Qt.openUrlExternally("https://github.com/btsouth/omakade/issues/new/choose") + } + Item { Layout.fillWidth: true } + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + visible: settingsOverlay.section === 4 + Text { + Layout.fillWidth: true; wrapMode: Text.Wrap + text: "Back up Omakade organization and preferences. Emulator save files are not included. Clearing downloaded artwork does not remove game files." + color: Theme.mutedText; font.family: Theme.fontFamily + } + GlassButton { compact: true; text: "CLEAR DOWNLOADED PORTRAITS"; enabled: Metadata && !Metadata.busy; onClicked: Metadata.clearPortraitCache() } + GlassButton { + objectName: "backupSettingsButton" + compact: true + text: "BACKUP & RESTORE" + enabled: Backups.available + onClicked: host.openBackupEditor() } + Flow { Layout.fillWidth: true; spacing: 8 GlassButton { Layout.fillWidth: true @@ -1329,24 +1449,10 @@ import QtQuick.Layouts } GlassButton { Layout.fillWidth: true compact: true - text: "CLEAR ART" + text: "CLEAR ACHIEVEMENT ART" onClicked: Achievements.clearCache() } } - RowLayout { - Layout.topMargin: 8 - spacing: 8 - GlassButton { - compact: true - text: "PROJECT" - onClicked: Qt.openUrlExternally("https://github.com/btsouth/omakade") - } - GlassButton { - compact: true - text: "REPORT ISSUE" - onClicked: Qt.openUrlExternally("https://github.com/btsouth/omakade/issues/new/choose") - } - Item { Layout.fillWidth: true } - } + } } } diff --git a/qml/screens/ArtworkEditor.qml b/qml/screens/ArtworkEditor.qml index d92442e..fe8628c 100644 --- a/qml/screens/ArtworkEditor.qml +++ b/qml/screens/ArtworkEditor.qml @@ -52,7 +52,7 @@ Rectangle { Layout.fillWidth: true Text { Layout.fillWidth: true - text: "ARTWORK" + text: "CUSTOM IMAGES" color: Theme.brightForeground font.family: Theme.fontFamily font.pixelSize: 24 * editor.uiScale @@ -77,9 +77,10 @@ Rectangle { Repeater { model: [ { kind: "cover", title: "COVER", note: "Portrait artwork for the library", flag: "customCover" }, - { kind: "hero", title: "HERO", note: "Wide background for game details", flag: "customHero" }, - { kind: "logo", title: "LOGO", note: "Title artwork, including transparent images", flag: "customLogo" } - ] + { kind: "hero", title: "BACKGROUND", note: "Wide background for game details", flag: "customHero" } + ].concat(editor.couchMode || editor.game.customLogo + ? [{ kind: "logo", title: "COUCH LOGO", note: "Optional title artwork in the Couch library", flag: "customLogo" }] + : []) ColumnLayout { required property var modelData Layout.fillWidth: true @@ -152,7 +153,7 @@ Rectangle { onClicked: { editor.selectedKind = modelData.kind; artworkDialog.open() } } GlassButton { - text: "RESET" + text: "USE AUTOMATIC" compact: true enabled: editor.game[modelData.flag] || false displayScale: editor.uiScale diff --git a/qml/screens/BackupEditor.qml b/qml/screens/BackupEditor.qml index 3174257..2b8f98b 100644 --- a/qml/screens/BackupEditor.qml +++ b/qml/screens/BackupEditor.qml @@ -41,7 +41,15 @@ Rectangle { text += "\n" + p.artworkCount + " artwork files · " + p.settingsCount + " preferences\n" if (p.missingPathCount) text += "\nUNAVAILABLE PATHS (" + p.missingPathCount + ")\n" + p.missingPaths.join("\n") + "\nThese entries remain stored for repair or reconnection.\n" if (p.savedFilterNameConflicts.length) text += "\nRENAMED DURING MERGE\n" + p.savedFilterNameConflicts.join(", ") + "\n" - const labels = {reduced_motion:"Reduced motion", artwork_cache_limit_mb:"Artwork cache limit (MB)", steam_enabled:"Steam", lutris_enabled:"Lutris", heroic_enabled:"Heroic", gog_enabled:"GOG", faugus_enabled:"Faugus", retroarch_enabled:"RetroArch", pcsx2_enabled:"PCSX2", ryujinx_enabled:"Ryujinx", pcsx2_auto:"Detect PCSX2 automatically", ryujinx_auto:"Detect Ryujinx automatically", battlenet_enabled:"Battle.net", close_after_launch:"Close after launching", couch_mode:"Couch Mode", couch_library_view:"Couch library view", gog_library_paths:"GOG folders"} + const labels = {reduced_motion:"Reduced motion", artwork_cache_limit_mb:"Artwork cache limit (MB)", steam_enabled:"Steam", lutris_enabled:"Lutris", heroic_enabled:"Heroic", gog_enabled:"GOG", faugus_enabled:"Faugus", retroarch_enabled:"RetroArch", pcsx2_enabled:"PCSX2", ryujinx_enabled:"Ryujinx", pcsx2_auto:"Detect PCSX2 automatically", ryujinx_auto:"Detect Ryujinx automatically", battlenet_enabled:"Battle.net", close_after_launch:"Close after launching", couch_mode:"Couch Mode", couch_library_view:"Couch library view", gog_library_paths:"GOG folders", + library_sort_mode:"Library sort order", shadps4_enabled:"shadPS4", cemu_enabled:"Cemu", + dolphin_enabled:"Dolphin", shadps4_auto:"Detect shadPS4 automatically", + cemu_auto:"Detect Cemu automatically", dolphin_auto:"Detect Dolphin automatically", + console_portals_enabled:"Console cards", expand_consoles:"Expand consoles", + prefer_standalone_emulators:"Prefer standalone emulators", track_play_sessions:"Record playtime", + cover_size:"Desktop cover size", couch_cover_size:"Couch cover size", + console_expand_limit:"Console expansion limit", rom_folders:"ROM folders", + console_layouts:"Console layout choices"} text += "\nPREFERENCES (CURRENT → BACKUP)\n" for (const setting of p.settings) { const show = value => Array.isArray(value) ? value.join(", ") || "None" : value === true ? "On" : value === false ? "Off" : value === undefined ? "Default" : String(value) @@ -87,7 +95,7 @@ Rectangle { } Text { Layout.fillWidth: true - text: Backups.message || "Keep a local copy of your library choices, manual games, artwork, and preferences. Game files and account credentials are excluded." + text: Backups.message || "Keep a local copy of your library choices, game identifications, play history, manual games, artwork, and preferences. ROMs, emulator saves, save states, and account credentials are excluded." textFormat: Text.PlainText wrapMode: Text.Wrap color: Theme.foreground diff --git a/qml/screens/GameDetails.qml b/qml/screens/GameDetails.qml index 8b2ebfe..6fa6591 100644 --- a/qml/screens/GameDetails.qml +++ b/qml/screens/GameDetails.qml @@ -14,7 +14,19 @@ Item { required property var game required property var installations required property var selectedInstallation + readonly property var detailsEntry: gameInfoSection.entry || ({}) + readonly property int releaseYear: gameInfoSection.entry && gameInfoSection.entry.year > 0 + ? gameInfoSection.entry.year : (game.year || 0) + property bool showOrganizationControls: !DemoMode property bool collectionEditorOpen: false + property bool aliasesExpanded: false + property bool titleExpanded: false + property bool romDetailsExpanded: false + readonly property string displayTitle: root.game.source === "RetroArch" + ? (root.game.title || "").replace(/\s*\([^)]*\b(?:translated|translation|patch|patched|rev|revision|hack|fastrom)\b[^)]*\)/gi, "").trim() + : (root.game.title || "") + readonly property string detailIdentity: game.metadataKey || game.appId || game.title || "" + onDetailIdentityChanged: { aliasesExpanded = false; titleExpanded = false; romDetailsExpanded = false; gameInfoSection.expanded = false } property bool couchMode: false readonly property real uiScale: couchMode ? Math.max(1, Math.min(2.4, @@ -30,6 +42,9 @@ Item { newCollectionButton.forceActiveFocus() } property bool navigationEnabled: true + property bool launchBusy: false + property string launchMessage: "" + property bool launchFailed: false readonly property bool achievementSourceIsRetroArch: selectedInstallation.source === "RetroArch" readonly property var achievementAccount: achievementSourceIsRetroArch ? RetroAchievements : SteamAccount property bool randomSelection: false @@ -54,6 +69,15 @@ Item { signal collectionCreateRequested(string name) signal textEntryRequested(var target, string title, bool password, string placeholder) + function focusPrimary() { + playButton.forceActiveFocus(Qt.TabFocusReason) + const flickable = detailsScroll.navigationFlickable + if (flickable) flickable.contentY = flickable.originY + } + + function comparableTitle(value) { + return (value || "").toLowerCase().replace(/\([^)]*\)|\[[^\]]*\]/g, "").replace(/[\s_:.!?'-]+/g, "") + } function alpha(color, value) { return Qt.rgba(color.r, color.g, color.b, value) } @@ -65,7 +89,7 @@ Item { } let ancestor = item while (ancestor) { - if (ancestor === externalLinks || ancestor === backButton) { + if (ancestor === backButton) { flickable.contentY = flickable.originY return } @@ -126,18 +150,42 @@ Item { } Image { + id: detailsHeroImage + objectName: "detailsHero" anchors.top: parent.top - anchors.left: parent.left anchors.right: parent.right - height: root.couchMode ? parent.height * 0.68 - : Math.min(parent.height * 0.58, 500) - source: root.game.heroPath || "" + height: root.couchMode ? Math.min(parent.height * 0.60, 600 * root.uiScale) + : Math.min(parent.height * 0.50, 500) + width: Math.min(parent.width, height * 16 / 9) + // Never enlarge a portrait cover into a backdrop or reuse legacy first-artwork picks. + source: root.game.heroPath || (root.detailsEntry.heroKind === "screenshot" + && !root.detailsEntry.identityAmbiguous && !root.detailsEntry.rejected + ? root.detailsEntry.heroUrl || "" : "") asynchronous: true - cache: false - fillMode: Image.PreserveAspectCrop + cache: true + fillMode: Image.PreserveAspectFit + horizontalAlignment: Image.AlignRight + verticalAlignment: Image.AlignTop sourceSize.width: Math.ceil(width * Math.max(1, Screen.devicePixelRatio) / 64) * 64 sourceSize.height: Math.ceil(height * Math.max(1, Screen.devicePixelRatio) / 64) * 64 - opacity: status === Image.Ready ? 0.48 : 0 + opacity: status === Image.Ready ? 0.40 : 0 + } + + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: detailsHeroImage.height + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Theme.darkerBackground } + GradientStop { + position: Math.max(0, Math.min(0.8, 1 - detailsHeroImage.paintedWidth / root.width)) + color: Theme.darkerBackground + } + GradientStop { position: 1.0; color: "transparent" } + } + visible: detailsHeroImage.status === Image.Ready } Rectangle { @@ -154,6 +202,9 @@ Item { GlassButton { id: backButton + objectName: "detailsBackButton" + property Item controllerDownTarget: playButton + property Item controllerRightTarget: detailSettingsButton anchors.top: parent.top anchors.left: parent.left anchors.margins: root.couchMode ? 42 * root.uiScale : 24 @@ -163,6 +214,16 @@ Item { onClicked: root.backRequested() } + GlassButton { + id: detailSettingsButton + property Item controllerLeftTarget: backButton + property Item controllerDownTarget: detailManageButton + anchors.right: parent.right; anchors.top: parent.top + anchors.margins: root.couchMode ? 42 * root.uiScale : 24 + text: "SETTINGS"; compact: true + onClicked: root.Window.window.diagnosticsOpen = true + } + Item { id: detailsArea anchors.fill: parent @@ -185,9 +246,7 @@ Item { (detailsArea.height - reservedControlHeight) / 1.5, detailsArea.width * 0.4)) spacing: 8 - readonly property real reservedControlHeight: - (coverActions.visible ? coverActions.implicitHeight + spacing : 0) - + (linkActions.visible ? linkActions.implicitHeight + spacing : 0) + readonly property real reservedControlHeight: (coverEditButton.visible ? 42 * root.uiScale : 0) Rectangle { Layout.fillWidth: true @@ -244,6 +303,14 @@ Item { } } + MenuAction { + id: coverEditButton + objectName: "coverEditButton" + text: !(root.detailsEntry.igdbId > 0) ? "IDENTIFY GAME" + : !(root.game.coverPath || root.detailsEntry.portrait) ? "FIND COVER" : "GAME & ARTWORK" + visible: !DemoMode + onClicked: identifyPanel.open() + } GlassButton { objectName: "pickAnotherButton" visible: root.randomSelection @@ -252,37 +319,7 @@ Item { text: "PICK ANOTHER" onClicked: root.randomRequested() } - RowLayout { - id: coverActions - Layout.fillWidth: true - visible: !DemoMode - spacing: 8 - GlassButton { - Layout.fillWidth: true - compact: true - text: "ARTWORK" - onClicked: root.coverRequested() - } - GlassButton { - visible: root.game.customCover || false - compact: true - text: "RESET" - onClicked: root.coverResetRequested() - } - } - RowLayout { - id: linkActions - Layout.fillWidth: true - visible: !DemoMode - spacing: 8 - GlassButton { - Layout.fillWidth: true - compact: true - text: root.game.linked ? "UNLINK INSTALLATIONS" : "LINK INSTALLATION" - onClicked: root.game.linked ? root.unlinkRequested() : root.linkRequested() - } - } } ScrollView { @@ -304,160 +341,155 @@ Item { width: detailsScroll.availableWidth spacing: root.couchMode ? 20 * root.uiScale : 16 - Image { - id: gameLogo - objectName: "gameDetailsLogo" - Layout.fillWidth: true - Layout.preferredHeight: root.couchMode ? 110 * root.uiScale : 90 - visible: status === Image.Ready - source: root.game.logoPath || "" - sourceSize.width: 1200 - sourceSize.height: 360 - asynchronous: true - autoTransform: true - cache: false - fillMode: Image.PreserveAspectFit - horizontalAlignment: Image.AlignLeft - } Text { Layout.fillWidth: true - visible: gameLogo.status !== Image.Ready - text: root.game.title || "Unknown game" + id: gameTitle + objectName: "gameDetailsTitle" + maximumLineCount: root.titleExpanded ? 1000 : 3 + elide: Text.ElideRight + HoverHandler { id: titleHover } + ToolTip.visible: titleHover.hovered && gameTitle.truncated + ToolTip.text: root.game.title || "" + text: root.displayTitle || "Unknown game" textFormat: Text.PlainText color: Theme.brightForeground font.family: Theme.fontFamily font.pixelSize: root.couchMode - ? Math.max(42, Math.min(68, width * 0.075)) * root.uiScale - : Math.max(28, Math.min(54, width * 0.07)) + ? Math.max(28, Math.min(48, width * 0.065)) * root.uiScale + : Math.max(26, Math.min(44, width * 0.065)) font.weight: Font.Bold wrapMode: Text.Wrap } - RowLayout { - spacing: 10 + GlassButton { + objectName: "fullTitleButton" + visible: gameTitle.truncated || root.titleExpanded + compact: true + text: root.titleExpanded ? "SHORTEN TITLE" : "FULL TITLE" + onClicked: { root.titleExpanded = !root.titleExpanded; Qt.callLater(function() { root.revealFocusedItem(gameTitle) }) } + } + Flow { + id: identitySummary + objectName: "gameIdentitySummary" + Layout.fillWidth: true + spacing: 6 * root.uiScale Text { - text: (root.game.linked - ? root.game.linkedSources - : (root.game.subtitle || "GAME")).toUpperCase() + objectName: "gamePlatformRelease" + width: Math.min(implicitWidth, identitySummary.width) + text: { + const info = root.detailsEntry + const values = [] + const platform = info.platformText || root.game.system + if (platform) values.push(platform) + if (info.releaseText) values.push((info.releaseLabel || "First catalog release") + ": " + info.releaseText) + else if (root.releaseYear > 0) values.push(String(root.releaseYear)) + return values.join(" · ") + } + visible: text !== "" + textFormat: Text.PlainText color: Theme.accent font.family: Theme.fontFamily - font.pixelSize: 11 - font.weight: Font.DemiBold - } - Text { - visible: root.game.year > 0 - text: "·" - color: root.alpha(Theme.foreground, 0.4) + font.pixelSize: (root.couchMode ? 15 : 12) * root.uiScale + wrapMode: Text.Wrap } Text { - visible: root.game.year > 0 - text: root.game.year || "" - color: Theme.mutedText + id: gameRating + objectName: "gameRating" + visible: root.detailsEntry.rating >= 0 + width: Math.min(implicitWidth, identitySummary.width) + text: visible ? root.detailsEntry.rating + "/100 · IGDB" : "" + textFormat: Text.PlainText + color: Theme.accent font.family: Theme.fontFamily - font.pixelSize: 11 + font.pixelSize: (root.couchMode ? 15 : 12) * root.uiScale + wrapMode: Text.Wrap + Accessible.role: Accessible.StaticText + Accessible.name: text + (root.detailsEntry.ratingCount > 0 + ? ", " + root.detailsEntry.ratingCount + " IGDB ratings" : "") + HoverHandler { id: ratingHover; enabled: gameRating.visible } + ToolTip { + objectName: "gameRatingTooltip" + visible: gameRating.visible && ratingHover.hovered && root.detailsEntry.ratingCount > 0 + text: (root.detailsEntry.ratingCount || 0) + " IGDB ratings" + delay: 500 + x: 0 + y: gameRating.height + 4 + width: Math.min(implicitWidth, detailsScroll.availableWidth) + margins: 8 + } } } - - RowLayout { - id: externalLinks - spacing: 8 - visible: !DemoMode - - GlassButton { - visible: root.selectedInstallation.source === "Steam" - compact: true - text: "PROTONDB" - onClicked: Qt.openUrlExternally( - "https://www.protondb.com/app/" + root.selectedInstallation.appId) - } - - GlassButton { - objectName: "pcGamingWikiButton" - compact: true - text: "PCGAMINGWIKI" - onClicked: Qt.openUrlExternally( - "https://www.pcgamingwiki.com/w/index.php?search=" - + encodeURIComponent(root.game.title || "")) + Text { + objectName: "gameActivitySummary" + Layout.fillWidth: true + text: { + const values = [] + const seconds = root.game.playtimeSeconds || (root.game.hours || 0) * 3600 + values.push(seconds > 0 ? (root.game.playtimeText || root.game.hours + "h") + " played" + : root.game.lastPlayed > 0 ? "Less than a minute recorded" : "Not played in Omakade") + if (root.game.lastPlayed > 0) values.push("Last played " + Qt.formatDate(new Date(root.game.lastPlayed * 1000), "MMM d, yyyy")) + if (root.game.completionStatus) values.push(root.game.completionStatus.charAt(0).toUpperCase() + root.game.completionStatus.slice(1)) + const total = Achievements.total || root.game.achievementsTotal || 0 + if (total > 0) values.push((Achievements.total > 0 ? Achievements.unlocked : root.game.achievementsUnlocked || 0) + "/" + total + " achievements") + return values.join(" · ") } + color: Theme.foreground + font.family: Theme.fontFamily + font.pixelSize: (root.couchMode ? 15 : 12) * root.uiScale + wrapMode: Text.Wrap } - Text { + objectName: "playtimeProvenanceText" Layout.fillWidth: true - Layout.maximumWidth: 720 - text: root.game.description || "" - color: Theme.foreground - opacity: 0.84 + visible: text !== "" + text: root.selectedInstallation.playtimeProvenance || "" + color: Theme.mutedText font.family: Theme.fontFamily - font.pixelSize: root.couchMode ? 17 * root.uiScale : 13 - lineHeight: 1.45 + font.pixelSize: (root.couchMode ? 13 : 11) * root.uiScale wrapMode: Text.Wrap } - - ColumnLayout { + Text { + objectName: "launchInstallationSummary" Layout.fillWidth: true - visible: root.installations.length > 1 - spacing: 7 - Text { - text: "LAUNCH WITH" - color: Theme.mutedText - font.family: Theme.fontFamily - font.pixelSize: 9 - font.weight: Font.DemiBold - } - GridLayout { - Layout.fillWidth: true - columns: Math.max(1, Math.floor(detailsContent.width / 160)) - columnSpacing: 8 - rowSpacing: 8 - Repeater { - model: root.installations - GlassButton { - required property var modelData - required property int index - objectName: "installationChoice_" + index - compact: true - text: (modelData.source || "LOCAL").toUpperCase() - + (modelData.runner ? " · " + modelData.runner.toUpperCase() : "") - + (modelData.preferred ? " · DEFAULT" : "") - selected: root.selectedInstallation.source === modelData.source - && (root.selectedInstallation.runner || "") === (modelData.runner || "") - && root.selectedInstallation.appId === modelData.appId - onClicked: root.installationSelected(modelData) - } - } - } + text: "Launch with " + (root.selectedInstallation.source || "local installation") + + (root.selectedInstallation.runner ? " · " + root.selectedInstallation.runner : "") + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: (root.couchMode ? 14 : 11) * root.uiScale + wrapMode: Text.Wrap } - GlassButton { - objectName: "editManualGameButton" - visible: root.selectedInstallation.source === "Manual" - text: "EDIT MANUAL GAME" - compact: true - onClicked: root.manualEditRequested() - } - GlassButton { - objectName: "preferredInstallationButton" - visible: root.installations.length > 1 - compact: true - text: root.selectedInstallation.preferred ? "DEFAULT INSTALLATION" : "MAKE DEFAULT" - enabled: !root.selectedInstallation.preferred - onClicked: root.preferredInstallationRequested() - } Text { objectName: "preferredUnavailableText" Layout.fillWidth: true - visible: root.selectedInstallation.preferredUnavailable === true - text: "Your default installation is unavailable. Choose another installation or reconnect its drive." + visible: root.selectedInstallation.preferredUnavailable === true || (root.selectedInstallation.launchAvailable === false && root.selectedInstallation.installed !== false) + text: "This installation is unavailable. Choose another in Manage or reconnect its drive." color: Theme.mutedText font.family: Theme.fontFamily font.pixelSize: (root.couchMode ? 16 : 11) * root.uiScale wrapMode: Text.Wrap } + Text { + objectName: "launchStatusText" + Layout.fillWidth: true + visible: root.launchMessage !== "" + text: (root.launchFailed ? "Launch failed: " : "") + root.launchMessage + color: Theme.brightForeground + font.family: Theme.fontFamily + font.pixelSize: (root.couchMode ? 16 : 12) * root.uiScale + wrapMode: Text.Wrap + Accessible.role: Accessible.StaticText + Accessible.name: text + } + GridLayout { id: gameActions objectName: "gameActions" Layout.fillWidth: true + Layout.maximumWidth: columns * 220 * root.uiScale + (columns - 1) * columnSpacing + uniformCellWidths: true + Layout.alignment: Qt.AlignLeft // One column below the width where two buttons and their text fit, for the // same reason as the status grid: a GridLayout overflows rather than // shrinking a child under its own label. @@ -468,83 +500,292 @@ Item { GlassButton { id: playButton + Layout.fillWidth: true objectName: "playButton" + property Item controllerUpTarget: backButton property Item controllerRightTarget: favoriteButton property Item controllerDownTarget: - gameActions.columns === 2 ? manageButton : null - text: root.selectedInstallation.installed === false + gameActions.columns === 2 ? addToQueueButton : null + text: root.launchBusy ? "OPENING..." : root.selectedInstallation.installed === false ? "INSTALL IN STEAM" : "PLAY" iconText: root.selectedInstallation.installed === false ? "↓" : "▶" primary: true - onClicked: root.playRequested() + // Keep focus on this button while suppressing repeated launches. + Accessible.description: root.launchBusy ? "Launch request in progress" : "" + onClicked: if (!root.launchBusy) root.playRequested() Component.onCompleted: forceActiveFocus() } GlassButton { id: favoriteButton + Layout.fillWidth: true objectName: "favoriteButton" property Item controllerLeftTarget: playButton property Item controllerRightTarget: - gameActions.columns === 4 ? manageButton : null + gameActions.columns === 4 ? addToQueueButton : null property Item controllerDownTarget: - gameActions.columns === 2 ? hideButton : null + gameActions.columns === 2 ? detailManageButton : null text: root.game.favorite ? "FAVORITE" : "ADD FAVORITE" iconText: root.game.favorite ? "♥" : "♡" onClicked: root.favoriteRequested() } GlassButton { - id: manageButton - objectName: "manageButton" - property Item controllerLeftTarget: - gameActions.columns === 4 ? favoriteButton : null - property Item controllerRightTarget: hideButton - property Item controllerUpTarget: - gameActions.columns === 2 ? playButton : null - visible: root.selectedInstallation.source === "Steam" - || root.selectedInstallation.source === "Lutris" - || root.selectedInstallation.source === "Heroic" - || root.selectedInstallation.source === "GOG" - || root.selectedInstallation.source === "Faugus" - || root.selectedInstallation.source === "RetroArch" - || root.selectedInstallation.source === "PCSX2" - || root.selectedInstallation.source === "Ryujinx" - || root.selectedInstallation.source === "shadPS4" - || root.selectedInstallation.source === "Cemu" - || root.selectedInstallation.source === "Dolphin" - || root.selectedInstallation.source === "Battle.net" - text: "MANAGE IN " + (root.selectedInstallation.source || "LAUNCHER").toUpperCase() - onClicked: root.manageRequested() + id: addToQueueButton + Layout.fillWidth: true + objectName: "addToQueueButton" + property Item controllerRightTarget: detailManageButton + property string addedIdentity: "" + property string currentIdentity: root.game.metadataKey || "" + onCurrentIdentityChanged: { addedIdentity = ""; saveFailed = false } + property bool saveFailed: false + text: saveFailed ? "RETRY ADD TO UP NEXT" + : addedIdentity !== "" && addedIdentity === root.game.metadataKey ? "ADDED TO UP NEXT" : "ADD TO UP NEXT" + onClicked: { + saveFailed = !Home.enqueue(root.game.source, root.game.runner || "", root.game.appId) + if (!saveFailed) addedIdentity = root.game.metadataKey || "" + } } GlassButton { - id: hideButton - objectName: "hideButton" - property Item controllerLeftTarget: manageButton - property Item controllerUpTarget: - gameActions.columns === 2 ? favoriteButton : null - text: root.game.hidden ? "UNHIDE" : "HIDE" - onClicked: root.hiddenRequested() + id: detailManageButton + Layout.fillWidth: true + objectName: "detailManageButton" + text: "MANAGE" + property Item controllerLeftTarget: addToQueueButton + property Item controllerUpTarget: gameActions.columns === 2 ? favoriteButton : null + onClicked: detailManage.open() + } + } + + ColumnLayout { + id: gameInfoSection + objectName: "gameInfoSection" + Layout.fillWidth: true + Layout.topMargin: 12 + spacing: 10 + property var entry: Metadata !== null ? Metadata.current : null + property bool expanded: false + + readonly property var facts: { + const info = gameInfoSection.entry + if (!info) { + return [] + } + const values = [] + if (info.genres && info.genres.length > 0) { + values.push(info.genres.join(" · ")) + } + return values + } + readonly property string credits: { + const info = gameInfoSection.entry + if (!info) { + return "" + } + const parts = [] + if (info.developers && info.developers.length > 0) { + parts.push("Developed by " + info.developers.join(", ")) + } + if (info.publishers && info.publishers.length > 0) { + parts.push("Published by " + info.publishers.join(", ")) + } + return parts.join(". ") + } + readonly property string background: + (gameInfoSection.entry ? gameInfoSection.entry.summary : "") || root.game.description || "" + visible: !game.isPortal + && (gameInfoSection.facts.length > 0 || gameInfoSection.credits !== "" + || gameInfoSection.background !== "" + || (!DemoMode && Metadata && Metadata.selectedStatus !== "")) + + RowLayout { + Layout.fillWidth: true + Text { + text: "ABOUT THE GAME" + color: Theme.brightForeground + font.family: Theme.fontFamily + font.pixelSize: 13 * root.uiScale + font.weight: Font.Bold + font.letterSpacing: 0.6 + } + Item { Layout.fillWidth: true } + } + RowLayout { + Layout.fillWidth: true + visible: !DemoMode && Metadata && Metadata.selectedStatus !== "" + Text { + Layout.fillWidth: true + text: Metadata ? Metadata.selectedStatus : "" + wrapMode: Text.Wrap + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: 13 * root.uiScale + } + GlassButton { + objectName: "detailsRetryButton" + text: "RETRY" + compact: true + enabled: Metadata && !Metadata.busy && (Metadata.selectedWritePending || (Insights && Insights.configured)) + onClicked: Metadata.refreshSelected() + } + } + GridLayout { + Layout.fillWidth: true + Layout.maximumWidth: 760 * root.uiScale + columns: 1 + rowSpacing: 6 + Text { + Layout.fillWidth: true + visible: gameInfoSection.background !== "" + Layout.row: gameInfoSection.expanded ? 1 : 0 + id: gameDescription + objectName: "gameDescription" + text: gameInfoSection.background + Layout.maximumWidth: 760 * root.uiScale + textFormat: Text.PlainText + maximumLineCount: gameInfoSection.expanded ? 1000 : 3 + elide: Text.ElideRight + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: (root.couchMode ? 17 : 13) * root.uiScale + lineHeight: 1.3 + wrapMode: Text.Wrap + } + GlassButton { + Layout.row: gameInfoSection.expanded ? 0 : 1 + id: descriptionToggle + objectName: "descriptionToggle" + visible: gameDescription.truncated || gameInfoSection.expanded + compact: true + text: gameInfoSection.expanded ? "READ LESS" : "READ MORE" + property Item controllerUpTarget: playButton + property Item controllerDownTarget: aliasesToggle.visible ? aliasesToggle : statusButtons.firstControl + onClicked: { + gameInfoSection.expanded = !gameInfoSection.expanded + Qt.callLater(function() { root.revealFocusedItem(descriptionToggle) }) + } + } + } + Text { + Layout.fillWidth: true + visible: gameInfoSection.facts.length > 0 + text: gameInfoSection.facts.join(" · ") + textFormat: Text.PlainText + color: Theme.brightForeground + font.family: Theme.fontFamily + font.pixelSize: (root.couchMode ? 15 : 12) * root.uiScale + wrapMode: Text.Wrap + } + Text { + objectName: "gameCredits" + Layout.fillWidth: true + visible: gameInfoSection.credits !== "" + text: gameInfoSection.credits + textFormat: Text.PlainText + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: (root.couchMode ? 15 : 12) * root.uiScale + wrapMode: Text.Wrap + } + Text { + objectName: "regionalIdentityText" + Layout.fillWidth: true + readonly property var info: gameInfoSection.entry || ({}) + text: { + const lines = [] + if (info.romContext) lines.push(info.romContext) + if (info.title && root.comparableTitle(info.title) !== root.comparableTitle(info.localTitle || root.game.title)) { + lines.push("Catalog title: " + (info.title || "")) + + } + return lines.join("\n") + } + visible: text !== "" + textFormat: Text.PlainText + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: (root.couchMode ? 15 : 12) * root.uiScale + wrapMode: Text.Wrap + } + GlassButton { + id: aliasesToggle + property Item controllerUpTarget: descriptionToggle.visible ? descriptionToggle : playButton + property Item controllerDownTarget: statusButtons.firstControl + objectName: "aliasesToggle" + readonly property var names: ((gameInfoSection.entry || {}).titleEvidence || []).filter((name, index, all) => all.indexOf(name) === index) + visible: names.length > 0 + compact: true + text: (root.aliasesExpanded ? "HIDE OTHER NAMES" : "OTHER NAMES") + " (" + names.length + ")" + onClicked: root.aliasesExpanded = !root.aliasesExpanded + } + Text { + objectName: "aliasesText" + Layout.fillWidth: true + visible: aliasesToggle.visible && root.aliasesExpanded + text: aliasesToggle.names.join("\n") + textFormat: Text.PlainText + wrapMode: Text.Wrap + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: (root.couchMode ? 15 : 12) * root.uiScale + } + Flow { + Layout.fillWidth: true + id: externalLinks + spacing: 8 + visible: !DemoMode + + GlassButton { + visible: root.selectedInstallation.source === "Steam" + compact: true + text: "PROTONDB" + onClicked: Qt.openUrlExternally( + "https://www.protondb.com/app/" + root.selectedInstallation.appId) + } + + GlassButton { + objectName: "pcGamingWikiButton" + compact: true + text: "PCGAMINGWIKI" + onClicked: Qt.openUrlExternally( + "https://www.pcgamingwiki.com/w/index.php?search=" + + encodeURIComponent(root.game.title || "")) + } } GlassButton { - id: pinButton - objectName: "pinButton" - // Games of a system that lives behind a console card can - // still hold a spot in the main library. - visible: !root.game.isPortal && !!root.game.system - && Preferences.consolePortalsEnabled - && Preferences.consoleLayout(root.game.system) === "card" - property Item controllerLeftTarget: hideButton - text: root.game.pinned ? "REMOVE FROM LIBRARY" : "SHOW IN LIBRARY" - onClicked: root.pinRequested() + objectName: "romDetailsToggle" + visible: root.displayTitle !== (root.game.title || "") + compact: true + text: root.romDetailsExpanded ? "HIDE ROM DETAILS" : "ROM DETAILS" + onClicked: root.romDetailsExpanded = !root.romDetailsExpanded + } + Text { + Layout.fillWidth: true + Layout.maximumWidth: 760 * root.uiScale + visible: root.romDetailsExpanded + text: (root.detailsEntry.romFilename || root.game.title || "") + textFormat: Text.PlainText + wrapMode: Text.Wrap + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: 12 * root.uiScale + } + Text { + Layout.fillWidth: true + text: "Game information from IGDB" + textFormat: Text.PlainText + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: 10 * root.uiScale } } ColumnLayout { Layout.fillWidth: true Layout.topMargin: 8 - visible: !DemoMode + visible: root.showOrganizationControls spacing: 9 Text { @@ -577,9 +818,16 @@ Item { Layout.columnSpan: statusLayout.columns === 2 ? 2 : 1 } Repeater { + id: statusButtons + property Item firstControl: null + onItemAdded: function(index, item) { if (index === 0) firstControl = item } + onItemRemoved: function(index, item) { if (firstControl === item) firstControl = null } model: ["backlog", "playing", "completed", "abandoned"] GlassButton { required property string modelData + required property int index + objectName: "completionStatus-" + modelData + property Item controllerUpTarget: index === 0 ? (aliasesToggle.visible ? aliasesToggle : descriptionToggle.visible ? descriptionToggle : playButton) : null compact: true Layout.fillWidth: true text: modelData.toUpperCase() @@ -602,6 +850,7 @@ Item { } TextField { id: tagsField + objectName: "detailsTagsField" property Item controllerRightTarget: tagsFieldClear.visible ? tagsFieldClear : null rightPadding: tagsFieldClear.reservedWidth FieldClearButton { id: tagsFieldClear; field: tagsField } @@ -788,75 +1037,6 @@ Item { } } - GridLayout { - Layout.fillWidth: true - Layout.topMargin: 12 - columns: detailsContent.width < 520 ? 1 : 3 - columnSpacing: 10 - rowSpacing: 10 - - Repeater { - model: root.selectedInstallation.source === "Steam" - ? [ - { label: "PLAYTIME", value: (root.game.hours || 0) + " HOURS" }, - { label: "ACHIEVEMENTS", value: (Achievements.unlocked || root.game.achievementsUnlocked || 0) + " / " + (Achievements.total || root.game.achievementsTotal || 0) }, - { label: "COMPLETION", value: Achievements.total > 0 ? Math.round(Achievements.unlocked * 100 / Achievements.total) + "%" : (root.game.progress || 0) + "%" } - ] - : [ - { label: "PLAYTIME", value: (root.game.hours || 0) + " HOURS" }, - { label: "SOURCE", value: (root.selectedInstallation.source || "LOCAL").toUpperCase() }, - { label: "LAUNCHER", value: (root.selectedInstallation.subtitle || root.selectedInstallation.source || "LOCAL").toUpperCase() } - ] - - Rectangle { - required property var modelData - Layout.fillWidth: true - Layout.minimumWidth: 150 - Layout.preferredHeight: 88 - radius: Math.max(5, Theme.cornerRadius) - color: root.alpha(Theme.foreground, 0.045) - border.color: root.alpha(Theme.foreground, 0.13) - - Column { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 16 - spacing: 7 - Text { - text: modelData.label - color: Theme.mutedText - font.family: Theme.fontFamily - font.pixelSize: 9 - font.weight: Font.DemiBold - } - Text { - text: modelData.value - color: Theme.brightForeground - font.family: Theme.fontFamily - font.pixelSize: 16 - font.weight: Font.DemiBold - } - } - } - } - } - - GameMetadataEditor { - id: metadataEditor - objectName: "metadataEditor" - game: root.game - couchMode: root.couchMode - uiScale: root.uiScale - previousSection: newCollectionButton - nextSection: insightRefreshButton.visible && insightRefreshButton.enabled - ? insightRefreshButton - : achievementSortButton.visible && achievementSortButton.enabled - ? achievementSortButton - : achievementRefreshButton.visible && achievementRefreshButton.enabled - ? achievementRefreshButton : null - onTextEntryRequested: (target, title, password, placeholder) => root.textEntryRequested(target, title, password, placeholder) - } - ColumnLayout { id: insightsSection objectName: "insightsSection" @@ -912,9 +1092,9 @@ Item { ? achievementRefreshButton : null compact: true text: Insights && Insights.configured - ? (Insights.busy ? "REFRESHING" : "REFRESH") + ? (Insights.refreshing ? "REFRESHING" : "REFRESH") : "CONNECT IGDB" - enabled: Insights && !Insights.busy + enabled: Insights && !Insights.refreshing onClicked: { if (Insights.configured) { Insights.refreshSteam(root.selectedInstallation.appId) @@ -1245,6 +1425,9 @@ Item { } Row { + id: detailsFooter + objectName: "detailsFooter" + parent: root anchors.right: parent.right anchors.bottom: parent.bottom anchors.rightMargin: 54 * root.uiScale @@ -1296,4 +1479,145 @@ Item { } } } + ActionMenu { + id: identifyPanel + objectName: "identifyGamePanel" + host: root.Window.window + anchorItem: coverEditButton + title: "GAME & ARTWORK" + width: Math.min(760 * root.uiScale, root.width - 48) + height: Math.min(implicitHeight, host.height - 48, 820 * root.uiScale) + showCloseButton: false + fixedHeader: true + doneObjectName: "metadataArtworkButton" + headerDownTarget: metadataEditor.firstBodyControl + GameMetadataEditor { + id: metadataEditor + objectName: "metadataEditor" + panelMode: true + externalDone: identifyPanel.doneControl + onLocalArtworkRequested: identifyPanel.invoke(root.coverRequested) + onConnectionsRequested: identifyPanel.invoke(root.connectRequested) + game: root.game + couchMode: root.couchMode + uiScale: root.uiScale + previousSection: null + nextSection: null + onTextEntryRequested: (target, title, password, placeholder) => root.textEntryRequested(target, title, password, placeholder) + } + + } + Connections { + target: identifyPanel + function onOpened() { + metadataEditor.editing = true + Qt.callLater(function() { root.Window.window.focusWithin(identifyPanel.contentItem, true, metadataEditor.firstControl) }) + } + function onClosed() { metadataEditor.editing = false } + } + Connections { + target: metadataEditor + function onEditingChanged() { if (!metadataEditor.editing && identifyPanel.opened) identifyPanel.close() } + } + + ActionMenu { + id: detailManage + objectName: "detailManageMenu" + host: root.Window.window + anchorItem: detailManageButton + title: "MANAGE GAME" + ColumnLayout { + Layout.fillWidth: true + visible: root.installations.length > 1 + spacing: 7 + Text { + text: "LAUNCH WITH" + color: Theme.mutedText + font.family: Theme.fontFamily + font.pixelSize: 9 + font.weight: Font.DemiBold + } + GridLayout { + Layout.fillWidth: true + columns: 1 + columnSpacing: 8 + rowSpacing: 8 + Repeater { + id: installationButtons + model: root.installations + MenuAction { + required property var modelData + required property int index + + objectName: "installationChoice_" + index + compact: true + text: (modelData.source || "LOCAL").toUpperCase() + + (modelData.runner ? " · " + modelData.runner.toUpperCase() : "") + + (modelData.preferred ? " · DEFAULT" : "") + selected: root.selectedInstallation.source === modelData.source + && (root.selectedInstallation.runner || "") === (modelData.runner || "") + && root.selectedInstallation.appId === modelData.appId + onClicked: { root.installationSelected(modelData); detailManage.close() } + } + } + } + } + + + MenuAction { + id: manageButton + Layout.fillWidth: true + compact: true + objectName: "manageButton" + visible: root.selectedInstallation.source === "Steam" || root.selectedInstallation.source === "Lutris" || root.selectedInstallation.source === "Heroic" || root.selectedInstallation.source === "GOG" || root.selectedInstallation.source === "Faugus" || root.selectedInstallation.source === "RetroArch" || root.selectedInstallation.source === "PCSX2" || root.selectedInstallation.source === "Ryujinx" || root.selectedInstallation.source === "shadPS4" || root.selectedInstallation.source === "Cemu" || root.selectedInstallation.source === "Dolphin" || root.selectedInstallation.source === "Battle.net" + text: "MANAGE IN " + (root.selectedInstallation.source || "LAUNCHER").toUpperCase() + onClicked: detailManage.invoke(root.manageRequested) + } + MenuAction { + id: hideButton + Layout.fillWidth: true + compact: true + objectName: "hideButton" + text: root.game.hidden ? "UNHIDE" : "HIDE" + onClicked: detailManage.invoke(root.hiddenRequested) + } + MenuAction { + id: pinButton + Layout.fillWidth: true + compact: true + objectName: "pinButton" + // Games of a system that lives behind a console card can + // still hold a spot in the main library. + visible: !root.game.isPortal && !!root.game.system && Preferences.consolePortalsEnabled && Preferences.consoleLayout(root.game.system) === "card" + text: root.game.pinned ? "SHOW ONLY INSIDE CONSOLE" : "SHOW BESIDE CONSOLE" + onClicked: detailManage.invoke(root.pinRequested) + } + MenuAction { + Layout.fillWidth: true + compact: true + objectName: "editManualGameButton" + visible: root.selectedInstallation.source === "Manual" + text: "EDIT MANUAL GAME" + onClicked: detailManage.invoke(root.manualEditRequested) + } + MenuAction { + Layout.fillWidth: true + compact: true + objectName: "preferredInstallationButton" + visible: root.installations.length > 1 + text: root.selectedInstallation.preferred ? "DEFAULT INSTALLATION" : "MAKE DEFAULT" + enabled: !root.selectedInstallation.preferred + onClicked: detailManage.invoke(root.preferredInstallationRequested) + } + MenuAction { + Layout.fillWidth: true + compact: true + visible: !DemoMode + text: root.game.linked ? "UNLINK INSTALLATIONS" : "LINK INSTALLATION" + onClicked: detailManage.invoke(function () { + root.game.linked ? root.unlinkRequested() : root.linkRequested() + }) + } + } + } diff --git a/qml/screens/HomeScreen.qml b/qml/screens/HomeScreen.qml new file mode 100644 index 0000000..bd1cce3 --- /dev/null +++ b/qml/screens/HomeScreen.qml @@ -0,0 +1,437 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../components" + +FocusScope { + id: root + property bool couchMode: false + readonly property real scaleFactor: couchMode ? 1.25 : 1 + property string focusedIdentity: "" + property string focusedAction: "" + property bool launchBusy: false + property string notice: "" + property var menuGame: ({}) + property bool allPlaces: false + signal libraryRequested() + signal gameRequested(var game, string action) + signal playRequested(var game) + signal browseRequested(string kind, string value) + readonly property var featured: Home.recent.length ? Home.recent[0] : ({}) + readonly property var nextGame: Home.queue.length ? Home.queue[0] : Home.suggestions.length ? Home.suggestions[0] : ({}) + function focusHome() { libraryButton.forceActiveFocus() } + function focusKey(game) { return game.queueKey ? "queue:" + game.queueKey : game.identity || "" } + function focusIdentity(identity) { restoreIdentity(identity, "") } + function restoreIdentity(identity, action) { + if (identity && focusKey(featured) === identity) { + const target = action === "details" || !featuredPlay.enabled ? featuredOpen : featuredPlay + target.forceActiveFocus(); reveal(target); return + } + for (const repeater of [recentTiles, queueTiles, suggestionTiles]) { + for (let i = 0; i < repeater.count; ++i) { + const tile = repeater.itemAt(i) + if (tile && focusKey(tile.game) === identity) { + if (action === "actions") tile.focusActions() + else tile.focusTile() + return + } + } + } + focusHome() + } + function focusQueueActions(identity) { + for (let i = 0; i < queueTiles.count; ++i) { + const tile = queueTiles.itemAt(i) + if (tile && focusKey(tile.game) === identity) { tile.focusActions(); return } + } + } + function queueAction(game, operation) { + let identity = focusKey(game) + if (operation === "remove") { + const queued = Home.queue + const index = queued.findIndex(item => item.queueKey === game.queueKey) + const neighbor = queued[index + 1] || queued[index - 1] + identity = neighbor ? focusKey(neighbor) : "" + } + let okay = false + if (operation === "add") okay = Home.enqueue(game.source, game.runner || "", game.appId) + else if (operation === "remove") okay = Home.remove(game.queueKey) + else okay = Home.move(game.queueKey, operation === "up" ? -1 : 1) + notice = okay ? (operation === "add" ? "Added to Up next" : "") : (Home.error || "Could not update Up next.") + Qt.callLater(function() { root.focusIdentity(identity) }) + } + Connections { + target: Home + function onChanged() { + if (root.visible && root.activeFocus && root.focusedIdentity !== "") { + const identity = root.focusedIdentity + const action = root.focusedAction + Qt.callLater(function() { + const current = root.Window.window.activeFocusItem + if (root.visible && root.activeFocus && (!current || current.homeIdentity !== identity)) root.restoreIdentity(identity, action) + }) + } + } + } + function reveal(item) { + if (!item || !root.Window.window.isWithin(item, content)) return + scroll.stopWheelScroll("focus-reveal") + const y = item.mapToItem(content, 0, 0).y + if (y < scroll.contentY + 16) scroll.contentY = Math.max(0, y - 16) + else if (y + item.height > scroll.contentY + scroll.height - 16) + scroll.contentY = Math.min(Math.max(0, scroll.contentHeight - scroll.height), y + item.height - scroll.height + 16) + } + function navigate(current, key) { + if (current && key === Qt.Key_Up && root.Window.window.isWithin(current, featureRow)) { + focusHome() + return true + } + if (current === libraryButton && key === Qt.Key_Down) { + if (Home.recent.length) focusIdentity(focusKey(featured)) + else if (Home.queue.length) focusIdentity(focusKey(Home.queue[0])) + else if (Home.suggestions.length) focusIdentity(focusKey(Home.suggestions[0])) + else browseAll.forceActiveFocus() + return true + } + return false + } + function openGame(game, action = "tile") { + if (game.available) gameRequested(game, action) + else notice = "Reconnect the drive or enable this game's source in Settings." + } + function gameCaption(game) { + const parts = [game.system ? game.subtitle || game.source : game.source] + if (game.playtimeSeconds > 0) parts.push(game.playtimeText + " played") + return parts.filter(value => !!value).join(" · ") + } + + component SectionTitle: RowLayout { + property string title + property string caption: "" + property string actionText: "" + signal actionRequested() + Layout.fillWidth: true + Layout.topMargin: 12 + spacing: 12 + Text { text: title; color: Theme.brightForeground; font.family: Theme.fontFamily; font.pixelSize: 19 * root.scaleFactor; font.bold: true } + Text { Layout.fillWidth: true; text: caption; color: Theme.mutedText; font.family: Theme.fontFamily; elide: Text.ElideRight; horizontalAlignment: Text.AlignRight } + GlassButton { visible: actionText !== ""; text: actionText; compact: true; onClicked: actionRequested() } + } + // Shelf dimensions depend on the available width and item count, never on + // the implicit width of children that are themselves sized by the shelf. + component GameShelf: Item { + id: shelf + property var games: [] + property bool queued: false + property bool suggested: false + readonly property int columns: Math.max(2, Math.min(6, Math.floor(width / (175 * root.scaleFactor)))) + readonly property real gap: 16 + readonly property real tileWidth: Math.max(1, (width - (columns - 1) * gap) / columns) + readonly property real buttonScale: root.couchMode ? Math.max(1, Math.min(2.4, root.Window.window.height / 900)) : 1 + readonly property real tileHeight: tileWidth * 1.5 + 99 * root.scaleFactor + 34 * buttonScale + 14 + readonly property int count: tiles.count + Layout.fillWidth: true + implicitHeight: games.length ? Math.ceil(games.length / columns) * (tileHeight + gap) - gap : 0 + function itemAt(index) { return tiles.itemAt(index) } + Repeater { + id: tiles + model: shelf.games.length + GameTile { + required property int index + x: (index % shelf.columns) * (shelf.tileWidth + shelf.gap) + y: Math.floor(index / shelf.columns) * (shelf.tileHeight + shelf.gap) + width: shelf.tileWidth + height: shelf.tileHeight + game: shelf.games[index] || ({}) + queued: shelf.queued + suggested: shelf.suggested + } + } + } + component GameTile: ColumnLayout { + id: tile + required property var game + property bool queued: false + property bool suggested: false + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: 7 + function focusTile() { openButton.forceActiveFocus(); root.reveal(openButton) } + function focusActions() { tileAction.forceActiveFocus(); root.reveal(tileAction) } + Button { + id: openButton + objectName: "homeTile-" + root.focusKey(tile.game) + property string homeIdentity: root.focusKey(tile.game) + Layout.fillWidth: true + implicitHeight: width * 1.5 + 69 * root.scaleFactor + focusPolicy: Qt.StrongFocus + Accessible.name: tile.game.title + (tile.game.available ? "" : ", unavailable") + onActiveFocusChanged: if (activeFocus) { root.focusedIdentity = root.focusKey(tile.game); root.focusedAction = "tile" } + onClicked: root.openGame(tile.game) + Keys.onReturnPressed: clicked() + Keys.onEnterPressed: clicked() + padding: 0 + background: Rectangle { color: Theme.background; radius: 7; border.width: openButton.activeFocus ? 3 : 1; border.color: openButton.activeFocus ? Theme.accent : Qt.alpha(Theme.foreground, 0.15) } + contentItem: Column { + spacing: 8 + Item { + width: parent.width; height: width * 1.5 + Rectangle { anchors.fill: parent; anchors.margins: 3; color: tile.game.accentStart || Theme.background + Text { anchors.centerIn: parent; text: (tile.game.title || "?").substring(0, 1); color: Theme.brightForeground; font.family: Theme.fontFamily; font.pixelSize: 48; visible: !art.ready } + } + CoverArtwork { id: art; anchors.fill: parent; anchors.margins: 3; source: tile.game.coverPath || "" } + Rectangle { visible: !tile.game.available; anchors.bottom: parent.bottom; width: parent.width; height: 30; color: Theme.darkerBackground + Text { anchors.centerIn: parent; text: "UNAVAILABLE"; color: Theme.mutedText; font.family: Theme.fontFamily } + } + } + Text { x: 10; width: parent.width - 20; text: tile.game.title || "Unavailable game"; color: Theme.brightForeground; font.family: Theme.fontFamily; font.pixelSize: 13 * root.scaleFactor; font.bold: true; maximumLineCount: 2; wrapMode: Text.Wrap; elide: Text.ElideRight; height: 39 * root.scaleFactor } + } + } + Text { Layout.fillWidth: true; text: tile.suggested ? tile.game.suggestionReason : root.gameCaption(tile.game); color: Theme.mutedText; font.family: Theme.fontFamily; font.pixelSize: 11 * root.scaleFactor; elide: Text.ElideRight; maximumLineCount: 2; wrapMode: Text.Wrap; Layout.preferredHeight: 30 * root.scaleFactor } + GlassButton { + id: tileAction + property string homeIdentity: root.focusKey(tile.game) + Layout.fillWidth: true; Layout.minimumWidth: 0 + compact: true + maximumLabelWidth: Math.max(30, width - 24) + text: tile.queued ? "QUEUE ACTIONS" : "+ UP NEXT" + Accessible.name: text + " for " + tile.game.title + onActiveFocusChanged: if (activeFocus) { root.focusedIdentity = root.focusKey(tile.game); root.focusedAction = "actions" } + onClicked: { + if (tile.queued) { root.menuGame = tile.game; queueMenu.anchorItem = tileAction; queueMenu.open() } + else root.queueAction(tile.game, "add") + } + } + } + + Rectangle { anchors.fill: parent; color: Theme.darkerBackground } + ColumnLayout { + anchors.fill: parent + anchors.margins: root.couchMode ? 32 : 24 + spacing: 18 + RowLayout { + Layout.fillWidth: true + Text { text: "HOME"; color: Theme.brightForeground; font.family: Theme.fontFamily; font.pixelSize: 25 * root.scaleFactor } + Item { Layout.fillWidth: true } + Flow { + Layout.preferredWidth: Math.min(410, root.width - 160) + Layout.preferredHeight: implicitHeight + spacing: 6 + GlassButton { id: libraryButton; objectName: "homeLibraryButton"; text: "LIBRARY"; compact: true; onActiveFocusChanged: if (activeFocus) root.focusedIdentity = ""; onClicked: root.libraryRequested() } + GlassButton { text: "SEARCH"; compact: true; onClicked: root.Window.window.openLibrarySearch() } + GlassButton { text: "SETTINGS"; compact: true; onClicked: root.Window.window.diagnosticsOpen = true } + GlassButton { text: root.couchMode ? "DESKTOP" : "COUCH"; compact: true; onClicked: root.Window.window.setCouchMode(!root.couchMode) } + } + } + Flickable { + id: scroll + objectName: "homeList" + Layout.fillWidth: true; Layout.fillHeight: true + contentWidth: width + contentHeight: content.implicitHeight + 24 + clip: true + boundsBehavior: Flickable.StopAtBounds + property real wheelTargetY: contentY + property real wheelDirection: 0 + property bool wheelActive: false + property real wheelPosition: 0 + onWheelPositionChanged: if (wheelActive) contentY = wheelPosition + readonly property real maximumScrollY: originY + Math.max(0, contentHeight - height) + function traceScroll(reason) { + if (ScrollTraceEnabled) console.info("scroll-trace", Date.now(), reason, + "y", contentY, "target", wheelTargetY, "running", wheelAnimation.running) + } + onContentYChanged: traceScroll("position") + function stopWheelScroll(reason) { + traceScroll("stop:" + (reason || "explicit")) + wheelActive = false + wheelPosition = contentY + wheelTargetY = contentY + wheelDirection = 0 + } + onMovementStarted: stopWheelScroll("movement-started") + onContentHeightChanged: stopWheelScroll("content-height") + onHeightChanged: stopWheelScroll("viewport-height") + onVisibleChanged: stopWheelScroll("visibility") + Behavior on wheelPosition { + enabled: scroll.wheelActive + SmoothedAnimation { + id: wheelAnimation + velocity: 1000 * root.scaleFactor + maximumEasingTime: 80 + reversingMode: SmoothedAnimation.Immediate + } + } + WheelHandler { + target: null + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + blocking: true + onWheel: function(event) { + scroll.traceScroll("wheel:" + event.angleDelta.y + ":" + event.pixelDelta.y) + const pixels = event.pixelDelta.y !== 0 + const travel = pixels ? event.pixelDelta.y : event.angleDelta.y / 120 * 100 * root.scaleFactor + if (travel === 0) return + const direction = Math.sign(travel) + const start = wheelAnimation.running && direction === scroll.wheelDirection + ? scroll.wheelTargetY : scroll.contentY + const destination = Math.max(scroll.originY, Math.min(scroll.maximumScrollY, start - travel)) + scroll.wheelDirection = direction + // Retarget the running animation without restarting its easing + // curve. Preserve velocity across consecutive mouse notches. + if (pixels || Preferences.reducedMotion) { + scroll.stopWheelScroll() + scroll.wheelTargetY = destination + scroll.contentY = destination + } else { + if (!scroll.wheelActive) scroll.wheelPosition = scroll.contentY + scroll.wheelActive = true + scroll.wheelTargetY = destination + scroll.wheelPosition = destination + } + event.accepted = true + } + } + ScrollBar.vertical: ScrollBar { + onPressedChanged: if (pressed) scroll.stopWheelScroll() + } + ColumnLayout { + id: content + width: Math.min(scroll.width - 12, 1480 * root.scaleFactor) + x: (scroll.width - width) / 2 + spacing: 18 + Text { text: Home.gameCount + " games ready to explore"; color: Theme.mutedText; font.family: Theme.fontFamily } + Text { Layout.fillWidth: true; visible: Home.error !== "" || root.notice !== ""; text: Home.error || root.notice; color: Theme.brightForeground; font.family: Theme.fontFamily; wrapMode: Text.Wrap } + Rectangle { + objectName: "homeFeaturedSection" + Layout.fillWidth: true + Layout.preferredHeight: featureRow.implicitHeight + 32 + visible: Home.recent.length > 0 + radius: 10 + gradient: Gradient { orientation: Gradient.Horizontal; GradientStop { position: 0; color: Qt.alpha(root.featured.accentStart || Theme.accent, 0.24) } GradientStop { position: 1; color: Theme.background } } + border.color: Qt.alpha(Theme.foreground, 0.14) + RowLayout { + id: featureRow + anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 16 + spacing: content.width < 650 ? 16 : 28 + Rectangle { + Layout.preferredWidth: content.width < 650 ? 105 : 154 * root.scaleFactor + Layout.preferredHeight: width * 1.5 + color: root.featured.accentStart || Theme.background + Text { anchors.centerIn: parent; text: (root.featured.title || "?").substring(0, 1); font.family: Theme.fontFamily; font.pixelSize: 48; color: Theme.brightForeground; visible: !featuredCover.ready } + CoverArtwork { id: featuredCover; anchors.fill: parent; source: root.featured.coverPath || "" } + } + ColumnLayout { + Layout.fillWidth: true; spacing: 12 + Text { text: "JUMP BACK IN"; color: Theme.accent; font.family: Theme.fontFamily; font.pixelSize: 12 * root.scaleFactor } + Text { Layout.fillWidth: true; text: root.featured.title || ""; color: Theme.brightForeground; font.family: Theme.fontFamily; font.bold: true; font.pixelSize: (content.width < 650 ? 22 : 32) * root.scaleFactor; wrapMode: Text.Wrap; maximumLineCount: 3; elide: Text.ElideRight } + Text { Layout.fillWidth: true; text: root.gameCaption(root.featured); color: Theme.mutedText; font.family: Theme.fontFamily; wrapMode: Text.Wrap } + Text { visible: root.featured.lastPlayed > 0; text: root.featured.lastPlayed > 0 ? "Last played " + Qt.formatDateTime(new Date(root.featured.lastPlayed * 1000), "MMM d, yyyy") : ""; color: Theme.mutedText; font.family: Theme.fontFamily } + Flow { + Layout.fillWidth: true; Layout.preferredHeight: implicitHeight; spacing: 8 + GlassButton { + id: featuredPlay + property string homeIdentity: root.focusKey(root.featured) + objectName: "homeFeaturedPlay" + text: root.launchBusy ? "OPENING..." : "PLAY" + primary: true + enabled: !!root.featured.available + Accessible.description: root.launchBusy ? "Launch request in progress" : "" + onActiveFocusChanged: if (activeFocus) { + root.focusedIdentity = root.focusKey(root.featured) + root.focusedAction = "play" + } + onClicked: if (!root.launchBusy) root.playRequested(root.featured) + } + GlassButton { + id: featuredOpen + property string homeIdentity: root.focusKey(root.featured) + objectName: "homeFeaturedOpen" + text: "DETAILS" + onActiveFocusChanged: if (activeFocus) { + root.focusedIdentity = root.focusKey(root.featured) + root.focusedAction = "details" + } + onClicked: root.openGame(root.featured, "details") + } + GlassButton { text: "+ UP NEXT"; onClicked: root.queueAction(root.featured, "add") } + } + } + Rectangle { visible: content.width > 1000 && !!root.nextGame.identity; Layout.preferredWidth: 1; Layout.preferredHeight: 160; color: Qt.alpha(Theme.foreground, 0.15) } + ColumnLayout { + visible: content.width > 1000 && !!root.nextGame.identity + Layout.preferredWidth: 280 * root.scaleFactor + Layout.maximumWidth: 280 * root.scaleFactor + spacing: 12 + Text { text: Home.queue.length ? "NEXT IN YOUR QUEUE" : "ON YOUR RADAR"; color: Theme.accent; font.family: Theme.fontFamily; font.pixelSize: 12 * root.scaleFactor } + Text { Layout.fillWidth: true; text: root.nextGame.title || ""; color: Theme.brightForeground; font.family: Theme.fontFamily; font.pixelSize: 22 * root.scaleFactor; font.bold: true; maximumLineCount: 2; wrapMode: Text.Wrap; elide: Text.ElideRight } + Text { Layout.fillWidth: true; text: Home.queue.length ? "Picked by you. Ready when you are." : root.nextGame.suggestionReason || ""; color: Theme.mutedText; font.family: Theme.fontFamily; wrapMode: Text.Wrap } + GlassButton { text: "EXPLORE GAME"; onClicked: root.openGame(root.nextGame) } + } + } + } + SectionTitle { title: "Continue playing"; caption: "Recently played"; actionText: "VIEW ALL"; onActionRequested: root.browseRequested("recent", ""); visible: Home.recent.length > 1 } + GameShelf { + id: recentTiles + objectName: "homeRecentShelf" + games: Home.recent.slice(1, 7) + visible: games.length > 0 + } + SectionTitle { title: "Up next"; caption: Home.queue.length ? Home.queue.length + " in your queue" : "Your own shortlist" } + Text { Layout.fillWidth: true; visible: !Home.queue.length; text: "Something catch your eye? Add it to Up next and keep your next session ready."; color: Theme.mutedText; font.family: Theme.fontFamily; wrapMode: Text.Wrap } + GameShelf { + id: queueTiles + objectName: "homeQueueShelf" + games: Home.queue + queued: true + visible: games.length > 0 + } + SectionTitle { title: "Find your next game"; caption: "From your library"; visible: Home.suggestions.length > 0 } + GameShelf { + id: suggestionTiles + objectName: "homeSuggestionShelf" + games: Home.suggestions + suggested: true + visible: games.length > 0 + } + SectionTitle { title: "Quick access"; caption: "" } + Flow { + Layout.fillWidth: true; Layout.preferredHeight: implicitHeight; spacing: 8 + GlassButton { id: browseAll; objectName: "homeBrowseAll"; text: "ALL GAMES"; onClicked: root.browseRequested("all", "") } + GlassButton { text: "FAVORITES"; onClicked: root.browseRequested("favorites", "") } + GlassButton { text: "BACKLOG"; onClicked: root.browseRequested("backlog", "") } + Repeater { + model: root.allPlaces ? Home.shortcuts : Home.shortcuts.slice(0, 5) + GlassButton { + required property var modelData + text: modelData.title + " · " + modelData.count + maximumLabelWidth: Math.min(220, content.width - 40) + onClicked: root.browseRequested(modelData.kind, modelData.value) + } + } + Repeater { + model: root.allPlaces ? Library.savedFilters : Library.savedFilters.slice(0, 3) + GlassButton { + required property var modelData + text: modelData.name + Accessible.name: "Saved view: " + modelData.name + maximumLabelWidth: Math.min(220, content.width - 40) + onClicked: root.browseRequested("saved", modelData.id) + } + } + GlassButton { visible: Home.shortcuts.length > 5 || Library.savedFilters.length > 3; text: root.allPlaces ? "FEWER PLACES" : "ALL PLACES"; onClicked: root.allPlaces = !root.allPlaces } + } + Text { Layout.fillWidth: true; visible: Home.gameCount === 0; text: "Your Home starts with your games. Add a source or ROM folder in Settings, then play something to make this space yours."; color: Theme.mutedText; font.family: Theme.fontFamily; wrapMode: Text.Wrap } + } + } + } + ActionMenu { + id: queueMenu + objectName: "homeQueueMenu" + host: root.Window.window + anchorItem: libraryButton + title: root.menuGame.title || "UP NEXT" + MenuAction { Layout.fillWidth: true; text: "MOVE EARLIER"; enabled: Home.queue.findIndex(game => game.queueKey === root.menuGame.queueKey) > 0; onClicked: queueMenu.invoke(function() { root.queueAction(root.menuGame, "up") }) } + MenuAction { Layout.fillWidth: true; text: "MOVE LATER"; enabled: Home.queue.findIndex(game => game.queueKey === root.menuGame.queueKey) < Home.queue.length - 1; onClicked: queueMenu.invoke(function() { root.queueAction(root.menuGame, "down") }) } + MenuAction { Layout.fillWidth: true; text: "REMOVE"; onClicked: queueMenu.invoke(function() { root.queueAction(root.menuGame, "remove") }) } + } +} diff --git a/resources/sessiond-profiles.json b/resources/sessiond-profiles.json new file mode 100644 index 0000000..a59e5ea --- /dev/null +++ b/resources/sessiond-profiles.json @@ -0,0 +1,22 @@ +{ + "romExtensions": [ + "7z", "app", "bin", "ccd", "chd", "ciso", "cue", "dsk", "dol", "dmg", "elf", "fds", + "gb", "gba", "gbc", "gcm", "gdi", "img", "iso", "lha", "m3u", "md", "n64", "nds", + "nes", "nro", "nso", "nsp", "pbp", "rpx", "rvz", "sfc", "smc", "swc", "wad", "wbfs", + "wud", "wux", "xci", "z64", "zip" + ], + "emulators": [ + { "name": "RetroArch", "binaries": ["retroarch", "RetroArch"], "rescanSource": "RetroArch" }, + { "name": "PCSX2", "binaries": ["pcsx2", "pcsx2-qt", "pcsx2-avx2", "pcsx2-avx", "pcsx2-sse4"], "rescanSource": "PCSX2" }, + { "name": "Ryujinx", "binaries": ["Ryujinx", "ryujinx"], "rescanSource": "Ryujinx" }, + { "name": "Eden", "binaries": ["eden", "suyu", "sudachi", "citron", "yuzu", "yuzu-mainline", "torzu"] }, + { "name": "Dolphin", "binaries": ["dolphin-emu", "dolphin-emu-nogui"], "rescanSource": "Dolphin" }, + { "name": "Cemu", "binaries": ["cemu", "cemu_qt"] }, + { "name": "shadPS4", "binaries": ["shadps4", "Shadps4", "shadPS4"] }, + { "name": "melonDS", "binaries": ["melonDS"] }, + { "name": "mGBA", "binaries": ["mgba", "mGBA", "mgba-qt"] }, + { "name": "PPSSPP", "binaries": ["ppsspp", "PPSSPP"] }, + { "name": "RPCS3", "binaries": ["rpcs3", "RPCS3"] }, + { "name": "xemu", "binaries": ["xemu"] } + ] +} diff --git a/src/app/AppSettings.cpp b/src/app/AppSettings.cpp index df656be..e1b3c8b 100644 --- a/src/app/AppSettings.cpp +++ b/src/app/AppSettings.cpp @@ -80,19 +80,62 @@ const QStringList kSortModeNames = {QStringLiteral("title"), QStringLiteral("rec } // namespace QJsonObject AppSettings::backupSettings() const { - return {{"reduced_motion", m_reducedMotion}, {"artwork_cache_limit_mb", m_artworkCacheLimitMb}, - {"steam_enabled", m_steamEnabled}, {"lutris_enabled", m_lutrisEnabled}, - {"heroic_enabled", m_heroicEnabled}, {"gog_enabled", m_gogEnabled}, - {"faugus_enabled", m_faugusEnabled}, {"retroarch_enabled", m_retroArchEnabled}, - {"pcsx2_enabled", m_pcsx2Enabled}, {"ryujinx_enabled", m_ryujinxEnabled}, - {"pcsx2_auto", m_pcsx2Auto}, {"ryujinx_auto", m_ryujinxAuto}, - {"battlenet_enabled", m_battleNetEnabled}, {"close_after_launch", m_closeAfterLaunch}, - {"couch_mode", m_couchModeEnabled}, {"couch_library_view", m_couchLibraryView}, - {"library_sort_mode", kSortModeNames.value(m_librarySortMode)}, - {"gog_library_paths", QJsonArray::fromStringList(m_gogLibraryPaths)}}; + return {{"shadps4_enabled", m_shadps4Enabled}, + {"cemu_enabled", m_cemuEnabled}, + {"dolphin_enabled", m_dolphinEnabled}, + {"shadps4_auto", m_shadps4Auto}, + {"cemu_auto", m_cemuAuto}, + {"dolphin_auto", m_dolphinAuto}, + {"console_portals_enabled", m_consolePortalsEnabled}, + {"expand_consoles", m_expandConsoles}, + {"prefer_standalone_emulators", m_preferStandaloneEmulators}, + {"track_play_sessions", m_trackPlaySessions}, + {"cover_size", m_coverSize}, + {"couch_cover_size", m_couchCoverSize}, + {"console_expand_limit", m_consoleExpandLimit}, + {"rom_folders", QJsonArray::fromStringList(m_romFolders)}, + {"console_layouts", QJsonArray::fromStringList(m_consoleLayouts)}, + {"reduced_motion", m_reducedMotion}, + {"artwork_cache_limit_mb", m_artworkCacheLimitMb}, + {"steam_enabled", m_steamEnabled}, + {"lutris_enabled", m_lutrisEnabled}, + {"heroic_enabled", m_heroicEnabled}, + {"gog_enabled", m_gogEnabled}, + {"faugus_enabled", m_faugusEnabled}, + {"retroarch_enabled", m_retroArchEnabled}, + {"pcsx2_enabled", m_pcsx2Enabled}, + {"ryujinx_enabled", m_ryujinxEnabled}, + {"pcsx2_auto", m_pcsx2Auto}, + {"ryujinx_auto", m_ryujinxAuto}, + {"battlenet_enabled", m_battleNetEnabled}, + {"close_after_launch", m_closeAfterLaunch}, + {"couch_mode", m_couchModeEnabled}, + {"couch_library_view", m_couchLibraryView}, + {"library_sort_mode", kSortModeNames.value(m_librarySortMode)}, + {"gog_library_paths", QJsonArray::fromStringList(m_gogLibraryPaths)}}; } void AppSettings::assignBackupSettings(const QJsonObject& settings) { + m_shadps4Enabled = settings.value("shadps4_enabled").toBool(); + m_cemuEnabled = settings.value("cemu_enabled").toBool(); + m_dolphinEnabled = settings.value("dolphin_enabled").toBool(); + m_shadps4Auto = settings.value("shadps4_auto").toBool(); + m_cemuAuto = settings.value("cemu_auto").toBool(); + m_dolphinAuto = settings.value("dolphin_auto").toBool(); + m_consolePortalsEnabled = settings.value("console_portals_enabled").toBool(); + m_expandConsoles = settings.value("expand_consoles").toBool(); + m_preferStandaloneEmulators = settings.value("prefer_standalone_emulators").toBool(); + m_trackPlaySessions = settings.value("track_play_sessions").toBool(); + m_coverSize = settings.value("cover_size").toInt(); + m_couchCoverSize = settings.value("couch_cover_size").toInt(); + m_consoleExpandLimit = settings.value("console_expand_limit").toInt(); + m_romFolders.clear(); + for (const auto& value : settings.value("rom_folders").toArray()) + m_romFolders.append(value.toString()); + m_consoleLayouts.clear(); + for (const auto& value : settings.value("console_layouts").toArray()) + m_consoleLayouts.append(value.toString()); + m_reducedMotion = settings.value("reduced_motion").toBool(); m_artworkCacheLimitMb = settings.value("artwork_cache_limit_mb").toInt(); m_steamEnabled = settings.value("steam_enabled").toBool(); @@ -126,12 +169,29 @@ bool AppSettings::applyBackupSettings(const QJsonObject& settings, bool replace) const auto before = backupSettings(); AppSettings defaults(UnloadedSettings{}); auto merged = replace ? defaults.backupSettings() : before; + // Archives written before these settings existed have no opinion about them. + for (const auto& key : + QStringList{"shadps4_enabled", "cemu_enabled", "dolphin_enabled", "shadps4_auto", + "cemu_auto", "dolphin_auto", "console_portals_enabled", "expand_consoles", + "prefer_standalone_emulators", "track_play_sessions", "cover_size", + "couch_cover_size", "console_expand_limit", "rom_folders", "console_layouts"}) + if (!settings.contains(key)) + merged.insert(key, before.value(key)); for (auto value = settings.begin(); value != settings.end(); ++value) merged.insert(value.key(), value.value()); assignBackupSettings(merged); if (!save()) { assignBackupSettings(before); return false; } emit reducedMotionChanged(); emit artworkCacheLimitMbChanged(); emit sourcesChanged(); emit closeAfterLaunchChanged(); emit couchModeEnabledChanged(); emit couchLibraryViewChanged(); emit librarySortModeChanged(); emit gogLibraryPathsChanged(); + emit consolePortalsEnabledChanged(); + emit expandConsolesChanged(); + emit preferStandaloneEmulatorsChanged(); + emit trackPlaySessionsChanged(); + emit coverSizeChanged(); + emit couchCoverSizeChanged(); + emit consoleExpandLimitChanged(); + emit romFoldersChanged(); + emit consoleLayoutsChanged(); return true; } @@ -521,6 +581,17 @@ void AppSettings::setCloseAfterLaunch(bool value) { emit closeAfterLaunchChanged(); } +bool AppSettings::trackPlaySessions() const { return m_trackPlaySessions; } + +void AppSettings::setTrackPlaySessions(bool value) { + if (m_trackPlaySessions == value) { + return; + } + m_trackPlaySessions = value; + save(); + emit trackPlaySessionsChanged(); +} + bool AppSettings::couchModeEnabled() const { return m_couchModeEnabled; } void AppSettings::setCouchModeEnabled(bool value) { @@ -667,6 +738,7 @@ void AppSettings::load() { } m_battleNetEnabled = readEnabled(QStringLiteral("battlenet_enabled"), true); m_closeAfterLaunch = readEnabled(QStringLiteral("close_after_launch"), false); + m_trackPlaySessions = readEnabled(QStringLiteral("track_play_sessions"), true); m_couchModeEnabled = readEnabled(QStringLiteral("couch_mode_enabled"), false); for (const auto& name : {QStringLiteral("cover_size"), QStringLiteral("couch_cover_size")}) { const auto match = QRegularExpression(QStringLiteral("(?m)^%1\\s*=\\s*(-?[0-9]+)\\s*$").arg(name)).match(contents); @@ -713,11 +785,15 @@ void AppSettings::setSunshineGameApps(bool value) { emit sunshineChanged(); } -bool AppSettings::save() const { +bool AppSettings::save() { + const auto failed = [this] { + emit saveFailed(QStringLiteral("Settings could not be saved. Recent changes may be lost when Omakade closes.")); + return false; + }; QDir().mkpath(QFileInfo(m_path).absolutePath()); QSaveFile file(m_path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - return false; + return failed(); } // Emulator source keys are written only once their state is explicit (detection // completed or the user chose a value); while auto-detection is pending the keys @@ -771,6 +847,7 @@ bool AppSettings::save() const { .arg(m_expandConsoles ? QStringLiteral("true") : QStringLiteral("false")) .arg(m_consoleExpandLimit); contents += QStringLiteral("close_after_launch = %1\n" + "track_play_sessions = %7\n" "couch_mode_enabled = %2\n" "couch_library_view = \"%3\"\n" "library_sort_mode = \"%6\"\n" @@ -780,13 +857,16 @@ bool AppSettings::save() const { .arg(m_couchLibraryView) .arg(m_sunshineOmakadeApp ? QStringLiteral("true") : QStringLiteral("false")) .arg(m_sunshineGameApps ? QStringLiteral("true") : QStringLiteral("false")) - .arg(kSortModeNames.value(m_librarySortMode)); + .arg(kSortModeNames.value(m_librarySortMode)) + .arg(m_trackPlaySessions ? QStringLiteral("true") : QStringLiteral("false")); contents += QStringLiteral("cover_size = %1\ncouch_cover_size = %2\n").arg(m_coverSize).arg(m_couchCoverSize); contents += QStringLiteral("gog_library_paths = ") + QString::fromUtf8(QJsonDocument(QJsonArray::fromStringList(m_gogLibraryPaths)) .toJson(QJsonDocument::Compact)) + QLatin1Char('\n'); const QByteArray encoded = contents.toUtf8(); - return file.write(encoded) == encoded.size() && file.commit(); + if (file.write(encoded) != encoded.size() || !file.commit()) + return failed(); + return true; } void AppSettings::setCoverSize(int value) { diff --git a/src/app/AppSettings.h b/src/app/AppSettings.h index 14b3115..4c7409f 100644 --- a/src/app/AppSettings.h +++ b/src/app/AppSettings.h @@ -45,6 +45,8 @@ class AppSettings final : public QObject { setPreferStandaloneEmulators NOTIFY preferStandaloneEmulatorsChanged) Q_PROPERTY(bool closeAfterLaunch READ closeAfterLaunch WRITE setCloseAfterLaunch NOTIFY closeAfterLaunchChanged) + Q_PROPERTY(bool trackPlaySessions READ trackPlaySessions WRITE setTrackPlaySessions NOTIFY + trackPlaySessionsChanged) Q_PROPERTY(bool couchModeEnabled READ couchModeEnabled WRITE setCouchModeEnabled NOTIFY couchModeEnabledChanged) Q_PROPERTY(QString couchLibraryView READ couchLibraryView WRITE setCouchLibraryView NOTIFY @@ -132,6 +134,9 @@ class AppSettings final : public QObject { void setBattleNetEnabled(bool value); [[nodiscard]] bool closeAfterLaunch() const; void setCloseAfterLaunch(bool value); + // Session recording by omakade-sessiond; the daemon reads the same config key. + [[nodiscard]] bool trackPlaySessions() const; + void setTrackPlaySessions(bool value); [[nodiscard]] bool couchModeEnabled() const; void setCouchModeEnabled(bool value); [[nodiscard]] QString couchLibraryView() const; @@ -154,6 +159,7 @@ class AppSettings final : public QObject { Q_INVOKABLE QString gogLibraryPathStatus(const QString& path) const; signals: + void saveFailed(const QString& message); void gogLibraryPathsChanged(); void reducedMotionChanged(); void artworkCacheLimitMbChanged(); @@ -162,6 +168,7 @@ class AppSettings final : public QObject { void retroAchievementsUsernameChanged(); void sourcesChanged(); void closeAfterLaunchChanged(); + void trackPlaySessionsChanged(); void couchModeEnabledChanged(); void couchLibraryViewChanged(); void librarySortModeChanged(); @@ -181,7 +188,7 @@ class AppSettings final : public QObject { void assignBackupSettings(const QJsonObject& settings); [[nodiscard]] static QString defaultPath(); void load(); - bool save() const; + bool save(); QString m_path; QStringList m_gogLibraryPaths; @@ -214,6 +221,7 @@ class AppSettings final : public QObject { bool m_preferStandaloneEmulators = false; bool m_battleNetEnabled = true; bool m_closeAfterLaunch = false; + bool m_trackPlaySessions = false; bool m_couchModeEnabled = false; QString m_couchLibraryView = QStringLiteral("detail"); int m_librarySortMode = 0; diff --git a/src/app/SingleInstance.cpp b/src/app/SingleInstance.cpp index a98339c..8fc974a 100644 --- a/src/app/SingleInstance.cpp +++ b/src/app/SingleInstance.cpp @@ -24,6 +24,12 @@ SingleInstance::SingleInstance(const QString& serverName, QObject* parent) delete buffer; if (command.startsWith("play ")) { emit playRequested(QString::fromUtf8(command.mid(5)).trimmed()); + } else if (command.startsWith("rescan ")) { + // Sent by omakade-sessiond when an emulator whose own playtime is only + // written on exit has ended a session. + emit rescanRequested(QString::fromUtf8(command.mid(7)).trimmed()); + } else if (command == "tracking-storage-error") { + emit trackingStorageFailed(); } else if (command == "quit") { emit quitRequested(); } else if (command.contains("activate")) { diff --git a/src/app/SingleInstance.h b/src/app/SingleInstance.h index e5b1721..dc2a2fb 100644 --- a/src/app/SingleInstance.h +++ b/src/app/SingleInstance.h @@ -5,7 +5,8 @@ // Owns the per-user local socket that keeps one Omakade window open. A second launch // forwards a short command instead of opening another window: "activate" raises the -// window, "play " launches a library game, and "quit" closes Omakade. +// window, "play " launches a library game, "rescan " asks a source model +// to re-import, and "quit" closes Omakade. class SingleInstance final : public QObject { Q_OBJECT @@ -19,7 +20,9 @@ class SingleInstance final : public QObject { signals: void activationRequested(bool fullscreen); void playRequested(const QString& launchKey); + void rescanRequested(const QString& source); void quitRequested(); + void trackingStorageFailed(); private: QString m_serverName; diff --git a/src/app/main.cpp b/src/app/main.cpp index 35ec2f9..48fca91 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -2,37 +2,40 @@ #include "achievements/RetroAchievementsService.h" #include "achievements/SteamAccountService.h" #include "app/AppSettings.h" +#include "app/IdleInhibitor.h" #include "app/SingleInstance.h" -#include "backup/BackupStartup.h" +#include "artwork/CoverImageProvider.h" #include "backup/BackupManager.h" #include "backup/BackupSnapshot.h" -#include "input/ControllerInput.h" +#include "backup/BackupStartup.h" #include "input/ControllerFocusGuard.h" +#include "input/ControllerInput.h" #include "input/CouchCursorManager.h" -#include "app/IdleInhibitor.h" -#include "artwork/CoverImageProvider.h" #include "launch/GameLauncher.h" #include "launch/PlayRequest.h" -#include "streaming/SunshineIntegration.h" #include "library/BattleNetGameModel.h" +#include "library/CemuGameModel.h" +#include "library/ConsolePortalModel.h" +#include "library/DolphinGameModel.h" #include "library/FaugusGameModel.h" #include "library/HeroicGameModel.h" +#include "library/HomeModel.h" #include "library/LibraryFilterModel.h" #include "library/LutrisGameModel.h" -#include "library/MockGameModel.h" #include "library/ManualGameModel.h" +#include "library/MockGameModel.h" #include "library/Pcsx2GameModel.h" +#include "library/RetroArchGameModel.h" #include "library/RyujinxGameModel.h" #include "library/Shadps4GameModel.h" -#include "library/CemuGameModel.h" -#include "library/DolphinGameModel.h" -#include "library/RetroArchGameModel.h" #include "library/SteamGameModel.h" -#include "library/ConsolePortalModel.h" #include "library/UnifiedGameModel.h" #include "metadata/GameInsightsService.h" #include "metadata/GameMetadata.h" +#include +#include "streaming/SunshineIntegration.h" #include "theme/OmarchyTheme.h" +#include "tracking/PlaySessionStore.h" #include #include @@ -48,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -705,6 +709,7 @@ int main(int argc, char* argv[]) { std::unique_ptr cemuGames; std::unique_ptr dolphinGames; std::unique_ptr battleNetGames; + std::unique_ptr playSessionStore; std::unique_ptr consolePortals; SteamGameModel* steamLibrary = nullptr; LutrisGameModel* lutrisLibrary = nullptr; @@ -773,6 +778,8 @@ int main(int argc, char* argv[]) { auto steam = std::make_unique(QString{}, &preferences); steamLibrary = steam.get(); libraryDatabasePath = steamLibrary->databasePath(); + playSessionStore = std::make_unique(libraryDatabasePath); + playSessionStore->setEnabled(preferences.trackPlaySessions()); games = std::move(steam); lutrisGames = std::make_unique(steamLibrary->databasePath()); lutrisLibrary = lutrisGames.get(); @@ -787,18 +794,23 @@ int main(int argc, char* argv[]) { faugusGames = std::make_unique(steamLibrary->databasePath()); faugusLibrary = faugusGames.get(); retroArchGames = std::make_unique(steamLibrary->databasePath(), - &preferences); + &preferences, playSessionStore.get()); retroArchLibrary = retroArchGames.get(); retroArchLibrary->setConfiguredRomFolders(preferences.romFolders()); - pcsx2Games = std::make_unique(steamLibrary->databasePath()); + pcsx2Games = + std::make_unique(steamLibrary->databasePath(), playSessionStore.get()); pcsx2Library = pcsx2Games.get(); - ryujinxGames = std::make_unique(steamLibrary->databasePath()); + ryujinxGames = + std::make_unique(steamLibrary->databasePath(), playSessionStore.get()); ryujinxLibrary = ryujinxGames.get(); - shadps4Games = std::make_unique(steamLibrary->databasePath()); + shadps4Games = + std::make_unique(steamLibrary->databasePath(), playSessionStore.get()); shadps4Library = shadps4Games.get(); - cemuGames = std::make_unique(steamLibrary->databasePath()); + cemuGames = + std::make_unique(steamLibrary->databasePath(), playSessionStore.get()); cemuLibrary = cemuGames.get(); - dolphinGames = std::make_unique(steamLibrary->databasePath()); + dolphinGames = + std::make_unique(steamLibrary->databasePath(), playSessionStore.get()); dolphinLibrary = dolphinGames.get(); battleNetGames = std::make_unique(steamLibrary->databasePath(), &preferences); @@ -818,7 +830,7 @@ int main(int argc, char* argv[]) { portals->setCardSystems(preferences.cardSystems()); }); } - if (navigationTest) { + if (navigationTest || renderOverlay.startsWith("library-reflow")) { libraryDatabasePath = QStringLiteral(":memory:"); } QTemporaryDir artworkFixture; @@ -1172,7 +1184,24 @@ int main(int argc, char* argv[]) { } } BackupManager backups(managerPaths, &preferences, steamLibrary != nullptr || backupFixture); + HomeModel home(&unifiedGames, libraryDatabasePath); QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("Home", &home); + const bool scrollTrace = qEnvironmentVariableIsSet("OMAKADE_SCROLL_TRACE"); + engine.rootContext()->setContextProperty("ScrollTraceEnabled", scrollTrace); + if (scrollTrace) { + auto* heartbeat = new QTimer(&application); + heartbeat->setInterval(16); + heartbeat->setTimerType(Qt::PreciseTimer); + auto elapsed = std::make_shared(); + elapsed->start(); + QObject::connect(heartbeat, &QTimer::timeout, &home, [elapsed, &home] { + const auto gap = elapsed->restart(); + if (home.active() && gap > 50) + qInfo() << "scroll-trace gui-gap-ms" << gap; + }); + heartbeat->start(); + } // Cover art is decoded once and kept, so scrolling away and back, or changing a filter, does // not send every card to disk again. The engine takes ownership. engine.addImageProvider(QStringLiteral("covers"), new CoverImageProvider()); @@ -1199,6 +1228,12 @@ int main(int argc, char* argv[]) { engine.rootContext()->setContextProperty(QStringLiteral("BattleNetLibrary"), battleNetLibrary); engine.rootContext()->setContextProperty(QStringLiteral("Launcher"), &launcher); engine.rootContext()->setContextProperty(QStringLiteral("Preferences"), &preferences); + if (renderOverlay.startsWith("settings-recorder-")) { + playSessionStore = std::make_unique(QStringLiteral(":memory:")); + preferences.setTrackPlaySessions(renderOverlay.endsWith("on")); + playSessionStore->setEnabled(preferences.trackPlaySessions()); + } + engine.rootContext()->setContextProperty(QStringLiteral("SessionRecorderStatus"), playSessionStore.get()); engine.rootContext()->setContextProperty(QStringLiteral("Controller"), &controller); engine.rootContext()->setContextProperty(QStringLiteral("Achievements"), &achievements); engine.rootContext()->setContextProperty(QStringLiteral("SteamAccount"), steamAccount.get()); @@ -1312,10 +1347,823 @@ int main(int argc, char* argv[]) { quickWindow->setProperty("testRenderSize", requestedRenderSize); } if (renderMode) { + if (renderOverlay.startsWith(QStringLiteral("library-reflow"))) { + auto* timer = new QTimer(quickWindow); + timer->setInterval(140); + auto step = std::make_shared(0); + QObject::connect(timer, &QTimer::timeout, quickWindow, + [quickWindow, timer, step, renderOverlay, &library, &preferences, &application] { + auto* grid = quickWindow->findChild("libraryGrid"); + auto* content = grid ? grid->property("contentItem").value() : nullptr; + if (!grid || !content) { application.exit(EXIT_FAILURE); return; } + QList seen; + for (auto* item : content->childItems()) { + if (!item->property("appId").isValid() || !item->isVisible()) continue; + const auto bounds = item->mapRectToItem(grid, item->boundingRect()); + if (!bounds.intersects(grid->boundingRect())) continue; + for (const auto& previous : seen) { + const auto overlap = previous.intersected(bounds); + if (overlap.width() > 2 && overlap.height() > 2) { + qCritical() << "Library delegates overlap after resize/filter" << *step + << item->property("index") << bounds << previous; + application.exit(EXIT_FAILURE); timer->stop(); return; + } + } + seen.append(bounds); + } + if (*step == 48) { + quickWindow->setProperty("libraryReflowComplete", true); + timer->stop(); return; + } + const int widths[] = {1255, 2024, 927, 1600, 600, 2039}; + const int n = (*step)++; + if (n % 8 == 0 || n % 8 == 4) { + const QSize size(widths[(n / 8) % 6], n % 8 == 4 ? 1104 : 1000); + if (!renderOverlay.endsWith("return")) { + quickWindow->setProperty("testRenderSize", size); + quickWindow->resize(size); + preferences.setCoverSize(n % 8 == 0 ? 100 : 140); + } + } else if (n % 8 == 1 || n % 8 == 7) { + library.setMode(LibraryFilterModel::Mode::Recent); + library.setSortMode(LibraryFilterModel::SortMode::RecentlyPlayed); + } else if (n % 8 == 2) { + grid->setProperty("contentY", grid->property("originY").toReal() + + qMax(0.0, grid->property("contentHeight").toReal() - grid->height())); + } else if (n % 8 == 3) { + QMetaObject::invokeMethod(quickWindow, "openGame", Q_ARG(QVariant, 0)); + } else if (n % 8 == 6) { + QMetaObject::invokeMethod(quickWindow, "closeDetails"); + } else { + if (renderOverlay.endsWith("return")) { + // A completed launch updates Recent while Details hides the grid. + const int row = library.rowCount() - 1; + const auto game = library.get(row); + if (!library.recordLaunch(row, game.value("source").toString(), + game.value("runner").toString(), game.value("appId").toString())) { + qCritical() << "Could not record isolated launch"; + application.exit(EXIT_FAILURE); timer->stop(); return; + } + } else { + library.setMode(LibraryFilterModel::Mode::All); + library.setSortMode(LibraryFilterModel::SortMode::Title); + } + grid->setProperty("contentY", grid->property("originY")); + } + }); + timer->start(); + } if (renderOverlay == QStringLiteral("couch-grid-small")) preferences.setCouchCoverSize(60); if (renderOverlay == QStringLiteral("couch-grid-large")) preferences.setCouchCoverSize(160); // `--render-overlay=settings|picker` opens an overlay so visual checks can cover it. - if (renderOverlay == "gog-folders") { + if (renderOverlay == QStringLiteral("launch-feedback")) { + QTimer::singleShot(120, quickWindow, [quickWindow, &application] { + QMetaObject::invokeMethod(quickWindow, "openGame", Q_ARG(QVariant, 0)); + auto* play = quickWindow->findChild("playButton"); + auto* feedback = quickWindow->findChild("launchFeedback"); + if (!play || !feedback) { application.exit(EXIT_FAILURE); return; } + play->forceActiveFocus(); + QMetaObject::invokeMethod(quickWindow, "playSelected"); + QMetaObject::invokeMethod(quickWindow, "playSelected"); + if (!feedback->property("pending").toBool() || play->property("text") != "OPENING...") { + qCritical() << "Launch feedback was not immediate"; + application.exit(EXIT_FAILURE); return; + } + QTimer::singleShot(150, quickWindow, [quickWindow, feedback, play, &application] { + auto* status = quickWindow->findChild("launchStatusText"); + if (feedback->property("pending").toBool() || !feedback->property("failed").toBool() + || !status || !status->isVisible() || !status->property("text").toString().contains("Demo games cannot be launched") + || quickWindow->activeFocusItem() != play || !play->isEnabled()) { + qCritical() << "Launch failure lost feedback or retry focus"; + application.exit(EXIT_FAILURE); return; + } + }); + }); + } + if (renderOverlay == QStringLiteral("home-empty")) { + unifiedGames.setSourceEnabled("Demo", false); + quickWindow->setProperty("homeOpen", true); + home.refresh(); + } + if (renderOverlay == QStringLiteral("home-wheel-stream")) { + quickWindow->setProperty("homeOpen", true); + home.refresh(); + for (int tick = 0; tick < 6; ++tick) { + QTimer::singleShot(200 + tick * 60, quickWindow, [quickWindow, &application] { + auto* scroll = quickWindow->findChild("homeList"); + const auto point = scroll->mapToScene(QPointF(scroll->width() / 2, scroll->height() / 2)); + const double before = scroll->property("contentY").toDouble(); + QWheelEvent event(point, quickWindow->mapToGlobal(point), QPoint(), QPoint(0, -120), + Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); + QCoreApplication::sendEvent(quickWindow, &event); + if (qAbs(scroll->property("contentY").toDouble() - before) > 1) { + qCritical() << "A wheel tick jumped the rendered position"; + application.exit(EXIT_FAILURE); + } + }); + } + QTimer::singleShot(1150, quickWindow, [quickWindow, &application] { + auto* scroll = quickWindow->findChild("homeList"); + const double expected = qMin(600.0, scroll->property("maximumScrollY").toDouble()); + if (qAbs(scroll->property("contentY").toDouble() - expected) > 1) { + qCritical() << "Continuous wheel input lost movement" << scroll->property("contentY") << expected; + application.exit(EXIT_FAILURE); + } + }); + } + if (renderOverlay == QStringLiteral("home-wheel")) { + quickWindow->setProperty("homeOpen", true); + home.refresh(); + QTimer::singleShot(200, quickWindow, [quickWindow, &application, &preferences] { + auto* scroll = quickWindow->findChild("homeList"); + auto* screen = quickWindow->findChild("homeScreen"); + if (!scroll || !screen) { application.exit(EXIT_FAILURE); return; } + const auto wheel = [quickWindow, scroll](int angle, int pixel = 0) { + const auto point = scroll->mapToScene(QPointF(scroll->width() / 2, scroll->height() / 2)); + QWheelEvent event(point, quickWindow->mapToGlobal(point), QPoint(0, pixel), QPoint(0, angle), + Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); + QCoreApplication::sendEvent(quickWindow, &event); + }; + scroll->setProperty("contentY", 0); + wheel(-120); + wheel(-120); + const double target = scroll->property("wheelTargetY").toDouble(); + if (target <= 100 || (!preferences.reducedMotion() && scroll->property("contentY").toDouble() >= target)) { + qCritical() << "Home wheel did not accumulate smooth movement"; + application.exit(EXIT_FAILURE); return; + } + QTimer::singleShot(350, quickWindow, [quickWindow, scroll, screen, wheel, target, &application] { + if (qAbs(scroll->property("contentY").toDouble() - target) > 1) { + qCritical() << "Home wheel did not settle at its target" << scroll->property("contentY") << target; + application.exit(EXIT_FAILURE); return; + } + wheel(-120); + const double before = scroll->property("contentY").toDouble(); + wheel(120); + if (scroll->property("wheelTargetY").toDouble() >= before) { + qCritical() << "Home wheel reversal retained forward momentum"; + application.exit(EXIT_FAILURE); return; + } + QMetaObject::invokeMethod(scroll, "stopWheelScroll"); + scroll->setProperty("contentY", 100); + wheel(0, 25); + if (qAbs(scroll->property("contentY").toDouble() - 75) > 1) { + qCritical() << "Home pixel scrolling was delayed"; + application.exit(EXIT_FAILURE); return; + } + const double maximum = scroll->property("maximumScrollY").toDouble(); + scroll->setProperty("contentY", maximum); + wheel(-120); + if (scroll->property("wheelTargetY").toDouble() > maximum) { + qCritical() << "Home wheel escaped content bounds"; + application.exit(EXIT_FAILURE); return; + } + QMetaObject::invokeMethod(scroll, "stopWheelScroll"); + scroll->setProperty("contentY", 0); + wheel(-120); + auto* first = quickWindow->findChild("homeFeaturedOpen"); + QMetaObject::invokeMethod(screen, "reveal", Q_ARG(QVariant, QVariant::fromValue(first))); + const double revealed = scroll->property("contentY").toDouble(); + QTimer::singleShot(350, quickWindow, [scroll, revealed, &application] { + if (qAbs(scroll->property("contentY").toDouble() - revealed) > 1) { + qCritical() << "Home wheel fought navigation reveal"; + application.exit(EXIT_FAILURE); + } + }); + }); + }); + } + if (renderOverlay == QStringLiteral("home-delayed")) { + const QSize originalSize = quickWindow->size(); + QTimer::singleShot(250, quickWindow, [quickWindow] { quickWindow->setProperty("homeOpen", true); }); + QTimer::singleShot(400, quickWindow, [quickWindow, &home] { + quickWindow->resize(820, 590); + home.enqueue("Demo", "", "demo-9"); + }); + QTimer::singleShot(550, quickWindow, [quickWindow] { quickWindow->setProperty("homeOpen", false); }); + QTimer::singleShot(650, quickWindow, [quickWindow, originalSize] { + quickWindow->resize(originalSize); + quickWindow->setProperty("homeOpen", true); + }); + } + if (renderOverlay == QStringLiteral("home-overview")) { + quickWindow->setProperty("homeOpen", true); + home.refresh(); + } + if (renderOverlay == QStringLiteral("home-full-queue")) { + for (int i = 0; i < 100; ++i) home.enqueue("Demo", "", QString("demo-%1").arg(i)); + quickWindow->setProperty("homeOpen", true); + home.refresh(); + QObject::connect(quickWindow, &QQuickWindow::frameSwapped, quickWindow, [quickWindow, &home, &application] { + auto* screen = quickWindow->findChild("homeScreen"); + if (!screen || home.queue().size() != 100) { + qCritical() << "Full queue fixture did not load"; + application.exit(EXIT_FAILURE); return; + } + quickWindow->requestActivate(); + QMetaObject::invokeMethod(screen, "focusHome"); + auto* first = quickWindow->activeFocusItem(); + QSet queueTiles; + bool wrapped = false; + for (int step = 0; step < 500; ++step) { + QKeyEvent tab(QEvent::KeyPress, Qt::Key_Tab, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &tab); + auto* focused = quickWindow->activeFocusItem(); + if (!focused || !focused->isVisible()) break; + if (focused->objectName().startsWith("homeTile-queue:")) queueTiles.insert(focused->objectName()); + if (focused == first) { wrapped = true; break; } + } + if (!wrapped || queueTiles.size() != 100) { + qCritical() << "Full queue traversal failed" << wrapped << queueTiles.size(); + application.exit(EXIT_FAILURE); return; + } + const auto last = home.queue().last().toMap(); + const auto lastIdentity = "queue:" + last.value("queueKey").toString(); + QMetaObject::invokeMethod(screen, "focusIdentity", Q_ARG(QVariant, lastIdentity)); + auto* shelf = screen->findChild("homeQueueShelf"); + const int columns = shelf ? shelf->property("columns").toInt() : 0; + if (columns <= 0) { application.exit(EXIT_FAILURE); return; } + const auto aboveIdentity = "queue:" + home.queue()[99 - columns].toMap().value("queueKey").toString(); + QKeyEvent up(QEvent::KeyPress, Qt::Key_Up, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &up); + if (screen->property("focusedIdentity").toString() != aboveIdentity) { + qCritical() << "Full queue could not navigate to the row above its final tile" + << "expected" << aboveIdentity << "actual" << screen->property("focusedIdentity") + << "focused" << (quickWindow->activeFocusItem() ? quickWindow->activeFocusItem()->objectName() : QString{}) + << "columns" << columns; + application.exit(EXIT_FAILURE); return; + } + QMetaObject::invokeMethod(screen, "queueAction", Q_ARG(QVariant, last), Q_ARG(QVariant, QString("up"))); + QCoreApplication::processEvents(); + if (screen->property("focusedIdentity").toString() != lastIdentity || + home.queue()[98].toMap().value("queueKey") != last.value("queueKey")) { + qCritical() << "Full queue reorder lost focus"; + application.exit(EXIT_FAILURE); return; + } + const auto neighborIdentity = "queue:" + home.queue()[99].toMap().value("queueKey").toString(); + QMetaObject::invokeMethod(screen, "queueAction", Q_ARG(QVariant, last), Q_ARG(QVariant, QString("remove"))); + QCoreApplication::processEvents(); + if (home.queue().size() != 99 || screen->property("focusedIdentity").toString() != neighborIdentity) { + qCritical() << "Full queue removal lost its neighboring game"; + application.exit(EXIT_FAILURE); return; + } + quickWindow->setProperty("fullQueueChecked", true); + }, Qt::ConnectionType(Qt::QueuedConnection | Qt::SingleShotConnection)); + } + if (renderOverlay == QStringLiteral("home")) { + for (const auto* id : {"demo-1", "demo-2", "demo-3"}) + home.enqueue("Demo", "", id); + library.setSearchText("unmatched-home-original"); + quickWindow->setProperty("homeOpen", true); + home.refresh(); + QTimer::singleShot(180, quickWindow, [quickWindow, &home, &library, &application] { + auto* screen = quickWindow->findChild("homeScreen"); + if (!screen || !screen->isVisible() || home.recent().isEmpty() || + home.queue().size() != 3) { + qCritical() << "Home fixture did not load"; + application.exit(EXIT_FAILURE); + return; + } + quickWindow->requestActivate(); + const auto identity = home.recent().first().toMap().value("identity"); + QMetaObject::invokeMethod(screen, "focusHome"); + auto* firstControl = quickWindow->activeFocusItem(); + QSet tileControls; + bool returnedToHeader = false; + for (int step = 0; step < 150; ++step) { + QKeyEvent tab(QEvent::KeyPress, Qt::Key_Tab, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &tab); + auto* focused = quickWindow->activeFocusItem(); + if (!focused || !focused->isVisible()) { application.exit(EXIT_FAILURE); return; } + if (focused->objectName().startsWith("homeTile-")) { + const auto rect = focused->mapRectToScene(QRectF(0, 0, focused->width(), focused->height())); + if (rect.left() < 0 || rect.right() > quickWindow->width() + 1 || + rect.top() < 0 || rect.bottom() > quickWindow->height() + 1) { + qCritical() << "Home tile focus is outside the viewport"; + application.exit(EXIT_FAILURE); return; + } + tileControls.insert(focused->objectName()); + } + if (focused == firstControl) { returnedToHeader = true; break; } + } + if (!returnedToHeader || tileControls.size() < 8) { + qCritical() << "Home Tab traversal skipped its game tiles"; + application.exit(EXIT_FAILURE); return; + } + QMetaObject::invokeMethod(screen, "focusHome"); + QKeyEvent down(QEvent::KeyPress, Qt::Key_Down, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &down); + if (screen->property("focusedIdentity") != identity) { + qCritical() << "Home header did not navigate to the first game"; + application.exit(EXIT_FAILURE); + return; + } + QKeyEvent up(QEvent::KeyPress, Qt::Key_Up, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &up); + if (quickWindow->activeFocusItem() != firstControl) { + qCritical() << "Home featured game could not return to the header"; + application.exit(EXIT_FAILURE); return; + } + QCoreApplication::sendEvent(quickWindow, &down); + if (quickWindow->activeFocusItem()->objectName() != "homeFeaturedPlay") { + qCritical() << "Home did not prioritize Play"; + application.exit(EXIT_FAILURE); return; + } + QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + // Demo mode exercises the normal launch route without starting an emulator. + QCoreApplication::sendEvent(quickWindow, &enter); + if (!quickWindow->property("detailOpen").toBool()) { + qCritical() << "Home Play did not select its game"; + application.exit(EXIT_FAILURE); return; + } + QMetaObject::invokeMethod(quickWindow, "closeDetails"); + QCoreApplication::processEvents(); + if (quickWindow->activeFocusItem()->objectName() != "homeFeaturedPlay") { + qCritical() << "Home did not restore Play focus"; + application.exit(EXIT_FAILURE); return; + } + QKeyEvent right(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &right); + if (quickWindow->activeFocusItem()->objectName() != "homeFeaturedOpen") { + qCritical() << "Home Details is not beside Play in navigation"; + application.exit(EXIT_FAILURE); return; + } + QCoreApplication::sendEvent(quickWindow, &enter); + if (!quickWindow->property("detailOpen").toBool()) { + qCritical() << "Home could not open game details using keyboard"; + application.exit(EXIT_FAILURE); + return; + } + QMetaObject::invokeMethod(quickWindow, "closeDetails"); + QCoreApplication::processEvents(); + if (!quickWindow->activeFocusItem() || quickWindow->activeFocusItem()->objectName() != "homeFeaturedOpen") { + qCritical() << "Home did not restore the Details action"; + application.exit(EXIT_FAILURE); return; + } + if (library.searchText() != "unmatched-home-original" || + !quickWindow->property("homeOpen").toBool()) { + qCritical() << "Home did not restore library filters"; + application.exit(EXIT_FAILURE); + return; + } + const auto key = home.queue().last().toMap().value("queueKey").toString(); + if (!home.move(key, -1) || + home.queue().at(1).toMap().value("queueKey").toString() != key) { + application.exit(EXIT_FAILURE); + return; + } + QTimer::singleShot(80, quickWindow, [quickWindow, screen, &home, &application] { + const QVariant firstKey = "queue:" + home.queue().first().toMap().value("queueKey").toString(); + QMetaObject::invokeMethod(screen, "focusIdentity", Q_ARG(QVariant, firstKey)); + QMetaObject::invokeMethod(screen, "focusQueueActions", Q_ARG(QVariant, firstKey)); + QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &enter); + QCoreApplication::processEvents(); + QKeyEvent down(QEvent::KeyPress, Qt::Key_Down, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &down); + auto* focused = quickWindow->activeFocusItem(); + if (!focused || focused->property("text").toString() != "REMOVE") { + qCritical() << "Home queue actions are not reachable using navigation"; + application.exit(EXIT_FAILURE); + return; + } + QCoreApplication::sendEvent(quickWindow, &enter); + QCoreApplication::processEvents(); + if (home.queue().size() != 2) { + qCritical() << "Home keyboard remove failed"; + application.exit(EXIT_FAILURE); + return; + } + auto* browse = screen->findChild("homeBrowseAll"); + if (!browse) { application.exit(EXIT_FAILURE); return; } + browse->forceActiveFocus(); + QCoreApplication::sendEvent(quickWindow, &enter); + auto* libraryModel = qmlContext(quickWindow)->contextProperty("Library").value(); + if (quickWindow->property("homeOpen").toBool() || !libraryModel || + !libraryModel->property("searchText").toString().isEmpty() || + libraryModel->property("mode").toInt() != 0) { + qCritical() << "Home quick access retained stale filters"; + application.exit(EXIT_FAILURE); return; + } + quickWindow->setProperty("homeOpen", true); + QCoreApplication::processEvents(); + // Leave the queue visible for the narrow-layout screenshot. + const QVariant remaining = "queue:" + home.queue().first().toMap().value("queueKey").toString(); + QMetaObject::invokeMethod(screen, "focusIdentity", Q_ARG(QVariant, remaining)); + }); + }); + } + if (renderOverlay == QStringLiteral("metadata-filters")) { + QTimer::singleShot(120, quickWindow, [quickWindow, &application] { + auto* button = quickWindow->findChild("decadeFilterButton"); + auto* library = qmlContext(quickWindow)->contextProperty("Library").value(); + if (!button || !library) { + application.exit(EXIT_FAILURE); + return; + } + quickWindow->requestActivate(); + auto* filters = quickWindow->findChild("filtersMenuButton"); + if (filters) QMetaObject::invokeMethod(filters, "clicked"); + button->forceActiveFocus(); + QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &enter); + QTimer::singleShot(120, quickWindow, [quickWindow, library, button, &application] { + QKeyEvent down(QEvent::KeyPress, Qt::Key_Down, Qt::NoModifier); + QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &down); + QCoreApplication::sendEvent(quickWindow, &enter); + if (library->property("decadeFilter").toString().isEmpty() || + quickWindow->property("filterPickerOpen").toBool()) { + qCritical() << "Desktop decade picker failed"; + application.exit(EXIT_FAILURE); + return; + } + QMetaObject::invokeMethod(quickWindow, "clearLibraryFilters"); + if (!library->property("decadeFilter").toString().isEmpty()) { + application.exit(EXIT_FAILURE); + return; + } + button->forceActiveFocus(); + QCoreApplication::sendEvent(quickWindow, &enter); + }); + }); + } + if (renderOverlay.startsWith(QStringLiteral("game-info"))) { + QMetaObject::invokeMethod(quickWindow, "openGame", Q_ARG(QVariant, 0)); + QTimer::singleShot(120, quickWindow, [quickWindow, renderOverlay, screenshotPath, &application] { + auto* section = quickWindow->findChild("gameInfoSection"); + auto* details = quickWindow->findChild("gameDetails"); + if (!section || !details) { + application.exit(EXIT_FAILURE); + return; + } + if (renderOverlay == "game-info-overview-long") { + auto game = quickWindow->property("selectedGame").toMap(); + game["title"] = "The Legend of an Exceptionally Long International Adventure: Complete Anniversary Collection (North America, Revision 1)"; + game["playtimeSeconds"] = 45000; + game["playtimeText"] = "12h 30m"; + game["lastPlayed"] = 1700000000; + game["completionStatus"] = "playing"; + game["achievementsTotal"] = 100; + game["achievementsUnlocked"] = 42; + quickWindow->setProperty("selectedGame", game); + } + QVariantMap entry; + if (renderOverlay != "game-info-empty") { + entry = { + {"year", 1997}, + {"rating", 92}, + {"ratingCount", 560}, + {"releaseText", "July 28, 1997"}, + {"releaseLabel", "North America release"}, + {"romContext", "ROM region: North America · Revision: 1"}, + {"title", "Catalog title"}, + {"titleEvidence", + QStringList{"Regional title (North American title)", "別の名前 (Japan)"}}, + {"platformText", "Nintendo Switch"}, + {"genres", QStringList{"Adventure", "Role-playing (RPG)"}}, + {"developers", QStringList{"Example Studio"}}, + {"publishers", QStringList{"Example Publisher"}}, + {"summary", QStringLiteral("Explore a quiet mountain town and uncover the stories " + "its residents have left behind. Each journey opens new " + "paths through forests, " + "old observatories and forgotten gardens. ") + .repeated(8)}}; + } + if (renderOverlay.startsWith("game-info-hero-")) { + QImage image(960, 540, QImage::Format_RGB32); + image.fill(QColor("#245b75")); + QPainter painter(&image); + painter.fillRect(0, 0, 80, 540, QColor("#d79b56")); + painter.fillRect(880, 0, 80, 540, QColor("#75bf87")); + painter.setPen(Qt::white); + painter.drawText(image.rect(), Qt::AlignCenter, "FULL SCENE"); + painter.end(); + const QString imagePath = screenshotPath + ".hero.png"; + if (!image.save(imagePath)) { application.exit(EXIT_FAILURE); return; } + const QString imageUrl = QUrl::fromLocalFile(imagePath).toString(); + auto game = quickWindow->property("selectedGame").toMap(); + game["coverPath"] = imageUrl; + game["heroPath"] = renderOverlay.endsWith("custom") ? imageUrl : QString(); + quickWindow->setProperty("selectedGame", game); + entry["heroUrl"] = imageUrl; + if (renderOverlay.endsWith("screenshot")) entry["heroKind"] = "screenshot"; + } + if (renderOverlay == "game-info-real") { + QFile fixture(optionValue(application.arguments(), "--render-game-info-file")); + if (!fixture.open(QIODevice::ReadOnly)) { application.exit(EXIT_FAILURE); return; } + const auto data = QJsonDocument::fromJson(fixture.readAll()).object(); + const auto game = data.value("game").toObject().toVariantMap(); + entry = data.value("metadata").toObject().toVariantMap(); + if (game.isEmpty() || entry.isEmpty()) { application.exit(EXIT_FAILURE); return; } + quickWindow->setProperty("selectedGame", game); + quickWindow->setProperty("selectedInstallation", game); + if (auto* editor = quickWindow->findChild("metadataEditor")) + QQmlProperty::write(editor, "entry", entry); + } + quickWindow->requestActivate(); + details->setProperty("showOrganizationControls", true); + section->setProperty("entry", entry); + QTimer::singleShot( + 100, quickWindow, [quickWindow, section, details, renderOverlay, &application] { + auto* toggle = quickWindow->findChild("descriptionToggle"); + auto* description = quickWindow->findChild("gameDescription"); + if (renderOverlay.startsWith("game-info-hero-")) { + auto* hero = quickWindow->findChild("detailsHero"); + const bool legacy = renderOverlay.endsWith("legacy"); + if (!hero || (legacy ? !hero->property("source").toUrl().isEmpty() + : hero->property("status").toInt() != 1) || + hero->property("fillMode").toInt() != 1 || + hero->width() > details->width() || hero->height() > details->height()) { + qCritical() << "Detail backdrop selection or fit failed"; + application.exit(EXIT_FAILURE); + } + return; + } + if (renderOverlay.startsWith("game-info-tooltip")) { + auto* rating = quickWindow->findChild("gameRating"); + auto* platform = quickWindow->findChild("gamePlatformRelease"); + auto* tooltip = quickWindow->findChild("gameRatingTooltip"); + if (!rating || !platform || !tooltip) { + qCritical() << "Rating tooltip fixture missing"; + application.exit(EXIT_FAILURE); return; + } + const bool show = renderOverlay.endsWith("rating"); + const bool missing = renderOverlay.endsWith("missing"); + if (missing) { + auto entry = section->property("entry").toMap(); + entry.remove("rating"); + section->setProperty("entry", entry); + } + auto* target = show ? rating : platform; + // Exercise both ends of the platform/date text, then the rating separately. + const QPointF local(renderOverlay.endsWith("date") ? target->width() - 2 : 2, + target->height() / 2); + const QPointF scene = target->mapToScene(local); + QMouseEvent move(QEvent::MouseMove, scene, quickWindow->mapToGlobal(scene), + Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &move); + QTimer::singleShot(600, quickWindow, [quickWindow, rating, tooltip, show, missing, &application] { + if (tooltip->property("visible").toBool() != show || (missing && rating->isVisible())) { + qCritical() << "Rating tooltip hover boundary failed" << show << missing; + application.exit(EXIT_FAILURE); return; + } + if (show) { + auto* content = tooltip->property("contentItem").value(); + const auto bounds = content ? content->mapRectToScene(content->boundingRect()) : QRectF{}; + const auto anchor = rating->mapRectToScene(rating->boundingRect()); + if (!content || bounds.left() < 0 || bounds.right() > quickWindow->width() || + bounds.top() < 0 || bounds.bottom() > quickWindow->height() || + qAbs(bounds.top() - anchor.bottom()) > 30) { + qCritical() << "Rating tooltip is detached or outside window" << bounds << anchor; + application.exit(EXIT_FAILURE); + } + } + }); + return; + } + if (renderOverlay == "game-info-real") { + auto* hero = quickWindow->findChild("detailsHero"); + if (!description || description->property("text").toString().isEmpty() + || !hero || hero->property("status").toInt() != 1) { + qCritical() << "Real game fixture is missing description or artwork"; + application.exit(EXIT_FAILURE); + } + return; + } + if (renderOverlay == "game-info-empty") { + if (section->isVisible() && (!description || description->property("text").toString().isEmpty())) + application.exit(EXIT_FAILURE); + return; + } + auto* footer = quickWindow->findChild("detailsFooter"); + auto* scroll = quickWindow->findChild("detailsScroll"); + if (footer && footer->isVisible() && scroll && + scroll->mapToScene(QPointF(0, scroll->height())).y() > + footer->mapToScene(QPointF(0, 0)).y()) { + qCritical() << "Controller hints overlap the detail viewport"; + application.exit(EXIT_FAILURE); + return; + } + if (renderOverlay.startsWith("game-info-identify")) { + if (renderOverlay.endsWith("matched")) { + auto* editor = quickWindow->findChild("metadataEditor"); + QVariantMap entry = section->property("entry").toMap(); + entry["igdbId"] = 123; + entry["title"] = "Identified Adventure"; + entry["matchStatus"] = "Matched to IGDB"; + if (!editor) { application.exit(EXIT_FAILURE); return; } + QQmlProperty::write(editor, "entry", entry); + QTimer::singleShot(100, quickWindow, [quickWindow, editor, &application] { + auto* titleField = quickWindow->findChild("metadataTitleField"); + if (editor->property("entry").toMap().value("igdbId").toInt() != 123 || + !titleField || titleField->isVisible()) { + qCritical() << "Identified artwork panel unexpectedly asks for identification"; + application.exit(EXIT_FAILURE); + } + }); + } + auto* panel = quickWindow->findChild("identifyGamePanel"); + if (!panel || !QMetaObject::invokeMethod(panel, "open")) application.exit(EXIT_FAILURE); + if (renderOverlay.contains("late")) { + QTimer::singleShot(180, quickWindow, [quickWindow, panel, &application] { + auto* editor = quickWindow->findChild("metadataEditor"); + QVariantList covers; + for (int i = 0; i < 18; ++i) { + const auto svg = QString("ADVENTURE %2") + .arg(QColor::fromHsl((i * 37) % 360, 100, 95).name()).arg(i + 1); + covers.append(QVariantMap{{"id", i + 1}, {"url", "data:image/svg+xml;base64," + svg.toUtf8().toBase64()}}); + } + QQmlProperty::write(editor, "coverChoices", covers); + QTimer::singleShot(120, quickWindow, [quickWindow, panel, &application] { + quickWindow->resize(quickWindow->width(), qMin(600, quickWindow->height())); + QTimer::singleShot(80, quickWindow, [quickWindow, panel, &application] { + auto* done = quickWindow->findChild("metadataArtworkButton"); + const qreal bottom = panel->property("y").toReal() + panel->property("height").toReal(); + if (!done || !done->isVisible() || panel->property("y").toReal() < 23 || + bottom > quickWindow->height() - 23) { + qCritical() << "Artwork panel escaped resized window after covers arrived"; + application.exit(EXIT_FAILURE); return; + } + auto* content = panel->property("contentItem").value(); + auto* scroll = content ? content->property("navigationScrollView").value() : nullptr; + auto* flickable = scroll ? scroll->property("contentItem").value() : nullptr; + if (!scroll || !flickable || done->mapToScene(QPointF(0, done->height())).y() > + scroll->mapToScene(QPointF()).y()) { + qCritical() << "Done overlaps the artwork scrolling area"; + application.exit(EXIT_FAILURE); return; + } + const auto before = done->mapToScene(QPointF()); + flickable->setProperty("contentY", flickable->property("contentHeight").toReal() - flickable->height()); + if (done->mapToScene(QPointF()) != before) { + qCritical() << "Done moved when artwork scrolled"; + application.exit(EXIT_FAILURE); + } + flickable->setProperty("contentY", 0); + auto* tile = findVisualItem(content, "metadataCoverTile0"); + auto* second = findVisualItem(content, "metadataCoverTile1"); + if (!tile || !second || tile->property("controllerRightTarget").value() != second) { + qCritical() << "Cover tile navigation is unavailable" << tile << second << (tile ? tile->property("controllerRightTarget") : QVariant()); + application.exit(EXIT_FAILURE); return; + } + }); + }); + }); + } + return; + } + if (renderOverlay.startsWith("game-info-overview")) { + for (const auto* name : {"gameDetailsTitle", "gameIdentitySummary", "gameActivitySummary", "gameActions"}) { + auto* item = quickWindow->findChild(name); + const auto bounds = item ? item->mapRectToScene(item->boundingRect()) : QRectF{}; + if (!item || !item->isVisible() || bounds.top() < scroll->mapToScene(QPointF()).y() - 1 || + bounds.bottom() > scroll->mapToScene(QPointF(0, scroll->height())).y() + 1) { + qCritical() << "Essential detail information below the fold" << name << bounds; + application.exit(EXIT_FAILURE); + } + } + return; + } + auto* regional = quickWindow->findChild("regionalIdentityText"); + auto* aliases = quickWindow->findChild("aliasesText"); + auto* aliasesToggle = quickWindow->findChild("aliasesToggle"); + auto* credits = quickWindow->findChild("gameCredits"); + if (!credits || !regional || credits->mapToScene(QPointF(0, credits->height())).y() > + regional->mapToScene(QPointF()).y()) { + qCritical() << "Credits must precede the regional information group"; + application.exit(EXIT_FAILURE); return; + } + if (!regional || !regional->isVisible() || !aliases || aliases->isVisible() || + !aliasesToggle || !aliasesToggle->isVisible()) { + qCritical() << "Regional details or collapsed alias disclosure missing"; + application.exit(EXIT_FAILURE); return; + } + QMetaObject::invokeMethod(aliasesToggle, "clicked"); + if (!aliases->isVisible() || !aliases->property("text").toString().contains("Regional title (North American title)")) { + qCritical() << "Expanded alias evidence missing"; + application.exit(EXIT_FAILURE); return; + } + QMetaObject::invokeMethod(aliasesToggle, "clicked"); + if (details->property("releaseYear").toInt() != 1997) { + qCritical() << "Provider release year did not reach the title"; + application.exit(EXIT_FAILURE); + return; + } + if (!toggle || !description || !toggle->isVisible() || + !description->property("truncated").toBool()) { + qCritical() << "Long game description did not offer expansion"; + application.exit(EXIT_FAILURE); + return; + } + toggle->forceActiveFocus(); + QKeyEvent press(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + QKeyEvent release(QEvent::KeyRelease, Qt::Key_Return, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &press); + QCoreApplication::sendEvent(quickWindow, &release); + if (!section->property("expanded").toBool()) { + qCritical() << "Game description did not expand with keyboard activation"; + application.exit(EXIT_FAILURE); + return; + } + if (renderOverlay != "game-info-expanded") { + QCoreApplication::sendEvent(quickWindow, &press); + QCoreApplication::sendEvent(quickWindow, &release); + if (section->property("expanded").toBool()) { + application.exit(EXIT_FAILURE); + return; + } + } + QKeyEvent down(QEvent::KeyPress, Qt::Key_Down, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &down); + if (!aliasesToggle->hasActiveFocus()) { + qCritical() << "Read More did not navigate to Other Names"; + application.exit(EXIT_FAILURE); return; + } + QCoreApplication::sendEvent(quickWindow, &down); + auto* backlog = findVisualItem(quickWindow->contentItem(), "completionStatus-backlog"); + if (!backlog || !backlog->hasActiveFocus()) { + qCritical() << "Description navigation did not reach organization controls" + << backlog << quickWindow->activeFocusItem(); + application.exit(EXIT_FAILURE); + return; + } + QKeyEvent up(QEvent::KeyPress, Qt::Key_Up, Qt::NoModifier); + QCoreApplication::sendEvent(quickWindow, &up); + if (!aliasesToggle->hasActiveFocus()) { application.exit(EXIT_FAILURE); return; } + QCoreApplication::sendEvent(quickWindow, &up); + if (!toggle->hasActiveFocus()) { + qCritical() << "Organization navigation did not return to description"; + application.exit(EXIT_FAILURE); + return; + } + // A stale explicit link must never escape the active screen. + QQuickItem outside(quickWindow->contentItem()); + outside.setWidth(20); + outside.setHeight(20); + outside.setActiveFocusOnTab(true); + const QVariant savedTarget = toggle->property("controllerDownTarget"); + toggle->setProperty("controllerDownTarget", QVariant::fromValue(&outside)); + QCoreApplication::sendEvent(quickWindow, &down); + const bool escaped = outside.hasActiveFocus(); + toggle->setProperty("controllerDownTarget", savedTarget); + if (escaped) { + qCritical() << "Explicit navigation escaped the active screen"; + application.exit(EXIT_FAILURE); + return; + } + // Check both keyboard focus cycles through the real organization controls. + for (const auto modifiers : {Qt::NoModifier, Qt::ShiftModifier}) { + toggle->forceActiveFocus(); + QSet visited; + bool returned = false; + for (int step = 0; step < 300; ++step) { + // Direct window key events bypass Qt's platform shortcut dispatcher. + // Activate the registered Tab shortcut to exercise its actual QML route. + auto* shortcut = quickWindow->findChild( + modifiers == Qt::NoModifier ? "navigationTabForward" : "navigationTabBackward"); + if (!shortcut || !shortcut->property("enabled").toBool() || + !QMetaObject::invokeMethod(shortcut, "activated")) { + qCritical() << "Details Tab shortcut is unavailable"; + application.exit(EXIT_FAILURE); + return; + } + auto* focused = quickWindow->activeFocusItem(); + bool contained = false; + for (auto* parent = focused; parent; parent = parent->parentItem()) { + visited.insert(parent->objectName()); + if (parent == details) { contained = true; break; } + } + if (!focused || !focused->isVisible() || !focused->isEnabled() || !contained) { + qCritical() << "Tab navigation lost usable focus within details" + << step << modifiers << focused << contained; + application.exit(EXIT_FAILURE); + return; + } + const QRectF bounds = focused->mapRectToScene(focused->boundingRect()); + if (bounds.top() < -1 || bounds.bottom() > quickWindow->height() + 1) { + qCritical() << "Tab focus is outside the visible window" << focused << bounds; + application.exit(EXIT_FAILURE); + return; + } + if (focused == toggle) { returned = true; break; } + } + for (const auto* required : {"completionStatus-backlog", "detailsTagsField", + "newCollectionButton", "coverEditButton"}) { + auto* control = findVisualItem(details, required); + if (control && !control->isVisible()) continue; + if (!returned || !visited.contains(QLatin1String(required))) { + qCritical() << "Tab cycle missed a detail control" << required << modifiers; + application.exit(EXIT_FAILURE); + return; + } + } + } + toggle->forceActiveFocus(); + QMetaObject::invokeMethod(details, "revealFocusedItem", + Q_ARG(QVariant, QVariant::fromValue(section))); + }); + }); + } else if (renderOverlay == "gog-folders") { quickWindow->setProperty("diagnosticsOpen", true); QTimer::singleShot(120, quickWindow, [quickWindow] { auto* section = findVisualItem(quickWindow->contentItem(), "gogFoldersSection"); @@ -1323,13 +2171,22 @@ int main(int argc, char* argv[]) { if (section && scroll) QMetaObject::invokeMethod(quickWindow, "revealInScrollView", Q_ARG(QVariant, QVariant::fromValue(scroll)), Q_ARG(QVariant, QVariant::fromValue(section))); }); - } else if (renderOverlay == "linked-preference" || renderOverlay == "linked-preference-missing") { + } else if (renderOverlay == "linked-preference" || + renderOverlay == "linked-preference-missing") { QMetaObject::invokeMethod(quickWindow, "openGame", Q_ARG(QVariant, 0)); QTimer::singleShot(120, quickWindow, [quickWindow] { + auto* manage = findVisualItem(quickWindow->contentItem(), "detailManageButton"); + if (manage) QMetaObject::invokeMethod(manage, "clicked"); auto* button = findVisualItem(quickWindow->contentItem(), "preferredInstallationButton"); auto* details = quickWindow->findChild("gameDetails"); if (button && details) QMetaObject::invokeMethod(details, "revealFocusedItem", Q_ARG(QVariant, QVariant::fromValue(button))); }); + } else if (renderOverlay == QStringLiteral("detail-manage")) { + QMetaObject::invokeMethod(quickWindow, "openGame", Q_ARG(QVariant, 0)); + QTimer::singleShot(180, quickWindow, [quickWindow] { + auto* button = quickWindow->findChild("detailManageButton"); + if (button) QMetaObject::invokeMethod(button, "clicked"); + }); } else if (renderOverlay == QStringLiteral("backup-editor")) { QMetaObject::invokeMethod(quickWindow, "openBackupEditor"); if (auto* field = quickWindow->findChild("backupPathField")) field->setProperty("text", backupFixturePath); @@ -1342,11 +2199,27 @@ int main(int argc, char* argv[]) { library.saveCurrentFilter(QStringLiteral("Weekend favorites")); library.saveCurrentFilter(QStringLiteral("Short games for a quiet evening")); QMetaObject::invokeMethod(quickWindow, "openSavedFilters"); + } else if (renderOverlay == QStringLiteral("library-actions") || renderOverlay == QStringLiteral("library-sources") || renderOverlay == QStringLiteral("library-filters") || renderOverlay == QStringLiteral("library-view")) { + QTimer::singleShot(180, quickWindow, [quickWindow, renderOverlay, &application] { + const char* name = renderOverlay == "library-sources" ? "sourcesMenuButton" + : renderOverlay == "library-filters" ? "filtersMenuButton" + : renderOverlay == "library-view" ? "viewMenuButton" : "libraryMoreButton"; + auto* button = quickWindow->findChild(name); + if (!button || !QMetaObject::invokeMethod(button, "clicked")) { + qCritical() << "Library actions preview could not open the menu"; + application.exit(EXIT_FAILURE); + } + }); } else if (renderOverlay == QStringLiteral("random-selection")) { QMetaObject::invokeMethod(quickWindow, "pickRandomGame"); } else if (renderOverlay == QStringLiteral("artwork-editor")) { QMetaObject::invokeMethod(quickWindow, "openGame", Q_ARG(QVariant, 0)); QMetaObject::invokeMethod(quickWindow, "editArtwork"); + } else if (renderOverlay == QStringLiteral("recorder-details")) { + QMetaObject::invokeMethod(quickWindow, "openGame", Q_ARG(QVariant, 0)); + auto installation = quickWindow->property("selectedInstallation").toMap(); + installation.insert("playtimeProvenance", "Imported from emulator: 1h 10m · Recorded by Omakade: 15m (not applied while recording is off)"); + quickWindow->setProperty("selectedInstallation", installation); } else if (renderOverlay == QStringLiteral("manual-editor")) { QMetaObject::invokeMethod(quickWindow, "editManualGame", Q_ARG(QVariant, QString{})); if (auto* editor = quickWindow->findChild(QStringLiteral("manualGameEditor"))) { @@ -1356,14 +2229,19 @@ int main(int argc, char* argv[]) { QMetaObject::invokeMethod(editor, "loadDraft", Q_ARG(QVariant, draft)); } } else if (renderOverlay.startsWith(QStringLiteral("settings")) || - renderOverlay == QStringLiteral("couch-settings-top") || - renderOverlay == QStringLiteral("couch-settings-bottom")) { + renderOverlay == QStringLiteral("couch-settings-top") || + renderOverlay == QStringLiteral("couch-settings-bottom")) { quickWindow->setProperty("diagnosticsOpen", true); if (renderOverlay.startsWith("settings-")) { auto* page = quickWindow->findChild("settingsOverlay"); - const QStringList sections{"sources", "library", "connections", "controls", "about"}; + const QStringList sections{"sources", "library", "connections", "controls", "storage", "appearance", "streaming", "about"}; const int section = sections.indexOf(renderOverlay.mid(9)); if (page && section >= 0) page->setProperty("section", section); + if (page && renderOverlay.startsWith("settings-recorder-")) page->setProperty("section", 1); + if (page && renderOverlay == "settings-categories") { + auto* category = quickWindow->findChild("settingsCategoryButton"); + if (category) QMetaObject::invokeMethod(category, "clicked"); + } if (page && renderOverlay.startsWith("settings-connection-")) { bool okay = false; const int connection = renderOverlay.mid(QStringLiteral("settings-connection-").size()).toInt(&okay); @@ -1373,7 +2251,8 @@ int main(int argc, char* argv[]) { } } } - if (renderOverlay == QStringLiteral("settings") || + if (renderOverlay.startsWith("settings-recorder-") || + renderOverlay == QStringLiteral("settings") || renderOverlay == QStringLiteral("couch-settings-bottom")) { QTimer::singleShot(400, quickWindow, [quickWindow] { // Scroll to the end so the lower sections land in the capture. @@ -1417,7 +2296,16 @@ int main(int argc, char* argv[]) { Q_ARG(QVariant, QStringLiteral("Enter a value"))); } } - QTimer::singleShot(900, quickWindow, [quickWindow, screenshotPath, renderOverlay, &application] { + QTimer::singleShot(renderOverlay == "home-full-queue" ? 6000 : renderOverlay.startsWith("library-reflow") ? 10000 : renderOverlay.startsWith("home-wheel") ? 1300 : 900, quickWindow, [quickWindow, screenshotPath, renderOverlay, &application] { + if (renderOverlay.startsWith("library-reflow") && + !quickWindow->property("libraryReflowComplete").toBool()) { + qCritical() << "Library return fixture did not complete every transition"; + application.exit(EXIT_FAILURE); return; + } + if (renderOverlay == "home-full-queue" && !quickWindow->property("fullQueueChecked").toBool()) { + qCritical() << "Full queue checks did not complete after layout"; + application.exit(EXIT_FAILURE); return; + } if (renderOverlay.startsWith(QStringLiteral("couch-grid"))) { auto* grid = quickWindow->findChild(QStringLiteral("couchGameGrid")); auto* content = grid ? grid->property("contentItem").value() : nullptr; @@ -1470,6 +2358,33 @@ int main(int argc, char* argv[]) { if (overlay) check(check, overlay); if (!okay) { application.exit(EXIT_FAILURE); return; } } + if (renderOverlay == "home-delayed") { + auto* feature = quickWindow->findChild("homeFeaturedSection"); + auto* shelf = quickWindow->findChild("homeRecentShelf"); + const auto rect = [](QQuickItem* item) { return item->mapRectToScene(QRectF(0, 0, item->width(), item->height())); }; + if (!feature || !shelf || feature->height() < 100 || shelf->height() < 150 || rect(shelf).top() < rect(feature).bottom()) { + qCritical() << "Opening Home after startup collapsed its sections"; + application.exit(EXIT_FAILURE); return; + } + QList tiles; + for (auto* child : shelf->childItems()) { + if (!child->property("game").isValid()) continue; + const auto bounds = rect(child); + if (bounds.width() < 80 || bounds.height() < 150 || bounds.left() < rect(shelf).left() - 1 || + bounds.right() > rect(shelf).right() + 1 || bounds.bottom() > rect(shelf).bottom() + 1) { + qCritical() << "Home tile escaped its shelf after resize"; + application.exit(EXIT_FAILURE); return; + } + for (const auto& other : tiles) { + if (bounds.intersects(other)) { + qCritical() << "Home tiles overlap after delayed loading"; + application.exit(EXIT_FAILURE); return; + } + } + tiles.append(bounds); + } + if (tiles.size() != 6) { qCritical() << "Home tiles did not load"; application.exit(EXIT_FAILURE); return; } + } const QImage screenshot = quickWindow->grabWindow(); if (screenshot.isNull() || !screenshot.save(screenshotPath)) { qCritical() << "Could not save screenshot to" << screenshotPath; @@ -1763,6 +2678,22 @@ int main(int argc, char* argv[]) { fail(QStringLiteral("Couch All Sources did not clear the Emulated filter")); return; } + // Reach the new categories through the actual scrolling category list. + sendKey(Qt::Key_Left); + for (int i = 0; i < 9; ++i) + sendKey(Qt::Key_Down); + sendKey(Qt::Key_Right); + sendKey(Qt::Key_Down); + sendKey(Qt::Key_Return); + if (library->property("decadeFilter").toString().isEmpty()) { + fail(QStringLiteral("Couch Browse did not apply a release decade")); + return; + } + library->setProperty("decadeFilter", QString()); + sendKey(Qt::Key_Left); + for (int i = 0; i < 9; ++i) + sendKey(Qt::Key_Up); + sendKey(Qt::Key_Right); sendKey(Qt::Key_Left); sendKey(Qt::Key_Down); sendKey(Qt::Key_Right); @@ -2068,8 +2999,14 @@ int main(int argc, char* argv[]) { *step = [&application, rootWindow, &controller, attempts, step] { auto* window = qobject_cast(rootWindow); auto* editor = rootWindow->findChild(QStringLiteral("metadataEditor")); - if (editor != nullptr) + auto* identifyPanel = rootWindow->findChild("identifyGamePanel"); + if (identifyPanel && !identifyPanel->property("opened").toBool()) + QMetaObject::invokeMethod(identifyPanel, "open"); + if (editor != nullptr) { + editor->setProperty("matchControlsOpen", true); + editor->setProperty("coverControlsOpen", true); editor->setProperty("editing", true); + } auto* opener = rootWindow->findChild(QStringLiteral("metadataArtworkButton")); auto* lastRow = @@ -2171,46 +3108,6 @@ int main(int argc, char* argv[]) { return; } } - auto* statusLayout = item("statusLayout"); - if (statusLayout && statusLayout->property("columns").toInt() == 5) { - qreal rowY = -1; - for (auto* child : statusLayout->childItems()) { - if (!child->property("modelData").isValid()) continue; - if (rowY < 0) rowY = child->y(); - if (qAbs(child->y() - rowY) > 1) { - qCritical("Status buttons do not share one row at wide widths"); - application.exit(EXIT_FAILURE); - return; - } - } - } - auto* collectionsScroll = item("collectionsScroll"); - auto* newCollection = item("newCollectionButton"); - if (!collectionsScroll || !newCollection || - newCollection->height() > collectionsScroll->height()) { - qCritical("Collection controls are clipped vertically"); - application.exit(EXIT_FAILURE); - return; - } - auto* scroll = item("detailsScroll"); - auto* wiki = item("pcGamingWikiButton"); - auto* details = item("gameDetails"); - auto* flickable = scroll ? scroll->property("navigationFlickable").value() : nullptr; - if (!flickable || !wiki || !wiki->isVisible() || !details) { - qCritical("Details title visibility fixture is missing"); - application.exit(EXIT_FAILURE); - return; - } - flickable->setProperty("contentY", flickable->property("originY").toReal() + 100); - wiki->forceActiveFocus(); - QMetaObject::invokeMethod(rootWindow, "revealNavigationItem", - Q_ARG(QVariant, QVariant::fromValue(details)), - Q_ARG(QVariant, QVariant::fromValue(wiki))); - if (qAbs(flickable->property("contentY").toReal() - flickable->property("originY").toReal()) > 1) { - qCritical("Returning to the top details control left the title scrolled away"); - application.exit(EXIT_FAILURE); - return; - } // The clear button sits inside its field's own rectangle, so no amount of geometry // finds it: right never enters the field it is already inside, and left prefers the // field itself. The field points at it, and from there right carries on. @@ -2268,7 +3165,57 @@ int main(int argc, char* argv[]) { } rootWindow->setProperty("couchTextEntryOpen", false); } + controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); + auto* manageInvoker = item("coverEditButton"); + if (identifyPanel->property("opened").toBool() || !manageInvoker || !manageInvoker->hasActiveFocus()) { + qCritical() << "Closing artwork did not restore the cover button focus"; + application.exit(EXIT_FAILURE); return; + } + // Closing the on-screen keyboard and popup schedules layout polish. Check the + // underlying page after that polish, not its intermediate row positions. + QTimer::singleShot(50, &application, [rootWindow, &application, item] { + auto* statusLayout = item("statusLayout"); + if (statusLayout && statusLayout->property("columns").toInt() == 5) { + qreal rowY = -1; + for (auto* child : statusLayout->childItems()) { + if (!child->property("modelData").isValid()) continue; + if (rowY < 0) rowY = child->y(); + if (qAbs(child->y() - rowY) > 1) { + qCritical("Status buttons do not share one row at wide widths"); + application.exit(EXIT_FAILURE); + return; + } + } + } + auto* collectionsScroll = item("collectionsScroll"); + auto* newCollection = item("newCollectionButton"); + if (!collectionsScroll || !newCollection || + newCollection->height() > collectionsScroll->height()) { + qCritical("Collection controls are clipped vertically"); + application.exit(EXIT_FAILURE); + return; + } + auto* scroll = item("detailsScroll"); + auto* wiki = item("detailsBackButton"); + auto* details = item("gameDetails"); + auto* flickable = scroll ? scroll->property("navigationFlickable").value() : nullptr; + if (!flickable || !wiki || !wiki->isVisible() || !details) { + qCritical("Details title visibility fixture is missing"); + application.exit(EXIT_FAILURE); + return; + } + flickable->setProperty("contentY", flickable->property("originY").toReal() + 100); + wiki->forceActiveFocus(); + QMetaObject::invokeMethod(rootWindow, "revealNavigationItem", + Q_ARG(QVariant, QVariant::fromValue(details)), + Q_ARG(QVariant, QVariant::fromValue(wiki))); + if (qAbs(flickable->property("contentY").toReal() - flickable->property("originY").toReal()) > 1) { + qCritical("Returning to the top details control left the title scrolled away"); + application.exit(EXIT_FAILURE); + return; + } application.exit(EXIT_SUCCESS); + }); } }; (*step)(); @@ -2304,284 +3251,148 @@ int main(int argc, char* argv[]) { fail(QStringLiteral("Controller Down did not return to the first library row")); return; } - auto* sort = quickWindow->findChild(QStringLiteral("sortButton")); - auto* pickGame = - quickWindow->findChild(QStringLiteral("randomGameButton")); - auto* coverSize = quickWindow->findChild(QStringLiteral("coverSizeButton")); - auto* rescan = quickWindow->findChild(QStringLiteral("rescanButton")); - auto* settings = - quickWindow->findChild(QStringLiteral("settingsButton")); - const bool narrow = quickWindow->width() < 1040; - auto* allMode = quickWindow->findChild( - narrow ? QStringLiteral("narrowAllModeButton") : QStringLiteral("allModeButton")); - auto* hiddenMode = quickWindow->findChild( - narrow ? QStringLiteral("narrowHiddenModeButton") - : QStringLiteral("hiddenModeButton")); - auto* allSources = - quickWindow->findChild(QStringLiteral("allSourcesButton")); - auto* sourceFlickable = - quickWindow->findChild(QStringLiteral("sourceFlickable")); - auto* retroArchSource = - quickWindow->findChild(QStringLiteral("retroArchSourceButton")); - auto* statusFilter = - quickWindow->findChild(QStringLiteral("statusFilterButton")); - auto* installedAvailability = quickWindow->findChild( - QStringLiteral("installedAvailabilityButton")); - auto* readyAvailability = - quickWindow->findChild(QStringLiteral("readyAvailabilityButton")); - auto* tagFilter = - quickWindow->findChild(QStringLiteral("tagFilterButton")); - auto* settingsScroll = - quickWindow->findChild(QStringLiteral("settingsScroll")); - if (sort == nullptr || coverSize == nullptr || rescan == nullptr || settings == nullptr || - allMode == nullptr || hiddenMode == nullptr || allSources == nullptr || - sourceFlickable == nullptr || - retroArchSource == nullptr || statusFilter == nullptr || tagFilter == nullptr || - installedAvailability == nullptr || readyAvailability == nullptr || - settingsScroll == nullptr) { - fail(QStringLiteral("Controller navigation test could not find toolbar controls")); - return; - } - const auto withinWindow = [quickWindow](QQuickItem* item) { - const QPointF topLeft = item->mapToScene(QPointF(0, 0)); - return topLeft.x() >= 0 && topLeft.y() >= 0 && - topLeft.x() + item->width() <= quickWindow->width() && - topLeft.y() + item->height() <= quickWindow->height(); + const auto item = [quickWindow](const char* name) { + return quickWindow->findChild(name); }; - if (!withinWindow(settings) || !withinWindow(sort) || !withinWindow(rescan)) { - fail(QStringLiteral("Library toolbar controls extend outside the window")); - return; - } - QObject* filterModel = qmlContext(quickWindow)->contextProperty(QStringLiteral("Library")).value(); - const QVariant originalMode = filterModel->property("mode"); - const QVariant originalAvailability = filterModel->property("availability"); - const QVariant originalIndex = grid->property("currentIndex"); - for (QQuickItem* control : {hiddenMode, allMode, readyAvailability, installedAvailability}) { + const auto settle = [] { + QEventLoop loop; + QTimer::singleShot(50, &loop, &QEventLoop::quit); + loop.exec(); + }; + const auto activate = [&controller, &settle](QQuickItem* control) { + if (!control || !control->isVisible() || !control->isEnabled()) return false; control->forceActiveFocus(); controller.keyRequested(Qt::Key_Return, Qt::NoModifier); - QEventLoop settle; - QTimer::singleShot(30, &settle, &QEventLoop::quit); - settle.exec(); - if (!control->hasActiveFocus()) { - fail(QStringLiteral("Desktop filter activation stole controller focus")); - return; - } - } - filterModel->setProperty("mode", originalMode); - filterModel->setProperty("availability", originalAvailability); - QCoreApplication::processEvents(); - grid->setProperty("currentIndex", originalIndex); - if (!narrow) { - hiddenMode->forceActiveFocus(); - controller.focusDirectionRequested(Qt::Key_Right); - if (!search->hasActiveFocus()) { - fail("Library navigation skipped the search field"); - return; - } + settle(); + return true; + }; + const auto withinWindow = [quickWindow](QQuickItem* control) { + if (!control || control->width() <= 0 || control->height() <= 0) return false; + const auto p = control->mapToScene(QPointF(0, 0)); + return p.x() >= 0 && p.y() >= 0 && p.x() + control->width() <= quickWindow->width() + 1 + && p.y() + control->height() <= quickWindow->height() + 1; + }; + const auto opened = [quickWindow](const char* name) { + auto* menu = quickWindow->findChild(name); + return menu && menu->property("opened").toBool(); + }; + auto* sort = item("sortButton"); + auto* sources = item("sourcesMenuButton"); + auto* filters = item("filtersMenuButton"); + auto* view = item("viewMenuButton"); + auto* more = item("libraryMoreButton"); + auto* settings = item("settingsButton"); + auto* settingsScroll = item("settingsScroll"); + for (auto* control : {sort, sources, filters, view, more, settings, search}) { + if (!withinWindow(control)) { fail("Library toolbar extends outside the window"); return; } } const QString fieldError = verifyEditorTextFields(quickWindow, search, controller); if (!fieldError.isEmpty()) { fail(fieldError); return; } grid->forceActiveFocus(); controller.toolbarRequested(); - if (!sort->hasActiveFocus()) { - fail(QStringLiteral("Controller Controls did not enter the library toolbar")); - return; - } - controller.focusDirectionRequested(Qt::Key_Left); - auto* random = quickWindow->findChild(QStringLiteral("randomGameButton")); - if (!random || !random->hasActiveFocus() || !withinWindow(random)) { - fail(QStringLiteral("Controller Left did not reach Pick a Game")); - return; - } - controller.focusDirectionRequested(Qt::Key_Left); - if (narrow) { - if (!retroArchSource->hasActiveFocus()) { - fail(QStringLiteral("Controller Left did not reach source filters when tiled")); - return; - } - const QPointF sourcePosition = - retroArchSource->mapToItem(sourceFlickable, QPointF(0, 0)); - if (sourcePosition.x() < 0 || - sourcePosition.x() + retroArchSource->width() > sourceFlickable->width()) { - fail(QStringLiteral("Focused source filter was not revealed")); - return; - } - // All Sources, Emulated, then the six demo sources up to RetroArch. - for (int step = 0; step < 8; ++step) { - controller.focusDirectionRequested(Qt::Key_Left); - } - controller.focusDirectionRequested(Qt::Key_Up); - if (!allMode->hasActiveFocus()) { - fail(QStringLiteral("Controller Up did not reach tiled library mode filters")); - return; - } - for (int step = 0; step < 3; ++step) { - controller.focusDirectionRequested(Qt::Key_Right); - } - if (!hiddenMode->hasActiveFocus()) { - fail(QStringLiteral("Controller could not traverse tiled library mode filters")); - return; - } - controller.focusDirectionRequested(Qt::Key_Down); - } else { - if (!hiddenMode->hasActiveFocus()) { - fail(QStringLiteral("Controller Left did not reach library mode filters")); - return; - } - for (int step = 0; step < 3; ++step) { - controller.focusDirectionRequested(Qt::Key_Left); - } - if (!allMode->hasActiveFocus()) { - fail(QStringLiteral("Controller could not traverse library mode filters")); - return; - } - controller.focusDirectionRequested(Qt::Key_Down); - } - if (!retroArchSource->hasActiveFocus()) { - fail(QStringLiteral("Controller Down did not reach source filters")); - return; - } - // Right past the last visible source continues along the toolbar, never - // into the grid, even though the emulator chips after RetroArch are hidden here. - // Pick A Game is the first toolbar button, so the row reaches it before Sort. - controller.focusDirectionRequested(Qt::Key_Right); - if (pickGame == nullptr || !pickGame->hasActiveFocus()) { - fail(QStringLiteral("Controller Right from the last source did not reach the toolbar")); - return; - } - controller.focusDirectionRequested(Qt::Key_Right); - if (!sort->hasActiveFocus()) { - fail(QStringLiteral("Controller Right from Pick A Game did not reach Sort")); - return; - } + if (!sort->hasActiveFocus()) { fail("Controls did not enter the toolbar"); return; } controller.focusDirectionRequested(Qt::Key_Left); - if (!pickGame->hasActiveFocus()) { - fail(QStringLiteral("Controller Left from Sort did not return to Pick A Game")); - return; + if (!filters->hasActiveFocus()) { fail("Toolbar left skipped Filters"); return; } + if (!activate(sources) || !opened("librarySources")) { fail("Sources menu did not open"); return; } + auto* allSources = item("allSourcesButton"); + auto* retro = item("retroArchSourceButton"); + auto* steam = item("steamSourceButton"); + auto* model = qmlContext(quickWindow)->contextProperty("Library").value(); + if (!model || !allSources || !retro || !steam || !allSources->hasActiveFocus()) { + fail("Sources menu did not focus All sources"); return; } - controller.focusDirectionRequested(Qt::Key_Left); - if (!narrow) { - if (!hiddenMode->hasActiveFocus()) { - fail(QStringLiteral("Controller Left from Pick A Game did not return to the mode filters")); - return; - } - controller.focusDirectionRequested(Qt::Key_Down); - } - if (!retroArchSource->hasActiveFocus()) { - fail(QStringLiteral("Controller could not return to the source filters")); - return; - } - // Source chips are multi-select: activating one highlights it and clears - // the All Sources highlight; activating it again undoes both. - auto* sourceLibrary = qmlContext(quickWindow) - ->contextProperty(QStringLiteral("Library")) - .value(); - controller.keyRequested(Qt::Key_Return, Qt::NoModifier); - if (sourceLibrary == nullptr || !retroArchSource->property("selected").toBool() || - allSources->property("selected").toBool() || - sourceLibrary->property("sourceFilters").toStringList() != QStringList{QStringLiteral("RetroArch")}) { - fail(QStringLiteral("Activating a source chip did not highlight it")); - return; - } - // Enter again keeps the single selection; the favorite button removes it. - controller.keyRequested(Qt::Key_Return, Qt::NoModifier); - if (!retroArchSource->property("selected").toBool()) { - fail(QStringLiteral("Enter on a selected source chip should keep it selected")); - return; - } - controller.favoriteRequested(); - if (retroArchSource->property("selected").toBool() || - !allSources->property("selected").toBool() || - !sourceLibrary->property("sourceFilters").toStringList().isEmpty()) { - fail(QStringLiteral("The favorite button did not remove the source chip")); - return; + controller.keyRequested(Qt::Key_Down, Qt::NoModifier); settle(); + if (allSources->hasActiveFocus()) { fail("Keyboard Down did not navigate Sources popup"); return; } + controller.keyRequested(Qt::Key_Up, Qt::NoModifier); settle(); + if (!allSources->hasActiveFocus()) { fail("Keyboard Up did not return to All sources"); return; } + if (!activate(retro) || model->property("sourceFilters").toStringList() != QStringList{"RetroArch"}) { + fail("Source selection changed behavior"); return; } - // Shift+Enter adds without replacing what is selected. - controller.keyRequested(Qt::Key_Return, Qt::NoModifier); - controller.focusDirectionRequested(Qt::Key_Left); + steam->forceActiveFocus(); controller.keyRequested(Qt::Key_Return, Qt::ShiftModifier); - if (sourceLibrary->property("sourceFilters").toStringList().size() != 2 || - !retroArchSource->property("selected").toBool()) { - fail(QStringLiteral("Shift+Enter did not add a second source")); - return; + settle(); + if (model->property("sourceFilters").toStringList().size() != 2) { + fail("Sources menu lost additive selection"); return; } controller.favoriteRequested(); - controller.focusDirectionRequested(Qt::Key_Right); - controller.favoriteRequested(); - if (!sourceLibrary->property("sourceFilters").toStringList().isEmpty() || - !retroArchSource->hasActiveFocus()) { - fail(QStringLiteral("Could not clear the multi-selection with the favorite button")); - return; + if (model->property("sourceFilters").toStringList() != QStringList{"RetroArch"}) { + fail("Controller favorite did not remove a source"); return; } - for (int step = 0; step < 8; ++step) { - controller.focusDirectionRequested(Qt::Key_Left); - } - if (!allSources->hasActiveFocus()) { - fail(QStringLiteral("Controller could not traverse all source filters")); - return; - } - controller.focusDirectionRequested(Qt::Key_Down); - if (ownedLayoutTest) { - if (!installedAvailability->hasActiveFocus()) { - fail(QStringLiteral("Controller Down did not reach availability filters")); - return; - } - controller.focusDirectionRequested(Qt::Key_Right); - controller.focusDirectionRequested(Qt::Key_Right); - if (!readyAvailability->hasActiveFocus()) { - fail(QStringLiteral("Controller could not traverse availability filters")); - return; - } - controller.focusDirectionRequested(Qt::Key_Down); - } - if (!statusFilter->hasActiveFocus()) { - fail(QStringLiteral("Controller Down did not reach organization filters")); - return; - } - controller.focusDirectionRequested(Qt::Key_Right); - controller.focusDirectionRequested(Qt::Key_Right); - if (!tagFilter->hasActiveFocus()) { - fail(QStringLiteral("Controller could not traverse organization filters")); - return; - } - controller.toolbarRequested(); - controller.toolbarRequested(); - controller.focusDirectionRequested(Qt::Key_Right); - if (!coverSize->hasActiveFocus()) { - fail(QStringLiteral("Controller Right did not reach Cover size")); return; + activate(allSources); + controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); settle(); + if (opened("librarySources") || !sources->hasActiveFocus()) { fail("Sources did not restore focus"); return; } + if (!activate(filters) || !opened("libraryFilters")) { fail("Filters menu did not open"); return; } + auto* filterStart = quickWindow->activeFocusItem(); + controller.keyRequested(Qt::Key_Down, Qt::NoModifier); settle(); + if (quickWindow->activeFocusItem() == filterStart) { fail("Keyboard Down did not navigate Filters popup"); return; } + auto* hidden = item("hiddenModeButton"); + if (hidden && hidden->isVisible()) { + activate(hidden); + if (model->property("mode").toInt() != 3) { fail("Hidden games filter was not applied"); return; } + activate(hidden); + if (model->property("mode").toInt() != 0) { fail("Hidden games filter did not clear"); return; } } - const int selectedBeforeSize = grid->property("currentIndex").toInt(); - controller.keyRequested(Qt::Key_Return, Qt::NoModifier); - QCoreApplication::processEvents(); - auto* slider = quickWindow->activeFocusItem(); - if (!slider || slider->objectName() != QStringLiteral("coverSizeSlider")) { - fail(QStringLiteral("Cover size did not focus its slider")); return; + if (!activate(item("statusFilterButton")) || !quickWindow->property("filterPickerOpen").toBool()) { + fail("Filters did not open the status picker"); return; } - const double oldSize = slider->property("value").toDouble(); + controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); settle(); + if (!opened("libraryFilters")) { fail("Value picker did not return to Filters"); return; } + controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); settle(); + if (!filters->hasActiveFocus()) { fail("Filters did not restore its invoker"); return; } + if (!activate(sort) || !opened("librarySort")) { fail("Sort menu did not open"); return; } + controller.keyRequested(Qt::Key_Down, Qt::NoModifier); settle(); + controller.keyRequested(Qt::Key_Return, Qt::NoModifier); settle(); + if (model->property("sortMode").toInt() != 1 || !sort->hasActiveFocus()) { fail("Sort choice was not applied"); return; } + model->setProperty("sortMode", 0); + if (!activate(view) || !opened("libraryViewMenu")) { fail("View menu did not open"); return; } + if (!activate(item("coverSizeButton"))) { fail("Cover size is unreachable"); return; } + auto* slider = item("coverSizeSlider"); + if (!slider || !slider->hasActiveFocus()) { fail("Cover size did not focus its slider"); return; } + const double size = slider->property("value").toDouble(); controller.keyRequested(Qt::Key_Left, Qt::NoModifier); - if (slider->property("value").toDouble() >= oldSize) { - fail(QStringLiteral("Controller Left did not reduce cover size")); return; - } + if (slider->property("value").toDouble() >= size) { fail("Cover size keyboard input failed"); return; } controller.keyRequested(Qt::Key_Right, Qt::NoModifier); - controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); - QCoreApplication::processEvents(); - if (!coverSize->hasActiveFocus() || grid->property("currentIndex").toInt() != selectedBeforeSize) { - fail(QStringLiteral("Cover sizing lost the selected game or toolbar focus")); return; - } - controller.focusDirectionRequested(Qt::Key_Right); - if (!rescan->hasActiveFocus()) { - fail(QStringLiteral("Controller Right did not reach Rescan")); - return; - } - controller.focusDirectionRequested(Qt::Key_Up); - if (!settings->hasActiveFocus()) { - controller.focusDirectionRequested(Qt::Key_Right); + controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); settle(); + if (!opened("libraryViewMenu")) { fail("Cover size did not return to View"); return; } + controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); settle(); + if (!view->hasActiveFocus()) { fail("View did not restore focus"); return; } + if (!activate(more) || !opened("libraryActions")) { fail("More did not open"); return; } + auto* first = item("randomGameButton"); + if (!first || !first->hasActiveFocus()) { fail("More initial focus is unstable"); return; } + for (const char* shortcutName : {"navigationTabForward", "navigationTabBackward"}) { + first->forceActiveFocus(); + QSet visited; + bool returned = false; + for (int step = 0; step < 20; ++step) { + controller.keyRequested(QString(shortcutName) == "navigationTabForward" ? Qt::Key_Tab : Qt::Key_Backtab, Qt::NoModifier); + settle(); + auto* focused = quickWindow->activeFocusItem(); + if (!focused || !focused->isVisible() || !withinWindow(focused)) { fail("Menu Tab lost usable focus"); return; } + visited.insert(focused->objectName()); + if (focused == first) { returned = true; break; } + } + if (!returned || !visited.contains("bulkOrganizationButton") || !visited.contains("savedFiltersButton") + || !visited.contains("rescanButton") || !visited.contains("actionMenuCloseButton")) { + fail("Menu Tab skipped a command"); return; + } } - if (!settings->hasActiveFocus()) { - fail(QStringLiteral("Controller could not reach Settings from Rescan")); - return; + controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); settle(); + for (const char* command : {"bulkOrganizationButton", "savedFiltersButton"}) { + activate(more); + if (!activate(item(command))) { fail("Editor menu command unavailable"); return; } + const char* state = QString(command) == "bulkOrganizationButton" ? "bulkOrganizationOpen" : "savedFiltersOpen"; + if (!quickWindow->property(state).toBool()) { fail("Menu did not open its editor"); return; } + controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); settle(); + if (quickWindow->property(state).toBool() || !more->hasActiveFocus()) { fail("Editor did not return to More"); return; } } - controller.keyRequested(Qt::Key_Return, Qt::NoModifier); + activate(more); + QMetaObject::invokeMethod(quickWindow, "updateCouchMode", Q_ARG(QVariant, true), Q_ARG(QVariant, false)); + settle(); + if (opened("libraryActions")) { fail("Desktop menu remained open in Couch Mode"); return; } + QMetaObject::invokeMethod(quickWindow, "updateCouchMode", Q_ARG(QVariant, false), Q_ARG(QVariant, false)); + settle(); + grid->setProperty("currentIndex", 0); + if (!activate(settings)) { fail("Settings is unreachable"); return; } QTimer::singleShot( 100, quickWindow, [quickWindow, &application, &controller, grid, settingsScroll, fail] { @@ -2622,9 +3433,9 @@ int main(int argc, char* argv[]) { auto* favorite = quickWindow->findChild(QStringLiteral("favoriteButton")); auto* manage = - quickWindow->findChild(QStringLiteral("manageButton")); + quickWindow->findChild(QStringLiteral("addToQueueButton")); auto* hide = - quickWindow->findChild(QStringLiteral("hideButton")); + quickWindow->findChild(QStringLiteral("detailManageButton")); auto* gameActions = quickWindow->findChild(QStringLiteral("gameActions")); if (!quickWindow->property("detailOpen").toBool() || play == nullptr || @@ -2698,6 +3509,25 @@ int main(int argc, char* argv[]) { fail(QStringLiteral("Controller could not reverse through game actions")); return; } + for (const char* name : {"favoriteButton", "addToQueueButton", "detailManageButton"}) { + auto* action = quickWindow->findChild(name); + if (!action || qAbs(action->width() - play->width()) > 1) { + fail("Game action buttons have unequal widths"); return; + } + } + auto* manageMenuButton = quickWindow->findChild("detailManageButton"); + manageMenuButton->forceActiveFocus(); + controller.keyRequested(Qt::Key_Return, Qt::NoModifier); + QCoreApplication::processEvents(); + auto* managePopup = quickWindow->findChild("detailManageMenu"); + if (!managePopup || !managePopup->property("opened").toBool()) { fail("Game Manage menu did not open"); return; } + auto* firstAction = quickWindow->activeFocusItem(); + for (int step = 0; step < 12; ++step) controller.focusDirectionRequested(Qt::Key_Down); + if (!firstAction || quickWindow->activeFocusItem() == firstAction) { fail("Game Manage menu did not traverse"); return; } + controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); + QCoreApplication::processEvents(); + if (!manageMenuButton->hasActiveFocus() || !quickWindow->property("detailOpen").toBool()) { fail("Game Manage Back lost detail context"); return; } + play->forceActiveFocus(); controller.keyRequested(Qt::Key_Up, Qt::NoModifier); QTimer::singleShot( 50, quickWindow, [quickWindow, &application, &controller, play, fail] { @@ -3010,6 +3840,8 @@ int main(int argc, char* argv[]) { fail(QStringLiteral("Navigation test could not find the filter picker")); return; } + auto* filtersMenu = quickWindow->findChild("filtersMenuButton"); + if (!filtersMenu || !QMetaObject::invokeMethod(filtersMenu, "clicked")) { fail("Filters menu missing"); return; } statusFilter->forceActiveFocus(); controller.keyRequested(Qt::Key_Return, Qt::NoModifier); QTimer::singleShot( @@ -3026,11 +3858,11 @@ int main(int argc, char* argv[]) { } controller.keyRequested(Qt::Key_Escape, Qt::NoModifier); QTimer::singleShot(80, quickWindow, [quickWindow, statusFilter, &application, fail] { - if (quickWindow->property("filterPickerOpen").toBool() || - !statusFilter->hasActiveFocus()) { - fail(QStringLiteral("Escape did not close the filter picker and restore focus")); - return; + auto* filters = quickWindow->findChild("libraryFilters"); + if (quickWindow->property("filterPickerOpen").toBool() || !filters || !filters->property("opened").toBool()) { + fail(QStringLiteral("Escape did not return to the Filters menu")); return; } + QMetaObject::invokeMethod(filters, "close"); runEmptyFilterFocusTest(quickWindow, &application); }); }); @@ -3066,7 +3898,20 @@ int main(int argc, char* argv[]) { } rootWindow->requestActivate(); }); + if (playSessionStore != nullptr) { + QObject::connect(&preferences, &AppSettings::trackPlaySessionsChanged, playSessionStore.get(), + [&preferences, store = playSessionStore.get()] { + store->setEnabled(preferences.trackPlaySessions()); + }); + } QObject* rootObject = engine.rootObjects().constFirst(); + QObject::connect( + &singleInstance, &SingleInstance::trackingStorageFailed, &application, [rootObject] { + QMetaObject::invokeMethod( + rootObject, "showToast", + Q_ARG(QVariant, + QStringLiteral("Playtime could not be saved. Check available storage."))); + }); QObject::connect(&singleInstance, &SingleInstance::playRequested, &application, [&unifiedGames, &launcher, rootObject](const QString& key) { QString error; @@ -3076,6 +3921,26 @@ int main(int argc, char* argv[]) { rootObject, "showToast", Q_ARG(QVariant, okay ? QStringLiteral("Launching from Sunshine") : error)); }); + QObject::connect(&singleInstance, &SingleInstance::rescanRequested, &application, + [&retroArchLibrary, &pcsx2Library, &ryujinxLibrary, &dolphinLibrary, + &preferences](const QString& source) { + // omakade-sessiond reports an emulator exit; some emulators only + // write their own playtime and last-played records on exit, so the + // owning source re-imports right away. + if (source == QStringLiteral("Ryujinx") && ryujinxLibrary != nullptr && + preferences.ryujinxEnabled()) { + ryujinxLibrary->refresh(); + } else if (source == QStringLiteral("PCSX2") && pcsx2Library != nullptr && + preferences.pcsx2Enabled()) { + pcsx2Library->refresh(); + } else if (source == QStringLiteral("RetroArch") && + retroArchLibrary != nullptr && preferences.retroArchEnabled()) { + retroArchLibrary->refresh(); + } else if (source == QStringLiteral("Dolphin") && dolphinLibrary != nullptr && + preferences.dolphinEnabled()) { + dolphinLibrary->refresh(); + } + }); QObject::connect(&singleInstance, &SingleInstance::quitRequested, &application, &QCoreApplication::quit); @@ -3218,8 +4083,10 @@ int main(int argc, char* argv[]) { QMetaObject::invokeMethod(rootWindow, "openGame", Q_ARG(QVariant, 0)); ++*step; } else if (*step == 1) { + if (!press("detailManageButton")) { fail("Installation choices menu is unreachable"); return; } if (!press("installationChoice_1")) { fail("Alternate linked installation is unreachable"); return; } if (rootWindow->property("selectedInstallation").toMap().value("source") != "Manual") { fail("Linked installation selection did not change"); return; } + if (!press("detailManageButton")) { fail("Manage menu is unreachable"); return; } auto* preferredButton = find("preferredInstallationButton"); for (int attempt = 0; preferredButton && !preferredButton->hasActiveFocus() && attempt < 6; ++attempt) controller.focusDirectionRequested(Qt::Key_Down); @@ -3426,10 +4293,15 @@ int main(int argc, char* argv[]) { auto* library = rootWindow->findChild(QStringLiteral("couchLibrary")); QMetaObject::invokeMethod(library, "openBrowse"); } + if (!couch) { + auto* more = rootWindow->findChild("libraryMoreButton"); + if (more) QMetaObject::invokeMethod(more, "clicked"); + } auto* pick = rootWindow->findChild(couch ? QStringLiteral("couchRandomGameButton") : QStringLiteral("randomGameButton")); if (!pick || !pick->isVisible()) { fail("Random game control is not visible"); return; } pick->forceActiveFocus(); controller.keyRequested(Qt::Key_Return, Qt::NoModifier); + QCoreApplication::processEvents(); if (!rootWindow->property("detailOpen").toBool() || !rootWindow->property("randomSelection").toBool()) { fail("Random game control did not show a selection"); return; } @@ -3459,7 +4331,7 @@ int main(int argc, char* argv[]) { auto* library = qmlContext(rootWindow) ->contextProperty(QStringLiteral("Library")) .value(); - auto* status = rootWindow->findChild(QStringLiteral("statusFilterButton")); + auto* status = rootWindow->findChild(QStringLiteral("filtersMenuButton")); if (library == nullptr || status == nullptr) { fail(QStringLiteral("Stale selection test could not find the library controls")); return; diff --git a/src/backup/BackupArchive.cpp b/src/backup/BackupArchive.cpp index ab27226..b857996 100644 --- a/src/backup/BackupArchive.cpp +++ b/src/backup/BackupArchive.cpp @@ -1,6 +1,8 @@ #include "backup/BackupArchive.h" +#include "library/ConsoleCatalog.h" #include "library/PersonalDataRules.h" +#include "library/SavedFilterRules.h" #include #include @@ -26,7 +28,7 @@ QJsonObject manifestFor(const BackupPayload& payload) { names.sort(); for (const auto& name : names) artworks.append(name); - return {{"format", "omakade-backup"}, {"version", 1}, + return {{"format", "omakade-backup"}, {"version", 2}, {"createdAt", payload.createdAt}, {"library", payload.library}, {"settings", payload.settings}, {"artwork", artworks}}; } @@ -46,32 +48,8 @@ bool text(const QJsonValue& value, int limit, bool empty = true) { return value.isString() && value.toString().size() <= limit && !value.toString().contains(QChar::Null) && (empty || !value.toString().isEmpty()); } -bool validSavedState(const QJsonObject& state) { - if (state.size() != 10 || !integer(state.value("version"), 1, 1) || - !integer(state.value("mode"), 0, 3) || - !integer(state.value("sort"), 0, PersonalDataRules::kSortModeCount - 1) || - !integer(state.value("availability"), 0, 2) || !state.value("showHidden").isBool()) - return false; - for (const QString& key : - {QStringLiteral("search"), QStringLiteral("status"), QStringLiteral("collection"), - QStringLiteral("tag")}) - if (!text(state.value(key), 4096)) - return false; - // Sources are a multi-select list. A bare string is still accepted so a filter saved by an - // earlier build exports instead of failing the whole archive. - const QJsonValue sources = state.value("source"); - if (sources.isArray()) { - if (sources.toArray().size() > PersonalDataRules::kMaxSavedFilterSources) - return false; - for (const auto& name : sources.toArray()) - if (!text(name, 4096)) - return false; - } else if (!text(sources, 4096)) { - return false; - } - return QStringList{"", "backlog", "playing", "completed", "abandoned"}.contains( - state.value("status").toString()); -} +bool validSavedState(const QJsonObject& state) { return SavedFilterRules::valid(state); } + bool validManual(const QJsonObject& entry, const QString& id) { const QSet fields{"id", "title", "executable", "directory", "arguments"}; if (entry.size() != fields.size() || entry.value("id").toString() != id || @@ -101,6 +79,12 @@ QString identity(const QString& table, const QJsonObject& row) { key.append(row.value("id")); else if (table == "launch_preferences") key.append(row.value("group_id")); + else if (table == "game_metadata") + key.append(row.value("game_key")); + else if (table == "play_sessions") + key.append(row.value("session_key")); + else if (table == "play_baselines") + key.append(row.value("game_path")); else { if (table == "collection_games") key.append(row.value("collection_name").toString().toCaseFolded()); @@ -113,8 +97,14 @@ QString identity(const QString& table, const QJsonObject& row) { } // namespace QMap BackupArchive::tableColumns() { - return {{"user_game_flags", {"source", "runner", "app_id", "favorite", "hidden"}}, - {"game_organization", {"source", "runner", "app_id", "completion_status", "tags_json", "pinned"}}, + return {{"play_queue", {"source", "runner", "app_id", "title", "position"}}, + {"play_sessions", + {"session_key", "game_path", "source", "started_at", "ended_at", "seconds"}}, + {"play_baselines", {"game_path", "baseline_seconds", "captured_at", "schema"}}, + {"game_metadata", {"game_key", "payload"}}, + {"user_game_flags", {"source", "runner", "app_id", "favorite", "hidden"}}, + {"game_organization", + {"source", "runner", "app_id", "completion_status", "tags_json", "pinned"}}, {"collections", {"name", "created_at"}}, {"collection_games", {"collection_name", "source", "runner", "app_id"}}, {"game_link_members", {"group_id", "source", "runner", "app_id", "is_primary"}}, @@ -127,15 +117,39 @@ QMap BackupArchive::tableColumns() { } QStringList BackupArchive::settingNames() { - return {"reduced_motion", "artwork_cache_limit_mb", - "steam_enabled", "lutris_enabled", - "heroic_enabled", "gog_enabled", - "faugus_enabled", "retroarch_enabled", - "pcsx2_enabled", "ryujinx_enabled", - "pcsx2_auto", "ryujinx_auto", - "battlenet_enabled", "close_after_launch", - "couch_mode", "couch_library_view", - "library_sort_mode", "gog_library_paths"}; + return {"shadps4_enabled", + "cemu_enabled", + "dolphin_enabled", + "shadps4_auto", + "cemu_auto", + "dolphin_auto", + "console_portals_enabled", + "expand_consoles", + "prefer_standalone_emulators", + "track_play_sessions", + "cover_size", + "couch_cover_size", + "console_expand_limit", + "rom_folders", + "console_layouts", + "reduced_motion", + "artwork_cache_limit_mb", + "steam_enabled", + "lutris_enabled", + "heroic_enabled", + "gog_enabled", + "faugus_enabled", + "retroarch_enabled", + "pcsx2_enabled", + "ryujinx_enabled", + "pcsx2_auto", + "ryujinx_auto", + "battlenet_enabled", + "close_after_launch", + "couch_mode", + "couch_library_view", + "library_sort_mode", + "gog_library_paths"}; } QString BackupArchive::artworkName(const QByteArray& bytes, QString* error) { @@ -165,8 +179,11 @@ bool BackupArchive::validate(const BackupPayload& payload, QString* error) { error->clear(); if (!QDateTime::fromString(payload.createdAt, Qt::ISODate).isValid()) return fail(error, "The backup timestamp is invalid."); + if (payload.library.contains("play_sessions") != payload.library.contains("play_baselines")) + return fail(error, "Play history must include both sessions and baselines."); const auto columns = tableColumns(); qint64 rowCount = 0; + qint64 remainingDuration = 9007199254740991LL; QSet references, collectionNames, memberships, groups, preferredGroups, savedNames; QHash primaryCounts; QHash groupForIdentity; @@ -176,7 +193,8 @@ bool BackupArchive::validate(const BackupPayload& payload, QString* error) { return fail(error, "The backup contains an unsupported library table."); const auto rows = table.value().toArray(); rowCount += rows.size(); - if (rowCount > 100000 || (table.key() == "saved_filters" && rows.size() > 500)) + if (rowCount > 100000 || (table.key() == "play_queue" && rows.size() > 100) || + (table.key() == "saved_filters" && rows.size() > 500)) return fail(error, "The backup contains too many personal records."); QSet identities; for (const auto& value : rows) { @@ -192,14 +210,58 @@ bool BackupArchive::validate(const BackupPayload& payload, QString* error) { continue; if (field.isUndefined()) return fail(error, "A personal record is missing a required field."); - if (column == "favorite" || column == "hidden") { + if (table.key() == "play_sessions" && column == "session_key") { + static const QRegularExpression uuid( + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); + if (!field.isString() || !uuid.match(field.toString()).hasMatch()) + return fail(error, "A play session identity is invalid."); + } else if (column == "schema" && table.key() == "play_baselines") { + if (!integer(field, 1, 1)) + return fail(error, "The play baseline version is unsupported."); + } else if (QStringList{"started_at", "ended_at", "seconds", "baseline_seconds", + "captured_at"} + .contains(column)) { + if (!integer(field, column == "ended_at" ? 1 : 0, 9007199254740991.0)) + return fail(error, "A play history duration or timestamp is invalid."); + if (column == "seconds" || column == "baseline_seconds") { + const qint64 duration = field.toInteger(); + if (duration > remainingDuration) + return fail(error, "Combined play history exceeds the supported duration limit."); + remainingDuration -= duration; + } + } else if (table.key() == "game_metadata" && column == "game_key") { + const auto parts = field.toString().split(QChar::Null); + if (!field.isString() || parts.size() != 3 || parts.first().isEmpty() || + parts.last().isEmpty() || field.toString().size() > 12288) + return fail(error, "A game identification key is invalid."); + for (const auto& part : parts) + if (hasControls(part)) + return fail(error, "A game identification key is invalid."); + } else if (table.key() == "game_metadata" && column == "payload") { + if (!text(field, 32768)) + return fail(error, "A game identification is too large."); + const auto doc = QJsonDocument::fromJson(field.toString().toUtf8()); + const auto choice = doc.object(); + const QSet allowed{"igdbId", "manualMatch", "rejected"}; + if (!doc.isObject() || choice.isEmpty()) + return fail(error, "A game identification is invalid."); + for (auto item = choice.begin(); item != choice.end(); ++item) { + if (!allowed.contains(item.key()) || + (item.key() == "igdbId" ? !integer(item.value(), 1, 9007199254740991.0) + : !item.value().isBool())) + return fail(error, "A game identification contains unsupported data."); + } + if (!(choice.value("rejected").toBool() || + (choice.value("manualMatch").toBool() && choice.contains("igdbId")))) + return fail(error, "A game identification has no user choice."); + } else if (column == "favorite" || column == "hidden") { if (!(table.key() == "user_game_flags" && field.isNull()) && !flag(field)) return fail(error, "A personal flag is invalid."); } else if (column == "is_primary" || column == "active" || column == "pinned") { if (!flag(field)) return fail(error, "A personal flag is invalid."); } else if (column == "created_at" || column == "last_launched" || - column == "launch_count") { + column == "launch_count" || column == "position") { if (!integer(field, 0, 9007199254740991.0)) return fail(error, "A personal timestamp or count is invalid."); } else if (column == "entry" || column == "tags_json" || column == "state_json") { @@ -222,8 +284,9 @@ bool BackupArchive::validate(const BackupPayload& payload, QString* error) { return fail(error, "A tag is invalid."); } } else { - const bool emptyAllowed = - column == "runner" || column == "completion_status" || column.endsWith("_path"); + const bool emptyAllowed = column == "runner" || column == "completion_status" || + (column.endsWith("_path") && column != "game_path") || + (table.key() == "play_sessions" && column == "source"); if (!text(field, 4096, emptyAllowed)) return fail(error, "A personal identifier or value is invalid."); } @@ -231,6 +294,9 @@ bool BackupArchive::validate(const BackupPayload& payload, QString* error) { !field.toString().isEmpty()) references.insert(field.toString()); } + if (table.key() == "play_sessions" && + row.value("ended_at").toDouble() < row.value("started_at").toDouble()) + return fail(error, "A play session ends before it starts."); if (table.key() == "game_organization" && !QStringList{"", "backlog", "playing", "completed", "abandoned"}.contains( row.value("completion_status").toString())) @@ -286,11 +352,40 @@ bool BackupArchive::validate(const BackupPayload& payload, QString* error) { if (setting.key() == "artwork_cache_limit_mb") { if (!integer(setting.value(), 128, 8192)) return fail(error, "The artwork cache limit is invalid."); + } else if (setting.key() == "cover_size" || setting.key() == "couch_cover_size") { + if (!integer(setting.value(), 60, 160)) + return fail(error, "The cover size is invalid."); + } else if (setting.key() == "console_expand_limit") { + if (!integer(setting.value(), 10, 100000)) + return fail(error, "The console expansion limit is invalid."); + } else if (setting.key() == "rom_folders" || setting.key() == "console_layouts") { + if (!setting.value().isArray() || setting.value().toArray().size() > 4096) + return fail(error, "A console preference list is invalid."); + QSet seen; + for (const auto& item : setting.value().toArray()) { + if (!text(item, 8192, false) || hasControls(item.toString()) || + seen.contains(item.toString())) + return fail(error, "A console preference is invalid or duplicated."); + seen.insert(item.toString()); + const QString value = item.toString(); + if (setting.key() == "rom_folders") { + const auto split = value.lastIndexOf('|'); + if (split <= 0 || !QDir::isAbsolutePath(value.left(split)) || + ConsoleCatalog::idFor(value.mid(split + 1)).isEmpty()) + return fail(error, "A ROM folder is invalid."); + } else { + const auto parts = value.split('='); + if (parts.size() != 2 || ConsoleCatalog::idFor(parts.first()).isEmpty() || + !QStringList{"card", "library"}.contains(parts.last())) + return fail(error, "A console layout is invalid."); + } + } } else if (setting.key() == "couch_library_view") { if (!QStringList{"detail", "grid"}.contains(setting.value().toString())) return fail(error, "The library view is invalid."); } else if (setting.key() == "library_sort_mode") { - if (!QStringList{"title", "recent", "playtime"}.contains(setting.value().toString())) + if (!QStringList{"title", "recent", "playtime", "rating", "popularity"}.contains( + setting.value().toString())) return fail(error, "The library sort order is invalid."); } else if (setting.key() == "gog_library_paths") { if (!setting.value().isArray() || setting.value().toArray().size() > 64) @@ -452,7 +547,7 @@ bool BackupArchive::read(const QString& path, BackupPayload* output, QString* er return fail(error, "The backup manifest is invalid."); const auto manifest = document.object(); if (manifest.size() != 6 || manifest.value("format").toString() != "omakade-backup" || - !integer(manifest.value("version"), 1, 1) || !manifest.value("createdAt").isString() || + !integer(manifest.value("version"), 1, 2) || !manifest.value("createdAt").isString() || !manifest.value("library").isObject() || !manifest.value("settings").isObject() || !manifest.value("artwork").isArray()) return fail(error, "This backup format or version is unsupported."); diff --git a/src/backup/BackupDatabase.cpp b/src/backup/BackupDatabase.cpp index 565b469..ba787da 100644 --- a/src/backup/BackupDatabase.cpp +++ b/src/backup/BackupDatabase.cpp @@ -1,4 +1,7 @@ #include "backup/BackupDatabase.h" +#include "tracking/SessionDatabase.h" +#include +#include #include #include @@ -13,11 +16,21 @@ namespace { const QMap schemas{ + {"play_sessions", + "id INTEGER PRIMARY KEY, session_key TEXT UNIQUE, game_path TEXT NOT NULL, " + "source TEXT NOT NULL DEFAULT '', started_at INTEGER NOT NULL, ended_at INTEGER NOT NULL " + "DEFAULT 0, " + "seconds INTEGER NOT NULL DEFAULT 0, pid INTEGER NOT NULL DEFAULT 0, " + "proc_start INTEGER NOT NULL DEFAULT -1, heartbeat_at INTEGER NOT NULL DEFAULT 0"}, + {"play_baselines", "game_path TEXT PRIMARY KEY, baseline_seconds INTEGER NOT NULL DEFAULT 0, " + "captured_at INTEGER NOT NULL, schema INTEGER NOT NULL DEFAULT 1"}, + {"game_metadata", "game_key TEXT PRIMARY KEY, payload TEXT NOT NULL"}, {"user_game_flags", "source TEXT NOT NULL, runner TEXT NOT NULL, app_id TEXT NOT NULL, " "favorite INTEGER, hidden INTEGER, PRIMARY KEY(source,runner,app_id)"}, {"game_organization", "source TEXT NOT NULL, runner TEXT NOT NULL, app_id TEXT NOT NULL, completion_status TEXT NOT " - "NULL DEFAULT '', tags_json TEXT NOT NULL DEFAULT '[]', pinned INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(source,runner,app_id)"}, + "NULL DEFAULT '', tags_json TEXT NOT NULL DEFAULT '[]', pinned INTEGER NOT NULL DEFAULT 0, " + "PRIMARY KEY(source,runner,app_id)"}, {"collections", "name TEXT PRIMARY KEY COLLATE NOCASE, created_at INTEGER NOT NULL"}, {"collection_games", "collection_name TEXT NOT NULL, source TEXT NOT NULL, runner TEXT NOT NULL, app_id TEXT NOT " @@ -32,12 +45,20 @@ const QMap schemas{ "NULL, launch_count INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(source,runner,app_id)"}, {"manual_games", "id TEXT PRIMARY KEY, entry TEXT NOT NULL, favorite INTEGER NOT NULL DEFAULT " "0, hidden INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1"}, + {"play_queue", "source TEXT NOT NULL, runner TEXT NOT NULL, app_id TEXT NOT NULL, title TEXT " + "NOT NULL, position INTEGER NOT NULL, PRIMARY KEY(source,runner,app_id)"}, {"saved_filters", "id TEXT PRIMARY KEY, name TEXT NOT NULL, name_key TEXT NOT NULL UNIQUE, " "state_json TEXT NOT NULL"}, {"artwork_overrides", "source TEXT NOT NULL, runner TEXT NOT NULL, app_id TEXT NOT NULL, " "cover_path TEXT NOT NULL, hero_path TEXT NOT NULL DEFAULT '', logo_path " "TEXT NOT NULL DEFAULT '', PRIMARY KEY(source,runner,app_id)"}}; QStringList primaryKey(const QString& table) { + if (table == "game_metadata") + return {"game_key"}; + if (table == "play_sessions") + return {"session_key"}; + if (table == "play_baselines") + return {"game_path"}; if (table == "collections") return {"name"}; if (table == "manual_games" || table == "saved_filters") @@ -91,6 +112,8 @@ bool restoreDatabase(QSqlDatabase& database, const QString& artworkDirectory, for (auto schema = schemas.begin(); schema != schemas.end(); ++schema) if (!query.exec("CREATE TABLE IF NOT EXISTS " + schema.key() + " (" + schema.value() + ")")) return fail("Could not prepare the personal-data schema."); + if (payload.library.contains("play_sessions") && !SessionDatabase::ensureSchema(database)) + return fail("Could not prepare portable play history."); if (!query.exec("PRAGMA table_info(artwork_overrides)")) return fail("Could not inspect the artwork schema."); QSet artworkColumns; @@ -113,7 +136,10 @@ bool restoreDatabase(QSqlDatabase& database, const QString& artworkDirectory, if (mode == BackupDatabase::Mode::Replace) { for (auto schema = schemas.begin(); schema != schemas.end(); ++schema) - if (!query.exec("DELETE FROM " + schema.key())) + if (((schema.key() != "game_metadata" && schema.key() != "play_sessions" && + schema.key() != "play_baselines" && schema.key() != "play_queue") || + payload.library.contains(schema.key())) && + !query.exec("DELETE FROM " + schema.key())) return fail("Could not replace existing personal records."); if (!query.exec("SELECT name FROM sqlite_master WHERE type='table'")) return fail("Could not inspect cached sources."); @@ -220,11 +246,21 @@ bool restoreDatabase(QSqlDatabase& database, const QString& artworkDirectory, return fail("Could not inspect saved filters."); while (query.next()) filterOwner.insert(query.value(1).toString(), query.value(0).toString()); + QSet existingHistory; + if (mode == BackupDatabase::Mode::Merge && payload.library.contains("play_sessions")) { + if (!query.exec( + "SELECT game_path FROM play_sessions UNION SELECT game_path FROM play_baselines")) + return fail("Could not inspect existing play history."); + while (query.next()) + existingHistory.insert(query.value(0).toString()); + query.finish(); + } const auto columns = BackupArchive::tableColumns(); - const QStringList order{"collections", "user_game_flags", "game_organization", - "manual_games", "artwork_overrides", "launch_activity", - "saved_filters", "collection_games", "game_link_members", - "launch_preferences"}; + const QStringList order{"collections", "user_game_flags", "game_organization", + "manual_games", "artwork_overrides", "launch_activity", + "saved_filters", "collection_games", "game_link_members", + "launch_preferences", "game_metadata", "play_sessions", + "play_baselines", "play_queue"}; for (const auto& table : order) { const auto key = primaryKey(table); const auto fields = columns.value(table); @@ -242,8 +278,43 @@ bool restoreDatabase(QSqlDatabase& database, const QString& artworkDirectory, : " DO UPDATE SET " + assignments.join(", "); const QString sql = "INSERT INTO " + table + "(" + fields.join(",") + ") VALUES(" + placeholders.join(",") + ") ON CONFLICT(" + key.join(",") + ")" + suffix; - for (const auto& value : payload.library.value(table).toArray()) { - auto row = value.toObject(); + auto incomingRows = payload.library.value(table).toArray().toVariantList(); + if (table == "play_queue") + std::stable_sort(incomingRows.begin(), incomingRows.end(), + [](const QVariant& a, const QVariant& b) { + return a.toMap().value("position").toLongLong() < + b.toMap().value("position").toLongLong(); + }); + for (const auto& value : incomingRows) { + auto row = QJsonObject::fromVariantMap(value.toMap()); + if (table == "play_queue" && mode == BackupDatabase::Mode::Merge) { + query.prepare("SELECT 1 FROM play_queue WHERE source=? AND runner=? AND app_id=?"); + for (const auto* field : {"source", "runner", "app_id"}) + query.addBindValue(row.value(field).toString()); + if (!query.exec()) + return fail("Could not inspect Up next."); + if (query.next()) + continue; + if (!query.exec("SELECT COUNT(*),COALESCE(MAX(position),-1)+1 FROM play_queue") || + !query.next()) + return fail("Could not inspect Up next order."); + if (query.value(0).toInt() >= 100) + return fail("Merged Up next would exceed 100 games."); + row["position"] = query.value(1).toLongLong(); + query.finish(); + } + if ((table == "play_sessions" || table == "play_baselines") && + existingHistory.contains(row.value("game_path").toString())) + continue; + if (table == "play_sessions") { + query.prepare("SELECT game_path FROM play_sessions WHERE session_key=?"); + query.addBindValue(row.value("session_key").toString()); + if (!query.exec()) + return fail("Could not check the play session identity."); + if (query.next() && query.value(0).toString() != row.value("game_path").toString()) + return fail("A play session identity belongs to a different game path."); + query.finish(); + } if (table == "collections" || table == "collection_games") { const QString field = table == "collections" ? "name" : "collection_name"; const QString name = row.value(field).toString(); @@ -328,6 +399,13 @@ bool BackupDatabase::restore(const QString& path, const BackupPayload& payload, *error = "The restore database path is invalid."; return false; } + QLockFile recorder(path + ".sessiond.lock"); + recorder.setStaleLockTime(0); + if (payload.library.contains("play_sessions") && !recorder.tryLock(0)) { + if (error) + *error = "Stop the play-session recorder before restoring play history, then retry."; + return false; + } const QString artwork = file.absolutePath() + "/artwork"; if (!stageArtwork(artwork, payload, error)) return false; diff --git a/src/backup/BackupManager.cpp b/src/backup/BackupManager.cpp index 57e8620..290efc8 100644 --- a/src/backup/BackupManager.cpp +++ b/src/backup/BackupManager.cpp @@ -18,6 +18,12 @@ QString recordKey(const QString& table, const QJsonObject& row) { parts.append(row.value("id")); else if (table == "launch_preferences") parts.append(row.value("group_id")); + else if (table == "game_metadata") + parts.append(row.value("game_key")); + else if (table == "play_sessions") + parts.append(row.value("session_key")); + else if (table == "play_baselines") + parts.append(row.value("game_path")); else { if (table == "collection_games") parts.append(row.value("collection_name").toString().toCaseFolded()); @@ -176,7 +182,10 @@ void BackupManager::confirmRestore(bool replace) { } QVariantMap BackupManager::describe(const BackupPayload& incoming, const BackupPayload& current) { - const QMap names{{"user_game_flags", "Favorites and hidden choices"}, + const QMap names{{"game_metadata", "Game identifications"}, + {"play_sessions", "Recorded play sessions"}, + {"play_baselines", "Imported playtime baselines"}, + {"user_game_flags", "Favorites and hidden choices"}, {"game_organization", "Completion states and tags"}, {"collections", "Collections"}, {"collection_games", "Collection memberships"}, @@ -185,6 +194,7 @@ QVariantMap BackupManager::describe(const BackupPayload& incoming, const BackupP {"launch_activity", "Launch activity"}, {"manual_games", "Manual games"}, {"saved_filters", "Saved filters"}, + {"play_queue", "Up next"}, {"artwork_overrides", "Custom artwork choices"}}; QVariantList counts; for (auto it = names.begin(); it != names.end(); ++it) { @@ -257,12 +267,18 @@ QVariantMap BackupManager::describe(const BackupPayload& incoming, const BackupP {"mergeExplanation", "Merge keeps unrelated personal data. Imported values take precedence for matching games. " "Collections gain memberships; imported link groups take precedence for their members. " - "Saved filters with a conflicting name receive a restored suffix."}, + "Saved filters with a conflicting name receive a restored suffix. Play history is imported " + "only for games with no local sessions or baseline; existing play history stays unchanged. " + "Up next keeps its current order and appends new games, up to 100 entries."}, {"replaceExplanation", "Replace clears current personal library choices, manual entries, and saved filters before " - "importing the backup. Cached game records and game files stay in place."}, + "importing the backup. Archived play history replaces current history when included; older " + "backups without history leave it unchanged. Up next is replaced only when included in the " + "backup. Game files stay in place."}, {"recoveryExplanation", "Omakade saves a recovery copy before applying changes on the next startup. Account-service " "identifiers and Sunshine publishing choices remain local. Missing games stay stored for " - "rediscovery, and missing manual paths can be repaired. Restoring does not launch games."}}; + "rediscovery, and missing manual paths can be repaired. Play-history restore requires the " + "recorder to be stopped. Emulator saves and save states are not included. Restoring does " + "not launch games."}}; } diff --git a/src/backup/BackupSnapshot.cpp b/src/backup/BackupSnapshot.cpp index 440e401..24927a1 100644 --- a/src/backup/BackupSnapshot.cpp +++ b/src/backup/BackupSnapshot.cpp @@ -99,7 +99,10 @@ bool captureDatabase(QSqlDatabase& database, const QJsonObject& settings, Backup columns.insert(query.value(1).toString()); QStringList expressions; for (const auto& column : schema.value()) { - if (columns.contains(column)) + if (schema.key() == "play_sessions" && column == "ended_at") + expressions.append( + "MAX(1, started_at, CASE WHEN ended_at=0 THEN heartbeat_at ELSE ended_at END)"); + else if (columns.contains(column)) expressions.append(column); else if (schema.key() == "game_organization" && column == "pinned") expressions.append("0"); @@ -128,7 +131,9 @@ bool captureDatabase(QSqlDatabase& database, const QJsonObject& settings, Backup ? QJsonValue(QJsonValue::Null) : QJsonValue(value.toBool())); } else if (column == "created_at" || column == "last_launched" || - column == "launch_count") + column == "launch_count" || column == "position" || column == "started_at" || column == "ended_at" || + column == "seconds" || column == "baseline_seconds" || + column == "captured_at" || column == "schema") row.insert(column, double(value.toLongLong())); else if (schema.key() == "artwork_overrides" && column.endsWith("_path")) { const QString path = value.toString(); @@ -170,6 +175,23 @@ bool captureDatabase(QSqlDatabase& database, const QJsonObject& settings, Backup row.insert(column, text); } } + if (schema.key() == "game_metadata") { + const auto doc = QJsonDocument::fromJson(row.value("payload").toString().toUtf8()); + if (!doc.isObject()) + return fail("Stored game metadata is invalid."); + const auto saved = doc.object(); + QJsonObject choice; + if (saved.value("rejected").toBool()) { + choice.insert("rejected", true); + } else if (saved.value("manualMatch").toBool() && saved.value("igdbId").toInteger() > 0) { + choice.insert("manualMatch", true); + choice.insert("igdbId", saved.value("igdbId")); + } else { + continue; // Provider descriptions and downloaded paths are regenerable cache. + } + row.insert("payload", + QString::fromUtf8(QJsonDocument(choice).toJson(QJsonDocument::Compact))); + } if (danglingArtwork) { bool anyArtwork = false; for (const QString& column : schema.value()) diff --git a/src/library/ArtworkPersistence.h b/src/library/ArtworkPersistence.h new file mode 100644 index 0000000..61d87ab --- /dev/null +++ b/src/library/ArtworkPersistence.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include + +namespace ArtworkPersistence { +// The statement is supplied by a source model, with path and identity placeholders. +// Retain the complete batch on any failure so a later flush can retry atomically. +inline bool flush(QSqlDatabase& database, const QString& statement, + QHash& pending) { + if (pending.isEmpty()) + return true; + if (!database.isOpen() || !database.transaction()) + return false; + QSqlQuery query(database); + if (!query.prepare(statement)) { + database.rollback(); + return false; + } + for (auto item = pending.cbegin(); item != pending.cend(); ++item) { + query.bindValue(0, item.value()); + query.bindValue(1, item.key()); + if (!query.exec()) { + database.rollback(); + return false; + } + } + if (!database.commit()) { + database.rollback(); + return false; + } + pending.clear(); + return true; +} +} // namespace ArtworkPersistence diff --git a/src/library/BattleNetGameModel.cpp b/src/library/BattleNetGameModel.cpp index e2fb978..3c1b82f 100644 --- a/src/library/BattleNetGameModel.cpp +++ b/src/library/BattleNetGameModel.cpp @@ -1,4 +1,5 @@ #include "library/BattleNetGameModel.h" +#include "library/CoverCachePolicy.h" #include "app/AppSettings.h" #include "library/DatabaseTuning.h" @@ -55,21 +56,6 @@ QString coverCacheRoot() { QStringLiteral("/omakade/covers/battlenet"); } -qint64 otherCoverCacheBytes() { - const QString sharedRoot = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + - QStringLiteral("/omakade/covers"); - const QString battleNetRoot = coverCacheRoot() + QLatin1Char('/'); - qint64 total = 0; - QDirIterator iterator(sharedRoot, QDir::Files, QDirIterator::Subdirectories); - while (iterator.hasNext()) { - const QFileInfo info(iterator.next()); - if (!info.absoluteFilePath().startsWith(battleNetRoot)) { - total += info.size(); - } - } - return total; -} - bool safeProductId(const QString& productId) { static const QRegularExpression valid(QStringLiteral("^[A-Za-z][A-Za-z0-9._-]{0,63}$")); return valid.match(productId).hasMatch(); @@ -429,6 +415,8 @@ QVariant BattleNetGameModel::valueForRole(const Game& game, int role) const { return QStringLiteral("Battle.net · %1").arg(runnerLabel(game.battlenet.runner)); case GameRoles::Description: return QStringLiteral("Installed locally through Battle.net."); + case GameRoles::PlaytimeSeconds: + return qint64(0); case GameRoles::Hours: case GameRoles::Progress: case GameRoles::AchievementsUnlocked: @@ -593,16 +581,23 @@ void BattleNetGameModel::applyArtwork(const QString& gameId, const QString& path !current.startsWith(coverCacheRoot())) { return; } - current = path; - if (m_database.isOpen()) { + if (!m_database.isOpen()) { + setStatus(m_statusText, QStringLiteral("Artwork cache changes could not be saved.")); + return; + } + { QSqlQuery query(m_database); query.prepare(hero ? QStringLiteral("UPDATE battlenet_games SET hero_path = ? WHERE game_id = ?") : QStringLiteral( "UPDATE battlenet_games SET cover_path = ? WHERE game_id = ?")); query.addBindValue(path); query.addBindValue(gameId); - query.exec(); + if (!query.exec()) { + setStatus(m_statusText, QStringLiteral("Artwork cache changes could not be saved.")); + return; + } } + current = path; emit dataChanged(index(row), index(row), {hero ? GameRoles::HeroPath : GameRoles::CoverPath}); return; @@ -611,30 +606,13 @@ void BattleNetGameModel::applyArtwork(const QString& gameId, const QString& path void BattleNetGameModel::pruneCoverCache() { const int limitMb = m_settings == nullptr ? 1024 : m_settings->artworkCacheLimitMb(); - const qint64 configuredLimit = static_cast(limitMb) * 1024 * 1024; - const qint64 limit = qMax(0, configuredLimit - otherCoverCacheBytes()); - struct CachedFile { - QString path; - QDateTime modified; - qint64 size = 0; - }; - QVector files; - qint64 total = 0; - QDirIterator iterator(coverCacheRoot(), QDir::Files); - while (iterator.hasNext()) { - const QFileInfo info(iterator.next()); - files.append({info.absoluteFilePath(), info.lastModified(), info.size()}); - total += info.size(); - } - std::sort(files.begin(), files.end(), [](const CachedFile& left, const CachedFile& right) { - return left.modified < right.modified; - }); - for (const CachedFile& file : files) { - if (total <= limit) { - break; - } - if (QFile::remove(file.path)) { - total -= file.size; - } + const QString sharedRoot = + QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + + QStringLiteral("/omakade/covers"); + QSet referenced; + for (const Game& game : m_games) { + referenced.insert(game.battlenet.coverPath); + referenced.insert(game.battlenet.heroPath); } + CoverCachePolicy::prune(sharedRoot, coverCacheRoot(), qint64(limitMb) * 1024 * 1024, referenced); } diff --git a/src/library/CemuGameModel.cpp b/src/library/CemuGameModel.cpp index f3e6f14..3e583e9 100644 --- a/src/library/CemuGameModel.cpp +++ b/src/library/CemuGameModel.cpp @@ -24,9 +24,20 @@ QString localUrl(const QString& path) { } } // namespace -CemuGameModel::CemuGameModel(const QString& omakadeDatabasePath, QObject* parent) +CemuGameModel::CemuGameModel(const QString& omakadeDatabasePath, PlaySessionStore* playSessions, + QObject* parent) : QAbstractListModel(parent), - m_connectionName(QStringLiteral("omakade-cemu-%1").arg(reinterpret_cast(this))) { + m_connectionName(QStringLiteral("omakade-cemu-%1").arg(reinterpret_cast(this))), + m_playSessions(playSessions) { + if (m_playSessions != nullptr) { + connect(m_playSessions, &PlaySessionStore::totalsChanged, this, [this] { + if (!m_games.isEmpty()) { + emit dataChanged(index(0), index(static_cast(m_games.size()) - 1), + {GameRoles::Hours, GameRoles::PlaytimeSeconds, GameRoles::PlaytimeText, + GameRoles::PlaytimeProvenance, GameRoles::LastPlayed}); + } + }); + } connect(&m_scanWatcher, &QFutureWatcher::finished, this, [this] { m_scanning = false; applyScan(m_scanWatcher.result()); @@ -254,7 +265,13 @@ QVariant CemuGameModel::valueForRole(const Game& game, int role) const { return QStringLiteral("Cemu"); case GameRoles::Description: return QStringLiteral("Wii U game launched through Cemu."); + case GameRoles::PlaytimeProvenance: + return PlaySessionStore::provenance(m_playSessions, game.cemu.path, -1); + case GameRoles::PlaytimeSeconds: + return PlaySessionStore::displayedSeconds(m_playSessions, game.cemu.path, 0); case GameRoles::Hours: + return static_cast(PlaySessionStore::displayedSeconds(m_playSessions, game.cemu.path, 0) / + 3600); case GameRoles::Progress: case GameRoles::AchievementsUnlocked: case GameRoles::AchievementsTotal: @@ -262,9 +279,9 @@ QVariant CemuGameModel::valueForRole(const Game& game, int role) const { case GameRoles::Favorite: return game.favorite; case GameRoles::Recent: - return false; + return PlaySessionStore::displayedLastPlayed(m_playSessions, game.cemu.path, 0) > 0; case GameRoles::LastPlayed: - return 0; + return PlaySessionStore::displayedLastPlayed(m_playSessions, game.cemu.path, 0); case GameRoles::AccentStart: return game.accentStart; case GameRoles::AccentEnd: diff --git a/src/library/CemuGameModel.h b/src/library/CemuGameModel.h index 58a9317..f7f20db 100644 --- a/src/library/CemuGameModel.h +++ b/src/library/CemuGameModel.h @@ -1,6 +1,7 @@ #pragma once #include "sources/cemu/CemuScanner.h" +#include "tracking/PlaySessionStore.h" #include #include @@ -17,7 +18,8 @@ class CemuGameModel final : public QAbstractListModel { Q_PROPERTY(qint64 lastScan READ lastScan NOTIFY statusChanged) public: - explicit CemuGameModel(const QString& omakadeDatabasePath, QObject* parent = nullptr); + explicit CemuGameModel(const QString& omakadeDatabasePath, + PlaySessionStore* playSessions = nullptr, QObject* parent = nullptr); ~CemuGameModel() override; [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex()) const override; @@ -58,6 +60,7 @@ class CemuGameModel final : public QAbstractListModel { QVector m_games; QSqlDatabase m_database; QString m_connectionName; + PlaySessionStore* m_playSessions = nullptr; QFutureWatcher m_scanWatcher; bool m_scanning = false; bool m_cemuDetected = false; diff --git a/src/library/ConsolePortalModel.cpp b/src/library/ConsolePortalModel.cpp index fecd1c0..c59f4b8 100644 --- a/src/library/ConsolePortalModel.cpp +++ b/src/library/ConsolePortalModel.cpp @@ -49,7 +49,8 @@ void ConsolePortalModel::addRomModel(QAbstractItemModel* model) { for (int role : roles) { if (role == GameRoles::Title || role == GameRoles::System || role == GameRoles::Source || role == GameRoles::Hidden || - role == GameRoles::LastPlayed || role == GameRoles::Hours) { + role == GameRoles::LastPlayed || role == GameRoles::Hours || + role == GameRoles::PlaytimeSeconds) { rebuild(); return; } @@ -128,6 +129,8 @@ void ConsolePortalModel::rebuild() { portal.gameCount += 1; portal.lastPlayed = std::max(portal.lastPlayed, index.data(GameRoles::LastPlayed).toLongLong()); portal.hours = std::max(portal.hours, index.data(GameRoles::Hours).toInt()); + portal.playtimeSeconds = + std::max(portal.playtimeSeconds, index.data(GameRoles::PlaytimeSeconds).toLongLong()); } } QVector portals = grouped.values(); @@ -142,9 +145,21 @@ void ConsolePortalModel::rebuild() { sameConsoles = portals.at(index).systemId == m_portals.at(index).systemId; } if (sameConsoles) { + const auto previous = m_portals; m_portals = portals; - if (!m_portals.isEmpty()) { - emit dataChanged(index(0), index(m_portals.size() - 1)); + // An empty role list means every field changed, which makes the library + // invalidate its entire mapping. Startup rescans usually change nothing. + // Keep existing cards and their decoded covers, and notify only real changes. + for (int row = 0; row < m_portals.size(); ++row) { + QList changedRoles; + for (int role : {GameRoles::Title, GameRoles::CoverMark, GameRoles::Subtitle, + GameRoles::Source, GameRoles::LinkedSources, GameRoles::LastPlayed, + GameRoles::Recent, GameRoles::Hours, GameRoles::PlaytimeSeconds, + GameRoles::PlaytimeText}) { + if (valueForRole(previous.at(row), role) != valueForRole(m_portals.at(row), role)) + changedRoles.append(role); + } + if (!changedRoles.isEmpty()) emit dataChanged(index(row), index(row), changedRoles); } return; } @@ -162,6 +177,10 @@ QVariant ConsolePortalModel::valueForRole(const Portal& portal, int role) const : QStringLiteral("%1 games").arg(portal.gameCount); case GameRoles::Description: return QStringLiteral("Open this console to browse its games."); + case GameRoles::PlaytimeSeconds: + return portal.playtimeSeconds; + case GameRoles::PlaytimeText: + return GameRoles::formatPlaytime(portal.playtimeSeconds); case GameRoles::Hours: return portal.hours; case GameRoles::Progress: diff --git a/src/library/ConsolePortalModel.h b/src/library/ConsolePortalModel.h index 4607006..302d23b 100644 --- a/src/library/ConsolePortalModel.h +++ b/src/library/ConsolePortalModel.h @@ -27,6 +27,7 @@ class ConsolePortalModel final : public QAbstractListModel { QStringList sources; qint64 lastPlayed = 0; int hours = 0; + qint64 playtimeSeconds = 0; QColor accentStart; QColor accentEnd; }; diff --git a/src/library/CoverCachePolicy.h b/src/library/CoverCachePolicy.h new file mode 100644 index 0000000..c249d19 --- /dev/null +++ b/src/library/CoverCachePolicy.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// Each source owns its direct cache files. All sources use the same total budget, +// but live references are protected even when that makes the budget a soft limit. +// This avoids deleting another model's files without notifying it or repeatedly +// downloading and immediately evicting the same visible cover. +namespace CoverCachePolicy { +inline qint64 prune(const QString& sharedRoot, const QString& ownedRoot, qint64 limit, + const QSet& referenced) { + qint64 total = 0; + QList candidates; + const QString owner = QDir(ownedRoot).absolutePath(); + QDirIterator files(sharedRoot, QDir::Files | QDir::NoSymLinks, QDirIterator::Subdirectories); + while (files.hasNext()) { + const QFileInfo file(files.next()); + total += file.size(); + if (file.absolutePath() == owner && !referenced.contains(file.absoluteFilePath())) + candidates.append(file); + } + std::sort(candidates.begin(), candidates.end(), [](const QFileInfo& a, const QFileInfo& b) { + return a.lastModified() != b.lastModified() ? a.lastModified() < b.lastModified() + : a.absoluteFilePath() < b.absoluteFilePath(); + }); + for (const auto& file : candidates) { + if (total <= qMax(0, limit)) + break; + if (QFile::remove(file.absoluteFilePath())) + total -= file.size(); + } + return total; +} +} // namespace CoverCachePolicy diff --git a/src/library/DolphinGameModel.cpp b/src/library/DolphinGameModel.cpp index 4376435..80d54b8 100644 --- a/src/library/DolphinGameModel.cpp +++ b/src/library/DolphinGameModel.cpp @@ -29,9 +29,20 @@ QString localUrl(const QString& path) { } } // namespace -DolphinGameModel::DolphinGameModel(const QString& omakadeDatabasePath, QObject* parent) +DolphinGameModel::DolphinGameModel(const QString& omakadeDatabasePath, + PlaySessionStore* playSessions, QObject* parent) : QAbstractListModel(parent), - m_connectionName(QStringLiteral("omakade-dolphin-%1").arg(reinterpret_cast(this))) { + m_connectionName(QStringLiteral("omakade-dolphin-%1").arg(reinterpret_cast(this))), + m_playSessions(playSessions) { + if (m_playSessions != nullptr) { + connect(m_playSessions, &PlaySessionStore::totalsChanged, this, [this] { + if (!m_games.isEmpty()) { + emit dataChanged(index(0), index(static_cast(m_games.size()) - 1), + {GameRoles::Hours, GameRoles::PlaytimeSeconds, GameRoles::PlaytimeText, + GameRoles::PlaytimeProvenance, GameRoles::LastPlayed}); + } + }); + } m_coverWriteTimer.setSingleShot(true); m_coverWriteTimer.setInterval(750); connect(&m_coverWriteTimer, &QTimer::timeout, this, &DolphinGameModel::flushCoverWrites); @@ -380,7 +391,13 @@ QVariant DolphinGameModel::valueForRole(const Game& game, int role) const { return QStringLiteral("Dolphin · %1").arg(game.dolphin.platform); case GameRoles::Description: return QStringLiteral("%1 disc launched through Dolphin.").arg(game.dolphin.platform); + case GameRoles::PlaytimeProvenance: + return PlaySessionStore::provenance(m_playSessions, game.dolphin.path, -1); + case GameRoles::PlaytimeSeconds: + return PlaySessionStore::displayedSeconds(m_playSessions, game.dolphin.path, 0); case GameRoles::Hours: + return static_cast( + PlaySessionStore::displayedSeconds(m_playSessions, game.dolphin.path, 0) / 3600); case GameRoles::Progress: case GameRoles::AchievementsUnlocked: case GameRoles::AchievementsTotal: @@ -388,9 +405,9 @@ QVariant DolphinGameModel::valueForRole(const Game& game, int role) const { case GameRoles::Favorite: return game.favorite; case GameRoles::Recent: - return false; + return PlaySessionStore::displayedLastPlayed(m_playSessions, game.dolphin.path, 0) > 0; case GameRoles::LastPlayed: - return 0; + return PlaySessionStore::displayedLastPlayed(m_playSessions, game.dolphin.path, 0); case GameRoles::AccentStart: return game.accentStart; case GameRoles::AccentEnd: diff --git a/src/library/DolphinGameModel.h b/src/library/DolphinGameModel.h index 0bd0589..29be285 100644 --- a/src/library/DolphinGameModel.h +++ b/src/library/DolphinGameModel.h @@ -1,6 +1,7 @@ #pragma once #include "sources/dolphin/DolphinScanner.h" +#include "tracking/PlaySessionStore.h" #include #include @@ -21,7 +22,8 @@ class DolphinGameModel final : public QAbstractListModel { Q_PROPERTY(qint64 lastScan READ lastScan NOTIFY statusChanged) public: - explicit DolphinGameModel(const QString& omakadeDatabasePath, QObject* parent = nullptr); + explicit DolphinGameModel(const QString& omakadeDatabasePath, + PlaySessionStore* playSessions = nullptr, QObject* parent = nullptr); ~DolphinGameModel() override; [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex()) const override; @@ -65,6 +67,7 @@ class DolphinGameModel final : public QAbstractListModel { QVector m_games; QSqlDatabase m_database; QString m_connectionName; + PlaySessionStore* m_playSessions = nullptr; void applyCover(const QString& gameId, const QString& path); // Cover paths are written to the database in one batch shortly after they arrive. void flushCoverWrites(); diff --git a/src/library/FaugusGameModel.cpp b/src/library/FaugusGameModel.cpp index cd65851..95983ca 100644 --- a/src/library/FaugusGameModel.cpp +++ b/src/library/FaugusGameModel.cpp @@ -259,6 +259,8 @@ QVariant FaugusGameModel::valueForRole(const Game& game, int role) const { : QStringLiteral("Faugus · %1").arg(game.faugus.runner); case GameRoles::Description: return QStringLiteral("Configured and managed by Faugus."); + case GameRoles::PlaytimeSeconds: + return qint64(game.faugus.playtimeSeconds); case GameRoles::Hours: return game.faugus.playtimeSeconds / 3600; case GameRoles::Progress: diff --git a/src/library/GameRoles.h b/src/library/GameRoles.h index d9c1818..3f249fc 100644 --- a/src/library/GameRoles.h +++ b/src/library/GameRoles.h @@ -2,6 +2,7 @@ #include #include +#include #include namespace GameRoles { @@ -50,14 +51,34 @@ enum Role { SourceCoverPath, CustomHero, CustomLogo, + PlaytimeSeconds, + PlaytimeText, + Genres, + PlaytimeProvenance, }; +inline QString formatPlaytime(qint64 seconds) { + if (seconds <= 0) + return QStringLiteral("0m"); + if (seconds < 60) + return QStringLiteral("<1m"); + const qint64 minutes = seconds / 60; + if (minutes < 60) + return QString::number(minutes) + "m"; + const QString hours = QString::number(minutes / 60) + "h"; + return minutes % 60 ? hours + " " + QString::number(minutes % 60) + "m" : hours; +} + inline QHash names() { return { {Title, "title"}, + {Genres, "genres"}, {Subtitle, "subtitle"}, {Description, "description"}, {Hours, "hours"}, + {PlaytimeSeconds, "playtimeSeconds"}, + {PlaytimeText, "playtimeText"}, + {PlaytimeProvenance, "playtimeProvenance"}, {Progress, "progress"}, {AchievementsUnlocked, "achievementsUnlocked"}, {AchievementsTotal, "achievementsTotal"}, diff --git a/src/library/HeroicGameModel.cpp b/src/library/HeroicGameModel.cpp index 075aaba..2098edd 100644 --- a/src/library/HeroicGameModel.cpp +++ b/src/library/HeroicGameModel.cpp @@ -444,6 +444,8 @@ QVariant HeroicGameModel::valueForRole(const Game& game, int role) const { : game.heroic.runner == QStringLiteral("sideload") ? QStringLiteral("Added manually to Heroic.") : QStringLiteral("Installed locally through Heroic."); + case GameRoles::PlaytimeSeconds: + return qint64(game.heroic.playtimeMinutes) * 60; case GameRoles::Hours: return game.heroic.playtimeMinutes / 60; case GameRoles::Progress: diff --git a/src/library/HomeModel.cpp b/src/library/HomeModel.cpp new file mode 100644 index 0000000..8b56ee8 --- /dev/null +++ b/src/library/HomeModel.cpp @@ -0,0 +1,376 @@ +#include "library/HomeModel.h" +#include "library/GameRoles.h" +#include "library/ConsoleCatalog.h" +#include "library/UnifiedGameModel.h" +#include +#include +#include +#include +#include +#include +#include +#include + +QString HomeModel::keyFor(const QVariantMap& game) { + return QString::fromUtf8( + QJsonDocument(QJsonArray{game.value("source").toString(), game.value("runner").toString(), + game.value("appId").toString()}) + .toJson(QJsonDocument::Compact)); +} +HomeModel::HomeModel(UnifiedGameModel* games, const QString& path, QObject* parent) + : QObject(parent), m_games(games), m_connection(QUuid::createUuid().toString()) { + m_database = QSqlDatabase::addDatabase("QSQLITE", m_connection); + m_database.setDatabaseName(path.isEmpty() ? ":memory:" : path); + if (!m_database.open()) + m_error = "Could not open Up next storage."; + else { + QSqlQuery query(m_database); + if (!query.exec( + "CREATE TABLE IF NOT EXISTS play_queue (source TEXT NOT NULL, runner TEXT NOT NULL, " + "app_id TEXT NOT NULL, title TEXT NOT NULL, position INTEGER NOT NULL, " + "PRIMARY KEY(source,runner,app_id))")) + m_error = "Could not prepare Up next storage."; + } + const auto invalidate = [this] { + m_cacheInvalid = true; + scheduleRefresh(); + }; + connect(games, &QAbstractItemModel::modelReset, this, invalidate); + connect(games, &QAbstractItemModel::rowsInserted, this, invalidate); + connect(games, &QAbstractItemModel::rowsRemoved, this, invalidate); + connect(games, &QAbstractItemModel::layoutChanged, this, invalidate); + connect(games, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex& first, const QModelIndex& last) { + // Invalidate even while Home is closed, so queue actions cannot use stale identities. + if (!first.isValid() || !last.isValid()) m_cacheInvalid = true; + else for (int row = first.row(); row <= last.row(); ++row) m_dirtyRows.insert(row); + scheduleRefresh(); + }); +} +HomeModel::~HomeModel() { + m_database.close(); + m_database = {}; + QSqlDatabase::removeDatabase(m_connection); +} +void HomeModel::scheduleRefresh() { + if (!m_active || m_refreshPending) + return; + m_refreshPending = true; + QTimer::singleShot(0, this, [this] { + m_refreshPending = false; + if (m_active) refreshCached(); + }); +} +QHash HomeModel::gamesByIdentity() const { + if (m_cacheInvalid || m_gameCache.size() != m_games->rowCount()) { + m_gameCache.clear(); + m_gameCache.resize(m_games->rowCount()); + m_dirtyRows.clear(); + for (int row = 0; row < m_games->rowCount(); ++row) m_dirtyRows.insert(row); + m_cacheInvalid = false; + } + const auto roles = m_games->roleNames(); + for (int row : std::as_const(m_dirtyRows)) { + auto& cached = m_gameCache[row]; + cached = {}; + const auto index = m_games->index(row); + if (index.data(GameRoles::IsPortal).toBool()) continue; + auto& game = cached.game; + for (auto role = roles.begin(); role != roles.end(); ++role) + game.insert(QString::fromUtf8(role.value()), index.data(role.key())); + // One installation read supplies both availability and the linked identities. + // preferredInstallation is available iff at least one member can launch. + const auto installations = m_games->installations(row); + bool available = false; + for (const auto& member : installations) { + const auto installation = member.toMap(); + available = available || installation.value("launchAvailable").toBool(); + cached.identities.append(keyFor(installation)); + } + game["available"] = available; + game["identity"] = keyFor(game); + cached.identities.append(game.value("identity").toString()); + } + m_dirtyRows.clear(); + QHash result; + result.reserve(m_gameCache.size()); + for (const auto& cached : std::as_const(m_gameCache)) + for (const auto& identity : cached.identities) result.insert(identity, cached.game); + return result; +} +QVariantList HomeModel::stored(bool* okay) const { + QVariantList rows; + if (okay) + *okay = false; + QSqlQuery query(m_database); + if (!query.exec("SELECT source,runner,app_id,title FROM play_queue ORDER BY " + "position,source,runner,app_id")) + return rows; + if (okay) + *okay = true; + while (query.next()) + rows.append(QVariantMap{{"source", query.value(0)}, + {"runner", query.value(1)}, + {"appId", query.value(2)}, + {"title", query.value(3)}}); + return rows; +} +void HomeModel::refresh() { + // Explicit refresh also rechecks external launch paths; background model changes + // only reread affected rows instead of blocking every frame on the whole library. + m_cacheInvalid = true; + refreshCached(); +} +void HomeModel::refreshCached() { + QElapsedTimer refreshTimer; + refreshTimer.start(); + bool readOkay = false; + const auto saved = stored(&readOkay); + if (!readOkay) { + m_error = "Could not read Up next. Your queue has not been changed."; + emit changed(); + return; + } + const auto games = gamesByIdentity(); + QSet seen; + QVariantList recent, queue; + for (const auto& game : games) { + const auto id = game.value("identity").toString(); + if (seen.contains(id) || game.value("hidden").toBool() || !game.value("available").toBool() || + game.value("lastPlayed").toLongLong() <= 0) + continue; + seen.insert(id); + recent.append(game); + } + std::sort(recent.begin(), recent.end(), [](const QVariant& a, const QVariant& b) { + const auto x = a.toMap(), y = b.toMap(); + const auto xt = x.value("lastPlayed").toLongLong(), yt = y.value("lastPlayed").toLongLong(); + return xt != yt ? xt > yt : x.value("identity").toString() < y.value("identity").toString(); + }); + while (recent.size() > 8) + recent.removeLast(); + QHash positions; + for (const auto& value : saved) { + const auto original = value.toMap(); + const auto key = keyFor(original); + auto game = games.value(key, original); + if (game.value("hidden").toBool()) + continue; + const auto group = game.value("identity", key).toString(); + if (positions.contains(group)) { + auto existing = queue.at(positions[group]).toMap(); + auto keys = existing.value("queueKeys").toStringList(); + keys.append(key); + existing["queueKeys"] = keys; + queue[positions[group]] = existing; + continue; + } + game["queueKey"] = key; + game["queueKeys"] = QStringList{key}; + game["available"] = game.value("available").toBool(); + positions.insert(group, queue.size()); + queue.append(game); + } + // Derive discovery from the whole library, independently of its current filters. + // Keep choices stable through artwork refreshes and never suggest hidden or unavailable games. + QSet excluded, favoriteGenres; + for (const auto& value : recent) { + const auto game = value.toMap(); + excluded.insert(game.value("identity").toString()); + for (const auto& genre : game.value("genres").toStringList()) favoriteGenres.insert(genre); + } + for (const auto& value : queue) excluded.insert(value.toMap().value("identity").toString()); + QVariantList suggestions, shortcuts; + struct Suggestion { + const QVariantMap* game; + int priority; + double rating; + QString identity, reason; + }; + QVector candidates; + candidates.reserve(games.size()); + QHash systems, collections, sources; + seen.clear(); + int gameCount = 0; + for (const auto& game : games) { + const auto id = game.value("identity").toString(); + if (seen.contains(id) || game.value("hidden").toBool() || !game.value("available").toBool()) continue; + seen.insert(id); + ++gameCount; + const auto system = game.value("system").toString(); + if (!system.isEmpty()) ++systems[system]; + else ++sources[game.value("source").toString()]; + for (const auto& collection : game.value("collections").toStringList()) ++collections[collection]; + const auto status = game.value("completionStatus").toString(); + if (excluded.contains(id) || status == "completed" || status == "abandoned") continue; + int score = 0; + QString reason; + if (status == "backlog") { score = 300; reason = "From your backlog"; } + else if (game.value("favorite").toBool()) { score = 250; reason = "One of your favorites"; } + else { + for (const auto& genre : game.value("genres").toStringList()) { + if (favoriteGenres.contains(genre)) { + score = 200; reason = genre + " · like your recent games"; break; + } + } + } + if (reason.isEmpty()) { + if (game.value("lastPlayed").toLongLong() <= 0) { score = 100; reason = "Not played in Omakade yet"; } + else { score = 50; reason = "Rediscover your library"; } + } + candidates.append({&game, score, game.value("rating").toDouble(), id, reason}); + } + const int suggestionCount = qMin(6, candidates.size()); + std::partial_sort(candidates.begin(), candidates.begin() + suggestionCount, candidates.end(), + [](const Suggestion& a, const Suggestion& b) { + if (a.priority != b.priority) return a.priority > b.priority; + if (a.rating != b.rating) return a.rating > b.rating; + return a.identity < b.identity; + }); + // Materialize only the displayed recommendations. Adding fields to every + // candidate detached thousands of complete metadata maps on each refresh. + for (int i = 0; i < suggestionCount; ++i) { + const auto& candidate = candidates[i]; + auto game = *candidate.game; + game["suggestionReason"] = candidate.reason; + game["suggestionPriority"] = candidate.priority; + suggestions.append(game); + } + const auto addShortcuts = [&](const QHash& groups, const QString& kind) { + auto names = groups.keys(); + std::sort(names.begin(), names.end(), [&](const QString& a, const QString& b) { + return groups[a] != groups[b] ? groups[a] > groups[b] : a < b; + }); + for (const auto& name : names) shortcuts.append(QVariantMap{ + {"kind", kind}, {"value", name}, {"count", groups[name]}, + {"title", kind == "console" ? ConsoleCatalog::displayNameFor(name) : name}}); + }; + addShortcuts(collections, "collection"); + addShortcuts(systems, "console"); + addShortcuts(sources, "source"); + if (m_recent != recent || m_queue != queue || m_suggestions != suggestions || m_shortcuts != shortcuts || m_gameCount != gameCount) { + m_recent = recent; + m_queue = queue; + m_suggestions = suggestions; + m_shortcuts = shortcuts; + m_gameCount = gameCount; + emit changed(); + } + if (qEnvironmentVariableIsSet("OMAKADE_SCROLL_TRACE")) + qInfo() << "scroll-trace home-refresh-ms" << refreshTimer.elapsed() << "rows" << m_games->rowCount(); +} +bool HomeModel::write(const QVariantList& rows) { + if (!m_database.transaction()) { + m_error = "Could not save Up next. Check available storage."; + emit changed(); + return false; + } + QSqlQuery query(m_database); + bool okay = query.exec("DELETE FROM play_queue"); + for (int i = 0; okay && i < rows.size(); ++i) { + const auto row = rows[i].toMap(); + query.prepare("INSERT INTO play_queue(source,runner,app_id,title,position) VALUES(?,?,?,?,?)"); + query.addBindValue(row.value("source")); + query.addBindValue(row.value("runner").toString()); + query.addBindValue(row.value("appId")); + query.addBindValue(row.value("title")); + query.addBindValue(i); + okay = query.exec(); + } + if (!okay || !m_database.commit()) { + m_database.rollback(); + m_error = "Could not save Up next. Your queue was kept."; + emit changed(); + return false; + } + const bool hadError = !m_error.isEmpty(); + m_error.clear(); + refreshCached(); + if (hadError) + emit changed(); + return true; +} +bool HomeModel::enqueue(const QString& source, const QString& runner, const QString& appId) { + const auto key = keyFor({{"source", source}, {"runner", runner}, {"appId", appId}}); + const auto games = gamesByIdentity(); + if (!games.contains(key) || games[key].value("hidden").toBool()) + return false; + const auto identity = games[key].value("identity").toString(); + bool readOkay = false; + auto rows = stored(&readOkay); + if (!readOkay) { + m_error = "Could not read Up next. Your queue has not been changed."; + emit changed(); + return false; + } + for (const auto& row : rows) { + const auto existing = keyFor(row.toMap()); + if (existing == key || games.value(existing).value("identity").toString() == identity) + return true; + } + if (rows.size() >= 100) { + m_error = "Up next holds up to 100 games."; + emit changed(); + return false; + } + rows.append(QVariantMap{{"source", source}, + {"runner", runner}, + {"appId", appId}, + {"title", games[key].value("title")}}); + return write(rows); +} +bool HomeModel::remove(const QString& key) { + refresh(); + QStringList keys; + for (const auto& item : m_queue) + if (item.toMap().value("queueKey").toString() == key) + keys = item.toMap().value("queueKeys").toStringList(); + if (keys.isEmpty()) + return false; + bool readOkay = false; + auto rows = stored(&readOkay); + if (!readOkay) { + m_error = "Could not read Up next. Your queue has not been changed."; + emit changed(); + return false; + } + for (int i = rows.size() - 1; i >= 0; --i) + if (keys.contains(keyFor(rows[i].toMap()))) + rows.removeAt(i); + return write(rows); +} +bool HomeModel::move(const QString& key, int direction) { + if (direction != -1 && direction != 1) + return false; + refresh(); + int index = -1; + for (int i = 0; i < m_queue.size(); ++i) + if (m_queue[i].toMap().value("queueKey").toString() == key) + index = i; + if (index < 0 || index + direction < 0 || index + direction >= m_queue.size()) + return false; + auto groups = m_queue; + groups.swapItemsAt(index, index + direction); + bool readOkay = false; + const auto original = stored(&readOkay); + if (!readOkay) { + m_error = "Could not read Up next. Your queue has not been changed."; + emit changed(); + return false; + } + QHash byKey; + for (const auto& row : original) + byKey.insert(keyFor(row.toMap()), row); + QVariantList ordered; + QSet used; + for (const auto& group : groups) + for (const auto& id : group.toMap().value("queueKeys").toStringList()) { + ordered.append(byKey.value(id)); + used.insert(id); + } + // Hidden entries remain saved and keep their relative order. + for (const auto& row : original) + if (!used.contains(keyFor(row.toMap()))) + ordered.append(row); + return write(ordered); +} diff --git a/src/library/HomeModel.h b/src/library/HomeModel.h new file mode 100644 index 0000000..7fb5d42 --- /dev/null +++ b/src/library/HomeModel.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include +#include +#include +#include +class UnifiedGameModel; +class HomeModel final : public QObject { + Q_OBJECT + Q_PROPERTY(bool active READ active WRITE setActive) + Q_PROPERTY(QVariantList recent READ recent NOTIFY changed) + Q_PROPERTY(QVariantList queue READ queue NOTIFY changed) + Q_PROPERTY(QVariantList suggestions READ suggestions NOTIFY changed) + Q_PROPERTY(QVariantList shortcuts READ shortcuts NOTIFY changed) + Q_PROPERTY(int gameCount READ gameCount NOTIFY changed) + Q_PROPERTY(QString error READ error NOTIFY changed) +public: + HomeModel(UnifiedGameModel* games, const QString& databasePath, QObject* parent = nullptr); + ~HomeModel() override; + bool active() const { return m_active; } + void setActive(bool value) { + if (m_active == value) return; + m_active = value; + if (value) + refresh(); + } + QVariantList recent() const { return m_recent; } + QVariantList queue() const { return m_queue; } + QVariantList suggestions() const { return m_suggestions; } + QVariantList shortcuts() const { return m_shortcuts; } + int gameCount() const { return m_gameCount; } + QString error() const { return m_error; } + Q_INVOKABLE bool enqueue(const QString& source, const QString& runner, const QString& appId); + Q_INVOKABLE bool remove(const QString& key); + Q_INVOKABLE bool move(const QString& key, int direction); + Q_INVOKABLE void refresh(); + +signals: + void changed(); + +private: + static QString keyFor(const QVariantMap& game); + QHash gamesByIdentity() const; + QVariantList stored(bool* okay = nullptr) const; + bool write(const QVariantList& rows); + void scheduleRefresh(); + void refreshCached(); + struct CachedGame { + QVariantMap game; + QStringList identities; + }; + mutable QVector m_gameCache; + mutable QSet m_dirtyRows; + mutable bool m_cacheInvalid = true; + UnifiedGameModel* m_games; + QSqlDatabase m_database; + QString m_connection, m_error; + QVariantList m_recent, m_queue, m_suggestions, m_shortcuts; + int m_gameCount = 0; + bool m_refreshPending = false; + bool m_active = false; +}; diff --git a/src/library/LibraryFilterModel.cpp b/src/library/LibraryFilterModel.cpp index 7182905..5a6f300 100644 --- a/src/library/LibraryFilterModel.cpp +++ b/src/library/LibraryFilterModel.cpp @@ -1,7 +1,9 @@ #include "library/LibraryFilterModel.h" +#include "launch/PlayRequest.h" #include "library/ConsoleCatalog.h" #include "library/PersonalDataRules.h" +#include "library/SavedFilterRules.h" #include "library/GameRoles.h" #include "library/UnifiedGameModel.h" @@ -13,9 +15,22 @@ #include #include +namespace { +int sortRoleFor(LibraryFilterModel::SortMode mode) { + switch (mode) { + case LibraryFilterModel::SortMode::Rating: return GameRoles::Rating; + case LibraryFilterModel::SortMode::Popularity: return GameRoles::Popularity; + case LibraryFilterModel::SortMode::RecentlyPlayed: return GameRoles::LastPlayed; + case LibraryFilterModel::SortMode::Playtime: return GameRoles::PlaytimeSeconds; + default: return GameRoles::Title; + } +} +} + LibraryFilterModel::LibraryFilterModel(QObject* parent) : QSortFilterProxyModel(parent), m_cardSystems(ConsoleCatalog::defaultCardSystems()) { setDynamicSortFilter(true); + setSortRole(sortRoleFor(m_sortMode)); sort(0); } @@ -29,12 +44,40 @@ void LibraryFilterModel::setSourceModel(QAbstractItemModel* source) { connect(source, &QAbstractItemModel::modelReset, this, &LibraryFilterModel::rebuildProxy); connect(source, &QAbstractItemModel::dataChanged, this, [this](const QModelIndex&, const QModelIndex&, const QList& roles) { - const QList filters{GameRoles::Title, GameRoles::Subtitle, GameRoles::Source, - GameRoles::System, GameRoles::IsPortal, GameRoles::LinkedSources, GameRoles::Hidden, - GameRoles::Favorite, GameRoles::Recent, GameRoles::Installed, - GameRoles::CompletionStatus, GameRoles::Collections, GameRoles::Tags}; - if (roles.isEmpty() || std::any_of(roles.cbegin(), roles.cend(), [&filters](int role) { return filters.contains(role); })) rebuildProxy(); - }); + const QList metadataRoles{GameRoles::CoverPath, GameRoles::Rating, + GameRoles::RatingCount, GameRoles::Popularity, GameRoles::Genres, GameRoles::Year}; + if (!roles.isEmpty() && m_genreFilter.isEmpty() && m_decadeFilter.isEmpty() && + std::all_of(roles.cbegin(), roles.cend(), + [&metadataRoles](int role) { return metadataRoles.contains(role); })) { + // Without a genre/year filter these updates cannot change portal + // membership. Qt updates changed rows and the active sort itself. + if (roles.contains(GameRoles::Genres) || roles.contains(GameRoles::Year)) + emit metadataOptionsChanged(); + return; + } + const QList filters{ + GameRoles::Title, GameRoles::Subtitle, GameRoles::Source, + GameRoles::System, GameRoles::IsPortal, GameRoles::LinkedSources, + GameRoles::Hidden, GameRoles::Favorite, GameRoles::Recent, + GameRoles::Installed, GameRoles::CompletionStatus, GameRoles::Collections, + GameRoles::Tags, GameRoles::Genres, GameRoles::Year}; + if (roles.isEmpty() || roles.contains(GameRoles::System) || + roles.contains(GameRoles::Source) || roles.contains(GameRoles::IsPortal) || + roles.contains(GameRoles::LinkedSources)) { + rebuildProxy(); + } else if (std::any_of(roles.cbegin(), roles.cend(), + [&filters](int role) { return filters.contains(role); })) { + // Metadata arrives one game at a time. Invalidating the full mapping here + // recreates visible delegates and briefly replaces all covers with placeholders. + const bool hadConsoleCards = hasConsoleCards(); + beginFilterChange(); + recountSystems(); + endFilterChange(Direction::Rows); + emit metadataOptionsChanged(); + if (hadConsoleCards != hasConsoleCards()) + emit consoleNavigationChanged(); + } + }); connect(source, &QAbstractItemModel::rowsInserted, this, &LibraryFilterModel::rebuildProxy); connect(source, &QAbstractItemModel::rowsRemoved, this, &LibraryFilterModel::rebuildProxy); } @@ -137,38 +180,24 @@ void LibraryFilterModel::setSavedFilterMessage(const QString& value) { } QVariantMap LibraryFilterModel::filterState() const { - return {{"version", 1}, {"search", m_searchText}, {"mode", int(m_mode)}, - {"sort", int(m_sortMode)}, {"availability", int(m_availability)}, {"showHidden", m_showHidden}, - {"source", m_sourceFilters}, {"status", m_completionFilter}, - {"collection", m_collectionFilter}, {"tag", m_tagFilter}}; + return {{"version", 2}, + {"search", m_searchText}, + {"mode", int(m_mode)}, + {"sort", int(m_sortMode)}, + {"availability", int(m_availability)}, + {"showHidden", m_showHidden}, + {"source", m_sourceFilters}, + {"status", m_completionFilter}, + {"collection", m_collectionFilter}, + {"tag", m_tagFilter}, + {"genre", m_genreFilter}, + {"decade", m_decadeFilter}, + {"platform", m_platformFilter}, + {"console", m_consoleFilter}}; } bool LibraryFilterModel::validFilterState(const QVariantMap& state) { - if (state.size() != 10 || state.value("version").toInt() != 1) return false; - for (const QString& key : {QStringLiteral("version"), QStringLiteral("mode"), QStringLiteral("sort"), QStringLiteral("availability")}) { - const auto value = state.value(key); - if (value.metaType().id() != QMetaType::Int && value.metaType().id() != QMetaType::LongLong && value.metaType().id() != QMetaType::Double) return false; - if (value.toDouble() != value.toInt()) return false; - } - if (state.value("mode").toInt() < 0 || state.value("mode").toInt() > 3 || - state.value("sort").toInt() < 0 || state.value("sort").toInt() >= PersonalDataRules::kSortModeCount || - state.value("availability").toInt() < 0 || state.value("availability").toInt() > 2 || - state.value("showHidden").metaType().id() != QMetaType::Bool) return false; - for (const QString& key : {QStringLiteral("search"), QStringLiteral("status"), QStringLiteral("collection"), QStringLiteral("tag")}) { - const auto value = state.value(key); - if (value.metaType().id() != QMetaType::QString || value.toString().size() > 4096 || value.toString().contains(QChar(0))) return false; - } - // Sources became a multi-select list. Accept a bare string too, so a filter saved by an - // earlier build still applies instead of being reported as corrupt. - const QVariant source = state.value("source"); - if (source.metaType().id() != QMetaType::QStringList && source.metaType().id() != QMetaType::QString && - source.metaType().id() != QMetaType::QVariantList) return false; - const QStringList sources = savedSources(state); - if (sources.size() > PersonalDataRules::kMaxSavedFilterSources) return false; - for (const QString& name : sources) { - if (name.size() > 4096 || name.contains(QChar(0))) return false; - } - return QStringList{"", "backlog", "playing", "completed", "abandoned"}.contains(state.value("status").toString()); + return SavedFilterRules::valid(QJsonObject::fromVariantMap(state)); } QString LibraryFilterModel::filterWarning(const QVariantMap& state) const { @@ -178,6 +207,15 @@ QString LibraryFilterModel::filterWarning(const QVariantMap& state) const { const QString tag = state.value("tag").toString(); if (!collection.isEmpty() && !collectionNames().contains(collection, Qt::CaseInsensitive)) missing << "collection: " + collection; if (!tag.isEmpty() && !tagNames().contains(tag, Qt::CaseInsensitive)) missing << "tag: " + tag; + const QString genre = state.value("genre").toString(); + const QString decade = state.value("decade").toString(); + const QString platform = state.value("platform").toString(); + if (!genre.isEmpty() && !genreNames().contains(genre, Qt::CaseInsensitive)) + missing << "genre: " + genre; + if (!decade.isEmpty() && !decadeNames().contains(decade)) + missing << "decade: " + decade; + if (!platform.isEmpty() && !platformNames().contains(platform)) + missing << "platform: " + platform; return missing.isEmpty() ? QString{} : QStringLiteral("Not currently available (%1). These criteria remain applied.").arg(missing.join(", ")); } @@ -236,21 +274,7 @@ bool LibraryFilterModel::applySavedFilter(const QString& id) { if (saved.value("id").toString() != id) continue; const auto state = saved.value("state").toMap(); if (!validFilterState(state)) break; - // Change the full query before invalidating so observers never see a partially applied view. - m_searchText = state.value("search").toString(); - m_mode = Mode(state.value("mode").toInt()); - m_sortMode = SortMode(state.value("sort").toInt()); - m_availability = Availability(state.value("availability").toInt()); - m_showHidden = state.value("showHidden").toBool(); - m_sourceFilters = savedSources(state); - m_completionFilter = state.value("status").toString(); - m_collectionFilter = state.value("collection").toString(); - m_tagFilter = state.value("tag").toString(); - invalidate(); - sort(0); - emit searchTextChanged(); emit modeChanged(); emit sortModeChanged(); - emit availabilityChanged(); emit showHiddenChanged(); emit sourceFilterChanged(); - emit organizationFilterChanged(); + applyFilterState(state); setSavedFilterMessage(saved.value("warning").toString()); return true; } @@ -258,6 +282,60 @@ bool LibraryFilterModel::applySavedFilter(const QString& id) { return false; } +bool LibraryFilterModel::applyFilterState(const QVariantMap& state) { + if (!validFilterState(state)) + return false; + // Change the full query before invalidating so observers never see a partially applied view. + m_searchText = state.value("search").toString(); + m_mode = Mode(state.value("mode").toInt()); + m_sortMode = SortMode(state.value("sort").toInt()); + m_availability = Availability(state.value("availability").toInt()); + m_showHidden = state.value("showHidden").toBool(); + m_sourceFilters = savedSources(state); + m_completionFilter = state.value("status").toString(); + m_collectionFilter = state.value("collection").toString(); + m_tagFilter = state.value("tag").toString(); + m_genreFilter = state.value("genre").toString(); + m_decadeFilter = state.value("decade").toString(); + m_platformFilter = state.value("platform").toString(); + m_consoleFilter = state.value("console").toString(); + setSortRole(sortRoleFor(m_sortMode)); + recountSystems(); + invalidate(); + sort(0); + emit searchTextChanged(); + emit modeChanged(); + emit sortModeChanged(); + emit availabilityChanged(); + emit showHiddenChanged(); + emit sourceFilterChanged(); + emit organizationFilterChanged(); + emit consoleNavigationChanged(); + return true; +} +int LibraryFilterModel::revealGame(const QString& source, const QString& runner, + const QString& appId) { + auto state = filterState(); + for (const auto* field : + {"search", "status", "collection", "tag", "genre", "decade", "platform", "console"}) + state[field] = ""; + state["source"] = QStringList{}; + state["mode"] = 0; + state["availability"] = 1; + state["showHidden"] = false; + // Temporarily flatten cards so any individual installation can be resolved. + const bool portals = m_consolePortalsEnabled; + m_consolePortalsEnabled = false; + applyFilterState(state); + const int found = indexOf(source, runner, appId); + const QString system = found >= 0 ? get(found).value("system").toString() : QString(); + m_consolePortalsEnabled = portals; + m_consoleFilter = system; + rebuildProxy(); + emit consoleNavigationChanged(); + return indexOf(source, runner, appId); +} + int LibraryFilterModel::pickRandomGame() { QList> eligible; for (int row = 0; row < rowCount(); ++row) { @@ -294,8 +372,10 @@ void LibraryFilterModel::setSortMode(SortMode value) { return; } m_sortMode = value; + setSortRole(sortRoleFor(m_sortMode)); const bool hadConsoleCards = hasConsoleCards(); recountSystems(); + emit metadataOptionsChanged(); invalidate(); if (hadConsoleCards != hasConsoleCards()) emit consoleNavigationChanged(); @@ -459,6 +539,63 @@ void LibraryFilterModel::setTagFilter(const QString& value) { emit organizationFilterChanged(); } +QString LibraryFilterModel::platformFor(const QModelIndex& index) { + const QString system = index.data(GameRoles::System).toString(); + return system.isEmpty() ? QStringLiteral("PC") : ConsoleCatalog::displayNameFor(system); +} + +QStringList LibraryFilterModel::metadataOptions(int role) const { + QStringList values; + if (!sourceModel()) + return values; + for (int row = 0; row < sourceModel()->rowCount(); ++row) { + const auto index = sourceModel()->index(row, 0); + if (index.data(GameRoles::IsPortal).toBool() || index.data(GameRoles::Hidden).toBool()) + continue; + if (role == GameRoles::Genres) + values.append(index.data(role).toStringList()); + else if (role == GameRoles::Year) { + const int year = index.data(role).toInt(); + if (year >= 1000 && year < 3000) + values.append(QString::number(year / 10 * 10) + "s"); + } else + values.append(platformFor(index)); + } + values.removeAll(QString()); + values.removeDuplicates(); + values.sort(Qt::CaseInsensitive); + return values; +} +QStringList LibraryFilterModel::genreNames() const { return metadataOptions(GameRoles::Genres); } +QStringList LibraryFilterModel::decadeNames() const { return metadataOptions(GameRoles::Year); } +QStringList LibraryFilterModel::platformNames() const { return metadataOptions(GameRoles::System); } +void LibraryFilterModel::setGenreFilter(const QString& value) { + const QString normalized = value.trimmed(); + if (m_genreFilter == normalized) + return; + m_genreFilter = normalized; + rebuildProxy(); + emit organizationFilterChanged(); +} +void LibraryFilterModel::setDecadeFilter(const QString& value) { + const QString normalized = value.trimmed(); + if (m_decadeFilter == normalized) + return; + if (!normalized.isEmpty() && !QRegularExpression("^[12][0-9]{2}0s$").match(normalized).hasMatch()) + return; + m_decadeFilter = normalized; + rebuildProxy(); + emit organizationFilterChanged(); +} +void LibraryFilterModel::setPlatformFilter(const QString& value) { + const QString normalized = value.trimmed(); + if (m_platformFilter == normalized) + return; + m_platformFilter = normalized; + rebuildProxy(); + emit organizationFilterChanged(); +} + QStringList LibraryFilterModel::collectionNames() const { const auto* games = qobject_cast(sourceModel()); return games == nullptr ? QStringList{} : games->collectionNames(); @@ -608,6 +745,7 @@ void LibraryFilterModel::rebuildProxy() { // no detach from the source model in between. const bool hadConsoleCards = hasConsoleCards(); recountSystems(); + emit metadataOptionsChanged(); invalidate(); if (hadConsoleCards != hasConsoleCards()) emit consoleNavigationChanged(); @@ -784,6 +922,18 @@ bool LibraryFilterModel::recordLaunch(int row, const QString& source, const QStr return games->recordLaunch(mapToSource(index(row, 0)).row(), source, runner, appId); } +bool LibraryFilterModel::recordLaunchByIdentity(const QString& source, const QString& runner, + const QString& appId) { + auto* games = qobject_cast(sourceModel()); + if (!games) return false; + // Resolve against the full library, including linked installations, even if + // the user changed filters while the launcher was opening. + int row = -1; + if (PlayRequest::findInstallation(*games, LaunchKey{source, runner, appId}, &row).isEmpty()) + return false; + return games->recordLaunch(row, source, runner, appId); +} + bool LibraryFilterModel::unlinkGames(int row) { auto* games = qobject_cast(sourceModel()); if (games == nullptr || row < 0 || row >= rowCount()) { @@ -907,6 +1057,16 @@ bool LibraryFilterModel::matchesGameFilters(const QModelIndex& sourceIndex) cons return false; } + if (!m_genreFilter.isEmpty() && + !containsCaseInsensitive(sourceIndex.data(GameRoles::Genres).toStringList(), m_genreFilter)) + return false; + const int year = sourceIndex.data(GameRoles::Year).toInt(); + if (!m_decadeFilter.isEmpty() && + (year <= 0 || QString::number(year / 10 * 10) + "s" != m_decadeFilter)) + return false; + if (!m_platformFilter.isEmpty() && platformFor(sourceIndex) != m_platformFilter) + return false; + if (m_searchText.isEmpty()) { return true; } @@ -938,8 +1098,8 @@ bool LibraryFilterModel::lessThan(const QModelIndex& left, const QModelIndex& ri } } if (m_sortMode == SortMode::Playtime) { - const int leftHours = left.data(GameRoles::Hours).toInt(); - const int rightHours = right.data(GameRoles::Hours).toInt(); + const qint64 leftHours = left.data(GameRoles::PlaytimeSeconds).toLongLong(); + const qint64 rightHours = right.data(GameRoles::PlaytimeSeconds).toLongLong(); if (leftHours != rightHours) { return leftHours > rightHours; } diff --git a/src/library/LibraryFilterModel.h b/src/library/LibraryFilterModel.h index d344937..368031e 100644 --- a/src/library/LibraryFilterModel.h +++ b/src/library/LibraryFilterModel.h @@ -37,6 +37,15 @@ class LibraryFilterModel final : public QSortFilterProxyModel { Q_PROPERTY(QString collectionFilter READ collectionFilter WRITE setCollectionFilter NOTIFY organizationFilterChanged) Q_PROPERTY(QString tagFilter READ tagFilter WRITE setTagFilter NOTIFY organizationFilterChanged) + Q_PROPERTY( + QString genreFilter READ genreFilter WRITE setGenreFilter NOTIFY organizationFilterChanged) + Q_PROPERTY( + QString decadeFilter READ decadeFilter WRITE setDecadeFilter NOTIFY organizationFilterChanged) + Q_PROPERTY(QString platformFilter READ platformFilter WRITE setPlatformFilter NOTIFY + organizationFilterChanged) + Q_PROPERTY(QStringList genreNames READ genreNames NOTIFY metadataOptionsChanged) + Q_PROPERTY(QStringList decadeNames READ decadeNames NOTIFY metadataOptionsChanged) + Q_PROPERTY(QStringList platformNames READ platformNames NOTIFY metadataOptionsChanged) Q_PROPERTY(QStringList collectionNames READ collectionNames NOTIFY organizationNamesChanged) Q_PROPERTY(QStringList tagNames READ tagNames NOTIFY organizationNamesChanged) Q_PROPERTY(bool consolePortalsEnabled READ consolePortalsEnabled WRITE setConsolePortalsEnabled @@ -96,6 +105,15 @@ class LibraryFilterModel final : public QSortFilterProxyModel { void setCollectionFilter(const QString& value); [[nodiscard]] QString tagFilter() const; void setTagFilter(const QString& value); + QString genreFilter() const { return m_genreFilter; } + QString decadeFilter() const { return m_decadeFilter; } + QString platformFilter() const { return m_platformFilter; } + void setGenreFilter(const QString& value); + void setDecadeFilter(const QString& value); + void setPlatformFilter(const QString& value); + QStringList genreNames() const; + QStringList decadeNames() const; + QStringList platformNames() const; [[nodiscard]] QStringList collectionNames() const; [[nodiscard]] QStringList tagNames() const; [[nodiscard]] bool consolePortalsEnabled() const; @@ -120,7 +138,9 @@ class LibraryFilterModel final : public QSortFilterProxyModel { Q_INVOKABLE bool renameSavedFilter(const QString& id, const QString& name); Q_INVOKABLE bool removeSavedFilter(const QString& id); Q_INVOKABLE bool applySavedFilter(const QString& id); - QVariantMap filterState() const; + Q_INVOKABLE QVariantMap filterState() const; + Q_INVOKABLE bool applyFilterState(const QVariantMap& state); + Q_INVOKABLE int revealGame(const QString& source, const QString& runner, const QString& appId); Q_INVOKABLE int indexOf(const QString& source, const QString& runner, const QString& appId) const; Q_INVOKABLE void toggleFavorite(int row); Q_INVOKABLE void toggleHidden(int row); @@ -135,6 +155,8 @@ class LibraryFilterModel final : public QSortFilterProxyModel { Q_INVOKABLE QVariantList linkCandidates(int row, const QString& search) const; Q_INVOKABLE bool recordLaunch(int row, const QString& source, const QString& runner, const QString& appId); + Q_INVOKABLE bool recordLaunchByIdentity(const QString& source, const QString& runner, + const QString& appId); Q_INVOKABLE bool linkGames(int row, const QString& source, const QString& runner, const QString& appId); Q_INVOKABLE bool unlinkGames(int row); @@ -157,6 +179,7 @@ class LibraryFilterModel final : public QSortFilterProxyModel { void sourceFilterChanged(); void organizationFilterChanged(); void organizationNamesChanged(); + void metadataOptionsChanged(); void consoleNavigationChanged(); protected: @@ -196,6 +219,9 @@ class LibraryFilterModel final : public QSortFilterProxyModel { QString m_completionFilter; QString m_collectionFilter; QString m_tagFilter; + QString m_genreFilter, m_decadeFilter, m_platformFilter; + QStringList metadataOptions(int role) const; + static QString platformFor(const QModelIndex& index); bool m_consolePortalsEnabled = true; QString m_consoleFilter; }; diff --git a/src/library/LutrisGameModel.cpp b/src/library/LutrisGameModel.cpp index 35d76a6..4a86690 100644 --- a/src/library/LutrisGameModel.cpp +++ b/src/library/LutrisGameModel.cpp @@ -274,6 +274,8 @@ QVariant LutrisGameModel::valueForRole(const Game& game, int role) const { : QStringLiteral("Lutris · %1").arg(game.lutris.runner); case GameRoles::Description: return QStringLiteral("Installed locally through Lutris."); + case GameRoles::PlaytimeSeconds: + return qint64(game.lutris.playtimeMinutes) * 60; case GameRoles::Hours: return game.lutris.playtimeMinutes / 60; case GameRoles::Progress: diff --git a/src/library/ManualGameModel.cpp b/src/library/ManualGameModel.cpp index 6dea2ef..a857e86 100644 --- a/src/library/ManualGameModel.cpp +++ b/src/library/ManualGameModel.cpp @@ -78,6 +78,7 @@ QHash ManualGameModel::roleNames() const { {GameRoles::HeroPath, "heroPath"}, {GameRoles::LogoPath, "logoPath"}, {GameRoles::Hours, "hours"}, + {GameRoles::PlaytimeSeconds, "playtimeSeconds"}, {GameRoles::Recent, "recent"}, {GameRoles::LastPlayed, "lastPlayed"}, {GameRoles::Progress, "progress"}, diff --git a/src/library/MockGameModel.cpp b/src/library/MockGameModel.cpp index fd1c257..455268a 100644 --- a/src/library/MockGameModel.cpp +++ b/src/library/MockGameModel.cpp @@ -130,6 +130,8 @@ QVariant MockGameModel::valueForRole(const Game& game, int role) const { return game.subtitle; case GameRoles::Description: return game.description; + case GameRoles::PlaytimeSeconds: + return qint64(game.hours) * 3600; case GameRoles::Hours: return game.hours; case GameRoles::Progress: diff --git a/src/library/Pcsx2GameModel.cpp b/src/library/Pcsx2GameModel.cpp index 79b2be1..8e78c7f 100644 --- a/src/library/Pcsx2GameModel.cpp +++ b/src/library/Pcsx2GameModel.cpp @@ -2,6 +2,7 @@ #include "library/DatabaseTuning.h" #include "library/GameRoles.h" +#include "tracking/PlaySessionStore.h" #include #include @@ -24,9 +25,20 @@ QString localUrl(const QString& path) { } } // namespace -Pcsx2GameModel::Pcsx2GameModel(const QString& omakadeDatabasePath, QObject* parent) +Pcsx2GameModel::Pcsx2GameModel(const QString& omakadeDatabasePath, PlaySessionStore* playSessions, + QObject* parent) : QAbstractListModel(parent), - m_connectionName(QStringLiteral("omakade-pcsx2-%1").arg(reinterpret_cast(this))) { + m_connectionName(QStringLiteral("omakade-pcsx2-%1").arg(reinterpret_cast(this))), + m_playSessions(playSessions) { + if (m_playSessions != nullptr) { + connect(m_playSessions, &PlaySessionStore::totalsChanged, this, [this] { + if (!m_games.isEmpty()) { + emit dataChanged(index(0), index(static_cast(m_games.size()) - 1), + {GameRoles::Hours, GameRoles::PlaytimeSeconds, GameRoles::PlaytimeText, + GameRoles::PlaytimeProvenance, GameRoles::LastPlayed}); + } + }); + } connect(&m_scanWatcher, &QFutureWatcher::finished, this, [this] { m_scanning = false; @@ -203,6 +215,9 @@ void Pcsx2GameModel::loadDatabase() { .lastPlayed = query.value(6).toLongLong(), .isElf = query.value(8).toBool(), .flatpak = query.value(9).toBool()}; + if (m_playSessions != nullptr) { + m_playSessions->captureBaseline(record.path, record.playtimeSeconds); + } loaded.append({.pcsx2 = record, .favorite = query.value(10).toBool(), .hidden = query.value(11).toBool(), @@ -296,8 +311,15 @@ QVariant Pcsx2GameModel::valueForRole(const Game& game, int role) const { : QStringLiteral("PCSX2 · %1").arg(game.pcsx2.region); case GameRoles::Description: return QStringLiteral("PlayStation 2 game launched through PCSX2."); + case GameRoles::PlaytimeProvenance: + return PlaySessionStore::provenance(m_playSessions, game.pcsx2.path, game.pcsx2.playtimeSeconds); + case GameRoles::PlaytimeSeconds: + return PlaySessionStore::displayedSeconds(m_playSessions, game.pcsx2.path, + game.pcsx2.playtimeSeconds); case GameRoles::Hours: - return static_cast(game.pcsx2.playtimeSeconds / 3600); + return static_cast(PlaySessionStore::displayedSeconds(m_playSessions, game.pcsx2.path, + game.pcsx2.playtimeSeconds) / + 3600); case GameRoles::Progress: case GameRoles::AchievementsUnlocked: case GameRoles::AchievementsTotal: @@ -305,9 +327,11 @@ QVariant Pcsx2GameModel::valueForRole(const Game& game, int role) const { case GameRoles::Favorite: return game.favorite; case GameRoles::Recent: - return game.pcsx2.lastPlayed > 0; + return PlaySessionStore::displayedLastPlayed(m_playSessions, game.pcsx2.path, + game.pcsx2.lastPlayed) > 0; case GameRoles::LastPlayed: - return game.pcsx2.lastPlayed; + return PlaySessionStore::displayedLastPlayed(m_playSessions, game.pcsx2.path, + game.pcsx2.lastPlayed); case GameRoles::AccentStart: return game.accentStart; case GameRoles::AccentEnd: diff --git a/src/library/Pcsx2GameModel.h b/src/library/Pcsx2GameModel.h index 5ac2393..fcb5342 100644 --- a/src/library/Pcsx2GameModel.h +++ b/src/library/Pcsx2GameModel.h @@ -1,6 +1,7 @@ #pragma once #include "sources/pcsx2/Pcsx2Scanner.h" +#include "tracking/PlaySessionStore.h" #include #include @@ -17,7 +18,8 @@ class Pcsx2GameModel final : public QAbstractListModel { Q_PROPERTY(qint64 lastScan READ lastScan NOTIFY statusChanged) public: - explicit Pcsx2GameModel(const QString& omakadeDatabasePath, QObject* parent = nullptr); + explicit Pcsx2GameModel(const QString& omakadeDatabasePath, + PlaySessionStore* playSessions = nullptr, QObject* parent = nullptr); ~Pcsx2GameModel() override; [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex()) const override; @@ -58,6 +60,7 @@ class Pcsx2GameModel final : public QAbstractListModel { QVector m_games; QSqlDatabase m_database; QString m_connectionName; + PlaySessionStore* m_playSessions = nullptr; QFutureWatcher m_scanWatcher; bool m_scanning = false; bool m_pcsx2Detected = false; diff --git a/src/library/RetroArchGameModel.cpp b/src/library/RetroArchGameModel.cpp index d068fea..6ca7740 100644 --- a/src/library/RetroArchGameModel.cpp +++ b/src/library/RetroArchGameModel.cpp @@ -1,4 +1,6 @@ #include "library/RetroArchGameModel.h" +#include "library/ArtworkPersistence.h" +#include "library/CoverCachePolicy.h" #include "app/AppSettings.h" #include "library/ConsoleCatalog.h" @@ -153,28 +155,23 @@ QString coverCacheRoot() { QStringLiteral("/omakade/covers/libretro"); } -qint64 otherCoverCacheBytes() { - const QString sharedRoot = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + - QStringLiteral("/omakade/covers"); - const QString libretroRoot = coverCacheRoot() + QLatin1Char('/'); - qint64 total = 0; - QDirIterator iterator(sharedRoot, QDir::Files, QDirIterator::Subdirectories); - while (iterator.hasNext()) { - const QFileInfo info(iterator.next()); - if (!info.absoluteFilePath().startsWith(libretroRoot)) { - total += info.size(); - } - } - return total; -} } // namespace RetroArchGameModel::RetroArchGameModel(const QString& databasePath, AppSettings* settings, - QObject* parent) + PlaySessionStore* playSessions, QObject* parent) : QAbstractListModel(parent), m_connectionName( QStringLiteral("omakade-retroarch-%1").arg(reinterpret_cast(this))), - m_settings(settings) { + m_settings(settings), m_playSessions(playSessions) { + if (m_playSessions != nullptr) { + connect(m_playSessions, &PlaySessionStore::totalsChanged, this, [this] { + if (!m_games.isEmpty()) { + emit dataChanged(index(0), index(static_cast(m_games.size()) - 1), + {GameRoles::Hours, GameRoles::PlaytimeSeconds, GameRoles::PlaytimeText, + GameRoles::PlaytimeProvenance, GameRoles::LastPlayed}); + } + }); + } m_coverWriteTimer.setSingleShot(true); m_coverWriteTimer.setInterval(750); connect(&m_coverWriteTimer, &QTimer::timeout, this, &RetroArchGameModel::flushCoverWrites); @@ -403,6 +400,12 @@ void RetroArchGameModel::loadDatabase() { .playtimeSeconds = query.value(8).toLongLong(), .lastPlayed = query.value(9).toLongLong(), .flatpak = query.value(10).toBool()}; + // A stale path prevents the view from requesting a replacement cover. + if (!record.coverPath.isEmpty() && !QFileInfo::exists(record.coverPath)) + record.coverPath.clear(); + if (m_playSessions != nullptr) { + m_playSessions->captureBaseline(record.contentPath, record.playtimeSeconds); + } const QPair achievements = achievementSummaries.value(record.gameId); loaded.append({.retroArch = record, .favorite = query.value(11).toBool(), @@ -601,8 +604,15 @@ QVariant RetroArchGameModel::valueForRole(const Game& game, int role) const { return record.corePath.isEmpty() ? QStringLiteral("Launch uses a detected emulator or RetroArch core.") : QStringLiteral("Configured and managed by RetroArch."); + case GameRoles::PlaytimeProvenance: + return PlaySessionStore::provenance(m_playSessions, record.contentPath, record.playtimeSeconds); + case GameRoles::PlaytimeSeconds: + return PlaySessionStore::displayedSeconds(m_playSessions, record.contentPath, + record.playtimeSeconds); case GameRoles::Hours: - return record.playtimeSeconds / 3600; + return static_cast(PlaySessionStore::displayedSeconds(m_playSessions, record.contentPath, + record.playtimeSeconds) / + 3600); case GameRoles::Progress: return game.achievementsTotal > 0 ? (game.achievementsUnlocked * 100) / game.achievementsTotal @@ -616,9 +626,11 @@ QVariant RetroArchGameModel::valueForRole(const Game& game, int role) const { case GameRoles::Favorite: return game.favorite; case GameRoles::Recent: - return record.lastPlayed > 0; + return PlaySessionStore::displayedLastPlayed(m_playSessions, record.contentPath, + record.lastPlayed) > 0; case GameRoles::LastPlayed: - return record.lastPlayed; + return PlaySessionStore::displayedLastPlayed(m_playSessions, record.contentPath, + record.lastPlayed); case GameRoles::AccentStart: return game.accentStart; case GameRoles::AccentEnd: @@ -833,78 +845,20 @@ void RetroArchGameModel::applyCover(const QString& gameId, const QString& path) } void RetroArchGameModel::flushCoverWrites() { - if (m_pendingCoverWrites.isEmpty() || !m_database.isOpen()) { - m_pendingCoverWrites.clear(); - return; - } - const QHash pending = m_pendingCoverWrites; - m_pendingCoverWrites.clear(); - if (!m_database.transaction()) { - return; - } - QSqlQuery query(m_database); - query.prepare(QStringLiteral("UPDATE retroarch_games SET cover_path = ? WHERE game_id = ?")); - for (auto it = pending.cbegin(); it != pending.cend(); ++it) { - query.addBindValue(it.value()); - query.addBindValue(it.key()); - query.exec(); - } - m_database.commit(); + if (!ArtworkPersistence::flush( + m_database, QStringLiteral("UPDATE retroarch_games SET cover_path = ? WHERE game_id = ?"), + m_pendingCoverWrites)) + setStatus(m_statusText, QStringLiteral("Artwork cache changes could not be saved.")); } void RetroArchGameModel::pruneCoverCache() { const int limitMb = m_settings == nullptr ? 1024 : m_settings->artworkCacheLimitMb(); - const qint64 configuredLimit = static_cast(limitMb) * 1024 * 1024; - const qint64 limit = qMax(0, configuredLimit - otherCoverCacheBytes()); - struct CachedFile { - QString path; - QDateTime modified; - qint64 size = 0; - }; - QVector files; - qint64 total = 0; - QDirIterator iterator(coverCacheRoot(), QDir::Files); - while (iterator.hasNext()) { - const QFileInfo info(iterator.next()); - files.append({info.absoluteFilePath(), info.lastModified(), info.size()}); - total += info.size(); - } - if (total <= limit) { - return; - } + const QString sharedRoot = + QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + + QStringLiteral("/omakade/covers"); QSet referenced; for (const Game& game : m_games) { referenced.insert(game.retroArch.coverPath); } - std::sort(files.begin(), files.end(), - [&referenced](const CachedFile& left, const CachedFile& right) { - const bool leftReferenced = referenced.contains(left.path); - const bool rightReferenced = referenced.contains(right.path); - if (leftReferenced != rightReferenced) { - return !leftReferenced; - } - return left.modified < right.modified; - }); - for (const CachedFile& file : files) { - if (total <= limit) { - break; - } - if (QFile::remove(file.path)) { - total -= file.size; - for (int row = 0; row < m_games.size(); ++row) { - if (m_games[row].retroArch.coverPath == file.path) { - m_games[row].retroArch.coverPath.clear(); - // Clear the stored path as well, not just the one in memory. The scan used to - // overwrite cover_path on every launch, which hid this; now that downloaded covers - // survive a scan, a path left behind here would outlive the file it names and the - // card would show nothing at all until it was scrolled back into view. - m_pendingCoverWrites.insert(m_games[row].retroArch.gameId, QString{}); - emit dataChanged(index(row), index(row), {GameRoles::CoverPath}); - } - } - } - } - if (!m_pendingCoverWrites.isEmpty() && !m_coverWriteTimer.isActive()) { - m_coverWriteTimer.start(); - } + CoverCachePolicy::prune(sharedRoot, coverCacheRoot(), qint64(limitMb) * 1024 * 1024, referenced); } diff --git a/src/library/RetroArchGameModel.h b/src/library/RetroArchGameModel.h index 996e452..fdf822c 100644 --- a/src/library/RetroArchGameModel.h +++ b/src/library/RetroArchGameModel.h @@ -1,6 +1,7 @@ #pragma once #include "sources/retroarch/RetroArchScanner.h" +#include "tracking/PlaySessionStore.h" #include #include @@ -27,7 +28,7 @@ class RetroArchGameModel final : public QAbstractListModel { public: explicit RetroArchGameModel(const QString& databasePath, AppSettings* settings = nullptr, - QObject* parent = nullptr); + PlaySessionStore* playSessions = nullptr, QObject* parent = nullptr); ~RetroArchGameModel() override; [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex()) const override; [[nodiscard]] QVariant data(const QModelIndex& index, int role) const override; @@ -93,6 +94,7 @@ class RetroArchGameModel final : public QAbstractListModel { QSqlDatabase m_database; QString m_connectionName; AppSettings* m_settings = nullptr; + PlaySessionStore* m_playSessions = nullptr; bool m_retroArchDetected = false; QString m_statusText; QString m_errorText; diff --git a/src/library/RyujinxGameModel.cpp b/src/library/RyujinxGameModel.cpp index d405913..aff794b 100644 --- a/src/library/RyujinxGameModel.cpp +++ b/src/library/RyujinxGameModel.cpp @@ -2,6 +2,7 @@ #include "library/DatabaseTuning.h" #include "library/GameRoles.h" +#include "tracking/PlaySessionStore.h" #include #include @@ -24,9 +25,20 @@ QString localUrl(const QString& path) { } } // namespace -RyujinxGameModel::RyujinxGameModel(const QString& omakadeDatabasePath, QObject* parent) +RyujinxGameModel::RyujinxGameModel(const QString& omakadeDatabasePath, + PlaySessionStore* playSessions, QObject* parent) : QAbstractListModel(parent), - m_connectionName(QStringLiteral("omakade-ryujinx-%1").arg(reinterpret_cast(this))) { + m_connectionName(QStringLiteral("omakade-ryujinx-%1").arg(reinterpret_cast(this))), + m_playSessions(playSessions) { + if (m_playSessions != nullptr) { + connect(m_playSessions, &PlaySessionStore::totalsChanged, this, [this] { + if (!m_games.isEmpty()) { + emit dataChanged(index(0), index(static_cast(m_games.size()) - 1), + {GameRoles::Hours, GameRoles::PlaytimeSeconds, GameRoles::PlaytimeText, + GameRoles::PlaytimeProvenance, GameRoles::LastPlayed}); + } + }); + } connect(&m_scanWatcher, &QFutureWatcher::finished, this, [this] { m_scanning = false; @@ -204,6 +216,9 @@ void RyujinxGameModel::loadDatabase() { .lastPlayed = query.value(5).toLongLong(), .flatpak = query.value(7).toBool(), .flatpakAppId = query.value(8).toString()}; + if (m_playSessions != nullptr) { + m_playSessions->captureBaseline(record.path, record.playtimeSeconds); + } loaded.append({.ryujinx = record, .favorite = query.value(9).toBool(), .hidden = query.value(10).toBool(), @@ -295,8 +310,15 @@ QVariant RyujinxGameModel::valueForRole(const Game& game, int role) const { return QStringLiteral("Ryujinx"); case GameRoles::Description: return QStringLiteral("Nintendo Switch game launched through Ryujinx."); + case GameRoles::PlaytimeProvenance: + return PlaySessionStore::provenance(m_playSessions, game.ryujinx.path, game.ryujinx.playtimeSeconds); + case GameRoles::PlaytimeSeconds: + return PlaySessionStore::displayedSeconds(m_playSessions, game.ryujinx.path, + game.ryujinx.playtimeSeconds); case GameRoles::Hours: - return static_cast(game.ryujinx.playtimeSeconds / 3600); + return static_cast(PlaySessionStore::displayedSeconds(m_playSessions, game.ryujinx.path, + game.ryujinx.playtimeSeconds) / + 3600); case GameRoles::Progress: case GameRoles::AchievementsUnlocked: case GameRoles::AchievementsTotal: @@ -304,9 +326,11 @@ QVariant RyujinxGameModel::valueForRole(const Game& game, int role) const { case GameRoles::Favorite: return game.favorite; case GameRoles::Recent: - return game.ryujinx.lastPlayed > 0; + return PlaySessionStore::displayedLastPlayed(m_playSessions, game.ryujinx.path, + game.ryujinx.lastPlayed) > 0; case GameRoles::LastPlayed: - return game.ryujinx.lastPlayed; + return PlaySessionStore::displayedLastPlayed(m_playSessions, game.ryujinx.path, + game.ryujinx.lastPlayed); case GameRoles::AccentStart: return game.accentStart; case GameRoles::AccentEnd: diff --git a/src/library/RyujinxGameModel.h b/src/library/RyujinxGameModel.h index 91d227a..784c266 100644 --- a/src/library/RyujinxGameModel.h +++ b/src/library/RyujinxGameModel.h @@ -1,6 +1,7 @@ #pragma once #include "sources/ryujinx/RyujinxScanner.h" +#include "tracking/PlaySessionStore.h" #include #include @@ -17,7 +18,8 @@ class RyujinxGameModel final : public QAbstractListModel { Q_PROPERTY(qint64 lastScan READ lastScan NOTIFY statusChanged) public: - explicit RyujinxGameModel(const QString& omakadeDatabasePath, QObject* parent = nullptr); + explicit RyujinxGameModel(const QString& omakadeDatabasePath, + PlaySessionStore* playSessions = nullptr, QObject* parent = nullptr); ~RyujinxGameModel() override; [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex()) const override; @@ -58,6 +60,7 @@ class RyujinxGameModel final : public QAbstractListModel { QVector m_games; QSqlDatabase m_database; QString m_connectionName; + PlaySessionStore* m_playSessions = nullptr; QFutureWatcher m_scanWatcher; bool m_scanning = false; bool m_ryujinxDetected = false; diff --git a/src/library/SavedFilterRules.h b/src/library/SavedFilterRules.h new file mode 100644 index 0000000..1fde5a9 --- /dev/null +++ b/src/library/SavedFilterRules.h @@ -0,0 +1,57 @@ +#pragma once +#include "library/ConsoleCatalog.h" +#include "library/PersonalDataRules.h" +#include +#include +#include +#include +namespace SavedFilterRules { +inline bool integer(const QJsonValue& value, double minimum, double maximum) { + return value.isDouble() && std::isfinite(value.toDouble()) && + value.toDouble() == std::floor(value.toDouble()) && value.toDouble() >= minimum && + value.toDouble() <= maximum; +} +inline bool text(const QJsonValue& value, int limit) { + return value.isString() && value.toString().size() <= limit && + !value.toString().contains(QChar::Null); +} +inline bool valid(const QJsonObject& state) { + if (!integer(state.value("version"), 1, 2) || + state.size() != (state.value("version").toInt() == 1 ? 10 : 14) || + !integer(state.value("mode"), 0, 3) || + !integer(state.value("sort"), 0, PersonalDataRules::kSortModeCount - 1) || + !integer(state.value("availability"), 0, 2) || !state.value("showHidden").isBool()) + return false; + for (const QString& key : {QStringLiteral("search"), QStringLiteral("status"), + QStringLiteral("collection"), QStringLiteral("tag")}) + if (!text(state.value(key), 4096)) + return false; + if (state.value("version").toInt() == 2) { + if (!text(state.value("genre"), 4096) || !text(state.value("platform"), 4096) || + !text(state.value("decade"), 4096)) + return false; + if (!text(state.value("console"), 4096)) + return false; + const QString console = state.value("console").toString(); + if (!console.isEmpty() && ConsoleCatalog::idFor(console) != console) + return false; + const QString decade = state.value("decade").toString(); + if (!decade.isEmpty() && !QRegularExpression("^[12][0-9]{2}0s$").match(decade).hasMatch()) + return false; + } + // Sources are a multi-select list. A bare string is still accepted so a filter saved by an + // earlier build exports instead of failing the whole archive. + const QJsonValue sources = state.value("source"); + if (sources.isArray()) { + if (sources.toArray().size() > PersonalDataRules::kMaxSavedFilterSources) + return false; + for (const auto& name : sources.toArray()) + if (!text(name, 4096)) + return false; + } else if (!text(sources, 4096)) { + return false; + } + return QStringList{"", "backlog", "playing", "completed", "abandoned"}.contains( + state.value("status").toString()); +} +} // namespace SavedFilterRules diff --git a/src/library/Shadps4GameModel.cpp b/src/library/Shadps4GameModel.cpp index 9438b8b..88fa4b3 100644 --- a/src/library/Shadps4GameModel.cpp +++ b/src/library/Shadps4GameModel.cpp @@ -24,9 +24,20 @@ QString localUrl(const QString& path) { } } // namespace -Shadps4GameModel::Shadps4GameModel(const QString& omakadeDatabasePath, QObject* parent) +Shadps4GameModel::Shadps4GameModel(const QString& omakadeDatabasePath, + PlaySessionStore* playSessions, QObject* parent) : QAbstractListModel(parent), - m_connectionName(QStringLiteral("omakade-shadps4-%1").arg(reinterpret_cast(this))) { + m_connectionName(QStringLiteral("omakade-shadps4-%1").arg(reinterpret_cast(this))), + m_playSessions(playSessions) { + if (m_playSessions != nullptr) { + connect(m_playSessions, &PlaySessionStore::totalsChanged, this, [this] { + if (!m_games.isEmpty()) { + emit dataChanged(index(0), index(static_cast(m_games.size()) - 1), + {GameRoles::Hours, GameRoles::PlaytimeSeconds, GameRoles::PlaytimeText, + GameRoles::PlaytimeProvenance, GameRoles::LastPlayed}); + } + }); + } connect(&m_scanWatcher, &QFutureWatcher::finished, this, [this] { m_scanning = false; applyScan(m_scanWatcher.result()); @@ -261,7 +272,13 @@ QVariant Shadps4GameModel::valueForRole(const Game& game, int role) const { return QStringLiteral("shadPS4"); case GameRoles::Description: return QStringLiteral("PlayStation 4 game launched through shadPS4."); + case GameRoles::PlaytimeProvenance: + return PlaySessionStore::provenance(m_playSessions, game.shadps4.path, -1); + case GameRoles::PlaytimeSeconds: + return PlaySessionStore::displayedSeconds(m_playSessions, game.shadps4.path, 0); case GameRoles::Hours: + return static_cast( + PlaySessionStore::displayedSeconds(m_playSessions, game.shadps4.path, 0) / 3600); case GameRoles::Progress: case GameRoles::AchievementsUnlocked: case GameRoles::AchievementsTotal: @@ -269,9 +286,9 @@ QVariant Shadps4GameModel::valueForRole(const Game& game, int role) const { case GameRoles::Favorite: return game.favorite; case GameRoles::Recent: - return false; + return PlaySessionStore::displayedLastPlayed(m_playSessions, game.shadps4.path, 0) > 0; case GameRoles::LastPlayed: - return 0; + return PlaySessionStore::displayedLastPlayed(m_playSessions, game.shadps4.path, 0); case GameRoles::AccentStart: return game.accentStart; case GameRoles::AccentEnd: diff --git a/src/library/Shadps4GameModel.h b/src/library/Shadps4GameModel.h index 07f48d2..013b537 100644 --- a/src/library/Shadps4GameModel.h +++ b/src/library/Shadps4GameModel.h @@ -1,6 +1,7 @@ #pragma once #include "sources/shadps4/Shadps4Scanner.h" +#include "tracking/PlaySessionStore.h" #include #include @@ -17,7 +18,8 @@ class Shadps4GameModel final : public QAbstractListModel { Q_PROPERTY(qint64 lastScan READ lastScan NOTIFY statusChanged) public: - explicit Shadps4GameModel(const QString& omakadeDatabasePath, QObject* parent = nullptr); + explicit Shadps4GameModel(const QString& omakadeDatabasePath, + PlaySessionStore* playSessions = nullptr, QObject* parent = nullptr); ~Shadps4GameModel() override; [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex()) const override; @@ -58,6 +60,7 @@ class Shadps4GameModel final : public QAbstractListModel { QVector m_games; QSqlDatabase m_database; QString m_connectionName; + PlaySessionStore* m_playSessions = nullptr; QFutureWatcher m_scanWatcher; bool m_scanning = false; bool m_shadps4Detected = false; diff --git a/src/library/SteamGameModel.cpp b/src/library/SteamGameModel.cpp index 91691e3..9afd485 100644 --- a/src/library/SteamGameModel.cpp +++ b/src/library/SteamGameModel.cpp @@ -1,4 +1,6 @@ #include "library/SteamGameModel.h" +#include "library/ArtworkPersistence.h" +#include "library/CoverCachePolicy.h" #include "app/AppSettings.h" #include "library/DatabaseTuning.h" @@ -46,20 +48,6 @@ QString coverCacheRoot() { QStringLiteral("/omakade/covers"); } -qint64 otherCoverCacheBytes() { - qint64 total = 0; - for (const QString& subdirectory : - {QStringLiteral("/battlenet"), QStringLiteral("/libretro"), QStringLiteral("/switch"), - QStringLiteral("/wiiu")}) { - QDirIterator iterator(coverCacheRoot() + subdirectory, QDir::Files, - QDirIterator::Subdirectories); - while (iterator.hasNext()) { - total += QFileInfo(iterator.next()).size(); - } - } - return total; -} - QString coverCachePath(const QString& appId) { return coverCacheRoot() + QLatin1Char('/') + appId + QStringLiteral(".jpg"); } @@ -288,6 +276,8 @@ QVariant SteamGameModel::valueForRole(const Game& game, int role) const { case GameRoles::Description: return game.installed ? QStringLiteral("Installed locally through Steam.") : QStringLiteral("Owned on Steam and ready to install."); + case GameRoles::PlaytimeSeconds: + return qint64(game.steam.playtimeMinutes) * 60; case GameRoles::Hours: return game.steam.playtimeMinutes / 60; case GameRoles::Progress: @@ -869,71 +859,20 @@ void SteamGameModel::applyCover(const QString& appId, const QString& path) { } void SteamGameModel::flushCoverWrites() { - if (m_pendingCoverWrites.isEmpty() || !m_database.isOpen()) { - m_pendingCoverWrites.clear(); - return; - } - const QHash pending = m_pendingCoverWrites; - m_pendingCoverWrites.clear(); - if (!m_database.transaction()) { - return; - } - QSqlQuery query(m_database); - query.prepare(QStringLiteral("UPDATE installations SET cover_path = ? WHERE app_id = ?")); - for (auto it = pending.cbegin(); it != pending.cend(); ++it) { - query.addBindValue(it.value()); - query.addBindValue(it.key()); - query.exec(); - } - m_database.commit(); + if (!ArtworkPersistence::flush( + m_database, QStringLiteral("UPDATE installations SET cover_path = ? WHERE app_id = ?"), + m_pendingCoverWrites)) + setStatus(m_statusText, QStringLiteral("Artwork cache changes could not be saved.")); } void SteamGameModel::pruneCoverCache() { const int limitMb = m_settings == nullptr ? 1024 : m_settings->artworkCacheLimitMb(); - const qint64 configuredLimit = static_cast(limitMb) * 1024 * 1024; - const qint64 limit = qMax(0, configuredLimit - otherCoverCacheBytes()); - struct CachedFile { - QString path; - QDateTime modified; - qint64 size = 0; - }; - QVector files; - qint64 total = 0; - QDirIterator iterator(coverCacheRoot(), QDir::Files); - while (iterator.hasNext()) { - const QFileInfo info(iterator.next()); - files.append({info.absoluteFilePath(), info.lastModified(), info.size()}); - total += info.size(); - } - if (total <= limit) { - return; - } - // Covers the library still shows go last, so leftovers from removed games are trimmed first. + const QString sharedRoot = + QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + + QStringLiteral("/omakade/covers"); QSet referenced; for (const Game& game : m_games) { referenced.insert(game.steam.coverPath); } - std::sort(files.begin(), files.end(), - [&referenced](const CachedFile& left, const CachedFile& right) { - const bool leftReferenced = referenced.contains(left.path); - const bool rightReferenced = referenced.contains(right.path); - if (leftReferenced != rightReferenced) { - return !leftReferenced; - } - return left.modified < right.modified; - }); - for (const CachedFile& file : files) { - if (total <= limit) { - break; - } - if (QFile::remove(file.path)) { - total -= file.size; - for (int row = 0; row < m_games.size(); ++row) { - if (m_games[row].steam.coverPath == file.path) { - m_games[row].steam.coverPath.clear(); - emit dataChanged(index(row), index(row), {GameRoles::CoverPath}); - } - } - } - } + CoverCachePolicy::prune(sharedRoot, coverCacheRoot(), qint64(limitMb) * 1024 * 1024, referenced); } diff --git a/src/library/UnifiedGameModel.cpp b/src/library/UnifiedGameModel.cpp index 8cd5944..9221c61 100644 --- a/src/library/UnifiedGameModel.cpp +++ b/src/library/UnifiedGameModel.cpp @@ -114,6 +114,14 @@ void UnifiedGameModel::addSourceModel(QAbstractItemModel* model) { } return; } + auto forwardedRoles = roles; + if (!roles.isEmpty() && + (roles.contains(GameRoles::Hours) || roles.contains(GameRoles::PlaytimeSeconds))) { + for (int role : + {GameRoles::Hours, GameRoles::PlaytimeSeconds, GameRoles::PlaytimeText}) + if (!forwardedRoles.contains(role)) + forwardedRoles.append(role); + } QSet changedGroups; for (int row = topLeft.row(); row <= bottomRight.row(); ++row) { const QString groupId = m_groupForGame.value(gameKey({.model = model, .row = row})); @@ -127,7 +135,7 @@ void UnifiedGameModel::addSourceModel(QAbstractItemModel* model) { source.row <= bottomRight.row(); if (direct || (!changedGroups.isEmpty() && changedGroups.contains(m_groupForGame.value(gameKey(source))))) { - emit dataChanged(index(row), index(row), roles); + emit dataChanged(index(row), index(row), forwardedRoles); } } }); @@ -157,6 +165,17 @@ QVariant UnifiedGameModel::data(const QModelIndex& index, int role) const { return {}; } if (role == GameRoles::MetadataKey) return gameKey(source); + if (role == GameRoles::Genres || role == GameRoles::Year) { + const auto metadata = m_metadata ? m_metadata->entry(gameKey(source)) : QVariantMap{}; + const bool confirmed = + !metadata.value("identityAmbiguous").toBool() && !metadata.value("rejected").toBool(); + if (role == GameRoles::Genres) + return confirmed ? metadata.value("genres", QStringList{}) : QVariant(QStringList{}); + if (confirmed && metadata.value("year").toInt() > 0) + return metadata.value("year"); + return source.model->data(source.model->index(source.row, 0), role); + } + if (role == GameRoles::Rating || role == GameRoles::RatingCount || role == GameRoles::Popularity) { const auto metadata = m_metadata ? m_metadata->entry(gameKey(source)) : QVariantMap{}; return metadata.value(role == GameRoles::Rating ? "rating" : role == GameRoles::Popularity ? "popularity" : "ratingCount", role == GameRoles::RatingCount ? 0 : -1); @@ -172,6 +191,8 @@ QVariant UnifiedGameModel::data(const QModelIndex& index, int role) const { case GameRoles::Recent: case GameRoles::LastPlayed: case GameRoles::Hours: + case GameRoles::PlaytimeSeconds: + case GameRoles::PlaytimeText: case GameRoles::Installed: case GameRoles::Pinned: break; @@ -281,12 +302,19 @@ QVariant UnifiedGameModel::data(const QModelIndex& index, int role) const { } return role == GameRoles::Recent ? lastPlayed > 0 : lastPlayed; } - if (role == GameRoles::Hours) { - int hours = 0; + if (role == GameRoles::Hours || role == GameRoles::PlaytimeSeconds || + role == GameRoles::PlaytimeText) { + qint64 seconds = 0; for (const SourceRow& member : members) { - hours = std::max(hours, member.model->index(member.row, 0).data(role).toInt()); + const auto index = member.model->index(member.row, 0); + const auto precise = index.data(GameRoles::PlaytimeSeconds); + seconds = + std::max(seconds, precise.isValid() ? precise.toLongLong() + : index.data(GameRoles::Hours).toLongLong() * 3600); } - return hours; + if (role == GameRoles::PlaytimeText) + return GameRoles::formatPlaytime(seconds); + return role == GameRoles::Hours ? seconds / 3600 : seconds; } if (role == GameRoles::Installed) { for (const SourceRow& member : members) { @@ -304,7 +332,10 @@ QVariant UnifiedGameModel::data(const QModelIndex& index, int role) const { QHash UnifiedGameModel::roleNames() const { QHash roles = m_models.isEmpty() ? QHash{} : m_models.constFirst()->roleNames(); + roles.insert(GameRoles::PlaytimeSeconds, "playtimeSeconds"); + roles.insert(GameRoles::PlaytimeText, "playtimeText"); roles.insert(GameRoles::MetadataKey, "metadataKey"); + roles.insert(GameRoles::Genres, "genres"); roles.insert(GameRoles::Rating, "rating"); roles.insert(GameRoles::RatingCount, "ratingCount"); roles.insert(GameRoles::Popularity, "popularity"); @@ -1434,9 +1465,40 @@ void UnifiedGameModel::loadCollections() { void UnifiedGameModel::setMetadata(GameMetadata* metadata) { if (m_metadata) disconnect(m_metadata, nullptr, this, nullptr); m_metadata = metadata; - if (metadata) connect(metadata, &GameMetadata::entryChanged, this, [this](const QString& key) { - for (int row = 0; row < m_rows.size(); ++row) if (gameKey(m_rows.at(row)) == key) - emit dataChanged(index(row), index(row), {GameRoles::CoverPath, GameRoles::Rating, GameRoles::RatingCount, GameRoles::Popularity}); + if (metadata) connect(metadata, &GameMetadata::entryChanged, this, + [this](const QString& key, const QVariantMap& previous) { + const auto current = m_metadata->entry(key); + QList roles; + if (previous.value("portrait") != current.value("portrait") || + (!current.value("portrait").toString().isEmpty() && + previous.value("portraitUpdated") != current.value("portraitUpdated"))) + roles.append(GameRoles::CoverPath); + for (const auto& field : {std::pair{"rating", GameRoles::Rating}, + std::pair{"ratingCount", GameRoles::RatingCount}, + std::pair{"popularity", GameRoles::Popularity}}) { + const int fallback = field.second == GameRoles::RatingCount ? 0 : -1; + if (previous.value(field.first, fallback) != current.value(field.first, fallback)) + roles.append(field.second); + } + const auto confirmed = [](const QVariantMap& entry) { + return !entry.value("identityAmbiguous").toBool() && !entry.value("rejected").toBool(); + }; + const auto genres = [&](const QVariantMap& entry) { + return confirmed(entry) ? entry.value("genres").toStringList() : QStringList{}; + }; + const auto year = [&](const QVariantMap& entry) { + return confirmed(entry) ? qMax(0, entry.value("year").toInt()) : 0; + }; + if (genres(previous) != genres(current)) roles.append(GameRoles::Genres); + if (year(previous) != year(current)) roles.append(GameRoles::Year); + // An empty dataChanged role list means every role. Details-only changes + // must not reload artwork, rebuild Home, or rescan the library filters. + if (roles.isEmpty()) return; + for (int row = 0; row < m_rows.size(); ++row) + if (gameKey(m_rows.at(row)) == key) emit dataChanged(index(row), index(row), roles); }); - if (!m_rows.isEmpty()) emit dataChanged(index(0), index(m_rows.size()-1), {GameRoles::CoverPath, GameRoles::Rating, GameRoles::RatingCount, GameRoles::Popularity}); + if (!m_rows.isEmpty()) + emit dataChanged(index(0), index(m_rows.size() - 1), + {GameRoles::CoverPath, GameRoles::Rating, GameRoles::RatingCount, + GameRoles::Popularity, GameRoles::Genres, GameRoles::Year}); } diff --git a/src/metadata/GameInsightsService.cpp b/src/metadata/GameInsightsService.cpp index 16cfc81..c3d7bbd 100644 --- a/src/metadata/GameInsightsService.cpp +++ b/src/metadata/GameInsightsService.cpp @@ -88,6 +88,15 @@ GameInsightsService::GameInsightsService(const QString& databasePath, AppSetting emit changed(); }); } + // A user refresh takes priority over the next background catalog request. Defer until + // the completed request has finished notifying its consumers. + connect(this, &GameInsightsService::changed, this, [this] { + if (m_busy || m_pendingRefreshAppId.isEmpty()) return; + const QString pending = m_pendingRefreshAppId; + m_pendingRefreshAppId.clear(); + if (pending == m_appId && configured()) refreshSteam(pending); + else emit changed(); + }, Qt::QueuedConnection); // Without a client ID there is nothing to look up, so skip the keyring at startup. if (!clientId().isEmpty()) { beginSecretOperation(SecretAction::Detect); @@ -109,6 +118,10 @@ QString GameInsightsService::clientId() const { bool GameInsightsService::hasClientSecret() const { return m_hasClientSecret; } bool GameInsightsService::configured() const { return !clientId().isEmpty() && m_hasClientSecret; } bool GameInsightsService::busy() const { return m_busy; } +bool GameInsightsService::refreshing() const { + return !m_appId.isEmpty() && (m_pendingRefreshAppId == m_appId || + (m_busy && m_catalogQuery.isEmpty() && m_refreshAppId == m_appId)); +} bool GameInsightsService::available() const { return m_insight.criticScore >= 0 || m_insight.rushedSeconds > 0 || m_insight.normalSeconds > 0 || m_insight.completeSeconds > 0; @@ -151,6 +164,7 @@ void GameInsightsService::removeCredentials() { beginSecretOperation(SecretActio void GameInsightsService::loadSteam(const QString& appId) { clearCurrent(); + if (appId != m_appId) m_pendingRefreshAppId.clear(); m_appId = appId; if (IgdbApi::steamMappingQuery(appId).isEmpty()) { m_statusText.clear(); @@ -172,9 +186,15 @@ void GameInsightsService::loadSteam(const QString& appId) { } void GameInsightsService::refreshSteam(const QString& appId) { - if (m_busy || !configured() || IgdbApi::steamMappingQuery(appId).isEmpty()) { + if (!configured() || IgdbApi::steamMappingQuery(appId).isEmpty()) return; + if (m_busy) { + if (appId == m_appId && !refreshing()) { + m_pendingRefreshAppId = appId; + emit changed(); + } return; } + m_pendingRefreshAppId.clear(); m_appId = appId; m_refreshAppId = appId; // Twitch app tokens last weeks. Reuse the one from this session instead of reading the @@ -196,6 +216,7 @@ void GameInsightsService::beginSecretOperation(SecretAction action, const QByteA if (action == SecretAction::Store || action == SecretAction::Remove) { m_accessToken.fill('\0'); m_accessToken.clear(); m_accessTokenExpiry = 0; } + if (action != SecretAction::Lookup) m_refreshAppId.clear(); m_secretAction = action; m_busy = true; emit changed(); @@ -355,7 +376,9 @@ void GameInsightsService::finishRequest(QNetworkReply* reply) { if (!QJsonDocument::fromJson(contents).isArray()) { fail(QStringLiteral("IGDB returned invalid data")); return; } m_catalogQuery.clear(); m_busy = false; - m_statusText = QStringLiteral("IGDB connection working"); + // Background catalog work must not replace the selected game's status. + if (m_testingConnection) m_statusText = QStringLiteral("IGDB connection working"); + m_testingConnection = false; emit changed(); emit catalogFinished(contents, {}); } else if (kind == RequestKind::Mapping) { @@ -404,7 +427,8 @@ void GameInsightsService::fail(const QString& message) { const bool catalog = !m_catalogQuery.isEmpty(); m_catalogQuery.clear(); m_busy = false; - m_statusText = message; + if (!catalog || m_testingConnection) m_statusText = message; + m_testingConnection = false; emit changed(); if (catalog) emit catalogFinished({}, message); } @@ -464,7 +488,7 @@ bool GameInsightsService::persist() { bool GameInsightsService::requestCatalog(const QByteArray& query, const QString& endpoint) { if (endpoint != "games" && endpoint != "external_games" && endpoint != "popularity_primitives") return false; - if (m_busy || !configured() || query.isEmpty()) return false; + if (m_busy || !m_pendingRefreshAppId.isEmpty() || !configured() || query.isEmpty()) return false; m_catalogQuery = query; m_catalogEndpoint = endpoint; if (!m_accessToken.isEmpty() && QDateTime::currentSecsSinceEpoch() < m_accessTokenExpiry - 60) { @@ -502,5 +526,7 @@ void GameInsightsService::saveCredentials(const QString& id, QString secret) { secret.fill(QChar::Null); } void GameInsightsService::testConnection() { - requestCatalog("fields id; limit 1;"); + if (m_busy || !m_pendingRefreshAppId.isEmpty()) return; + m_testingConnection = true; + if (!requestCatalog("fields id; limit 1;")) m_testingConnection = false; } diff --git a/src/metadata/GameInsightsService.h b/src/metadata/GameInsightsService.h index 092b436..39e4b47 100644 --- a/src/metadata/GameInsightsService.h +++ b/src/metadata/GameInsightsService.h @@ -24,6 +24,7 @@ class GameInsightsService final : public QObject { Q_PROPERTY(bool hasClientSecret READ hasClientSecret NOTIFY changed) Q_PROPERTY(bool configured READ configured NOTIFY changed) Q_PROPERTY(bool busy READ busy NOTIFY changed) + Q_PROPERTY(bool refreshing READ refreshing NOTIFY changed) Q_PROPERTY(bool available READ available NOTIFY changed) Q_PROPERTY(QString statusText READ statusText NOTIFY changed) Q_PROPERTY(int criticScore READ criticScore NOTIFY changed) @@ -42,6 +43,7 @@ class GameInsightsService final : public QObject { [[nodiscard]] bool hasClientSecret() const; [[nodiscard]] bool configured() const; [[nodiscard]] bool busy() const; + [[nodiscard]] bool refreshing() const; [[nodiscard]] bool available() const; [[nodiscard]] QString statusText() const; [[nodiscard]] int criticScore() const; @@ -66,6 +68,7 @@ class GameInsightsService final : public QObject { void changed(); private: + friend class CoreTests; enum class SecretAction { Detect, Store, Remove, Lookup }; enum class RequestKind { Token, Mapping, Game, Time, Catalog }; @@ -92,6 +95,7 @@ class GameInsightsService final : public QObject { QHash m_buffers; QString m_appId; QString m_refreshAppId; + QString m_pendingRefreshAppId; QString m_catalogEndpoint; QByteArray m_catalogQuery; QByteArray m_accessToken; @@ -100,5 +104,6 @@ class GameInsightsService final : public QObject { qint64 m_updatedAt = 0; bool m_hasClientSecret = false; bool m_busy = false; + bool m_testingConnection = false; QString m_statusText; }; diff --git a/src/metadata/GameMetadata.cpp b/src/metadata/GameMetadata.cpp index 7d69fac..c0b3d50 100644 --- a/src/metadata/GameMetadata.cpp +++ b/src/metadata/GameMetadata.cpp @@ -1,11 +1,10 @@ -#include "app/SecretService.h" -#include #include "metadata/GameMetadata.h" +#include "app/SecretService.h" #include "library/ConsoleCatalog.h" -#include -#include +#include "library/CoverCachePolicy.h" #include "library/GameRoles.h" #include "library/UnifiedGameModel.h" +#include "metadata/RegionalMetadata.h" #include #include #include @@ -15,14 +14,19 @@ #include #include #include +#include +#include #include #include #include +#include #include +#include #include #include #include #include +#include #include #pragma push_macro("signals") #undef signals @@ -30,14 +34,24 @@ #pragma pop_macro("signals") namespace { -constexpr auto fields = "fields " - "name,platforms,first_release_date,total_rating,total_rating_count," - "aggregated_rating,aggregated_rating_count; "; +constexpr auto fields = + "fields " + "name,platforms,first_release_date,total_rating,total_rating_count," + "release_dates.date,release_dates.human,release_dates.y,release_dates.platform," + "release_dates.release_region.region," + "aggregated_rating,aggregated_rating_count,genres.name,summary," + "involved_companies.company.name,involved_companies.developer," + "involved_companies.publisher,screenshots.image_id,screenshots.width,screenshots.height,screenshots.animated," + "alternative_names.name,alternative_names.comment," + "game_localizations.name,game_localizations.region.name," + "game_localizations.region.identifier,version_parent,version_title; "; + // IGDB allows four requests a second; 350 ms keeps a comfortable margin. SteamGridDB is not // documented as precisely, so its calls are held a little further apart. With both providers // paced at the request, the gap between games only has to yield to the event loop. constexpr int kGridRequestGapMs = 250; constexpr int kBetweenGamesMs = 100; +constexpr int kPayloadVersion = 6; QString quoted(QString text) { text.replace('\\', "\\\\"); @@ -150,8 +164,8 @@ bool GameMetadata::wantsPortraitCover(const QString& system, const QString& sour // system it came from. A physical box that was printed portrait, an NES box or a GameTDB // cover, already works as a cover and is authentic, so it is kept. A box that was printed // wide or square, an N64 carton or a Dreamcast case, cannot fill a card without being cropped - // or letterboxed, and a portrait reads better there even when it is fan made. Artwork that - // arrives later is reconsidered, since dropUnwantedPortraits applies this same rule. + // or letterboxed, and a portrait reads better there even when it is fan made. This rule + // only decides whether a new portrait download is needed. QString path = sourceCover; if (path.startsWith(QStringLiteral("file://"))) path = QUrl(path).toLocalFile(); @@ -245,6 +259,46 @@ QList GameMetadata::platformIds(const QString& system) { return ids.value(id); return system.isEmpty() ? QList{6} : QList{}; } + +QStringList GameMetadata::platformNames(const QVariantList& ids) { + static const QHash names{ + {3, "Linux"}, + {4, "Nintendo 64"}, + {5, "Wii"}, + {6, "PC"}, + {7, "PlayStation"}, + {8, "PlayStation 2"}, + {9, "PlayStation 3"}, + {11, "Xbox"}, + {12, "Xbox 360"}, + {14, "Mac"}, + {18, "NES"}, + {19, "Super Nintendo"}, + {20, "Nintendo DS"}, + {21, "GameCube"}, + {22, "Game Boy Color"}, + {23, "Dreamcast"}, + {24, "Game Boy Advance"}, + {29, "Sega Genesis"}, + {33, "Game Boy"}, + {37, "Nintendo 3DS"}, + {38, "PSP"}, + {41, "Wii U"}, + {46, "PS Vita"}, + {48, "PlayStation 4"}, + {49, "Xbox One"}, + {130, "Switch"}, + {167, "PlayStation 5"}, + {169, "Xbox Series X|S"}, + }; + QStringList result; + for (const auto& id : ids) { + const QString name = names.value(id.toLongLong()); + if (!name.isEmpty() && !result.contains(name)) + result.append(name); + } + return result; +} QByteArray GameMetadata::searchQuery(const QString& title, const QString& system) { const QList platforms = platformIds(system); if (title.trimmed().isEmpty() || platforms.isEmpty()) @@ -255,6 +309,18 @@ QByteArray GameMetadata::searchQuery(const QString& title, const QString& system return QByteArray(fields) + "search " + quoted(cleanTitle(title)).toUtf8() + "; where platforms = (" + numbers.join(',') + "); limit 20;"; } +QByteArray GameMetadata::aliasSearchQuery(const QString& title, const QString& system) { + const auto platforms = platformIds(system); + if (platforms.isEmpty() || cleanTitle(title).isEmpty()) + return {}; + QList numbers; + for (int platform : platforms) + numbers.append(QByteArray::number(platform)); + const auto name = quoted(cleanTitle(title)).toUtf8(); + return QByteArray(fields) + "where platforms = (" + numbers.join(',') + ") & (name ~ " + name + + " | alternative_names.name ~ " + name + " | game_localizations.name ~ " + name + + "); limit 20;"; +} QVariantList GameMetadata::parseMatches(const QByteArray& data, const QList& platforms) { QVariantList result; const auto doc = QJsonDocument::fromJson(data); @@ -273,8 +339,67 @@ QVariantList GameMetadata::parseMatches(const QByteArray& data, const QList continue; } QVariantMap match{{"id", obj.value("id").toInteger()}, {"title", obj.value("name").toString()}}; + QStringList aliases; + QVariantList aliasEvidence, localizations; + for (const auto& item : obj.value("alternative_names").toArray()) { + const auto alias = item.toObject(); + const QString name = alias.value("name").toString().simplified(); + if (name.isEmpty()) + continue; + aliases.append(name); + aliasEvidence.append( + QVariantMap{{"name", name}, {"comment", alias.value("comment").toString()}}); + } + for (const auto& item : obj.value("game_localizations").toArray()) { + const auto localization = item.toObject(); + const auto region = localization.value("region").toObject(); + const QString name = localization.value("name").toString().simplified(); + if (name.isEmpty()) + continue; + aliases.append(name); + localizations.append( + QVariantMap{{"name", name}, + {"region", region.value("name").toString()}, + {"regionIdentifier", region.value("identifier").toString()}}); + } + aliases.removeDuplicates(); + match["aliases"] = aliases; + match["alternativeNames"] = aliasEvidence; + match["localizations"] = localizations; + match["versionParent"] = obj.value("version_parent").toInteger(); + match["edition"] = obj.value("version_title").toString(); + QVariantList releases; + for (const auto& row : obj.value("release_dates").toArray()) { + const auto release = row.toObject(); + releases.append(QVariantMap{ + {"date", release.value("date").toInteger()}, + {"human", release.value("human").toString()}, + {"year", release.value("y").toInt()}, + {"platform", release.value("platform").toInt()}, + {"region", release.value("release_region").toObject().value("region").toString()}}); + } + match["releaseDates"] = releases; + QStringList releaseRegions; + for (const auto& row : releases) { + const auto release = row.toMap(); + if (platforms.contains(release.value("platform").toInt()) && + !release.value("region").toString().isEmpty()) { + QString region = release.value("region").toString(); + region.replace('_', ' '); + releaseRegions.append(region); + } + } + releaseRegions.removeDuplicates(); + match["releaseRegions"] = releaseRegions; const qint64 released = obj.value("first_release_date").toInteger(); - match["year"] = released > 0 ? QDateTime::fromSecsSinceEpoch(released).date().year() : 0; + match["year"] = + released > 0 ? QDateTime::fromSecsSinceEpoch(released, QTimeZone::UTC).date().year() : 0; + if (released > 0) { + match["releaseText"] = + QLocale(QLocale::English) + .toString(QDateTime::fromSecsSinceEpoch(released, QTimeZone::UTC).date(), + "MMMM d, yyyy"); + } const auto rating = obj.value("total_rating"); const int count = obj.value("total_rating_count").toInt(); match["rating"] = @@ -282,6 +407,69 @@ QVariantList GameMetadata::parseMatches(const QByteArray& data, const QList ? qRound(rating.toDouble()) : -1; match["ratingCount"] = qMax(0, count); + QStringList genres; + for (const auto& genre : obj.value("genres").toArray()) + if (!genre.toObject().value("name").toString().isEmpty()) + genres.append(genre.toObject().value("name").toString()); + if (!genres.isEmpty()) + match["genres"] = genres; + const QString summary = obj.value("summary").toString().simplified(); + if (!summary.isEmpty()) + match["summary"] = summary.left(1500); + QStringList developers, publishers; + QVariantList platformIds; + for (const auto& platformId : obj.value("platforms").toArray()) + platformIds.append(platformId.toInteger()); + for (const auto& involved : obj.value("involved_companies").toArray()) { + const auto company = involved.toObject(); + const QString name = company.value("company").toObject().value("name").toString(); + if (name.isEmpty()) + continue; + if (company.value("developer").toBool() && !developers.contains(name)) + developers.append(name); + if (company.value("publisher").toBool() && !publishers.contains(name)) + publishers.append(name); + } + if (!developers.isEmpty()) + match["developers"] = developers; + if (!publishers.isEmpty()) + match["publishers"] = publishers; + if (!platformIds.isEmpty()) + match["platformIds"] = platformIds; + // Provider image IDs are identifiers, never arbitrary URLs or paths. + static const QRegularExpression imageId(QStringLiteral("^[A-Za-z0-9_]+$")); + // Artwork includes advertisements, box scans, and unrelated promotional illustrations. + // Only use landscape screenshots for an automatic backdrop. Keep ordering deterministic + // when the provider returns the same candidates in a different order. + QString bestId; + qint64 bestArea = 0; + int bestWidth = 0, bestHeight = 0; + for (const auto& candidate : obj.value("screenshots").toArray()) { + const auto image = candidate.toObject(); + const QString id = image.value("image_id").toString(); + const int width = image.value("width").toInt(); + const int height = image.value("height").toInt(); + if (!imageId.match(id).hasMatch() || image.value("animated").toBool() || + width < 160 || height < 144 || width > 32768 || height > 32768) + continue; + const double aspect = double(width) / height; + if (aspect < 1.0 || aspect > 2.4) + continue; + // Resolution is capped at the downloaded size, rather than preferring huge originals. + const qint64 area = qint64(std::min(width, 1920)) * std::min(height, 1080); + if (area > bestArea || (area == bestArea && (bestId.isEmpty() || id < bestId))) { + bestId = id; + bestArea = area; + bestWidth = width; + bestHeight = height; + } + } + if (!bestId.isEmpty()) { + match["heroUrl"] = QStringLiteral("https://images.igdb.com/igdb/image/upload/t_1080p/%1.jpg").arg(bestId); + match["heroKind"] = "screenshot"; + match["heroWidth"] = bestWidth; + match["heroHeight"] = bestHeight; + } result.append(match); } return result; @@ -295,17 +483,30 @@ QVariantList GameMetadata::parseCovers(const QByteArray& data) { const auto object = QJsonDocument::fromJson(data).object(); if (!object.value("success").toBool()) return result; + QSet ids; + QSet urls; for (const auto& value : object.value("data").toArray()) { auto cover = value.toObject(); if (cover.value("id").toInteger() <= 0 || cover.value("width").toInt() != 600 || cover.value("height").toInt() != 900 || cover.value("nsfw").toBool() || cover.value("humor").toBool() || !trustedImageUrl(QUrl(cover.value("url").toString()))) continue; + const auto id = cover.value("id").toInteger(); + const auto url = cover.value("url").toString(); + if (ids.contains(id) || urls.contains(url)) continue; + ids.insert(id); + urls.insert(url); result.append( QVariantMap{{"id", cover.value("id").toInteger()}, {"url", cover.value("url").toString()}, + {"score", cover.value("score").toDouble()}, {"author", cover.value("author").toObject().value("name").toString()}}); } + // Preserve provider order for ties and missing scores. Score is community preference, + // not evidence that an image is official or belongs to a particular edition. + std::stable_sort(result.begin(), result.end(), [](const QVariant& a, const QVariant& b) { + return a.toMap().value("score").toDouble() > b.toMap().value("score").toDouble(); + }); return result; } GameMetadata::GameMetadata(const QString& databasePath, GameInsightsService* insights, @@ -340,8 +541,10 @@ GameMetadata::GameMetadata(const QString& databasePath, GameInsightsService* ins // before they arrive. Without this the pass looked once, found no connection, and never // looked again, leaving the whole library unidentified until something else changed. connect(insights, &GameInsightsService::changed, this, [this] { - if (!m_stoppedByHand && !m_editing) + if (!m_stoppedByHand && !m_editing) { + queueSelected(); m_settle.start(); + } }); } if (QFileInfo::exists(m_cacheRoot + "/configured")) @@ -358,16 +561,9 @@ void GameMetadata::setLibrary(UnifiedGameModel* library) { m_library = library; if (m_library == nullptr) return; - // Sources populate the library over the first few seconds, so wait for rows to arrive before - // judging what artwork a game has. The review runs once and is cheap: only entries that - // actually hold a portrait are examined. const auto settled = [this] { if (m_library == nullptr || m_library->rowCount() == 0) return; - if (!m_reviewedPortraits) { - m_reviewedPortraits = true; - dropUnwantedPortraits(); - } // Sources arrive over several seconds. Wait for a quiet moment before queuing, so a // library still loading is not walked once per source. m_settle.start(); @@ -500,53 +696,35 @@ void GameMetadata::promoteVisibleGames() { m_queue.clear(); for (const QVariantMap& game : ordered) m_queue.enqueue(game); -} - -void GameMetadata::dropUnwantedPortraits() { - if (m_library == nullptr) - return; - int dropped = 0; - for (int row = 0; row < m_library->rowCount(); ++row) { - const QModelIndex game = m_library->index(row); - const QString id = game.data(GameRoles::MetadataKey).toString(); - if (id.isEmpty()) - continue; - auto value = entry(id); - if (!value.contains("portrait")) - continue; - if (wantsPortraitCover(game.data(GameRoles::System).toString(), - game.data(GameRoles::Source).toString(), - game.data(GameRoles::SourceCoverPath).toString())) - continue; - // A portrait the user chose is stored as a custom cover, which outranks this and stays. - value.remove("portrait"); - value.remove("gridCoverId"); - persist(id, value); - ++dropped; - } - if (dropped > 0) { - m_status = QStringLiteral("Restored artwork on %1 %2") - .arg(dropped) - .arg(dropped == 1 ? "game" : "games"); - emit changed(); + const QString selected = m_selected.value("metadataKey").toString(); + for (qsizetype i = 0; i < m_queue.size(); ++i) { + if (m_queue.at(i).value("metadataKey").toString() == selected) { + m_queue.move(i, 0); + break; + } } } -void GameMetadata::persist(const QString& id, const QVariantMap& value) { + +bool GameMetadata::persist(const QString& id, const QVariantMap& value) { if (id.isEmpty()) - return; + return false; QSqlQuery query(m_database); query.prepare("INSERT OR REPLACE INTO game_metadata(game_key,payload) VALUES(?,?)"); query.addBindValue(id); query.addBindValue( QJsonDocument(QJsonObject::fromVariantMap(value)).toJson(QJsonDocument::Compact)); if (!query.exec()) { - m_status = "Could not save game metadata"; - emit changed(); - return; + m_pendingWrites.insert(id, value); + m_queue.clear(); + finish("Could not save game metadata. Retry when storage is available."); + return false; } + m_pendingWrites.remove(id); + const auto previous = m_entries.value(id); m_entries.insert(id, value); - emit entryChanged(id); + emit entryChanged(id, previous); emit changed(); + return true; } void GameMetadata::inspect(const QVariantMap& game) { m_selected = game; @@ -564,8 +742,78 @@ void GameMetadata::inspect(const QVariantMap& game) { break; } } + queueSelected(); emit changed(); } + +QVariantMap GameMetadata::current() const { + auto value = entry(m_selected.value("metadataKey").toString()); + QString filename; + if (!m_selected.value("system").toString().isEmpty()) { + filename = m_selected.value("installPath").toString(); + if (filename.isEmpty()) + filename = m_selected.value("title").toString(); + } + return RegionalMetadata::details(value, filename, + platformIds(m_selected.value("system").toString())); +} + +bool GameMetadata::selectedBusy() const { + const QString selected = m_selected.value("metadataKey").toString(); + if (selected.isEmpty()) return false; + if (m_busy && key() == selected) return true; + for (const auto& game : m_queue) + if (game.value("metadataKey").toString() == selected) return true; + return false; +} + +QString GameMetadata::selectedStatus() const { + if (m_selected.isEmpty()) return {}; + if (m_pendingWrites.contains(m_selected.value("metadataKey").toString())) + return QStringLiteral("Game metadata could not be saved. Retry when storage is available."); + if (selectedBusy()) return QStringLiteral("Loading game details…"); + const QString selected = m_selected.value("metadataKey").toString(); + if (m_detailErrors.contains(selected)) return QStringLiteral("Couldn't refresh game details. Try again."); + if (current().value("identityAmbiguous").toBool()) + return QStringLiteral("Multiple editions match. Identify this game to confirm its details."); + if (current().value("v").toInt() >= kPayloadVersion) return {}; + if (!m_insights || !m_insights->configured()) return QStringLiteral("Connect IGDB in Settings to load game details."); + if (current().value("igdbId").toLongLong() <= 0) return QStringLiteral("Identify this game to find its details."); + return QStringLiteral("Game details are waiting to refresh."); +} + +void GameMetadata::queueSelected(bool force) { + const QString selected = m_selected.value("metadataKey").toString(); + if (selected.isEmpty() || m_editing || m_stoppedByHand || selectedBusy()) return; + if (!m_insights || !m_insights->configured()) return; + if (!force && m_detailAttempts.value(selected, 0) > QDateTime::currentSecsSinceEpoch() - 60) return; + QVariantMap game = m_selected; + if (force) game["refreshDetails"] = true; + const auto before = m_queue.size(); + enqueue(game); + if (m_queue.size() == before) return; + m_detailAttempts[selected] = QDateTime::currentSecsSinceEpoch(); + m_detailErrors.remove(selected); + m_queue.move(m_queue.size() - 1, 0); + next(); +} + +void GameMetadata::refreshSelected() { + const QString selected = m_selected.value("metadataKey").toString(); + if (m_pendingWrites.contains(selected)) { + if (busy()) + return; + const auto pending = m_pendingWrites.value(selected); + if (persist(selected, pending)) + finish("Game metadata saved"); + return; + } + m_stoppedByHand = false; + m_cancelled = false; + queueSelected(true); + emit changed(); +} + QByteArray GameMetadata::matchingRulesFingerprint() { // Representative of every rule: dump tags, sorted articles, accents, brand prefixes, // catalogue numbers, editions that must survive, and the platforms each system searches. @@ -615,11 +863,19 @@ bool GameMetadata::needsCoverAttempt(const QVariantMap& saved, qint64 now) { void GameMetadata::enqueue(const QVariantMap& game) { if (game.value("isPortal").toBool() || game.value("metadataKey").toString().isEmpty()) return; + if (m_pendingWrites.contains(game.value("metadataKey").toString())) + return; const auto saved = entry(game.value("metadataKey").toString()); if (saved.value("rejected").toBool()) return; const qint64 now = QDateTime::currentSecsSinceEpoch(); - const bool ratings = m_insights && m_insights->configured() && needsIdentifying(saved, now); + // Entries saved before the richer IGDB payload carry no version; refresh them + // once so release, credits, genres, and summary arrive without waiting for the + // regular freshness cycle. + const bool enrich = + saved.value("igdbId").toLongLong() > 0 && saved.value("v").toInt() < kPayloadVersion; + const bool ratings = m_insights && m_insights->configured() && + (game.value("refreshDetails").toBool() || enrich || needsIdentifying(saved, now)); const bool portrait = hasGridKey() && wantsPortraitCover(game.value("system").toString(), game.value("source").toString(), @@ -671,9 +927,14 @@ void GameMetadata::next() { return; } m_active = m_queue.dequeue(); + if (key() == m_selected.value("metadataKey").toString() && m_insights && m_insights->configured()) + m_detailAttempts[key()] = QDateTime::currentSecsSinceEpoch(); m_manual = false; m_numberedRetryTitle.clear(); + m_aliasRetried = false; m_brandRetryTitle.clear(); + m_artworkTitles.clear(); + m_artworkTitleIndex = 0; m_manualSearchTitle.clear(); m_pendingGridId = 0; m_busy = true; @@ -683,11 +944,20 @@ void GameMetadata::next() { // again. Judging freshness by the timestamp alone here meant every game queued because the // rules had changed was dequeued, sent straight to artwork, and never re-identified, so its // recorded rule version never moved and the whole library stayed on old answers forever. - if (!needsIdentifying(saved, QDateTime::currentSecsSinceEpoch())) { + // Games identified before the richer payload arrived also come back once so the new + // fields land without waiting out the regular cycle. + const bool enriched = + saved.value("igdbId").toLongLong() <= 0 || saved.value("v").toInt() >= kPayloadVersion; + if (!m_active.value("refreshDetails").toBool() && + !needsIdentifying(saved, QDateTime::currentSecsSinceEpoch()) && enriched) { gridSearch(); return; } - if (saved.value("igdbId").toLongLong() > 0 && m_insights && m_insights->configured()) { + if (saved.value("igdbId").toLongLong() > 0 && + (saved.value("manualMatch").toBool() || + (!saved.value("identityAmbiguous").toBool() && + saved.value("matchVersion").toInt() >= kMatchVersion)) && + m_insights && m_insights->configured()) { const QByteArray query = QByteArray(fields) + "where id = " + QByteArray::number(saved.value("igdbId").toLongLong()) + "; limit 1;"; requestIgdb(query, "games", "games"); @@ -723,7 +993,10 @@ void GameMetadata::search(const QString& title) { m_active = m_selected; m_manual = true; m_numberedRetryTitle.clear(); + m_aliasRetried = false; m_brandRetryTitle.clear(); + m_artworkTitles.clear(); + m_artworkTitleIndex = 0; m_manualSearchTitle.clear(); m_pendingGridId = 0; m_candidateProvider = "igdb"; @@ -762,13 +1035,15 @@ void GameMetadata::matchResult(const QByteArray& data, const QString& error) { break; } } - persist(key(), value); + if (!persist(key(), value)) + return; } m_igdbStage.clear(); gridSearch(); return; } if (!error.isEmpty()) { + m_detailErrors[key()] = error; m_queue.clear(); finish(error); return; @@ -784,6 +1059,7 @@ void GameMetadata::matchResult(const QByteArray& data, const QString& error) { return; } if (!QJsonDocument::fromJson(data).isArray()) { + m_detailErrors[key()] = "Invalid provider response"; m_queue.clear(); finish("IGDB returned invalid data. Cached metadata is unchanged."); return; @@ -817,30 +1093,48 @@ void GameMetadata::matchResult(const QByteArray& data, const QString& error) { exact.append(match); continue; } - if (m_igdbStage == "mappedGame" || - saved.value("igdbId").toLongLong() == match.toMap().value("id").toLongLong() || + const QString local = normalizedTitle( + m_numberedRetryTitle.isEmpty() ? m_active.value("title").toString() : m_numberedRetryTitle); + bool aliasMatches = false; + for (const auto& alias : match.toMap().value("aliases").toStringList()) + aliasMatches = aliasMatches || normalizedTitle(alias) == local; + if (aliasMatches || m_igdbStage == "mappedGame" || + (!saved.value("identityAmbiguous").toBool() && + saved.value("matchVersion").toInt() >= kMatchVersion && + saved.value("igdbId").toLongLong() == match.toMap().value("id").toLongLong()) || sameGame(normalizedTitle(match.toMap().value("title").toString()), - normalizedTitle(m_numberedRetryTitle.isEmpty() - ? m_active.value("title").toString() - : m_numberedRetryTitle))) + normalizedTitle(m_numberedRetryTitle.isEmpty() ? m_active.value("title").toString() + : m_numberedRetryTitle))) exact.append(match); } - if (exact.size() == 1) - acceptMatch(exact.first().toMap()); - else if (exact.size() > 1) { - // Several catalogue entries carry the same name on the same platform: usually a regional - // duplicate or a compilation beside the game. The entry people actually rated is the one - // to keep, so pick the most rated and fall back to the lowest id for a stable answer. - QVariantMap best; - for (const auto& candidate : exact) { - const auto map = candidate.toMap(); - if (best.isEmpty() || - map.value("ratingCount").toInt() > best.value("ratingCount").toInt() || - (map.value("ratingCount").toInt() == best.value("ratingCount").toInt() && - map.value("id").toLongLong() < best.value("id").toLongLong())) - best = map; + const bool truncated = QJsonDocument::fromJson(data).array().size() >= 20 && !userChose; + if (!userChose && !m_aliasRetried && (truncated || exact.size() > 1)) { + // Broad search pages can be filled by sequels, hacks, or punctuation lookalikes. + // Ask the catalogue for the exact title/aliases before declaring an edition conflict. + const auto query = aliasSearchQuery( + m_numberedRetryTitle.isEmpty() ? m_active.value("title").toString() : m_numberedRetryTitle, + m_active.value("system").toString()); + if (!query.isEmpty()) { + m_aliasRetried = true; + requestIgdb(query, "games", "aliases"); + return; } - acceptMatch(best); + } + if (exact.size() == 1 && !truncated) + acceptMatch(exact.first().toMap()); + else if (exact.size() > 1 || truncated) { + // Popularity cannot distinguish regional releases, compilations, or editions. + // Preserve the last payload and artwork until the user resolves the identity. + auto value = saved; + value["matchStatus"] = "Needs identification: multiple matching editions"; + value["identityAmbiguous"] = true; + value["updated"] = QDateTime::currentSecsSinceEpoch(); + value["matchVersion"] = kMatchVersion; + if (!persist(key(), value)) + return; + m_candidates = exact; + m_candidateProvider = "igdb"; + finish("Multiple editions match. Identify this game to choose the correct one."); } else { // Some ROM sets number their files, as "1636 - Pokemon Fire Red". Searching for the number // finds nothing. Trying again without it only after the title as written has failed means a @@ -858,11 +1152,23 @@ void GameMetadata::matchResult(const QByteArray& data, const QString& error) { return; } } + if (!userChose && !m_aliasRetried && m_insights && m_insights->configured()) { + const auto query = + aliasSearchQuery(m_numberedRetryTitle.isEmpty() ? m_active.value("title").toString() + : m_numberedRetryTitle, + m_active.value("system").toString()); + if (!query.isEmpty()) { + m_aliasRetried = true; + requestIgdb(query, "games", "aliases"); + return; + } + } auto value = saved; value["matchStatus"] = "Needs identification"; value["updated"] = QDateTime::currentSecsSinceEpoch(); value["matchVersion"] = kMatchVersion; - persist(key(), value); + if (!persist(key(), value)) + return; gridSearch(); } } @@ -877,24 +1183,55 @@ void GameMetadata::chooseMatch(int index) { acceptMatch(m_candidates.at(index).toMap()); } void GameMetadata::acceptMatch(const QVariantMap& match) { + m_detailErrors.remove(key()); auto value = entry(key()); - if (value.value("igdbId") != match.value("id")) - value = {}; + if (value.value("igdbId") != match.value("id")) { + QVariantMap artwork; + for (const auto& field : {"portrait", "gridCoverId", "portraitUpdated"}) + if (value.contains(field)) + artwork.insert(field, value.value(field)); + value = artwork; + } value["igdbId"] = match.value("id"); value["title"] = match.value("title"); value["year"] = match.value("year"); value["rating"] = match.value("rating"); value["ratingCount"] = match.value("ratingCount"); + // A successful provider response replaces its own fields, including removals. + // User identity/artwork choices elsewhere in the payload remain untouched. + for (const char* field : {"releaseText", "summary", "genres", "developers", "publishers", + "platformIds", "heroUrl", "heroKind", "heroWidth", "heroHeight", "aliases", "alternativeNames", + "localizations", "versionParent", "edition", "releaseDates"}) { + value.remove(QLatin1String(field)); + if (match.contains(QLatin1String(field))) + value[QLatin1String(field)] = match.value(QLatin1String(field)); + } + value["platform"] = m_active.value("system"); + const QString platform = value.value("platform").toString(); + const QString platformText = + platform.isEmpty() + ? platformNames(value.value("platformIds").toList()).join(QStringLiteral(", ")) + : ConsoleCatalog::displayNameFor(platform); + value.remove("platformText"); + if (!platformText.isEmpty()) + value["platformText"] = platformText; + value.remove("identityAmbiguous"); value["matchStatus"] = "Matched to IGDB"; value["rejected"] = false; value["updated"] = QDateTime::currentSecsSinceEpoch(); + value["v"] = kPayloadVersion; value["ratingProvider"] = "igdb"; value["ratingField"] = "total_rating"; - value["platform"] = m_active.value("system"); value["localTitle"] = m_active.value("title"); + if (!m_active.value("system").toString().isEmpty()) { + const QString path = m_active.value("installPath").toString(); + if (!path.isEmpty()) + value["romFilename"] = QFileInfo(path).fileName(); + } value["manualMatch"] = m_manual || value.value("manualMatch").toBool(); value["matchVersion"] = kMatchVersion; - persist(key(), value); + if (!persist(key(), value)) + return; m_candidates.clear(); requestIgdb("fields game_id,value; where game_id = " + QByteArray::number(value.value("igdbId").toLongLong()) + @@ -904,8 +1241,9 @@ void GameMetadata::acceptMatch(const QVariantMap& match) { void GameMetadata::rejectMatch() { if (busy() || m_selected.isEmpty()) return; - persist(m_selected.value("metadataKey").toString(), - {{"rejected", true}, {"matchStatus", "Automatic matching disabled"}}); + if (!persist(m_selected.value("metadataKey").toString(), + {{"rejected", true}, {"matchStatus", "Automatic matching disabled"}})) + return; m_candidates.clear(); m_covers.clear(); m_status = "Match removed. Search to identify this game again."; @@ -922,7 +1260,10 @@ void GameMetadata::beginCoverSearch(const QString& typedTitle) { m_active = m_selected; m_manual = true; m_numberedRetryTitle.clear(); + m_aliasRetried = false; m_brandRetryTitle.clear(); + m_artworkTitles.clear(); + m_artworkTitleIndex = 0; m_manualSearchTitle = typedTitle; m_pendingGridId = 0; m_busy = true; @@ -946,7 +1287,8 @@ void GameMetadata::clearGridSelection() { // someone clearing a wrong cover is asking for. value.remove("coverAttempt"); value.remove("coverRules"); - persist(id, value); + if (!persist(id, value)) + return; m_pendingGridId = 0; m_candidates.clear(); m_covers.clear(); @@ -954,25 +1296,54 @@ void GameMetadata::clearGridSelection() { : "This game has no downloaded cover to clear."); } +QStringList GameMetadata::artworkSearchTitles(const QVariantMap& entry) { + QStringList result; + QSet seen; + auto append = [&](const QString& title) { + const QString normalized = normalizedTitle(title); + if (!normalized.isEmpty() && !seen.contains(normalized)) { + seen.insert(normalized); + result.append(title); + } + }; + append(entry.value("title").toString()); + for (const auto& item : entry.value("alternativeNames").toList()) { + const auto alias = item.toMap(); + const QString comment = alias.value("comment").toString().toLower(); + if (comment.contains("abbreviat") || comment.contains("acronym")) continue; + append(alias.value("name").toString()); + if (result.size() >= 8) break; + } + const auto originals = result; + for (const auto& title : originals) append(withoutBrandPrefix(normalizedTitle(title))); + return result; +} + +bool GameMetadata::canSharePortrait(const QVariantMap& target, const QVariantMap& donor) { + return target.value("igdbId").toLongLong() > 0 && + target.value("igdbId") == donor.value("igdbId") && + !target.value("identityAmbiguous").toBool() && !donor.value("identityAmbiguous").toBool() && + !target.value("rejected").toBool() && !donor.value("rejected").toBool() && + !target.value("platform").toString().isEmpty() && + target.value("platform") == donor.value("platform") && + target.value("edition") == donor.value("edition") && + donor.value("gridId").toLongLong() > 0 && !donor.value("portrait").toString().isEmpty(); +} + void GameMetadata::gridSearch() { + if (!m_manual && entry(key()).value("identityAmbiguous").toBool()) { + finish("Identify this game before downloading new artwork."); + return; + } if (!hasGridKey()) { - finish("IGDB data saved. Connect SteamGridDB for portrait covers."); + finish("Game identified. Connect SteamGridDB to find covers."); return; } if (!m_manual && !wantsPortraitCover(m_active.value("system").toString(), m_active.value("source").toString(), m_active.value("sourceCoverPath").toString())) { - // An earlier run may have downloaded a portrait over artwork that should have been kept. - // Drop it so the game shows its own art again. A portrait the user picked is stored as a - // custom cover, which outranks this and is untouched. The file stays for the ordinary - // cache trim to reclaim. - auto value = entry(key()); - if (value.contains("portrait")) { - value.remove("portrait"); - value.remove("gridCoverId"); - persist(key(), value); - } - finish("IGDB data saved. This game keeps the artwork its source provides."); + // Source artwork can avoid a new download, but must not replace an existing portrait. + finish("IGDB data saved. Existing artwork kept."); return; } auto value = entry(key()); @@ -980,6 +1351,24 @@ void GameMetadata::gridSearch() { finish("Cached portrait kept"); return; } + // Share only an existing provider download for the same identified platform/edition. + // Custom artwork remains a separate per-installation override. + if (!value.contains("gridId")) { + for (auto it = m_entries.cbegin(); it != m_entries.cend(); ++it) { + if (!canSharePortrait(value, it.value()) || + !QFileInfo::exists(it.value().value("portrait").toString())) continue; + value["gridId"] = it.value().value("gridId"); + value["coverRules"] = kCoverRulesVersion; + if (!m_manual) { + for (const auto& field : {"portrait", "gridCoverId", "portraitUpdated"}) + value[field] = it.value().value(field); + value["coverRules"] = kCoverRulesVersion; + if (persist(key(), value)) finish("Cover found from another installation of this game"); + return; + } + break; + } + } // A stored grid game with no portrait to show for it was never confirmed by anyone: either an // older rule settled on it, or someone opened a candidate to look at it. Trusting one of those // is how a game ends up wearing another game's box art, so on a rules change it is dropped and @@ -989,15 +1378,18 @@ void GameMetadata::gridSearch() { } value["coverAttempt"] = QDateTime::currentSecsSinceEpoch(); value["coverRules"] = kCoverRulesVersion; - persist(key(), value); + if (!persist(key(), value)) + return; if (m_manualSearchTitle.isEmpty() && value.value("gridId").toLongLong() > 0) { gridCovers(value.value("gridId").toLongLong()); return; } - const QString title = !m_manualSearchTitle.isEmpty() ? m_manualSearchTitle - : !m_brandRetryTitle.isEmpty() - ? m_brandRetryTitle - : value.value("title", m_active.value("title")).toString(); + if (m_artworkTitles.isEmpty()) { + m_artworkTitles = m_manualSearchTitle.isEmpty() ? artworkSearchTitles(value) + : QStringList{m_manualSearchTitle}; + if (m_artworkTitles.isEmpty()) m_artworkTitles.append(m_active.value("title").toString()); + } + const QString title = m_artworkTitles.value(m_artworkTitleIndex); get(QUrl("https://www.steamgriddb.com/api/v2/search/autocomplete/" + QString::fromLatin1(QUrl::toPercentEncoding(title))), "search"); @@ -1082,7 +1474,8 @@ void GameMetadata::get(const QUrl& url, const QString& stage) { auto value = entry(key()); if (value.remove("coverAttempt") > 0) { value.remove("coverRules"); - persist(key(), value); + if (!persist(key(), value)) + return; } } m_queue.clear(); @@ -1106,12 +1499,12 @@ void GameMetadata::response(const QByteArray& data, const QString& stage) { QImageReader reader(&buffer); const QSize size = reader.size(); if (size != QSize(600, 900)) { - finish("Portrait has unexpected dimensions"); + finish("Downloaded cover has unexpected dimensions"); return; } const QImage image = reader.read(); if (image.isNull()) { - finish("Could not decode portrait"); + finish("Could not read downloaded cover"); return; } QDir().mkpath(m_cacheRoot); @@ -1127,9 +1520,10 @@ void GameMetadata::response(const QByteArray& data, const QString& stage) { QSaveFile file(path); const QImage opaque = image.convertToFormat(QImage::Format_RGB32); if (!file.open(QIODevice::WriteOnly) || !opaque.save(&file, "JPG", 92) || !file.commit()) { - finish("Could not save portrait"); + finish("Could not save downloaded cover"); return; } + QString selectedCoverPath; if (m_manual && m_library) { for (int row = 0; row < m_library->rowCount(); ++row) if (m_library->data(m_library->index(row), GameRoles::MetadataKey).toString() == key()) { @@ -1137,10 +1531,12 @@ void GameMetadata::response(const QByteArray& data, const QString& stage) { finish("Could not apply the selected cover"); return; } + selectedCoverPath = m_library->data(m_library->index(row), GameRoles::CoverPath).toString(); break; } } auto value = entry(key()); + value["selectedCoverPath"] = selectedCoverPath; value["portrait"] = path; value["gridCoverId"] = m_downloadId; value["portraitUpdated"] = QDateTime::currentSecsSinceEpoch(); @@ -1151,13 +1547,14 @@ void GameMetadata::response(const QByteArray& data, const QString& stage) { value["coverRules"] = kCoverRulesVersion; m_pendingGridId = 0; } - persist(key(), value); + if (!persist(key(), value)) + return; trimPortraitCache(); const bool selectedManually = m_manual; const QString selectedKey = key(); if (selectedManually) m_covers.clear(); - finish("Portrait saved from SteamGridDB"); + finish("Cover saved from SteamGridDB"); if (selectedManually) emit portraitSelected(selectedKey); return; @@ -1184,34 +1581,33 @@ void GameMetadata::response(const QByteArray& data, const QString& stage) { matches.append(QVariantMap{ {"id", game.value("id").toInteger()}, {"title", game.value("name").toString()}, - {"year", released > 0 ? QDateTime::fromSecsSinceEpoch(released).date().year() : 0}}); + {"year", released > 0 + ? QDateTime::fromSecsSinceEpoch(released, QTimeZone::UTC).date().year() + : 0}}); } - const qint64 chosen = m_manual || saved.value("igdbId").toLongLong() <= 0 - ? 0 - : chooseGridMatch(matches, title, saved.value("year").toInt()); - // Searching SteamGridDB for "Disney's Goof Troop" returns ten other Disney games and not - // that one, because the catalogue files it as plain Goof Troop: the prefix is what the - // search matches on. Ask again without it, but only once the name as written has failed, so - // a game whose name really begins that way is searched for as written first. - if (chosen == 0 && !m_manual && m_brandRetryTitle.isEmpty()) { - const QString stripped = withoutBrandPrefix(normalizedTitle(title)); - if (!stripped.isEmpty() && stripped != normalizedTitle(title)) { - m_brandRetryTitle = stripped; - gridSearch(); - return; - } + const qint64 chosen = saved.value("igdbId").toLongLong() <= 0 || !m_manualSearchTitle.isEmpty() + ? 0 : chooseGridMatch(matches, m_artworkTitles.value(m_artworkTitleIndex, title), + saved.value("year").toInt()); + if (chosen == 0 && m_manualSearchTitle.isEmpty() && m_artworkTitleIndex + 1 < m_artworkTitles.size()) { + ++m_artworkTitleIndex; + gridSearch(); + return; } if (chosen > 0) { - auto value = saved; - value["gridId"] = chosen; - persist(key(), value); + if (m_manual) { + m_pendingGridId = chosen; + } else { + auto value = saved; + value["gridId"] = chosen; + if (!persist(key(), value)) return; + } gridCovers(chosen); } else if (m_manual) { m_candidates = matches; m_candidateProvider = "grid"; finish("Choose the matching SteamGridDB game"); } else - finish("Portrait needs a confirmed match. Open game details to choose."); + finish("No confident cover match. Open Game & Artwork to choose a matching game."); } else { m_covers = parseCovers(data); if (!m_manual && !m_covers.isEmpty()) { @@ -1219,7 +1615,7 @@ void GameMetadata::response(const QByteArray& data, const QString& stage) { m_downloadId = cover.value("id").toLongLong(); get(QUrl(cover.value("url").toString()), "image"); } else - finish(m_covers.isEmpty() ? "No portrait covers found" : "Choose a portrait cover"); + finish(m_covers.isEmpty() ? "No suitable covers found" : "Choose a cover"); } } void GameMetadata::finish(const QString& message) { @@ -1254,8 +1650,10 @@ void GameMetadata::secretOperation(int action, QByteArray value) { result.secret.fill('\0'); finish("Secret Service could not update the SteamGridDB key"); // Ratings do not need this key, so a failure here must not end the pass. - if (!m_stoppedByHand && !m_editing) + if (!m_stoppedByHand && !m_editing) { + queueSelected(); m_settle.start(); + } return; } m_gridKey.fill('\0'); @@ -1320,6 +1718,8 @@ void GameMetadata::requestIgdb(QByteArray query, QString endpoint, QString stage emit changed(); m_igdbStage = stage; m_queryKey = endpoint.toUtf8() + ':' + query; + if (m_active.value("refreshDetails").toBool()) + m_queryCache.remove(m_queryKey); if (m_queryCache.contains(m_queryKey)) { const auto cached = m_queryCache.value(m_queryKey); QTimer::singleShot(0, this, [this, cached] { matchResult(cached, {}); }); @@ -1348,6 +1748,10 @@ void GameMetadata::testGridConnection() { get(QUrl("https://www.steamgriddb.com/api/v2/search/autocomplete/Mario"), "test"); } void GameMetadata::clearPortraitCache() { + if (!m_pendingWrites.isEmpty()) { + finish("Retry unsaved game metadata before clearing portraits."); + return; + } if (busy()) return; const QDir cache(m_cacheRoot); @@ -1364,7 +1768,8 @@ void GameMetadata::clearPortraitCache() { // Without this the games just cleared would wait out the backoff before anything could // be downloaded again, so clearing appeared to do nothing for a day. value.remove("coverRules"); - persist(id, value); + if (!persist(id, value)) + return; } } finish("Downloaded portraits cleared. Your chosen covers are kept."); @@ -1375,23 +1780,10 @@ void GameMetadata::setCacheLimitMb(int megabytes) { trimPortraitCache(); } void GameMetadata::trimPortraitCache() { - const QDir cache(m_cacheRoot); - qint64 kept = 0; - QSet removed; - for (const auto& file : - cache.entryInfoList({"*.jpg", "*.png"}, QDir::Files, QDir::Time)) { - if (kept + file.size() <= m_cacheLimitBytes) - kept += file.size(); - else if (QFile::remove(file.absoluteFilePath())) - removed.insert(file.absoluteFilePath()); - } - if (removed.isEmpty()) - return; - for (const auto& id : m_entries.keys()) { - auto value = entry(id); - if (removed.contains(value.value("portrait").toString())) { - value.remove("portrait"); - persist(id, value); - } - } + QSet referenced; + for (const auto& value : std::as_const(m_entries)) + referenced.insert(value.value("portrait").toString()); + for (const auto& value : std::as_const(m_pendingWrites)) + referenced.insert(value.value("portrait").toString()); + CoverCachePolicy::prune(m_cacheRoot, m_cacheRoot, m_cacheLimitBytes, referenced); } diff --git a/src/metadata/GameMetadata.h b/src/metadata/GameMetadata.h index 9ff2976..3e8c0c3 100644 --- a/src/metadata/GameMetadata.h +++ b/src/metadata/GameMetadata.h @@ -19,6 +19,9 @@ class UnifiedGameModel; class GameMetadata final : public QObject { Q_OBJECT Q_PROPERTY(bool busy READ busy NOTIFY changed) + Q_PROPERTY(bool selectedBusy READ selectedBusy NOTIFY changed) + Q_PROPERTY(bool selectedWritePending READ selectedWritePending NOTIFY changed) + Q_PROPERTY(QString selectedStatus READ selectedStatus NOTIFY changed) Q_PROPERTY(int pending READ pending NOTIFY changed) Q_PROPERTY(bool hasGridKey READ hasGridKey NOTIFY changed) Q_PROPERTY(QString status READ status NOTIFY changed) @@ -33,18 +36,20 @@ class GameMetadata final : public QObject { // The filtered view the user is looking at. Games on screen are identified first, so opening // a console fills it in rather than waiting for the rest of the library. void setVisibleLibrary(QAbstractItemModel* visible); - // Drops portraits that were downloaded over artwork the game's own source provides. Runs by - // itself as the library settles, so a rule change reaches an existing library without anyone - // being asked to run anything. - void dropUnwantedPortraits(); void setCacheLimitMb(int megabytes); QVariantMap entry(const QString& key) const { return m_entries.value(key); } bool busy() const { return m_busy || !m_queue.isEmpty() || m_secrets.isRunning(); } bool hasGridKey() const { return !m_gridKey.isEmpty(); } int pending() const { return m_queue.size() + (m_busy ? 1 : 0); } Q_INVOKABLE void cancel(); + Q_INVOKABLE void refreshSelected(); + bool selectedBusy() const; + bool selectedWritePending() const { + return m_pendingWrites.contains(m_selected.value("metadataKey").toString()); + } + QString selectedStatus() const; QString status() const { return m_status; } - QVariantMap current() const { return entry(m_selected.value("metadataKey").toString()); } + QVariantMap current() const; QVariantList candidates() const { return m_active.value("metadataKey") == m_selected.value("metadataKey") ? m_candidates : QVariantList{}; @@ -68,7 +73,9 @@ class GameMetadata final : public QObject { // give, so matchingRulesFingerprint below fails the build's tests until this is raised. // 2 dump tags, sorted articles, tie-breaking between equal titles // 3 regional platforms, accents, publisher prefixes, catalogue numbers - static constexpr int kMatchVersion = 3; + // 4 ambiguous editions require identification; recheck older automatic IDs + // 5 exact title/alias lookup before declaring broad search results ambiguous + static constexpr int kMatchVersion = 5; // Everything the identification rules depend on, folded into one value. A test pins it, so a // change to any rule fails until kMatchVersion is raised alongside it. [[nodiscard]] static QByteArray matchingRulesFingerprint(); @@ -105,7 +112,7 @@ class GameMetadata final : public QObject { // day they make it sees nothing happen at all and concludes it does not work. // 1 exact title with the year as a tie-breaker, replacing exact title and exact year // 2 publisher prefixes, and unconfirmed grid selections dropped rather than trusted - static constexpr int kCoverRulesVersion = 2; + static constexpr int kCoverRulesVersion = 3; static constexpr qint64 kCoverAttemptBackoffSeconds = 86400; [[nodiscard]] static bool needsCoverAttempt(const QVariantMap& saved, qint64 now); // A licensed game is often catalogued with its publisher in front: IGDB calls a cartridge @@ -132,19 +139,28 @@ class GameMetadata final : public QObject { // catalogued under the regional machine rather than the western one. static QList platformIds(const QString& system); static QByteArray searchQuery(const QString& title, const QString& system); + static QByteArray aliasSearchQuery(const QString& title, const QString& system); static QVariantList parseMatches(const QByteArray& data, const QList& platforms); static QVariantList parseCovers(const QByteArray& data); static bool trustedImageUrl(const QUrl& url); + // IGDB platform ids to readable names for games whose source carries no system + // of its own, like Steam. Unknown ids come back as empty and are skipped. + Q_INVOKABLE static QStringList platformNames(const QVariantList& ids); signals: void changed(); - void entryChanged(const QString& key); + void entryChanged(const QString& key, const QVariantMap& previous); void portraitSelected(const QString& key); private: friend class CoreTests; void trimPortraitCache(); - void persist(const QString& key, const QVariantMap& value); + bool persist(const QString& key, const QVariantMap& value); void enqueue(const QVariantMap& game); + void queueSelected(bool force = false); + QHash m_detailAttempts; + QHash m_detailErrors; + QHash m_pendingWrites; + bool m_aliasRetried = false; void next(); void finish(const QString& message); void requestIgdb(QByteArray query, QString endpoint, QString stage); @@ -153,6 +169,8 @@ class GameMetadata final : public QObject { // Shared by findCovers and searchCovers: an empty title uses the catalogue's own. void beginCoverSearch(const QString& typedTitle); void gridSearch(); + static QStringList artworkSearchTitles(const QVariantMap& entry); + static bool canSharePortrait(const QVariantMap& target, const QVariantMap& donor); void gridCovers(qint64 id); void get(const QUrl& url, const QString& stage); void response(const QByteArray& data, const QString& stage); @@ -170,7 +188,6 @@ class GameMetadata final : public QObject { QByteArray m_gridKey; // Each provider is paced on its own, so the queue does not need a blanket pause between games. QElapsedTimer m_sinceGridRequest; - bool m_reviewedPortraits = false; bool m_stoppedByHand = false; bool m_editing = false; QQueue m_pausedQueue; @@ -195,6 +212,8 @@ class GameMetadata final : public QObject { // only dropped after the title as written has failed, so a game whose name really starts that // way is searched for as written first. QString m_brandRetryTitle; + QStringList m_artworkTitles; + int m_artworkTitleIndex = 0; // A name typed by hand in the cover panel, used instead of the catalogue's own title. QString m_manualSearchTitle; // A grid game picked by hand is held here rather than stored. Storing it on the click meant a diff --git a/src/metadata/RegionalMetadata.h b/src/metadata/RegionalMetadata.h new file mode 100644 index 0000000..e617aa0 --- /dev/null +++ b/src/metadata/RegionalMetadata.h @@ -0,0 +1,144 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Explicit dump tags are evidence, not a guess based on the title or desktop locale. +namespace RegionalMetadata { +inline QVariantMap romTags(const QString& filename) { + static const QHash regions{{"usa", "North America"}, + {"us", "North America"}, + {"na", "North America"}, + {"u", "North America"}, + {"canada", "North America"}, + {"europe", "Europe"}, + {"eur", "Europe"}, + {"eu", "Europe"}, + {"e", "Europe"}, + {"japan", "Japan"}, + {"jpn", "Japan"}, + {"jp", "Japan"}, + {"j", "Japan"}, + {"world", "Worldwide"}, + {"w", "Worldwide"}, + {"australia", "Australia"}, + {"china", "China"}, + {"korea", "Korea"}, + {"asia", "Asia"}, + {"brazil", "Brazil"}, + {"new zealand", "New Zealand"}}; + static const QRegularExpression tags(R"(\(([^()]*)\))"); + static const QRegularExpression language( + R"(^(En|Ja|Fr|De|Es|It|Nl|Pt|Ko|Zh|Sv|Da|No|Fi|Ru|Pl)$)"); + static const QRegularExpression revision(R"(^Rev(?:ision)?\s+([A-Za-z0-9.]+)$)", + QRegularExpression::CaseInsensitiveOption); + QStringList foundRegions, languages, revisions; + auto it = tags.globalMatch(QFileInfo(filename).fileName()); + while (it.hasNext()) { + const auto tag = it.next().captured(1); + for (const auto& part : tag.split(',')) { + const QString token = part.trimmed(); + const QString region = regions.value(token.toLower()); + if (!region.isEmpty()) + foundRegions.append(region); + else if (language.match(token).hasMatch()) + languages.append(token); + else if (const auto rev = revision.match(token); rev.hasMatch()) + revisions.append(rev.captured(1)); + } + } + foundRegions.removeDuplicates(); + languages.removeDuplicates(); + revisions.removeDuplicates(); + return {{"regions", foundRegions}, {"languages", languages}, {"revisions", revisions}}; +} + +inline QString regionKey(QString region) { + region = region.toLower(); + region.remove(QRegularExpression("[^a-z]")); + return region; +} + +// Keep all provider rows. Select only within the known platform, and never pretend that a +// worldwide/earliest-platform fallback is the ROM's regional release date. +inline QVariantMap details(QVariantMap value, const QString& filename, + const QList& platforms) { + const auto tags = romTags(filename); + value["romTags"] = tags; + const auto regions = tags.value("regions").toStringList(); + QStringList context; + if (!regions.isEmpty()) + context.append("ROM region: " + regions.join(", ")); + const auto languages = tags.value("languages").toStringList(); + if (!languages.isEmpty()) + context.append("Languages: " + languages.join(", ")); + const auto revisions = tags.value("revisions").toStringList(); + if (!revisions.isEmpty()) + context.append("Revision: " + revisions.join(", ")); + value["romContext"] = context.join(" · "); + value["releaseLabel"] = "First catalog release"; + QVariantList releases; + for (const auto& row : value.value("releaseDates").toList()) { + const auto release = row.toMap(); + if (platforms.contains(release.value("platform").toInt()) && + !release.value("human").toString().trimmed().isEmpty() && + release.value("date").toLongLong() > 0) + releases.append(release); + } + std::stable_sort(releases.begin(), releases.end(), [](const QVariant& a, const QVariant& b) { + return a.toMap().value("date").toLongLong() < b.toMap().value("date").toLongLong(); + }); + QVariantMap chosen; + if (regions.size() == 1) { + for (const auto& row : releases) { + if (regionKey(row.toMap().value("region").toString()) == regionKey(regions.first())) { + chosen = row.toMap(); + value["releaseLabel"] = regions.first() + " release"; + break; + } + } + } + if (chosen.isEmpty() && !releases.isEmpty()) { + chosen = releases.first().toMap(); + value["releaseLabel"] = "First platform release"; + } + if (!chosen.isEmpty()) { + value["releaseText"] = chosen.value("human"); + value["year"] = + chosen.value("year").toInt() > 0 + ? chosen.value("year").toInt() + : QDateTime::fromSecsSinceEpoch(chosen.value("date").toLongLong(), QTimeZone::UTC) + .date() + .year(); + } + QStringList names; + for (const auto& row : value.value("localizations").toList()) { + const auto localization = row.toMap(); + const QString name = localization.value("name").toString(); + const QString region = localization.value("region").toString(); + if (!name.isEmpty()) + names.append(name + (region.isEmpty() ? QString() : " (" + region + ")")); + } + for (const auto& row : value.value("alternativeNames").toList()) { + const auto alias = row.toMap(); + const QString name = alias.value("name").toString(); + const QString comment = alias.value("comment").toString(); + // Acronyms and capitalization variants add noise without explaining a regional rename. + if (comment.compare("Acronym", Qt::CaseInsensitive) == 0 || + comment.compare("Alternative spelling", Qt::CaseInsensitive) == 0 || + comment.compare("Stylized title", Qt::CaseInsensitive) == 0) + continue; + if (!name.isEmpty()) + names.append(name + (comment.isEmpty() ? QString() : " (" + comment + ")")); + } + names.removeDuplicates(); + value["titleEvidence"] = names; + return value; +} +} // namespace RegionalMetadata diff --git a/src/sessiond/main.cpp b/src/sessiond/main.cpp new file mode 100644 index 0000000..256130d --- /dev/null +++ b/src/sessiond/main.cpp @@ -0,0 +1,118 @@ +#include "tracking/AppNotify.h" +#include "tracking/ProcFs.h" +#include "tracking/ProcessMatcher.h" +#include "tracking/SessionDatabase.h" +#include "tracking/SessionRecorder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kPollIntervalMs = 5000; + +// The record switch lives in Omakade's own config so one toggle controls the +// display and the recording. Read with the file's mtime so the poll loop stays +// cheap when nothing changed. +class ConfigToggle { +public: + bool load() { + const QString path = SessionDatabase::defaultConfigPath(); + QFileInfo info(path); + if (!info.exists()) { + return false; + } + if (m_checked == info.lastModified()) { + return m_enabled; + } + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return false; + } + m_checked = info.lastModified(); + const QRegularExpression pattern( + QStringLiteral("(?m)^track_play_sessions\\s*=\\s*(true|false)\\s*$")); + const QRegularExpressionMatch match = pattern.match(QString::fromUtf8(file.readAll())); + m_enabled = !match.hasMatch() || match.captured(1) == QStringLiteral("true"); + return m_enabled; + } + +private: + QDateTime m_checked; + bool m_enabled = true; +}; + +QString profilesPath() { + const QString userPath = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + + QStringLiteral("/omakade/sessiond-profiles.json"); + if (QFileInfo::exists(userPath)) { + return userPath; + } + return QStringLiteral(OMAKADE_SESSIOND_PROFILES); +} +} // namespace + +int main(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("omakade-sessiond")); + + const QString databasePath = SessionDatabase::defaultDatabasePath(); + if (!QDir().mkpath(QFileInfo(databasePath).absolutePath())) { + qWarning("omakade-sessiond: could not create the data directory"); + return 1; + } + // One owner per database, including manually started copies of the daemon. + QLockFile owner(databasePath + QStringLiteral(".sessiond.lock")); + owner.setStaleLockTime(0); + if (!owner.tryLock(0)) { + qWarning("omakade-sessiond: recorder already running or its lock is unavailable"); + return 1; + } + + QString profileError; + const ProcessProfileSet profiles = ProcessMatcher::load(profilesPath(), &profileError); + if (!profileError.isEmpty()) { + qWarning("omakade-sessiond: %s", qPrintable(profileError)); + } + + QSqlDatabase database; + if (!SessionDatabase::open(database, SessionDatabase::defaultDatabasePath(), + QStringLiteral("omakade-sessiond"))) { + qWarning("omakade-sessiond: could not open the play session database"); + return 1; + } + + ConfigToggle toggle; + SessionRecorder recorder(database); + const qint64 nowWall = QDateTime::currentSecsSinceEpoch(); + if (toggle.load()) recorder.recover(ProcFs::listProcesses(), profiles, nowWall); + else recorder.endAll(nowWall); + + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, [&] { + if (!toggle.load()) { + recorder.endAll(QDateTime::currentSecsSinceEpoch()); + } else { + recorder.sync(ProcessMatcher::match(ProcFs::listProcesses(), profiles), + QDateTime::currentSecsSinceEpoch()); + } + if (recorder.takeStorageFailure()) { + qWarning("omakade-sessiond: session storage failed; pending progress may be lost if the " + "recorder exits"); + AppNotify::send("tracking-storage-error"); + } + const QStringList rescans = recorder.takeRescanRequests(); + for (const QString& source : rescans) { + AppNotify::send(QStringLiteral("rescan %1").arg(source).toUtf8()); + } + }); + poll.start(kPollIntervalMs); + return app.exec(); +} diff --git a/src/tracking/AppNotify.cpp b/src/tracking/AppNotify.cpp new file mode 100644 index 0000000..5197d1b --- /dev/null +++ b/src/tracking/AppNotify.cpp @@ -0,0 +1,21 @@ +#include "tracking/AppNotify.h" + +#include "tracking/SessionDatabase.h" + +#include + +namespace AppNotify { + +bool send(const QByteArray& command, int timeoutMs) { + QLocalSocket socket; + socket.connectToServer(SessionDatabase::appServerName(), QIODevice::WriteOnly); + if (!socket.waitForConnected(timeoutMs)) { + return false; + } + socket.write(command); + socket.flush(); + socket.waitForBytesWritten(timeoutMs); + return true; +} + +} // namespace AppNotify diff --git a/src/tracking/AppNotify.h b/src/tracking/AppNotify.h new file mode 100644 index 0000000..9733f18 --- /dev/null +++ b/src/tracking/AppNotify.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +// One-shot client for Omakade's single-instance socket, the same channel the +// launcher uses for "play " commands. Best effort: a closed Omakade window +// simply misses the notification. +namespace AppNotify { + +bool send(const QByteArray& command, int timeoutMs = 200); + +} // namespace AppNotify diff --git a/src/tracking/PlaySessionStore.cpp b/src/tracking/PlaySessionStore.cpp new file mode 100644 index 0000000..b157987 --- /dev/null +++ b/src/tracking/PlaySessionStore.cpp @@ -0,0 +1,136 @@ +#include "tracking/PlaySessionStore.h" + +#include "tracking/SessionDatabase.h" +#include "library/GameRoles.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kRefreshIntervalMs = 20000; +} // namespace + +PlaySessionStore::PlaySessionStore(const QString& databasePath, QObject* parent) + : QObject(parent), + m_connectionName(QStringLiteral("omakade-sessions-%1").arg(QUuid::createUuid().toString())) { + m_databasePath = databasePath; + m_valid = SessionDatabase::open(m_database, databasePath, m_connectionName); + refresh(); + m_baselines = SessionDatabase::baselinesByPath(m_database); + m_refreshTimer = new QTimer(this); + m_refreshTimer->setInterval(kRefreshIntervalMs); + connect(m_refreshTimer, &QTimer::timeout, this, &PlaySessionStore::refresh); + m_refreshTimer->start(); +} + +PlaySessionStore::~PlaySessionStore() { + m_refreshTimer->stop(); + m_database.close(); + m_database = {}; + QSqlDatabase::removeDatabase(m_connectionName); +} + +bool PlaySessionStore::recorderOwnsDatabase(const QString& databasePath) { + QLockFile owner(databasePath + QStringLiteral(".sessiond.lock")); + qint64 pid = 0; + QString hostname, application; + if (!owner.getLockInfo(&pid, &hostname, &application) || pid <= 0 || + hostname != QSysInfo::machineHostName() || application != QStringLiteral("omakade-sessiond")) + return false; + const QFileInfo process(QStringLiteral("/proc/%1").arg(pid)); + const QFileInfo executable(QStringLiteral("/proc/%1/exe").arg(pid)); + return process.ownerId() == static_cast(geteuid()) && + QFileInfo(executable.symLinkTarget()).fileName() == QStringLiteral("omakade-sessiond"); +} + +void PlaySessionStore::refreshRecorderStatus() { + const bool running = recorderOwnsDatabase(m_databasePath); + if (running == m_recorderRunning) return; + m_recorderRunning = running; + emit recorderStatusChanged(); +} + +QString PlaySessionStore::provenance(const PlaySessionStore* store, const QString& path, + qint64 importedSeconds) { + const QString imported = importedSeconds < 0 + ? QStringLiteral("No imported emulator playtime") + : QStringLiteral("Imported from emulator: %1").arg(GameRoles::formatPlaytime(importedSeconds)); + if (!store || !store->m_valid) return imported; + return imported + QStringLiteral(" · Recorded by Omakade: %1%2") + .arg(GameRoles::formatPlaytime(store->m_trackedSeconds.value(path, 0)), + store->enabled() ? QString{} : QStringLiteral(" (not applied while recording is off)")); +} + +bool PlaySessionStore::enabled() const { return m_enabled; } + +void PlaySessionStore::setEnabled(bool value) { + if (m_enabled == value) { + return; + } + m_enabled = value; + emit enabledChanged(); + refresh(); + emit totalsChanged(); +} + +void PlaySessionStore::captureBaseline(const QString& gamePath, qint64 importedSeconds) { + if (!m_valid || !m_enabled || m_baselines.contains(gamePath)) { + return; + } + SessionDatabase::captureBaseline(m_database, gamePath, importedSeconds, + QDateTime::currentSecsSinceEpoch()); + m_baselines = SessionDatabase::baselinesByPath(m_database); +} + +qint64 PlaySessionStore::displaySeconds(const QString& gamePath, qint64 importedSeconds) const { + if (!m_valid || !m_enabled || gamePath.isEmpty()) { + return importedSeconds; + } + return merge(importedSeconds, m_baselines.value(gamePath, 0), + m_trackedSeconds.value(gamePath, 0)); +} + +qint64 PlaySessionStore::sessionLastPlayed(const QString& gamePath) const { + if (!m_valid || !m_enabled || gamePath.isEmpty()) { + return 0; + } + return m_lastPlayed.value(gamePath, 0); +} + +qint64 PlaySessionStore::merge(qint64 importedSeconds, qint64 baselineSeconds, + qint64 trackedSeconds) { + return qMax(importedSeconds, baselineSeconds + trackedSeconds); +} + +qint64 PlaySessionStore::displayedSeconds(const PlaySessionStore* store, const QString& gamePath, + qint64 importedSeconds) { + return store == nullptr ? importedSeconds : store->displaySeconds(gamePath, importedSeconds); +} + +qint64 PlaySessionStore::displayedLastPlayed(const PlaySessionStore* store, const QString& gamePath, + qint64 importedLastPlayed) { + return store == nullptr ? importedLastPlayed + : qMax(importedLastPlayed, store->sessionLastPlayed(gamePath)); +} + +void PlaySessionStore::refresh() { + refreshRecorderStatus(); + if (!m_valid) { + return; + } + const QHash tracked = + SessionDatabase::trackedSecondsByPath(m_database); + const QHash lastPlayed = + SessionDatabase::lastPlayedByPath(m_database); + if (tracked == m_trackedSeconds && lastPlayed == m_lastPlayed) { + return; + } + m_trackedSeconds = tracked; + m_lastPlayed = lastPlayed; + emit totalsChanged(); +} diff --git a/src/tracking/PlaySessionStore.h b/src/tracking/PlaySessionStore.h new file mode 100644 index 0000000..dff9808 --- /dev/null +++ b/src/tracking/PlaySessionStore.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include + +#include + +class QTimer; + +// Aggregates the sessions recorded by omakade-sessiond and merges them with the +// playtime each source imports from its own emulator. The displayed total is +// max(imported, baseline + tracked). On first observation the baseline excludes +// already recorded time, conservatively treating it as included in the import. +// Existing baselines are preserved. Later gaps in tracking can make the import +// win until observed time catches up; this is not exact overlap reconciliation. +class PlaySessionStore final : public QObject { + Q_OBJECT + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged) + Q_PROPERTY(bool recorderRunning READ recorderRunning NOTIFY recorderStatusChanged) + Q_PROPERTY(bool storageAvailable READ storageAvailable CONSTANT) + +public: + explicit PlaySessionStore(const QString& databasePath, QObject* parent = nullptr); + + ~PlaySessionStore() override; + + [[nodiscard]] bool enabled() const; + void setEnabled(bool value); + bool recorderRunning() const { return m_recorderRunning; } + bool storageAvailable() const { return m_valid; } + Q_INVOKABLE void refreshRecorderStatus(); + static bool recorderOwnsDatabase(const QString& databasePath); + // A negative import means this source has no imported playtime counter. + static QString provenance(const PlaySessionStore* store, const QString& gamePath, + qint64 importedSeconds); + + // Models report the playtime their emulator imports so the first sighting is + // remembered. Later sightings are ignored by design. + void captureBaseline(const QString& gamePath, qint64 importedSeconds); + + [[nodiscard]] qint64 displaySeconds(const QString& gamePath, qint64 importedSeconds) const; + [[nodiscard]] qint64 sessionLastPlayed(const QString& gamePath) const; + + [[nodiscard]] static qint64 merge(qint64 importedSeconds, qint64 baselineSeconds, + qint64 trackedSeconds); + + // Model helpers: they read through the store when present and fall back to the + // imported values otherwise, so sources built without a store behave exactly + // as they did before session tracking existed. + [[nodiscard]] static qint64 displayedSeconds(const PlaySessionStore* store, + const QString& gamePath, qint64 importedSeconds); + [[nodiscard]] static qint64 displayedLastPlayed(const PlaySessionStore* store, + const QString& gamePath, + qint64 importedLastPlayed); + +signals: + void enabledChanged(); + void recorderStatusChanged(); + void totalsChanged(); + +private: + void refresh(); + + QSqlDatabase m_database; + QString m_connectionName; + QString m_databasePath; + bool m_recorderRunning = false; + bool m_enabled = true; + bool m_valid = false; + QHash m_trackedSeconds; + QHash m_baselines; + QHash m_lastPlayed; + QTimer* m_refreshTimer = nullptr; +}; diff --git a/src/tracking/ProcFs.cpp b/src/tracking/ProcFs.cpp new file mode 100644 index 0000000..f87facf --- /dev/null +++ b/src/tracking/ProcFs.cpp @@ -0,0 +1,84 @@ +#include "tracking/ProcFs.h" + +#include +#include +#include + +namespace { +// Parses the fields after the command name from /proc//stat: state first, +// start time the twentieth, exactly like the launcher's own process tracking. +qint64 statStartTime(QFile& stat, char* state) { + if (!stat.open(QIODevice::ReadOnly)) { + return -1; + } + const QByteArray contents = stat.readAll(); + const qsizetype commEnd = contents.lastIndexOf(')'); + if (commEnd < 0) { + return -1; + } + const QList fields = contents.mid(commEnd + 2).simplified().split(' '); + if (fields.size() < 20) { + return -1; + } + *state = fields.at(0).isEmpty() ? '?' : fields.at(0).at(0); + bool okay = false; + const qint64 startTime = fields.at(19).toLongLong(&okay); + return okay ? startTime : -1; +} +} // namespace + +namespace ProcFs { + +QVector listProcesses() { + QVector processes; + QDir procDir(QStringLiteral("/proc")); + const QStringList entries = procDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + processes.reserve(entries.size()); + for (const QString& entry : entries) { + bool numeric = false; + const qint64 pid = entry.toLongLong(&numeric); + if (!numeric || pid <= 0) { + continue; + } + const QString base = QStringLiteral("/proc/%1").arg(pid); + if (QFileInfo(base).ownerId() != static_cast(geteuid())) + continue; + QFile stat(base + QStringLiteral("/stat")); + char state = '?'; + const qint64 procStart = statStartTime(stat, &state); + if (procStart < 0 || state == 'Z' || state == 'X') { + continue; + } + QFile cmdline(base + QStringLiteral("/cmdline")); + if (!cmdline.open(QIODevice::ReadOnly)) { + continue; + } + const QList rawArguments = cmdline.readAll().split('\0'); + QStringList arguments; + for (const QByteArray& argument : rawArguments) { + if (!argument.isEmpty()) { + arguments.append(QString::fromLocal8Bit(argument)); + } + } + if (arguments.isEmpty()) { + continue; + } + processes.append({.pid = pid, + .procStart = procStart, + .comm = QFileInfo(arguments.first()).fileName(), + .arguments = arguments}); + } + return processes; +} + +bool processAlive(qint64 pid, qint64 procStart) { + if (pid <= 0 || procStart < 0) { + return false; + } + QFile stat(QStringLiteral("/proc/%1/stat").arg(pid)); + char state = '?'; + const qint64 current = statStartTime(stat, &state); + return current == procStart && state != 'Z' && state != 'X'; +} + +} // namespace ProcFs diff --git a/src/tracking/ProcFs.h b/src/tracking/ProcFs.h new file mode 100644 index 0000000..f8b3a7f --- /dev/null +++ b/src/tracking/ProcFs.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +// A point-in-time snapshot of one process from procfs, enough to decide whether +// it is an emulator and which game it was started with. +struct ProcessSnapshot { + qint64 pid = 0; + // procfs stat field 22, the value GameLauncher also uses to tell a reused pid + // apart from the process it was tracking. + qint64 procStart = -1; + QString comm; + QStringList arguments; +}; + +namespace ProcFs { + +// Lists user-space processes. Kernel threads have an empty cmdline and are skipped. +[[nodiscard]] QVector listProcesses(); + +[[nodiscard]] bool processAlive(qint64 pid, qint64 procStart); + +} // namespace ProcFs diff --git a/src/tracking/ProcessMatcher.cpp b/src/tracking/ProcessMatcher.cpp new file mode 100644 index 0000000..3a96b3c --- /dev/null +++ b/src/tracking/ProcessMatcher.cpp @@ -0,0 +1,104 @@ +#include "tracking/ProcessMatcher.h" + +#include +#include +#include +#include +#include + +namespace { +bool binaryMatches(const QString& candidate, const QStringList& binaries) { + for (const QString& binary : binaries) { + if (candidate.compare(binary, Qt::CaseInsensitive) == 0) { + return true; + } + } + return false; +} + +QString romPathFromArguments(const QStringList& arguments, const QSet& romExtensions) { + for (qsizetype index = 1; index < arguments.size(); ++index) { + const QString& argument = arguments.at(index); + const qsizetype dot = argument.lastIndexOf(QLatin1Char('.')); + if (dot < 0 || dot + 1 >= argument.size()) { + continue; + } + if (romExtensions.contains(argument.mid(dot + 1).toLower())) { + return argument; + } + } + return {}; +} +} // namespace + +namespace ProcessMatcher { + +ProcessProfileSet load(const QString& path, QString* error) { + ProcessProfileSet set; + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + if (error != nullptr) { + *error = QStringLiteral("Could not read %1").arg(path); + } + return set; + } + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + if (error != nullptr) { + *error = QStringLiteral("%1: %2").arg(path, parseError.errorString()); + } + return set; + } + const QJsonObject root = document.object(); + const QJsonArray extensions = root.value(QLatin1String("romExtensions")).toArray(); + for (const QJsonValue& value : extensions) { + const QString extension = value.toString().toLower(); + if (!extension.isEmpty()) { + set.romExtensions.insert(extension); + } + } + const QJsonArray emulators = root.value(QLatin1String("emulators")).toArray(); + for (const QJsonValue& value : emulators) { + const QJsonObject entry = value.toObject(); + SessionProcessProfile profile; + profile.name = entry.value(QLatin1String("name")).toString(); + const QJsonArray binaries = entry.value(QLatin1String("binaries")).toArray(); + for (const QJsonValue& binary : binaries) { + const QString name = binary.toString(); + if (!name.isEmpty()) { + profile.binaries.append(name); + } + } + profile.rescanSource = entry.value(QLatin1String("rescanSource")).toString(); + if (!profile.name.isEmpty() && !profile.binaries.isEmpty()) { + set.emulators.append(profile); + } + } + return set; +} + +QVector match(const QVector& processes, + const ProcessProfileSet& profiles) { + QVector matches; + for (const ProcessSnapshot& process : processes) { + for (const SessionProcessProfile& profile : profiles.emulators) { + if (!binaryMatches(process.comm, profile.binaries)) { + continue; + } + const QString gamePath = romPathFromArguments(process.arguments, profiles.romExtensions); + if (gamePath.isEmpty()) { + break; + } + matches.append({.pid = process.pid, + .procStart = process.procStart, + .emulator = profile.name, + .rescanSource = profile.rescanSource, + .gamePath = gamePath}); + break; + } + } + return matches; +} + +} // namespace ProcessMatcher diff --git a/src/tracking/ProcessMatcher.h b/src/tracking/ProcessMatcher.h new file mode 100644 index 0000000..a995068 --- /dev/null +++ b/src/tracking/ProcessMatcher.h @@ -0,0 +1,47 @@ +#pragma once + +#include "tracking/ProcFs.h" + +#include +#include +#include + +// Emulator launch profiles drive session attribution. A profile names the +// binaries an emulator runs under; the matcher then looks for a command line +// argument that looks like a game image. That covers every launch path that +// names the game on the command line: Omakade launches, terminal launches, and +// wrapper scripts. Loading a game from inside the emulator's own file picker +// shows no path on the command line and stays untracked for now. +struct SessionProcessProfile { + QString name; + QStringList binaries; + // Omakade source to ask for a rescan when a session of this emulator ends, + // for emulators whose own playtime is only written on exit. Empty when the + // source keeps itself current. + QString rescanSource; +}; + +struct SessionMatch { + qint64 pid = 0; + qint64 procStart = -1; + QString emulator; + QString rescanSource; + QString gamePath; +}; + +struct ProcessProfileSet { + QVector emulators; + QSet romExtensions; +}; + +namespace ProcessMatcher { + +// Reads a profiles JSON document: {"romExtensions": [...], +// "emulators": [{"name": "...", "binaries": [...], "rescanSource": "..."}]}. +// Returns an empty set and a non-empty error on malformed input. +[[nodiscard]] ProcessProfileSet load(const QString& path, QString* error = nullptr); + +[[nodiscard]] QVector match(const QVector& processes, + const ProcessProfileSet& profiles); + +} // namespace ProcessMatcher diff --git a/src/tracking/SessionDatabase.cpp b/src/tracking/SessionDatabase.cpp new file mode 100644 index 0000000..0357082 --- /dev/null +++ b/src/tracking/SessionDatabase.cpp @@ -0,0 +1,248 @@ +#include "tracking/SessionDatabase.h" + +#include "library/DatabaseTuning.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { +constexpr int kCurrentSchema = 1; +} // namespace + +namespace SessionDatabase { + +QString defaultDatabasePath() { + const QString directory = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + + QStringLiteral("/omakade"); + return directory + QStringLiteral("/library.sqlite3"); +} + +QString defaultConfigPath() { + return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + + QStringLiteral("/omakade/config.toml"); +} + +QString appServerName() { return QStringLiteral("omakade-%1").arg(getuid()); } + +bool open(QSqlDatabase& database, const QString& path, const QString& connectionName) { + if (path != QStringLiteral(":memory:")) { + QDir().mkpath(QFileInfo(path).absolutePath()); + } + database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connectionName); + database.setDatabaseName(path); + if (!openTunedDatabase(database)) { + return false; + } + if (!database.transaction()) + return false; + if (!ensureSchema(database) || !database.commit()) { + database.rollback(); + return false; + } + return true; +} + +bool ensureSchema(QSqlDatabase& database) { + QSqlQuery query(database); + if (!query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS play_sessions (id INTEGER PRIMARY KEY, game_path TEXT NOT " + "NULL, source TEXT NOT NULL DEFAULT '', started_at INTEGER NOT NULL, ended_at INTEGER " + "NOT " + "NULL DEFAULT 0, seconds INTEGER NOT NULL DEFAULT 0, pid INTEGER NOT NULL DEFAULT 0, " + "proc_start INTEGER NOT NULL DEFAULT -1, heartbeat_at INTEGER NOT NULL DEFAULT 0)"))) + return false; + if (!query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS play_sessions_path ON play_sessions(game_path)"))) + return false; + if (!query.exec( + QStringLiteral("CREATE TABLE IF NOT EXISTS play_baselines (game_path TEXT PRIMARY " + "KEY, baseline_seconds INTEGER NOT NULL DEFAULT 0, captured_at " + "INTEGER NOT NULL, schema INTEGER NOT NULL DEFAULT %1)") + .arg(kCurrentSchema))) + return false; + if (!query.exec("PRAGMA table_info(play_sessions)")) + return false; + bool hasKey = false; + while (query.next()) + hasKey = hasKey || query.value(1).toString() == "session_key"; + query.finish(); + if (!hasKey && !query.exec("ALTER TABLE play_sessions ADD COLUMN session_key TEXT")) + return false; + if (!query.exec("SELECT id FROM play_sessions WHERE session_key IS NULL OR session_key=''")) + return false; + QList legacy; + while (query.next()) + legacy.append(query.value(0).toLongLong()); + query.finish(); + for (qint64 id : legacy) { + query.prepare("UPDATE play_sessions SET session_key=? WHERE id=? AND (session_key IS NULL OR " + "session_key='')"); + query.addBindValue(QUuid::createUuid().toString(QUuid::WithoutBraces)); + query.addBindValue(id); + if (!query.exec()) + return false; + } + if (!query.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS play_sessions_key ON play_sessions(session_key)")) + return false; + // A recorder from the previous build may still be running during a local upgrade. + // Its inserts omit session_key; assign one without changing existing identities. + return query.exec( + "CREATE TRIGGER IF NOT EXISTS play_sessions_assign_key AFTER INSERT ON play_sessions " + "WHEN NEW.session_key IS NULL OR NEW.session_key='' BEGIN " + "UPDATE play_sessions SET session_key=lower(hex(randomblob(4)))||'-'||" + "lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(2)))||'-'||" + "lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))) WHERE id=NEW.id; END"); +} + +QVector openSessions(QSqlDatabase& database) { + QVector rows; + QSqlQuery query(database); + if (!query.exec(QStringLiteral( + "SELECT id, game_path, source, started_at, ended_at, seconds, pid, proc_start, " + "heartbeat_at FROM play_sessions WHERE ended_at = 0 ORDER BY id"))) { + return rows; + } + while (query.next()) { + rows.append({.id = query.value(0).toLongLong(), + .gamePath = query.value(1).toString(), + .source = query.value(2).toString(), + .startedAt = query.value(3).toLongLong(), + .endedAt = query.value(4).toLongLong(), + .seconds = query.value(5).toLongLong(), + .pid = query.value(6).toLongLong(), + .procStart = query.value(7).toLongLong(), + .heartbeatAt = query.value(8).toLongLong()}); + } + return rows; +} + +qint64 beginSession(QSqlDatabase& database, const QString& gamePath, const QString& source, + qint64 startedAt, qint64 pid, qint64 procStart) { + QSqlQuery query(database); + query.prepare( + QStringLiteral("INSERT INTO play_sessions(game_path, source, started_at, pid, " + "proc_start, heartbeat_at, session_key) VALUES(?, ?, ?, ?, ?, ?, ?)")); + query.addBindValue(gamePath); + query.addBindValue(source); + query.addBindValue(startedAt); + query.addBindValue(pid); + query.addBindValue(procStart); + query.addBindValue(startedAt); + query.addBindValue(QUuid::createUuid().toString(QUuid::WithoutBraces)); + if (!query.exec()) { + return 0; + } + return query.lastInsertId().toLongLong(); +} + +bool updateProgress(QSqlDatabase& database, qint64 id, qint64 seconds, qint64 heartbeatAt) { + QSqlQuery query(database); + query.prepare( + QStringLiteral("UPDATE play_sessions SET seconds = ?, heartbeat_at = ? WHERE id = ?")); + query.addBindValue(seconds); + query.addBindValue(heartbeatAt); + query.addBindValue(id); + return query.exec() && query.numRowsAffected() == 1; +} + +bool endSession(QSqlDatabase& database, qint64 id, qint64 endedAt, qint64 seconds) { + QSqlQuery query(database); + query.prepare(QStringLiteral("UPDATE play_sessions SET ended_at = ?, seconds = ? WHERE id = ?")); + query.addBindValue(endedAt); + query.addBindValue(seconds); + query.addBindValue(id); + return query.exec() && query.numRowsAffected() == 1; +} + +bool endAllSessions(QSqlDatabase& database, qint64 endedAt) { + QSqlQuery query(database); + query.prepare(QStringLiteral( + "UPDATE play_sessions SET ended_at = ?, seconds = CASE WHEN heartbeat_at > started_at " + "THEN seconds ELSE 0 END WHERE ended_at = 0")); + query.addBindValue(endedAt); + return query.exec(); +} + +QVector reconcileOpenSessions(QSqlDatabase& database, + const std::function& processAlive) { + QVector survivors; + const QVector open = openSessions(database); + for (const SessionRow& row : open) { + const bool alive = + processAlive && processAlive(row.pid, row.procStart) && row.pid > 0 && row.procStart > 0; + if (alive) { + survivors.append(row); + continue; + } + const qint64 endedAt = row.heartbeatAt > row.startedAt ? row.heartbeatAt : row.startedAt; + if (!endSession(database, row.id, endedAt, row.seconds)) + survivors.append(row); + } + return survivors; +} + +QHash trackedSecondsByPath(QSqlDatabase& database) { + QHash totals; + QSqlQuery query(database); + if (!query.exec( + QStringLiteral("SELECT game_path, SUM(seconds) FROM play_sessions GROUP BY game_path"))) { + return totals; + } + while (query.next()) { + totals.insert(query.value(0).toString(), query.value(1).toLongLong()); + } + return totals; +} + +QHash lastPlayedByPath(QSqlDatabase& database) { + QHash lastPlayed; + QSqlQuery query(database); + if (!query.exec(QStringLiteral( + "SELECT game_path, MAX(COALESCE(NULLIF(ended_at, 0), started_at)) FROM play_sessions " + "GROUP BY game_path"))) { + return lastPlayed; + } + while (query.next()) { + lastPlayed.insert(query.value(0).toString(), query.value(1).toLongLong()); + } + return lastPlayed; +} + +void captureBaseline(QSqlDatabase& database, const QString& gamePath, qint64 importedSeconds, + qint64 capturedAt) { + if (gamePath.isEmpty() || importedSeconds < 0) { + return; + } + QSqlQuery query(database); + query.prepare(QStringLiteral("INSERT OR IGNORE INTO play_baselines(game_path, baseline_seconds, " + "captured_at) SELECT ?, MAX(0, ? - COALESCE(SUM(seconds), 0)), ? " + "FROM play_sessions WHERE game_path = ?")); + query.addBindValue(gamePath); + query.addBindValue(importedSeconds); + query.addBindValue(capturedAt); + query.addBindValue(gamePath); + query.exec(); +} + +QHash baselinesByPath(QSqlDatabase& database) { + QHash baselines; + QSqlQuery query(database); + if (!query.exec(QStringLiteral("SELECT game_path, baseline_seconds FROM play_baselines"))) { + return baselines; + } + while (query.next()) { + baselines.insert(query.value(0).toString(), query.value(1).toLongLong()); + } + return baselines; +} + +} // namespace SessionDatabase diff --git a/src/tracking/SessionDatabase.h b/src/tracking/SessionDatabase.h new file mode 100644 index 0000000..ddcfd36 --- /dev/null +++ b/src/tracking/SessionDatabase.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include + +#include + +// Session storage shared by Omakade and the omakade-sessiond recorder. Both open +// the same library database, so every function takes the caller's connection and +// the schema is created idempotently. +namespace SessionDatabase { + +// One recorded play session. Sessions stay open with ended_at = 0 while the game +// process lives; seconds is the wall time accumulated so far and is flushed +// periodically so a crash loses at most one flush interval. +struct SessionRow { + qint64 id = 0; + QString gamePath; + QString source; + qint64 startedAt = 0; + qint64 endedAt = 0; + qint64 seconds = 0; + qint64 pid = 0; + qint64 procStart = -1; + qint64 heartbeatAt = 0; +}; + +[[nodiscard]] QString defaultDatabasePath(); +[[nodiscard]] QString defaultConfigPath(); +[[nodiscard]] QString appServerName(); + +// Opens (or reuses) a tuned connection to the library database and creates the +// session tables. Returns false when opening or preparing the schema fails. +bool open(QSqlDatabase& database, const QString& path, const QString& connectionName); +bool ensureSchema(QSqlDatabase& database); + +QVector openSessions(QSqlDatabase& database); +qint64 beginSession(QSqlDatabase& database, const QString& gamePath, const QString& source, + qint64 startedAt, qint64 pid, qint64 procStart); +bool updateProgress(QSqlDatabase& database, qint64 id, qint64 seconds, qint64 heartbeatAt); +bool endSession(QSqlDatabase& database, qint64 id, qint64 endedAt, qint64 seconds); +bool endAllSessions(QSqlDatabase& database, qint64 endedAt); + +// Closes open sessions whose tracked process is gone, using the last heartbeat as +// the end time so a dead daemon never invents play time. Returns the survivors. +QVector +reconcileOpenSessions(QSqlDatabase& database, + const std::function& processAlive); + +[[nodiscard]] QHash trackedSecondsByPath(QSqlDatabase& database); +[[nodiscard]] QHash lastPlayedByPath(QSqlDatabase& database); + +// Capture once, including zero. Subtract already observed time conservatively: +// a late first import may already contain those sessions. Existing baselines +// are never rewritten because historical overlap cannot be inferred reliably. +void captureBaseline(QSqlDatabase& database, const QString& gamePath, qint64 importedSeconds, + qint64 capturedAt); +[[nodiscard]] QHash baselinesByPath(QSqlDatabase& database); + +} // namespace SessionDatabase diff --git a/src/tracking/SessionRecorder.cpp b/src/tracking/SessionRecorder.cpp new file mode 100644 index 0000000..1a03eb1 --- /dev/null +++ b/src/tracking/SessionRecorder.cpp @@ -0,0 +1,177 @@ +#include "tracking/SessionRecorder.h" + +#include +#include +#include + +#include + +namespace { +constexpr qint64 kDefaultFlushIntervalMs = 30000; + +QElapsedTimer& defaultClock() { + static QElapsedTimer clock; + if (!clock.isValid()) { + clock.start(); + } + return clock; +} +} // namespace + +SessionRecorder::SessionRecorder(const QSqlDatabase& database, + const std::function& elapsedMs) + : m_database(database), m_elapsedMs(elapsedMs) { + if (!m_elapsedMs) { + m_elapsedMs = [] { return defaultClock().elapsed(); }; + } +} + +SessionRecorder::~SessionRecorder() = default; + +void SessionRecorder::setFlushIntervalMs(int intervalMs) { + if (intervalMs > 0) { + m_flushIntervalMs = intervalMs; + } +} + +QString SessionRecorder::keyFor(const SessionMatch& match) const { + return QStringLiteral("%1:%2").arg(match.pid).arg(match.procStart); +} + +void SessionRecorder::recover(const QVector& processes, + const ProcessProfileSet& profiles, qint64 nowWall) { + Q_UNUSED(nowWall); + const QVector survivors = + SessionDatabase::reconcileOpenSessions(m_database, &ProcFs::processAlive); + if (survivors.isEmpty()) { + return; + } + const QVector matches = ProcessMatcher::match(processes, profiles); + const qint64 nowMs = m_elapsedMs(); + for (const SessionDatabase::SessionRow& row : survivors) { + const SessionMatch* adopted = nullptr; + for (const SessionMatch& match : matches) { + if (match.pid == row.pid && match.procStart == row.procStart && + match.gamePath == row.gamePath) { + adopted = &match; + break; + } + } + if (adopted != nullptr) { + ActiveSession session; + session.id = row.id; + session.gamePath = row.gamePath; + session.emulator = adopted->emulator; + session.rescanSource = adopted->rescanSource; + session.elapsedMs = row.seconds * 1000; + session.markMs = nowMs; + session.lastFlushMs = nowMs; + m_active.insert(QStringLiteral("%1:%2").arg(row.pid).arg(row.procStart), session); + continue; + } + // The process lives but no longer runs the same game; keep the recorded time + // and stop where the last heartbeat proved it was still playing. + if (!SessionDatabase::endSession(m_database, row.id, qMax(row.startedAt, row.heartbeatAt), + row.seconds)) { + m_pendingCloses.append({row.id, qMax(row.startedAt, row.heartbeatAt), row.seconds}); + m_lastCloseAttemptMs = nowMs; + m_storageFailure = true; + } + } +} + +void SessionRecorder::flush(ActiveSession& session, qint64 nowMs, qint64 nowWall) { + if (!SessionDatabase::updateProgress(m_database, session.id, session.elapsedMs / 1000, nowWall)) + m_storageFailure = true; + session.lastFlushMs = nowMs; +} + +QHash::Iterator +SessionRecorder::closeSession(QHash::Iterator session, qint64 nowMs, + qint64 nowWall) { + const qint64 totalMs = session->elapsedMs + (nowMs - session->markMs); + if (!SessionDatabase::endSession(m_database, session->id, nowWall, totalMs / 1000)) { + m_pendingCloses.append({session->id, nowWall, totalMs / 1000}); + m_lastCloseAttemptMs = nowMs; + m_storageFailure = true; + } + if (!session->rescanSource.isEmpty() && !m_rescanRequests.contains(session->rescanSource)) { + m_rescanRequests.append(session->rescanSource); + } + return m_active.erase(session); +} + +void SessionRecorder::retryClosed(qint64 nowMs) { + if (m_pendingCloses.isEmpty() || nowMs - m_lastCloseAttemptMs < m_flushIntervalMs) + return; + m_lastCloseAttemptMs = nowMs; + for (qsizetype i = 0; i < m_pendingCloses.size();) { + const auto pending = m_pendingCloses.at(i); + if (SessionDatabase::endSession(m_database, pending.id, pending.endedAt, pending.seconds)) + m_pendingCloses.removeAt(i); + else { + m_storageFailure = true; + ++i; + } + } +} + +void SessionRecorder::sync(const QVector& matches, qint64 nowWall) { + const qint64 nowMs = m_elapsedMs(); + retryClosed(nowMs); + QSet matched; + matched.reserve(matches.size()); + for (const SessionMatch& match : matches) { + const QString key = keyFor(match); + matched.insert(key); + auto existing = m_active.find(key); + // The same emulator process can report a different game on a later poll. + if (existing != m_active.end() && existing->gamePath != match.gamePath) { + closeSession(existing, nowMs, nowWall); + existing = m_active.end(); + } + if (existing == m_active.end()) { + const qint64 id = SessionDatabase::beginSession(m_database, match.gamePath, match.emulator, + nowWall, match.pid, match.procStart); + if (id <= 0) { + m_storageFailure = true; + continue; + } + ActiveSession session; + session.id = id; + session.gamePath = match.gamePath; + session.emulator = match.emulator; + session.rescanSource = match.rescanSource; + session.markMs = nowMs; + session.lastFlushMs = nowMs; + m_active.insert(key, session); + continue; + } + existing->elapsedMs += nowMs - existing->markMs; + existing->markMs = nowMs; + if (nowMs - existing->lastFlushMs >= m_flushIntervalMs) { + flush(*existing, nowMs, nowWall); + } + } + for (auto it = m_active.begin(); it != m_active.end();) { + if (!matched.contains(it.key())) { + it = closeSession(it, nowMs, nowWall); + } else { + ++it; + } + } +} + +void SessionRecorder::endAll(qint64 nowWall) { + const qint64 nowMs = m_elapsedMs(); + retryClosed(nowMs); + for (auto it = m_active.begin(); it != m_active.end();) { + it = closeSession(it, nowMs, nowWall); + } + // Pending closures retain their original boundary. A blanket close must not + // replace it with a later toggle/poll time while storage is unavailable. + if (m_pendingCloses.isEmpty() && !SessionDatabase::endAllSessions(m_database, nowWall)) + m_storageFailure = true; +} + +QStringList SessionRecorder::takeRescanRequests() { return std::move(m_rescanRequests); } diff --git a/src/tracking/SessionRecorder.h b/src/tracking/SessionRecorder.h new file mode 100644 index 0000000..5ea0022 --- /dev/null +++ b/src/tracking/SessionRecorder.h @@ -0,0 +1,79 @@ +#pragma once + +#include "tracking/ProcFs.h" +#include "tracking/ProcessMatcher.h" +#include "tracking/SessionDatabase.h" + +#include +#include + +#include +#include + +// Turns emulator process sightings into play_sessions rows. One recorder owns +// the active sessions of one database connection. Elapsed time comes from a +// monotonic clock, so suspended wall time is never billed as play time, and +// every row is flushed periodically so a crash loses at most one interval. +class SessionRecorder final { +public: + // elapsedMs must return monotonic milliseconds. The default uses the process + // start; tests inject a controllable clock. + explicit SessionRecorder(const QSqlDatabase& database, + const std::function& elapsedMs = {}); + ~SessionRecorder(); + + void setFlushIntervalMs(int intervalMs); + + // Startup: closes open sessions whose process is gone (at the last heartbeat), + // adopts survivors still running the same game, and closes any that now run a + // different game. + void recover(const QVector& processes, const ProcessProfileSet& profiles, + qint64 nowWall); + + // One poll: opens sessions for new matches, extends live ones, and closes + // sessions whose process disappeared. + void sync(const QVector& matches, qint64 nowWall); + + // Closes everything, used when tracking is switched off. + void endAll(qint64 nowWall); + + // Omakade sources that should rescan, one entry per ended session with a + // rescan mapping, deduplicated since the previous call. + [[nodiscard]] QStringList takeRescanRequests(); + + bool takeStorageFailure() { return std::exchange(m_storageFailure, false); } + [[nodiscard]] int pendingCloseCount() const { return m_pendingCloses.size(); } + + [[nodiscard]] int activeCount() const { return static_cast(m_active.size()); } + +private: + struct ActiveSession { + qint64 id = 0; + QString gamePath; + QString emulator; + QString rescanSource; + qint64 elapsedMs = 0; + qint64 markMs = 0; + qint64 lastFlushMs = 0; + }; + + QString keyFor(const SessionMatch& match) const; + QHash::Iterator + closeSession(QHash::Iterator session, qint64 nowMs, qint64 nowWall); + void flush(ActiveSession& session, qint64 nowMs, qint64 nowWall); + void retryClosed(qint64 nowMs); + struct PendingClose { + qint64 id; + qint64 endedAt; + qint64 seconds; + }; + QVector m_pendingCloses; + qint64 m_lastCloseAttemptMs = 0; + bool m_storageFailure = false; + + QSqlDatabase m_database; + std::function m_elapsedMs; + int m_flushIntervalMs = 30000; + QHash m_active; + QStringList m_rescanRequests; +}; diff --git a/tests/BackupRecoveryTests.cpp b/tests/BackupRecoveryTests.cpp index 3fcb211..d29867a 100644 --- a/tests/BackupRecoveryTests.cpp +++ b/tests/BackupRecoveryTests.cpp @@ -42,6 +42,50 @@ BackupPayload payload(const QString& title, bool couch) { {"collections", QJsonArray{QJsonObject{{"name", title}, {"created_at", 1788566400}}}}}; return value; } +BackupPayload historyPayload(const QString& title, bool couch) { + auto value = payload(title, couch); + const QString path = "/games/" + title + ".nes"; + const QString uuid = + QUuid::fromRfc4122(QCryptographicHash::hash(title.toUtf8(), QCryptographicHash::Md5)) + .toString(QUuid::WithoutBraces); + value.library["play_sessions"] = QJsonArray{QJsonObject{{"session_key", uuid}, + {"game_path", path}, + {"source", "Example"}, + {"started_at", 1000}, + {"ended_at", 1045}, + {"seconds", 45}}}; + value.library["play_baselines"] = QJsonArray{QJsonObject{ + {"game_path", path}, {"baseline_seconds", 100}, {"captured_at", 1000}, {"schema", 1}}}; + const QString key = QString("Example") + QChar::Null + QChar::Null + path; + value.library["game_metadata"] = QJsonArray{ + QJsonObject{{"game_key", key}, {"payload", "{\"igdbId\":42,\"manualMatch\":true}"}}}; + return value; +} +QStringList historyPaths(const QString& path) { + BackupPayload snapshot; + if (!BackupSnapshot::capture(path, {}, &snapshot)) + return {"capture failed"}; + QStringList result; + for (const auto& row : snapshot.library.value("play_sessions").toArray()) + result.append(row.toObject().value("game_path").toString()); + result.sort(); + return result; +} +QStringList identificationPaths(const QString& path) { + BackupPayload snapshot; + if (!BackupSnapshot::capture(path, {}, &snapshot)) + return {"capture failed"}; + QStringList result; + for (const auto& row : snapshot.library.value("game_metadata").toArray()) { + const auto choice = + QJsonDocument::fromJson(row.toObject().value("payload").toString().toUtf8()).object(); + if (!choice.value("manualMatch").toBool() || choice.value("igdbId").toInt() != 42) + return {"invalid identification"}; + result.append(row.toObject().value("game_key").toString().split(QChar::Null).last()); + } + result.sort(); + return result; +} QByteArray read(const QString& path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) @@ -179,7 +223,7 @@ void BackupRecoveryTests::abruptRestore() { QTemporaryDir temp; const auto p = paths(temp.path()); QString error; - QVERIFY2(BackupDatabase::restore(p.database, payload("Local", false), + QVERIFY2(BackupDatabase::restore(p.database, historyPayload("Local", false), BackupDatabase::Mode::Replace, &error), qPrintable(error)); AppSettings settings(p.settings); @@ -187,13 +231,13 @@ void BackupRecoveryTests::abruptRestore() { settings.setSunshineGameApps(true); const auto originalSettings = read(p.settings); BackupRecovery recovery(p); - QVERIFY2(recovery.stage(payload("Imported", true), + QVERIFY2(recovery.stage(historyPayload("Imported", true), merge ? BackupDatabase::Mode::Merge : BackupDatabase::Mode::Replace, &error), qPrintable(error)); QCOMPARE(collections(p.database), QStringList{"Local"}); QCOMPARE(read(p.settings), originalSettings); - QVERIFY(!recovery.stage(payload("Other", false), BackupDatabase::Mode::Merge, &error)); + QVERIFY(!recovery.stage(historyPayload("Other", false), BackupDatabase::Mode::Merge, &error)); QCOMPARE(child(temp.path(), checkpoint), 73); QCOMPARE(recovery.status(), checkpoint == "complete" ? "complete" : "prepared"); const auto archive = recovery.recoveryArchive(); @@ -203,6 +247,10 @@ void BackupRecoveryTests::abruptRestore() { QCOMPARE(recovery.status(), "complete"); QCOMPARE(collections(p.database), merge ? QStringList({"Imported", "Local"}) : QStringList{"Imported"}); + QCOMPARE(historyPaths(p.database), merge + ? QStringList({"/games/Imported.nes", "/games/Local.nes"}) + : QStringList{"/games/Imported.nes"}); + QCOMPARE(identificationPaths(p.database), historyPaths(p.database)); AppSettings restored(p.settings); QVERIFY(restored.couchModeEnabled()); QCOMPARE(restored.igdbClientId(), "localclient"); @@ -217,7 +265,7 @@ void BackupRecoveryTests::abruptRestore() { "Local"); QVERIFY(!QJsonDocument(before.settings).toJson().contains("localclient")); // The completed recovery job is retained when a later request is staged. - QVERIFY(recovery.stage(payload("Later", false), BackupDatabase::Mode::Merge, &error)); + QVERIFY(recovery.stage(historyPayload("Later", false), BackupDatabase::Mode::Merge, &error)); QCOMPARE(read(archive), recoveryBytes); QVERIFY(recovery.undo(&error)); QCOMPARE(recovery.status(), "reverted"); @@ -233,14 +281,14 @@ void BackupRecoveryTests::abruptUndo() { QTemporaryDir temp; const auto p = paths(temp.path()); QString error; - QVERIFY(BackupDatabase::restore(p.database, payload("Original", false), + QVERIFY(BackupDatabase::restore(p.database, historyPayload("Original", false), BackupDatabase::Mode::Replace, &error)); const QByteArray original = "# Original formatting\nigdb_client_id = \"localclient\"\nunknown_future_key = 17\n"; QVERIFY(write(p.settings, original)); QCOMPARE(AppSettings(p.settings).igdbClientId(), "localclient"); BackupRecovery recovery(p); - QVERIFY(recovery.stage(payload("Imported", true), BackupDatabase::Mode::Replace, &error)); + QVERIFY(recovery.stage(historyPayload("Imported", true), BackupDatabase::Mode::Replace, &error)); QCOMPARE(child(temp.path(), "settings"), 73); QCOMPARE(collections(p.database), QStringList{"Imported"}); QVERIFY(read(p.settings) != original); @@ -250,6 +298,8 @@ void BackupRecoveryTests::abruptUndo() { QVERIFY2(recovery.resume(&error), qPrintable(error)); QCOMPARE(recovery.status(), "reverted"); QCOMPARE(collections(p.database), QStringList{"Original"}); + QCOMPARE(historyPaths(p.database), QStringList{"/games/Original.nes"}); + QCOMPARE(identificationPaths(p.database), historyPaths(p.database)); QCOMPARE(read(p.settings), original); QVERIFY(recovery.resume(&error)); QCOMPARE(read(p.settings), original); @@ -306,7 +356,7 @@ void BackupRecoveryTests::failedSettingsWriteKeepsRecoveryPending() { QVERIFY(write(p.settings, original)); QCOMPARE(AppSettings(p.settings).igdbClientId(), "localclient"); QString error; - QVERIFY(BackupDatabase::restore(p.database, payload("Original", false), + QVERIFY(BackupDatabase::restore(p.database, historyPayload("Original", false), BackupDatabase::Mode::Replace, &error)); BackupRecovery recovery(p, [&](const QString& checkpoint) { if (checkpoint == "database") { @@ -315,7 +365,7 @@ void BackupRecoveryTests::failedSettingsWriteKeepsRecoveryPending() { QVERIFY(write(config, "blocked")); } }); - QVERIFY(recovery.stage(payload("Imported", true), BackupDatabase::Mode::Replace, &error)); + QVERIFY(recovery.stage(historyPayload("Imported", true), BackupDatabase::Mode::Replace, &error)); QVERIFY(!recovery.resume(&error)); QVERIFY(error.contains("preferences")); QCOMPARE(recovery.status(), "prepared"); @@ -325,6 +375,8 @@ void BackupRecoveryTests::failedSettingsWriteKeepsRecoveryPending() { BackupRecovery retry(p); QVERIFY2(retry.undo(&error), qPrintable(error)); QCOMPARE(collections(p.database), QStringList{"Original"}); + QCOMPARE(historyPaths(p.database), QStringList{"/games/Original.nes"}); + QCOMPARE(identificationPaths(p.database), historyPaths(p.database)); QCOMPARE(read(p.settings), original); const auto permissions = QFileInfo(retry.recoveryArchive()).permissions(); QVERIFY(!(permissions & diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0e3baee..291c043 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,6 +1,8 @@ qt_add_executable(omakade_core_tests CoreTests.cpp ) +target_compile_definitions(omakade_core_tests PRIVATE + OMAKADE_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures") add_test(NAME omakade_details_recovery_isolation COMMAND ${CMAKE_COMMAND} -DOMAKADE=$ @@ -77,6 +79,7 @@ target_link_libraries(omakade_core_tests PRIVATE Qt6::Test ) +add_dependencies(omakade_core_tests omakade-sessiond) add_test(NAME omakade_core_tests COMMAND omakade_core_tests) set_tests_properties(omakade_core_tests PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" @@ -113,6 +116,13 @@ set_tests_properties(omakade_controller_navigation_tiled PROPERTIES TIMEOUT 30 ) +add_test(NAME omakade_controller_navigation_narrow + COMMAND omakade --controller-navigation-test --owned-layout-test --uninstalled-layout-test + --render-size=600x800) +set_tests_properties(omakade_controller_navigation_narrow PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + TIMEOUT 30) + add_test(NAME omakade_controller_navigation_wide COMMAND omakade --controller-navigation-test --owned-layout-test --uninstalled-layout-test --render-size=1600x900 @@ -538,7 +548,7 @@ foreach(editor_case IN ITEMS saved-filter bulk-editor manual-editor) endforeach() endforeach() -foreach(settings_page IN ITEMS sources library connections controls about connection-0 connection-1 connection-2 connection-3) +foreach(settings_page IN ITEMS sources library connections controls storage appearance streaming about categories connection-0 connection-1 connection-2 connection-3) foreach(settings_mode IN ITEMS desktop couch) set(settings_args) if(settings_mode STREQUAL "couch") @@ -553,3 +563,256 @@ foreach(settings_page IN ITEMS sources library connections controls about connec TIMEOUT 30) endforeach() endforeach() + +# Populated and empty provider details, including keyboard expand/collapse. +foreach(info_case IN ITEMS game-info game-info-expanded game-info-empty) + foreach(info_mode IN ITEMS desktop couch) + set(info_args --render-overlay=${info_case}) + if(info_mode STREQUAL "couch") + list(APPEND info_args --couch --render-size=1920x1080) + else() + list(APPEND info_args --render-size=900x720) + endif() + add_test(NAME omakade_${info_case}_${info_mode} COMMAND omakade ${info_args} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/${info_case}-${info_mode}.png) + set_tests_properties(omakade_${info_case}_${info_mode} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + TIMEOUT 20) + endforeach() +endforeach() + +# The real organization controls must remain navigable when detail rows wrap. +add_test(NAME omakade_game_info_navigation_narrow + COMMAND omakade --render-overlay=game-info --render-size=600x800 + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/game-info-navigation-narrow.png) +add_test(NAME omakade_game_info_navigation_couch_720p + COMMAND omakade --owned-layout-test --couch --render-overlay=game-info-expanded --render-size=1280x720 + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/game-info-navigation-couch-720p.png) +set_tests_properties(omakade_game_info_navigation_narrow omakade_game_info_navigation_couch_720p + PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + TIMEOUT 20) + +foreach(discovery_size IN ITEMS 600x800 1280x720) + add_test(NAME omakade_metadata_filters_${discovery_size} COMMAND omakade + --render-overlay=metadata-filters --render-size=${discovery_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/metadata-filters-${discovery_size}.png) + set_tests_properties(omakade_metadata_filters_${discovery_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + TIMEOUT 20) +endforeach() + +foreach(home_mode IN ITEMS desktop couch) + set(home_args) + if(home_mode STREQUAL "couch") + list(APPEND home_args --couch) + endif() + foreach(home_size IN ITEMS 600x800 1280x720) + add_test(NAME omakade_home_${home_mode}_${home_size} COMMAND omakade ${home_args} + --render-overlay=home --render-size=${home_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/home-${home_mode}-${home_size}.png) + set_tests_properties(omakade_home_${home_mode}_${home_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + TIMEOUT 20) + endforeach() +endforeach() + +foreach(hero_case IN ITEMS legacy screenshot custom) + foreach(hero_size IN ITEMS 600x800 1920x1080) + add_test(NAME omakade_hero_${hero_case}_${hero_size} COMMAND omakade + --render-overlay=game-info-hero-${hero_case} --render-size=${hero_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/hero-${hero_case}-${hero_size}.png) + set_tests_properties(omakade_hero_${hero_case}_${hero_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + TIMEOUT 20) + endforeach() +endforeach() + +# Responsive menu previews also validate QML loading on the installed candidate. +foreach(nav_menu IN ITEMS library-sources library-filters library-view library-actions detail-manage) + add_test(NAME omakade_menu_${nav_menu} + COMMAND omakade --owned-layout-test --render-size=600x800 --render-overlay=${nav_menu} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/${nav_menu}.png) + set_tests_properties(omakade_menu_${nav_menu} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + TIMEOUT 20) +endforeach() +add_test(NAME omakade_menu_detail_manage_4k + COMMAND omakade --couch --owned-layout-test --render-size=3840x2160 --render-overlay=detail-manage + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/detail-manage-4k.png) +set_tests_properties(omakade_menu_detail_manage_4k PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + TIMEOUT 20) + +foreach(home_overview_size IN ITEMS 600x800 1280x720 2048x1152) + add_test(NAME omakade_home_overview_${home_overview_size} COMMAND omakade + --render-overlay=home-overview --render-size=${home_overview_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/home-overview-${home_overview_size}.png) + set_tests_properties(omakade_home_overview_${home_overview_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + TIMEOUT 20) +endforeach() + +add_test(NAME omakade_home_empty COMMAND omakade --render-overlay=home-empty --render-size=600x800 + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/home-empty.png) +set_tests_properties(omakade_home_empty PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" TIMEOUT 20) + +foreach(home_delayed_mode IN ITEMS desktop couch) + set(home_delayed_args) + if(home_delayed_mode STREQUAL "couch") + list(APPEND home_delayed_args --couch) + endif() + foreach(home_delayed_size IN ITEMS 600x800 1280x720 1920x1080 3840x2160) + add_test(NAME omakade_home_delayed_${home_delayed_mode}_${home_delayed_size} COMMAND omakade ${home_delayed_args} + --render-overlay=home-delayed --render-size=${home_delayed_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/home-delayed-${home_delayed_mode}-${home_delayed_size}.png) + set_tests_properties(omakade_home_delayed_${home_delayed_mode}_${home_delayed_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "polish.*loop;Binding loop;TypeError;ReferenceError" + TIMEOUT 20) + endforeach() +endforeach() + +foreach(home_wheel_motion IN ITEMS animated reduced) + set(home_wheel_args) + if(home_wheel_motion STREQUAL "reduced") + list(APPEND home_wheel_args --reduced-motion) + endif() + foreach(home_wheel_size IN ITEMS 600x800 1920x1080) + add_test(NAME omakade_home_wheel_${home_wheel_motion}_${home_wheel_size} COMMAND omakade ${home_wheel_args} + --render-overlay=home-wheel --render-size=${home_wheel_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/home-wheel-${home_wheel_motion}-${home_wheel_size}.png) + set_tests_properties(omakade_home_wheel_${home_wheel_motion}_${home_wheel_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "polish.*loop;Binding loop;TypeError;ReferenceError" TIMEOUT 20) + endforeach() +endforeach() + +foreach(home_stream_size IN ITEMS 600x800 1920x1080) + add_test(NAME omakade_home_wheel_stream_${home_stream_size} COMMAND omakade + --render-overlay=home-wheel-stream --render-size=${home_stream_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/home-wheel-stream-${home_stream_size}.png) + set_tests_properties(omakade_home_wheel_stream_${home_stream_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "polish.*loop;Binding loop;TypeError;ReferenceError" TIMEOUT 20) +endforeach() + +# Opening details keeps identity, activity and launch actions in the viewport. +foreach(detail_overview_mode IN ITEMS desktop couch) + set(detail_overview_args) + if(detail_overview_mode STREQUAL "couch") + list(APPEND detail_overview_args --couch) + endif() + foreach(detail_overview_size IN ITEMS 600x800 1280x720 1920x1080) + add_test(NAME omakade_detail_overview_${detail_overview_mode}_${detail_overview_size} + COMMAND omakade --owned-layout-test ${detail_overview_args} + --render-overlay=game-info-overview-long --render-size=${detail_overview_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/detail-overview-${detail_overview_mode}-${detail_overview_size}.png) + set_tests_properties(omakade_detail_overview_${detail_overview_mode}_${detail_overview_size} + PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "Binding loop;TypeError;ReferenceError" TIMEOUT 20) + endforeach() +endforeach() + +foreach(identify_size IN ITEMS 600x800 1280x720) + add_test(NAME omakade_identify_panel_${identify_size} + COMMAND omakade --owned-layout-test --render-overlay=game-info-identify --render-size=${identify_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/identify-panel-${identify_size}.png) + set_tests_properties(omakade_identify_panel_${identify_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "Binding loop;TypeError;ReferenceError" TIMEOUT 20) +endforeach() + +foreach(panel_size IN ITEMS 600x800 1280x720) + add_test(NAME omakade_artwork_matched_${panel_size} + COMMAND omakade --owned-layout-test --render-overlay=game-info-identify-matched --render-size=${panel_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/artwork-matched-${panel_size}.png) + set_tests_properties(omakade_artwork_matched_${panel_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "Binding loop;TypeError;ReferenceError" TIMEOUT 20) +endforeach() + +foreach(panel_size IN ITEMS 600x800 1280x720) + add_test(NAME omakade_artwork_late_resize_${panel_size} + COMMAND omakade --owned-layout-test --render-overlay=game-info-identify-late-matched --render-size=${panel_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/artwork-late-${panel_size}.png) + set_tests_properties(omakade_artwork_late_resize_${panel_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "Binding loop;TypeError;ReferenceError" TIMEOUT 20) +endforeach() + +foreach(tooltip_mode IN ITEMS desktop narrow couch) + if(tooltip_mode STREQUAL "narrow") + set(tooltip_size 600x800) + else() + set(tooltip_size 1280x720) + endif() + set(tooltip_args) + if(tooltip_mode STREQUAL "couch") + list(APPEND tooltip_args --couch) + endif() + foreach(tooltip_target IN ITEMS platform date rating missing) + add_test(NAME omakade_rating_tooltip_${tooltip_mode}_${tooltip_target} + COMMAND omakade --owned-layout-test ${tooltip_args} + --render-overlay=game-info-tooltip-${tooltip_target} --render-size=${tooltip_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/tooltip-${tooltip_mode}-${tooltip_target}.png) + set_tests_properties(omakade_rating_tooltip_${tooltip_mode}_${tooltip_target} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "Binding loop;TypeError;ReferenceError" TIMEOUT 20) + endforeach() +endforeach() + +add_test(NAME omakade_library_reflow + COMMAND omakade --owned-layout-test --render-overlay=library-reflow --render-size=1255x1000 + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/library-reflow.png) +set_tests_properties(omakade_library_reflow PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "Binding loop;TypeError;ReferenceError" TIMEOUT 20) + +add_test(NAME omakade_library_return_recent + COMMAND omakade --owned-layout-test --render-overlay=library-reflow-return --render-size=2024x1104 + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/library-return-recent.png) +set_tests_properties(omakade_library_return_recent PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "Binding loop;TypeError;ReferenceError" TIMEOUT 20) + +foreach(launch_mode IN ITEMS desktop couch) + set(launch_args --render-size=600x800) + if(launch_mode STREQUAL "couch") + set(launch_args --couch --render-size=1280x720) + endif() + add_test(NAME omakade_launch_feedback_${launch_mode} COMMAND omakade ${launch_args} + --render-overlay=launch-feedback + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/launch-feedback-${launch_mode}.png) + set_tests_properties(omakade_launch_feedback_${launch_mode} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + TIMEOUT 20) +endforeach() + +foreach(queue_mode IN ITEMS desktop couch) + set(queue_args) + if(queue_mode STREQUAL "couch") + list(APPEND queue_args --couch) + endif() + add_test(NAME omakade_home_full_queue_${queue_mode} COMMAND omakade ${queue_args} + --render-overlay=home-full-queue --render-size=1280x720 + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/home-full-queue-${queue_mode}.png) + set_tests_properties(omakade_home_full_queue_${queue_mode} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "polish.*loop;Binding loop;TypeError;ReferenceError" TIMEOUT 30) +endforeach() + +foreach(recorder_overlay IN ITEMS recorder-details settings-recorder-on settings-recorder-off) + foreach(recorder_size IN ITEMS 600x800 1920x1080) + set(recorder_args) + if(recorder_size STREQUAL "1920x1080") + list(APPEND recorder_args --couch) + endif() + add_test(NAME omakade_${recorder_overlay}_${recorder_size} COMMAND omakade ${recorder_args} + --render-overlay=${recorder_overlay} --render-size=${recorder_size} + --render-screenshot=${CMAKE_CURRENT_BINARY_DIR}/${recorder_overlay}-${recorder_size}.png) + set_tests_properties(omakade_${recorder_overlay}_${recorder_size} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QT_FORCE_STDERR_LOGGING=1" + FAIL_REGULAR_EXPRESSION "polish.*loop;Binding loop;TypeError;ReferenceError" TIMEOUT 20) + endforeach() +endforeach() diff --git a/tests/CoreTests.cpp b/tests/CoreTests.cpp index c34d8bd..4bedbf4 100644 --- a/tests/CoreTests.cpp +++ b/tests/CoreTests.cpp @@ -1,67 +1,81 @@ -#include -#include -#include -#include -#include "metadata/GameMetadata.h" #include "achievements/AchievementModel.h" #include "achievements/RetroAchievementsApi.h" #include "achievements/RetroAchievementsHasher.h" #include "achievements/RetroAchievementsService.h" +#include "library/ArtworkPersistence.h" +#include "library/CoverCachePolicy.h" +#include "metadata/GameMetadata.h" +#include "metadata/RegionalMetadata.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include -#include #include "achievements/SteamAchievementApi.h" #include "app/AppSettings.h" -#include "artwork/SwitchTitleReader.h" +#include "app/SingleInstance.h" #include "artwork/CoverImageProvider.h" +#include "artwork/SwitchTitleReader.h" #include "artwork/TgaImage.h" #include "artwork/ZArchiveReader.h" #include "backup/BackupArchive.h" -#include "backup/BackupSnapshot.h" #include "backup/BackupDatabase.h" -#include "app/SingleInstance.h" -#include "input/ControllerInput.h" +#include "backup/BackupSnapshot.h" #include "input/ControllerFocusGuard.h" +#include "input/ControllerInput.h" #include "input/CouchCursorManager.h" #include "launch/GameLauncher.h" #include "launch/PlayRequest.h" #include "launch/SteamLauncher.h" #include "library/BattleNetGameModel.h" +#include "library/CemuGameModel.h" +#include "library/ConsoleCatalog.h" +#include "library/ConsolePortalModel.h" +#include "library/DolphinGameModel.h" #include "library/FaugusGameModel.h" #include "library/GameRoles.h" #include "library/HeroicGameModel.h" +#include "library/HomeModel.h" #include "library/LibraryFilterModel.h" #include "library/LutrisGameModel.h" -#include "library/MockGameModel.h" #include "library/ManualGameModel.h" -#include "library/PersonalDataRules.h" +#include "library/MockGameModel.h" #include "library/Pcsx2GameModel.h" +#include "library/PersonalDataRules.h" +#include "library/RetroArchGameModel.h" #include "library/RyujinxGameModel.h" #include "library/Shadps4GameModel.h" -#include "library/CemuGameModel.h" -#include "library/RetroArchGameModel.h" #include "library/SteamGameModel.h" #include "library/SteamOwnedGamesApi.h" -#include "library/ConsoleCatalog.h" -#include "library/ConsolePortalModel.h" #include "library/UnifiedGameModel.h" #include "metadata/GameInsightsService.h" #include "metadata/IgdbApi.h" #include "sources/battlenet/BattleNetScanner.h" -#include "sources/faugus/FaugusScanner.h" -#include "sources/heroic/HeroicScanner.h" -#include "sources/pcsx2/Pcsx2Scanner.h" -#include "sources/ryujinx/RyujinxScanner.h" -#include "sources/shadps4/Shadps4Scanner.h" #include "sources/cemu/CemuScanner.h" #include "sources/dolphin/DolphinScanner.h" -#include "library/DolphinGameModel.h" +#include "sources/faugus/FaugusScanner.h" +#include "sources/heroic/HeroicScanner.h" #include "sources/lutris/LutrisScanner.h" +#include "sources/pcsx2/Pcsx2Scanner.h" #include "sources/retro/RomFolderScanner.h" #include "sources/retroarch/RetroArchScanner.h" +#include "sources/ryujinx/RyujinxScanner.h" +#include "sources/shadps4/Shadps4Scanner.h" #include "sources/steam/SteamScanner.h" #include "sources/steam/ValveKeyValues.h" #include "streaming/SunshineIntegration.h" #include "theme/OmarchyTheme.h" +#include "tracking/PlaySessionStore.h" +#include "tracking/ProcFs.h" +#include "tracking/ProcessMatcher.h" +#include "tracking/SessionDatabase.h" +#include "tracking/SessionRecorder.h" +#include #include #include @@ -76,6 +90,7 @@ #include #include #include +#include #include #include #include @@ -681,12 +696,21 @@ private slots: void changingSourceLeavesConsoleDrillIn(); void randomPickRespectsFiltersAndLinkedIdentity(); void savedFiltersPersistAndPreserveQueries(); + void metadataDiscoveryFiltersPersistAndRefresh(); + void metadataUpdatesOnlyInvalidateChangedRoles(); + void homeQueuePreservesIdentityAndStorage(); + void homeQueueCapacityAndRecovery(); + void recordingPreferenceMigration(); + void homeDiscoveryRespectsLibraryState(); + void homeRefreshOnlyReadsChangedGames(); void completionWorkflowPersistsAtLibraryScale(); void bulkOrganizationIsAtomicAndPreservesSelection(); void backupArchiveRoundTripsAndRejectsInvalidContent(); void backupSnapshotConsolidatesLegacyPersonalState(); void backupDatabaseMergeReplaceAndRollback(); void backupSettingsApplyAtomicallyAndKeepAccounts(); + void backupPreservesIdentificationChoices(); + void backupIncludesCurrentPreferences(); void themeLoadsSemanticColors(); void themeFallsBackWithoutOmarchy(); void themeReloadsWhenActiveFileChanges(); @@ -770,6 +794,13 @@ private slots: void cemuModelIsRepeatableAndPreservesLocalState(); void malformedCemuDataDoesNotReplaceCachedGames(); void cemuLauncherBuildsSafeCommands(); + void processMatcherExtractsRomPaths(); + void processDiscoveryStaysWithinCurrentUser(); + void sessionRecorderTracksExtendsAndClosesSessions(); + void sessionRecorderSeparatesGamesWithinOneProcess(); + void sessionRecorderSurvivesRestartsWithoutInventingTime(); + void sessionStoreMergesImportedAndTrackedPlaytime(); + void launchFeedbackGuardsRepeatedRequests(); void consolePortalsGroupRetroArchRomsAndCanFlatten(); void consolePortalsDoNotRebuildTheLibraryWhenCoversChange(); void consolePortalsDoNotMergeDifferentFiles(); @@ -790,6 +821,7 @@ private slots: void launcherReportsInvalidAndStaleTargets(); void igdbApiBuildsSafeQueriesAndParsesInsights(); void igdbInsightsLoadFromOfflineCache(); + void igdbRefreshIgnoresBackgroundCatalogWork(); void retroAchievementsHasherAppliesHeaderStripRules(); void retroAchievementsHasherReadsZipArchivedRoms(); void retroAchievementsApiBuildsUrlsAndParsesResponses(); @@ -798,6 +830,8 @@ private slots: void retroAchievementsServiceBlocksAccountSwitchWhileBusy(); void stressLibraryContainsOneThousandGames(); void settingsPersistReducedMotionAndCacheLimit(); + void settingsReportWriteFailuresAndRecover(); + void invalidMetadataResponseKeepsDataAndReportsFailure(); void gogFoldersPersistAndHandleDisconnectedRoots(); void manualGamesImportEditLaunchAndRemove(); void launchKeysRoundTripAndResolveInstallations(); @@ -815,12 +849,34 @@ private slots: void dreamcastFoldersBecomeAPortal(); void consoleLayoutsPinAndExpand(); void metadataMatchingKeepsPlatformsAndEditions(); + void ambiguousMetadataNeverUsesPopularity(); + void broadCatalogSearchRetriesExactTitle(); + void metadataWriteFailureIsRetryable(); void coverCacheDecodesArtworkOnce(); + void sharedCoverBudgetProtectsReferencedArtwork(); + void precisePlaytimeSortsAndNotifies(); + void sessionWriteFailureKeepsOriginalBoundary(); + void artworkWriteBatchRollsBackAndRemainsPending(); + void metadataRecognizesProviderAliases(); + void regionalReleaseEvidence(); + void regionalCatalogRegressionMatrix(); + void metadataCacheProtectsReferencedAndPendingPortraits(); + void backupPlayHistoryHasSafeMergeAndRecorderGuard(); void libraryOnlyResetsWhenGamesActuallyMove(); + void metadataCarriesReleaseCreditsGenresAndSummary(); + void metadataRefreshReplacesProviderFieldsAndPersists(); + void selectedMetadataStaysFirstInQueue(); + void explicitMetadataRefreshBypassesQueryCache(); + void sourceArtworkDoesNotDiscardDownloadedPortrait(); + void metadataHeroUsesProviderImageIds(); + void sessionRecoveryPreservesLiveProgress(); + void sessionDaemonRejectsDuplicateOwner(); + void sessionBaselineHandlesFirstAndLateObservation(); void coverSizesPersistIndependently(); void controllerNavigationFollowsWindowFocus(); void metadataPersistsRatingsAndPreservesCustomArt(); void portraitBatchContinuesAndKeepsRatingTimestamp(); + void artworkAliasesAndSharedIdentityRecoverMissingCovers(); void portraitSelectionCompletesOnlyAfterSuccessfulSave(); void unconfirmedGridSelectionIsDroppedOnARulesChange(); void startupBenchmarkDoesNotActivateAnotherInstance(); @@ -2224,7 +2280,8 @@ void CoreTests::backupArchiveRoundTripsAndRejectsInvalidContent() { restored = original; QVERIFY(!BackupArchive::read(badPath, &restored, &error)); QCOMPARE(restored.library, original.library); // Invalid reads never replace the caller's payload. - auto future = emptyManifest; future.insert("version", 2); + auto future = emptyManifest; + future.insert("version", 3); rawArchive(badPath, future, {}); QVERIFY(!BackupArchive::read(badPath, &restored, &error)); writeFile(badPath, before.left(before.size() / 2)); @@ -2719,6 +2776,9 @@ void CoreTests::explicitLinksPersistAndPreserveInstallations() { library.setSourceFilter(QStringLiteral("Lutris")); QCOMPARE(library.rowCount(), 1); QVERIFY(library.get(0).value(QStringLiteral("linked")).toBool()); + library.setSearchText(QStringLiteral("no-linked-launch-match")); + QCOMPARE(library.rowCount(), 0); + QVERIFY(library.recordLaunchByIdentity(QStringLiteral("Lutris"), QString{}, QStringLiteral("7"))); } MockGameModel demo(nullptr, 2); @@ -2794,6 +2854,13 @@ void CoreTests::launchActivityPersistsAndSortsExactly() { QStringLiteral("demo-10"))); QVERIFY(!library.recordLaunch(launchRow, QStringLiteral("Steam"), QString{}, QStringLiteral("10"))); + library.setSearchText(QStringLiteral("no-match-for-launch")); + QCOMPARE(library.rowCount(), 0); + QVERIFY(library.recordLaunchByIdentity(QStringLiteral("Demo"), QString{}, + QStringLiteral("demo-10"))); + QVERIFY(!library.recordLaunchByIdentity(QStringLiteral("Demo"), QString{}, + QStringLiteral("missing"))); + library.setSearchText({}); library.setMode(LibraryFilterModel::Mode::Recent); QCOMPARE(library.rowCount(), 10); library.setSortMode(LibraryFilterModel::SortMode::RecentlyPlayed); @@ -3930,6 +3997,56 @@ void CoreTests::igdbApiBuildsSafeQueriesAndParsesInsights() { QVERIFY(!IgdbApi::parseGame("not json", &insight, &error)); } +void CoreTests::igdbRefreshIgnoresBackgroundCatalogWork() { + QTemporaryDir directory; + AppSettings settings(directory.path() + QStringLiteral("/config.toml")); + GameInsightsService insights(directory.path() + QStringLiteral("/insights.db"), &settings); + insights.m_hasClientSecret = true; + settings.setIgdbClientId(QStringLiteral("fixtureclient")); + QVERIFY(insights.configured()); + insights.m_appId = QStringLiteral("10"); + insights.m_refreshAppId = QStringLiteral("10"); // Previous completed foreground request. + insights.m_statusText = QStringLiteral("Cached IGDB data"); + for (int pass = 0; pass < 3; ++pass) { + insights.m_catalogQuery = QByteArrayLiteral("fields name; limit 1;"); + insights.m_busy = true; + QVERIFY(insights.busy()); + QVERIFY(!insights.refreshing()); + insights.fail(QStringLiteral("Background catalog unavailable")); + QVERIFY(!insights.refreshing()); + QCOMPARE(insights.statusText(), QStringLiteral("Cached IGDB data")); + } + insights.m_catalogQuery = QByteArrayLiteral("fields id; limit 1;"); + insights.m_busy = true; + insights.m_testingConnection = true; + insights.fail(QStringLiteral("Connection test failed")); + QCOMPARE(insights.statusText(), QStringLiteral("Connection test failed")); + QVERIFY(!insights.m_testingConnection); + insights.m_catalogQuery = QByteArrayLiteral("fields name; limit 1;"); + insights.m_busy = true; + insights.refreshSteam(QStringLiteral("10")); + QVERIFY(insights.refreshing()); + QCOMPARE(insights.m_pendingRefreshAppId, QStringLiteral("10")); + insights.refreshSteam(QStringLiteral("10")); + QCOMPARE(insights.m_pendingRefreshAppId, QStringLiteral("10")); + insights.fail(QStringLiteral("Background catalog unavailable")); + QVERIFY(insights.refreshing()); // Queued click stays pending across the completion signal. + QVERIFY(!insights.requestCatalog(QByteArrayLiteral("fields name; limit 1;"))); + insights.m_hasClientSecret = false; // Losing credentials cancels without sending a request. + QTRY_VERIFY(insights.m_pendingRefreshAppId.isEmpty()); + QVERIFY(!insights.refreshing()); + insights.m_busy = true; + insights.m_refreshAppId = QStringLiteral("10"); + QVERIFY(insights.refreshing()); + insights.m_appId = QStringLiteral("20"); + QVERIFY(!insights.refreshing()); // An old game's request cannot animate the new game's button. + insights.m_pendingRefreshAppId = QStringLiteral("20"); + insights.loadSteam(QString{}); + QVERIFY(insights.m_pendingRefreshAppId.isEmpty()); + QVERIFY(!insights.refreshing()); + insights.m_busy = false; +} + void CoreTests::igdbInsightsLoadFromOfflineCache() { QTemporaryDir directory; QVERIFY(directory.isValid()); @@ -4725,6 +4842,9 @@ void CoreTests::singleInstanceForwardsPlayAndQuitCommands() { QSignalSpy plays(&primary, &SingleInstance::playRequested); QSignalSpy quits(&primary, &SingleInstance::quitRequested); QSignalSpy activations(&primary, &SingleInstance::activationRequested); + QSignalSpy storageFailures(&primary, &SingleInstance::trackingStorageFailed); + QVERIFY(SingleInstance::sendCommand(name, "tracking-storage-error")); + QTRY_COMPARE_WITH_TIMEOUT(storageFailures.size(), 1, 1000); QVERIFY(SingleInstance::sendCommand(name, "play Steam::620")); QTRY_COMPARE_WITH_TIMEOUT(plays.size(), 1, 1000); @@ -5815,6 +5935,40 @@ void CoreTests::malformedCemuDataDoesNotReplaceCachedGames() { QCOMPARE(model.rowCount(), 0); } +void CoreTests::launchFeedbackGuardsRepeatedRequests() { + QQmlEngine engine; + QQmlComponent component(&engine, QUrl::fromLocalFile( + QStringLiteral(OMAKADE_FIXTURE_DIR "/../../qml/components/LaunchFeedback.qml"))); + QScopedPointer feedback(component.create()); + QVERIFY2(feedback, qPrintable(component.errorString())); + QSignalSpy dispatch(feedback.data(), SIGNAL(dispatchRequested(QVariant))); + QVERIFY(dispatch.isValid()); + auto state = engine.newQObject(feedback.data()); + auto request = engine.evaluate("({title:'First game', installation:{appId:'first'}})"); + auto begin = state.property("begin"); + auto finish = state.property("finish"); + QVERIFY(begin.callWithInstance(state, {request}).toBool()); + QVERIFY(state.property("pending").toBool()); + QCOMPARE(state.property("message").toString(), QStringLiteral("Opening First game...")); + request.property("installation").setProperty("appId", QStringLiteral("second")); + QVERIFY(!begin.callWithInstance(state, {request}).toBool()); + QCOMPARE(state.property("request").property("installation").property("appId").toString(), + QStringLiteral("first")); + QTRY_COMPARE(dispatch.count(), 1); + finish.callWithInstance(state, {false, QStringLiteral("Missing executable")}); + QVERIFY(!state.property("pending").toBool()); + QVERIFY(state.property("failed").toBool()); + QCOMPARE(state.property("message").toString(), QStringLiteral("Missing executable")); + QVERIFY(begin.callWithInstance(state, {request}).toBool()); + QVERIFY(!state.property("failed").toBool()); + QTRY_COMPARE(dispatch.count(), 2); + finish.callWithInstance(state, {true, QStringLiteral("Opening First game")}); + QVERIFY(!begin.callWithInstance(state, {request}).toBool()); + QTRY_VERIFY_WITH_TIMEOUT(!state.property("pending").toBool(), 2500); + QVERIFY(state.property("message").toString().isEmpty()); + QCOMPARE(dispatch.count(), 2); +} + void CoreTests::consolePortalsGroupRetroArchRomsAndCanFlatten() { QTemporaryDir directory; QVERIFY(directory.isValid()); @@ -5924,6 +6078,14 @@ void CoreTests::consolePortalsDoNotRebuildTheLibraryWhenCoversChange() { QCOMPARE(portalResets.count(), 0); QCOMPARE(libraryResets.count(), 0); QCOMPARE(library.rowCount(), 1); + + // Startup rescans of unchanged ROMs must not invalidate the whole grid. + QSignalSpy portalChanges(&portals, &QAbstractItemModel::dataChanged); + QSignalSpy libraryLayouts(&library, &QAbstractItemModel::layoutChanged); + for (int scan = 0; scan < 3; ++scan) roms.refreshFromRoots({root}); + QCOMPARE(portalChanges.count(), 0); + QCOMPARE(libraryLayouts.count(), 0); + QCOMPARE(libraryResets.count(), 0); } void CoreTests::consolePortalsDoNotMergeDifferentFiles() { @@ -6128,6 +6290,13 @@ void CoreTests::downloadedCoversSurviveARescan() { .toString() .contains(QStringLiteral("downloaded.png"))); + // A missing file must be exposed as missing artwork so the view requests it again. + QVERIFY(QFile::remove(cached)); + RetroArchGameModel missing(database); + const int missingRow = rowFor(missing, QStringLiteral("Unassigned")); + QVERIFY(missingRow >= 0); + QVERIFY(missing.data(missing.index(missingRow), GameRoles::CoverPath).toString().isEmpty()); + // A library that has already lost its cover paths still has the files. Rather than making // someone scroll a thousand cartridges past the screen to download them a second time, the // covers already in the cache are taken back the next time the library is read. @@ -6260,6 +6429,160 @@ void CoreTests::cemuLauncherBuildsSafeCommands() { QVERIFY(!GameLauncher::cemuCommand(QStringLiteral("/games/notes.txt"), false).isValid()); } +void CoreTests::processMatcherExtractsRomPaths() { + ProcessProfileSet profiles; + profiles.emulators.append({.name = QStringLiteral("Ryujinx"), + .binaries = {QStringLiteral("Ryujinx")}, + .rescanSource = QStringLiteral("Ryujinx")}); + profiles.emulators.append({.name = QStringLiteral("Eden"), .binaries = {QStringLiteral("eden")}}); + profiles.romExtensions = {QStringLiteral("nsp"), QStringLiteral("sfc")}; + + const QVector processes = { + {.pid = 10, + .procStart = 100, + .comm = QStringLiteral("Ryujinx"), + .arguments = {QStringLiteral("/usr/bin/Ryujinx"), + QStringLiteral("/data/Games/Switch/Game.nsp")}}, + {.pid = 11, + .procStart = 101, + .comm = QStringLiteral("eden"), + .arguments = {QStringLiteral("AppRun"), QStringLiteral("-g"), + QStringLiteral("/data/Games/Switch/FFT The Ivalice.nsp")}}, + {.pid = 12, + .procStart = 102, + .comm = QStringLiteral("RetroArch"), + .arguments = {QStringLiteral("retroarch"), QStringLiteral("-L"), + QStringLiteral("/usr/lib/libretro/snes9x_libretro.so"), + QStringLiteral("/data/roms/snes/Game.sfc")}}, + {.pid = 13, + .procStart = 103, + .comm = QStringLiteral("rsync"), + .arguments = {QStringLiteral("rsync"), QStringLiteral("/backup/Game.nsp")}}, + {.pid = 14, + .procStart = 104, + .comm = QStringLiteral("Ryujinx"), + .arguments = {QStringLiteral("/usr/bin/Ryujinx")}}}; + const QVector matches = ProcessMatcher::match(processes, profiles); + QCOMPARE(matches.size(), 2); + QCOMPARE(matches.at(0).pid, qint64(10)); + QCOMPARE(matches.at(0).gamePath, QStringLiteral("/data/Games/Switch/Game.nsp")); + QCOMPARE(matches.at(0).emulator, QStringLiteral("Ryujinx")); + QCOMPARE(matches.at(0).rescanSource, QStringLiteral("Ryujinx")); + QCOMPARE(matches.at(1).pid, qint64(11)); + QCOMPARE(matches.at(1).gamePath, QStringLiteral("/data/Games/Switch/FFT The Ivalice.nsp")); + QCOMPARE(matches.at(1).emulator, QStringLiteral("Eden")); + QVERIFY(matches.at(1).rescanSource.isEmpty()); +} + +void CoreTests::sessionRecorderTracksExtendsAndClosesSessions() { + const QString connection = QStringLiteral("test-recorder-sync"); + { + QSqlDatabase database; + QVERIFY(SessionDatabase::open(database, QStringLiteral(":memory:"), connection)); + + qint64 nowMs = 0; + SessionRecorder recorder(database, [&nowMs] { return nowMs; }); + recorder.setFlushIntervalMs(60000); + const QVector processes = { + {.pid = 10, + .procStart = 100, + .comm = QStringLiteral("Ryujinx"), + .arguments = {QStringLiteral("Ryujinx"), QStringLiteral("/games/a.nsp")}}, + {.pid = 11, + .procStart = 101, + .comm = QStringLiteral("dolphin-emu"), + .arguments = {QStringLiteral("dolphin-emu"), QStringLiteral("-e"), + QStringLiteral("/games/b.iso")}}}; + const ProcessProfileSet profiles = { + .emulators = {{.name = QStringLiteral("Ryujinx"), + .binaries = {QStringLiteral("Ryujinx")}, + .rescanSource = QStringLiteral("Ryujinx")}, + {.name = QStringLiteral("Dolphin"), + .binaries = {QStringLiteral("dolphin-emu")}}}, + .romExtensions = {QStringLiteral("nsp"), QStringLiteral("iso")}}; + + recorder.sync(ProcessMatcher::match(processes, profiles), 1000); + QCOMPARE(recorder.activeCount(), 2); + nowMs = 60000; + recorder.sync(ProcessMatcher::match(processes, profiles), 1060); + QCOMPARE(recorder.activeCount(), 2); + nowMs = 120000; + recorder.sync({}, 1120); + QCOMPARE(recorder.activeCount(), 0); + const QHash totals = SessionDatabase::trackedSecondsByPath(database); + QCOMPARE(totals.value(QStringLiteral("/games/a.nsp")), qint64(120)); + QCOMPARE(totals.value(QStringLiteral("/games/b.iso")), qint64(120)); + const QHash lastPlayed = SessionDatabase::lastPlayedByPath(database); + QCOMPARE(lastPlayed.value(QStringLiteral("/games/a.nsp")), qint64(1120)); + const QStringList rescans = recorder.takeRescanRequests(); + QCOMPARE(rescans, QStringList{QStringLiteral("Ryujinx")}); + QVERIFY(recorder.takeRescanRequests().isEmpty()); + } + QSqlDatabase::removeDatabase(connection); +} + +void CoreTests::sessionRecorderSurvivesRestartsWithoutInventingTime() { + const QString connection = QStringLiteral("test-recorder-recover"); + { + QSqlDatabase database; + QVERIFY(SessionDatabase::open(database, QStringLiteral(":memory:"), connection)); + + qint64 nowMs = 0; + SessionRecorder recorder(database, [&nowMs] { return nowMs; }); + recorder.setFlushIntervalMs(1); + recorder.sync({{.pid = 7, + .procStart = 70, + .emulator = QStringLiteral("Ryujinx"), + .rescanSource = QStringLiteral("Ryujinx"), + .gamePath = QStringLiteral("/games/a.nsp")}}, + 2000); + nowMs = 30000; + recorder.sync({{.pid = 7, + .procStart = 70, + .emulator = QStringLiteral("Ryujinx"), + .rescanSource = QStringLiteral("Ryujinx"), + .gamePath = QStringLiteral("/games/a.nsp")}}, + 2030); + + // A restart loses the in-memory state; the open session with no live process + // behind it closes at its heartbeat, never at the current wall clock. + SessionRecorder restarted(database, [&nowMs] { return nowMs; }); + restarted.recover(ProcFs::listProcesses(), + {.emulators = {{.name = QStringLiteral("Ryujinx"), + .binaries = {QStringLiteral("Ryujinx")}, + .rescanSource = QStringLiteral("Ryujinx")}}, + .romExtensions = {QStringLiteral("nsp")}}, + 9999); + QCOMPARE(restarted.activeCount(), 0); + const QVector rows = SessionDatabase::openSessions(database); + QCOMPARE(rows.size(), 0); + const QHash totals = SessionDatabase::trackedSecondsByPath(database); + QCOMPARE(totals.value(QStringLiteral("/games/a.nsp")), qint64(30)); + } + QSqlDatabase::removeDatabase(connection); +} + +void CoreTests::sessionStoreMergesImportedAndTrackedPlaytime() { + QTemporaryDir directory; + const QString path = directory.filePath(QStringLiteral("library.sqlite3")); + { + PlaySessionStore store(path); + store.captureBaseline(QStringLiteral("/games/a.nsp"), 3600); + store.captureBaseline(QStringLiteral("/games/a.nsp"), 7200); + QCOMPARE(PlaySessionStore::merge(7200, 3600, 1800), qint64(7200)); + QCOMPARE(PlaySessionStore::merge(3600, 3600, 1800), qint64(5400)); + QCOMPARE(store.displaySeconds(QStringLiteral("/games/a.nsp"), 7200), qint64(7200)); + QCOMPARE(store.displaySeconds(QStringLiteral("/games/missing.nsp"), 500), qint64(500)); + store.setEnabled(false); + QCOMPARE(store.displaySeconds(QStringLiteral("/games/a.nsp"), 7200), qint64(7200)); + } + { + PlaySessionStore store(path); + QCOMPARE(store.displaySeconds(QStringLiteral("/games/a.nsp"), 7200), qint64(7200)); + QCOMPARE(store.sessionLastPlayed(QStringLiteral("/games/a.nsp")), qint64(0)); + } +} + QTEST_MAIN(CoreTests) #include "CoreTests.moc" @@ -7048,7 +7371,7 @@ void CoreTests::metadataMatchingKeepsPlatformsAndEditions() { // GameMetadata::kMatchVersion alongside it and update this expectation, or every library // already out there stays on answers the rules would no longer give. QCOMPARE(GameMetadata::matchingRulesFingerprint(), QByteArray("506f0b8fef280446")); - QCOMPARE(GameMetadata::kMatchVersion, 3); + QCOMPARE(GameMetadata::kMatchVersion, 5); // An entry decided by older matching rules is stale however recently it was written, so a // matching fix reaches an existing library on the next update instead of a month later. @@ -7163,10 +7486,51 @@ void CoreTests::metadataMatchingKeepsPlatformsAndEditions() { {"id":3,"width":600,"height":900,"nsfw":true,"url":"https://cdn2.steamgriddb.com/grid/flagged.png"}, {"id":4,"width":600,"height":900,"url":"https://untrusted.example/image.png"}]})json"); QCOMPARE(covers.size(),1); + const auto ranked = GameMetadata::parseCovers(R"json({"success":true,"data":[ + {"id":1,"width":600,"height":900,"score":2,"url":"https://cdn2.steamgriddb.com/grid/a.png"}, + {"id":2,"width":600,"height":900,"score":8,"url":"https://cdn2.steamgriddb.com/grid/b.png"}, + {"id":3,"width":600,"height":900,"score":8,"url":"https://cdn2.steamgriddb.com/grid/c.png"}, + {"id":2,"width":600,"height":900,"score":20,"url":"https://cdn2.steamgriddb.com/grid/d.png"}, + {"id":4,"width":600,"height":900,"score":20,"url":"https://cdn2.steamgriddb.com/grid/a.png"}, + {"id":5,"width":600,"height":900,"humor":true,"score":99,"url":"https://cdn2.steamgriddb.com/grid/e.png"}]})json"); + QCOMPARE(ranked.size(), 3); + QCOMPARE(ranked.at(0).toMap().value("id").toInt(), 2); + QCOMPARE(ranked.at(1).toMap().value("id").toInt(), 3); + QCOMPARE(ranked.at(2).toMap().value("id").toInt(), 1); + QVERIFY(!GameMetadata::trustedImageUrl(QUrl("http://cdn2.steamgriddb.com/grid/image.png"))); QVERIFY(!GameMetadata::trustedImageUrl(QUrl("https://cdn2.steamgriddb.com.evil.example/image.png"))); } +void CoreTests::metadataCarriesReleaseCreditsGenresAndSummary() { + const auto matches = GameMetadata::parseMatches(R"json([ + {"id": 42, "name": "A Great Game", "platforms": [6, 130], + "first_release_date": 870048000, "total_rating": 88.4, "total_rating_count": 1200, + "genres": [{"id": 4, "name": "Fighting"}, {"id": 12, "name": "Role-playing (RPG)"}], + "summary": " A tale of things.\nLine two. ", + "involved_companies": [ + {"id": 1, "company": {"id": 11, "name": "Dev Studio"}, "developer": true}, + {"id": 2, "company": {"id": 12, "name": "Dev Studio"}, "developer": true}, + {"id": 3, "company": {"id": 13, "name": "Publisher Co"}, "publisher": true}, + {"id": 4, "company": {"id": 14, "name": ""}, "developer": true}, + {"id": 5, "company": {"id": 15, "name": "Late Publisher"}, "developer": false, + "publisher": true} + ]}])json", + QList{6}); + QCOMPARE(matches.size(), 1); + const auto match = matches.first().toMap(); + QCOMPARE(match.value("year").toInt(), 1997); + QVERIFY(match.value("releaseText").toString().endsWith(QStringLiteral("1997"))); + QCOMPARE(match.value("genres").toStringList(), + QStringList({QStringLiteral("Fighting"), QStringLiteral("Role-playing (RPG)")})); + QCOMPARE(match.value("developers").toStringList(), QStringList{QStringLiteral("Dev Studio")}); + QCOMPARE(match.value("publishers").toStringList(), + QStringList({QStringLiteral("Publisher Co"), QStringLiteral("Late Publisher")})); + QCOMPARE(match.value("summary").toString(), QStringLiteral("A tale of things. Line two.")); + QCOMPARE(GameMetadata::platformNames(QVariantList{6, 130, 99999}), + QStringList({QStringLiteral("PC"), QStringLiteral("Switch")})); +} + void CoreTests::metadataPersistsRatingsAndPreservesCustomArt() { QTemporaryDir temp; const QString database = temp.filePath("library.sqlite3"); @@ -7279,19 +7643,67 @@ class PortraitFixtureNetwork final : public QNetworkAccessManager { public: QList requests; QByteArray png; + QHash searches; protected: QNetworkReply* createRequest(Operation, const QNetworkRequest& request, QIODevice*) override { requests.append(request); QByteArray body = png; if (request.url().host() == "www.steamgriddb.com") { const QString id = request.url().path().section('/', -1); - body = QString(R"({"success":true,"data":[{"id":%1,"width":600,"height":900,"url":"https://cdn2.steamgriddb.com/grid/%1.png"}]})").arg(id).toUtf8(); + if (request.url().path().contains("/search/")) + body = searches.value(id, R"({"success":true,"data":[]})"); + else body = QString(R"({"success":true,"data":[{"id":%1,"width":600,"height":900,"url":"https://cdn2.steamgriddb.com/grid/%1.png"}]})").arg(id).toUtf8(); } return new PortraitFixtureReply(request, body, this); } }; } +void CoreTests::artworkAliasesAndSharedIdentityRecoverMissingCovers() { + QTemporaryDir temp; + PortraitFixtureNetwork network; + QImage image(600, 900, QImage::Format_RGB32); + image.fill(Qt::blue); + QBuffer buffer(&network.png); + QVERIFY(buffer.open(QIODevice::WriteOnly)); + QVERIFY(image.save(&buffer, "PNG")); + network.searches.insert("Regional Adventure", R"({"success":true,"data":[{"id":11,"name":"Regional Adventure","release_date":788918400}]})"); + GameMetadata metadata(temp.filePath("metadata.sqlite3"), nullptr, nullptr, &network); + metadata.m_gridKey = "offline-fixture-key"; + QVariantMap entry{{"igdbId", 123}, {"title", "Original Adventure"}, {"year", 1995}, + {"platform", "SNES"}, {"updated", 123456}, {"rating", 87}, + {"alternativeNames", QVariantList{QVariantMap{{"name", "OA"}, {"comment", "Acronym"}}, + QVariantMap{{"name", "Regional Adventure"}, {"comment", "Alternative title"}}}}}; + QCOMPARE(GameMetadata::artworkSearchTitles(entry), QStringList({"Original Adventure", "Regional Adventure"})); + metadata.persist("first", entry); + metadata.m_active = {{"metadataKey", "first"}, {"source", "RetroArch"}, {"system", "SNES"}}; + metadata.m_busy = true; + metadata.gridSearch(); + QTRY_VERIFY_WITH_TIMEOUT(!metadata.busy(), 5000); + const auto downloaded = metadata.entry("first"); + QVERIFY(QFileInfo::exists(downloaded.value("portrait").toString())); + QCOMPARE(downloaded.value("gridId").toLongLong(), 11); + QCOMPARE(downloaded.value("updated").toInt(), 123456); + QCOMPARE(network.requests.size(), 4); + QVERIFY(GameMetadata::canSharePortrait(entry, downloaded)); + auto other = entry; + other["igdbId"] = 456; + QVERIFY(!GameMetadata::canSharePortrait(other, downloaded)); + other = entry; other["platform"] = "Game Boy Advance"; + QVERIFY(!GameMetadata::canSharePortrait(other, downloaded)); + other = entry; other["edition"] = "Remake"; + QVERIFY(!GameMetadata::canSharePortrait(other, downloaded)); + other = entry; other["identityAmbiguous"] = true; + QVERIFY(!GameMetadata::canSharePortrait(other, downloaded)); + metadata.persist("second", entry); + metadata.m_active["metadataKey"] = "second"; + metadata.m_busy = true; + metadata.gridSearch(); + QVERIFY(!metadata.busy()); + QCOMPARE(metadata.entry("second").value("portrait"), downloaded.value("portrait")); + QCOMPARE(network.requests.size(), 4); // Sharing requires no new request or file copy. +} + void CoreTests::portraitBatchContinuesAndKeepsRatingTimestamp() { QTemporaryDir temp; PortraitFixtureNetwork network; @@ -7397,7 +7809,7 @@ void CoreTests::portraitSelectionCompletesOnlyAfterSuccessfulSave() { QTRY_VERIFY(!metadata.busy()); QCOMPARE(selected.count(), 1); QCOMPARE(metadata.covers().size(), 1); - QCOMPARE(metadata.status(), QString("Portrait has unexpected dimensions")); + QCOMPARE(metadata.status(), QString("Downloaded cover has unexpected dimensions")); } void CoreTests::unconfirmedGridSelectionIsDroppedOnARulesChange() { @@ -7493,3 +7905,1281 @@ void CoreTests::startupBenchmarkDoesNotActivateAnotherInstance() { QVERIFY(!otherInstance.hasPendingConnections()); QVERIFY(!QFileInfo::exists(temp.filePath("config/omakade/config.toml"))); } + +void CoreTests::sessionRecoveryPreservesLiveProgress() { + QSqlDatabase db; + QVERIFY(SessionDatabase::open(db, ":memory:", "live-recovery")); + ProcessSnapshot self; + for (const auto& process : ProcFs::listProcesses()) + if (process.pid == QCoreApplication::applicationPid()) + self = process; + QVERIFY(self.procStart > 0); + self.comm = "Ryujinx"; + self.arguments = {"Ryujinx", "/games/live.nsp"}; + ProcessProfileSet profiles; + profiles.emulators.append({.name = "Ryujinx", .binaries = {"Ryujinx"}}); + profiles.romExtensions = {"nsp"}; + const auto id = SessionDatabase::beginSession(db, "/games/live.nsp", "Ryujinx", 1000, self.pid, + self.procStart); + SessionDatabase::updateProgress(db, id, 120, 1120); + qint64 ms = 0; + { + SessionRecorder recorder(db, [&] { return ms; }); + recorder.setFlushIntervalMs(1); + recorder.recover({self}, profiles, 9000); + QCOMPARE(recorder.activeCount(), 1); + ms = 30000; + recorder.sync(ProcessMatcher::match({self}, profiles), 9030); + QCOMPARE(SessionDatabase::trackedSecondsByPath(db).value("/games/live.nsp"), qint64(150)); + } + { + SessionRecorder recorder(db, [&] { return ms; }); + recorder.recover({self}, profiles, 9990); + ms += 10000; + recorder.endAll(10000); + QCOMPARE(SessionDatabase::trackedSecondsByPath(db).value("/games/live.nsp"), qint64(160)); + QVERIFY(SessionDatabase::openSessions(db).isEmpty()); + } + db.close(); + db = {}; + QSqlDatabase::removeDatabase("live-recovery"); +} + +void CoreTests::sessionBaselineHandlesFirstAndLateObservation() { + QTemporaryDir temp; + const auto path = temp.filePath("library.sqlite3"); + QSqlDatabase db; + QVERIFY(SessionDatabase::open(db, path, "first-baseline")); + { + PlaySessionStore store(path); + store.captureBaseline("/games/new.nsp", 0); + auto id = SessionDatabase::beginSession(db, "/games/new.nsp", "Ryujinx", 1000, 1, 1); + SessionDatabase::endSession(db, id, 1600, 600); + store.captureBaseline("/games/new.nsp", 600); + store.setEnabled(false); + store.setEnabled(true); + QCOMPARE(store.displaySeconds("/games/new.nsp", 600), qint64(600)); + // The daemon recorded this game before the UI imported its counter. + id = SessionDatabase::beginSession(db, "/games/late.nsp", "Ryujinx", 1000, 2, 2); + SessionDatabase::endSession(db, id, 1600, 600); + store.captureBaseline("/games/late.nsp", 4200); + store.setEnabled(false); + store.setEnabled(true); + QCOMPARE(store.displaySeconds("/games/late.nsp", 4200), qint64(4200)); + id = SessionDatabase::beginSession(db, "/games/late.nsp", "Ryujinx", 2000, 2, 2); + SessionDatabase::endSession(db, id, 2300, 300); + store.setEnabled(false); + store.setEnabled(true); + QCOMPARE(store.displaySeconds("/games/late.nsp", 4200), qint64(4500)); + QVERIFY(PlaySessionStore::provenance(&store, "/games/late.nsp", 4200) + .contains("Recorded by Omakade: 15m")); + store.setEnabled(false); + QCOMPARE(store.displaySeconds("/games/late.nsp", 4200), qint64(4200)); + const auto provenance = PlaySessionStore::provenance(&store, "/games/late.nsp", 4200); + QVERIFY(provenance.contains("Imported from emulator: 1h 10m")); + QVERIFY(provenance.contains("Recorded by Omakade: 15m")); + QVERIFY(provenance.contains("not applied while recording is off")); + } + { + PlaySessionStore reopened(path); + QCOMPARE(reopened.displaySeconds("/games/new.nsp", 600), qint64(600)); + QCOMPARE(reopened.displaySeconds("/games/late.nsp", 4500), qint64(4500)); + } + db.close(); + db = {}; + QSqlDatabase::removeDatabase("first-baseline"); +} + +void CoreTests::metadataRefreshReplacesProviderFieldsAndPersists() { + QTemporaryDir temp; + const auto path = temp.filePath("library.sqlite3"); + const QByteArray json = R"([{"id":42,"name":"Example","platforms":[6,130], + "first_release_date":870048000,"summary":"An adventure.", + "genres":[{"name":"Adventure"}],"involved_companies":[ + {"company":{"name":"Studio"},"developer":true}]}])"; + const auto match = GameMetadata::parseMatches(json, {130}).first().toMap(); + QCOMPARE(match.value("releaseText").toString(), QString("July 28, 1997")); + { + GameMetadata metadata(path, nullptr); + metadata.m_active = {{"metadataKey", "example"}, {"title", "Example"}, {"system", "switch"}}; + metadata.m_manual = true; + metadata.acceptMatch(match); + QCOMPARE(metadata.entry("example").value("platformText").toString(), + ConsoleCatalog::displayNameFor("switch")); + QVERIFY(metadata.entry("example").value("manualMatch").toBool()); + } + { + GameMetadata metadata(path, nullptr); + QCOMPARE(metadata.entry("example").value("summary").toString(), QString("An adventure.")); + metadata.m_active = {{"metadataKey", "example"}, {"title", "Example"}, {"system", "switch"}}; + metadata.m_busy = true; + metadata.m_igdbStage = "games"; + metadata.matchResult({}, "Offline"); + QCOMPARE(metadata.entry("example").value("summary").toString(), QString("An adventure.")); + const auto sparse = + GameMetadata::parseMatches(R"([{"id":42,"name":"Example","platforms":[130]}])", {130}) + .first() + .toMap(); + metadata.acceptMatch(sparse); + QVERIFY(metadata.entry("example").value("summary").toString().isEmpty()); + QVERIFY(metadata.entry("example").value("genres").toStringList().isEmpty()); + QVERIFY(metadata.entry("example").value("developers").toStringList().isEmpty()); + QVERIFY(metadata.entry("example").value("releaseText").toString().isEmpty()); + QVERIFY(metadata.entry("example").value("manualMatch").toBool()); + } + GameMetadata reopened(path, nullptr); + QVERIFY(reopened.entry("example").value("summary").toString().isEmpty()); + QCOMPARE(reopened.entry("example").value("igdbId").toLongLong(), qint64(42)); +} + +void CoreTests::sessionDaemonRejectsDuplicateOwner() { + QTemporaryDir temp; + QVERIFY(temp.isValid()); + const auto config = temp.filePath("config"); + const auto data = temp.filePath("data"); + QVERIFY(QDir().mkpath(config + "/omakade")); + QFile profiles(config + "/omakade/sessiond-profiles.json"); + QVERIFY(profiles.open(QIODevice::WriteOnly)); + profiles.write(R"({"romExtensions":[],"emulators":[]})"); + profiles.close(); + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert("XDG_CONFIG_HOME", config); + env.insert("XDG_DATA_HOME", data); + env.insert("QT_FORCE_STDERR_LOGGING", "1"); + QProcess first, duplicate, restarted; + const auto cleanup = qScopeGuard([&] { + for (auto* child : {&first, &duplicate, &restarted}) { + if (child->state() != QProcess::NotRunning) { + child->kill(); + child->waitForFinished(); + } + } + }); + const auto executable = QCoreApplication::applicationDirPath() + "/../omakade-sessiond"; + first.setProcessEnvironment(env); + first.start(executable, {}); + QVERIFY(first.waitForStarted()); + QTRY_VERIFY_WITH_TIMEOUT(QFileInfo::exists(data + "/omakade/library.sqlite3"), 5000); + duplicate.setProcessEnvironment(env); + duplicate.start(executable, {}); + QVERIFY(duplicate.waitForFinished(5000)); + QCOMPARE(duplicate.exitCode(), 1); + QVERIFY(duplicate.readAllStandardError().contains("recorder already running")); + QCOMPARE(first.state(), QProcess::Running); + const auto databasePath = data + "/omakade/library.sqlite3"; + QVERIFY(PlaySessionStore::recorderOwnsDatabase(databasePath)); + PlaySessionStore status(databasePath); + QVERIFY(status.recorderRunning()); + QSignalSpy statusChanged(&status, &PlaySessionStore::recorderStatusChanged); + first.kill(); + QVERIFY(first.waitForFinished()); + status.refreshRecorderStatus(); + QVERIFY(!status.recorderRunning()); + QCOMPARE(statusChanged.count(), 1); + restarted.setProcessEnvironment(env); + restarted.start(executable, {}); + QVERIFY(restarted.waitForStarted()); + QTest::qWait(200); + QCOMPARE(restarted.state(), QProcess::Running); + status.refreshRecorderStatus(); + QVERIFY(status.recorderRunning()); + QCOMPARE(statusChanged.count(), 2); +} + + +void CoreTests::selectedMetadataStaysFirstInQueue() { + QTemporaryDir temp; + GameMetadata metadata(temp.filePath("library.sqlite3"), nullptr); + metadata.m_busy = true; // An unrelated request is still in flight. + metadata.m_active = {{"metadataKey", "in-flight"}}; + metadata.m_queue.enqueue({{"metadataKey", "other"}, {"system", "snes"}}); + metadata.m_queue.enqueue({{"metadataKey", "selected"}, {"system", "switch"}}); + metadata.inspect({{"metadataKey", "selected"}, {"system", "switch"}}); + QVERIFY(metadata.selectedBusy()); + QCOMPARE(metadata.m_queue.head().value("metadataKey").toString(), QString("selected")); + // Even visible games must not displace the open details page. + QStandardItemModel visible(1, 1); + visible.setData(visible.index(0, 0), "other", GameRoles::MetadataKey); + metadata.m_visible = &visible; + metadata.promoteVisibleGames(); + QCOMPARE(metadata.m_queue.head().value("metadataKey").toString(), QString("selected")); +} + +void CoreTests::metadataHeroUsesProviderImageIds() { + const auto matches = GameMetadata::parseMatches(R"([{"id":426,"name":"Example", + "platforms":[19],"artworks":[{"image_id":"ad_scan","width":1920,"height":1080}], + "screenshots":[ + {"image_id":"../invalid","width":1920,"height":1080}, + {"image_id":"portrait","width":900,"height":1600}, + {"image_id":"strip","width":1920,"height":200}, + {"image_id":"unknown_dimensions"}, + {"image_id":"animated","width":1920,"height":1080,"animated":true}, + {"image_id":"retro","width":256,"height":224}, + {"image_id":"z_scene","width":1280,"height":720}, + {"image_id":"a_scene","width":1280,"height":720}]}])", {19}); + const auto match = matches.first().toMap(); + QCOMPARE(match.value("heroUrl").toString(), + QString("https://images.igdb.com/igdb/image/upload/t_1080p/a_scene.jpg")); + QCOMPARE(match.value("heroKind").toString(), QString("screenshot")); + QCOMPARE(match.value("heroWidth").toInt(), 1280); + QCOMPARE(match.value("heroHeight").toInt(), 720); + const auto reordered = GameMetadata::parseMatches(R"([{"id":426,"name":"Example", + "platforms":[19],"screenshots":[ + {"image_id":"a_scene","width":1280,"height":720}, + {"image_id":"z_scene","width":1280,"height":720}]}])", {19}).first().toMap(); + QCOMPARE(reordered.value("heroUrl"), match.value("heroUrl")); + auto invalid = GameMetadata::parseMatches(R"([{"id":1,"name":"Example","platforms":[19], + "artworks":[{"image_id":"ad_scan","width":1920,"height":1080}], + "screenshots":[{"image_id":"https://example.com/image","width":1280,"height":720}]}])", {19}).first().toMap(); + QVERIFY(!invalid.contains("heroUrl")); + auto retro = GameMetadata::parseMatches(R"([{"id":1,"name":"Example","platforms":[19], + "screenshots":[{"image_id":"retro","width":256,"height":224}]}])", {19}).first().toMap(); + QVERIFY(retro.contains("heroUrl")); + QVERIFY(GameMetadata::searchQuery("Example", "snes").contains("screenshots.width")); + QVERIFY(!GameMetadata::searchQuery("Example", "snes").contains("artworks.image_id")); +} + +void CoreTests::sourceArtworkDoesNotDiscardDownloadedPortrait() { + QTemporaryDir temp; + const QString portrait = temp.filePath("portrait.png"); + const QString source = temp.filePath("source.png"); + QImage image(600, 900, QImage::Format_RGB32); + image.fill(Qt::blue); + QVERIFY(image.save(portrait)); + QVERIFY(image.save(source)); + GameMetadata metadata(temp.filePath("library.sqlite3"), nullptr); + metadata.m_gridKey = "test-only"; + metadata.m_active = {{"metadataKey", "example"}, {"source", "RetroArch"}, + {"system", "nes"}, {"sourceCoverPath", source}}; + metadata.persist("example", {{"portrait", portrait}, {"gridCoverId", 42}}); + metadata.gridSearch(); + QCOMPARE(metadata.entry("example").value("portrait").toString(), portrait); + QCOMPARE(metadata.entry("example").value("gridCoverId").toInt(), 42); + QVERIFY(QFileInfo::exists(portrait)); +} + +void CoreTests::explicitMetadataRefreshBypassesQueryCache() { + QTemporaryDir temp; + GameMetadata metadata(temp.filePath("library.sqlite3"), nullptr); + const QByteArray query = "fields name; where id = 42;"; + const QByteArray cacheKey = "games:" + query; + metadata.m_queryCache.insert(cacheKey, "[]"); + metadata.m_active = {{"metadataKey", "example"}, {"refreshDetails", true}}; + metadata.requestIgdb(query, "games", "games"); + QVERIFY(!metadata.m_queryCache.contains(cacheKey)); +} + +void CoreTests::sessionRecorderSeparatesGamesWithinOneProcess() { + const QString connection = "test-recorder-game-switch"; + { + QSqlDatabase database; + QVERIFY(SessionDatabase::open(database, ":memory:", connection)); + qint64 ms = 0; + SessionRecorder recorder(database, [&] { return ms; }); + SessionMatch match{.pid = 10, .procStart = 100, .emulator = "Example", + .rescanSource = "RetroArch", .gamePath = "/games/first.nes"}; + recorder.sync({match}, 1000); + ms = 60000; + match.gamePath = "/games/second.nes"; + recorder.sync({match}, 1060); + QCOMPARE(recorder.activeCount(), 1); + ms = 90000; + recorder.sync({}, 1090); + const auto totals = SessionDatabase::trackedSecondsByPath(database); + QCOMPARE(totals.value("/games/first.nes"), qint64(60)); + QCOMPARE(totals.value("/games/second.nes"), qint64(30)); + QCOMPARE(SessionDatabase::openSessions(database).size(), 0); + } + QSqlDatabase::removeDatabase(connection); +} + +void CoreTests::settingsReportWriteFailuresAndRecover() { + QTemporaryDir temp; + const QString path = temp.filePath("config.toml"); + AppSettings settings(path); + settings.setReducedMotion(true); + QVERIFY(QFileInfo::exists(path)); + QSignalSpy failures(&settings, &AppSettings::saveFailed); + QVERIFY(QFile::remove(path)); + QVERIFY(QDir().mkdir(path)); // Portable write failure, including when run as root. + settings.setReducedMotion(false); + QCOMPARE(failures.count(), 1); + QVERIFY(!failures.first().first().toString().isEmpty()); + QVERIFY(QDir().rmdir(path)); + settings.setArtworkCacheLimitMb(512); + QCOMPARE(failures.count(), 1); + AppSettings reloaded(path); + QCOMPARE(reloaded.artworkCacheLimitMb(), 512); + QVERIFY(!reloaded.reducedMotion()); +} + +void CoreTests::invalidMetadataResponseKeepsDataAndReportsFailure() { + QTemporaryDir temp; + GameMetadata metadata(temp.filePath("library.sqlite3"), nullptr); + metadata.persist("example", {{"igdbId", 42}, {"summary", "Existing description"}, {"v", 4}}); + metadata.m_selected = {{"metadataKey", "example"}}; + metadata.m_active = metadata.m_selected; + metadata.m_busy = true; + metadata.m_igdbStage = "games"; + metadata.matchResult("invalid JSON", {}); + QCOMPARE(metadata.entry("example").value("summary").toString(), QString("Existing description")); + QVERIFY(metadata.selectedStatus().contains("Couldn't refresh")); +} + +void CoreTests::processDiscoveryStaysWithinCurrentUser() { + bool foundSelf = false; + for (const auto& process : ProcFs::listProcesses()) { + const QFileInfo info(QStringLiteral("/proc/%1").arg(process.pid)); + if (!info.exists()) continue; // An unrelated short-lived process may have exited. + QCOMPARE(info.ownerId(), static_cast(geteuid())); + foundSelf = foundSelf || process.pid == QCoreApplication::applicationPid(); + } + QVERIFY(foundSelf); +} + +void CoreTests::broadCatalogSearchRetriesExactTitle() { + QTemporaryDir temp; + GameMetadata metadata(temp.filePath("library.sqlite3"), nullptr); + metadata.m_active = {{"metadataKey", "example"}, {"title", "Super Mario World (NA)"}, + {"system", "snes"}}; + metadata.m_selected = metadata.m_active; + QVERIFY(metadata.persist("example", {{"igdbId", 1070}, {"identityAmbiguous", true}, + {"matchVersion", 4}, {"portrait", "/kept/cover.png"}})); + QFile broad(QString(OMAKADE_FIXTURE_DIR) + "/regional-metadata/smw-broad.json"); + QFile exact(QString(OMAKADE_FIXTURE_DIR) + "/regional-metadata/smw-exact.json"); + QVERIFY(broad.open(QIODevice::ReadOnly)); + QVERIFY(exact.open(QIODevice::ReadOnly)); + const auto query = GameMetadata::aliasSearchQuery("Super Mario World (NA)", "snes"); + metadata.m_queryCache.insert("games:" + query, exact.readAll()); + metadata.m_busy = true; + metadata.m_igdbStage = "games"; + metadata.matchResult(broad.readAll(), {}); + QVERIFY(metadata.m_aliasRetried); + QCOMPARE(metadata.m_igdbStage, QString("aliases")); + QTRY_VERIFY(!metadata.entry("example").value("identityAmbiguous").toBool()); + QCOMPARE(metadata.entry("example").value("igdbId").toInt(), 1070); + QCOMPARE(metadata.entry("example").value("portrait").toString(), QString("/kept/cover.png")); + QCOMPARE(metadata.entry("example").value("matchVersion").toInt(), GameMetadata::kMatchVersion); +} + +void CoreTests::ambiguousMetadataNeverUsesPopularity() { + QTemporaryDir temp; + GameMetadata metadata(temp.filePath("library.sqlite3"), nullptr); + const QVariantMap original{{"igdbId", 42}, + {"summary", "Cached description"}, + {"portrait", "/cached/cover.png"}, + {"matchVersion", 3}}; + metadata.persist("example", original); + metadata.m_selected = {{"metadataKey", "example"}, {"title", "Example"}, {"system", "nes"}}; + metadata.m_active = metadata.m_selected; + const QByteArray response = + R"([{"id":42,"name":"Example","platforms":[18],"total_rating_count":1}, + {"id":99,"name":"Example","platforms":[18],"total_rating_count":99999}])"; + for (int attempt = 0; attempt < 2; ++attempt) { + metadata.m_busy = true; + metadata.m_igdbStage = "aliases"; + metadata.m_aliasRetried = true; // Still ambiguous after the exact catalogue query. + metadata.matchResult(response, {}); + const auto saved = metadata.entry("example"); + QCOMPARE(saved.value("igdbId").toInt(), 42); + QCOMPARE(saved.value("portrait"), original.value("portrait")); + QCOMPARE(saved.value("summary"), original.value("summary")); + QVERIFY(saved.value("identityAmbiguous").toBool()); + QCOMPARE(metadata.candidates().size(), 2); + QVERIFY(metadata.selectedStatus().contains("Multiple editions")); + } + auto selected = original; + selected["manualMatch"] = true; + metadata.persist("example", selected); + metadata.m_busy = true; + metadata.matchResult(response, {}); + QCOMPARE(metadata.entry("example").value("igdbId").toInt(), 42); + QVERIFY(metadata.entry("example").value("manualMatch").toBool()); + QVERIFY(!metadata.entry("example").value("identityAmbiguous").toBool()); +} + +void CoreTests::metadataWriteFailureIsRetryable() { + QTemporaryDir temp; + GameMetadata metadata(temp.filePath("library.sqlite3"), nullptr); + QVERIFY(metadata.persist("example", {{"summary", "Original"}})); + QSqlQuery query(metadata.m_database); + QVERIFY(query.exec("CREATE TRIGGER deny_metadata BEFORE INSERT ON game_metadata " + "BEGIN SELECT RAISE(ABORT, 'test disk failure'); END")); + metadata.m_selected = {{"metadataKey", "example"}}; + metadata.m_active = metadata.m_selected; + metadata.m_busy = true; + metadata.m_manual = true; + metadata.acceptMatch({{"id", 42}, {"title", "Example"}, {"summary", "Chosen description"}}); + QVERIFY(!metadata.busy()); + QCOMPARE(metadata.entry("example").value("summary").toString(), QString("Original")); + QVERIFY(metadata.status().contains("Could not save")); + QVERIFY(metadata.selectedStatus().contains("could not be saved")); + QVERIFY(metadata.m_pendingWrites.value("example").value("manualMatch").toBool()); + metadata.refreshSelected(); // Failure remains pending and does not claim success. + QVERIFY(metadata.status().contains("Could not save")); + QVERIFY(query.exec("DROP TRIGGER deny_metadata")); + metadata.refreshSelected(); + QCOMPARE(metadata.entry("example").value("summary").toString(), QString("Chosen description")); + QVERIFY(metadata.entry("example").value("manualMatch").toBool()); + QVERIFY(metadata.m_pendingWrites.isEmpty()); + QCOMPARE(metadata.status(), QString("Game metadata saved")); +} + +void CoreTests::backupPreservesIdentificationChoices() { + QTemporaryDir sourceRoot, targetRoot; + const QString source = sourceRoot.filePath("library.sqlite3"); + const QString target = targetRoot.filePath("library.sqlite3"); + const QString key = + QString("RetroArch") + QChar::Null + "core" + QChar::Null + "/roms/example.nes"; + const QString rejectedKey = + QString("RetroArch") + QChar::Null + "core" + QChar::Null + "/roms/unknown.nes"; + { + GameMetadata metadata(source, nullptr); + QVERIFY(metadata.persist(key, {{"igdbId", 42}, + {"manualMatch", true}, + {"portrait", "/private/cache/cover.jpg"}, + {"summary", "Cached"}})); + QVERIFY(metadata.persist(rejectedKey, {{"rejected", true}})); + QVERIFY(metadata.persist("automatic", {{"igdbId", 99}, {"summary", "Regenerable"}})); + } + BackupPayload snapshot; + QString error; + QVERIFY2(BackupSnapshot::capture(source, {}, &snapshot, &error), qPrintable(error)); + QCOMPARE(snapshot.library.value("game_metadata").toArray().size(), 2); + const auto serialized = QJsonDocument(snapshot.library).toJson(); + QVERIFY(!serialized.contains("/private/cache")); + QVERIFY(!serialized.contains("Cached")); + const QString archive = sourceRoot.filePath("choices.omakade-backup"); + QVERIFY2(BackupArchive::write(archive, snapshot, &error), qPrintable(error)); + BackupPayload restored; + QVERIFY2(BackupArchive::read(archive, &restored, &error), qPrintable(error)); + for (auto mode : {BackupDatabase::Mode::Merge, BackupDatabase::Mode::Replace}) { + QVERIFY2(BackupDatabase::restore(target, restored, mode, &error), qPrintable(error)); + GameMetadata metadata(target, nullptr); + QCOMPARE(metadata.entry(key).value("igdbId").toInt(), 42); + QVERIFY(metadata.entry(key).value("manualMatch").toBool()); + QVERIFY(metadata.entry(rejectedKey).value("rejected").toBool()); + QVERIFY(!metadata.entry(key).contains("portrait")); + } + // A legacy archive without this table must not erase decisions on replacement. + restored.library.remove("game_metadata"); + QVERIFY2(BackupDatabase::restore(target, restored, BackupDatabase::Mode::Replace, &error), + qPrintable(error)); + { + GameMetadata metadata(target, nullptr); + QVERIFY(metadata.entry(key).value("manualMatch").toBool()); + } + auto invalid = snapshot; + invalid.library["game_metadata"] = QJsonArray{QJsonObject{ + {"game_key", key}, + {"payload", "{\"manualMatch\":true,\"igdbId\":42,\"portrait\":\"/arbitrary/path\"}"}}}; + QVERIFY(!BackupArchive::validate(invalid, &error)); +} + +void CoreTests::backupIncludesCurrentPreferences() { + QTemporaryDir temp; + AppSettings source(temp.filePath("source.toml")); + source.setLibrarySortMode(3); + source.setCoverSize(130); + source.setCouchCoverSize(80); + source.setDolphinEnabled(true); + source.setTrackPlaySessions(false); + source.setConsoleLayout("nes", "card"); + source.setRomFolders({"/offline/roms|nes"}); + BackupPayload payload; + payload.createdAt = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + payload.settings = source.backupSettings(); + QString error; + QVERIFY2(BackupArchive::validate(payload, &error), qPrintable(error)); + AppSettings target(temp.filePath("target.toml")); + QVERIFY(target.applyBackupSettings(payload.settings, true)); + QCOMPARE(target.backupSettings(), source.backupSettings()); + source.setLibrarySortMode(4); + payload.settings = source.backupSettings(); + QVERIFY2(BackupArchive::validate(payload, &error), qPrintable(error)); + QVERIFY(target.applyBackupSettings({{"reduced_motion", true}}, true)); + QCOMPARE(target.coverSize(), 130); // Older archives have no choice for new preferences. + QCOMPARE(target.romFolders(), source.romFolders()); + payload.settings["cover_size"] = 10000; + QVERIFY(!BackupArchive::validate(payload, &error)); +} + +void CoreTests::sharedCoverBudgetProtectsReferencedArtwork() { + QTemporaryDir temp; + const QString steam = temp.path(); + const QString retro = temp.filePath("libretro"); + QVERIFY(QDir().mkpath(retro)); + const QString activeSteam = temp.filePath("active.jpg"); + const QString activeRetro = retro + "/active.jpg"; + const QString staleSteam = temp.filePath("stale.jpg"); + const QString staleRetro = retro + "/stale.jpg"; + for (const auto& path : {activeSteam, activeRetro, staleSteam, staleRetro}) + writeFile(path, QByteArray(100, 'x')); + QCOMPARE(CoverCachePolicy::prune(steam, steam, 1, {activeSteam}), 300); + QVERIFY(QFileInfo::exists(activeSteam)); + QVERIFY(QFileInfo::exists(activeRetro)); + QVERIFY(QFileInfo::exists(staleRetro)); // A source cannot remove another model's file. + QVERIFY(!QFileInfo::exists(staleSteam)); + QCOMPARE(CoverCachePolicy::prune(steam, retro, 1, {activeRetro}), 200); + QVERIFY(QFileInfo::exists(activeRetro)); + QVERIFY(!QFileInfo::exists(staleRetro)); + QCOMPARE(CoverCachePolicy::prune(steam, steam, 1, {activeSteam}), 200); +} + +void CoreTests::precisePlaytimeSortsAndNotifies() { + QTemporaryDir temp; + QStandardItemModel source(2, 1); + source.setItemRoleNames(GameRoles::names()); + for (int row = 0; row < 2; ++row) { + const auto index = source.index(row, 0); + source.setData(index, row == 0 ? "Alpha" : "Zulu", GameRoles::Title); + source.setData(index, "Example", GameRoles::Source); + source.setData(index, QString::number(row), GameRoles::AppId); + source.setData(index, 0, GameRoles::Hours); + source.setData(index, row == 0 ? 300 : 2700, GameRoles::PlaytimeSeconds); + } + UnifiedGameModel games(temp.filePath("library.sqlite3")); + games.addSourceModel(&source); + LibraryFilterModel library; + library.setSourceModel(&games); + library.setSortMode(LibraryFilterModel::SortMode::Playtime); + QCOMPARE(library.index(0, 0).data(GameRoles::Title).toString(), QString("Zulu")); + QCOMPARE(library.index(0, 0).data(GameRoles::PlaytimeText).toString(), QString("45m")); + QSignalSpy changed(&games, &QAbstractItemModel::dataChanged); + source.setData(source.index(0, 0), 3900, GameRoles::PlaytimeSeconds); + QVERIFY(!changed.isEmpty()); + QVERIFY(changed.last().at(2).value>().contains(GameRoles::PlaytimeText)); + QCOMPARE(library.index(0, 0).data(GameRoles::Title).toString(), QString("Alpha")); + QCOMPARE(library.index(0, 0).data(GameRoles::PlaytimeText).toString(), QString("1h 5m")); + QCOMPARE(GameRoles::formatPlaytime(0), QString("0m")); + QCOMPARE(GameRoles::formatPlaytime(59), QString("<1m")); + QCOMPARE(GameRoles::formatPlaytime(3600), QString("1h")); +} + +void CoreTests::sessionWriteFailureKeepsOriginalBoundary() { + const QString connection = "session-write-failure"; + { + QSqlDatabase database; + QVERIFY(SessionDatabase::open(database, ":memory:", connection)); + qint64 now = 0; + SessionRecorder recorder(database, [&] { return now; }); + const SessionMatch match{ + .pid = 10, .procStart = 100, .emulator = "Example", .gamePath = "/games/example.nes"}; + recorder.sync({match}, 1000); + QSqlQuery query(database); + QVERIFY(query.exec("CREATE TRIGGER deny_session BEFORE UPDATE ON play_sessions " + "BEGIN SELECT RAISE(ABORT, 'test storage failure'); END")); + now = 30000; + recorder.sync({match}, 1030); + QVERIFY(recorder.takeStorageFailure()); + now = 60000; + recorder.sync({}, 1060); + QVERIFY(recorder.takeStorageFailure()); + QCOMPARE(recorder.activeCount(), 0); + QCOMPARE(recorder.pendingCloseCount(), 1); + now = 65000; + recorder.endAll(1065); // A later toggle cannot change the original end boundary. + QCOMPARE(recorder.pendingCloseCount(), 1); + QVERIFY(query.exec("DROP TRIGGER deny_session")); + now = 90000; + recorder.sync({}, 1090); + QCOMPARE(recorder.pendingCloseCount(), 0); + QVERIFY(query.exec("SELECT ended_at,seconds FROM play_sessions")); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toLongLong(), 1060); + QCOMPARE(query.value(1).toLongLong(), 60); + } + QSqlDatabase::removeDatabase(connection); +} + +void CoreTests::artworkWriteBatchRollsBackAndRemainsPending() { + const QString connection = "artwork-write-failure"; + { + auto database = QSqlDatabase::addDatabase("QSQLITE", connection); + database.setDatabaseName(":memory:"); + QVERIFY(database.open()); + QSqlQuery query(database); + QVERIFY(query.exec("CREATE TABLE artwork(id TEXT PRIMARY KEY, path TEXT)")); + QVERIFY(query.exec("INSERT INTO artwork VALUES('a','old'),('b','old')")); + QVERIFY(query.exec("CREATE TRIGGER deny_artwork BEFORE UPDATE ON artwork WHEN NEW.id='b' " + "BEGIN SELECT RAISE(ABORT, 'test storage failure'); END")); + QHash pending{{"a", "new-a"}, {"b", "new-b"}}; + const QString statement = "UPDATE artwork SET path=? WHERE id=?"; + QVERIFY(!ArtworkPersistence::flush(database, statement, pending)); + QCOMPARE(pending.size(), 2); + QVERIFY(query.exec("SELECT count(*) FROM artwork WHERE path='old'")); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 2); + QVERIFY(query.exec("DROP TRIGGER deny_artwork")); + QVERIFY(ArtworkPersistence::flush(database, statement, pending)); + QVERIFY(pending.isEmpty()); + QVERIFY(query.exec("SELECT path FROM artwork WHERE id='a'")); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toString(), QString("new-a")); + } + QSqlDatabase::removeDatabase(connection); +} + +void CoreTests::metadataRecognizesProviderAliases() { + QTemporaryDir temp; + GameMetadata metadata(temp.filePath("library.sqlite3"), nullptr); + metadata.m_active = {{"metadataKey", "example"}, + {"title", "Regional Title (USA)"}, + {"system", "nes"}, + {"installPath", "/roms/Regional Title (USA, Rev 1).nes"}}; + metadata.m_selected = metadata.m_active; + QVERIFY(metadata.persist("example", {{"igdbId", 1}, {"portrait", "/cached/portrait.jpg"}})); + metadata.m_busy = true; + metadata.m_igdbStage = "games"; + metadata.matchResult(R"([{"id":42,"name":"Original Title","platforms":[18], + "alternative_names":[{"name":"Regional Title","comment":"North American title"}], + "game_localizations":[{"name":"Another Title","region":{"name":"Japan","identifier":"ja-JP"}}]}])", + {}); + const auto saved = metadata.entry("example"); + QCOMPARE(saved.value("igdbId").toInt(), 42); + QCOMPARE(saved.value("title").toString(), QString("Original Title")); + QCOMPARE(saved.value("localTitle").toString(), QString("Regional Title (USA)")); + QCOMPARE(saved.value("romFilename").toString(), QString("Regional Title (USA, Rev 1).nes")); + QCOMPARE(saved.value("portrait").toString(), QString("/cached/portrait.jpg")); + QVERIFY(saved.value("aliases").toStringList().contains("Regional Title")); + QCOMPARE( + saved.value("localizations").toList().first().toMap().value("regionIdentifier").toString(), + QString("ja-JP")); + const auto query = GameMetadata::aliasSearchQuery("Regional Title (USA)", "nes"); + QVERIFY(query.contains("alternative_names.name ~ \"Regional Title\"")); + QVERIFY(query.contains("game_localizations.name ~")); + QVERIFY(query.contains("platforms = (18")); +} + +void CoreTests::backupPlayHistoryHasSafeMergeAndRecorderGuard() { + QTemporaryDir sourceRoot, targetRoot; + const QString source = sourceRoot.filePath("library.sqlite3"); + const QString target = targetRoot.filePath("library.sqlite3"); + { + QSqlDatabase database; + QVERIFY(SessionDatabase::open(database, source, "backup-history-source")); + const qint64 id = + SessionDatabase::beginSession(database, "/games/a.nes", "Example", 1000, 100, 200); + QVERIFY(id > 0); + QVERIFY(SessionDatabase::updateProgress(database, id, 45, 1045)); + SessionDatabase::captureBaseline(database, "/games/a.nes", 100, 1045); + const qint64 other = + SessionDatabase::beginSession(database, "/games/b.nes", "Example", 2000, 101, 201); + QVERIFY(SessionDatabase::endSession(database, other, 2060, 60)); + // An older recorder omits session_key during a rolling upgrade. + QSqlQuery legacyWriter(database); + QVERIFY(legacyWriter.exec("INSERT INTO play_sessions(game_path,source,started_at,ended_at) " + "VALUES('/games/legacy.nes','Old recorder',500,501)")); + QVERIFY(legacyWriter.exec( + "SELECT session_key FROM play_sessions WHERE game_path='/games/legacy.nes'")); + QVERIFY(legacyWriter.next()); + QVERIFY(!legacyWriter.value(0).toString().isEmpty()); + legacyWriter.finish(); + // Simulate an old row before stable IDs were introduced, then migrate twice. + QSqlQuery query(database); + QVERIFY(query.exec("UPDATE play_sessions SET session_key=NULL WHERE game_path='/games/b.nes'")); + QVERIFY(SessionDatabase::ensureSchema(database)); + QVERIFY(query.exec("SELECT session_key FROM play_sessions WHERE game_path='/games/b.nes'")); + QVERIFY(query.next()); + const QString stable = query.value(0).toString(); + query.finish(); + QVERIFY(!stable.isEmpty()); + QVERIFY(SessionDatabase::ensureSchema(database)); + QVERIFY(query.exec("SELECT session_key FROM play_sessions WHERE game_path='/games/b.nes'")); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toString(), stable); + } + QSqlDatabase::removeDatabase("backup-history-source"); + BackupPayload snapshot; + QString error; + QVERIFY2(BackupSnapshot::capture(source, {}, &snapshot, &error), qPrintable(error)); + QCOMPARE(snapshot.library.value("play_sessions").toArray().size(), 3); + for (const auto& value : snapshot.library.value("play_sessions").toArray()) { + const auto row = value.toObject(); + QVERIFY(row.value("ended_at").toInteger() > 0); + QVERIFY(!row.contains("pid")); + if (row.value("game_path").toString() == "/games/a.nes") + QCOMPARE(row.value("ended_at").toInteger(), 1045); + } + const QString archive = sourceRoot.filePath("history.omakade-backup"); + QVERIFY2(BackupArchive::write(archive, snapshot, &error), qPrintable(error)); + BackupPayload decoded; + QVERIFY2(BackupArchive::read(archive, &decoded, &error), qPrintable(error)); + { + QSqlDatabase database; + QVERIFY(SessionDatabase::open(database, target, "backup-history-target")); + const auto id = SessionDatabase::beginSession(database, "/games/a.nes", "Local", 3000, 1, 2); + QVERIFY(SessionDatabase::endSession(database, id, 3010, 10)); + } + QSqlDatabase::removeDatabase("backup-history-target"); + for (int repeat = 0; repeat < 2; ++repeat) { + QVERIFY2(BackupDatabase::restore(target, decoded, BackupDatabase::Mode::Merge, &error), + qPrintable(error)); + QSqlDatabase database; + QVERIFY(SessionDatabase::open(database, target, "backup-history-check")); + QCOMPARE(SessionDatabase::trackedSecondsByPath(database).value("/games/a.nes"), 10); + QCOMPARE(SessionDatabase::trackedSecondsByPath(database).value("/games/b.nes"), 60); + QVERIFY(SessionDatabase::openSessions(database).isEmpty()); + database.close(); + database = {}; + QSqlDatabase::removeDatabase("backup-history-check"); + } + { + QLockFile recorder(target + ".sessiond.lock"); + recorder.setStaleLockTime(0); + QVERIFY(recorder.tryLock(0)); + QVERIFY(!BackupDatabase::restore(target, decoded, BackupDatabase::Mode::Replace, &error)); + QVERIFY(error.contains("recorder")); + } + QVERIFY2(BackupDatabase::restore(target, decoded, BackupDatabase::Mode::Replace, &error), + qPrintable(error)); + // Old backups have no authority to remove history. + auto legacy = decoded; + legacy.library.remove("play_sessions"); + legacy.library.remove("play_baselines"); + QVERIFY2(BackupDatabase::restore(target, legacy, BackupDatabase::Mode::Replace, &error), + qPrintable(error)); + { + QSqlDatabase database; + QVERIFY(SessionDatabase::open(database, target, "backup-history-final")); + QCOMPARE(SessionDatabase::trackedSecondsByPath(database).value("/games/a.nes"), 45); + QCOMPARE(SessionDatabase::baselinesByPath(database).value("/games/a.nes"), 55); + QVERIFY(SessionDatabase::openSessions(database).isEmpty()); + } + QSqlDatabase::removeDatabase("backup-history-final"); + auto invalid = decoded; + invalid.library.remove("play_baselines"); + QVERIFY(!BackupArchive::validate(invalid, &error)); + invalid = decoded; + auto excessive = invalid.library.value("play_sessions").toArray(); + auto row = excessive.first().toObject(); + row["seconds"] = 9007199254740991.0; + excessive[0] = row; + invalid.library["play_sessions"] = excessive; + QVERIFY(!BackupArchive::validate(invalid, &error)); + QVERIFY(error.contains("duration limit")); +} + +void CoreTests::metadataCacheProtectsReferencedAndPendingPortraits() { + QTemporaryDir temp; + GameMetadata metadata(temp.filePath("library.sqlite3"), nullptr); + QVERIFY(QDir().mkpath(metadata.m_cacheRoot)); + const QString kept = metadata.m_cacheRoot + "/kept.jpg"; + const QString pending = metadata.m_cacheRoot + "/pending.jpg"; + const QString unused = metadata.m_cacheRoot + "/unused.jpg"; + for (const auto& path : {kept, pending, unused}) + writeFile(path, QByteArray(100, 'x')); + QVERIFY(metadata.persist("kept", {{"portrait", kept}})); + metadata.m_pendingWrites.insert("pending", {{"portrait", pending}}); + metadata.m_cacheLimitBytes = 1; + metadata.trimPortraitCache(); + QVERIFY(QFileInfo::exists(kept)); + QVERIFY(QFileInfo::exists(pending)); + QVERIFY(!QFileInfo::exists(unused)); +} + +void CoreTests::regionalReleaseEvidence() { + const auto tags = RegionalMetadata::romTags("/Japan/Game (USA, Europe) (En,Ja) (Rev 1).nes"); + QCOMPARE(tags.value("regions").toStringList(), QStringList({"North America", "Europe"})); + QCOMPARE(tags.value("languages").toStringList(), QStringList({"En", "Ja"})); + QCOMPARE(tags.value("revisions").toStringList(), QStringList({"1"})); + QVERIFY(RegionalMetadata::romTags("Japan Racing (Director's Cut).nes") + .value("regions") + .toStringList() + .isEmpty()); + const auto matches = + GameMetadata::parseMatches(R"([{"id":426,"name":"Final Fantasy VI","platforms":[19], + "first_release_date":765244800, + "alternative_names":[{"name":"Final Fantasy III","comment":"North American title"}], + "release_dates":[ + {"date":100,"human":"1990","y":1990,"platform":18,"release_region":{"region":"north_america"}}, + {"date":765244800,"human":"Apr 02, 1994","y":1994,"platform":19,"release_region":{"region":"japan"}}, + {"date":781833600,"human":"Oct 11, 1994","y":1994,"platform":19,"release_region":{"region":"north_america"}}]}])", + {19}); + QCOMPARE(matches.size(), 1); + const auto value = matches.first().toMap(); + const auto na = RegionalMetadata::details(value, "Final Fantasy III (NA, Rev 1).sfc", {19}); + QCOMPARE(na.value("releaseLabel").toString(), QString("North America release")); + QCOMPARE(na.value("releaseText").toString(), QString("Oct 11, 1994")); + QVERIFY(na.value("titleEvidence").toStringList().join(" ").contains("North American title")); + const auto jp = RegionalMetadata::details(value, "Final Fantasy VI (Japan).sfc", {19}); + QCOMPARE(jp.value("releaseText").toString(), QString("Apr 02, 1994")); + const auto multi = RegionalMetadata::details(value, "Game (USA, Europe).sfc", {19}); + QCOMPARE(multi.value("releaseLabel").toString(), QString("First platform release")); + const auto unknown = RegionalMetadata::details(value, "Game.sfc", {19}); + QCOMPARE(unknown.value("releaseLabel").toString(), QString("First platform release")); + const auto other = RegionalMetadata::details(value, "Game (Japan).nds", {20}); + QCOMPARE(other.value("releaseLabel").toString(), QString("First catalog release")); + auto partial = value; + partial["releaseDates"] = QVariantList{QVariantMap{ + {"platform", 19}, {"region", "japan"}, {"date", 100}, {"human", "1994"}, {"year", 1994}}}; + QCOMPARE( + RegionalMetadata::details(partial, "Game (Japan).sfc", {19}).value("releaseText").toString(), + QString("1994")); + partial["releaseDates"] = QVariantList{ + QVariantMap{{"platform", 19}, {"region", "japan"}, {"date", 765244800}, {"human", "1994"}}}; + partial["year"] = 1980; + QCOMPARE(RegionalMetadata::details(partial, "Game (Japan).sfc", {19}).value("year").toInt(), + 1994); + QVERIFY( + GameMetadata::searchQuery("Game", "snes").contains("release_dates.release_region.region")); +} + +void CoreTests::regionalCatalogRegressionMatrix() { + struct Case { + const char* file; + const char* title; + const char* system; + int expected; + }; + const Case cases[] = { + {"regional-ff3.json", "Final Fantasy III (NA, Rev 1)", "snes", 426}, + {"regional-ff3-jp.json", "Final Fantasy III (Japan)", "nes", 77234}, + {"regional-ff2.json", "Final Fantasy II (USA)", "snes", 0}, + {"regional-starwing.json", "Starwing (Europe)", "snes", 8581}, + {"regional-paperboy.json", "Paperboy (USA)", "nes", 256083}, + }; + for (const auto& test : cases) { + QTemporaryDir temp; + const auto database = temp.filePath("library.sqlite3"); + GameMetadata metadata(database, nullptr); + metadata.m_active = {{"metadataKey", "game"}, + {"title", test.title}, + {"system", test.system}, + {"installPath", QString("/roms/") + test.title + ".rom"}}; + metadata.m_selected = metadata.m_active; + QVERIFY(metadata.persist("game", {{"igdbId", 1}, {"portrait", "/cached/portrait.jpg"}})); + QFile fixture(QString(OMAKADE_FIXTURE_DIR) + "/regional-metadata/" + test.file); + QVERIFY(fixture.open(QIODevice::ReadOnly)); + metadata.m_busy = true; + metadata.m_igdbStage = "games"; + metadata.m_aliasRetried = test.expected == 0; // The negative fixture is an exact-query tie. + metadata.matchResult(fixture.readAll(), {}); + const auto saved = metadata.entry("game"); + QCOMPARE(saved.value("portrait").toString(), QString("/cached/portrait.jpg")); + if (test.expected == 0) { + QVERIFY(saved.value("identityAmbiguous").toBool()); + QCOMPARE(metadata.candidates().size(), 2); + continue; + } + QCOMPARE(saved.value("igdbId").toInt(), test.expected); + const auto details = metadata.current(); + QVERIFY(!details.value("releaseDates").toList().isEmpty()); + if (test.expected == 426) { + QCOMPARE(details.value("releaseLabel").toString(), QString("North America release")); + QCOMPARE(details.value("releaseText").toString(), QString("Oct 20, 1994")); + QVERIFY(details.value("titleEvidence").toStringList().join(" ").contains("Final Fantasy VI")); + // A different installation of the same identity derives its own regional date. + metadata.m_selected["installPath"] = "/roms/Final Fantasy VI (Japan).sfc"; + QCOMPARE(metadata.current().value("releaseText").toString(), QString("Apr 02, 1994")); + } + GameMetadata reopened(database, nullptr); + reopened.m_selected = metadata.m_selected; + QCOMPARE(reopened.current().value("releaseText"), metadata.current().value("releaseText")); + } +} + +void CoreTests::metadataUpdatesOnlyInvalidateChangedRoles() { + class CountingSource : public QIdentityProxyModel { + public: + mutable QSet systemReads; + QVariant data(const QModelIndex& index, int role) const override { + if (role == GameRoles::System) systemReads.insert(index.row()); + return QIdentityProxyModel::data(index, role); + } + } source; + MockGameModel mock(nullptr, 1500); + source.setSourceModel(&mock); + QTemporaryDir temp; + const auto database = temp.filePath("library.sqlite3"); + UnifiedGameModel games(database); + games.addSourceModel(&source); + GameMetadata metadata(database, nullptr); + games.setMetadata(&metadata); + LibraryFilterModel filter; + filter.setSourceModel(&games); + QCOMPARE(filter.rowCount(), 1500); + const auto key = games.index(10).data(GameRoles::MetadataKey).toString(); + QSignalSpy updates(&games, &QAbstractItemModel::dataChanged); + QSignalSpy options(&filter, &LibraryFilterModel::metadataOptionsChanged); + source.systemReads.clear(); + QVariantMap value{{"year", 1994}, {"genres", QStringList{"RPG"}}, {"rating", 91}}; + QVERIFY(metadata.persist(key, value)); + QCOMPARE(updates.size(), 1); + const auto roles = qvariant_cast>(updates.first()[2]); + QVERIFY(roles.contains(GameRoles::Year)); + QVERIFY(roles.contains(GameRoles::Genres)); + QVERIFY(roles.contains(GameRoles::Rating)); + QVERIFY(!roles.contains(GameRoles::CoverPath)); + QVERIFY2(source.systemReads.size() <= 1, "Metadata without active filters rescanned the whole library"); + QCOMPARE(options.size(), 1); + + updates.clear(); options.clear(); + value["description"] = "New details without changing library fields"; + QVERIFY(metadata.persist(key, value)); + QVERIFY(updates.isEmpty()); + QVERIFY(options.isEmpty()); + value["rating"] = 92; + QVERIFY(metadata.persist(key, value)); + QCOMPARE(qvariant_cast>(updates.last()[2]), QList{GameRoles::Rating}); + QVERIFY(options.isEmpty()); + updates.clear(); + value["portrait"] = temp.filePath("new-cover.png"); + QVERIFY(metadata.persist(key, value)); + QCOMPARE(qvariant_cast>(updates.last()[2]), QList{GameRoles::CoverPath}); + + updates.clear(); + value["portraitUpdated"] = 12345; + QVERIFY(metadata.persist(key, value)); + QCOMPARE(qvariant_cast>(updates.last()[2]), QList{GameRoles::CoverPath}); + + // Active filters must still respond to metadata and ambiguous identities. + filter.setGenreFilter("RPG"); + QCOMPARE(filter.rowCount(), 1); + updates.clear(); + value["identityAmbiguous"] = true; + QVERIFY(metadata.persist(key, value)); + QCOMPARE(filter.rowCount(), 0); + const auto ambiguousRoles = qvariant_cast>(updates.last()[2]); + QVERIFY(ambiguousRoles.contains(GameRoles::Genres)); + QVERIFY(ambiguousRoles.contains(GameRoles::Year)); + value["identityAmbiguous"] = false; + QVERIFY(metadata.persist(key, value)); + QCOMPARE(filter.rowCount(), 1); + filter.setDecadeFilter("1990s"); + value["year"] = 2001; + QVERIFY(metadata.persist(key, value)); + QCOMPARE(filter.rowCount(), 0); +} + +void CoreTests::metadataDiscoveryFiltersPersistAndRefresh() { + QTemporaryDir temp; + const auto database = temp.filePath("library.sqlite3"); + MockGameModel source(nullptr, 3); + UnifiedGameModel games(database); + games.addSourceModel(&source); + GameMetadata metadata(database, nullptr); + games.setMetadata(&metadata); + const QString first = games.index(0).data(GameRoles::MetadataKey).toString(); + const QString second = games.index(1).data(GameRoles::MetadataKey).toString(); + QVERIFY(metadata.persist( + first, {{"igdbId", 1}, {"year", 1994}, {"genres", QStringList{"Adventure", "RPG"}}})); + QVERIFY(metadata.persist(second, + {{"igdbId", 2}, {"year", 2001}, {"genres", QStringList{"Adventure"}}})); + LibraryFilterModel filter; + filter.setSourceModel(&games); + // Background metadata refreshes must not invalidate every visible card. + QSignalSpy layouts(&filter, &QAbstractItemModel::layoutChanged); + QSignalSpy resets(&filter, &QAbstractItemModel::modelReset); + for (int i = 0; i < 12; ++i) + QVERIFY(metadata.persist(first, {{"igdbId", 1}, {"year", 1994}, + {"genres", QStringList{"Adventure", "RPG"}}, + {"rating", 80 + i}})); + QCOMPARE(layouts.count(), 0); + QCOMPARE(resets.count(), 0); + filter.setSortMode(LibraryFilterModel::SortMode::Rating); + QCOMPARE(filter.get(0).value("metadataKey").toString(), first); + QVERIFY(metadata.persist(second, {{"igdbId", 2}, {"year", 2001}, + {"genres", QStringList{"Adventure"}}, {"rating", 99}})); + QCOMPARE(filter.get(0).value("metadataKey").toString(), second); + layouts.clear(); + QVERIFY(metadata.persist(second, {{"igdbId", 2}, {"year", 2001}, + {"genres", QStringList{"Adventure"}}, {"rating", 99}, + {"description", "Background details refresh"}})); + QCOMPARE(layouts.count(), 0); + filter.setSortMode(LibraryFilterModel::SortMode::Title); + QVERIFY(filter.genreNames().contains("RPG")); + QVERIFY(filter.decadeNames().contains("1990s")); + QCOMPARE(filter.platformNames(), QStringList{"PC"}); + filter.setGenreFilter("adventure"); + QCOMPARE(filter.rowCount(), 2); + filter.setDecadeFilter("1990s"); + QCOMPARE(filter.rowCount(), 1); + filter.setPlatformFilter("PC"); + QCOMPARE(filter.rowCount(), 1); + QCOMPARE(filter.get(0).value("year").toInt(), 1994); + const auto savedState = filter.filterState(); + const QString id = filter.saveCurrentFilter("Nineties adventures"); + QVERIFY(!id.isEmpty()); + QSignalSpy options(&filter, &LibraryFilterModel::metadataOptionsChanged); + QVERIFY(metadata.persist(second, + {{"igdbId", 2}, {"year", 1998}, {"genres", QStringList{"Adventure"}}})); + QCOMPARE(filter.rowCount(), 2); + QVERIFY(!options.isEmpty()); + QVERIFY(metadata.persist(second, {{"igdbId", 2}, + {"year", 1998}, + {"genres", QStringList{"Adventure"}}, + {"identityAmbiguous", true}})); + QCOMPARE(filter.rowCount(), 1); + filter.setPlatformFilter("Super Nintendo"); + QCOMPARE(filter.rowCount(), 0); + filter.setConsoleFilter("nes"); + QVERIFY(filter.applySavedFilter(id)); + QVERIFY(filter.consoleFilter().isEmpty()); + QCOMPARE(filter.filterState(), savedState); + QCOMPARE(filter.rowCount(), 1); + // A legacy query clears new criteria instead of silently inheriting them. + auto legacy = savedState; + legacy["version"] = 1; + for (const auto* key : {"genre", "decade", "platform", "console"}) + legacy.remove(key); + QVERIFY(games.saveFilter("legacy", "Legacy", legacy)); + QVERIFY(filter.applySavedFilter("legacy")); + QVERIFY(filter.genreFilter().isEmpty()); + QVERIFY(filter.decadeFilter().isEmpty()); + QVERIFY(filter.platformFilter().isEmpty()); + QCOMPARE(filter.rowCount(), 3); + // Restart and archive round trips preserve the new criteria. + UnifiedGameModel reopened(database); + reopened.addSourceModel(&source); + reopened.setMetadata(&metadata); + LibraryFilterModel restored; + restored.setSourceModel(&reopened); + QVERIFY(restored.applySavedFilter(id)); + QCOMPARE(restored.filterState(), savedState); + QCOMPARE(restored.rowCount(), 1); + BackupPayload payload, read; + QString error; + QVERIFY2(BackupSnapshot::capture(database, {}, &payload, &error), qPrintable(error)); + const auto archive = temp.filePath("filters.omakade-backup"); + QVERIFY2(BackupArchive::write(archive, payload, &error), qPrintable(error)); + QVERIFY2(BackupArchive::read(archive, &read, &error), qPrintable(error)); + QCOMPARE(read.library.value("saved_filters"), payload.library.value("saved_filters")); + auto invalid = savedState; + invalid["decade"] = "1994"; + QVERIFY(games.saveFilter("invalid", "Invalid", invalid)); + QVERIFY(!filter.applySavedFilter("invalid")); + const auto stateBefore = filter.filterState(); + filter.setDecadeFilter("1994"); + QCOMPARE(filter.filterState(), stateBefore); +} + +void CoreTests::homeDiscoveryRespectsLibraryState() { + QTemporaryDir temp; + const QString path = temp.filePath("home.sqlite3"); + MockGameModel source(nullptr, 24); + UnifiedGameModel games(path); + games.addSourceModel(&source); + HomeModel home(&games, path); + QVERIFY(games.setCompletionStatus(10, "backlog")); + QVERIFY(games.setCompletionStatus(11, "completed")); + QVERIFY(games.setCompletionStatus(12, "abandoned")); + QVERIFY(games.bulkOrganize({games.index(13).data(GameRoles::MetadataKey).toString()}, {{"hidden", true}})); + QVERIFY(games.createCollection("Weekend")); + QVERIFY(games.setCollectionMembership(10, "Weekend", true)); + home.setActive(true); + QCOMPARE(home.gameCount(), 23); + QCOMPARE(home.suggestions().first().toMap().value("appId").toString(), QString("demo-10")); + QCOMPARE(home.suggestions().first().toMap().value("suggestionReason").toString(), QString("From your backlog")); + bool collectionFound = false; + for (const auto& item : home.shortcuts()) { + const auto shortcut = item.toMap(); + if (shortcut.value("kind") == "collection" && shortcut.value("value") == "Weekend") { + collectionFound = true; + QCOMPARE(shortcut.value("count").toInt(), 1); + } + } + QVERIFY(collectionFound); + QVERIFY(home.enqueue("Demo", "", "demo-10")); + QSet excluded{"demo-10", "demo-11", "demo-12", "demo-13"}; + for (const auto& item : home.recent()) excluded.insert(item.toMap().value("appId").toString()); + QSet seen; + for (const auto& item : home.suggestions()) { + const auto game = item.toMap(); + const auto id = game.value("appId").toString(); + QVERIFY(!excluded.contains(id)); + QVERIFY(!seen.contains(id)); + QVERIFY(!game.value("suggestionReason").toString().isEmpty()); + seen.insert(id); + } + const auto before = home.suggestions(); + home.refresh(); + QCOMPARE(home.suggestions(), before); + games.setSourceEnabled("Demo", false); + home.refresh(); + QVERIFY(home.suggestions().isEmpty()); + QVERIFY(home.shortcuts().isEmpty()); + QCOMPARE(home.gameCount(), 0); +} + +void CoreTests::homeRefreshOnlyReadsChangedGames() { + // Count expensive artwork reads, not wall time: a single update in a real-sized + // library must not trigger another full-library scan on the GUI thread. + class CountingSource : public QIdentityProxyModel { + public: + mutable QSet artworkRows; + QVariant data(const QModelIndex& index, int role) const override { + if (role == GameRoles::CoverPath) artworkRows.insert(index.row()); + return QIdentityProxyModel::data(index, role); + } + } source; + MockGameModel mock(nullptr, 1500); + source.setSourceModel(&mock); + QTemporaryDir temp; + UnifiedGameModel games(temp.filePath("home.sqlite3")); + games.addSourceModel(&source); + games.setCompletionStatus(10, "backlog"); + HomeModel home(&games, {}); + home.setActive(true); + QCoreApplication::processEvents(); + QCOMPARE(source.artworkRows.size(), 1500); + const bool favorite = home.suggestions().first().toMap().value("favorite").toBool(); + source.artworkRows.clear(); + QSignalSpy changed(&home, &HomeModel::changed); + mock.toggleFavorite(10); + QTRY_VERIFY(!changed.isEmpty()); + QCOMPARE(source.artworkRows, QSet{10}); + QCOMPARE(home.suggestions().first().toMap().value("favorite").toBool(), !favorite); + + // Queue actions must see changes received while the Home page is closed. + home.setActive(false); + source.artworkRows.clear(); + mock.toggleFavorite(10); + QVERIFY(home.enqueue("Demo", "", "demo-10")); + QCOMPARE(source.artworkRows, QSet{10}); + QCOMPARE(home.queue().first().toMap().value("favorite").toBool(), favorite); + games.setSourceEnabled("Demo", false); + QVERIFY(!home.enqueue("Demo", "", "demo-11")); + home.refresh(); + for (const auto& value : home.queue()) QVERIFY(!value.toMap().value("available").toBool()); + games.setSourceEnabled("Demo", true); + source.artworkRows.clear(); + home.refresh(); + QCOMPARE(source.artworkRows.size(), 1500); + for (const auto& value : home.queue()) QVERIFY(value.toMap().value("available").toBool()); +} + +void CoreTests::homeQueuePreservesIdentityAndStorage() { + QTemporaryDir temp; + const QString path = temp.filePath("library.sqlite3"); + MockGameModel source(nullptr, 4); + UnifiedGameModel games(path); + games.addSourceModel(&source); + HomeModel home(&games, path); + home.setActive(true); + QVERIFY(home.enqueue("Demo", "", "demo-1")); + QVERIFY(home.enqueue("Demo", "", "demo-2")); + QVERIFY(home.enqueue("Demo", "", "demo-1")); + QCOMPARE(home.queue().size(), 2); + const auto second = home.queue()[1].toMap().value("queueKey").toString(); + QVERIFY(home.move(second, -1)); + QCOMPARE(home.queue()[0].toMap().value("appId").toString(), QString("demo-2")); + HomeModel reopened(&games, path); + reopened.refresh(); + QCOMPARE(reopened.queue(), home.queue()); + games.setSourceEnabled("Demo", false); + home.refresh(); + QCOMPARE(home.queue().size(), 2); + QVERIFY(!home.queue()[0].toMap().value("available").toBool()); + QVERIFY(home.recent().isEmpty()); + games.setSourceEnabled("Demo", true); + home.refresh(); + QVERIFY(home.queue()[0].toMap().value("available").toBool()); + // Library filters are restored after revealing a game from Home. + LibraryFilterModel filter; + filter.setSourceModel(&games); + filter.setSearchText("no matches"); + const auto state = filter.filterState(); + QVERIFY(filter.revealGame("Demo", "", "demo-1") >= 0); + QVERIFY(filter.applyFilterState(state)); + QCOMPARE(filter.rowCount(), 0); + // A write failure cannot erase or reorder the committed queue. + const QString connection = "home-failure"; + { + auto db = QSqlDatabase::addDatabase("QSQLITE", connection); + db.setDatabaseName(path); + QVERIFY(db.open()); + QSqlQuery q(db); + QVERIFY(q.exec("CREATE TRIGGER deny_queue BEFORE DELETE ON play_queue BEGIN SELECT " + "RAISE(ABORT,'test'); END")); + const auto before = home.queue(); + QVERIFY(!home.remove(second)); + QCOMPARE(home.queue(), before); + QVERIFY(!home.error().isEmpty()); + QVERIFY(q.exec("DROP TRIGGER deny_queue")); + } + QSqlDatabase::removeDatabase(connection); + // Linking queued installations presents one entry and removes the whole group together. + QVERIFY(games.linkGames(1, "Demo", "", "demo-2")); + home.refresh(); + QCOMPARE(home.queue().size(), 1); + QVERIFY(home.remove(home.queue()[0].toMap().value("queueKey").toString())); + QVERIFY(home.queue().isEmpty()); + QVERIFY(home.enqueue("Demo", "", "demo-1")); + QVERIFY(games.bulkOrganize({games.index(1).data(GameRoles::MetadataKey).toString()}, {{"hidden", true}})); + home.refresh(); + QVERIFY(home.queue().isEmpty()); + QVERIFY(games.bulkOrganize({games.index(1).data(GameRoles::MetadataKey).toString()}, {{"hidden", false}})); + home.refresh(); + QCOMPARE(home.queue().size(), 1); + BackupPayload payload, read; + QString error; + QVERIFY2(BackupSnapshot::capture(path, {}, &payload, &error), qPrintable(error)); + QVERIFY2(BackupArchive::write(temp.filePath("queue.backup"), payload, &error), qPrintable(error)); + QVERIFY2(BackupArchive::read(temp.filePath("queue.backup"), &read, &error), qPrintable(error)); + QCOMPARE(read.library.value("play_queue"), payload.library.value("play_queue")); + const auto target = temp.filePath("restored.sqlite3"); + QVERIFY2(BackupDatabase::restore(target, read, BackupDatabase::Mode::Replace, &error), + qPrintable(error)); + HomeModel restored(&games, target); + restored.refresh(); + QCOMPARE(restored.queue().size(), 1); + QVERIFY(restored.enqueue("Demo", "", "demo-3")); + QVERIFY2(BackupDatabase::restore(target, read, BackupDatabase::Mode::Merge, &error), + qPrintable(error)); + restored.refresh(); + QCOMPARE(restored.queue().size(), 2); + QCOMPARE(restored.queue()[1].toMap().value("appId").toString(), QString("demo-3")); + auto legacy = read; + legacy.library.remove("play_queue"); + QVERIFY2(BackupDatabase::restore(target, legacy, BackupDatabase::Mode::Replace, &error), + qPrintable(error)); + restored.refresh(); + QCOMPARE(restored.queue().size(), 2); +} + +void CoreTests::recordingPreferenceMigration() { + QTemporaryDir temp; + const auto path = temp.filePath("config.toml"); + AppSettings fresh(path); + QVERIFY(!fresh.trackPlaySessions()); + fresh.setTrackPlaySessions(true); + AppSettings enabled(path); + QVERIFY(enabled.trackPlaySessions()); + enabled.setTrackPlaySessions(false); + AppSettings disabled(path); + QVERIFY(!disabled.trackPlaySessions()); + writeFile(path, "[general]\nclose_after_launch = false\n"); + AppSettings legacy(path); + QVERIFY(legacy.trackPlaySessions()); +} + +void CoreTests::homeQueueCapacityAndRecovery() { + QTemporaryDir temp; + const auto path = temp.filePath("library.sqlite3"); + MockGameModel source(nullptr, 101); + UnifiedGameModel games(path); + games.addSourceModel(&source); + HomeModel home(&games, path); + home.setActive(true); + for (int i = 0; i < 100; ++i) + QVERIFY(home.enqueue("Demo", "", QString("demo-%1").arg(i))); + QCOMPARE(home.queue().size(), 100); + QVERIFY(!home.enqueue("Demo", "", "demo-100")); + const auto last = home.queue().last().toMap().value("queueKey").toString(); + QVERIFY(home.move(last, -1)); + QCOMPARE(home.queue()[98].toMap().value("queueKey").toString(), last); + HomeModel reopened(&games, path); + reopened.refresh(); + QCOMPARE(reopened.queue(), home.queue()); + games.setSourceEnabled("Demo", false); + home.refresh(); + QCOMPARE(home.queue().size(), 100); + for (const auto& entry : home.queue()) QVERIFY(!entry.toMap().value("available").toBool()); + QVERIFY(home.remove(last)); + games.setSourceEnabled("Demo", true); + home.refresh(); + QCOMPARE(home.queue().size(), 99); + QVERIFY(home.enqueue("Demo", "", "demo-100")); + QCOMPARE(home.queue().size(), 100); +} diff --git a/tests/fixtures/regional-metadata/README.md b/tests/fixtures/regional-metadata/README.md new file mode 100644 index 0000000..840e351 --- /dev/null +++ b/tests/fixtures/regional-metadata/README.md @@ -0,0 +1,20 @@ +# Regional identity fixtures + +Read-only IGDB responses captured September 8, 2026 using GameMetadata::aliasSearchQuery. +Only identity and release facts are retained. These are provider assertions, not an independent +historical audit. Refresh deliberately when catalog evidence changes. + +- FF3 SNES: regional numbering, one catalog ID, different platform/territory dates. +- FF3 NES/Famicom: a distinct game despite the same English title. +- FF2 SNES: two exact title/alias candidates; requires identification. +- Starwing: an alternative name resolves to Star Fox on SNES. +- Paperboy NES: port identity refresh must retain an existing portrait. + +Sources: https://api-docs.igdb.com/#game-localization and +https://api-docs.igdb.com/#release-date. + +`smw-broad.json` and `smw-exact.json` were captured from IGDB on 2026-09-08 using the +same SNES/Super Famicom platform filter (19, 58). The broad search for Super Mario World +fills the 20-result page, including punctuation lookalikes and unrelated hacks. The exact +name/alternative-name/localization query returns only game 1070. These are offline matching +regressions; no game-specific production rule is used. diff --git a/tests/fixtures/regional-metadata/regional-ff2.json b/tests/fixtures/regional-metadata/regional-ff2.json new file mode 100644 index 0000000..21b683f --- /dev/null +++ b/tests/fixtures/regional-metadata/regional-ff2.json @@ -0,0 +1,215 @@ +[ + { + "id": 387, + "alternative_names": [ + { + "id": 31049, + "comment": "Acronym", + "name": "FFII" + }, + { + "id": 52682, + "comment": "Alternative title", + "name": "Final Fantasy IV" + }, + { + "id": 59533, + "comment": "Alternative spelling", + "name": "Final Fantasy 2" + }, + { + "id": 59534, + "comment": "Alternative spelling", + "name": "Final Fantasy 4" + }, + { + "id": 59530, + "comment": "Alternative spelling", + "name": "FFIV" + }, + { + "id": 59531, + "comment": "Alternative spelling", + "name": "FF4" + }, + { + "id": 59532, + "comment": "Alternative spelling", + "name": "FF2" + } + ], + "first_release_date": 690854400, + "name": "Final Fantasy II", + "platforms": [ + 19, + 5 + ], + "release_dates": [ + { + "id": 866064, + "date": 690854400, + "human": "Nov 23, 1991", + "platform": 19, + "y": 1991, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 866065, + "date": 1276214400, + "human": "Jun 11, 2010", + "platform": 5, + "y": 2010, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 866066, + "date": 1268006400, + "human": "Mar 08, 2010", + "platform": 5, + "y": 2010, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 866067, + "date": 1276214400, + "human": "Jun 11, 2010", + "platform": 5, + "y": 2010, + "release_region": { + "id": 3, + "region": "australia" + } + } + ], + "game_localizations": [ + { + "id": 26180, + "name": "파이널 판타지 II", + "region": { + "id": 2, + "name": "Korea", + "identifier": "ko-KR" + } + } + ] + }, + { + "id": 16587, + "alternative_names": [ + { + "id": 59522, + "comment": "Alternative title", + "name": "Final Fantasy II" + }, + { + "id": 59523, + "comment": "Alternative spelling", + "name": "FF2" + }, + { + "id": 59524, + "comment": "Alternative spelling", + "name": "FF4" + }, + { + "id": 59526, + "comment": "Alternative spelling", + "name": "Final Fantasy 4" + }, + { + "id": 59527, + "comment": "Acronym", + "name": "FFIV" + }, + { + "id": 59528, + "comment": "Alternative spelling", + "name": "FFII" + }, + { + "id": 59529, + "comment": "Alternative spelling", + "name": "Final Fantasy 2" + }, + { + "id": 62772, + "comment": "Stylized title", + "name": "FINAL FANTASY IV" + } + ], + "first_release_date": 679881600, + "name": "Final Fantasy IV", + "platforms": [ + 5, + 41, + 137, + 58 + ], + "release_dates": [ + { + "id": 570243, + "date": 679881600, + "human": "Jul 19, 1991", + "platform": 58, + "y": 1991, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 873678, + "date": 1503446400, + "human": "Aug 23, 2017", + "platform": 137, + "y": 2017, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 873679, + "date": 1249344000, + "human": "Aug 04, 2009", + "platform": 5, + "y": 2009, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 873680, + "date": 1392768000, + "human": "Feb 19, 2014", + "platform": 41, + "y": 2014, + "release_region": { + "id": 5, + "region": "japan" + } + } + ], + "game_localizations": [ + { + "id": 2198, + "name": "ファイナルファンタジーIV", + "region": { + "id": 3, + "name": "Japan", + "identifier": "ja-JP" + } + } + ] + } +] diff --git a/tests/fixtures/regional-metadata/regional-ff3-jp.json b/tests/fixtures/regional-metadata/regional-ff3-jp.json new file mode 100644 index 0000000..8d747f0 --- /dev/null +++ b/tests/fixtures/regional-metadata/regional-ff3-jp.json @@ -0,0 +1,92 @@ +[ + { + "id": 77234, + "alternative_names": [ + { + "id": 31054, + "comment": "Acronym", + "name": "FFIII" + }, + { + "id": 59513, + "comment": "Alternative spelling", + "name": "Final Fantasy 3" + }, + { + "id": 62751, + "comment": "Stylized title", + "name": "FINAL FANTASY III" + }, + { + "id": 85331, + "comment": "Acronym", + "name": "FF3" + } + ], + "first_release_date": 641174400, + "name": "Final Fantasy III", + "platforms": [ + 37, + 5, + 99, + 41 + ], + "release_dates": [ + { + "id": 525578, + "date": 1389139200, + "human": "Jan 08, 2014", + "platform": 41, + "y": 2014, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 570258, + "date": 1398211200, + "human": "Apr 23, 2014", + "platform": 37, + "y": 2014, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 525575, + "date": 641174400, + "human": "Apr 27, 1990", + "platform": 99, + "y": 1990, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 525577, + "date": 1248134400, + "human": "Jul 21, 2009", + "platform": 5, + "y": 2009, + "release_region": { + "id": 5, + "region": "japan" + } + } + ], + "game_localizations": [ + { + "id": 9863, + "name": "ファイナルファンタジー III", + "region": { + "id": 3, + "name": "Japan", + "identifier": "ja-JP" + } + } + ] + } +] diff --git a/tests/fixtures/regional-metadata/regional-ff3.json b/tests/fixtures/regional-metadata/regional-ff3.json new file mode 100644 index 0000000..0784837 --- /dev/null +++ b/tests/fixtures/regional-metadata/regional-ff3.json @@ -0,0 +1,171 @@ +[ + { + "id": 426, + "alternative_names": [ + { + "id": 59833, + "comment": "Alternative spelling", + "name": "FF6" + }, + { + "id": 24604, + "comment": "Acronym", + "name": "FFVI" + }, + { + "id": 59854, + "comment": "Alternative spelling", + "name": "FF3" + }, + { + "id": 59855, + "comment": "Alternative spelling", + "name": "FFIII" + }, + { + "id": 59849, + "comment": "Alternative spelling", + "name": "Final Fantasy 6" + }, + { + "id": 59851, + "comment": "Chinese title - simplified", + "name": "最终幻想6" + }, + { + "id": 59859, + "comment": "Alternative spelling", + "name": "Final Fantasy 3" + }, + { + "id": 192751, + "comment": "Alternative title", + "name": "Final Fantasy VI" + }, + { + "id": 192752, + "comment": "Stylized title", + "name": "FINAL FANTASY III" + } + ], + "first_release_date": 765244800, + "name": "Final Fantasy III", + "platforms": [ + 19, + 5, + 41, + 137, + 58 + ], + "release_dates": [ + { + "id": 570941, + "date": 765244800, + "human": "Apr 02, 1994", + "platform": 58, + "y": 1994, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 570942, + "date": 1300147200, + "human": "Mar 15, 2011", + "platform": 5, + "y": 2011, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 570943, + "date": 1372204800, + "human": "Jun 26, 2013", + "platform": 41, + "y": 2013, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 570944, + "date": 1503446400, + "human": "Aug 23, 2017", + "platform": 137, + "y": 2017, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 823934, + "date": 1300406400, + "human": "Mar 18, 2011", + "platform": 5, + "y": 2011, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 823935, + "date": 1300406400, + "human": "Mar 18, 2011", + "platform": 5, + "y": 2011, + "release_region": { + "id": 3, + "region": "australia" + } + }, + { + "id": 823933, + "date": 1309392000, + "human": "Jun 30, 2011", + "platform": 5, + "y": 2011, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 823936, + "date": 782611200, + "human": "Oct 20, 1994", + "platform": 19, + "y": 1994, + "release_region": { + "id": 2, + "region": "north_america" + } + } + ], + "game_localizations": [ + { + "id": 550, + "name": "파이널 판타지 VI", + "region": { + "id": 2, + "name": "Korea", + "identifier": "ko-KR" + } + }, + { + "id": 551, + "name": "ファイナルファンタジーVI", + "region": { + "id": 3, + "name": "Japan", + "identifier": "ja-JP" + } + } + ] + } +] diff --git a/tests/fixtures/regional-metadata/regional-paperboy.json b/tests/fixtures/regional-metadata/regional-paperboy.json new file mode 100644 index 0000000..96e61a0 --- /dev/null +++ b/tests/fixtures/regional-metadata/regional-paperboy.json @@ -0,0 +1,24 @@ +[ + { + "id": 256083, + "first_release_date": 596937600, + "name": "Paperboy", + "platforms": [ + 99, + 18 + ], + "release_dates": [ + { + "id": 489347, + "date": 596937600, + "human": "Dec 01, 1988", + "platform": 18, + "y": 1988, + "release_region": { + "id": 2, + "region": "north_america" + } + } + ] + } +] diff --git a/tests/fixtures/regional-metadata/regional-starwing.json b/tests/fixtures/regional-metadata/regional-starwing.json new file mode 100644 index 0000000..2a3ebb8 --- /dev/null +++ b/tests/fixtures/regional-metadata/regional-starwing.json @@ -0,0 +1,100 @@ +[ + { + "id": 8581, + "alternative_names": [ + { + "id": 115369, + "comment": "Alternative spelling", + "name": "StarFox" + }, + { + "id": 115370, + "comment": "Alternative title", + "name": "Star Wing" + } + ], + "first_release_date": 730252800, + "name": "Star Fox", + "platforms": [ + 19, + 58 + ], + "release_dates": [ + { + "id": 548240, + "date": 746668800, + "human": "Aug 30, 1993", + "platform": 19, + "y": 1993, + "release_region": { + "id": 10, + "region": "brazil" + } + }, + { + "id": 548239, + "date": 730252800, + "human": "Feb 21, 1993", + "platform": 58, + "y": 1993, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 592138, + "date": 859852800, + "human": "Apr 1997", + "platform": 19, + "y": 1997, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 548241, + "date": 739065600, + "human": "Jun 03, 1993", + "platform": 19, + "y": 1993, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 548242, + "date": 732844800, + "human": "Mar 23, 1993", + "platform": 19, + "y": 1993, + "release_region": { + "id": 2, + "region": "north_america" + } + } + ], + "game_localizations": [ + { + "id": 739, + "name": "Starwing", + "region": { + "id": 4, + "name": "Europe", + "identifier": "EU" + } + }, + { + "id": 737, + "name": "スターフォックス", + "region": { + "id": 3, + "name": "Japan", + "identifier": "ja-JP" + } + } + ] + } +] diff --git a/tests/fixtures/regional-metadata/smw-broad.json b/tests/fixtures/regional-metadata/smw-broad.json new file mode 100644 index 0000000..94e0382 --- /dev/null +++ b/tests/fixtures/regional-metadata/smw-broad.json @@ -0,0 +1,785 @@ +[ + { + "id": 1070, + "alternative_names": [ + { + "id": 56571, + "comment": "Acronym", + "name": "SMW" + }, + { + "id": 93079, + "comment": "Japanese title - abbreviation", + "name": "マリオワールド" + }, + { + "id": 95856, + "comment": "Chinese title - simplified", + "name": "超级马力欧世界" + }, + { + "id": 98790, + "comment": "Alternative title", + "name": "Super Mario Bros. 4: Super Mario World" + } + ], + "name": "Super Mario World", + "platforms": [ + 52, + 19, + 5, + 41, + 137, + 58 + ], + "release_dates": [ + { + "id": 505220, + "platform": 58, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 592043, + "platform": 137, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 505228, + "platform": 5, + "release_region": { + "id": 3, + "region": "australia" + } + }, + { + "id": 505221, + "platform": 19, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 505222, + "platform": 19, + "release_region": { + "id": 3, + "region": "australia" + } + }, + { + "id": 505227, + "platform": 5, + "release_region": { + "id": 9, + "region": "korea" + } + }, + { + "id": 505229, + "platform": 5, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 505225, + "platform": 19, + "release_region": { + "id": 10, + "region": "brazil" + } + }, + { + "id": 505223, + "platform": 19, + "release_region": { + "id": 9, + "region": "korea" + } + }, + { + "id": 592044, + "platform": 137, + "release_region": { + "id": 3, + "region": "australia" + } + }, + { + "id": 592049, + "platform": 137, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 592037, + "platform": 52, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 592046, + "platform": 137, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 505224, + "platform": 19, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 505226, + "platform": 5, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 505230, + "platform": 5, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 956596, + "platform": 41, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 956597, + "platform": 41, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 956598, + "platform": 41, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 956599, + "platform": 41, + "release_region": { + "id": 3, + "region": "australia" + } + } + ], + "game_localizations": [ + { + "id": 162, + "name": "スーパーマリオワールド", + "region": { + "id": 3, + "name": "Japan" + } + }, + { + "id": 2325, + "name": "슈퍼 마리오 월드", + "region": { + "id": 2, + "name": "Korea" + } + } + ], + "game_type": 0 + }, + { + "id": 268102, + "name": "Super Mario World: A Super Mario Adventure", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 518950, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 277106, + "alternative_names": [ + { + "id": 144082, + "comment": "Abbreviation", + "name": "SMW: A Super Mario Adventure 3" + } + ], + "name": "Super Mario World: A Super Mario Adventure 3", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 537569, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 277107, + "alternative_names": [ + { + "id": 144085, + "comment": "Abbreviation", + "name": "SMW: A Super Mario Adventure 2" + } + ], + "name": "Super Mario World: A Super Mario Adventure 2", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 537575, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 1073, + "alternative_names": [ + { + "id": 81580, + "comment": "Japanese title - romanization", + "name": "Super Mario: Yoshi's Island" + }, + { + "id": 81581, + "comment": "Abbreviation", + "name": "Yoshi's Island" + }, + { + "id": 81582, + "comment": "Abbreviation", + "name": "Super Mario World 2" + }, + { + "id": 93081, + "comment": "Japanese title - alternative title", + "name": "スーパマリオワールド2" + }, + { + "id": 141524, + "comment": "Working title", + "name": "Super Mario Brothers 5: Yoshi's Island" + } + ], + "name": "Super Mario World 2: Yoshi's Island", + "platforms": [ + 306, + 19, + 58 + ], + "release_dates": [ + { + "id": 548949, + "platform": 58, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 592199, + "platform": 19, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 592921, + "platform": 306, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 548952, + "platform": 19, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 548953, + "platform": 19, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 548950, + "platform": 19, + "release_region": { + "id": 9, + "region": "korea" + } + }, + { + "id": 548951, + "platform": 19, + "release_region": { + "id": 10, + "region": "brazil" + } + }, + { + "id": 913336, + "platform": 58, + "release_region": { + "id": 7, + "region": "asia" + } + } + ], + "game_localizations": [ + { + "id": 19250, + "name": "슈퍼 마리오 월드 2: 요시 아일랜드", + "region": { + "id": 2, + "name": "Korea" + } + }, + { + "id": 3058, + "name": "スーパーマリオ ヨッシーアイランド", + "region": { + "id": 3, + "name": "Japan" + } + } + ], + "game_type": 0 + }, + { + "id": 145481, + "alternative_names": [ + { + "id": 58409, + "comment": "Alternative title", + "name": "Super Mario World Backwards" + }, + { + "id": 58408, + "comment": "Alternative title", + "name": "Backwards Super Mario World" + }, + { + "id": 58410, + "comment": "Alternative title", + "name": "Inverted Super Mario World" + } + ], + "name": "dlroW oiraM repuS", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 542153, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 278033, + "alternative_names": [ + { + "id": 144369, + "comment": "Alternative title", + "name": "Super Mario World Redone" + } + ], + "name": "Super Mario World Redone: Luigi Version", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 539521, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "version_parent": 275300, + "version_title": "Luigi Version", + "game_type": 5 + }, + { + "id": 323063, + "alternative_names": [ + { + "id": 162003, + "comment": "Alternative spelling", + "name": "A Super Mario World Central Production" + } + ], + "name": "An Super Mario World Central Production", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 669630, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 297240, + "name": "Super \"Mario\" World", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 583531, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 202377, + "alternative_names": [ + { + "id": 89270, + "comment": "Abbreviation", + "name": "Super Mario World TSRPR" + }, + { + "id": 89271, + "comment": "Alternative title", + "name": "The Second Reality Project Reloaded" + } + ], + "name": "Super Mario World: The Second Reality Project - Reloaded", + "platforms": [ + 19, + 6 + ], + "release_dates": [ + { + "id": 360530, + "platform": 6, + "release_region": { + "id": 8, + "region": "worldwide" + } + }, + { + "id": 360532, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 297496, + "name": "Super \"Mario\" World 2", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 584160, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 222515, + "alternative_names": [ + { + "id": 141441, + "comment": "Abbreviation", + "name": "NSMW1: The Twelve Magic Orbs - Powered-Up" + }, + { + "id": 141442, + "comment": "Working title", + "name": "New Super Mario World 1: The Twelve Magic Orbs - Remastered" + } + ], + "name": "New Super Mario World 1: The Twelve Magic Orbs - Powered-Up", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 521878, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + }, + { + "id": 522629, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 267933, + "name": "Super Mario World 2021", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 518426, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 268367, + "name": "Super Mario World Bros.", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 519280, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 365286, + "name": "Super Mario World: 2025", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 796384, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 234948, + "name": "Super Mario World Beta", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 449064, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 141607, + "name": "Super Mario World Remix", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 814581, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 202378, + "alternative_names": [ + { + "id": 89273, + "comment": "Acronym", + "name": "TSRPR2" + }, + { + "id": 89274, + "comment": "Acronym", + "name": "SMW-TSRPR2" + }, + { + "id": 104906, + "comment": "Abbreviation", + "name": "Zycloboo's Challenge" + }, + { + "id": 134701, + "comment": "Alternative title", + "name": "Super Mario World: The Second Reality Project Reloaded 2 - Zycloboo's Challenge" + }, + { + "id": 143728, + "comment": "Alternative title", + "name": "The Second Reality Project Reloaded 2: Zycloboo's Challenge" + } + ], + "name": "Super Mario World: The Second Reality Project 2 - Zycloboo's Challenge", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 534610, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 260811, + "name": "A Very Super Mario World", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 501897, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + }, + { + "id": 501987, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + }, + { + "id": 275300, + "name": "Super Mario World Redone", + "platforms": [ + 19 + ], + "release_dates": [ + { + "id": 533585, + "platform": 19, + "release_region": { + "id": 8, + "region": "worldwide" + } + } + ], + "game_type": 5 + } +] diff --git a/tests/fixtures/regional-metadata/smw-exact.json b/tests/fixtures/regional-metadata/smw-exact.json new file mode 100644 index 0000000..2a60dd8 --- /dev/null +++ b/tests/fixtures/regional-metadata/smw-exact.json @@ -0,0 +1,217 @@ +[ + { + "id": 1070, + "alternative_names": [ + { + "id": 56571, + "comment": "Acronym", + "name": "SMW" + }, + { + "id": 93079, + "comment": "Japanese title - abbreviation", + "name": "マリオワールド" + }, + { + "id": 95856, + "comment": "Chinese title - simplified", + "name": "超级马力欧世界" + }, + { + "id": 98790, + "comment": "Alternative title", + "name": "Super Mario Bros. 4: Super Mario World" + } + ], + "name": "Super Mario World", + "platforms": [ + 52, + 19, + 5, + 41, + 137, + 58 + ], + "release_dates": [ + { + "id": 505220, + "platform": 58, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 592043, + "platform": 137, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 505228, + "platform": 5, + "release_region": { + "id": 3, + "region": "australia" + } + }, + { + "id": 505221, + "platform": 19, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 505222, + "platform": 19, + "release_region": { + "id": 3, + "region": "australia" + } + }, + { + "id": 505227, + "platform": 5, + "release_region": { + "id": 9, + "region": "korea" + } + }, + { + "id": 505229, + "platform": 5, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 505225, + "platform": 19, + "release_region": { + "id": 10, + "region": "brazil" + } + }, + { + "id": 505223, + "platform": 19, + "release_region": { + "id": 9, + "region": "korea" + } + }, + { + "id": 592044, + "platform": 137, + "release_region": { + "id": 3, + "region": "australia" + } + }, + { + "id": 592049, + "platform": 137, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 592037, + "platform": 52, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 592046, + "platform": 137, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 505224, + "platform": 19, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 505226, + "platform": 5, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 505230, + "platform": 5, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 956596, + "platform": 41, + "release_region": { + "id": 5, + "region": "japan" + } + }, + { + "id": 956597, + "platform": 41, + "release_region": { + "id": 1, + "region": "europe" + } + }, + { + "id": 956598, + "platform": 41, + "release_region": { + "id": 2, + "region": "north_america" + } + }, + { + "id": 956599, + "platform": 41, + "release_region": { + "id": 3, + "region": "australia" + } + } + ], + "game_localizations": [ + { + "id": 162, + "name": "スーパーマリオワールド", + "region": { + "id": 3, + "name": "Japan" + } + }, + { + "id": 2325, + "name": "슈퍼 마리오 월드", + "region": { + "id": 2, + "name": "Korea" + } + } + ], + "game_type": 0 + } +]