mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 22:06:38 +08:00
优化
This commit is contained in:
@@ -45,6 +45,16 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
libs = filtered
|
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)
|
c.JSON(http.StatusOK, libs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,35 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
|
|||||||
return items, total, err
|
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.
|
// DeleteByLibrary purges all media tied to a library.
|
||||||
func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) error {
|
func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) error {
|
||||||
// FTS 行由 media 表上的触发器同步清理(物理删除触发 FTS 清理)。
|
// FTS 行由 media 表上的触发器同步清理(物理删除触发 FTS 清理)。
|
||||||
|
|||||||
@@ -4,14 +4,81 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/ShukeBta/MMTL/internal/model"
|
||||||
|
"github.com/ShukeBta/MMTL/internal/repository"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type LibraryPreviewItem struct {
|
||||||
|
model.Library
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Cards []SeriesCard `json:"cards"`
|
||||||
|
}
|
||||||
|
|
||||||
// ListLibraries returns every library configured on the server.
|
// ListLibraries returns every library configured on the server.
|
||||||
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
|
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
|
||||||
return s.repo.Library.List(ctx)
|
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
|
// DeleteLibrary removes a library and its media rows. The on-disk files are
|
||||||
// left untouched.
|
// left untouched.
|
||||||
func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-3
@@ -81,11 +81,19 @@ export interface MediaMetadataUpdate {
|
|||||||
nsfw?: boolean
|
nsfw?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LibraryWithPreview extends Library {
|
||||||
|
total?: number
|
||||||
|
cards?: SeriesCard[]
|
||||||
|
}
|
||||||
|
|
||||||
export const libraryAPI = {
|
export const libraryAPI = {
|
||||||
list: (options?: { includeHidden?: boolean }) =>
|
list: (options?: { includeHidden?: boolean; withPreview?: boolean }) =>
|
||||||
api
|
api
|
||||||
.get<Library[]>('/libraries', {
|
.get<LibraryWithPreview[]>('/libraries', {
|
||||||
params: options?.includeHidden ? { include_hidden: 1 } : undefined,
|
params: {
|
||||||
|
...(options?.includeHidden ? { include_hidden: 1 } : {}),
|
||||||
|
...(options?.withPreview ? { with_preview: 1 } : {}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
LibrariesEmptyState,
|
LibrariesEmptyState,
|
||||||
LibrariesHeader,
|
LibrariesHeader,
|
||||||
} from './LibrariesPageSections'
|
} from './LibrariesPageSections'
|
||||||
import { isSeriesLibraryType, latestLibraryCards, type LibraryPreview } from './librariesPageModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
|
|
||||||
export function LibrariesPage() {
|
export function LibrariesPage() {
|
||||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||||
@@ -20,24 +20,15 @@ export function LibrariesPage() {
|
|||||||
const loadLibraries = useCallback(async () => {
|
const loadLibraries = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const libs = await libraryAPI.list()
|
const libs = await libraryAPI.list({ withPreview: true })
|
||||||
const rows = await Promise.all(libs.map(async (library) => {
|
setPreviews(
|
||||||
try {
|
libs.map((library) => ({
|
||||||
if (isSeriesLibraryType(library.type)) {
|
library,
|
||||||
const [seriesPage, mediaPage] = await Promise.all([
|
items: [],
|
||||||
libraryAPI.listSeries(library.id, 1, 10),
|
total: library.total ?? 0,
|
||||||
libraryAPI.listMedia(library.id, 1, 1, { groupVersions: false }),
|
cards: library.cards ?? [],
|
||||||
])
|
})),
|
||||||
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)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { artworkScore, groupSeries, type SeriesCard } from '../utils/groupSeries
|
|||||||
|
|
||||||
export type LibraryPreview = {
|
export type LibraryPreview = {
|
||||||
library: Library
|
library: Library
|
||||||
items: Media[]
|
items?: Media[]
|
||||||
total: number
|
total: number
|
||||||
cards: SeriesCard[]
|
cards: SeriesCard[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user