Files
silo-server/internal/jellycompat/path_normalization.go
0bd4f8cb3b fix(jellycompat): restore CanDownload with a real Download route for Infuse (#123)
dd81a7ef set CanDownload=false to stop Wholphin's screensaver from
404ing on the nonexistent /Items/{id}/Download route — but the flag is
load-bearing for Infuse, which refuses Direct Play (Static=true
streaming) of items it believes it cannot download. With omitempty the
field vanished from the JSON entirely and Infuse playback broke, while
PlaybackInfo-negotiating clients were unaffected.

Resolve the underlying inconsistency instead of trading one client for
the other: implement GET/HEAD /Items/{id}/Download serving the original
file (range support, Content-Disposition, optional mediaSourceId for
multi-version items) under stream-group auth, and restore
CanDownload=true now that the route exists. Fixes Infuse playback and
keeps Wholphin's download callers working.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:09:47 -04:00

141 lines
3.9 KiB
Go

package jellycompat
import (
"context"
"net/http"
"strings"
)
type requestContextKey string
const originalPathKey requestContextKey = "jellycompat_original_path"
var compatPathSegments = map[string]string{
"system": "System",
"info": "Info",
"public": "Public",
"branding": "Branding",
"configuration": "Configuration",
"quickconnect": "QuickConnect",
"enabled": "Enabled",
"users": "Users",
"authenticatebyname": "AuthenticateByName",
"me": "Me",
"views": "Views",
"items": "Items",
"latest": "Latest",
"suggestions": "Suggestions",
"similar": "Similar",
"thememedia": "ThemeMedia",
"themesongs": "ThemeSongs",
"specialfeatures": "SpecialFeatures",
"intros": "Intros",
"download": "Download",
"images": "Images",
"primary": "Primary",
"backdrop": "Backdrop",
"logo": "Logo",
"genres": "Genres",
"shows": "Shows",
"seasons": "Seasons",
"episodes": "Episodes",
"nextup": "NextUp",
"useritems": "UserItems",
"resume": "Resume",
"userdata": "UserData",
"userviews": "UserViews",
"userfavoriteitems": "UserFavoriteItems",
"userplayeditems": "UserPlayedItems",
"favoriteitems": "FavoriteItems",
"playeditems": "PlayedItems",
"search": "Search",
"hints": "Hints",
"sessions": "Sessions",
"capabilities": "Capabilities",
"full": "Full",
"videos": "Videos",
"hls": "hls",
"subtitles": "Subtitles",
"logout": "Logout",
"playing": "Playing",
"progress": "Progress",
"stopped": "Stopped",
"ping": "Ping",
"filters": "Filters",
"mediasegments": "MediaSegments",
"episode": "Episode",
"timestamps": "Timestamps",
"introtimestamps": "IntroTimestamps",
"playback": "Playback",
"bitratetest": "BitrateTest",
"playbackinfo": "PlaybackInfo",
"displaypreferences": "DisplayPreferences",
"persons": "Persons",
"studios": "Studios",
"movies": "Movies",
"recommendations": "Recommendations",
"groupingoptions": "GroupingOptions",
"library": "Library",
"virtualfolders": "VirtualFolders",
"socket": "socket",
}
func normalizeCompatPathMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
normalized := canonicalizeCompatPath(r.URL.Path)
if normalized == r.URL.Path {
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), originalPathKey, r.URL.Path)
clone := r.Clone(ctx)
urlCopy := *clone.URL
urlCopy.Path = normalized
urlCopy.RawPath = normalized
clone.URL = &urlCopy
next.ServeHTTP(w, clone)
})
}
func canonicalizeCompatPath(path string) string {
if path == "" || path == "/" {
return path
}
parts := strings.Split(path, "/")
if len(parts) > 1 {
switch strings.ToLower(parts[1]) {
case "emby", "jellyfin":
parts = append([]string{""}, parts[2:]...)
}
}
for i := 1; i < len(parts); i++ {
part := parts[i]
if part == "" {
continue
}
parts[i] = canonicalizeCompatSegment(part)
}
return strings.Join(parts, "/")
}
func canonicalizeCompatSegment(segment string) string {
lower := strings.ToLower(segment)
if mapped, ok := compatPathSegments[lower]; ok {
return mapped
}
if lower == "master.m3u8" {
return lower
}
if strings.HasPrefix(lower, "stream.") {
return lower
}
return segment
}
func originalPathFromContext(ctx context.Context) string {
path, _ := ctx.Value(originalPathKey).(string)
return path
}