package service import ( "context" "encoding/xml" "path/filepath" "strconv" "strings" "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) type cloudSidecarSet struct { nfoByName map[string]string nfoByBase map[string]string jsonByName map[string]string jsonByBase map[string]string imageByName map[string]string imageByBase map[string]string } func newCloudSidecarSet(typ string, entries []cloud.FileEntry) cloudSidecarSet { set := cloudSidecarSet{ nfoByName: make(map[string]string), nfoByBase: make(map[string]string), jsonByName: make(map[string]string), jsonByBase: make(map[string]string), imageByName: make(map[string]string), imageByBase: make(map[string]string), } for _, entry := range entries { if entry.IsDir { continue } ref := cloudEntryRef(typ, entry.ID, entry.PickCode) if ref == "" { continue } name := strings.TrimSpace(entry.Name) ext := strings.ToLower(filepath.Ext(name)) base := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(name, ext))) if name == "" || base == "" { continue } switch ext { case ".nfo": set.nfoByName[strings.ToLower(name)] = ref set.nfoByBase[base] = ref case ".json": set.jsonByName[strings.ToLower(name)] = ref set.jsonByBase[base] = ref case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn": set.imageByName[strings.ToLower(name)] = ref set.imageByBase[base] = ref } } return set } func (s *ScannerService) cloudDirectoryMetadata(ctx context.Context, typ, displayDir string, sidecars cloudSidecarSet, inherited *LocalMetadata) *LocalMetadata { meta := cloneLocalMetadata(inherited) if hinted, _ := pathHintMetadata(displayDir, true); hinted != nil { meta = mergeCloudMetadata(meta, hinted) } for _, name := range cloudShowNFOCandidates(displayDir) { ref := sidecars.nfoByName[strings.ToLower(name)] if ref == "" { ref = sidecars.nfoByBase[strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))] } if ref == "" { continue } if local, doc, err := s.readCloudNFO(ctx, typ, ref, true); err == nil && local != nil { local = applyCloudNFOArtwork(typ, sidecars, local, doc) meta = mergeCloudMetadata(meta, local) break } } for _, name := range cloudDirectoryJSONCandidates(displayDir) { ref := cloudJSONRefByName(sidecars, name) if ref == "" { continue } if local, err := s.readCloudJSONMetadata(ctx, typ, ref, sidecars); err == nil && local != nil { meta = mergeCloudMetadata(meta, local) break } } meta = applyCloudDirectoryArtwork(typ, displayDir, sidecars, meta) if !cloudMetadataUseful(meta) { return nil } return meta } func (s *ScannerService) cloudFileMetadata(ctx context.Context, typ, displayPath, fileName string, sidecars cloudSidecarSet, inherited *LocalMetadata, seriesLike bool) *LocalMetadata { season, episode := ParseEpisode(displayPath) seriesLike = seriesLike || season > 0 || episode > 0 meta := cloneLocalMetadata(inherited) if hinted, _ := pathHintMetadata(displayPath, seriesLike); hinted != nil { meta = mergeCloudPathHintMetadata(meta, hinted) } base := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fileName, filepath.Ext(fileName)))) if ref := sidecars.nfoByBase[base]; ref != "" { if local, doc, err := s.readCloudNFO(ctx, typ, ref, seriesLike); err == nil && local != nil { local = applyCloudNFOArtwork(typ, sidecars, local, doc) if seriesLike && doc != nil { if meta == nil { meta = &LocalMetadata{} } mergeEpisodeMetadata(meta, local, doc) meta.HasNFO = true } else { meta = mergeCloudMetadata(meta, local) } } } for _, name := range cloudFileJSONCandidates(fileName, base) { ref := cloudJSONRefByName(sidecars, name) if ref == "" { continue } if local, err := s.readCloudJSONMetadata(ctx, typ, ref, sidecars); err == nil && local != nil { meta = mergeCloudMetadata(meta, local) break } } meta = applyCloudFileArtwork(typ, sidecars, displayPath, fileName, base, meta) if !cloudMetadataUseful(meta) { return nil } return meta } func (s *ScannerService) readCloudJSONMetadata(ctx context.Context, typ, ref string, sidecars cloudSidecarSet) (*LocalMetadata, error) { if s.storage == nil { return nil, nil } body, err := s.storage.CloudReadText(ctx, typ, ref, 512<<10) if err != nil { return nil, err } meta, artwork := metadataFromCloudJSON([]byte(body)) if meta == nil { return nil, nil } meta = applyCloudJSONArtwork(typ, sidecars, meta, artwork) return meta, nil } func (s *ScannerService) readCloudNFO(ctx context.Context, typ, ref string, seriesLike bool) (*LocalMetadata, *nfoDocument, error) { if s.storage == nil { return nil, nil, nil } body, err := s.storage.CloudReadText(ctx, typ, ref, 512<<10) if err != nil { return nil, nil, err } var doc nfoDocument if err := xml.Unmarshal([]byte(body), &doc); err != nil { return nil, nil, err } meta := metadataFromDoc(&doc, "", seriesLike) return meta, &doc, nil } func applyCloudDirectoryArtwork(typ, displayDir string, sidecars cloudSidecarSet, meta *LocalMetadata) *LocalMetadata { if meta == nil { meta = &LocalMetadata{} } if meta.PosterURL == "" { if ref := firstCloudImageRef(sidecars, cloudPosterNameCandidates(cloudDirectoryArtworkBases(displayDir), "poster", "folder", "cover", "show", "tvshow")...); ref != "" { meta.PosterURL = cloudPlaybackURL(typ, ref) meta.HasArtwork = true } } if meta.BackdropURL == "" { if ref := firstCloudImageRef(sidecars, cloudBackdropNameCandidates(cloudDirectoryArtworkBases(displayDir), "fanart", "backdrop", "background", "landscape")...); ref != "" { meta.BackdropURL = cloudPlaybackURL(typ, ref) meta.HasArtwork = true } } return meta } func applyCloudFileArtwork(typ string, sidecars cloudSidecarSet, displayPath, fileName, base string, meta *LocalMetadata) *LocalMetadata { if meta == nil { meta = &LocalMetadata{} } bases := cloudFileArtworkBases(displayPath, fileName, base) if meta.PosterURL == "" { if ref := firstCloudImageRef(sidecars, cloudPosterNameCandidates(bases, "poster", "folder", "cover", "movie", "show", "thumb")...); ref != "" { meta.PosterURL = cloudPlaybackURL(typ, ref) meta.HasArtwork = true } } if meta.BackdropURL == "" { if ref := firstCloudImageRef(sidecars, cloudBackdropNameCandidates(bases, "fanart", "backdrop", "background", "landscape")...); ref != "" { meta.BackdropURL = cloudPlaybackURL(typ, ref) meta.HasArtwork = true } } return meta } func cloudShowNFOCandidates(displayDir string) []string { names := []string{"tvshow.nfo", "series.nfo", "show.nfo", "movie.nfo"} base := strings.TrimSpace(pathBaseSlash(displayDir)) if base != "" { names = append(names, base+".nfo") } return names } func cloudDirectoryJSONCandidates(displayDir string) []string { names := []string{"movie.json", "metadata.json", "tvshow.json", "series.json", "show.json"} base := strings.TrimSpace(pathBaseSlash(displayDir)) if base != "" { names = append(names, base+".json", base+"-metadata.json", base+".metadata.json", base+"-mediainfo.json", base+".mediainfo.json") } return names } func cloudFileJSONCandidates(fileName, base string) []string { if base == "" { base = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fileName, filepath.Ext(fileName)))) } cleanBases := cloudCleanArtworkBases(fileName) bases := uniqueCloudArtworkNames(append([]string{base}, cleanBases...)...) out := make([]string, 0, len(bases)*5+2) for _, value := range bases { out = append(out, value+".json", value+"-metadata.json", value+".metadata.json", value+"-mediainfo.json", value+".mediainfo.json") } return append(out, "movie.json", "metadata.json") } func cloudJSONRefByName(sidecars cloudSidecarSet, name string) string { name = normalizeCloudArtworkName(name) if name == "" || isHTTPURL(name) { return "" } if ref := sidecars.jsonByName[strings.ToLower(name)]; ref != "" { return ref } base := strings.TrimSuffix(name, filepath.Ext(name)) return sidecars.jsonByBase[strings.ToLower(base)] } func cloudFileArtworkBases(displayPath, fileName, base string) []string { return uniqueCloudArtworkNames(append( []string{base}, append(cloudCleanArtworkBases(fileName), cloudDirectoryArtworkBases(pathDirSlash(displayPath))...)..., )...) } func cloudDirectoryArtworkBases(displayDir string) []string { base := pathBaseSlash(displayDir) return uniqueCloudArtworkNames(append([]string{base}, cloudCleanArtworkBases(base)...)...) } func cloudCleanArtworkBases(value string) []string { title, year := CleanQuery(value) title = strings.TrimSpace(title) if title == "" { return nil } out := []string{title} if year > 0 { yearText := strconv.Itoa(year) out = append(out, title+" ("+yearText+")", title+"."+yearText, title+" "+yearText, ) } return out } func cloudPosterNameCandidates(bases []string, fallback ...string) []string { out := make([]string, 0, len(bases)*7+len(fallback)) for _, base := range bases { base = strings.TrimSpace(base) if base == "" { continue } out = append(out, base, base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb") } return append(out, fallback...) } func cloudBackdropNameCandidates(bases []string, fallback ...string) []string { out := make([]string, 0, len(bases)*6+len(fallback)) for _, base := range bases { base = strings.TrimSpace(base) if base == "" { continue } out = append(out, base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+".background") } return append(out, fallback...) } func uniqueCloudArtworkNames(values ...string) []string { out := make([]string, 0, len(values)) seen := map[string]struct{}{} for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } key := strings.ToLower(value) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, value) } return out } func mergeCloudMetadata(dst, src *LocalMetadata) *LocalMetadata { if src == nil { return dst } if dst == nil { return cloneLocalMetadata(src) } if src.Title != "" { dst.Title = src.Title } if src.OriginalName != "" { dst.OriginalName = src.OriginalName } if src.EpisodeTitle != "" { dst.EpisodeTitle = src.EpisodeTitle } if src.AdultCode != "" { dst.AdultCode = src.AdultCode } if src.Year > 0 { dst.Year = src.Year } if src.Overview != "" { dst.Overview = src.Overview } if src.Rating > 0 { dst.Rating = src.Rating } if src.PosterURL != "" { dst.PosterURL = src.PosterURL } if src.BackdropURL != "" { dst.BackdropURL = src.BackdropURL } if src.TMDbID > 0 { dst.TMDbID = src.TMDbID } if src.BangumiID > 0 { dst.BangumiID = src.BangumiID } if src.DoubanID != "" { dst.DoubanID = src.DoubanID } if src.TheTVDBID != "" { dst.TheTVDBID = src.TheTVDBID } if src.SeasonNum > 0 || src.EpisodeNum > 0 { dst.SeasonNum = src.SeasonNum } if src.EpisodeNum > 0 { dst.EpisodeNum = src.EpisodeNum } if src.Genres != "" { dst.Genres = src.Genres } if src.Countries != "" { dst.Countries = src.Countries } if src.Languages != "" { dst.Languages = src.Languages } dst.NSFW = dst.NSFW || src.NSFW dst.HasNFO = dst.HasNFO || src.HasNFO dst.HasArtwork = dst.HasArtwork || src.HasArtwork dst.PathHint = dst.PathHint || src.PathHint return dst } func mergeCloudPathHintMetadata(dst, hint *LocalMetadata) *LocalMetadata { if hint == nil { return dst } if dst == nil || !dst.HasNFO { return mergeCloudMetadata(dst, hint) } if dst.Title == "" { dst.Title = hint.Title } if dst.OriginalName == "" { dst.OriginalName = hint.OriginalName } if dst.Year == 0 { dst.Year = hint.Year } if dst.TMDbID == 0 { dst.TMDbID = hint.TMDbID } if dst.BangumiID == 0 { dst.BangumiID = hint.BangumiID } if dst.DoubanID == "" { dst.DoubanID = hint.DoubanID } if dst.TheTVDBID == "" { dst.TheTVDBID = hint.TheTVDBID } dst.PathHint = dst.PathHint || hint.PathHint return dst } func cloneLocalMetadata(src *LocalMetadata) *LocalMetadata { if src == nil { return nil } cp := *src return &cp } func cloudMetadataUseful(meta *LocalMetadata) bool { return meta != nil && (meta.HasNFO || meta.HasArtwork || localHasDescriptiveMetadata(meta)) } func cloudPlaybackURL(typ, ref string) string { return CloudArtworkURL(typ, ref) } func joinCloudDisplayPath(parent, child string) string { parent = strings.Trim(strings.ReplaceAll(strings.TrimSpace(parent), "\\", "/"), "/") child = strings.Trim(strings.ReplaceAll(strings.TrimSpace(child), "\\", "/"), "/") switch { case parent == "": return child case child == "": return parent default: return parent + "/" + child } } func pathBaseSlash(value string) string { value = strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/") if value == "" { return "" } parts := strings.Split(value, "/") return parts[len(parts)-1] } func pathDirSlash(value string) string { value = strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/") if value == "" { return "" } idx := strings.LastIndex(value, "/") if idx < 0 { return "" } return value[:idx] }