* fix(jellycompat): report fileless episodes as LocationType=Virtual
Provider-metadata-only episodes (unaired/missing entries pulled from
TVDB/TMDB that have no underlying media file) were mapped with
LocationType=FileSystem and an empty MediaSources list. Jellyfin's
contract is that such items report LocationType=Virtual.
Because they were not marked Virtual, clients that build playback queues
from the episode list (Wholphin, Infuse, Findroid, ...) treated them as
playable, queued them, and failed on advance with "no media sources".
Wholphin specifically filters LocationType=Virtual out of its
auto-advance playlist, so marking these Virtual lets next-episode /
skip-outro jump cleanly to the next real episode, and the episode list
greys them out as expected.
itemFromDetailWithFields now stamps LocationType=Virtual on playable
items (movie/episode) that have zero file versions.
* fix(jellycompat): mark fileless episodes Virtual on list paths too
The Virtual fix only covered itemFromDetailWithFields, which clients reach
only when requesting detail-level Fields (MediaSources, MediaStreams, ...).
itemFromList and episodeFromUpstream still stamped LocationType=FileSystem
unconditionally, so the same fileless episode reported Virtual or FileSystem
depending on the endpoint/Fields combination used.
Centralize the decision in applyPlayableLocation (which also clears VideoType
on virtual items, matching Jellyfin) and plumb a HasMediaFiles signal into the
list paths:
- episode targets query gains an EXISTS check against media_files
- the pool-less fallback uses a new EpisodeRepository.HasFilesByIDs
- the /Shows/{id}/Episodes non-detail path reuses the already-fetched
episode targets, so no extra query is needed
A nil signal preserves the historical FileSystem default for producers that
do not check file presence (movies, series-level lists).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
105 lines
3.4 KiB
Go
105 lines
3.4 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
|
)
|
|
|
|
// TestVirtualEpisodeLocationType verifies the fix: a playable episode with no
|
|
// underlying file versions (provider-metadata-only / unaired) reports
|
|
// LocationType=Virtual with no MediaSources, while a real episode with a
|
|
// version reports LocationType=FileSystem and gets MediaSources.
|
|
func TestVirtualEpisodeLocationType(t *testing.T) {
|
|
m := newMapper(NewResourceIDCodec(), &config.Config{})
|
|
fields := map[string]bool{"mediasources": true}
|
|
|
|
// Fileless episode -> Virtual, no media sources.
|
|
fileless := upstreamItemDetail{
|
|
ContentID: "1001",
|
|
Type: "episode",
|
|
Title: "Unaired Episode",
|
|
Versions: nil,
|
|
}
|
|
dto := m.itemFromDetailWithFields(fileless, false, nil, fields)
|
|
if dto.LocationType != "Virtual" {
|
|
t.Errorf("fileless episode LocationType = %q, want Virtual", dto.LocationType)
|
|
}
|
|
if dto.VideoType != "" {
|
|
t.Errorf("fileless episode VideoType = %q, want empty", dto.VideoType)
|
|
}
|
|
if len(dto.MediaSources) != 0 {
|
|
t.Errorf("fileless episode MediaSources = %d, want 0", len(dto.MediaSources))
|
|
}
|
|
|
|
// Episode with a real file version -> FileSystem + MediaSources present.
|
|
withFile := upstreamItemDetail{
|
|
ContentID: "1002",
|
|
Type: "episode",
|
|
Title: "Real Episode",
|
|
Versions: []catalog.FileVersion{{
|
|
FileID: 42,
|
|
Container: "mkv",
|
|
Duration: 1200,
|
|
}},
|
|
}
|
|
dto2 := m.itemFromDetailWithFields(withFile, false, nil, fields)
|
|
if dto2.LocationType != "FileSystem" {
|
|
t.Errorf("real episode LocationType = %q, want FileSystem", dto2.LocationType)
|
|
}
|
|
if len(dto2.MediaSources) != 1 {
|
|
t.Errorf("real episode MediaSources = %d, want 1", len(dto2.MediaSources))
|
|
}
|
|
}
|
|
|
|
// TestVirtualEpisodeListPaths verifies the list-side mappers honor the
|
|
// HasMediaFiles signal: false -> Virtual, true -> FileSystem, and nil
|
|
// (unknown, producing query did not check) preserves the historical
|
|
// FileSystem default.
|
|
func TestVirtualEpisodeListPaths(t *testing.T) {
|
|
m := newMapper(NewResourceIDCodec(), &config.Config{})
|
|
boolp := func(v bool) *bool { return &v }
|
|
|
|
cases := []struct {
|
|
name string
|
|
hasMediaFiles *bool
|
|
wantLocation string
|
|
wantVideoType string
|
|
}{
|
|
{"fileless", boolp(false), "Virtual", ""},
|
|
{"with file", boolp(true), "FileSystem", "VideoFile"},
|
|
{"unknown", nil, "FileSystem", "VideoFile"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run("itemFromList/"+tc.name, func(t *testing.T) {
|
|
dto := m.itemFromList(upstreamListItem{
|
|
ContentID: "2001",
|
|
Type: "episode",
|
|
Title: "Episode",
|
|
HasMediaFiles: tc.hasMediaFiles,
|
|
}, false, nil, nil)
|
|
if dto.LocationType != tc.wantLocation {
|
|
t.Errorf("LocationType = %q, want %q", dto.LocationType, tc.wantLocation)
|
|
}
|
|
if dto.VideoType != tc.wantVideoType {
|
|
t.Errorf("VideoType = %q, want %q", dto.VideoType, tc.wantVideoType)
|
|
}
|
|
})
|
|
t.Run("episodeFromUpstream/"+tc.name, func(t *testing.T) {
|
|
dto := m.episodeFromUpstream(upstreamEpisode{
|
|
ContentID: "3001",
|
|
Title: "Episode",
|
|
HasMediaFiles: tc.hasMediaFiles,
|
|
}, false, nil)
|
|
if dto.LocationType != tc.wantLocation {
|
|
t.Errorf("LocationType = %q, want %q", dto.LocationType, tc.wantLocation)
|
|
}
|
|
if dto.VideoType != tc.wantVideoType {
|
|
t.Errorf("VideoType = %q, want %q", dto.VideoType, tc.wantVideoType)
|
|
}
|
|
})
|
|
}
|
|
}
|