mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 22:36:37 +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, "")
|
}, fallbackTitle, "")
|
||||||
t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta)
|
t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta)
|
||||||
if err != nil {
|
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) {
|
if errors.Is(err, service.ErrDownloadAlreadyExists) {
|
||||||
c.JSON(http.StatusOK, t)
|
c.JSON(http.StatusOK, t)
|
||||||
return
|
return
|
||||||
|
|||||||
+157
-12
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -81,9 +82,6 @@ func embyWithRequestAddress(c *gin.Context, payload map[string]any) map[string]a
|
|||||||
out["WanAddress"] = address
|
out["WanAddress"] = address
|
||||||
out["PublishedServerUrl"] = address
|
out["PublishedServerUrl"] = address
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(strings.ToLower(c.Request.URL.Path), "/emby") {
|
|
||||||
out["ProductName"] = "Emby Server"
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,8 +386,8 @@ func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// ─── Items ───────────────────────────────────────────────────────────────────
|
// ─── Items ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50"))
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
||||||
offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0"))
|
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||||
uid := c.Param("userId")
|
uid := c.Param("userId")
|
||||||
if uid == "" {
|
if uid == "" {
|
||||||
uid = embyUserID(c)
|
uid = embyUserID(c)
|
||||||
@@ -410,18 +408,27 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
|||||||
}
|
}
|
||||||
return service.ItemsParams{
|
return service.ItemsParams{
|
||||||
UserID: uid,
|
UserID: uid,
|
||||||
ParentID: c.Query("ParentId"),
|
ParentID: firstQueryValue(c, "ParentId", "parentId", "parentid"),
|
||||||
IDs: splitOpt(c.Query("Ids")),
|
IDs: splitOpt(firstQueryValue(c, "Ids", "ids")),
|
||||||
SearchTerm: c.Query("SearchTerm"),
|
SearchTerm: firstQueryValue(c, "SearchTerm", "searchTerm", "searchterm"),
|
||||||
IncludeItemTypes: splitOpt(c.Query("IncludeItemTypes")),
|
IncludeItemTypes: splitOpt(firstQueryValue(c, "IncludeItemTypes", "includeItemTypes", "includeitemtypes")),
|
||||||
Recursive: strings.EqualFold(c.Query("Recursive"), "true"),
|
Recursive: strings.EqualFold(firstQueryValue(c, "Recursive", "recursive"), "true"),
|
||||||
SortBy: c.Query("SortBy"),
|
SortBy: firstQueryValue(c, "SortBy", "sortBy", "sortby"),
|
||||||
SortOrder: c.Query("SortOrder"),
|
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
StartIndex: offset,
|
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 {
|
func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
out, err := svc.Emby.Items(c.Request.Context(), parseEmbyItemsParams(c))
|
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")
|
embyError(c, http.StatusNotFound, "not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
embyAttachRequestTokenToPlaybackInfo(c, out)
|
||||||
c.JSON(http.StatusOK, 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 的入口,
|
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
|
||||||
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
|
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
|
||||||
func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc {
|
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 {
|
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.HEAD("/Videos/:id/stream.:container", embyVideoStreamHandler(svc))
|
||||||
auth.GET("/Videos/:id/original", embyVideoStreamHandler(svc))
|
auth.GET("/Videos/:id/original", embyVideoStreamHandler(svc))
|
||||||
auth.GET("/Videos/:id/original.:container", 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", embyPlayingProgressHandler(svc))
|
||||||
auth.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
|
auth.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
|
||||||
auth.POST("/Sessions/Playing/Stopped", 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.POST("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, true))
|
||||||
auth.DELETE("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, false))
|
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.DELETE("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, false))
|
||||||
|
|
||||||
auth.GET("/Sessions", embySessionsHandler(svc))
|
auth.GET("/Sessions", embySessionsHandler(svc))
|
||||||
|
auth.GET("/System/Configuration", embyServerConfigurationHandler(svc))
|
||||||
auth.GET("/DisplayPreferences/:id", func(c *gin.Context) {
|
auth.GET("/DisplayPreferences/:id", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"Id": c.Param("id"), "CustomPrefs": gin.H{}})
|
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.HEAD("/videos/:id/stream.:container", embyVideoStreamHandler(svc))
|
||||||
auth.GET("/videos/:id/original", embyVideoStreamHandler(svc))
|
auth.GET("/videos/:id/original", embyVideoStreamHandler(svc))
|
||||||
auth.GET("/videos/:id/original.:container", 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", embyPlayingProgressHandler(svc))
|
||||||
auth.POST("/sessions/playing/progress", 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) {
|
func TestEmbyVirtualFoldersRouteReturnsJSON(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
@@ -325,6 +394,22 @@ func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
|
|||||||
if _, ok := body["MediaSources"]; !ok {
|
if _, ok := body["MediaSources"]; !ok {
|
||||||
t.Fatalf("missing MediaSources: %#v", body)
|
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) {
|
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 {
|
func signedTestToken(t *testing.T, secret string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
claims := middleware.Claims{
|
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 {
|
if err := repos.Setting.Set(t.Context(), service.AdultLibraryIDsSettingKey, `["`+adult.ID+`"]`); err != nil {
|
||||||
t.Fatal(err)
|
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{
|
svc := &service.Container{
|
||||||
Repo: repos,
|
Repo: repos,
|
||||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), 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.
|
// successful dedup hit, not as a retryable enqueue failure.
|
||||||
var ErrDownloadAlreadyExists = errors.New("download already exists")
|
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
|
// DownloadTaskMeta carries public display metadata for a download. It is
|
||||||
// deliberately separate from the private torrent URL so API responses never
|
// deliberately separate from the private torrent URL so API responses never
|
||||||
// need to expose tracker tokens.
|
// need to expose tracker tokens.
|
||||||
type DownloadTaskMeta struct {
|
type DownloadTaskMeta struct {
|
||||||
Title string
|
Title string
|
||||||
PosterURL string
|
PosterURL string
|
||||||
BackdropURL string
|
BackdropURL string
|
||||||
Overview string
|
Overview string
|
||||||
|
AllowExistingLibrary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadTaskView struct {
|
type DownloadTaskView struct {
|
||||||
@@ -196,6 +206,9 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
|
|||||||
title = publicDownloadTitle(urlStr)
|
title = publicDownloadTitle(urlStr)
|
||||||
meta.Title = title
|
meta.Title = title
|
||||||
}
|
}
|
||||||
|
if !meta.AllowExistingLibrary && d.localMediaAlreadyExists(ctx, title) {
|
||||||
|
return nil, ErrMediaAlreadyInLibrary
|
||||||
|
}
|
||||||
if existing, ok := d.findExistingDownloadTask(ctx, title); ok {
|
if existing, ok := d.findExistingDownloadTask(ctx, title); ok {
|
||||||
return existing, ErrDownloadAlreadyExists
|
return existing, ErrDownloadAlreadyExists
|
||||||
}
|
}
|
||||||
@@ -229,6 +242,59 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
|
|||||||
return d.createTask(ctx, userID, urlStr, savePath, meta)
|
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) {
|
func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title string) (*model.DownloadTask, bool) {
|
||||||
key := downloadTaskIdentityKey(title)
|
key := downloadTaskIdentityKey(title)
|
||||||
if key == "" || d == nil || d.repo == nil || d.repo.Download == nil {
|
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) {
|
func TestReloadConfigDoesNotFallbackToLegacyAfterClientDeleted(t *testing.T) {
|
||||||
var addCalls int32
|
var addCalls int32
|
||||||
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ import (
|
|||||||
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
|
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
|
||||||
const embyServerID = "mediastation-go-001"
|
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 控制「客户端直连解码」模式:开启后宿主机
|
// PlaybackDirectOnlySettingKey 控制「客户端直连解码」模式:开启后宿主机
|
||||||
// 不再提供转码,所有播放交给第三方客户端本地解码(direct play / 302 直链),
|
// 不再提供转码,所有播放交给第三方客户端本地解码(direct play / 302 直链),
|
||||||
// 以释放宿主机 CPU 资源。
|
// 以释放宿主机 CPU 资源。
|
||||||
@@ -61,8 +66,9 @@ func (e *EmbyService) SystemInfo() map[string]any {
|
|||||||
"Id": embyServerID,
|
"Id": embyServerID,
|
||||||
"ServerId": embyServerID,
|
"ServerId": embyServerID,
|
||||||
"ServerName": "MediaStationGo",
|
"ServerName": "MediaStationGo",
|
||||||
"Version": "10.8.13",
|
"Version": embyCompatVersion,
|
||||||
"ProductName": "Jellyfin Server",
|
"ServerVersion": embyCompatVersion,
|
||||||
|
"ProductName": "Emby Server",
|
||||||
"OperatingSystem": "Windows",
|
"OperatingSystem": "Windows",
|
||||||
"Architecture": "X64",
|
"Architecture": "X64",
|
||||||
"LocalAddress": "",
|
"LocalAddress": "",
|
||||||
@@ -89,8 +95,9 @@ func (e *EmbyService) SystemInfoPublic() map[string]any {
|
|||||||
"Id": embyServerID,
|
"Id": embyServerID,
|
||||||
"ServerId": embyServerID,
|
"ServerId": embyServerID,
|
||||||
"ServerName": "MediaStationGo",
|
"ServerName": "MediaStationGo",
|
||||||
"Version": "10.8.13",
|
"Version": embyCompatVersion,
|
||||||
"ProductName": "Jellyfin Server",
|
"ServerVersion": embyCompatVersion,
|
||||||
|
"ProductName": "Emby Server",
|
||||||
"OperatingSystem": "Windows",
|
"OperatingSystem": "Windows",
|
||||||
"LocalAddress": "",
|
"LocalAddress": "",
|
||||||
"WanAddress": "",
|
"WanAddress": "",
|
||||||
@@ -1220,7 +1227,7 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
|
|||||||
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
||||||
// 宿主机不参与转码。
|
// 宿主机不参与转码。
|
||||||
if !directOnly {
|
if !directOnly {
|
||||||
src["TranscodingUrl"] = "/Videos/" + m.ID + "/stream"
|
src["TranscodingUrl"] = "/Videos/" + m.ID + "/master.m3u8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(m.STRMURL) != "" {
|
if strings.TrimSpace(m.STRMURL) != "" {
|
||||||
|
|||||||
@@ -225,6 +225,9 @@ func TestEmbyPlaybackInfoRespectsDirectPlayOnly(t *testing.T) {
|
|||||||
if _, ok := src["TranscodingUrl"]; !ok {
|
if _, ok := src["TranscodingUrl"]; !ok {
|
||||||
t.Fatalf("expected TranscodingUrl present by default: %#v", src)
|
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。
|
// 开启「客户端直连解码」:不再下发转码能力 / TranscodingUrl,仍保留 DirectStream。
|
||||||
if err := svc.repo.Setting.Set(t.Context(), PlaybackDirectOnlySettingKey, "true"); err != nil {
|
if err := svc.repo.Setting.Set(t.Context(), PlaybackDirectOnlySettingKey, "true"); err != nil {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
"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 {
|
type LocalAvailability struct {
|
||||||
DownloadedEpisodes int
|
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 {
|
func sortedMediaTitles(rows []model.Media) []string {
|
||||||
out := make([]string, 0, len(rows))
|
out := make([]string, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
|
|||||||
@@ -231,12 +231,13 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, download, savePath, DownloadTaskMeta{
|
if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, download, savePath, DownloadTaskMeta{
|
||||||
Title: firstNonEmpty(item.Title, sub.Name),
|
Title: firstNonEmpty(item.Title, sub.Name),
|
||||||
PosterURL: sub.PosterURL,
|
PosterURL: sub.PosterURL,
|
||||||
BackdropURL: sub.BackdropURL,
|
BackdropURL: sub.BackdropURL,
|
||||||
Overview: sub.Overview,
|
Overview: sub.Overview,
|
||||||
|
AllowExistingLibrary: sub.WashEnabled,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if errors.Is(err, ErrDownloadAlreadyExists) {
|
if IsDownloadDedupError(err) {
|
||||||
if washOff {
|
if washOff {
|
||||||
addAvailabilityTitle(item.Title, availQuery, &avail)
|
addAvailabilityTitle(item.Title, availQuery, &avail)
|
||||||
}
|
}
|
||||||
@@ -328,12 +329,13 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, realURL, savePath, DownloadTaskMeta{
|
if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, realURL, savePath, DownloadTaskMeta{
|
||||||
Title: firstNonEmpty(item.Title, sub.Name),
|
Title: firstNonEmpty(item.Title, sub.Name),
|
||||||
PosterURL: sub.PosterURL,
|
PosterURL: sub.PosterURL,
|
||||||
BackdropURL: sub.BackdropURL,
|
BackdropURL: sub.BackdropURL,
|
||||||
Overview: sub.Overview,
|
Overview: sub.Overview,
|
||||||
|
AllowExistingLibrary: sub.WashEnabled,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if errors.Is(err, ErrDownloadAlreadyExists) {
|
if IsDownloadDedupError(err) {
|
||||||
seen = append(seen, candidate.GUID)
|
seen = append(seen, candidate.GUID)
|
||||||
seenSet[candidate.GUID] = struct{}{}
|
seenSet[candidate.GUID] = struct{}{}
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -105,11 +105,20 @@ func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib
|
|||||||
if visibility.IncludeNSFW {
|
if visibility.IncludeNSFW {
|
||||||
return true
|
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 {
|
if id == lib.ID {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if hasConfiguredAdultLibraries {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if LibraryLooksAdult(lib) {
|
if LibraryLooksAdult(lib) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user