Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f07a300
feat(iso): concat Blu-ray main feature across clips and discs
javi11 May 20, 2026
b782ea2
fix(iso): index BDMV/STREAM/SSIF/*.SSIF for 3D Blu-ray main features
javi11 May 20, 2026
919ecad
chore(iso): instrument BDMV resolver with [DEBUG-isobd] tags
javi11 May 22, 2026
3aa16da
chore(iso): extend [DEBUG-isobd] scan with size distribution
javi11 May 22, 2026
ec8a048
chore(iso): log ISO-size vs walker-coverage for each ISO
javi11 May 22, 2026
62162e8
fix(iso): read full directory extent in UDF walker
javi11 May 22, 2026
775871a
fix(iso): walk every extent of multi-extent UDF files
javi11 May 22, 2026
a7cfcc1
perf(iso): coalesce physically contiguous UDF extents
javi11 May 24, 2026
86321ea
feat(importer): add bare-ISO content bridge + partition helper
javi11 May 24, 2026
41c6b59
docs(importer): note AesIv/AesIV casing mismatch in ISO bridge
javi11 May 24, 2026
ad52456
refactor(archive): expose NewFileMetadataFromContent for non-RAR callers
javi11 May 24, 2026
0b877aa
feat(importer): orchestrate bare-ISO Blu-ray expansion
javi11 May 24, 2026
0c07087
docs(importer): clarify isos[i] safety and test failure message
javi11 May 24, 2026
d6cee97
feat(iso): surface silent drops in UDF enumeration as WARN logs
javi11 May 25, 2026
330dd69
docs(iso): clarify ctx threading rationale and slog test parallel-safety
javi11 May 25, 2026
05976c8
fix(iso): follow UDF Indirect Entry (tag 248) chains in file and dire…
javi11 May 25, 2026
3e34d7f
refactor(iso): named depth-cap constant, wrap indirect read error, mo…
javi11 May 25, 2026
5782cdc
feat(iso): preserve underlying parse errors in ListISOFiles fallback
javi11 May 26, 2026
53194c8
feat(iso): honour context cancellation in UDF walk and AED chain
javi11 May 26, 2026
98b9224
refactor(iso): cancellation log at Debug, partial-return doc, test ha…
javi11 May 26, 2026
1f2e6b5
feat(iso): bound AnalyzeISO with a configurable per-ISO timeout
javi11 May 26, 2026
e8a6f40
refactor(iso): consolidate AnalyzeISO success log, document timeout=0…
javi11 May 26, 2026
af2dbae
perf(metadata): partial-read lite fields in ReadFileMetadataLite to f…
javi11 May 27, 2026
f7e8295
perf(metadata): dedupe outer segments across NestedSources to shrink …
javi11 May 27, 2026
dbe5188
fix(iso): score Blu-ray playlists by unique-clip bytes to avoid picki…
javi11 May 27, 2026
153e001
feat(iso): TS timestamp-rewrite core for continuous-timeline remux
javi11 May 30, 2026
dbcf1d3
feat(iso): persist per-clip boundary table for continuous-timeline remux
javi11 May 30, 2026
92af984
feat(nzbfs): apply continuous-timeline TS remux on ISO-merged BD reads
javi11 May 30, 2026
8e483bb
fix(nzbfs): frame TS packets from clip grid so seek/ephemeral reads r…
javi11 May 30, 2026
084dce2
feat(iso): report import progress during ISO analysis
javi11 May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions internal/config/accessors.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,23 @@ func (c *Config) GetReadTimeout() time.Duration {
return time.Duration(c.GetReadTimeoutSeconds()) * time.Second
}

// GetIsoAnalyzeTimeout returns the per-ISO analyse deadline with a 120s
// default fallback. This bounds the entire iso.AnalyzeISO walk so a
// degraded NNTP provider cannot stall the importer indefinitely.
//
// Sentinel handling:
// - nil (config field unset) → 120s default
// - 0 or negative (explicit "none") → 120s default; users cannot disable
// the cap — the whole purpose of this knob is to prevent unbounded
// waits. To approximate "unlimited", set a very large value (e.g.
// 86400 for a one-day budget).
func (c *Config) GetIsoAnalyzeTimeout() time.Duration {
if c.Import.IsoAnalyzeTimeoutSeconds == nil || *c.Import.IsoAnalyzeTimeoutSeconds <= 0 {
return 120 * time.Second
}
return time.Duration(*c.Import.IsoAnalyzeTimeoutSeconds) * time.Second
}

// GetMetadataBackupKeep returns the number of metadata backups to keep with a default fallback.
func (c *Config) GetMetadataBackupKeep() int {
if c.Metadata.Backup.KeepBackups <= 0 {
Expand Down
5 changes: 4 additions & 1 deletion internal/config/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ type ImportConfig struct {
MaxDownloadPrefetch int `yaml:"max_download_prefetch" mapstructure:"max_download_prefetch" json:"max_download_prefetch"`
SegmentSamplePercentage int `yaml:"segment_sample_percentage" mapstructure:"segment_sample_percentage" json:"segment_sample_percentage"`
ReadTimeoutSeconds int `yaml:"read_timeout_seconds" mapstructure:"read_timeout_seconds" json:"read_timeout_seconds"`
IsoAnalyzeTimeoutSeconds *int `yaml:"iso_analyze_timeout_seconds" mapstructure:"iso_analyze_timeout_seconds" json:"iso_analyze_timeout_seconds,omitempty"`
ImportStrategy ImportStrategy `yaml:"import_strategy" mapstructure:"import_strategy" json:"import_strategy"`
ImportDir *string `yaml:"import_dir" mapstructure:"import_dir" json:"import_dir,omitempty"`
WatchDir *string `yaml:"watch_dir" mapstructure:"watch_dir" json:"watch_dir,omitempty"`
Expand Down Expand Up @@ -1247,6 +1248,7 @@ func DefaultConfig(configDir ...string) *Config {
watchIntervalSeconds := 10 // Default watch interval
failedItemRetentionHours := 24 // Default: auto-remove failed items after 24 hours
historyRetentionDays := 90 // Default: auto-remove import history after 90 days (3 months)
isoAnalyzeTimeoutSeconds := 120 // Default: 120s hard cap per ISO analyse (prevents stuck NNTP from stalling import for 9+ minutes)
cleanupAutomaticImportFailure := false
metadataBackupEnabled := false
failureMaskingEnabled := false
Expand Down Expand Up @@ -1378,7 +1380,8 @@ func DefaultConfig(configDir ...string) *Config {
MaxImportConnections: 5, // Default: 5 concurrent NNTP connections for validation and archive processing
MaxDownloadPrefetch: 10, // Default: 10 segments prefetched ahead for archive analysis
SegmentSamplePercentage: 1, // Default: 1% segment sampling
ReadTimeoutSeconds: 300, // Default: 5 minutes read timeout
ReadTimeoutSeconds: 300, // Default: 5 minutes read timeout
IsoAnalyzeTimeoutSeconds: &isoAnalyzeTimeoutSeconds,
ImportStrategy: ImportStrategyNone, // Default: no import strategy (direct import)
ImportDir: nil, // No default import directory
WatchDir: nil,
Expand Down
14 changes: 14 additions & 0 deletions internal/importer/archive/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ type Content struct {
// are sorted by size descending (1 = largest / main feature).
// Zero means this Content did not come from an ISO.
ISOExpansionIndex int `json:"iso_expansion_index,omitempty"`
// ClipBoundaries is the per-clip timeline table for a byte-concatenated
// multi-clip Blu-ray main feature. Empty for everything else. At read
// time a TS filter adds each clip's Delta90k to the timestamps inside
// its byte range to build one continuous timeline.
ClipBoundaries []ClipBoundary `json:"clip_boundaries,omitempty"`
}

// ClipBoundary mirrors metapb.ClipBoundary at the archive layer: one clip in a
// concatenated multi-clip BD main feature. ByteLen is the clip's size in the
// virtual file; Delta90k is the signed 90 kHz timeline offset for packets
// inside this clip's byte range.
type ClipBoundary struct {
ByteLen int64 `json:"byte_len"`
Delta90k int64 `json:"delta_90k"`
}

// GetContentSegmentCount returns the total number of segments for a Content,
Expand Down
166 changes: 166 additions & 0 deletions internal/importer/archive/content_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package archive

import (
"time"
"unsafe"

metapb "github.com/javi11/altmount/internal/metadata/proto"
)

// NewFileMetadataFromContent creates a FileMetadata from a Content (with its NestedSources)
// for the metadata system. It mirrors the conversion previously inlined inside
// rar.CreateFileMetadataFromRarContent and sevenzip.CreateFileMetadataFromSevenZipContent
// so that non-RAR/non-7z callers (e.g. ISO expansion) can produce equivalent metadata.
//
// Behaviour:
// - Sets CreatedAt/ModifiedAt to time.Now().Unix().
// - Defaults Status to FILE_STATUS_HEALTHY.
// - Copies SegmentData from content.Segments.
// - When content.AesKey is non-empty, sets Encryption=AES with key/iv.
// - Appends one NestedSegmentSource per content.NestedSources entry.
func NewFileMetadataFromContent(
content Content,
sourceNzbPath string,
releaseDate int64,
nzbdavId string,
) *metapb.FileMetadata {
now := time.Now().Unix()

meta := &metapb.FileMetadata{
FileSize: content.Size,
SourceNzbPath: sourceNzbPath,
Status: metapb.FileStatus_FILE_STATUS_HEALTHY,
CreatedAt: now,
ModifiedAt: now,
SegmentData: content.Segments,
ReleaseDate: releaseDate,
NzbdavId: nzbdavId,
}

// Set AES encryption if keys are present (single-layer encrypted archive)
if len(content.AesKey) > 0 {
meta.Encryption = metapb.Encryption_AES
meta.AesKey = content.AesKey
meta.AesIv = content.AesIV
}

// Carry the per-clip timeline table for multi-clip BD main features.
// Empty for everything else, which keeps the read-path remux filter
// disabled for all other files.
for _, cb := range content.ClipBoundaries {
meta.ClipBoundaries = append(meta.ClipBoundaries, &metapb.ClipBoundary{
ByteLen: cb.ByteLen,
Delta_90K: cb.Delta90k,
})
}

// Populate nested sources. For multi-extent encrypted volumes (e.g. a
// Blu-ray main feature with hundreds of extents that all read from the
// same encrypted RAR) every NestedSource shares the same Segments slice
// in memory. Serialising them naïvely duplicates the segment list per
// extent — for Avatar 3D that produced an 8 GB .meta file. We dedupe
// here by detecting shared segment-list backing arrays and emitting
// one entry in meta.SharedOuterSources per unique group; each
// NestedSource then carries only its inner_offset + inner_length plus
// a 1-based shared_outer_source_index. Sources without sharing fall
// through to the legacy on-disk layout so old code paths are unaffected.
appendNestedSourcesWithDedupe(meta, content.NestedSources)

return meta
}

// nestedSourceShareKey identifies a NestedSource by the backing array of its
// Segments slice plus the AES key/IV and inner volume size. Sources with the
// same key can share one entry in FileMetadata.SharedOuterSources.
type nestedSourceShareKey struct {
segmentsPtr uintptr
segmentsLen int
aesKey string
aesIv string
innerVolumeSize int64
}

// shareKeyFor builds a sharing key. It uses the backing-array pointer of
// the Segments slice (cheap O(1) check) plus the slice length to catch
// accidental pointer reuse across distinct slices. The AES key/iv and
// inner_volume_size complete the identity — two sources are only
// shareable when those match exactly.
func shareKeyFor(ns NestedSource) nestedSourceShareKey {
var ptr uintptr
if len(ns.Segments) > 0 {
ptr = uintptr(unsafe.Pointer(unsafe.SliceData(ns.Segments)))
}
return nestedSourceShareKey{
segmentsPtr: ptr,
segmentsLen: len(ns.Segments),
aesKey: string(ns.AesKey),
aesIv: string(ns.AesIV),
innerVolumeSize: ns.InnerVolumeSize,
}
}

// appendNestedSourcesWithDedupe writes the NestedSources into meta,
// deduplicating shared outer-segment data into meta.SharedOuterSources.
// When fewer than two sources qualify for sharing (e.g. a single source,
// or every source has a unique segment list) the legacy layout is used:
// every NestedSegmentSource carries its own Segments + AesKey + AesIv.
func appendNestedSourcesWithDedupe(meta *metapb.FileMetadata, sources []NestedSource) {
if len(sources) == 0 {
return
}

// First pass: count how many sources share each key. Only keys that
// appear in >= 2 sources are worth deduping (single-use keys cost more
// to store as shared entries than as inline data).
counts := make(map[nestedSourceShareKey]int, len(sources))
for _, ns := range sources {
if len(ns.Segments) == 0 {
continue
}
counts[shareKeyFor(ns)]++
}

// Build the SharedOuterSources slice, preserving first-appearance order.
keyToIndex := make(map[nestedSourceShareKey]int32, len(counts))
for _, ns := range sources {
if len(ns.Segments) == 0 {
continue
}
key := shareKeyFor(ns)
if counts[key] < 2 {
continue
}
if _, seen := keyToIndex[key]; seen {
continue
}
meta.SharedOuterSources = append(meta.SharedOuterSources, &metapb.NestedSegmentSource{
Segments: ns.Segments,
AesKey: ns.AesKey,
AesIv: ns.AesIV,
InnerVolumeSize: ns.InnerVolumeSize,
})
keyToIndex[key] = int32(len(meta.SharedOuterSources)) // 1-based
}

// Second pass: emit one NestedSegmentSource per input, referencing
// the shared entry where applicable.
for _, ns := range sources {
entry := &metapb.NestedSegmentSource{
InnerOffset: ns.InnerOffset,
InnerLength: ns.InnerLength,
}
if idx, ok := keyToIndex[shareKeyFor(ns)]; ok && len(ns.Segments) > 0 {
entry.SharedOuterSourceIndex = idx
} else {
entry.Segments = ns.Segments
entry.AesKey = ns.AesKey
entry.AesIv = ns.AesIV
entry.InnerVolumeSize = ns.InnerVolumeSize
}
meta.NestedSources = append(meta.NestedSources, entry)
}
}

// The read-side counterpart of the dedupe written here lives in
// internal/metadata.ExpandSharedOuterSources — called from
// MetadataService.ReadFileMetadata after proto.Unmarshal.
Loading
Loading