Skip to content
8 changes: 7 additions & 1 deletion pkg/api/methods/media_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,17 @@ import (
)

// defaultImageTypes is the preference order used when no imageTypes param is provided.
var defaultImageTypes = []string{"image", "boxart", "screenshot", "wheel", "titleshot", "map", "marquee", "fanart"}
var defaultImageTypes = []string{
"image", "boxart", "boxart3d", "screenshot", "wheel", "titleshot", "map",
"marquee", "fanart",
}

var imageTypeTags = map[string]string{
"image": tags.PropertyTypeTag(tags.TagPropertyImageImage),
"boxart": tags.PropertyTypeTag(tags.TagPropertyImageBoxart),
"boxart3d": tags.PropertyTypeTag(tags.TagPropertyImageBoxart3D),
"boxartside": tags.PropertyTypeTag(tags.TagPropertyImageBoxartSide),
"boxartback": tags.PropertyTypeTag(tags.TagPropertyImageBoxartBack),
"screenshot": tags.PropertyTypeTag(tags.TagPropertyImageScreenshot),
"wheel": tags.PropertyTypeTag(tags.TagPropertyImageWheel),
"titleshot": tags.PropertyTypeTag(tags.TagPropertyImageTitleshot),
Expand Down
72 changes: 72 additions & 0 deletions pkg/api/methods/media_title_parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// 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 (
"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/mediadb"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database/mediascanner"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database/slugs"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database/systemdefs"
)

// HandleMediaTitleParse computes a MediaTitle from a system ID and path
// without touching the filesystem or database. Used to preview the title
// parsing and slug generation that the media scanner would produce.
func HandleMediaTitleParse(
env requests.RequestEnv, //nolint:gocritic // single-use parameter in API handler
) (any, error) {
var params models.MediaTitleParseParams
if err := validation.ValidateAndUnmarshal(env.Params, &params); err != nil {
return nil, models.ClientErrf("invalid params: %w", err)
}

mediaType := slugs.MediaTypeGame
if params.SystemID != "" {
if system, err := systemdefs.GetSystem(params.SystemID); err == nil {
mediaType = system.GetMediaType()
}
}

pf := mediascanner.GetPathFragments(&mediascanner.PathFragmentParams{
Config: env.Config,
Path: params.Path,
SystemID: params.SystemID,
MediaType: mediaType,
})

metadata := mediadb.GenerateSlugMetadataFromTokens(mediaType, pf.Title, pf.Slug, pf.SlugTokens)

var secondarySlug *string
if metadata.SecondarySlug != "" {
s := metadata.SecondarySlug
secondarySlug = &s
}

return models.MediaTitleParseResponse{
Slug: pf.Slug,
Name: pf.Title,
SecondarySlug: secondarySlug,
SlugLength: metadata.SlugLength,
SlugWordCount: metadata.SlugWordCount,
}, nil
}
253 changes: 253 additions & 0 deletions pkg/api/methods/media_title_parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
// 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"
"encoding/json"
"path/filepath"
"testing"
"unicode/utf8"

"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/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func makeTitleParseEnv(t *testing.T, systemID, path string) requests.RequestEnv {
t.Helper()
params, err := json.Marshal(map[string]string{
"systemId": systemID,
"path": path,
})
require.NoError(t, err)
return requests.RequestEnv{
Context: context.Background(),
Config: &config.Instance{},
Params: params,
}
}

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

nesPath := filepath.Join("roms", "nes", "game.nes")

missingSystemParams, err := json.Marshal(map[string]string{"path": nesPath})
require.NoError(t, err)

emptySystemParams, err := json.Marshal(map[string]string{"systemId": "", "path": nesPath})
require.NoError(t, err)

tests := []struct {
name string
params []byte
}{
{
name: "missing systemId",
params: missingSystemParams,
},
{
name: "empty systemId",
params: emptySystemParams,
},
{
name: "missing path",
params: []byte(`{"systemId": "NES"}`),
},
{
name: "empty path",
params: []byte(`{"systemId": "NES", "path": ""}`),
},
{
name: "invalid JSON",
params: []byte(`not valid json`),
},
{
name: "null params",
params: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

env := requests.RequestEnv{
Context: context.Background(),
Config: &config.Instance{},
Params: tt.params,
}
_, err := HandleMediaTitleParse(env)
require.Error(t, err)
})
}
}

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

env := makeTitleParseEnv(t, "NES",
filepath.Join("roms", "nes", "Super Mario Bros.nes"),
)

result, err := HandleMediaTitleParse(env)
require.NoError(t, err)

resp, ok := result.(models.MediaTitleParseResponse)
require.True(t, ok)
assert.Equal(t, "Super Mario Bros", resp.Name)
assert.NotEmpty(t, resp.Slug)
assert.Nil(t, resp.SecondarySlug)
assert.Equal(t, utf8.RuneCountInString(resp.Slug), resp.SlugLength)
assert.Positive(t, resp.SlugWordCount)
}

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

env := makeTitleParseEnv(t, "NES",
filepath.Join("roms", "nes", "The Legend of Zelda: Links Awakening.nes"),
)

result, err := HandleMediaTitleParse(env)
require.NoError(t, err)

