diff --git a/internal/handler/emby_items_handlers.go b/internal/handler/emby_items_handlers.go index d182880..3018dad 100644 --- a/internal/handler/emby_items_handlers.go +++ b/internal/handler/emby_items_handlers.go @@ -144,8 +144,24 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc { } } -func embyItemsCountsHandler(_ *service.Container) gin.HandlerFunc { +func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { + if svc != nil && svc.Emby != nil { + uid := firstQueryValue(c, "UserId", "userId") + if uid == "" { + uid = c.Param("userId") + } + if uid == "" { + uid = embyUserID(c) + } + out, err := svc.Emby.ItemCounts(c.Request.Context(), uid) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, out) + return + } c.JSON(http.StatusOK, gin.H{ "MovieCount": 0, "SeriesCount": 0, @@ -166,18 +182,18 @@ func embyDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc { "RememberIndexing": false, "PrimaryImageHeight": 250, "PrimaryImageWidth": 250, - "ScrollDirection": "Horizontal", + "ScrollDirection": "Vertical", "ShowSidebar": true, "CustomPrefs": gin.H{ "homeexploresection": "1", "homesection0": "smalllibrarytiles", "homesection1": "resume", - "homesection2": "latestmedia", + "homesection2": "none", "homesection3": "nextup", "homesection4": "none", "homesection5": "none", "homesection6": "none", - "latestItems": "true", + "latestItems": "false", "landing-livetv": "false", }, }) diff --git a/internal/handler/emby_misc_routes_test.go b/internal/handler/emby_misc_routes_test.go index 9c6d527..3317590 100644 --- a/internal/handler/emby_misc_routes_test.go +++ b/internal/handler/emby_misc_routes_test.go @@ -146,8 +146,11 @@ func TestEmbyDisplayPreferencesAllowsAnonymousCompatibility(t *testing.T) { if !ok { t.Fatalf("missing CustomPrefs: %#v", body) } - if customPrefs["homesection0"] != "smalllibrarytiles" || customPrefs["homesection2"] != "latestmedia" { - t.Fatalf("homepage sections should expose library tiles and latest media: %#v", customPrefs) + if customPrefs["homesection0"] != "smalllibrarytiles" || customPrefs["homesection2"] != "none" || customPrefs["latestItems"] != "false" { + t.Fatalf("homepage sections should expose library tiles without duplicate latest rails: %#v", customPrefs) + } + if body["ScrollDirection"] != "Vertical" { + t.Fatalf("homepage should prefer vertical library browsing, got %#v", body) } req = httptest.NewRequest(http.MethodPost, "/emby/displaypreferences/usersettings", strings.NewReader(`{}`)) diff --git a/internal/handler/media_extra.go b/internal/handler/media_extra.go index c0bb4d1..9d88dc9 100644 --- a/internal/handler/media_extra.go +++ b/internal/handler/media_extra.go @@ -18,7 +18,7 @@ func recentMediaHandler(svc *service.Container) gin.HandlerFunc { if limit <= 0 || limit > 100 { limit = 12 } - items, err := svc.Media.SearchMediaVisible(c.Request.Context(), "", limit, mediaVisibilityForRequest(c, svc)) + items, err := svc.Media.ListRecentSeriesCards(c.Request.Context(), limit, mediaVisibilityForRequest(c, svc)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/handler/telegram_webhook.go b/internal/handler/telegram_webhook.go index fef3785..e1abae8 100644 --- a/internal/handler/telegram_webhook.go +++ b/internal/handler/telegram_webhook.go @@ -4,8 +4,10 @@ package handler import ( + "context" "io" "net/http" + "time" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -83,7 +85,10 @@ func telegramGetWebhookHandler(svc *service.Container) gin.HandlerFunc { // 路由:POST /api/admin/telegram/polling/start (需 admin 认证) func telegramStartPollingHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - result := svc.TelegramBot.StartPolling(c.Request.Context()) + setupCtx, cancel := context.WithTimeout(svc.Context(), 30*time.Second) + defer cancel() + + result := svc.TelegramBot.StartPolling(setupCtx) c.JSON(http.StatusOK, result) } } diff --git a/internal/service/download_add.go b/internal/service/download_add.go index a11caa7..29659f5 100644 --- a/internal/service/download_add.go +++ b/internal/service/download_add.go @@ -55,7 +55,7 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt } _ = d.ReloadConfig(ctx) if !d.qb.IsConfigured() { - return nil, errors.New("no default downloader configured") + return nil, d.defaultDownloaderNotConfiguredError(ctx) } if d.torrentExistsByIdentity(ctx, req) { task, err := d.createTask(ctx, userID, urlStr, req.savePath, req.meta) diff --git a/internal/service/download_config_test.go b/internal/service/download_config_test.go index 237b70f..17ec090 100644 --- a/internal/service/download_config_test.go +++ b/internal/service/download_config_test.go @@ -3,6 +3,7 @@ package service import ( "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" @@ -176,6 +177,9 @@ func TestAddDownloadWithMetaFailsClosedWhenNoDownloaderConfigured(t *testing.T) if err == nil { t.Fatal("expected no downloader configured error") } + if !strings.Contains(err.Error(), "当前没有已启用的下载器") { + t.Fatalf("err = %v, want enabled downloader guidance", err) + } if task != nil { t.Fatalf("task = %#v, want nil", task) } @@ -301,3 +305,30 @@ func TestReloadConfigManagedModeDoesNotFallbackToLegacyWithoutRows(t *testing.T) t.Fatalf("qb add calls = %d, want 0", got) } } + +func TestAddDownloadWithMetaExplainsUnsupportedEnabledDownloader(t *testing.T) { + db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) + repos := repository.New(db) + if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { + t.Fatal(err) + } + if err := repos.DownloadClient.Create(t.Context(), &model.DownloadClient{ + Name: "aria2", + Type: "aria2", + Host: "http://127.0.0.1:6800", + Enabled: true, + }); err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + _, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ + Title: "Movie 2026 1080p", + }) + if err == nil { + t.Fatal("expected unsupported downloader error") + } + if !strings.Contains(err.Error(), "订阅投递目前需要 qBittorrent") { + t.Fatalf("err = %v, want qBittorrent guidance", err) + } +} diff --git a/internal/service/downloads_config_runtime.go b/internal/service/downloads_config_runtime.go index 9223e69..9506045 100644 --- a/internal/service/downloads_config_runtime.go +++ b/internal/service/downloads_config_runtime.go @@ -2,6 +2,8 @@ package service import ( "context" + "errors" + "fmt" "strings" "go.uber.org/zap" @@ -89,3 +91,36 @@ func (d *DownloadService) preferredEnabledQBitClient(ctx context.Context) (*mode } return selected, nil } + +func (d *DownloadService) defaultDownloaderNotConfiguredError(ctx context.Context) error { + const prefix = "no default downloader configured" + if d == nil || d.repo == nil || d.repo.DownloadClient == nil { + return errors.New(prefix + ": 请在下载客户端中配置并启用 qBittorrent") + } + rows, err := d.repo.DownloadClient.ListEnabled(ctx) + if err != nil { + return fmt.Errorf("%s: 读取下载客户端配置失败: %w", prefix, err) + } + if len(rows) == 0 { + return errors.New(prefix + ": 请在下载客户端中启用 qBittorrent 并设为默认;当前没有已启用的下载器") + } + + var enabled []string + var hasQBit bool + for _, row := range rows { + label := strings.TrimSpace(row.Name) + if label == "" { + label = row.Type + } else if row.Type != "" { + label += "(" + row.Type + ")" + } + enabled = append(enabled, label) + if strings.EqualFold(strings.TrimSpace(row.Type), "qbittorrent") { + hasQBit = true + } + } + if !hasQBit { + return fmt.Errorf("%s: 订阅投递目前需要 qBittorrent;当前启用的下载器为 %s", prefix, strings.Join(enabled, ", ")) + } + return errors.New(prefix + ": 请在下载客户端中选择一个启用的 qBittorrent 作为默认下载器") +} diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 23dc8ea..dd26123 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -113,7 +113,7 @@ const ( embyVirtualSeasonPrefix = "msgo-season-" embyVirtualCacheTTL = 10 * time.Minute embyVisibilityCacheTTL = 30 * time.Second - embySeriesGroupingLimit = 5000 + embySeriesGroupingLimit = maxMediaSearchLimit ) var ( diff --git a/internal/service/emby_counts.go b/internal/service/emby_counts.go new file mode 100644 index 0000000..c1ceb05 --- /dev/null +++ b/internal/service/emby_counts.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "gorm.io/gorm" +) + +func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string]any, error) { + base := func() *gorm.DB { + q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL") + return e.applyUserMediaVisibility(ctx, q, userID) + } + + var itemCount int64 + if err := base().Count(&itemCount).Error; err != nil { + return nil, err + } + + var movieCount int64 + if err := e.filterMovieItems(ctx, base()).Count(&movieCount).Error; err != nil { + return nil, err + } + + var episodeCount int64 + if err := e.filterEpisodeItems(ctx, base()).Count(&episodeCount).Error; err != nil { + return nil, err + } + + seriesCount, err := e.countVisibleSeries(ctx, userID) + if err != nil { + return nil, err + } + + return map[string]any{ + "MovieCount": movieCount, + "SeriesCount": seriesCount, + "EpisodeCount": episodeCount, + "ItemCount": itemCount, + }, nil +} + +func (e *EmbyService) countVisibleSeries(ctx context.Context, userID string) (int, error) { + q := e.repo.DB.WithContext(ctx).Model(&model.Media{}). + Select("id, library_id, series_id, title, original_name, path, season_num, episode_num"). + Where("season_num > 0 OR episode_num > 0") + q = e.applyUserMediaVisibility(ctx, q, userID) + + seen := map[string]struct{}{} + var rows []model.Media + err := q.Order("media.id asc").FindInBatches(&rows, 1000, func(tx *gorm.DB, batch int) error { + for i := range rows { + key := strings.TrimSpace(rows[i].SeriesID) + if key == "" { + key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(&rows[i])) + } + seen[key] = struct{}{} + } + return nil + }).Error + if err != nil { + return 0, err + } + return len(seen), nil +} diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 387dd6a..f87ca1c 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -122,18 +122,11 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l if limit <= 0 || limit > 100 { limit = 20 } - rowLimit := limit * 40 - if rowLimit < 200 { - rowLimit = 200 - } - if rowLimit > embySeriesGroupingLimit { - rowLimit = embySeriesGroupingLimit - } q := e.repo.DB.WithContext(ctx).Model(&model.Media{}). Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID)) q = e.applyUserMediaVisibility(ctx, q, userID) var rows []model.Media - if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil { + if err := q.Order("media.created_at desc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { return nil, err } groups := e.seriesGroupsFromMedia(rows) diff --git a/internal/service/emby_items_list.go b/internal/service/emby_items_list.go index d819a45..bff6f29 100644 --- a/internal/service/emby_items_list.go +++ b/internal/service/emby_items_list.go @@ -230,15 +230,8 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin } q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID) } - rowLimit := p.StartIndex + maxInt(p.Limit*40, 1000) - if rowLimit < p.Limit { - rowLimit = p.Limit - } - if rowLimit > embySeriesGroupingLimit { - rowLimit = embySeriesGroupingLimit - } var rows []model.Media - if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil { + if err := q.Order("media.created_at desc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { return nil, err } groups := e.seriesGroupsFromMedia(rows) diff --git a/internal/service/emby_series_hierarchy_test.go b/internal/service/emby_series_hierarchy_test.go index cfb4741..9600fa0 100644 --- a/internal/service/emby_series_hierarchy_test.go +++ b/internal/service/emby_series_hierarchy_test.go @@ -2,7 +2,9 @@ package service import ( "context" + "fmt" "testing" + "time" "github.com/ShukeBta/MediaStationGo/internal/model" ) @@ -97,6 +99,63 @@ func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(t *testing.T) { } } +func TestEmbySeriesGroupingPaginatesAfterFullLibraryGrouping(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "国漫", Path: `/media/anime`, Type: "anime", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + rows := make([]model.Media, 0, 25*40) + for series := 1; series <= 25; series++ { + for episode := 1; episode <= 40; episode++ { + created := now.Add(time.Duration(series*1000+episode) * time.Second) + rows = append(rows, model.Media{ + Base: model.Base{ID: fmt.Sprintf("show-%02d-ep-%02d", series, episode), CreatedAt: created, UpdatedAt: created}, + LibraryID: lib.ID, + Title: fmt.Sprintf("测试番 %02d", series), + Path: fmt.Sprintf(`/media/anime/测试番 %02d/Season 01/测试番 %02d.S01E%02d.mkv`, series, series, episode), + SeasonNum: 1, + EpisodeNum: episode, + }) + } + } + if err := svc.repo.DB.CreateInBatches(rows, 200).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 20}) + if err != nil { + t.Fatalf("library items: %v", err) + } + if root["TotalRecordCount"] != 25 { + t.Fatalf("series total = %#v, want 25", root["TotalRecordCount"]) + } + rootItems := root["Items"].([]map[string]any) + if len(rootItems) != 20 { + t.Fatalf("first page series len = %d, want 20", len(rootItems)) + } + if rootItems[0]["RecursiveItemCount"] != 40 { + t.Fatalf("first series episode count = %#v, want 40", rootItems[0]["RecursiveItemCount"]) + } + + latest, err := svc.LatestItems(t.Context(), "user-1", lib.ID, 25) + if err != nil { + t.Fatalf("latest items: %v", err) + } + if len(latest) != 25 { + t.Fatalf("latest series len = %d, want 25", len(latest)) + } + + counts, err := svc.ItemCounts(t.Context(), "user-1") + if err != nil { + t.Fatalf("item counts: %v", err) + } + if counts["SeriesCount"] != 25 || counts["EpisodeCount"] != int64(1000) { + t.Fatalf("counts = %#v, want 25 series and 1000 episodes", counts) + } +} + func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) { svc := newTestEmbyService(t) lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true} diff --git a/internal/service/media_path_normalize.go b/internal/service/media_path_normalize.go new file mode 100644 index 0000000..4eec80e --- /dev/null +++ b/internal/service/media_path_normalize.go @@ -0,0 +1,51 @@ +package service + +import ( + "os" + "path/filepath" + "strings" +) + +func cleanPathForVolumeMapping(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + path = strings.ReplaceAll(path, "\\", "/") + path = trimEmbeddedWindowsDrive(path) + return filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) +} + +func pathAfterWindowsDrivePrefix(path string) string { + if len(path) >= 3 && path[1] == ':' && path[2] == '/' && isASCIIAlpha(path[0]) { + return path[2:] + } + return path +} + +func trimEmbeddedWindowsDrive(path string) string { + for i := 0; i+2 < len(path); i++ { + if !isASCIIAlpha(path[i]) || path[i+1] != ':' || path[i+2] != '/' { + continue + } + if i == 0 || path[i-1] == '/' { + return path[i:] + } + } + return path +} + +func isASCIIAlpha(ch byte) bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') +} + +func sameLibraryPath(a, b string) bool { + return filepath.Clean(a) == filepath.Clean(b) +} + +func envOrDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} diff --git a/internal/service/media_paths.go b/internal/service/media_paths.go index d8644bc..03e10a9 100644 --- a/internal/service/media_paths.go +++ b/internal/service/media_paths.go @@ -278,47 +278,3 @@ func isRelativeVolumeMarkerPath(path string) bool { } return false } - -func cleanPathForVolumeMapping(path string) string { - path = strings.TrimSpace(path) - if path == "" { - return "" - } - path = strings.ReplaceAll(path, "\\", "/") - path = trimEmbeddedWindowsDrive(path) - return filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) -} - -func pathAfterWindowsDrivePrefix(path string) string { - if len(path) >= 3 && path[1] == ':' && path[2] == '/' && isASCIIAlpha(path[0]) { - return path[2:] - } - return path -} - -func trimEmbeddedWindowsDrive(path string) string { - for i := 0; i+2 < len(path); i++ { - if !isASCIIAlpha(path[i]) || path[i+1] != ':' || path[i+2] != '/' { - continue - } - if i == 0 || path[i-1] == '/' { - return path[i:] - } - } - return path -} - -func isASCIIAlpha(ch byte) bool { - return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') -} - -func sameLibraryPath(a, b string) bool { - return filepath.Clean(a) == filepath.Clean(b) -} - -func envOrDefault(key, fallback string) string { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return value - } - return fallback -} diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 30dc827..10f7c76 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -31,6 +31,26 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str return cards, int64(len(cards)), nil } +func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) { + if limit <= 0 { + limit = 24 + } else if limit > 100 { + limit = 100 + } + rows, err := s.SearchMediaVisible(ctx, "", maxMediaSearchLimit, visibility) + if err != nil { + return nil, err + } + cards := groupMediaSeriesCards(rows) + if len(cards) == 0 { + return []SeriesCard{}, nil + } + if len(cards) > limit { + cards = cards[:limit] + } + return cards, nil +} + func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID, key string, visibility MediaVisibility) ([]model.Media, error) { rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility) if err != nil { diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index f9a3238..86ad538 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -1,12 +1,57 @@ package service import ( + "fmt" "testing" "time" + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" ) +func TestListRecentSeriesCardsCountsAllEpisodesInSeries(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Name: "国漫", Path: "/media/anime", Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + rows := make([]model.Media, 0, 40) + for i := 1; i <= 40; i++ { + created := now.Add(-48 * time.Hour) + if i > 23 { + created = now.Add(time.Duration(i) * time.Minute) + } + rows = append(rows, model.Media{ + Base: model.Base{ID: fmt.Sprintf("recent-ep-%02d", i), CreatedAt: created, UpdatedAt: created}, + LibraryID: lib.ID, + Title: "史上最强炼体老祖", + Path: fmt.Sprintf("/media/anime/国漫/史上最强炼体老祖/Season 01/史上最强炼体老祖.S01E%02d.mkv", i), + SeasonNum: 1, + EpisodeNum: i, + }) + } + if err := repos.DB.Create(&rows).Error; err != nil { + t.Fatal(err) + } + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + + cards, err := svc.ListRecentSeriesCards(t.Context(), 24, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if len(cards) != 1 { + t.Fatalf("recent cards = %#v, want one series card", cards) + } + if cards[0].Count != 40 { + t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count) + } +} + func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) { main := model.Media{ LibraryID: "lib-tv", diff --git a/internal/service/organizer_classify.go b/internal/service/organizer_classify.go index 25016a3..985ef21 100644 --- a/internal/service/organizer_classify.go +++ b/internal/service/organizer_classify.go @@ -2,6 +2,7 @@ package service import ( "context" + "path/filepath" "strings" "github.com/ShukeBta/MediaStationGo/internal/model" @@ -34,6 +35,42 @@ func (o *OrganizerService) SmartClassify(ctx context.Context, m *model.Media) st return o.classifyMedia(ctx, m, lib.Type) } +func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sourceRoot, mediaType, title, parsedTitle string, metadataMatch *Match) string { + if o == nil || !o.isSmartClassifyEnabled(ctx) { + return "" + } + seriesLike := isSeriesLibraryType(mediaType) + input := mediaClassifyInput{ + MediaType: mediaType, + Title: strings.Join([]string{title, parsedTitle, filepath.Base(src)}, " "), + Category: strings.Join(organizeDirectoryCategoryCandidates(src, sourceRoot), " "), + } + if metadataMatch != nil { + input.Title = strings.Join([]string{ + metadataMatch.OriginalName, + title, + parsedTitle, + filepath.Base(src), + }, " ") + input.Languages = metadataMatch.Languages + input.Countries = metadataMatch.Countries + input.Genres = metadataMatch.Genres + if metadataMatch.NSFW { + input.MediaType = "adult" + } + } + if meta, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && meta != nil && meta.HasNFO { + input.Title = strings.Join([]string{meta.Title, meta.OriginalName, title, parsedTitle, filepath.Base(src)}, " ") + input.Languages = parseCommaList(meta.Languages) + input.Countries = parseCommaList(meta.Countries) + input.Genres = parseCommaList(meta.Genres) + if meta.NSFW { + input.MediaType = "adult" + } + } + return sanitizeFilename(classifyMediaCategory(input, o.categoryMap())) +} + // parseCommaList splits a comma-separated string into trimmed non-empty values. func parseCommaList(s string) []string { if s == "" { diff --git a/internal/service/organizer_directory_metadata.go b/internal/service/organizer_directory_metadata.go index 06de588..ea92722 100644 --- a/internal/service/organizer_directory_metadata.go +++ b/internal/service/organizer_directory_metadata.go @@ -2,7 +2,6 @@ package service import ( "context" - "fmt" "path/filepath" "strings" @@ -193,145 +192,3 @@ func (o *OrganizerService) lookupOrganizeAdultMetadata(ctx context.Context, src, } return nil } - -func organizeMetadataMatchTrusted(query string, sourceYear int, match *Match) bool { - if match == nil || strings.TrimSpace(match.Title) == "" { - return false - } - if unsafeAutomaticEpisodeQuery(query) { - return false - } - if sourceYear > 0 && match.Year > 0 { - diff := sourceYear - match.Year - if diff < 0 { - diff = -diff - } - if diff > 1 { - return false - } - } - return automaticMetadataTitleTrusted(query, match) -} - -func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match { - if local == nil || strings.TrimSpace(local.Title) == "" { - return nil - } - match := &Match{ - Title: strings.TrimSpace(local.Title), - OriginalName: strings.TrimSpace(local.OriginalName), - Overview: local.Overview, - PosterURL: local.PosterURL, - BackdropURL: local.BackdropURL, - Year: local.Year, - Rating: local.Rating, - TMDbID: local.TMDbID, - DoubanID: local.DoubanID, - TheTVDBID: local.TheTVDBID, - NSFW: local.NSFW, - } - if local.Genres != "" { - match.Genres = splitNFOList(local.Genres) - } - if local.Countries != "" { - match.Countries = splitNFOList(local.Countries) - } - if local.Languages != "" { - match.Languages = splitNFOList(local.Languages) - } - return match -} - -func (o *OrganizerService) lookupOrganizeSourceMedia(ctx context.Context, path string) *model.Media { - if o == nil || o.repo == nil || o.repo.DB == nil { - return nil - } - path = filepath.Clean(strings.TrimSpace(path)) - if path == "" || path == "." { - return nil - } - var media model.Media - if err := o.repo.DB.WithContext(ctx). - Where("path = ? AND deleted_at IS NULL", path). - Limit(1). - Take(&media).Error; err != nil { - return nil - } - return &media -} - -func organizeMatchFromMedia(media *model.Media) *Match { - if media == nil || strings.TrimSpace(media.Title) == "" { - return nil - } - return &Match{ - TMDbID: media.TMDbID, - BangumiID: media.BangumiID, - DoubanID: strings.TrimSpace(media.DoubanID), - TheTVDBID: strings.TrimSpace(media.TheTVDBID), - Title: strings.TrimSpace(media.Title), - OriginalName: strings.TrimSpace(media.OriginalName), - Overview: media.Overview, - PosterURL: media.PosterURL, - BackdropURL: media.BackdropURL, - Year: media.Year, - Rating: media.Rating, - Languages: parseCommaList(media.Languages), - Countries: parseCommaList(media.Countries), - Genres: parseCommaList(media.Genres), - NSFW: media.NSFW, - } -} - -func applyOrganizeMetadataMatch(match *Match, title, parsedTitle *string, year *int) { - if match == nil { - return - } - if matchedTitle := sanitizeFilename(strings.TrimSpace(match.Title)); matchedTitle != "" { - *title = matchedTitle - *parsedTitle = strings.TrimSpace(match.Title) - } - if match.Year > 0 { - *year = match.Year - } -} - -func organizeMetadataCacheKey(mediaType, query string, year int) string { - return strings.ToLower(strings.TrimSpace(mediaType)) + "|" + fmt.Sprint(year) + "|" + strings.ToLower(strings.TrimSpace(query)) -} - -func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sourceRoot, mediaType, title, parsedTitle string, metadataMatch *Match) string { - if o == nil || !o.isSmartClassifyEnabled(ctx) { - return "" - } - seriesLike := isSeriesLibraryType(mediaType) - input := mediaClassifyInput{ - MediaType: mediaType, - Title: strings.Join([]string{title, parsedTitle, filepath.Base(src)}, " "), - Category: strings.Join(organizeDirectoryCategoryCandidates(src, sourceRoot), " "), - } - if metadataMatch != nil { - input.Title = strings.Join([]string{ - metadataMatch.OriginalName, - title, - parsedTitle, - filepath.Base(src), - }, " ") - input.Languages = metadataMatch.Languages - input.Countries = metadataMatch.Countries - input.Genres = metadataMatch.Genres - if metadataMatch.NSFW { - input.MediaType = "adult" - } - } - if meta, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && meta != nil && meta.HasNFO { - input.Title = strings.Join([]string{meta.Title, meta.OriginalName, title, parsedTitle, filepath.Base(src)}, " ") - input.Languages = parseCommaList(meta.Languages) - input.Countries = parseCommaList(meta.Countries) - input.Genres = parseCommaList(meta.Genres) - if meta.NSFW { - input.MediaType = "adult" - } - } - return sanitizeFilename(classifyMediaCategory(input, o.categoryMap())) -} diff --git a/internal/service/organizer_metadata_match.go b/internal/service/organizer_metadata_match.go new file mode 100644 index 0000000..f89cb0b --- /dev/null +++ b/internal/service/organizer_metadata_match.go @@ -0,0 +1,116 @@ +package service + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func organizeMetadataMatchTrusted(query string, sourceYear int, match *Match) bool { + if match == nil || strings.TrimSpace(match.Title) == "" { + return false + } + if unsafeAutomaticEpisodeQuery(query) { + return false + } + if sourceYear > 0 && match.Year > 0 { + diff := sourceYear - match.Year + if diff < 0 { + diff = -diff + } + if diff > 1 { + return false + } + } + return automaticMetadataTitleTrusted(query, match) +} + +func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match { + if local == nil || strings.TrimSpace(local.Title) == "" { + return nil + } + match := &Match{ + Title: strings.TrimSpace(local.Title), + OriginalName: strings.TrimSpace(local.OriginalName), + Overview: local.Overview, + PosterURL: local.PosterURL, + BackdropURL: local.BackdropURL, + Year: local.Year, + Rating: local.Rating, + TMDbID: local.TMDbID, + DoubanID: local.DoubanID, + TheTVDBID: local.TheTVDBID, + NSFW: local.NSFW, + } + if local.Genres != "" { + match.Genres = splitNFOList(local.Genres) + } + if local.Countries != "" { + match.Countries = splitNFOList(local.Countries) + } + if local.Languages != "" { + match.Languages = splitNFOList(local.Languages) + } + return match +} + +func (o *OrganizerService) lookupOrganizeSourceMedia(ctx context.Context, path string) *model.Media { + if o == nil || o.repo == nil || o.repo.DB == nil { + return nil + } + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + return nil + } + var media model.Media + if err := o.repo.DB.WithContext(ctx). + Where("path = ? AND deleted_at IS NULL", path). + Limit(1). + Take(&media).Error; err != nil { + return nil + } + return &media +} + +func organizeMatchFromMedia(media *model.Media) *Match { + if media == nil || strings.TrimSpace(media.Title) == "" { + return nil + } + return &Match{ + TMDbID: media.TMDbID, + BangumiID: media.BangumiID, + DoubanID: strings.TrimSpace(media.DoubanID), + TheTVDBID: strings.TrimSpace(media.TheTVDBID), + Title: strings.TrimSpace(media.Title), + OriginalName: strings.TrimSpace(media.OriginalName), + Overview: media.Overview, + PosterURL: media.PosterURL, + BackdropURL: media.BackdropURL, + Year: media.Year, + Rating: media.Rating, + Languages: parseCommaList(media.Languages), + Countries: parseCommaList(media.Countries), + Genres: parseCommaList(media.Genres), + NSFW: media.NSFW, + } +} + +func applyOrganizeMetadataMatch(match *Match, title, parsedTitle *string, year *int) { + if match == nil { + return + } + if matchedTitle := sanitizeFilename(strings.TrimSpace(match.Title)); matchedTitle != "" { + *title = matchedTitle + *parsedTitle = strings.TrimSpace(match.Title) + } + if match.Year > 0 { + *year = match.Year + } +} + +func organizeMetadataCacheKey(mediaType, query string, year int) string { + return strings.ToLower(strings.TrimSpace(mediaType)) + "|" + fmt.Sprint(year) + "|" + strings.ToLower(strings.TrimSpace(query)) +} diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 8b82e31..b717c1b 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -151,6 +151,9 @@ export const libraryAPI = { } export const mediaAPI = { + recent: (limit = 24) => + api.get('/media/recent', { params: { limit } }).then((r) => r.data), + search: (q: string, limit = 50) => api.get('/media', { params: { q, limit } }).then((r) => r.data), diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index e62af65..41c8842 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import { libraryAPI, mediaAPI } from '../api/library' import { playbackAPI, type HistoryItem } from '../api/playback' import type { Library, Media } from '../types' -import { groupSeries } from '../utils/groupSeries' +import { groupSeries, type SeriesCard } from '../utils/groupSeries' import { ContinueWatchingSection, HomeEmptyState, @@ -17,7 +17,7 @@ const asArray = (value: unknown): T[] => (Array.isArray(value) ? value as T[ export function HomePage() { const [libraries, setLibraries] = useState([]) - const [recent, setRecent] = useState([]) + const [recentCards, setRecentCards] = useState([]) const [history, setHistory] = useState([]) const [loading, setLoading] = useState(true) @@ -28,12 +28,15 @@ export function HomePage() { try { const [libs, recentItems, hist] = await Promise.all([ libraryAPI.list().then((rows) => asArray(rows)).catch(() => [] as Library[]), - mediaAPI.search('', 120).then((d) => asArray(d?.items)).catch(() => [] as Media[]), + mediaAPI.recent(24).then((rows) => asArray(rows)).catch(async () => { + const fallback = await mediaAPI.search('', 120).then((d) => asArray(d?.items)).catch(() => [] as Media[]) + return groupSeries(fallback).slice(0, 24) + }), playbackAPI.recentHistory().then((rows) => asArray(rows)).catch(() => [] as HistoryItem[]), ]) if (cancelled) return setLibraries(libs) - setRecent(recentItems) + setRecentCards(recentItems) setHistory(hist.filter((h) => h && !h.completed && !!h.media)) } finally { if (!cancelled) setLoading(false) @@ -43,15 +46,13 @@ export function HomePage() { return () => { cancelled = true } }, []) - const recentCards = useMemo(() => groupSeries(recent).slice(0, 24), [recent]) const featuredItem = useMemo(() => { const candidates = [ ...(history.map((h) => h.media).filter(Boolean) as Media[]), ...recentCards.map((card) => card.rep), - ...recent, ] return candidates.find(hasArtwork) ?? candidates[0] ?? null - }, [history, recentCards, recent]) + }, [history, recentCards]) const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || '' const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || '' const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()