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:
parent
e332c0a6e4
commit
197e33956a
24 changed files with 2310 additions and 84 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
46
internal/engine/notify_test.go
Normal file
46
internal/engine/notify_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue