Skip to content

feat(api): speed up Core media browsing - #1239

Merged
wizzomafizzo merged 11 commits into
mainfrom
feat/pr-360-core-api-performance
Aug 13, 2026
Merged

feat(api): speed up Core media browsing#1239
wizzomafizzo merged 11 commits into
mainfrom
feat/pr-360-core-api-performance

Conversation

@wizzomafizzo

@wizzomafizzo wizzomafizzo commented Aug 13, 2026

Copy link
Copy Markdown
Member

Summary

  • add media.image local-path delivery for validated Core-owned cached thumbnails while keeping inline delivery as the default and fallback
  • add distinct media history and always-present cover availability for search, history, and browse clients
  • cache exact cover availability in memory with bounded SQL fallback and safe invalidation
  • stream dense default-order search candidates by DBID, including bounded scopes of up to four systems with grouped-SQL fallback
  • reuse fetched media tags for launch disambiguation and add request/search-stage timing instrumentation

This keeps Core as schema and data owner while addressing the MiSTer browsing bottlenecks demonstrated in Zaparoo frontend PR #360. Relevant feature commits credit Giancarlo Erra as co-author.

MiSTer results

Measured on a 244,623-item MiSTer library:

  • SNES search, 100 rows: 1,471 ms to 384 ms warm
  • global search, 100 rows: 3,921 ms to about 535 ms
  • NES + SNES search: 2,635 ms to 524 ms
  • NES + SNES + Genesis search: 3,422 ms to 535 ms
  • warm cover status for 101 entries: about 0.06–0.31 ms
  • warm local-path image response: 98% smaller than inline response in measured case

No database migration or persistent flattened cover cache is added. Sparse, sorted, filtered, and unsupported search scopes retain established SQL paths.

Ref ZaparooProject/zaparoo-frontend#360

Summary by CodeRabbit

  • New Features

    • Media search and history responses now indicate whether cover artwork is available.
    • Media history supports returning only the newest entry for each unique media path, with cursor pagination.
    • Media images can be delivered inline as base64 data or through a cached local path, with configurable size limits and inline fallback.
    • Image responses identify the delivery method used.
  • Documentation

    • Updated API reference and examples for the new options and response formats.

wizzomafizzo and others added 10 commits August 13, 2026 05:30
Let clients request Core-owned cached thumbnail paths while preserving inline delivery as the default. Validate returned cache artifacts and fall back to inline data when materialization fails.

Inspired by Giancarlo Erra's MiSTer artwork performance work and measurements in ZaparooProject/zaparoo-frontend#360.

Ref ZaparooProject/zaparoo-frontend#360

Co-authored-by: Giancarlo Erra <giancarlo@widescreen.studio>
Let clients request newest-session-per-media history directly from Core while preserving existing paginated history behavior. Group by system and media path before cursor filtering so older sessions cannot reappear on later pages.

Inspired by Giancarlo Erra's recents performance work and measurements in ZaparooProject/zaparoo-frontend#360.

Ref ZaparooProject/zaparoo-frontend#360

Co-authored-by: Giancarlo Erra <giancarlo@widescreen.studio>
Correlate queue, handler, response, marshal, payload, encryption, and HTTP timing logs by request ID. This makes target-device API latency attributable without changing response contracts.

Motivated by MiSTer performance measurements from ZaparooProject/zaparoo-frontend#360.

Ref ZaparooProject/zaparoo-frontend#360
Build an exact in-memory index of media and title artwork properties while retaining a bounded title-first SQL fallback during cold start. Invalidate the index on image-property and database lifecycle changes so Core can serve cover status without exposing its schema.

Inspired by Giancarlo Erra's MiSTer artwork and browse performance work and measurements in ZaparooProject/zaparoo-frontend#360.

Ref ZaparooProject/zaparoo-frontend#360

Co-authored-by: Giancarlo Erra <giancarlo@widescreen.studio>
Return an always-present hasCover flag from media.search and media.history using Core's batched media/title artwork lookup. This lets clients plan artwork requests without reading Core-owned database tables.

