diff --git a/internal/api/router.go b/internal/api/router.go index 22444c50..f6ed6d1c 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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} diff --git a/internal/jellycompat/auth_api_key_session_test.go b/internal/jellycompat/auth_api_key_session_test.go index faa5d8ec..9c49a90e 100644 --- a/internal/jellycompat/auth_api_key_session_test.go +++ b/internal/jellycompat/auth_api_key_session_test.go @@ -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 { diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index de834d68..7e4d8149 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -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, diff --git a/internal/jellycompat/content_direct_test.go b/internal/jellycompat/content_direct_test.go index 9bdd3cc4..1bc035d6 100644 --- a/internal/jellycompat/content_direct_test.go +++ b/internal/jellycompat/content_direct_test.go @@ -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) { diff --git a/internal/jellycompat/handlers_auth.go b/internal/jellycompat/handlers_auth.go index 6bf24f03..5db07f4f 100644 --- a/internal/jellycompat/handlers_auth.go +++ b/internal/jellycompat/handlers_auth.go @@ -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 } } diff --git a/internal/jellycompat/handlers_auth_test.go b/internal/jellycompat/handlers_auth_test.go new file mode 100644 index 00000000..858fcfd9 --- /dev/null +++ b/internal/jellycompat/handlers_auth_test.go @@ -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) + } +} diff --git a/internal/jellycompat/handlers_subtitles.go b/internal/jellycompat/handlers_subtitles.go new file mode 100644 index 00000000..14c6b483 --- /dev/null +++ b/internal/jellycompat/handlers_subtitles.go @@ -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 +} diff --git a/internal/jellycompat/handlers_subtitles_test.go b/internal/jellycompat/handlers_subtitles_test.go new file mode 100644 index 00000000..183fcf32 --- /dev/null +++ b/internal/jellycompat/handlers_subtitles_test.go @@ -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 +} diff --git a/internal/jellycompat/path_normalization.go b/internal/jellycompat/path_normalization.go index 2c620d7b..d7d9f44b 100644 --- a/internal/jellycompat/path_normalization.go +++ b/internal/jellycompat/path_normalization.go @@ -48,6 +48,7 @@ var compatPathSegments = map[string]string{ "userplayeditems": "UserPlayedItems", "favoriteitems": "FavoriteItems", "playeditems": "PlayedItems", + "remotesearch": "RemoteSearch", "search": "Search", "hints": "Hints", "sessions": "Sessions", diff --git a/internal/jellycompat/playback_subtitle_auth_test.go b/internal/jellycompat/playback_subtitle_auth_test.go index f19f4581..764c6fb6 100644 --- a/internal/jellycompat/playback_subtitle_auth_test.go +++ b/internal/jellycompat/playback_subtitle_auth_test.go @@ -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] = © + } + 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) { diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index 137c355d..371622fa 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -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) diff --git a/internal/jellycompat/server.go b/internal/jellycompat/server.go index be3f6db5..0047d8bc 100644 --- a/internal/jellycompat/server.go +++ b/internal/jellycompat/server.go @@ -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 diff --git a/internal/jellycompat/upstream_types.go b/internal/jellycompat/upstream_types.go index bdff2679..bcff4039 100644 --- a/internal/jellycompat/upstream_types.go +++ b/internal/jellycompat/upstream_types.go @@ -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"` diff --git a/internal/subtitles/providerregistry/registry.go b/internal/subtitles/providerregistry/registry.go new file mode 100644 index 00000000..ae885387 --- /dev/null +++ b/internal/subtitles/providerregistry/registry.go @@ -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})) + } + } +}