mirror of
https://github.com/tiennm99/telegram-exporter.git
synced 2026-10-12 22:14:54 +00:00
Phases 4 through 6: the two legs and the command that joins them. Downloads go to <name>.part and are renamed only once complete, so a file without the suffix is always whole. That is what lets the upload leg treat "exists" as "finished" — the property run.sh could only approximate with a filename convention plus an age guard, because it could not see inside tdl. Every finished file is checked against the size Telegram reported, and that check rather than the error is the authoritative signal. core's downloader logs a failed transfer and returns nil, and its completion callback is deferred on that named return, so a failure arrives indistinguishable from a success. Trusting it would promote a truncated file and archive it as complete. The disk cap is a semaphore over bytes. A download reserves its own size before starting and releases it only after the upload confirms, so a slow remote stalls downloads by itself. Blocking the iterator is safe because the downloader calls it from its dispatch loop while workers run in a group, so a blocked iterator never stops the uploads that free the space. Gone with it: the du polling, the SIGSTOP and SIGCONT suspension, the min-age guard, the temp-file filter and the sweep-failure counter. A cap smaller than the largest file is refused up front. The semaphore could never admit it, and a run blocked on a file it can never start looks exactly like a stalled remote. Uploads re-state each object to prove its size before the local copy is gone, closing a gap where a truncated upload was only noticed by a later verify. The destination is created before the chat is read. It is also the credentials check, and doing it first means a bad destination fails in seconds rather than after a full history walk. One invocation converges: each item is checked against the index immediately before download, so there are no passes and re-running is the resume path. Options that no longer exist say what replaced them instead of failing as unknown flags. Verified end to end against the live chat and a scratch remote path: two files downloaded, uploaded, confirmed present at the right size, staging left empty.
109 lines
3.0 KiB
Go
109 lines
3.0 KiB
Go
// Package report renders a run's progress for whoever is watching.
|
|
package report
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/tiennm99dev/telegram-exporter/internal/pipeline"
|
|
)
|
|
|
|
// statsInterval is how often a redirected run prints a line.
|
|
//
|
|
// The shell pipeline learned this the hard way: tdl's progress bar is ANSI
|
|
// redraws, which are right on a terminal and turn a captured log into
|
|
// megabytes of control characters. So a TTY gets a redrawn line and everything
|
|
// else gets a periodic summary.
|
|
const statsInterval = 30 * time.Second
|
|
|
|
// Reporter renders progress, adapting to whether it is writing to a terminal.
|
|
type Reporter struct {
|
|
w io.Writer
|
|
tty bool
|
|
total int
|
|
totalBytes int64
|
|
|
|
mu sync.Mutex
|
|
started time.Time
|
|
lastLine time.Time
|
|
}
|
|
|
|
// New builds a reporter for w, which is treated as a terminal when it is one.
|
|
//
|
|
// The totals are passed in rather than taken from Stats because Stats.BytesTotal
|
|
// only counts items the downloader has started, so a progress line built from it
|
|
// shows a denominator that grows as the run proceeds — "0 B of 52 KiB" on a run
|
|
// that will move gigabytes. The caller knows the real figures before starting.
|
|
func New(w io.Writer, total int, totalBytes int64) *Reporter {
|
|
return &Reporter{w: w, tty: isTerminal(w), total: total, totalBytes: totalBytes, started: time.Now()}
|
|
}
|
|
|
|
// Update renders a snapshot. Safe to call from several goroutines, and cheap
|
|
// enough to call on every progress callback.
|
|
func (r *Reporter) Update(s pipeline.Stats) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
if r.tty {
|
|
// \r rather than \n: one line, redrawn.
|
|
fmt.Fprintf(r.w, "\r\033[K%s", r.line(s, now))
|
|
return
|
|
}
|
|
if now.Sub(r.lastLine) < statsInterval {
|
|
return
|
|
}
|
|
r.lastLine = now
|
|
fmt.Fprintf(r.w, "%s\n", r.line(s, now))
|
|
}
|
|
|
|
// Finish writes the closing summary, ending the redrawn line if there was one.
|
|
func (r *Reporter) Finish(s pipeline.Stats) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.tty {
|
|
fmt.Fprint(r.w, "\r\033[K")
|
|
}
|
|
elapsed := time.Since(r.started).Round(time.Second)
|
|
fmt.Fprintf(r.w, "%d done, %d failed, %s in %s (%s/s)\n",
|
|
s.Done, s.Failed, humanBytes(s.BytesDone), elapsed,
|
|
humanBytes(int64(float64(s.BytesDone)/max(elapsed.Seconds(), 1))))
|
|
}
|
|
|
|
func (r *Reporter) line(s pipeline.Stats, now time.Time) string {
|
|
elapsed := now.Sub(r.started)
|
|
rate := float64(s.BytesDone) / max(elapsed.Seconds(), 1)
|
|
return fmt.Sprintf("%d/%d done, %d failed, %s of %s, %s/s",
|
|
s.Done, r.total, s.Failed,
|
|
humanBytes(s.BytesDone), humanBytes(r.totalBytes), humanBytes(int64(rate)))
|
|
}
|
|
|
|
func humanBytes(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for v := n / unit; v >= unit; v /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGT"[exp])
|
|
}
|
|
|
|
// isTerminal reports whether w is a character device.
|
|
func isTerminal(w io.Writer) bool {
|
|
f, ok := w.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.Mode()&os.ModeCharDevice != 0
|
|
}
|