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

View file

@ -14,8 +14,29 @@ func desktopNotify(title, body string) {
case "darwin":
script := `display notification "` + escapeAppleScript(body) + `" with title "` + escapeAppleScript(title) + `"`
exec.Command("osascript", "-e", script).Start()
case "windows":
// Use PowerShell toast notification (Windows 10+)
script := `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null;` +
`$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(1);` +
`$text = $xml.GetElementsByTagName('text');` +
`$text[0].AppendChild($xml.CreateTextNode('` + escapePowerShell(title) + `')) > $null;` +
`$text[1].AppendChild($xml.CreateTextNode('` + escapePowerShell(body) + `')) > $null;` +
`$toast = [Windows.UI.Notifications.ToastNotification]::new($xml);` +
`[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('unarr').Show($toast)`
exec.Command("powershell", "-NoProfile", "-Command", script).Start()
}
// Windows: no-op for now
}
func escapePowerShell(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\'' {
out = append(out, '\'', '\'') // double single-quote to escape
} else {
out = append(out, s[i])
}
}
return string(out)
}
func escapeAppleScript(s string) string {

View file

@ -0,0 +1,46 @@
package engine
import "testing"
func TestEscapePowerShell(t *testing.T) {
tests := []struct {
input string
want string
}{
{"hello", "hello"},
{"it's done", "it''s done"},
{"Tom's 'file'", "Tom''s ''file''"},
{"no quotes", "no quotes"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := escapePowerShell(tt.input)
if got != tt.want {
t.Errorf("escapePowerShell(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestEscapeAppleScript(t *testing.T) {
tests := []struct {
input string
want string
}{
{"hello", "hello"},
{`say "hi"`, `say \"hi\"`},
{`back\slash`, `back\\slash`},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := escapeAppleScript(tt.input)
if got != tt.want {
t.Errorf("escapeAppleScript(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

View file

@ -10,8 +10,10 @@ import (
)
var (
yearRegex = regexp.MustCompile(`\b(19|20)\d{2}\b`)
seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`)
yearRegex = regexp.MustCompile(`\b(19|20)\d{2}\b`)
seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`)
episodeRegex = regexp.MustCompile(`(?i)S(\d{2})E(\d{2})`)
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`) // 1x05 format
)
// OrganizeConfig holds file organization settings.
@ -37,9 +39,15 @@ func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
isTV := strings.Contains(strings.ToLower(task.PreferredMethod), "show") ||
seasonRegex.MatchString(result.FileName)
// Detect season for TV
// Detect season for TV (S01E05 or 1x05 format)
var season string
if m := seasonRegex.FindStringSubmatch(result.FileName); len(m) > 1 {
if m := episodeRegex.FindStringSubmatch(result.FileName); len(m) > 2 {
season = m[1]
isTV = true
} else if m := altEpRegex.FindStringSubmatch(result.FileName); len(m) > 2 {
season = fmt.Sprintf("%02s", m[1])
isTV = true
} else if m := seasonRegex.FindStringSubmatch(result.FileName); len(m) > 1 {
season = m[1]
isTV = true
}
@ -80,6 +88,23 @@ func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
destPath := filepath.Join(destDir, filepath.Base(result.FilePath))
// Check if source is a directory (multi-file torrent)
srcInfo, err := os.Stat(result.FilePath)
if err != nil {
return "", fmt.Errorf("stat source: %w", err)
}
if srcInfo.IsDir() {
// For directories: remove existing destination if present, then rename
if _, err := os.Stat(destPath); err == nil {
os.RemoveAll(destPath)
}
if err := os.Rename(result.FilePath, destPath); err != nil {
return "", fmt.Errorf("move directory: %w", err)
}
return destPath, nil
}
// Try rename first (same filesystem), fall back to copy+delete
if err := os.Rename(result.FilePath, destPath); err != nil {
if err := copyFile(result.FilePath, destPath); err != nil {

View file

@ -71,6 +71,60 @@ func TestOrganizeTVShow(t *testing.T) {
}
}
func TestOrganizeTVShowAltFormat(t *testing.T) {
tmp := t.TempDir()
srcFile := filepath.Join(tmp, "Show.3x12.HDTV.mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
tvDir := filepath.Join(tmp, "TV Shows")
r := &Result{FilePath: srcFile, FileName: "Show.3x12.HDTV.mkv"}
task := &Task{Title: "Show 3x12"}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
TVShowsDir: tvDir,
})
if err != nil {
t.Fatal(err)
}
// Should detect season 03 from "3x12" format
if _, err := os.Stat(path); err != nil {
t.Errorf("organized file should exist at %s: %v", path, err)
}
// Verify it went into Season 03 directory
dir := filepath.Dir(path)
if filepath.Base(dir) != "Season 03" {
t.Errorf("expected Season 03 directory, got %q", filepath.Base(dir))
}
}
func TestSeasonDetectionFormats(t *testing.T) {
tests := []struct {
filename string
wantTV bool
}{
{"Show.S01E05.720p.mkv", true},
{"Show.s02e10.1080p.mkv", true},
{"Show.3x12.HDTV.mkv", true},
{"Show.12x01.mkv", true},
{"Movie.2023.1080p.mkv", false},
{"Just.A.Movie.mkv", false},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
isTV := episodeRegex.MatchString(tt.filename) ||
altEpRegex.MatchString(tt.filename) ||
seasonRegex.MatchString(tt.filename)
if isTV != tt.wantTV {
t.Errorf("isTV(%q) = %v, want %v", tt.filename, isTV, tt.wantTV)
}
})
}
}
func TestCleanTitle(t *testing.T) {
tests := []struct {
input string

View file

@ -3,6 +3,7 @@ package engine
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@ -233,7 +234,7 @@ func (s *StreamEngine) WaitBuffer(ctx context.Context, progressFn func(buffered,
// NewFileReader creates a new reader for the selected file.
// Each HTTP request should get its own reader (not safe for concurrent use).
func (s *StreamEngine) NewFileReader(ctx context.Context) torrent.Reader {
func (s *StreamEngine) NewFileReader(ctx context.Context) io.ReadSeekCloser {
reader := s.file.NewReader()
reader.SetResponsive()
reader.SetReadahead(5 * 1024 * 1024) // 5MB readahead

View file

@ -3,9 +3,11 @@ package engine
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@ -15,7 +17,7 @@ import (
// fileProvider abstracts where to get a file reader for streaming.
type fileProvider interface {
NewFileReader(ctx context.Context) torrent.Reader
NewFileReader(ctx context.Context) io.ReadSeekCloser
FileName() string
}
@ -49,7 +51,7 @@ type torrentFileProvider struct {
file *torrent.File
}
func (p *torrentFileProvider) NewFileReader(ctx context.Context) torrent.Reader {
func (p *torrentFileProvider) NewFileReader(ctx context.Context) io.ReadSeekCloser {
reader := p.file.NewReader()
reader.SetResponsive()
reader.SetReadahead(5 * 1024 * 1024)
@ -61,6 +63,33 @@ func (p *torrentFileProvider) FileName() string {
return filepath.Base(p.file.DisplayPath())
}
// diskFileProvider serves a file from disk.
type diskFileProvider struct {
path string
name string
}
func (p *diskFileProvider) NewFileReader(_ context.Context) io.ReadSeekCloser {
f, err := os.Open(p.path)
if err != nil {
return nil
}
return f
}
func (p *diskFileProvider) FileName() string { return p.name }
// NewStreamServerFromDisk creates a server that streams a file from disk.
func NewStreamServerFromDisk(filePath string, port int) *StreamServer {
return &StreamServer{
provider: &diskFileProvider{
path: filePath,
name: filepath.Base(filePath),
},
port: port,
}
}
// Start begins serving the file on localhost. Returns the full URL.
func (ss *StreamServer) Start(ctx context.Context) (string, error) {
mux := http.NewServeMux()
@ -106,6 +135,10 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
reader := ss.provider.NewFileReader(r.Context())
if reader == nil {
http.Error(w, "file not found", http.StatusNotFound)
return
}
defer reader.Close()
w.Header().Set("Content-Type", mimeTypeFromExt(ss.provider.FileName()))

View file

@ -146,13 +146,28 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
}
// 4. Determine file path
// For multi-file torrents, fileName includes the torrent dir prefix (e.g. "TorrentName/file.mkv").
// Try the full path first, then just the file inside the torrent dir.
filePath := filepath.Join(d.cfg.DataDir, fileName)
if _, statErr := os.Stat(filePath); statErr != nil {
filePath = filepath.Join(d.cfg.DataDir, t.Name())
// File might have been moved — try torrent directory
dirPath := filepath.Join(d.cfg.DataDir, t.Name())
if fi, statErr2 := os.Stat(dirPath); statErr2 == nil && fi.IsDir() {
// Look for the actual file inside the directory
base := filepath.Base(fileName)
candidate := filepath.Join(dirPath, base)
if _, statErr3 := os.Stat(candidate); statErr3 == nil {
filePath = candidate
} else {
filePath = dirPath
}
} else {
filePath = dirPath
}
}
result.FilePath = filePath
result.FileName = fileName
result.FileName = filepath.Base(fileName)
result.Method = MethodTorrent
result.Size = totalBytes
@ -211,6 +226,13 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
// Peer stats
stats := t.Stats()
// Terminal progress
pct := int(float64(downloaded) / float64(totalBytes) * 100)
fmt.Fprintf(os.Stderr, "\r[%s] %d%% — %s/%s @ %s/s peers:%d seeds:%d",
task.ID[:8], pct,
formatBytes(downloaded), formatBytes(totalBytes), formatBytes(speed),
stats.ActivePeers, stats.ConnectedSeeders)
// Report progress
p := Progress{
DownloadedBytes: downloaded,
@ -230,6 +252,7 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
// Check completion
if downloaded >= totalBytes {
fmt.Fprint(os.Stderr, "\r\033[2K") // clear progress line
log.Printf("[%s] download complete: %s", task.ID[:8], fileName)
return &Result{}, nil
}

View file

@ -11,7 +11,7 @@ import (
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
"github.com/torrentclaw/torrentclaw-cli/internal/usenet/download"
"github.com/torrentclaw/torrentclaw-cli/internal/usenet/nntp"
"github.com/torrentclaw/torrentclaw-cli/internal/usenet/nzb"
@ -125,10 +125,22 @@ func (u *UsenetDownloader) Download(ctx context.Context, task *Task, outputDir s
log.Printf("[%s] NZB: %s", shortID, nzbTitle)
// Step 2: Download NZB file
nzbData, err := u.apiClient.DownloadNzb(dlCtx, nzbID)
// Step 2: Download NZB file (or use cached version for resume)
resumeDir := filepath.Join(config.DataDir(), "resume")
nzbCachePath := filepath.Join(resumeDir, task.ID+".nzb")
nzbData, err := os.ReadFile(nzbCachePath)
if err != nil {
return nil, fmt.Errorf("download NZB: %w", err)
// Not cached — download from server
nzbData, err = u.apiClient.DownloadNzb(dlCtx, nzbID)
if err != nil {
return nil, fmt.Errorf("download NZB: %w", err)
}
// Cache for future resume
os.MkdirAll(resumeDir, 0o755)
os.WriteFile(nzbCachePath, nzbData, 0o644)
} else {
log.Printf("[%s] using cached NZB", shortID)
}
// Step 3: Parse NZB
@ -140,7 +152,15 @@ func (u *UsenetDownloader) Download(ctx context.Context, task *Task, outputDir s
totalBytes := nzbFile.TotalBytes()
totalSegs := nzbFile.TotalSegments()
log.Printf("[%s] NZB parsed: %d files, %d segments, %s",
shortID, len(nzbFile.Files), totalSegs, ui.FormatBytes(totalBytes))
shortID, len(nzbFile.Files), totalSegs, formatBytes(totalBytes))
// Step 3.5: Resume support — load or create progress tracker
tracker := download.NewProgressTracker(task.ID, nzbFile, resumeDir)
resumed, _ := tracker.Load()
if resumed {
log.Printf("[%s] resuming usenet download (%d/%d segments completed)",
shortID, tracker.TotalCompleted(), totalSegs)
}
// Step 4: Get NNTP credentials and connect
creds, err := u.getCredentials(dlCtx)
@ -185,7 +205,7 @@ func (u *UsenetDownloader) Download(ctx context.Context, task *Task, outputDir s
}
}()
downloadedFiles, err := dl.DownloadNZB(dlCtx, nzbFile, taskDir, dlProgressCh)
downloadedFiles, err := dl.DownloadNZB(dlCtx, nzbFile, taskDir, tracker, dlProgressCh)
close(dlProgressCh)
if err != nil {
@ -234,6 +254,9 @@ func (u *UsenetDownloader) Download(ctx context.Context, task *Task, outputDir s
finalSize = fi.Size()
}
// Clean up resume state on successful completion
tracker.Remove()
return &Result{
FilePath: finalPath,
FileName: filepath.Base(finalPath),