mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 20:46:37 +08:00
fix subscription dedup availability confirmation
This commit is contained in:
@@ -0,0 +1,175 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, media model.Media, lib model.Library, mount CloudMountInfo, dryRun bool, res *OrganizeResult) (bool, error) {
|
||||||
|
mediaType := normalizeOrganizeMediaType(lib.Type)
|
||||||
|
metadataMatch := organizeMatchFromMedia(&media)
|
||||||
|
if !mediaHasReliableCategoryMetadata(media) {
|
||||||
|
metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType)
|
||||||
|
if metadataMatch == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
media = mediaWithReclassifyMatch(media, metadataMatch)
|
||||||
|
}
|
||||||
|
if matchType := normalizeOrganizeMediaType(metadataMatchMediaType(metadataMatch)); matchType != "" {
|
||||||
|
mediaType = matchType
|
||||||
|
}
|
||||||
|
category := o.classifyMedia(ctx, &media, mediaType)
|
||||||
|
if category == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" {
|
||||||
|
mediaType = impliedType
|
||||||
|
category = normalizedCategory
|
||||||
|
}
|
||||||
|
if mediaType == "" {
|
||||||
|
mediaType = normalizeOrganizeMediaType(lib.Type)
|
||||||
|
}
|
||||||
|
displayDir := o.cloudReclassifyCategoryDisplayDir(mediaType, category)
|
||||||
|
if displayDir == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if normalizeCloudMountDir(mount.Provider, mount.DisplayDir) == normalizeCloudMountDir(mount.Provider, displayDir) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
targetLibrary, ok, err := o.ensureCloudReclassifyLibrary(ctx, mount.Provider, displayDir, mediaType, dryRun)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(targetLibrary.ID) != "" && targetLibrary.ID == lib.ID {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
title := sanitizeFilename(strings.TrimSpace(media.Title))
|
||||||
|
if title == "" {
|
||||||
|
title = "Unknown"
|
||||||
|
}
|
||||||
|
res.Items = append(res.Items, OrganizePreviewItem{
|
||||||
|
Source: media.Path,
|
||||||
|
Target: targetLibrary.Path,
|
||||||
|
Action: "reclassify",
|
||||||
|
Reason: "cloud metadata category library changed",
|
||||||
|
MediaType: mediaType,
|
||||||
|
Category: category,
|
||||||
|
Title: title,
|
||||||
|
})
|
||||||
|
if dryRun {
|
||||||
|
res.Reclassified++
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
updates := map[string]any{
|
||||||
|
"library_id": targetLibrary.ID,
|
||||||
|
"series_id": "",
|
||||||
|
}
|
||||||
|
applyReclassifyMatchUpdates(updates, metadataMatch)
|
||||||
|
if err := o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", media.ID).Updates(updates).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Info("cloud media library reclassified by metadata",
|
||||||
|
zap.String("media", media.ID),
|
||||||
|
zap.String("path", media.Path),
|
||||||
|
zap.String("from_library", lib.ID),
|
||||||
|
zap.String("to_library", targetLibrary.ID),
|
||||||
|
zap.String("category", category),
|
||||||
|
zap.String("media_type", mediaType),
|
||||||
|
zap.String("display_dir", displayDir))
|
||||||
|
}
|
||||||
|
res.Reclassified++
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OrganizerService) cloudReclassifyCategoryDisplayDir(mediaType, category string) string {
|
||||||
|
category = sanitizeFilename(strings.TrimSpace(category))
|
||||||
|
if category == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
root := o.mediaTypeRootDirForCategory(mediaType, category)
|
||||||
|
if root == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join([]string{root, category}, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OrganizerService) ensureCloudReclassifyLibrary(ctx context.Context, provider, displayDir, mediaType string, dryRun bool) (model.Library, bool, error) {
|
||||||
|
if o == nil || o.repo == nil || o.repo.Library == nil {
|
||||||
|
return model.Library{}, false, nil
|
||||||
|
}
|
||||||
|
provider = strings.TrimSpace(provider)
|
||||||
|
displayDir = normalizeCloudMountDir(provider, displayDir)
|
||||||
|
if provider == "" || displayDir == "" {
|
||||||
|
return model.Library{}, false, nil
|
||||||
|
}
|
||||||
|
if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil {
|
||||||
|
return *existing, true, nil
|
||||||
|
}
|
||||||
|
path := BuildCloudAutoCategoryLibraryPath(provider, displayDir)
|
||||||
|
if path == "" {
|
||||||
|
return model.Library{}, false, nil
|
||||||
|
}
|
||||||
|
name := cloudMountDirBase(displayDir)
|
||||||
|
if name == "" {
|
||||||
|
name = displayDir
|
||||||
|
}
|
||||||
|
libType := InferCloudMountMediaType(displayDir, name)
|
||||||
|
if normalizeOrganizeMediaType(libType) == "" {
|
||||||
|
libType = organizeLibraryModelType(mediaType)
|
||||||
|
}
|
||||||
|
lib := model.Library{
|
||||||
|
Name: name,
|
||||||
|
Path: path,
|
||||||
|
Type: libType,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
return lib, true, nil
|
||||||
|
}
|
||||||
|
if err := o.repo.Library.Create(ctx, &lib); err != nil {
|
||||||
|
if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil {
|
||||||
|
return *existing, true, nil
|
||||||
|
}
|
||||||
|
return model.Library{}, false, err
|
||||||
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Info("created cloud metadata reclassify library",
|
||||||
|
zap.String("library_id", lib.ID),
|
||||||
|
zap.String("provider", provider),
|
||||||
|
zap.String("display_dir", displayDir),
|
||||||
|
zap.String("type", lib.Type))
|
||||||
|
}
|
||||||
|
return lib, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OrganizerService) findCloudReclassifyLibrary(ctx context.Context, provider, displayDir string) *model.Library {
|
||||||
|
if o == nil || o.repo == nil || o.repo.Library == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
libs, err := o.repo.Library.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warn("list cloud libraries for metadata reclassify failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
displayDir = normalizeCloudMountDir(provider, displayDir)
|
||||||
|
for _, lib := range libs {
|
||||||
|
if !lib.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, ok := ParseCloudLibraryMount(lib.Path)
|
||||||
|
if !ok || info.Provider != provider {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if normalizeCloudMountDir(provider, info.DisplayDir) == displayDir {
|
||||||
|
return &lib
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (o *OrganizerService) lookupReclassifyMetadata(ctx context.Context, media model.Media, lib model.Library, mediaType string) *Match {
|
||||||
|
if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(media.Title)
|
||||||
|
if title == "" {
|
||||||
|
title, _ = CleanQuery(media.Path)
|
||||||
|
}
|
||||||
|
for _, typ := range reclassifyMetadataLookupTypes(mediaType, media) {
|
||||||
|
if match := o.lookupOrganizeMetadata(ctx, media.Path, lib.Path, typ, title, media.Year, media.SeasonNum, media.EpisodeNum, nil); match != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Info("metadata category reclassify filled missing metadata",
|
||||||
|
zap.String("media", media.ID),
|
||||||
|
zap.String("path", media.Path),
|
||||||
|
zap.String("title", match.Title),
|
||||||
|
zap.String("media_type", typ),
|
||||||
|
zap.Int("tmdb_id", match.TMDbID),
|
||||||
|
zap.Int("bangumi_id", match.BangumiID),
|
||||||
|
zap.String("douban_id", match.DoubanID),
|
||||||
|
zap.String("thetvdb_id", match.TheTVDBID))
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reclassifyMetadataLookupTypes(mediaType string, media model.Media) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, 3)
|
||||||
|
add := func(value string) {
|
||||||
|
value = normalizeOrganizeMediaType(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
add(mediaType)
|
||||||
|
if media.SeasonNum > 0 || media.EpisodeNum > 0 {
|
||||||
|
add("tv")
|
||||||
|
add("anime")
|
||||||
|
}
|
||||||
|
switch normalizeOrganizeMediaType(mediaType) {
|
||||||
|
case "tv":
|
||||||
|
add("anime")
|
||||||
|
add("movie")
|
||||||
|
case "anime":
|
||||||
|
add("tv")
|
||||||
|
add("movie")
|
||||||
|
case "movie", "":
|
||||||
|
add("tv")
|
||||||
|
add("anime")
|
||||||
|
add("movie")
|
||||||
|
default:
|
||||||
|
add("tv")
|
||||||
|
add("movie")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaWithReclassifyMatch(media model.Media, match *Match) model.Media {
|
||||||
|
if match == nil {
|
||||||
|
return media
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(match.Title); value != "" {
|
||||||
|
media.Title = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(match.OriginalName); value != "" {
|
||||||
|
media.OriginalName = value
|
||||||
|
}
|
||||||
|
if match.Year > 0 {
|
||||||
|
media.Year = match.Year
|
||||||
|
}
|
||||||
|
if match.TMDbID > 0 {
|
||||||
|
media.TMDbID = match.TMDbID
|
||||||
|
}
|
||||||
|
if match.BangumiID > 0 {
|
||||||
|
media.BangumiID = match.BangumiID
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(match.DoubanID); value != "" {
|
||||||
|
media.DoubanID = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(match.TheTVDBID); value != "" {
|
||||||
|
media.TheTVDBID = value
|
||||||
|
}
|
||||||
|
if len(match.Languages) > 0 {
|
||||||
|
media.Languages = strings.Join(match.Languages, ",")
|
||||||
|
}
|
||||||
|
if len(match.Countries) > 0 {
|
||||||
|
media.Countries = strings.Join(match.Countries, ",")
|
||||||
|
}
|
||||||
|
if len(match.Genres) > 0 {
|
||||||
|
media.Genres = strings.Join(match.Genres, ",")
|
||||||
|
}
|
||||||
|
if match.NSFW {
|
||||||
|
media.NSFW = true
|
||||||
|
}
|
||||||
|
media.ScrapeStatus = "matched"
|
||||||
|
return media
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataMatchMediaType(match *Match) string {
|
||||||
|
if match == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return match.MediaType
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaHasReliableCategoryMetadata(media model.Media) bool {
|
||||||
|
return media.NSFW ||
|
||||||
|
strings.TrimSpace(media.Languages) != "" ||
|
||||||
|
strings.TrimSpace(media.Countries) != "" ||
|
||||||
|
strings.TrimSpace(media.Genres) != ""
|
||||||
|
}
|
||||||
@@ -183,171 +183,6 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, media model.Media, lib model.Library, mount CloudMountInfo, dryRun bool, res *OrganizeResult) (bool, error) {
|
|
||||||
mediaType := normalizeOrganizeMediaType(lib.Type)
|
|
||||||
metadataMatch := organizeMatchFromMedia(&media)
|
|
||||||
if !mediaHasReliableCategoryMetadata(media) {
|
|
||||||
metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType)
|
|
||||||
if metadataMatch == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
media = mediaWithReclassifyMatch(media, metadataMatch)
|
|
||||||
}
|
|
||||||
if matchType := normalizeOrganizeMediaType(metadataMatchMediaType(metadataMatch)); matchType != "" {
|
|
||||||
mediaType = matchType
|
|
||||||
}
|
|
||||||
category := o.classifyMedia(ctx, &media, mediaType)
|
|
||||||
if category == "" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" {
|
|
||||||
mediaType = impliedType
|
|
||||||
category = normalizedCategory
|
|
||||||
}
|
|
||||||
if mediaType == "" {
|
|
||||||
mediaType = normalizeOrganizeMediaType(lib.Type)
|
|
||||||
}
|
|
||||||
displayDir := o.cloudReclassifyCategoryDisplayDir(mediaType, category)
|
|
||||||
if displayDir == "" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if normalizeCloudMountDir(mount.Provider, mount.DisplayDir) == normalizeCloudMountDir(mount.Provider, displayDir) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
targetLibrary, ok, err := o.ensureCloudReclassifyLibrary(ctx, mount.Provider, displayDir, mediaType, dryRun)
|
|
||||||
if err != nil || !ok {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(targetLibrary.ID) != "" && targetLibrary.ID == lib.ID {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
title := sanitizeFilename(strings.TrimSpace(media.Title))
|
|
||||||
if title == "" {
|
|
||||||
title = "Unknown"
|
|
||||||
}
|
|
||||||
res.Items = append(res.Items, OrganizePreviewItem{
|
|
||||||
Source: media.Path,
|
|
||||||
Target: targetLibrary.Path,
|
|
||||||
Action: "reclassify",
|
|
||||||
Reason: "cloud metadata category library changed",
|
|
||||||
MediaType: mediaType,
|
|
||||||
Category: category,
|
|
||||||
Title: title,
|
|
||||||
})
|
|
||||||
if dryRun {
|
|
||||||
res.Reclassified++
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
updates := map[string]any{
|
|
||||||
"library_id": targetLibrary.ID,
|
|
||||||
"series_id": "",
|
|
||||||
}
|
|
||||||
applyReclassifyMatchUpdates(updates, metadataMatch)
|
|
||||||
if err := o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", media.ID).Updates(updates).Error; err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Info("cloud media library reclassified by metadata",
|
|
||||||
zap.String("media", media.ID),
|
|
||||||
zap.String("path", media.Path),
|
|
||||||
zap.String("from_library", lib.ID),
|
|
||||||
zap.String("to_library", targetLibrary.ID),
|
|
||||||
zap.String("category", category),
|
|
||||||
zap.String("media_type", mediaType),
|
|
||||||
zap.String("display_dir", displayDir))
|
|
||||||
}
|
|
||||||
res.Reclassified++
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *OrganizerService) cloudReclassifyCategoryDisplayDir(mediaType, category string) string {
|
|
||||||
category = sanitizeFilename(strings.TrimSpace(category))
|
|
||||||
if category == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
root := o.mediaTypeRootDirForCategory(mediaType, category)
|
|
||||||
if root == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.Join([]string{root, category}, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *OrganizerService) ensureCloudReclassifyLibrary(ctx context.Context, provider, displayDir, mediaType string, dryRun bool) (model.Library, bool, error) {
|
|
||||||
if o == nil || o.repo == nil || o.repo.Library == nil {
|
|
||||||
return model.Library{}, false, nil
|
|
||||||
}
|
|
||||||
provider = strings.TrimSpace(provider)
|
|
||||||
displayDir = normalizeCloudMountDir(provider, displayDir)
|
|
||||||
if provider == "" || displayDir == "" {
|
|
||||||
return model.Library{}, false, nil
|
|
||||||
}
|
|
||||||
if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil {
|
|
||||||
return *existing, true, nil
|
|
||||||
}
|
|
||||||
path := BuildCloudAutoCategoryLibraryPath(provider, displayDir)
|
|
||||||
if path == "" {
|
|
||||||
return model.Library{}, false, nil
|
|
||||||
}
|
|
||||||
name := cloudMountDirBase(displayDir)
|
|
||||||
if name == "" {
|
|
||||||
name = displayDir
|
|
||||||
}
|
|
||||||
libType := InferCloudMountMediaType(displayDir, name)
|
|
||||||
if normalizeOrganizeMediaType(libType) == "" {
|
|
||||||
libType = organizeLibraryModelType(mediaType)
|
|
||||||
}
|
|
||||||
lib := model.Library{
|
|
||||||
Name: name,
|
|
||||||
Path: path,
|
|
||||||
Type: libType,
|
|
||||||
Enabled: true,
|
|
||||||
}
|
|
||||||
if dryRun {
|
|
||||||
return lib, true, nil
|
|
||||||
}
|
|
||||||
if err := o.repo.Library.Create(ctx, &lib); err != nil {
|
|
||||||
if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil {
|
|
||||||
return *existing, true, nil
|
|
||||||
}
|
|
||||||
return model.Library{}, false, err
|
|
||||||
}
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Info("created cloud metadata reclassify library",
|
|
||||||
zap.String("library_id", lib.ID),
|
|
||||||
zap.String("provider", provider),
|
|
||||||
zap.String("display_dir", displayDir),
|
|
||||||
zap.String("type", lib.Type))
|
|
||||||
}
|
|
||||||
return lib, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *OrganizerService) findCloudReclassifyLibrary(ctx context.Context, provider, displayDir string) *model.Library {
|
|
||||||
if o == nil || o.repo == nil || o.repo.Library == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
libs, err := o.repo.Library.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Warn("list cloud libraries for metadata reclassify failed", zap.Error(err))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
displayDir = normalizeCloudMountDir(provider, displayDir)
|
|
||||||
for _, lib := range libs {
|
|
||||||
if !lib.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, ok := ParseCloudLibraryMount(lib.Path)
|
|
||||||
if !ok || info.Provider != provider {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if normalizeCloudMountDir(provider, info.DisplayDir) == displayDir {
|
|
||||||
return &lib
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context, media model.Media, oldLib, targetLib model.Library, category, mediaType string, dryRun bool, res *OrganizeResult, metadataMatch *Match) (bool, error) {
|
func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context, media model.Media, oldLib, targetLib model.Library, category, mediaType string, dryRun bool, res *OrganizeResult, metadataMatch *Match) (bool, error) {
|
||||||
res.Items = append(res.Items, OrganizePreviewItem{
|
res.Items = append(res.Items, OrganizePreviewItem{
|
||||||
Source: media.Path,
|
Source: media.Path,
|
||||||
@@ -382,122 +217,3 @@ func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context
|
|||||||
res.Reclassified++
|
res.Reclassified++
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OrganizerService) lookupReclassifyMetadata(ctx context.Context, media model.Media, lib model.Library, mediaType string) *Match {
|
|
||||||
if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
title := strings.TrimSpace(media.Title)
|
|
||||||
if title == "" {
|
|
||||||
title, _ = CleanQuery(media.Path)
|
|
||||||
}
|
|
||||||
for _, typ := range reclassifyMetadataLookupTypes(mediaType, media) {
|
|
||||||
if match := o.lookupOrganizeMetadata(ctx, media.Path, lib.Path, typ, title, media.Year, media.SeasonNum, media.EpisodeNum, nil); match != nil {
|
|
||||||
if o.log != nil {
|
|
||||||
o.log.Info("metadata category reclassify filled missing metadata",
|
|
||||||
zap.String("media", media.ID),
|
|
||||||
zap.String("path", media.Path),
|
|
||||||
zap.String("title", match.Title),
|
|
||||||
zap.String("media_type", typ),
|
|
||||||
zap.Int("tmdb_id", match.TMDbID),
|
|
||||||
zap.Int("bangumi_id", match.BangumiID),
|
|
||||||
zap.String("douban_id", match.DoubanID),
|
|
||||||
zap.String("thetvdb_id", match.TheTVDBID))
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func reclassifyMetadataLookupTypes(mediaType string, media model.Media) []string {
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
out := make([]string, 0, 3)
|
|
||||||
add := func(value string) {
|
|
||||||
value = normalizeOrganizeMediaType(value)
|
|
||||||
if value == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := seen[value]; ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[value] = struct{}{}
|
|
||||||
out = append(out, value)
|
|
||||||
}
|
|
||||||
add(mediaType)
|
|
||||||
if media.SeasonNum > 0 || media.EpisodeNum > 0 {
|
|
||||||
add("tv")
|
|
||||||
add("anime")
|
|
||||||
}
|
|
||||||
switch normalizeOrganizeMediaType(mediaType) {
|
|
||||||
case "tv":
|
|
||||||
add("anime")
|
|
||||||
add("movie")
|
|
||||||
case "anime":
|
|
||||||
add("tv")
|
|
||||||
add("movie")
|
|
||||||
case "movie", "":
|
|
||||||
add("tv")
|
|
||||||
add("anime")
|
|
||||||
add("movie")
|
|
||||||
default:
|
|
||||||
add("tv")
|
|
||||||
add("movie")
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaWithReclassifyMatch(media model.Media, match *Match) model.Media {
|
|
||||||
if match == nil {
|
|
||||||
return media
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(match.Title); value != "" {
|
|
||||||
media.Title = value
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(match.OriginalName); value != "" {
|
|
||||||
media.OriginalName = value
|
|
||||||
}
|
|
||||||
if match.Year > 0 {
|
|
||||||
media.Year = match.Year
|
|
||||||
}
|
|
||||||
if match.TMDbID > 0 {
|
|
||||||
media.TMDbID = match.TMDbID
|
|
||||||
}
|
|
||||||
if match.BangumiID > 0 {
|
|
||||||
media.BangumiID = match.BangumiID
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(match.DoubanID); value != "" {
|
|
||||||
media.DoubanID = value
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(match.TheTVDBID); value != "" {
|
|
||||||
media.TheTVDBID = value
|
|
||||||
}
|
|
||||||
if len(match.Languages) > 0 {
|
|
||||||
media.Languages = strings.Join(match.Languages, ",")
|
|
||||||
}
|
|
||||||
if len(match.Countries) > 0 {
|
|
||||||
media.Countries = strings.Join(match.Countries, ",")
|
|
||||||
}
|
|
||||||
if len(match.Genres) > 0 {
|
|
||||||
media.Genres = strings.Join(match.Genres, ",")
|
|
||||||
}
|
|
||||||
if match.NSFW {
|
|
||||||
media.NSFW = true
|
|
||||||
}
|
|
||||||
media.ScrapeStatus = "matched"
|
|
||||||
return media
|
|
||||||
}
|
|
||||||
|
|
||||||
func metadataMatchMediaType(match *Match) string {
|
|
||||||
if match == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return match.MediaType
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaHasReliableCategoryMetadata(media model.Media) bool {
|
|
||||||
return media.NSFW ||
|
|
||||||
strings.TrimSpace(media.Languages) != "" ||
|
|
||||||
strings.TrimSpace(media.Countries) != "" ||
|
|
||||||
strings.TrimSpace(media.Genres) != ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -315,8 +315,17 @@ func (s *SubscriptionService) enqueueRSSSubscriptionCandidate(ctx context.Contex
|
|||||||
AllowExistingLibrary: sub.WashEnabled,
|
AllowExistingLibrary: sub.WashEnabled,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if IsDownloadDedupError(err) {
|
if IsDownloadDedupError(err) {
|
||||||
state.markCandidateAvailable(candidate)
|
if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) {
|
||||||
state.markSeen(candidate.GUID)
|
state.markCandidateAvailable(candidate)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Info("subscription dedup candidate not confirmed available",
|
||||||
|
zap.String("title", item.Title),
|
||||||
|
zap.String("media_type", mediaType),
|
||||||
|
zap.String("media_category", mediaCategory),
|
||||||
|
zap.String("save_path", savePath))
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
s.log.Warn("subscription enqueue failed",
|
s.log.Warn("subscription enqueue failed",
|
||||||
|
|||||||
@@ -156,6 +156,35 @@ func addSiteSearchCandidateAvailability(candidate siteSearchCandidate, out *Loca
|
|||||||
addTrustedAvailabilityTitle(subscriptionSearchResultText(candidate.Item), candidate.Season, candidate.Episode, candidate.Pack, out)
|
addTrustedAvailabilityTitle(subscriptionSearchResultText(candidate.Item), candidate.Season, candidate.Episode, candidate.Pack, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionService) subscriptionCandidateConfirmedAvailable(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate) bool {
|
||||||
|
availability := mergeLocalAvailability(
|
||||||
|
SubscriptionLocalAvailability(ctx, s.repo, sub),
|
||||||
|
s.pendingDownloadAvailability(ctx, sub),
|
||||||
|
)
|
||||||
|
return candidateAvailableInAvailability(sub, candidate, availability)
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateAvailableInAvailability(sub *model.Subscription, candidate siteSearchCandidate, availability LocalAvailability) bool {
|
||||||
|
mediaType := normalizeMediaType(subscriptionMediaType(sub), subscriptionName(sub)+" "+subscriptionFilter(sub), "")
|
||||||
|
if !isSubscriptionSeriesType(mediaType) {
|
||||||
|
return availability.LocalMediaCount > 0 || availability.InLibrary
|
||||||
|
}
|
||||||
|
episodes := candidateEpisodeNumbers(candidate)
|
||||||
|
if len(episodes) == 0 {
|
||||||
|
return availability.HasSeriesPack
|
||||||
|
}
|
||||||
|
season := candidate.Season
|
||||||
|
if season <= 0 {
|
||||||
|
season = 1
|
||||||
|
}
|
||||||
|
for _, episode := range episodes {
|
||||||
|
if _, ok := availability.ExistingEpisodeKeys[episodeKey(season, episode)]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func addTrustedAvailabilityTitle(title string, season, episode int, pack bool, out *LocalAvailability) {
|
func addTrustedAvailabilityTitle(title string, season, episode int, pack bool, out *LocalAvailability) {
|
||||||
if out == nil {
|
if out == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ func TestSiteSearchDownloadDedupMarksCandidateAvailable(t *testing.T) {
|
|||||||
t.Fatalf("deduped candidate should mark E%d available: %#v", episode, state.Availability.ExistingEpisodeKeys)
|
t.Fatalf("deduped candidate should mark E%d available: %#v", episode, state.Availability.ExistingEpisodeKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(state.Seen) != 1 || state.Seen[0] != "site|mteam|nanyang-7-8" {
|
if len(state.Seen) != 0 {
|
||||||
t.Fatalf("deduped candidate should be marked seen: %#v", state.Seen)
|
t.Fatalf("deduped candidate should stay retryable instead of being marked seen: %#v", state.Seen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,34 @@ func TestAddSiteSearchCandidateAvailabilityTracksEpisodeRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCandidateAvailableInAvailabilityRequiresFullRangeCoverage(t *testing.T) {
|
||||||
|
sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv", TotalEpisodes: 33}
|
||||||
|
candidate := siteSearchCandidate{
|
||||||
|
Item: SearchResult{
|
||||||
|
Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL",
|
||||||
|
DownloadURL: "https://pt/download/29-33",
|
||||||
|
},
|
||||||
|
Season: 1,
|
||||||
|
Episode: 29,
|
||||||
|
Episodes: []int{29, 30, 31, 32, 33},
|
||||||
|
Pack: true,
|
||||||
|
}
|
||||||
|
availability := LocalAvailability{
|
||||||
|
TotalEpisodes: 33,
|
||||||
|
ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 29): {}, episodeKey(1, 30): {}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidateAvailableInAvailability(sub, candidate, availability) {
|
||||||
|
t.Fatal("partial range availability must not confirm a deduped subscription candidate")
|
||||||
|
}
|
||||||
|
for episode := 31; episode <= 33; episode++ {
|
||||||
|
availability.ExistingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
|
||||||
|
}
|
||||||
|
if !candidateAvailableInAvailability(sub, candidate, availability) {
|
||||||
|
t.Fatal("complete range availability should confirm a deduped subscription candidate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShouldSkipExistingTorrentKeepsSeriesRangeCandidate(t *testing.T) {
|
func TestShouldSkipExistingTorrentKeepsSeriesRangeCandidate(t *testing.T) {
|
||||||
svc := &SubscriptionService{downloads: &DownloadService{}}
|
svc := &SubscriptionService{downloads: &DownloadService{}}
|
||||||
candidate := siteSearchCandidate{
|
candidate := siteSearchCandidate{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, su
|
|||||||
mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, matchText, item.Category)
|
mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, matchText, item.Category)
|
||||||
if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) {
|
if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) {
|
||||||
state.markCandidateAvailable(candidate)
|
state.markCandidateAvailable(candidate)
|
||||||
|
state.markCandidateSeen(candidate.GUID)
|
||||||
s.logSiteSearchCandidateSkipped(sub, state, candidate, "existing_torrent", mediaType, "", "")
|
s.logSiteSearchCandidateSkipped(sub, state, candidate, "existing_torrent", mediaType, "", "")
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -52,6 +54,7 @@ func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, su
|
|||||||
savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
|
savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
|
||||||
if s.downloadPathHasCandidate(ctx, sub, matchText, savePath) {
|
if s.downloadPathHasCandidate(ctx, sub, matchText, savePath) {
|
||||||
state.markCandidateAvailable(candidate)
|
state.markCandidateAvailable(candidate)
|
||||||
|
state.markCandidateSeen(candidate.GUID)
|
||||||
s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_path_has_candidate", mediaType, mediaCategory, savePath)
|
s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_path_has_candidate", mediaType, mediaCategory, savePath)
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -68,8 +71,12 @@ func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, su
|
|||||||
AllowExistingLibrary: sub.WashEnabled,
|
AllowExistingLibrary: sub.WashEnabled,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if IsDownloadDedupError(err) {
|
if IsDownloadDedupError(err) {
|
||||||
state.markCandidateAvailable(candidate)
|
if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) {
|
||||||
s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup", mediaType, mediaCategory, savePath)
|
state.markCandidateAvailable(candidate)
|
||||||
|
s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup", mediaType, mediaCategory, savePath)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup_unconfirmed", mediaType, mediaCategory, savePath)
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
s.logSiteSearchEnqueueFailed(sub, state, candidate, mediaType, mediaCategory, savePath, err)
|
s.logSiteSearchEnqueueFailed(sub, state, candidate, mediaType, mediaCategory, savePath, err)
|
||||||
@@ -77,15 +84,22 @@ func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, su
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.markCandidateAvailable(candidate)
|
state.markCandidateAvailable(candidate)
|
||||||
|
state.markCandidateSeen(candidate.GUID)
|
||||||
s.logSiteSearchCandidateQueued(sub, state, candidate, mediaType, mediaCategory, savePath)
|
s.logSiteSearchCandidateQueued(sub, state, candidate, mediaType, mediaCategory, savePath)
|
||||||
return item.Title, nil
|
return item.Title, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *siteSearchRunState) markCandidateAvailable(candidate siteSearchCandidate) {
|
func (state *siteSearchRunState) markCandidateAvailable(candidate siteSearchCandidate) {
|
||||||
addSiteSearchCandidateAvailability(candidate, &state.Availability)
|
addSiteSearchCandidateAvailability(candidate, &state.Availability)
|
||||||
state.Seen = append(state.Seen, candidate.GUID)
|
}
|
||||||
|
|
||||||
|
func (state *siteSearchRunState) markCandidateSeen(guid string) {
|
||||||
|
if strings.TrimSpace(guid) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.Seen = append(state.Seen, guid)
|
||||||
if state.SeenSet != nil {
|
if state.SeenSet != nil {
|
||||||
state.SeenSet[candidate.GUID] = struct{}{}
|
state.SeenSet[guid] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ func TestEnqueueSiteSearchDedupMarksEnglishRangeAvailableForChineseSubscription(
|
|||||||
t.Fatalf("availability missing E%d after dedup range: %#v", episode, state.Availability.ExistingEpisodeKeys)
|
t.Fatalf("availability missing E%d after dedup range: %#v", episode, state.Availability.ExistingEpisodeKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, ok := state.SeenSet[candidate.GUID]; !ok {
|
if _, ok := state.SeenSet[candidate.GUID]; ok {
|
||||||
t.Fatalf("seen set missing candidate guid after dedup")
|
t.Fatalf("deduped candidate should not be marked seen before the next availability scan")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user