resp, ok := result.(models.MediaTitleParseResponse)
require.True(t, ok)
assert.NotEmpty(t, resp.Name)
assert.NotEmpty(t, resp.Slug)
require.NotNil(t, resp.SecondarySlug)
assert.NotEmpty(t, *resp.SecondarySlug)
assert.Equal(t, utf8.RuneCountInString(resp.Slug), resp.SlugLength)
}

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

env := makeTitleParseEnv(t, "SNES",
filepath.Join("roms", "snes", "Donkey Kong Country - Tropical Freeze.sfc"),
)

result, err := HandleMediaTitleParse(env)
require.NoError(t, err)

resp, ok := result.(models.MediaTitleParseResponse)
require.True(t, ok)
assert.NotEmpty(t, resp.Name)
require.NotNil(t, resp.SecondarySlug)
assert.NotEmpty(t, *resp.SecondarySlug)
}

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

env := makeTitleParseEnv(t, "NES",
filepath.Join("roms", "nes", "Tetris.nes"),
)

result, err := HandleMediaTitleParse(env)
require.NoError(t, err)

resp, ok := result.(models.MediaTitleParseResponse)
require.True(t, ok)
assert.Equal(t, "Tetris", resp.Name)
assert.Nil(t, resp.SecondarySlug)
assert.Positive(t, resp.SlugLength)
assert.Positive(t, resp.SlugWordCount)
}

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

env := makeTitleParseEnv(t, "UNKNOWN_SYSTEM_XYZ",
filepath.Join("roms", "misc", "Some Game.rom"),
)

result, err := HandleMediaTitleParse(env)
require.NoError(t, err)

resp, ok := result.(models.MediaTitleParseResponse)
require.True(t, ok)
assert.Equal(t, "Some Game", resp.Name)
assert.NotEmpty(t, resp.Slug)
}

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

tests := []struct {
name string
systemID string
filename string
}{
{
name: "NES multi-word",
systemID: "NES",
filename: "Mega Man 2.nes",
},
{
name: "Genesis multi-word",
systemID: "Genesis",
filename: "Sonic the Hedgehog 2.md",
},
{
name: "SNES single word",
systemID: "SNES",
filename: "Tetris.sfc",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

env := makeTitleParseEnv(t, tt.systemID,
filepath.Join("roms", tt.filename),
)

result, err := HandleMediaTitleParse(env)
require.NoError(t, err)

resp, ok := result.(models.MediaTitleParseResponse)
require.True(t, ok)
assert.Equal(t, utf8.RuneCountInString(resp.Slug), resp.SlugLength,
"SlugLength must equal rune count of Slug")
assert.Positive(t, resp.SlugWordCount)
})
}
}

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

env := makeTitleParseEnv(t, "NES",
filepath.Join("roms", "nes", "Castlevania.nes"),
)

result, err := HandleMediaTitleParse(env)
require.NoError(t, err)
require.NotNil(t, result)

_, ok := result.(models.MediaTitleParseResponse)
require.True(t, ok, "result must be MediaTitleParseResponse")
}
1 change: 1 addition & 0 deletions pkg/api/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const (
MethodInputKeyboard = "input.keyboard"
MethodInputGamepad = "input.gamepad"
MethodScreenshot = "screenshot"
MethodMediaTitleParse = "media.title.parse"
)

// ResponseWithCallback wraps a method result with a function that should be
Expand Down
5 changes: 5 additions & 0 deletions pkg/api/models/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,8 @@ type InputKeyboardParams struct {
type InputGamepadParams struct {
Buttons string `json:"buttons" validate:"required,min=1"`
}

type MediaTitleParseParams struct {
SystemID string `json:"systemId" validate:"required,min=1"`
Path string `json:"path" validate:"required,min=1"`
}
Comment on lines +243 to +246
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Move MediaTitleParseParams above method declarations.

MediaTitleParseParams is introduced after ReaderConnection.IsEnabled (Line 112). Please keep new type declarations before functions/methods in this file.

As per coding guidelines, "Define Go types and consts near the top of the file, before functions and methods".

🤖 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/models/params.go` around lines 243 - 246, Move the
MediaTitleParseParams type declaration so type definitions appear before
methods: locate MediaTitleParseParams and cut/paste it above the
ReaderConnection.IsEnabled method declaration (i.e., with other top-level
type/const defs near the top of the file). Ensure the struct tag and field names
remain unchanged and run go vet/go fmt to confirm formatting.

8 changes: 8 additions & 0 deletions pkg/api/models/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,14 @@ type LogDownloadResponse struct {
Size int `json:"size"`
}

type MediaTitleParseResponse struct {
SecondarySlug *string `json:"secondarySlug,omitempty"`
Slug string `json:"slug"`
Name string `json:"name"`
SlugLength int `json:"slugLength"`
SlugWordCount int `json:"slugWordCount"`
}

type Launcher struct {
ID string `json:"id"`
SystemID string `json:"systemId,omitempty"`
Expand Down
1 change: 1 addition & 0 deletions pkg/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ func NewMethodMap() *MethodMap {
models.MethodMediaScrapeCancel: methods.HandleMediaScrapeCancel,
models.MethodMediaScrapeResume: methods.HandleMediaScrapeResume,
models.MethodMediaControl: methods.HandleMediaControl,
models.MethodMediaTitleParse: methods.HandleMediaTitleParse,
// settings
models.MethodSettings: methods.HandleSettings,
models.MethodSettingsUpdate: methods.HandleSettingsUpdate,
Expand Down
Loading
Loading