Inspired by Giancarlo Erra's artwork, browse, and recents performance work in ZaparooProject/zaparoo-frontend#360.

Ref ZaparooProject/zaparoo-frontend#360

Co-authored-by: Giancarlo Erra <giancarlo@widescreen.studio>
Measure semaphore wait, database search, cover lookup, response construction, system metadata, ZapScript, and relative-path stages independently. This makes remaining target-device search latency attributable without changing API behavior.

Motivated by MiSTer performance measurements from ZaparooProject/zaparoo-frontend#360.

Ref ZaparooProject/zaparoo-frontend#360
Carry title IDs through search results and derive disambiguating ZapScript tags from media-scope rows already returned by the full tag query. Keep a small-page tag preflight while avoiding a redundant query for larger tagged result sets.

Developed while attributing MiSTer search latency reported through ZaparooProject/zaparoo-frontend#360.

Ref ZaparooProject/zaparoo-frontend#360
Update existing scoped and multi-variant search mocks for the MediaTitleDBID column and shared tag lookup introduced by search enrichment.
Stream default-order media rows by DBID and filter large cached title candidate sets in memory, stopping once the page fills. Cache scoped media bounds, support up to four systems, and fall back to grouped SQL when a bounded window is sparse or the request is unsupported.

Developed from target-device latency analysis prompted by ZaparooProject/zaparoo-frontend#360.

Ref ZaparooProject/zaparoo-frontend#360
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2d748635-8659-4f01-b9ef-3f4de5149cc0

📥 Commits

Reviewing files that changed from the base of the PR and between ae4434b and 077eb7c.

📒 Files selected for processing (13)
  • docs/api/methods.md
  • pkg/api/methods/media.go
  • pkg/api/methods/media_history.go
  • pkg/api/methods/media_history_test.go
  • pkg/api/methods/media_image.go
  • pkg/api/methods/media_image_test.go
  • pkg/api/methods/media_search_test.go
  • pkg/api/transport_timing_test.go
  • pkg/database/database.go
  • pkg/database/mediadb/media_search_scope_test.go
  • pkg/database/mediadb/mediadb.go
  • pkg/database/mediadb/sql_browse.go
  • pkg/database/mediadb/sql_browse_test.go
🚧 Files skipped from review as they are similar to previous changes (7)
  • pkg/api/methods/media_search_test.go
  • pkg/database/database.go
  • docs/api/methods.md
  • pkg/api/methods/media_history.go
  • pkg/database/mediadb/mediadb.go
  • pkg/api/methods/media_image.go
  • pkg/api/methods/media_image_test.go

📝 Walkthrough

Walkthrough

The API now reports cover availability, supports distinct media history and local-path image delivery, and adds transport timing diagnostics. The database adds cover-status caching, large-candidate search paths, title-aware lookups, and distinct history pagination.

Changes

Media API and database enhancements

