feat: improve daemon resilience, streaming, and usenet downloads

- Add daemon state persistence and stale resume file cleanup
- Add TriggerPoll for WebSocket resume actions
- Improve stream server with graceful shutdown and connection tracking
- Add desktop notifications for download completion
- Add media file organization with Movies/TV Shows detection
- Improve usenet downloader with progress tracking and resume support
- Add self-update package with GitHub release verification
- Downgrade tablewriter to v0.0.5 (v1.x API breaking change)
This commit is contained in:
Deivid Soto 2026-03-28 21:36:12 +01:00
parent e332c0a6e4
commit 197e33956a
24 changed files with 2310 additions and 84 deletions

123
internal/upgrade/extract.go Normal file
View file

@ -0,0 +1,123 @@
package upgrade
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
// extractBinary extracts the unarr binary from the release archive into destDir.
// Returns the path to the extracted binary.
func extractBinary(archivePath, destDir string) (string, error) {
if runtime.GOOS == "windows" {
return extractZip(archivePath, destDir)
}
return extractTarGz(archivePath, destDir)
}
func extractTarGz(archivePath, destDir string) (string, error) {
f, err := os.Open(archivePath)
if err != nil {
return "", err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return "", fmt.Errorf("gzip: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
target := binaryName
if runtime.GOOS == "windows" {
target += ".exe"
}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("tar: %w", err)
}
name := filepath.Base(hdr.Name)
if name != target {
continue
}
// Validate: must be a regular file
if hdr.Typeflag != tar.TypeReg {
continue
}
dst := filepath.Join(destDir, target)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return "", err
}
if _, err := io.Copy(out, io.LimitReader(tr, 200<<20)); err != nil { // 200MB limit
out.Close()
return "", fmt.Errorf("extract: %w", err)
}
out.Close()
return dst, nil
}
return "", fmt.Errorf("binary %q not found in archive", target)
}
func extractZip(archivePath, destDir string) (string, error) {
r, err := zip.OpenReader(archivePath)
if err != nil {
return "", fmt.Errorf("zip: %w", err)
}
defer r.Close()
target := binaryName + ".exe"
for _, f := range r.File {
name := filepath.Base(f.Name)
// Guard against path traversal
if strings.Contains(f.Name, "..") {
continue
}
if name != target {
continue
}
rc, err := f.Open()
if err != nil {
return "", err
}
dst := filepath.Join(destDir, target)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
rc.Close()
return "", err
}
if _, err := io.Copy(out, io.LimitReader(rc, 200<<20)); err != nil { // 200MB limit
out.Close()
rc.Close()
return "", fmt.Errorf("extract: %w", err)
}
out.Close()
rc.Close()
return dst, nil
}
return "", fmt.Errorf("binary %q not found in archive", target)
}