Skip to content
143 changes: 143 additions & 0 deletions internal/importer/archive/iso/bluray.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package iso

import (
"context"
"io"
"log/slog"
"sort"
"strings"
)

// MainFeaturePlaylist is the result of analysing a Blu-ray's BDMV.
// Streams is the ordered list of M2TS file entries that, concatenated,
// form the main feature; the slice is empty if no parseable playlist
// was found.
type MainFeaturePlaylist struct {
PlaylistName string // e.g. "00800.MPLS" — for logging only
DurationTicks int64 // sum of (OUT-IN) at 45 kHz
Streams []isoFileEntry // ordered M2TS entries
}

// ResolveMainFeature inspects the entries returned by ListISOFiles for a
// Blu-ray (BDMV) structure and returns the playlist that represents the
// main movie. Returns nil if the disc is not BDMV, has no .mpls, or no
// playlist resolves to a non-empty M2TS sequence.
//
// Selection heuristic: pick the playlist with the longest total
// presentation duration. Ties break on PlayItem count (more clips wins),
// then lexicographically smallest filename for determinism.
//
// Failures parsing individual playlists are non-fatal — we skip them and
// keep evaluating the rest, mirroring how every Blu-ray player tolerates
// malformed entries in BDMV/PLAYLIST/.
func ResolveMainFeature(ctx context.Context, rs io.ReadSeeker, files []isoFileEntry) *MainFeaturePlaylist {
// Build per-clip indexes. M2TS streams live at BDMV/STREAM/<NNNNN>.M2TS
// and carry the 2D version (or the only version on a 2D disc). SSIF
// streams live at BDMV/STREAM/SSIF/<NNNNN>.SSIF and carry the
// stereoscopic interleaved 3D version — on 3D-only Blu-ray releases
// the main feature playlist references SSIF clips, while the M2TS
// directory holds only extras. We prefer M2TS when both exist (smaller
// bytes, universal playback) and fall back to SSIF when only it
// resolves the playlist's clip names.
m2tsByClip := make(map[string]isoFileEntry)
ssifByClip := make(map[string]isoFileEntry)
var playlistEntries []isoFileEntry
for _, f := range files {
up := strings.ToUpper(f.path)
switch {
case strings.HasPrefix(up, "BDMV/PLAYLIST/") && strings.HasSuffix(up, ".MPLS"):
playlistEntries = append(playlistEntries, f)
case strings.HasPrefix(up, "BDMV/STREAM/SSIF/") && strings.HasSuffix(up, ".SSIF"):
base := up[len("BDMV/STREAM/SSIF/") : len(up)-len(".SSIF")]
ssifByClip[base] = f
case strings.HasPrefix(up, "BDMV/STREAM/") && strings.HasSuffix(up, ".M2TS"):
base := up[len("BDMV/STREAM/") : len(up)-len(".M2TS")]
m2tsByClip[base] = f
}
}
if len(playlistEntries) == 0 || (len(m2tsByClip) == 0 && len(ssifByClip) == 0) {
return nil
}

// Deterministic order: shorter filenames (and lexicographic ties) win
// the tie-break later.
sort.Slice(playlistEntries, func(i, j int) bool {
return playlistEntries[i].path < playlistEntries[j].path
})

var best *MainFeaturePlaylist
for _, pe := range playlistEntries {
data, err := readISOFile(rs, pe)
if err != nil {
continue
}
pl, err := ParseMPLS(data)
if err != nil {
continue
}

// Resolve clip names in playlist order, preferring M2TS over SSIF.
streams := make([]isoFileEntry, 0, len(pl.PlayItems))
for _, it := range pl.PlayItems {
name := strings.ToUpper(it.ClipName)
if entry, ok := m2tsByClip[name]; ok {
streams = append(streams, entry)
continue
}
if entry, ok := ssifByClip[name]; ok {
streams = append(streams, entry)
}
}
if len(streams) == 0 {
continue
}

cand := &MainFeaturePlaylist{
PlaylistName: pe.path,
DurationTicks: pl.DurationTicks(),
Streams: streams,
}
if best == nil || isBetterPlaylist(cand, best, len(pl.PlayItems), len(best.Streams)) {
best = cand
}
}
if best != nil {
slog.InfoContext(ctx, "Blu-ray main feature playlist resolved",
"playlist", best.PlaylistName,
"clips", len(best.Streams),
"duration_seconds", best.DurationTicks/45000,
)
}
return best
}

// isBetterPlaylist returns true when cand should replace best.
// Comparison: longer duration > more PlayItems > earlier filename.
// The filename tie-break relies on playlistEntries being sorted before
// iteration so the smaller path is seen first; we therefore only swap
// when strictly better.
func isBetterPlaylist(cand, best *MainFeaturePlaylist, candItems, bestItems int) bool {
if cand.DurationTicks != best.DurationTicks {
return cand.DurationTicks > best.DurationTicks
}
return candItems > bestItems
}