Layer / File(s) Summary
API contracts and media history
docs/api/methods.md, pkg/api/models/*, pkg/api/methods/media_history.go, pkg/database/userdb/media_history.go
The API documents hasCover, distinctMedia, and image delivery modes. Media history supports distinct pagination and cover-status enrichment.
Cover availability cache
pkg/database/mediadb/sql_browse.go, pkg/database/mediadb/mediadb.go, pkg/database/mediadb/sql_scraper.go
The database caches media and title cover availability, falls back to SQL, and invalidates cached data after relevant mutations.
Media search candidate streaming
pkg/database/mediadb/sql_search.go, pkg/database/mediadb/mediadb.go, pkg/database/mediadb/*_test.go
Search results preserve title IDs, reuse fetched tags, and support large candidate sets with scoped streaming, cursors, bounds, and fallback queries.
Image delivery and thumbnail cache
pkg/api/methods/media_image.go, pkg/api/methods/media_image_test.go, pkg/api/methods/media_scrape_test.go
media.image validates delivery options and maxSize, returns safe cached paths when possible, and falls back to inline data when local-path delivery cannot be written.
Request and transport diagnostics
pkg/api/request_priority.go, pkg/api/server.go, pkg/api/ws_dispatcher.go, pkg/ui/tui/*
Request IDs are truncated in logs. HTTP and WebSocket paths record queue, marshal, write, encryption, and response timing data.

Estimated code review effort: 5 (Critical) | ~120 minutes

Mergeability Score: 🟡 Moderate · up to 077eb

The PR adds request and search timing behavior, but focused tests for the new transport and queue diagnostics are still missing, leaving a bounded risk of unnoticed regressions. Merge should wait for those tests or explicit owner acceptance.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main objective of speeding up Core media browsing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/pr-360-core-api-performance

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (3)
pkg/api/methods/media_image.go (1)

861-884: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Reuse the resolved path instead of scanning the cache twice.

cachedMediaImageResponse calls lookupPath, and the inline branch then calls read, which repeats the same format scan and stat calls. Read the bytes from the already-resolved path instead. afero.ReadFile returning an error still yields a cache miss, so the deletion-between-lookup-and-read behavior is unchanged.

♻️ Proposed refactor
 	if localPath {
 		if response, ok := localMediaImageResponse(cache, path, contentType, typeTag); ok {
 			return response, true
 		}
 	}
-	data, contentType, found := cache.read(ref, system, typeTag, maxSize)
-	if !found {
+	//nolint:gosec // lookupPath only returns controlled cache paths.
+	data, err := afero.ReadFile(cache.fs, path)
+	if err != nil {
 		return models.MediaImageResponse{}, false
 	}
 	return inlineMediaImageResponse(data, contentType, sourcePath, typeTag), true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/api/methods/media_image.go` around lines 861 - 884, Update
cachedMediaImageResponse to read inline response bytes directly from the
already-resolved path returned by cache.lookupPath, rather than calling
cache.read and repeating the cache scan. Use afero.ReadFile with the cache
filesystem, return a cache miss when reading fails, and preserve the existing
localMediaImageResponse path and inlineMediaImageResponse behavior.
pkg/database/database.go (1)

251-254: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the key semantics of GetMediaCoverStatus and MediaCoverRef.

The returned map is keyed by MediaDBID, and a media entry is reported as covered when either the media-level or title-level image property exists. Neighboring declarations in this file carry doc comments. Add a short comment so callers do not have to read mediadb to learn the key and the fallback rule. Also state whether absent keys mean "no cover".

Also applies to: 1048-1048

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/database/database.go` around lines 251 - 254, Add concise Go doc comments
for GetMediaCoverStatus and MediaCoverRef describing that results are keyed by
MediaDBID, coverage is true when either the media-level or title-level image
exists, and absent keys mean no cover. Follow the surrounding declaration
comment style.
pkg/database/mediadb/mediadb.go (1)

2486-2520: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

A bounds-query error fails the search instead of using the grouped SQL path.

If db.getMediaSearchBounds returns an error, streamErr is set, strategy stays empty, and control reaches if streamErr != nil && strategy == "", which returns the error. The grouped SQL path below does not need the bounds and would still serve the request. Treat a bounds lookup failure as "streaming unavailable" and fall through, so a transient error on the bounds query does not fail media.search.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/database/mediadb/mediadb.go` around lines 2486 - 2520, Update the
system-scoped branch around getMediaSearchBounds so a bounds lookup error
disables the streaming strategy without leaving streamErr set for the final
failure check. Preserve the grouped SQL fallback by allowing execution to
continue with strategy empty, while retaining existing error propagation for
failures from the grouped search itself.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/api/methods.md`:
- Line 560: Update both media.search response examples’ results objects to
include the required hasCover field, using a representative boolean value such
as false, consistent with the SearchResultMedia.HasCover JSON output and the
documented table.

In `@pkg/api/methods/media_history.go`:
- Around line 107-136: Update the media-history enrichment flow around
resolveMediaPathIDs and GetMediaCoverStatus to use the existing
optionalDBEnrichmentContext timeout and treat all lookup failures as non-fatal.
Preserve the history response by retaining zero-value media IDs and cover
statuses (MediaID 0 and HasCover false) when either media.db operation fails,
rather than returning an error from the handler; keep successful enrichment
unchanged.

Apply the same fix in `@pkg/api/methods/media.go` around lines 1026 - 1029: Covers
the equivalent whole-request failure in media.search when cover-status
enrichment fails.

Apply the same fix in `@pkg/api/methods/media_history_test.go` around lines 157 -
194: Covers the missing regression test for history enrichment failures.

In `@pkg/api/methods/media_image_test.go`:
- Around line 176-178: Update the symlink setup in the test around
cache.isSafeLocalPath to detect os.Symlink errors indicating unsupported
symlinks or insufficient privileges, skip the test in those cases, and continue
failing on unexpected errors. Keep the existing symlink safety assertion
unchanged when creation succeeds.

In `@pkg/api/server.go`:
- Around line 469-489: Add focused tests for the new transport diagnostics: in
pkg/api/server.go ranges 469-489, 501-514, 529-542, 1338-1350, 1373-1385, and
1482-1531, verify WebSocket success/failure and plaintext/encrypted HTTP
result/error timing fields; in pkg/api/ws_dispatcher.go ranges 60-87 and
208-410, verify zero/nonzero queue timestamps and request, response, and pong
queue metadata. Use the relevant logging and dispatcher symbols to assert
emitted fields and error behavior.

In `@pkg/database/mediadb/media_search_scope_test.go`:
- Around line 310-313: Use require.Equal for the result-count comparison before
each indexed comparison loop in the affected tests, including the matching
pattern around the second comparison. Keep the per-item assert.Equal checks
unchanged so mismatched lengths fail cleanly before expected[i] can panic.

In `@pkg/database/mediadb/mediadb.go`:
- Around line 278-317: Refactor getMediaSearchBounds so the SQL MIN/MAX query
never runs while mediaSearchBoundsMu is held and concurrent misses are coalesced
per systemDBID rather than globally. Keep cache reads and writes under the
mutex, and before storing query results re-check the relevant invalidation
generation or dirty state so results from an invalidated query are not cached;
preserve existing return and error behavior.

In `@pkg/database/mediadb/sql_browse.go`:
- Around line 1307-1345: Update queryImagePropertyEntityIDs to split entityIDs
into chunks of sqliteMaxParams minus len(imageTagIDs) before querying, while
preserving the existing scope-specific SQL and argument ordering. Execute each
chunk, merge all returned IDs into one result map, and retain early returns and
scope validation. Add a regression test covering a maximum-sized page with one
image tag.

In `@pkg/database/userdb/media_history.go`:
- Around line 499-518: Add a database migration creating a composite index on
MediaHistory covering SystemID, MediaPath, and DBID, so the LatestMedia GROUP BY
query can avoid scanning and temporary grouping structures. Follow existing
migration conventions and ensure the migration is safely applied and reversible
if supported.

---

Nitpick comments:
In `@pkg/api/methods/media_image.go`:
- Around line 861-884: Update cachedMediaImageResponse to read inline response
bytes directly from the already-resolved path returned by cache.lookupPath,
rather than calling cache.read and repeating the cache scan. Use afero.ReadFile
with the cache filesystem, return a cache miss when reading fails, and preserve
the existing localMediaImageResponse path and inlineMediaImageResponse behavior.

In `@pkg/database/database.go`:
- Around line 251-254: Add concise Go doc comments for GetMediaCoverStatus and
MediaCoverRef describing that results are keyed by MediaDBID, coverage is true
when either the media-level or title-level image exists, and absent keys mean no
cover. Follow the surrounding declaration comment style.

In `@pkg/database/mediadb/mediadb.go`:
- Around line 2486-2520: Update the system-scoped branch around
getMediaSearchBounds so a bounds lookup error disables the streaming strategy
without leaving streamErr set for the final failure check. Preserve the grouped
SQL fallback by allowing execution to continue with strategy empty, while
retaining existing error propagation for failures from the grouped search
itself.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e26308f-ea5e-4e78-928a-ba770a094278

📥 Commits

Reviewing files that changed from the base of the PR and between 04f8fe8 and ae4434b.

📒 Files selected for processing (32)
  • docs/api/methods.md
  • pkg/api/methods/media.go
  • pkg/api/methods/media_history.go
  • pkg/api/methods/media_history_test.go
  • pkg/api/methods/media_image.go
  • pkg/api/methods/media_image_test.go
  • pkg/api/methods/media_response_helpers.go
  • pkg/api/methods/media_scrape_test.go
  • pkg/api/methods/media_search_test.go
  • pkg/api/models/params.go
  • pkg/api/models/responses.go
  • pkg/api/request_priority.go
  • pkg/api/request_priority_test.go
  • pkg/api/server.go
  • pkg/api/ws_dispatcher.go
  • pkg/database/database.go
  • pkg/database/mediadb/disambiguation_test.go
  • pkg/database/mediadb/media_search_scope_test.go
  • pkg/database/mediadb/mediadb.go
  • pkg/database/mediadb/multi_variant_search_test.go
  • pkg/database/mediadb/sql_browse.go
  • pkg/database/mediadb/sql_browse_test.go
  • pkg/database/mediadb/sql_scraper.go
  • pkg/database/mediadb/sql_scraper_test.go
  • pkg/database/mediadb/sql_search.go
  • pkg/database/mediadb/sql_search_test.go
  • pkg/database/mediadb/sql_test.go
  • pkg/database/userdb/media_history.go
  • pkg/database/userdb/media_history_property_test.go
  • pkg/testing/helpers/db_mocks.go
  • pkg/ui/tui/mock_api_client_test.go
  • pkg/ui/tui/settings_service.go

Comment thread docs/api/methods.md
Comment thread pkg/api/methods/media_history.go Outdated
Comment thread pkg/api/methods/media_image_test.go
Comment thread pkg/api/server.go
Comment on lines +469 to +489
func logWebSocketTransportTiming(
id models.RPCID,
responseType string,
encrypted bool,
responseBytes int,
marshalDuration time.Duration,
writeDuration time.Duration,
writeErr error,
) {
event := log.Debug().
Str("requestId", requestIDForLog(id)).
Str("responseType", responseType).
Bool("encrypted", encrypted).
Int("responseBytes", responseBytes).
Dur("marshalDuration", marshalDuration).
Dur("writeDuration", writeDuration)
if writeErr != nil {
event = event.Err(writeErr)
}
event.Msg("websocket response transport timing")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add focused tests for the new transport diagnostics.

The changed tests cover request metadata and request-ID truncation. They do not cover the new WebSocket and HTTP timing fields or the dispatcher queue-duration fields.

  • pkg/api/server.go#L469-L489: Test the WebSocket timing event for successful and failed writes.
  • pkg/api/server.go#L501-L514: Test plaintext result-response timing.
  • pkg/api/server.go#L529-L542: Test plaintext error-response timing.
  • pkg/api/server.go#L1338-L1350: Test encrypted result-response timing.
  • pkg/api/server.go#L1373-L1385: Test encrypted error-response timing.
  • pkg/api/server.go#L1482-L1531: Test HTTP result and error response timing.
  • pkg/api/ws_dispatcher.go#L60-L87: Test zero and nonzero queue timestamps.
  • pkg/api/ws_dispatcher.go#L208-L410: Test request, response, and pong queue metadata.

As per coding guidelines: “Write tests for all new code — see TESTING.md and pkg/testing/README.md.”

📍 Affects 2 files
  • pkg/api/server.go#L469-L489 (this comment)
  • pkg/api/server.go#L501-L514
  • pkg/api/server.go#L529-L542
  • pkg/api/server.go#L1338-L1350
  • pkg/api/server.go#L1373-L1385
  • pkg/api/server.go#L1482-L1531
  • pkg/api/ws_dispatcher.go#L60-L87
  • pkg/api/ws_dispatcher.go#L208-L410
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/api/server.go` around lines 469 - 489, Add focused tests for the new
transport diagnostics: in pkg/api/server.go ranges 469-489, 501-514, 529-542,
1338-1350, 1373-1385, and 1482-1531, verify WebSocket success/failure and
plaintext/encrypted HTTP result/error timing fields; in pkg/api/ws_dispatcher.go
ranges 60-87 and 208-410, verify zero/nonzero queue timestamps and request,
response, and pong queue metadata. Use the relevant logging and dispatcher
symbols to assert emitted fields and error behavior.

Source: Coding guidelines

Comment thread pkg/database/mediadb/media_search_scope_test.go
Comment thread pkg/database/mediadb/mediadb.go
Comment thread pkg/database/mediadb/sql_browse.go
Comment on lines +499 to +518
// Group before applying the cursor. Filtering raw history rows first would
// let an older session for a media identity reappear on a later page.
//nolint:gosec // latestWhere contains only fixed SQL and placeholders.
query := fmt.Sprintf(`
WITH LatestMedia AS (
SELECT MAX(DBID) AS DBID
FROM MediaHistory
%s
GROUP BY SystemID, MediaPath
)
SELECT
history.DBID, history.StartTime, history.EndTime,
history.SystemID, history.SystemName, history.MediaPath,
history.MediaName, history.LauncherID, history.PlayTime
FROM MediaHistory AS history
INNER JOIN LatestMedia AS latest ON latest.DBID = history.DBID
WHERE history.DBID < ?
ORDER BY history.DBID DESC
LIMIT ?;
`, latestWhere)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect MediaHistory schema and indexes in the userdb migrations/schema.
rg -nP --type=go -C3 'CREATE (UNIQUE )?INDEX.*MediaHistory|CREATE TABLE MediaHistory'

Repository: ZaparooProject/zaparoo-core

Length of output: 165


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
git ls-files | rg -i '(^|/)(database|db|migration|migrations|schema)(/|$)|userdb|\.sql$' | head -200
printf '%s\n' '--- MediaHistory references ---'
rg -n -C3 'MediaHistory|media_history' .
printf '%s\n' '--- relevant index/table declarations ---'
rg -n -i -C3 'create[[:space:]]+(unique[[:space:]]+)?index|create[[:space:]]+table|index[[:space:]]+.*(systemid|mediapath|dbid)' . | rg -i 'MediaHistory|SystemID|MediaPath|DBID|userdb|database|migration|schema' | head -300

Repository: ZaparooProject/zaparoo-core

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- database files ---'
git ls-files pkg/database | sed -n '1,240p'
printf '%s\n' '--- exact table/index statements ---'
rg -n -i -C2 --glob '*.go' --glob '*.sql' --glob '*.json' \
  '(CREATE[[:space:]]+TABLE|CREATE[[:space:]]+(UNIQUE[[:space:]]+)?INDEX|MediaHistory)' \
  pkg/database | head -400
printf '%s\n' '--- query symbols ---'
rg -n -C8 'func .*GetMediaHistory|latestWhere|distinctMedia|LatestMedia' pkg/database/userdb

Repository: ZaparooProject/zaparoo-core

Length of output: 50383


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- userdb files ---'
git ls-files pkg/database/userdb
printf '%s\n' '--- userdb schema/index declarations ---'
rg -n -i -C3 --glob '*.go' --glob '*.sql' \
  '(CREATE[[:space:]]+TABLE|CREATE[[:space:]]+(UNIQUE[[:space:]]+)?INDEX|MediaHistory)' \
  pkg/database/userdb
printf '%s\n' '--- distinct query ---'
sed -n '430,535p' pkg/database/userdb/media_history.go
printf '%s\n' '--- distinct test schema and assertions ---'
sed -n '264,334p' pkg/database/userdb/media_history_property_test.go
printf '%s\n' '--- migration loading references ---'
rg -n -C4 'userdb/migrations|migrations.*userdb|goose|UserDB' pkg/database/migrations.go pkg/database/userdb pkg/database/conn.go

Repository: ZaparooProject/zaparoo-core

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- migration filenames ---'
git ls-files | rg -i '(^|/)(migrations?|schema)(/|$)|migration.*\.(sql|go)$|(^|/)sql_.*\.go$' | rg -i 'user|database|migration|schema' | sed -n '1,300p'
printf '%s\n' '--- possible embedded migration declarations ---'
rg -l -i 'MediaHistory|CREATE TABLE.*History|CREATE INDEX.*History' --glob '*.sql' --glob '*.go' . | sort
printf '%s\n' '--- index declarations mentioning relevant columns ---'
rg -n -i --glob '*.sql' --glob '*.go' \
  'CREATE[[:space:]]+(UNIQUE[[:space:]]+)?INDEX[^;\n]*(MediaHistory|SystemID|MediaPath|DBID)|INDEX[^;\n]*ON[[:space:]]+MediaHistory' \
  . | sed -n '1,240p'
printf '%s\n' '--- relevant schema loader symbols ---'
rg -n -C3 'migrationFiles|migrationDir|embed\.FS|userdb' pkg/database --glob '*.go' | rg -i 'migration|embed|userdb' | sed -n '1,240p'

Repository: ZaparooProject/zaparoo-core

Length of output: 32335


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
import sqlite3

query = """
WITH LatestMedia AS (
    SELECT MAX(DBID) AS DBID
    FROM MediaHistory
    {where}
    GROUP BY SystemID, MediaPath
)
SELECT
    history.DBID, history.StartTime, history.EndTime,
    history.SystemID, history.SystemName, history.MediaPath,
    history.MediaName, history.LauncherID, history.PlayTime
FROM MediaHistory AS history
INNER JOIN LatestMedia AS latest ON latest.DBID = history.DBID
WHERE history.DBID < ?
ORDER BY history.DBID DESC
LIMIT ?;
"""

def explain(extra_index, where):
    db = sqlite3.connect(":memory:")
    db.executescript("""
        CREATE TABLE MediaHistory (
            DBID INTEGER PRIMARY KEY AUTOINCREMENT,
            StartTime INTEGER NOT NULL,
            EndTime INTEGER,
            SystemID TEXT NOT NULL,
            SystemName TEXT NOT NULL,
            MediaPath TEXT NOT NULL,
            MediaName TEXT NOT NULL,
            LauncherID TEXT NOT NULL,
            PlayTime INTEGER NOT NULL,
            BootUUID TEXT,
            UpdatedAt INTEGER,
            DeviceID TEXT,
            ProfileID TEXT
        );
        CREATE INDEX idx_media_history_start_time ON MediaHistory (StartTime);
        CREATE INDEX idx_media_history_open ON MediaHistory (EndTime) WHERE EndTime IS NULL;
        CREATE INDEX idx_media_history_boot ON MediaHistory (BootUUID);
        CREATE INDEX idx_media_history_updated ON MediaHistory (UpdatedAt);
        CREATE INDEX idx_media_history_device ON MediaHistory (DeviceID) WHERE DeviceID IS NOT NULL;
        CREATE INDEX idx_media_history_profile ON MediaHistory (ProfileID) WHERE ProfileID IS NOT NULL;
    """)
    if extra_index:
        db.execute(
            "CREATE INDEX idx_media_history_distinct ON "
            "MediaHistory (SystemID, MediaPath, DBID)"
        )
    args = ("NES", 9223372036854775807, 25) if where else (9223372036854775807, 25)
    plan = db.execute(
        "EXPLAIN QUERY PLAN " + query.format(where="WHERE SystemID = ?" if where else ""),
        args,
    ).fetchall()
    db.close()
    return plan

for where_name, where in (("unfiltered", False), ("system-filtered", True)):
    for index_name, extra_index in (("production indexes", False), ("plus grouping index", True)):
        print(f"--- {where_name}; {index_name} ---")
        for row in explain(extra_index, where):
            print(" | ".join(map(str, row)))
PY

Repository: ZaparooProject/zaparoo-core

Length of output: 1320


Add an index for distinct media grouping. LatestMedia scans all matching rows before applying history.DBID < ?; SQLite uses a temporary B-tree for GROUP BY. Add a migration with (SystemID, MediaPath, DBID) to support this query.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/database/userdb/media_history.go` around lines 499 - 518, Add a database
migration creating a composite index on MediaHistory covering SystemID,
MediaPath, and DBID, so the LatestMedia GROUP BY query can avoid scanning and
temporary grouping structures. Follow existing migration conventions and ensure
the migration is safely applied and reversible if supported.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant