From 7e37126f7c2375584b767c813e24774683cd37e1 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Sun, 7 Jun 2026 17:10:10 +0800 Subject: [PATCH] fix(downloads): prevent readding existing media --- internal/handler/downloads.go | 4 + internal/handler/emby.go | 169 ++++++++++++++++++++-- internal/handler/emby_test.go | 147 +++++++++++++++++++ internal/handler/media_test.go | 3 + internal/service/downloads.go | 74 +++++++++- internal/service/downloads_test.go | 72 +++++++++ internal/service/emby_compat.go | 17 ++- internal/service/emby_compat_test.go | 3 + internal/service/local_availability.go | 2 +- internal/service/media_visibility_test.go | 51 +++++++ internal/service/subscription.go | 22 +-- internal/service/visibility.go | 11 +- 12 files changed, 542 insertions(+), 33 deletions(-) diff --git a/internal/handler/downloads.go b/internal/handler/downloads.go index 0a69025..7301a0e 100644 --- a/internal/handler/downloads.go +++ b/internal/handler/downloads.go @@ -74,6 +74,10 @@ func addDownloadHandler(svc *service.Container) gin.HandlerFunc { }, fallbackTitle, "") t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta) if err != nil { + if errors.Is(err, service.ErrMediaAlreadyInLibrary) { + c.JSON(http.StatusConflict, gin.H{"error": "media already exists in library"}) + return + } if errors.Is(err, service.ErrDownloadAlreadyExists) { c.JSON(http.StatusOK, t) return diff --git a/internal/handler/emby.go b/internal/handler/emby.go index e368889..44a1628 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -9,6 +9,7 @@ import ( "errors" "io" "net/http" + "net/url" "strconv" "strings" "time" @@ -81,9 +82,6 @@ func embyWithRequestAddress(c *gin.Context, payload map[string]any) map[string]a out["WanAddress"] = address out["PublishedServerUrl"] = address } - if strings.HasPrefix(strings.ToLower(c.Request.URL.Path), "/emby") { - out["ProductName"] = "Emby Server" - } return out } @@ -388,8 +386,8 @@ func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc { // ─── Items ─────────────────────────────────────────────────────────────────── func parseEmbyItemsParams(c *gin.Context) service.ItemsParams { - limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50")) - offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0")) + limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50")) + offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0")) uid := c.Param("userId") if uid == "" { uid = embyUserID(c) @@ -410,18 +408,27 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams { } return service.ItemsParams{ UserID: uid, - ParentID: c.Query("ParentId"), - IDs: splitOpt(c.Query("Ids")), - SearchTerm: c.Query("SearchTerm"), - IncludeItemTypes: splitOpt(c.Query("IncludeItemTypes")), - Recursive: strings.EqualFold(c.Query("Recursive"), "true"), - SortBy: c.Query("SortBy"), - SortOrder: c.Query("SortOrder"), + ParentID: firstQueryValue(c, "ParentId", "parentId", "parentid"), + IDs: splitOpt(firstQueryValue(c, "Ids", "ids")), + SearchTerm: firstQueryValue(c, "SearchTerm", "searchTerm", "searchterm"), + IncludeItemTypes: splitOpt(firstQueryValue(c, "IncludeItemTypes", "includeItemTypes", "includeitemtypes")), + Recursive: strings.EqualFold(firstQueryValue(c, "Recursive", "recursive"), "true"), + SortBy: firstQueryValue(c, "SortBy", "sortBy", "sortby"), + SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"), Limit: limit, StartIndex: offset, } } +func embyFirstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + func embyItemsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { out, err := svc.Emby.Items(c.Request.Context(), parseEmbyItemsParams(c)) @@ -577,10 +584,98 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc { embyError(c, http.StatusNotFound, "not found") return } + embyAttachRequestTokenToPlaybackInfo(c, out) c.JSON(http.StatusOK, out) } } +func embyAttachRequestTokenToPlaybackInfo(c *gin.Context, out map[string]any) { + token := embyRequestToken(c) + if token == "" || out == nil { + return + } + sources, ok := out["MediaSources"].([]map[string]any) + if !ok { + return + } + for _, source := range sources { + for _, key := range []string{"DirectStreamUrl", "TranscodingUrl"} { + raw, ok := source[key].(string) + if !ok { + continue + } + source[key] = embyAppendAPIKey(raw, token) + } + } +} + +func embyRequestToken(c *gin.Context) string { + if c == nil { + return "" + } + for _, key := range []string{"api_key", "apiKey", "ApiKey", "token"} { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} { + if value := strings.TrimSpace(c.GetHeader(header)); value != "" { + return value + } + } + for _, header := range []string{"Authorization", "X-Emby-Authorization"} { + if token := embyTokenFromAuthHeader(c.GetHeader(header)); token != "" { + return token + } + } + return "" +} + +func embyTokenFromAuthHeader(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + for _, prefix := range []string{"Bearer ", "Emby "} { + if strings.HasPrefix(value, prefix) { + return strings.TrimSpace(strings.TrimPrefix(value, prefix)) + } + } + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser ")) + if !strings.HasPrefix(part, "Token=") { + continue + } + token := strings.TrimSpace(strings.TrimPrefix(part, "Token=")) + return strings.Trim(token, `"`) + } + if strings.Contains(value, "Token=") { + return "" + } + return value +} + +func embyAppendAPIKey(raw, token string) string { + raw = strings.TrimSpace(raw) + token = strings.TrimSpace(token) + if raw == "" || token == "" { + return raw + } + if strings.HasPrefix(raw, "//") { + return raw + } + u, err := url.Parse(raw) + if err != nil || u.IsAbs() { + return raw + } + q := u.Query() + if q.Get("api_key") == "" && q.Get("apiKey") == "" && q.Get("token") == "" { + q.Set("api_key", token) + u.RawQuery = q.Encode() + } + return u.String() +} + // embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口, // 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。 func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc { @@ -599,6 +694,43 @@ func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc { } } +func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid := embyUserID(c) + item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid) + if err != nil || item == nil || svc.Stream == nil { + c.Status(http.StatusNotFound) + return + } + err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id")) + if errors.Is(err, service.ErrTranscodeDisabled) { + c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"}) + return + } + if errors.Is(err, service.ErrTranscodeBusy) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "transcode busy"}) + return + } + if err != nil { + c.Status(http.StatusNotFound) + } + } +} + +func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid := embyUserID(c) + item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid) + if err != nil || item == nil || svc.Stream == nil { + c.Status(http.StatusNotFound) + return + } + if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg")); err != nil { + c.Status(http.StatusNotFound) + } + } +} + // ─── 播放进度 / 收藏 / 已看 ──────────────────────────────────────────────── type embyPlayingReq struct { @@ -831,10 +963,17 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) auth.HEAD("/Videos/:id/stream.:container", embyVideoStreamHandler(svc)) auth.GET("/Videos/:id/original", embyVideoStreamHandler(svc)) auth.GET("/Videos/:id/original.:container", embyVideoStreamHandler(svc)) + auth.GET("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc)) + auth.HEAD("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc)) + auth.GET("/Videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc)) + auth.HEAD("/Videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc)) + auth.GET("/Videos/:id/:seg", embyVideoHLSSegmentHandler(svc)) auth.POST("/Sessions/Playing", embyPlayingProgressHandler(svc)) auth.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc)) auth.POST("/Sessions/Playing/Stopped", embyPlayingProgressHandler(svc)) + auth.POST("/Sessions/Capabilities", embyNoContentHandler(svc)) + auth.POST("/Sessions/Capabilities/Full", embyNoContentHandler(svc)) auth.POST("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, true)) auth.DELETE("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, false)) @@ -842,6 +981,7 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) auth.DELETE("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, false)) auth.GET("/Sessions", embySessionsHandler(svc)) + auth.GET("/System/Configuration", embyServerConfigurationHandler(svc)) auth.GET("/DisplayPreferences/:id", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"Id": c.Param("id"), "CustomPrefs": gin.H{}}) }) @@ -885,6 +1025,11 @@ func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Contain auth.HEAD("/videos/:id/stream.:container", embyVideoStreamHandler(svc)) auth.GET("/videos/:id/original", embyVideoStreamHandler(svc)) auth.GET("/videos/:id/original.:container", embyVideoStreamHandler(svc)) + auth.GET("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc)) + auth.HEAD("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc)) + auth.GET("/videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc)) + auth.HEAD("/videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc)) + auth.GET("/videos/:id/:seg", embyVideoHLSSegmentHandler(svc)) auth.POST("/sessions/playing", embyPlayingProgressHandler(svc)) auth.POST("/sessions/playing/progress", embyPlayingProgressHandler(svc)) diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go index bc0c567..8683f4e 100644 --- a/internal/handler/emby_test.go +++ b/internal/handler/emby_test.go @@ -86,6 +86,75 @@ func TestEmbyWithRequestAddressHonorsForwardedHeaders(t *testing.T) { } } +func TestEmbyPublicSystemInfoLooksLikeModernEmbyServer(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + repos := repository.New(db) + router := gin.New() + registerEmbyRoutes(router, "test-secret", &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), + }) + + req := httptest.NewRequest(http.MethodGet, "/System/Info/Public", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode system info: %v", err) + } + if payload["ProductName"] != "Emby Server" { + t.Fatalf("ProductName = %#v, want Emby Server", payload["ProductName"]) + } + version, _ := payload["Version"].(string) + if !strings.HasPrefix(version, "4.") { + t.Fatalf("Version = %q, want Emby-compatible 4.x", version) + } +} + +func TestEmbyUppercaseSessionCapabilitiesRouteNoContent(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatalf("create user: %v", err) + } + + const secret = "test-secret" + router := gin.New() + registerEmbyRoutes(router, secret, &service.Container{Repo: repos}) + + req := httptest.NewRequest(http.MethodPost, "/Sessions/Capabilities/Full", strings.NewReader(`{}`)) + req.Header.Set("X-Emby-Token", signedTestToken(t, secret)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } +} + func TestEmbyVirtualFoldersRouteReturnsJSON(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -325,6 +394,22 @@ func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) { if _, ok := body["MediaSources"]; !ok { t.Fatalf("missing MediaSources: %#v", body) } + sources, ok := body["MediaSources"].([]any) + if !ok || len(sources) == 0 { + t.Fatalf("unexpected MediaSources: %#v", body["MediaSources"]) + } + source, ok := sources[0].(map[string]any) + if !ok { + t.Fatalf("unexpected MediaSource: %#v", sources[0]) + } + directURL, _ := source["DirectStreamUrl"].(string) + if !strings.Contains(directURL, "api_key=") { + t.Fatalf("DirectStreamUrl should carry api_key for clients that do not repeat auth headers: %#v", source) + } + transcodeURL, _ := source["TranscodingUrl"].(string) + if transcodeURL != "" && !strings.Contains(transcodeURL, "api_key=") { + t.Fatalf("TranscodingUrl should carry api_key: %#v", source) + } } func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) { @@ -386,6 +471,68 @@ func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) { } } +func TestEmbyLowercaseVideoHLSRouteDoesNot404WhenDirectOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatalf("create user: %v", err) + } + dir := t.TempDir() + mediaPath := filepath.Join(dir, "sample.mp4") + if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil { + t.Fatalf("write media: %v", err) + } + lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + if err := db.Create(&model.Media{ + Base: model.Base{ID: "media-1"}, + LibraryID: lib.ID, + Title: "Lowercase HLS", + Path: mediaPath, + Container: "mp4", + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + if err := repos.Setting.Set(t.Context(), service.PlaybackDirectOnlySettingKey, "true"); err != nil { + t.Fatalf("set direct-only: %v", err) + } + + const secret = "test-secret" + router := gin.New() + registerEmbyRoutes(router, secret, &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), + Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil), + }) + + req := httptest.NewRequest(http.MethodGet, "/videos/media-1/master.m3u8?api_key="+signedTestToken(t, secret), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code == http.StatusNotFound { + t.Fatalf("lowercase HLS route should be registered, got 404") + } + if w.Code != http.StatusConflict { + t.Fatalf("direct-only HLS should return 409, got %d body=%s", w.Code, w.Body.String()) + } +} + func signedTestToken(t *testing.T, secret string) string { t.Helper() claims := middleware.Claims{ diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index da8c3ee..8fb4bcc 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -43,6 +43,9 @@ func TestListLibrariesHidesAdultDirectoriesUnlessAdminRequestsAll(t *testing.T) if err := repos.Setting.Set(t.Context(), service.AdultLibraryIDsSettingKey, `["`+adult.ID+`"]`); err != nil { t.Fatal(err) } + if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: safe.ID, Title: "误入普通库的成人条目", Path: "/media/movie/nsfw.mkv", NSFW: true}); err != nil { + t.Fatal(err) + } svc := &service.Container{ Repo: repos, Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos), diff --git a/internal/service/downloads.go b/internal/service/downloads.go index b18ee35..b35c3e3 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -55,14 +55,24 @@ var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`) // successful dedup hit, not as a retryable enqueue failure. var ErrDownloadAlreadyExists = errors.New("download already exists") +// ErrMediaAlreadyInLibrary tells callers that the requested movie/episode is +// already present in the scanned media library and must not be sent to the +// downloader again. +var ErrMediaAlreadyInLibrary = errors.New("media already exists in library") + +func IsDownloadDedupError(err error) bool { + return errors.Is(err, ErrDownloadAlreadyExists) || errors.Is(err, ErrMediaAlreadyInLibrary) +} + // DownloadTaskMeta carries public display metadata for a download. It is // deliberately separate from the private torrent URL so API responses never // need to expose tracker tokens. type DownloadTaskMeta struct { - Title string - PosterURL string - BackdropURL string - Overview string + Title string + PosterURL string + BackdropURL string + Overview string + AllowExistingLibrary bool } type DownloadTaskView struct { @@ -196,6 +206,9 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt title = publicDownloadTitle(urlStr) meta.Title = title } + if !meta.AllowExistingLibrary && d.localMediaAlreadyExists(ctx, title) { + return nil, ErrMediaAlreadyInLibrary + } if existing, ok := d.findExistingDownloadTask(ctx, title); ok { return existing, ErrDownloadAlreadyExists } @@ -229,6 +242,59 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt return d.createTask(ctx, userID, urlStr, savePath, meta) } +func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool { + if d == nil || d.repo == nil || d.repo.DB == nil { + return false + } + if !d.repo.DB.Migrator().HasTable(&model.Media{}) { + return false + } + query := availabilityQuery(title, "") + if query == "" { + return false + } + like := "%" + query + "%" + var rows []model.Media + if err := d.repo.DB.WithContext(ctx). + Where("title LIKE ? OR original_name LIKE ? OR path LIKE ?", like, like, like). + Order("season_num asc, episode_num asc, created_at desc"). + Limit(200). + Find(&rows).Error; err != nil || len(rows) == 0 { + return false + } + + wantSeason, wantEpisode := ParseEpisode(title) + if wantSeason <= 0 { + wantSeason = 1 + } + if wantEpisode <= 0 { + return true + } + for _, row := range rows { + rowSeason := row.SeasonNum + rowEpisode := row.EpisodeNum + if rowSeason <= 0 || rowEpisode <= 0 { + parsedSeason, parsedEpisode := ParseEpisode(row.Path) + if rowSeason <= 0 { + rowSeason = parsedSeason + } + if rowEpisode <= 0 { + rowEpisode = parsedEpisode + } + } + if rowSeason <= 0 { + rowSeason = 1 + } + if rowEpisode == wantEpisode && rowSeason == wantSeason { + return true + } + if rowEpisode <= 0 && isSeriesPackTitle(row.Title+" "+row.OriginalName+" "+row.Path) { + return true + } + } + return false +} + func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title string) (*model.DownloadTask, bool) { key := downloadTaskIdentityKey(title) if key == "" || d == nil || d.repo == nil || d.repo.Download == nil { diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go index c7f475f..2cd9cae 100644 --- a/internal/service/downloads_test.go +++ b/internal/service/downloads_test.go @@ -105,6 +105,78 @@ func TestAddDownloadWithMetaSkipsExistingTaskBeforeQBAdd(t *testing.T) { } } +func TestAddDownloadWithMetaSkipsExistingLocalMovieBeforeQBAdd(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Media{}, &model.DownloadTask{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + if err := db.Create(&model.Media{ + Title: "Inception", + Path: "/media/movies/Inception (2010)/Inception (2010).mkv", + }).Error; err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Inception+2010+1080p", "/downloads", DownloadTaskMeta{ + Title: "Inception 2010 1080p WEB-DL", + }) + if !errors.Is(err, ErrMediaAlreadyInLibrary) { + t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) + } + if task != nil { + t.Fatalf("task = %#v, want nil because local media already exists", task) + } + rows, err := repos.Download.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("download rows = %d, want 0", len(rows)) + } +} + +func TestAddDownloadWithMetaSkipsExistingLocalEpisodeBeforeQBAdd(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Media{}, &model.DownloadTask{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + if err := db.Create(&model.Media{ + Title: "Some Show", + Path: "/media/tv/Some Show/Season 01/Some Show - S01E01.mkv", + SeasonNum: 1, + EpisodeNum: 1, + }).Error; err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Some+Show+S01E01", "/downloads", DownloadTaskMeta{ + Title: "Some Show S01E01 2160p WEB-DL", + }) + if !errors.Is(err, ErrMediaAlreadyInLibrary) { + t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) + } + if task != nil { + t.Fatalf("task = %#v, want nil because local episode already exists", task) + } + rows, err := repos.Download.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("download rows = %d, want 0", len(rows)) + } +} + func TestReloadConfigDoesNotFallbackToLegacyAfterClientDeleted(t *testing.T) { var addCalls int32 qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index b1e6215..b4d5761 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -36,6 +36,11 @@ import ( // 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。 const embyServerID = "mediastation-go-001" +// embyCompatVersion deliberately reports an Emby 4.x server. Official Emby +// clients reject Jellyfin-style 10.x identities as unsupported/too old during +// the login handshake, even when the API shape is compatible enough for us. +const embyCompatVersion = "4.8.10.0" + // PlaybackDirectOnlySettingKey 控制「客户端直连解码」模式:开启后宿主机 // 不再提供转码,所有播放交给第三方客户端本地解码(direct play / 302 直链), // 以释放宿主机 CPU 资源。 @@ -61,8 +66,9 @@ func (e *EmbyService) SystemInfo() map[string]any { "Id": embyServerID, "ServerId": embyServerID, "ServerName": "MediaStationGo", - "Version": "10.8.13", - "ProductName": "Jellyfin Server", + "Version": embyCompatVersion, + "ServerVersion": embyCompatVersion, + "ProductName": "Emby Server", "OperatingSystem": "Windows", "Architecture": "X64", "LocalAddress": "", @@ -89,8 +95,9 @@ func (e *EmbyService) SystemInfoPublic() map[string]any { "Id": embyServerID, "ServerId": embyServerID, "ServerName": "MediaStationGo", - "Version": "10.8.13", - "ProductName": "Jellyfin Server", + "Version": embyCompatVersion, + "ServerVersion": embyCompatVersion, + "ProductName": "Emby Server", "OperatingSystem": "Windows", "LocalAddress": "", "WanAddress": "", @@ -1220,7 +1227,7 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m // 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连, // 宿主机不参与转码。 if !directOnly { - src["TranscodingUrl"] = "/Videos/" + m.ID + "/stream" + src["TranscodingUrl"] = "/Videos/" + m.ID + "/master.m3u8" } } if strings.TrimSpace(m.STRMURL) != "" { diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 8676119..3085f22 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -225,6 +225,9 @@ func TestEmbyPlaybackInfoRespectsDirectPlayOnly(t *testing.T) { if _, ok := src["TranscodingUrl"]; !ok { t.Fatalf("expected TranscodingUrl present by default: %#v", src) } + if src["TranscodingUrl"] != "/Videos/m-1/master.m3u8" { + t.Fatalf("expected HLS TranscodingUrl by default, got %#v", src["TranscodingUrl"]) + } // 开启「客户端直连解码」:不再下发转码能力 / TranscodingUrl,仍保留 DirectStream。 if err := svc.repo.Setting.Set(t.Context(), PlaybackDirectOnlySettingKey, "true"); err != nil { diff --git a/internal/service/local_availability.go b/internal/service/local_availability.go index 269d85e..f9df5b1 100644 --- a/internal/service/local_availability.go +++ b/internal/service/local_availability.go @@ -11,7 +11,7 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/repository" ) -var availabilityNoiseRE = regexp.MustCompile(`(?i)(自动订阅|订阅|全集|合集|complete|batch|season\s*\d+|s\d{1,2}|s\d{1,2}e\d{1,3}|第\s*\d+\s*季|第\s*\d+\s*[集话話期]|\(\d{4}\)|\b\d{4}\b|2160p|1080p|720p|4k|uhd|bluray|blu-ray|web-?dl|hdtv|remux|x26[45]|h\.?26[45]|hevc|avc|hdr10?\+?|dovi|dv|atmos|aac|ddp?5\.1|truehd|flac)`) +var availabilityNoiseRE = regexp.MustCompile(`(?i)(自动订阅|订阅|全集|合集|complete|batch|s\d{1,2}e\d{1,3}|season\s*\d+|s\d{1,2}|第\s*\d+\s*季|第\s*\d+\s*[集话話期]|\(\d{4}\)|\b\d{4}\b|2160p|1080p|720p|4k|uhd|bluray|blu-ray|web-?dl|hdtv|remux|x26[45]|h\.?26[45]|hevc|avc|hdr10?\+?|dovi|dv|atmos|aac|ddp?5\.1|truehd|flac)`) type LocalAvailability struct { DownloadedEpisodes int diff --git a/internal/service/media_visibility_test.go b/internal/service/media_visibility_test.go index 08dd2ff..4981d47 100644 --- a/internal/service/media_visibility_test.go +++ b/internal/service/media_visibility_test.go @@ -89,6 +89,57 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) { } } +func TestConfiguredAdultLibrariesDoNotHideSafeLibraryWithNSFWItems(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + + safe := model.Library{Name: "电影", Path: "/media/movie", Type: "movie", Enabled: true} + adult := model.Library{Name: "9KG", Path: "/media/9KG", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &safe); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), &adult); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), AdultLibraryIDsSettingKey, `["`+adult.ID+`"]`); err != nil { + t.Fatal(err) + } + if err := db.Create(&[]model.Media{ + {LibraryID: safe.ID, Title: "普通电影", Path: "/media/movie/a.mkv"}, + {LibraryID: safe.ID, Title: "误入普通库的成人条目", Path: "/media/movie/b.mkv", NSFW: true}, + {LibraryID: adult.ID, Title: "成人影片", Path: "/media/9KG/c.mkv"}, + }).Error; err != nil { + t.Fatal(err) + } + viewer := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user", HideAdult: true} + if err := repos.User.Create(t.Context(), viewer); err != nil { + t.Fatal(err) + } + + visibility := UserDefaultMediaVisibility(t.Context(), repos, viewer.ID) + if LibraryVisibleForUser(t.Context(), repos, safe, visibility) != true { + t.Fatal("configured adult libraries should not hide a safe library just because it contains NSFW items") + } + if LibraryVisibleForUser(t.Context(), repos, adult, visibility) != false { + t.Fatal("configured adult library should be hidden when the user hides adult content") + } + + items, err := NewMediaService(&config.Config{}, zap.NewNop(), repos). + SearchMediaVisible(t.Context(), "电影", 20, visibility) + if err != nil { + t.Fatal(err) + } + if got := sortedMediaTitles(items); !slices.Equal(got, []string{"普通电影"}) { + t.Fatalf("safe library should stay visible while NSFW media is filtered, got %#v", got) + } +} + func sortedMediaTitles(rows []model.Media) []string { out := make([]string, 0, len(rows)) for _, row := range rows { diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 62a45c2..6172fb4 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -231,12 +231,13 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio continue } if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, download, savePath, DownloadTaskMeta{ - Title: firstNonEmpty(item.Title, sub.Name), - PosterURL: sub.PosterURL, - BackdropURL: sub.BackdropURL, - Overview: sub.Overview, + Title: firstNonEmpty(item.Title, sub.Name), + PosterURL: sub.PosterURL, + BackdropURL: sub.BackdropURL, + Overview: sub.Overview, + AllowExistingLibrary: sub.WashEnabled, }); err != nil { - if errors.Is(err, ErrDownloadAlreadyExists) { + if IsDownloadDedupError(err) { if washOff { addAvailabilityTitle(item.Title, availQuery, &avail) } @@ -328,12 +329,13 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs continue } if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, realURL, savePath, DownloadTaskMeta{ - Title: firstNonEmpty(item.Title, sub.Name), - PosterURL: sub.PosterURL, - BackdropURL: sub.BackdropURL, - Overview: sub.Overview, + Title: firstNonEmpty(item.Title, sub.Name), + PosterURL: sub.PosterURL, + BackdropURL: sub.BackdropURL, + Overview: sub.Overview, + AllowExistingLibrary: sub.WashEnabled, }); err != nil { - if errors.Is(err, ErrDownloadAlreadyExists) { + if IsDownloadDedupError(err) { seen = append(seen, candidate.GUID) seenSet[candidate.GUID] = struct{}{} continue diff --git a/internal/service/visibility.go b/internal/service/visibility.go index 1fea1e2..b805cae 100644 --- a/internal/service/visibility.go +++ b/internal/service/visibility.go @@ -105,11 +105,20 @@ func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib if visibility.IncludeNSFW { return true } - for _, id := range visibility.HiddenLibraryIDs { + hiddenLibraryIDs := visibility.HiddenLibraryIDs + configuredAdultLibraryIDs := AdultLibraryIDs(ctx, repo) + hasConfiguredAdultLibraries := len(hiddenLibraryIDs) > 0 || len(configuredAdultLibraryIDs) > 0 + if len(hiddenLibraryIDs) == 0 { + hiddenLibraryIDs = configuredAdultLibraryIDs + } + for _, id := range hiddenLibraryIDs { if id == lib.ID { return false } } + if hasConfiguredAdultLibraries { + return true + } if LibraryLooksAdult(lib) { return false }