// readISOFile reads the full contents of one isoFileEntry from rs,
// concatenating bytes across every on-disc extent. MPLS files are tiny
// (~KBs) and almost always single-extent, but multi-extent MPLS is
// legal so we iterate.
func readISOFile(rs io.ReadSeeker, e isoFileEntry) ([]byte, error) {
out := make([]byte, 0, e.size)
for _, ext := range e.extents {
if _, err := rs.Seek(int64(ext.lba)*iso9660SectorSize, io.SeekStart); err != nil {
return nil, err
}
chunk := make([]byte, ext.length)
if _, err := io.ReadFull(rs, chunk); err != nil {
return nil, err
}
out = append(out, chunk...)
}
return out, nil
}
214 changes: 214 additions & 0 deletions internal/importer/archive/iso/bluray_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package iso

import (
"bytes"
"context"
"io"
"testing"
)

// mkEntry builds a single-extent isoFileEntry — the common case for tests.
func mkEntry(path string, lba uint32, size uint64) isoFileEntry {
return isoFileEntry{
path: path,
size: size,
extents: []isoExtent{{lba: lba, length: size}},
}
}

// makeImage assembles an in-memory disc image by placing each piece of
// data at the sector index given in its key. The returned reader can be
// used as if it were a real ISO read-seeker.
func makeImage(t *testing.T, pieces map[uint32][]byte) io.ReadSeeker {
t.Helper()
var maxSect uint32
for s, b := range pieces {
end := s + uint32((len(b)+iso9660SectorSize-1)/iso9660SectorSize)
if end > maxSect {
maxSect = end
}
}
if maxSect == 0 {
maxSect = 1
}
img := make([]byte, int(maxSect)*iso9660SectorSize)
for s, b := range pieces {
copy(img[int(s)*iso9660SectorSize:], b)
}
return bytes.NewReader(img)
}

