Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/api/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,17 +559,17 @@ All parameters are optional. When called with no parameters, returns root entrie

| Key | Type | Required | Description |
| :----------- | :------- | :------- | :----------------------------------------------------------------------------------------------- |
| mediaId | number | No | Opaque media database row ID. Present on `media` entries for efficient follow-up `media.meta` and `media.image` requests. |
| mediaId | number | No | Opaque media database row ID. Present on `media` entries, and on zip-as-directory platform `directory` entries that contain exactly one indexed media descendant, for efficient follow-up `media.meta` and `media.image` requests. |
| name | string | Yes | Display name of the entry. |
| path | string | Yes | Full path to the entry. |
| type | string | Yes | Entry type: `root`, `directory`, or `media`. |
| fileCount | number | No | Number of files in this directory. Present on `root` and `directory` entries. |
| group | string | No | Launcher group name. Present on virtual scheme `root` entries. |
| systemId | string | No | System ID for the media or single-system filtered route (e.g. `SNES`). Present on `media` entries and filtered `root` entries when exactly one system applies. |
| systemIds | string[] | No | System IDs represented by a filtered `root` or `directory` entry. |
| zapScript | string | No | ZapScript command to launch this media. Present on `media` entries. |
| relativePath | string | No | Relative path from root directory. Present on `media` entries. |
| tags | object[] | No | Tags attached to the media. Each object has `tag` (string) and `type` (string). Present on `media` entries. |
| zapScript | string | No | ZapScript command to launch this media. Present on `media` entries and singleton media-container `directory` entries on zip-as-directory platforms. |
| relativePath | string | No | Relative path from root directory. Present on `media` entries and singleton media-container `directory` entries on zip-as-directory platforms. |
| tags | object[] | No | Tags attached to the media. Each object has `tag` (string) and `type` (string). Present on `media` entries and singleton media-container `directory` entries on zip-as-directory platforms. |

##### Browse pagination object

