mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
fix: improve media grouping and bot startup
This commit is contained in:
@@ -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",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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(`{}`))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 作为默认下载器")
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ const (
|
||||
embyVirtualSeasonPrefix = "msgo-season-"
|
||||
embyVirtualCacheTTL = 10 * time.Minute
|
||||
embyVisibilityCacheTTL = 30 * time.Second
|
||||
embySeriesGroupingLimit = 5000
|
||||
embySeriesGroupingLimit = maxMediaSearchLimit
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -151,6 +151,9 @@ export const libraryAPI = {
|
||||
}
|
||||
|
||||
export const mediaAPI = {
|
||||
recent: (limit = 24) =>
|
||||
api.get<SeriesCard[]>('/media/recent', { params: { limit } }).then((r) => r.data),
|
||||
|
||||
search: (q: string, limit = 50) =>
|
||||
api.get<MediaSearchPage>('/media', { params: { q, limit } }).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -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 = <T,>(value: unknown): T[] => (Array.isArray(value) ? value as T[
|
||||
|
||||
export function HomePage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [recent, setRecent] = useState<Media[]>([])
|
||||
const [recentCards, setRecentCards] = useState<SeriesCard[]>([])
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
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<Library>(rows)).catch(() => [] as Library[]),
|
||||
mediaAPI.search('', 120).then((d) => asArray<Media>(d?.items)).catch(() => [] as Media[]),
|
||||
mediaAPI.recent(24).then((rows) => asArray<SeriesCard>(rows)).catch(async () => {
|
||||
const fallback = await mediaAPI.search('', 120).then((d) => asArray<Media>(d?.items)).catch(() => [] as Media[])
|
||||
return groupSeries(fallback).slice(0, 24)
|
||||
}),
|
||||
playbackAPI.recentHistory().then((rows) => asArray<HistoryItem>(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()
|
||||
|
||||
Reference in New Issue
Block a user