A scan that cannot read a library root found no files there, so every cataloged file under it was marked missing. Catalog reads all filter on missing_since IS NULL, so marking is equivalent to deletion from a user's point of view: the title leaves browse, search and next-up, and playback answers "Source media file is missing" for media that is intact on disk. The dead-root protection already existed but only guarded the destructive operations. protectedConfiguredRoots was computed *after* the marking loop in scanPaths, and applyScopedScan received the protected set but applied it only to its force-delete branch. So an unreachable root could not lose its rows, but could still have its entire catalog hidden until the next successful scan. On a CephFS deployment whose per-library subvolume mounts flap, this marked 190 present files missing in a single day — 15% of all missing-flagged rows were files sitting untouched on disk, some flagged more than 20 hours after their last write. Hoist the probe above the marking loop and skip files under an unreachable or suspect-empty root in both the folder and scoped paths. Pass the unreachable set to the walked-scope call too: a nested child mount can die under a healthy parent, and its rows are inside the parent's scope. An offline root tells us nothing about whether its files exist. The only safe reading is to leave them alone and let the next good scan decide. Genuine deletions under a reachable root are unaffected and still marked and swept on the same schedule as before. Report the count as ScanResult.MissingSkippedProtected and log it, so an operator can tell "my library shrank" from "my mount dropped". Known gap: a suspect-empty *nested child* root under a healthy parent is still marked missing, because the suspect set is not resolved until after the walk loop. Unreachable roots — the case observed in production — are covered.
183 lines
5.6 KiB
Go
183 lines
5.6 KiB
Go
package scanner
|
|
|
|
// ScanResult contains the outcome of scanning a media folder.
|
|
type ScanResult struct {
|
|
New int
|
|
Updated int
|
|
Unchanged int
|
|
Missing int
|
|
FilesDeleted int
|
|
MembershipsRemoved int
|
|
ItemsDeleted int
|
|
Errors int
|
|
EmptyRootGuarded bool
|
|
// MissingSkippedProtected counts files the scan did not find on disk but
|
|
// left alone because they sit under an unreachable or suspect-empty root.
|
|
// A non-zero value means the library is partly offline, not partly gone.
|
|
MissingSkippedProtected int
|
|
// UnreachableRoots lists configured library roots that failed the
|
|
// reachability probe at scan start. Files under them are left untouched:
|
|
// not marked missing, not trashed, and not purged. A root that does not
|
|
// answer tells us nothing about whether its files still exist, and
|
|
// marking them missing would hide working titles from every catalog read
|
|
// until the next successful scan.
|
|
UnreachableRoots []string
|
|
// SuspectEmptyRoots lists roots that probed reachable but were literally
|
|
// empty directories while the library still holds cataloged files under
|
|
// them — the signature of a lost mount exposing its bare mountpoint
|
|
// directory. They receive the same exemptions as UnreachableRoots until
|
|
// the operator confirms cleanup or the files return.
|
|
SuspectEmptyRoots []string
|
|
RootObservations []RootObservation
|
|
}
|
|
|
|
// FileHints contains the OSHash gathered during scanning.
|
|
type FileHints struct {
|
|
FileHash string // OSHash from xattr or computed
|
|
}
|
|
|
|
// ProbeData contains media file technical information from ffprobe.
|
|
type ProbeData struct {
|
|
CodecVideo string
|
|
CodecAudio string
|
|
Resolution string // 1080p, 2160p, etc.
|
|
AudioChannels int
|
|
HDR bool
|
|
Container string
|
|
Duration int // seconds
|
|
Bitrate int // kbps
|
|
VideoTracks []VideoTrackInfo
|
|
AudioTracks []AudioTrackInfo
|
|
SubtitleTracks []SubtitleTrackInfo
|
|
Chapters []ChapterInfo
|
|
FormatTags map[string]string // format-level tags from ffprobe (title, artist, album, date, etc.)
|
|
}
|
|
|
|
// VideoTrackInfo describes a probed video track.
|
|
type VideoTrackInfo struct {
|
|
Title string
|
|
Codec string
|
|
DolbyVision string
|
|
DVProfile int
|
|
DVBLCompatID int
|
|
DVELPresent bool
|
|
DVEnhancementLayer string
|
|
HDR10Plus bool
|
|
Profile string
|
|
Level int
|
|
Width int
|
|
Height int
|
|
AspectRatio string
|
|
Interlaced bool
|
|
FrameRate string
|
|
Bitrate int
|
|
VideoRange string
|
|
VideoRangeType string
|
|
ColorRange string
|
|
ColorPrimaries string
|
|
ColorSpace string
|
|
ColorTransfer string
|
|
BitDepth int
|
|
PixelFormat string
|
|
ReferenceFrames int
|
|
}
|
|
|
|
// AudioTrackInfo describes a probed audio track.
|
|
type AudioTrackInfo struct {
|
|
Title string
|
|
EmbeddedTitle string
|
|
Language string
|
|
Codec string
|
|
Profile string
|
|
Layout string
|
|
Channels int
|
|
Bitrate int
|
|
SampleRate int
|
|
BitDepth int
|
|
Default bool
|
|
}
|
|
|
|
// SubtitleTrackInfo describes an embedded subtitle track from probing.
|
|
type SubtitleTrackInfo struct {
|
|
Index int
|
|
Language string
|
|
Codec string
|
|
Title string
|
|
EmbeddedTitle string
|
|
Resolution string
|
|
Forced bool
|
|
Default bool
|
|
HearingImpaired bool
|
|
}
|
|
|
|
// ExternalSubtitleInfo describes a discovered sidecar subtitle file.
|
|
type ExternalSubtitleInfo struct {
|
|
Path string
|
|
Language string
|
|
Format string // srt, vtt, ass, ssa, sub
|
|
Title string
|
|
Forced bool
|
|
}
|
|
|
|
// ChapterInfo describes an embedded chapter extracted from ffprobe.
|
|
type ChapterInfo struct {
|
|
Index int
|
|
Title string
|
|
StartSeconds float64
|
|
EndSeconds float64
|
|
Source string
|
|
}
|
|
|
|
// IntroCreditsMarkers contains intro/credits timing from S3 markers.
|
|
type IntroCreditsMarkers struct {
|
|
IntroStart *float64
|
|
IntroEnd *float64
|
|
CreditsStart *float64
|
|
CreditsEnd *float64
|
|
}
|
|
|
|
// MarkerUpdate is the narrow marker-only update payload shared by scanner and analyzers.
|
|
type MarkerUpdate struct {
|
|
IntroStart *float64
|
|
IntroEnd *float64
|
|
CreditsStart *float64
|
|
CreditsEnd *float64
|
|
RecapStart *float64
|
|
RecapEnd *float64
|
|
PreviewStart *float64
|
|
PreviewEnd *float64
|
|
MarkersSource string
|
|
MarkersProvider *string
|
|
MarkersConfidence *float64
|
|
MarkersAlgorithm string
|
|
|
|
// Optional per-segment provenance overrides. When set for a segment,
|
|
// UpsertMarkers writes these source/provider/confidence/algorithm values
|
|
// for that segment instead of the shared Markers* fields above. Used by
|
|
// merged multi-provider results where each segment may come from a
|
|
// different provider/source class; left nil for single-source writes
|
|
// (scanner/s3/local).
|
|
IntroProvenance *SegmentProvenance
|
|
CreditsProvenance *SegmentProvenance
|
|
RecapProvenance *SegmentProvenance
|
|
PreviewProvenance *SegmentProvenance
|
|
}
|
|
|
|
// SegmentProvenance is a per-segment attribution override for MarkerUpdate.
|
|
// When Source is empty, the shared MarkerUpdate.MarkersSource applies.
|
|
type SegmentProvenance struct {
|
|
Source string
|
|
Provider *string
|
|
Confidence *float64
|
|
Algorithm string
|
|
}
|
|
|
|
// HasAnySegment reports whether the update would write at least one segment.
|
|
// An update with no segment bounds set is a no-op and skipped by UpsertMarkers.
|
|
func (u MarkerUpdate) HasAnySegment() bool {
|
|
return u.IntroStart != nil || u.IntroEnd != nil ||
|
|
u.CreditsStart != nil || u.CreditsEnd != nil ||
|
|
u.RecapStart != nil || u.RecapEnd != nil ||
|
|
u.PreviewStart != nil || u.PreviewEnd != nil
|
|
}
|