mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 00:06: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) {
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"MovieCount": 0,
|
"MovieCount": 0,
|
||||||
"SeriesCount": 0,
|
"SeriesCount": 0,
|
||||||
@@ -166,18 +182,18 @@ func embyDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
|||||||
"RememberIndexing": false,
|
"RememberIndexing": false,
|
||||||
"PrimaryImageHeight": 250,
|
"PrimaryImageHeight": 250,
|
||||||
"PrimaryImageWidth": 250,
|
"PrimaryImageWidth": 250,
|
||||||
"ScrollDirection": "Horizontal",
|
"ScrollDirection": "Vertical",
|
||||||
"ShowSidebar": true,
|
"ShowSidebar": true,
|
||||||
"CustomPrefs": gin.H{
|
"CustomPrefs": gin.H{
|
||||||
"homeexploresection": "1",
|
"homeexploresection": "1",
|
||||||
"homesection0": "smalllibrarytiles",
|
"homesection0": "smalllibrarytiles",
|
||||||
"homesection1": "resume",
|
"homesection1": "resume",
|
||||||
"homesection2": "latestmedia",
|
"homesection2": "none",
|
||||||
"homesection3": "nextup",
|
"homesection3": "nextup",
|
||||||
"homesection4": "none",
|
"homesection4": "none",
|
||||||
"homesection5": "none",
|
"homesection5": "none",
|
||||||
"homesection6": "none",
|
"homesection6": "none",
|
||||||
"latestItems": "true",
|
"latestItems": "false",
|
||||||
"landing-livetv": "false",
|
"landing-livetv": "false",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -146,8 +146,11 @@ func TestEmbyDisplayPreferencesAllowsAnonymousCompatibility(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("missing CustomPrefs: %#v", body)
|
t.Fatalf("missing CustomPrefs: %#v", body)
|
||||||
}
|
}
|
||||||
if customPrefs["homesection0"] != "smalllibrarytiles" || customPrefs["homesection2"] != "latestmedia" {
|
if customPrefs["homesection0"] != "smalllibrarytiles" || customPrefs["homesection2"] != "none" || customPrefs["latestItems"] != "false" {
|
||||||
t.Fatalf("homepage sections should expose library tiles and latest media: %#v", customPrefs)
|
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(`{}`))
|
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 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 12
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -83,7 +85,10 @@ func telegramGetWebhookHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// 路由:POST /api/admin/telegram/polling/start (需 admin 认证)
|
// 路由:POST /api/admin/telegram/polling/start (需 admin 认证)
|
||||||
func telegramStartPollingHandler(svc *service.Container) gin.HandlerFunc {
|
func telegramStartPollingHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
|
|||||||
}
|
}
|
||||||
_ = d.ReloadConfig(ctx)
|
_ = d.ReloadConfig(ctx)
|
||||||
if !d.qb.IsConfigured() {
|
if !d.qb.IsConfigured() {
|
||||||
return nil, errors.New("no default downloader configured")
|
return nil, d.defaultDownloaderNotConfiguredError(ctx)
|
||||||
}
|
}
|
||||||
if d.torrentExistsByIdentity(ctx, req) {
|
if d.torrentExistsByIdentity(ctx, req) {
|
||||||
task, err := d.createTask(ctx, userID, urlStr, req.savePath, req.meta)
|
task, err := d.createTask(ctx, userID, urlStr, req.savePath, req.meta)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -176,6 +177,9 @@ func TestAddDownloadWithMetaFailsClosedWhenNoDownloaderConfigured(t *testing.T)
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected no downloader configured error")
|
t.Fatal("expected no downloader configured error")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(err.Error(), "当前没有已启用的下载器") {
|
||||||
|
t.Fatalf("err = %v, want enabled downloader guidance", err)
|
||||||
|
}
|
||||||
if task != nil {
|
if task != nil {
|
||||||
t.Fatalf("task = %#v, want nil", task)
|
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)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -89,3 +91,36 @@ func (d *DownloadService) preferredEnabledQBitClient(ctx context.Context) (*mode
|
|||||||
}
|
}
|
||||||
return selected, nil
|
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-"
|
embyVirtualSeasonPrefix = "msgo-season-"
|
||||||
embyVirtualCacheTTL = 10 * time.Minute
|
embyVirtualCacheTTL = 10 * time.Minute
|
||||||
embyVisibilityCacheTTL = 30 * time.Second
|
embyVisibilityCacheTTL = 30 * time.Second
|
||||||
embySeriesGroupingLimit = 5000
|
embySeriesGroupingLimit = maxMediaSearchLimit
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
rowLimit := limit * 40
|
|
||||||
if rowLimit < 200 {
|
|
||||||
rowLimit = 200
|
|
||||||
}
|
|
||||||
if rowLimit > embySeriesGroupingLimit {
|
|
||||||
rowLimit = embySeriesGroupingLimit
|
|
||||||
}
|
|
||||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
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))
|
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
|
||||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||||
var rows []model.Media
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
groups := e.seriesGroupsFromMedia(rows)
|
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)
|
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
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
groups := e.seriesGroupsFromMedia(rows)
|
groups := e.seriesGroupsFromMedia(rows)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"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) {
|
func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) {
|
||||||
svc := newTestEmbyService(t)
|
svc := newTestEmbyService(t)
|
||||||
lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true}
|
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
|
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
|
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) {
|
func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID, key string, visibility MediaVisibility) ([]model.Media, error) {
|
||||||
rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
|
rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,12 +1,57 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"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) {
|
func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) {
|
||||||
main := model.Media{
|
main := model.Media{
|
||||||
LibraryID: "lib-tv",
|
LibraryID: "lib-tv",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"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)
|
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.
|
// parseCommaList splits a comma-separated string into trimmed non-empty values.
|
||||||
func parseCommaList(s string) []string {
|
func parseCommaList(s string) []string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -193,145 +192,3 @@ func (o *OrganizerService) lookupOrganizeAdultMetadata(ctx context.Context, src,
|
|||||||
}
|
}
|
||||||
return nil
|
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 = {
|
export const mediaAPI = {
|
||||||
|
recent: (limit = 24) =>
|
||||||
|
api.get<SeriesCard[]>('/media/recent', { params: { limit } }).then((r) => r.data),
|
||||||
|
|
||||||
search: (q: string, limit = 50) =>
|
search: (q: string, limit = 50) =>
|
||||||
api.get<MediaSearchPage>('/media', { params: { q, limit } }).then((r) => r.data),
|
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 { libraryAPI, mediaAPI } from '../api/library'
|
||||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
import { groupSeries } from '../utils/groupSeries'
|
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||||
import {
|
import {
|
||||||
ContinueWatchingSection,
|
ContinueWatchingSection,
|
||||||
HomeEmptyState,
|
HomeEmptyState,
|
||||||
@@ -17,7 +17,7 @@ const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? value as T[
|
|||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
const [recent, setRecent] = useState<Media[]>([])
|
const [recentCards, setRecentCards] = useState<SeriesCard[]>([])
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -28,12 +28,15 @@ export function HomePage() {
|
|||||||
try {
|
try {
|
||||||
const [libs, recentItems, hist] = await Promise.all([
|
const [libs, recentItems, hist] = await Promise.all([
|
||||||
libraryAPI.list().then((rows) => asArray<Library>(rows)).catch(() => [] as Library[]),
|
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[]),
|
playbackAPI.recentHistory().then((rows) => asArray<HistoryItem>(rows)).catch(() => [] as HistoryItem[]),
|
||||||
])
|
])
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLibraries(libs)
|
setLibraries(libs)
|
||||||
setRecent(recentItems)
|
setRecentCards(recentItems)
|
||||||
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
|
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setLoading(false)
|
||||||
@@ -43,15 +46,13 @@ export function HomePage() {
|
|||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const recentCards = useMemo(() => groupSeries(recent).slice(0, 24), [recent])
|
|
||||||
const featuredItem = useMemo(() => {
|
const featuredItem = useMemo(() => {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
...(history.map((h) => h.media).filter(Boolean) as Media[]),
|
...(history.map((h) => h.media).filter(Boolean) as Media[]),
|
||||||
...recentCards.map((card) => card.rep),
|
...recentCards.map((card) => card.rep),
|
||||||
...recent,
|
|
||||||
]
|
]
|
||||||
return candidates.find(hasArtwork) ?? candidates[0] ?? null
|
return candidates.find(hasArtwork) ?? candidates[0] ?? null
|
||||||
}, [history, recentCards, recent])
|
}, [history, recentCards])
|
||||||
const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
|
const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
|
||||||
const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
|
const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
|
||||||
const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
|
const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
|
||||||
|
|||||||
Reference in New Issue
Block a user