fix(jellycompat): expose subtitle management

This commit is contained in:
Quick
2026-06-26 12:11:02 -04:00
parent b21c3eea37
commit 5fecb07c4f
14 changed files with 914 additions and 61 deletions
+10 -31
View File
@@ -61,9 +61,7 @@ import (
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/subtitles"
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles"
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
"github.com/Silo-Server/silo-server/internal/subtitles/providerregistry"
"github.com/Silo-Server/silo-server/internal/taskmanager"
"github.com/Silo-Server/silo-server/internal/taskmanager/repository"
"github.com/Silo-Server/silo-server/internal/usercollections"
@@ -980,34 +978,15 @@ func NewRouter(deps Dependencies) chi.Router {
// Build subtitle search handler if we have DB and S3.
var subtitleSearchHandler *handlers.SubtitleSearchHandler
if deps.DB != nil && deps.S3Public != nil && subtitleRepo != nil {
subtitleManager = subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket())
// Load provider configs from DB and register enabled providers.
providerConfigs, _ := subtitleRepo.ListProviderConfigs(deps.AppContext)
for _, cfg := range providerConfigs {
if !cfg.Enabled {
continue
}
switch cfg.ProviderName {
case "opensubtitles":
if cfg.Username == "" || cfg.Password == "" {
continue
}
subtitleManager.RegisterProvider(opensubtitles.New(opensubtitles.Config{
Username: cfg.Username,
Password: cfg.Password,
}))
case "subdl":
if cfg.APIKey == "" {
continue
}
subtitleManager.RegisterProvider(subdl.New(subdl.Config{APIKey: cfg.APIKey}))
case "subsource":
if cfg.APIKey == "" {
continue
}
subtitleManager.RegisterProvider(subsource.New(subsource.Config{APIKey: cfg.APIKey}))
}
var managerErr error
subtitleManager, managerErr = providerregistry.NewManagerFromRepository(
deps.AppContext,
subtitleRepo,
deps.S3Public,
deps.S3Public.Bucket(),
)
if managerErr != nil {
slog.Warn("subtitle provider configuration unavailable", "error", managerErr)
}
mediaResolver := &pgSubtitleMediaResolver{pool: deps.DB}
@@ -315,7 +315,7 @@ func TestValidatePseudoUser(t *testing.T) {
}
func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil)
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil, nil)
session := &Session{PseudoUserID: PseudoUserID(2, "p1"), Username: "admin"}
req := httptest.NewRequest(http.MethodGet, "/Users", nil)
@@ -342,7 +342,7 @@ func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
}
func TestHandleUsers_RequiresSession(t *testing.T) {
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil)
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil, nil)
rec := httptest.NewRecorder()
h.HandleUsers(rec, httptest.NewRequest(http.MethodGet, "/Users", nil))
if rec.Code != http.StatusUnauthorized {
+3
View File
@@ -1040,6 +1040,9 @@ func itemDetailToUpstream(d *catalog.ItemDetail) upstreamItemDetail {
Genres: d.Genres,
RatingIMDB: d.RatingIMDB,
RatingTMDB: d.RatingTMDB,
ImdbID: d.ImdbID,
TmdbID: d.TmdbID,
TvdbID: d.TvdbID,
PosterURL: d.PosterURL,
BackdropURL: d.BackdropURL,
LogoURL: d.LogoURL,
+27 -3
View File
@@ -171,6 +171,9 @@ type progressCountingStore struct {
forUserCalls int
listProgressCalls int
lastListedMediaItemIDs []string
profile *userstore.Profile
updatedProfileID string
updatedProfileInput userstore.UpdateProfileInput
}
func (s *progressCountingStore) ListProgressByMediaItems(_ context.Context, _ string, mediaItemIDs []string) (map[string]userstore.WatchProgress, error) {
@@ -184,13 +187,34 @@ func (s *progressCountingStore) CreateProfile(context.Context, userstore.Profile
panic("unused")
}
func (s *progressCountingStore) GetProfile(context.Context, string) (*userstore.Profile, error) {
panic("unused")
if s.profile == nil {
return nil, nil
}
profile := *s.profile
return &profile, nil
}
func (s *progressCountingStore) ListProfiles(context.Context) ([]userstore.Profile, error) {
panic("unused")
}
func (s *progressCountingStore) UpdateProfile(context.Context, string, userstore.UpdateProfileInput) error {
panic("unused")
func (s *progressCountingStore) UpdateProfile(_ context.Context, profileID string, input userstore.UpdateProfileInput) error {
s.updatedProfileID = profileID
s.updatedProfileInput = input
if s.profile == nil {
return nil
}
if input.Language != nil {
s.profile.Language = *input.Language
}
if input.SubtitleLanguage != nil {
s.profile.SubtitleLanguage = *input.SubtitleLanguage
}
if input.SubtitleMode != nil {
s.profile.SubtitleMode = *input.SubtitleMode
}
if input.ShowForcedSubtitles != nil {
s.profile.ShowForcedSubtitles = *input.ShowForcedSubtitles
}
return nil
}
func (s *progressCountingStore) DeleteProfile(context.Context, string) error { panic("unused") }
func (s *progressCountingStore) VerifyPIN(context.Context, string, string) (bool, error) {
+155 -17
View File
@@ -4,12 +4,15 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/lang"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type authenticateByNameRequest struct {
@@ -73,7 +76,9 @@ type userDTOResponse struct {
}
type userConfigurationResponse struct {
AudioLanguagePreference string `json:"AudioLanguagePreference,omitempty"`
PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"`
SubtitleLanguagePreference string `json:"SubtitleLanguagePreference,omitempty"`
DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"`
GroupedFolders []string `json:"GroupedFolders"`
SubtitleMode string `json:"SubtitleMode"`
@@ -118,15 +123,22 @@ type AuthHandler struct {
cfg func() *config.Config
loginResolver loginResolver
authenticator *Authenticator
storeProvider userstore.UserStoreProvider
}
// NewAuthHandler creates a new auth handler. The config provider is invoked
// per request so compat setting changes apply without restart.
func NewAuthHandler(cfg func() *config.Config, loginResolver loginResolver, authenticator *Authenticator) *AuthHandler {
func NewAuthHandler(
cfg func() *config.Config,
loginResolver loginResolver,
authenticator *Authenticator,
storeProvider userstore.UserStoreProvider,
) *AuthHandler {
return &AuthHandler{
cfg: cfg,
loginResolver: loginResolver,
authenticator: authenticator,
storeProvider: storeProvider,
}
}
@@ -209,6 +221,65 @@ func (h *AuthHandler) HandleUserByID(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.userDTO(session))
}
// HandleUpdateConfiguration serves POST /Users/Configuration.
func (h *AuthHandler) HandleUpdateConfiguration(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
queryUserID := strings.TrimSpace(newCaseInsensitiveQuery(r.URL.Query()).Get("userId"))
if queryUserID != "" && queryUserID != session.PseudoUserID.String() {
writeError(w, http.StatusForbidden, "Forbidden", "User configuration update forbidden")
return
}
if h.storeProvider == nil {
writeError(w, http.StatusServiceUnavailable, "Unavailable", "User preferences are unavailable")
return
}
var req userConfigurationUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequest", "Invalid request body")
return
}
update := userstore.UpdateProfileInput{}
if req.AudioLanguagePreference != nil {
value := lang.Canonical(*req.AudioLanguagePreference)
update.Language = &value
}
if req.SubtitleLanguagePreference != nil {
value := lang.Canonical(*req.SubtitleLanguagePreference)
update.SubtitleLanguage = &value
}
if req.SubtitleMode != nil {
mode, showForced, hasShowForced, ok := siloSubtitleModeFromJellyfin(*req.SubtitleMode)
if !ok {
writeError(w, http.StatusBadRequest, "BadRequest", "Invalid SubtitleMode")
return
}
update.SubtitleMode = &mode
if hasShowForced {
update.ShowForcedSubtitles = &showForced
}
}
if update.Language == nil && update.SubtitleLanguage == nil && update.SubtitleMode == nil && update.ShowForcedSubtitles == nil {
w.WriteHeader(http.StatusNoContent)
return
}
store, err := h.storeProvider.ForUser(r.Context(), session.StreamAppUserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to access user store")
return
}
if err := store.UpdateProfile(r.Context(), session.ProfileID, update); err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Profile not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleLogout serves POST /Sessions/Logout.
func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
token, ok := ExtractToken(r)
@@ -222,6 +293,7 @@ func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
name := session.Username
profile := h.currentProfile(context.Background(), session)
return userDTOResponse{
ID: session.PseudoUserID.String(),
@@ -234,7 +306,7 @@ func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
IsAdministrator: false,
IsHidden: false,
EnableCollectionManagement: false,
EnableSubtitleManagement: false,
EnableSubtitleManagement: true,
EnableLyricManagement: false,
IsDisabled: false,
EnableUserPreferenceAccess: true,
@@ -264,21 +336,87 @@ func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
PasswordResetProviderID: "silo-local",
SyncPlayAccess: "None",
},
Configuration: userConfigurationResponse{
PlayDefaultAudioTrack: true,
DisplayMissingEpisodes: false,
GroupedFolders: []string{},
SubtitleMode: "Smart",
DisplayCollectionsView: true,
EnableLocalPassword: false,
OrderedViews: []string{},
LatestItemsExcludes: []string{},
MyMediaExcludes: []string{},
HidePlayedInLatest: false,
RememberAudioSelections: true,
RememberSubtitleSelections: true,
EnableNextEpisodeAutoPlay: true,
},
Configuration: h.userConfiguration(profile),
}
}
type userConfigurationUpdateRequest struct {
AudioLanguagePreference *string `json:"AudioLanguagePreference"`
SubtitleLanguagePreference *string `json:"SubtitleLanguagePreference"`
SubtitleMode *string `json:"SubtitleMode"`
}
func (h *AuthHandler) currentProfile(ctx context.Context, session *Session) *userstore.Profile {
if h == nil || h.storeProvider == nil || session == nil || session.ProfileID == "" || session.StreamAppUserID == 0 {
return nil
}
store, err := h.storeProvider.ForUser(ctx, session.StreamAppUserID)
if err != nil {
return nil
}
profile, err := store.GetProfile(ctx, session.ProfileID)
if err != nil {
return nil
}
return profile
}
func (h *AuthHandler) userConfiguration(profile *userstore.Profile) userConfigurationResponse {
subtitleMode := "Smart"
audioLanguage := ""
subtitleLanguage := ""
if profile != nil {
audioLanguage = profile.Language
subtitleLanguage = profile.SubtitleLanguage
subtitleMode = jellyfinSubtitleModeFromProfile(*profile)
}
return userConfigurationResponse{
AudioLanguagePreference: audioLanguage,
PlayDefaultAudioTrack: true,
SubtitleLanguagePreference: subtitleLanguage,
DisplayMissingEpisodes: false,
GroupedFolders: []string{},
SubtitleMode: subtitleMode,
DisplayCollectionsView: true,
EnableLocalPassword: false,
OrderedViews: []string{},
LatestItemsExcludes: []string{},
MyMediaExcludes: []string{},
HidePlayedInLatest: false,
RememberAudioSelections: true,
RememberSubtitleSelections: true,
EnableNextEpisodeAutoPlay: true,
}
}
func jellyfinSubtitleModeFromProfile(profile userstore.Profile) string {
switch strings.ToLower(strings.TrimSpace(profile.SubtitleMode)) {
case "always":
return "Always"
case "off":
if profile.ShowForcedSubtitles {
return "OnlyForced"
}
return "None"
case "", "auto":
return "Smart"
default:
return "Smart"
}
}
func siloSubtitleModeFromJellyfin(mode string) (string, bool, bool, bool) {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "", "default", "smart":
return "auto", false, false, true
case "always":
return "always", false, false, true
case "none":
return "off", false, true, true
case "onlyforced":
return "off", true, true, true
default:
return "", false, false, false
}
}
+126
View File
@@ -0,0 +1,126 @@
package jellycompat
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func TestUserDTOReportsSubtitleManagementAndProfileConfiguration(t *testing.T) {
provider := newProgressCountingStoreProvider()
provider.store.profile = &userstore.Profile{
ID: "profile-1",
Language: "ja",
SubtitleLanguage: "en",
SubtitleMode: "off",
ShowForcedSubtitles: true,
}
handler := NewAuthHandler(func() *config.Config {
return &config.Config{}
}, nil, nil, provider)
session := &Session{
StreamAppUserID: 7,
ProfileID: "profile-1",
PseudoUserID: PseudoUserID(7, "profile-1"),
Username: "user",
}
dto := handler.userDTO(session)
if !dto.Policy.EnableSubtitleManagement {
t.Fatal("EnableSubtitleManagement = false, want true")
}
if dto.Configuration.AudioLanguagePreference != "ja" {
t.Fatalf("AudioLanguagePreference = %q, want ja", dto.Configuration.AudioLanguagePreference)
}
if dto.Configuration.SubtitleLanguagePreference != "en" {
t.Fatalf("SubtitleLanguagePreference = %q, want en", dto.Configuration.SubtitleLanguagePreference)
}
if dto.Configuration.SubtitleMode != "OnlyForced" {
t.Fatalf("SubtitleMode = %q, want OnlyForced", dto.Configuration.SubtitleMode)
}
}
func TestHandleUpdateConfigurationMapsJellyfinSubtitlePreferences(t *testing.T) {
provider := newProgressCountingStoreProvider()
provider.store.profile = &userstore.Profile{ID: "profile-1"}
handler := NewAuthHandler(func() *config.Config {
return &config.Config{}
}, nil, nil, provider)
session := &Session{
StreamAppUserID: 7,
ProfileID: "profile-1",
PseudoUserID: PseudoUserID(7, "profile-1"),
}
body := bytes.NewBufferString(`{
"AudioLanguagePreference": "jpn",
"SubtitleLanguagePreference": "eng",
"SubtitleMode": "OnlyForced"
}`)
req := httptest.NewRequest(
http.MethodPost,
"/Users/Configuration?userId="+session.PseudoUserID.String(),
body,
)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, session))
rec := httptest.NewRecorder()
handler.HandleUpdateConfiguration(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if provider.store.updatedProfileID != "profile-1" {
t.Fatalf("updated profile = %q, want profile-1", provider.store.updatedProfileID)
}
if got := provider.store.profile.Language; got != "ja" {
t.Fatalf("profile language = %q, want ja", got)
}
if got := provider.store.profile.SubtitleLanguage; got != "en" {
t.Fatalf("subtitle language = %q, want en", got)
}
if got := provider.store.profile.SubtitleMode; got != "off" {
t.Fatalf("subtitle mode = %q, want off", got)
}
if !provider.store.profile.ShowForcedSubtitles {
t.Fatal("ShowForcedSubtitles = false, want true")
}
}
func TestHandleUpdateConfigurationRejectsMismatchedUser(t *testing.T) {
provider := newProgressCountingStoreProvider()
handler := NewAuthHandler(func() *config.Config {
return &config.Config{}
}, nil, nil, provider)
session := &Session{
StreamAppUserID: 7,
ProfileID: "profile-1",
PseudoUserID: PseudoUserID(7, "profile-1"),
}
req := httptest.NewRequest(
http.MethodPost,
"/Users/Configuration?userId="+PseudoUserID(8, "other").String(),
bytes.NewBufferString(`{"SubtitleMode":"Always"}`),
)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, session))
rec := httptest.NewRecorder()
handler.HandleUpdateConfiguration(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if provider.store.updatedProfileID != "" {
t.Fatalf("updated profile = %q, want no update", provider.store.updatedProfileID)
}
}
+285
View File
@@ -0,0 +1,285 @@
package jellycompat
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/text/language"
"github.com/Silo-Server/silo-server/internal/catalog"
silolang "github.com/Silo-Server/silo-server/internal/lang"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
// SubtitleHandler serves Jellyfin-compatible remote subtitle search/download
// routes backed by Silo's configured subtitle providers.
type SubtitleHandler struct {
content ContentService
codec *ResourceIDCodec
manager *subtitles.Manager
}
func NewSubtitleHandler(content ContentService, codec *ResourceIDCodec, manager *subtitles.Manager) *SubtitleHandler {
return &SubtitleHandler{content: content, codec: codec, manager: manager}
}
type remoteSubtitleInfoDTO struct {
ThreeLetterISOLanguageName string `json:"ThreeLetterISOLanguageName,omitempty"`
ID string `json:"Id,omitempty"`
ProviderName string `json:"ProviderName,omitempty"`
Name string `json:"Name,omitempty"`
Format string `json:"Format,omitempty"`
DateCreated *time.Time `json:"DateCreated,omitempty"`
CommunityRating *float64 `json:"CommunityRating,omitempty"`
DownloadCount *int `json:"DownloadCount,omitempty"`
IsHashMatch *bool `json:"IsHashMatch,omitempty"`
HearingImpaired *bool `json:"HearingImpaired,omitempty"`
}
type remoteSubtitleID struct {
Provider string `json:"p"`
ID string `json:"id"`
Language string `json:"l"`
ReleaseName string `json:"r,omitempty"`
Format string `json:"f,omitempty"`
Score float64 `json:"s,omitempty"`
HearingImpaired bool `json:"hi,omitempty"`
}
// HandleSearchRemoteSubtitles serves GET /Items/{id}/RemoteSearch/Subtitles/{language}.
func (h *SubtitleHandler) HandleSearchRemoteSubtitles(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
if h == nil || h.manager == nil {
writeJSON(w, http.StatusOK, []remoteSubtitleInfoDTO{})
return
}
detail, version, ok := h.resolveSubtitleItem(w, r, session)
if !ok {
return
}
languageCode := silolang.Canonical(chi.URLParam(r, "language"))
resp, err := h.manager.Search(r.Context(), subtitleSearchRequestForDetail(*detail, version, languageCode))
if err != nil {
writeError(w, http.StatusInternalServerError, "ServerError", "Subtitle search failed")
return
}
results := make([]remoteSubtitleInfoDTO, 0, len(resp.Results))
for _, result := range resp.Results {
id, err := encodeRemoteSubtitleID(remoteSubtitleID{
Provider: result.Provider,
ID: result.ID,
Language: result.Language,
ReleaseName: result.ReleaseName,
Format: string(result.Format),
Score: result.Score,
HearingImpaired: result.HearingImpaired,
})
if err != nil {
continue
}
results = append(results, remoteSubtitleInfoDTO{
ThreeLetterISOLanguageName: compatThreeLetterLanguageName(result.Language),
ID: id,
ProviderName: subtitleProviderLabel(result.Provider),
Name: remoteSubtitleName(result),
Format: string(result.Format),
DateCreated: optionalTime(result.UploadDate),
CommunityRating: optionalFloat(result.Score),
DownloadCount: optionalInt(result.Downloads),
HearingImpaired: boolPtr(result.HearingImpaired),
})
}
writeJSON(w, http.StatusOK, results)
}
// HandleDownloadRemoteSubtitle serves POST /Items/{id}/RemoteSearch/Subtitles/{subtitleId}.
func (h *SubtitleHandler) HandleDownloadRemoteSubtitle(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
if h == nil || h.manager == nil {
writeError(w, http.StatusServiceUnavailable, "Unavailable", "Subtitle providers are unavailable")
return
}
_, version, ok := h.resolveSubtitleItem(w, r, session)
if !ok {
return
}
decoded, err := decodeRemoteSubtitleID(chi.URLParam(r, "subtitleId"))
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequest", "Invalid subtitle id")
return
}
languageCode := silolang.Canonical(decoded.Language)
if languageCode == "" {
languageCode = decoded.Language
}
_, err = h.manager.Download(r.Context(), subtitles.DownloadRequest{
ProviderName: decoded.Provider,
SubtitleID: decoded.ID,
MediaFileID: version.FileID,
UserID: &session.StreamAppUserID,
Language: languageCode,
ReleaseName: decoded.ReleaseName,
Score: decoded.Score,
HearingImpaired: decoded.HearingImpaired,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to download subtitle")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *SubtitleHandler) resolveSubtitleItem(
w http.ResponseWriter,
r *http.Request,
session *Session,
) (*upstreamItemDetail, catalog.FileVersion, bool) {
contentID, err := decodeItemID(h.codec, chi.URLParam(r, "itemId"))
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return nil, catalog.FileVersion{}, false
}
if h.content == nil {
writeError(w, http.StatusServiceUnavailable, "Unavailable", "Content service unavailable")
return nil, catalog.FileVersion{}, false
}
detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return nil, catalog.FileVersion{}, false
}
if detail == nil || len(detail.Versions) == 0 {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return nil, catalog.FileVersion{}, false
}
return detail, detail.Versions[0], true
}
func subtitleSearchRequestForDetail(detail upstreamItemDetail, version catalog.FileVersion, languageCode string) subtitles.SearchRequest {
season, episode := 0, 0
if detail.SeasonNumber != nil {
season = *detail.SeasonNumber
}
if detail.EpisodeNumber != nil {
episode = *detail.EpisodeNumber
}
releaseInfo := subtitles.ParseReleaseInfo(version.FilePath)
req := subtitles.SearchRequest{
IMDbID: detail.ImdbID,
TMDbID: detail.TmdbID,
Title: firstNonEmpty(detail.SeriesTitle, detail.Title),
Year: detail.Year,
Season: season,
Episode: episode,
Filename: firstNonEmpty(version.FileName, filepath.Base(version.FilePath)),
MediaInfo: &subtitles.MediaMatchInfo{
ReleaseGroup: releaseInfo.ReleaseGroup,
Resolution: firstNonEmpty(version.Resolution, releaseInfo.Resolution),
VideoCodec: firstNonEmpty(version.CodecVideo, releaseInfo.VideoCodec),
AudioCodec: firstNonEmpty(version.CodecAudio, releaseInfo.AudioCodec),
Source: releaseInfo.Source,
},
}
if languageCode != "" {
req.Languages = []string{languageCode}
}
return req
}
func encodeRemoteSubtitleID(id remoteSubtitleID) (string, error) {
data, err := json.Marshal(id)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(data), nil
}
func decodeRemoteSubtitleID(value string) (remoteSubtitleID, error) {
var id remoteSubtitleID
data, err := base64.RawURLEncoding.DecodeString(value)
if err != nil {
return id, err
}
if err := json.Unmarshal(data, &id); err != nil {
return id, err
}
if strings.TrimSpace(id.Provider) == "" || strings.TrimSpace(id.ID) == "" {
return id, fmt.Errorf("missing provider or id")
}
return id, nil
}
func remoteSubtitleName(result subtitles.SubtitleResult) string {
name := strings.TrimSpace(result.ReleaseName)
if name == "" {
name = compatLanguageName(result.Language)
}
tags := make([]string, 0, 3)
if result.HearingImpaired {
tags = append(tags, "SDH")
}
if result.Format != "" {
tags = append(tags, subtitleFormatLabel(string(result.Format)))
}
if provider := subtitleProviderLabel(result.Provider); provider != "" {
tags = append(tags, provider)
}
return formatSubtitleLabel(name, tags...)
}
func compatThreeLetterLanguageName(code string) string {
normalized := silolang.Canonical(code)
if normalized == "" {
return strings.ToLower(strings.TrimSpace(code))
}
tag, err := language.Parse(normalized)
if err != nil {
return normalized
}
base, confidence := tag.Base()
if confidence == language.No {
return normalized
}
if iso3 := base.ISO3(); iso3 != "" {
return iso3
}
return base.String()
}
func optionalTime(value time.Time) *time.Time {
if value.IsZero() {
return nil
}
return &value
}
func optionalFloat(value float64) *float64 {
if value == 0 {
return nil
}
return &value
}
func optionalInt(value int) *int {
if value == 0 {
return nil
}
return &value
}
@@ -0,0 +1,196 @@
package jellycompat
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
type recordingSubtitleProvider struct {
name string
results []subtitles.SubtitleResult
data []byte
format subtitles.SubtitleFormat
lastQuery subtitles.SearchRequest
}
func (p *recordingSubtitleProvider) Name() string {
return p.name
}
func (p *recordingSubtitleProvider) Search(_ context.Context, req subtitles.SearchRequest) ([]subtitles.SubtitleResult, error) {
p.lastQuery = req
return p.results, nil
}
func (p *recordingSubtitleProvider) Download(_ context.Context, _ string) ([]byte, subtitles.SubtitleFormat, error) {
return p.data, p.format, nil
}
type fakeSubtitleS3Client struct {
objects map[string][]byte
}
func (s *fakeSubtitleS3Client) PutObject(_ context.Context, _, key string, data []byte) error {
s.objects[key] = append([]byte(nil), data...)
return nil
}
func (s *fakeSubtitleS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
return append([]byte(nil), s.objects[key]...), nil
}
func (s *fakeSubtitleS3Client) DeleteObject(_ context.Context, _, key string) error {
delete(s.objects, key)
return nil
}
func TestHandleSearchRemoteSubtitlesReturnsJellyfinResults(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "episode-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
provider := &recordingSubtitleProvider{
name: "subdl",
results: []subtitles.SubtitleResult{
{
ID: "/download/subtitle.srt",
Provider: "subdl",
Language: "eng",
ReleaseName: "Show.S01E02.1080p.WEB",
Format: subtitles.FormatSRT,
Downloads: 42,
HearingImpaired: true,
},
},
}
repo := fakeSubtitleRepository{downloaded: map[int][]subtitles.DownloadedSubtitle{}, byKey: map[string]*subtitles.DownloadedSubtitle{}}
manager := subtitles.NewManager(repo, &fakeSubtitleS3Client{objects: map[string][]byte{}}, "bucket")
manager.RegisterProvider(provider)
handler := NewSubtitleHandler(&stubContentService{detail: &upstreamItemDetail{
ContentID: contentID,
Title: "Episode title",
SeriesTitle: "Show",
Year: 2026,
ImdbID: "tt1234567",
SeasonNumber: intPtr(1),
EpisodeNumber: intPtr(2),
Versions: []catalog.FileVersion{
{
FileID: 42,
FileName: "Show.S01E02.1080p.WEB.mkv",
FilePath: "/media/Show.S01E02.1080p.WEB.mkv",
Resolution: "1080p",
CodecVideo: "h264",
CodecAudio: "aac",
},
},
}}, codec, manager)
req := newRemoteSubtitleRequest(http.MethodGet, "/Items/"+routeID+"/RemoteSearch/Subtitles/eng", routeID, "eng")
rec := httptest.NewRecorder()
handler.HandleSearchRemoteSubtitles(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var results []remoteSubtitleInfoDTO
if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if len(results) != 1 {
t.Fatalf("results = %d, want 1: %s", len(results), rec.Body.String())
}
if results[0].ThreeLetterISOLanguageName != "eng" {
t.Fatalf("ThreeLetterISOLanguageName = %q, want eng", results[0].ThreeLetterISOLanguageName)
}
if results[0].ID == "" || !strings.Contains(results[0].Name, "SubDL") {
t.Fatalf("unexpected result: %+v", results[0])
}
if got := provider.lastQuery.Languages; len(got) != 1 || got[0] != "en" {
t.Fatalf("search languages = %#v, want [en]", got)
}
if provider.lastQuery.Title != "Show" || provider.lastQuery.Season != 1 || provider.lastQuery.Episode != 2 {
t.Fatalf("search query = %+v", provider.lastQuery)
}
}
func TestHandleDownloadRemoteSubtitleStoresSelectedResult(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
repo := fakeSubtitleRepository{downloaded: map[int][]subtitles.DownloadedSubtitle{}, byKey: map[string]*subtitles.DownloadedSubtitle{}}
s3 := &fakeSubtitleS3Client{objects: map[string][]byte{}}
manager := subtitles.NewManager(repo, s3, "bucket")
manager.RegisterProvider(&recordingSubtitleProvider{
name: "subdl",
data: []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n"),
format: subtitles.FormatSRT,
})
handler := NewSubtitleHandler(&stubContentService{detail: &upstreamItemDetail{
ContentID: contentID,
Title: "Movie",
Year: 2026,
Versions: []catalog.FileVersion{
{FileID: 99, FileName: "Movie.2026.mkv", FilePath: "/media/Movie.2026.mkv"},
},
}}, codec, manager)
subtitleID, err := encodeRemoteSubtitleID(remoteSubtitleID{
Provider: "subdl",
ID: "/download/subtitle.srt",
Language: "eng",
ReleaseName: "Movie.2026",
Format: "srt",
Score: 10,
HearingImpaired: true,
})
if err != nil {
t.Fatalf("encodeRemoteSubtitleID: %v", err)
}
req := newRemoteSubtitleRequest(http.MethodPost, "/Items/"+routeID+"/RemoteSearch/Subtitles/"+subtitleID, routeID, subtitleID)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{
StreamAppUserID: 7,
ProfileID: "profile-1",
}))
rec := httptest.NewRecorder()
handler.HandleDownloadRemoteSubtitle(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
stored := repo.downloaded[99]
if len(stored) != 1 {
t.Fatalf("stored subtitles = %d, want 1", len(stored))
}
if stored[0].Language != "en" || stored[0].Provider != "subdl" || !stored[0].HearingImpaired {
t.Fatalf("stored subtitle = %+v", stored[0])
}
if stored[0].DownloadedBy == nil || *stored[0].DownloadedBy != 7 {
t.Fatalf("DownloadedBy = %v, want 7", stored[0].DownloadedBy)
}
if _, ok := s3.objects[stored[0].S3Key]; !ok {
t.Fatalf("s3 object %q was not written", stored[0].S3Key)
}
}
func newRemoteSubtitleRequest(method, path, itemID, subtitleParam string) *http.Request {
req := httptest.NewRequest(method, path, nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("itemId", itemID)
if method == http.MethodGet {
routeCtx.URLParams.Add("language", subtitleParam)
} else {
routeCtx.URLParams.Add("subtitleId", subtitleParam)
}
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{StreamAppUserID: 7, ProfileID: "profile-1"}))
return req
}
@@ -48,6 +48,7 @@ var compatPathSegments = map[string]string{
"userplayeditems": "UserPlayedItems",
"favoriteitems": "FavoriteItems",
"playeditems": "PlayedItems",
"remotesearch": "RemoteSearch",
"search": "Search",
"hints": "Hints",
"sessions": "Sessions",
@@ -19,10 +19,24 @@ import (
type fakeSubtitleRepository struct {
downloaded map[int][]subtitles.DownloadedSubtitle
byKey map[string]*subtitles.DownloadedSubtitle
}
func (r fakeSubtitleRepository) InsertDownloadedSubtitle(context.Context, *subtitles.DownloadedSubtitle) error {
panic("unused")
func (r fakeSubtitleRepository) InsertDownloadedSubtitle(_ context.Context, sub *subtitles.DownloadedSubtitle) error {
if sub == nil {
return nil
}
if sub.ID == 0 && r.byKey != nil {
sub.ID = len(r.byKey) + 1
}
if r.byKey != nil {
copy := *sub
r.byKey[sub.S3Key] = &copy
}
if r.downloaded != nil {
r.downloaded[sub.MediaFileID] = append(r.downloaded[sub.MediaFileID], *sub)
}
return nil
}
func (r fakeSubtitleRepository) GetDownloadedSubtitle(context.Context, int) (*subtitles.DownloadedSubtitle, error) {
@@ -41,8 +55,11 @@ func (r fakeSubtitleRepository) DeleteDownloadedSubtitle(context.Context, int) (
panic("unused")
}
func (r fakeSubtitleRepository) GetDownloadedSubtitleByS3Key(context.Context, string) (*subtitles.DownloadedSubtitle, error) {
panic("unused")
func (r fakeSubtitleRepository) GetDownloadedSubtitleByS3Key(_ context.Context, s3Key string) (*subtitles.DownloadedSubtitle, error) {
if r.byKey == nil {
return nil, nil
}
return r.byKey[s3Key], nil
}
func (r fakeSubtitleRepository) ListProviderConfigs(context.Context) ([]subtitles.ProviderConfig, error) {
+20 -1
View File
@@ -1,6 +1,7 @@
package jellycompat
import (
"context"
"log/slog"
"net/http"
"os"
@@ -17,6 +18,7 @@ import (
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/recommendations"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/subtitles/providerregistry"
)
// NewRouter builds the Jellyfin-compatibility router.
@@ -62,7 +64,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Use(middleware.Recoverer)
systemHandler := NewSystemHandler(deps.CurrentConfig)
authHandler := NewAuthHandler(deps.CurrentConfig, deps.LoginResolver, deps.Authenticator)
authHandler := NewAuthHandler(deps.CurrentConfig, deps.LoginResolver, deps.Authenticator, deps.UserStoreProvider)
nextUpRepo := catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
var subtitleRepo subtitles.Repository
if deps.SubtitleRepo != nil {
@@ -70,6 +72,19 @@ func NewRouter(deps Dependencies) chi.Router {
} else if deps.DB != nil {
subtitleRepo = subtitles.NewPgRepository(deps.DB, deps.SecretCipher)
}
subtitleManager := deps.SubtitleManager
if subtitleManager == nil && subtitleRepo != nil && deps.S3Client != nil {
var err error
subtitleManager, err = providerregistry.NewManagerFromRepository(
context.Background(),
subtitleRepo,
deps.S3Client,
deps.S3Bucket,
)
if err != nil {
slog.Warn("jellycompat subtitle provider configuration unavailable", "error", err)
}
}
itemsHandler := NewItemsHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.ImageCache, nextUpRepo, deps.BrowseRepo, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.EpisodeRepo, deps.SeasonRepo, deps.AccessFilterFn, subtitleRepo)
itemsHandler.recommender = deps.Recommender
if deps.DB != nil {
@@ -92,6 +107,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config)
playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider)
subtitleHandler := NewSubtitleHandler(deps.ContentService, deps.IDCodec, subtitleManager)
if deps.DB != nil {
playbackHandler.profileStaler = recommendations.NewRepo(deps.DB)
}
@@ -143,6 +159,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Use(RequireSessionOrAPIKeySession(deps.Authenticator, adminAPIKeyAuth))
r.Get("/Users/Me", authHandler.HandleCurrentUser)
r.Get("/Users", authHandler.HandleUsers)
r.Post("/Users/Configuration", authHandler.HandleUpdateConfiguration)
r.Get("/Users/{id}", authHandler.HandleUserByID)
r.Get("/UserViews", itemsHandler.HandleViews)
r.Get("/UserViews/GroupingOptions", itemsHandler.HandleGroupingOptionsStub)
@@ -215,6 +232,8 @@ func NewRouter(deps Dependencies) chi.Router {
r.Post("/Items/{id}/PlaybackInfo", playbackHandler.HandlePlaybackInfo)
r.Get("/Users/{userId}/Items/{id}/PlaybackInfo", playbackHandler.HandlePlaybackInfo)
r.Post("/Users/{userId}/Items/{id}/PlaybackInfo", playbackHandler.HandlePlaybackInfo)
r.Get("/Items/{itemId}/RemoteSearch/Subtitles/{language}", subtitleHandler.HandleSearchRemoteSubtitles)
r.Post("/Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}", subtitleHandler.HandleDownloadRemoteSubtitle)
r.Post("/Sessions/Playing", playbackHandler.HandleSessionPlaying)
r.Post("/Sessions/Playing/Progress", playbackHandler.HandleSessionPlayingProgress)
r.Post("/Sessions/Playing/Stopped", playbackHandler.HandleSessionPlayingStopped)
+4 -3
View File
@@ -86,9 +86,10 @@ type Dependencies struct {
SettingsRepo SettingsReader
// Subtitle support (optional)
SubtitleRepo subtitles.Repository // optional; downloaded subtitle support
S3Client subtitles.S3Client // optional
S3Bucket string // optional
SubtitleRepo subtitles.Repository // optional; downloaded subtitle support
SubtitleManager *subtitles.Manager // optional; remote search/download support
S3Client subtitles.S3Client // optional
S3Bucket string // optional
}
// CurrentConfig returns the live config when hot reload is wired, falling
+3
View File
@@ -81,6 +81,9 @@ type upstreamItemDetail struct {
Genres []string `json:"genres"`
RatingIMDB *float64 `json:"rating_imdb"`
RatingTMDB *float64 `json:"rating_tmdb"`
ImdbID string `json:"imdb_id,omitempty"`
TmdbID string `json:"tmdb_id,omitempty"`
TvdbID string `json:"tvdb_id,omitempty"`
PosterURL string `json:"poster_url"`
BackdropURL string `json:"backdrop_url"`
LogoURL string `json:"logo_url"`
@@ -0,0 +1,61 @@
package providerregistry
import (
"context"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles"
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
)
// NewManagerFromRepository builds a subtitle manager and registers every
// enabled provider config that has the credentials needed for that provider.
func NewManagerFromRepository(
ctx context.Context,
repo subtitles.Repository,
s3 subtitles.S3Client,
bucket string,
) (*subtitles.Manager, error) {
if ctx == nil {
ctx = context.Background()
}
manager := subtitles.NewManager(repo, s3, bucket)
configs, err := repo.ListProviderConfigs(ctx)
if err != nil {
return manager, err
}
RegisterEnabledProviders(manager, configs)
return manager, nil
}
func RegisterEnabledProviders(manager *subtitles.Manager, configs []subtitles.ProviderConfig) {
if manager == nil {
return
}
for _, cfg := range configs {
if !cfg.Enabled {
continue
}
switch cfg.ProviderName {
case "opensubtitles":
if cfg.Username == "" || cfg.Password == "" {
continue
}
manager.RegisterProvider(opensubtitles.New(opensubtitles.Config{
Username: cfg.Username,
Password: cfg.Password,
}))
case "subdl":
if cfg.APIKey == "" {
continue
}
manager.RegisterProvider(subdl.New(subdl.Config{APIKey: cfg.APIKey}))
case "subsource":
if cfg.APIKey == "" {
continue
}
manager.RegisterProvider(subsource.New(subsource.Config{APIKey: cfg.APIKey}))
}
}
}