diff --git a/internal/handler/media.go b/internal/handler/media.go index 876fb22..676c7ef 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -45,6 +45,16 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { } libs = filtered } + withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true" + if withPreview { + previews, err := svc.Media.ListLibrariesWithPreview(c.Request.Context(), libs, mediaVisibilityForRequest(c, svc), 10) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, previews) + return + } c.JSON(http.StatusOK, libs) } } diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index d0b6966..d3905c2 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -114,6 +114,35 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID return items, total, err } +type libraryCountRow struct { + LibraryID string `gorm:"column:library_id"` + Total int64 `gorm:"column:total"` +} + +// CountByLibraries returns a map of library_id -> total media count for the given library IDs. +func (r *MediaRepository) CountByLibraries(ctx context.Context, libraryIDs []string, filter MediaQueryFilter) (map[string]int64, error) { + out := make(map[string]int64, len(libraryIDs)) + if len(libraryIDs) == 0 { + return out, nil + } + var rows []libraryCountRow + q := r.db.WithContext(ctx).Model(&model.Media{}). + Select("library_id, count(*) as total") + if len(libraryIDs) == 1 { + q = q.Where("library_id = ?", libraryIDs[0]) + } else { + q = q.Where("library_id IN ?", libraryIDs) + } + q = applyMediaQueryFilter(q, filter) + if err := q.Group("library_id").Scan(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + out[row.LibraryID] = row.Total + } + return out, nil +} + // DeleteByLibrary purges all media tied to a library. func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) error { // FTS 行由 media 表上的触发器同步清理(物理删除触发 FTS 清理)。 diff --git a/internal/service/media_library.go b/internal/service/media_library.go index 528e551..21e1ed3 100644 --- a/internal/service/media_library.go +++ b/internal/service/media_library.go @@ -4,14 +4,81 @@ import ( "context" "github.com/ShukeBta/MMTL/internal/model" + "github.com/ShukeBta/MMTL/internal/repository" "gorm.io/gorm" ) +type LibraryPreviewItem struct { + model.Library + Total int64 `json:"total"` + Cards []SeriesCard `json:"cards"` +} + // ListLibraries returns every library configured on the server. func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) { return s.repo.Library.List(ctx) } +// ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards. +func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) { + if cardLimit <= 0 { + cardLimit = 10 + } + out := make([]LibraryPreviewItem, len(libraries)) + if len(libraries) == 0 { + return out, nil + } + + libIDs := make([]string, 0, len(libraries)) + for i, lib := range libraries { + out[i] = LibraryPreviewItem{ + Library: lib, + Total: 0, + Cards: []SeriesCard{}, + } + libIDs = append(libIDs, lib.ID) + } + + visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) + filter := repository.MediaQueryFilter{ + IncludeNSFW: visibility.IncludeNSFW, + AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, + } + + counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter) + if err != nil { + return nil, err + } + + for i := range out { + if total, ok := counts[out[i].ID]; ok { + out[i].Total = total + } + } + + for i := range out { + if out[i].Total == 0 { + continue + } + items, _, err := s.repo.Media.ListByLibrariesFiltered(ctx, []string{out[i].ID}, 0, 60, filter) + if err != nil { + continue + } + s.attachLibraryMetadata(ctx, items) + cards := groupMediaSeriesCards(items) + if len(cards) > cardLimit { + cards = cards[:cardLimit] + } + if cards == nil { + cards = []SeriesCard{} + } + out[i].Cards = cards + } + + return out, nil +} + // DeleteLibrary removes a library and its media rows. The on-disk files are // left untouched. func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error { diff --git a/internal/service/media_library_test.go b/internal/service/media_library_test.go new file mode 100644 index 0000000..b7e48e9 --- /dev/null +++ b/internal/service/media_library_test.go @@ -0,0 +1,94 @@ +package service + +import ( + "fmt" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MMTL/internal/config" + "github.com/ShukeBta/MMTL/internal/model" + "github.com/ShukeBta/MMTL/internal/repository" +) + +func TestListLibrariesWithPreview(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + + lib1 := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib1); err != nil { + t.Fatal(err) + } + lib2 := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib2); err != nil { + t.Fatal(err) + } + + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + var rows []model.Media + + // Add 5 movies to lib1 + for i := 1; i <= 5; i++ { + rows = append(rows, model.Media{ + Base: model.Base{ID: fmt.Sprintf("movie-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Hour)}, + LibraryID: lib1.ID, + Title: fmt.Sprintf("电影%d", i), + Path: fmt.Sprintf("/media/movies/电影%d/movie%d.mp4", i, i), + PosterURL: fmt.Sprintf("/api/media/movie-%02d/poster", i), + }) + } + + // Add 12 episodes of 1 anime to lib2 + for i := 1; i <= 12; i++ { + rows = append(rows, model.Media{ + Base: model.Base{ID: fmt.Sprintf("anime-ep-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)}, + LibraryID: lib2.ID, + Title: fmt.Sprintf("某动漫 第%d集", i), + Path: fmt.Sprintf("/media/anime/某动漫/Season 01/某动漫.S01E%02d.mp4", i), + SeasonNum: 1, + EpisodeNum: i, + PosterURL: "/api/media/anime-01/poster", + }) + } + + if err := repos.DB.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + + previews, err := svc.ListLibrariesWithPreview(t.Context(), []model.Library{lib1, lib2}, MediaVisibility{IncludeNSFW: true}, 10) + if err != nil { + t.Fatalf("ListLibrariesWithPreview failed: %v", err) + } + + if len(previews) != 2 { + t.Fatalf("got %d previews, want 2", len(previews)) + } + + // Verify lib1 (movies) + if previews[0].ID != lib1.ID { + t.Errorf("preview[0].ID = %q, want %q", previews[0].ID, lib1.ID) + } + if previews[0].Total != 5 { + t.Errorf("preview[0].Total = %d, want 5", previews[0].Total) + } + if len(previews[0].Cards) != 5 { + t.Errorf("preview[0].Cards count = %d, want 5", len(previews[0].Cards)) + } + + // Verify lib2 (anime) + if previews[1].ID != lib2.ID { + t.Errorf("preview[1].ID = %q, want %q", previews[1].ID, lib2.ID) + } + if previews[1].Total != 12 { + t.Errorf("preview[1].Total = %d, want 12", previews[1].Total) + } + // 12 episodes should be grouped into 1 SeriesCard with Count = 12 + if len(previews[1].Cards) != 1 { + t.Errorf("preview[1].Cards count = %d, want 1", len(previews[1].Cards)) + } else if previews[1].Cards[0].Count != 12 { + t.Errorf("preview[1].Cards[0].Count = %d, want 12", previews[1].Cards[0].Count) + } +} diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 05d23d6..dc97634 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -81,11 +81,19 @@ export interface MediaMetadataUpdate { nsfw?: boolean } +export interface LibraryWithPreview extends Library { + total?: number + cards?: SeriesCard[] +} + export const libraryAPI = { - list: (options?: { includeHidden?: boolean }) => + list: (options?: { includeHidden?: boolean; withPreview?: boolean }) => api - .get('/libraries', { - params: options?.includeHidden ? { include_hidden: 1 } : undefined, + .get('/libraries', { + params: { + ...(options?.includeHidden ? { include_hidden: 1 } : {}), + ...(options?.withPreview ? { with_preview: 1 } : {}), + }, }) .then((r) => r.data), diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index c56ae6b..6f59f0d 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -8,7 +8,7 @@ import { LibrariesEmptyState, LibrariesHeader, } from './LibrariesPageSections' -import { isSeriesLibraryType, latestLibraryCards, type LibraryPreview } from './librariesPageModel' +import type { LibraryPreview } from './librariesPageModel' export function LibrariesPage() { const [previews, setPreviews] = useState([]) @@ -20,24 +20,15 @@ export function LibrariesPage() { const loadLibraries = useCallback(async () => { setLoading(true) try { - const libs = await libraryAPI.list() - const rows = await Promise.all(libs.map(async (library) => { - try { - if (isSeriesLibraryType(library.type)) { - const [seriesPage, mediaPage] = await Promise.all([ - libraryAPI.listSeries(library.id, 1, 10), - libraryAPI.listMedia(library.id, 1, 1, { groupVersions: false }), - ]) - return { library, items: [], total: mediaPage.total, cards: seriesPage.items ?? [] } satisfies LibraryPreview - } - const page = await libraryAPI.listMedia(library.id, 1, 160, { groupVersions: false }) - const cards = latestLibraryCards(page.items) - return { library, items: page.items, total: page.total, cards } satisfies LibraryPreview - } catch { - return { library, items: [], total: 0, cards: [] } satisfies LibraryPreview - } - })) - setPreviews(rows) + const libs = await libraryAPI.list({ withPreview: true }) + setPreviews( + libs.map((library) => ({ + library, + items: [], + total: library.total ?? 0, + cards: library.cards ?? [], + })), + ) } finally { setLoading(false) } diff --git a/web/src/pages/librariesPageModel.ts b/web/src/pages/librariesPageModel.ts index fa95d92..30aec17 100644 --- a/web/src/pages/librariesPageModel.ts +++ b/web/src/pages/librariesPageModel.ts @@ -3,7 +3,7 @@ import { artworkScore, groupSeries, type SeriesCard } from '../utils/groupSeries export type LibraryPreview = { library: Library - items: Media[] + items?: Media[] total: number cards: SeriesCard[] }