This commit is contained in:
truewhile
2026-09-16 23:59:38 +08:00
parent d283d39008
commit 93866d169b
5 changed files with 292 additions and 33 deletions
+13 -13
View File
@@ -2,7 +2,6 @@ package service
import (
"context"
"sort"
"strings"
"time"
@@ -179,9 +178,18 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
libraryIDs = []string{media.LibraryID}
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
// ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠
// 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄,
// 直接在该库范围内比对版本键。
var candidates []model.Media
narrowed := false
if mediaAdultGroupCode(*media) == "" {
candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
}
}
if !narrowed {
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
@@ -199,14 +207,6 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
if len(matched) == 0 {
return []model.Media{*media}, nil
}
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].ID == media.ID {
return true
}
if matched[j].ID == media.ID {
return false
}
return betterMediaVersion(matched[i], matched[j])
})
sortMediaVersionsForDisplay(matched)
return matched, nil
}
+90 -18
View File
@@ -3,6 +3,7 @@ package service
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -77,34 +78,28 @@ func groupMediaVersions(items []model.Media) []MediaItem {
return nil
}
type group struct {
key string
primary model.Media
rows []model.Media
key string
rows []model.Media
}
groups := make([]group, 0, len(items))
byKey := make(map[string]int, len(items))
for _, item := range items {
key := mediaVersionGroupKey(item)
if key == "" {
groups = append(groups, group{primary: item, rows: []model.Media{item}})
groups = append(groups, group{rows: []model.Media{item}})
continue
}
if idx, ok := byKey[key]; ok {
groups[idx].rows = append(groups[idx].rows, item)
if betterMediaVersion(item, groups[idx].primary) {
groups[idx].primary = item
}
continue
}
byKey[key] = len(groups)
groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}})
groups = append(groups, group{key: key, rows: []model.Media{item}})
}
out := make([]MediaItem, 0, len(groups))
for _, g := range groups {
sort.SliceStable(g.rows, func(i, j int) bool {
return betterMediaVersion(g.rows[i], g.rows[j])
})
item := MediaItem{Media: g.primary}
sortMediaVersionsForDisplay(g.rows)
item := MediaItem{Media: g.rows[0]}
if len(g.rows) > 1 {
item.Versions = g.rows
}
@@ -351,8 +346,11 @@ func mediaVersionFileStem(name string) string {
}
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
// 当只有容器等无法区分多版本的信息时,回退到清理后的文件名。
func MediaVersionLabel(m model.Media) string {
parts := make([]string, 0, 4)
isStrm := strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") ||
strings.TrimSpace(m.STRMURL) != ""
if m.Height > 0 {
parts = append(parts, fmt.Sprintf("%dp", m.Height))
} else if m.Width > 0 {
@@ -368,17 +366,91 @@ func MediaVersionLabel(m model.Media) string {
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
parts = append(parts, strings.ToUpper(codec))
}
if m.SizeBytes > 0 {
// STRM 占位体积(通常几十到几百字节)不能区分分片,跳过。
placeholderSize := isStrm && m.SizeBytes > 0 && m.SizeBytes < 1024*1024
if m.SizeBytes > 0 && !placeholderSize {
parts = append(parts, formatMediaSize(m.SizeBytes))
}
if len(parts) > 0 {
if len(parts) > 0 && !mediaVersionLabelIndistinct(parts) {
return strings.Join(parts, " · ")
}
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
if base == "" || base == "." {
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
if cleaned := cleanMediaVersionFilename(m.Path); cleaned != "" {
return cleaned
}
return base
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
}
func mediaVersionLabelIndistinct(parts []string) bool {
if len(parts) == 0 {
return true
}
for _, part := range parts {
upper := strings.ToUpper(strings.TrimSpace(part))
if upper == "" || upper == "云端直链" || upper == "STRM" {
continue
}
if _, ok := videoExtensions["."+strings.ToLower(upper)]; ok {
continue
}
return false
}
return true
}
func cleanMediaVersionFilename(path string) string {
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
if base == "" || base == "." {
return ""
}
stem := mediaFileStem(base)
stem = strings.ReplaceAll(stem, "_", " ")
stem = strings.ReplaceAll(stem, ".", " ")
return strings.Join(strings.Fields(stem), " ")
}
// mediaPartNumber 从路径提取分片序号(part1 / cd2 / -3)。
// 裸后缀只认 1–2 位数字,避免把年份 2024 当成 part。
func mediaPartNumber(path string) (int, bool) {
stem := mediaFileStem(filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")))
if stem == "" {
return 0, false
}
lower := strings.ToLower(stem)
for _, re := range mediaPartNumberPatterns {
if m := re.FindStringSubmatch(lower); len(m) == 2 {
n, err := strconv.Atoi(m[1])
if err == nil && n > 0 {
return n, true
}
}
}
return 0, false
}
var mediaPartNumberPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?:^|[.\-_])part[\s._-]*(\d{1,3})$`),
regexp.MustCompile(`(?:^|[.\-_])(?:cd|disc|disk)[\s._-]*(\d{1,3})$`),
regexp.MustCompile(`[-_](\d{1,2})$`),
}
func sortMediaVersionsForDisplay(rows []model.Media) {
sort.SliceStable(rows, func(i, j int) bool {
return compareMediaVersionsForDisplay(rows[i], rows[j])
})
}
// compareMediaVersionsForDisplay:有分片号时按 1→2→3… 升序;否则按画质/体积择优。
func compareMediaVersionsForDisplay(a, b model.Media) bool {
pa, oka := mediaPartNumber(a.Path)
pb, okb := mediaPartNumber(b.Path)
if oka && okb {
if pa != pb {
return pa < pb
}
} else if oka != okb {
return oka
}
return betterMediaVersion(a, b)
}
func mediaVersionContainerFromPath(path, strmURL string) string {
+134
View File
@@ -443,6 +443,88 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
}
}
func TestMediaPartNumberFromPath(t *testing.T) {
cases := []struct {
path string
want int
ok bool
}{
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm", 1, true},
{"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm", 2, true},
{"/media/云下载/sivr-270/sivr-270-1.strm", 1, true},
{"/media/云下载/sivr-270/sivr-270-7.strm", 7, true},
{"/media/云下载/fc2/FC2PPV-4701981-cd2.strm", 2, true},
{"/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", 0, false},
{"/media/Movies/Movie.2024.mkv", 0, false},
}
for _, tc := range cases {
got, ok := mediaPartNumber(tc.path)
if ok != tc.ok || got != tc.want {
t.Fatalf("mediaPartNumber(%q) = (%d, %v), want (%d, %v)", tc.path, got, ok, tc.want, tc.ok)
}
}
}
func TestGroupMediaVersionsSortsPartsAscending(t *testing.T) {
rows := []model.Media{
{
Base: model.Base{ID: "p3"},
LibraryID: "cloud",
Title: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-3.strm",
Year: 2023,
NSFW: true,
SizeBytes: 300,
},
{
Base: model.Base{ID: "p1"},
LibraryID: "cloud",
Title: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
Year: 2023,
NSFW: true,
SizeBytes: 100,
},
{
Base: model.Base{ID: "p2"},
LibraryID: "cloud",
Title: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-2.strm",
Year: 2023,
NSFW: true,
SizeBytes: 200,
},
}
grouped := groupMediaVersions(rows)
if len(grouped) != 1 || len(grouped[0].Versions) != 3 {
t.Fatalf("grouped = %#v", grouped)
}
want := []string{"p1", "p2", "p3"}
for i, id := range want {
if grouped[0].Versions[i].ID != id {
t.Fatalf("versions[%d] = %q, want %q (order %#v)", i, grouped[0].Versions[i].ID, id, grouped[0].Versions)
}
}
if grouped[0].ID != "p1" {
t.Fatalf("primary = %q, want p1 (first part)", grouped[0].ID)
}
}
func TestMediaVersionLabelFallsBackToFilenameWhenIndistinct(t *testing.T) {
label := MediaVersionLabel(model.Media{
Title: "SIVR-270-【VR】河北彩花",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
SizeBytes: 157,
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=a",
})
if !strings.Contains(label, "sivr-270-1") {
t.Fatalf("indistinct strm label should fall back to filename, got %q", label)
}
if strings.EqualFold(label, "MP4") || strings.Contains(label, "云端") {
t.Fatalf("should not keep generic-only label %q", label)
}
}
// SIVR-270 现场:一部分分片走在线刮削(MetaTube 把番号/provider 借用进
// douban_id/thetvdb_id),另一部分只从本地 NFO 拿到标题(没有外部 ID)。
// 按外部 ID 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。
@@ -566,6 +648,58 @@ func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T)
if len(grouped[0].Versions) != 2 {
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
}
if grouped[0].Versions[0].ID != "ipvr-00192-part1" || grouped[0].Versions[1].ID != "ipvr-00192-part2" {
t.Fatalf("versions should be part1 then part2, got %#v", grouped[0].Versions)
}
}
func TestGetMediaItemListsAdultVersionsAcrossMetadataSources(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "云下载", Path: "/media/云下载", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{
// 走 MetaTube 刮削的分片:番号/provider 被借用进 douban/thetvdb。
LibraryID: lib.ID,
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-1.strm",
Year: 2023,
NSFW: true,
DoubanID: "SIVR-270",
TheTVDBID: "JavBus",
ScrapeStatus: "matched",
SizeBytes: 200,
},
{
// 只有本地 NFO 身份的分片:两个外部 ID 都为空。
LibraryID: lib.ID,
Title: "SIVR-270-【VR】河北彩花",
OriginalName: "SIVR-270",
Path: "/media/云下载/sivr-270/sivr-270-4.strm",
Year: 2023,
NSFW: true,
ScrapeStatus: "matched",
SizeBytes: 100,
},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
item, err := svc.GetMediaItem(t.Context(), rows[0].ID)
if err != nil {
t.Fatal(err)
}
if item == nil || len(item.Versions) != 2 {
// 详情页版本列表不能只按外部 ID 收窄,否则没有 ID 的分片会消失。
t.Fatalf("versions = %#v, want both parts", item)
}
}
func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
+44
View File
@@ -0,0 +1,44 @@
import { mediaVersionLabel } from './mediaVersion.ts'
import type { Media } from '../types/index.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`mediaVersion: ${name}`)
}
function media(partial: Partial<Media>): Media {
return {
id: 'm1',
title: 'SIVR-270',
path: '',
height: 0,
width: 0,
size_bytes: 0,
...partial,
} as Media
}
const partLabel = mediaVersionLabel(media({
path: '/media/云下载/sivr-270/sivr-270-1.strm',
size_bytes: 157,
strm_url: '/api/strm/play/cloud115/video.mp4?pickcode=a',
}))
check('indistinct strm falls back to filename', partLabel.includes('sivr-270-1'))
check('indistinct strm is not generic MP4 only', partLabel.toUpperCase() !== 'MP4')
const richLabel = mediaVersionLabel(media({
path: '/strm/竞女01.mkv.strm',
height: 1080,
size_bytes: 1024 * 1024 * 1200,
strm_url: '/api/strm/play/cloud115/video.mkv?pickcode=a',
}))
check('rich metadata keeps resolution label', richLabel.includes('1080p'))
check('rich metadata keeps container', richLabel.toUpperCase().includes('MKV'))
const dottedPart = mediaVersionLabel(media({
path: '/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm',
size_bytes: 157,
strm_url: '/api/strm/play/cloud115/video.mp4?pickcode=b',
}))
check('part2 filename fallback keeps part token', dottedPart.includes('part2'))
console.log('mediaVersion.test.ts: ok')
+11 -2
View File
@@ -107,6 +107,15 @@ function cleanFallbackFilename(raw: string): string {
return name
}
function isIndistinctVersionLabel(parts: string[]): boolean {
if (parts.length === 0) return true
return parts.every((part) => {
const upper = part.toUpperCase()
if (upper === '云端直链' || upper === 'STRM') return true
return VIDEO_EXTENSIONS.has(part.toLowerCase())
})
}
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
export function mediaVersionLabel(media: Media): string {
const isStrm = isStrmMedia(media)
@@ -153,11 +162,11 @@ export function mediaVersionLabel(media: Media): string {
parts.push('云端直链')
}
if (parts.length > 0) {
// 只有容器 /「云端直链」时无法区分多分片,回退文件名(sivr-270-1 / …part1)
if (parts.length > 0 && !isIndistinctVersionLabel(parts)) {
return parts.join(' · ')
}
// 兜底:使用清理后的文件名
const cleaned = cleanFallbackFilename(media.path || '')
return cleaned || media.title || media.id
}