node build fixed
This commit is contained in:
116
seanime-2.9.10/internal/mkvparser/metadata.go
Normal file
116
seanime-2.9.10/internal/mkvparser/metadata.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package mkvparser
|
||||
|
||||
// TrackType represents the type of a Matroska track.
|
||||
type TrackType string
|
||||
|
||||
const (
|
||||
TrackTypeVideo TrackType = "video"
|
||||
TrackTypeAudio TrackType = "audio"
|
||||
TrackTypeSubtitle TrackType = "subtitle"
|
||||
TrackTypeLogo TrackType = "logo"
|
||||
TrackTypeButtons TrackType = "buttons"
|
||||
TrackTypeComplex TrackType = "complex"
|
||||
TrackTypeUnknown TrackType = "unknown"
|
||||
)
|
||||
|
||||
type AttachmentType string
|
||||
|
||||
const (
|
||||
AttachmentTypeFont AttachmentType = "font"
|
||||
AttachmentTypeSubtitle AttachmentType = "subtitle"
|
||||
AttachmentTypeOther AttachmentType = "other"
|
||||
)
|
||||
|
||||
// TrackInfo holds extracted information about a media track.
|
||||
type TrackInfo struct {
|
||||
Number int64 `json:"number"`
|
||||
UID int64 `json:"uid"`
|
||||
Type TrackType `json:"type"` // "video", "audio", "subtitle", etc.
|
||||
CodecID string `json:"codecID"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Language string `json:"language,omitempty"` // Best effort language code
|
||||
LanguageIETF string `json:"languageIETF,omitempty"` // IETF language code
|
||||
Default bool `json:"default"`
|
||||
Forced bool `json:"forced"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CodecPrivate string `json:"codecPrivate,omitempty"` // Raw CodecPrivate data, often used for subtitle headers (e.g., ASS/SSA styles)
|
||||
|
||||
// Video specific
|
||||
Video *VideoTrack `json:"video,omitempty"`
|
||||
// Audio specific
|
||||
Audio *AudioTrack `json:"audio,omitempty"`
|
||||
// Internal fields
|
||||
contentEncodings *ContentEncodings `json:"-"`
|
||||
defaultDuration uint64 `json:"-"` // in ns
|
||||
}
|
||||
|
||||
// ChapterInfo holds extracted information about a chapter.
|
||||
type ChapterInfo struct {
|
||||
UID uint64 `json:"uid"`
|
||||
Start float64 `json:"start"` // Start time in seconds
|
||||
End float64 `json:"end,omitempty"` // End time in seconds
|
||||
Text string `json:"text,omitempty"`
|
||||
Languages []string `json:"languages,omitempty"` // Legacy 3-letter language codes
|
||||
LanguagesIETF []string `json:"languagesIETF,omitempty"` // IETF language tags
|
||||
}
|
||||
|
||||
// AttachmentInfo holds extracted information about an attachment.
|
||||
type AttachmentInfo struct {
|
||||
UID uint64 `json:"uid"`
|
||||
Filename string `json:"filename"`
|
||||
Mimetype string `json:"mimetype"`
|
||||
Size int `json:"size"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Type AttachmentType `json:"type,omitempty"`
|
||||
Data []byte `json:"-"` // Data loaded into memory
|
||||
IsCompressed bool `json:"-"` // Whether the data is compressed
|
||||
}
|
||||
|
||||
// Metadata holds all extracted metadata.
|
||||
type Metadata struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Duration float64 `json:"duration"` // Duration in seconds
|
||||
TimecodeScale float64 `json:"timecodeScale"` // Original timecode scale from Info
|
||||
MuxingApp string `json:"muxingApp,omitempty"`
|
||||
WritingApp string `json:"writingApp,omitempty"`
|
||||
Tracks []*TrackInfo `json:"tracks"`
|
||||
VideoTracks []*TrackInfo `json:"videoTracks"`
|
||||
AudioTracks []*TrackInfo `json:"audioTracks"`
|
||||
SubtitleTracks []*TrackInfo `json:"subtitleTracks"`
|
||||
Chapters []*ChapterInfo `json:"chapters"`
|
||||
Attachments []*AttachmentInfo `json:"attachments"`
|
||||
MimeCodec string `json:"mimeCodec,omitempty"` // RFC 6381 codec string
|
||||
Error error `json:"-"`
|
||||
}
|
||||
|
||||
func (m *Metadata) GetTrackByNumber(num int64) *TrackInfo {
|
||||
for _, track := range m.Tracks {
|
||||
if track.Number == num {
|
||||
return track
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Metadata) GetAttachmentByName(name string) (*AttachmentInfo, bool) {
|
||||
for _, attachment := range m.Attachments {
|
||||
if attachment.Filename == name {
|
||||
return attachment, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (t *TrackInfo) IsAudioTrack() bool {
|
||||
return t.Type == TrackTypeAudio
|
||||
}
|
||||
|
||||
func (t *TrackInfo) IsVideoTrack() bool {
|
||||
return t.Type == TrackTypeVideo
|
||||
}
|
||||
|
||||
func (t *TrackInfo) IsSubtitleTrack() bool {
|
||||
return t.Type == TrackTypeSubtitle
|
||||
}
|
||||
1203
seanime-2.9.10/internal/mkvparser/mkvparser.go
Normal file
1203
seanime-2.9.10/internal/mkvparser/mkvparser.go
Normal file
File diff suppressed because it is too large
Load Diff
146
seanime-2.9.10/internal/mkvparser/mkvparser_subtitles.go
Normal file
146
seanime-2.9.10/internal/mkvparser/mkvparser_subtitles.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package mkvparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/5rahim/go-astisub"
|
||||
)
|
||||
|
||||
const (
|
||||
SubtitleTypeASS = iota
|
||||
SubtitleTypeSRT
|
||||
SubtitleTypeSTL
|
||||
SubtitleTypeTTML
|
||||
SubtitleTypeWEBVTT
|
||||
SubtitleTypeUnknown
|
||||
)
|
||||
|
||||
func isProbablySrt(content string) bool {
|
||||
separatorCounts := strings.Count(content, "-->")
|
||||
return separatorCounts > 5
|
||||
}
|
||||
|
||||
func DetectSubtitleType(content string) int {
|
||||
if strings.HasPrefix(strings.TrimSpace(content), "[Script Info]") {
|
||||
return SubtitleTypeASS
|
||||
} else if isProbablySrt(content) {
|
||||
return SubtitleTypeSRT
|
||||
} else if strings.Contains(content, "<tt ") || strings.Contains(content, "<tt>") {
|
||||
return SubtitleTypeTTML
|
||||
} else if strings.HasPrefix(strings.TrimSpace(content), "WEBVTT") {
|
||||
return SubtitleTypeWEBVTT
|
||||
} else if strings.Contains(content, "{\\") || strings.Contains(content, "\\N") {
|
||||
return SubtitleTypeSTL
|
||||
}
|
||||
return SubtitleTypeUnknown
|
||||
}
|
||||
|
||||
func ConvertToASS(content string, from int) (string, error) {
|
||||
var o *astisub.Subtitles
|
||||
var err error
|
||||
|
||||
reader := bytes.NewReader([]byte(content))
|
||||
|
||||
read:
|
||||
switch from {
|
||||
case SubtitleTypeSRT:
|
||||
o, err = astisub.ReadFromSRT(reader)
|
||||
case SubtitleTypeSTL:
|
||||
o, err = astisub.ReadFromSTL(reader, astisub.STLOptions{IgnoreTimecodeStartOfProgramme: true})
|
||||
case SubtitleTypeTTML:
|
||||
o, err = astisub.ReadFromTTML(reader)
|
||||
case SubtitleTypeWEBVTT:
|
||||
o, err = astisub.ReadFromWebVTT(reader)
|
||||
case SubtitleTypeUnknown:
|
||||
detectedType := DetectSubtitleType(content)
|
||||
if detectedType == SubtitleTypeUnknown {
|
||||
return "", fmt.Errorf("failed to detect subtitle format from content")
|
||||
}
|
||||
from = detectedType
|
||||
goto read
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported subtitle format: %d", from)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read subtitles: %w", err)
|
||||
}
|
||||
|
||||
if o == nil {
|
||||
return "", fmt.Errorf("failed to read subtitles: %w", err)
|
||||
}
|
||||
|
||||
o.Metadata = &astisub.Metadata{
|
||||
SSAScriptType: "v4.00+",
|
||||
SSAWrapStyle: "0",
|
||||
SSAPlayResX: &[]int{640}[0],
|
||||
SSAPlayResY: &[]int{360}[0],
|
||||
SSAScaledBorderAndShadow: true,
|
||||
}
|
||||
|
||||
//Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
//Style: Default, Roboto Medium,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1.3,0,2,20,20,23,0
|
||||
o.Styles["Default"] = &astisub.Style{
|
||||
ID: "Default",
|
||||
InlineStyle: &astisub.StyleAttributes{
|
||||
SSAFontName: "Roboto Medium",
|
||||
SSAFontSize: &[]float64{24}[0],
|
||||
SSAPrimaryColour: &astisub.Color{
|
||||
Red: 255,
|
||||
Green: 255,
|
||||
Blue: 255,
|
||||
Alpha: 0,
|
||||
},
|
||||
SSASecondaryColour: &astisub.Color{
|
||||
Red: 255,
|
||||
Green: 0,
|
||||
Blue: 0,
|
||||
Alpha: 0,
|
||||
},
|
||||
SSAOutlineColour: &astisub.Color{
|
||||
Red: 0,
|
||||
Green: 0,
|
||||
Blue: 0,
|
||||
Alpha: 0,
|
||||
},
|
||||
SSABackColour: &astisub.Color{
|
||||
Red: 0,
|
||||
Green: 0,
|
||||
Blue: 0,
|
||||
Alpha: 0,
|
||||
},
|
||||
SSABold: &[]bool{false}[0],
|
||||
SSAItalic: &[]bool{false}[0],
|
||||
SSAUnderline: &[]bool{false}[0],
|
||||
SSAStrikeout: &[]bool{false}[0],
|
||||
SSAScaleX: &[]float64{100}[0],
|
||||
SSAScaleY: &[]float64{100}[0],
|
||||
SSASpacing: &[]float64{0}[0],
|
||||
SSAAngle: &[]float64{0}[0],
|
||||
SSABorderStyle: &[]int{1}[0],
|
||||
SSAOutline: &[]float64{1.3}[0],
|
||||
SSAShadow: &[]float64{0}[0],
|
||||
SSAAlignment: &[]int{2}[0],
|
||||
SSAMarginLeft: &[]int{20}[0],
|
||||
SSAMarginRight: &[]int{20}[0],
|
||||
SSAMarginVertical: &[]int{23}[0],
|
||||
SSAEncoding: &[]int{0}[0],
|
||||
},
|
||||
}
|
||||
|
||||
for _, item := range o.Items {
|
||||
item.Style = &astisub.Style{
|
||||
ID: "Default",
|
||||
}
|
||||
}
|
||||
|
||||
w := &bytes.Buffer{}
|
||||
err = o.WriteToSSA(w)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to write subtitles: %w", err)
|
||||
}
|
||||
|
||||
return w.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package mkvparser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertSRTToASS(t *testing.T) {
|
||||
srt := `1
|
||||
00:00:00,000 --> 00:00:03,000
|
||||
Hello, world!
|
||||
|
||||
2
|
||||
00:00:04,000 --> 00:00:06,000
|
||||
This is a <--> test.
|
||||
`
|
||||
out, err := ConvertToASS(srt, SubtitleTypeSRT)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, `[Script Info]
|
||||
PlayResX: 640
|
||||
PlayResY: 360
|
||||
ScriptType: v4.00+
|
||||
WrapStyle: 0
|
||||
ScaledBorderAndShadow: yes
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Alignment, Angle, BackColour, Bold, BorderStyle, Encoding, Fontname, Fontsize, Italic, MarginL, MarginR, MarginV, Outline, OutlineColour, PrimaryColour, ScaleX, ScaleY, SecondaryColour, Shadow, Spacing, Strikeout, Underline
|
||||
Style: Default,2,0.000,&H00000000,0,1,0,Roboto Medium,24.000,0,20,20,23,1.300,&H00000000,&H00ffffff,100.000,100.000,&H000000ff,0.000,0.000,0,0
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,00:00:00.00,00:00:03.00,Default,,0,0,0,,Hello, world!
|
||||
Dialogue: 0,00:00:04.00,00:00:06.00,Default,,0,0,0,,This is a <--> test.
|
||||
`, out)
|
||||
}
|
||||
367
seanime-2.9.10/internal/mkvparser/mkvparser_test.go
Normal file
367
seanime-2.9.10/internal/mkvparser/mkvparser_test.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package mkvparser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"seanime/internal/util"
|
||||
httputil "seanime/internal/util/http"
|
||||
"seanime/internal/util/torrentutil"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
//testMagnet = util.Decode("bWFnbmV0Oj94dD11cm46YnRpaDpRRVI1TFlQSkFYWlFBVVlLSE5TTE80TzZNTlY2VUQ2QSZ0cj1odHRwJTNBJTJGJTJGbnlhYS50cmFja2VyLndmJTNBNzc3NyUyRmFubm91bmNlJnRyPXVkcCUzQSUyRiUyRnRyYWNrZXIuY29wcGVyc3VyZmVyLnRrJTNBNjk2OSUyRmFubm91bmNlJnRyPXVkcCUzQSUyRiUyRnRyYWNrZXIub3BlbnRyYWNrci5vcmclM0ExMzM3JTJGYW5ub3VuY2UmdHI9dWRwJTNBJTJGJTJGOS5yYXJiZy50byUzQTI3MTAlMkZhbm5vdW5jZSZ0cj11ZHAlM0ElMkYlMkY5LnJhcmJnLm1lJTNBMjcxMCUyRmFubm91bmNlJmRuPSU1QlN1YnNQbGVhc2UlNUQlMjBTb3Vzb3UlMjBubyUyMEZyaWVyZW4lMjAtJTIwMjglMjAlMjgxMDgwcCUyOSUyMCU1QjhCQkJDMjhDJTVELm1rdg==")
|
||||
//testMagnet = util.Decode("bWFnbmV0Oj94dD11cm46YnRpaDpiMDA1MmU2OWZlOWJlYWEyYTc2ODIwOGY5M2ZkMGY1YmVkNTcxNWM1JmRuPVNBS0FNT1RPJTIwREFZUyUyMFMwMUUwNCUyMEhhcmQtQm9pbGVkJTIwUkVQQUNLJTIwMTA4MHAlMjBORiUyMFdFQi1ETCUyMEREUDUuMSUyMEglMjAyNjQlMjBNVUxUaS1WQVJZRyUyMCUyOE11bHRpLUF1ZGlvJTJDJTIwTXVsdGktU3VicyUyOSZ0cj1odHRwJTNBJTJGJTJGbnlhYS50cmFja2VyLndmJTNBNzc3NyUyRmFubm91bmNlJnRyPXVkcCUzQSUyRiUyRm9wZW4uc3RlYWx0aC5zaSUzQTgwJTJGYW5ub3VuY2UmdHI9dWRwJTNBJTJGJTJGdHJhY2tlci5vcGVudHJhY2tyLm9yZyUzQTEzMzclMkZhbm5vdW5jZSZ0cj11ZHAlM0ElMkYlMkZleG9kdXMuZGVzeW5jLmNvbSUzQTY5NjklMkZhbm5vdW5jZSZ0cj11ZHAlM0ElMkYlMkZ0cmFja2VyLnRvcnJlbnQuZXUub3JnJTNBNDUxJTJGYW5ub3VuY2U=")
|
||||
testMagnet = "magnet:?xt=urn:btih:TZP5JOTCMYEDJYQSWFXTKREGJ2LNYMIU&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce&tr=http%3A%2F%2Fanidex.moe%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.internetwarriors.net%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.zer0day.to%3A1337%2Fannounce&dn=%5BGJM%5D%20Love%20Me%2C%20Love%20Me%20Not%20%28Omoi%2C%20Omoware%2C%20Furi%2C%20Furare%29%20%28BD%201080p%29%20%5B841C23CD%5D.mkv"
|
||||
testHttpUrl = ""
|
||||
testFile = util.Decode("L1VzZXJzL3JhaGltL0RvY3VtZW50cy9jb2xsZWN0aW9uL1NvdXNvdSBubyBGcmllcmVuL0ZyaWVyZW4uQmV5b25kLkpvdXJuZXlzLkVuZC5TMDFFMDEuMTA4MHAuQ1IuV0VCLURMLkFBQzIuMC5IaW4tVGFtLUVuZy1KcG4tR2VyLVNwYS1TcGEtRnJhLVBvci5ILjI2NC5NU3Vicy1Ub29uc0h1Yi5ta3Y=")
|
||||
testFile2 = util.Decode("L1VzZXJzL3JhaGltL0RvY3VtZW50cy9jb2xsZWN0aW9uL0RhbmRhZGFuL1tTdWJzUGxlYXNlXSBEYW5kYWRhbiAtIDA0ICgxMDgwcCkgWzNEMkNDN0NGXS5ta3Y=")
|
||||
// Timeout for torrent operations
|
||||
torrentInfoTimeout = 60 * time.Second
|
||||
// Timeout for metadata parsing test
|
||||
metadataTestTimeout = 90 * time.Second
|
||||
// Number of initial pieces to prioritize for header metadata
|
||||
initialPiecesToPrioritize = 20
|
||||
)
|
||||
|
||||
// getTestTorrentClient creates a new torrent client for testing.
|
||||
func getTestTorrentClient(t *testing.T, tempDir string) *torrent.Client {
|
||||
t.Helper()
|
||||
cfg := torrent.NewDefaultClientConfig()
|
||||
// Use a subdirectory within the temp dir for torrent data
|
||||
cfg.DataDir = filepath.Join(tempDir, "torrent_data")
|
||||
err := os.MkdirAll(cfg.DataDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create torrent data directory: %v", err)
|
||||
}
|
||||
|
||||
client, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create torrent client: %v", err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// hasExt checks if a file path has a specific extension (case-insensitive).
|
||||
func hasExt(name, ext string) bool {
|
||||
if len(name) < len(ext) {
|
||||
return false
|
||||
}
|
||||
return strings.ToLower(name[len(name)-len(ext):]) == strings.ToLower(ext)
|
||||
}
|
||||
|
||||
// hasVideoExt checks for common video file extensions.
|
||||
func hasVideoExt(name string) bool {
|
||||
return hasExt(name, ".mkv") || hasExt(name, ".mp4") || hasExt(name, ".avi") || hasExt(name, ".mov") || hasExt(name, ".webm")
|
||||
}
|
||||
|
||||
// getTestTorrentFile adds the torrent, waits for metadata, returns the first video file.
|
||||
func getTestTorrentFile(t *testing.T, magnet string, tempDir string) (*torrent.Client, *torrent.Torrent, *torrent.File) {
|
||||
t.Helper()
|
||||
client := getTestTorrentClient(t, tempDir)
|
||||
|
||||
tctx, cancel := context.WithTimeout(context.Background(), torrentInfoTimeout)
|
||||
defer cancel()
|
||||
|
||||
tor, err := client.AddMagnet(magnet)
|
||||
if err != nil {
|
||||
client.Close() // Close client on error
|
||||
t.Fatalf("failed to add magnet: %v", err)
|
||||
}
|
||||
|
||||
t.Log("Waiting for torrent info...")
|
||||
select {
|
||||
case <-tor.GotInfo():
|
||||
t.Log("Torrent info received.")
|
||||
// continue
|
||||
case <-tctx.Done():
|
||||
tor.Drop() // Attempt to drop torrent
|
||||
client.Close() // Close client
|
||||
t.Fatalf("timeout waiting for torrent metadata (%v)", torrentInfoTimeout)
|
||||
}
|
||||
|
||||
// Find the first video file
|
||||
for _, f := range tor.Files() {
|
||||
path := f.DisplayPath()
|
||||
|
||||
if hasVideoExt(path) {
|
||||
t.Logf("Found video file: %s (Size: %d bytes)", path, f.Length())
|
||||
return client, tor, f
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("No video file found in torrent info: %s", tor.Info().Name)
|
||||
tor.Drop() // Drop torrent if no suitable file found
|
||||
client.Close() // Close client
|
||||
t.Fatalf("no video file found in torrent")
|
||||
return nil, nil, nil // Should not be reached
|
||||
}
|
||||
|
||||
func assertTestResult(t *testing.T, result *Metadata) {
|
||||
|
||||
//util.Spew(result)
|
||||
|
||||
if result.Error != nil {
|
||||
// If the error is context timeout/canceled, it's less severe but still worth noting
|
||||
if errors.Is(result.Error, context.DeadlineExceeded) || errors.Is(result.Error, context.Canceled) {
|
||||
t.Logf("Warning: GetMetadata context deadline exceeded or canceled: %v", result.Error)
|
||||
} else {
|
||||
t.Errorf("GetMetadata failed with unexpected error: %v", result.Error)
|
||||
}
|
||||
} else if result.Error != nil {
|
||||
t.Logf("Note: GetMetadata stopped with expected error: %v", result.Error)
|
||||
}
|
||||
|
||||
// Check Duration (should be positive for this known file)
|
||||
assert.True(t, result.Duration > 0, "Expected Duration to be positive, got %.2f", result.Duration)
|
||||
t.Logf("Duration: %.2f seconds", result.Duration)
|
||||
|
||||
// Check TimecodeScale
|
||||
assert.True(t, result.TimecodeScale > 0, "Expected TimecodeScale to be positive, got %f", result.TimecodeScale)
|
||||
t.Logf("TimecodeScale: %f", result.TimecodeScale)
|
||||
|
||||
// Check Muxing/Writing App (often present)
|
||||
if result.MuxingApp != "" {
|
||||
t.Logf("MuxingApp: %s", result.MuxingApp)
|
||||
}
|
||||
if result.WritingApp != "" {
|
||||
t.Logf("WritingApp: %s", result.WritingApp)
|
||||
}
|
||||
|
||||
// Check Tracks (expecting video, audio, subs for this file)
|
||||
assert.NotEmpty(t, result.Tracks, "Expected to find tracks")
|
||||
t.Logf("Found %d total tracks:", len(result.Tracks))
|
||||
foundVideo := false
|
||||
foundAudio := false
|
||||
for i, track := range result.Tracks {
|
||||
t.Logf(" Track %d:\n Type=%s, Codec=%s, Lang=%s, Name='%s', Default=%v, Forced=%v, Enabled=%v",
|
||||
i, track.Type, track.CodecID, track.Language, track.Name, track.Default, track.Forced, track.Enabled)
|
||||
if track.Video != nil {
|
||||
foundVideo = true
|
||||
assert.True(t, track.Video.PixelWidth > 0, "Video track should have PixelWidth > 0")
|
||||
assert.True(t, track.Video.PixelHeight > 0, "Video track should have PixelHeight > 0")
|
||||
t.Logf(" Video Details: %dx%d", track.Video.PixelWidth, track.Video.PixelHeight)
|
||||
}
|
||||
if track.Audio != nil {
|
||||
foundAudio = true
|
||||
assert.True(t, track.Audio.SamplingFrequency > 0, "Audio track should have SamplingFrequency > 0")
|
||||
assert.True(t, track.Audio.Channels > 0, "Audio track should have Channels > 0")
|
||||
t.Logf(" Audio Details: Freq=%.1f, Channels=%d, BitDepth=%d", track.Audio.SamplingFrequency, track.Audio.Channels, track.Audio.BitDepth)
|
||||
}
|
||||
t.Log()
|
||||
}
|
||||
assert.True(t, foundVideo, "Expected to find at least one video track")
|
||||
assert.True(t, foundAudio, "Expected to find at least one audio track")
|
||||
|
||||
t.Logf("Found %d total chapters:", len(result.Chapters))
|
||||
for _, chapter := range result.Chapters {
|
||||
t.Logf(" Chapter %d: StartTime=%.2f, EndTime=%.2f, Name='%s'",
|
||||
chapter.UID, chapter.Start, chapter.End, chapter.Text)
|
||||
}
|
||||
|
||||
t.Logf("Found %d total attachments:", len(result.Attachments))
|
||||
for _, att := range result.Attachments {
|
||||
t.Logf(" Attachment %d: Name='%s', MimeType='%s', Size=%d bytes",
|
||||
att.UID, att.Filename, att.Mimetype, att.Size)
|
||||
}
|
||||
|
||||
// Print the JSON representation of the result
|
||||
//jsonResult, err := json.MarshalIndent(result, "", " ")
|
||||
//if err != nil {
|
||||
// t.Fatalf("Failed to marshal result to JSON: %v", err)
|
||||
//}
|
||||
//t.Logf("JSON Result: %s", string(jsonResult))
|
||||
}
|
||||
|
||||
func testStreamSubtitles(t *testing.T, parser *MetadataParser, reader io.ReadSeekCloser, offset int64, ctx context.Context) {
|
||||
// Stream for 30 seconds
|
||||
streamCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
subtitleCh, errCh, _ := parser.ExtractSubtitles(streamCtx, reader, offset, 1024*1024)
|
||||
|
||||
var streamedSubtitles []*SubtitleEvent
|
||||
|
||||
// Collect subtitles with a timeout
|
||||
collectDone := make(chan struct{})
|
||||
go func() {
|
||||
defer func() {
|
||||
// Close the reader if it implements io.Closer
|
||||
if closer, ok := reader.(io.Closer); ok {
|
||||
_ = closer.Close()
|
||||
}
|
||||
}()
|
||||
defer close(collectDone)
|
||||
for {
|
||||
select {
|
||||
case subtitle, ok := <-subtitleCh:
|
||||
if !ok {
|
||||
return // Channel closed
|
||||
}
|
||||
streamedSubtitles = append(streamedSubtitles, subtitle)
|
||||
case <-streamCtx.Done():
|
||||
return // Timeout
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for all subtitles or timeout
|
||||
select {
|
||||
case <-collectDone:
|
||||
// All subtitles collected
|
||||
case <-streamCtx.Done():
|
||||
t.Log("StreamSubtitles collection timed out (this is expected for large files)")
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Logf("StreamSubtitles returned an error: %v", err)
|
||||
}
|
||||
default:
|
||||
// No errors yet
|
||||
}
|
||||
|
||||
t.Logf("Found %d streamed subtitles:", len(streamedSubtitles))
|
||||
for i, sub := range streamedSubtitles {
|
||||
if i < 5 { // Log first 5 subtitles
|
||||
t.Logf(" Streamed Subtitle %d: TrackNumber=%d, StartTime=%.2f, Text='%s'",
|
||||
i, sub.TrackNumber, sub.StartTime, sub.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetadataParser_Torrent performs an integration test.
|
||||
// It downloads the header of a real torrent and parses its metadata.
|
||||
func TestMetadataParser_Torrent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
client, tor, file := getTestTorrentFile(t, testMagnet, tempDir)
|
||||
|
||||
// Ensure client and torrent are closed/dropped eventually
|
||||
t.Cleanup(func() {
|
||||
t.Log("Dropping torrent...")
|
||||
tor.Drop()
|
||||
t.Log("Closing torrent client...")
|
||||
client.Close()
|
||||
t.Log("Cleanup finished.")
|
||||
})
|
||||
|
||||
logger := util.NewLogger()
|
||||
parser := NewMetadataParser(file.NewReader(), logger)
|
||||
|
||||
// Create context with timeout for the metadata parsing operation itself
|
||||
ctx, cancel := context.WithTimeout(context.Background(), metadataTestTimeout)
|
||||
defer cancel()
|
||||
|
||||
t.Log("Calling file.Download() to enable piece requests...")
|
||||
file.Download() // Start download requests
|
||||
|
||||
// Prioritize initial pieces to ensure metadata is fetched quickly
|
||||
torInfo := tor.Info()
|
||||
if torInfo != nil && torInfo.NumPieces() > 0 {
|
||||
numPieces := torInfo.NumPieces()
|
||||
piecesToFetch := initialPiecesToPrioritize
|
||||
if numPieces < piecesToFetch {
|
||||
piecesToFetch = numPieces
|
||||
}
|
||||
t.Logf("Prioritizing first %d pieces (out of %d) for header parsing...", piecesToFetch, numPieces)
|
||||
for i := 0; i < piecesToFetch; i++ {
|
||||
p := tor.Piece(i)
|
||||
if p != nil {
|
||||
p.SetPriority(torrent.PiecePriorityNow)
|
||||
}
|
||||
}
|
||||
// Give a moment for prioritization to take effect and requests to start
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
} else {
|
||||
t.Log("Torrent info or pieces not available for prioritization.")
|
||||
}
|
||||
|
||||
t.Log("Calling GetMetadata...")
|
||||
startTime := time.Now()
|
||||
metadata := parser.GetMetadata(ctx)
|
||||
elapsed := time.Since(startTime)
|
||||
t.Logf("GetMetadata took %v", elapsed)
|
||||
|
||||
assertTestResult(t, metadata)
|
||||
|
||||
testStreamSubtitles(t, parser, torrentutil.NewReadSeeker(tor, file, logger), 78123456, ctx)
|
||||
}
|
||||
|
||||
// TestMetadataParser_HTTPStream tests parsing from an HTTP stream
|
||||
func TestMetadataParser_HTTPStream(t *testing.T) {
|
||||
if testHttpUrl == "" {
|
||||
t.Skip("Skipping HTTP stream test")
|
||||
}
|
||||
|
||||
logger := util.NewLogger()
|
||||
|
||||
res, err := http.Get(testHttpUrl)
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP GET request failed: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
rs := httputil.NewHttpReadSeeker(res)
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("HTTP GET request returned non-OK status: %s", res.Status)
|
||||
}
|
||||
|
||||
parser := NewMetadataParser(rs, logger)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // 30-second timeout for parsing
|
||||
defer cancel()
|
||||
|
||||
metadata := parser.GetMetadata(ctx)
|
||||
|
||||
assertTestResult(t, metadata)
|
||||
|
||||
_, err = rs.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
|
||||
testStreamSubtitles(t, parser, rs, 1230000000, ctx)
|
||||
}
|
||||
|
||||
func TestMetadataParser_File(t *testing.T) {
|
||||
if testFile == "" {
|
||||
t.Skip("Skipping file test")
|
||||
}
|
||||
|
||||
logger := util.NewLogger()
|
||||
|
||||
file, err := os.Open(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Could not open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
parser := NewMetadataParser(file, logger)
|
||||
|
||||
ctx, _ := context.WithTimeout(context.Background(), 30*time.Second) // 30-second timeout for parsing
|
||||
//defer cancel()
|
||||
|
||||
metadata := parser.GetMetadata(ctx)
|
||||
|
||||
assertTestResult(t, metadata)
|
||||
|
||||
_, err = file.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
|
||||
testStreamSubtitles(t, parser, file, 1230000000, ctx)
|
||||
}
|
||||
183
seanime-2.9.10/internal/mkvparser/mkvparser_utils.go
Normal file
183
seanime-2.9.10/internal/mkvparser/mkvparser_utils.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package mkvparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReadIsMkvOrWebm reads the first 1KB of the stream to determine if it is a Matroska or WebM file.
|
||||
// It returns the mime type and a boolean indicating if it is a Matroska or WebM file.
|
||||
// It seeks to the beginning of the stream before and after reading.
|
||||
func ReadIsMkvOrWebm(r io.ReadSeeker) (string, bool) {
|
||||
// Go to the beginning of the stream
|
||||
_, err := r.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer r.Seek(0, io.SeekStart)
|
||||
|
||||
return isMkvOrWebm(r)
|
||||
}
|
||||
|
||||
func isMkvOrWebm(r io.Reader) (string, bool) {
|
||||
header := make([]byte, 1024) // Read the first 1KB to be safe
|
||||
n, err := r.Read(header)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Check for EBML magic bytes
|
||||
if !bytes.HasPrefix(header, []byte{0x1A, 0x45, 0xDF, 0xA3}) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Look for the DocType tag (0x42 82) and check the string
|
||||
docTypeTag := []byte{0x42, 0x82}
|
||||
idx := bytes.Index(header, docTypeTag)
|
||||
if idx == -1 || idx+3 >= n {
|
||||
return "", false
|
||||
}
|
||||
|
||||
size := int(header[idx+2]) // Size of DocType field
|
||||
if idx+3+size > n {
|
||||
return "", false
|
||||
}
|
||||
|
||||
docType := string(header[idx+3 : idx+3+size])
|
||||
switch docType {
|
||||
case "matroska":
|
||||
return "video/x-matroska", true
|
||||
case "webm":
|
||||
return "video/webm", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// UTF8ToASSText
|
||||
//
|
||||
// note: needs testing
|
||||
func UTF8ToASSText(text string) string {
|
||||
// Convert HTML entities to actual characters
|
||||
type tags struct {
|
||||
values []string
|
||||
replace string
|
||||
}
|
||||
t := []tags{
|
||||
{values: []string{"<"}, replace: "<"},
|
||||
{values: []string{">"}, replace: ">"},
|
||||
{values: []string{"&"}, replace: "&"},
|
||||
{values: []string{" "}, replace: "\\h"},
|
||||
{values: []string{"""}, replace: "\""},
|
||||
{values: []string{"'"}, replace: "'"},
|
||||
{values: []string{"'"}, replace: "'"},
|
||||
{values: []string{"«"}, replace: "«"},
|
||||
{values: []string{"»"}, replace: "»"},
|
||||
{values: []string{"–"}, replace: "-"},
|
||||
{values: []string{"—"}, replace: "—"},
|
||||
{values: []string{"…"}, replace: "…"},
|
||||
{values: []string{"©"}, replace: "©"},
|
||||
{values: []string{"®"}, replace: "®"},
|
||||
{values: []string{"™"}, replace: "™"},
|
||||
{values: []string{"€"}, replace: "€"},
|
||||
{values: []string{"£"}, replace: "£"},
|
||||
{values: []string{"¥"}, replace: "¥"},
|
||||
{values: []string{"$"}, replace: "$"},
|
||||
{values: []string{"¢"}, replace: "¢"},
|
||||
//
|
||||
{values: []string{"\r\n", "\n", "\r", "<br>", "<br/>", "<br />", "<BR>", "<BR/>", "<BR />"}, replace: "\\N"},
|
||||
{values: []string{"<b>", "<B>", "<strong>"}, replace: "{\\b1}"},
|
||||
{values: []string{"</b>", "</B>", "</strong>"}, replace: "{\\b0}"},
|
||||
{values: []string{"<i>", "<I>", "<em>"}, replace: "{\\i1}"},
|
||||
{values: []string{"</i>", "</I>", "</em>"}, replace: "{\\i0}"},
|
||||
{values: []string{"<u>", "<U>"}, replace: "{\\u1}"},
|
||||
{values: []string{"</u>", "</U>"}, replace: "{\\u0}"},
|
||||
{values: []string{"<s>", "<S>", "<strike>", "<del>"}, replace: "{\\s1}"},
|
||||
{values: []string{"</s>", "</S>", "</strike>", "</del>"}, replace: "{\\s0}"},
|
||||
{values: []string{"<center>", "<CENTER>"}, replace: "{\\an8}"},
|
||||
{values: []string{"</center>", "</CENTER>"}, replace: ""},
|
||||
{values: []string{"<ruby>", "<rt>"}, replace: "{\\ruby1}"},
|
||||
{values: []string{"</ruby>", "</rt>"}, replace: "{\\ruby0}"},
|
||||
{values: []string{"<p>", "<P>", "<div>", "<DIV>"}, replace: ""},
|
||||
{values: []string{"</p>", "</P>", "</div>", "</DIV>"}, replace: "\\N"},
|
||||
}
|
||||
|
||||
for _, tag := range t {
|
||||
for _, value := range tag.values {
|
||||
text = strings.ReplaceAll(text, value, tag.replace)
|
||||
}
|
||||
}
|
||||
|
||||
// Font tags with color and size
|
||||
if strings.Contains(text, "<font") || strings.Contains(text, "<FONT") {
|
||||
// Process font tags with attributes
|
||||
for strings.Contains(text, "<font") || strings.Contains(text, "<FONT") {
|
||||
var tagStart int
|
||||
if idx := strings.Index(text, "<font"); idx != -1 {
|
||||
tagStart = idx
|
||||
} else {
|
||||
tagStart = strings.Index(text, "<FONT")
|
||||
}
|
||||
|
||||
if tagStart == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
tagEnd := strings.Index(text[tagStart:], ">")
|
||||
if tagEnd == -1 {
|
||||
break
|
||||
}
|
||||
tagEnd += tagStart
|
||||
|
||||
// Extract the font tag content
|
||||
fontTag := text[tagStart : tagEnd+1]
|
||||
replacement := ""
|
||||
|
||||
// Handle color attribute
|
||||
if colorStart := strings.Index(fontTag, "color=\""); colorStart != -1 {
|
||||
colorStart += 7 // length of 'color="'
|
||||
if colorEnd := strings.Index(fontTag[colorStart:], "\""); colorEnd != -1 {
|
||||
color := fontTag[colorStart : colorStart+colorEnd]
|
||||
// Convert HTML color to ASS format
|
||||
if strings.HasPrefix(color, "#") {
|
||||
if len(color) == 7 { // #RRGGBB format
|
||||
color = "&H" + color[5:7] + color[3:5] + color[1:3] + "&" // Convert to ASS BGR format
|
||||
}
|
||||
}
|
||||
replacement += "{\\c" + color + "}"
|
||||
}
|
||||
}
|
||||
|
||||
// Handle size attribute
|
||||
if sizeStart := strings.Index(fontTag, "size=\""); sizeStart != -1 {
|
||||
sizeStart += 6 // length of 'size="'
|
||||
if sizeEnd := strings.Index(fontTag[sizeStart:], "\""); sizeEnd != -1 {
|
||||
size := fontTag[sizeStart : sizeStart+sizeEnd]
|
||||
replacement += "{\\fs" + size + "}"
|
||||
}
|
||||
}
|
||||
|
||||
// Handle face/family attribute
|
||||
if faceStart := strings.Index(fontTag, "face=\""); faceStart != -1 {
|
||||
faceStart += 6 // length of 'face="'
|
||||
if faceEnd := strings.Index(fontTag[faceStart:], "\""); faceEnd != -1 {
|
||||
face := fontTag[faceStart : faceStart+faceEnd]
|
||||
replacement += "{\\fn" + face + "}"
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the opening font tag
|
||||
text = text[:tagStart] + replacement + text[tagEnd+1:]
|
||||
|
||||
// Find and remove the corresponding closing tag
|
||||
if closeStart := strings.Index(text, "</font>"); closeStart != -1 {
|
||||
text = text[:closeStart] + "{\\r}" + text[closeStart+7:]
|
||||
} else if closeStart = strings.Index(text, "</FONT>"); closeStart != -1 {
|
||||
text = text[:closeStart] + "{\\r}" + text[closeStart+7:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
115
seanime-2.9.10/internal/mkvparser/structs.go
Normal file
115
seanime-2.9.10/internal/mkvparser/structs.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package mkvparser
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Info element and its children
|
||||
type Info struct {
|
||||
Title string
|
||||
MuxingApp string
|
||||
WritingApp string
|
||||
TimecodeScale uint64
|
||||
Duration float64
|
||||
DateUTC time.Time
|
||||
}
|
||||
|
||||
// TrackEntry represents a track in the MKV file
|
||||
type TrackEntry struct {
|
||||
TrackNumber uint64
|
||||
TrackUID uint64
|
||||
TrackType uint64
|
||||
FlagEnabled uint64
|
||||
FlagDefault uint64
|
||||
FlagForced uint64
|
||||
DefaultDuration uint64
|
||||
Name string
|
||||
Language string
|
||||
LanguageIETF string
|
||||
CodecID string
|
||||
CodecPrivate []byte
|
||||
Video *VideoTrack
|
||||
Audio *AudioTrack
|
||||
ContentEncodings *ContentEncodings
|
||||
}
|
||||
|
||||
// VideoTrack contains video-specific track data
|
||||
type VideoTrack struct {
|
||||
PixelWidth uint64
|
||||
PixelHeight uint64
|
||||
}
|
||||
|
||||
// AudioTrack contains audio-specific track data
|
||||
type AudioTrack struct {
|
||||
SamplingFrequency float64
|
||||
Channels uint64
|
||||
BitDepth uint64
|
||||
}
|
||||
|
||||
// ContentEncodings contains information about how the track data is encoded
|
||||
type ContentEncodings struct {
|
||||
ContentEncoding []ContentEncoding
|
||||
}
|
||||
|
||||
// ContentEncoding describes a single encoding applied to the track data
|
||||
type ContentEncoding struct {
|
||||
ContentEncodingOrder uint64
|
||||
ContentEncodingScope uint64
|
||||
ContentEncodingType uint64
|
||||
ContentCompression *ContentCompression
|
||||
}
|
||||
|
||||
// ContentCompression describes how the track data is compressed
|
||||
type ContentCompression struct {
|
||||
ContentCompAlgo uint64
|
||||
ContentCompSettings []byte
|
||||
}
|
||||
|
||||
// ChapterAtom represents a single chapter point
|
||||
type ChapterAtom struct {
|
||||
ChapterUID uint64
|
||||
ChapterTimeStart uint64
|
||||
ChapterTimeEnd uint64
|
||||
ChapterDisplay []ChapterDisplay
|
||||
}
|
||||
|
||||
// ChapterDisplay contains displayable chapter information
|
||||
type ChapterDisplay struct {
|
||||
ChapString string
|
||||
ChapLanguage []string
|
||||
ChapLanguageIETF []string
|
||||
}
|
||||
|
||||
// AttachedFile represents a file attached to the MKV container
|
||||
type AttachedFile struct {
|
||||
FileDescription string
|
||||
FileName string
|
||||
FileMimeType string
|
||||
FileData []byte
|
||||
FileUID uint64
|
||||
}
|
||||
|
||||
// Block represents a data block in the MKV file
|
||||
type Block struct {
|
||||
TrackNumber uint64
|
||||
Timecode int16
|
||||
Data [][]byte
|
||||
}
|
||||
|
||||
// BlockGroup represents a group of blocks with additional information
|
||||
type BlockGroup struct {
|
||||
Block Block
|
||||
BlockDuration uint64
|
||||
}
|
||||
|
||||
// Cluster represents a cluster of blocks in the MKV file
|
||||
type Cluster struct {
|
||||
Timecode uint64
|
||||
SimpleBlock []Block
|
||||
BlockGroup []BlockGroup
|
||||
}
|
||||
|
||||
// Tracks element and its children
|
||||
type Tracks struct {
|
||||
TrackEntry []TrackEntry `ebml:"TrackEntry"`
|
||||
}
|
||||
Reference in New Issue
Block a user