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

@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/anacrolix/torrent"
@ -24,11 +25,12 @@ type fileProvider interface {
// StreamServer serves a torrent file over HTTP with Range request support.
type StreamServer struct {
provider fileProvider
server *http.Server
port int
url string
upnpMapping *UPnPMapping
provider fileProvider
server *http.Server
port int
url string
upnpMapping *UPnPMapping
lastActivity atomic.Int64 // UnixNano of last HTTP request
}
// NewStreamServer creates a new HTTP server for streaming via StreamEngine.
@ -93,11 +95,38 @@ func NewStreamServerFromDisk(filePath string, port int) *StreamServer {
}
}
// Start begins serving the file on all interfaces. Returns the best reachable URL:
// 1. UPnP public IP (accessible from anywhere on the internet)
// 2. Tailscale IP (accessible from any device in the tailnet)
// 3. LAN IP (accessible from local network)
// FindVideoFile scans a directory (recursively) for the largest video file.
// Returns empty string if no video file found.
func FindVideoFile(dir string) string {
var best string
var bestSize int64
filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(d.Name()))
if !VideoExts[ext] {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
if info.Size() > bestSize {
best = path
bestSize = info.Size()
}
return nil
})
return best
}
// Start begins serving the file on all interfaces. Returns the best reachable URL.
// The file is served as-is — the user's media player (VLC, mpv, etc.) handles decoding.
func (ss *StreamServer) Start(ctx context.Context) (string, error) {
ss.lastActivity.Store(time.Now().UnixNano())
mux := http.NewServeMux()
mux.HandleFunc("/stream", ss.handler)
@ -107,19 +136,9 @@ func (ss *StreamServer) Start(ctx context.Context) (string, error) {
return "", fmt.Errorf("listen on %s: %w", addr, err)
}
// Extract actual port (important when port=0)
ss.port = listener.Addr().(*net.TCPAddr).Port
// Try UPnP for public internet access (like Plex Remote Access)
if mapping, upnpErr := setupUPnP(ss.port); upnpErr == nil {
ss.upnpMapping = mapping
ss.url = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort)
log.Printf("stream: UPnP mapped %s:%d -> local:%d", mapping.ExternalIP, mapping.ExternalPort, ss.port)
} else {
// Fallback: Tailscale IP > LAN IP > 127.0.0.1
ss.url = fmt.Sprintf("http://%s:%d/stream", reachableIP(), ss.port)
log.Printf("stream: UPnP unavailable (%v), using %s", upnpErr, ss.url)
}
ss.url = fmt.Sprintf("http://%s:%d/stream", reachableIP(), ss.port)
log.Printf("stream: serving on %s", ss.url)
ss.server = &http.Server{
Handler: mux,
@ -141,6 +160,15 @@ func (ss *StreamServer) URL() string { return ss.url }
// Port returns the bound port.
func (ss *StreamServer) Port() int { return ss.port }
// IdleSince returns how long since the last HTTP request was received.
func (ss *StreamServer) IdleSince() time.Duration {
last := ss.lastActivity.Load()
if last == 0 {
return 0
}
return time.Since(time.Unix(0, last))
}
// Shutdown gracefully stops the HTTP server and removes the UPnP port mapping.
func (ss *StreamServer) Shutdown(ctx context.Context) error {
ss.upnpMapping.Remove()
@ -151,6 +179,8 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
}
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
ss.lastActivity.Store(time.Now().UnixNano())
// CORS headers — only when browser sends Origin (HTTPS site → localhost)
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", "*")