fix: improve media grouping and bot startup

This commit is contained in:
ShukeBta
2026-07-02 20:36:45 +08:00
parent 77b6e448e0
commit c07290619c
21 changed files with 508 additions and 220 deletions
+20 -4
View File
@@ -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",
},
})
+5 -2
View File
@@ -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(`{}`))
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
+31
View File
@@ -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 作为默认下载器")
}
+1 -1
View File
@@ -113,7 +113,7 @@ const (
embyVirtualSeasonPrefix = "msgo-season-"
embyVirtualCacheTTL = 10 * time.Minute
embyVisibilityCacheTTL = 30 * time.Second
embySeriesGroupingLimit = 5000
embySeriesGroupingLimit = maxMediaSearchLimit
)
var (
+67
View File
@@ -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
}
+1 -8
View File
@@ -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)
+1 -8
View File
@@ -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}
+51
View File
@@ -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
}
-44
View File
@@ -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
}
+20
View File
@@ -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 {
+45
View File
@@ -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",
+37
View File
@@ -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))
}
+3
View File
@@ -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),
+8 -7
View File
@@ -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()