mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
fix(downloads): prevent readding existing media
This commit is contained in:
@@ -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
|
||||
|
||||
+157
-12
@@ -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))
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user