Skip to content
Merged
67 changes: 56 additions & 11 deletions docs/api/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ An object:
| name | string | Yes | A human-readable version of the result's filename without a file extension. |
| path | string | Yes | Canonical indexed media path. Use with `system.id` for `media.meta` and `media.image`. |
| relativePath | string | No | Launcher-relative convenience path, when it can be derived. Not a stable media identity. |
| hasCover | boolean | Yes | Whether media-level or title-level image properties are available. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| zapScript | string | Yes | ZapScript command to launch this media item. Includes the disambiguating tags inline (e.g. `@Arcade/X-Men Vs. Street Fighter (region:eu) (builddate:1996-10-04)`) so the written command resolves back to this specific variant. |
| tags | [TagInfo](#taginfo-object)[] | Yes | Array of tags associated with this media item. |
| disambiguatingTags | [TagInfo](#taginfo-object)[] | No | Subset of `tags` whose values differ across same-named siblings of this title, ordered by display importance. Omitted when the title has nothing to disambiguate. Clients can render these to tell variants apart. |
Expand Down Expand Up @@ -615,6 +616,7 @@ An object:
"name": "240p Test Suite (PD) v0.03 tepples",
"path": "/media/fat/games/Gameboy/240p Test Suite (PD) v0.03 tepples.gb",
"relativePath": "Gameboy/240p Test Suite (PD) v0.03 tepples.gb",
"hasCover": false,
"zapScript": "@Gameboy/240p Test Suite (PD) v0.03 tepples",
"system": {
"category": "Handheld",
Expand Down Expand Up @@ -672,6 +674,7 @@ An object:
"name": "Super Mario Bros.",
"path": "/media/fat/games/NES/Super Mario Bros.nes",
"relativePath": "NES/Super Mario Bros.nes",
"hasCover": true,
"zapScript": "@NES/Super Mario Bros. (year:1985)",
"system": {
"category": "Console",
Expand Down Expand Up @@ -1419,18 +1422,19 @@ None. Empty params may be omitted or sent as `{}`.

**Access:** All clients.

Return paginated media play history.
Return paginated media play history. Set `distinctMedia` to return only the newest session for each `(systemId, mediaPath)` identity, which is useful for recents grids.

#### Parameters

Optionally, an object:

| Key | Type | Required | Description |
| :---------- | :------- | :------- | :---------------------------------------------------------------------------------------------- |
| limit | number | No | Maximum number of entries to return. Default is 25, maximum is 100. |
| cursor | string | No | Cursor for pagination. Omit for first page, use `nextCursor` from previous response for subsequent pages. |
| systems | string[] | No | Filter to one or more system IDs (e.g., `["SNES", "NES"]`). |
| fuzzySystem | boolean | No | Enable fuzzy matching for system IDs. |
| Key | Type | Required | Description |
| :------------ | :------- | :------- | :---------------------------------------------------------------------------------------------- |
| limit | number | No | Maximum number of entries to return. Default is 25, maximum is 100. |
| cursor | string | No | Cursor for pagination. Omit for first page, use `nextCursor` from previous response for subsequent pages with the same filters and `distinctMedia` value. |
| systems | string[] | No | Filter to one or more system IDs (e.g., `["SNES", "NES"]`). |
| fuzzySystem | boolean | No | Enable fuzzy matching for system IDs. |
| distinctMedia | boolean | No | Return the newest session for each unique `(systemId, mediaPath)` pair. Each page contains up to `limit` unique media entries. Default is `false`. |

#### Result

Expand All @@ -1449,6 +1453,7 @@ Optionally, an object:
| mediaName | string | Yes | Display name of the media. |
| mediaPath | string | Yes | Path to the media file. |
| relativePath | string | No | Launcher-relative convenience path, when it can be derived. Not a stable media identity. |
| hasCover | boolean | Yes | Whether media-level or title-level image properties are available. |
| launcherId | string | Yes | ID of the launcher used. |
| startedAt | string | Yes | Timestamp when media started in RFC3339 format. |
| endedAt | string | No | Timestamp when media stopped in RFC3339 format. Omitted if media is still active. |
Expand All @@ -1464,7 +1469,8 @@ Optionally, an object:
"id": "a1b2c3d4-7a5d-11ef-9c7b-020304050607",
"method": "media.history",
"params": {
"limit": 10
"limit": 10,
"distinctMedia": true
}
}
```
Expand All @@ -1484,6 +1490,7 @@ Optionally, an object:
"mediaName": "Super Mario World",
"mediaPath": "/roms/snes/Super Mario World (USA).sfc",
"relativePath": "snes/Super Mario World (USA).sfc",
"hasCover": true,
"launcherId": "SNES",
"startedAt": "2025-01-22T14:30:00Z",
"endedAt": "2025-01-22T15:15:30Z",
Expand Down Expand Up @@ -1895,7 +1902,7 @@ An object identifying the media row by `mediaId` or by `system` and canonical `p

**Access:** All clients.

Return the best matching image for one indexed media row as base64-encoded data.
Return the best matching image for one indexed media row. Inline base64 delivery remains default. Clients can explicitly request a transient path to a Core-owned cached thumbnail.

`media.image` checks the requested image types in order. For each type it tries media-level properties first, then title-level properties. If a stored file path no longer exists, the stale property is removed and lookup continues.

Expand All @@ -1909,19 +1916,24 @@ An object identifying the media row by `mediaId` or `(system, path)`. Canonical
| system | string | No | System ID. Required when `mediaId` is omitted. |
| path | string | No | Canonical indexed media path. Required when `mediaId` is omitted. |
| imageTypes | string[] | No | Image type preference order. Defaults to `image`, `thumbnail`, `boxart`, `boxart3d`, `screenshot`, `wheel`, `titleshot`, `map`, `marquee`, `fanart`. |
| maxSize | number | No | Longest-edge size hint in pixels. When set, the server resizes the image to fit a `maxSize`×`maxSize` box and caches the result; omit it for the full-size image. |
| maxSize | number | No | Longest-edge size hint in pixels. When set, the server resizes the image to fit a `maxSize`×`maxSize` box and caches the result; omit it for the full-size image. Required for `localPath` delivery. |
| delivery | string | No | `inline` (default) or `localPath`. `localPath` requires a positive `maxSize` and returns a path on the Core host. |

Supported image type values are `image`, `thumbnail`, `boxart`, `boxart3d`, `screenshot`, `wheel`, `titleshot`, `map`, `marquee`, and `fanart`. They resolve to canonical property tags such as `property:image-image` and `property:image-boxart`.

Resizing is intended for grid and preview views where transferring and holding full-size art is expensive. `maxSize` is snapped up to the nearest of a small set of standard tiers (`32`, `64`, `128`, `256`, `512`, `768`) server-side. The returned image is **never larger than the snapped tier and never larger than the source** — when the source already fits the tier it is returned at its native dimensions, so the result may still be larger than the exact `maxSize` you asked for. Request your true display size (logical size × pixel ratio) and downscale to the final size on the client. The snapped tiers bound how many resized variants are cached per image. Output is re-encoded as WebP (lossy, alpha preserved) regardless of source format — including when the source already fits the box, so even a near-native request still gets the smaller WebP — and cached on disk so repeat requests are cheap. The original bytes are kept only when WebP would not shrink them (already-compact sources), when `maxSize` is omitted/non-positive (full size), or when the source cannot be decoded.

`localPath` never returns an original scraper or media path. Core resolves image semantics, materializes its own bounded thumbnail cache artifact, and returns that path. Path delivery is available to any client that explicitly requests it, regardless of peer locality or Core platform; remote callers are responsible for having an appropriate shared-filesystem view of the Core host path. Treat the path as opaque, transient, and nonportable: read it immediately, never persist it or derive neighboring paths, and retry once with `delivery: "inline"` if the file is inaccessible or disappears before it is opened. If cache materialization fails, Core can safely return `delivery: "inline"` in the same response.

#### Result

| Key | Type | Required | Description |
| :---------- | :----- | :------- | :------------------------------------------- |
| delivery | string | Yes | Actual delivery used: `inline` or `localPath`. Clients must inspect this field because a requested local path can fall back inline. |
| contentType | string | Yes | MIME type of the returned image data. |
| extension | string | No | File extension without a dot, derived from MIME type or source path. |
| data | string | Yes | Base64-encoded image bytes. |
| data | string | No | Base64-encoded image bytes. Present for `inline` delivery. |
| localPath | string | No | Absolute, opaque Core-host path to a cached thumbnail. Present for `localPath` delivery. |
| typeTag | string | Yes | Canonical property tag that matched. |

#### Example
Expand Down Expand Up @@ -1949,6 +1961,7 @@ Resizing is intended for grid and preview views where transferring and holding f
"jsonrpc": "2.0",
"id": "e5f6a7b8-7a5d-11ef-9c7b-020304050607",
"result": {
"delivery": "inline",
"contentType": "image/webp",
"extension": "webp",
"data": "UklGRiQAAABXRUJQVlA4...",
Expand All @@ -1957,6 +1970,38 @@ Resizing is intended for grid and preview views where transferring and holding f
}
```

##### Local-path request

```json
{
"jsonrpc": "2.0",
"id": "e5f6a7b8-7a5d-11ef-9c7b-020304050607",
"method": "media.image",
"params": {
"mediaId": 123,
"imageTypes": ["boxart"],
"maxSize": 256,
"delivery": "localPath"
}
}
```

##### Local-path response

```json
{
"jsonrpc": "2.0",
"id": "e5f6a7b8-7a5d-11ef-9c7b-020304050607",
"result": {
"delivery": "localPath",
"contentType": "image/webp",
"extension": "webp",
"localPath": "/media/fat/zaparoo/cache/thumbs/v2/U05FUw/example.webp",
"typeTag": "property:image-boxart"
}
}
```

### scrapers

**Access:** All clients.
Expand Down
52 changes: 51 additions & 1 deletion pkg/api/methods/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -905,13 +905,16 @@ func searchResultSystem(

func HandleMediaSearch(env requests.RequestEnv) (any, error) { //nolint:gocritic // single-use parameter in API handler
log.Info().Msg("received media search request")
handlerStarted := time.Now()
semaphoreStarted := time.Now()

select {
case searchSem <- struct{}{}:
defer func() { <-searchSem }()
case <-env.Context.Done():
return nil, env.Context.Err()
}
semaphoreDuration := time.Since(semaphoreStarted)

var params models.SearchParams
if err := validation.ValidateAndUnmarshal(env.Params, &params); err != nil {
Expand All @@ -923,7 +926,6 @@ func HandleMediaSearch(env requests.RequestEnv) (any, error) { //nolint:gocritic
if params.MaxResults != nil && *params.MaxResults > 0 {
maxResults = *params.MaxResults
}

ctx := env.Context

var sortOrder string
Expand Down Expand Up @@ -998,7 +1000,9 @@ func HandleMediaSearch(env requests.RequestEnv) (any, error) { //nolint:gocritic
Limit: limit,
}

searchStarted := time.Now()
searchResults, err = env.Database.MediaDB.SearchMediaWithFilters(ctx, &searchFilters)
searchDuration := time.Since(searchStarted)
if err != nil {
return nil, fmt.Errorf("error searching media with filters: %w", err)
}
Expand All @@ -1009,7 +1013,29 @@ func HandleMediaSearch(env requests.RequestEnv) (any, error) { //nolint:gocritic
searchResults = searchResults[:maxResults]
}

coverStarted := time.Now()
coverStatuses := make(map[int64]bool)
if len(searchResults) > 0 {
coverRefs := make([]database.MediaCoverRef, len(searchResults))
for i := range searchResults {
coverRefs[i] = database.MediaCoverRef{
MediaDBID: searchResults[i].MediaID,
MediaTitleDBID: searchResults[i].MediaTitleID,
}
}
coverCtx, cancelCoverLookup := optionalDBEnrichmentContext(ctx)
resolvedCoverStatuses, coverErr := env.Database.MediaDB.GetMediaCoverStatus(coverCtx, coverRefs)
cancelCoverLookup()
if coverErr != nil {
log.Debug().Err(coverErr).Msg("could not enrich media search cover status")
} else {
coverStatuses = resolvedCoverStatuses
}
}
coverDuration := time.Since(coverStarted)

// Convert to API models
responseBuildStarted := time.Now()
var rootDirs []string
if env.LauncherCache != nil && env.Platform != nil {
rootDirs = env.Platform.RootDirs(env.Config)
Expand All @@ -1024,22 +1050,33 @@ func HandleMediaSearch(env requests.RequestEnv) (any, error) { //nolint:gocritic
}

results := make([]models.SearchResultMedia, 0, len(searchResults))
var systemBuildDuration time.Duration
var zapScriptDuration time.Duration
var relativePathDuration time.Duration
for i := range searchResults {
result := &searchResults[i]
stageStarted := time.Now()
resultSystem := searchResultSystem(result.SystemID, launchableSystems)
systemBuildDuration += time.Since(stageStarted)

stageStarted = time.Now()
zapScript := result.ZapScript()
zapScriptDuration += time.Since(stageStarted)

stageStarted = time.Now()
var relPath *string
if env.LauncherCache != nil {
rel := env.LauncherCache.ToRelativePath(rootDirs, result.SystemID, result.Path)
if rel != result.Path {
relPath = &rel
}
}
relativePathDuration += time.Since(stageStarted)

results = append(results, models.SearchResultMedia{
MediaID: result.MediaID,
RelPath: relPath,
HasCover: coverStatuses[result.MediaID],
System: resultSystem,
Name: result.Name,
Path: result.Path,
Expand Down Expand Up @@ -1075,6 +1112,19 @@ func HandleMediaSearch(env requests.RequestEnv) (any, error) { //nolint:gocritic
PageSize: maxResults,
}
}
responseBuildDuration := time.Since(responseBuildStarted)

log.Debug().
Int("rows", len(results)).
Dur("semaphoreDuration", semaphoreDuration).
Dur("searchDuration", searchDuration).
Dur("coverDuration", coverDuration).
Dur("responseBuildDuration", responseBuildDuration).
Dur("systemBuildDuration", systemBuildDuration).
Dur("zapScriptDuration", zapScriptDuration).
Dur("relativePathDuration", relativePathDuration).
Dur("handlerDuration", time.Since(handlerStarted)).
Msg("media search handler step timing")

return models.SearchResults{
Results: results,
Expand Down
63 changes: 57 additions & 6 deletions pkg/api/methods/media_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/validation"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
"github.com/rs/zerolog/log"
)

Expand All @@ -36,6 +37,7 @@ func HandleMediaHistory(env requests.RequestEnv) (any, error) { //nolint:gocriti
limit := defaultMediaHistoryLimit
var lastID int64
var systemIDs []string
var distinctMedia bool

if len(env.Params) > 0 {
var params models.MediaHistoryParams
Expand All @@ -49,7 +51,9 @@ func HandleMediaHistory(env requests.RequestEnv) (any, error) { //nolint:gocriti
if params.Limit != nil {
limit = *params.Limit
}

if params.DistinctMedia != nil {
distinctMedia = *params.DistinctMedia
}
if params.Cursor != nil {
cursor, err := decodeCursor(*params.Cursor)
if err != nil {
Expand All @@ -73,11 +77,17 @@ func HandleMediaHistory(env requests.RequestEnv) (any, error) { //nolint:gocriti
}
}

// Fetch one extra to detect next page
// Fetch one extra to detect next page.
queryStarted := time.Now()
entries, err := env.Database.UserDB.GetMediaHistory(systemIDs, lastID, limit+1)
var entries []database.MediaHistoryEntry
var err error
if distinctMedia {
entries, err = env.Database.UserDB.GetDistinctMediaHistory(env.Context, systemIDs, lastID, limit+1)
} else {
entries, err = env.Database.UserDB.GetMediaHistory(systemIDs, lastID, limit+1)
}
if err != nil {
log.Error().Err(err).Msg("error getting media history")
log.Error().Err(err).Bool("distinctMedia", distinctMedia).Msg("error getting media history")
return nil, fmt.Errorf("error getting media history: %w", err)
}
queryElapsed := time.Since(queryStarted)
Expand All @@ -94,7 +104,45 @@ func HandleMediaHistory(env requests.RequestEnv) (any, error) { //nolint:gocriti
})
}
enrichStarted := time.Now()
mediaIDs := mediaResponseMediaIDs(&env, mediaRefs)
mediaIDs := make(map[mediaPathRef]int64)
coverStatuses := make(map[int64]bool)
enrichCtx, cancelEnrichment := optionalDBEnrichmentContext(env.Context)
defer cancelEnrichment()

mediaRows, enrichErr := resolveMediaPathIDs(enrichCtx, env.Database.MediaDB, mediaRefs)
if enrichErr != nil {
log.Debug().Err(enrichErr).Msg("could not enrich media history from media database")
} else {
resolvedMediaIDs := make(map[mediaPathRef]int64, len(mediaRows))
coverRefs := make([]database.MediaCoverRef, 0, len(mediaRows))
seenIDs := make(map[int64]struct{}, len(mediaRows))
for _, ref := range mediaRefs {
row := mediaRows[ref]
if row.DBID <= 0 {
continue
}
resolvedMediaIDs[ref] = row.DBID
if _, ok := seenIDs[row.DBID]; ok {
continue
}
seenIDs[row.DBID] = struct{}{}
coverRefs = append(coverRefs, database.MediaCoverRef{
MediaDBID: row.DBID,
MediaTitleDBID: row.MediaTitleDBID,
})
}

resolvedCoverStatuses := make(map[int64]bool)
if len(coverRefs) > 0 {
resolvedCoverStatuses, enrichErr = env.Database.MediaDB.GetMediaCoverStatus(enrichCtx, coverRefs)
}
if enrichErr != nil {
log.Debug().Err(enrichErr).Msg("could not enrich media history cover status")
} else {
mediaIDs = resolvedMediaIDs
coverStatuses = resolvedCoverStatuses
}
}
enrichElapsed := time.Since(enrichStarted)

buildStarted := time.Now()
Expand All @@ -109,10 +157,12 @@ func HandleMediaHistory(env requests.RequestEnv) (any, error) { //nolint:gocriti
formatted := entry.EndTime.Format(time.RFC3339)
endedAt = &formatted
}
mediaID := mediaIDs[ref]

responseEntries = append(responseEntries, models.MediaHistoryResponseEntry{
MediaID: mediaIDs[ref],
MediaID: mediaID,
RelPath: mediaResponseRelativePath(&env, entry.SystemID, entry.MediaPath),
HasCover: coverStatuses[mediaID],
SystemID: entry.SystemID,
SystemName: entry.SystemName,
MediaName: entry.MediaName,
Expand All @@ -126,6 +176,7 @@ func HandleMediaHistory(env requests.RequestEnv) (any, error) { //nolint:gocriti

log.Debug().
Int("entries", len(responseEntries)).
Bool("distinctMedia", distinctMedia).
Dur("queryDuration", queryElapsed).
Dur("enrichDuration", enrichElapsed).
Dur("buildDuration", time.Since(buildStarted)).
Expand Down
Loading
Loading