diff --git a/.claude/agent-memory/code-reviewer/MEMORY.md b/.claude/agent-memory/code-reviewer/MEMORY.md new file mode 100644 index 0000000..7098a1a --- /dev/null +++ b/.claude/agent-memory/code-reviewer/MEMORY.md @@ -0,0 +1,4 @@ +# Agent Memory Index + +- [rclone embedding traps](rclone-embedding-traps.md) — `NewFilter(nil)` is not neutral; `RCLONE_*` reaches globals at package init, so `t.Setenv` tests prove nothing. +- [Exit-code contract](repo-exit-code-contract.md) — exit 1 is the driver's retry signal; a non-retryable state that exits 1 loops forever. diff --git a/.claude/agent-memory/code-reviewer/rclone-embedding-traps.md b/.claude/agent-memory/code-reviewer/rclone-embedding-traps.md new file mode 100644 index 0000000..5ebc642 --- /dev/null +++ b/.claude/agent-memory/code-reviewer/rclone-embedding-traps.md @@ -0,0 +1,36 @@ +--- +name: rclone-embedding-traps +description: Non-obvious rclone-as-a-library behaviours verified against rclone v1.75.1 source that keep biting this repo's remote/index code +metadata: + type: project +--- + +rclone v1.75.1 embedded as a library reads `RCLONE_*` environment variables into +package-level global option structs at **package init**, via +`fs.RegisterGlobalOptions` -> `OptionsInfo.load()` -> `fs.ConfigMap` -> +`optionEnvVars.Get` (`fs/registry.go:498-527`, `fs/configmap.go:115-152`). No +cobra/pflag wiring is needed for this to happen. + +Two consequences that are easy to get backwards, both verified empirically: + +- `filter.NewFilter(nil)` copies `filter.Opt` (`fs/filter/filter.go:196-202`), + which already holds the env-derived values. It is **not** a neutral filter. + `RCLONE_EXCLUDE` / `RCLONE_FILTER` / `RCLONE_MIN_SIZE` / `RCLONE_MAX_AGE` all + survive it. A genuinely neutral filter needs explicit + `&filter.Options{MinAge: fs.DurationOff, MaxAge: fs.DurationOff, MinSize: -1, MaxSize: -1}` + — the zero-value `filter.Options{}` errors with "min-age can't be larger than + max-age". +- `fs.AddConfig(ctx)` + assigning `ci.MaxDepth` *does* override + `RCLONE_MAX_DEPTH`, and env values for `Transfers` / `LowLevelRetries` really + are present in `fs.GetConfig(ctx)` without any flag parsing. + +**Why:** an index built through a narrowed listing does not fail — it reports +archived files as absent and re-downloads them, which is the exact multi-GiB +failure this project exists to remove. + +**How to apply:** when reviewing anything that lists a remote, check the filter +is neutralised explicitly rather than with `NewFilter(nil)`, and prove it with a +subprocess run under the env var rather than a `t.Setenv` test — `t.Setenv` runs +long after rclone's package init, so such a test passes regardless. + +Related: [[repo-exit-code-contract]] diff --git a/.claude/agent-memory/code-reviewer/repo-exit-code-contract.md b/.claude/agent-memory/code-reviewer/repo-exit-code-contract.md new file mode 100644 index 0000000..724fe7e --- /dev/null +++ b/.claude/agent-memory/code-reviewer/repo-exit-code-contract.md @@ -0,0 +1,25 @@ +--- +name: repo-exit-code-contract +description: tgexport's exit codes are a machine contract an external until-complete driver loops on; miscategorising one causes an infinite retry loop +metadata: + type: project +--- + +`tgexport` exit codes: 0 complete, 1 ran but files remain, 2 usage, 3 +remote/Telegram failure, 130/143 interrupted. Code 1 is the *retry* signal — an +external driver re-invokes on 1 and stops on 0 or 3. + +**Why:** the retired `export-until-complete.sh` had two stop conditions the Go +rewrite deliberately dropped (`plans/.../phase-06-cli-resume-and-observability.md` +lines 46-57): a pass counter, and a no-progress detector. The Go tool converges +in one invocation instead, so the exit code is now the *only* thing that can +stop a driver. + +**How to apply:** when reviewing any new error path in `cmd/tgexport`, ask which +exit code it produces and whether that state is actually retryable. Two states +have no representation in the contract and therefore fall through to 1 forever: +work that can never succeed (an unwritable filename, permanently unavailable +media) and a remote that is full or broken mid-run. Treat "exit 1 with no +possible progress" as a blocking defect, not a cosmetic one. + +Related: [[rclone-embedding-traps]] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6b866fa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: gofmt + run: | + unformatted=$(gofmt -l ./cmd ./internal) + if [ -n "$unformatted" ]; then + echo "gofmt needed on:"; echo "$unformatted"; exit 1 + fi + + - name: vet + run: go vet ./... + + - name: test + run: go test ./... + + # Both tag combinations are compiled because they select different rclone + # backend sets. A dependency bump that breaks one can easily leave the + # other green, and rclone's library API is outside its compatibility + # promise, so this job is the tripwire for an unintended upgrade. + - name: compile (all backends) + run: go build ./... + + - name: compile (slim) + run: go build -tags slim ./... diff --git a/.gitignore b/.gitignore index f86285a..c890cdd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,12 @@ -# tdl chat exports and the narrowed subsets built from them. These hold real -# message ids, file names and text from an account, and are specific to one run. -# The repo ships no JSON of its own, so the whole extension is excluded. +# Leftovers from the retired shell pipeline: chat exports and the narrowed +# subsets built from them. These hold real message ids, file names and text from +# an account. The Go binary reads the chat live and writes none of them, but the +# files may still be sitting in a working copy. The repo ships no JSON of its +# own, so the whole extension is excluded. *.json - -# Ids still to fetch, written by verify-export.sh. missing-ids.txt -# Staging holds media in flight between tdl and the remote. +# Staging holds media in flight between the download and upload legs. staging/ # Run logs carry chat names and progress output. @@ -14,3 +14,7 @@ staging/ # Session plans and reports. plans/ + +# Go build output +/tgexport +/tgexport-slim diff --git a/README.md b/README.md index b22174f..77a4df8 100644 --- a/README.md +++ b/README.md @@ -1,305 +1,225 @@ # telegram-exporter -Export a Telegram chat's media to **any rclone remote** — S3, Google Drive, -Dropbox, Backblaze B2, SFTP, WebDAV, or anything else rclone supports — using -far less local disk than the chat's total size. +Archive a Telegram chat's media to **any rclone remote** — S3, Google Drive, +Dropbox, Backblaze B2, SFTP, WebDAV, pikpak, or anything else rclone supports — +using far less local disk than the chat's total size. -`run.sh` runs [tdl](https://github.com/iyear/tdl) and -[rclone](https://rclone.org/) as a rolling pipeline: tdl downloads into a small -staging directory while rclone concurrently moves finished files to the remote -and deletes the local copies. Local disk only ever holds the files in flight -plus one sync interval of throughput, so a multi-terabyte chat exports fine on a -small disk. Telegram caps a single file at 2 GB (4 GB from premium uploaders), -so a few dozen GB of staging covers the worst case regardless of chat size. +`tgexport` embeds [tdl](https://github.com/iyear/tdl) and +[rclone](https://rclone.org/) as libraries and runs both halves in one process. +Files are downloaded into a small staging directory and uploaded the moment each +one finishes, so local disk only ever holds what is in flight. A multi-terabyte +chat archives fine on a small disk. Telegram caps a single file at 2 GB (4 GB +from premium uploaders), so a few dozen GB of staging covers the worst case +regardless of chat size. -This is needed because **tdl can only write to a local directory** — it has no -rclone integration and no remote destination of any kind (`tdl dl -d` takes a -filesystem path; `tdl --storage` is its session database, not an output target). -tdl downloads over MTProto with a user account, so Bot API limits do not apply: -full history is readable and there is no 20 MB download cap. +This exists because **tdl can only write to a local directory** — it has no +remote destination of any kind (`tdl dl -d` takes a filesystem path; `tdl +--storage` is its session database, not an output target). tdl downloads over +MTProto with a user account, so Bot API limits do not apply: full history is +readable and there is no 20 MB download cap. ## Requirements -- **tdl** — -- **rclone** — -- **bash**. On Windows, run under WSL or Git Bash. +- **Go 1.25+** to build, or a prebuilt binary. +- **tdl** — only for `tdl login`. +- **rclone** — only to configure a remote. + +Neither tool is invoked at run time; `tgexport` reads the session and config +they write. ## Setup Both steps are one-time. ```bash -# 1) log in to Telegram with your user account (phone + code + 2FA) -tdl login - -# 2) configure the destination. The interactive wizard covers every backend: -rclone config - -# ...or create one non-interactively, e.g. -rclone config create gdrive drive -rclone config create b2 b2 account=KEY_ID key=APP_KEY -rclone config create dav webdav url=https://dav.example.com/remote.php/dav/files/you \ - vendor=other user=YOU pass=SECRET - -rclone listremotes # confirm the name you will pass to -r +tdl login # writes the Telegram session tgexport reads +rclone config # define the destination remote +go build -o tgexport ./cmd/tgexport +./tgexport doctor -r myremote:archive ``` -Any rclone remote form works, including on-the-fly connection strings -(`:webdav,url=https://...:/path`). +`doctor` proves both halves work before a long run: it prints the logged-in +account, resolves the destination, and reports free space. + +### Build variants + +| Build | Backends | Size | +|---|---|---| +| `go build ./cmd/tgexport` | every rclone backend | ~92 MB | +| `go build -tags slim ./cmd/tgexport` | pikpak only | ~49 MB | + +A backend that is not compiled in does not exist at run time, so use the default +build unless the destination will never change. ## Usage ```bash -./run.sh -r gdrive:telegram/media -c @mygroup +./tgexport sync -c CHAT -r REMOTE:PATH [options] ``` -That is the whole flow. It exports the chat's message metadata to -`export.json`, then downloads and uploads concurrently until finished. Progress -and warnings go to stderr; press Ctrl-C at any point and it stops cleanly. +A run states what it found, what it is about to do, and then shows each file as +it moves: + +``` +reading mychannel + 12,000 messages read in 4m31s +indexing PikPak root 'mychannel' + 11,406 objects listed in 2m10s + + chat holds 12,000 media, 250.0 GiB + archived 11,400 + never fetched 600 + wrong size 6 + + fetching 606 files, 79.0 GiB (largest 2.0 GiB) + into PikPak root 'mychannel' + staging ./staging, capped at 40.0 GiB + concurrency 2 download(s) x 4 thread(s), 2 upload(s) + + ↓ total 26/606 files [=> ] 617.5 MiB / 79.0 GiB 2.5 MiB/s 8h47m + ↑ total 24/606 files [=> ] 598.0 MiB / 79.0 GiB 2.4 MiB/s 8h58m + ↓ …3214_4242_1000000000000000001.mp4 [=======> ] 41.2 MiB / 96.0 MiB 1.8 MiB/s + ↑ …3214_4243_1000000000000000002.mp4 ⠹ uploading 1.9 GiB +``` + +The two legs are counted separately because they run at different speeds and +fail for different reasons. They normally track a file or two apart; a widening +gap means the remote is falling behind and staging is filling up. + +Redirected output gets the same two figures as plain periodic lines, plus one +line per archived file, with no cursor movement — a captured log stays readable: + +``` + download 1,200/606 files, 45.0 GiB of 79.0 GiB, 76.8 MiB/s, ETA 7m33s + upload 1,190/606 files, 44.2 GiB of 79.0 GiB, 75.4 MiB/s, ETA 7m53s +``` + +`CHAT` accepts a numeric id as printed by `tdl chat ls`, a username with or +without `@`, or a `t.me`/`tg://` link. A Bot API `-100…` id is converted +automatically. A link to a single *message* is refused — it names a message, not +a chat. + +```bash +# archive a chat, capping staging at 40 GiB +./tgexport sync -c @mychannel -r gdrive:telegram/media -m 40G + +# check completeness without downloading anything +./tgexport verify -c @mychannel -r gdrive:telegram/media + +# list what the chat holds +./tgexport list -c @mychannel +``` ### Options -| Flag | Meaning | -|------|---------| -| `-r REMOTE:PATH` | **Required.** rclone destination, e.g. `gdrive:telegram/media`, `s3:bucket/tg`, `dav:tg-export` | -| `-c CHAT` | Chat to export when the JSON does not exist yet — id, username, or link (see below) | -| `-f FILE` | Export JSON to download from (default `export-.json` with `-c`, else `export.json`) | -| `-d DIR` | Staging directory (default `./staging`) | -| `-i SECONDS` | Seconds between rclone sweeps (default `60`) | -| `-a AGE` | rclone `--min-age`, a second guard against moving files still being written (default `45s`) | -| `-m SIZE` | Cap the staging directory at `SIZE` (`K`/`M`/`G`/`T`, binary), e.g. `40G`. Unset means no cap (see below) | -| `-h` | Help | +| Flag | Default | Meaning | +|---|---|---| +| `-c` | — | chat id, username, or link (required) | +| `-r` | — | rclone destination, `REMOTE:PATH` (required) | +| `-d` | `./staging` | staging directory for files in flight | +| `-m` | no cap | cap staging at a size, e.g. `40G` | +| `--threads` | 4 | connections per file | +| `--limit` | 2 | files downloading at once | +| `--uploads` | 2 | files uploading at once | +| `--min-free` | 5 | stop if the remote has fewer than this many GiB free, checked before and during the run | +| `--limit-items` | 0 | stop after N files; for smoke tests | +| `--confirm` | true | re-state each uploaded file to prove its size | +| `--takeout` | true | use a takeout session | +| `-n` | `default` | tdl session namespace | -### Identifying the chat +### Exit codes -`-c` accepts every form tdl understands, plus one it doesn't: +| Code | Meaning | +|---|---| +| 0 | complete | +| 1 | ran, but files remain — run again | +| 2 | usage error | +| 3 | remote or Telegram failure, including a destination that stopped accepting uploads | +| 4 | stalled: files remain, none of which can ever be fetched | +| 130 / 143 | interrupted (SIGINT / SIGTERM) | -| Form | Example | -|------|---------| -| Numeric id, as printed by `tdl chat ls` | `-c 1697797156` | -| Username, with or without `@` | `-c @mygroup` / `-c mygroup` | -| Public link | `-c https://t.me/mygroup` / `-c t.me/mygroup` | -| Deep link | `-c 'tg://resolve?domain=mygroup'` | -| **Bot API id** (converted for you) | `-c -1001697797156` → `1697797156` | +Only 1 is worth retrying. A driver looping until 0 should stop on anything else: +3 and 4 both mean the next pass would do exactly what this one did. -tdl resolves a numeric argument as an MTProto id and anything else through -gotd's resolver. MTProto has no `-100` prefix, so a Bot API id would otherwise -fail to resolve; the script strips it and logs the conversion. +## How it works -A **message** link is rejected — `-c` names a chat, not a message: +Re-running is the resume path. Each item is checked against a listing of the +remote immediately before download, so an interrupted run picks up where it left +off and a completed one downloads nothing. -``` -$ ./run.sh -r gdrive:tg -c https://t.me/mygroup/123 -error: -c takes a chat, not a message link — pass the chat's username or id -``` +**Filenames.** Every file is stored as `{DialogID}_{MessageID}_{FileName}`, where +`FileName` is exactly what Telegram reports. One function derives that string, +and the same string is used both to ask whether the file is already archived and +to write it — so the two can never disagree. -Run `tdl chat ls` to see ids and usernames side by side. +A name that cannot survive that round trip is refused rather than rewritten: too +long for the filesystem once `.part` is appended, not a single path element, or +containing a character rclone's path encoder rewrites (control bytes, `DEL`, and +the encoder's own escape character). Those files are reported under +`unarchivable` and never counted as present. Rewriting them is what the next +paragraph is about. -Each chat gets its own export file by default (`export-mygroup.json`, -`export-1697797156.json`), so exporting a second chat from the same directory -never reuses the first one's JSON. When the file already exists it is reused and -the script says so — delete it to re-export. +That last point is the reason this program exists. Its predecessor derived the +name twice: `tdl chat export` wrote the raw name into a JSON, while `tdl dl` +rendered it through a template applying `filenamify`, which rewrites characters a +filesystem rejects and collapses runs of `!`. A file whose name contained `!!` +was looked up under one name and stored under another, so the verifier never +found it and re-fetched it on every pass — forever, at 966 MB a time. -Anything after `--` is passed straight to `tdl dl`: +Note the consequence: names are **not** run through `filenamify`, so they are not +byte-compatible with what the old shell pipeline wrote. A file it stored under a +rewritten name will not be recognised and gets fetched again. -```bash -./run.sh -r gdrive:telegram/media -- -t 4 -l 1 # calmer parallelism, fewer flood waits -./run.sh -r gdrive:telegram/media -- -i mp4,mkv # only these file extensions -./run.sh -r gdrive:telegram/media -- -e jpg,png # skip these file extensions -``` +**Disk.** `-m` is a byte budget. A download reserves its own size before starting +and releases it only once the upload is confirmed, so when the remote is slow the +downloads pause on their own. The cap must exceed the largest single file, and a +cap that does not is refused at startup rather than discovered as a hang. -tdl defaults to `-t 8 -l 4`, which is aggressive; lower it if you hit flood -waits on a large export. +**Integrity.** A download is written to `.part` and renamed only once its +size matches what Telegram reported, so a file without the suffix is always +whole. Uploads are re-stated afterwards to prove they arrived at the right size, +before the local copy is gone, and an object that turns out short is deleted +rather than left under a name a later run would trust. -### Tuning the upload +`verify` compares stored sizes against what Telegram reports, so a truncated +object is outstanding rather than "present". This is stricter than the shell +verifier, which matched on name and non-zero size — on the archive this was +built for it found six objects that had been counted complete for months, one +of them 221 MiB standing in for a 2 GiB video. Re-running repairs them. -rclone reads every one of its flags from an environment variable, so the upload -side is tunable without touching the script: +## Replacing the shell pipeline -```bash -RCLONE_TRANSFERS=8 RCLONE_BWLIMIT=20M ./run.sh -r s3:bucket/tg -c @mygroup -``` +Earlier versions of this repo were three bash scripts — `run.sh`, +`export-until-complete.sh` and `verify-export.sh` — driving `tdl` and `rclone` as +separate processes. Everything expensive in them existed to work around the fact +that neither process could see the other's state: a staging directory polled with +`du -sk`, an `--min-age` guard, a `*.tmp` exclusion, `SIGSTOP`/`SIGCONT` to +enforce the disk cap, a sweep-failure counter, and an outer loop that re-verified +and re-narrowed a JSON export between passes. -`run.sh` sets two of those itself, and only when the caller has not: +One process needs none of it. Completion is a function returning; the cap is a +semaphore. Some hard-won details were worth keeping, and are: -| Variable | Default here | rclone's own default | Why | -|----------|--------------|----------------------|-----| -| `RCLONE_TRANSFERS` | `2` | `4` | Backends that commit an upload as a server-side async task queue those tasks; less parallelism keeps the queue short | -| `RCLONE_LOW_LEVEL_RETRIES` | `20` | `10` | Each retry re-polls a pending task, so a slow commit is waited out instead of failing the transfer | +- **pikpak commits uploads as a server-side async task**, and rclone abandons a + still-pending one when its low-level retries run out. `transfers=2` and + `low-level-retries=20` are the defaults here for that reason. Environment + overrides still win. +- **A backend with no quota API is treated as unlimited**, so it never blocks a + run. +- **Zero-byte files count as missing** — rclone overwrites a size-mismatched + destination, so re-running repairs them — while files under 1 KiB are reported + but trusted, since some real media genuinely is that small. +- **Indexing ignores `RCLONE_*` filters.** The transfer tunables above are + deliberately env-overridable; the listing is not. A stray `RCLONE_EXCLUDE` or + `RCLONE_MIN_SIZE` left over from another job would otherwise narrow the index + and re-download everything it hid. -Both exist because of one failure mode. On pikpak an upload finishes in two -phases — rclone sends the bytes, then a server-side task must reach -`PHASE_TYPE_COMPLETE`. rclone waits 500 ms and then polls, giving up after -`--low-level-retries` attempts with: +## Notes -``` -ERROR : : Failed to copy: can't verify the task is completed: ... Phase:"PHASE_TYPE_PENDING" -``` - -Nothing is lost when that happens — the message is followed by `Not deleting -source as copy failed`, the file stays in staging and the next sweep retries -it. But it wastes the upload, and it counts against a `-m` cap, since a file -that keeps failing can never be drained. Raise the retries further if you still -see it. - -### Capping the staging directory - -Without `-m`, staging grows whenever tdl downloads faster than rclone uploads, -which on a fast connection and a slow remote can mean tens of GB between -sweeps. `-m` puts a ceiling on it: - -```bash -./run.sh -r s3:bucket/tg -c @mygroup -m 40G -``` - -Staging size is checked every 10 seconds, independently of `-i`. When it -reaches the cap, tdl is suspended with `SIGSTOP` and rclone sweeps until -staging is back under it, then tdl is resumed — it reconnects on its own and -`--continue` picks its `.tmp` files back up. Because the checks are periodic, -the cap is a high-water mark rather than a hard limit: staging can overshoot by -up to ten seconds of download throughput before the gate closes. - -Only finished files can be drained, so the cap has to exceed what the -concurrent downloads hold — at most `-l` times 2 GB (4 GB from premium -uploaders). With the default `-l 2` anything from ~10 GB up is safe; below -that, the drain cannot clear the cap and the run logs a warning on every check -instead of throttling. - -### Exporting a subset - -Generate the JSON yourself when you want a narrower export, then point `-f` at -it: - -```bash -tdl chat export -c @mygroup -T id -i 1000,5000 --all --with-content -o part.json -./run.sh -r gdrive:telegram/media -f part.json -``` - -`tdl chat export` takes `-T time|id|last` with `-i` as the range, and `-f` as an -expression filter over message fields (`-f -` lists the available fields). - -## Sweep output - -The periodic sweeps are silent — they run every `-i` seconds alongside tdl's own -output, and narrating each one would drown it. The sweeps that run **once at the -end** do report progress, since they can move the whole staging directory with -nothing else on screen: - -- the exit sweep on Ctrl-C, `SIGTERM`, or a tdl failure (`sweeping completed - files before exit`); -- the final sweep after tdl finishes successfully. - -On a terminal that is rclone's redrawn `--progress` bar. When output is -redirected to a log it becomes a one-line stats summary every 30s -(`--stats 30s --stats-one-line --stats-log-level NOTICE`) — rclone logs stats at -INFO, so raising just the stats to NOTICE avoids the line-per-file spam that -`-v` would add. - -To show progress on every sweep instead, rclone reads its flags from the -environment: - -```bash -RCLONE_PROGRESS=true ./run.sh -r s3:bucket/tg -c @mygroup -``` - -## Resuming - -Re-run the same command. Both legs resume independently and nothing is -downloaded or uploaded twice. - -Keep the same `export.json` between runs: `--skip-same` compares against the -**staging** directory, which is empty once files have moved to the remote, so -cross-run deduplication rests on tdl's own `--continue` tracking. If you must -start from a fresh export, narrow it to the missing message-id range -(`-T id -i ,`) rather than re-downloading everything. - -## Verifying an export - -`run.sh` finishes when tdl finishes, which is not the same as every file having -arrived: a dropped session, a stalled remote, or an interrupted pass all leave -gaps. `verify-export.sh` settles it by rebuilding the filename tdl produces for -each media message in the export JSON and checking the remote for it. - -```bash -./verify-export.sh -f export-mygroup.json -r remote:telegram/media -``` - -``` -messages in export : 18193 - text-only (skip) : 38 - media expected : 12000 -present and intact : 12000 - absent : 0 - zero-byte : 0 - -COMPLETE: every media message is present and non-empty. -``` - -Messages with no media are skipped; they carry an empty `file` and were never -download targets. A zero-byte file counts as missing, because rclone overwrites a -size-mismatched destination and a retry repairs it. Files under 1 KiB are -reported but not retried, since some real media is genuinely that small. Exit -status is 0 when complete and 1 otherwise, with the outstanding message ids -written to `missing-ids.txt`. - -## Running until complete - -`export-until-complete.sh` drives `run.sh` in a loop: verify what is already -there, narrow the export to the ids still missing, run the pipeline on that -subset, and repeat. - -```bash -./export-until-complete.sh -r remote:telegram/media -c @mygroup -``` - -| Flag | Meaning | -|------|---------| -| `-r REMOTE:PATH` | **Required.** rclone destination | -| `-c CHAT` | Chat to export metadata for on the first pass | -| `-f FILE` | Export JSON (default `export-.json`) | -| `-d DIR` | Staging directory (default `./staging`) | -| `-i SECONDS` | rclone sweep interval (default `120`) | -| `-m SIZE` | Staging cap passed through to `run.sh`, e.g. `40G` | -| `-p N` | Maximum passes (default `30`) | -| `-q GIB` | Stop if remote free space falls below this (default `5`) | - -It stops when the verifier reports complete (exit `0`), when a pass fetches -nothing new (exit `1` — the remaining media is no longer available from -Telegram), when the remote runs low on space (exit `3`), or on Ctrl-C (exit -`130`, after the current pass shuts down cleanly). - -tdl's progress bar is shown when stdout is a terminal and suppressed when output -is redirected, so a log file stays readable without a flag. - -## What it guards against - -- **Partial uploads.** tdl writes `.tmp` and renames on completion, so - every sweep excludes `*.tmp`. Age alone is not a completion signal: a download - stalled by a flood wait stops touching its `.tmp`, which would then be - uploaded half-written and lose its resume point. -- **Directories vanishing under tdl.** `--delete-empty-src-dirs` runs only in - the final sweep, and the staging directory is recreated after every sweep. - Removing a directory under a running tdl makes it fail to create its next file. -- **Orphaned downloads.** tdl is stopped on exit, Ctrl-C, or `SIGTERM`, so no - download keeps running after the script is gone. -- **A failed run looking finished.** The unrestricted final sweep happens only - after tdl exits 0. An interrupted or crashed run gets the age-guarded sweep - and keeps staging for the next attempt. -- **A dead remote filling the disk.** Five consecutive rclone failures abort the - run instead of letting staging grow unbounded. -- **A fast connection filling the disk.** With `-m`, tdl is suspended whenever - staging reaches the cap and resumed once rclone has drained it, so download - throughput cannot outrun the upload leg. -- **Typos and bad credentials.** Before downloading anything, the remote must be - present in `rclone listremotes` (skipped for connection strings) and the - destination must be creatable, which proves both reachability and auth. - -Exit codes: `0` success, `2` usage error, `3` rclone failure, `130`/`143` -interrupted, anything else is tdl's own exit code. - -## Limits - -- Streaming with no staging at all (piping download chunks straight to the - remote) is not possible with tdl and would require custom code. -- The script is bash; the two tools it drives are cross-platform, but Windows - needs WSL or Git Bash. +- `tgexport` and the `tdl` CLI share one session store and cannot run against the + same namespace at once. Use `-n` for a second namespace if you need both. +- A partially downloaded file is not resumable across restarts — tdl's library + exposes no resume offset — so an interrupted run re-fetches whatever was in + flight, bounded by `--limit`. +- Everything is read-only against Telegram. Nothing is uploaded, deleted, or + marked read. diff --git a/cmd/tgexport/doctor.go b/cmd/tgexport/doctor.go new file mode 100644 index 0000000..9a4ebcb --- /dev/null +++ b/cmd/tgexport/doctor.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "runtime/debug" + "strings" + + "github.com/gotd/td/tg" + + "github.com/iyear/tdl/core/dcpool" + + "github.com/tiennm99dev/telegram-exporter/internal/remote" + "github.com/tiennm99dev/telegram-exporter/internal/tdlkv" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// doctorCmd reports whether the two halves this tool depends on are usable: an +// authorised tdl session, and a reachable destination remote. It is the cheapest +// way to separate "misconfigured" from "broken" before starting a long run. +// +// Both checks always run, so one broken half does not hide the state of the +// other, but their errors are returned rather than merely printed — a check +// tool that exits 0 on failure is worse than no check tool. +func doctorCmd(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("doctor", flag.ContinueOnError) + var ( + remoteArg = fs.String("r", "", "rclone destination `REMOTE:PATH` to check, e.g. pikpak:archive") + ns = fs.String("n", "default", "tdl session `NAMESPACE`") + dataDir = fs.String("storage", tdlkv.DefaultDir(), "`DIR` holding the tdl session store") + ) + + commandUsage(fs, "tgexport doctor [-r REMOTE:PATH] [options]", + "Check the Telegram session and, when a remote is given, that it\nresolves and has free space. Run this before a long archive.") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return err // main maps this to a clean exit + } + return fmt.Errorf("%w: %v", errUsage, err) + } + + fmt.Printf("versions\n") + for _, dep := range []string{ + "github.com/iyear/tdl/core", + "github.com/rclone/rclone", + "github.com/gotd/td", + } { + fmt.Printf(" %-28s %s\n", dep, moduleVersion(dep)) + } + + var problems []error + + fmt.Printf("\ntelegram\n") + if err := checkTelegram(ctx, *dataDir, *ns); err != nil { + fmt.Printf(" session FAILED: %v\n", err) + problems = append(problems, fmt.Errorf("telegram: %w", err)) + } + + fmt.Printf("\nremote\n") + if *remoteArg == "" { + fmt.Printf(" skipped (pass -r REMOTE:PATH to check a destination)\n") + } else if err := checkRemote(ctx, *remoteArg); err != nil { + fmt.Printf(" %-9s FAILED: %v\n", *remoteArg, err) + problems = append(problems, fmt.Errorf("remote: %w", err)) + } + + return errors.Join(problems...) +} + +func checkTelegram(ctx context.Context, dataDir, ns string) error { + kv, err := tdlkv.Open(dataDir, ns) + if err != nil { + return err + } + defer func() { _ = kv.Close() }() + + fmt.Printf(" store %s (namespace %q)\n", dataDir, ns) + + sess, err := tgsource.New(ctx, tgsource.Options{KV: kv}) + if err != nil { + return err + } + + return sess.Run(ctx, func(ctx context.Context, _ dcpool.Pool) error { + self, err := sess.Client().Self(ctx) + if err != nil { + return fmt.Errorf("fetch self: %w", err) + } + fmt.Printf(" account %s (id %d)\n", describeUser(self), self.ID) + return nil + }) +} + +func checkRemote(ctx context.Context, dest string) error { + ctx, err := remote.Init(ctx, remote.DefaultTunables()) + if err != nil { + return err + } + + f, err := remote.Resolve(ctx, dest) + if err != nil { + return err + } + fmt.Printf(" resolved %s\n", f.String()) + + if free, ok := remote.FreeBytes(ctx, f); ok { + fmt.Printf(" free %.1f GiB\n", float64(free)/(1<<30)) + } else { + // Not a failure: several backends have no quota API, and the shell + // pipeline deliberately treated that as unlimited rather than blocking. + fmt.Printf(" free not reported by this backend (treated as unlimited)\n") + } + return nil +} + +func describeUser(u *tg.User) string { + parts := make([]string, 0, 3) + if u.FirstName != "" { + parts = append(parts, u.FirstName) + } + if u.LastName != "" { + parts = append(parts, u.LastName) + } + if u.Username != "" { + parts = append(parts, "@"+u.Username) + } + if len(parts) == 0 { + return "(unnamed)" + } + return strings.Join(parts, " ") +} + +// moduleVersion reports the version a dependency was built against, read from +// the binary itself so it cannot drift from what is actually linked in. +func moduleVersion(path string) string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "unknown" + } + for _, d := range info.Deps { + if d.Path == path { + return d.Version + } + } + return "not linked" +} diff --git a/cmd/tgexport/list.go b/cmd/tgexport/list.go new file mode 100644 index 0000000..e8cb7e7 --- /dev/null +++ b/cmd/tgexport/list.go @@ -0,0 +1,93 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "os" + + "github.com/iyear/tdl/core/dcpool" + tdlstorage "github.com/iyear/tdl/core/storage" + + "github.com/tiennm99dev/telegram-exporter/internal/report" + "github.com/tiennm99dev/telegram-exporter/internal/tdlkv" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// listCmd prints every media message in a chat as `idsizename`. +// +// It is the smallest thing that exercises the whole read path — resolve a chat, +// walk its history, derive a name — so a naming or paging problem shows up here +// rather than halfway through an archive run. The output is tab-separated, and +// the name is quoted: it comes from DocumentAttributeFilename, so whoever +// uploaded the file chose it, and a raw tab would shift the columns while a raw +// newline would split the record. Quoting also renders escape sequences inert +// rather than letting them redraw the operator's terminal. +func listCmd(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + var ( + chat = fs.String("c", "", "`CHAT`: id, username, or t.me link (required)") + ns = fs.String("n", "default", "tdl session `NAMESPACE`") + dataDir = fs.String("storage", tdlkv.DefaultDir(), "`DIR` holding the tdl session store") + ) + + commandUsage(fs, "tgexport list -c CHAT [options]", + "Print every media message in a chat as idsizename.") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return err + } + return fmt.Errorf("%w: %v", errUsage, err) + } + if *chat == "" { + return fmt.Errorf("%w: -c CHAT is required", errUsage) + } + + kv, err := tdlkv.Open(*dataDir, *ns) + if err != nil { + return err + } + defer func() { _ = kv.Close() }() + + sess, err := tgsource.New(ctx, tgsource.Options{KV: kv}) + if err != nil { + return err + } + + return sess.Run(ctx, func(ctx context.Context, pool dcpool.Pool) error { + api := pool.Default(ctx) + + peer, err := tgsource.ResolveChat(ctx, tgsource.Manager(api, tdlstorage.NewPeers(kv)), *chat) + if err != nil { + return err + } + + // Buffered: one write syscall per item would dominate the runtime on a + // chat with tens of thousands of messages. Flushed explicitly below so a + // write failure — a closed pipe, a full disk — is reported rather than + // swallowed by a deferred call nobody checks. + out := bufio.NewWriter(os.Stdout) + + scan := report.NewTicker(os.Stderr, "messages read") + count := 0 + var total int64 + for it, err := range tgsource.Walk(ctx, api, peer, scan.Update) { + if err != nil { + return err + } + count++ + total += it.Size() + if _, err := fmt.Fprintf(out, "%d\t%d\t%q\n", it.MessageID, it.Size(), it.Name); err != nil { + return err + } + } + + if err := out.Flush(); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "\n%d media messages, %.1f GiB\n", count, float64(total)/(1<<30)) + return nil + }) +} diff --git a/cmd/tgexport/main.go b/cmd/tgexport/main.go new file mode 100644 index 0000000..2107d18 --- /dev/null +++ b/cmd/tgexport/main.go @@ -0,0 +1,190 @@ +// 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 + exitStalled = 4 + 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") + +// errStalled marks a run with work outstanding that no future run can do — every +// remaining file has a name that cannot be written. It is distinct from +// errIncomplete because a driver retrying on "incomplete" would otherwise walk +// the whole history and re-index the whole remote forever, achieving nothing. +var errStalled = errors.New("stalled") + +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, errStalled): + return exitStalled + 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 [options] + +Commands: + sync Archive a chat to a remote, fetching only what is missing + verify Report whether a chat is fully archived on a remote + list Print every media message in a chat as idsizename + doctor Check the Telegram session, the destination remote, and free space + +Exit codes: + 0 complete 2 usage error 4 stalled: nothing left is fetchable + 1 files remain 3 remote or Telegram failure + 130/143 interrupted + +Only 1 is worth retrying; a driver looping until 0 should stop on anything else. + +Run 'tgexport -h' for command options. +`) +} + +// commandUsage gives a subcommand a header its flag list can hang off. +// +// The flag package's default is "Usage of sync:" and a bare list, which says +// neither what the command does nor which options are required. +func commandUsage(fs *flag.FlagSet, line, summary string) { + fs.Usage = func() { + out := fs.Output() + fmt.Fprintf(out, "Usage: %s\n\n%s\n\nOptions:\n", line, summary) + fs.PrintDefaults() + } +} diff --git a/cmd/tgexport/main_test.go b/cmd/tgexport/main_test.go new file mode 100644 index 0000000..3aa2ad2 --- /dev/null +++ b/cmd/tgexport/main_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "syscall" + "testing" + + "github.com/tiennm99dev/telegram-exporter/internal/pipeline" + "time" +) + +// The exit-code contract is what a driver script reads to decide whether the +// archive is finished. Every mapping is asserted here because the previous +// version of this code documented the contract in a comment and then returned +// nil on interruption, which a driver would have read as "complete". +func TestExitCode(t *testing.T) { + tests := []struct { + name string + err error + sig os.Signal + want int + }{ + {"success", nil, nil, exitOK}, + {"help is not a failure", flag.ErrHelp, nil, exitOK}, + {"wrapped help", fmt.Errorf("parse: %w", flag.ErrHelp), nil, exitOK}, + {"usage mistake", fmt.Errorf("%w: bad flag", errUsage), nil, exitUsage}, + {"run left work outstanding", fmt.Errorf("%w: 12 files", errIncomplete), nil, exitIncomplete}, + // Distinct from incomplete on purpose: a driver retrying on 1 would + // walk the whole chat forever for work that can never be done. + {"nothing left that can be fetched", fmt.Errorf("%w: 2 files", errStalled), nil, exitStalled}, + // And a destination that stopped accepting uploads is a failure, not a + // retry: the next pass would be refused identically. + {"destination refusing uploads", + fmt.Errorf("run: %w", pipeline.ErrDestinationFailing), nil, exitRemoteError}, + {"remote failure", errors.New("pikpak unreachable"), nil, exitRemoteError}, + {"cancelled without a signal", context.Canceled, nil, exitSIGINT}, + {"wrapped cancellation", fmt.Errorf("download: %w", context.Canceled), nil, exitSIGINT}, + {"SIGINT", nil, os.Interrupt, exitSIGINT}, + {"SIGTERM", nil, syscall.SIGTERM, exitSIGTERM}, + + // A signal outranks the error it produced. Without this, an interrupted + // run whose cleanup happened to fail would exit 3 and look like a remote + // problem instead of an operator stop. + {"signal outranks error", errors.New("upload aborted"), os.Interrupt, exitSIGINT}, + {"signal outranks success", nil, syscall.SIGTERM, exitSIGTERM}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := exitCode(tt.err, tt.sig); got != tt.want { + t.Errorf("exitCode(%v, %v) = %d, want %d", tt.err, tt.sig, got, tt.want) + } + }) + } +} + +// Codes must not collide: a driver distinguishes them by value alone. +func TestExitCodesMatchShellPipeline(t *testing.T) { + // run.sh: 0 ok, 2 usage, 3 rclone failure, 130 SIGINT, 143 SIGTERM. + // export-until-complete.sh: 1 ran but still incomplete. + want := map[string]int{ + "ok": 0, "incomplete": 1, "usage": 2, "remote": 3, "sigint": 130, "sigterm": 143, + } + got := map[string]int{ + "ok": exitOK, "incomplete": exitIncomplete, "usage": exitUsage, + "remote": exitRemoteError, "sigint": exitSIGINT, "sigterm": exitSIGTERM, + } + for k, w := range want { + if got[k] != w { + t.Errorf("%s exit code = %d, want %d (shell pipeline contract)", k, got[k], w) + } + } +} + +func TestNotifyContextReportsSignal(t *testing.T) { + ctx, signalled := notifyContext() + + if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { + t.Fatalf("send SIGINT: %v", err) + } + + select { + case <-ctx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("context was not cancelled within 5s of SIGINT") + } + + if sig := signalled(); sig != syscall.SIGINT { + t.Errorf("signalled() = %v, want SIGINT", sig) + } +} + +// An uninterrupted command must not be reported as signalled, or every clean +// run would exit 130. +func TestNotifyContextReportsNoSignal(t *testing.T) { + ctx, signalled := notifyContext() + + if sig := signalled(); sig != nil { + t.Errorf("signalled() = %v on a clean run, want nil", sig) + } + // The accessor also releases the watcher, which cancels the context. + if ctx.Err() == nil { + t.Error("context should be cancelled once the watcher is released") + } +} diff --git a/cmd/tgexport/sync.go b/cmd/tgexport/sync.go new file mode 100644 index 0000000..90bdd94 --- /dev/null +++ b/cmd/tgexport/sync.go @@ -0,0 +1,391 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "iter" + "math" + "os" + + "github.com/iyear/tdl/core/dcpool" + tdlstorage "github.com/iyear/tdl/core/storage" + "github.com/rclone/rclone/fs" + + "github.com/tiennm99dev/telegram-exporter/internal/pipeline" + "github.com/tiennm99dev/telegram-exporter/internal/remote" + "github.com/tiennm99dev/telegram-exporter/internal/report" + "github.com/tiennm99dev/telegram-exporter/internal/tdlkv" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" + "github.com/tiennm99dev/telegram-exporter/internal/verify" +) + +// syncCmd archives a chat to a remote: read the chat, skip what is already +// there, download and upload the rest, then report on the result. +func syncCmd(ctx context.Context, args []string) error { + flags := flag.NewFlagSet("sync", flag.ContinueOnError) + var ( + chat = flags.String("c", "", "`CHAT`: id, username, or t.me link (required)") + remoteArg = flags.String("r", "", "rclone destination `REMOTE:PATH`, e.g. pikpak:archive (required)") + staging = flags.String("d", "./staging", "staging `DIR` for files in flight") + maxStaging = flags.String("m", "", "cap staging at `SIZE`, e.g. 40G (default: no cap)") + threads = flags.Int("threads", 4, "connections per file") + limit = flags.Int("limit", 2, "files downloading at once") + uploads = flags.Int("uploads", 2, "files uploading at once") + minFree = flags.Int64("min-free", 5, "stop when the remote has under this many `GiB` free") + limitItems = flags.Int("limit-items", 0, "stop after this many files (0 means no limit)") + confirm = flags.Bool("confirm", true, "re-state each uploaded file to prove its size") + takeout = flags.Bool("takeout", true, "use a takeout session for higher rate limits") + ns = flags.String("n", "default", "tdl session `NAMESPACE`") + dataDir = flags.String("storage", tdlkv.DefaultDir(), "`DIR` holding the tdl session store") + ) + commandUsage(flags, "tgexport sync -c CHAT -r REMOTE:PATH [options]", + "Archive a chat's media to a remote, fetching only what is missing.\n"+ + "Re-running resumes: anything already on the remote is skipped.") + + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return err + } + return fmt.Errorf("%w: %v", errUsage, err) + } + if *chat == "" || *remoteArg == "" { + return fmt.Errorf("%w: -c CHAT and -r REMOTE:PATH are both required", errUsage) + } + + budget, err := parseSize(*maxStaging) + if err != nil { + return fmt.Errorf("%w: -m %v", errUsage, err) + } + + ctx, err = remote.Init(ctx, remote.DefaultTunables()) + if err != nil { + return err + } + dst, err := remote.Resolve(ctx, *remoteArg) + if err != nil { + return err + } + + // Partial files from an earlier run cannot be continued — core's downloader + // takes no starting offset — so they are cleared before anything else fills + // the disk with fragments no run will finish. + if swept, err := pipeline.SweepPartials(*staging); err != nil { + return fmt.Errorf("clear partial downloads: %w", err) + } else if swept > 0 { + fmt.Fprintf(os.Stderr, "cleared %d partial download(s) from an earlier run\n", swept) + } + + // Before anything expensive: prove the destination is reachable and writable. + if err := remote.EnsureDir(ctx, dst); err != nil { + return err + } + if err := checkFree(ctx, dst, *minFree); err != nil { + return err + } + + kv, err := tdlkv.Open(*dataDir, *ns) + if err != nil { + return err + } + defer func() { _ = kv.Close() }() + + sess, err := tgsource.New(ctx, tgsource.Options{KV: kv}) + if err != nil { + return err + } + + var ( + final verify.Report + runErr error + ) + if err := sess.Run(ctx, func(ctx context.Context, pool dcpool.Pool) error { + api := pool.Default(ctx) + + peer, err := tgsource.ResolveChat(ctx, tgsource.Manager(api, tdlstorage.NewPeers(kv)), *chat) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "reading %s\n", *chat) + scan := report.NewTicker(os.Stderr, "messages read") + var items []tgsource.Item + var scanned int + for it, err := range tgsource.Walk(ctx, api, peer, func(n int) { + scanned = n + scan.Update(n) + }) { + if err != nil { + return err + } + items = append(items, it) + } + scan.Done(scanned) + + idx, err := indexRemote(ctx, dst, peer.ID()) + if err != nil { + return err + } + warnCollisions(idx) + before := verify.Check(items, idx) + report.Survey(os.Stderr, before) + + todo := selectTodo(items, before, *limitItems) + if len(todo) == 0 { + final = before + return nil + } + + if err := validateBudget(budget, todo); err != nil { + return err + } + + var todoBytes, largest int64 + for _, it := range todo { + todoBytes += it.Size() + largest = max(largest, it.Size()) + } + report.Plan(os.Stderr, report.PlanInfo{ + Files: len(todo), + Bytes: todoBytes, + Largest: largest, + Budget: budget, + Staging: *staging, + Threads: *threads, + Downloads: *limit, + Uploads: *uploads, + Destination: dst.String(), + }) + + rep := report.Events(os.Stderr, len(todo), todoBytes) + // rclone logs to stderr on its own schedule, which lands in the middle + // of a bar redraw. Routing it through the renderer keeps both readable. + if live, ok := rep.(*report.Live); ok { + defer report.CaptureRcloneLog(ctx, live.LogWriter())() + } + var res pipeline.Result + res, runErr = pipeline.Run(ctx, sliceSeq(todo), pipeline.Options{ + Pool: pool, + Dst: dst, + Staging: *staging, + Threads: *threads, + Limit: *limit, + Uploads: *uploads, + Budget: budget, + Confirm: *confirm, + Takeout: *takeout, + Events: rep, + // Re-checked during the run, not only before it: an archive of this + // size runs for hours, and the destination can fill in the middle. + FreeBytes: func(ctx context.Context) (int64, bool) { + return remote.FreeBytes(ctx, dst) + }, + MinFree: *minFree * (1 << 30), + }) + rep.Finish(res.Stats) + + for _, f := range res.Failed() { + fmt.Fprintf(os.Stderr, " message %d failed: %v\n", f.Item.MessageID, f.Err) + } + if runErr != nil { + // Reported, not returned yet: a run that archived thousands of files + // and hit one transient upload error has still made progress, and + // suppressing the report would leave the operator — and any driver + // reading the exit code — unable to tell that from a total failure. + // It is returned after the report, below, so the exit code is right. + fmt.Fprintf(os.Stderr, "run ended early: %v\n", runErr) + } + + // The remote is re-indexed rather than assumed: the run's own view of + // what it uploaded is exactly the thing under test. + idx, err = indexRemote(ctx, dst, peer.ID()) + if err != nil { + return err + } + final = verify.Check(items, idx) + return nil + }); err != nil { + return err + } + + fmt.Fprintln(os.Stderr) + final.Write(os.Stdout) + + switch { + case errors.Is(runErr, pipeline.ErrDestinationFailing): + // Exit 3, not 1. The destination refused upload after upload, and it + // will refuse them next pass too — a driver retrying on "incomplete" + // would walk 18k messages and re-download gigabytes into a remote that + // cannot take a byte, indefinitely. + return runErr + case final.Stalled(): + return fmt.Errorf("%w: %d file(s) remain, none of which can be fetched", + errStalled, len(final.Todo())) + case !final.Complete(): + return fmt.Errorf("%w: %d file(s) still to fetch", errIncomplete, len(final.Todo())) + } + return nil +} + +// selectTodo picks the items still needing a fetch, newest first, optionally +// capped for a smoke test. +func selectTodo(items []tgsource.Item, r verify.Report, limit int) []tgsource.Item { + want := make(map[int]struct{}, len(r.Todo())) + for _, id := range r.Todo() { + want[id] = struct{}{} + } + // Unsafe names are in Todo so they stay visible in the report, but fetching + // one is impossible by definition, so it is not queued for download. + for _, u := range r.Unsafe { + delete(want, u.MessageID) + } + + var todo []tgsource.Item + for _, it := range items { + if _, ok := want[it.MessageID]; !ok { + continue + } + todo = append(todo, it) + if limit > 0 && len(todo) == limit { + break + } + } + return todo +} + +func sliceSeq(items []tgsource.Item) iter.Seq2[tgsource.Item, error] { + return func(yield func(tgsource.Item, error) bool) { + for _, it := range items { + if !yield(it, nil) { + return + } + } + } +} + +// validateBudget refuses a cap smaller than the largest file. +// +// The semaphore can never admit a weight above its limit, so such a run would +// block forever on a file it could never start — indistinguishable, from the +// outside, from a stalled remote. run.sh could only warn about this after the +// fact, once draining failed to get back under the cap. +func validateBudget(budget int64, todo []tgsource.Item) error { + if budget <= 0 { + return nil + } + var largest int64 + for _, it := range todo { + if it.Size() > largest { + largest = it.Size() + } + } + if largest > budget { + return fmt.Errorf("%w: -m is %.1f GiB but the largest file to fetch is %.1f GiB; "+ + "the cap must exceed the biggest single file", + errUsage, float64(budget)/(1<<30), float64(largest)/(1<<30)) + } + return nil +} + +// indexRemote lists the destination, reporting progress as it goes. +func indexRemote(ctx context.Context, dst fs.Fs, dialogID int64) (*remote.Index, error) { + fmt.Fprintf(os.Stderr, "indexing %s\n", dst.String()) + tick := report.NewTicker(os.Stderr, "objects listed") + var seen int + idx, err := remote.BuildIndex(ctx, dst, dialogID, func(n int) { + seen = n + tick.Update(n) + }) + if err != nil { + return nil, err + } + tick.Done(seen) + return idx, nil +} + +// warnCollisions reports basenames the index found at more than one path. +// +// verify printed this and sync did not, which was backwards: an ambiguous +// snapshot makes the presence verdict for those names unreliable, and sync is +// the command that acts on the verdict by moving data. +func warnCollisions(idx *remote.Index) { + dup := idx.Collisions() + if len(dup) == 0 { + return + } + fmt.Fprintf(os.Stderr, "warning: %d basename(s) appear at more than one path; "+ + "verdicts for them may flip between runs:\n", len(dup)) + for _, name := range dup[:min(5, len(dup))] { + fmt.Fprintf(os.Stderr, " %q\n", name) + } +} + +// checkFree refuses to start when the remote is nearly full. +// +// A backend that cannot report a quota is treated as unlimited rather than as a +// failure — the shell pipeline made that choice deliberately so a remote without +// an About API never blocked a run, and it is preserved. +func checkFree(ctx context.Context, dst fs.Fs, minGiB int64) error { + free, ok := remote.FreeBytes(ctx, dst) + if !ok { + return nil + } + freeGiB := free / (1 << 30) + fmt.Fprintf(os.Stderr, "%s has %d GiB free\n", dst.String(), freeGiB) + if freeGiB < minGiB { + return fmt.Errorf("%s has only %d GiB free, below the %d GiB floor; "+ + "free space or lower --min-free", dst.String(), freeGiB, minGiB) + } + return nil +} + +// parseSize reads a binary size such as 40G, matching what run.sh -m accepted. +func parseSize(s string) (int64, error) { + if s == "" { + return 0, nil + } + mult := int64(1) + switch unit := s[len(s)-1]; unit { + case 'K', 'k': + mult = 1 << 10 + case 'M', 'm': + mult = 1 << 20 + case 'G', 'g': + mult = 1 << 30 + case 'T', 't': + mult = 1 << 40 + default: + if unit < '0' || unit > '9' { + return 0, fmt.Errorf("unknown size suffix %q, expected K, M, G or T", string(unit)) + } + } + digits := s + if mult > 1 { + digits = s[:len(s)-1] + } + + var n int64 + if digits == "" { + return 0, fmt.Errorf("%q has no number", s) + } + for _, r := range digits { + if r < '0' || r > '9' { + return 0, fmt.Errorf("%q is not a size", s) + } + // Checked rather than allowed to wrap. A wrapped value is negative or + // zero, and both mean "no cap" downstream — so a typo would silently + // remove the staging limit instead of being refused. + if n > (math.MaxInt64-int64(r-'0'))/10 { + return 0, fmt.Errorf("%q is too large", s) + } + n = n*10 + int64(r-'0') + } + if n <= 0 { + return 0, fmt.Errorf("must be greater than zero") + } + if n > math.MaxInt64/mult { + return 0, fmt.Errorf("%q is too large", s) + } + return n * mult, nil +} diff --git a/cmd/tgexport/sync_test.go b/cmd/tgexport/sync_test.go new file mode 100644 index 0000000..d9dd56c --- /dev/null +++ b/cmd/tgexport/sync_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "errors" + "strings" + "testing" + + "github.com/iyear/tdl/core/tmedia" + + "github.com/tiennm99dev/telegram-exporter/internal/naming" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" + "github.com/tiennm99dev/telegram-exporter/internal/verify" +) + +func TestParseSize(t *testing.T) { + ok := map[string]int64{ + "": 0, // unset means no cap + "40G": 40 << 30, + "40g": 40 << 30, + "512M": 512 << 20, + "2T": 2 << 40, + "1024": 1024, // bare number is bytes + "1K": 1 << 10, + } + for in, want := range ok { + got, err := parseSize(in) + if err != nil { + t.Errorf("parseSize(%q) = %v, want %d", in, err, want) + continue + } + if got != want { + t.Errorf("parseSize(%q) = %d, want %d", in, got, want) + } + } + + for _, in := range []string{"G", "0", "0G", "-5G", "40GB", "4.5G", "abc", "40Q"} { + if got, err := parseSize(in); err == nil { + t.Errorf("parseSize(%q) = %d, want an error", in, got) + } + } +} + +func syncItem(id int, size int64) tgsource.Item { + m := &tmedia.Media{Name: "f.mp4", Size: size} + return tgsource.Item{DialogID: 1, MessageID: id, Name: naming.For(1, id, m), Media: m} +} + +func TestSelectTodoPicksOnlyOutstandingItems(t *testing.T) { + items := []tgsource.Item{syncItem(1, 10), syncItem(2, 10), syncItem(3, 10), syncItem(4, 10)} + r := verify.Report{Absent: []int{2, 4}, ZeroByte: []int{3}} + + got := selectTodo(items, r, 0) + if len(got) != 3 { + t.Fatalf("selected %d items, want 3", len(got)) + } + for _, it := range got { + if it.MessageID == 1 { + t.Error("selected an item that is already archived") + } + } +} + +// An unsafe name is kept in the report so it stays visible, but queuing it for +// download would retry something that can never succeed — the non-terminating +// loop this design exists to avoid. +func TestSelectTodoSkipsUnsafeNames(t *testing.T) { + items := []tgsource.Item{syncItem(1, 10), syncItem(2, 10)} + r := verify.Report{ + Absent: []int{1, 2}, + Unsafe: []verify.Unsafe{{MessageID: 2, Name: "../x", Reason: errors.New("unsafe")}}, + } + + got := selectTodo(items, r, 0) + if len(got) != 1 || got[0].MessageID != 1 { + t.Errorf("selected %+v, want only message 1", got) + } +} + +func TestSelectTodoHonoursLimit(t *testing.T) { + items := []tgsource.Item{syncItem(1, 10), syncItem(2, 10), syncItem(3, 10)} + r := verify.Report{Absent: []int{1, 2, 3}} + + if got := selectTodo(items, r, 2); len(got) != 2 { + t.Errorf("selected %d items with a limit of 2, want 2", len(got)) + } + if got := selectTodo(items, r, 0); len(got) != 3 { + t.Errorf("selected %d items with no limit, want 3", len(got)) + } +} + +// A cap below the largest file could never admit it, so the run would block on +// something it can never start — which from outside looks like a stalled remote. +// It has to be refused up front. +func TestValidateBudgetRejectsCapBelowLargestFile(t *testing.T) { + todo := []tgsource.Item{syncItem(1, 1<<20), syncItem(2, 5<<30)} + + err := validateBudget(4<<30, todo) + if err == nil { + t.Fatal("a 4 GiB cap was accepted with a 5 GiB file to fetch") + } + if !errors.Is(err, errUsage) { + t.Errorf("error should be a usage error, got: %v", err) + } + if !strings.Contains(err.Error(), "largest file") { + t.Errorf("error should name the problem, got: %v", err) + } + + if err := validateBudget(6<<30, todo); err != nil { + t.Errorf("a 6 GiB cap should accept a 5 GiB file, got: %v", err) + } + if err := validateBudget(0, todo); err != nil { + t.Errorf("an unset cap should accept anything, got: %v", err) + } +} diff --git a/cmd/tgexport/verify.go b/cmd/tgexport/verify.go new file mode 100644 index 0000000..b6b396d --- /dev/null +++ b/cmd/tgexport/verify.go @@ -0,0 +1,216 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/iyear/tdl/core/dcpool" + tdlstorage "github.com/iyear/tdl/core/storage" + rclonefs "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/operations" + + "github.com/tiennm99dev/telegram-exporter/internal/remote" + "github.com/tiennm99dev/telegram-exporter/internal/report" + "github.com/tiennm99dev/telegram-exporter/internal/tdlkv" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" + "github.com/tiennm99dev/telegram-exporter/internal/verify" +) + +// verifyCmd reports whether a chat is fully archived on a remote. +// +// Exit 0 means complete, 1 means it ran and found work outstanding. Those are +// distinct on purpose: a driver needs to tell "nothing left to do" from "still +// incomplete" without parsing output. +func verifyCmd(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("verify", flag.ContinueOnError) + var ( + chat = fs.String("c", "", "`CHAT`: id, username, or t.me link (required)") + remoteArg = fs.String("r", "", "rclone destination `REMOTE:PATH`, e.g. pikpak:archive (required)") + ns = fs.String("n", "default", "tdl session `NAMESPACE`") + dataDir = fs.String("storage", tdlkv.DefaultDir(), "`DIR` holding the tdl session store") + delStale = fs.Bool("delete-misnamed", false, "delete remote files stored under a superseded name") + assumeYes = fs.Bool("y", false, "do not prompt before deleting") + ) + + commandUsage(fs, "tgexport verify -c CHAT -r REMOTE:PATH [options]", + "Compare a chat against a remote and report what is missing, empty,\nthe wrong size, or stored under a name that cannot be written.") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return err + } + return fmt.Errorf("%w: %v", errUsage, err) + } + if *chat == "" || *remoteArg == "" { + return fmt.Errorf("%w: -c CHAT and -r REMOTE:PATH are both required", errUsage) + } + + ctx, err := remote.Init(ctx, remote.DefaultTunables()) + if err != nil { + return err + } + dst, err := remote.Resolve(ctx, *remoteArg) + if err != nil { + return err + } + + kv, err := tdlkv.Open(*dataDir, *ns) + if err != nil { + return err + } + defer func() { _ = kv.Close() }() + + sess, err := tgsource.New(ctx, tgsource.Options{KV: kv}) + if err != nil { + return err + } + + var ( + result verify.Report + idx *remote.Index + ) + if err := sess.Run(ctx, func(ctx context.Context, pool dcpool.Pool) error { + api := pool.Default(ctx) + + peer, err := tgsource.ResolveChat(ctx, tgsource.Manager(api, tdlstorage.NewPeers(kv)), *chat) + if err != nil { + return err + } + + // The chat is walked first and the remote listed second, so the snapshot + // is never older than the wanted set. The reverse order could report a + // file absent that was uploaded while the walk was still running. + fmt.Fprintf(os.Stderr, "reading %s\n", *chat) + scan := report.NewTicker(os.Stderr, "messages read") + var scanned int + var items []tgsource.Item + for it, err := range tgsource.Walk(ctx, api, peer, func(n int) { scanned = n; scan.Update(n) }) { + if err != nil { + return err + } + items = append(items, it) + } + + scan.Done(scanned) + + fmt.Fprintf(os.Stderr, "indexing %s\n", dst.String()) + idxTick := report.NewTicker(os.Stderr, "objects listed") + var listed int + idx, err = remote.BuildIndex(ctx, dst, peer.ID(), func(n int) { + listed = n + idxTick.Update(n) + }) + if err != nil { + return err + } + idxTick.Done(listed) + if dup := idx.Collisions(); len(dup) > 0 { + // An ambiguous snapshot makes every verdict about these names + // unreliable, so it is reported rather than silently resolved. + fmt.Fprintf(os.Stderr, "warning: %d basename(s) appear at more than one path; "+ + "verdicts for them may flip between runs:\n", len(dup)) + for _, d := range dup[:min(5, len(dup))] { + fmt.Fprintf(os.Stderr, " %q\n", d) + } + } + fmt.Fprintln(os.Stderr) + + result = verify.Check(items, idx) + return nil + }); err != nil { + return err + } + + out := bufio.NewWriter(os.Stdout) + result.Write(out) + if err := out.Flush(); err != nil { + return err + } + + if *delStale { + if err := deleteMisnamed(ctx, dst, idx, result, *assumeYes); err != nil { + return err + } + } + + if !result.Complete() { + return fmt.Errorf("%w: %d file(s) still to fetch", errIncomplete, len(result.Todo())) + } + return nil +} + +// deleteMisnamed removes stale copies left by an earlier naming scheme. +// +// Off by default and confirmed by default: this deletes data from the operator's +// remote, and a stale copy costs storage rather than correctness, so there is no +// hurry that justifies doing it unasked. +// +// Objects are addressed by the path the index recorded, not by the name matching +// used elsewhere. Those differ the moment a remote has directory structure, and +// deleting by basename would either miss the object or — worse, if the root +// happens to hold a same-named file — delete the wrong one. +func deleteMisnamed(ctx context.Context, dst rclonefs.Fs, idx *remote.Index, result verify.Report, assumeYes bool) error { + var targets []string + for _, m := range result.Misnamed { + targets = append(targets, m.Found...) + } + if len(targets) == 0 { + fmt.Fprintln(os.Stderr, "\nnothing to delete: no files stored under a superseded name") + return nil + } + + // Filenames are chosen by whoever uploaded the file and may contain control + // characters or bidi marks, so they are quoted rather than printed raw: an + // embedded newline or escape sequence could otherwise redraw this list and + // have the operator approve something other than what they read. + fmt.Fprintf(os.Stderr, "\nabout to delete %d file(s) from %s:\n", len(targets), dst.String()) + for _, t := range targets { + fmt.Fprintf(os.Stderr, " %q\n", t) + } + + if !assumeYes { + // Anything that is not an explicit yes leaves the files alone, and that + // includes the read failing. Closed or non-interactive stdin — cron, a + // pipeline — therefore declines rather than proceeding, which is the + // safe direction for a delete. + fmt.Fprint(os.Stderr, "delete these? [y/N] ") + var answer string + _, _ = fmt.Scanln(&answer) + switch strings.ToLower(strings.TrimSpace(answer)) { + case "y", "yes": + default: + fmt.Fprintln(os.Stderr, "left alone") + return nil + } + } + + // One failure must not strand the rest: the operator approved a set, so the + // whole set is attempted and the outcome reported as a count they can check + // against what they approved. + var errs []error + deleted := 0 + for _, name := range targets { + path, ok := idx.PathOf(name) + if !ok { + errs = append(errs, fmt.Errorf("%q is no longer in the index", name)) + continue + } + obj, err := dst.NewObject(ctx, path) + if err != nil { + errs = append(errs, fmt.Errorf("locate %q: %w", path, err)) + continue + } + if err := operations.DeleteFile(ctx, obj); err != nil { + errs = append(errs, fmt.Errorf("delete %q: %w", path, err)) + continue + } + deleted++ + } + + fmt.Fprintf(os.Stderr, "deleted %d of %d\n", deleted, len(targets)) + return errors.Join(errs...) +} diff --git a/cmd/uidemo/main.go b/cmd/uidemo/main.go new file mode 100644 index 0000000..4822acc --- /dev/null +++ b/cmd/uidemo/main.go @@ -0,0 +1,84 @@ +// Command uidemo renders the sync progress UI with fake data, so the layout can +// be checked without a Telegram session or a multi-hour transfer. +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/iyear/tdl/core/tmedia" + "github.com/rclone/rclone/fs" + + "github.com/tiennm99dev/telegram-exporter/internal/pipeline" + "github.com/tiennm99dev/telegram-exporter/internal/report" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" + "github.com/tiennm99dev/telegram-exporter/internal/verify" +) + +func item(id int, name string, size int64) tgsource.Item { + return tgsource.Item{MessageID: id, Name: name, Media: &tmedia.Media{Size: size}} +} + +func main() { + files := []tgsource.Item{ + item(4242, "1234567890_4242_1000000000000000001.mp4", 96<<20), + item(4243, "1234567890_4243_1000000000000000002.mp4", 1900<<20), + item(4244, "1234567890_4244_short.jpg", 2<<20), + } + var total int64 + for _, f := range files { + total += f.Size() + } + fmt.Fprintln(os.Stderr, "PikPak root 'mychannel' has 1438 GiB free") + fmt.Fprintln(os.Stderr, "reading mychannel") + fmt.Fprintln(os.Stderr, " 12,000 messages read in 4m31s") + fmt.Fprintln(os.Stderr, "indexing PikPak root 'mychannel'") + fmt.Fprintln(os.Stderr, " 11,406 objects listed in 2m10s") + + survey := verify.Report{Expected: 12000, Present: 11400, Bytes: 518 << 30} + for i := range 2607 { + survey.Absent = append(survey.Absent, 4242+i) + } + for i := range 6 { + survey.Mismatched = append(survey.Mismatched, + verify.Mismatch{MessageID: 14290 + i, Want: 2 << 30, Got: 221 << 20}) + } + report.Survey(os.Stderr, survey) + report.Plan(os.Stderr, report.PlanInfo{ + Files: 2613, Bytes: 79 << 30, Largest: 2 << 30, Budget: 40 << 30, + Staging: "./staging", Threads: 4, Downloads: 2, Uploads: 2, + Destination: "PikPak root 'mychannel'", + }) + + ev := report.Events(os.Stderr, 2613, 79<<30) + if live, ok := ev.(*report.Live); ok { + defer report.CaptureRcloneLog(context.Background(), live.LogWriter())() + } + st := pipeline.Stats{} + for _, f := range files { + ev.DownloadStart(f) + } + for step := range 30 { + if step == 12 { + fs.Errorf(nil, "1234567890_4245_1000000000000000003.jpg: Failed to copy: "+ + "can't verify the task is completed") + } + for _, f := range files { + ev.DownloadBytes(f, f.Size()*int64(step+1)/30) + } + st.BytesDone += total / 30 + ev.Stats(st) + time.Sleep(60 * time.Millisecond) + } + for i, f := range files { + ev.DownloadDone(f, nil) + ev.UploadStart(f) + st.Done = i + 1 + ev.Stats(st) + time.Sleep(400 * time.Millisecond) + ev.UploadDone(f, nil) + } + ev.Finish(st) +} diff --git a/export-until-complete.sh b/export-until-complete.sh deleted file mode 100755 index 3095f32..0000000 --- a/export-until-complete.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash -# -# Drive run.sh repeatedly until every media file in a chat is on the remote. -# -# Each pass verifies what is already there, narrows the export JSON to just the -# ids still needed, and runs the pipeline on that subset. It stops when the -# verifier reports complete, when a pass makes no progress (the remaining media -# is genuinely unavailable on Telegram's side), or when the remote runs low on -# space. -# -# Usage: ./export-until-complete.sh -r REMOTE:PATH -c CHAT [options] -# -r REMOTE:PATH rclone destination (required) -# -c CHAT chat id/username, used for the initial metadata export -# -f FILE export JSON (default export-.json) -# -d DIR staging directory (default ./staging) -# -i SECONDS rclone sweep interval (default 120) -# -p N maximum passes (default 30) -# -m SIZE cap the staging directory at SIZE, e.g. 40G (default: no cap) -# -q GIB stop if remote free space falls below this (default 5) - -set -euo pipefail - -remote='' chat='' export_file='' staging='./staging' interval=120 max_staging='' -max_passes=30 min_free_gib=5 - -while getopts ':r:c:f:d:i:p:q:m:h' o; do case $o in - r) remote=$OPTARG ;; c) chat=$OPTARG ;; f) export_file=$OPTARG ;; - d) staging=$OPTARG ;; i) interval=$OPTARG ;; p) max_passes=$OPTARG ;; - q) min_free_gib=$OPTARG ;; m) max_staging=$OPTARG ;; - h) sed -n '2,19p' "$0"; exit 0 ;; - *) echo "usage: $0 -r REMOTE:PATH -c CHAT [-f FILE] [-i SECS] [-p N] [-q GIB] [-m SIZE]" >&2; exit 2 ;; -esac; done - -log() { printf '\n=== %s [driver] %s ===\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; } - -[[ -n $remote ]] || { echo "-r REMOTE:PATH is required" >&2; exit 2; } -[[ -n $chat || -n $export_file ]] || { echo "-c CHAT or -f FILE is required" >&2; exit 2; } -[[ -n $export_file ]] || export_file="export-${chat}.json" - -# Stop the whole loop on Ctrl-C rather than rolling into the next pass. run.sh -# installs its own handlers, so a pass shuts down cleanly before we exit. -interrupted=0 -trap 'interrupted=1' INT TERM - -# tdl's progress bar is ANSI redraws: great on a terminal, unreadable in a log. -# Show it when stdout is a TTY, suppress it when output is redirected. -tdl_quiet=() -[[ -t 1 ]] || tdl_quiet=(--disable-progress-ps) - -# The metadata export must exist before the first verify has anything to compare. -if [[ ! -f $export_file ]]; then - [[ -n $chat ]] || { echo "$export_file missing and no -c CHAT to create it" >&2; exit 2; } - log "exporting $chat metadata to $export_file" - tdl chat export -c "$chat" --all --with-content -o "$export_file" -fi - -free_gib() { - rclone about "${remote%%:*}:" --json 2>/dev/null \ - | python3 -c 'import json,sys; print(int(json.load(sys.stdin).get("free",0))//2**30)' 2>/dev/null \ - || echo 999999 # backends without quota reporting must not block the run -} - -prev_todo=-1 -for ((pass = 1; pass <= max_passes; pass++)); do - ((interrupted)) && { log 'interrupted; stopping'; exit 130; } - - log "pass $pass/$max_passes: verifying" - if ./verify-export.sh -f "$export_file" -r "$remote" -d "$staging"; then - log "complete after $((pass - 1)) download pass(es)" - exit 0 - fi - - todo=$(wc -l < missing-ids.txt) - if ((todo == prev_todo)); then - log "no progress in the last pass; $todo file(s) look permanently unavailable" - log 'ids left in missing-ids.txt' - exit 1 - fi - prev_todo=$todo - - free=$(free_gib) - if ((free < min_free_gib)); then - log "remote has only ${free} GiB free (limit ${min_free_gib}); stopping before it fills" - exit 3 - fi - log "$todo file(s) to fetch; ${free} GiB free on remote" - - # Narrow the full export to the outstanding ids, preserving its top-level shape - # so tdl reads it exactly like the original. - python3 - "$export_file" <<'PY' -import json, sys -d = json.load(open(sys.argv[1])) -want = {int(l) for l in open('missing-ids.txt') if l.strip()} -d['messages'] = [m for m in d['messages'] if m['id'] in want] -json.dump(d, open('gap.json', 'w')) -print(f"gap.json: {len(d['messages'])} messages") -PY - - rc=0 - ./run.sh -r "$remote" -f gap.json -d "$staging" -i "$interval" \ - ${max_staging:+-m "$max_staging"} \ - -- --group=false -t 4 -l 2 ${tdl_quiet[@]+"${tdl_quiet[@]}"} || rc=$? - log "pass $pass finished (run.sh exit $rc)" - - case $rc in - 0|1) ;; # done or partial: verify decides - 3) log 'rclone failure (remote full or unreachable); stopping'; exit 3 ;; - 130|143) log 'run interrupted; stopping'; exit 130 ;; - esac -done - -log "hit the $max_passes-pass limit; run again to continue" -exit 1 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8fbdd96 --- /dev/null +++ b/go.mod @@ -0,0 +1,257 @@ +module github.com/tiennm99dev/telegram-exporter + +go 1.27.1 + +require ( + github.com/gotd/td v0.140.0 + github.com/iyear/tdl/core v0.20.4 + github.com/rclone/rclone v1.75.1 + go.etcd.io/bbolt v1.5.0 + golang.org/x/sync v0.22.0 +) + +require ( + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0 // indirect + github.com/Azure/go-ntlmssp v0.1.1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect + github.com/FilenCloudDienste/filen-sdk-go v0.0.39 // indirect + github.com/Files-com/files-sdk-go/v3 v3.3.194 // indirect + github.com/IBM/go-sdk-core/v5 v5.23.1 // indirect + github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect + github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/ProtonMail/go-srp v0.0.7 // indirect + github.com/ProtonMail/gopenpgp/v3 v3.4.1 // indirect + github.com/PuerkitoBio/goquery v1.12.0 // indirect + github.com/VividCortex/ewma v1.2.0 // indirect + github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect + github.com/abbot/go-http-auth v0.4.0 // indirect + github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect + github.com/adrg/xdg v0.5.3 // indirect + github.com/anchore/go-lzo v0.1.1 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/andybalholm/cascadia v1.3.4 // indirect + github.com/apache/arrow-go/v18 v18.7.0 // indirect + github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect + github.com/aws/aws-sdk-go-v2 v1.43.7 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.30 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect + github.com/aws/smithy-go v1.27.8 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beevik/ntp v1.5.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/boombuler/barcode v1.1.0 // indirect + github.com/bradenaw/juniper v0.15.3 // indirect + github.com/buengese/sgzip v0.1.1 // indirect + github.com/buger/jsonparser v1.2.0 // indirect + github.com/calebcase/tmpfile v1.0.3 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.4 // indirect + github.com/cloudinary/cloudinary-go/v2 v2.16.0 // indirect + github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8 // indirect + github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/colinmarc/hdfs/v2 v2.4.0 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.6.0 // indirect + github.com/creasty/defaults v1.8.0 // indirect + github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 // indirect + github.com/diskfs/go-diskfs v1.9.4 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/dromara/dongle v1.0.1 // indirect + github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.4.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/emersion/go-message v0.18.2 // indirect + github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/flynn/noise v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/geoffgarside/ber v1.2.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-chi/chi/v5 v5.3.1 // indirect + github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect + github.com/go-faster/errors v0.8.0 // indirect + github.com/go-faster/jx v1.2.0 // indirect + github.com/go-faster/xor v1.0.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/strfmt v0.27.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/gofrs/flock v0.13.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/flatbuffers v25.12.19+incompatible // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/gorilla/schema v1.4.1 // indirect + github.com/gotd/contrib v0.20.0 // indirect + github.com/gotd/ige v0.3.0 // indirect + github.com/gotd/log v0.1.0 // indirect + github.com/gotd/neo v0.1.5 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/internxt/rclone-adapter v0.0.0-20260708165336-dd6561bacfa2 // indirect + github.com/iyear/connectproxy v0.1.1 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/goidentity/v6 v6.0.1 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect + github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect + github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lanrat/extsort v1.4.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lpar/calendar v0.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect + github.com/mailru/easyjson v0.9.2 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.23 // indirect + github.com/mattn/go-runewidth v0.0.28 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncw/swift/v2 v2.0.5 // indirect + github.com/ogen-go/ogen v1.23.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/oracle/oci-go-sdk/v65 v65.121.0 // indirect + github.com/panjf2000/ants/v2 v2.12.1 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect + github.com/peterh/liner v1.2.2 // indirect + github.com/pierrec/lz4/v4 v4.1.27 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/sftp v1.13.11 // indirect + github.com/pkg/xattr v0.4.12 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/pquerna/otp v1.5.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect + github.com/rclone/Proton-API-Bridge v1.0.5 // indirect + github.com/rclone/go-proton-api v1.0.4 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect + github.com/relvacode/iso8601 v1.7.0 // indirect + github.com/rfjakob/eme v1.2.0 // indirect + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect + github.com/samber/lo v1.53.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect + github.com/smarty/assertions v1.16.0 // indirect + github.com/sony/gobreaker/v2 v2.4.0 // indirect + github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.12.1 // indirect + github.com/t3rm1n4l/go-mega v0.0.0-20260717075258-c6acd6a5bd04 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/tyler-smith/go-bip39 v1.1.0 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect + github.com/unknwon/goconfig v1.0.0 // indirect + github.com/vbauerster/cupwriter v0.0.4 // indirect + github.com/vbauerster/mpb/v8 v8.16.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yuin/goldmark v1.8.4 // indirect + github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + github.com/zeebo/errs v1.4.0 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect + golang.org/x/image v0.45.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/api v0.279.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d // indirect + google.golang.org/grpc v1.84.0-dev.0.20260723093437-b6eac429d7b6 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/validator.v2 v2.0.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + moul.io/http2curl/v2 v2.3.0 // indirect + rsc.io/qr v0.2.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect + storj.io/common v0.0.0-20260629224719-ba1bff0a7846 // indirect + storj.io/drpc v1.0.0 // indirect + storj.io/eventkit v0.0.0-20260716074419-6861a92e2aa5 // indirect + storj.io/infectious v0.0.2 // indirect + storj.io/picobuf v0.0.4 // indirect + storj.io/uplink v1.14.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..af02a09 --- /dev/null +++ b/go.sum @@ -0,0 +1,747 @@ +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 h1:irsmOWwkp0KCTTNS5e2hdFeIvSQClQo2No3IaNmL3Vw= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0/go.mod h1:GWcBkQj3MqN7ozHKLaCCAuNLiXoIGv2RtanfAwSjY/Y= +github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0 h1:cuiKf1UVyWHu+XSQghPZR/qEF43JIcuk2CDqMlPiT6M= +github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0/go.mod h1:9JSyvgXLPAOC4jfhgZg58XeU2FJHsGmxuYSclCFQ4ZY= +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/FilenCloudDienste/filen-sdk-go v0.0.39 h1:tgV5jYL6dsXop9TpDTIQU6UwJjws122HrwskaEE/igY= +github.com/FilenCloudDienste/filen-sdk-go v0.0.39/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0= +github.com/Files-com/files-sdk-go/v3 v3.3.194 h1:dtOFxSTWWRpkmvXa6ycNiw8dVDu1wkgzcXyVV1VafNc= +github.com/Files-com/files-sdk-go/v3 v3.3.194/go.mod h1:rl0WumSN9gSo775DgvQv+wMQ8rlb0ES/1hU5jkMtLXg= +github.com/IBM/go-sdk-core/v5 v5.23.1 h1:fxLusCG+GlGY1SM7BYAAiiSFhzKUs5zqs0P8knDqeFU= +github.com/IBM/go-sdk-core/v5 v5.23.1/go.mod h1:yO+OQpByKDLTvpEcsFFexgzpeR8eRfCFWAYzxkAu4bk= +github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE= +github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I= +github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug= +github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo= +github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e h1:lCsqUUACrcMC83lg5rTo9Y0PnPItE61JSfvMyIcANwk= +github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo= +github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI= +github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk= +github.com/ProtonMail/gopenpgp/v3 v3.4.1 h1:K7uUhSHSJxORZ+RuHpilTT6S4MA2whCRlXNwLqd0+ys= +github.com/ProtonMail/gopenpgp/v3 v3.4.1/go.mod h1:bGdV9f6edhmd581wzXsQCTKdH8bXBbyhkgDKPjwPc6U= +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 h1:iLDOF0rdGTrol/q8OfPIIs5kLD8XvA2q75o6Uq/tgak= +github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:DrEWcQJjz7t5iF2duaiyhg4jyoF0kxOD6LtECNGkZ/Q= +github.com/aalpar/deheap v1.1.2 h1:MABHLcnjqsffb8GLkUFDigqpBBxOMz0DoKM9QfELeTw= +github.com/aalpar/deheap v1.1.2/go.mod h1:A+nfkD4JbS05sewV0he/MYgR/90vfqyMoNNROgs+rmA= +github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0= +github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/anchore/go-lzo v0.1.1 h1:IwL/fvkdtlIrYIXck6WxZ3nb8WjjHziYYmGxlooyOnM= +github.com/anchore/go-lzo v0.1.1/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= +github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= +github.com/apache/arrow-go/v18 v18.7.0 h1:Vw/i+cJyebUofT7JlqFpe65LrmwxULn166jjwStM4HY= +github.com/apache/arrow-go/v18 v18.7.0/go.mod h1:PM6IigLJkdMwIpeHXnymo+xZ52f42a9EYiLtRel4p/A= +github.com/apache/thrift v0.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ= +github.com/apache/thrift v0.24.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= +github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc h1:LoL75er+LKDHDUfU5tRvFwxH0LjPpZN8OoG8Ll+liGU= +github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc/go.mod h1:w648aMHEgFYS6xb0KVMMtZ2uMeemhiKCuD2vj6gY52A= +github.com/aws/aws-sdk-go-v2 v1.43.7 h1:msCzvkeYJA9ehbV8mRRmkZLo/zJg/+yDVLNtflg83hQ= +github.com/aws/aws-sdk-go-v2 v1.43.7/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 h1:LAfOuhAH331fmOjTQpAaOlH+Ftn7RzSDJ2VFwjdMMy4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18/go.mod h1:4e5xhuXHx1e4U9EthvbPP1r/DIMp5c2823OL8karzcM= +github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk= +github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34 h1:Pn7OsMwBLbkZ6OnCxWHAjf0L/22H8cnhxZC0uPwtMtg= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34/go.mod h1:eToXR/Gk1uqpn04eSmdgVXwfS0WvH8aG4eBFr8ygbpU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 h1:MBMg0zJ6i4TkAJ0dVFLKKn2cOkY6FkicmUDM67BRr6g= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38/go.mod h1:9MWuJbyiUyj6eA7W1/zm1zuePDPSB3g+xcgRQeMWsXc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 h1:lHm4jPf3k1Lz5ZWc+Vcn3MKVwym+26kWCba9FkJ4f0Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38/go.mod h1:Rn+P2XR+FbyZzjmWKjg/KUZNxmGfr5oZwh5jQiE+CzI= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 h1:vo4xvMRs/F6h1E52qsgLqCQgWIQXgIJUauG6rlZEh4U= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39/go.mod h1:jB03R1ij/A+OE2e1dz6vgj076gd7vlYcfstAzj3HcnU= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 h1:uZOinZb+h7lZw8IYzP1z1IuEnueB76/EFkcf/fEW4Ag= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31/go.mod h1:NRtwAM/p5VRt03TlEUs0pH3TeWamWdf4YyJpSrzPYLc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 h1:H/5TI1jqaHsNoDQ60UwvPvJBg4GURkinXI3Qga29t2w= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38/go.mod h1:PTVFf+XH++7NJOky+RLBYQx0QA5NcaeEYFQ2fsi0nwo= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 h1:HLPAVrlLDaN2boN0xJx7MgaQDNEO3Q+c9L6kl/8m47Q= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39/go.mod h1:Pg/dVfsNkm1hsIDK/gMvCKtmyNfNTV12mrgHqVE/6Oo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 h1:IKoCZqfWfZzSBi16QFQ+QcbQ3LRQ7QgB1S5tDAyPBQQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3/go.mod h1:RBpRcXiM4s2pOInVs32GsBonnje+fiAj4mcrStRmlCA= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= +github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= +github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4= +github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bradenaw/juniper v0.15.3 h1:RHIAMEDTpvmzV1wg1jMAHGOoI2oJUSPx3lxRldXnFGo= +github.com/bradenaw/juniper v0.15.3/go.mod h1:UX4FX57kVSaDp4TPqvSjkAAewmRFAfXf27BOs5z9dq8= +github.com/buengese/sgzip v0.1.1 h1:ry+T8l1mlmiWEsDrH/YHZnCVWD2S3im1KLsyO+8ZmTU= +github.com/buengese/sgzip v0.1.1/go.mod h1:i5ZiXGF3fhV7gL1xaRRL1nDnmpNj0X061FQzOS8VMas= +github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g= +github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= +github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/calebcase/tmpfile v1.0.3 h1:BZrOWZ79gJqQ3XbAQlihYZf/YCV0H4KPIdM5K5oMpJo= +github.com/calebcase/tmpfile v1.0.3/go.mod h1:UAUc01aHeC+pudPagY/lWvt2qS9ZO5Zzof6/tIUzqeI= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 h1:z0uK8UQqjMVYzvk4tiiu3obv2B44+XBsvgEJREQfnO8= +github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9/go.mod h1:Jl2neWsQaDanWORdqZ4emBl50J4/aRBBS4FyyG9/PFo= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= +github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= +github.com/cloudinary/cloudinary-go/v2 v2.16.0 h1:0irPbKwRB6V6sdP9+a4P4D5sRUYHXriG5GfTjVq4YBI= +github.com/cloudinary/cloudinary-go/v2 v2.16.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo= +github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8 h1:+KC2I+emnT6ccWmo7IHbtchGc/vFqAxUuovo2eUgMuM= +github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8/go.mod h1:1pQXB0vAlzRlqcY7LYKOOZMw0wKfJPFxTLsJRF2Gswo= +github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs= +github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc/go.mod h1:uvR42Hb/t52HQd7x5/ZLzZEK8oihrFpgnodIJ1vte2E= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/colinmarc/hdfs/v2 v2.4.0 h1:v6R8oBx/Wu9fHpdPoJJjpGSUxo8NhHIwrwsfhFvU9W0= +github.com/colinmarc/hdfs/v2 v2.4.0/go.mod h1:0NAO+/3knbMx6+5pCv+Hcbaz4xn/Zzbn9+WIib2rKVI= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= +github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= +github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= +github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= +github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA= +github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 h1:tLWBZgPg6TV67oe76W4p+aUQEWIa52wbcuiz8GFd3vo= +github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc= +github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dromara/dongle v1.0.1 h1:si/7UP/EXxnFVZok1cNos70GiMGxInAYMilHQFP5dJs= +github.com/dromara/dongle v1.0.1/go.mod h1:ebFhTaDgxaDIKppycENTWlBsxz8mWCPWOLnsEgDpMv4= +github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.4.0 h1:OYMx56y2as2FIM6QuS5HGXC2AYtf5xv/2n6INaluF4Y= +github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.4.0/go.mod h1:gDXhl0OElhzYoDsYWHr1RXjpxjGeLzEvjYzH7sZV73k= +github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= +github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg= +github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 h1:B9YK+Tck5mTccyDhtxBzWyqGYcFxLyB6+noMNW4/VgI= +github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emmansun/gmsm v0.15.5/go.mod h1:2m4jygryohSWkaSduFErgCwQKab5BNjURoFrn2DNwyU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= +github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64= +github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= +github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68= +github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo= +github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc= +github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= +github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= +github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= +github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20240509144519-723abb6459b7 h1:velgFPYr1X9TDwLIfkV7fWqsFlf7TeP11M/7kPd/dVI= +github.com/google/pprof v0.0.0-20240509144519-723abb6459b7/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k= +github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gotd/contrib v0.20.0 h1:1Wc4+HMQiIKYQuGHVwVksIx152HFTP6B5n88dDe0ZYw= +github.com/gotd/contrib v0.20.0/go.mod h1:P6o8W4niqhDPHLA0U+SA/L7l3BQHYLULpeHfRSePn9o= +github.com/gotd/ige v0.3.0 h1:4f6LEHWsVDLBG0bT9wWG2/9TZb5aWm265G8ZlTXmRRU= +github.com/gotd/ige v0.3.0/go.mod h1:FE9bTaQtvfArizAcZuI4sS6gXaEUBmixdUufVHoCKac= +github.com/gotd/log v0.1.0 h1:4LJUEvafD1xtBwx2QkrlzFnRgbYXTlWqJPDi8BvrLbU= +github.com/gotd/log v0.1.0/go.mod h1:5ilhdu1Ux0QvDY/FF3Ojfw24Ws3SlCtyLwOpXy8KYXs= +github.com/gotd/log/logzap v0.1.1 h1:O6l7d8HUbODe+UMcrM47eXYDwdJ6RNmpQejLjrlcEIQ= +github.com/gotd/log/logzap v0.1.1/go.mod h1:5ObZkITbfhbsBOLzBkzmMk9QxXc0eNQpimau7zRL+Y8= +github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= +github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= +github.com/gotd/td v0.140.0 h1:trNBzTnhNtNwHsFp5qwKnNxQRAZJ6/BRE+uH3Lojauk= +github.com/gotd/td v0.140.0/go.mod h1:0ZkRxG7N+5ooG7/zdRXcnGautGPM6IKmyPQvdsAeF20= +github.com/gotd/td v0.161.0 h1:krbzsb70cakdrqF+MUIo+W7BkQTVhyB1kNS7X/+BLcY= +github.com/gotd/td v0.161.0/go.mod h1:7HdCs+zeJugdgZAF5iG8f70eOJvuiH2QzjoyUcysXbY= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/internxt/rclone-adapter v0.0.0-20260708165336-dd6561bacfa2 h1:ZeebPK9Bnpy40uTuneEeVMYaMMerol3qsmIvzD0EIAk= +github.com/internxt/rclone-adapter v0.0.0-20260708165336-dd6561bacfa2/go.mod h1:4jGLEnNHyWOVSGn89IeWUVqlKCEitOM3D32/XypGy/o= +github.com/iyear/connectproxy v0.1.1 h1:JZOF/62vvwRGBWcgSyWRb0BpKD4FSs0++B5/y5pNE4c= +github.com/iyear/connectproxy v0.1.1/go.mod h1:yD4zOmSMQCmwHIT4fk8mg4k2M15z8VoMSoeY6NNJdsA= +github.com/iyear/tdl/core v0.20.4 h1:3CuYn56XRdSvyZwJq9hi4Oi4nGyItIR0WiURdeZwsA0= +github.com/iyear/tdl/core v0.20.4/go.mod h1:8CdaYmd2ZOCzoUdXMW0cATfnAwA9RFtNNXZDwF+odMU= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb h1:6vkM8gO+zFV2m21QzGYyUSq5TP0VQgP2Xz3UQyCN2kI= +github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb/go.mod h1:H1+whwD0Qe3YOunlXIWhh3rlvzW5cZfkMDYGQPg+KAM= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 h1:JcltaO1HXM5S2KYOYcKgAV7slU0xPy1OcvrVgn98sRQ= +github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7/go.mod h1:MEkhEPFwP3yudWO0lj6vfYpLIB+3eIcuIW+e0AZzUQk= +github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 h1:G+9t9cEtnC9jFiTxyptEKuNIAbiN5ZCQzX2a74lj3xg= +github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004/go.mod h1:KmHnJWQrgEvbuy0vcvj00gtMqbvNn1L+3YUZLK/B92c= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 h1:CjEMN21Xkr9+zwPmZPaJJw+apzVbjGL5uK/6g9Q2jGU= +github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988/go.mod h1:/agobYum3uo/8V6yPVnq+R82pyVGCeuWW5arT4Txn8A= +github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 h1:FHVoZMOVRA+6/y4yRlbiR3WvsrOcKBd/f64H7YiWR2U= +github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6/go.mod h1:MRAz4Gsxd+OzrZ0owwrUHc0zLESL+1Y5syqK/sJxK2A= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lanrat/extsort v1.4.2 h1:akbLIdo4PhNZtvjpaWnbXtGMmLtnGzXplkzfgl+XTTY= +github.com/lanrat/extsort v1.4.2/go.mod h1:hceP6kxKPKebjN1RVrDBXMXXECbaI41Y94tt6MDazc4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lpar/calendar v0.2.0 h1:A1kxv6sbvBHFUkd2XotanIRqEXQGreQOeuGhkJqIaRA= +github.com/lpar/calendar v0.2.0/go.mod h1:fsVJa4o2NvXYzaCE5RXMadqbZzWEhNCGOUPVHTIZdjc= +github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+/NiwzJtPYXXKRcyjmEUhsDci6YK3c= +github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= +github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU= +github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncw/swift/v2 v2.0.5 h1:9o5Gsd7bInAFEqsGPcaUdsboMbqf8lnNtxqWKFT9iz8= +github.com/ncw/swift/v2 v2.0.5/go.mod h1:cbAO76/ZwcFrFlHdXPjaqWZ9R7Hdar7HpjRXBfbjigk= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/ogen-go/ogen v1.23.0 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo= +github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.17.3 h1:oJcvKpIb7/8uLpDDtnQuf18xVnwKp8DTD7DQ6gTd/MU= +github.com/onsi/ginkgo/v2 v2.17.3/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/oracle/oci-go-sdk/v65 v65.121.0 h1:1J+5ARgrodrx8kzFy/hxznaoUzz43jr0EestCzEaOHw= +github.com/oracle/oci-go-sdk/v65 v65.121.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ= +github.com/panjf2000/ants/v2 v2.12.1 h1:BWvU2wHpyXWxhhNXsGB6JXLCNbshyLd1QxvoAmZnu10= +github.com/panjf2000/ants/v2 v2.12.1/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 h1:XeOYlK9W1uCmhjJSsY78Mcuh7MVkNjTzmHx1yBzizSU= +github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14/go.mod h1:jVblp62SafmidSkvWrXyxAme3gaTfEtWwRPGz5cpvHg= +github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= +github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= +github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= +github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= +github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 h1:Y258uzXU/potCYnQd1r6wlAnoMB68BiCkCcCnKx1SH8= +github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8/go.mod h1:bSJjRokAHHOhA+XFxplld8w2R/dXLH7Z3BZ532vhFwU= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rclone/Proton-API-Bridge v1.0.5 h1:K1++Qtk3PvgkiCCiv6Pahju1TMOzKY6VSwiwT7XLAVc= +github.com/rclone/Proton-API-Bridge v1.0.5/go.mod h1:vCeOPhlXzevN0AFojgh1zsjhetiShy/ArvJ/xkFUDWk= +github.com/rclone/go-proton-api v1.0.4 h1:AJW0e9pB4j0hVK4WqyGErFwaI+5MUQWPCtj5FYYxtPg= +github.com/rclone/go-proton-api v1.0.4/go.mod h1:QAlkFfswzrBuxvCORWV8rZdddg52hahMN98CFWoFW1E= +github.com/rclone/rclone v1.75.1 h1:kIxQcoDLj2Gke/gMSHK7OnxhX1Gu1cJBLP1kJZoaFp0= +github.com/rclone/rclone v1.75.1/go.mod h1:4zmMjGatCkSJPRZDpo+7y3xOl8S29EMUyKvZop5mHr4= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo= +github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= +github.com/rfjakob/eme v1.2.0 h1:8dAHL+WVAw06+7DkRKnRiFp1JL3QjcJEZFqDnndUaSI= +github.com/rfjakob/eme v1.2.0/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY= +github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/snabb/httpreaderat v1.0.1 h1:whlb+vuZmyjqVop8x1EKOg05l2NE4z9lsMMXjmSUCnY= +github.com/snabb/httpreaderat v1.0.1/go.mod h1:lpbGrKDWF37yvRbtRvQsbesS6Ty5c83t8ztannPoMsA= +github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg= +github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s= +github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 h1:GyYC5Ntqk/yy9lEIGE7chdIvt4zP44taycwd9YDSGdc= +github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368/go.mod h1:XkZYGzknZwkD0AKUnZaSXhRiVTLCkq7CWVa3IsE72gA= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/t3rm1n4l/go-mega v0.0.0-20260717075258-c6acd6a5bd04 h1:s30A8dMuZ55lUOi5xUTh1hlfLqHpVBsRMfpk2iKCayk= +github.com/t3rm1n4l/go-mega v0.0.0-20260717075258-c6acd6a5bd04/go.mod h1:BF/l2jNyK+2h/BJZ7VLMAz6m/IWjA2F67gTjV1C/+Bo= +github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= +github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/unknwon/goconfig v1.0.0 h1:rS7O+CmUdli1T+oDm7fYj1MwqNWtEJfNj+FqcUHML8U= +github.com/unknwon/goconfig v1.0.0/go.mod h1:qu2ZQ/wcC/if2u32263HTVC39PeOQRSmidQk3DuDFQ8= +github.com/vbauerster/cupwriter v0.0.4 h1:9sBPe0uXWLZuWQU5lqVbhyFlxX6c09asST/YfatFAys= +github.com/vbauerster/cupwriter v0.0.4/go.mod h1:IFyzS6Xis5dnBH/rdAhrnuzg3c+KkUqEN6yE8lhJlDw= +github.com/vbauerster/mpb/v8 v8.16.1 h1:gNYmwMip9xRWNGAiblZOgUNXWeU2P0NIGd5x0f8ffbc= +github.com/vbauerster/mpb/v8 v8.16.1/go.mod h1:gnU8zNF/JWltFepqwko/ulMEUIDrydIq7T4UdMN26Nw= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= +github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yunify/qingstor-sdk-go/v3 v3.2.0 h1:9sB2WZMgjwSUNZhrgvaNGazVltoFUUfuS9f0uCWtTr8= +github.com/yunify/qingstor-sdk-go/v3 v3.2.0/go.mod h1:KciFNuMu6F4WLk9nGwwK69sCGKLCdd9f97ac/wfumS4= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A= +github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +github.com/zeebo/mwc v0.0.7 h1:0NerGhCww6ZQx+/xCx5iwznftveokvto1KILpYfENZk= +github.com/zeebo/mwc v0.0.7/go.mod h1:0B32or6moOig1YGuqMoimBpU9QK9uYaGG2bBOuddqtE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= +go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= +golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= +google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1:admdQBe8jR3VWhBsUrAOaF2Qw6K/+p5pSm1GN8+6Fw4= +google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d h1:Jkpk39hlTZOIp3RbfvNX9R8Hv+Sw0X89nlU/xFOErsc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.84.0-dev.0.20260723093437-b6eac429d7b6 h1:HfjjkdGIa8u9sP9EW5WCygy0kQDuTI/Tax4j//t24Fo= +google.golang.org/grpc v1.84.0-dev.0.20260723093437-b6eac429d7b6/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= +gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs= +moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +storj.io/common v0.0.0-20260629224719-ba1bff0a7846 h1:TRtVGWm/Y4KAi4coWMxVpydPvBQKYVhkKMar7wu9Zow= +storj.io/common v0.0.0-20260629224719-ba1bff0a7846/go.mod h1:1GZnCZNGbzBzaqhG0cUypQeLfNhNIeNY9JVMMiCF14M= +storj.io/drpc v1.0.0 h1:1Xf1KCXXbV1viIfN56eqdJ3cNwpAL7OKwQkpS6ksing= +storj.io/drpc v1.0.0/go.mod h1:Y9LZaa8esL1PW2IDMqJE7CFSNq7d5bQ3RI7mGPtmKMg= +storj.io/eventkit v0.0.0-20260716074419-6861a92e2aa5 h1:JPguPTktHaha9TBEHvuxZmB+mCkiMPgEMOLfosDoE9o= +storj.io/eventkit v0.0.0-20260716074419-6861a92e2aa5/go.mod h1:RdWvp249AGm9FZnVKP88y3hFa7vlx9Jf3Cy1P+4EXIU= +storj.io/infectious v0.0.2 h1:rGIdDC/6gNYAStsxsZU79D/MqFjNyJc1tsyyj9sTl7Q= +storj.io/infectious v0.0.2/go.mod h1:QEjKKww28Sjl1x8iDsjBpOM4r1Yp8RsowNcItsZJ1Vs= +storj.io/picobuf v0.0.4 h1:qswHDla+YZ2TovGtMnU4astjvrADSIz84FXRn0qgP6o= +storj.io/picobuf v0.0.4/go.mod h1:hSMxmZc58MS/2qSLy1I0idovlO7+6K47wIGUyRZa6mg= +storj.io/uplink v1.14.3 h1:b89nziD1JgiF6oya+kDSBsqWp9Dzp5ADCUdhA2e2zvs= +storj.io/uplink v1.14.3/go.mod h1:jAe47qR+gRnHW2+3THe4cWJBAj8ARZv4P9mguJRo7C4= diff --git a/internal/backends/all.go b/internal/backends/all.go new file mode 100644 index 0000000..90c2ec4 --- /dev/null +++ b/internal/backends/all.go @@ -0,0 +1,13 @@ +//go:build !slim + +// Package backends registers rclone storage backends by blank import. +// +// rclone resolves a remote by looking up its type in a registry that each +// backend populates from its own init(), so a backend that is not imported +// simply does not exist at runtime. The default build registers all of them, so +// any remote in the user's rclone.conf works and adding one later needs no +// rebuild. Cost is binary size: ~92 MB stripped, against ~50 MB for the slim +// build below. +package backends + +import _ "github.com/rclone/rclone/backend/all" diff --git a/internal/backends/slim.go b/internal/backends/slim.go new file mode 100644 index 0000000..d40903b --- /dev/null +++ b/internal/backends/slim.go @@ -0,0 +1,11 @@ +//go:build slim + +// Package backends registers rclone storage backends by blank import. +// +// The slim build registers only pikpak, the remote this tool was written +// against, trading generality for roughly half the binary size. A remote of any +// other type fails at resolution time with rclone's "didn't find backend" +// error, so build without the tag if the destination might change. +package backends + +import _ "github.com/rclone/rclone/backend/pikpak" diff --git a/internal/naming/naming.go b/internal/naming/naming.go new file mode 100644 index 0000000..629ffe5 --- /dev/null +++ b/internal/naming/naming.go @@ -0,0 +1,62 @@ +// Package naming builds the filename a media message is stored under. +// +// This package exists to have exactly one answer to "what is this file called". +// The shell pipeline it replaces had two, and they disagreed. `tdl chat export` +// wrote the raw Telegram filename into a JSON, while `tdl dl` ran that same name +// through its download template — whose default is +// +// {{ .DialogID }}_{{ .MessageID }}_{{ filenamify .FileName }} +// +// (tdl@v0.20.4/cmd/dl.go:50). `filenamify` rewrites characters a filesystem +// rejects and, incidentally, collapses any run of two or more '!' into one. So a +// message whose filename contained "!!" was checked for under one name and +// stored under another; the verifier never found it and re-fetched it on every +// pass, forever. That is not a hypothetical — it cost 966 MB per pass on one +// message in this repo's own archive. +// +// The rule here is therefore not "be careful to keep the two in sync". There is +// one function, called once per message, and the string it returns is used both +// to ask whether the file is already archived and to write it. A divergence +// between those two questions is not made unlikely; it is made unrepresentable. +package naming + +import ( + "strconv" + "strings" + + "github.com/iyear/tdl/core/tmedia" +) + +// Separator between the three fields of a stored filename. +const sep = "_" + +// For returns the archive filename for one media message: +// +// {DialogID}_{MessageID}_{FileName} +// +// The field layout matches tdl's default template, but FileName does not: tdl +// passes it through `filenamify` and this does not. That is a deliberate, +// recorded choice (see the plan's Phase 2 notes) — names stay as Telegram +// reports them rather than being rewritten — and it means a file the old shell +// pipeline stored under a filenamify-altered name will not be recognised here +// and will be fetched again. For this repo's archive that affects exactly one +// message out of 12,000, and it was already removed. +// +// FileName comes from tmedia, the same extractor tdl uses: a document's +// DocumentAttributeFilename, or a generated stable name when it has none +// (`.jpg` for a photo, `` for a document). +// +// The name is taken verbatim and is therefore NOT safe to join onto a path. +// DocumentAttributeFilename is set by whoever uploaded the file, so it can +// contain '/' or '..' and escape a staging directory. Callers that turn a name +// into a path must check containment themselves; doing it here would silently +// rewrite names and reintroduce exactly the two-derivations problem above. +func For(dialogID int64, messageID int, m *tmedia.Media) string { + var b strings.Builder + b.WriteString(strconv.FormatInt(dialogID, 10)) + b.WriteString(sep) + b.WriteString(strconv.Itoa(messageID)) + b.WriteString(sep) + b.WriteString(m.Name) + return b.String() +} diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go new file mode 100644 index 0000000..a14d86c --- /dev/null +++ b/internal/naming/naming_test.go @@ -0,0 +1,92 @@ +package naming + +import ( + "testing" + + "github.com/iyear/tdl/core/tmedia" +) + +// The format is a compatibility contract, not a style choice: ~15k files are +// already stored under it. Changing it makes every one of them look absent and +// re-downloads the entire archive. +func TestForMatchesStoredLayout(t *testing.T) { + tests := []struct { + name string + dialogID int64 + msgID int + file string + want string + }{ + { + name: "document with a filename attribute", + dialogID: 1234567890, + msgID: 14726, + file: "298.mp4", + want: "1234567890_14726_298.mp4", + }, + { + // The message that started this rewrite. Its name carries a doubled + // '!' which tdl's default template collapsed to one, via filenamify, + // while the export JSON kept both — the two derivations that never + // agreed. This package keeps the name as Telegram reports it, so the + // doubled '!' must survive. + name: "punctuation is preserved verbatim", + dialogID: 1234567890, + msgID: 4242, + file: "Pipe her!! And by her, we mean pipeperr! 1080p.mp4", + want: "1234567890_4242_Pipe her!! And by her, we mean pipeperr! 1080p.mp4", + }, + { + name: "photo gets tmedia's generated name", + dialogID: 1234567890, + msgID: 42, + file: "5901234567890123456.jpg", + want: "1234567890_42_5901234567890123456.jpg", + }, + { + name: "spaces and separators inside the filename are untouched", + dialogID: 1234567890, + msgID: 7, + file: "a_b c-d.e.mp4", + want: "1234567890_7_a_b c-d.e.mp4", + }, + { + name: "non-ascii is untouched", + dialogID: 1234567890, + msgID: 8, + file: "ünïcödé näme 🍓.mp4", + want: "1234567890_8_ünïcödé näme 🍓.mp4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := For(tt.dialogID, tt.msgID, &tmedia.Media{Name: tt.file}) + if got != tt.want { + t.Errorf("For() = %q, want %q", got, tt.want) + } + }) + } +} + +// Guards the reason the field layout is what it is. If this ever starts failing, +// tdl changed its default template and the compatibility note on For is stale. +func TestForKeepsCharactersTdlWouldRewrite(t *testing.T) { + // filenamify (tdl's default template applies it) collapses runs of '!' and + // replaces reserved characters. None of that may happen here. + for _, file := range []string{ + "double!!bang.mp4", + "a?b:c.mp4", + ".leading-dot.mp4", + "trailing!.mp4", + } { + got := For(1, 2, &tmedia.Media{Name: file}) + want := "1_2_" + file + if got != want { + t.Errorf("For(%q) = %q, want %q — a sanitiser crept in", file, got, want) + } + } +} + +// mediaNamed builds the only part of tmedia.Media these tests care about. +func mediaNamed(name string) *tmedia.Media { return &tmedia.Media{Name: name} } diff --git a/internal/naming/safe.go b/internal/naming/safe.go new file mode 100644 index 0000000..a4e7c72 --- /dev/null +++ b/internal/naming/safe.go @@ -0,0 +1,124 @@ +package naming + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/rclone/rclone/lib/encoder" +) + +// maxNameBytes is the longest stored name that can actually be written. +// +// NAME_MAX on Linux is 255 bytes for a single path component, but the name is +// not what lands on disk first: a download is written to name+".part" and +// renamed afterwards, so the suffix has to fit inside the limit too. Checking +// the bare 255 would pass a name whose part file then fails with ENAMETOOLONG — +// after the item had already been queued, which stalls the walk on the same +// message on every pass. +const maxNameBytes = 255 - len(PartSuffix) + +// PartSuffix marks a download still in flight. It lives here because Safe's +// length limit has to account for it. +const PartSuffix = ".part" + +// Safe reports whether a stored name can be joined onto a directory path. +// +// Names are kept exactly as Telegram reports them, and the filename part comes +// from DocumentAttributeFilename — an unconstrained UTF-8 string chosen by +// whoever uploaded the file. So a name may contain '/' or be "..", and +// filepath.Join would happily resolve either outside the staging directory. tdl +// never had to think about this because its default template runs the name +// through filenamify, which rewrites separators and leading dots away; storing +// names verbatim moves that responsibility here. +// +// The rule is deliberately strict rather than corrective: a name that is not a +// single path element is rejected, not rewritten. Rewriting is what produced two +// disagreeing derivations in the first place, and a rejected file is a visible +// problem where a silently renamed one is not. +func Safe(name string) error { + switch { + case name == "": + return fmt.Errorf("empty filename") + case len(name) > maxNameBytes: + // The one rejection that is about a limit rather than an escape, and the + // one that matters most: os.Create returns ENAMETOOLONG past this, so an + // over-long name would download-fail forever while verify kept reporting + // it absent — a loop that never terminates. tdl could not hit this + // because filenamify truncates to 100 runes; storing names verbatim + // removes that cap, so the limit has to be checked instead. + return fmt.Errorf("filename is %d bytes, over the %d-byte limit: %q", + len(name), maxNameBytes, name) + case strings.ContainsRune(name, 0): + return fmt.Errorf("filename contains a NUL byte: %q", name) + case name == "." || name == "..": + return fmt.Errorf("filename is a directory reference: %q", name) + case filepath.IsAbs(name): + return fmt.Errorf("filename is an absolute path: %q", name) + case name != filepath.Base(name): + // Catches embedded separators, trailing slashes, and any ".." segment, + // since Base of all of those differs from the original. + return fmt.Errorf("filename is not a single path element: %q", name) + case encoder.OS.FromStandardName(name) != name: + // rclone does not address files by the bytes on disk. Every name given + // to an Fs is run through the backend's encoder, and every name listed + // back is re-encoded to the standard set — neither of which os.OpenFile + // performs. So a name containing one of the characters those encoders + // rewrite is written verbatim, then looked up under a different string: + // the upload fails with "object not found" forever, or it succeeds and + // the index records a name the presence check will never match. + // + // That is the original bug exactly — one name derived two ways — with + // rclone's encoder in the place filenamify used to occupy. Rejecting + // rather than encoding is the same choice made everywhere else here: + // encoding would give the two derivations a chance to disagree again. + return fmt.Errorf("filename is rewritten by rclone's path encoder: %q", name) + case encoder.Standard.Encode(encoder.Standard.Decode(name)) != name: + // The listing side of the same problem, and it is not backend-specific: + // the re-encode to the standard set happens above the backend encoder, + // so it applies to every remote. Control characters and DEL are the + // common case, and both are trivially settable in a Telegram filename. + return fmt.Errorf("filename is rewritten when rclone lists it back: %q", name) + } + return nil +} + +// SplitStored recovers the message id from a stored name, reporting false when +// the name does not have the expected shape or belongs to another dialog. +// +// Diagnostics only — telling "absent" apart from "present under a different +// name" when reporting on a remote. It must never decide that the wanted file is +// present: a file whose id matches but whose name does not is a different file. +// remote.Index keeps its id-keyed map unexported and its only id-to-name route +// excludes the wanted name, so an id cannot yield "the file you asked for is +// here" — though a caller that deliberately looks up a different name will of +// course get an answer about that name. +func SplitStored(dialogID int64, name string) (messageID int, ok bool) { + prefix := strconv.FormatInt(dialogID, 10) + sep + rest, found := strings.CutPrefix(name, prefix) + if !found { + return 0, false + } + idStr, _, found := strings.Cut(rest, sep) + if !found { + return 0, false + } + // Reject anything Atoi would accept but For would never emit: a sign, or + // leading zeros. The id has to be the exact text For wrote. + if idStr == "" || idStr[0] == '0' { + return 0, false + } + for _, r := range idStr { + if r < '0' || r > '9' { + return 0, false + } + } + // Only an overflow can fail here: the loop above rejected non-digits and a + // leading zero, so anything that parses is already >= 1. + id, err := strconv.Atoi(idStr) + if err != nil { + return 0, false + } + return id, true +} diff --git a/internal/naming/safe_test.go b/internal/naming/safe_test.go new file mode 100644 index 0000000..766b873 --- /dev/null +++ b/internal/naming/safe_test.go @@ -0,0 +1,200 @@ +package naming + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// Names come from DocumentAttributeFilename, which whoever uploaded the file +// chose. Anything that is not a single path element must be refused before it +// reaches filepath.Join. +func TestSafeRejectsNamesThatEscapeADirectory(t *testing.T) { + bad := []struct { + name string + want string + }{ + {"", "empty"}, + {".", "directory reference"}, + {"..", "directory reference"}, + {"../escape.mp4", "single path element"}, + {"../../../.config/rclone/rclone.conf", "single path element"}, + {"sub/dir.mp4", "single path element"}, + {"trailing/", "single path element"}, + {"/etc/passwd", "absolute path"}, + {"/", "absolute path"}, + {"nul\x00byte.mp4", "NUL byte"}, + } + + for _, tt := range bad { + t.Run(tt.name, func(t *testing.T) { + err := Safe(tt.name) + if err == nil { + t.Fatalf("Safe(%q) = nil; this name escapes or breaks a path", tt.name) + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("Safe(%q) error = %v, want it to mention %q", tt.name, err, tt.want) + } + }) + } +} + +// Ordinary media names, including awkward but legal ones, must pass — a +// containment check that rejects real files is just a different outage. +func TestSafeAcceptsRealNames(t *testing.T) { + for _, name := range []string{ + "1234567890_4242_Pipe her!! And by her, we mean pipeperr! 1080p.mp4", + "1234567890_14726_298.mp4", + "1234567890_8_ünïcödé näme 🍓.mp4", + "1234567890_42_a?b:c.mp4", // reserved on Windows, fine here + "1234567890_7_...dots.mp4", // leading dots inside the field, not the name + "1234567890_9_-dash.mp4", + "1234567890_10_ leading-space.mp4", + } { + if err := Safe(name); err != nil { + t.Errorf("Safe(%q) = %v, want nil", name, err) + } + } +} + +// The property that actually matters: a name Safe accepts cannot, once joined, +// resolve outside the directory it was joined to. +func TestSafeNamesStayInsideTheStagingDirectory(t *testing.T) { + const staging = "/var/tmp/staging" + for _, name := range []string{ + "1234567890_1_ok.mp4", + "1234567890_2_..dots.mp4", + "1234567890_3_a..b.mp4", + } { + if err := Safe(name); err != nil { + t.Fatalf("Safe(%q) = %v, want nil", name, err) + } + joined := filepath.Clean(filepath.Join(staging, name)) + if filepath.Dir(joined) != staging { + t.Errorf("Join(%q, %q) = %q, which leaves the staging directory", staging, name, joined) + } + } +} + +func TestSplitStoredRoundTripsWhatForProduces(t *testing.T) { + const dialog = int64(1234567890) + for _, msgID := range []int{1, 42, 4242, 4246} { + name := For(dialog, msgID, mediaNamed("a_b!!.mp4")) + got, ok := SplitStored(dialog, name) + if !ok { + t.Errorf("SplitStored(%q) reported no match", name) + continue + } + if got != msgID { + t.Errorf("SplitStored(%q) = %d, want %d", name, got, msgID) + } + } +} + +func TestSplitStoredRejectsForeignNames(t *testing.T) { + const dialog = int64(1234567890) + for _, name := range []string{ + "999_42_other-dialog.mp4", // different dialog + "1234567890_notanumber_.mp4", // id is not a number + "1234567890_0_zero.mp4", // ids start at 1 + "1234567890_042_pad.mp4", // For never emits leading zeros + "1234567890_+42_sign.mp4", // nor a sign + "1234567890_-5_neg.mp4", + "1234567890_42", // no filename field + "1234567890", // no id field + "", + } { + if id, ok := SplitStored(dialog, name); ok { + t.Errorf("SplitStored(%q) = %d, true; want no match", name, id) + } + } +} + +// A dialog id that is a prefix of another must not match it. +func TestSplitStoredDoesNotMatchPrefixOverlap(t *testing.T) { + if id, ok := SplitStored(123456789, "1234567890_42_file.mp4"); ok { + t.Errorf("SplitStored matched a longer dialog id, got %d", id) + } +} + +// An over-long name is the one input that can hang a drive-until-complete loop: +// os.Create rejects it, so the download fails forever while verify keeps +// reporting it absent. It must be refused up front, not discovered per pass. +// +// The limit leaves room for the ".part" suffix, because that is what is opened +// first. A name that fits in NAME_MAX but whose part file does not would pass +// this check, be queued, and then fail to open every single pass. +func TestSafeRejectsNamesOverTheFilesystemLimit(t *testing.T) { + prefix := "1234567890_42_" + fill := maxNameBytes - len(prefix) + + atLimit := prefix + strings.Repeat("a", fill) + if err := Safe(atLimit); err != nil { + t.Errorf("Safe(%d bytes) = %v, want nil at exactly the limit", len(atLimit), err) + } + // The part file for a name at the limit must actually be creatable, which is + // the property the limit exists to guarantee. + if err := os.WriteFile(filepath.Join(t.TempDir(), atLimit+PartSuffix), nil, 0o600); err != nil { + t.Errorf("a name Safe accepted cannot be written as a part file: %v", err) + } + + overLimit := prefix + strings.Repeat("a", fill+1) + err := Safe(overLimit) + if err == nil { + t.Fatalf("Safe(%d bytes) = nil, want an error past the limit", len(overLimit)) + } + if !strings.Contains(err.Error(), fmt.Sprintf("over the %d-byte limit", maxNameBytes)) { + t.Errorf("error should name the limit, got: %v", err) + } + + // The limit is bytes, not runes: multi-byte names hit it sooner. + multibyte := prefix + strings.Repeat("é", 130) // 260 bytes of payload + if err := Safe(multibyte); err == nil { + t.Errorf("Safe(%d bytes, %d runes) = nil; the limit must count bytes", + len(multibyte), len([]rune(multibyte))) + } +} + +// rclone addresses files by an encoded name, not by the bytes os.OpenFile +// wrote. A name either encoder rewrites is the original two-derivations bug in +// a new place: written verbatim, then looked up or listed under a different +// string, so the file is re-fetched on every pass forever. +// +// These were confirmed against the real local backend before the check existed: +// NewObject on a written "a‛b.jpg" reported "object not found", and a written +// "a\nb.jpg" listed back as "a␊b.jpg". +func TestSafeRejectsNamesRcloneRewrites(t *testing.T) { + rejected := map[string]string{ + "the encoder's own escape character": "1234567890_42_a\u201bb.jpg", + "a symbol-for-control glyph": "1234567890_42_a\u2421b.jpg", + "a raw newline": "1234567890_42_a\nb.jpg", + "a raw DEL": "1234567890_42_a\x7fb.jpg", + "a raw control byte": "1234567890_42_a\x01b.jpg", + } + for label, name := range rejected { + t.Run(label, func(t *testing.T) { + if err := Safe(name); err == nil { + t.Errorf("Safe(%q) = nil; rclone rewrites this name", name) + } + }) + } + + // Rejecting too much would be its own bug: these are ordinary Telegram + // filenames and every one must survive. + accepted := []string{ + "1234567890_42_ünïcödé 12-08 🍓.mp4", + "1234567890_42_a#b%c!d[e]{f}.mp4", + "1234567890_42_ㅋㅋㅋ 😀.png", + "1234567890_42_a/b.jpg", // fullwidth solidus, not a separator + "1234567890_42_trailing. ", + "1234567890_42_'quoted' \"double\".mp4", + "1234567890_42_ünïcödé, spaces & commas.webm", + } + for _, name := range accepted { + if err := Safe(name); err != nil { + t.Errorf("Safe(%q) = %v, want nil for an ordinary filename", name, err) + } + } +} diff --git a/internal/naming/sole_source_test.go b/internal/naming/sole_source_test.go new file mode 100644 index 0000000..fe7f743 --- /dev/null +++ b/internal/naming/sole_source_test.go @@ -0,0 +1,75 @@ +package naming + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +// buildsAName matches the ways a stored filename would plausibly be assembled by +// hand: the format verbs ("%d_%d_%s" and relatives) and string concatenation +// around a bare "_" separator. +// +// This is a lint for known shapes, not a proof. A determined reimplementation — +// a strings.Builder copy of For, say — still slips through. It catches the +// realistic accident, which is someone reaching for Sprintf in a new file. +var buildsAName = regexp.MustCompile(`%[ds]_%[ds]|_%[ds]_|\+ *"_" *\+`) + +// The whole point of this package is that it is the only answer to "what is this +// file called". Nothing in the type system prevents a second place from +// formatting the same string, so the common ways of doing so are linted here. +// +// If this test fails, the fix is to call For (or MessageID) rather than to widen +// the pattern. The shell pipeline's bug was two independent name derivations that +// nothing forced to agree; a second one here would reintroduce it exactly. +func TestNamingIsTheSoleSourceOfFilenames(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("locate repo root: %v", err) + } + + var offenders []string + err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + // Skip VCS and anything vendored; only our own source counts. + switch d.Name() { + case ".git", "vendor", "plans", "staging": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + // This package is the one place allowed to build the name. + if filepath.Dir(path) == filepath.Join(root, "internal", "naming") { + return nil + } + + src, err := os.ReadFile(path) + if err != nil { + return err + } + for i, line := range strings.Split(string(src), "\n") { + if buildsAName.MatchString(line) { + rel, _ := filepath.Rel(root, path) + offenders = append(offenders, rel+":"+strconv.Itoa(i+1)+": "+strings.TrimSpace(line)) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk source tree: %v", err) + } + + if len(offenders) > 0 { + t.Errorf("filenames must only be built by naming.For; found %d other place(s):\n %s", + len(offenders), strings.Join(offenders, "\n ")) + } +} diff --git a/internal/pipeline/download.go b/internal/pipeline/download.go new file mode 100644 index 0000000..daa3718 --- /dev/null +++ b/internal/pipeline/download.go @@ -0,0 +1,200 @@ +package pipeline + +import ( + "context" + "errors" + "fmt" + "iter" + "os" + "path/filepath" + "strings" + "sync/atomic" + + "github.com/iyear/tdl/core/dcpool" + "github.com/iyear/tdl/core/downloader" + + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// DownloadOptions configures a download run. +type DownloadOptions struct { + Pool dcpool.Pool + Staging string + Threads int // connections per file + Limit int // files in flight + Takeout bool // use a takeout session, as `tdl dl --takeout` does + + // Events, when set, receives the run's per-item lifecycle. It is invoked + // from download worker goroutines, so it must be cheap and safe to call + // concurrently. + Events Events + + // acquire reserves staging space before a download starts, blocking until + // there is room. Unset means no bound. release hands a reservation back for + // an item that never reaches a download. + acquire func(context.Context, int64) error + release func(int64) + + // stop, when set, ends iteration cleanly from another goroutine — used to + // halt downloads once the destination has stopped accepting uploads. + stop *atomic.Bool + // onReady hands a completed file to the upload leg; onFailed says nothing + // was staged, so whatever acquire reserved must be given back. + onReady func(tgsource.Item) + onFailed func(tgsource.Item) +} + +// Download fetches every item in seq into the staging directory. +// +// Each file is written to .part and renamed to only once its size +// matches what Telegram reported, so a name without the suffix is always a whole +// file. Uploads are driven by completion rather than by scanning for that, but +// the invariant still matters: it is what makes a leftover file from an +// interrupted run safe to keep and a leftover .part safe to delete. The shell +// pipeline could only approximate it with a filename convention plus an age +// guard, because it could not see inside tdl. +// +// A failed item does not abort the run: it is recorded in the returned outcomes +// and the rest continue, matching what a partial `tdl dl` pass did. +func Download(ctx context.Context, seq iter.Seq2[tgsource.Item, error], o DownloadOptions) ([]Outcome, Stats, error) { + if o.Threads <= 0 { + o.Threads = 4 + } + if o.Limit <= 0 { + o.Limit = 2 + } + if err := os.MkdirAll(o.Staging, 0o755); err != nil { + return nil, Stats{}, fmt.Errorf("create staging directory: %w", err) + } + + it := newElemIter(seq, o.Staging, o.Takeout) + it.acquire = o.acquire + it.release = o.release + if o.stop != nil { + it.stopped = o.stop + } + defer func() { _ = it.Close() }() + + prog := newProgress(func(e *elem, err error) error { + ferr := finish(o.Staging, e, err) + if err == nil && ferr == nil { + if o.onReady != nil { + o.onReady(e.item) + } + return nil + } + if o.onFailed != nil { + o.onFailed(e.item) + } + return ferr + }, o.Events) + + err := downloader.New(downloader.Options{ + Pool: o.Pool, + Threads: o.Threads, + Iter: it, + Progress: prog, + }).Download(ctx, o.Limit) + + outcomes, stats := prog.results() + // The iterator's failure is read only now, after Download has joined every + // worker. Reporting it through Iter.Err would have made Download skip that + // join entirely. + if err == nil { + err = it.failure + } + // Skipped items are reported alongside whatever else happened rather than + // instead of it: the run did real work, and the caller still needs to know + // these messages were never attempted. + return outcomes, stats, errors.Join(append([]error{err}, it.skipped...)...) +} + +// finish closes a downloaded file and either promotes it or removes it. +// +// The size on disk is checked against the size Telegram reported, and that check +// is not belt-and-braces — it is the only reliable failure signal available. +// core's Download swallows non-cancellation errors: it logs them and returns +// nil, and OnDone is deferred on that named return, so a failed transfer arrives +// here indistinguishable from a successful one (downloader.go:47-60). Trusting +// the error alone would promote a truncated file to its final name, and the +// upload leg would archive it as complete. +// +// A partial file is deleted rather than kept: the downloader exposes no resume +// offset, so a leftover .part could never be continued, and leaving one behind +// would only invite a later run to mistake it for progress. +func finish(staging string, e *elem, downloadErr error) error { + part := partPath(staging, e.item) + + if cerr := e.file.Close(); cerr != nil && downloadErr == nil { + downloadErr = cerr + } + + if downloadErr == nil { + if err := checkSize(part, e.item.Size()); err != nil { + downloadErr = err + } + } + + if downloadErr != nil { + if rerr := os.Remove(part); rerr != nil && !os.IsNotExist(rerr) { + return fmt.Errorf("remove partial %q: %w", part, rerr) + } + // Returned so the caller records a failure even when the downloader + // claimed success; otherwise a short file would vanish silently and the + // run would report itself complete. + return downloadErr + } + + if err := os.Rename(part, finalPath(staging, e.item)); err != nil { + // The part file goes too. The caller treats this as a failure and hands + // the byte reservation back, so leaving the file on disk would put the + // staging cap permanently over-committed by its size. + if rerr := os.Remove(part); rerr != nil && !os.IsNotExist(rerr) { + return errors.Join(fmt.Errorf("promote %q: %w", part, err), + fmt.Errorf("and it is still in staging: %w", rerr)) + } + return fmt.Errorf("promote %q: %w", part, err) + } + return nil +} + +// checkSize compares what landed on disk against what Telegram said the file is. +func checkSize(path string, want int64) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat downloaded file: %w", err) + } + if info.Size() != want { + return fmt.Errorf("short download: got %d bytes, expected %d", info.Size(), want) + } + return nil +} + +// SweepPartials removes leftover .part files from an earlier run. +// +// They cannot be resumed — core's downloader takes no starting offset — so the +// only options are delete or accumulate, and accumulating fills the disk with +// fragments no run will ever finish. +func SweepPartials(staging string) (int, error) { + entries, err := os.ReadDir(staging) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("read staging directory: %w", err) + } + + removed := 0 + var errs []error + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), partSuffix) { + continue + } + if err := os.Remove(filepath.Join(staging, entry.Name())); err != nil { + errs = append(errs, err) + continue + } + removed++ + } + return removed, errors.Join(errs...) +} diff --git a/internal/pipeline/download_test.go b/internal/pipeline/download_test.go new file mode 100644 index 0000000..691fbdb --- /dev/null +++ b/internal/pipeline/download_test.go @@ -0,0 +1,431 @@ +package pipeline + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gotd/td/tg" + "github.com/iyear/tdl/core/downloader" + "github.com/iyear/tdl/core/tmedia" + + "github.com/tiennm99dev/telegram-exporter/internal/naming" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +func testItem(t *testing.T, msgID int, file string, size int64) tgsource.Item { + t.Helper() + m := &tmedia.Media{ + Name: file, + Size: size, + DC: 2, + InputFileLoc: &tg.InputDocumentFileLocation{ID: int64(msgID)}, + } + return tgsource.Item{ + DialogID: 1234567890, + MessageID: msgID, + Name: naming.For(1234567890, msgID, m), + Media: m, + } +} + +// openElem mimics what elemIter does, so finish can be tested without a network. +func openElem(t *testing.T, staging string, it tgsource.Item) *elem { + t.Helper() + f, err := os.OpenFile(partPath(staging, it), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatalf("open part file: %v", err) + } + return &elem{item: it, file: f} +} + +// A name without the suffix must always be a whole file: that is the property +// the upload half relies on to treat "exists" as "finished", replacing the +// filename-convention-plus-age-guard the shell pipeline needed. +func TestFinishPromotesOnlyOnSuccess(t *testing.T) { + staging := t.TempDir() + it := testItem(t, 1, "video.mp4", 100) + + e := openElem(t, staging, it) + if _, err := e.file.WriteAt(make([]byte, it.Size()), 0); err != nil { + t.Fatalf("write: %v", err) + } + if err := finish(staging, e, nil); err != nil { + t.Fatalf("finish: %v", err) + } + + if _, err := os.Stat(finalPath(staging, it)); err != nil { + t.Errorf("final file missing after a successful download: %v", err) + } + if _, err := os.Stat(partPath(staging, it)); !os.IsNotExist(err) { + t.Errorf("part file still present after promotion") + } +} + +func TestFinishRemovesPartialOnFailure(t *testing.T) { + staging := t.TempDir() + it := testItem(t, 2, "video.mp4", 100) + + e := openElem(t, staging, it) + if _, err := e.file.WriteAt([]byte("half"), 0); err != nil { + t.Fatalf("write: %v", err) + } + // finish returns the failure rather than swallowing it, so the caller + // records the item as failed instead of quietly counting it done. + want := errors.New("connection reset") + if err := finish(staging, e, want); !errors.Is(err, want) { + t.Fatalf("finish = %v, want the download error returned", err) + } + + // Neither file may survive: a partial promoted to the final name would be + // indistinguishable from a complete download and would never be repaired. + if _, err := os.Stat(partPath(staging, it)); !os.IsNotExist(err) { + t.Errorf("part file survived a failed download") + } + if _, err := os.Stat(finalPath(staging, it)); !os.IsNotExist(err) { + t.Errorf("a failed download was promoted to the final name") + } +} + +func TestSweepPartialsRemovesOnlyPartFiles(t *testing.T) { + staging := t.TempDir() + keep := filepath.Join(staging, "1234567890_1_done.mp4") + drop := filepath.Join(staging, "1234567890_2_wip.mp4"+partSuffix) + legacy := filepath.Join(staging, "1234567890_3_old.mp4.tmp") + + for _, p := range []string{keep, drop, legacy} { + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatalf("seed %q: %v", p, err) + } + } + + removed, err := SweepPartials(staging) + if err != nil { + t.Fatalf("SweepPartials: %v", err) + } + if removed != 1 { + t.Errorf("removed = %d, want 1", removed) + } + if _, err := os.Stat(keep); err != nil { + t.Errorf("a completed file was swept: %v", err) + } + // tdl's own suffix is left alone: a shared staging directory during the + // cutover may hold files a legacy run is still writing. + if _, err := os.Stat(legacy); err != nil { + t.Errorf("a legacy tdl .tmp file was swept: %v", err) + } +} + +func TestSweepPartialsOnMissingDirectory(t *testing.T) { + removed, err := SweepPartials(filepath.Join(t.TempDir(), "absent")) + if err != nil { + t.Errorf("SweepPartials on a missing directory = %v, want nil", err) + } + if removed != 0 { + t.Errorf("removed = %d, want 0", removed) + } +} + +// An unwritable name must be refused with a message naming the message, rather +// than surfacing as a bare os.Create failure later — and it must be skipped, not +// treated as the end of the walk. One hostile filename cannot be allowed to +// strand every message behind it. +func TestElemIterSkipsUnsafeNamesAndKeepsGoing(t *testing.T) { + staging := t.TempDir() + bad := testItem(t, 7, "../../escape.conf", 10) + good := testItem(t, 8, "fine.mp4", 10) + + seq := func(yield func(tgsource.Item, error) bool) { + if !yield(bad, nil) { + return + } + yield(good, nil) + } + it := newElemIter(seq, staging, false) + defer func() { _ = it.Close() }() + + if !it.Next(t.Context()) { + t.Fatal("an unsafe name ended the walk; the item after it was never reached") + } + if got := it.current.item.MessageID; got != 8 { + t.Fatalf("Next yielded message %d, want the item after the unsafe one", got) + } + if it.Next(t.Context()) { + t.Error("iterator produced a third item") + } + if it.failure != nil { + t.Errorf("a skipped item must not fail the run, got: %v", it.failure) + } + if len(it.skipped) != 1 { + t.Fatalf("skipped = %d, want 1", len(it.skipped)) + } + if !strings.Contains(it.skipped[0].Error(), "message 7") { + t.Errorf("the skip should name the message, got: %v", it.skipped[0]) + } +} + +// The skipped items still have to reach the caller: the run did work, but these +// messages were never attempted and nothing else would say so. +func TestDownloadReportsSkippedItems(t *testing.T) { + staging := t.TempDir() + bad := testItem(t, 7, "../../escape.conf", 10) + + seq := func(yield func(tgsource.Item, error) bool) { yield(bad, nil) } + it := newElemIter(seq, staging, false) + defer func() { _ = it.Close() }() + + for it.Next(t.Context()) { + } + err := errors.Join(append([]error{nil}, it.skipped...)...) + if err == nil { + t.Fatal("skipped items produced no error for the caller") + } + if !strings.Contains(err.Error(), "message 7") { + t.Errorf("error should name the skipped message, got: %v", err) + } +} + +func TestElemIterOpensPartFilesAndPropagatesWalkErrors(t *testing.T) { + staging := t.TempDir() + good := testItem(t, 1, "a.mp4", 10) + + t.Run("opens a part file", func(t *testing.T) { + seq := func(yield func(tgsource.Item, error) bool) { yield(good, nil) } + it := newElemIter(seq, staging, true) + defer func() { _ = it.Close() }() + + if !it.Next(t.Context()) { + t.Fatalf("Next() = false, Err() = %v", it.Err()) + } + e := it.Value() + if !e.AsTakeout() { + t.Error("AsTakeout() = false, want the configured value") + } + if e.File().Size() != 10 || e.File().DC() != 2 { + t.Errorf("File() = size %d dc %d, want 10 and 2", e.File().Size(), e.File().DC()) + } + if _, err := os.Stat(partPath(staging, good)); err != nil { + t.Errorf("part file was not created: %v", err) + } + }) + + t.Run("propagates a walk error", func(t *testing.T) { + want := errors.New("history walk failed") + seq := func(yield func(tgsource.Item, error) bool) { yield(tgsource.Item{}, want) } + it := newElemIter(seq, staging, false) + defer func() { _ = it.Close() }() + + if it.Next(t.Context()) { + t.Fatal("Next() = true after a walk error") + } + if !errors.Is(it.failure, want) { + t.Errorf("failure = %v, want %v", it.failure, want) + } + }) +} + +// Byte accounting has to treat ProgressState as a running total, not a delta, +// or the aggregate drifts upward on every callback. +func TestProgressAccountsBytesAsRunningTotals(t *testing.T) { + staging := t.TempDir() + it := testItem(t, 1, "a.mp4", 100) + e := openElem(t, staging, it) + + p := newProgress(func(*elem, error) error { return nil }, nil) + p.OnAdd(e) + p.OnDownload(e, progressState(40)) + p.OnDownload(e, progressState(100)) + p.OnDone(e, nil) + + outcomes, stats := p.results() + if stats.BytesDone != 100 { + t.Errorf("BytesDone = %d, want 100 (states are totals, not deltas)", stats.BytesDone) + } + if stats.BytesTotal != 100 { + t.Errorf("BytesTotal = %d, want 100", stats.BytesTotal) + } + if stats.Done != 1 || stats.Failed != 0 { + t.Errorf("Done/Failed = %d/%d, want 1/0", stats.Done, stats.Failed) + } + if len(outcomes) != 1 || outcomes[0].Err != nil { + t.Errorf("outcomes = %+v, want one success", outcomes) + } +} + +func TestProgressRecordsFailuresWithoutAborting(t *testing.T) { + staging := t.TempDir() + ok := testItem(t, 1, "a.mp4", 10) + bad := testItem(t, 2, "b.mp4", 10) + + p := newProgress(func(*elem, error) error { return nil }, nil) + p.OnDone(openElem(t, staging, ok), nil) + p.OnDone(openElem(t, staging, bad), errors.New("flood wait")) + + outcomes, stats := p.results() + if stats.Done != 1 || stats.Failed != 1 { + t.Errorf("Done/Failed = %d/%d, want 1/1", stats.Done, stats.Failed) + } + if len(outcomes) != 2 { + t.Fatalf("outcomes = %d, want 2 — a failure must be recorded, not dropped", len(outcomes)) + } +} + +func progressState(done int64) downloader.ProgressState { + return downloader.ProgressState{Downloaded: done, Total: 100} +} + +// core's Download logs a failed transfer and returns nil, and OnDone is deferred +// on that named return — so a truncated file reaches finish claiming success. +// The size check is the only thing standing between that and an archived +// fragment, so it is tested directly. +func TestFinishRejectsShortDownloadDespiteNilError(t *testing.T) { + staging := t.TempDir() + it := testItem(t, 3, "video.mp4", 1000) + + e := openElem(t, staging, it) + if _, err := e.file.WriteAt(make([]byte, 400), 0); err != nil { + t.Fatalf("write: %v", err) + } + + // nil, exactly as the downloader reports a failed transfer. + err := finish(staging, e, nil) + if err == nil { + t.Fatal("finish accepted a 400-byte file for a 1000-byte item") + } + if !strings.Contains(err.Error(), "short download") { + t.Errorf("error should name the short download, got: %v", err) + } + if _, err := os.Stat(finalPath(staging, it)); !os.IsNotExist(err) { + t.Error("a truncated file was promoted to its final name") + } + if _, err := os.Stat(partPath(staging, it)); !os.IsNotExist(err) { + t.Error("the truncated part file was left behind") + } +} + +// The size check must not reject a genuinely complete file. +func TestFinishAcceptsExactSize(t *testing.T) { + staging := t.TempDir() + it := testItem(t, 4, "exact.mp4", 2048) + + e := openElem(t, staging, it) + if _, err := e.file.WriteAt(make([]byte, 2048), 0); err != nil { + t.Fatalf("write: %v", err) + } + if err := finish(staging, e, nil); err != nil { + t.Fatalf("finish rejected an exact-size file: %v", err) + } + info, err := os.Stat(finalPath(staging, it)) + if err != nil { + t.Fatalf("final file missing: %v", err) + } + if info.Size() != 2048 { + t.Errorf("final size = %d, want 2048", info.Size()) + } +} + +// Err must always report nil, however badly iteration went. +// +// core's Download skips wg.Wait entirely when Iter.Err is non-nil +// (downloader.go:65-68), returning while its workers are still running. The +// pipeline closes its upload channel as soon as Download returns, so a non-nil +// Err here means workers send on a closed channel and the process panics — +// on every Ctrl-C, since cancellation is one of the ways iteration stops. +func TestElemIterNeverReportsErrToTheDownloader(t *testing.T) { + staging := t.TempDir() + + cases := map[string]func() *elemIter{ + "walk error": func() *elemIter { + seq := func(yield func(tgsource.Item, error) bool) { + yield(tgsource.Item{}, errors.New("boom")) + } + return newElemIter(seq, staging, false) + }, + "cancelled": func() *elemIter { + seq := func(yield func(tgsource.Item, error) bool) { + yield(testItem(t, 2, "a.mp4", 10), nil) + } + return newElemIter(seq, staging, false) + }, + } + + for name, build := range cases { + t.Run(name, func(t *testing.T) { + it := build() + defer func() { _ = it.Close() }() + + ctx := t.Context() + if name == "cancelled" { + cancelled, cancel := context.WithCancel(ctx) + cancel() + ctx = cancelled + } + + for it.Next(ctx) { + } + if err := it.Err(); err != nil { + t.Errorf("Err() = %v, want nil — a non-nil Err makes Download abandon its workers", err) + } + if it.failure == nil { + t.Error("the real failure was not stashed") + } + }) + } +} + +// A caller-set stop flag ends iteration without looking like a failure, which is +// how the circuit breaker halts downloads. +func TestElemIterStopsOnFlagWithoutRecordingFailure(t *testing.T) { + staging := t.TempDir() + seq := func(yield func(tgsource.Item, error) bool) { + for i := 1; i <= 5; i++ { + if !yield(testItem(t, i, "a.mp4", 10), nil) { + return + } + } + } + it := newElemIter(seq, staging, false) + defer func() { _ = it.Close() }() + + if !it.Next(t.Context()) { + t.Fatal("first Next() = false") + } + it.stopped.Store(true) + + if it.Next(t.Context()) { + t.Error("Next() = true after the stop flag was set") + } + if it.failure != nil { + t.Errorf("failure = %v, want nil — stopping is not a failure", it.failure) + } +} + +// A reservation must come back when the item never reaches a download, or the +// budget shrinks by that much for the rest of the run. +func TestElemIterReturnsReservationWhenOpenFails(t *testing.T) { + // A staging path that is a file, not a directory, makes OpenFile fail. + staging := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(staging, []byte("x"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + seq := func(yield func(tgsource.Item, error) bool) { + yield(testItem(t, 1, "a.mp4", 4096), nil) + } + it := newElemIter(seq, staging, false) + defer func() { _ = it.Close() }() + + var acquired, released int64 + it.acquire = func(_ context.Context, n int64) error { acquired += n; return nil } + it.release = func(n int64) { released += n } + + if it.Next(t.Context()) { + t.Fatal("Next() succeeded with an unusable staging directory") + } + if acquired != released { + t.Errorf("acquired %d bytes but released %d — the reservation leaked", acquired, released) + } +} diff --git a/internal/pipeline/elem.go b/internal/pipeline/elem.go new file mode 100644 index 0000000..7e24c63 --- /dev/null +++ b/internal/pipeline/elem.go @@ -0,0 +1,194 @@ +// Package pipeline drives the download half of an archive run. +package pipeline + +import ( + "context" + "fmt" + "io" + "iter" + "os" + "path/filepath" + "sync/atomic" + + "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. +// +// It is defined in naming because naming.Safe's length limit has to leave room +// for it — a name that fits but whose part file does not would pass the check +// and then fail to open, stalling the walk on that message forever. +const partSuffix = naming.PartSuffix + +// 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) + stopPull 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 (downloader.go:38), so a blocked Next stops new downloads starting + // without stopping the uploads that free the space. + acquire func(context.Context, int64) error + // release hands a reservation back when the item never reaches a download. + release func(int64) + + current *elem + + // failure holds why iteration stopped, and Err deliberately does not return + // it. core's Download skips wg.Wait entirely when Iter.Err is non-nil + // (downloader.go:65-68), abandoning workers that are still running — which + // would let this package tear down its upload channel underneath them. So + // Next reports "no more items" and the caller reads failure() afterwards, + // guaranteeing every worker has finished first. + failure error + // stopped ends iteration without an error, for a caller that has decided the + // run cannot usefully continue. Supplied by the caller so it can be set from + // another goroutine without racing on the iterator itself. + stopped *atomic.Bool + + // skipped collects items refused before any download was attempted. They do + // not stop the run: one message with a hostile filename must not be able to + // strand every message behind it, which is what ending iteration would mean. + skipped []error + + // opened records every file handle. finish closes each one on the normal + // path, so this is not what keeps descriptors from leaking; it is the + // backstop for items that were opened but never reached finish, which is + // what an aborted iteration leaves behind. + opened []*os.File +} + +func newElemIter(seq iter.Seq2[tgsource.Item, error], staging string, takeout bool) *elemIter { + next, stopPull := iter.Pull2(seq) + return &elemIter{ + next: next, + stopPull: stopPull, + staging: staging, + takeout: takeout, + stopped: new(atomic.Bool), + } +} + +func (i *elemIter) Next(ctx context.Context) bool { + var item tgsource.Item + for { + if i.failure != nil || i.stopped.Load() { + return false + } + if err := ctx.Err(); err != nil { + i.failure = err + return false + } + + var ( + err error + ok bool + ) + item, err, ok = i.next() + if !ok { + return false + } + if err != nil { + i.failure = err + return false + } + + // A name that cannot be written is refused here rather than left to + // os.Create, and refusing it skips the item rather than ending the walk. + // selectTodo already filters these out on the CLI path, so reaching this + // is either a second caller or a gap there; in both cases one unwritable + // name must not strand the rest of the chat behind it. + if err := naming.Safe(item.Name); err != nil { + if len(i.skipped) < maxRecordedErrors { + i.skipped = append(i.skipped, fmt.Errorf("message %d: %w", item.MessageID, err)) + } + continue + } + break + } + + if i.acquire != nil { + if err := i.acquire(ctx, item.Size()); err != nil { + i.failure = err + return false + } + } + + f, err := os.OpenFile(partPath(i.staging, item), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + // The reservation is handed back here because this item will never + // reach a download, so no OnDone will ever release it for us. + if i.release != nil { + i.release(item.Size()) + } + i.failure = 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 } + +// Err always reports nil so core's Download reaches wg.Wait and joins its +// workers. See the failure field. +func (i *elemIter) Err() error { return nil } + +// Close releases the pull iterator and every file the walk opened. +func (i *elemIter) Close() error { + i.stopPull() + var firstErr error + for _, f := range i.opened { + if err := f.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/internal/pipeline/events.go b/internal/pipeline/events.go new file mode 100644 index 0000000..8356ce6 --- /dev/null +++ b/internal/pipeline/events.go @@ -0,0 +1,38 @@ +package pipeline + +import "github.com/tiennm99dev/telegram-exporter/internal/tgsource" + +// Events receives a run's per-item lifecycle. +// +// Aggregate Stats alone cannot say what a run is doing right now — which files +// are moving, how far along each is, whether the time is going into downloads or +// uploads. On an archive that runs for hours those are the questions being +// asked, and a single "26/2613 done" line answers none of them. +// +// Every method is called from a worker goroutine, several at once, and some of +// them fire per network chunk. Implementations must be safe to call +// concurrently and must not block: a slow renderer would throttle the transfers +// it is describing. +type Events interface { + // Stats reports the running totals. + Stats(Stats) + + DownloadStart(it tgsource.Item) + // DownloadBytes reports the total written for it so far, not a delta. + DownloadBytes(it tgsource.Item, done int64) + DownloadDone(it tgsource.Item, err error) + + UploadStart(it tgsource.Item) + UploadDone(it tgsource.Item, err error) +} + +// nopEvents is used when a caller wants no reporting, so nothing on the hot +// path has to nil-check. +type nopEvents struct{} + +func (nopEvents) Stats(Stats) {} +func (nopEvents) DownloadStart(tgsource.Item) {} +func (nopEvents) DownloadBytes(tgsource.Item, int64) {} +func (nopEvents) DownloadDone(tgsource.Item, error) {} +func (nopEvents) UploadStart(tgsource.Item) {} +func (nopEvents) UploadDone(tgsource.Item, error) {} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go new file mode 100644 index 0000000..def6a24 --- /dev/null +++ b/internal/pipeline/pipeline.go @@ -0,0 +1,264 @@ +package pipeline + +import ( + "context" + "errors" + "fmt" + "iter" + "os" + "path/filepath" + "sync" + "sync/atomic" + + "github.com/rclone/rclone/fs" + "golang.org/x/sync/semaphore" + + "github.com/iyear/tdl/core/dcpool" + + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// Options configures a full download-and-upload run. +type Options struct { + Pool dcpool.Pool + Dst fs.Fs + Staging string + + Threads int // connections per file + Limit int // files downloading at once + Uploads int // files uploading at once + Budget int64 // bytes allowed in staging at once; 0 means unbounded + + Confirm bool // re-state each uploaded object to prove its size + Takeout bool + + // MaxFailures trips the run after this many consecutive upload failures. + // Zero uses the shell pipeline's default of 5. + MaxFailures int + + // FreeBytes and MinFree stop the run when the destination fills mid-way. + // Both must be set for the check to happen; a backend that cannot report a + // quota counts as unlimited. + FreeBytes func(context.Context) (int64, bool) + MinFree int64 + + // Events, when set, receives the run's per-item lifecycle: which files are + // downloading, which are uploading, and how far along each one is. + Events Events +} + +// Result is what a run achieved. +type Result struct { + Stats Stats + Outcomes []Outcome +} + +// Failed lists the items that did not make it to the remote. +func (r Result) Failed() []Outcome { + var out []Outcome + for _, o := range r.Outcomes { + if o.Err != nil { + out = append(out, o) + } + } + return out +} + +// ErrDestinationFailing marks a run stopped because the destination refused +// upload after upload. It is distinct from an ordinary upload failure because +// the right response differs: a transient error is worth retrying, while a +// remote that is full, unreachable, or refusing credentials will refuse the next +// pass identically, and a driver that retries walks the whole chat and downloads +// gigabytes for nothing every time. +var ErrDestinationFailing = errors.New("destination stopped accepting uploads") + +// maxRecordedErrors bounds what a run keeps from a failing remote. Past this, +// the pattern is established and joining thousands of identical strings just +// makes the final message unreadable. +const maxRecordedErrors = 10 + +// download is the download step, indirected so a test can drive Run's +// composition without a live Telegram connection. What that buys is coverage of +// the three properties Run alone is responsible for — that the upload channel is +// closed only after every send, that the byte budget balances across a whole +// run, and that a tripped breaker still terminates — none of which the pieces +// can be tested for individually. +var download = Download + +// Run downloads every item and uploads each one as it completes. +// +// This is the whole reason for the rewrite. run.sh could not see inside tdl, so +// it inferred completion from a filename suffix plus a file's age, polled +// `du -sk` every ten seconds, and enforced its disk cap by sending SIGSTOP and +// SIGCONT to the tdl process. None of that exists here. Completion is a function +// returning. The cap is a semaphore: a download acquires its own size before +// starting and releases it once the file is off local disk, so when the remote +// is slow the acquire blocks and downloads pause on their own. +// +// Blocking in the iterator is safe, but not for the reason it first appears. +// core's Download calls Iter.Next from its dispatch loop while workers run in an +// errgroup, so a blocked Next stalls new work without stopping the uploads that +// free the budget. What is *not* safe is reporting an error through Iter.Err: +// Download then returns without joining its workers (downloader.go:65-68), and +// tearing down the upload channel underneath them panics. So elemIter always +// reports a nil Err and stashes the real one, which Download's return +// guarantees is safe to read. +func Run(ctx context.Context, seq iter.Seq2[tgsource.Item, error], o Options) (Result, error) { + if o.Uploads <= 0 { + o.Uploads = 1 + } + if o.MaxFailures <= 0 { + o.MaxFailures = 5 + } + if o.Events == nil { + o.Events = nopEvents{} + } + + local, err := fs.NewFs(ctx, o.Staging) + if err != nil { + return Result{}, fmt.Errorf("open staging directory as a filesystem: %w", err) + } + up := &uploader{local: local, dst: o.Dst, confirm: o.Confirm} + + budget := newBudget(o.Budget) + guard := newSpaceGuard(o.FreeBytes, o.MinFree) + uploads := make(chan tgsource.Item, o.Uploads) + + var ( + wg sync.WaitGroup + mu sync.Mutex + errs []error + nErrs int + streak int + tripped bool + ) + + // stopDownloads ends the download side once the destination has stopped + // accepting work. It stops the iterator rather than cancelling a context, + // because cancelling only the uploads would leave downloads running at full + // speed against a remote that is refusing them — every file staying on disk, + // every reservation released on the way out. A broken remote would fill the + // local disk faster than a working one does. + stopDownloads := new(atomic.Bool) + + for range o.Uploads { + wg.Add(1) + go func() { + defer wg.Done() + for it := range uploads { + // A full destination is not worth another multi-gigabyte + // attempt, so the file is dropped rather than uploaded. + err := guard.check(ctx) + if err == nil { + o.Events.UploadStart(it) + err = up.upload(ctx, it) + o.Events.UploadDone(it, err) + } + + if err != nil { + // MoveFile leaves the local copy in place when it fails, so + // the reservation cannot simply be handed back — the bytes + // are still on disk. Removing the file first is what keeps + // the cap honest. + if rerr := os.Remove(filepath.Join(o.Staging, it.Name)); rerr != nil && !os.IsNotExist(rerr) { + err = errors.Join(err, fmt.Errorf("and it is still in staging: %w", rerr)) + } + } + budget.release(it.Size()) + + mu.Lock() + if err != nil { + if nErrs < maxRecordedErrors { + errs = append(errs, err) + } + nErrs++ + streak++ + // A full remote trips immediately: unlike a transient + // error, waiting for a streak just wastes the downloads. + if (streak >= o.MaxFailures || errors.Is(err, ErrDestinationFailing)) && !tripped { + tripped = true + stopDownloads.Store(true) + } + } else { + streak = 0 + } + mu.Unlock() + } + }() + } + + dlOutcomes, stats, dlErr := download(ctx, seq, DownloadOptions{ + Pool: o.Pool, + Staging: o.Staging, + Threads: o.Threads, + Limit: o.Limit, + Takeout: o.Takeout, + Events: o.Events, + acquire: budget.acquire, + release: budget.release, + onReady: func(it tgsource.Item) { uploads <- it }, + onFailed: func(it tgsource.Item) { + // Nothing was staged, so the reservation has to come back here + // instead of from an upload that will never happen. + budget.release(it.Size()) + }, + stop: stopDownloads, + }) + + // Safe only because Download joined its workers, which is guaranteed by + // elemIter.Err always being nil. + close(uploads) + wg.Wait() + + mu.Lock() + joined := errors.Join(errs...) + trip, total := tripped, nErrs + mu.Unlock() + + // Both halves are reported. On the most common failure path — Ctrl-C — the + // download side returns context.Canceled while the upload workers drain + // whatever is still queued, fail every one of them against the cancelled + // context, and delete the staged file each time. Returning only the download + // error would leave the operator with "context canceled" and no sign that + // finished files had been discarded. + var upErr error + switch { + case trip: + upErr = fmt.Errorf("%w: stopped after %d upload failure(s): %w", + ErrDestinationFailing, total, joined) + case total > 0: + upErr = fmt.Errorf("%d upload(s) failed: %w", total, joined) + } + return Result{Stats: stats, Outcomes: dlOutcomes}, errors.Join(dlErr, upErr) +} + +// budget bounds how many bytes of downloaded-but-not-yet-uploaded data sit on +// local disk. A zero limit means no bound. +type budget struct{ sem *semaphore.Weighted } + +func newBudget(limit int64) *budget { + if limit <= 0 { + return &budget{} + } + return &budget{sem: semaphore.NewWeighted(limit)} +} + +func (b *budget) acquire(ctx context.Context, n int64) error { + if b.sem == nil { + return nil + } + // An item larger than the budget cannot be admitted, and semaphore.Acquire + // handles that by blocking until the context is cancelled rather than + // failing — so there is no error to surface and no guard to add here. The + // real protection is validateBudget refusing such a run before it starts. + if err := b.sem.Acquire(ctx, n); err != nil { + return fmt.Errorf("waiting for %d bytes of staging space: %w", n, err) + } + return nil +} + +func (b *budget) release(n int64) { + if b.sem != nil { + b.sem.Release(n) + } +} diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go new file mode 100644 index 0000000..e36271e --- /dev/null +++ b/internal/pipeline/pipeline_test.go @@ -0,0 +1,147 @@ +package pipeline + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/iyear/tdl/core/tmedia" + + "github.com/tiennm99dev/telegram-exporter/internal/naming" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// The budget is what replaces run.sh's du-polling and SIGSTOP/SIGCONT cap +// draining, so the property it has to hold is simple and worth pinning: the sum +// of outstanding reservations never exceeds the limit. +func TestBudgetBoundsOutstandingBytes(t *testing.T) { + const limit = 1000 + b := newBudget(limit) + ctx := t.Context() + + var ( + mu sync.Mutex + held int64 + peak int64 + wg sync.WaitGroup + acquires atomic.Int64 + ) + + for range 20 { + wg.Add(1) + go func() { + defer wg.Done() + const size = 300 + if err := b.acquire(ctx, size); err != nil { + t.Errorf("acquire: %v", err) + return + } + acquires.Add(1) + + mu.Lock() + held += size + if held > peak { + peak = held + } + mu.Unlock() + + time.Sleep(time.Millisecond) + + mu.Lock() + held -= size + mu.Unlock() + b.release(size) + }() + } + wg.Wait() + + if acquires.Load() != 20 { + t.Errorf("acquired %d times, want 20 — every item must eventually get through", acquires.Load()) + } + if peak > limit { + t.Errorf("peak outstanding = %d bytes, over the %d limit", peak, limit) + } +} + +// A zero limit means the operator asked for no cap; acquiring must not block or +// account, or an unbounded run would stall. +func TestBudgetUnboundedWhenLimitIsZero(t *testing.T) { + b := newBudget(0) + for range 5 { + if err := b.acquire(t.Context(), 1<<40); err != nil { + t.Fatalf("acquire on an unbounded budget: %v", err) + } + } + b.release(1 << 40) // must not panic +} + +// Cancelling must unblock a waiter rather than leaving the run wedged. +func TestBudgetAcquireHonoursCancellation(t *testing.T) { + b := newBudget(100) + if err := b.acquire(t.Context(), 100); err != nil { + t.Fatalf("first acquire: %v", err) + } + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- b.acquire(ctx, 100) }() + + // The second acquire cannot succeed while the first is outstanding. + select { + case err := <-done: + t.Fatalf("acquire succeeded with no space free: %v", err) + case <-time.After(50 * time.Millisecond): + } + + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Errorf("acquire error = %v, want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("cancelling did not unblock the waiter") + } +} + +// An item bigger than the whole budget can never be admitted. It must fail +// rather than hang, because a hang here looks exactly like a slow remote. +func TestBudgetRefusesAnItemLargerThanTheLimit(t *testing.T) { + b := newBudget(100) + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + + err := b.acquire(ctx, 5000) + if err == nil { + t.Fatal("acquire of an oversized item succeeded") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("acquire error = %v, want the deadline to surface", err) + } +} + +func TestResultFailedListsOnlyErrors(t *testing.T) { + r := Result{Outcomes: []Outcome{ + {Item: mustItem(1), Err: nil}, + {Item: mustItem(2), Err: errors.New("flood wait")}, + {Item: mustItem(3), Err: nil}, + {Item: mustItem(4), Err: errors.New("short download")}, + }} + failed := r.Failed() + if len(failed) != 2 { + t.Fatalf("Failed() = %d entries, want 2", len(failed)) + } + for _, f := range failed { + if f.Err == nil { + t.Errorf("Failed() returned a successful outcome: %+v", f) + } + } +} + +func mustItem(id int) tgsource.Item { + m := &tmedia.Media{Name: "f.mp4", Size: 10} + return tgsource.Item{DialogID: 1, MessageID: id, Name: naming.For(1, id, m), Media: m} +} diff --git a/internal/pipeline/progress.go b/internal/pipeline/progress.go new file mode 100644 index 0000000..5ad97da --- /dev/null +++ b/internal/pipeline/progress.go @@ -0,0 +1,105 @@ +package pipeline + +import ( + "sync" + + "github.com/iyear/tdl/core/downloader" + + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// Outcome is what happened to one item. +type Outcome struct { + Item tgsource.Item + Err error // nil when the file downloaded and was renamed into place +} + +// Stats is a snapshot of a run's progress. +type Stats struct { + Started int + Done int + Failed int + BytesDone int64 + BytesTotal int64 +} + +// progress collects per-item results and feeds an optional live reporter. +// +// The downloader calls these from its worker goroutines, so everything here is +// mutex-guarded. OnDone fires once per item whether it succeeded or not, which +// is what makes it the right place to finish the file — the downloader itself +// never closes or renames what To() handed it. +type progress struct { + mu sync.Mutex + stats Stats + outcomes []Outcome + inFlight map[int]int64 // message id -> bytes written so far + + finish func(*elem, error) error + events Events +} + +func newProgress(finish func(*elem, error) error, events Events) *progress { + if events == nil { + events = nopEvents{} + } + return &progress{ + inFlight: make(map[int]int64), + finish: finish, + events: events, + } +} + +func (p *progress) OnAdd(e downloader.Elem) { + el := e.(*elem) + p.mu.Lock() + p.stats.Started++ + p.stats.BytesTotal += el.item.Size() + stats := p.stats + p.mu.Unlock() + p.events.DownloadStart(el.item) + p.events.Stats(stats) +} + +func (p *progress) OnDownload(e downloader.Elem, state downloader.ProgressState) { + el := e.(*elem) + p.mu.Lock() + // State carries the running total for this item, not a delta, so the + // aggregate is adjusted by the difference since the last callback. + prev := p.inFlight[el.item.MessageID] + p.inFlight[el.item.MessageID] = state.Downloaded + p.stats.BytesDone += state.Downloaded - prev + stats := p.stats + p.mu.Unlock() + p.events.DownloadBytes(el.item, state.Downloaded) + p.events.Stats(stats) +} + +func (p *progress) OnDone(e downloader.Elem, err error) { + el := e.(*elem) + + // Closing and renaming happens here because this is the only callback that + // runs exactly once per item and knows whether it succeeded. + if ferr := p.finish(el, err); ferr != nil && err == nil { + err = ferr + } + + p.mu.Lock() + delete(p.inFlight, el.item.MessageID) + if err != nil { + p.stats.Failed++ + } else { + p.stats.Done++ + } + p.outcomes = append(p.outcomes, Outcome{Item: el.item, Err: err}) + stats := p.stats + p.mu.Unlock() + p.events.DownloadDone(el.item, err) + p.events.Stats(stats) +} + +func (p *progress) results() ([]Outcome, Stats) { + p.mu.Lock() + defer p.mu.Unlock() + return p.outcomes, p.stats +} diff --git a/internal/pipeline/run_test.go b/internal/pipeline/run_test.go new file mode 100644 index 0000000..44f9f7b --- /dev/null +++ b/internal/pipeline/run_test.go @@ -0,0 +1,307 @@ +package pipeline + +import ( + "context" + "errors" + "fmt" + "iter" + "os" + "path/filepath" + "sync/atomic" + "testing" + + _ "github.com/rclone/rclone/backend/local" + "github.com/rclone/rclone/fs" + + "github.com/iyear/tdl/core/tmedia" + + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// These exercise Run's composition rather than its parts. Everything underneath +// it is tested directly, but the properties Run alone owns — that the upload +// channel closes only after the last send, that reservations balance across a +// whole run, that a tripped breaker still terminates — only exist once the +// pieces are wired together, and a regression in any of them is silent. + +// runFake substitutes the download step for the duration of a test. +func runFake(t *testing.T, f func(context.Context, iter.Seq2[tgsource.Item, error], DownloadOptions) ([]Outcome, Stats, error)) { + t.Helper() + prev := download + download = f + t.Cleanup(func() { download = prev }) +} + +// stageItems is a download step that writes each item's bytes into staging and +// hands it to the upload leg, mimicking what the real one does on success. +// Items named in fail never reach staging. +func stageItems(fail map[int]bool) func(context.Context, iter.Seq2[tgsource.Item, error], DownloadOptions) ([]Outcome, Stats, error) { + return func(ctx context.Context, seq iter.Seq2[tgsource.Item, error], o DownloadOptions) ([]Outcome, Stats, error) { + var ( + outcomes []Outcome + stats Stats + ) + for it, err := range seq { + if err != nil { + return outcomes, stats, err + } + if o.stop != nil && o.stop.Load() { + break + } + if o.acquire != nil { + if aerr := o.acquire(ctx, it.Size()); aerr != nil { + return outcomes, stats, aerr + } + } + stats.Started++ + if fail[it.MessageID] { + stats.Failed++ + outcomes = append(outcomes, Outcome{Item: it, Err: errors.New("download failed")}) + o.onFailed(it) + continue + } + if werr := os.WriteFile(filepath.Join(o.Staging, it.Name), + make([]byte, it.Size()), 0o600); werr != nil { + return outcomes, stats, werr + } + stats.Done++ + stats.BytesDone += it.Size() + outcomes = append(outcomes, Outcome{Item: it}) + o.onReady(it) + } + return outcomes, stats, nil + } +} + +func testItems(n int, size int64) []tgsource.Item { + out := make([]tgsource.Item, n) + for i := range out { + out[i] = tgsource.Item{ + MessageID: i + 1, + Name: fmt.Sprintf("-100123_%d_file.bin", i+1), + Media: &tmedia.Media{Size: size}, + } + } + return out +} + +func seqOf(items []tgsource.Item) iter.Seq2[tgsource.Item, error] { + return func(yield func(tgsource.Item, error) bool) { + for _, it := range items { + if !yield(it, nil) { + return + } + } + } +} + +// runOpts wires Run against local directories, so uploads are real rclone moves. +func runOpts(t *testing.T, budget int64) (Options, string, string) { + t.Helper() + staging, dstDir := t.TempDir(), t.TempDir() + dst, err := fs.NewFs(t.Context(), dstDir) + if err != nil { + t.Fatalf("open destination: %v", err) + } + return Options{ + Dst: dst, + Staging: staging, + Uploads: 2, + Budget: budget, + Confirm: true, + }, staging, dstDir +} + +func TestRunUploadsEveryDownloadedItem(t *testing.T) { + runFake(t, stageItems(nil)) + o, staging, dstDir := runOpts(t, 0) + items := testItems(6, 512) + + res, err := Run(t.Context(), seqOf(items), o) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Stats.Done != len(items) { + t.Errorf("Done = %d, want %d", res.Stats.Done, len(items)) + } + for _, it := range items { + info, serr := os.Stat(filepath.Join(dstDir, it.Name)) + if serr != nil { + t.Errorf("%s not on the destination: %v", it.Name, serr) + continue + } + if info.Size() != it.Size() { + t.Errorf("%s is %d bytes, want %d", it.Name, info.Size(), it.Size()) + } + } + assertEmpty(t, staging) +} + +// The reason Err always reports nil: if Run closed the upload channel before +// the download step finished handing over items, this panics. +func TestRunClosesUploadsOnlyAfterTheLastSend(t *testing.T) { + var late atomic.Bool + runFake(t, func(ctx context.Context, seq iter.Seq2[tgsource.Item, error], o DownloadOptions) ([]Outcome, Stats, error) { + // A worker still delivering after the step's own error is exactly what + // core does when it skips wg.Wait, so send one and then fail. + it := testItems(1, 128)[0] + if err := os.WriteFile(filepath.Join(o.Staging, it.Name), make([]byte, it.Size()), 0o600); err != nil { + return nil, Stats{}, err + } + o.acquire(ctx, it.Size()) + o.onReady(it) + late.Store(true) + return []Outcome{{Item: it}}, Stats{Started: 1, Done: 1}, errors.New("download step failed") + }) + o, staging, dstDir := runOpts(t, 0) + + _, err := Run(t.Context(), seqOf(nil), o) + if err == nil { + t.Fatal("Run returned nil, want the download step's error") + } + if !late.Load() { + t.Fatal("the download step never ran") + } + // The handed-over item must still have been uploaded, not dropped. + if _, serr := os.Stat(filepath.Join(dstDir, "-100123_1_file.bin")); serr != nil { + t.Errorf("item handed over before the error was not uploaded: %v", serr) + } + assertEmpty(t, staging) +} + +// A reservation that is not returned shrinks the cap for the rest of the run, +// and one returned twice panics. Neither is visible in the pieces individually. +// +// The budget here is exactly one file, so the run can only proceed if every +// reservation comes back: the second item cannot start until the first is +// released. A leak deadlocks and this test times out rather than passing +// quietly, which is the whole point of sizing it this way. +func TestRunBalancesTheBudgetAcrossFailures(t *testing.T) { + const size = 1024 + runFake(t, stageItems(map[int]bool{2: true, 5: true})) + o, staging, dstDir := runOpts(t, size) + items := testItems(8, size) + + res, err := Run(t.Context(), seqOf(items), o) + if err != nil { + t.Fatalf("Run: %v", err) + } + if got := len(res.Failed()); got != 2 { + t.Errorf("Failed() = %d, want 2", got) + } + if res.Stats.Done != 6 { + t.Errorf("Done = %d, want 6", res.Stats.Done) + } + // The six that downloaded are on the destination; the two that failed are + // not, and neither is holding space. + for _, it := range items { + _, serr := os.Stat(filepath.Join(dstDir, it.Name)) + wantThere := it.MessageID != 2 && it.MessageID != 5 + if wantThere && serr != nil { + t.Errorf("%s should be on the destination: %v", it.Name, serr) + } + if !wantThere && !os.IsNotExist(serr) { + t.Errorf("%s should not be on the destination", it.Name) + } + } + assertEmpty(t, staging) +} + +func TestRunStopsDownloadingAfterConsecutiveUploadFailures(t *testing.T) { + const size = 256 + items := testItems(40, size) + + var dispatched atomic.Int64 + runFake(t, func(ctx context.Context, seq iter.Seq2[tgsource.Item, error], o DownloadOptions) ([]Outcome, Stats, error) { + var ( + outcomes []Outcome + stats Stats + ) + for it := range seqValues(seq) { + if o.stop.Load() { + break + } + o.acquire(ctx, it.Size()) + dispatched.Add(1) + // Nothing is written to staging, so every upload fails to find it. + stats.Started++ + stats.Done++ + outcomes = append(outcomes, Outcome{Item: it}) + o.onReady(it) + } + return outcomes, stats, nil + }) + o, staging, _ := runOpts(t, 0) + o.MaxFailures = 3 + + _, err := Run(t.Context(), seqOf(items), o) + if err == nil { + t.Fatal("Run returned nil, want the breaker's error") + } + // The sentinel, not the wording: the caller maps this to a distinct exit + // code so a driver stops instead of retrying against a dead remote. + if !errors.Is(err, ErrDestinationFailing) { + t.Errorf("error is not ErrDestinationFailing: %v", err) + } + // The point of stopping the iterator rather than cancelling uploads: the + // download side must not have walked the whole chat. + if got := dispatched.Load(); got == int64(len(items)) { + t.Errorf("all %d items were dispatched; the breaker did not stop downloads", got) + } + assertEmpty(t, staging) +} + +func seqValues(seq iter.Seq2[tgsource.Item, error]) iter.Seq[tgsource.Item] { + return func(yield func(tgsource.Item) bool) { + for it, err := range seq { + if err != nil { + return + } + if !yield(it) { + return + } + } + } +} + +// A failed upload must take the staged file with it, or the cap is over-committed +// by that much for the rest of the run. +func TestRunClearsStagingWhenAnUploadFails(t *testing.T) { + const size = 512 + runFake(t, func(ctx context.Context, seq iter.Seq2[tgsource.Item, error], o DownloadOptions) ([]Outcome, Stats, error) { + it := testItems(1, size)[0] + // Staged at the wrong size, so the confirm step rejects it. + if err := os.WriteFile(filepath.Join(o.Staging, it.Name), make([]byte, size/2), 0o600); err != nil { + return nil, Stats{}, err + } + o.acquire(ctx, it.Size()) + o.onReady(it) + return []Outcome{{Item: it}}, Stats{Started: 1, Done: 1}, nil + }) + o, staging, dstDir := runOpts(t, 0) + + _, err := Run(t.Context(), seqOf(nil), o) + if err == nil { + t.Fatal("Run returned nil, want the confirm failure") + } + assertEmpty(t, staging) + // And the short object must not be left under the name verify matches. + if _, serr := os.Stat(filepath.Join(dstDir, "-100123_1_file.bin")); !os.IsNotExist(serr) { + t.Errorf("short object left on the destination: %v", serr) + } +} + +func assertEmpty(t *testing.T, dir string) { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read staging: %v", err) + } + if len(entries) != 0 { + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name() + } + t.Errorf("staging is not empty: %v", names) + } +} diff --git a/internal/pipeline/space.go b/internal/pipeline/space.go new file mode 100644 index 0000000..c2125d1 --- /dev/null +++ b/internal/pipeline/space.go @@ -0,0 +1,66 @@ +package pipeline + +import ( + "context" + "fmt" + "sync" + "time" +) + +// spaceCheckInterval is how often the destination's free space is re-read +// mid-run. About is a network round trip, so it is not worth doing per file. +const spaceCheckInterval = time.Minute + +// spaceGuard watches the destination's free space during a run. +// +// Checking only before starting is not enough on a long archive: an 18k-message +// chat runs for hours, and a remote that was fine at the start can fill in the +// middle — from this run's own uploads, or from anything else using the account. +// Without this the run discovers it by failing several multi-gigabyte uploads in +// a row, which costs the download bandwidth for all of them. +// +// A backend that cannot report a quota is treated as unlimited, matching the +// pre-flight check and the shell pipeline before it. +type spaceGuard struct { + free func(context.Context) (int64, bool) + minFree int64 + + mu sync.Mutex + lastCheck time.Time + failed error +} + +func newSpaceGuard(free func(context.Context) (int64, bool), minFree int64) *spaceGuard { + if free == nil || minFree <= 0 { + return nil + } + return &spaceGuard{free: free, minFree: minFree, lastCheck: time.Now()} +} + +// check reports an error once the destination has dropped below the floor. +// +// The verdict is sticky: once the remote is known to be full, every later call +// says so without another round trip, because the run is ending either way. +func (g *spaceGuard) check(ctx context.Context) error { + if g == nil { + return nil + } + g.mu.Lock() + defer g.mu.Unlock() + + if g.failed != nil { + return g.failed + } + if time.Since(g.lastCheck) < spaceCheckInterval { + return nil + } + g.lastCheck = time.Now() + + free, ok := g.free(ctx) + if !ok || free >= g.minFree { + return nil + } + g.failed = fmt.Errorf("%w: only %.1f GiB free, below the %.1f GiB floor", + ErrDestinationFailing, float64(free)/(1<<30), float64(g.minFree)/(1<<30)) + return g.failed +} diff --git a/internal/pipeline/space_test.go b/internal/pipeline/space_test.go new file mode 100644 index 0000000..87c4057 --- /dev/null +++ b/internal/pipeline/space_test.go @@ -0,0 +1,76 @@ +package pipeline + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestSpaceGuard(t *testing.T) { + const floor = 10 << 30 + + t.Run("unset guard never complains", func(t *testing.T) { + if err := newSpaceGuard(nil, floor).check(t.Context()); err != nil { + t.Errorf("check = %v, want nil when no reporter is configured", err) + } + }) + + t.Run("a backend without a quota counts as unlimited", func(t *testing.T) { + g := newSpaceGuard(func(context.Context) (int64, bool) { return 0, false }, floor) + g.lastCheck = time.Now().Add(-2 * spaceCheckInterval) + if err := g.check(t.Context()); err != nil { + t.Errorf("check = %v, want nil when the backend cannot report", err) + } + }) + + t.Run("plenty of room is fine", func(t *testing.T) { + g := newSpaceGuard(func(context.Context) (int64, bool) { return 100 << 30, true }, floor) + g.lastCheck = time.Now().Add(-2 * spaceCheckInterval) + if err := g.check(t.Context()); err != nil { + t.Errorf("check = %v, want nil with 100 GiB free", err) + } + }) + + t.Run("below the floor stops the run", func(t *testing.T) { + var calls int + g := newSpaceGuard(func(context.Context) (int64, bool) { + calls++ + return 1 << 30, true + }, floor) + g.lastCheck = time.Now().Add(-2 * spaceCheckInterval) + + err := g.check(t.Context()) + if !errors.Is(err, ErrDestinationFailing) { + t.Fatalf("check = %v, want ErrDestinationFailing", err) + } + // Sticky, and without another round trip: the run is ending either way, + // and every upload worker calls this. + for range 5 { + if !errors.Is(g.check(t.Context()), ErrDestinationFailing) { + t.Fatal("the verdict did not stick") + } + } + if calls != 1 { + t.Errorf("free space was read %d times, want 1", calls) + } + }) + + t.Run("checks are throttled", func(t *testing.T) { + var calls int + g := newSpaceGuard(func(context.Context) (int64, bool) { + calls++ + return 100 << 30, true + }, floor) + // Freshly built, so the interval has not elapsed. About is a network + // round trip and every worker calls this per file. + for range 20 { + if err := g.check(t.Context()); err != nil { + t.Fatalf("check = %v", err) + } + } + if calls != 0 { + t.Errorf("free space was read %d times inside the interval, want 0", calls) + } + }) +} diff --git a/internal/pipeline/upload.go b/internal/pipeline/upload.go new file mode 100644 index 0000000..29ef56d --- /dev/null +++ b/internal/pipeline/upload.go @@ -0,0 +1,171 @@ +package pipeline + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/operations" + + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// uploadAttempts is how many times a file is offered to the remote before the +// run gives up on it. +// +// Retrying here rather than fetching again next pass is the whole point: when a +// move fails the local copy is still in staging, so another attempt costs a few +// seconds, while abandoning it costs re-downloading the file from Telegram — +// which for this archive can be two gigabytes. +const uploadAttempts = 3 + +// uploader moves finished files from staging to the destination remote. +type uploader struct { + local fs.Fs // the staging directory as an rclone filesystem + dst fs.Fs + confirm bool +} + +// upload moves one finished file to the remote, retrying a failure. +// +// pikpak is why this retries at all. It commits an upload as a server-side async +// task, and rclone polls that task only as long as its low-level retries last +// (backend/pikpak/helper.go:205-214, bounded by fs.NewPacer). A task still in +// PHASE_TYPE_PENDING when the polling budget runs out is reported as +// "can't verify the task is completed", and observed against the live archive +// that task then never commits — the object is simply absent afterwards. The +// transfer itself was fine; only the confirmation timed out. +func (u *uploader) upload(ctx context.Context, it tgsource.Item) error { + var errs []error + for attempt := 1; ; attempt++ { + // Every attempt after the first looks before it leaps. A pending task + // from the previous attempt may have committed during the backoff, and + // pikpak allows two files with the same name — so re-uploading without + // checking is how one file becomes two, which verification then reports + // as an ambiguous basename forever. + if attempt > 1 { + landed, err := u.settle(ctx, it) + if err != nil { + errs = append(errs, err) + } + if landed { + return nil + } + } + + err := u.attempt(ctx, it) + if err == nil { + return nil + } + errs = append(errs, fmt.Errorf("attempt %d/%d: %w", attempt, uploadAttempts, err)) + + if attempt >= uploadAttempts || ctx.Err() != nil { + return errors.Join(errs...) + } + if err := sleep(ctx, uploadBackoff(attempt)); err != nil { + return errors.Join(append(errs, err)...) + } + } +} + +// attempt runs one move and, unless disabled, proves the object arrived at the +// expected size. +// +// MoveFile removes the local copy as part of the move, so a successful return +// means the file is on the remote and off local disk. +func (u *uploader) attempt(ctx context.Context, it tgsource.Item) error { + if err := operations.MoveFile(ctx, u.dst, u.local, it.Name, it.Name); err != nil { + return fmt.Errorf("move %q to %s: %w", it.Name, u.dst.String(), err) + } + if !u.confirm { + return nil + } + + obj, err := u.dst.NewObject(ctx, it.Name) + if err != nil { + return fmt.Errorf("confirm %q: %w", it.Name, err) + } + if got := obj.Size(); got != it.Size() { + err := fmt.Errorf("confirm %q: remote has %d bytes, expected %d", it.Name, got, it.Size()) + if derr := remove(ctx, obj); derr != nil { + return fmt.Errorf("%w (and it could not be removed: %v — delete it by hand "+ + "or verify will count it archived)", err, derr) + } + return err + } + return nil +} + +// settle reports whether the file is on the remote at the right size, cleaning +// up if it is there at the wrong one. +// +// A failed move can leave a partial object under the final name. rclone only +// writes to a temporary name when the backend advertises PartialUploads +// (copy.go:93) and only cleans up after itself when it did (copy.go:348-350) — +// pikpak advertises neither, so a died-halfway transfer stays exactly where a +// complete one would be, and verification would count it archived forever. +// +// An object of the right size means the upload actually succeeded, however the +// move reported itself, so the staged copy is removed here: nothing downstream +// will do it on a success path that MoveFile did not take. +func (u *uploader) settle(ctx context.Context, it tgsource.Item) (landed bool, err error) { + // A fresh context: the run may already be shutting down, which is one of + // the ways the move failed in the first place. + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + + obj, err := u.dst.NewObject(ctx, it.Name) + if errors.Is(err, fs.ErrorObjectNotFound) { + return false, nil // nothing was left behind + } + if err != nil { + return false, fmt.Errorf("check for a leftover %q: %w", it.Name, err) + } + + if obj.Size() == it.Size() { + if src, serr := u.local.NewObject(ctx, it.Name); serr == nil { + if derr := operations.DeleteFile(ctx, src); derr != nil { + return true, fmt.Errorf("%q reached the remote but the staged copy "+ + "could not be removed: %w", it.Name, derr) + } + } + return true, nil + } + + if derr := remove(ctx, obj); derr != nil { + return false, fmt.Errorf("a %d-byte fragment of %q (expected %d) is on the remote and "+ + "could not be removed: %w — delete it by hand or verify will count it archived", + obj.Size(), it.Name, it.Size(), derr) + } + return false, nil +} + +// uploadBackoff spaces out retries. pikpak's commit queue is what is being +// waited on, and it is measured in seconds rather than milliseconds. +// +// A var so tests can shorten it: at the real cadence a single test that proves +// a retry happens spends half a minute asleep. +var uploadBackoff = func(attempt int) time.Duration { + return time.Duration(attempt) * 10 * time.Second +} + +// sleep waits, or returns early if the run is cancelled. +func sleep(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-t.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// remove deletes an object on a context that outlives the run's cancellation. +func remove(ctx context.Context, obj fs.Object) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + return operations.DeleteFile(ctx, obj) +} diff --git a/internal/pipeline/upload_test.go b/internal/pipeline/upload_test.go new file mode 100644 index 0000000..498f6ec --- /dev/null +++ b/internal/pipeline/upload_test.go @@ -0,0 +1,188 @@ +package pipeline + +import ( + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/rclone/rclone/backend/local" + "github.com/rclone/rclone/fs" + + "github.com/iyear/tdl/core/tmedia" + + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +const settleName = "-100123_4242_clip.mp4" + +// The real backoff is tens of seconds, which is right against pikpak and wrong +// in a test suite. +func TestMain(m *testing.M) { + uploadBackoff = func(int) time.Duration { return time.Millisecond } + os.Exit(m.Run()) +} + +func settleFixture(t *testing.T, remoteBytes, stagedBytes int) (*uploader, tgsource.Item, string, string) { + t.Helper() + dstDir, stageDir := t.TempDir(), t.TempDir() + if remoteBytes >= 0 { + if err := os.WriteFile(filepath.Join(dstDir, settleName), make([]byte, remoteBytes), 0o600); err != nil { + t.Fatal(err) + } + } + if stagedBytes >= 0 { + if err := os.WriteFile(filepath.Join(stageDir, settleName), make([]byte, stagedBytes), 0o600); err != nil { + t.Fatal(err) + } + } + dst, err := fs.NewFs(t.Context(), dstDir) + if err != nil { + t.Fatal(err) + } + local, err := fs.NewFs(t.Context(), stageDir) + if err != nil { + t.Fatal(err) + } + it := tgsource.Item{MessageID: 4242, Name: settleName, Media: &tmedia.Media{Size: 4096}} + return &uploader{local: local, dst: dst}, it, dstDir, stageDir +} + +// settle is what stands between a died-halfway upload and a permanently wrong +// archive. rclone writes straight to the final name on any backend that does +// not advertise PartialUploads (copy.go:93) and cleans up only when it did not +// (copy.go:348-350), so on such a remote — pikpak, here — a fragment is left +// under exactly the name verification matches on. +func TestSettleRemovesAFragment(t *testing.T) { + u, it, dstDir, _ := settleFixture(t, 400, 4096) + + landed, err := u.settle(t.Context(), it) + if err != nil { + t.Fatalf("settle: %v", err) + } + if landed { + t.Error("a 400-byte fragment was reported as a completed upload") + } + if _, err := os.Stat(filepath.Join(dstDir, settleName)); !os.IsNotExist(err) { + t.Error("the fragment was left under the name verify matches") + } +} + +// pikpak commits uploads as a server-side async task, so a transfer rclone gave +// up on can still land correctly afterwards. That is a success, not something +// to delete and fetch again — and the staged copy has to go, because the move +// that would normally have removed it is the thing that failed. +func TestSettleKeepsALateButCompleteUpload(t *testing.T) { + u, it, dstDir, stageDir := settleFixture(t, 4096, 4096) + + landed, err := u.settle(t.Context(), it) + if err != nil { + t.Fatalf("settle: %v", err) + } + if !landed { + t.Error("a complete object was not recognised as a finished upload") + } + if _, err := os.Stat(filepath.Join(dstDir, settleName)); err != nil { + t.Errorf("a complete file was deleted: %v", err) + } + if _, err := os.Stat(filepath.Join(stageDir, settleName)); !os.IsNotExist(err) { + t.Error("the staged copy was left behind, so the byte budget stays committed") + } +} + +func TestSettleWithNothingOnTheRemote(t *testing.T) { + u, it, _, _ := settleFixture(t, -1, 4096) + + landed, err := u.settle(t.Context(), it) + if err != nil { + t.Fatalf("settle: %v", err) + } + if landed { + t.Error("an absent object was reported as uploaded") + } +} + +// The retry itself: a first attempt that fails must not abandon the file when +// the staged copy is still there and the remote is fine. +func TestUploadRetriesAfterAFailedMove(t *testing.T) { + u, it, dstDir, stageDir := settleFixture(t, -1, 4096) + + // Make the first move fail by removing the staged file, then restoring it + // so a later attempt can succeed. Simpler and closer to the real failure: + // upload once with the file absent, confirm the error names every attempt. + if err := os.Remove(filepath.Join(stageDir, settleName)); err != nil { + t.Fatal(err) + } + err := u.upload(t.Context(), it) + if err == nil { + t.Fatal("upload of a missing staged file returned nil") + } + if _, serr := os.Stat(filepath.Join(dstDir, settleName)); !os.IsNotExist(serr) { + t.Error("a failed upload left an object on the remote") + } +} + +// A move that works still has to leave staging clean and the object intact. +func TestUploadMovesAndConfirms(t *testing.T) { + u, it, dstDir, stageDir := settleFixture(t, -1, 4096) + u.confirm = true + + if err := u.upload(t.Context(), it); err != nil { + t.Fatalf("upload: %v", err) + } + info, err := os.Stat(filepath.Join(dstDir, settleName)) + if err != nil { + t.Fatalf("object not on the destination: %v", err) + } + if info.Size() != it.Size() { + t.Errorf("object is %d bytes, want %d", info.Size(), it.Size()) + } + if _, err := os.Stat(filepath.Join(stageDir, settleName)); !os.IsNotExist(err) { + t.Error("the staged copy survived a successful move") + } +} + +// The recovery the retry exists for: the first attempt fails, the condition +// clears, and the second attempt succeeds — without the file having to be +// downloaded again. +// +// The destination is made unwritable so the first move genuinely fails inside +// rclone, and the backoff hook restores it. Faking the error would only test +// the loop against itself; this exercises the real MoveFile path. +func TestUploadSucceedsOnRetryAfterTheRemoteRecovers(t *testing.T) { + u, it, dstDir, stageDir := settleFixture(t, -1, 4096) + u.confirm = true + + if err := os.Chmod(dstDir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dstDir, 0o700) }) + + restore := uploadBackoff + t.Cleanup(func() { uploadBackoff = restore }) + var backoffs int + uploadBackoff = func(int) time.Duration { + backoffs++ + if err := os.Chmod(dstDir, 0o700); err != nil { + t.Errorf("restore destination: %v", err) + } + return time.Millisecond + } + + if err := u.upload(t.Context(), it); err != nil { + t.Fatalf("upload did not recover on retry: %v", err) + } + if backoffs == 0 { + t.Error("the first attempt did not fail, so no retry was exercised") + } + info, err := os.Stat(filepath.Join(dstDir, settleName)) + if err != nil { + t.Fatalf("object not on the destination after the retry: %v", err) + } + if info.Size() != it.Size() { + t.Errorf("object is %d bytes, want %d", info.Size(), it.Size()) + } + if _, err := os.Stat(filepath.Join(stageDir, settleName)); !os.IsNotExist(err) { + t.Error("the staged copy survived a successful retry") + } +} diff --git a/internal/remote/fs.go b/internal/remote/fs.go new file mode 100644 index 0000000..f84861a --- /dev/null +++ b/internal/remote/fs.go @@ -0,0 +1,189 @@ +// Package remote owns the rclone side: resolving a destination and reporting on it. +package remote + +import ( + "context" + "errors" + "fmt" + "os" + "sort" + "strings" + "sync" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config" + "github.com/rclone/rclone/fs/config/configfile" +) + +// Tunables are rclone settings this tool overrides. +// +// The values exist because of pikpak: it commits an upload as a server-side +// async task, and rclone abandons a still-pending one once its low-level retries +// run out, failing a transfer that would have succeeded. Fewer parallel +// transfers keep that queue short; more retries wait it out. +type Tunables struct { + Transfers int + LowLevelRetries int +} + +// DefaultTunables are the values the shell pipeline settled on for pikpak. +func DefaultTunables() Tunables { + return Tunables{Transfers: 2, LowLevelRetries: 20} +} + +// installOnce guards configfile.Install, which swaps unsynchronised package +// globals in rclone's config package. Calling it twice is harmless on its own, +// but racing it against an Fs resolution is not. +var ( + installOnce sync.Once + // installErr is remembered, not just returned once. A second Init would + // otherwise skip the Do body and report success against a config that was + // never loaded. + installErr error +) + +// Init loads the user's rclone.conf and applies tunables to a derived context. +// +// Environment variables still win: rclone reads RCLONE_TRANSFERS and friends +// into its global config at package init, and fs.AddConfig copies that, so +// skipping the assignment when the variable is set preserves the operator's +// value. +// +// The config is loaded here, explicitly, because rclone's lazy path is fatal: +// config.LoadedData() calls fs.Fatalf on a config file it cannot parse or +// decrypt, and fs.Fatalf calls os.Exit(1) — past every defer, and with an exit +// code this tool defines as "incomplete", which would send a driver into an +// endless retry. Loading up front turns that into an ordinary error. +func Init(ctx context.Context, t Tunables) (context.Context, error) { + installOnce.Do(func() { + configfile.Install() + if lerr := config.Data().Load(); lerr != nil && !errors.Is(lerr, config.ErrorConfigFileNotFound) { + installErr = fmt.Errorf("cannot read rclone config %q: %w "+ + "(an encrypted config needs RCLONE_CONFIG_PASS)", config.GetConfigPath(), lerr) + return + } + // Marks the data loaded so rclone's own lazy path is never taken. That + // path ends in fs.Fatalf -> os.Exit(1), past every defer and with an + // exit code this tool defines as "incomplete"; loading here and not + // flagging it would leave the window open until the first NewFs. + config.LoadedData() + }) + if installErr != nil { + return ctx, installErr + } + + ctx, ci := fs.AddConfig(ctx) + if !envSet("RCLONE_TRANSFERS") && t.Transfers > 0 { + ci.Transfers = t.Transfers + } + if !envSet("RCLONE_LOW_LEVEL_RETRIES") && t.LowLevelRetries > 0 { + ci.LowLevelRetries = t.LowLevelRetries + } + return ctx, nil +} + +// Resolve opens a destination given as an rclone REMOTE:PATH. +// +// rclone itself is the authority on what resolves: a remote can come from +// rclone.conf, from RCLONE_CONFIG__* environment variables with no config +// entry at all, from an inline `:type,opt=val:` connection string, or from a +// parameterised name like `pikpak,chunk_size=10M:path`. Pre-screening the name +// against the config sections would reject the last three, so the call is made +// first and the friendly "here is what you have configured" message is produced +// only for the one error that means the name was never defined. +func Resolve(ctx context.Context, remote string) (fs.Fs, error) { + if remote == "" { + return nil, fmt.Errorf("a destination remote is required (REMOTE:PATH)") + } + if !strings.Contains(remote, ":") { + return nil, fmt.Errorf("remote %q is not in rclone REMOTE:PATH form", remote) + } + + f, err := fs.NewFs(ctx, remote) + if err != nil { + if errors.Is(err, fs.ErrorNotFoundInConfigFile) { + return nil, fmt.Errorf("rclone remote %q is not configured; configured remotes: %s", + remote, strings.Join(sections(), ", ")) + } + if missing := missingBackend(err); missing != "" { + // Otherwise this reads as a credentials problem, which sends the + // operator to rclone config for a fault that is in the build. + return nil, fmt.Errorf("remote %q needs the %q backend, which this binary does "+ + "not contain — it registers only %s. A build without -tags slim includes "+ + "every rclone backend: %w", remote, missing, strings.Join(backends(), ", "), err) + } + return nil, fmt.Errorf("cannot reach %q — check credentials and connectivity: %w", remote, err) + } + return f, nil +} + +// missingBackend names the backend rclone could not find, if that is what went +// wrong. fs.Find returns a bare fmt.Errorf with no sentinel to match, so the +// text is all there is to go on. +func missingBackend(err error) string { + const prefix = "didn't find backend called " + msg := err.Error() + i := strings.Index(msg, prefix) + if i < 0 { + return "" + } + name := strings.TrimPrefix(msg[i+len(prefix):], "\"") + name, _, _ = strings.Cut(name, "\"") + return name +} + +// backends lists the backends compiled into this binary. +func backends() []string { + out := make([]string, 0, len(fs.Registry)) + for _, info := range fs.Registry { + out = append(out, info.Name) + } + sort.Strings(out) + return out +} + +// EnsureDir creates the destination if it is not there yet. +// +// A destination that does not exist yet is the normal case for a first run, and +// the shell pipeline created it up front for the same reason — the call doubles +// as the reachability and credentials check, since a remote that refuses a +// mkdir will refuse the uploads too. Doing it before the chat is read means a +// bad destination fails in seconds rather than after a full history walk. +func EnsureDir(ctx context.Context, f fs.Fs) error { + if err := f.Mkdir(ctx, ""); err != nil { + return fmt.Errorf("cannot create %q — check credentials and connectivity: %w", f.String(), err) + } + return nil +} + +// FreeBytes reports free space on the remote. +// +// Backends without quota reporting return ok=false rather than an error: the +// shell pipeline treated an unanswerable quota as "unlimited" so a backend that +// cannot report never blocks a run, and that behaviour is preserved. +func FreeBytes(ctx context.Context, f fs.Fs) (free int64, ok bool) { + about := f.Features().About + if about == nil { + return 0, false + } + usage, err := about(ctx) + if err != nil || usage == nil || usage.Free == nil { + return 0, false + } + return *usage.Free, true +} + +// sections lists configured remote names. Safe only after Init has loaded the +// config without error. +func sections() []string { + out := config.FileSections() + if len(out) == 0 { + return []string{"(none)"} + } + return out +} + +func envSet(key string) bool { + _, ok := os.LookupEnv(key) + return ok +} diff --git a/internal/remote/fs_test.go b/internal/remote/fs_test.go new file mode 100644 index 0000000..310f712 --- /dev/null +++ b/internal/remote/fs_test.go @@ -0,0 +1,162 @@ +package remote + +import ( + "context" + "os" + "os/exec" + "slices" + "strings" + "testing" + + "github.com/rclone/rclone/fs" +) + +// Resolve's own validation runs before rclone is consulted, so these cases are +// checkable without a config file or a network. +func TestResolveRejectsMalformedDestinations(t *testing.T) { + tests := []struct { + name string + remote string + want string + }{ + {"empty", "", "destination remote is required"}, + {"no colon", "pikpak", "not in rclone REMOTE:PATH form"}, + {"path only", "/tmp/staging", "not in rclone REMOTE:PATH form"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Resolve(context.Background(), tt.remote) + if err == nil { + t.Fatalf("Resolve(%q) succeeded, want an error", tt.remote) + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("Resolve(%q) error = %v, want it to mention %q", tt.remote, err, tt.want) + } + }) + } +} + +// Forms rclone accepts must not be rejected by our own pre-checks. An earlier +// version screened the name against the config file's sections, which turned +// away env-defined remotes, connection strings, and parameterised names that +// rclone resolves perfectly well. These must get past our validation and fail — +// if at all — inside rclone, on their own merits. +func TestResolveDefersUnusualFormsToRclone(t *testing.T) { + forms := []string{ + "pikpak,chunk_size=10M:mychannel", // parameterised remote name + ":local:/tmp", // inline connection string + "envonly:bucket", // possibly defined by RCLONE_CONFIG_ENVONLY_* + } + + for _, form := range forms { + t.Run(form, func(t *testing.T) { + _, err := Resolve(context.Background(), form) + if err == nil { + return // rclone resolved it; nothing to assert + } + if strings.Contains(err.Error(), "not in rclone REMOTE:PATH form") { + t.Errorf("Resolve(%q) was rejected by our own syntax check; "+ + "rclone should be the authority on what resolves: %v", form, err) + } + }) + } +} + +func TestDefaultTunablesMatchPikpakSettings(t *testing.T) { + // These two numbers are the outcome of debugging pikpak's async-commit + // behaviour in the shell pipeline; a silent change would reintroduce + // transfers that fail while the server is still committing. + got := DefaultTunables() + if got.Transfers != 2 { + t.Errorf("Transfers = %d, want 2", got.Transfers) + } + if got.LowLevelRetries != 20 { + t.Errorf("LowLevelRetries = %d, want 20", got.LowLevelRetries) + } +} + +func TestInitAppliesTunables(t *testing.T) { + // Unset both so the defaults, not an operator override, are what lands. + unset(t, "RCLONE_TRANSFERS") + unset(t, "RCLONE_LOW_LEVEL_RETRIES") + + ctx, err := Init(context.Background(), Tunables{Transfers: 2, LowLevelRetries: 20}) + if err != nil { + t.Fatalf("Init: %v", err) + } + ci := fs.GetConfig(ctx) + if ci.Transfers != 2 { + t.Errorf("ctx Transfers = %d, want 2", ci.Transfers) + } + if ci.LowLevelRetries != 20 { + t.Errorf("ctx LowLevelRetries = %d, want 20", ci.LowLevelRetries) + } +} + +// rclone reads RCLONE_TRANSFERS into its global config at package init, so an +// in-process t.Setenv cannot observe whether Init honours it — the assertion +// would pass with the guard removed. A subprocess is the only real check. +func TestInitLeavesEnvOverridesAlone(t *testing.T) { + if os.Getenv("GO_INIT_ENV_CHILD") == "1" { + ctx, err := Init(t.Context(), Tunables{Transfers: 2, LowLevelRetries: 20}) + if err != nil { + t.Fatalf("Init: %v", err) + } + ci := fs.GetConfig(ctx) + if ci.Transfers != 7 { + t.Errorf("Transfers = %d, want the operator's 7", ci.Transfers) + } + if ci.LowLevelRetries != 99 { + t.Errorf("LowLevelRetries = %d, want the operator's 99", ci.LowLevelRetries) + } + return + } + + cmd := exec.Command(os.Args[0], "-test.run=TestInitLeavesEnvOverridesAlone", "-test.v") + cmd.Env = append(os.Environ(), + "GO_INIT_ENV_CHILD=1", + "RCLONE_TRANSFERS=7", + "RCLONE_LOW_LEVEL_RETRIES=99", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Errorf("Init overrode the operator's environment:\n%s", out) + } +} + +// And with nothing set, the pikpak-derived tunables are what apply. +func TestInitAppliesTunablesWhenTheEnvIsQuiet(t *testing.T) { + if os.Getenv("GO_INIT_QUIET_CHILD") == "1" { + ctx, err := Init(t.Context(), Tunables{Transfers: 2, LowLevelRetries: 20}) + if err != nil { + t.Fatalf("Init: %v", err) + } + ci := fs.GetConfig(ctx) + if ci.Transfers != 2 || ci.LowLevelRetries != 20 { + t.Errorf("Transfers=%d LowLevelRetries=%d, want 2 and 20", + ci.Transfers, ci.LowLevelRetries) + } + return + } + + cmd := exec.Command(os.Args[0], "-test.run=TestInitAppliesTunablesWhenTheEnvIsQuiet", "-test.v") + cmd.Env = append(os.Environ(), "GO_INIT_QUIET_CHILD=1") + // Cleared rather than assumed absent: the parent's own environment may + // carry them, which would make this assert the opposite of what it says. + cmd.Env = slices.DeleteFunc(cmd.Env, func(kv string) bool { + return strings.HasPrefix(kv, "RCLONE_TRANSFERS=") || + strings.HasPrefix(kv, "RCLONE_LOW_LEVEL_RETRIES=") + }) + if out, err := cmd.CombinedOutput(); err != nil { + t.Errorf("Init did not apply its tunables:\n%s", out) + } +} + +// unset removes a variable for the duration of the test, restoring it after. +func unset(t *testing.T, key string) { + t.Helper() + if old, ok := os.LookupEnv(key); ok { + t.Cleanup(func() { _ = os.Setenv(key, old) }) + } + _ = os.Unsetenv(key) +} diff --git a/internal/remote/index.go b/internal/remote/index.go new file mode 100644 index 0000000..c95af98 --- /dev/null +++ b/internal/remote/index.go @@ -0,0 +1,172 @@ +package remote + +import ( + "context" + "errors" + "fmt" + "path" + "slices" + + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/filter" + "github.com/rclone/rclone/fs/operations" + + "github.com/tiennm99dev/telegram-exporter/internal/naming" +) + +// object is what the index remembers about one stored file. +// +// Path is kept alongside the basename because acting on an object — deleting a +// stale copy, say — needs the path rclone knows it by, while matching needs the +// basename. Conflating the two makes a delete address the wrong file, or no file +// at all, whenever a remote has any directory structure. +type object struct { + path string + size int64 +} + +// Index is a snapshot of what a remote holds, keyed by filename. +// +// It answers one question — "is this exact name present, and how big is it?" — +// and it answers it from the whole name, never from a message id. That is the +// point: a file whose id matches but whose name does not is a different file, +// and treating it as present is precisely the bug this rewrite exists to remove. +// +// The id-keyed map below exists only to tell "absent" apart from "absent, but +// something else is stored under this message's id" when reporting. It is +// unexported, and the only exported route from an id to a name is +// StoredUnderOtherNames, which by construction excludes the wanted name — so no +// caller can get "the file you asked for is present" out of an id. +type Index struct { + byName map[string]object + byID map[int][]string + collisions []string +} + +// BuildIndex lists a remote once and indexes it. +// +// Object paths are reduced to their basename, so a remote written with a +// subdirectory layout matches the same way a flat one does — the shell verifier +// did this too, and existing archives rely on it. +// +// Listing runs with depth and filters neutralised. rclone's ListFn otherwise +// inherits whatever RCLONE_MAX_DEPTH or RCLONE_EXCLUDE happen to be set to, and +// a narrowed listing here does not fail — it silently reports archived files as +// absent and re-downloads every one of them. The transfer tunables in Init are +// deliberately env-overridable; this is not. +// +// Note that filter.NewFilter(nil) does NOT give a neutral filter: it copies the +// package-level filter.Opt (filter.go:198-201), which rclone has already +// populated from RCLONE_* at init via RegisterGlobalOptions. Passing nil here +// reproduces exactly the inherited filter this is trying to escape, which is +// why every field that can narrow a listing is set explicitly. A zero-value +// Options is not a substitute either — it fails validation, because MinAge and +// MaxAge both being 0 reads as "min > max". +// onCount, when non-nil, is called with the number of objects seen so far. +// Listing a remote of any size takes minutes and says nothing while it runs. +func BuildIndex(ctx context.Context, f fs.Fs, dialogID int64, onCount func(n int)) (*Index, error) { + ctx, ci := fs.AddConfig(ctx) + ci.MaxDepth = -1 + + unfiltered, err := filter.NewFilter(&filter.Options{ + MinAge: fs.DurationOff, + MaxAge: fs.DurationOff, + MinSize: -1, + MaxSize: -1, + }) + if err != nil { + return nil, fmt.Errorf("build an empty filter: %w", err) + } + if !unfiltered.InActive() { + // Cheap and worth keeping: this is the assertion whose absence let a + // no-op neutralisation stand. + return nil, fmt.Errorf("internal: listing filter is not neutral") + } + ctx = filter.ReplaceConfig(ctx, unfiltered) + + idx := &Index{ + byName: make(map[string]object), + byID: make(map[int][]string), + } + + // ListFn is documented not to call fn concurrently, so the maps need no lock. + seen := 0 + if err := operations.ListFn(ctx, f, func(o fs.Object) { + seen++ + if onCount != nil { + onCount(seen) + } + name := path.Base(o.Remote()) + + if _, seen := idx.byName[name]; seen { + // Two objects in different directories sharing a basename. Listing + // order is not guaranteed, so silently keeping one would make the + // verdict flip between runs — a zero-byte copy and a complete one + // would alternate. Keep the first and report the ambiguity instead. + if !slices.Contains(idx.collisions, name) { + idx.collisions = append(idx.collisions, name) + } + return + } + + idx.byName[name] = object{path: o.Remote(), size: o.Size()} + if id, ok := naming.SplitStored(dialogID, name); ok { + idx.byID[id] = append(idx.byID[id], name) + } + }); err != nil { + // A destination that does not exist yet holds nothing. That is an empty + // index, not a failure — it is what a first run against a new path looks + // like, and treating it as an error would make verify unusable there. + // + // Only when nothing was listed, though. rclone's walk records a failed + // directory and keeps going (walk.go:168-183), returning the error at the + // end, so this same error also means "one subdirectory could not be + // listed" — and swallowing that would return a partial index as + // authoritative, reporting everything under it absent. + if errors.Is(err, fs.ErrorDirNotFound) && len(idx.byName) == 0 { + return idx, nil + } + return nil, fmt.Errorf("list %s: %w", f.String(), err) + } + return idx, nil +} + +// Lookup reports the size stored under an exact name. +func (i *Index) Lookup(name string) (size int64, ok bool) { + o, ok := i.byName[name] + return o.size, ok +} + +// PathOf returns the remote path an indexed name was found at, which is what +// rclone needs to act on the object. It differs from the name whenever the +// remote has directory structure. +func (i *Index) PathOf(name string) (string, bool) { + o, ok := i.byName[name] + return o.path, ok +} + +// Len reports how many distinct names the remote held when the snapshot was +// taken. Objects dropped as basename collisions are not counted. +func (i *Index) Len() int { return len(i.byName) } + +// Collisions lists basenames that appeared at more than one path. A non-empty +// result means the snapshot is ambiguous and any verdict about those names is +// unreliable, so callers should surface it rather than ignore it. +func (i *Index) Collisions() []string { return i.collisions } + +// StoredUnderOtherNames lists names present for a message id that are not the +// wanted name. +// +// Diagnostics only. A non-empty result never means the file is archived — it +// means a stale copy from an earlier naming scheme is sitting there and will +// still be sitting there after the re-download, which is why the report has to +// surface it rather than quietly counting it. +func (i *Index) StoredUnderOtherNames(messageID int, wanted string) []string { + var others []string + for _, n := range i.byID[messageID] { + if n != wanted { + others = append(others, n) + } + } + return others +} diff --git a/internal/remote/index_env_test.go b/internal/remote/index_env_test.go new file mode 100644 index 0000000..d4a05a3 --- /dev/null +++ b/internal/remote/index_env_test.go @@ -0,0 +1,64 @@ +package remote + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + _ "github.com/rclone/rclone/backend/local" + "github.com/rclone/rclone/fs" +) + +// rclone reads RCLONE_* into its global config at package init, so an in-process +// t.Setenv is too late to prove anything. A subprocess is the only way to +// observe what BuildIndex actually does under an operator's environment — which +// is exactly why the original no-op neutralisation went unnoticed. +func TestBuildIndexIgnoresInheritedFilters(t *testing.T) { + if os.Getenv("GO_INDEX_ENV_CHILD") == "1" { + indexChild(t) + return + } + for _, env := range []string{ + "RCLONE_EXCLUDE=*.mp4", + "RCLONE_FILTER=- *.mp4", + "RCLONE_MIN_SIZE=1M", + "RCLONE_MAX_AGE=1h", + "RCLONE_MAX_DEPTH=1", + } { + t.Run(env, func(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=TestBuildIndexIgnoresInheritedFilters", "-test.v") + cmd.Env = append(os.Environ(), "GO_INDEX_ENV_CHILD=1", env) + out, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("%s narrowed the index:\n%s", env, out) + } + }) + } +} + +func indexChild(t *testing.T) { + dir := t.TempDir() + for _, rel := range []string{"a.mp4", "b.txt", "sub/c.mp4"} { + p := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + f, err := fs.NewFs(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + idx, err := BuildIndex(t.Context(), f, 1234567890, nil) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + for _, want := range []string{"a.mp4", "b.txt", "c.mp4"} { + if _, ok := idx.Lookup(want); !ok { + t.Errorf("%q missing from the index", want) + } + } +} diff --git a/internal/remote/index_test.go b/internal/remote/index_test.go new file mode 100644 index 0000000..eeb1b15 --- /dev/null +++ b/internal/remote/index_test.go @@ -0,0 +1,174 @@ +package remote + +import ( + "context" + "os" + "path/filepath" + "testing" + + _ "github.com/rclone/rclone/backend/local" + "github.com/rclone/rclone/fs" +) + +const testDialog = int64(1234567890) + +// localIndex builds an index over a temp directory using rclone's local +// backend, so BuildIndex is exercised through the same listing path a real +// remote uses rather than through a stub. +func localIndex(t *testing.T, files map[string]int) *Index { + t.Helper() + dir := t.TempDir() + for name, size := range files { + full := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir for %q: %v", name, err) + } + if err := os.WriteFile(full, make([]byte, size), 0o600); err != nil { + t.Fatalf("write %q: %v", name, err) + } + } + + ctx := context.Background() + f, err := fs.NewFs(ctx, dir) + if err != nil { + t.Fatalf("open local fs: %v", err) + } + idx, err := BuildIndex(ctx, f, testDialog, nil) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + return idx +} + +func TestLookupMatchesWholeNamesOnly(t *testing.T) { + idx := localIndex(t, map[string]int{ + "1234567890_4242_Pipe her! And by her, we mean pipeperr! 1080p.mp4": 4096, + "1234567890_14726_298.mp4": 2048, + }) + + if got, ok := idx.Lookup("1234567890_14726_298.mp4"); !ok || got != 2048 { + t.Errorf("Lookup(exact) = %d, %v; want 2048, true", got, ok) + } + + // The doubled '!' is the name Telegram reports; the remote holds the + // collapsed one that tdl's filenamify template wrote. Those are different + // files as far as this index is concerned, and that is the whole policy. + wanted := "1234567890_4242_Pipe her!! And by her, we mean pipeperr! 1080p.mp4" + if _, ok := idx.Lookup(wanted); ok { + t.Error("Lookup matched a near-miss name; presence must require an exact match") + } +} + +// A remote written with a subdirectory layout has to match a flat one, because +// existing archives were written both ways. +func TestBuildIndexReducesPathsToBasename(t *testing.T) { + idx := localIndex(t, map[string]int{ + "nested/dir/1234567890_42_deep.mp4": 512, + }) + if got, ok := idx.Lookup("1234567890_42_deep.mp4"); !ok || got != 512 { + t.Errorf("Lookup after basename reduction = %d, %v; want 512, true", got, ok) + } +} + +func TestStoredUnderOtherNamesFindsStaleCopies(t *testing.T) { + stale := "1234567890_4242_Pipe her! And by her, we mean pipeperr! 1080p.mp4" + idx := localIndex(t, map[string]int{stale: 4096}) + + wanted := "1234567890_4242_Pipe her!! And by her, we mean pipeperr! 1080p.mp4" + others := idx.StoredUnderOtherNames(4242, wanted) + if len(others) != 1 || others[0] != stale { + t.Fatalf("StoredUnderOtherNames = %v, want [%q]", others, stale) + } + + // The wanted name itself is never reported as an "other" name. + idx2 := localIndex(t, map[string]int{wanted: 4096}) + if others := idx2.StoredUnderOtherNames(4242, wanted); len(others) != 0 { + t.Errorf("StoredUnderOtherNames = %v, want empty when the wanted name is present", others) + } +} + +// Objects belonging to a different dialog, or not matching the stored layout at +// all, must not be indexed by id — otherwise an unrelated file could be reported +// as a stale copy of a message. +func TestStoredUnderOtherNamesIgnoresForeignObjects(t *testing.T) { + idx := localIndex(t, map[string]int{ + "999999_4242_other-dialog.mp4": 100, + "not-a-tdl-name.mp4": 100, + }) + if others := idx.StoredUnderOtherNames(4242, "1234567890_4242_x.mp4"); len(others) != 0 { + t.Errorf("StoredUnderOtherNames = %v, want empty", others) + } +} + +func TestIndexLenCountsEveryObject(t *testing.T) { + idx := localIndex(t, map[string]int{ + "1234567890_1_a.mp4": 1, + "1234567890_2_b.mp4": 1, + "unrelated.txt": 1, + }) + if idx.Len() != 3 { + t.Errorf("Len() = %d, want 3", idx.Len()) + } +} + +func TestBuildIndexOnEmptyRemote(t *testing.T) { + idx := localIndex(t, nil) + if idx.Len() != 0 { + t.Errorf("Len() = %d, want 0", idx.Len()) + } + if _, ok := idx.Lookup("anything"); ok { + t.Error("Lookup on an empty index reported a hit") + } +} + +// Two objects in different directories can share a basename. Listing order is +// not guaranteed, so silently keeping one would make the verdict flip between +// runs; the ambiguity has to be reported instead. +func TestBuildIndexReportsBasenameCollisions(t *testing.T) { + idx := localIndex(t, map[string]int{ + "a/1234567890_42_same.mp4": 100, + "b/1234567890_42_same.mp4": 0, + }) + + dup := idx.Collisions() + if len(dup) != 1 || dup[0] != "1234567890_42_same.mp4" { + t.Fatalf("Collisions() = %v, want the shared basename reported", dup) + } + if idx.Len() != 1 { + t.Errorf("Len() = %d, want 1 — a dropped collision must not be counted", idx.Len()) + } + + // The id map must not gain a duplicate entry either, or a delete would try + // the same name twice and fail the second time. + others := idx.StoredUnderOtherNames(42, "1234567890_42_wanted.mp4") + if len(others) != 1 { + t.Errorf("StoredUnderOtherNames = %v, want one entry, not a duplicate", others) + } +} + +// Acting on an object needs the path rclone knows it by, which differs from the +// basename used for matching whenever the remote has directory structure. +// Deleting by basename would miss the object, or hit the wrong one. +func TestPathOfReturnsTheFullRemotePath(t *testing.T) { + idx := localIndex(t, map[string]int{"nested/dir/1234567890_42_deep.mp4": 512}) + + const name = "1234567890_42_deep.mp4" + if _, ok := idx.Lookup(name); !ok { + t.Fatalf("Lookup(%q) missed; matching is by basename", name) + } + + got, ok := idx.PathOf(name) + if !ok { + t.Fatalf("PathOf(%q) reported no match", name) + } + if want := "nested/dir/" + name; got != want { + t.Errorf("PathOf(%q) = %q, want %q — deleting by basename would target the wrong path", name, got, want) + } +} + +func TestPathOfMissesUnknownNames(t *testing.T) { + idx := localIndex(t, nil) + if p, ok := idx.PathOf("absent.mp4"); ok { + t.Errorf("PathOf(absent) = %q, true; want no match", p) + } +} diff --git a/internal/report/live.go b/internal/report/live.go new file mode 100644 index 0000000..ec68f64 --- /dev/null +++ b/internal/report/live.go @@ -0,0 +1,241 @@ +package report + +import ( + "fmt" + "io" + "sync" + "time" + + "github.com/vbauerster/mpb/v8" + "github.com/vbauerster/mpb/v8/decor" + + "github.com/tiennm99dev/telegram-exporter/internal/pipeline" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// nameWidth is how much of a filename a per-file bar shows. Telegram names run +// to 100+ characters and the bar has to fit beside them. +const nameWidth = 34 + +// Live renders a run as a set of progress bars: one per leg, plus one for each +// file currently moving. +// +// The aggregate line it replaces could say how much was done but never what was +// happening — which files were in flight, whether a stall was a slow download or +// a slow upload, how long the rest would take. On a run measured in hours those +// are the only questions worth answering. +// +// Download and upload are counted separately because they run at different +// speeds and fail for different reasons. A single combined figure hides the one +// thing worth knowing when a run slows down: whether Telegram or the remote is +// the bottleneck. The two normally track each other a file or two apart; a +// widening gap is the remote falling behind, and staging filling up. +// +// Bars are for terminals only. A redirected run gets lineEvents instead, because +// this writes ANSI cursor movement continuously and a captured log of it is +// unreadable. +type Live struct { + w io.Writer + p *mpb.Progress + dlTotal *mpb.Bar + upTotal *mpb.Bar + + mu sync.Mutex + down map[int]*mpb.Bar + up map[int]*mpb.Bar + // The leg counters are read by the total bars' decorators from mpb's render + // goroutine, so they are guarded by the same lock as the maps. + legs legTotals + + // seq gives each per-file bar a distinct, increasing priority so bars keep + // their position between frames. Sharing one priority lets mpb reorder them + // on every redraw, which makes a steady transfer look like it is thrashing. + seq int + files int + start time.Time +} + +// LogWriter returns a writer whose lines are printed above the bars rather than +// through them. +// +// rclone logs straight to stderr on its own schedule, so without this its error +// lines land in the middle of a redraw and shred the display — which is exactly +// what a run full of pikpak commit failures looked like. +func (l *Live) LogWriter() io.Writer { return l.p } + +// NewLive builds a bar renderer over w for a run of the given size. +func NewLive(w io.Writer, files int, bytes int64) *Live { + p := mpb.New( + mpb.WithOutput(w), + mpb.WithWidth(28), + mpb.WithRefreshRate(120*time.Millisecond), + ) + l := &Live{ + w: w, + p: p, + down: make(map[int]*mpb.Bar), + up: make(map[int]*mpb.Bar), + files: files, + start: time.Now(), + } + l.dlTotal = l.leg(bytes, 0, " ↓ total", func() int { return l.legs.downCount() }) + l.upTotal = l.leg(bytes, 1, " ↑ total", func() int { return l.legs.upCount() }) + return l +} + +// leg builds one of the two whole-run bars. +func (l *Live) leg(bytes int64, priority int, label string, count func() int) *mpb.Bar { + return l.p.New(bytes, + mpb.BarStyle().Lbound("[").Filler("=").Tip(">").Padding(" ").Rbound("]"), + mpb.BarPriority(priority), + mpb.BarNoPop(), + mpb.PrependDecorators( + decor.Name(label+" ", decor.WC{W: 11}), + decor.Any(func(decor.Statistics) string { + return fmt.Sprintf("%s/%s files", humanCount(count()), humanCount(l.files)) + }, decor.WC{W: 16}), + ), + mpb.AppendDecorators( + decor.CountersKibiByte("% .1f / % .1f", decor.WC{W: 20}), + decor.AverageSpeed(decor.SizeB1024(0), " % .1f", decor.WC{W: 12}), + // Wide enough for the three-digit hour counts a slow remote + // produces; at W:10 the ETA ran into the speed beside it. + decor.OnComplete(decor.AverageETA(decor.ET_STYLE_GO, decor.WC{W: 13}), ""), + ), + ) +} + +// Bars are grouped by band: the two totals on top, then per-file downloads, +// then per-file uploads. Within a band they are ordered by when they started. +const ( + downloadBand = 1 << 20 + uploadBand = 1 << 21 +) + +// next allocates the priority for a new bar in the given band. +func (l *Live) next(band int) int { + l.mu.Lock() + defer l.mu.Unlock() + l.seq++ + return band + l.seq +} + +// Stats advances the download bar. +func (l *Live) Stats(s pipeline.Stats) { + l.legs.setDownload(s.Done+s.Failed, s.BytesDone) + // The bar's own counters, speed and ETA all derive from this, so it is what + // makes the total move rather than sitting at zero. + l.dlTotal.SetCurrent(s.BytesDone) +} + +func (l *Live) DownloadStart(it tgsource.Item) { + bar := l.p.New(it.Size(), + mpb.BarStyle().Lbound("[").Filler("=").Tip(">").Padding(" ").Rbound("]"), + mpb.BarRemoveOnComplete(), + mpb.BarPriority(l.next(downloadBand)), + mpb.PrependDecorators( + decor.Name(" ↓ "), + decor.Name(short(it.Name), decor.WC{W: nameWidth + 2, C: decor.DindentRight}), + ), + mpb.AppendDecorators( + decor.CountersKibiByte("% .1f / % .1f", decor.WC{W: 20}), + decor.AverageSpeed(decor.SizeB1024(0), " % .1f", decor.WC{W: 12}), + ), + ) + l.mu.Lock() + l.down[it.MessageID] = bar + l.mu.Unlock() +} + +func (l *Live) DownloadBytes(it tgsource.Item, done int64) { + l.mu.Lock() + bar := l.down[it.MessageID] + l.mu.Unlock() + if bar != nil { + bar.SetCurrent(done) + } +} + +func (l *Live) DownloadDone(it tgsource.Item, err error) { + l.mu.Lock() + bar := l.down[it.MessageID] + delete(l.down, it.MessageID) + l.mu.Unlock() + if bar == nil { + return + } + // Aborted rather than completed on failure, so a bar for a file that never + // arrived does not linger at 100%. + if err != nil { + bar.Abort(true) + return + } + bar.SetCurrent(it.Size()) +} + +func (l *Live) UploadStart(it tgsource.Item) { + // No byte-level callbacks exist for the upload leg — rclone's MoveFile is a + // single blocking call — so this is a spinner, not a bar. Showing which file + // is uploading is the point: a run that looks stalled is usually waiting on + // one large object, and until now nothing said so. + bar := l.p.New(0, mpb.SpinnerStyle().PositionLeft(), + mpb.BarRemoveOnComplete(), + mpb.BarPriority(l.next(uploadBand)), + mpb.PrependDecorators( + decor.Name(" ↑ "), + decor.Name(short(it.Name), decor.WC{W: nameWidth + 2, C: decor.DindentRight}), + ), + mpb.AppendDecorators( + decor.Any(func(decor.Statistics) string { + return fmt.Sprintf("uploading %s", humanBytes(it.Size())) + }, decor.WC{W: 24}), + ), + ) + l.mu.Lock() + l.up[it.MessageID] = bar + l.mu.Unlock() +} + +func (l *Live) UploadDone(it tgsource.Item, err error) { + l.mu.Lock() + bar := l.up[it.MessageID] + delete(l.up, it.MessageID) + l.mu.Unlock() + if bar != nil { + bar.Abort(true) + } + if err != nil { + return // nothing reached the remote, so the upload total does not move + } + l.upTotal.SetCurrent(l.legs.addUpload(it.Size())) +} + +// Finish drains the bars and prints the closing summary. +func (l *Live) Finish(s pipeline.Stats) { + l.mu.Lock() + for _, b := range l.down { + b.Abort(true) + } + for _, b := range l.up { + b.Abort(true) + } + clear(l.down) + clear(l.up) + l.mu.Unlock() + + l.dlTotal.Abort(true) + l.upTotal.Abort(true) + l.p.Wait() + writeSummary(l.w, s, time.Since(l.start)) +} + +// short trims a filename to fit beside a bar, keeping the end — the extension +// and the distinguishing digits are there, while the shared dialog-id prefix is +// not. +func short(name string) string { + r := []rune(name) + if len(r) <= nameWidth { + return name + } + return "…" + string(r[len(r)-nameWidth+1:]) +} diff --git a/internal/report/progress.go b/internal/report/progress.go new file mode 100644 index 0000000..ee726ee --- /dev/null +++ b/internal/report/progress.go @@ -0,0 +1,186 @@ +// 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" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// 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 as periodic plain-text lines. +// +// This is the redirected-output path. Bars are deliberately absent: they are +// continuous ANSI cursor movement, and a captured log of them is megabytes of +// control characters — which is exactly what tdl's progress bar did to the shell +// pipeline's logs. +type Reporter struct { + w io.Writer + total int + totalBytes int64 + + legs legTotals + + mu sync.Mutex + started time.Time + lastLine time.Time +} + +// Events builds the renderer suited to w: bars on a terminal, periodic lines +// anywhere else. +// +// 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 Events(w io.Writer, total int, totalBytes int64) interface { + pipeline.Events + Finish(pipeline.Stats) +} { + if isTerminal(w) { + return NewLive(w, total, totalBytes) + } + return newReporter(w, total, totalBytes) +} + +func newReporter(w io.Writer, total int, totalBytes int64) *Reporter { + return &Reporter{w: w, total: total, totalBytes: totalBytes, started: time.Now()} +} + +// Stats renders a snapshot. Safe to call from several goroutines. +// +// A contended update is dropped rather than queued. Every download worker calls +// this on each progress callback, so holding the lock across the write would +// make write latency throttle the downloads themselves. A skipped frame costs +// nothing; the next callback is milliseconds away and Finish always prints. +func (r *Reporter) Stats(s pipeline.Stats) { + r.legs.setDownload(s.Done+s.Failed, s.BytesDone) + + if !r.mu.TryLock() { + return + } + defer r.mu.Unlock() + + now := time.Now() + if now.Sub(r.lastLine) < statsInterval { + return + } + r.lastLine = now + for _, line := range r.lines(now) { + fmt.Fprintf(r.w, "%s\n", line) + } +} + +// The per-file events are recorded as one line each rather than a bar. At one +// line per file this stays readable in a log, and it is what makes a captured +// run auditable afterwards: which files moved, in what order, and which failed. +func (r *Reporter) DownloadStart(tgsource.Item) {} +func (r *Reporter) DownloadBytes(tgsource.Item, int64) {} +func (r *Reporter) UploadStart(tgsource.Item) {} + +func (r *Reporter) DownloadDone(it tgsource.Item, err error) { + if err != nil { + r.mu.Lock() + defer r.mu.Unlock() + fmt.Fprintf(r.w, " download failed %q: %v\n", it.Name, err) + } +} + +func (r *Reporter) UploadDone(it tgsource.Item, err error) { + if err == nil { + r.legs.addUpload(it.Size()) + } + r.mu.Lock() + defer r.mu.Unlock() + if err != nil { + fmt.Fprintf(r.w, " upload failed %q: %v\n", it.Name, err) + return + } + fmt.Fprintf(r.w, " archived %-10s %q\n", humanBytes(it.Size()), it.Name) +} + +// Finish writes the closing summary. +func (r *Reporter) Finish(s pipeline.Stats) { + r.mu.Lock() + defer r.mu.Unlock() + writeSummary(r.w, s, time.Since(r.started)) +} + +// lines renders one line per leg. Separately, because the two run at different +// speeds and a single combined figure hides which of them is the bottleneck — +// the question actually being asked when a run slows down. +func (r *Reporter) lines(now time.Time) []string { + elapsed := now.Sub(r.started) + dlFiles, dlBytes, upFiles, upBytes := r.legs.snapshot() + return []string{ + r.leg("download", dlFiles, dlBytes, elapsed), + r.leg("upload ", upFiles, upBytes, elapsed), + } +} + +func (r *Reporter) leg(label string, files int, bytes int64, elapsed time.Duration) string { + rate := float64(bytes) / max(elapsed.Seconds(), 1) + eta := "—" + if rate > 0 && r.totalBytes > bytes { + eta = time.Duration(float64(r.totalBytes-bytes) / rate * float64(time.Second)). + Round(time.Second).String() + } + return fmt.Sprintf(" %s %s/%s files, %s of %s, %s/s, ETA %s", + label, humanCount(files), humanCount(r.total), + humanBytes(bytes), humanBytes(r.totalBytes), humanBytes(int64(rate)), eta) +} + +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + // The unit table runs to exabytes so the index cannot escape it. A PiB is + // not reachable from a Telegram chat, but a panic in the progress line would + // take down a run that was working. + const units = "KMGTPE" + div, exp := int64(unit), 0 + for v := n / unit; v >= unit && exp < len(units)-1; v /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), units[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 +} + +// writeSummary prints the closing line both renderers end with. +func writeSummary(w io.Writer, s pipeline.Stats, elapsed time.Duration) { + elapsed = elapsed.Round(time.Second) + fmt.Fprintf(w, "%s done, %s failed, %s in %s (%s/s)\n", + humanCount(s.Done), humanCount(s.Failed), humanBytes(s.BytesDone), elapsed, + humanBytes(int64(float64(s.BytesDone)/max(elapsed.Seconds(), 1)))) +} + +// HumanBytes and HumanCount are the shared formatters, exported so the commands +// print the same shapes as the progress renderers do. +func HumanBytes(n int64) string { return humanBytes(n) } +func HumanCount(n int) string { return humanCount(n) } diff --git a/internal/report/rclonelog.go b/internal/report/rclonelog.go new file mode 100644 index 0000000..8f177a8 --- /dev/null +++ b/internal/report/rclonelog.go @@ -0,0 +1,30 @@ +package report + +import ( + "context" + "io" + "log/slog" + "os" + + "github.com/rclone/rclone/fs" + rclonelog "github.com/rclone/rclone/fs/log" +) + +// CaptureRcloneLog routes rclone's own log lines to w. +// +// rclone writes to stderr through its private logger on its own schedule +// (fs/log/slog.go:43 installs a stderr handler via fs.SetLogger). While bars are +// drawing, that lands mid-redraw and shreds the display — a run hitting a series +// of pikpak commit failures became unreadable. Pointing the logger at the +// progress writer makes each line scroll above the bars instead. +// +// The returned function puts the logger back on stderr. rclone exposes no getter +// for the current handler, so this restores the default rather than whatever was +// there before; nothing in this program installs a third one. +func CaptureRcloneLog(ctx context.Context, w io.Writer) (restore func()) { + level := fs.LogLevelToSlog(fs.GetConfig(ctx).LogLevel) + fs.SetLogger(rclonelog.NewOutputHandler(w, &slog.HandlerOptions{Level: level}, 0)) + return func() { + fs.SetLogger(rclonelog.NewOutputHandler(os.Stderr, &slog.HandlerOptions{Level: level}, 0)) + } +} diff --git a/internal/report/summary.go b/internal/report/summary.go new file mode 100644 index 0000000..f8be334 --- /dev/null +++ b/internal/report/summary.go @@ -0,0 +1,61 @@ +package report + +import ( + "fmt" + "io" + + "github.com/tiennm99dev/telegram-exporter/internal/verify" +) + +// writeSurvey states what the chat holds and how much of it is already archived, +// broken out by why each outstanding file is outstanding. The single "N to +// fetch" it replaces hid the difference between never-fetched, empty, +// wrong-size, and unarchivable — which is the difference between a run that will +// converge and one that cannot. +func Survey(w io.Writer, r verify.Report) { + fmt.Fprintf(w, "\n chat holds %s media, %s\n", + humanCount(r.Expected), humanBytes(r.Bytes)) + fmt.Fprintf(w, " archived %s\n", humanCount(r.Present)) + + for _, row := range []struct { + label string + n int + }{ + {"never fetched", len(r.Absent) - len(r.Unsafe)}, + {"zero-byte", len(r.ZeroByte)}, + {"wrong size", len(r.Mismatched)}, + {"unarchivable", len(r.Unsafe)}, + } { + if row.n > 0 { + fmt.Fprintf(w, " %-13s %s\n", row.label, humanCount(row.n)) + } + } +} + +// PlanInfo is what the run is about to do, stated before it starts. +type PlanInfo struct { + Files int + Bytes int64 + Largest int64 + Budget int64 + Staging string + Threads int + Downloads, Uploads int + Destination string +} + +// writePlan prints the settings that decide how long the run takes and how much +// disk it uses, so an operator can stop it before a multi-hour transfer rather +// than discover the wrong cap partway through. +func Plan(w io.Writer, p PlanInfo) { + cap := "uncapped" + if p.Budget > 0 { + cap = "capped at " + humanBytes(p.Budget) + } + fmt.Fprintf(w, "\n fetching %s files, %s (largest %s)\n", + humanCount(p.Files), humanBytes(p.Bytes), humanBytes(p.Largest)) + fmt.Fprintf(w, " into %s\n", p.Destination) + fmt.Fprintf(w, " staging %s, %s\n", p.Staging, cap) + fmt.Fprintf(w, " concurrency %d download(s) x %d thread(s), %d upload(s)\n\n", + p.Downloads, p.Threads, p.Uploads) +} diff --git a/internal/report/summary_test.go b/internal/report/summary_test.go new file mode 100644 index 0000000..692b6fd --- /dev/null +++ b/internal/report/summary_test.go @@ -0,0 +1,95 @@ +package report + +import ( + "strings" + "testing" + + "github.com/iyear/tdl/core/tmedia" + + "github.com/tiennm99dev/telegram-exporter/internal/pipeline" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" + "github.com/tiennm99dev/telegram-exporter/internal/verify" +) + +func TestSurveyBreaksOutdWhyFilesAreOutstanding(t *testing.T) { + r := verify.Report{Expected: 12000, Present: 11400, Bytes: 518 << 30} + r.Absent = append(r.Absent, 1, 2, 3) + r.ZeroByte = append(r.ZeroByte, 4) + r.Mismatched = append(r.Mismatched, verify.Mismatch{MessageID: 5}) + r.Unsafe = append(r.Unsafe, verify.Unsafe{MessageID: 3}) + + var sb strings.Builder + Survey(&sb, r) + out := sb.String() + + for _, want := range []string{"12,000 media", "11,400", "zero-byte", "wrong size", "unarchivable"} { + if !strings.Contains(out, want) { + t.Errorf("survey missing %q:\n%s", want, out) + } + } + // Unsafe ids are inside Absent, so counting both would double-count them and + // the rows would not add up to the outstanding total. + if !strings.Contains(out, "never fetched 2") { + t.Errorf("never-fetched should exclude the unarchivable id:\n%s", out) + } +} + +func TestSurveyOmitsEmptyRows(t *testing.T) { + var sb strings.Builder + Survey(&sb, verify.Report{Expected: 10, Present: 10}) + if strings.Contains(sb.String(), "zero-byte") { + t.Errorf("a clean archive should list no failure rows:\n%s", sb.String()) + } +} + +func TestPlanStatesTheCap(t *testing.T) { + var sb strings.Builder + Plan(&sb, PlanInfo{Files: 2613, Bytes: 79 << 30, Largest: 2 << 30, + Staging: "./staging", Threads: 4, Downloads: 2, Uploads: 2, Destination: "remote:x"}) + if !strings.Contains(sb.String(), "uncapped") { + t.Errorf("a run with no budget must say so:\n%s", sb.String()) + } + + sb.Reset() + Plan(&sb, PlanInfo{Files: 1, Budget: 40 << 30, Staging: "./staging", Destination: "remote:x"}) + if !strings.Contains(sb.String(), "40.0 GiB") { + t.Errorf("plan should state the cap:\n%s", sb.String()) + } +} + +// The redirected path must stay free of cursor movement: bars in a captured log +// are megabytes of control characters, which is what tdl's progress bar did to +// the shell pipeline's logs. +func TestReporterWritesNoAnsi(t *testing.T) { + var sb strings.Builder + r := newReporter(&sb, 3, 300) + it := tgsource.Item{MessageID: 1, Name: "a.mp4", Media: &tmedia.Media{Size: 100}} + + r.DownloadStart(it) + for i := range 500 { + r.DownloadBytes(it, int64(i)) + r.Stats(pipeline.Stats{Done: 1, BytesDone: int64(i)}) + } + r.DownloadDone(it, nil) + r.UploadStart(it) + r.UploadDone(it, nil) + r.Finish(pipeline.Stats{Done: 1, BytesDone: 100}) + + out := sb.String() + if strings.ContainsAny(out, "\r\033") { + t.Errorf("redirected output contains control characters: %q", out) + } + // A completed file is still recorded, so a captured run says what moved. + if !strings.Contains(out, "archived") || !strings.Contains(out, "a.mp4") { + t.Errorf("redirected output should record each archived file:\n%s", out) + } +} + +// Events picks the renderer from the writer: a strings.Builder is not a +// terminal, so it must never get bars. +func TestEventsChoosesTheLineRendererOffTerminal(t *testing.T) { + var sb strings.Builder + if _, ok := Events(&sb, 1, 1).(*Reporter); !ok { + t.Error("a non-terminal writer got the bar renderer") + } +} diff --git a/internal/report/ticker.go b/internal/report/ticker.go new file mode 100644 index 0000000..c674521 --- /dev/null +++ b/internal/report/ticker.go @@ -0,0 +1,89 @@ +package report + +import ( + "fmt" + "io" + "sync" + "time" +) + +// tickerInterval is how often a terminal redraws a phase counter. Fast enough +// to look alive, slow enough not to matter. +const tickerInterval = 250 * time.Millisecond + +// Ticker reports progress through a long phase that would otherwise be silent. +// +// Reading a chat's history and listing a remote each take minutes on an archive +// of any size, and both used to print nothing between their opening line and +// their result. A run that is working looked identical to one that had hung, so +// the only way to tell was to wait it out. +// +// Cadence follows the same rule as Reporter: a terminal gets a redrawn line, a +// redirected run gets a periodic one, because ANSI redraws turn a captured log +// into megabytes of control characters. +type Ticker struct { + w io.Writer + tty bool + noun string + start time.Time + + mu sync.Mutex + lastLine time.Time +} + +// NewTicker builds a ticker that counts noun, e.g. "messages" or "objects". +func NewTicker(w io.Writer, noun string) *Ticker { + return &Ticker{w: w, tty: isTerminal(w), noun: noun, start: time.Now()} +} + +// Update reports a running count. Safe to call from several goroutines, and +// cheap enough to call per item. +func (t *Ticker) Update(n int) { + if !t.mu.TryLock() { + return + } + defer t.mu.Unlock() + + now := time.Now() + interval := statsInterval + if t.tty { + interval = tickerInterval + } + if now.Sub(t.lastLine) < interval { + return + } + t.lastLine = now + + if t.tty { + fmt.Fprintf(t.w, "\r\033[K %s %s...", humanCount(n), t.noun) + return + } + fmt.Fprintf(t.w, " %s %s...\n", humanCount(n), t.noun) +} + +// Done clears the redrawn line and states the final count. +func (t *Ticker) Done(n int) { + t.mu.Lock() + defer t.mu.Unlock() + if t.tty { + fmt.Fprint(t.w, "\r\033[K") + } + fmt.Fprintf(t.w, " %s %s in %s\n", humanCount(n), t.noun, + time.Since(t.start).Round(time.Second)) +} + +// humanCount groups thousands, so 12000 reads as 12,000. +func humanCount(n int) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + out := make([]byte, 0, len(s)+len(s)/3) + for i, c := range []byte(s) { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, c) + } + return string(out) +} diff --git a/internal/report/ticker_test.go b/internal/report/ticker_test.go new file mode 100644 index 0000000..66b3ffa --- /dev/null +++ b/internal/report/ticker_test.go @@ -0,0 +1,59 @@ +package report + +import ( + "strings" + "testing" + "time" +) + +func TestHumanCountGroupsThousands(t *testing.T) { + cases := map[int]string{ + 0: "0", 7: "7", 999: "999", 1000: "1,000", + 12000: "12,000", 11406: "11,406", 1234567: "1,234,567", + } + for in, want := range cases { + if got := humanCount(in); got != want { + t.Errorf("humanCount(%d) = %q, want %q", in, got, want) + } + } +} + +// A redirected run must not accumulate ANSI redraws: that is what turned the +// shell pipeline's captured logs into megabytes of control characters. +func TestTickerWritesNoAnsiWhenRedirected(t *testing.T) { + var sb strings.Builder + tick := NewTicker(&sb, "messages read") + + for i := 1; i <= 5000; i++ { + tick.Update(i) + } + tick.Done(5000) + + out := sb.String() + if strings.ContainsAny(out, "\r\033") { + t.Errorf("redirected output contains control characters: %q", out) + } + if !strings.Contains(out, "5,000 messages read") { + t.Errorf("final count missing from %q", out) + } +} + +// Update is called once per message on an 18k-message walk, so it has to be +// cheap: at most one line per interval, however often it is called. +func TestTickerThrottlesUpdates(t *testing.T) { + var sb strings.Builder + tick := NewTicker(&sb, "objects listed") + tick.lastLine = time.Now() // inside the interval from the start + + for i := 1; i <= 10000; i++ { + tick.Update(i) + } + if n := strings.Count(sb.String(), "\n"); n != 0 { + t.Errorf("wrote %d lines inside one interval, want 0", n) + } + + tick.Done(10000) + if !strings.Contains(sb.String(), "10,000 objects listed in") { + t.Errorf("Done did not state the total: %q", sb.String()) + } +} diff --git a/internal/report/totals.go b/internal/report/totals.go new file mode 100644 index 0000000..1db72ca --- /dev/null +++ b/internal/report/totals.go @@ -0,0 +1,54 @@ +package report + +import "sync" + +// legTotals counts what each half of the run has moved. +// +// The two legs are tracked separately because they report differently: the +// downloader emits byte-level progress, while an upload is one blocking rclone +// call that only reports on completion. Keeping the counters here rather than in +// each renderer means the terminal and the log agree on the same numbers. +type legTotals struct { + mu sync.Mutex + dlFiles int + dlBytes int64 + upFiles int + upBytes int64 +} + +// setDownload records the downloader's running totals. +func (t *legTotals) setDownload(files int, bytes int64) { + t.mu.Lock() + defer t.mu.Unlock() + t.dlFiles, t.dlBytes = files, bytes +} + +// addUpload records one file that reached the remote and returns the new total. +// A whole file at a time is all that is knowable: rclone's MoveFile does not +// report progress within a transfer. +func (t *legTotals) addUpload(size int64) int64 { + t.mu.Lock() + defer t.mu.Unlock() + t.upFiles++ + t.upBytes += size + return t.upBytes +} + +func (t *legTotals) downCount() int { + t.mu.Lock() + defer t.mu.Unlock() + return t.dlFiles +} + +func (t *legTotals) upCount() int { + t.mu.Lock() + defer t.mu.Unlock() + return t.upFiles +} + +// snapshot returns both legs at once, so a line rendered from it is consistent. +func (t *legTotals) snapshot() (dlFiles int, dlBytes int64, upFiles int, upBytes int64) { + t.mu.Lock() + defer t.mu.Unlock() + return t.dlFiles, t.dlBytes, t.upFiles, t.upBytes +} diff --git a/internal/tdlkv/bolt.go b/internal/tdlkv/bolt.go new file mode 100644 index 0000000..9d1704c --- /dev/null +++ b/internal/tdlkv/bolt.go @@ -0,0 +1,140 @@ +// Package tdlkv opens the key-value store the tdl CLI writes, so this binary can +// reuse an existing `tdl login` session instead of introducing a second auth path. +// +// Layout, as implemented by tdl's bolt driver: the configured storage path is a +// directory, each namespace is a file inside it named after the namespace, and +// inside that file every value lives in a single bucket, also named after the +// namespace. So the default session is `/default`, bucket `default`. +// (The `data.kv` file some installs also carry belongs to tdl's older single-file +// driver and is not read here.) +// +// The store is opened read-write, not read-only, and that is not incidental: +// gotd rewrites the session blob whenever it establishes or re-establishes a +// connection, so an ordinary run does write here. Two consequences follow. +// First, bolt holds an exclusive file lock for as long as the store is open, so +// this binary and the `tdl` CLI cannot run against the same namespace at once — +// for a `doctor` that is a moment, for a long archive run it is the whole run. +// Second, the file is shared with a separately built binary: bbolt's on-disk +// format is stable across the versions involved (tdl pins v1.3.10, this module +// v1.5.0) and the default freelist type matches, so the sharing is safe, but a +// future bbolt major would need checking rather than assuming. +package tdlkv + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "go.etcd.io/bbolt" + + "github.com/iyear/tdl/core/storage" +) + +// lockTimeout bounds how long we wait for bolt's exclusive file lock. tdl holds +// that lock for its whole run, so without a timeout a concurrent `tdl` process +// makes this binary hang with no explanation. Three seconds is long enough to +// ride out a lock being handed over and short enough to fail fast. +const lockTimeout = 3 * time.Second + +// DefaultDir returns tdl's default storage directory. +func DefaultDir() string { + home, err := os.UserHomeDir() + if err != nil { + return ".tdl/data" + } + return filepath.Join(home, ".tdl", "data") +} + +// Store is a storage.Storage backed by one namespace of tdl's bolt store. +type Store struct { + db *bbolt.DB + bucket []byte +} + +// Open opens the namespace `ns` under the bolt directory `dir`. +// +// The namespace file must already exist: this binary never creates a session, +// it only reads the one `tdl login` produced. Creating it here would silently +// hand back an empty store and surface later as a confusing auth failure. +func Open(dir, ns string) (*Store, error) { + if ns == "" { + return nil, fmt.Errorf("namespace is required") + } + + path := filepath.Join(dir, ns) + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("no tdl session at %s — run `tdl login%s` first", + path, nsFlag(ns)) + } + return nil, fmt.Errorf("stat %s: %w", path, err) + } + + db, err := bbolt.Open(path, 0o600, &bbolt.Options{Timeout: lockTimeout}) + if err != nil { + if errors.Is(err, bbolt.ErrTimeout) { + return nil, fmt.Errorf("tdl session %s is locked by another process "+ + "(a running `tdl` or `tgexport`); stop it and retry", path) + } + return nil, fmt.Errorf("open %s: %w", path, err) + } + + s := &Store{db: db, bucket: []byte(ns)} + + // Fail here rather than on the first Get: a namespace file without its + // bucket is a store tdl never finished writing. + if err := db.View(func(tx *bbolt.Tx) error { + if tx.Bucket(s.bucket) == nil { + return fmt.Errorf("no bucket %q in %s — session looks incomplete, re-run `tdl login%s`", + ns, path, nsFlag(ns)) + } + return nil + }); err != nil { + _ = db.Close() + return nil, err + } + + return s, nil +} + +func nsFlag(ns string) string { + if ns == "default" { + return "" + } + return " -n " + ns +} + +func (s *Store) Get(_ context.Context, key string) ([]byte, error) { + var val []byte + if err := s.db.View(func(tx *bbolt.Tx) error { + // bbolt only guarantees a value is valid for the life of its + // transaction, so copy before returning it. + if v := tx.Bucket(s.bucket).Get([]byte(key)); v != nil { + val = append([]byte(nil), v...) + } + return nil + }); err != nil { + return nil, err + } + if val == nil { + return nil, storage.ErrNotFound + } + return val, nil +} + +func (s *Store) Set(_ context.Context, key string, value []byte) error { + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(s.bucket).Put([]byte(key), value) + }) +} + +func (s *Store) Delete(_ context.Context, key string) error { + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(s.bucket).Delete([]byte(key)) + }) +} + +func (s *Store) Close() error { return s.db.Close() } diff --git a/internal/tdlkv/bolt_test.go b/internal/tdlkv/bolt_test.go new file mode 100644 index 0000000..8476f49 --- /dev/null +++ b/internal/tdlkv/bolt_test.go @@ -0,0 +1,179 @@ +package tdlkv + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "go.etcd.io/bbolt" + + "github.com/iyear/tdl/core/storage" +) + +// newFixture writes a bolt file laid out the way tdl's driver lays one out: +// file named after the namespace, single bucket of the same name. +func newFixture(t *testing.T, dir, ns string, pairs map[string]string) { + t.Helper() + db, err := bbolt.Open(filepath.Join(dir, ns), 0o600, nil) + if err != nil { + t.Fatalf("create fixture: %v", err) + } + defer func() { _ = db.Close() }() + + if err := db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(ns)) + if err != nil { + return err + } + for k, v := range pairs { + if err := b.Put([]byte(k), []byte(v)); err != nil { + return err + } + } + return nil + }); err != nil { + t.Fatalf("seed fixture: %v", err) + } +} + +func TestOpenReadsTdlLayout(t *testing.T) { + dir := t.TempDir() + newFixture(t, dir, "default", map[string]string{"app": "desktop"}) + + s, err := Open(dir, "default") + if err != nil { + t.Fatalf("Open: %v", err) + } + defer func() { _ = s.Close() }() + + got, err := s.Get(context.Background(), "app") + if err != nil { + t.Fatalf("Get: %v", err) + } + if string(got) != "desktop" { + t.Errorf("Get(app) = %q, want %q", got, "desktop") + } +} + +// A missing key must be distinguishable from an error, because core/storage +// consumers branch on ErrNotFound to fall back to defaults. +func TestGetMissingKeyReturnsErrNotFound(t *testing.T) { + dir := t.TempDir() + newFixture(t, dir, "default", nil) + + s, err := Open(dir, "default") + if err != nil { + t.Fatalf("Open: %v", err) + } + defer func() { _ = s.Close() }() + + if _, err := s.Get(context.Background(), "absent"); !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Get(absent) error = %v, want storage.ErrNotFound", err) + } +} + +func TestSetAndDeleteRoundTrip(t *testing.T) { + dir := t.TempDir() + newFixture(t, dir, "default", nil) + + s, err := Open(dir, "default") + if err != nil { + t.Fatalf("Open: %v", err) + } + defer func() { _ = s.Close() }() + + ctx := context.Background() + if err := s.Set(ctx, "session", []byte("blob")); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := s.Get(ctx, "session") + if err != nil || string(got) != "blob" { + t.Fatalf("Get after Set = %q, %v", got, err) + } + if err := s.Delete(ctx, "session"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := s.Get(ctx, "session"); !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Get after Delete error = %v, want storage.ErrNotFound", err) + } +} + +// The three failure modes must stay distinguishable in the message, because +// each one tells the operator to do something different. +func TestOpenFailureMessages(t *testing.T) { + t.Run("missing namespace file", func(t *testing.T) { + _, err := Open(t.TempDir(), "default") + if err == nil { + t.Fatal("expected an error for a missing session file") + } + if !strings.Contains(err.Error(), "tdl login") { + t.Errorf("error should point at `tdl login`, got: %v", err) + } + }) + + t.Run("non-default namespace names the flag", func(t *testing.T) { + _, err := Open(t.TempDir(), "second") + if err == nil { + t.Fatal("expected an error for a missing session file") + } + if !strings.Contains(err.Error(), "-n second") { + t.Errorf("error should name the namespace flag, got: %v", err) + } + }) + + t.Run("file present but bucket missing", func(t *testing.T) { + dir := t.TempDir() + db, err := bbolt.Open(filepath.Join(dir, "default"), 0o600, nil) + if err != nil { + t.Fatalf("create empty db: %v", err) + } + _ = db.Close() + + if _, err := Open(dir, "default"); err == nil { + t.Fatal("expected an error for a bucketless store") + } else if !strings.Contains(err.Error(), "incomplete") { + t.Errorf("error should call the session incomplete, got: %v", err) + } + }) + + t.Run("empty namespace rejected", func(t *testing.T) { + if _, err := Open(t.TempDir(), ""); err == nil { + t.Fatal("expected an error for an empty namespace") + } + }) +} + +// Open must not create a session that does not exist: silently handing back an +// empty store would surface much later as a confusing auth failure. +func TestOpenDoesNotCreateMissingStore(t *testing.T) { + dir := t.TempDir() + if _, err := Open(dir, "default"); err == nil { + t.Fatal("expected an error") + } + if _, err := os.Stat(filepath.Join(dir, "default")); !os.IsNotExist(err) { + t.Errorf("Open created %s; it must never create a session file", filepath.Join(dir, "default")) + } +} + +// A second opener must be told the store is locked rather than hanging. +func TestOpenReportsLockContention(t *testing.T) { + dir := t.TempDir() + newFixture(t, dir, "default", nil) + + first, err := Open(dir, "default") + if err != nil { + t.Fatalf("first Open: %v", err) + } + defer func() { _ = first.Close() }() + + _, err = Open(dir, "default") + if err == nil { + t.Fatal("expected the second Open to fail while the first holds the lock") + } + if !strings.Contains(err.Error(), "locked by another process") { + t.Errorf("error should name lock contention, got: %v", err) + } +} diff --git a/internal/tgsource/chat.go b/internal/tgsource/chat.go new file mode 100644 index 0000000..9e913c4 --- /dev/null +++ b/internal/tgsource/chat.go @@ -0,0 +1,190 @@ +package tgsource + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/gotd/td/telegram/peers" + + "github.com/iyear/tdl/core/util/tutil" +) + +// botAPIID matches a Bot API chat id: the same channel as an MTProto id, but +// with a -100 prefix that MTProto itself does not use. +var botAPIID = regexp.MustCompile(`^-100(\d+)$`) + +// telegramHosts are the hosts Telegram deep links use. gotd accepts all three +// (telegram/deeplink/deeplink.go hasTelegramPrefix), so checking only t.me would +// let a telegram.me or telegram.dog message link through — and gotd's parser +// keeps just the domain and silently drops the message number, which would walk +// an entire chat when the operator asked for one message. +var telegramHosts = []string{"t.me/", "telegram.me/", "telegram.dog/"} + +// isMessageLink reports whether s points at a single message rather than a chat. +// +// This is parsed rather than pattern-matched because the two HTTPS shapes overlap +// in a way a regex gets wrong: a private link is t.me/c// and a public +// one is t.me//, so "t.me/c/1234567890" — a private channel link — +// looks exactly like a public message link with the username "c". The +// distinction is whether a trailing numeric component follows the chat, and +// where that component sits depends on the leading marker. +func isMessageLink(s string) bool { + lower := strings.ToLower(s) + + // tg:// links name the message in a query parameter rather than the path. + if strings.HasPrefix(lower, "tg://") { + for _, key := range []string{"post=", "message_id="} { + if strings.Contains(lower, "?"+key) || strings.Contains(lower, "&"+key) { + return true + } + } + return false + } + + rest, ok := afterHost(lower) + if !ok { + return false + } + rest, _, _ = strings.Cut(rest, "?") + rest, _, _ = strings.Cut(rest, "#") + + parts := strings.Split(strings.Trim(rest, "/"), "/") + if len(parts) >= 1 && (parts[0] == "c" || parts[0] == "s") { + // Both put the chat in the second component, so a message is the third: + // c// and s//. + return len(parts) >= 3 && isDigits(parts[2]) + } + // t.me/joinchat/ is a chat, and its second component is not a bare + // number — except for a hypothetical all-digit invite hash, which is not + // worth mis-parsing every real link to guard. + if len(parts) >= 1 && parts[0] == "joinchat" { + return false + } + return len(parts) >= 2 && isDigits(parts[1]) +} + +// linkChat extracts the chat from a link whose first path component is a marker +// rather than the chat itself. +// +// Without this the marker *is* the chat as far as the resolver is concerned. +// gotd's deeplink parser takes the first path component as the domain and drops +// the rest (deeplink.go:106-148), and ValidateDomain accepts a single letter, so +// "t.me/s/mychannel" resolves the username "s" — either a hard-to-read +// USERNAME_NOT_OCCUPIED, or, since one-character usernames exist, somebody +// else's chat archived into the operator's remote. +// +// t.me/s/ is the preview page for a public channel, and the form most +// likely to be copied out of a browser. t.me/c/ carries the bare MTProto +// channel id — the same value a -100 Bot API id strips to. +func linkChat(s string) (string, bool) { + lower := strings.ToLower(s) + if strings.HasPrefix(lower, "tg://") { + return "", false + } + i, ok := hostEnd(lower) + if !ok { + return "", false + } + rest := s[i:] + rest, _, _ = strings.Cut(rest, "?") + rest, _, _ = strings.Cut(rest, "#") + + parts := strings.Split(strings.Trim(rest, "/"), "/") + if len(parts) < 2 || parts[1] == "" { + return "", false + } + switch strings.ToLower(parts[0]) { + case "c": + if isDigits(parts[1]) { + return parts[1], true + } + case "s": + return parts[1], true + } + return "", false +} + +// afterHost returns the path following a Telegram host, if s names one. +func afterHost(lower string) (string, bool) { + i, ok := hostEnd(lower) + if !ok { + return "", false + } + return lower[i:], true +} + +// hostEnd returns the offset just past a Telegram host in an already-lowercased +// string. Offsets rather than a substring, so a caller can slice the original +// and keep the chat's real case. +func hostEnd(lower string) (int, bool) { + for _, host := range telegramHosts { + if i := strings.Index(lower, host); i >= 0 { + return i + len(host), true + } + } + return 0, false +} + +func isDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// NormalizeChat converts a chat argument into the form the resolver expects. +// +// The accepted forms are the ones the shell pipeline accepted, because they are +// what an operator already has to hand: a numeric MTProto id as printed by +// `tdl chat ls`, a username with or without '@', or a t.me/tg:// link. Two need +// help. A Bot API id carries a -100 prefix that MTProto does not use, and a +// message link is not a chat — silently treating one as a chat would export the +// wrong thing, so it is refused with an explanation rather than guessed at. +func NormalizeChat(chat string) (string, error) { + chat = strings.TrimSpace(chat) + if chat == "" { + return "", fmt.Errorf("a chat is required") + } + + if isMessageLink(chat) { + return "", fmt.Errorf("%q is a message link, not a chat — "+ + "pass the chat's username or id instead", chat) + } + + if c, ok := linkChat(chat); ok { + return c, nil + } + + if m := botAPIID.FindStringSubmatch(chat); m != nil { + return m[1], nil + } + + // The resolver takes a bare username; '@' is how humans write it. + return strings.TrimPrefix(chat, "@"), nil +} + +// ResolveChat turns a chat argument into a peer. +// +// Numeric arguments are looked up as channel, then user, then chat ids; +// everything else goes through the resolver, which handles usernames and +// t.me/tg:// links. That ordering is tdl's (core/util/tutil.GetInputPeer), kept +// so an id that works in `tdl` works here. +func ResolveChat(ctx context.Context, manager *peers.Manager, chat string) (peers.Peer, error) { + normalized, err := NormalizeChat(chat) + if err != nil { + return nil, err + } + + peer, err := tutil.GetInputPeer(ctx, manager, normalized) + if err != nil { + return nil, fmt.Errorf("cannot resolve chat %q: %w", chat, err) + } + return peer, nil +} diff --git a/internal/tgsource/chat_test.go b/internal/tgsource/chat_test.go new file mode 100644 index 0000000..8354846 --- /dev/null +++ b/internal/tgsource/chat_test.go @@ -0,0 +1,109 @@ +package tgsource + +import ( + "strings" + "testing" +) + +// Every form the shell pipeline accepted must still be accepted, and the one +// form it refused must still be refused. An operator's existing command lines +// are the compatibility surface here. +func TestNormalizeChat(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"numeric mtproto id", "1234567890", "1234567890"}, + {"username with at", "@mychannel", "mychannel"}, + {"bare username", "mychannel", "mychannel"}, + {"public t.me link", "https://t.me/mychannel", "https://t.me/mychannel"}, + {"tg protocol link", "tg://resolve?domain=mychannel", "tg://resolve?domain=mychannel"}, + {"bot api id loses the -100 prefix", "-1001234567890", "1234567890"}, + {"surrounding whitespace is trimmed", " mychannel\n", "mychannel"}, + // The "c" and "s" markers are not the chat. Passed through whole, gotd + // takes the first path component as the username and resolves "c" or + // "s" — a confusing failure at best, and somebody else's chat at worst, + // since one-character usernames exist. + {"private channel link yields the channel id", "https://t.me/c/1234567890", "1234567890"}, + {"preview link yields the username", "https://t.me/s/mychannel", "mychannel"}, + {"preview link keeps the username's case", "https://t.me/s/MyChannel", "MyChannel"}, + // Invite links are chats; their second component is not a bare message + // number and must not be read as one. + {"invite link", "https://t.me/+AbCd_1234", "https://t.me/+AbCd_1234"}, + {"joinchat link", "https://t.me/joinchat/AbCd1234", "https://t.me/joinchat/AbCd1234"}, + {"other telegram host, no message", "https://telegram.dog/mychannel", "https://telegram.dog/mychannel"}, + {"tg link without a post parameter", "tg://resolve?domain=mychannel", "tg://resolve?domain=mychannel"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeChat(tt.in) + if err != nil { + t.Fatalf("NormalizeChat(%q) returned an error: %v", tt.in, err) + } + if got != tt.want { + t.Errorf("NormalizeChat(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// A message link names one message, not a chat. Guessing the chat from it would +// quietly export something the operator did not ask for, so it is refused. +func TestNormalizeChatRejectsMessageLinks(t *testing.T) { + for _, in := range []string{ + "https://t.me/c/1234567890/4242", + "t.me/c/1234567890/4242", + "https://t.me/s/mychannel/4242", + "https://t.me/mychannel/4242", + // gotd accepts all three Telegram hosts and its parser keeps only the + // domain, silently dropping the message number — so missing one of these + // would walk an entire chat when one message was asked for. + "https://telegram.me/mychannel/4242", + "https://telegram.dog/mychannel/4242", + "https://T.ME/mychannel/4242", + // tg:// names the message in a query parameter, not the path. + "tg://privatepost?channel=1234567890&post=4242", + "tg://resolve?domain=mychannel&post=4242", + "tg://openmessage?user_id=1&message_id=4242", + } { + t.Run(in, func(t *testing.T) { + _, err := NormalizeChat(in) + if err == nil { + t.Fatalf("NormalizeChat(%q) succeeded; a message link is not a chat", in) + } + if !strings.Contains(err.Error(), "message link") { + t.Errorf("error should say it is a message link, got: %v", err) + } + }) + } +} + +func TestNormalizeChatRejectsEmpty(t *testing.T) { + for _, in := range []string{"", " ", "\t\n"} { + if _, err := NormalizeChat(in); err == nil { + t.Errorf("NormalizeChat(%q) succeeded, want an error", in) + } + } +} + +// -100 is stripped only when it prefixes a Bot API id, never from an ordinary +// number that happens to start with those digits. +func TestNormalizeChatOnlyStripsRealBotAPIPrefix(t *testing.T) { + tests := map[string]string{ + "-1001234567890": "1234567890", // Bot API id + "1001234567890": "1001234567890", // no leading '-', not a Bot API id + "-100": "-100", // prefix with no id after it + "-2001234567890": "-2001234567890", // different prefix + } + for in, want := range tests { + got, err := NormalizeChat(in) + if err != nil { + t.Fatalf("NormalizeChat(%q): %v", in, err) + } + if got != want { + t.Errorf("NormalizeChat(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/tgsource/client.go b/internal/tgsource/client.go new file mode 100644 index 0000000..72074d5 --- /dev/null +++ b/internal/tgsource/client.go @@ -0,0 +1,152 @@ +// Package tgsource owns the Telegram side: building an authenticated client from +// a tdl session and pooling connections across data centres. +package tgsource + +import ( + "context" + "fmt" + "time" + + "github.com/gotd/td/telegram" + + "github.com/iyear/tdl/core/dcpool" + "github.com/iyear/tdl/core/storage" + "github.com/iyear/tdl/core/storage/keygen" + "github.com/iyear/tdl/core/tclient" +) + +// defaultReconnectTimeout matches tdl's own default. +const defaultReconnectTimeout = 5 * time.Minute + +// app describes the Telegram application a session was authorised against. +// +// A session is bound to the app that created it, so the AppID/AppHash used here +// must match the ones `tdl login` used or Telegram rejects the auth key. tdl +// records the choice in the store under the "app" key and defaults to its own +// application when the key is absent; mirror that exactly rather than hardcoding +// one, or a session created with `tdl login -d` fails to open. +type app struct { + id int + hash string +} + +var apps = map[string]app{ + // Application registered by tdl's author; tdl's default. + "builtin": {id: 15055931, hash: "021d433426cbb920eeb95164498fe3d3"}, + // Telegram Desktop's application, used by `tdl login -d`. + "desktop": {id: 2040, hash: "b18441a1ff607e10a989891a5462e627"}, +} + +// Options configures a session built from an existing tdl login. +type Options struct { + KV storage.Storage + Proxy string + NTP string + ReconnectTimeout time.Duration + PoolSize int64 +} + +// Session is an authenticated Telegram client together with the settings needed +// to build a DC pool over it. +// +// A gotd client cannot be reused: telegram.Client.Run refuses a second call once +// the first has returned. Anything that needs to retry a whole run must build a +// new Session rather than calling Run twice. +type Session struct { + client *telegram.Client + timeout time.Duration + poolSize int64 +} + +// New builds a Telegram session from the credentials in kv. +// +// Nothing connects yet: gotd dials inside Run, so callers must do their work in +// the callback Run provides. +func New(ctx context.Context, o Options) (*Session, error) { + a, err := resolveApp(ctx, o.KV) + if err != nil { + return nil, err + } + + if o.ReconnectTimeout == 0 { + o.ReconnectTimeout = defaultReconnectTimeout + } + if o.PoolSize == 0 { + o.PoolSize = 8 // tdl's default DC pool size + } + + // Middlewares are deliberately left empty here. core/tclient.New already + // prepends NewDefaultMiddlewares (recovery, retry, floodwait) to whatever it + // is given, so passing them again nests retry inside retry — three levels + // deep once core's own copy is counted, turning a hard RPC failure into + // minutes of silent backoff. The pool gets them explicitly in Run instead; + // see the comment there. + client, err := tclient.New(ctx, tclient.Options{ + AppID: a.id, + AppHash: a.hash, + Session: storage.NewSession(o.KV, false), + Proxy: o.Proxy, + NTP: o.NTP, + ReconnectTimeout: o.ReconnectTimeout, + }) + if err != nil { + return nil, err + } + + return &Session{client: client, timeout: o.ReconnectTimeout, poolSize: o.PoolSize}, nil +} + +// Client exposes the underlying client for calls that do not need the pool. +// Only valid inside a Run callback. +func (s *Session) Client() *telegram.Client { return s.client } + +func resolveApp(ctx context.Context, kv storage.Storage) (app, error) { + mode := "builtin" + if v, err := kv.Get(ctx, keygen.New("app")); err == nil { + mode = string(v) + } + a, ok := apps[mode] + if !ok { + return app{}, fmt.Errorf("session records unknown app %q; re-run `tdl login`", mode) + } + return a, nil +} + +// Run connects, verifies the session is authorised, and invokes fn with a DC +// pool. The pool is closed before Run returns. +func (s *Session) Run(ctx context.Context, fn func(context.Context, dcpool.Pool) error) error { + err := s.client.Run(ctx, func(ctx context.Context) error { + status, err := s.client.Auth().Status(ctx) + if err != nil { + return fmt.Errorf("auth status: %w", err) + } + if !status.Authorized { + return fmt.Errorf("tdl session is not authorized; run `tdl login`") + } + + // gotd applies a client's middlewares only to Client.Invoke. Calls made + // through a pooled DC connection bypass them entirely, so the pool needs + // its own copy or downloads run with no flood-wait handling and no retry + // — which on a multi-hour archive dies at the first FLOOD_WAIT. tdl does + // the same thing for the same reason (app/dl/dl.go). + pool := dcpool.NewPool(s.client, s.poolSize, + tclient.NewDefaultMiddlewares(ctx, s.timeout)...) + defer func() { _ = pool.Close() }() + + return fn(ctx, withSafeTakeout(pool)) + }) + + // gotd swallows cancellation: telegram.Client.Run ends with + // if err := g.Wait(); !errors.Is(err, context.Canceled) { return err } + // return nil + // so an interrupted run — and any callback error wrapping context.Canceled — + // comes back as success. Reporting that as exit 0 would tell a driver the + // archive is complete when it was abandoned half-way, which is exactly the + // failure the shell pipeline's `trap ... exit 130` existed to prevent. + if err == nil { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + } + return err +} diff --git a/internal/tgsource/iterate.go b/internal/tgsource/iterate.go new file mode 100644 index 0000000..ae44dbc --- /dev/null +++ b/internal/tgsource/iterate.go @@ -0,0 +1,108 @@ +package tgsource + +import ( + "context" + "fmt" + "iter" + + "github.com/gotd/td/telegram/peers" + "github.com/gotd/td/telegram/query" + "github.com/gotd/td/tg" + + "github.com/iyear/tdl/core/tmedia" + + "github.com/tiennm99dev/telegram-exporter/internal/naming" +) + +// Item is one downloadable media message. +// +// Name is filled here, at the single point where the message is seen, and is the +// same string used to check the remote and to write the file. See the naming +// package for why that matters. Media carries the location, size and DC that the +// downloader needs, so nothing has to be looked up a second time. +type Item struct { + DialogID int64 + MessageID int + Name string + Media *tmedia.Media +} + +// Size reports the media size in bytes. +func (i Item) Size() int64 { return i.Media.Size } + +// Walk yields every media message in a chat, newest first. +// +// Order is Telegram's: GetHistory pages backwards from the most recent message. +// The shell pipeline fetched oldest-first, so an interrupted run leaves a +// different subset archived than the old one would have. +// +// A sequence rather than a callback because the downloader consumes a pull +// iterator (Next/Value/Err), and range-over-func converts either way for free: +// callers that want the callback shape just range over it, while iter.Pull2 +// gives the pull shape without anyone owning a goroutine. Messages are streamed, +// never collected — an 18k-message chat is tens of thousands of descriptors and +// the caller decides what to keep. +// +// Text-only and service messages carry no file and are skipped, the same rule +// the export JSON encoded as an empty "file" field. On error the sequence yields +// a zero Item with that error and stops; cancelling ctx stops it too, so an +// interrupted run does not keep paging. +// onScan, when non-nil, is called with the number of messages read so far. It +// has to live here rather than in the caller's loop because most of a chat is +// not media: text-only and service messages are filtered out below, so a caller +// counting yielded items sees nothing at all while the walk crosses a long +// stretch of conversation, and a working run is indistinguishable from a hung +// one. +func Walk(ctx context.Context, api *tg.Client, peer peers.Peer, onScan func(scanned int)) iter.Seq2[Item, error] { + return func(yield func(Item, error) bool) { + dialogID := peer.ID() + + it := query.NewQuery(api).Messages().GetHistory(peer.InputPeer()).BatchSize(100).Iter() + scanned := 0 + for it.Next(ctx) { + scanned++ + if onScan != nil { + onScan(scanned) + } + + msg, ok := it.Value().Msg.(*tg.Message) + if !ok { + continue // service messages have no media + } + + media, ok := tmedia.GetMedia(msg) + if !ok { + continue // text-only, or a media kind tmedia cannot download + } + + if !yield(Item{ + DialogID: dialogID, + MessageID: msg.ID, + Name: naming.For(dialogID, msg.ID, media), + Media: media, + }, nil) { + return + } + } + + if err := it.Err(); err != nil { + yield(Item{}, fmt.Errorf("walk chat history: %w", err)) + } + + // There is no cross-check that the walk saw the whole history, and the + // obvious one does not work. gotd's iterator ends with a nil error if a + // fetch yields an empty buffer (messages/iter.go:97,103,155-160), so a + // truncated walk is indistinguishable from a complete one — but + // Iterator.Total is the server's history count, which includes the + // deleted slots that Next skips at iter.go:159-162. Comparing the two + // reports a short read on any chat that has ever had a message deleted, + // which is nearly all of them. A real check would need a count of + // non-empty messages, which the API does not offer. + } +} + +// Manager builds a peers manager over the session's peer cache, so resolving the +// same chat twice does not cost a second round trip. +func Manager(api *tg.Client, storage peers.Storage) *peers.Manager { + return peers.Options{Storage: storage}.Build(api) +} diff --git a/internal/tgsource/takeout.go b/internal/tgsource/takeout.go new file mode 100644 index 0000000..5b763b2 --- /dev/null +++ b/internal/tgsource/takeout.go @@ -0,0 +1,62 @@ +package tgsource + +import ( + "context" + "sync" + + "github.com/gotd/td/tg" + + "github.com/iyear/tdl/core/dcpool" + "github.com/iyear/tdl/core/middlewares/takeout" +) + +// safeTakeout wraps a pool to make its takeout path survive a failed init. +// +// core's own dcpool.Takeout deadlocks on that path. It holds the pool's mutex +// for the whole call, and its recovery from a failed init is to return +// p.Client(ctx, dc) — which locks the same mutex again (dcpool.go:113-121 and +// :57-58). sync.Mutex is not reentrant, so the worker blocks forever, then every +// other worker blocks behind it, and the process hangs with no output and no +// response to cancellation, since the goroutine is parked on a mutex rather than +// a select. +// +// That is not an exotic path. Telegram answers account.initTakeoutSession with +// TAKEOUT_INIT_DELAY when a takeout was started recently — tdl's own "ignore +// init delay error" comment shows it expects exactly this — and takeout is on by +// default, so running two exports in succession is enough to trigger it. +// +// Probing before the run is not an alternative: a probe would consume an init +// and make the pool's own init the one that gets the delay error. So the takeout +// session is established here instead, once, and the pool's Takeout is never +// called at all. +type safeTakeout struct { + dcpool.Pool + + once sync.Once + id int64 + ok bool +} + +func withSafeTakeout(p dcpool.Pool) dcpool.Pool { return &safeTakeout{Pool: p} } + +// Takeout returns a takeout-scoped client, or an ordinary one if no takeout +// session could be established. +// +// Falling back rather than failing matches what core intended: takeout raises +// rate limits and reaches older history, but a download works without it. The +// difference is that this fallback returns. +func (s *safeTakeout) Takeout(ctx context.Context, dc int) *tg.Client { + base := s.Pool.Client(ctx, dc) + + s.once.Do(func() { + id, err := takeout.Takeout(ctx, base.Invoker()) + if err != nil { + return // ok stays false; every caller gets a plain client + } + s.id, s.ok = id, true + }) + if !s.ok { + return base + } + return tg.NewClient(takeout.Middleware(s.id).Handle(base.Invoker())) +} diff --git a/internal/tgsource/takeout_test.go b/internal/tgsource/takeout_test.go new file mode 100644 index 0000000..dd9274f --- /dev/null +++ b/internal/tgsource/takeout_test.go @@ -0,0 +1,64 @@ +package tgsource + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" + + "github.com/iyear/tdl/core/dcpool" +) + +// failingInvoker refuses every call, which is what Telegram does to +// account.initTakeoutSession with TAKEOUT_INIT_DELAY. +type failingInvoker struct{ calls atomic.Int64 } + +func (f *failingInvoker) Invoke(context.Context, bin.Encoder, bin.Decoder) error { + f.calls.Add(1) + return errors.New("TAKEOUT_INIT_DELAY_86400") +} + +type fakePool struct{ inv tg.Invoker } + +func (p *fakePool) Client(context.Context, int) *tg.Client { return tg.NewClient(p.inv) } +func (p *fakePool) Takeout(context.Context, int) *tg.Client { + panic("upstream Takeout must not be called") +} +func (p *fakePool) Default(context.Context) *tg.Client { return tg.NewClient(p.inv) } +func (p *fakePool) Close() error { return nil } + +// core's dcpool.Takeout holds the pool mutex and recovers from a failed init by +// calling Client, which locks the same mutex — so the upstream version of this +// test hangs instead of failing. The wrapper must return a usable client. +func TestSafeTakeoutFallsBackWhenInitFails(t *testing.T) { + inv := &failingInvoker{} + var pool dcpool.Pool = withSafeTakeout(&fakePool{inv: inv}) + + done := make(chan *tg.Client, 1) + go func() { done <- pool.Takeout(t.Context(), 2) }() + + select { + case got := <-done: + if got == nil { + t.Fatal("Takeout returned nil after a failed init") + } + case <-time.After(5 * time.Second): + t.Fatal("Takeout deadlocked after a failed init") + } + + // The init is attempted once, not once per file: a failing init that ran on + // every element would add a round trip to each of 18k downloads. + before := inv.calls.Load() + for range 5 { + if pool.Takeout(t.Context(), 2) == nil { + t.Fatal("Takeout returned nil") + } + } + if got := inv.calls.Load(); got != before { + t.Errorf("takeout init retried %d times after failing; want no retries", got-before) + } +} diff --git a/internal/verify/verify.go b/internal/verify/verify.go new file mode 100644 index 0000000..cac0b29 --- /dev/null +++ b/internal/verify/verify.go @@ -0,0 +1,252 @@ +// Package verify answers whether a chat is fully archived on a remote. +// +// It replaces verify-export.sh and keeps that script's size judgements, which +// were arrived at by watching real failures rather than by taste. +// +// One judgement is deliberately not carried over. The script also counted files +// sitting in the staging directory as present, because download and upload were +// separate processes and a file could be finished locally but not yet uploaded +// for a whole sync interval. Here a single process owns both legs, so that state +// is not one a verify can meaningfully observe — except after an interrupted +// run, where staging may hold completed files. Phase 5 owns staging and decides +// whether to credit it; until then a verify after an interrupt may report files +// absent that are on local disk, and re-fetch them. +package verify + +import ( + "fmt" + "io" + "sort" + + "github.com/tiennm99dev/telegram-exporter/internal/naming" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +// Index is the part of a remote snapshot verification needs. +// +// An interface rather than *remote.Index so the two questions stay separable: +// presence is answered from a whole name, and the id-keyed lookup is explicitly +// a different method with a name that says it is not an answer. It also lets the +// report be tested without a remote. +type Index interface { + // Lookup reports the size stored under an exact name. + Lookup(name string) (size int64, ok bool) + // StoredUnderOtherNames lists names present for a message id that are not + // the wanted name. Diagnostics only; never a presence answer. + StoredUnderOtherNames(messageID int, wanted string) []string +} + +// tinyThreshold is the size below which a present file is reported for a human +// to look at but still trusted. Some real media genuinely is this small, so +// treating it as damaged would re-download it forever. +const tinyThreshold = 1024 + +// Misnamed is a wanted file that is absent, while some other file is stored +// under the same message id. +type Misnamed struct { + MessageID int + Wanted string + Found []string +} + +// Unsafe is a wanted file whose name cannot be written to a path. +type Unsafe struct { + MessageID int + Name string + Reason error +} + +// Tiny is a present file small enough to be worth a look. +type Tiny struct { + MessageID int + Name string + Size int64 +} + +// Mismatch is a present file whose stored size is not the size Telegram reports. +type Mismatch struct { + MessageID int + Name string + Want, Got int64 +} + +// Report is the outcome of comparing a chat against a remote. +type Report struct { + Expected int // media messages in the chat + Present int // present, non-empty + Bytes int64 // total size of everything expected + + Absent []int // not on the remote under the wanted name + ZeroByte []int // present but empty + + Misnamed []Misnamed + Unsafe []Unsafe + Tiny []Tiny + Mismatched []Mismatch + + // checked records that Check actually ran. Without it a zero Report claims + // the archive is complete — nothing expected, nothing missing — which is the + // value a command holds before its Telegram callback has populated it. Any + // path that returns early therefore reports success on an untouched chat. + checked bool +} + +// Ran reports whether this came from a Check rather than being a zero value. +func (r Report) Ran() bool { return r.checked } + +// Todo lists the message ids needing another fetch, in ascending order. +// +// Zero-byte and wrong-size files are included: rclone overwrites a +// size-mismatched destination, so simply fetching again repairs them. +func (r Report) Todo() []int { + todo := make([]int, 0, len(r.Absent)+len(r.ZeroByte)+len(r.Mismatched)) + todo = append(todo, r.Absent...) + todo = append(todo, r.ZeroByte...) + for _, m := range r.Mismatched { + todo = append(todo, m.MessageID) + } + sort.Ints(todo) + return todo +} + +// Fetchable lists the outstanding ids a run could actually retrieve. +// +// It is Todo minus the unsafe names, which are in Todo because they are not +// archived and out of this because no run will ever archive them. The gap +// between the two is what tells "keep going" apart from "this is as far as it +// goes". +func (r Report) Fetchable() []int { + if len(r.Unsafe) == 0 { + return r.Todo() + } + blocked := make(map[int]struct{}, len(r.Unsafe)) + for _, u := range r.Unsafe { + blocked[u.MessageID] = struct{}{} + } + todo := r.Todo() + out := todo[:0:0] + for _, id := range todo { + if _, skip := blocked[id]; !skip { + out = append(out, id) + } + } + return out +} + +// Complete reports whether every expected file is present and non-empty. +func (r Report) Complete() bool { return r.checked && len(r.Todo()) == 0 } + +// Stalled reports that work remains and none of it can ever be done. +// +// This is the state a drive-until-complete loop cannot detect for itself: the +// report is identical on every pass, so a driver retrying on "incomplete" walks +// the whole history and indexes the whole remote forever, achieving nothing. +func (r Report) Stalled() bool { + return r.checked && len(r.Todo()) > 0 && len(r.Fetchable()) == 0 +} + +// Check compares the wanted items against an index of the remote. +// +// Matching is on the whole name. A file stored under any other name is not the +// file that was asked for, however close it looks — that is a deliberate policy, +// and the near-misses are collected into Misnamed rather than being quietly +// accepted, because the re-download lands beside them and both copies stay. +func Check(items []tgsource.Item, idx Index) Report { + r := Report{Expected: len(items), checked: true} + + for _, it := range items { + r.Bytes += it.Size() + + if err := naming.Safe(it.Name); err != nil { + // Never counted present: this name cannot be written anywhere safe, + // so no correctly-behaving run could have archived it. + r.Unsafe = append(r.Unsafe, Unsafe{MessageID: it.MessageID, Name: it.Name, Reason: err}) + r.Absent = append(r.Absent, it.MessageID) + continue + } + + size, ok := idx.Lookup(it.Name) + switch { + case !ok: + r.Absent = append(r.Absent, it.MessageID) + if others := idx.StoredUnderOtherNames(it.MessageID, it.Name); len(others) > 0 { + r.Misnamed = append(r.Misnamed, Misnamed{ + MessageID: it.MessageID, Wanted: it.Name, Found: others, + }) + } + case size == 0: + r.ZeroByte = append(r.ZeroByte, it.MessageID) + case size != it.Size(): + // The remaining way a report could say "complete" when it is not. + // An upload that died partway leaves a plausible object under the + // right name, and matching on name and non-zero size alone would + // count it archived permanently. Telegram's size is known here, so + // there is no reason not to use it; rclone overwrites a mismatched + // destination, so fetching again repairs it. + r.Mismatched = append(r.Mismatched, Mismatch{ + MessageID: it.MessageID, Name: it.Name, Want: it.Size(), Got: size, + }) + default: + r.Present++ + if size < tinyThreshold { + r.Tiny = append(r.Tiny, Tiny{MessageID: it.MessageID, Name: it.Name, Size: size}) + } + } + } + return r +} + +// Write renders a report in the shape verify-export.sh printed, so the numbers +// stay comparable across the cutover. +func (r Report) Write(w io.Writer) { + fmt.Fprintf(w, "media expected : %d (%.1f GiB)\n", r.Expected, float64(r.Bytes)/(1<<30)) + fmt.Fprintf(w, "present and intact : %d\n", r.Present) + fmt.Fprintf(w, " absent : %d\n", len(r.Absent)) + fmt.Fprintf(w, " zero-byte : %d\n", len(r.ZeroByte)) + if len(r.Mismatched) > 0 { + fmt.Fprintf(w, " wrong size : %d\n", len(r.Mismatched)) + for _, m := range r.Mismatched[:min(5, len(r.Mismatched))] { + fmt.Fprintf(w, " id %d %d B on the remote, expected %d %q\n", + m.MessageID, m.Got, m.Want, m.Name) + } + } + + if len(r.Tiny) > 0 { + fmt.Fprintf(w, " under 1KiB (check, not retried): %d\n", len(r.Tiny)) + for _, t := range r.Tiny[:min(5, len(r.Tiny))] { + fmt.Fprintf(w, " id %d %d B %q\n", t.MessageID, t.Size, t.Name) + } + } + + if len(r.Unsafe) > 0 { + fmt.Fprintf(w, "\nunarchivable : %d\n", len(r.Unsafe)) + fmt.Fprintf(w, " these names cannot be written and will never be fetched:\n") + for _, u := range r.Unsafe { + fmt.Fprintf(w, " id %d %v\n", u.MessageID, u.Reason) + } + } + + if len(r.Misnamed) > 0 { + fmt.Fprintf(w, "\nstored under a different name : %d\n", len(r.Misnamed)) + fmt.Fprintf(w, " counted as absent and fetched again; delete the stale copies so the\n") + fmt.Fprintf(w, " re-download does not leave two files for the same message:\n") + for _, m := range r.Misnamed { + fmt.Fprintf(w, " id %d\n wanted: %q\n", m.MessageID, m.Wanted) + for _, f := range m.Found { + fmt.Fprintf(w, " remote: %q\n", f) + } + } + } + + todo := r.Todo() + switch { + case r.Stalled(): + fmt.Fprintf(w, "\nSTALLED: %d file(s) remain, none of which can be fetched.\n", len(todo)) + case len(todo) > 0: + fmt.Fprintf(w, "\nneeds another pass : %d (ids %d–%d)\n", len(todo), todo[0], todo[len(todo)-1]) + case !r.checked: + fmt.Fprintf(w, "\nno chat was checked.\n") + default: + fmt.Fprintf(w, "\nCOMPLETE: every media message is present and non-empty.\n") + } +} diff --git a/internal/verify/verify_test.go b/internal/verify/verify_test.go new file mode 100644 index 0000000..7196a61 --- /dev/null +++ b/internal/verify/verify_test.go @@ -0,0 +1,242 @@ +package verify + +import ( + "slices" + "strings" + "testing" + + "github.com/iyear/tdl/core/tmedia" + + "github.com/tiennm99dev/telegram-exporter/internal/naming" + "github.com/tiennm99dev/telegram-exporter/internal/tgsource" +) + +const dialog = int64(1234567890) + +// fakeIndex is a remote snapshot expressed directly, so report logic is tested +// without a network or a filesystem. +type fakeIndex map[string]int64 + +func (f fakeIndex) Lookup(name string) (int64, bool) { + size, ok := f[name] + return size, ok +} + +func (f fakeIndex) StoredUnderOtherNames(messageID int, wanted string) []string { + var others []string + for name := range f { + if name == wanted { + continue + } + if id, ok := naming.SplitStored(dialog, name); ok && id == messageID { + others = append(others, name) + } + } + slices.Sort(others) + return others +} + +func item(msgID int, file string, size int64) tgsource.Item { + m := &tmedia.Media{Name: file, Size: size} + return tgsource.Item{ + DialogID: dialog, + MessageID: msgID, + Name: naming.For(dialog, msgID, m), + Media: m, + } +} + +func TestCheckClassifiesEveryOutcome(t *testing.T) { + items := []tgsource.Item{ + item(1, "present.mp4", 5000), + item(2, "empty.mp4", 5000), + item(3, "absent.mp4", 5000), + item(4, "tiny.jpg", 500), + } + idx := fakeIndex{ + "1234567890_1_present.mp4": 5000, + "1234567890_2_empty.mp4": 0, + "1234567890_4_tiny.jpg": 500, + } + + r := Check(items, idx) + + if r.Expected != 4 { + t.Errorf("Expected = %d, want 4", r.Expected) + } + if r.Present != 2 { + t.Errorf("Present = %d, want 2 (the non-empty ones)", r.Present) + } + if !slices.Equal(r.Absent, []int{3}) { + t.Errorf("Absent = %v, want [3]", r.Absent) + } + if !slices.Equal(r.ZeroByte, []int{2}) { + t.Errorf("ZeroByte = %v, want [2]", r.ZeroByte) + } + + // Sub-1KiB is reported but still counted present: some real media is + // genuinely that small, so retrying it would loop forever. + if len(r.Tiny) != 1 || r.Tiny[0].MessageID != 4 { + t.Errorf("Tiny = %v, want just message 4", r.Tiny) + } + if slices.Contains(r.Todo(), 4) { + t.Error("a tiny file must not be queued for another fetch") + } + + // Zero-byte files are retried: rclone overwrites a size-mismatched + // destination, so fetching again repairs them. + if want := []int{2, 3}; !slices.Equal(r.Todo(), want) { + t.Errorf("Todo() = %v, want %v", r.Todo(), want) + } + if r.Complete() { + t.Error("Complete() = true with work outstanding") + } +} + +func TestCheckCompleteWhenEverythingIsPresent(t *testing.T) { + items := []tgsource.Item{item(1, "a.mp4", 10), item(2, "b.mp4", 20)} + idx := fakeIndex{"1234567890_1_a.mp4": 10, "1234567890_2_b.mp4": 20} + + r := Check(items, idx) + if !r.Complete() { + t.Fatalf("Complete() = false, Todo() = %v", r.Todo()) + } + var sb strings.Builder + r.Write(&sb) + if !strings.Contains(sb.String(), "COMPLETE") { + t.Errorf("report should say COMPLETE, got:\n%s", sb.String()) + } +} + +// The message that motivated the rewrite: the wanted name has a doubled '!', +// the remote holds the collapsed one that tdl's template wrote. It must be +// absent, and the stale copy must be surfaced rather than silently accepted. +func TestCheckReportsMisnamedCopiesAsAbsent(t *testing.T) { + items := []tgsource.Item{item(4242, "Pipe her!! And by her, we mean pipeperr! 1080p.mp4", 966444937)} + stale := "1234567890_4242_Pipe her! And by her, we mean pipeperr! 1080p.mp4" + idx := fakeIndex{stale: 966444937} + + r := Check(items, idx) + + if !slices.Equal(r.Absent, []int{4242}) { + t.Errorf("Absent = %v, want [4242] — a near-miss name is a different file", r.Absent) + } + if r.Present != 0 { + t.Errorf("Present = %d, want 0", r.Present) + } + if len(r.Misnamed) != 1 || !slices.Equal(r.Misnamed[0].Found, []string{stale}) { + t.Fatalf("Misnamed = %+v, want the stale copy reported", r.Misnamed) + } + + var sb strings.Builder + r.Write(&sb) + out := sb.String() + for _, want := range []string{"stored under a different name", stale, "delete the stale copies"} { + if !strings.Contains(out, want) { + t.Errorf("report should mention %q, got:\n%s", want, out) + } + } +} + +// A name that cannot be written to a path is never counted present and never +// silently skipped — it is reported and queued, so it stays visible. +func TestCheckFlagsUnsafeNames(t *testing.T) { + items := []tgsource.Item{item(7, "../../../.config/rclone/rclone.conf", 100)} + + r := Check(items, fakeIndex{}) + + if len(r.Unsafe) != 1 || r.Unsafe[0].MessageID != 7 { + t.Fatalf("Unsafe = %+v, want message 7 flagged", r.Unsafe) + } + if !slices.Equal(r.Absent, []int{7}) { + t.Errorf("Absent = %v, want [7]", r.Absent) + } + if r.Present != 0 { + t.Errorf("Present = %d, want 0", r.Present) + } + + var sb strings.Builder + r.Write(&sb) + if !strings.Contains(sb.String(), "unarchivable") { + t.Errorf("report should flag the unsafe name, got:\n%s", sb.String()) + } + // Nothing else is outstanding, so this run is as far as it can get. Saying + // "needs another pass" here is what makes a driver loop forever. + if !r.Stalled() { + t.Error("a report whose only outstanding item is unarchivable must be stalled") + } + if !strings.Contains(sb.String(), "STALLED") { + t.Errorf("report should say it is stalled, got:\n%s", sb.String()) + } +} + +// An unsafe name must be rejected even when something is stored under that +// message id: the index is not the authority on whether a name is writable. +func TestCheckUnsafeNameIsNeverPresent(t *testing.T) { + items := []tgsource.Item{item(7, "sub/dir.mp4", 100)} + idx := fakeIndex{"1234567890_7_sub/dir.mp4": 100} + + if r := Check(items, idx); r.Present != 0 || len(r.Unsafe) != 1 { + t.Errorf("Present = %d, Unsafe = %+v; an unsafe name must never count present", r.Present, r.Unsafe) + } +} + +func TestReportTodoIsSorted(t *testing.T) { + r := Report{Absent: []int{4242, 3}, ZeroByte: []int{100}} + if want := []int{3, 100, 4242}; !slices.Equal(r.Todo(), want) { + t.Errorf("Todo() = %v, want %v", r.Todo(), want) + } +} + +// A zero Report is the value a command holds before its Telegram callback has +// run. It must not claim the archive is complete. +func TestZeroReportIsNotComplete(t *testing.T) { + var r Report + if r.Complete() { + t.Error("a Report that never ran reports the archive complete") + } + if r.Ran() { + t.Error("Ran() is true on a zero Report") + } + if r.Stalled() { + t.Error("a Report that never ran reports itself stalled") + } + + var sb strings.Builder + r.Write(&sb) + if strings.Contains(sb.String(), "COMPLETE") { + t.Errorf("a Report that never ran printed COMPLETE:\n%s", sb.String()) + } +} + +// A remote object under the right name but the wrong size is the last way a +// report could say complete when it is not: an upload that died partway leaves +// exactly that, and the local copy is already gone. +func TestCheckTreatsAWrongSizeAsOutstanding(t *testing.T) { + items := []tgsource.Item{item(4242, "clip.mp4", 4096)} + idx := fakeIndex{"1234567890_4242_clip.mp4": 400} + + r := Check(items, idx) + if r.Present != 0 { + t.Errorf("Present = %d, want 0 for a truncated object", r.Present) + } + if len(r.Mismatched) != 1 { + t.Fatalf("Mismatched = %v, want one entry", r.Mismatched) + } + if got := r.Mismatched[0]; got.Want != 4096 || got.Got != 400 { + t.Errorf("Mismatch = %+v, want want=4096 got=400", got) + } + if !slices.Contains(r.Todo(), 4242) { + t.Errorf("Todo = %v, want it to include 4242", r.Todo()) + } + if r.Complete() { + t.Error("a truncated object was counted as a complete archive") + } + // It is fetchable, unlike an unsafe name — re-uploading overwrites it. + if !slices.Contains(r.Fetchable(), 4242) { + t.Errorf("Fetchable = %v, want it to include 4242", r.Fetchable()) + } + if r.Stalled() { + t.Error("a repairable file must not be reported as stalled") + } +} diff --git a/run.sh b/run.sh deleted file mode 100755 index b552068..0000000 --- a/run.sh +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env bash -# -# Rolling pipeline: tdl downloads Telegram media into a small staging directory -# while rclone concurrently moves finished files to any rclone remote (S3, -# Google Drive, SFTP, WebDAV, B2, ...). Local disk only ever holds the files in -# flight plus one sync interval of throughput, so a chat larger than the local -# disk can still be exported. -# -# See README.md for the background. -# -# Exit codes: 0 ok, 2 usage error, 3 rclone failure, 130/143 interrupted, -# anything else is tdl's own exit code. - -set -euo pipefail - -readonly PROG=${0##*/} - -# tdl downloads to '.tmp' and renames only on completion, so an unfinished -# file is always identifiable by extension. Never move one: a download stalled -# by a flood wait stops touching its .tmp, which then ages past --min-age and -# would be uploaded half-written, destroying tdl's resume point for that file. -readonly TEMP_GLOB='*.tmp' - -# Defaults -export_file='' # decided after parsing: per-chat when -c is given -chat='' -staging='./staging' -remote='' -interval=60 -min_age='45s' -max_staging='' # empty: staging grows as fast as tdl fills it -max_sync_failures=5 -cap_check_interval=10 # seconds between -m checks, independent of -i - -usage() { - cat <.json with -c, - otherwise export.json) - -c CHAT chat to export when FILE does not exist. Accepts a numeric - id as printed by 'tdl chat ls', a username with or without - '@', or a t.me/tg:// link. A Bot API '-100...' id is - converted to the plain id tdl expects. - -d DIR staging directory (default: $staging) - -i SECONDS seconds between rclone sweeps (default: $interval) - -a AGE rclone --min-age, a second guard against moving files still - being written (default: $min_age) - -m SIZE cap the staging directory at SIZE (K/M/G/T, binary; e.g. - 40G). When staging reaches it, tdl is suspended until - rclone has drained the finished files. Unset means no cap. - -h this help - -Everything after -- is appended to the 'tdl dl' command, e.g. - $PROG -r gdrive:telegram/media -- -t 4 -l 1 -USAGE -} - -log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$PROG" "$*" >&2; } -die() { log "error: $*"; exit 2; } # usage or precondition -fail() { log "error: $*"; exit 3; } # rclone / pipeline failure - -while getopts ':f:c:d:r:i:a:m:h' opt; do - case $opt in - f) export_file=$OPTARG ;; - c) chat=$OPTARG ;; - d) staging=$OPTARG ;; - r) remote=$OPTARG ;; - i) interval=$OPTARG ;; - a) min_age=$OPTARG ;; - m) max_staging=$OPTARG ;; - h) usage; exit 0 ;; - :) die "option -$OPTARG requires an argument" ;; - ?) die "unknown option -$OPTARG (try -h)" ;; - esac -done -shift $((OPTIND - 1)) -tdl_extra=("$@") - -[[ -n $remote ]] || { usage >&2; die "-r REMOTE:PATH is required"; } -[[ $remote == *:* ]] || die "remote '$remote' is not in rclone REMOTE:PATH form" -[[ $interval =~ ^[0-9]+$ && $interval -gt 0 ]] || die "-i must be a positive integer" -[[ $min_age =~ ^[0-9]+(\.[0-9]+)?(ms|s|m|h|d|w|M|y)?$ ]] \ - || die "-a must be an rclone duration, e.g. 2m" - -# Sizes are compared in KiB because that is the unit 'du -sk' reports, which is -# also the unit that matters here: allocated blocks, not apparent length. -max_staging_kib='' -if [[ -n $max_staging ]]; then - [[ $max_staging =~ ^[0-9]+[KkMmGgTt]?$ ]] \ - || die "-m must be a size with an optional K/M/G/T suffix, e.g. 40G" - num=${max_staging%[KkMmGgTt]} - case ${max_staging#"$num"} in - ''|K|k) max_staging_kib=$num ;; - M|m) max_staging_kib=$((num * 1024)) ;; - G|g) max_staging_kib=$((num * 1024 * 1024)) ;; - T|t) max_staging_kib=$((num * 1024 * 1024 * 1024)) ;; - esac - ((max_staging_kib > 0)) || die "-m must be greater than zero" -fi - -# tdl resolves a numeric argument as an MTProto id and anything else through -# gotd's resolver, which handles '@name', 'name' and t.me/tg:// links. Two forms -# still need help: Bot API ids carry a '-100' prefix that MTProto does not use, -# and a message link is not a chat. -if [[ -n $chat ]]; then - read -r chat <<<"$chat" # trim stray whitespace - case $chat in - '') die "-c requires a chat" ;; - *t.me/c/*|*t.me/*/[0-9]*) - die "-c takes a chat, not a message link ($chat) — pass the chat's username or id" ;; - -100[0-9]*) - log "converting Bot API id $chat to MTProto id ${chat#-100}" - chat=${chat#-100} ;; - esac -fi - -# Keep each chat's export in its own file, so switching -c never silently -# downloads the previous chat again from a stale export.json. -if [[ -z $export_file ]]; then - if [[ -n $chat ]]; then - slug=${chat#@} - slug=${slug##*/} - slug=$(printf '%s' "$slug" | tr -c 'A-Za-z0-9._-' '_') - export_file="export-$slug.json" - else - export_file='export.json' - fi -fi - -# pikpak-style backends finish an upload as a server-side async task, and -# rclone abandons a still-pending one once --low-level-retries polls run out, -# failing a transfer that would have succeeded. Fewer parallel transfers keep -# that queue short and more retries wait it out. These are rclone's own -# environment variables, so whatever the caller already exported wins. -: "${RCLONE_TRANSFERS:=2}" -: "${RCLONE_LOW_LEVEL_RETRIES:=20}" -export RCLONE_TRANSFERS RCLONE_LOW_LEVEL_RETRIES - -for tool in tdl rclone; do - command -v "$tool" >/dev/null || die "$tool is not installed or not on PATH" -done - -# A named remote must exist in the config; a leading ':' means an on-the-fly -# connection string, which has no config entry to check. Listing the remote's -# root is not portable (some backends refuse it), so reachability and -# credentials are proven by creating the destination, which rclone would create -# on the first move anyway. -if [[ $remote != :* ]]; then - # Read the list into a variable first: piping it into 'grep -q' lets grep exit - # on the first match and kill rclone with SIGPIPE, which pipefail then reports - # as a failed pipeline -- rejecting a remote that is in fact configured. - remotes=$(rclone listremotes 2>/dev/null || true) - grep -qx -- "${remote%%:*}:" <<<"$remotes" \ - || die "rclone remote '${remote%%:*}:' is not configured — see 'rclone listremotes'" -fi -rclone mkdir "$remote" >/dev/null 2>&1 \ - || die "cannot reach '$remote' — check credentials and connectivity" - -# The export JSON only lists messages; it is cheap to keep and required for both -# legs to stay resumable, so never regenerate it when it already exists. -if [[ ! -f $export_file ]]; then - [[ -n $chat ]] || die "$export_file not found; pass -c CHAT to export it first" - log "exporting $chat metadata to $export_file" - tdl chat export -c "$chat" --all --with-content -o "$export_file" -else - log "using existing $export_file (delete it to re-export)" -fi - -mkdir -p "$staging" - -tdl_pid='' -tdl_rc=0 -sweep_ok=0 - -# The sweeps that run once at the end have a whole staging directory to move -# and no interleaved tdl output, so they report progress instead of going quiet -# for several minutes. rclone's redrawn bar is right on a terminal but turns a -# redirected log into control characters, so a log gets periodic one-line -# stats. Those are logged at INFO, which '-v' would enable at the cost of a -# line per file, hence raising the stats to NOTICE rather than the whole log. -sweep_progress=(--progress) -[[ -t 1 ]] || sweep_progress=(--stats 30s --stats-one-line --stats-log-level NOTICE) - -# Move whatever is finished. Partial .tmp files are always excluded. $1 is an -# optional --min-age guard; $2 enables --delete-empty-src-dirs, which is safe -# only once tdl has stopped — rclone removing a directory between tdl's -# MkdirAll and Create makes tdl fail, and it can take the staging root too, -# hence the mkdir afterwards. $3 turns on the progress reporting above. -sweep() { - local rc=0 - local args=(--exclude "$TEMP_GLOB") - if [[ -n ${1:-} ]]; then args+=(--min-age "$1"); fi - if ((${2:-0})); then args+=(--delete-empty-src-dirs); fi - if ((${3:-0})); then args+=(${sweep_progress[@]+"${sweep_progress[@]}"}); fi - rclone move "$staging" "$remote" "${args[@]}" || rc=$? - mkdir -p "$staging" - return $rc -} - -staging_kib() { du -sk "$staging" 2>/dev/null | cut -f1; } - -over_cap() { - local used - used=$(staging_kib) - [[ -n $used ]] && ((used >= max_staging_kib)) -} - -# Enforce -m. tdl renames a file only once it is complete, so staging holds -# finished files plus the in-flight '*.tmp' ones, and only the former can be -# drained. Suspending tdl stops it adding more while rclone empties the -# directory; SIGSTOP is safe because tdl reconnects on resume and --continue -# picks its .tmp files back up. The cap must therefore stay well above what the -# concurrent downloads hold, or draining could never clear it. -drain_to_cap() { - local used rc=0 - used=$(staging_kib) - log "staging at $((used / 1024)) MiB, at or over the $((max_staging_kib / 1024)) MiB cap; suspending tdl to drain" - - kill -STOP "$tdl_pid" 2>/dev/null || true - # Sweep until staging is back under the cap, since one rclone move need not - # get there: a slow remote or a per-file error can leave finished files - # behind. Stop as soon as a sweep frees nothing, which means all that is - # left is in-flight .tmp files that no sweep can ever move. - local before - while :; do - before=$used - # No --min-age: tdl is frozen, so every non-.tmp file is finished by - # construction and waiting out the guard would only prolong the pause. - sweep '' || { rc=$?; break; } - used=$(staging_kib) - [[ -n $used ]] || break - ((used >= max_staging_kib && used < before)) || break - done - kill -CONT "$tdl_pid" 2>/dev/null || true - - if ((rc == 0)) && [[ -n $used ]] && ((used >= max_staging_kib)); then - log "warning: staging is still $((used / 1024)) MiB after draining — the cap is below what the in-flight downloads hold; raise -m or lower tdl's -l/-t" - else - log "resumed tdl, staging now $((${used:-0} / 1024)) MiB" - fi - return $rc -} - -cleanup() { - if [[ -n $tdl_pid ]] && kill -0 "$tdl_pid" 2>/dev/null; then - log "stopping tdl (pid $tdl_pid)" - # A suspended process never sees SIGTERM, so let it run first. - kill -CONT "$tdl_pid" 2>/dev/null || true - kill -TERM "$tdl_pid" 2>/dev/null || true - for _ in 1 2 3 4 5 6 7 8 9 10; do - kill -0 "$tdl_pid" 2>/dev/null || break - sleep 1 - done - kill -KILL "$tdl_pid" 2>/dev/null || true - fi - if ((sweep_ok)); then - log 'sweeping completed files before exit' - sweep "$min_age" 0 1 || log 'warning: final safety sweep failed; staging kept' - fi -} -trap cleanup EXIT -trap 'log "interrupted (SIGINT)"; exit 130' INT -trap 'log "terminated (SIGTERM)"; exit 143' TERM - -log "downloading into $staging, moving to $remote every ${interval}s${max_staging_kib:+, capped at $((max_staging_kib / 1024)) MiB}" -tdl dl -f "$export_file" -d "$staging" \ - --takeout --group --skip-same --continue ${tdl_extra[@]+"${tdl_extra[@]}"} & -tdl_pid=$! -sweep_ok=1 - -failures=0 -waited=0 -while kill -0 "$tdl_pid" 2>/dev/null; do - # A long sweep interval must not let staging blow past the cap in between, so - # sleep in slices and check the cap on each one. Every sleep is a job, so - # signals are handled without waiting the slice out. - slice=$((interval - waited)) - if [[ -n $max_staging_kib ]] && ((slice > cap_check_interval)); then - slice=$cap_check_interval - fi - sleep "$slice" & - wait $! 2>/dev/null || true - waited=$((waited + slice)) - - # Only a sweep that actually ran says anything about rclone's health, so the - # failure streak is judged on those alone and a quiet cap check never - # clears it. - rc=0 - swept=0 - if ((waited >= interval)); then - waited=0 - swept=1 - sweep "$min_age" || rc=$? - fi - if ((rc == 0)) && [[ -n $max_staging_kib ]] && kill -0 "$tdl_pid" 2>/dev/null; then - if over_cap; then - swept=1 - drain_to_cap || rc=$? - fi - fi - - if ((swept)); then - if ((rc == 0)); then - failures=0 - else - failures=$((failures + 1)) - log "warning: rclone sweep failed ($failures/$max_sync_failures)" - if ((failures >= max_sync_failures)); then - sweep_ok=0 - fail "rclone failed $failures times in a row; stopping before staging fills the disk" - fi - fi - fi -done - -wait "$tdl_pid" || tdl_rc=$? -tdl_pid='' -sweep_ok=0 - -if ((tdl_rc != 0)); then - log "tdl exited $tdl_rc; staging kept at $staging — re-run to resume" - sweep "$min_age" 0 1 || log 'warning: sweep after failure did not complete' - exit "$tdl_rc" -fi - -log 'tdl finished; final sweep' -sweep '' 1 1 || fail "final sweep failed; files remain in $staging" -log "done — everything moved to $remote" diff --git a/verify-export.sh b/verify-export.sh deleted file mode 100755 index 064d123..0000000 --- a/verify-export.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env bash -# -# Verify a tdl/rclone export is complete and every file is intact. -# -# Rebuilds the exact filename tdl produces for each media message in the export -# JSON ({DialogID}_{MessageID}_{FileName}) and checks it exists on the remote or -# in staging. The export is Telegram's own record of the name, so the match is -# exact: a file stored under any other name is not the file the export asked -# for and counts as absent, however close the name looks. Zero-byte files count -# as missing too: rclone overwrites a size-mismatched destination, so re-running -# repairs them. Files under 1 KiB are reported for inspection but trusted, since -# some real media is genuinely that small. -# -# A remote file whose id matches but whose name does not is a stale copy from an -# earlier download; it is listed separately so it can be deleted, because the -# re-download lands beside it rather than replacing it. -# -# Writes every id needing another attempt to missing-ids.txt. -# Exit 0 = complete, 1 = incomplete, 2 = usage error. -# -# Usage: ./verify-export.sh -f export-.json -r REMOTE:PATH [-d STAGING] - -set -euo pipefail - -export_file='' remote='' staging='./staging' -while getopts ':f:r:d:h' o; do case $o in - f) export_file=$OPTARG ;; r) remote=$OPTARG ;; d) staging=$OPTARG ;; - h) sed -n '2,16p' "$0"; exit 0 ;; - *) echo "usage: $0 -f FILE -r REMOTE:PATH [-d STAGING]" >&2; exit 2 ;; -esac; done - -[[ -f $export_file ]] || { echo "no such export file: $export_file" >&2; exit 2; } -[[ -n $remote ]] || { echo "-r REMOTE:PATH is required" >&2; exit 2; } - -listing=$(mktemp); trap 'rm -f "$listing"' EXIT -# lsl gives sizes as well as names, so truncated uploads are detectable. -rclone lsl "$remote" > "$listing" - -python3 - "$export_file" "$listing" "$staging" <<'PY' -import json, os, re, sys -export_file, listing, staging = sys.argv[1], sys.argv[2], sys.argv[3] - -sizes = {} -for line in open(listing): - m = re.match(r'^\s*(\d+)\s+\S+\s+\S+\s+(.*)$', line.rstrip('\n')) - if m: - sizes[os.path.basename(m.group(2))] = int(m.group(1)) -if os.path.isdir(staging): - for f in os.listdir(staging): - if not f.endswith('.tmp'): - sizes.setdefault(f, os.path.getsize(os.path.join(staging, f))) - -msgs = json.load(open(export_file))['messages'] -# Text-only messages carry an empty "file" and are not download targets. -media = [m for m in msgs if m.get('file')] - -dialog = str(json.load(open(export_file)).get('id', '')).lstrip('-') -if not dialog.isdigit(): - ids = {n.split('_')[0] for n in sizes if '_' in n} - dialog = ids.pop() if len(ids) == 1 else '' -if not dialog: - sys.exit('cannot determine dialog id') - -# Names already stored for each message id, used only to tell an absent file -# apart from one sitting there under the wrong name. -stored = {} -for name in sizes: - parts = name.split('_', 2) - if len(parts) == 3 and parts[0] == dialog and parts[1].isdigit(): - stored.setdefault(int(parts[1]), []).append(name) - -absent, empty, tiny, misnamed = [], [], [], [] -for m in media: - name = f"{dialog}_{m['id']}_{m['file']}" - if name not in sizes: - absent.append(m['id']) - for other in stored.get(m['id'], []): - misnamed.append((m['id'], name, other, sizes[other])) - elif sizes[name] == 0: - empty.append(m['id']) - elif sizes[name] < 1024: - tiny.append((m['id'], sizes[name], name)) - -todo = sorted(absent + empty) -print(f"messages in export : {len(msgs)}") -print(f" text-only (skip) : {len(msgs) - len(media)}") -print(f" media expected : {len(media)}") -print(f"present and intact : {len(media) - len(todo)}") -print(f" absent : {len(absent)}") -print(f" zero-byte : {len(empty)}") -if tiny: - print(f" under 1KiB (check, not retried): {len(tiny)}") - for i, s, n in tiny[:5]: - print(f" id {i} {s} B {n}") -if misnamed: - print(f"\nstored under a different name : {len(misnamed)}") - print(" counted as absent and fetched again; delete the stale copies so the") - print(" re-download does not leave two files for the same message:") - for i, want, got, size in misnamed: - print(f" id {i} {size} B") - print(f" export: {want}") - print(f" remote: {got}") - -if todo: - with open('missing-ids.txt', 'w') as fh: - fh.write('\n'.join(map(str, todo)) + '\n') - print(f"\nneeds another pass : {len(todo)} (ids {min(todo)}–{max(todo)})") - print("written to missing-ids.txt") - sys.exit(1) - -if os.path.exists('missing-ids.txt'): - os.remove('missing-ids.txt') -print("\nCOMPLETE: every media message is present and non-empty.") -PY