diff options
| -rw-r--r-- | CLAUDE.md | 2 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | tg_backup.py | 40 |
3 files changed, 43 insertions, 4 deletions
@@ -41,7 +41,7 @@ Resume/dedup is also file-based: media is named `{message.id}_{sanitized_name}` `--archive-dir` holds media files, `state.json`, and `failures.json`, and is created if missing (`parents=True`). It is per-chat: one dir per backed-up chat. -`state.json` also stores the `target` the dir was last backed up with, so subsequent runs need only `--archive-dir`. An explicit `--target` still wins; it is only required for a dir with no saved target (a first run, or one created before this was added). Nothing guards against pointing an existing dir at a *different* chat: `last_id` from the old chat would be applied as `min_id` to the new one and skip its history. +`state.json` also stores the `target` the dir was last backed up with, so subsequent runs need only `--archive-dir`. An explicit `--target` still wins; it is only required for a dir with no saved target (a first run, or one created before this was added). Pointing an existing dir at a *different* chat is refused before any network work (`same_target` in `run_backup`), because `last_id` from the old chat would be applied as `min_id` to the new one and skip its history while reporting success. The comparison is textual, ignoring case and a leading `@`; it cannot tell that `@foo` and `-100…` are the same chat, since resolving that needs a logged-in client, so that case refuses too. `--force-target` overrides and keeps the existing resume position. `failures.json` is append-only JSONL, one `{"id", "file", "error"}` object per line, written only when a download exhausts `MAX_RETRIES`. JSONL rather than a JSON array so appending is a single write with no read-modify-write: a crash costs the last line, not the file. Read it with `jq -s`. Nothing reads or prunes it, and a message failing on two runs is logged twice. @@ -66,7 +66,9 @@ Update it later with just the directory: tg_backup.py --archive-dir ~/backups/examplegroup ``` -The target is recorded in the archive directory on the first run, so repeat runs do not need it. Passing `--target` again still overrides what was saved. Point a directory at a different chat and the resume position carries over, which will skip that chat's history; use a fresh directory instead. +The target is recorded in the archive directory on the first run, so repeat runs do not need it. Passing the same `--target` again is fine; the `@` and letter case do not have to match. + +Passing a *different* one is refused, because the directory's resume position belongs to the old chat and would skip the new chat's history. Use one directory per chat. If a chat genuinely changed handle or ID, `--force-target` accepts the new target and keeps the resume position. Switching between a chat's `@username` and its numeric ID also trips this, as the two cannot be compared without resolving them first. ### Options @@ -75,6 +77,7 @@ The target is recorded in the archive directory on the first run, so repeat runs | `--target` | first run only | Chat to back up: `@username`, numeric ID, t.me link, or `me`. Reused from the archive directory afterwards | | `--archive-dir` | `./tg_archive` | Where media and resume state are written; created if missing | | `--list-chats` | | Print your dialogs with IDs and usernames, then exit | +| `--force-target` | | Accept a `--target` that differs from the one saved in the archive directory | | `--self-check` | | Run internal assertions and exit. No network, no login | Use one archive directory per chat. Prefer absolute paths: the default is relative to the current directory, so running from elsewhere silently starts a second archive. diff --git a/tg_backup.py b/tg_backup.py index 382a756..779d76e 100644 --- a/tg_backup.py +++ b/tg_backup.py @@ -144,6 +144,15 @@ def parse_target(target): return int(t) return t +def same_target(a, b): + # Only catches targets that are textually the same chat. "@foo" and "foo" + # match; "@foo" and its numeric ID do not, since resolving them needs a + # logged-in client and the check runs before that. + if a is None or b is None: + return True # nothing saved to contradict + norm = lambda t: str(t).strip().lstrip('@').lower() + return norm(a) == norm(b) + def make_client(archive_dir): # One session in CONFIG_DIR, shared by every archive dir: log in once, back # up any number of chats. @@ -189,7 +198,19 @@ async def list_chats(archive_dir): await client.disconnect() print("\nBack up with: --target=<ID> (the = matters for negative IDs)") -async def run_backup(target, archive_dir): +async def run_backup(target, archive_dir, force_target=False): + # last_id is a high-water mark for one specific chat. Applied as min_id to a + # different one it silently skips that chat's history, and the run reports + # success. Refuse before touching the network. + saved = load_state(archive_dir).get('target') + if not force_target and not same_target(saved, target): + sys.exit(f"Archive dir {archive_dir} was last backed up with target " + f"'{saved}', not '{target}'.\n" + "Its saved resume position belongs to that chat and would skip " + "this one's history. Use a separate archive dir per chat, or " + "pass --force-target to overwrite (only correct if the same " + "chat changed handle or ID).") + client = make_client(archive_dir) await start_client(client) @@ -251,6 +272,9 @@ def main(): help="Directory to store media and state.") parser.add_argument("--list-chats", action="store_true", help="List your dialogs with their IDs and exit.") + parser.add_argument("--force-target", action="store_true", + help="Back up --target into an archive dir saved with a " + "different one, keeping its resume position.") args = parser.parse_args() if args.list_chats: @@ -259,7 +283,7 @@ def main(): # An archive dir already backed up once knows its own target. target = args.target or load_state(args.archive_dir).get('target') if target: - asyncio.run(run_backup(target, args.archive_dir)) + asyncio.run(run_backup(target, args.archive_dir, args.force_target)) else: parser.error(f"--target is required the first time ({args.archive_dir}/state.json " "has no saved target), or use --list-chats") @@ -308,6 +332,18 @@ def _self_check(): assert load_state(archive).get('target') == '-1001234567890' assert load_state(Path(tempfile.mkdtemp())).get('target') is None + # A dir keeps its chat. Handle case and the @ prefix are noise, a different + # chat is not. Nothing saved yet must never block a first run. + assert same_target('@examplegroup', 'examplegroup') + assert same_target('@ExampleGroup', ' @examplegroup ') + assert same_target('-1001234567890', '-1001234567890') + assert same_target(-1001234567890, '-1001234567890') # state may hold an int + assert same_target(None, '@examplegroup') # first run, nothing saved + assert same_target('@examplegroup', None) # resumed run, no --target given + assert not same_target('@examplegroup', '@othergroup') + assert not same_target('-1001234567890', '-1009876543210') + assert not same_target('@examplegroup', '-1001234567890') # unresolvable, refuse + # Failures append, one parseable record per line, and survive an exception # object being passed straight in. record_failure(archive, 11, '11_a.jpg', OSError('disk full')) |
