diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go index 1ce23c5..17aed6d 100644 --- a/internal/handler/cloud.go +++ b/internal/handler/cloud.go @@ -330,6 +330,7 @@ func serveCloudResolvedLink(svc *service.Container, c *gin.Context, typ, ref str } if !link.Proxy { // Pure offload: send the client straight to the cloud CDN. + setRedirectNoStoreHeaders(c) logCloudPlayback(svc, "cloud playback redirect", append(cloudPlaybackLogFields(typ, ref, link, resolveDur), zap.String("mode", "redirect"), diff --git a/internal/handler/emby.go b/internal/handler/emby.go index 0df426a..823e873 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -1059,6 +1059,15 @@ func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.Handle c.Status(http.StatusNotFound) return } + if embyShouldRedirectVideoStreamToSTRM(c, svc, c.Param("id"), cloudMode) { + target := "/api/stream/" + url.PathEscape(strings.TrimSpace(c.Param("id"))) + if token := embyPlaybackRedirectToken(c, svc); token != "" { + target = embyAppendAPIKey(target, token) + } + setRedirectNoStoreHeaders(c) + c.Redirect(http.StatusFound, absoluteRequestURL(c, target)) + return + } // 直接调用 Stream service 写入 response。 // 此前这里把所有错误一律吞成 404:云盘 Cookie 过期、直链解析失败、 // STRM 播放被关闭……在第三方播放器上全部表现为「404 不存在」, @@ -1080,6 +1089,43 @@ func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.Handle } } +func embyPlaybackRedirectToken(c *gin.Context, svc *service.Container) string { + if token := embyRequestToken(c); token != "" { + return token + } + if c == nil || svc == nil || svc.Auth == nil || svc.Repo == nil || svc.Repo.User == nil { + return "" + } + uid := embyUserID(c) + if uid == "" { + return "" + } + u, err := svc.Repo.User.FindByID(c.Request.Context(), uid) + if err != nil || u == nil { + return "" + } + token, err := svc.Auth.IssueEmbyToken(u) + if err != nil { + return "" + } + return token +} + +func embyShouldRedirectVideoStreamToSTRM(c *gin.Context, svc *service.Container, mediaID, cloudMode string) bool { + if c == nil || svc == nil || svc.Repo == nil || svc.Repo.Media == nil || cloudMode != service.CloudPlaybackModeRedirectProxy { + return false + } + settings := service.CloudPlaybackSettings(c.Request.Context(), svc.Repo) + if settings.PreferredMode != service.CloudPlaybackModeSTRM || !settings.STRMEnabled { + return false + } + m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID) + if err != nil || m == nil { + return false + } + return strings.TrimSpace(m.STRMURL) != "" +} + func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid := embyUserID(c) diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go index 60c2de4..4067659 100644 --- a/internal/handler/emby_test.go +++ b/internal/handler/emby_test.go @@ -1063,6 +1063,152 @@ func TestEmbyItemsTokenizesEmbeddedCloudMediaSources(t *testing.T) { } } +func TestEmbyVideoStreamUsesSTRMWhenRedirectProxyDisabled(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.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil { + t.Fatalf("set cloud playback mode: %v", err) + } + if err := repos.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "true"); err != nil { + t.Fatalf("enable strm playback: %v", err) + } + if err := repos.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil { + t.Fatalf("disable redirect playback: %v", err) + } + 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) + } + lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", 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: "cloud-1"}, + LibraryID: lib.ID, + Title: "Cloud Movie", + Path: "cloud://openlist/Movies/Movie.mkv", + STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv", + Container: "mkv", + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + const secret = "test-secret" + router := gin.New() + cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}} + registerEmbyRoutes(router, secret, &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), + Stream: service.NewStreamService(cfg, zap.NewNop(), repos, nil), + }) + + token := signedTestToken(t, secret) + req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream?api_key="+token, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "/api/stream/cloud-1") || !strings.Contains(loc, "api_key=") { + t.Fatalf("STRM mode should redirect /Videos fallback to tokenized /api/stream, got %q", loc) + } + if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") { + t.Fatalf("STRM fallback redirect Cache-Control = %q, want no-store", got) + } + if strings.Contains(loc, "/api/cloud/play/") { + t.Fatalf("STRM mode should not expose cloud play directly from /Videos fallback: %q", loc) + } +} + +func TestEmbyVideoStreamIssuesTokenForSessionFallbackSTRMRedirect(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.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil { + t.Fatalf("set cloud playback mode: %v", err) + } + if err := repos.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "true"); err != nil { + t.Fatalf("enable strm playback: %v", err) + } + if err := repos.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil { + t.Fatalf("disable redirect playback: %v", err) + } + user := model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + } + if err := repos.User.Create(t.Context(), &user); err != nil { + t.Fatalf("create user: %v", err) + } + lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", 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: "cloud-1"}, + LibraryID: lib.ID, + Title: "Cloud Movie", + Path: "cloud://openlist/Movies/Movie.mkv", + STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv", + Container: "mkv", + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + const secret = "test-secret" + cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}} + svc := &service.Container{ + Repo: repos, + Auth: service.NewAuthService(cfg, zap.NewNop(), repos, nil, nil), + Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), + Stream: service.NewStreamService(cfg, zap.NewNop(), repos, nil), + } + router := gin.New() + router.GET("/videos/:id/stream", func(c *gin.Context) { + c.Set(middleware.CtxUserID, user.ID) + c.Set(middleware.CtxUserRole, user.Role) + embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)(c) + }) + + req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "/api/stream/cloud-1") || !strings.Contains(loc, "api_key=") { + t.Fatalf("session fallback redirect should include api_key for /api/stream, got %q", loc) + } +} + func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go index 048efce..3e7e040 100644 --- a/internal/handler/playback_extra.go +++ b/internal/handler/playback_extra.go @@ -159,6 +159,15 @@ func absoluteRequestURL(c *gin.Context, path string) string { return scheme + "://" + host + path } +func setRedirectNoStoreHeaders(c *gin.Context) { + if c == nil { + return + } + c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") +} + // transcodeStatusHandler reports the live status of one transcode job. // We surface the active jobs the transcoder knows about. func transcodeStatusHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/strm.go b/internal/handler/strm.go index caf72fb..d4c2624 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -118,10 +118,14 @@ func generateSTRMHandler(svc *service.Container) gin.HandlerFunc { if strmSvc == nil { strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg) } + baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") + if baseURL == "" { + baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/") + } res, err := strmSvc.GenerateForLibrary(c.Request.Context(), service.GenerateSTRMOptions{ LibraryID: req.LibraryID, OutputDir: req.OutputDir, - BaseURL: req.BaseURL, + BaseURL: baseURL, Enabled: req.Enabled, Overwrite: req.Overwrite, IncludeLocal: true, diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go index 80e553a..221a206 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -28,6 +28,13 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" ) +const ( + organizeSkipAlreadyOrganized = "already organized" + organizeSkipDuplicateLibrary = "duplicate in library" + organizeSkipTargetExists = "target file exists" + organizeSkipSampleClip = "sample/trailer clip" +) + // OrganizeSourceCandidate is a selectable organize source directory surfaced to // the UI so operators can organize an arbitrary directory (such as the download // directory) and not only registered libraries. @@ -127,6 +134,20 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO if _, ok := videoExtensions[ext]; !ok { return nil, fmt.Errorf("source is not a supported video file: %s", source) } + if skipped, reason := shouldSkipOrganizeSourceVideo(source, filepath.Dir(source)); skipped { + res.Skipped++ + res.Items = append(res.Items, OrganizePreviewItem{Source: source, Action: "skip", Reason: reason}) + o.log.Info("organize file finished", + zap.String("source", source), + zap.String("dest", dest), + zap.String("mode", string(mode)), + zap.Int("organized", res.Organized), + zap.Int("replaced", res.Replaced), + zap.Int("skipped", res.Skipped), + zap.Any("skip_reasons", OrganizeSkipReasonCounts(res)), + ) + return res, nil + } if err := o.organizeSourceFile(ctx, source, filepath.Dir(source), dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, res); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(source), err.Error())) res.Items = append(res.Items, OrganizePreviewItem{Source: source, Action: "error", Reason: err.Error()}) @@ -138,6 +159,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO zap.Int("organized", res.Organized), zap.Int("replaced", res.Replaced), zap.Int("skipped", res.Skipped), + zap.Any("skip_reasons", OrganizeSkipReasonCounts(res)), ) return res, nil } @@ -149,6 +171,11 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO if _, ok := videoExtensions[ext]; !ok { return nil } + if skipped, reason := shouldSkipOrganizeSourceVideo(path, source); skipped { + res.Skipped++ + res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "skip", Reason: reason}) + return nil + } if err := o.organizeSourceFile(ctx, path, source, dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, res); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(path), err.Error())) res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "error", Reason: err.Error()}) @@ -165,6 +192,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO zap.Int("organized", res.Organized), zap.Int("replaced", res.Replaced), zap.Int("skipped", res.Skipped), + zap.Any("skip_reasons", OrganizeSkipReasonCounts(res)), ) return res, nil } @@ -212,6 +240,9 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo if !matchedLibrary && layout.Category != "" { layoutRoot = categoryRoot(layoutRoot, sanitizeFilename(layout.Category)) } + if !matchedLibrary && !dryRun { + o.ensureOrganizeLibraryForRoot(ctx, layoutRoot, layout.MediaType, layout.Category) + } var destDir, dst, episodeTag string isSeries := season > 0 || episode > 0 @@ -237,7 +268,7 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo if filepath.Clean(src) == filepath.Clean(dst) { res.Skipped++ res.Items = append(res.Items, OrganizePreviewItem{ - Source: src, Target: dst, Action: "skip", Reason: "already organized", + Source: src, Target: dst, Action: "skip", Reason: organizeSkipAlreadyOrganized, MediaType: layout.MediaType, Category: layout.Category, Title: title, }) return nil @@ -245,7 +276,9 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo // 去重候选:合并「目的地媒体库已扫描入库的同一媒体(按标题/年份/季集匹配, // 不受目录大小写或布局影响)」与「目标文件夹内已存在的同名视频文件」。 - existing := o.existingVersionPaths(ctx, destRoot, destDir, parsedTitle, episodeTag, year, season, episode) + identityExisting := o.existingByIdentity(ctx, destRoot, parsedTitle, year, season, episode) + folderExisting := o.existingByFolder(destDir, episodeTag) + existing := mergeExistingVersionPaths(identityExisting, folderExisting) if len(existing) > 0 { srcArea := o.resolutionArea(ctx, src) bestArea := 0 @@ -278,11 +311,15 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo return nil } // 去重:目的地已存在同一媒体且不低于来源分辨率,跳过不再整理过去。 + reason := organizeSkipTargetExists + if len(identityExisting) > 0 || o.allExistingPathsInDB(ctx, existing) { + reason = organizeSkipDuplicateLibrary + } o.log.Debug("organize skip duplicate", - zap.String("src", src), zap.String("dest_dir", destDir)) + zap.String("src", src), zap.String("dest_dir", destDir), zap.String("reason", reason)) res.Skipped++ res.Items = append(res.Items, OrganizePreviewItem{ - Source: src, Target: dst, Action: "skip", Reason: "duplicate exists", + Source: src, Target: dst, Action: "skip", Reason: reason, MediaType: layout.MediaType, Category: layout.Category, Title: title, }) return nil @@ -303,7 +340,7 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo res.Skipped++ if len(res.Items) > 0 { res.Items[len(res.Items)-1].Action = "skip" - res.Items[len(res.Items)-1].Reason = "target exists" + res.Items[len(res.Items)-1].Reason = organizeSkipTargetExists } return nil } @@ -318,6 +355,37 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo return nil } +func shouldSkipOrganizeSourceVideo(path, sourceRoot string) (bool, string) { + cleanPath := filepath.Clean(path) + cleanRoot := filepath.Clean(sourceRoot) + if rel, err := filepath.Rel(cleanRoot, cleanPath); err == nil && rel != "." && !strings.HasPrefix(rel, "..") { + dir := filepath.Dir(rel) + if dir != "." { + for _, part := range strings.Split(dir, string(os.PathSeparator)) { + switch normalizeOrganizeCategoryKey(part) { + case "sample", "samples", "trailer", "trailers", "preview", "previews", "teaser", "teasers": + return true, organizeSkipSampleClip + } + } + } + } + base := strings.ToLower(strings.TrimSuffix(filepath.Base(cleanPath), filepath.Ext(cleanPath))) + normalized := strings.NewReplacer("_", " ", "-", " ", ".", " ").Replace(base) + fields := strings.Fields(normalized) + if len(fields) == 0 { + return false, "" + } + if len(fields) == 1 && strings.HasPrefix(fields[0], "sample") { + return true, organizeSkipSampleClip + } + last := fields[len(fields)-1] + switch last { + case "sample", "trailer", "preview", "teaser": + return true, organizeSkipSampleClip + } + return false, "" +} + func normalizeOrganizeMediaType(mediaType string) string { switch strings.ToLower(strings.TrimSpace(mediaType)) { case "movie", "film": @@ -507,6 +575,92 @@ func (o *OrganizerService) organizeLibraryRootForLayout(ctx context.Context, des return filepath.Clean(bestPath), true } +func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, root, mediaType, category string) { + if o == nil || o.repo == nil || o.repo.Library == nil { + return + } + root = filepath.Clean(strings.TrimSpace(root)) + if root == "" || root == "." { + return + } + if _, ok := ParseCloudLibraryMount(root); ok { + return + } + libraries, err := o.repo.Library.List(ctx) + if err != nil { + if o.log != nil { + o.log.Debug("list libraries before organize auto-create failed", zap.Error(err)) + } + return + } + for _, lib := range libraries { + if !lib.Enabled || strings.TrimSpace(lib.Path) == "" { + continue + } + if _, ok := ParseCloudLibraryMount(lib.Path); ok { + continue + } + if pathWithin(root, lib.Path) { + return + } + } + name := strings.TrimSpace(category) + if name == "" { + name = filepath.Base(root) + } + if name == "" || name == "." || name == string(os.PathSeparator) { + name = organizeLibraryTypeName(mediaType) + } + lib := model.Library{ + Name: name, + Path: root, + Type: organizeLibraryModelType(mediaType), + Enabled: true, + } + if err := o.repo.Library.Create(ctx, &lib); err != nil { + if o.log != nil { + o.log.Warn("organize auto-create library failed", + zap.String("path", root), + zap.String("type", lib.Type), + zap.String("name", lib.Name), + zap.Error(err)) + } + return + } + if o.log != nil { + o.log.Info("organize auto-created missing library", + zap.String("path", root), + zap.String("type", lib.Type), + zap.String("name", lib.Name)) + } +} + +func organizeLibraryModelType(mediaType string) string { + switch normalizeOrganizeMediaType(mediaType) { + case "tv", "anime", "variety": + return "tv" + case "adult", "movie": + return "movie" + default: + return "movie" + } +} + +func organizeLibraryTypeName(mediaType string) string { + switch normalizeOrganizeMediaType(mediaType) { + case "tv": + return "电视剧" + case "anime": + return "动漫" + case "variety": + return "综艺" + case "adult": + return "成人" + default: + return "电影" + } +} + func (o *OrganizerService) organizeCategoryAliases(mediaType, category string) map[string]struct{} { aliases := map[string]struct{}{} add := func(values ...string) { @@ -603,6 +757,13 @@ func pathDepth(path string) int { // 2. Filesystem: video files inside the computed destination folder (matching // the SxxExx tag for episodes). Covers destinations that were not scanned. func (o *OrganizerService) existingVersionPaths(ctx context.Context, destRoot, destDir, title, episodeTag string, year, season, episode int) []string { + return mergeExistingVersionPaths( + o.existingByIdentity(ctx, destRoot, title, year, season, episode), + o.existingByFolder(destDir, episodeTag), + ) +} + +func mergeExistingVersionPaths(groups ...[]string) []string { seen := map[string]struct{}{} var out []string add := func(p string) { @@ -619,15 +780,44 @@ func (o *OrganizerService) existingVersionPaths(ctx context.Context, destRoot, d seen[c] = struct{}{} out = append(out, c) } - for _, p := range o.existingByIdentity(ctx, destRoot, title, year, season, episode) { - add(p) - } - for _, p := range o.existingByFolder(destDir, episodeTag) { - add(p) + for _, group := range groups { + for _, p := range group { + add(p) + } } return out } +func (o *OrganizerService) allExistingPathsInDB(ctx context.Context, paths []string) bool { + if o == nil || o.repo == nil || o.repo.DB == nil || len(paths) == 0 { + return false + } + cleaned := make([]string, 0, len(paths)) + seen := map[string]struct{}{} + for _, path := range paths { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + continue + } + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + cleaned = append(cleaned, path) + } + if len(cleaned) == 0 { + return false + } + var count int64 + if err := o.repo.DB.WithContext(ctx). + Model(&model.Media{}). + Where("path IN ?", cleaned). + Count(&count).Error; err != nil { + return false + } + return count == int64(len(cleaned)) +} + // existingByIdentity finds scanned destination media matching the parsed // identity (case-insensitive title + year for movies; title + season/episode // for episodes), located under destRoot. diff --git a/internal/service/organizer_directory_test.go b/internal/service/organizer_directory_test.go index 5b7cc4b..a543c52 100644 --- a/internal/service/organizer_directory_test.go +++ b/internal/service/organizer_directory_test.go @@ -313,6 +313,73 @@ func TestOrganizeDirectoryDedup(t *testing.T) { } } +func TestOrganizeDirectoryTreatsScannedTargetPathAsLibraryDuplicate(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + + writeOrgFile(t, filepath.Join(src, "Weird.Release.2024.1080p.mkv"), "source") + existing := filepath.Join(dest, "电影", "Weird Release (2024)", "Weird Release (2024).mkv") + writeOrgFile(t, existing, "existing") + + repos := newOrganizerTestRepo(t) + row := model.Media{Title: "刮削后的正式片名", Path: existing, Year: 2024, Container: "mkv", Width: 1920, Height: 1080} + if err := repos.Media.Upsert(t.Context(), &row); err != nil { + t.Fatal(err) + } + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Organized != 0 || res.Skipped != 1 { + t.Fatalf("result = %+v, want organized=0 skipped=1", res) + } + if len(res.Items) != 1 || res.Items[0].Reason != organizeSkipDuplicateLibrary { + t.Fatalf("items = %+v, want duplicate in library", res.Items) + } + if OrganizeResultNeedsVisibilitySync(res) { + t.Fatal("path already present in DB should not trigger another visibility scan") + } +} + +func TestOrganizeDirectorySkipsSampleClips(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + + writeOrgFile(t, filepath.Join(src, "Demo.Movie.2024.1080p.mkv"), "main") + writeOrgFile(t, filepath.Join(src, "Samples", "Sample1.mkv"), "sample-dir") + writeOrgFile(t, filepath.Join(src, "Demo.Movie.2024.sample.mkv"), "sample-file") + + org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t)) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Organized != 1 || res.Skipped != 2 { + t.Fatalf("result = %+v, want organized=1 skipped=2", res) + } + if got := OrganizeSkipReasonCounts(res)[organizeSkipSampleClip]; got != 2 { + t.Fatalf("sample skip count = %d, want 2; items=%+v", got, res.Items) + } + if _, err := os.Stat(filepath.Join(dest, "电影", "Sample1", "Sample1.mkv")); !os.IsNotExist(err) { + t.Fatalf("sample directory clip should not be organized, stat err=%v", err) + } + if _, err := os.Stat(filepath.Join(dest, "电影", "Demo Movie Sample (2024)", "Demo Movie Sample (2024).mkv")); !os.IsNotExist(err) { + t.Fatalf("sample suffix clip should not be organized, stat err=%v", err) + } +} + // TestOrganizeDirectorySkipsHigherResolutionWhenReplacementDisabled verifies // that dedup wins by default: even a higher-resolution source must not replace // an existing library item unless the caller explicitly allows washing. @@ -549,6 +616,54 @@ func TestOrganizeDirectoryUsesExplicitCategoryLibraryRoot(t *testing.T) { } } +func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.T) { + root := t.TempDir() + srcRoot := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + source := filepath.Join(srcRoot, "Gourd.Brothers.S01E01.2026.1080p.mkv") + target := filepath.Join(dest, "电视剧", "未分类", "Gourd Brothers", "Season 01", "Gourd Brothers - S01E01.mkv") + writeOrgFile(t, source, "source") + writeOrgFile(t, target, "already-there") + + repos := newOrganizerTestRepo(t) + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: srcRoot, + DestPath: dest, + MediaType: "tv", + MediaCategory: "未分类", + TransferMode: TransferCopy, + AllowReplaceExisting: false, + }) + if err != nil { + t.Fatalf("organize missing category: %v", err) + } + if res.Organized != 0 || res.Skipped != 1 || len(res.Items) != 1 || res.Items[0].Reason != organizeSkipTargetExists { + t.Fatalf("result = %+v, want skipped target exists", res) + } + + var lib model.Library + if err := repos.DB.Where("path = ?", filepath.Join(dest, "电视剧", "未分类")).First(&lib).Error; err != nil { + t.Fatalf("missing auto-created category library: %v", err) + } + if lib.Name != "未分类" || lib.Type != "tv" || !lib.Enabled { + t.Fatalf("auto-created library = %+v, want enabled tv 未分类", lib) + } + + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) + scans := scanner.ScanLibrariesForPath(t.Context(), res.DestPath, "") + added := 0 + for _, scan := range scans { + if scan.Error != "" { + t.Fatalf("scan failed: %#v", scan) + } + added += scan.Added + } + if added != 1 { + t.Fatalf("scan added = %d, want 1; scans=%#v", added, scans) + } +} + func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) { root := t.TempDir() src := filepath.Join(root, "downloads") diff --git a/internal/service/organizer_scan.go b/internal/service/organizer_scan.go index 93d3b8a..5e85a69 100644 --- a/internal/service/organizer_scan.go +++ b/internal/service/organizer_scan.go @@ -57,11 +57,27 @@ func OrganizeResultHasChanges(res *OrganizeResult) bool { } // OrganizeResultNeedsVisibilitySync reports whether a just-finished organize -// should make sure the destination media exists in the DB. Skipped target files -// can still be invisible after a restart or an older organize run, so download -// completion should run one visibility sync even when no bytes were moved. +// should make sure destination files exist in the DB. New/replaced files always +// need it. Skips only need it when the target file exists on disk but was not +// proven to be an already scanned library duplicate; otherwise an automatic +// organize loop with only seeding duplicates would rescan libraries forever. func OrganizeResultNeedsVisibilitySync(res *OrganizeResult) bool { - return res != nil && (res.Organized > 0 || res.Replaced > 0 || res.Skipped > 0) + if OrganizeResultHasChanges(res) { + return true + } + if res == nil { + return false + } + for _, item := range res.Items { + if item.Action != "skip" { + continue + } + switch item.Reason { + case organizeSkipAlreadyOrganized, organizeSkipTargetExists, "duplicate exists", "target exists": + return true + } + } + return false } // ScanLibrariesForPath recursively scans libraries affected by an organize @@ -144,7 +160,10 @@ func (s *ScannerService) scrapeOrganizeTargets(ctx context.Context, targets []mo Name: lib.Name, Path: lib.Path, } - matched, err := s.scraper.EnrichLibrary(ctx, lib.ID) + // Organize is an explicit ingest workflow: after rename/classification, + // previously failed no_match rows should be retried so the operator does + // not need to run a separate manual scrape. + matched, err := s.scraper.EnrichLibrary(ctx, lib.ID, true) if err != nil { summary.Error = err.Error() } else { diff --git a/internal/service/organizer_scrape_test.go b/internal/service/organizer_scrape_test.go index ed50656..7d066c8 100644 --- a/internal/service/organizer_scrape_test.go +++ b/internal/service/organizer_scrape_test.go @@ -69,6 +69,75 @@ func TestOrganizeDirectoryScanAndScrapeAfter(t *testing.T) { } } +func TestOrganizeScanAndScrapeRetriesNoMatchRows(t *testing.T) { + scraper, repos, closeServer := newTestScraper(t) + defer closeServer() + + root := t.TempDir() + libRoot := filepath.Join(root, "media", "电视剧") + mediaPath := filepath.Join(libRoot, "间谍过家家", "Season 02", "间谍过家家 - S02E02.mkv") + writeOrgFile(t, mediaPath, "episode") + + lib := model.Library{ + Name: "剧集", + Path: libRoot, + Type: "tv", + Enabled: true, + } + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: lib.ID, + Title: "间谍过家家", + Path: mediaPath, + SeasonNum: 2, + EpisodeNum: 2, + ScrapeStatus: "no_match", + } + if err := repos.Media.Upsert(t.Context(), &media); err != nil { + t.Fatal(err) + } + + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, scraper) + _, scrapes := scanner.ScanAndScrapeLibrariesForPath(t.Context(), filepath.Join(root, "media"), "", true) + if len(scrapes) != 1 || scrapes[0].Matched != 1 || scrapes[0].Error != "" || scrapes[0].Skipped { + t.Fatalf("scrapes = %#v, want one retried no_match row", scrapes) + } + + var got model.Media + if err := repos.DB.First(&got, "path = ?", mediaPath).Error; err != nil { + t.Fatal(err) + } + if got.ScrapeStatus != "matched" || got.TMDbID != 12345 { + t.Fatalf("media scrape status=%q tmdb=%d, want matched/12345", got.ScrapeStatus, got.TMDbID) + } +} + +func TestOrganizeResultNeedsVisibilitySyncIgnoresScannedDuplicates(t *testing.T) { + if OrganizeResultNeedsVisibilitySync(&OrganizeResult{ + Skipped: 1, + Items: []OrganizePreviewItem{{Action: "skip", Reason: organizeSkipDuplicateLibrary}}, + }) { + t.Fatal("already-scanned duplicate should not trigger another visibility scan") + } + if OrganizeResultNeedsVisibilitySync(&OrganizeResult{ + Skipped: 1, + Items: []OrganizePreviewItem{{Action: "skip", Reason: organizeSkipSampleClip}}, + }) { + t.Fatal("sample clip skip should not trigger visibility scan") + } + if !OrganizeResultNeedsVisibilitySync(&OrganizeResult{ + Skipped: 1, + Items: []OrganizePreviewItem{{Action: "skip", Reason: organizeSkipTargetExists}}, + }) { + t.Fatal("unscanned target file should trigger visibility scan") + } + if !OrganizeResultNeedsVisibilitySync(&OrganizeResult{Organized: 1}) { + t.Fatal("organized files must trigger visibility scan") + } +} + func TestOrganizeScrapeAfterEnabledDefaultsOn(t *testing.T) { if !OrganizeScrapeAfterEnabled(t.Context(), nil) { t.Fatalf("organize scrape-after should default on without a repo") diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go index 069e20d..1d58eb7 100644 --- a/internal/service/storage_config.go +++ b/internal/service/storage_config.go @@ -494,10 +494,8 @@ func cloudResolveHotRefreshWindow(ttl time.Duration) time.Duration { func cloudResolveCacheTTL(typ string) time.Duration { switch typ { - case cloud.TypeQuark, cloud.Type115: + case cloud.TypeQuark, cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList: return 2 * time.Minute - case cloud.TypeCloudDrive2, cloud.TypeOpenList: - return 15 * time.Minute default: return 5 * time.Minute } diff --git a/internal/service/storage_config_cache_test.go b/internal/service/storage_config_cache_test.go index ff82e14..2260cb5 100644 --- a/internal/service/storage_config_cache_test.go +++ b/internal/service/storage_config_cache_test.go @@ -79,3 +79,11 @@ func TestCloudResolveHotCacheRefreshesInBackground(t *testing.T) { t.Fatalf("refreshed link = %s, want second URL", link.URL) } } + +func TestCloudResolveCacheTTLUsesShortTTLForCloudPlaybackLinks(t *testing.T) { + for _, typ := range []string{"quark", "cloud115", "clouddrive2", "openlist"} { + if got := cloudResolveCacheTTL(typ); got != 2*time.Minute { + t.Fatalf("%s cloud resolve cache ttl = %v, want 2m", typ, got) + } + } +} diff --git a/internal/service/stream.go b/internal/service/stream.go index ace5558..3f1b300 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -380,6 +380,7 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re // 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。 target := normalizeCloudPlayTarget(strmURL) target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg)) + setCloudRedirectNoStore(w) http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound) return nil } @@ -405,6 +406,15 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re return nil } +func setCloudRedirectNoStore(w http.ResponseWriter) { + if w == nil { + return + } + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") +} + func isCloudPlaybackTarget(raw string) bool { _, _, ok := parseCloudMediaPlaybackURL(raw) return ok diff --git a/internal/service/stream_test.go b/internal/service/stream_test.go index 510c747..efb28ca 100644 --- a/internal/service/stream_test.go +++ b/internal/service/stream_test.go @@ -84,6 +84,9 @@ func TestServeFileRedirectsInternalSTRMAsAbsoluteURLWithToken(t *testing.T) { !strings.Contains(loc, "token=jwt123") { t.Fatalf("redirect Location should be absolute and tokenized, got %q", loc) } + if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") { + t.Fatalf("cloud redirect Cache-Control = %q, want no-store", got) + } } func TestServeFileRedirectUsesForwardedTunnelHost(t *testing.T) { diff --git a/internal/service/strm_svc.go b/internal/service/strm_svc.go index 5d16d7b..7ae382b 100644 --- a/internal/service/strm_svc.go +++ b/internal/service/strm_svc.go @@ -98,7 +98,9 @@ func (s *STRMService) GenerateForLibrary(ctx context.Context, opts GenerateSTRMO return nil, errors.New("output_dir required") } if strings.TrimSpace(opts.BaseURL) != "" && s.repo.Setting != nil { - _ = s.repo.Setting.Set(ctx, "app.server_url", strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")) + baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") + _ = s.repo.Setting.Set(ctx, "app.server_url", baseURL) + _ = s.repo.Setting.Set(ctx, "strm.base_url", baseURL) } if s.repo.Setting != nil { _ = s.repo.Setting.Set(ctx, "strm.auto_generate_enabled", strconv.FormatBool(opts.Enabled)) diff --git a/internal/service/strm_svc_test.go b/internal/service/strm_svc_test.go index 60675b2..733348a 100644 --- a/internal/service/strm_svc_test.go +++ b/internal/service/strm_svc_test.go @@ -59,6 +59,12 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) { localSTRM := filepath.Join(outDir, "本地电影 (2025)", "本地电影 (2025).strm") assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/stream/cloud-media?token=strm-token") assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media?token=strm-token") + if got, err := repos.Setting.Get(t.Context(), "app.server_url"); err != nil || got != "http://nas.example:18080" { + t.Fatalf("app.server_url = %q, %v; want generated base url", got, err) + } + if got, err := repos.Setting.Get(t.Context(), "strm.base_url"); err != nil || got != "http://nas.example:18080" { + t.Fatalf("strm.base_url = %q, %v; want generated base url", got, err) + } var count int64 if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil { diff --git a/internal/service/task_tracker.go b/internal/service/task_tracker.go index d77ba79..8dc4756 100644 --- a/internal/service/task_tracker.go +++ b/internal/service/task_tracker.go @@ -1,6 +1,7 @@ package service import ( + "strings" "sync" "time" @@ -257,6 +258,9 @@ func OrganizeTaskMetrics(res *OrganizeResult) map[string]int64 { metrics["scan_updated"] = scanUpdated metrics["scan_removed"] = scanRemoved } + for reason, count := range OrganizeSkipReasonCounts(res) { + metrics["skip_"+organizeMetricKey(reason)] = int64(count) + } var scrapeMatched int64 for _, scrape := range res.Scrapes { scrapeMatched += int64(scrape.Matched) @@ -273,3 +277,44 @@ func OrganizeTaskMetrics(res *OrganizeResult) map[string]int64 { } return metrics } + +func OrganizeSkipReasonCounts(res *OrganizeResult) map[string]int { + if res == nil || len(res.Items) == 0 { + return nil + } + counts := map[string]int{} + for _, item := range res.Items { + if item.Action != "skip" { + continue + } + reason := strings.TrimSpace(item.Reason) + if reason == "" { + reason = "unknown" + } + counts[reason]++ + } + if len(counts) == 0 { + return nil + } + return counts +} + +func organizeMetricKey(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "unknown" + } + replacer := strings.NewReplacer(" ", "_", "-", "_", "/", "_", "\\", "_") + value = replacer.Replace(value) + var b strings.Builder + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' { + b.WriteRune(r) + } + } + out := strings.Trim(b.String(), "_") + if out == "" { + return "unknown" + } + return out +} diff --git a/web/src/pages/StrmPage.tsx b/web/src/pages/StrmPage.tsx index feddc01..38df799 100644 --- a/web/src/pages/StrmPage.tsx +++ b/web/src/pages/StrmPage.tsx @@ -10,6 +10,29 @@ import type { Library, Media } from '../types' type CloudPlaybackMode = 'strm' | 'redirect_proxy' +function currentOrigin() { + if (typeof window === 'undefined') return '' + return window.location.origin.replace(/\/+$/, '') +} + +function isLocalPlaybackBase(raw: string) { + try { + const u = new URL(raw) + const host = u.hostname.toLowerCase() + return host === 'localhost' || host === '127.0.0.1' || host === '::1' + } catch { + return false + } +} + +function preferredSTRMBaseURL(saved: string) { + const current = currentOrigin() + const trimmed = saved.trim().replace(/\/+$/, '') + if (!trimmed) return current + if (current && isLocalPlaybackBase(trimmed) && trimmed !== current) return current + return trimmed +} + // StrmPage exposes the URL-as-file admin tooling backed by the Go server: // - import a brand-new media row directly from a (library, title, url) // tuple — useful for streaming-only entries with no on-disk file. @@ -50,7 +73,7 @@ export function StrmPage() { .listSettings() .then((rows) => { const settings = Object.fromEntries(rows.map((row) => [row.key, row.value])) - setBaseURL(settings['app.server_url'] || settings['strm.base_url'] || '') + setBaseURL(preferredSTRMBaseURL(settings['strm.base_url'] || settings['app.server_url'] || '')) setOutputDir(settings['strm.output_dir'] || '') const mode = settings['cloud.playback_mode'] const nextMode = @@ -336,6 +359,13 @@ export function StrmPage() { value={baseURL} onChange={(e) => setBaseURL(e.target.value)} /> +