feat(cli): upgrade command, rich status, and version cache
- Replace `upgrade` stub with real command (alias for `self-update`) - Also register `update` as alias: `unarr update` works too - Rewrite `status` to show full config, disk usage, daemon state, and update availability with colored sections - Add version check cache (1h TTL) so `status` is instant on repeat runs - Guard against division by zero on empty filesystems - Guard against negative durations from clock skew - Guard against stale PID via heartbeat recency check (2 min) - Add comprehensive test coverage across agent, engine, upgrade, usenet, arr, library, mediaserver, and UI packages - Improve Makefile coverage target to exclude cmd/ glue code - Fix stream handler resource cleanup and ffprobe error handling
This commit is contained in:
parent
01d62ffa13
commit
3e0f3a5a64
33 changed files with 7084 additions and 65 deletions
|
|
@ -78,6 +78,12 @@ func ExtractMediaInfo(ctx context.Context, ffprobePath, filePath string) (*Media
|
|||
return nil, fmt.Errorf("ffprobe JSON parse failed: %w", err)
|
||||
}
|
||||
|
||||
return parseFFprobeOutput(data)
|
||||
}
|
||||
|
||||
// parseFFprobeOutput converts parsed ffprobe JSON into MediaInfo.
|
||||
// Separated from ExtractMediaInfo so it can be tested without running ffprobe.
|
||||
func parseFFprobeOutput(data ffprobeOutput) (*MediaInfo, error) {
|
||||
if len(data.Streams) == 0 {
|
||||
return nil, fmt.Errorf("ffprobe returned no streams")
|
||||
}
|
||||
|
|
|
|||
430
internal/library/mediainfo/ffprobe_test.go
Normal file
430
internal/library/mediainfo/ffprobe_test.go
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
package mediainfo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want float64
|
||||
}{
|
||||
{"", 0},
|
||||
{"0", 0},
|
||||
{"-5", 0},
|
||||
{"invalid", 0},
|
||||
{"7423.500000", 7423.5},
|
||||
{"120.123456", 120.123},
|
||||
{"3600", 3600},
|
||||
{"0.001", 0.001},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := parseDuration(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseDuration(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagValue(t *testing.T) {
|
||||
tags := map[string]string{
|
||||
"language": "eng",
|
||||
"title": "Main Audio",
|
||||
"HANDLER": "VideoHandler",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"language", "eng"},
|
||||
{"title", "Main Audio"},
|
||||
{"handler", "VideoHandler"},
|
||||
{"missing", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
got := tagValue(tags, tt.key)
|
||||
if got != tt.want {
|
||||
t.Errorf("tagValue(tags, %q) = %q, want %q", tt.key, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagValueNil(t *testing.T) {
|
||||
got := tagValue(nil, "language")
|
||||
if got != "" {
|
||||
t.Errorf("tagValue(nil, language) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsAny(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
subs []string
|
||||
want bool
|
||||
}{
|
||||
{"yuv420p10le", []string{"10le", "10be", "p010"}, true},
|
||||
{"yuv420p12be", []string{"10le", "10be", "p010"}, false},
|
||||
{"yuv420p12be", []string{"12le", "12be"}, true},
|
||||
{"yuv420p", []string{"10le", "10be"}, false},
|
||||
{"", []string{"any"}, false},
|
||||
{"something", []string{}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := containsAny(tt.s, tt.subs...)
|
||||
if got != tt.want {
|
||||
t.Errorf("containsAny(%q, %v) = %v, want %v", tt.s, tt.subs, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_BasicH264(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Format: ffprobeFormat{Duration: "7423.5"},
|
||||
Streams: []ffprobeStream{
|
||||
{
|
||||
CodecType: "video",
|
||||
CodecName: "h264",
|
||||
Profile: "High",
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
RFrameRate: "24000/1001",
|
||||
},
|
||||
{
|
||||
CodecType: "audio",
|
||||
CodecName: "aac",
|
||||
Channels: 2,
|
||||
Tags: map[string]string{"language": "eng"},
|
||||
Disposition: map[string]int{"default": 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parseFFprobeOutput: %v", err)
|
||||
}
|
||||
if mi.Video == nil {
|
||||
t.Fatal("expected video info")
|
||||
}
|
||||
if mi.Video.Codec != "h264" {
|
||||
t.Errorf("codec = %q, want h264", mi.Video.Codec)
|
||||
}
|
||||
if mi.Video.Width != 1920 || mi.Video.Height != 1080 {
|
||||
t.Errorf("dimensions = %dx%d, want 1920x1080", mi.Video.Width, mi.Video.Height)
|
||||
}
|
||||
if mi.Video.Profile != "High" {
|
||||
t.Errorf("profile = %q, want High", mi.Video.Profile)
|
||||
}
|
||||
if mi.Video.Duration != 7423.5 {
|
||||
t.Errorf("duration = %v, want 7423.5", mi.Video.Duration)
|
||||
}
|
||||
if mi.Video.FrameRate < 23.975 || mi.Video.FrameRate > 23.977 {
|
||||
t.Errorf("frameRate = %v, want ~23.976", mi.Video.FrameRate)
|
||||
}
|
||||
if len(mi.Audio) != 1 {
|
||||
t.Fatalf("audio tracks = %d, want 1", len(mi.Audio))
|
||||
}
|
||||
if mi.Audio[0].Lang != "en" {
|
||||
t.Errorf("audio lang = %q, want en", mi.Audio[0].Lang)
|
||||
}
|
||||
if !mi.Audio[0].Default {
|
||||
t.Error("expected default audio track")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_HEVC_HDR10(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Format: ffprobeFormat{Duration: "3600"},
|
||||
Streams: []ffprobeStream{
|
||||
{
|
||||
CodecType: "video",
|
||||
CodecName: "hevc",
|
||||
Width: 3840,
|
||||
Height: 2160,
|
||||
BitsPerRaw: "10",
|
||||
ColorSpace: "bt2020nc",
|
||||
ColorTransfer: "smpte2084",
|
||||
RFrameRate: "24/1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.HDR != "HDR10" {
|
||||
t.Errorf("hdr = %q, want HDR10", mi.Video.HDR)
|
||||
}
|
||||
if mi.Video.BitDepth != 10 {
|
||||
t.Errorf("bitDepth = %d, want 10", mi.Video.BitDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_DolbyVisionWithHDR10(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Streams: []ffprobeStream{
|
||||
{
|
||||
CodecType: "video",
|
||||
CodecName: "hevc",
|
||||
Width: 3840,
|
||||
Height: 2160,
|
||||
ColorSpace: "bt2020nc",
|
||||
ColorTransfer: "smpte2084",
|
||||
SideDataList: []sideData{{SideDataType: "DOVI configuration record"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.HDR != "DV+HDR10" {
|
||||
t.Errorf("hdr = %q, want DV+HDR10", mi.Video.HDR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_DolbyVisionOnly(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Streams: []ffprobeStream{
|
||||
{
|
||||
CodecType: "video",
|
||||
CodecName: "hevc",
|
||||
Width: 3840,
|
||||
Height: 2160,
|
||||
SideDataList: []sideData{{SideDataType: "DOVI configuration record"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.HDR != "DV" {
|
||||
t.Errorf("hdr = %q, want DV", mi.Video.HDR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_HLG(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Streams: []ffprobeStream{
|
||||
{
|
||||
CodecType: "video",
|
||||
CodecName: "hevc",
|
||||
Width: 3840,
|
||||
Height: 2160,
|
||||
ColorSpace: "bt2020nc",
|
||||
ColorTransfer: "arib-std-b67",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.HDR != "HLG" {
|
||||
t.Errorf("hdr = %q, want HLG", mi.Video.HDR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_MultiAudioAndSubtitles(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Format: ffprobeFormat{Duration: "5400"},
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "video", CodecName: "h264", Width: 1920, Height: 1080},
|
||||
{
|
||||
CodecType: "audio", CodecName: "ac3", Channels: 6,
|
||||
Tags: map[string]string{"language": "eng", "title": "English 5.1"},
|
||||
Disposition: map[string]int{"default": 1},
|
||||
},
|
||||
{
|
||||
CodecType: "audio", CodecName: "aac", Channels: 2,
|
||||
Tags: map[string]string{"language": "spa"},
|
||||
},
|
||||
{
|
||||
CodecType: "subtitle", CodecName: "subrip",
|
||||
Tags: map[string]string{"language": "eng"},
|
||||
},
|
||||
{
|
||||
CodecType: "subtitle", CodecName: "ass",
|
||||
Tags: map[string]string{"language": "spa"},
|
||||
Disposition: map[string]int{"forced": 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mi.Audio) != 2 {
|
||||
t.Fatalf("audio tracks = %d, want 2", len(mi.Audio))
|
||||
}
|
||||
if mi.Audio[0].Title != "English 5.1" {
|
||||
t.Errorf("audio[0].title = %q", mi.Audio[0].Title)
|
||||
}
|
||||
if len(mi.Subtitles) != 2 {
|
||||
t.Fatalf("subtitle tracks = %d, want 2", len(mi.Subtitles))
|
||||
}
|
||||
if !mi.Subtitles[1].Forced {
|
||||
t.Error("expected subtitle[1] to be forced")
|
||||
}
|
||||
if len(mi.Languages) != 2 {
|
||||
t.Errorf("languages = %v, want 2 entries", mi.Languages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_BitDepthFromPixFmt(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "video", CodecName: "hevc", Width: 1920, Height: 1080, PixFmt: "yuv420p10le"},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.BitDepth != 10 {
|
||||
t.Errorf("bitDepth = %d, want 10", mi.Video.BitDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_12BitFromPixFmt(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "video", CodecName: "hevc", Width: 1920, Height: 1080, PixFmt: "yuv420p12be"},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.BitDepth != 12 {
|
||||
t.Errorf("bitDepth = %d, want 12", mi.Video.BitDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_DurationFromStreamFallback(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Format: ffprobeFormat{Duration: ""},
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "video", CodecName: "h264", Width: 1280, Height: 720, Duration: "1800.5"},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.Duration != 1800.5 {
|
||||
t.Errorf("duration = %v, want 1800.5", mi.Video.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_NoStreams(t *testing.T) {
|
||||
data := ffprobeOutput{}
|
||||
_, err := parseFFprobeOutput(data)
|
||||
if err == nil {
|
||||
t.Error("expected error for no streams")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_OnlyFirstVideoStream(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "video", CodecName: "h264", Width: 1920, Height: 1080},
|
||||
{CodecType: "video", CodecName: "mjpeg", Width: 320, Height: 240}, // cover art
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.Codec != "h264" {
|
||||
t.Errorf("should use first video stream, got codec %q", mi.Video.Codec)
|
||||
}
|
||||
if mi.Video.Width != 1920 {
|
||||
t.Errorf("width = %d, should be from first video stream", mi.Video.Width)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_SMPTE2084_WithoutBT2020(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "video", CodecName: "hevc", Width: 3840, Height: 2160, ColorTransfer: "smpte2084"},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.HDR != "HDR10" {
|
||||
t.Errorf("hdr = %q, want HDR10", mi.Video.HDR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_AribWithoutBT2020(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "video", CodecName: "hevc", Width: 3840, Height: 2160, ColorTransfer: "arib-std-b67", ColorSpace: "other"},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.HDR != "HLG" {
|
||||
t.Errorf("hdr = %q, want HLG", mi.Video.HDR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_AudioOnly(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "audio", CodecName: "flac", Channels: 2, Tags: map[string]string{"language": "eng"}},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video != nil {
|
||||
t.Error("expected no video info for audio-only")
|
||||
}
|
||||
if len(mi.Audio) != 1 {
|
||||
t.Errorf("audio tracks = %d, want 1", len(mi.Audio))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFFprobeOutput_FrameRateNoSlash(t *testing.T) {
|
||||
data := ffprobeOutput{
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "video", CodecName: "h264", Width: 1920, Height: 1080, RFrameRate: "30"},
|
||||
},
|
||||
}
|
||||
|
||||
mi, err := parseFFprobeOutput(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Video.FrameRate != 0 {
|
||||
t.Errorf("frameRate = %v, want 0 (no slash)", mi.Video.FrameRate)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue