mirror of
https://github.com/tiennm99/telegram-exporter.git
synced 2026-10-14 16:18:02 +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.
145 lines
4.3 KiB
Go
145 lines
4.3 KiB
Go
// Package pipeline drives the download half of an archive run.
|
|
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"iter"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/gotd/td/tg"
|
|
|
|
"github.com/iyear/tdl/core/downloader"
|
|
|
|
"github.com/tiennm99dev/telegram-exporter/internal/naming"
|
|
"github.com/tiennm99dev/telegram-exporter/internal/tgsource"
|
|
)
|
|
|
|
// partSuffix marks a download that is still in flight.
|
|
//
|
|
// Deliberately not tdl's ".tmp": nothing here excludes by extension any more,
|
|
// because upload is triggered by a download returning rather than by a filter
|
|
// over a directory. A distinct suffix just keeps a staging directory shared with
|
|
// a legacy tdl run unambiguous during the cutover.
|
|
const partSuffix = ".part"
|
|
|
|
// elem adapts one media item to the downloader's element interface.
|
|
type elem struct {
|
|
item tgsource.Item
|
|
file *os.File
|
|
takeout bool
|
|
}
|
|
|
|
func (e *elem) File() downloader.File { return mediaFile{e.item} }
|
|
func (e *elem) To() io.WriterAt { return e.file }
|
|
func (e *elem) AsTakeout() bool { return e.takeout }
|
|
|
|
// mediaFile exposes what the downloader needs to locate the bytes. All three
|
|
// values come straight from tmedia, so nothing is looked up a second time.
|
|
type mediaFile struct{ item tgsource.Item }
|
|
|
|
func (f mediaFile) Location() tg.InputFileLocationClass { return f.item.Media.InputFileLoc }
|
|
func (f mediaFile) Size() int64 { return f.item.Media.Size }
|
|
func (f mediaFile) DC() int { return f.item.Media.DC }
|
|
|
|
// partPath and finalPath are where an item is written and where it lands.
|
|
func partPath(staging string, it tgsource.Item) string {
|
|
return filepath.Join(staging, it.Name+partSuffix)
|
|
}
|
|
func finalPath(staging string, it tgsource.Item) string {
|
|
return filepath.Join(staging, it.Name)
|
|
}
|
|
|
|
// elemIter turns the item sequence into the pull iterator the downloader wants,
|
|
// opening each destination file as it goes.
|
|
//
|
|
// The downloader consumes Next/Value/Err; Walk produces an iter.Seq2. iter.Pull2
|
|
// bridges them without this code owning a goroutine or a channel, which is why
|
|
// Walk returns a sequence in the first place.
|
|
type elemIter struct {
|
|
next func() (tgsource.Item, error, bool)
|
|
stop func()
|
|
staging string
|
|
takeout bool
|
|
|
|
// acquire reserves staging space for the next item. Blocking here is what
|
|
// makes backpressure work: core's Download calls Next from its dispatch
|
|
// loop, so a blocked Next stops new downloads starting without stopping the
|
|
// uploads that free the space.
|
|
acquire func(context.Context, int64) error
|
|
|
|
current *elem
|
|
err error
|
|
|
|
// opened records every file handle so a run can close them all. The
|
|
// downloader never closes what To() hands it, and a leak here is thousands
|
|
// of descriptors on a full archive run.
|
|
opened []*os.File
|
|
}
|
|
|
|
func newElemIter(seq iter.Seq2[tgsource.Item, error], staging string, takeout bool) *elemIter {
|
|
next, stop := iter.Pull2(seq)
|
|
return &elemIter{next: next, stop: stop, staging: staging, takeout: takeout}
|
|
}
|
|
|
|
func (i *elemIter) Next(ctx context.Context) bool {
|
|
if i.err != nil {
|
|
return false
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
i.err = err
|
|
return false
|
|
}
|
|
|
|
item, err, ok := i.next()
|
|
if !ok {
|
|
return false
|
|
}
|
|
if err != nil {
|
|
i.err = err
|
|
return false
|
|
}
|
|
|
|
// A name that cannot be written is refused here rather than left to
|
|
// os.Create: the error names the message, and the run continues instead of
|
|
// failing on a path that could never have worked.
|
|
if err := naming.Safe(item.Name); err != nil {
|
|
i.err = fmt.Errorf("message %d: %w", item.MessageID, err)
|
|
return false
|
|
}
|
|
|
|
if i.acquire != nil {
|
|
if err := i.acquire(ctx, item.Size()); err != nil {
|
|
i.err = err
|
|
return false
|
|
}
|
|
}
|
|
|
|
f, err := os.OpenFile(partPath(i.staging, item), os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
i.err = fmt.Errorf("open destination for message %d: %w", item.MessageID, err)
|
|
return false
|
|
}
|
|
i.opened = append(i.opened, f)
|
|
|
|
i.current = &elem{item: item, file: f, takeout: i.takeout}
|
|
return true
|
|
}
|
|
|
|
func (i *elemIter) Value() downloader.Elem { return i.current }
|
|
func (i *elemIter) Err() error { return i.err }
|
|
|
|
// Close releases the pull iterator and every file the walk opened.
|
|
func (i *elemIter) Close() error {
|
|
i.stop()
|
|
var firstErr error
|
|
for _, f := range i.opened {
|
|
if err := f.Close(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|