Expand Down
4 changes: 2 additions & 2 deletions docs/scraper.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,9 @@ Progress is queryable with `media.scrape.status` and broadcast as `media.scrapin

Only one scraper can run at a time, and scraping is mutually exclusive with media indexing.

`media.meta` returns the metadata graph for media rows: media-level tags and properties, title-level tags and properties, and stored system identity. Single requests accept `mediaId` or `system`/`path` and keep the single-response shape; batch requests use `items` and return per-item results. Binary property bytes are not included; clients should use `media.image` for image data.
`media.meta` returns the metadata graph for media rows: media-level tags and properties, title-level tags and properties, and stored system identity. Single requests accept `mediaId` or `system`/`path` and keep the single-response shape; batch requests use `items` and return per-item results. Binary property bytes are not included; clients should use `media.image` for image data. On platforms that treat zips as directories, a `system`/`path` request for a folder or zip-as-directory container resolves to its only non-missing indexed media descendant when exactly one exists.

`media.image` accepts one media ref plus image type preferences such as `image`, `boxart`, `boxart3d`, `screenshot`, `wheel`, `titleshot`, `map`, `marquee`, and `fanart`. These resolve to canonical image property tags; for example `boxart` becomes `property:image-boxart` and `image` becomes `property:image-image`. Media-level properties are preferred over title-level properties for the same type. For stale image properties in these canonical tags, such as missing file paths for `property:image-boxart` or `property:image-image`, `media.image` logs the stale property in memory only and does not delete DB rows; lookup falls through to the next available source.
`media.image` accepts one media ref plus image type preferences such as `image`, `boxart`, `boxart3d`, `screenshot`, `wheel`, `titleshot`, `map`, `marquee`, and `fanart`. These resolve to canonical image property tags; for example `boxart` becomes `property:image-boxart` and `image` becomes `property:image-image`. Media-level properties are preferred over title-level properties for the same type. On zip-as-directory platforms, singleton container aliases are checked as media-level fallbacks, so artwork attached to a sole child or its container can be found from either path. For stale image properties in these canonical tags, such as missing file paths for `property:image-boxart` or `property:image-image`, `media.image` logs the stale property in memory only and does not delete DB rows; lookup falls through to the next available source.

## Useful Focused Tests

Expand Down
142 changes: 142 additions & 0 deletions pkg/api/methods/media_alias.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Zaparoo Core
// Copyright (c) 2026 The Zaparoo Project Contributors.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of Zaparoo Core.
//
// Zaparoo Core is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Zaparoo Core is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Zaparoo Core. If not, see <http://www.gnu.org/licenses/>.

package methods

import (
"fmt"
"strings"

"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
)

func singletonMediaAliasesEnabled(env *requests.RequestEnv) bool {
return env != nil && env.Platform != nil && env.Platform.Settings().ZipsAsDirs
}

func resolveSingletonMediaPath(
env *requests.RequestEnv,
system database.System,
mediaPath string,
) (*database.Media, error) {
if !singletonMediaAliasesEnabled(env) {
return nil, nil //nolint:nilnil // disabled aliasing has no singleton fallback
}

media, err := env.Database.MediaDB.FindSingleDescendantMedia(env.Context, system.DBID, mediaPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve singleton media path: %w", err)
}
return media, nil
}

func equivalentMediaIDs(env *requests.RequestEnv, row *database.MediaFullRow) ([]int64, error) {
if row == nil {
return nil, nil
}

ids := []int64{row.DBID}
if env == nil || env.Database == nil || env.Database.MediaDB == nil || !singletonMediaAliasesEnabled(env) {
return ids, nil
}
seen := map[int64]bool{row.DBID: true}
add := func(media *database.Media) {
if media == nil || seen[media.DBID] {
return
}
seen[media.DBID] = true
ids = append(ids, media.DBID)
}

child, err := env.Database.MediaDB.FindSingleDescendantMedia(env.Context, row.System.DBID, row.Path)
if err != nil {
return nil, fmt.Errorf("failed to find child alias media: %w", err)
}
add(child)

parentPath := strings.TrimSuffix(row.ParentDir, "/")
if parentPath == "" || parentPath == row.Path {
return ids, nil
}

onlyChild, err := env.Database.MediaDB.FindSingleDescendantMedia(env.Context, row.System.DBID, parentPath)
if err != nil {
return nil, fmt.Errorf("failed to verify parent alias media: %w", err)
}
if onlyChild == nil || onlyChild.DBID != row.DBID {
return ids, nil
}

parent, err := env.Database.MediaDB.FindMediaBySystemAndPath(env.Context, row.System.DBID, parentPath)
if err != nil {
return nil, fmt.Errorf("failed to find parent alias media: %w", err)
}
add(parent)

return ids, nil
}

func mergeMediaTags(primary []database.TagInfo, aliases ...[]database.TagInfo) []database.TagInfo {
if len(aliases) == 0 {
return primary
}
merged := make([]database.TagInfo, 0, len(primary))
seen := make(map[string]bool)
appendUnique := func(tags []database.TagInfo) {
for _, tag := range tags {
key := tag.Type + "\x00" + tag.Tag
if seen[key] {
continue
}
seen[key] = true
merged = append(merged, tag)
}
}
appendUnique(primary)
for _, tags := range aliases {
appendUnique(tags)
}
return merged
}

func mergeMediaProperties(
primary []database.MediaProperty,
aliases ...[]database.MediaProperty,
) []database.MediaProperty {
if len(aliases) == 0 {
return primary
}
merged := make([]database.MediaProperty, 0, len(primary))
seen := make(map[string]bool)
appendUnique := func(props []database.MediaProperty) {
for _, prop := range props {
if seen[prop.TypeTag] {
continue
}
seen[prop.TypeTag] = true
merged = append(merged, prop)
}
}
appendUnique(primary)
for _, props := range aliases {
appendUnique(props)
}
return merged
}
162 changes: 162 additions & 0 deletions pkg/api/methods/media_alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Zaparoo Core
// Copyright (c) 2026 The Zaparoo Project Contributors.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of Zaparoo Core.
//
// Zaparoo Core is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Zaparoo Core is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Zaparoo Core. If not, see <http://www.gnu.org/licenses/>.

package methods

import (
"context"
"testing"

"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
testhelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestEquivalentMediaIDsNilAndDisabledGuards(t *testing.T) {
t.Parallel()

row := &database.MediaFullRow{Media: database.Media{DBID: 10}}

ids, err := equivalentMediaIDs(nil, nil)
require.NoError(t, err)
assert.Nil(t, ids)

ids, err = equivalentMediaIDs(nil, row)
require.NoError(t, err)
assert.Equal(t, []int64{10}, ids)

ids, err = equivalentMediaIDs(&requests.RequestEnv{}, row)
require.NoError(t, err)
assert.Equal(t, []int64{10}, ids)

ids, err = equivalentMediaIDs(&requests.RequestEnv{Database: &database.Database{}}, row)
require.NoError(t, err)
assert.Equal(t, []int64{10}, ids)
}

func TestEquivalentMediaIDsParentChildAliases(t *testing.T) {
t.Parallel()

mockDB := testhelpers.NewMockMediaDBI()
platform := mocks.NewMockPlatform()
platform.On("Settings").Return(platforms.Settings{ZipsAsDirs: true})

row := &database.MediaFullRow{
Media: database.Media{
DBID: 20,
Path: "roms/Game.zip/Game.nes",
ParentDir: "roms/Game.zip/",
Comment on lines +68 to +69
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify slash-delimited hardcoded paths in Go tests under methods.
rg -nP --type=go '"[^"]*/[^"]*"' pkg/api/methods/media_alias_test.go

Repository: ZaparooProject/zaparoo-core

Length of output: 1022


Use filepath.Join for test path literals in pkg/api/methods/media_alias_test.go
Replace the hardcoded slash-delimited strings (e.g., "roms/Game.zip/Game.nes", "roms/Game.zip/", "roms/Game.zip") at lines 68-69, 73, 77-79, 107, and 111 with filepath.Join-constructed paths, keeping ParentDir’s trailing-separator semantics consistent with the existing expectations.

🤖 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_alias_test.go` around lines 68 - 69, Update the test
literals in pkg/api/methods/media_alias_test.go to build paths with
filepath.Join instead of hardcoded slash strings: replace occurrences used for
Path (e.g., "roms/Game.zip/Game.nes"), ParentDir (e.g., "roms/Game.zip/"), and
archive base ("roms/Game.zip") with filepath.Join calls so path separators are
correct on all platforms; for ParentDir preserve the trailing-separator
semantics by appending os.PathSeparator (or using filepath.Join and adding
string(os.PathSeparator)) as needed when constructing the ParentDir field;
update all mentioned instances at the Path/ParentDir assignments and other
comparisons so they use the new filepath.Join-based values.

},
System: database.System{DBID: 1, SystemID: "NES"},
}
parent := &database.Media{DBID: 10, Path: "roms/Game.zip"}

