feat(cli): upgrade command, rich status, and version cache
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s

- 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:
Deivid Soto 2026-03-31 22:05:43 +02:00
parent 01d62ffa13
commit 3e0f3a5a64
33 changed files with 7084 additions and 65 deletions

View file

@ -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")
}

View 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)
}
}

View file

@ -0,0 +1,93 @@
package library
import (
"os"
"path/filepath"
"testing"
)
func TestDiscoverFiles(t *testing.T) {
dir := t.TempDir()
// Create video files (need to be >= 100MB to pass size check)
largeContent := make([]byte, 101*1024*1024)
videoFiles := []string{"movie.mkv", "show.mp4", "clip.avi"}
for _, name := range videoFiles {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, largeContent, 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
// Non-video files (should be excluded)
nonVideo := []string{"readme.txt", "cover.jpg", "subs.srt"}
for _, name := range nonVideo {
if err := os.WriteFile(filepath.Join(dir, name), largeContent, 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
// Small video file (should be excluded, < 100MB)
if err := os.WriteFile(filepath.Join(dir, "small.mkv"), []byte("small"), 0o644); err != nil {
t.Fatal(err)
}
// Excluded pattern (sample)
sampleDir := filepath.Join(dir, "sample")
os.MkdirAll(sampleDir, 0o755)
if err := os.WriteFile(filepath.Join(sampleDir, "sample.mkv"), largeContent, 0o644); err != nil {
t.Fatal(err)
}
files, err := discoverFiles(dir)
if err != nil {
t.Fatalf("discoverFiles: %v", err)
}
if len(files) != 3 {
t.Errorf("expected 3 files, got %d: %v", len(files), files)
}
// Check that all returned files are video extensions
for _, f := range files {
ext := filepath.Ext(f)
if ext != ".mkv" && ext != ".mp4" && ext != ".avi" {
t.Errorf("unexpected extension: %s", ext)
}
}
}
func TestDiscoverFilesEmptyDir(t *testing.T) {
dir := t.TempDir()
files, err := discoverFiles(dir)
if err != nil {
t.Fatalf("discoverFiles: %v", err)
}
if len(files) != 0 {
t.Errorf("expected 0 files, got %d", len(files))
}
}
func TestDiscoverFilesExcludePatterns(t *testing.T) {
dir := t.TempDir()
largeContent := make([]byte, 101*1024*1024)
excludeDirs := []string{"trailer", "featurette", "extras", "bonus"}
for _, name := range excludeDirs {
sub := filepath.Join(dir, name)
os.MkdirAll(sub, 0o755)
if err := os.WriteFile(filepath.Join(sub, "video.mkv"), largeContent, 0o644); err != nil {
t.Fatal(err)
}
}
files, err := discoverFiles(dir)
if err != nil {
t.Fatal(err)
}
if len(files) != 0 {
t.Errorf("expected 0 files (all excluded), got %d: %v", len(files), files)
}
}

View file

@ -0,0 +1,108 @@
package library
import (
"testing"
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
func TestBuildSyncItems(t *testing.T) {
cache := &LibraryCache{
Items: []LibraryItem{
{
FilePath: "/media/movies/Inception.mkv",
FileName: "Inception.2010.1080p.mkv",
FileSize: 5000000000,
Title: "Inception",
Year: "2010",
MediaInfo: &mediainfo.MediaInfo{
Video: &mediainfo.VideoInfo{
Codec: "hevc",
Width: 1920,
Height: 1080,
BitDepth: 10,
HDR: "HDR10",
},
Audio: []mediainfo.AudioTrack{
{Lang: "en", Codec: "ac3", Channels: 6, Default: true},
{Lang: "es", Codec: "aac", Channels: 2},
},
Subtitles: []mediainfo.SubtitleTrack{
{Lang: "en", Codec: "subrip"},
{Lang: "es", Codec: "subrip"},
},
},
},
{
FilePath: "/media/shows/Breaking.Bad.S01E01.mkv",
FileName: "Breaking.Bad.S01E01.mkv",
FileSize: 1000000000,
Title: "Breaking Bad",
Season: 1,
Episode: 1,
},
{
// Item with scan error — should be skipped
FilePath: "/media/bad.mkv",
FileName: "bad.mkv",
ScanError: "ffprobe failed",
},
},
}
items := BuildSyncItems(cache)
if len(items) != 2 {
t.Fatalf("expected 2 items (1 skipped), got %d", len(items))
}
// First item: movie with full media info
movie := items[0]
if movie.Title != "Inception" {
t.Errorf("title = %q, want Inception", movie.Title)
}
if movie.ContentType != "movie" {
t.Errorf("contentType = %q, want movie", movie.ContentType)
}
if movie.Resolution != "1080p" {
t.Errorf("resolution = %q, want 1080p", movie.Resolution)
}
if movie.VideoCodec != "hevc" {
t.Errorf("videoCodec = %q, want hevc", movie.VideoCodec)
}
if movie.HDR != "HDR10" {
t.Errorf("hdr = %q, want HDR10", movie.HDR)
}
if movie.AudioCodec != "ac3" {
t.Errorf("audioCodec = %q, want ac3", movie.AudioCodec)
}
if movie.AudioChannels != 6 {
t.Errorf("audioChannels = %d, want 6", movie.AudioChannels)
}
if len(movie.AudioLanguages) != 2 {
t.Errorf("audioLanguages count = %d, want 2", len(movie.AudioLanguages))
}
if len(movie.SubtitleLanguages) != 2 {
t.Errorf("subtitleLanguages count = %d, want 2", len(movie.SubtitleLanguages))
}
// Second item: show without media info
show := items[1]
if show.ContentType != "show" {
t.Errorf("contentType = %q, want show", show.ContentType)
}
if show.Season != 1 || show.Episode != 1 {
t.Errorf("season/episode = %d/%d, want 1/1", show.Season, show.Episode)
}
if show.Resolution != "" {
t.Errorf("resolution should be empty, got %q", show.Resolution)
}
}
func TestBuildSyncItemsEmpty(t *testing.T) {
cache := &LibraryCache{Items: nil}
items := BuildSyncItems(cache)
if len(items) != 0 {
t.Errorf("expected 0 items, got %d", len(items))
}
}