mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
329 lines
9.8 KiB
Go
329 lines
9.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
)
|
|
|
|
type SeriesCard struct {
|
|
Key string `json:"key"`
|
|
Rep model.Media `json:"rep"`
|
|
LinkMedia model.Media `json:"linkMedia"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
|
|
rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
cards := groupMediaSeriesCards(rows)
|
|
return cards, int64(len(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 {
|
|
return nil, err
|
|
}
|
|
out := make([]model.Media, 0)
|
|
for _, row := range rows {
|
|
if mediaSeriesKey(row) == key {
|
|
out = append(out, row)
|
|
}
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].SeasonNum != out[j].SeasonNum {
|
|
return out[i].SeasonNum < out[j].SeasonNum
|
|
}
|
|
if out[i].EpisodeNum != out[j].EpisodeNum {
|
|
return out[i].EpisodeNum < out[j].EpisodeNum
|
|
}
|
|
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
|
|
const pageSize = 2000
|
|
var all []model.Media
|
|
var total int64
|
|
for page := 1; ; page++ {
|
|
rows, n, err := s.ListMediaVisible(ctx, libraryID, page, pageSize, visibility)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if page == 1 {
|
|
total = n
|
|
all = make([]model.Media, 0, minInt64(n, pageSize))
|
|
}
|
|
all = append(all, rows...)
|
|
if int64(len(all)) >= n || len(rows) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
return all, total, nil
|
|
}
|
|
|
|
func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
cards := make([]SeriesCard, 0)
|
|
byKey := make(map[string]int, len(items))
|
|
for _, item := range items {
|
|
key := mediaSeriesKey(item)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if idx, ok := byKey[key]; ok {
|
|
card := &cards[idx]
|
|
card.Count++
|
|
if betterSeriesLinkMedia(item, card.LinkMedia) {
|
|
card.LinkMedia = item
|
|
}
|
|
currentArtwork := seriesArtworkScore(item)
|
|
representativeArtwork := seriesArtworkScore(card.Rep)
|
|
if currentArtwork > representativeArtwork {
|
|
card.Rep = item
|
|
} else if currentArtwork == representativeArtwork {
|
|
cur := item.SeasonNum*10000 + item.EpisodeNum
|
|
rep := card.Rep.SeasonNum*10000 + card.Rep.EpisodeNum
|
|
if cur > 0 && (rep == 0 || cur < rep) {
|
|
card.Rep = item
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
byKey[key] = len(cards)
|
|
cards = append(cards, SeriesCard{Key: key, Rep: item, LinkMedia: item, Count: 1})
|
|
}
|
|
return cards
|
|
}
|
|
|
|
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|动漫|番剧|国漫|日番|欧美动漫|欧美动画|儿童|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
|
|
|
|
func mediaSeriesKey(media model.Media) string {
|
|
return compactSeriesKey(mediaSeriesRawKey(media))
|
|
}
|
|
|
|
func mediaSeriesRawKey(media model.Media) string {
|
|
fromPath := seriesTitleFromMediaPath(media.Path)
|
|
if media.SeasonNum > 0 || media.EpisodeNum > 0 || episodicPathRE.MatchString(media.Path+" "+media.DisplayLibraryPath+" "+media.LibraryPath) {
|
|
if fromPath != "" {
|
|
return seriesFingerprint("library-path", mediaTargetLibraryID(media), fromPath)
|
|
}
|
|
if media.TMDbID > 0 {
|
|
return fmt.Sprintf("tmdb:%d", media.TMDbID)
|
|
}
|
|
if media.BangumiID > 0 {
|
|
return fmt.Sprintf("bgm:%d", media.BangumiID)
|
|
}
|
|
if strings.TrimSpace(media.DoubanID) != "" {
|
|
return "douban:" + strings.TrimSpace(media.DoubanID)
|
|
}
|
|
if strings.TrimSpace(media.TheTVDBID) != "" {
|
|
return "thetvdb:" + strings.TrimSpace(media.TheTVDBID)
|
|
}
|
|
if strings.TrimSpace(media.SeriesID) != "" {
|
|
return "series:" + strings.TrimSpace(media.SeriesID)
|
|
}
|
|
return seriesFingerprint("library-title", mediaTargetLibraryID(media), normalizeSeriesTitle(seriesDisplayTitle(media)))
|
|
}
|
|
if strings.TrimSpace(media.SeriesID) != "" {
|
|
return "series:" + strings.TrimSpace(media.SeriesID)
|
|
}
|
|
if media.TMDbID > 0 {
|
|
return fmt.Sprintf("tmdb:%d", media.TMDbID)
|
|
}
|
|
if media.BangumiID > 0 {
|
|
return fmt.Sprintf("bgm:%d", media.BangumiID)
|
|
}
|
|
if fromPath != "" {
|
|
return seriesFingerprint("library-path", media.LibraryID, fromPath)
|
|
}
|
|
return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title))
|
|
}
|
|
|
|
func seriesFingerprint(parts ...string) string {
|
|
return strings.Join(parts, "\x1f")
|
|
}
|
|
|
|
func compactSeriesKey(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
var hash uint32 = 2166136261
|
|
for _, b := range []byte(raw) {
|
|
hash ^= uint32(b)
|
|
hash *= 16777619
|
|
}
|
|
return fmt.Sprintf("series:%08x", hash)
|
|
}
|
|
|
|
var (
|
|
seriesYearRE = regexp.MustCompile(`\s*\((?:19|20)\d{2}\)\s*`)
|
|
seriesIDRE = regexp.MustCompile(`(?i)\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*`)
|
|
seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`)
|
|
seriesSpacerRE = regexp.MustCompile(`[\s._-]+`)
|
|
seriesSeasonDirRE = regexp.MustCompile(`(?i)^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$`)
|
|
seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$`)
|
|
seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`)
|
|
)
|
|
|
|
func normalizeSeriesTitle(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
value = seriesYearRE.ReplaceAllString(value, " ")
|
|
value = seriesIDRE.ReplaceAllString(value, " ")
|
|
value = seriesBraceRE.ReplaceAllString(value, " ")
|
|
value = seriesSpacerRE.ReplaceAllString(value, " ")
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func normalizeSeriesPathTitle(value string) string {
|
|
title, _ := CleanQuery(value)
|
|
if title == "" {
|
|
title = normalizeSeriesTitle(value)
|
|
} else {
|
|
title = normalizeSeriesTitle(title)
|
|
}
|
|
stripped := stripSeriesSpecialSuffix(title)
|
|
if stripped != "" {
|
|
return stripped
|
|
}
|
|
return title
|
|
}
|
|
|
|
func stripSeriesSpecialSuffix(title string) string {
|
|
for _, re := range []*regexp.Regexp{seriesSpecialCodeRE, seriesSpecialCJKRE} {
|
|
stripped := strings.TrimSpace(re.ReplaceAllString(title, ""))
|
|
if stripped != "" && stripped != title {
|
|
return stripped
|
|
}
|
|
}
|
|
return title
|
|
}
|
|
|
|
func seriesTitleFromMediaPath(path string) string {
|
|
if strings.TrimSpace(path) == "" {
|
|
return ""
|
|
}
|
|
parts := strings.FieldsFunc(path, func(r rune) bool { return r == '/' || r == '\\' })
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
dirIndex := len(parts) - 2
|
|
for dirIndex >= 0 && seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) {
|
|
dirIndex--
|
|
}
|
|
if dirIndex < 0 {
|
|
return ""
|
|
}
|
|
title := normalizeSeriesPathTitle(parts[dirIndex])
|
|
if unsafeAutomaticEpisodeQuery(title) {
|
|
return ""
|
|
}
|
|
return title
|
|
}
|
|
|
|
func seriesDisplayTitle(media model.Media) string {
|
|
if fromPath := seriesTitleFromMediaPath(media.Path); fromPath != "" {
|
|
return fromPath
|
|
}
|
|
if media.Title != "" {
|
|
return media.Title
|
|
}
|
|
if media.OriginalName != "" {
|
|
return media.OriginalName
|
|
}
|
|
return "未命名节目"
|
|
}
|
|
|
|
func mediaTargetLibraryID(media model.Media) string {
|
|
if strings.TrimSpace(media.DisplayLibraryID) != "" {
|
|
return media.DisplayLibraryID
|
|
}
|
|
return media.LibraryID
|
|
}
|
|
|
|
func betterSeriesLinkMedia(candidate, current model.Media) bool {
|
|
candidateScore := librarySpecificityScore(candidate)
|
|
currentScore := librarySpecificityScore(current)
|
|
if candidateScore != currentScore {
|
|
return candidateScore > currentScore
|
|
}
|
|
return seriesArtworkScore(candidate) > seriesArtworkScore(current)
|
|
}
|
|
|
|
func librarySpecificityScore(media model.Media) int {
|
|
rawPath := strings.TrimSpace(firstNonEmpty(media.DisplayLibraryPath, media.LibraryPath))
|
|
if rawPath == "" {
|
|
return 0
|
|
}
|
|
normalized := strings.TrimRight(strings.ReplaceAll(rawPath, "\\", "/"), "/")
|
|
lower := strings.ToLower(normalized)
|
|
if strings.HasPrefix(lower, "cloud://") {
|
|
rest := normalized[len("cloud://"):]
|
|
slash := strings.Index(rest, "/")
|
|
if slash < 0 || slash == len(rest)-1 {
|
|
return 0
|
|
}
|
|
return 100 + len(nonEmptySlashParts(rest[slash+1:]))
|
|
}
|
|
return 200 + len(nonEmptySlashParts(normalized))
|
|
}
|
|
|
|
func nonEmptySlashParts(value string) []string {
|
|
parts := strings.Split(value, "/")
|
|
out := parts[:0]
|
|
for _, part := range parts {
|
|
if strings.TrimSpace(part) != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
var (
|
|
posterArtworkRE = regexp.MustCompile(`(poster|folder|cover|movie|show|pl)(?:[._-]|\.[a-z0-9]+$|$)`)
|
|
badArtworkRE = regexp.MustCompile(`(actor|actress|cast|avatar|sample|screenshot|screen|still|scene|fanart|backdrop|background|landscape|banner|logo|disc)`)
|
|
)
|
|
|
|
func seriesArtworkScore(media model.Media) int {
|
|
poster := strings.ToLower(media.PosterURL)
|
|
backdrop := strings.ToLower(media.BackdropURL)
|
|
if poster == "" {
|
|
if backdrop != "" {
|
|
return 5
|
|
}
|
|
return 0
|
|
}
|
|
if posterArtworkRE.MatchString(poster) {
|
|
return 40
|
|
}
|
|
if badArtworkRE.MatchString(poster) {
|
|
return 10
|
|
}
|
|
if strings.Contains(poster, "thumb") {
|
|
return 20
|
|
}
|
|
return 30
|
|
}
|
|
|
|
func minInt64(a int64, b int) int {
|
|
if a <= 0 {
|
|
return 0
|
|
}
|
|
if a > int64(b) {
|
|
return b
|
|
}
|
|
return int(a)
|
|
}
|