fix(jellycompat): expose subtitle management
This commit is contained in:
+10
-31
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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] = ©
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user