func TestResolveMainFeature(t *testing.T) {
t.Parallel()

t.Run("picks longest playlist", func(t *testing.T) {
t.Parallel()
// Two playlists:
// 00001.MPLS → 1 clip, short (extras playlist)
// 00800.MPLS → 3 clips, long (main feature)
short := buildMPLS(t, "0200", []MPLSPlayItem{
{ClipName: "00010", InTime: 0, OutTime: 45000},
}, nil)
long := buildMPLS(t, "0200", []MPLSPlayItem{
{ClipName: "00001", InTime: 0, OutTime: 90 * 45000},
{ClipName: "00002", InTime: 0, OutTime: 60 * 45000},
{ClipName: "00003", InTime: 0, OutTime: 30 * 45000},
}, nil)

rs := makeImage(t, map[uint32][]byte{
100: short,
110: long,
})

// File listing: two playlists and four M2TS clips (one extra).
files := []isoFileEntry{
mkEntry("BDMV/PLAYLIST/00001.MPLS", 100, uint64(len(short))),
mkEntry("BDMV/PLAYLIST/00800.MPLS", 110, uint64(len(long))),
mkEntry("BDMV/STREAM/00001.M2TS", 200, 1_000_000),
mkEntry("BDMV/STREAM/00002.M2TS", 300, 2_000_000),
mkEntry("BDMV/STREAM/00003.M2TS", 400, 3_000_000),
mkEntry("BDMV/STREAM/00010.M2TS", 500, 500_000),
}

got := ResolveMainFeature(context.Background(), rs, files)
if got == nil {
t.Fatal("ResolveMainFeature returned nil")
}
if got.PlaylistName != "BDMV/PLAYLIST/00800.MPLS" {
t.Errorf("PlaylistName = %q, want 00800.MPLS", got.PlaylistName)
}
if len(got.Streams) != 3 {
t.Fatalf("Streams len = %d, want 3", len(got.Streams))
}
wantOrder := []string{"BDMV/STREAM/00001.M2TS", "BDMV/STREAM/00002.M2TS", "BDMV/STREAM/00003.M2TS"}
for i, s := range got.Streams {
if s.path != wantOrder[i] {
t.Errorf("Streams[%d].path = %q, want %q", i, s.path, wantOrder[i])
}
}
})

t.Run("non-BDMV disc returns nil", func(t *testing.T) {
t.Parallel()
files := []isoFileEntry{
mkEntry("movie.mkv", 100, 1_000_000),
}
if got := ResolveMainFeature(context.Background(), bytes.NewReader(make([]byte, 16*iso9660SectorSize)), files); got != nil {
t.Errorf("expected nil for non-BDMV disc, got %+v", got)
}
})

t.Run("BDMV with no parseable MPLS returns nil", func(t *testing.T) {
t.Parallel()
rs := makeImage(t, map[uint32][]byte{
100: []byte("not a real mpls"),
})
files := []isoFileEntry{
mkEntry("BDMV/PLAYLIST/00001.MPLS", 100, 15),
mkEntry("BDMV/STREAM/00001.M2TS", 200, 1_000_000),
}
if got := ResolveMainFeature(context.Background(), rs, files); got != nil {
t.Errorf("expected nil for unparseable MPLS, got %+v", got)
}
})

t.Run("3D BD: playlist resolves against SSIF when M2TS missing", func(t *testing.T) {
t.Parallel()
// Avatar-2-style 3D-only release: BDMV/STREAM/*.M2TS holds only
// extras (tiny). The real main feature lives in BDMV/STREAM/SSIF/
// and is referenced by its own MPLS. The resolver must index SSIF
// so the long playlist resolves and wins.
extras := buildMPLS(t, "0200", []MPLSPlayItem{
{ClipName: "00010", InTime: 0, OutTime: 90 * 45000}, // 90s extra
}, nil)
mainFeature3D := buildMPLS(t, "0200", []MPLSPlayItem{
{ClipName: "00100", InTime: 0, OutTime: 60 * 60 * 45000},
{ClipName: "00101", InTime: 0, OutTime: 60 * 60 * 45000},
{ClipName: "00102", InTime: 0, OutTime: 12 * 60 * 45000}, // 132 min total
}, nil)

rs := makeImage(t, map[uint32][]byte{
100: extras,
110: mainFeature3D,
})

files := []isoFileEntry{
mkEntry("BDMV/PLAYLIST/00001.MPLS", 100, uint64(len(extras))),
mkEntry("BDMV/PLAYLIST/00800.MPLS", 110, uint64(len(mainFeature3D))),
// Only the extras live as M2TS:
mkEntry("BDMV/STREAM/00010.M2TS", 200, 50_000_000),
// Main feature is SSIF only:
mkEntry("BDMV/STREAM/SSIF/00100.SSIF", 300, 25_000_000_000),
mkEntry("BDMV/STREAM/SSIF/00101.SSIF", 400, 25_000_000_000),
mkEntry("BDMV/STREAM/SSIF/00102.SSIF", 500, 5_000_000_000),
}

got := ResolveMainFeature(context.Background(), rs, files)
if got == nil {
t.Fatal("ResolveMainFeature returned nil — SSIF index missing?")
}
if got.PlaylistName != "BDMV/PLAYLIST/00800.MPLS" {
t.Errorf("PlaylistName = %q, want 00800.MPLS (3D main feature)", got.PlaylistName)
}
if len(got.Streams) != 3 {
t.Fatalf("Streams len = %d, want 3 SSIF clips", len(got.Streams))
}
wantOrder := []string{
"BDMV/STREAM/SSIF/00100.SSIF",
"BDMV/STREAM/SSIF/00101.SSIF",
"BDMV/STREAM/SSIF/00102.SSIF",
}
for i, s := range got.Streams {
if s.path != wantOrder[i] {
t.Errorf("Streams[%d].path = %q, want %q", i, s.path, wantOrder[i])
}
}
})

t.Run("hybrid 3D BD: prefers M2TS over SSIF when both exist", func(t *testing.T) {
t.Parallel()
// Both 2D MPLS (refs M2TS) and 3D MPLS (refs SSIF) point at clips
// of the same name. With both files present, the M2TS version is
// the right pick: smaller bytes, universal playback. The resolver
// should select it even if the 3D playlist is marginally longer.
mainFeature := buildMPLS(t, "0200", []MPLSPlayItem{
{ClipName: "00100", InTime: 0, OutTime: 60 * 60 * 45000},
}, nil)
rs := makeImage(t, map[uint32][]byte{100: mainFeature})

files := []isoFileEntry{
mkEntry("BDMV/PLAYLIST/00800.MPLS", 100, uint64(len(mainFeature))),
mkEntry("BDMV/STREAM/00100.M2TS", 200, 20_000_000_000),
mkEntry("BDMV/STREAM/SSIF/00100.SSIF", 300, 40_000_000_000),
}

got := ResolveMainFeature(context.Background(), rs, files)
if got == nil {
t.Fatal("ResolveMainFeature returned nil")
}
if len(got.Streams) != 1 {
t.Fatalf("Streams len = %d, want 1", len(got.Streams))
}
if got.Streams[0].path != "BDMV/STREAM/00100.M2TS" {
t.Errorf("picked %q, want M2TS over SSIF", got.Streams[0].path)
}
})

t.Run("playlist referencing missing M2TS yields nil", func(t *testing.T) {
t.Parallel()
// Playlist references a clip that has no corresponding M2TS entry.
data := buildMPLS(t, "0200", []MPLSPlayItem{
{ClipName: "99999", InTime: 0, OutTime: 45000},
}, nil)
rs := makeImage(t, map[uint32][]byte{
100: data,
})
files := []isoFileEntry{
mkEntry("BDMV/PLAYLIST/00001.MPLS", 100, uint64(len(data))),
mkEntry("BDMV/STREAM/00001.M2TS", 200, 1_000_000),
}
if got := ResolveMainFeature(context.Background(), rs, files); got != nil {
t.Errorf("expected nil when MPLS references unknown clip, got %+v", got)
}
})
}
Loading
Loading