This commit is contained in:
itexpert228 2026-03-07 19:40:49 +03:00
parent 5eae46041a
commit bd5ebf2fd7
No known key found for this signature in database

View file

@ -13,7 +13,6 @@ import (
fyneapp "fyne.io/fyne/v2/app" fyneapp "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
appcore "github.com/veggiedefender/torrent-client/internal/app" appcore "github.com/veggiedefender/torrent-client/internal/app"
@ -23,14 +22,20 @@ import (
type desktopUI struct { type desktopUI struct {
controller *appcore.Controller controller *appcore.Controller
app fyne.App
window fyne.Window window fyne.Window
pathEntry *widget.Entry pathEntry *widget.Entry
downloadDirEntry *widget.Entry downloadDirEntry *widget.Entry
browseButton *widget.Button
browseOutputButton *widget.Button
startButton *widget.Button
stopButton *widget.Button
progress *widget.ProgressBar progress *widget.ProgressBar
phaseLabel *widget.Label phaseLabel *widget.Label
phaseMetricLabel *widget.Label phaseMetricLabel *widget.Label
statusSummaryLabel *widget.Label
messageLabel *widget.Label messageLabel *widget.Label
errorLabel *widget.Label errorLabel *widget.Label
nameLabel *widget.Label nameLabel *widget.Label
@ -46,6 +51,8 @@ type desktopUI struct {
trackers []torrent.TrackerStatus trackers []torrent.TrackerStatus
peers []torrent.PeerStatus peers []torrent.PeerStatus
files []torrentfile.File files []torrentfile.File
inputError string
loading bool
trackersTable *widget.Table trackersTable *widget.Table
peersTable *widget.Table peersTable *widget.Table
@ -61,26 +68,26 @@ func Start(controller *appcore.Controller) {
func newDesktopUI(controller *appcore.Controller) *desktopUI { func newDesktopUI(controller *appcore.Controller) *desktopUI {
a := fyneapp.NewWithID("github.com/veggiedefender/ztorrent") a := fyneapp.NewWithID("github.com/veggiedefender/ztorrent")
w := a.NewWindow("Ztorrent") w := a.NewWindow("Ztorrent Desktop")
w.Resize(fyne.NewSize(1200, 760)) w.Resize(fyne.NewSize(1280, 800))
ui := &desktopUI{ ui := &desktopUI{
controller: controller, controller: controller,
app: a,
window: w, window: w,
stopRefresh: make(chan struct{}), stopRefresh: make(chan struct{}),
pathEntry: widget.NewEntry(), pathEntry: widget.NewEntry(),
downloadDirEntry: widget.NewEntry(), downloadDirEntry: widget.NewEntry(),
progress: widget.NewProgressBar(), progress: widget.NewProgressBar(),
phaseLabel: widget.NewLabel("Phase: idle"), phaseLabel: widget.NewLabel("Phase: Idle"),
phaseMetricLabel: widget.NewLabel("idle"), phaseMetricLabel: widget.NewLabel("Idle"),
messageLabel: widget.NewLabel(""), statusSummaryLabel: widget.NewLabel("0.0% | 0 B / - | 0 B/s"),
messageLabel: widget.NewLabel("Ready"),
errorLabel: widget.NewLabel(""), errorLabel: widget.NewLabel(""),
nameLabel: widget.NewLabel("-"), nameLabel: widget.NewLabel("-"),
sizeLabel: widget.NewLabel("-"), sizeLabel: widget.NewLabel("-"),
pieceLabel: widget.NewLabel("-"), pieceLabel: widget.NewLabel("-"),
pieceCountLabel: widget.NewLabel("-"), pieceCountLabel: widget.NewLabel("-"),
downloadedLabel: widget.NewLabel("0 / 0"), downloadedLabel: widget.NewLabel("0 B / -"),
outputLabel: widget.NewLabel("-"), outputLabel: widget.NewLabel("-"),
speedLabel: widget.NewLabel("0 B/s"), speedLabel: widget.NewLabel("0 B/s"),
peerCountLabel: widget.NewLabel("0"), peerCountLabel: widget.NewLabel("0"),
@ -88,28 +95,41 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI {
ui.pathEntry.SetPlaceHolder("/path/to/file.torrent") ui.pathEntry.SetPlaceHolder("/path/to/file.torrent")
ui.downloadDirEntry.SetPlaceHolder("/path/to/download/folder") ui.downloadDirEntry.SetPlaceHolder("/path/to/download/folder")
ui.messageLabel.Wrapping = fyne.TextWrapWord
ui.errorLabel.Wrapping = fyne.TextWrapWord
ui.outputLabel.Wrapping = fyne.TextWrapWord
ui.nameLabel.Wrapping = fyne.TextWrapWord
if home, err := os.UserHomeDir(); err == nil { if home, err := os.UserHomeDir(); err == nil {
ui.downloadDirEntry.SetText(home) ui.downloadDirEntry.SetText(home)
} }
browseButton := widget.NewButton("Browse Torrent", ui.onBrowse) ui.browseButton = widget.NewButton("Browse Torrent", ui.onBrowse)
browseOutputButton := widget.NewButton("Browse Folder", ui.onBrowseOutputFolder) ui.browseOutputButton = widget.NewButton("Browse Folder", ui.onBrowseOutputFolder)
loadButton := widget.NewButton("Load Torrent", ui.onLoad) ui.startButton = widget.NewButton("Start Download", ui.onLoad)
torrentRow := container.NewBorder(nil, nil, nil, browseButton, ui.pathEntry) ui.stopButton = widget.NewButton("Stop", ui.onStop)
outputRow := container.NewBorder(nil, nil, nil, container.NewHBox(browseOutputButton, loadButton), ui.downloadDirEntry) ui.stopButton.Disable()
topRows := container.NewVBox(torrentRow, outputRow)
metaGrid := container.NewGridWithColumns(4, torrentRow := container.NewBorder(nil, nil, nil, ui.browseButton, ui.pathEntry)
actions := container.NewHBox(ui.browseOutputButton, ui.startButton, ui.stopButton)
outputRow := container.NewBorder(nil, nil, nil, actions, ui.downloadDirEntry)
inputCard := widget.NewCard(
"Input",
"Choose .torrent source and download output folder",
container.NewVBox(torrentRow, outputRow),
)
metaGrid := container.NewGridWithColumns(3,
wrapMetric("Name", ui.nameLabel), wrapMetric("Name", ui.nameLabel),
wrapMetric("Size (bytes)", ui.sizeLabel), wrapMetric("Size", ui.sizeLabel),
wrapMetric("Piece length", ui.pieceLabel), wrapMetric("Piece Length", ui.pieceLabel),
wrapMetric("Completed pieces", ui.pieceCountLabel), wrapMetric("Completed Pieces", ui.pieceCountLabel),
wrapMetric("Downloaded", ui.downloadedLabel), wrapMetric("Transferred", ui.downloadedLabel),
wrapMetric("Speed", ui.speedLabel), wrapMetric("Speed", ui.speedLabel),
wrapMetric("Output", ui.outputLabel), wrapMetric("Output", ui.outputLabel),
wrapMetric("Discovered peers", ui.peerCountLabel), wrapMetric("Discovered Peers", ui.peerCountLabel),
wrapMetric("Phase", ui.phaseMetricLabel), wrapMetric("Phase", ui.phaseMetricLabel),
) )
detailsCard := widget.NewCard("Details", "", metaGrid)
ui.trackersTable = ui.newTrackersTable() ui.trackersTable = ui.newTrackersTable()
ui.peersTable = ui.newPeersTable() ui.peersTable = ui.newPeersTable()
@ -120,33 +140,28 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI {
container.NewTabItem("Peers", container.NewMax(ui.peersTable)), container.NewTabItem("Peers", container.NewMax(ui.peersTable)),
container.NewTabItem("Files", container.NewMax(ui.filesTable)), container.NewTabItem("Files", container.NewMax(ui.filesTable)),
) )
tabs.SetTabLocation(container.TabLocationTop)
statusColumn := container.NewVBox( statusColumn := container.NewVBox(
ui.phaseLabel, ui.phaseLabel,
ui.progress, ui.progress,
ui.statusSummaryLabel,
ui.messageLabel, ui.messageLabel,
ui.errorLabel, ui.errorLabel,
) )
statusCard := widget.NewCard("Transfer Status", "", statusColumn)
content := container.NewBorder( header := container.NewVBox(inputCard, statusCard, detailsCard)
topRows, content := container.NewBorder(header, nil, nil, nil, tabs)
nil,
nil,
nil,
container.NewVBox(
statusColumn,
layout.NewSpacer(),
metaGrid,
layout.NewSpacer(),
tabs,
),
)
w.SetContent(content) w.SetContent(content)
w.SetOnClosed(func() { w.SetOnClosed(func() {
close(ui.stopRefresh) close(ui.stopRefresh)
ui.controller.StopTorrent()
}) })
ui.updateFromStatus(ui.controller.Status())
if len(os.Args) > 1 { if len(os.Args) > 1 {
ui.pathEntry.SetText(os.Args[1]) ui.pathEntry.SetText(os.Args[1])
ui.onLoad() ui.onLoad()
@ -173,47 +188,108 @@ func (ui *desktopUI) onBrowse() {
defer reader.Close() defer reader.Close()
ui.pathEntry.SetText(reader.URI().Path()) ui.pathEntry.SetText(reader.URI().Path())
ui.setError("")
}, ui.window) }, ui.window)
fd.SetFilter(filter) fd.SetFilter(filter)
fd.Show() fd.Show()
} }
func (ui *desktopUI) onLoad() { func (ui *desktopUI) onLoad() {
path := strings.TrimSpace(ui.pathEntry.Text) path, downloadRoot, err := ui.validateInputs()
if path == "" {
ui.setError("select a .torrent file first")
return
}
downloadRoot := strings.TrimSpace(ui.downloadDirEntry.Text)
if downloadRoot == "" {
ui.setError("select a download folder first")
return
}
info, err := os.Stat(downloadRoot)
if err != nil { if err != nil {
ui.setError("download folder is not accessible: " + err.Error()) ui.setError(err.Error())
return
}
if !info.IsDir() {
ui.setError("download path must be a folder")
return return
} }
ui.setMessage("Loading torrent...") ui.mu.Lock()
if ui.loading {
ui.mu.Unlock()
return
}
ui.loading = true
ui.inputError = ""
ui.mu.Unlock()
ui.setMessage("Loading metadata and contacting trackers...")
ui.setError("")
ui.applyControlState("loading_metadata")
go func(path, outputRoot string) {
err := ui.controller.StartTorrent(path, outputRoot)
status := ui.controller.Status()
fyne.Do(func() {
ui.mu.Lock()
ui.loading = false
ui.mu.Unlock()
ui.updateFromStatusOnUI(status)
if err != nil {
ui.setMessageOnUI("Failed to load " + filepath.Base(path))
ui.setErrorOnUI(err.Error())
return
}
ui.setMessageOnUI("Loaded " + filepath.Base(path))
})
}(path, downloadRoot)
}
func (ui *desktopUI) onStop() {
ui.mu.Lock()
ui.loading = false
ui.mu.Unlock()
ui.setMessage("Stopping...")
ui.setError("") ui.setError("")
go func() { go func() {
err := ui.controller.StartTorrent(path, downloadRoot) ui.controller.StopTorrent()
status := ui.controller.Status() status := ui.controller.Status()
ui.updateFromStatus(status)
if err != nil { fyne.Do(func() {
ui.setError(err.Error()) ui.updateFromStatusOnUI(status)
} else { if status.Phase == "stopped" || status.Phase == "idle" {
ui.setMessage("Loaded " + filepath.Base(path)) ui.setMessageOnUI("Stopped")
} }
})
}() }()
} }
func (ui *desktopUI) validateInputs() (string, string, error) {
pathRaw := strings.TrimSpace(ui.pathEntry.Text)
if pathRaw == "" {
return "", "", fmt.Errorf("select a .torrent file first")
}
path := filepath.Clean(pathRaw)
info, err := os.Stat(path)
if err != nil {
return "", "", fmt.Errorf("torrent file is not accessible: %w", err)
}
if info.IsDir() {
return "", "", fmt.Errorf("torrent path must point to a file")
}
if ext := strings.ToLower(filepath.Ext(path)); ext != ".torrent" {
return "", "", fmt.Errorf("selected file must have .torrent extension")
}
downloadRootRaw := strings.TrimSpace(ui.downloadDirEntry.Text)
if downloadRootRaw == "" {
return "", "", fmt.Errorf("select a download folder first")
}
downloadRoot := filepath.Clean(downloadRootRaw)
dirInfo, err := os.Stat(downloadRoot)
if err != nil {
return "", "", fmt.Errorf("download folder is not accessible: %w", err)
}
if !dirInfo.IsDir() {
return "", "", fmt.Errorf("download path must be a folder")
}
return path, downloadRoot, nil
}
func (ui *desktopUI) onBrowseOutputFolder() { func (ui *desktopUI) onBrowseOutputFolder() {
fd := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) { fd := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil { if err != nil {
@ -224,12 +300,13 @@ func (ui *desktopUI) onBrowseOutputFolder() {
return return
} }
ui.downloadDirEntry.SetText(uri.Path()) ui.downloadDirEntry.SetText(uri.Path())
ui.setError("")
}, ui.window) }, ui.window)
fd.Show() fd.Show()
} }
func (ui *desktopUI) refreshLoop() { func (ui *desktopUI) refreshLoop() {
ticker := time.NewTicker(1200 * time.Millisecond) ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
@ -244,71 +321,128 @@ func (ui *desktopUI) refreshLoop() {
func (ui *desktopUI) updateFromStatus(status torrent.Status) { func (ui *desktopUI) updateFromStatus(status torrent.Status) {
fyne.Do(func() { fyne.Do(func() {
ui.progress.SetValue(status.Progress) ui.updateFromStatusOnUI(status)
ui.phaseLabel.SetText("Phase: " + status.Phase) })
ui.phaseMetricLabel.SetText(status.Phase) }
func (ui *desktopUI) updateFromStatusOnUI(status torrent.Status) {
progress := clampProgress(status.Progress)
phaseText := formatPhase(status.Phase)
ui.progress.SetValue(progress)
ui.phaseLabel.SetText("Phase: " + phaseText)
ui.phaseMetricLabel.SetText(phaseText)
ui.statusSummaryLabel.SetText(formatProgressSummary(status))
if status.Name == "" {
ui.nameLabel.SetText("-")
} else {
ui.nameLabel.SetText(status.Name) ui.nameLabel.SetText(status.Name)
ui.sizeLabel.SetText(strconv.Itoa(status.Length)) }
ui.pieceLabel.SetText(strconv.Itoa(status.PieceLength))
if status.Length > 0 {
ui.sizeLabel.SetText(formatBytes(int64(status.Length)))
} else {
ui.sizeLabel.SetText("-")
}
if status.PieceLength > 0 {
ui.pieceLabel.SetText(formatBytes(int64(status.PieceLength)))
} else {
ui.pieceLabel.SetText("-")
}
if status.PieceCount > 0 { if status.PieceCount > 0 {
ui.pieceCountLabel.SetText(fmt.Sprintf("%d / %d", status.CompletedPieces, status.PieceCount)) pieceProgress := float64(status.CompletedPieces) / float64(status.PieceCount)
ui.pieceCountLabel.SetText(fmt.Sprintf("%d / %d (%s)", status.CompletedPieces, status.PieceCount, formatPercent(pieceProgress)))
} else { } else {
ui.pieceCountLabel.SetText("-") ui.pieceCountLabel.SetText("-")
} }
ui.downloadedLabel.SetText(fmt.Sprintf("%d / %d", status.DownloadedBytes, status.TotalBytes))
ui.downloadedLabel.SetText(formatTransferred(status.DownloadedBytes, status.TotalBytes))
ui.speedLabel.SetText(formatBytesRate(status.DownloadSpeed)) ui.speedLabel.SetText(formatBytesRate(status.DownloadSpeed))
if status.OutputPath == "" {
ui.outputLabel.SetText("-")
} else {
ui.outputLabel.SetText(status.OutputPath) ui.outputLabel.SetText(status.OutputPath)
}
ui.peerCountLabel.SetText(strconv.Itoa(status.PeerCount)) ui.peerCountLabel.SetText(strconv.Itoa(status.PeerCount))
ui.mu.Lock() ui.mu.Lock()
ui.trackers = append(ui.trackers[:0], status.Trackers...) ui.trackers = append(ui.trackers[:0], status.Trackers...)
ui.peers = append(ui.peers[:0], status.Peers...) ui.peers = append(ui.peers[:0], status.Peers...)
ui.files = append(ui.files[:0], status.Files...) ui.files = append(ui.files[:0], status.Files...)
manualErr := ui.inputError
ui.mu.Unlock() ui.mu.Unlock()
ui.trackersTable.Refresh() ui.trackersTable.Refresh()
ui.peersTable.Refresh() ui.peersTable.Refresh()
ui.filesTable.Refresh() ui.filesTable.Refresh()
if status.LastError != "" { switch {
ui.errorLabel.SetText(status.LastError) case status.LastError != "":
} else { ui.errorLabel.SetText("Error: " + status.LastError)
case manualErr != "":
ui.errorLabel.SetText("Error: " + manualErr)
default:
ui.errorLabel.SetText("") ui.errorLabel.SetText("")
} }
})
ui.applyControlState(status.Phase)
} }
func formatBytesRate(bytesPerSec float64) string { func (ui *desktopUI) applyControlState(phase string) {
if bytesPerSec < 0 { ui.mu.RLock()
bytesPerSec = 0 loading := ui.loading
ui.mu.RUnlock()
busy := loading || isBusyPhase(phase)
if busy {
ui.startButton.Disable()
ui.stopButton.Enable()
ui.pathEntry.Disable()
ui.downloadDirEntry.Disable()
ui.browseButton.Disable()
ui.browseOutputButton.Disable()
return
} }
units := []string{"B/s", "KB/s", "MB/s", "GB/s", "TB/s"}
value := bytesPerSec ui.startButton.Enable()
unit := 0 ui.stopButton.Disable()
for value >= 1024 && unit < len(units)-1 { ui.pathEntry.Enable()
value /= 1024 ui.downloadDirEntry.Enable()
unit++ ui.browseButton.Enable()
} ui.browseOutputButton.Enable()
if unit == 0 {
return fmt.Sprintf("%.0f %s", value, units[unit])
}
return fmt.Sprintf("%.2f %s", value, units[unit])
} }
func (ui *desktopUI) setMessage(msg string) { func (ui *desktopUI) setMessage(msg string) {
fyne.Do(func() { fyne.Do(func() {
ui.messageLabel.SetText(msg) ui.setMessageOnUI(msg)
}) })
} }
func (ui *desktopUI) setMessageOnUI(msg string) {
ui.messageLabel.SetText(strings.TrimSpace(msg))
}
func (ui *desktopUI) setError(msg string) { func (ui *desktopUI) setError(msg string) {
fyne.Do(func() { fyne.Do(func() {
ui.setErrorOnUI(msg)
})
}
func (ui *desktopUI) setErrorOnUI(msg string) {
msg = strings.TrimSpace(msg)
ui.mu.Lock()
ui.inputError = msg
ui.mu.Unlock()
if msg == "" { if msg == "" {
ui.errorLabel.SetText("") ui.errorLabel.SetText("")
return return
} }
ui.errorLabel.SetText("Error: " + msg) ui.errorLabel.SetText("Error: " + msg)
})
} }
func (ui *desktopUI) newTrackersTable() *widget.Table { func (ui *desktopUI) newTrackersTable() *widget.Table {
@ -344,7 +478,7 @@ func (ui *desktopUI) newTrackersTable() *widget.Table {
case 0: case 0:
label.SetText(tr.URL) label.SetText(tr.URL)
case 1: case 1:
label.SetText(tr.State) label.SetText(formatPhase(tr.State))
case 2: case 2:
label.SetText(strconv.Itoa(tr.PeerCount)) label.SetText(strconv.Itoa(tr.PeerCount))
case 3: case 3:
@ -352,9 +486,9 @@ func (ui *desktopUI) newTrackersTable() *widget.Table {
} }
}, },
) )
table.SetColumnWidth(0, 420) table.SetColumnWidth(0, 440)
table.SetColumnWidth(1, 90) table.SetColumnWidth(1, 110)
table.SetColumnWidth(2, 70) table.SetColumnWidth(2, 80)
table.SetColumnWidth(3, 420) table.SetColumnWidth(3, 420)
return table return table
} }
@ -382,7 +516,7 @@ func (ui *desktopUI) newPeersTable() *widget.Table {
case 3: case 3:
label.SetText("Pieces") label.SetText("Pieces")
case 4: case 4:
label.SetText("Source tracker") label.SetText("Source Tracker")
case 5: case 5:
label.SetText("Error") label.SetText("Error")
} }
@ -398,7 +532,7 @@ func (ui *desktopUI) newPeersTable() *widget.Table {
case 1: case 1:
label.SetText(strconv.Itoa(int(peer.Port))) label.SetText(strconv.Itoa(int(peer.Port)))
case 2: case 2:
label.SetText(peer.State) label.SetText(formatPhase(peer.State))
case 3: case 3:
label.SetText(strconv.Itoa(peer.DownloadedPieces)) label.SetText(strconv.Itoa(peer.DownloadedPieces))
case 4: case 4:
@ -408,12 +542,12 @@ func (ui *desktopUI) newPeersTable() *widget.Table {
} }
}, },
) )
table.SetColumnWidth(0, 170) table.SetColumnWidth(0, 190)
table.SetColumnWidth(1, 80) table.SetColumnWidth(1, 80)
table.SetColumnWidth(2, 100) table.SetColumnWidth(2, 120)
table.SetColumnWidth(3, 70) table.SetColumnWidth(3, 90)
table.SetColumnWidth(4, 310) table.SetColumnWidth(4, 320)
table.SetColumnWidth(5, 440) table.SetColumnWidth(5, 400)
return table return table
} }
@ -446,11 +580,100 @@ func (ui *desktopUI) newFilesTable() *widget.Table {
case 0: case 0:
label.SetText(file.Path) label.SetText(file.Path)
case 1: case 1:
label.SetText(fmt.Sprintf("%d", file.Length)) label.SetText(formatBytes(int64(file.Length)))
} }
}, },
) )
table.SetColumnWidth(0, 780) table.SetColumnWidth(0, 820)
table.SetColumnWidth(1, 120) table.SetColumnWidth(1, 140)
return table return table
} }
func formatBytesRate(bytesPerSec float64) string {
return formatByteValue(bytesPerSec, true)
}
func formatBytes(bytes int64) string {
return formatByteValue(float64(bytes), false)
}
func formatByteValue(value float64, perSecond bool) string {
if value < 0 {
value = 0
}
units := []string{"B", "KB", "MB", "GB", "TB"}
unit := 0
for value >= 1024 && unit < len(units)-1 {
value /= 1024
unit++
}
suffix := ""
if perSecond {
suffix = "/s"
}
if unit == 0 {
return fmt.Sprintf("%.0f %s%s", value, units[unit], suffix)
}
return fmt.Sprintf("%.2f %s%s", value, units[unit], suffix)
}
func formatTransferred(done, total int64) string {
if total <= 0 {
return fmt.Sprintf("%s / -", formatBytes(done))
}
pct := float64(done) / float64(total)
return fmt.Sprintf("%s / %s (%s)", formatBytes(done), formatBytes(total), formatPercent(pct))
}
func formatPercent(progress float64) string {
progress = clampProgress(progress)
return fmt.Sprintf("%.1f%%", progress*100)
}
func formatProgressSummary(status torrent.Status) string {
return fmt.Sprintf(
"%s | %s | %s",
formatPercent(status.Progress),
formatTransferred(status.DownloadedBytes, status.TotalBytes),
formatBytesRate(status.DownloadSpeed),
)
}
func formatPhase(phase string) string {
phase = strings.TrimSpace(phase)
if phase == "" {
return "Idle"
}
parts := strings.Split(phase, "_")
for i, part := range parts {
if part == "" {
continue
}
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
return strings.Join(parts, " ")
}
func clampProgress(progress float64) float64 {
if progress < 0 {
return 0
}
if progress > 1 {
return 1
}
return progress
}
func isBusyPhase(phase string) bool {
switch phase {
case "loading_metadata", "querying_trackers", "ready", "preparing_download", "downloading", "writing_files":
return true
default:
return false
}
}