aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md2
-rw-r--r--README.md5
-rw-r--r--tg_backup.py40
3 files changed, 43 insertions, 4 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index c0af417..e0fb0e1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
diff --git a/README.md b/README.md
index ee917fd..29c7521 100644
--- a/README.md
+++ b/README.md
@@ -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'))