fix media workflows and download organization

This commit is contained in:
ShukeBta
2026-06-26 08:42:16 +08:00
parent 0da96f7e4e
commit 65adca04df
102 changed files with 4063 additions and 437 deletions
+30 -4
View File
@@ -9,6 +9,7 @@ package handler
import (
"context"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -60,8 +61,13 @@ func discoverFeedHandler(svc *service.Container) gin.HandlerFunc {
if strings.TrimSpace(rawSections) == "" {
rawSections = strings.Join(defaultDiscoverSectionKeys(c.Request.Context(), svc), ",")
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
keys := strings.Split(rawSections, ",")
out := gin.H{}
meta := gin.H{}
artworkItems := []service.ExternalMediaResult{}
for _, raw := range keys {
k := strings.TrimSpace(raw)
@@ -70,16 +76,19 @@ func discoverFeedHandler(svc *service.Container) gin.HandlerFunc {
}
if provider := discoverSectionProvider(k); provider != "" && !discoverProviderEnabled(c.Request.Context(), svc, provider) {
out[k] = []service.ExternalMediaResult{}
meta[k] = gin.H{"page": page, "has_next": false}
continue
}
items, err := discoverSectionItems(c.Request.Context(), svc, k)
items, err := discoverSectionItems(c.Request.Context(), svc, k, page)
if err != nil {
svc.Log.Debug("discover fetch failed")
items = nil
}
artworkItems = append(artworkItems, items...)
out[k] = items
meta[k] = gin.H{"page": page, "has_next": discoverSectionHasNext(k, len(items))}
}
out["_meta"] = meta
svc.Discover.WarmExternalArtwork(artworkItems)
c.JSON(http.StatusOK, out)
}
@@ -145,22 +154,39 @@ func discoverProviderEnabled(ctx context.Context, svc *service.Container, provid
return cfg.Enabled
}
func discoverSectionItems(ctx context.Context, svc *service.Container, k string) ([]service.ExternalMediaResult, error) {
func discoverSectionItems(ctx context.Context, svc *service.Container, k string, page int) ([]service.ExternalMediaResult, error) {
switch k {
case "tmdb_trending_day", "tmdb_trending_week", "tmdb_latest_movie", "tmdb_latest_tv", "tmdb_popular_movie", "tmdb_popular_tv", "tmdb_top_rated_movie", "tmdb_upcoming_movie",
"trending_day", "trending_week", "latest_movie", "latest_tv", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie":
return svc.Discover.TMDbSection(ctx, k)
return svc.Discover.TMDbSection(ctx, k, page)
case "douban_hot_movie", "douban_hot_tv", "douban_top_movie":
if svc.Douban == nil {
return []service.ExternalMediaResult{}, nil
}
return svc.Douban.Discover(ctx, k)
return svc.Douban.Discover(ctx, k, page)
case "bangumi_calendar":
if svc.Bangumi == nil {
return []service.ExternalMediaResult{}, nil
}
if page > 1 {
return []service.ExternalMediaResult{}, nil
}
return svc.Bangumi.Calendar(ctx)
default:
return []service.ExternalMediaResult{}, nil
}
}
func discoverSectionHasNext(key string, itemCount int) bool {
if itemCount <= 0 {
return false
}
switch discoverSectionProvider(key) {
case "tmdb":
return itemCount >= 20
case "douban":
return itemCount >= 24
default:
return false
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
return
}
clientInfo := embyClientInfoFromRequest(c)
if svc.Device != nil && svc.Device.IsDeviceKicked(c.Request.Context(), uid, clientInfo.DeviceID) {
if svc.Device != nil && svc.Device.IsTerminalKicked(c.Request.Context(), uid, clientInfo.DeviceID, clientInfo.DeviceName, clientInfo.Client) {
c.Status(http.StatusUnauthorized)
return
}
+7 -1
View File
@@ -61,11 +61,16 @@ func manualScrapeApplyOneHandler(svc *service.Container) gin.HandlerFunc {
}
applyCtx, cancel := manualScrapeApplyContext(c)
defer cancel()
media, err := svc.Scraper.ApplyManualMatch(applyCtx, c.Param("id"), req)
mediaID := c.Param("id")
media, err := svc.Scraper.ApplyManualMatch(applyCtx, mediaID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
reclassifyMediaAfterScrape(applyCtx, svc, mediaID)
if refreshed, _ := svc.Repo.Media.FindByID(applyCtx, mediaID); refreshed != nil {
media = refreshed
}
c.JSON(http.StatusOK, media)
}
}
@@ -92,6 +97,7 @@ func manualScrapeApplyBatchHandler(svc *service.Container) gin.HandlerFunc {
errorsOut = append(errorsOut, id+": "+err.Error())
continue
}
reclassifyMediaAfterScrape(applyCtx, svc, id)
applied++
}
if applied == 0 && len(errorsOut) > 0 {
+5 -1
View File
@@ -341,7 +341,7 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
err = svc.Stream.ServeFileWithCloudMode(c.Writer, c.Request, c.Param("id"), service.CloudPlaybackModeSTRM)
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -350,6 +350,10 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if errors.Is(err, service.ErrCloudPlaybackUnavailable) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+1
View File
@@ -94,6 +94,7 @@ func aiScrapeMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
reclassifyMediaAfterScrape(c.Request.Context(), svc, m.ID)
refreshed, _ := svc.Repo.Media.FindByID(c.Request.Context(), m.ID)
c.JSON(http.StatusOK, refreshed)
}
+83
View File
@@ -84,6 +84,23 @@ func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
}
}
func TestStreamCloudMediaWithoutSTRMURLReturnsBadGateway(t *testing.T) {
router, _, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/stream/media-missing-strm", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("status = %d body=%s, want 502", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), service.ErrCloudPlaybackUnavailable.Error()) {
t.Fatalf("body = %q, want cloud playback unavailable error", w.Body.String())
}
}
func TestExternalURLPrefersBrowserPublicOriginOverForwardedSource(t *testing.T) {
router, _, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
@@ -215,6 +232,29 @@ func TestExternalURLPrefersConfiguredPublicServerURLOverLocalBrowserOrigin(t *te
}
}
func TestAPIStreamAllowsRedirectPlaybackWhenSTRMModeDisabled(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
if err := svc.Repo.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "false"); err != nil {
t.Fatal(err)
}
if err := svc.Repo.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "true"); err != nil {
t.Fatal(err)
}
loginToken := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/stream/media-1?api_key="+url.QueryEscape(loginToken), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("status = %d body=%s, want 302", w.Code, w.Body.String())
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "/api/cloud/play/openlist?") || !strings.Contains(loc, "token="+url.QueryEscape(loginToken)) {
t.Fatalf("redirect Location should target tokenized cloud play endpoint, got %q", loc)
}
}
func TestScopedPlaybackTokenCannotStreamAnotherMedia(t *testing.T) {
router, svc, _ := newPlaybackScopeTestRouter(t)
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
@@ -235,6 +275,43 @@ func TestScopedPlaybackTokenCannotStreamAnotherMedia(t *testing.T) {
}
}
func TestScopedPlaybackTokenCanFollowCloudRedirectForSameMedia(t *testing.T) {
router, svc, _ := newPlaybackScopeTestRouter(t)
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("find user: %v", err)
}
playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60)
if err != nil {
t.Fatalf("issue playback token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/stream/media-1?token="+url.QueryEscape(playToken), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("stream status = %d body=%s, want 302", w.Code, w.Body.String())
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "/api/cloud/play/openlist?") ||
!strings.Contains(loc, "media_id=media-1") ||
!strings.Contains(loc, "token="+url.QueryEscape(playToken)) {
t.Fatalf("redirect Location should carry scoped token and media_id, got %q", loc)
}
req = httptest.NewRequest(http.MethodGet, loc, nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code == http.StatusForbidden || w.Code == http.StatusUnauthorized {
t.Fatalf("cloud redirect rejected scoped token: status=%d body=%s", w.Code, w.Body.String())
}
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("cloud redirect status = %d body=%s, want storage service fallback 503", w.Code, w.Body.String())
}
}
func TestScopedPlaybackTokenCannotRetargetCloudRef(t *testing.T) {
router, svc, _ := newPlaybackScopeTestRouter(t)
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
@@ -370,6 +447,12 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
Path: "cloud://openlist/Movies/Other.mkv",
STRMURL: "/api/cloud/play/openlist?ref=/Movies/Other.mkv",
},
{
Base: model.Base{ID: "media-missing-strm"},
LibraryID: lib.ID,
Title: "Cloud missing STRM",
Path: "cloud://openlist/Movies/Missing.mkv",
},
}
if err := repos.DB.Create(&rows).Error; err != nil {
t.Fatal(err)
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"context"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func reclassifyMediaAfterScrape(ctx context.Context, svc *service.Container, mediaIDs ...string) int {
if svc == nil || svc.Organizer == nil {
return 0
}
res, err := svc.Organizer.ReclassifyMisclassifiedMedia(ctx, service.MediaCategoryReclassifyOptions{MediaIDs: mediaIDs})
if err != nil {
if svc.Log != nil {
svc.Log.Warn("scrape reclassify media failed", zap.Strings("media_ids", mediaIDs), zap.Error(err))
}
return 0
}
return res.Reclassified
}
func reclassifyLibraryAfterScrape(ctx context.Context, svc *service.Container, libraryIDs ...string) int {
if svc == nil || svc.Organizer == nil {
return 0
}
res, err := svc.Organizer.ReclassifyMisclassifiedMedia(ctx, service.MediaCategoryReclassifyOptions{LibraryIDs: libraryIDs})
if err != nil {
if svc.Log != nil {
svc.Log.Warn("scrape reclassify library failed", zap.Strings("library_ids", libraryIDs), zap.Error(err))
}
return 0
}
return res.Reclassified
}
+11
View File
@@ -85,6 +85,17 @@ func testStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}
if service.IsAdminCloudConfigurable(in.Type) {
enabled := true
if _, err := svc.StorageCfg.Save(c.Request.Context(), service.StorageInput{
Type: in.Type,
Config: in.Config,
Enabled: &enabled,
}); err != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
+73
View File
@@ -1,12 +1,20 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func TestStorageConfigHandlersRejectQuark(t *testing.T) {
@@ -26,3 +34,68 @@ func TestStorageConfigHandlersRejectQuark(t *testing.T) {
t.Fatalf("body = %s, want unsupported storage type", w.Body.String())
}
}
func TestStorageConfigCloudTestSuccessSavesAndEnablesProvider(t *testing.T) {
gin.SetMode(gin.TestMode)
openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/fs/list" {
t.Fatalf("unexpected openlist path %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "openlist-token" {
t.Fatalf("authorization = %q, want openlist-token", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 200,
"data": map[string]any{"content": []any{}, "total": 0},
})
}))
defer openlist.Close()
db, err := gorm.Open(sqlite.Open("file:storage_config_cloud_test?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.StorageConfig{}, &model.Setting{}, &model.Library{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
log := zap.NewNop()
storage := service.NewStorageConfigService(log, repos, service.NewCryptoService("", log))
enabled := false
if _, err := storage.Save(t.Context(), service.StorageInput{
Type: "openlist",
Config: map[string]any{
"server": openlist.URL,
"token": "openlist-token",
},
Enabled: &enabled,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
router.POST("/admin/storage/:type/test", testStorageConfigHandler(&service.Container{StorageCfg: storage}))
body := `{"type":"openlist","config":{"server":"` + openlist.URL + `","token":"openlist-token"}}`
req := httptest.NewRequest(http.MethodPost, "/admin/storage/openlist/test", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"ok":true`) {
t.Fatalf("body = %s, want ok true", w.Body.String())
}
view, err := storage.Get(t.Context(), "openlist")
if err != nil {
t.Fatal(err)
}
if view == nil || !view.Enabled {
t.Fatalf("openlist enabled = %#v, want enabled after successful test", view)
}
if _, err := storage.CloudProvider(t.Context(), "openlist"); err != nil {
t.Fatalf("cloud provider after test: %v", err)
}
}
+11
View File
@@ -182,11 +182,15 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
reclassified := reclassifyMediaAfterScrape(c.Request.Context(), svc, m.ID)
refreshed, _ := svc.Repo.Media.FindByID(c.Request.Context(), m.ID)
metrics := map[string]int64{"processed": 1}
if refreshed != nil && refreshed.ScrapeStatus == "matched" {
metrics["matched"] = 1
}
if reclassified > 0 {
metrics["reclassified"] = int64(reclassified)
}
finishHTTPTask(task, nil, "completed", "手动刮削媒体结束", metrics, nil)
c.JSON(http.StatusOK, refreshed)
}
@@ -212,11 +216,18 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
// pushes per-item progress on the "scrape" topic.
go func(libID string, task *service.TaskHandle, options service.ScrapeOptions) {
result, err := svc.Scraper.EnrichLibraryDetailedWithOptions(context.Background(), libID, options)
reclassified := 0
if result.Processed > 0 {
reclassified = reclassifyLibraryAfterScrape(context.Background(), svc, libID)
}
metrics := map[string]int64{
"matched": int64(result.Matched),
"processed": int64(result.Processed),
"candidates": int64(result.Candidates),
}
if reclassified > 0 {
metrics["reclassified"] = int64(reclassified)
}
if result.Failed > 0 {
metrics["errors"] = int64(result.Failed)
}
+38 -22
View File
@@ -11,28 +11,32 @@ import (
)
type subscriptionPatchReq struct {
Name *string `json:"name"`
FeedURL *string `json:"feed_url"`
Filter *string `json:"filter"`
MediaType *string `json:"media_type"`
MediaCategory *string `json:"media_category"`
SavePath *string `json:"save_path"`
SearchMode *string `json:"search_mode"`
IMDBID *string `json:"imdb_id"`
Source *string `json:"source"`
PosterURL *string `json:"poster_url"`
BackdropURL *string `json:"backdrop_url"`
Overview *string `json:"overview"`
Resolution *string `json:"resolution"`
Quality *string `json:"quality"`
Effects *string `json:"effects"`
ReleaseGroups *string `json:"release_groups"`
ExcludeWords *string `json:"exclude_words"`
WashEnabled *bool `json:"wash_enabled"`
WashPriority *string `json:"wash_priority"`
TotalEpisodes *int `json:"total_episodes"`
Priority *int `json:"priority"`
Enabled *bool `json:"enabled"`
Name *string `json:"name"`
FeedURL *string `json:"feed_url"`
Filter *string `json:"filter"`
MediaType *string `json:"media_type"`
MediaCategory *string `json:"media_category"`
SavePath *string `json:"save_path"`
SearchMode *string `json:"search_mode"`
IMDBID *string `json:"imdb_id"`
Source *string `json:"source"`
PosterURL *string `json:"poster_url"`
BackdropURL *string `json:"backdrop_url"`
Overview *string `json:"overview"`
OriginalName *string `json:"original_name"`
Year *int `json:"year"`
Rating *float32 `json:"rating"`
Genres *string `json:"genres"`
Resolution *string `json:"resolution"`
Quality *string `json:"quality"`
Effects *string `json:"effects"`
ReleaseGroups *string `json:"release_groups"`
ExcludeWords *string `json:"exclude_words"`
WashEnabled *bool `json:"wash_enabled"`
WashPriority *string `json:"wash_priority"`
TotalEpisodes *int `json:"total_episodes"`
Priority *int `json:"priority"`
Enabled *bool `json:"enabled"`
}
// updateSubscriptionHandler patches a subscription row.
@@ -97,6 +101,18 @@ func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any {
if patch.Overview != nil {
updates["overview"] = *patch.Overview
}
if patch.OriginalName != nil {
updates["original_name"] = *patch.OriginalName
}
if patch.Year != nil {
updates["year"] = *patch.Year
}
if patch.Rating != nil {
updates["rating"] = *patch.Rating
}
if patch.Genres != nil {
updates["genres"] = *patch.Genres
}
if patch.Resolution != nil {
updates["resolution"] = *patch.Resolution
}
+2 -1
View File
@@ -2,6 +2,7 @@
package handler
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
@@ -96,8 +97,8 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
enrichAndPersistSubscriptions(c.Request.Context(), svc, items)
svc.Subscription.EnrichProgress(c.Request.Context(), items)
go enrichAndPersistSubscriptions(context.Background(), svc, append([]model.Subscription(nil), items...))
c.JSON(http.StatusOK, gin.H{"items": items})
}
}