stremio/node/media-node/internal/mediainfo/matroska.go
Jos Vooges | STH 6de2c767bf Show real subtitle languages by probing MKV track tags on the node.
Pass embedded track language/title through play ACK into clients so Android/iOS can label tracks instead of generic Ondertitel N when ExoPlayer/VLC miss tags.
2026-09-12 20:58:24 +02:00

302 lines
5.9 KiB
Go

package mediainfo
import (
"encoding/binary"
"io"
"os"
"strings"
)
// Track is a media stream with optional Matroska language/title.
type Track struct {
Type string `json:"type"` // video | audio | subtitle | unknown
Number int `json:"number"`
Language string `json:"language,omitempty"`
Title string `json:"title,omitempty"`
Codec string `json:"codec,omitempty"`
}
// ProbeFile returns embedded tracks. For MKV/WebM we parse EBML; other containers return nil.
func ProbeFile(path string) ([]Track, error) {
lower := strings.ToLower(path)
if strings.HasSuffix(lower, ".mkv") || strings.HasSuffix(lower, ".webm") || strings.HasSuffix(lower, ".mka") {
return probeMatroska(path)
}
return nil, nil
}
func probeMatroska(path string) ([]Track, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
// Tracks element is normally near the start; cap read for huge files.
limit := info.Size()
if limit > 8<<20 {
limit = 8 << 20
}
r := &ebmlReader{r: io.LimitReader(f, limit)}
id, _, err := r.readIDSize()
if err != nil {
return nil, err
}
// EBML header
if id != 0x1A45DFA3 {
return nil, nil
}
if err := r.skip(r.lastSize); err != nil {
return nil, err
}
var tracks []Track
for {
id, size, err := r.readIDSize()
if err == io.EOF {
break
}
if err != nil {
break
}
switch id {
case 0x18538067: // Segment — descend
continue
case 0x1654AE6B: // Tracks
tracks, err = parseTracksElement(r, size)
return tracks, err
case 0x114D9B74, 0x1549A966, 0x1C53BB6B, 0x1F43B675, 0x1043A770, 0x1254C367, 0x1941A469:
// SeekHead, Info, Cues, Cluster, Chapters, Tags, Attachments
if err := r.skip(size); err != nil {
return tracks, nil
}
default:
if err := r.skip(size); err != nil {
return tracks, nil
}
}
}
return tracks, nil
}
func parseTracksElement(r *ebmlReader, size uint64) ([]Track, error) {
end := r.pos + size
var out []Track
for r.pos < end {
id, sz, err := r.readIDSize()
if err != nil {
return out, nil
}
if id == 0xAE { // TrackEntry
t, err := parseTrackEntry(r, sz)
if err == nil && t.Type != "" {
out = append(out, t)
}
} else {
_ = r.skip(sz)
}
}
return out, nil
}
func parseTrackEntry(r *ebmlReader, size uint64) (Track, error) {
end := r.pos + size
t := Track{Type: "unknown"}
for r.pos < end {
id, sz, err := r.readIDSize()
if err != nil {
return t, err
}
switch id {
case 0xD7: // TrackNumber
v, err := r.readUint(sz)
if err != nil {
return t, err
}
t.Number = int(v)
case 0x83: // TrackType
v, err := r.readUint(sz)
if err != nil {
return t, err
}
switch v {
case 1:
t.Type = "video"
case 2:
t.Type = "audio"
case 17:
t.Type = "subtitle"
default:
t.Type = "unknown"
}
case 0x22B59C, 0x22B59D: // Language / LanguageIETF
s, err := r.readString(sz)
if err != nil {
return t, err
}
s = strings.TrimSpace(s)
if s != "" && !strings.EqualFold(s, "und") {
t.Language = s
}
case 0x536E: // Name
s, err := r.readString(sz)
if err != nil {
return t, err
}
t.Title = strings.TrimSpace(s)
case 0x86: // CodecID
s, err := r.readString(sz)
if err != nil {
return t, err
}
t.Codec = s
default:
if err := r.skip(sz); err != nil {
return t, err
}
}
}
return t, nil
}
type ebmlReader struct {
r io.Reader
pos uint64
lastSize uint64
buf [8]byte
}
func (e *ebmlReader) readByte() (byte, error) {
n, err := e.r.Read(e.buf[:1])
if n == 1 {
e.pos++
return e.buf[0], nil
}
if err == nil {
err = io.EOF
}
return 0, err
}
func (e *ebmlReader) readIDSize() (id uint32, size uint64, err error) {
b0, err := e.readByte()
if err != nil {
return 0, 0, err
}
var idLen int
switch {
case b0&0x80 != 0:
idLen = 1
id = uint32(b0)
case b0&0x40 != 0:
idLen = 2
id = uint32(b0)
case b0&0x20 != 0:
idLen = 3
id = uint32(b0)
case b0&0x10 != 0:
idLen = 4
id = uint32(b0)
default:
return 0, 0, io.ErrUnexpectedEOF
}
for i := 1; i < idLen; i++ {
b, err := e.readByte()
if err != nil {
return 0, 0, err
}
id = (id << 8) | uint32(b)
}
b0, err = e.readByte()
if err != nil {
return 0, 0, err
}
var sizeLen int
var mask byte
switch {
case b0&0x80 != 0:
sizeLen, mask = 1, 0x7F
case b0&0x40 != 0:
sizeLen, mask = 2, 0x3F
case b0&0x20 != 0:
sizeLen, mask = 3, 0x1F
case b0&0x10 != 0:
sizeLen, mask = 4, 0x0F
case b0&0x08 != 0:
sizeLen, mask = 5, 0x07
case b0&0x04 != 0:
sizeLen, mask = 6, 0x03
case b0&0x02 != 0:
sizeLen, mask = 7, 0x01
case b0&0x01 != 0:
sizeLen, mask = 8, 0x00
default:
return 0, 0, io.ErrUnexpectedEOF
}
size = uint64(b0 & mask)
for i := 1; i < sizeLen; i++ {
b, err := e.readByte()
if err != nil {
return 0, 0, err
}
size = (size << 8) | uint64(b)
}
// Unknown size marker
if size == (uint64(1)<<(7*sizeLen))-1 {
size = 0
}
e.lastSize = size
return id, size, nil
}
func (e *ebmlReader) skip(n uint64) error {
if n == 0 {
return nil
}
buf := make([]byte, 32*1024)
left := n
for left > 0 {
chunk := uint64(len(buf))
if chunk > left {
chunk = left
}
read, err := io.ReadFull(e.r, buf[:chunk])
e.pos += uint64(read)
if err != nil {
return err
}
left -= chunk
}
return nil
}
func (e *ebmlReader) readUint(n uint64) (uint64, error) {
if n == 0 || n > 8 {
return 0, io.ErrUnexpectedEOF
}
var raw [8]byte
if _, err := io.ReadFull(e.r, raw[8-n:]); err != nil {
return 0, err
}
e.pos += n
return binary.BigEndian.Uint64(raw[:]), nil
}
func (e *ebmlReader) readString(n uint64) (string, error) {
if n > 1<<20 {
return "", io.ErrUnexpectedEOF
}
buf := make([]byte, n)
if _, err := io.ReadFull(e.r, buf); err != nil {
return "", err
}
e.pos += n
// Trim trailing NULs common in Matroska strings
return strings.TrimRight(string(buf), "\x00"), nil
}