mirror of
https://github.com/tiennm99/telegram-exporter.git
synced 2026-10-11 03:13:49 +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.
163 lines
4.4 KiB
Go
163 lines
4.4 KiB
Go
// Command tgexport archives Telegram chat media to an rclone remote.
|
|
//
|
|
// It replaces a three-script shell pipeline that ran `tdl dl` and `rclone move`
|
|
// as separate processes. Both are embedded here as libraries, so the program can
|
|
// see a download finish rather than inferring it from a filename suffix and a
|
|
// file's age.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
|
|
// Registers the rclone storage backends this binary can talk to. Backend
|
|
// selection is a property of the binary, so the import lives here rather
|
|
// than in a library package where it would leak into every importer and
|
|
// make the `slim` build tag meaningless.
|
|
_ "github.com/tiennm99dev/telegram-exporter/internal/backends"
|
|
)
|
|
|
|
// Exit codes, matching the shell pipeline so existing habits and any wrapper
|
|
// scripts keep working: run.sh used 0 ok, 2 usage, 3 rclone failure, 130 SIGINT,
|
|
// 143 SIGTERM, and export-until-complete.sh used 1 for "ran, still incomplete".
|
|
const (
|
|
exitOK = 0
|
|
exitIncomplete = 1
|
|
exitUsage = 2
|
|
exitRemoteError = 3
|
|
exitSIGINT = 130
|
|
exitSIGTERM = 143
|
|
)
|
|
|
|
// errUsage marks an error as the operator's mistake rather than a failure,
|
|
// selecting exit code 2.
|
|
var errUsage = errors.New("usage")
|
|
|
|
// errIncomplete marks a run that finished cleanly but left work outstanding.
|
|
var errIncomplete = errors.New("incomplete")
|
|
|
|
func main() {
|
|
os.Exit(run())
|
|
}
|
|
|
|
func run() int {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
return exitUsage
|
|
}
|
|
if a := os.Args[1]; a == "-h" || a == "--help" || a == "help" {
|
|
usage()
|
|
return exitOK
|
|
}
|
|
|
|
ctx, signalled := notifyContext()
|
|
|
|
var err error
|
|
switch os.Args[1] {
|
|
case "doctor":
|
|
err = doctorCmd(ctx, os.Args[2:])
|
|
case "list":
|
|
err = listCmd(ctx, os.Args[2:])
|
|
case "verify":
|
|
err = verifyCmd(ctx, os.Args[2:])
|
|
case "sync":
|
|
err = syncCmd(ctx, os.Args[2:])
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1])
|
|
usage()
|
|
return exitUsage
|
|
}
|
|
|
|
sig := signalled()
|
|
if sig != nil {
|
|
fmt.Fprintf(os.Stderr, "interrupted (%v)\n", sig)
|
|
} else if err != nil && !errors.Is(err, flag.ErrHelp) {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
}
|
|
return exitCode(err, sig)
|
|
}
|
|
|
|
// exitCode maps a command's outcome onto the shell pipeline's contract.
|
|
//
|
|
// A signal outranks whatever error the interruption produced on the way out:
|
|
// the operator stopped this, and the code has to say so rather than letting a
|
|
// driver read an abandoned run as finished.
|
|
func exitCode(err error, sig os.Signal) int {
|
|
if sig != nil {
|
|
if sig == syscall.SIGTERM {
|
|
return exitSIGTERM
|
|
}
|
|
return exitSIGINT
|
|
}
|
|
|
|
switch {
|
|
case err == nil, errors.Is(err, flag.ErrHelp):
|
|
return exitOK
|
|
case errors.Is(err, context.Canceled):
|
|
return exitSIGINT
|
|
case errors.Is(err, errUsage):
|
|
return exitUsage
|
|
case errors.Is(err, errIncomplete):
|
|
return exitIncomplete
|
|
default:
|
|
return exitRemoteError
|
|
}
|
|
}
|
|
|
|
// notifyContext cancels ctx on SIGINT or SIGTERM and reports which arrived.
|
|
//
|
|
// Unlike signal.NotifyContext it stops trapping after the first signal, so a
|
|
// second Ctrl-C kills the process outright. That matters when shutdown itself
|
|
// hangs — an rclone upload waiting on a slow pikpak commit, say — and the
|
|
// operator needs a way out that does not involve another terminal.
|
|
func notifyContext() (context.Context, func() os.Signal) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
ch := make(chan os.Signal, 1)
|
|
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
|
|
|
var got os.Signal
|
|
done := make(chan struct{})
|
|
finished := make(chan struct{})
|
|
|
|
go func() {
|
|
defer close(finished)
|
|
select {
|
|
case sig := <-ch:
|
|
got = sig
|
|
signal.Stop(ch) // next one gets the default disposition: die
|
|
cancel()
|
|
case <-done:
|
|
signal.Stop(ch)
|
|
cancel()
|
|
}
|
|
}()
|
|
|
|
// Waiting on finished before reading got is what makes the read safe: the
|
|
// goroutine writes it and then closes the channel, so the happens-before
|
|
// edge is the close, not the return.
|
|
return ctx, func() os.Signal {
|
|
close(done)
|
|
<-finished
|
|
return got
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprint(os.Stderr, `Usage: tgexport <command> [options]
|
|
|
|
Commands:
|
|
sync Archive a chat to a remote, fetching only what is missing
|
|
list Print every media message in a chat as id<TAB>size<TAB>name
|
|
verify Report whether a chat is fully archived on a remote
|
|
doctor Check the Telegram session, the destination remote, and free space
|
|
|
|
Run 'tgexport <command> -h' for command options.
|
|
`)
|
|
}
|