mockDB.On("FindSingleDescendantMedia", mock.Anything, int64(1), row.Path).
Return((*database.Media)(nil), nil)
mockDB.On("FindSingleDescendantMedia", mock.Anything, int64(1), "roms/Game.zip").
Return(&database.Media{DBID: 20, Path: row.Path}, nil)
mockDB.On("FindMediaBySystemAndPath", mock.Anything, int64(1), "roms/Game.zip").Return(parent, nil)

env := &requests.RequestEnv{
Context: context.Background(),
Database: &database.Database{MediaDB: mockDB},
Platform: platform,
}
ids, err := equivalentMediaIDs(env, row)
require.NoError(t, err)
assert.Equal(t, []int64{20, 10}, ids)
mockDB.AssertExpectations(t)
platform.AssertExpectations(t)
}

func TestEquivalentMediaIDsSkipsEmptyOrSelfParent(t *testing.T) {
t.Parallel()

mockDB := testhelpers.NewMockMediaDBI()
platform := mocks.NewMockPlatform()
platform.On("Settings").Return(platforms.Settings{ZipsAsDirs: true}).Twice()
env := &requests.RequestEnv{
Context: context.Background(),
Database: &database.Database{MediaDB: mockDB},
Platform: platform,
}

for _, row := range []*database.MediaFullRow{
{
Media: database.Media{DBID: 20, Path: "roms/Game.nes"},
System: database.System{DBID: 1},
},
{
Media: database.Media{DBID: 21, Path: "roms/Game.zip", ParentDir: "roms/Game.zip/"},
System: database.System{DBID: 1},
},
} {
mockDB.On("FindSingleDescendantMedia", mock.Anything, row.System.DBID, row.Path).
Return((*database.Media)(nil), nil).Once()
ids, err := equivalentMediaIDs(env, row)
require.NoError(t, err)
assert.Equal(t, []int64{row.DBID}, ids)
}

mockDB.AssertExpectations(t)
platform.AssertExpectations(t)
}

func TestMergeMediaTagsDedupesAndPreservesPrimary(t *testing.T) {
t.Parallel()

primary := []database.TagInfo{
{Type: "region", Tag: "us"},
{Type: "lang", Tag: "en"},
}
alias := []database.TagInfo{
{Type: "region", Tag: "us"},
{Type: "region", Tag: "jp"},
}

assert.Equal(t, []database.TagInfo{
{Type: "region", Tag: "us"},
{Type: "lang", Tag: "en"},
{Type: "region", Tag: "jp"},
}, mergeMediaTags(primary, alias))
}

func TestMergeMediaPropertiesDedupesAndPreservesPrimary(t *testing.T) {
t.Parallel()

primary := []database.MediaProperty{
{TypeTag: "property:image-boxart", Text: "primary.png"},
{TypeTag: "property:description", Text: "primary desc"},
}
alias := []database.MediaProperty{
{TypeTag: "property:image-boxart", Text: "alias.png"},
{TypeTag: "property:image-screenshot", Text: "shot.png"},
}

assert.Equal(t, []database.MediaProperty{
{TypeTag: "property:image-boxart", Text: "primary.png"},
{TypeTag: "property:description", Text: "primary desc"},
{TypeTag: "property:image-screenshot", Text: "shot.png"},
}, mergeMediaProperties(primary, alias))
}
9 changes: 9 additions & 0 deletions pkg/api/methods/media_batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@ func resolveMediaRefs(env *requests.RequestEnv, refs []mediaRefParam) ([]resolve
}
continue
}
if fallback == nil {
fallback, fallbackErr = resolveSingletonMediaPath(env, system, path)
if fallbackErr != nil {
for _, index := range pathIndexes[systemID+"\x00"+path] {
resolved[index].Err = fallbackErr
}
continue
}
}
if fallback == nil {
for _, index := range pathIndexes[systemID+"\x00"+path] {
resolved[index].Err = models.ClientErrf("media not found: %s/%s", systemID, path)
Expand Down
Loading
Loading