first commit
This commit is contained in:
+119
@@ -0,0 +1,119 @@
|
|||||||
|
# Copy to .env for local runs. On Heroku set these as Config Vars instead.
|
||||||
|
# Placeholder values only - never commit real secrets.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Telegram
|
||||||
|
# user = your own account (full history, needs login); bot = bot admin of the channel
|
||||||
|
TELEGRAM_MODE=user
|
||||||
|
# From https://my.telegram.org -> API development tools (needed in both modes)
|
||||||
|
TELEGRAM_API_ID=1234567
|
||||||
|
TELEGRAM_API_HASH=your_api_hash_here
|
||||||
|
# From @BotFather: admin command interface (required in bot mode, recommended in user mode)
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:replace-with-your-bot-token
|
||||||
|
# User mode on Heroku: output of `python main.py login --session-string`.
|
||||||
|
# Locally you can leave it empty and use `python main.py login` (session file).
|
||||||
|
TELEGRAM_SESSION_STRING=
|
||||||
|
# Numeric Telegram user IDs allowed to use the commands (comma-separated)
|
||||||
|
ADMIN_USER_IDS=111111111
|
||||||
|
# Chats that receive progress/notifications (default: ADMIN_USER_IDS)
|
||||||
|
NOTIFY_CHAT_IDS=
|
||||||
|
# @username, https://t.me/... link, or -100... numeric ID
|
||||||
|
SOURCE_CHANNEL_ID=@your_channel
|
||||||
|
# existing | new | both
|
||||||
|
PROCESS_MODE=both
|
||||||
|
START_MESSAGE_ID=1
|
||||||
|
# Optional ISO date: only messages on/after this date (e.g. 2024-01-31)
|
||||||
|
START_DATE=
|
||||||
|
INCLUDE_DOCUMENT_VIDEOS=true
|
||||||
|
# Optional comma-separated filters (case-insensitive)
|
||||||
|
FILTER_TOPICS=
|
||||||
|
FILTER_BATCHES=
|
||||||
|
# Bot mode history probing (bots cannot list history)
|
||||||
|
BOT_SCAN_MAX_EMPTY=200
|
||||||
|
BOT_SCAN_END_ID=
|
||||||
|
# Telegram flood waits shorter than this (seconds) are slept through automatically
|
||||||
|
FLOOD_SLEEP_THRESHOLD=60
|
||||||
|
SESSION_DIR=data/sessions
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Google Drive
|
||||||
|
# oauth (personal Drive) | service_account (Shared Drive)
|
||||||
|
GOOGLE_AUTH_MODE=oauth
|
||||||
|
# drive.file (least privilege; bot only sees folders it created) | drive (full access)
|
||||||
|
GOOGLE_DRIVE_SCOPE=drive.file
|
||||||
|
# Local OAuth files (used by `python main.py drive-auth`)
|
||||||
|
GOOGLE_CLIENT_SECRETS_FILE=credentials/client_secret.json
|
||||||
|
GOOGLE_TOKEN_FILE=credentials/token.json
|
||||||
|
# Heroku / file-less alternatives (raw JSON or base64 JSON):
|
||||||
|
GOOGLE_OAUTH_TOKEN_JSON=
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_JSON=
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_FILE=
|
||||||
|
# Folder that will contain the topic folders (empty = top level of My Drive)
|
||||||
|
GOOGLE_DRIVE_ROOT_FOLDER_ID=
|
||||||
|
DRIVE_SHARED_DRIVE_ID=
|
||||||
|
# none (private, default) | anyone_with_link
|
||||||
|
DRIVE_SHARE_MODE=none
|
||||||
|
# Multiple of 256KB; one chunk is held in memory at a time
|
||||||
|
UPLOAD_CHUNK_SIZE=16MB
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Organisation & naming
|
||||||
|
BATCH_SUBFOLDERS_ENABLED=false
|
||||||
|
FALLBACK_TOPIC_FOLDER=Uncategorized
|
||||||
|
FALLBACK_BATCH_FOLDER=Unknown Batch
|
||||||
|
# suffix_number | suffix_message_id | skip | fail (never overwrites)
|
||||||
|
DUPLICATE_POLICY=suffix_number
|
||||||
|
# Reuse an existing same-name file in the folder when size and MD5 match
|
||||||
|
REUSE_IDENTICAL_FILES=true
|
||||||
|
# skip | upload : the same Telegram media posted again under a new message ID
|
||||||
|
DUPLICATE_MEDIA_POLICY=skip
|
||||||
|
# fallback (Telegram file name / telegram_<id>) | fail
|
||||||
|
MISSING_TITLE_POLICY=fallback
|
||||||
|
# keep | append : when the Title extension disagrees with the real container
|
||||||
|
EXTENSION_MISMATCH_POLICY=keep
|
||||||
|
# Replace characters Windows cannot store (<>:"|?*)
|
||||||
|
STRICT_FILENAMES=false
|
||||||
|
# Optional persistent sequence number prefix, e.g. "001 - Class 01.mp4"
|
||||||
|
SEQUENCE_PREFIX_ENABLED=false
|
||||||
|
SEQUENCE_PREFIX_FORMAT="{seq:03d} - "
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Storage
|
||||||
|
# PostgreSQL (required on Heroku; Heroku Postgres sets it automatically)
|
||||||
|
DATABASE_URL=
|
||||||
|
# SQLite file used when DATABASE_URL is empty (local development)
|
||||||
|
DATABASE_PATH=data/bot.db
|
||||||
|
# Only set to true if you accept losing all progress on every Heroku restart
|
||||||
|
ALLOW_EPHEMERAL_DATABASE=false
|
||||||
|
DATA_DIR=data
|
||||||
|
DOWNLOAD_DIR=downloads
|
||||||
|
LOG_DIR=logs
|
||||||
|
# 0 = unlimited
|
||||||
|
MAX_TEMP_STORAGE=0
|
||||||
|
MIN_FREE_DISK_SPACE=1GB
|
||||||
|
KEEP_LOCAL_FILES=false
|
||||||
|
# Keep the local file of a video that FAILED (default: delete it; it can be downloaded again)
|
||||||
|
KEEP_FAILED_DOWNLOADS=false
|
||||||
|
# Seconds between sweeps that delete abandoned temp files (0 = only at startup / when disk is low)
|
||||||
|
TEMP_CLEANUP_INTERVAL=3600
|
||||||
|
COMPUTE_SHA256=true
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Processing
|
||||||
|
MAX_RETRIES=4
|
||||||
|
RETRY_DELAY=30
|
||||||
|
RETRY_MAX_DELAY=1800
|
||||||
|
# Retries of a single HTTP request inside one attempt
|
||||||
|
HTTP_RETRIES=5
|
||||||
|
PROCESSING_DELAY=2
|
||||||
|
# true = halt the whole queue when any video fails permanently
|
||||||
|
STRICT_STOP=false
|
||||||
|
# true = start processing at every boot (otherwise /run; then remembered across restarts)
|
||||||
|
AUTO_START=false
|
||||||
|
# Seconds to wait for another worker to release the queue lock (default 0 locally, 90 on Heroku)
|
||||||
|
INSTANCE_LOCK_WAIT=
|
||||||
|
PROGRESS_INTERVAL=10
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- /upload tasks
|
||||||
|
# Minutes before an unfinished /upload setup (waiting for links or /confirm) expires
|
||||||
|
UPLOAD_SETUP_TIMEOUT=30
|
||||||
|
# Largest allowed range (end - start + 1) for one task
|
||||||
|
UPLOAD_MAX_RANGE=5000
|
||||||
|
# true = links must point to SOURCE_CHANNEL_ID; false = any channel the Telegram account/bot can access
|
||||||
|
UPLOAD_RESTRICT_TO_SOURCE_CHANNEL=true
|
||||||
|
LOG_LEVEL=info
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Secrets and credentials - never commit
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
credentials/
|
||||||
|
client_secret*.json
|
||||||
|
token.json
|
||||||
|
*service-account*.json
|
||||||
|
*.session
|
||||||
|
*.session-journal
|
||||||
|
session_string.txt
|
||||||
|
|
||||||
|
# Local state (SQLite, sessions, downloads, logs)
|
||||||
|
data/
|
||||||
|
downloads/
|
||||||
|
logs/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.lock
|
||||||
|
|
||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
+388
@@ -0,0 +1,388 @@
|
|||||||
|
# How to Obtain Every Environment Variable
|
||||||
|
|
||||||
|
This guide explains where each configuration value comes from and how to get it. Work through the
|
||||||
|
sections in order: some steps (the Telegram session string and the Google token) need values from
|
||||||
|
earlier steps.
|
||||||
|
|
||||||
|
- **Local runs:** put the values in a `.env` file (`cp .env.example .env`).
|
||||||
|
- **Heroku:** set them as Config Vars, either in the dashboard (*App → Settings → Reveal Config Vars*)
|
||||||
|
or with `heroku config:set NAME=value --app YOUR_APP_NAME`.
|
||||||
|
|
||||||
|
> All values below are placeholders. Never commit real values or paste them into chats, issues or
|
||||||
|
> screenshots. `.env`, `credentials/` and `data/` are already in `.gitignore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick checklist
|
||||||
|
|
||||||
|
| # | Variable | Required? | Where it comes from |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `TELEGRAM_API_ID` | Always | my.telegram.org |
|
||||||
|
| 2 | `TELEGRAM_API_HASH` | Always | my.telegram.org |
|
||||||
|
| 3 | `TELEGRAM_BOT_TOKEN` | Always on Heroku (control commands); required in bot mode | @BotFather |
|
||||||
|
| 4 | `ADMIN_USER_IDS` | When a bot token is set | @userinfobot |
|
||||||
|
| 5 | `SOURCE_CHANNEL_ID` | Always | Channel username, link, or numeric ID |
|
||||||
|
| 6 | `TELEGRAM_MODE` | Optional (default `user`) | Your choice: `user` or `bot` |
|
||||||
|
| 7 | `TELEGRAM_SESSION_STRING` | `TELEGRAM_MODE=user` on Heroku | `python main.py login --session-string` |
|
||||||
|
| 8 | `GOOGLE_AUTH_MODE` | Optional (default `oauth`) | Your choice: `oauth` or `service_account` |
|
||||||
|
| 9 | `GOOGLE_OAUTH_TOKEN_JSON` | `oauth` on Heroku | `python main.py drive-auth` |
|
||||||
|
| 10 | `GOOGLE_SERVICE_ACCOUNT_JSON` | `service_account` on Heroku | Google Cloud Console key |
|
||||||
|
| 11 | `GOOGLE_DRIVE_ROOT_FOLDER_ID` | Recommended | Drive folder URL, or `python main.py drive-init` |
|
||||||
|
| 12 | `GOOGLE_DRIVE_SCOPE` | Optional (default `drive.file`) | Your choice |
|
||||||
|
| 13 | `DRIVE_SHARED_DRIVE_ID` | Shared Drive setups only | Shared Drive URL |
|
||||||
|
| 14 | `DATABASE_URL` | Required on Heroku | Heroku Postgres (set automatically) |
|
||||||
|
|
||||||
|
Everything else has a sensible default (see [Optional settings](#optional-settings)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Telegram
|
||||||
|
|
||||||
|
### 1–2. `TELEGRAM_API_ID` and `TELEGRAM_API_HASH`
|
||||||
|
|
||||||
|
These identify your Telegram API application. They are needed in **both** modes, because the bot talks
|
||||||
|
to Telegram through Telethon (MTProto).
|
||||||
|
|
||||||
|
1. Open <https://my.telegram.org> and log in with your phone number. Telegram sends a code to your
|
||||||
|
Telegram app, not by SMS.
|
||||||
|
2. Click **API development tools**.
|
||||||
|
3. If no app exists yet, fill in the form:
|
||||||
|
- *App title*: anything, for example `Drive Organizer`;
|
||||||
|
- *Short name*: 5–32 letters or digits, for example `driveorganizer`;
|
||||||
|
- *Platform*: `Desktop`;
|
||||||
|
- URL and description can stay empty.
|
||||||
|
4. Click **Create application**.
|
||||||
|
5. Copy the two values:
|
||||||
|
- **App api_id** (a number) → `TELEGRAM_API_ID`
|
||||||
|
- **App api_hash** (32 hex characters) → `TELEGRAM_API_HASH`
|
||||||
|
|
||||||
|
```env
|
||||||
|
TELEGRAM_API_ID=1234567
|
||||||
|
TELEGRAM_API_HASH=0123456789abcdef0123456789abcdef
|
||||||
|
```
|
||||||
|
|
||||||
|
> The API hash is a secret. If my.telegram.org shows an "ERROR" when you create the app, try another
|
||||||
|
> browser, disable ad blockers or VPNs, and use a short alphanumeric short name.
|
||||||
|
|
||||||
|
### 3. `TELEGRAM_BOT_TOKEN`
|
||||||
|
|
||||||
|
The bot provides the admin commands (`/run`, `/status`, …) and progress messages. In bot mode it also
|
||||||
|
reads the channel.
|
||||||
|
|
||||||
|
1. In Telegram, open [@BotFather](https://t.me/BotFather) and send `/newbot`.
|
||||||
|
2. Enter a display name, then a username ending in `bot` (for example `my_drive_organizer_bot`).
|
||||||
|
3. BotFather replies with a token like `123456789:AAH...`. Copy it → `TELEGRAM_BOT_TOKEN`.
|
||||||
|
4. **Open your new bot and press Start.** Bots cannot message you until you do, so without this you
|
||||||
|
get no progress messages.
|
||||||
|
|
||||||
|
```env
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:replace-with-your-bot-token
|
||||||
|
```
|
||||||
|
|
||||||
|
If the token leaks, send `/revoke` to @BotFather to get a new one.
|
||||||
|
|
||||||
|
### 4. `ADMIN_USER_IDS` (and optional `NOTIFY_CHAT_IDS`)
|
||||||
|
|
||||||
|
Only these numeric Telegram user IDs may control the bot.
|
||||||
|
|
||||||
|
1. Open [@userinfobot](https://t.me/userinfobot) and press Start.
|
||||||
|
2. It replies with your **Id** (a number such as `111111111`). Copy it.
|
||||||
|
3. For several admins, ask each person to do the same and separate the IDs with commas.
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_USER_IDS=111111111,222222222
|
||||||
|
```
|
||||||
|
|
||||||
|
`NOTIFY_CHAT_IDS` is optional. It lists the chats that receive progress and error messages, and
|
||||||
|
defaults to `ADMIN_USER_IDS`. Each person listed must have pressed Start in the bot.
|
||||||
|
|
||||||
|
### 5. `SOURCE_CHANNEL_ID`
|
||||||
|
|
||||||
|
Any of these formats is accepted:
|
||||||
|
|
||||||
|
| Channel type | How to get it | Example value |
|
||||||
|
|---|---|---|
|
||||||
|
| Public channel | The `@username` shown in the channel info | `@my_channel` or `https://t.me/my_channel` |
|
||||||
|
| Private channel: post link | Right-click (or long-press) any post → **Copy Post Link** | `https://t.me/c/1234567890/55` |
|
||||||
|
| Private channel: numeric ID | Prefix the number from the post link with `-100` | `-1001234567890` |
|
||||||
|
| Private channel: Telegram Web | Open the channel at <https://web.telegram.org/k/>; the URL ends with `#-100…` | `-1001234567890` |
|
||||||
|
|
||||||
|
Another way: forward a channel post to [@JsonDumpBot](https://t.me/JsonDumpBot) and read
|
||||||
|
`forward_from_chat.id`.
|
||||||
|
|
||||||
|
```env
|
||||||
|
SOURCE_CHANNEL_ID=-1001234567890
|
||||||
|
```
|
||||||
|
|
||||||
|
> Bot mode with a private channel: the bot must be a channel **administrator**. If the numeric ID
|
||||||
|
> can't be resolved at the first start, post one message in the channel. The bot then learns the
|
||||||
|
> channel and remembers it in the database.
|
||||||
|
|
||||||
|
### 6. `TELEGRAM_MODE`
|
||||||
|
|
||||||
|
This is a choice, not a credential.
|
||||||
|
|
||||||
|
| Value | Use when | Needs |
|
||||||
|
|---|---|---|
|
||||||
|
| `user` (default) | You need the channel's **existing history**, or you are a member but not an admin | `TELEGRAM_SESSION_STRING` (Heroku) or a local login |
|
||||||
|
| `bot` | You only need **new posts**, and you can make the bot a channel admin | Bot added as channel administrator |
|
||||||
|
|
||||||
|
To add the bot as admin: open the channel → **Manage channel → Administrators → Add Admin** → search
|
||||||
|
for your bot's username → save. No extra rights are needed beyond the default.
|
||||||
|
|
||||||
|
### 7. `TELEGRAM_SESSION_STRING` (user mode only)
|
||||||
|
|
||||||
|
This lets the bot act as your Telegram account without a session file. Heroku's disk is wiped on
|
||||||
|
every restart, so a file wouldn't survive. You create it **once, on your own computer**.
|
||||||
|
|
||||||
|
1. Do the [local setup](README.md#19-local-development): create the virtualenv and install
|
||||||
|
requirements.
|
||||||
|
2. Create `.env` containing at least:
|
||||||
|
```env
|
||||||
|
TELEGRAM_API_ID=1234567
|
||||||
|
TELEGRAM_API_HASH=0123456789abcdef0123456789abcdef
|
||||||
|
```
|
||||||
|
3. Run:
|
||||||
|
```bash
|
||||||
|
python main.py login --session-string
|
||||||
|
```
|
||||||
|
4. Enter your phone number in international format (`+15551234567`), then the login code Telegram
|
||||||
|
sends to your app, then your 2FA password if you have one. **None of these are stored.**
|
||||||
|
5. The string is written to `data/sessions/session_string.txt` with owner-only permissions.
|
||||||
|
6. Put it on Heroku without printing it:
|
||||||
|
```bash
|
||||||
|
heroku config:set TELEGRAM_SESSION_STRING="$(cat data/sessions/session_string.txt)" --app YOUR_APP_NAME
|
||||||
|
```
|
||||||
|
For a local `.env`, paste the file's contents after `TELEGRAM_SESSION_STRING=`.
|
||||||
|
7. Delete the file: `rm data/sessions/session_string.txt`.
|
||||||
|
|
||||||
|
> The session string grants **full access to your Telegram account**. Treat it like a password. To
|
||||||
|
> revoke it: Telegram → *Settings → Devices* → terminate the session. Then generate a new string.
|
||||||
|
|
||||||
|
For local-only use you can skip the string and run `python main.py login`, which creates
|
||||||
|
`data/sessions/user.session` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Google Drive
|
||||||
|
|
||||||
|
Pick **one** authentication method:
|
||||||
|
|
||||||
|
| Your Drive | Use | Variable to obtain |
|
||||||
|
|---|---|---|
|
||||||
|
| Personal Google account (`@gmail.com`) or your own Workspace Drive | **OAuth** (`GOOGLE_AUTH_MODE=oauth`) | `GOOGLE_OAUTH_TOKEN_JSON` |
|
||||||
|
| Google Workspace **Shared Drive** | **Service account** (`GOOGLE_AUTH_MODE=service_account`) | `GOOGLE_SERVICE_ACCOUNT_JSON` |
|
||||||
|
|
||||||
|
> A service account **cannot** upload into a personal My Drive: it has no storage quota there. Use
|
||||||
|
> OAuth for personal Drives.
|
||||||
|
|
||||||
|
### Common first step: Google Cloud project and Drive API
|
||||||
|
|
||||||
|
1. Open <https://console.cloud.google.com/> and sign in with the Google account that owns the
|
||||||
|
destination Drive.
|
||||||
|
2. Use the project selector at the top → **New Project** → give it a name → **Create**, then select
|
||||||
|
it.
|
||||||
|
3. Go to **APIs & Services → Library**, search for **Google Drive API**, and click **Enable**.
|
||||||
|
|
||||||
|
### 9. `GOOGLE_OAUTH_TOKEN_JSON` (OAuth)
|
||||||
|
|
||||||
|
#### a) Configure the consent screen
|
||||||
|
|
||||||
|
1. Go to **APIs & Services → OAuth consent screen**. In the newer console this is **Google Auth
|
||||||
|
Platform → Branding / Audience**.
|
||||||
|
2. User type: **External** (or *Internal* if you use Workspace and only your organisation needs
|
||||||
|
access).
|
||||||
|
3. Fill in the app name, your support email and developer contact email, then save.
|
||||||
|
4. Under **Audience** (or *Test users*), add your own Google account.
|
||||||
|
5. **Publish the app** (*Publishing status → In production*).
|
||||||
|
> If the app stays in *Testing*, Google expires its refresh tokens after **7 days** and the bot
|
||||||
|
> will lose Drive access every week. With the default `drive.file` scope no Google verification
|
||||||
|
> is needed. With the full `drive` scope you'll see an "unverified app" warning during your own
|
||||||
|
> login: click *Advanced → Go to (app name)*.
|
||||||
|
|
||||||
|
#### b) Create the OAuth client
|
||||||
|
|
||||||
|
1. Go to **APIs & Services → Credentials → Create Credentials → OAuth client ID**.
|
||||||
|
2. Application type: **Desktop app**. Give it any name → **Create**.
|
||||||
|
3. Click **Download JSON** and save it in the project as `credentials/client_secret.json`. Create the
|
||||||
|
`credentials/` folder if needed; it is gitignored.
|
||||||
|
|
||||||
|
#### c) Choose the scope *before* authorizing
|
||||||
|
|
||||||
|
| `GOOGLE_DRIVE_SCOPE` | Meaning | Choose it if |
|
||||||
|
|---|---|---|
|
||||||
|
| `drive.file` (default, least privilege) | The bot only sees files and folders **it created** | You let the bot create its own root folder (step 11, option B) |
|
||||||
|
| `drive` | Full access to your Drive | You want uploads to go into a folder **you** created |
|
||||||
|
|
||||||
|
Set it in `.env` (for example `GOOGLE_DRIVE_SCOPE=drive.file`). Use the **same value on Heroku**.
|
||||||
|
|
||||||
|
#### d) Authorize and get the token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py drive-auth # opens a browser
|
||||||
|
python main.py drive-auth --no-browser # prints a URL to open yourself
|
||||||
|
```
|
||||||
|
|
||||||
|
Sign in, approve access, and the token is saved to `credentials/token.json`. It contains the refresh
|
||||||
|
token, client ID and client secret. Then set it on Heroku:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
heroku config:set GOOGLE_OAUTH_TOKEN_JSON="$(cat credentials/token.json)" --app YOUR_APP_NAME
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer pasting into the Heroku dashboard, a single-line base64 version also works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
base64 < credentials/token.json | tr -d '\n'
|
||||||
|
```
|
||||||
|
|
||||||
|
```env
|
||||||
|
GOOGLE_AUTH_MODE=oauth
|
||||||
|
GOOGLE_OAUTH_TOKEN_JSON={"token": "...", "refresh_token": "...", "client_id": "...", "client_secret": "...", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
> If you change `GOOGLE_DRIVE_SCOPE` later, delete `credentials/token.json`, run `drive-auth` again,
|
||||||
|
> and update the Config Var. To revoke access: <https://myaccount.google.com/permissions>.
|
||||||
|
|
||||||
|
### 10. `GOOGLE_SERVICE_ACCOUNT_JSON` (Shared Drive)
|
||||||
|
|
||||||
|
1. In the Cloud Console: **IAM & Admin → Service Accounts → Create service account**. Give it a name;
|
||||||
|
no project roles are needed. Click **Done**.
|
||||||
|
2. Open the service account → **Keys → Add key → Create new key → JSON**. The file downloads once.
|
||||||
|
Store it safely.
|
||||||
|
3. Copy the `client_email` value from the JSON (for example
|
||||||
|
`uploader@your-project.iam.gserviceaccount.com`).
|
||||||
|
4. In Google Drive, open the **Shared Drive** → **Manage members** → add that email as
|
||||||
|
**Content manager**.
|
||||||
|
5. Set the variables:
|
||||||
|
```bash
|
||||||
|
heroku config:set GOOGLE_AUTH_MODE=service_account \
|
||||||
|
GOOGLE_DRIVE_SCOPE=drive \
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_JSON="$(cat path/to/key.json)" --app YOUR_APP_NAME
|
||||||
|
```
|
||||||
|
Use `GOOGLE_DRIVE_SCOPE=drive`, because the service account must reach a Shared Drive folder it
|
||||||
|
did not create.
|
||||||
|
Locally you can instead set `GOOGLE_SERVICE_ACCOUNT_FILE=path/to/key.json`.
|
||||||
|
|
||||||
|
> Your organisation may block service-account key creation (policy
|
||||||
|
> `iam.disableServiceAccountKeyCreation`). In that case use OAuth.
|
||||||
|
|
||||||
|
### 11. `GOOGLE_DRIVE_ROOT_FOLDER_ID`
|
||||||
|
|
||||||
|
This is the folder that will contain all topic folders. If you leave it empty, topic folders are
|
||||||
|
created at the top level of My Drive.
|
||||||
|
|
||||||
|
**Option A: a folder you created** (needs `GOOGLE_DRIVE_SCOPE=drive`, or a Shared Drive folder)
|
||||||
|
1. Open the folder in Google Drive in a browser.
|
||||||
|
2. The URL looks like `https://drive.google.com/drive/folders/1AbCdEfGhIjKlMnOpQrStUvWxYz`.
|
||||||
|
3. The part after `/folders/` is the ID → `GOOGLE_DRIVE_ROOT_FOLDER_ID`.
|
||||||
|
|
||||||
|
**Option B: let the bot create it** (works with the default `drive.file` scope)
|
||||||
|
1. Run this after `drive-auth`:
|
||||||
|
```bash
|
||||||
|
python main.py drive-init --name "Telegram Videos"
|
||||||
|
```
|
||||||
|
2. It prints `Set GOOGLE_DRIVE_ROOT_FOLDER_ID=...`. Copy that ID.
|
||||||
|
|
||||||
|
```env
|
||||||
|
GOOGLE_DRIVE_ROOT_FOLDER_ID=1AbCdEfGhIjKlMnOpQrStUvWxYz
|
||||||
|
```
|
||||||
|
|
||||||
|
At startup the bot checks that it can see this folder. If it can't, it stops and explains how to fix
|
||||||
|
the problem.
|
||||||
|
|
||||||
|
### 13. `DRIVE_SHARED_DRIVE_ID` (Shared Drive only)
|
||||||
|
|
||||||
|
Open the Shared Drive itself (its top level). The URL is
|
||||||
|
`https://drive.google.com/drive/folders/<ID>`, and that `<ID>` is the Shared Drive ID.
|
||||||
|
|
||||||
|
```env
|
||||||
|
DRIVE_SHARED_DRIVE_ID=0AbCdEfGhIjKlUk9PVA
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DRIVE_SHARE_MODE`
|
||||||
|
|
||||||
|
Leave it as `none` so uploads stay private. Set `anyone_with_link` only if every uploaded video
|
||||||
|
should be viewable by anyone who has its link.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Database
|
||||||
|
|
||||||
|
### 14. `DATABASE_URL`
|
||||||
|
|
||||||
|
- **Heroku Deploy button:** the Heroku Postgres add-on is provisioned and `DATABASE_URL` is set
|
||||||
|
automatically. Nothing to do.
|
||||||
|
- **Manual Heroku setup:**
|
||||||
|
```bash
|
||||||
|
heroku addons:create heroku-postgresql:essential-0 --app YOUR_APP_NAME
|
||||||
|
heroku config:get DATABASE_URL --app YOUR_APP_NAME # confirm it is set
|
||||||
|
```
|
||||||
|
This is a paid plan; check Heroku's current pricing. Don't copy the URL anywhere else, because
|
||||||
|
Heroku may rotate it.
|
||||||
|
- **Other PostgreSQL providers:** use their connection string
|
||||||
|
(`postgresql://USER:PASSWORD@HOST:5432/DBNAME`). TLS is added automatically for remote hosts.
|
||||||
|
- **Local development:** leave it empty to use SQLite at `data/bot.db`.
|
||||||
|
|
||||||
|
On Heroku the bot refuses to start without `DATABASE_URL`, unless you set
|
||||||
|
`ALLOW_EPHEMERAL_DATABASE=true`. With that set, all progress is lost on every restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional settings
|
||||||
|
|
||||||
|
These are preferences, not credentials. Defaults are shown; full descriptions are in
|
||||||
|
[`.env.example`](.env.example).
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PROCESS_MODE` | `both` | `existing` (history), `new` (new posts), or `both` |
|
||||||
|
| `START_MESSAGE_ID` | `1` | First message ID to scan |
|
||||||
|
| `START_DATE` | empty | Only messages on or after this ISO date, for example `2024-01-31` |
|
||||||
|
| `INCLUDE_DOCUMENT_VIDEOS` | `true` | Also process videos sent as files |
|
||||||
|
| `FILTER_TOPICS` / `FILTER_BATCHES` | empty | Comma-separated allow-lists |
|
||||||
|
| `BATCH_SUBFOLDERS_ENABLED` | `false` | `Root/Batch/Topic` folder layout |
|
||||||
|
| `DUPLICATE_POLICY` | `suffix_number` | `suffix_number`, `suffix_message_id`, `skip`, `fail` |
|
||||||
|
| `MAX_RETRIES` / `RETRY_DELAY` / `RETRY_MAX_DELAY` | `4` / `30` / `1800` | Retry behaviour (seconds) |
|
||||||
|
| `PROGRESS_INTERVAL` | `10` | Seconds between progress-message edits |
|
||||||
|
| `DOWNLOAD_DIR` | `downloads` (`/tmp/telegram_drive_bot` on Heroku) | Temporary video storage |
|
||||||
|
| `UPLOAD_CHUNK_SIZE` | `16MB` (`8MB` on Heroku) | Upload chunk size (multiple of 256KB) |
|
||||||
|
| `MIN_FREE_DISK_SPACE` | `1GB` (`512MB` on Heroku) | Free-disk reserve |
|
||||||
|
| `AUTO_START` | `false` | Start processing at every boot |
|
||||||
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warning`, `error` |
|
||||||
|
| `BOT_SCAN_MAX_EMPTY` / `BOT_SCAN_END_ID` | `200` / empty | Bot-mode history probing limits |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verify your configuration
|
||||||
|
|
||||||
|
Locally, with a filled `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py check-config
|
||||||
|
```
|
||||||
|
|
||||||
|
This prints `Configuration OK.` or lists every problem with a fix. On Heroku, run
|
||||||
|
`heroku logs --tail --app YOUR_APP_NAME` after deploying. A healthy start logs:
|
||||||
|
- `Database: PostgreSQL`
|
||||||
|
- `Google Drive credentials validated`
|
||||||
|
- `Source channel resolved`
|
||||||
|
|
||||||
|
The bot then sends you *Bot online* in Telegram.
|
||||||
|
|
||||||
|
## Minimal example (Heroku, user mode, OAuth)
|
||||||
|
|
||||||
|
```env
|
||||||
|
TELEGRAM_MODE=user
|
||||||
|
TELEGRAM_API_ID=1234567
|
||||||
|
TELEGRAM_API_HASH=0123456789abcdef0123456789abcdef
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:replace-with-your-bot-token
|
||||||
|
TELEGRAM_SESSION_STRING=1BVtsOK...replace-with-your-session-string
|
||||||
|
ADMIN_USER_IDS=111111111
|
||||||
|
SOURCE_CHANNEL_ID=-1001234567890
|
||||||
|
GOOGLE_AUTH_MODE=oauth
|
||||||
|
GOOGLE_DRIVE_SCOPE=drive.file
|
||||||
|
GOOGLE_OAUTH_TOKEN_JSON={"refresh_token": "...", "client_id": "...", "client_secret": "...", "token_uri": "https://oauth2.googleapis.com/token"}
|
||||||
|
GOOGLE_DRIVE_ROOT_FOLDER_ID=1AbCdEfGhIjKlMnOpQrStUvWxYz
|
||||||
|
# DATABASE_URL is set automatically by Heroku Postgres
|
||||||
|
```
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
# Telegram Channel → Google Drive Organizer
|
||||||
|
|
||||||
|
[](https://heroku.com/deploy?template=https://github.com/YOUR_GITHUB_USERNAME/YOUR_REPOSITORY_NAME)
|
||||||
|
|
||||||
|
> **Before the button works:** replace `https://github.com/YOUR_GITHUB_USERNAME/YOUR_REPOSITORY_NAME`
|
||||||
|
> in the line above with the URL of the **public** GitHub repository that contains this project
|
||||||
|
> (see [Publishing to GitHub](#publishing-to-github)). That URL is the only value to change.
|
||||||
|
> The button flow has not been tested against a live repository yet. Test it once after publishing.
|
||||||
|
|
||||||
|
A long-running worker that reads video posts from a Telegram channel, downloads each video, and
|
||||||
|
uploads it to Google Drive. The caption's **Title** becomes the file name and its **Topic** picks the
|
||||||
|
Drive folder. Videos are processed one at a time. All progress is stored in a database, so the bot
|
||||||
|
resumes after crashes, restarts and redeploys without uploading anything twice.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
1. [Overview](#1-overview) · 2. [Features](#2-features) · 3. [Caption format](#3-caption-format-and-metadata-extraction) ·
|
||||||
|
4. [Drive organization](#4-google-drive-topic-folder-organization) · 5. [Prerequisites](#5-prerequisites) ·
|
||||||
|
6. [One-click Heroku deployment](#6-one-click-heroku-deployment) · 7. [Telegram setup](#7-telegram-bot-setup) ·
|
||||||
|
8. [Historical access](#8-historical-channel-access-requirements) · 9. [Google Drive auth](#9-google-drive-authentication) ·
|
||||||
|
10. [Config Vars](#10-required-heroku-config-vars) · 11. [Database](#11-database-provisioning-and-configuration) ·
|
||||||
|
12. [First deployment](#12-first-deployment-and-startup) · 13. [Commands](#13-telegram-commands) · 14. [Logs](#14-checking-logs) ·
|
||||||
|
15. [Restart / redeploy](#15-restarting-and-redeploying) · 16. [Troubleshooting](#16-troubleshooting) ·
|
||||||
|
17. [Security](#17-security-and-credential-handling) · 18. [Limitations](#18-known-limitations-and-heroku-resource-constraints) ·
|
||||||
|
19. [Local development](#19-local-development)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Telegram channel ──(Telethon / MTProto)──► queue worker (one video at a time) ──► Google Drive
|
||||||
|
│ (resumable upload,
|
||||||
|
Admin chat ◄── bot commands & progress ─────────┤ verified by size + MD5)
|
||||||
|
▼
|
||||||
|
PostgreSQL (Heroku) / SQLite (local)
|
||||||
|
```
|
||||||
|
|
||||||
|
For each video: **discover → parse caption → resolve the topic folder → download to a temp disk →
|
||||||
|
upload (resumable) → verify size and MD5 in Drive → save the Drive file ID → mark COMPLETED → delete
|
||||||
|
the temp file → next video.**
|
||||||
|
|
||||||
|
## 2. Features
|
||||||
|
|
||||||
|
- Caption parsing for `Index` (one or many), `Title`, `Topic`, `Batch` and `Extracted By`.
|
||||||
|
- Topic folders are created once and reused. Optional `Batch/Topic` layout.
|
||||||
|
- Files are never overwritten. Name collisions get `(2)`, `(3)`, … or `[message id]`, or are skipped or failed, per your policy.
|
||||||
|
- No duplicate uploads, even if the process dies just after Drive accepted the file. Every upload carries a hidden `appProperties` tag (`channel:message`) that is checked before uploading.
|
||||||
|
- Resumable uploads that survive restarts. The upload session URI is stored in the database.
|
||||||
|
- Resumable downloads, continuing from the last 1 MiB boundary of a partial file.
|
||||||
|
- Explicit state machine: `PENDING → DOWNLOADING → DOWNLOADED → UPLOADING → COMPLETED`, plus `RETRY_WAIT`, `FAILED` and `SKIPPED`. Every transition is validated and logged in the `transitions` table.
|
||||||
|
- Bounded retries with exponential backoff, honouring Telegram flood waits and Drive rate limits. Permission errors are not retried.
|
||||||
|
- Disk-space checks before each download. A full disk, Drive quota or expired auth halts the queue cleanly instead of burning retries.
|
||||||
|
- Admin-only Telegram commands, plus one progress message that is edited in place rather than spammed.
|
||||||
|
- Graceful shutdown on `SIGTERM` (Heroku restarts) at a resumable checkpoint.
|
||||||
|
- A single-worker guard (PostgreSQL advisory lock) prevents two dynos from processing the same queue.
|
||||||
|
|
||||||
|
## 3. Caption format and metadata extraction
|
||||||
|
|
||||||
|
```
|
||||||
|
Index: 001
|
||||||
|
|
||||||
|
Index: 002
|
||||||
|
|
||||||
|
Title: Class 01 Aman sir.mp4
|
||||||
|
|
||||||
|
Topic: Misc• Grammar Practice
|
||||||
|
|
||||||
|
Batch: English ( SSC MAINS )
|
||||||
|
|
||||||
|
Extracted By: @PUTINxINDIA
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Use |
|
||||||
|
|---|---|
|
||||||
|
| `Title` | Drive file name (`Class 01 Aman sir.mp4`) |
|
||||||
|
| `Topic` | Drive folder (`Misc• Grammar Practice`) |
|
||||||
|
| `Index` | All values kept in order (`["001", "002"]`); never used as the file name. The Telegram message ID is the unique identity. |
|
||||||
|
| `Batch` | Stored; optional `Batch/Topic` folders |
|
||||||
|
| `Extracted By` | Stored in the record |
|
||||||
|
|
||||||
|
Parsing rules:
|
||||||
|
- Fields can appear in any order.
|
||||||
|
- Labels are case-insensitive.
|
||||||
|
- Blank lines, emoji or bullet prefixes, extra spaces and full-width colons are allowed.
|
||||||
|
- Unicode text is preserved.
|
||||||
|
- Parsing is line-based, so one field never swallows the next.
|
||||||
|
- A flattened single-line caption is split at each known label.
|
||||||
|
|
||||||
|
Missing data never makes a video silently disappear:
|
||||||
|
|
||||||
|
| Case | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| No Topic | Folder `Uncategorized` (`FALLBACK_TOPIC_FOLDER`) |
|
||||||
|
| No Title | Telegram file name, else `telegram_<message id>.<ext>`. A warning is recorded. `MISSING_TITLE_POLICY=fail` marks it FAILED instead. |
|
||||||
|
| Title without extension | Extension taken from the real file's magic bytes, else from the MIME type |
|
||||||
|
| Title extension ≠ real container | Kept as written, with a warning. `EXTENSION_MISMATCH_POLICY=append` appends the real one. |
|
||||||
|
| `/` `\` or control characters | Replaced (`STRICT_FILENAMES=true` also replaces `<>:"\|?*`) |
|
||||||
|
|
||||||
|
## 4. Google Drive topic-folder organization
|
||||||
|
|
||||||
|
```
|
||||||
|
<GOOGLE_DRIVE_ROOT_FOLDER_ID>/
|
||||||
|
├── Misc• Grammar Practice/
|
||||||
|
│ ├── Class 01 Aman sir.mp4
|
||||||
|
│ └── Class 02 Aman sir.mp4
|
||||||
|
├── Error Detection/
|
||||||
|
│ └── Class 01 Aman sir.mp4
|
||||||
|
└── Uncategorized/
|
||||||
|
```
|
||||||
|
|
||||||
|
Folder lookup goes database cache → Drive search → create. If duplicate folders already exist, the
|
||||||
|
oldest is reused. If a folder-create response is lost, the bot searches again before retrying, so
|
||||||
|
it never creates a second copy. Set `BATCH_SUBFOLDERS_ENABLED=true` for `Root/Batch/Topic/`.
|
||||||
|
|
||||||
|
**Duplicate policy (`DUPLICATE_POLICY`)** applies when a *different* file already has the target
|
||||||
|
name in the folder:
|
||||||
|
|
||||||
|
| Value | Result |
|
||||||
|
|---|---|
|
||||||
|
| `suffix_number` (default) | `Class 01 Aman sir (2).mp4` |
|
||||||
|
| `suffix_message_id` | `Class 01 Aman sir [1234].mp4` |
|
||||||
|
| `skip` | Item marked SKIPPED with the reason |
|
||||||
|
| `fail` | Item marked FAILED |
|
||||||
|
|
||||||
|
An existing file is reused, not re-uploaded, only when its identity is verified. That means either
|
||||||
|
its hidden tag matches this Telegram message, or it has the same size and MD5
|
||||||
|
(`REUSE_IDENTICAL_FILES=true`). The same Telegram media re-posted under a new message ID is SKIPPED
|
||||||
|
by default (`DUPLICATE_MEDIA_POLICY`).
|
||||||
|
|
||||||
|
## 5. Prerequisites
|
||||||
|
|
||||||
|
- A Telegram account, plus API credentials from <https://my.telegram.org>.
|
||||||
|
- A Telegram bot from [@BotFather](https://t.me/BotFather).
|
||||||
|
- A Google account with a Google Cloud project (free) with the Drive API enabled.
|
||||||
|
- **A local computer with Python 3.12** for two one-time interactive logins. Telegram and Google logins need a human, so they can't run on Heroku.
|
||||||
|
- A Heroku account with billing enabled. The worker dyno and Heroku Postgres are paid resources; check current pricing on Heroku.
|
||||||
|
- Optional: the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) and the [GitHub CLI](https://cli.github.com/).
|
||||||
|
|
||||||
|
## 6. One-click Heroku deployment
|
||||||
|
|
||||||
|
The button (top of this page) opens Heroku's deploy form. It:
|
||||||
|
- creates the app from this repository's `app.json`;
|
||||||
|
- builds it with the official Python buildpack;
|
||||||
|
- provisions **Heroku Postgres (`essential-0`)**, which sets `DATABASE_URL`;
|
||||||
|
- starts **one `basic` worker dyno** (`worker: python main.py run`); there is no web process.
|
||||||
|
|
||||||
|
**It is not fully one-click.** Two secrets must be created on your computer first, because they need
|
||||||
|
interactive logins:
|
||||||
|
|
||||||
|
1. `TELEGRAM_SESSION_STRING`, only for `TELEGRAM_MODE=user` (see [§7](#7-telegram-bot-setup)).
|
||||||
|
2. `GOOGLE_OAUTH_TOKEN_JSON` or `GOOGLE_SERVICE_ACCOUNT_JSON` (see [§9](#9-google-drive-authentication)).
|
||||||
|
|
||||||
|
Prepare those, click the button, fill the form, then press **Deploy app**. If add-on provisioning
|
||||||
|
fails (for example because billing isn't verified or the plan isn't available), create the app
|
||||||
|
anyway and follow [§11](#11-database-provisioning-and-configuration) manually. The worker refuses
|
||||||
|
to start on Heroku without a database rather than silently losing progress.
|
||||||
|
|
||||||
|
Manual alternative with the CLI (from the repository root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
heroku create YOUR_APP_NAME --stack heroku-24
|
||||||
|
heroku addons:create heroku-postgresql:essential-0 --app YOUR_APP_NAME
|
||||||
|
heroku config:set TELEGRAM_API_ID=1234567 TELEGRAM_API_HASH=your_api_hash \
|
||||||
|
TELEGRAM_BOT_TOKEN=123456:your-bot-token ADMIN_USER_IDS=111111111 \
|
||||||
|
SOURCE_CHANNEL_ID=@your_channel --app YOUR_APP_NAME
|
||||||
|
heroku config:set TELEGRAM_SESSION_STRING="$(cat data/sessions/session_string.txt)" --app YOUR_APP_NAME
|
||||||
|
heroku config:set GOOGLE_OAUTH_TOKEN_JSON="$(cat credentials/token.json)" --app YOUR_APP_NAME
|
||||||
|
git push heroku main
|
||||||
|
heroku ps:scale worker=1 --app YOUR_APP_NAME
|
||||||
|
```
|
||||||
|
|
||||||
|
### Publishing to GitHub
|
||||||
|
|
||||||
|
This directory (the one containing `Procfile` and `app.json`) must be the **repository root**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd telegram_drive_bot
|
||||||
|
git init -b main
|
||||||
|
git add .
|
||||||
|
git status # confirm: no .env, credentials/, data/, *.session, downloads/
|
||||||
|
git commit -m "Telegram to Google Drive organizer"
|
||||||
|
gh repo create YOUR_REPOSITORY_NAME --public --source=. --push
|
||||||
|
# or create the repo on github.com, then:
|
||||||
|
# git remote add origin https://github.com/YOUR_GITHUB_USERNAME/YOUR_REPOSITORY_NAME.git && git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Then put that repository URL into the button link at the top of this README, commit, and push.
|
||||||
|
The Heroku button only works with **public** repositories.
|
||||||
|
|
||||||
|
## 7. Telegram bot setup
|
||||||
|
|
||||||
|
1. **API ID / hash:** log in at <https://my.telegram.org> → *API development tools* → create an app.
|
||||||
|
Copy `api_id` → `TELEGRAM_API_ID` and `api_hash` → `TELEGRAM_API_HASH`.
|
||||||
|
These are needed in both modes, because the bot talks MTProto through Telethon.
|
||||||
|
2. **Bot:** talk to @BotFather → `/newbot` → copy the token → `TELEGRAM_BOT_TOKEN`.
|
||||||
|
**Open your bot and press Start.** A bot can't message you until you do, so you won't get progress messages otherwise.
|
||||||
|
3. **Admin IDs:** message [@userinfobot](https://t.me/userinfobot) to get your numeric ID → `ADMIN_USER_IDS`
|
||||||
|
(comma-separate several). Only these users can run commands.
|
||||||
|
4. **Source channel (`SOURCE_CHANNEL_ID`):** any of these forms work:
|
||||||
|
- a public channel: `@channelname` or `https://t.me/channelname`;
|
||||||
|
- a private channel: the numeric ID `-100…`, or a post link `https://t.me/c/1234567890/55` (use *Copy Post Link* in Telegram);
|
||||||
|
- forwarding a post to @userinfobot also shows the channel ID.
|
||||||
|
5. **Choose a mode (`TELEGRAM_MODE`):**
|
||||||
|
- `user` (default): reads the channel as *your* account, which must be a member. Create the session string locally:
|
||||||
|
```bash
|
||||||
|
python main.py login --session-string
|
||||||
|
```
|
||||||
|
Telethon asks for your phone number, the login code and, only if enabled, your 2FA password.
|
||||||
|
None of these are stored. The resulting session string is written to
|
||||||
|
`data/sessions/session_string.txt` (mode 600). Copy it into `TELEGRAM_SESSION_STRING`, then delete the file.
|
||||||
|
- `bot`: add the bot to the channel as an **administrator**. No session string is needed.
|
||||||
|
|
||||||
|
## 8. Historical channel access requirements
|
||||||
|
|
||||||
|
| Capability | `user` mode | `bot` mode |
|
||||||
|
|---|---|---|
|
||||||
|
| New posts (live) | ✅ | ✅ (bot must be a channel admin) |
|
||||||
|
| Existing history | ✅ full history the account can see | ⚠️ best effort: bots can't list history, so message IDs are probed in batches until `BOT_SCAN_MAX_EMPTY` consecutive gaps (or up to `BOT_SCAN_END_ID`) |
|
||||||
|
| Large files | ✅ up to Telegram's file limit | ✅ uses MTProto, so the HTTP Bot API's 20 MB download limit does **not** apply |
|
||||||
|
| Private channel by numeric ID | ✅ | ⚠️ the bot learns the channel's access key from its first live post; the key is then stored in the database |
|
||||||
|
| Extra secret needed | `TELEGRAM_SESSION_STRING` | none |
|
||||||
|
|
||||||
|
`PROCESS_MODE` controls what is queued:
|
||||||
|
- `existing`: history only (from `START_MESSAGE_ID` / `START_DATE`).
|
||||||
|
- `new`: only posts after the first start.
|
||||||
|
- `both` (default).
|
||||||
|
|
||||||
|
The bot never deletes or edits Telegram messages and doesn't bypass channel restrictions. A video
|
||||||
|
in a channel with content protection (no forwarding/saving) may not be downloadable.
|
||||||
|
|
||||||
|
## 9. Google Drive authentication
|
||||||
|
|
||||||
|
Pick **one**.
|
||||||
|
|
||||||
|
### Option A — OAuth (personal Google Drive, recommended)
|
||||||
|
|
||||||
|
1. In the [Google Cloud Console](https://console.cloud.google.com/): create a project, then enable the **Google Drive API**.
|
||||||
|
2. Configure the **OAuth consent screen**: *External*, add yourself as a test user.
|
||||||
|
Then set the publishing status to **In production**. In *Testing* status, Google expires refresh
|
||||||
|
tokens after 7 days and the bot would lose Drive access weekly. With the default `drive.file`
|
||||||
|
scope no verification is required. With the full `drive` scope Google shows an "unverified app"
|
||||||
|
screen during your own login (*Advanced → continue*).
|
||||||
|
3. Under *Credentials*, create an **OAuth client ID** of type **Desktop app**. Download the JSON to
|
||||||
|
`credentials/client_secret.json` in this project.
|
||||||
|
4. Locally: `python main.py drive-auth`. This opens a browser; use `--no-browser` to get a URL to open yourself.
|
||||||
|
It writes `credentials/token.json` (mode 600).
|
||||||
|
5. Heroku: `heroku config:set GOOGLE_OAUTH_TOKEN_JSON="$(cat credentials/token.json)" --app YOUR_APP_NAME`.
|
||||||
|
Multi-line JSON works through the CLI. In the dashboard you can paste raw JSON, or a single-line
|
||||||
|
base64 version: `base64 < credentials/token.json | tr -d '\n'`.
|
||||||
|
|
||||||
|
**Scope and root folder.** `GOOGLE_DRIVE_SCOPE=drive.file` (least privilege) lets the bot see only
|
||||||
|
files and folders *it created*. So either:
|
||||||
|
- let the bot create the root folder: `python main.py drive-init --name "Telegram Videos"` prints the ID for `GOOGLE_DRIVE_ROOT_FOLDER_ID`; or
|
||||||
|
- use a folder you created yourself: set `GOOGLE_DRIVE_SCOPE=drive` **before** running `drive-auth`.
|
||||||
|
|
||||||
|
The folder ID is the part after `/folders/` in its URL. The bot checks at startup that it can see
|
||||||
|
the folder and stops with an explanation if it can't.
|
||||||
|
|
||||||
|
### Option B — Service account (Google Workspace Shared Drive)
|
||||||
|
|
||||||
|
A service account has **no storage quota in a personal My Drive**. Uploads into a folder that a
|
||||||
|
normal Gmail user merely shared with it fail with `storageQuotaExceeded`. Use it only with a
|
||||||
|
**Shared Drive**:
|
||||||
|
|
||||||
|
1. Create a service account, then create a JSON key for it.
|
||||||
|
2. In the Shared Drive, *Manage members* → add the service account's `client_email` as **Content manager**.
|
||||||
|
3. Set:
|
||||||
|
- `GOOGLE_AUTH_MODE=service_account`
|
||||||
|
- `GOOGLE_SERVICE_ACCOUNT_JSON="$(cat key.json)"`
|
||||||
|
- `DRIVE_SHARED_DRIVE_ID=<shared drive id>`
|
||||||
|
- `GOOGLE_DRIVE_ROOT_FOLDER_ID=<folder id inside it>`
|
||||||
|
- `GOOGLE_DRIVE_SCOPE=drive` (needed to use a folder the service account did not create).
|
||||||
|
|
||||||
|
Credentials from environment variables are parsed in memory. Nothing is written to the dyno's disk,
|
||||||
|
and they are validated by an authenticated Drive call at startup. Uploaded files are **private**
|
||||||
|
unless you set `DRIVE_SHARE_MODE=anyone_with_link`.
|
||||||
|
|
||||||
|
## 10. Required Heroku Config Vars
|
||||||
|
|
||||||
|
Set these in the dashboard (*Settings → Reveal Config Vars*) or with
|
||||||
|
`heroku config:set NAME=value --app YOUR_APP_NAME`.
|
||||||
|
|
||||||
|
| Variable | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `TELEGRAM_API_ID`, `TELEGRAM_API_HASH` | yes | my.telegram.org |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | yes | @BotFather; command interface (and channel access in bot mode) |
|
||||||
|
| `ADMIN_USER_IDS` | yes | numeric IDs, comma-separated |
|
||||||
|
| `SOURCE_CHANNEL_ID` | yes | `@name`, link, or `-100…` |
|
||||||
|
| `TELEGRAM_MODE` | yes (default `user`) | `user` or `bot` |
|
||||||
|
| `TELEGRAM_SESSION_STRING` | **when `TELEGRAM_MODE=user`** | from `main.py login --session-string` |
|
||||||
|
| `GOOGLE_AUTH_MODE` | yes (default `oauth`) | `oauth` or `service_account` |
|
||||||
|
| `GOOGLE_OAUTH_TOKEN_JSON` | **when `oauth`** | contents of `credentials/token.json` |
|
||||||
|
| `GOOGLE_SERVICE_ACCOUNT_JSON` | **when `service_account`** | key JSON |
|
||||||
|
| `GOOGLE_DRIVE_ROOT_FOLDER_ID` | recommended | empty = top level of My Drive |
|
||||||
|
| `GOOGLE_DRIVE_SCOPE` | no (`drive.file`) | must match the scope used in `drive-auth` |
|
||||||
|
| `DATABASE_URL` | **yes on Heroku** | set automatically by Heroku Postgres |
|
||||||
|
| `PROCESS_MODE` | no (`both`) | `existing` / `new` / `both` |
|
||||||
|
| `DUPLICATE_POLICY` | no (`suffix_number`) | see §4 |
|
||||||
|
| `BATCH_SUBFOLDERS_ENABLED` | no (`false`) | `Root/Batch/Topic` |
|
||||||
|
| `MAX_RETRIES` / `RETRY_DELAY` | no (`4` / `30`) | automatic retries / base delay in seconds |
|
||||||
|
| `PROGRESS_INTERVAL` | no (`10`) | seconds between progress-message edits |
|
||||||
|
| `DOWNLOAD_DIR` | no (`/tmp/telegram_drive_bot` in app.json) | temporary files |
|
||||||
|
| `UPLOAD_CHUNK_SIZE` | no (`8MB` in app.json) | multiple of 256KB; memory per chunk |
|
||||||
|
| `AUTO_START` | no (`false`) | start processing on every boot |
|
||||||
|
|
||||||
|
Step-by-step instructions for obtaining every value are in **[ENV_SETUP.md](ENV_SETUP.md)**. Every other supported variable is listed with an explanation in [`.env.example`](.env.example).
|
||||||
|
|
||||||
|
## 11. Database provisioning and configuration
|
||||||
|
|
||||||
|
Production state lives in **PostgreSQL**:
|
||||||
|
- video records with all caption metadata;
|
||||||
|
- states, attempts, errors, timestamps and Drive file IDs;
|
||||||
|
- upload session URIs;
|
||||||
|
- the folder map;
|
||||||
|
- discovery and completion cursors;
|
||||||
|
- duplicate-detection data;
|
||||||
|
- the transition history;
|
||||||
|
- the pause/run/stop choice.
|
||||||
|
|
||||||
|
- **Via the button:** `app.json` requests `heroku-postgresql:essential-0`, a paid plan.
|
||||||
|
- **Manually:** `heroku addons:create heroku-postgresql:essential-0 --app YOUR_APP_NAME`
|
||||||
|
(or any plan, or any PostgreSQL reachable through `DATABASE_URL`).
|
||||||
|
- The schema is created and migrated automatically at startup. The version is tracked in the `kv` table.
|
||||||
|
- TLS (`sslmode=require`) is added automatically for remote hosts.
|
||||||
|
- **Without `DATABASE_URL` on Heroku the worker refuses to start.** SQLite on a dyno would be wiped
|
||||||
|
at every restart, and the bot would re-upload everything. To accept that risk explicitly, set
|
||||||
|
`ALLOW_EPHEMERAL_DATABASE=true`; a warning is logged at every start.
|
||||||
|
- Locally, without `DATABASE_URL`, SQLite at `data/bot.db` is used. This is for development only.
|
||||||
|
|
||||||
|
## 12. First deployment and startup
|
||||||
|
|
||||||
|
1. Deploy (button or CLI). Watch the logs: `heroku logs --tail --app YOUR_APP_NAME`.
|
||||||
|
You should see the database backend, `Google Drive credentials validated`, and `Source channel resolved`.
|
||||||
|
2. The bot messages each admin: *Bot online (PostgreSQL); send /sync then /run to start.*
|
||||||
|
3. Send `/sync` to discover channel videos, then `/run`. Watch `/progress`.
|
||||||
|
4. After `/run`, the choice is stored. After any dyno restart the bot syncs and resumes on its own.
|
||||||
|
`/pause` and `/stop` are also remembered.
|
||||||
|
|
||||||
|
## 13. Telegram commands
|
||||||
|
|
||||||
|
Send these to the bot in a **private chat**. Users not in `ADMIN_USER_IDS` are refused.
|
||||||
|
|
||||||
|
| Command | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `/start`, `/help` | Instructions |
|
||||||
|
| `/status` | Queue state, the current video, live progress |
|
||||||
|
| `/progress` | Processed / pending / retrying / failed / skipped / remaining counts |
|
||||||
|
| `/sync` | Discover unseen messages and queue them (idempotent). `/sync all` rescans from `START_MESSAGE_ID`. |
|
||||||
|
| `/run` | Start processing |
|
||||||
|
| `/pause` | Stop the current transfer at the next chunk boundary (resumable) and wait |
|
||||||
|
| `/resume` | Continue |
|
||||||
|
| `/stop` | Checkpoint and end the worker until `/run` |
|
||||||
|
| `/retry` | Re-queue FAILED items with a fresh retry budget |
|
||||||
|
| `/failed` | Failed items with short error summaries |
|
||||||
|
| `/folders` | Folder count and uploads per topic |
|
||||||
|
| `/check ID` | Full record for one Telegram message ID |
|
||||||
|
|
||||||
|
Progress is a single message, edited every `PROGRESS_INTERVAL` seconds:
|
||||||
|
|
||||||
|
```
|
||||||
|
Processing video 12 of 150
|
||||||
|
Topic: Misc• Grammar Practice
|
||||||
|
Title: Class 01 Aman sir.mp4
|
||||||
|
Status: Uploading
|
||||||
|
Download: 100% (1.2 GB / 1.2 GB)
|
||||||
|
Upload: 45% (540.0 MB / 1.2 GB) · 18.3 MB/s · ETA 36s
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Checking logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
heroku logs --tail --app YOUR_APP_NAME # live
|
||||||
|
heroku logs -n 1500 --app YOUR_APP_NAME # recent history
|
||||||
|
heroku ps --app YOUR_APP_NAME # dyno status
|
||||||
|
```
|
||||||
|
|
||||||
|
On Heroku, logs go to stdout only. Locally they also go to `logs/bot.log`, rotated at 10 MB.
|
||||||
|
Tokens, hashes, session strings, private keys and the database URL are redacted from log output.
|
||||||
|
|
||||||
|
## 15. Restarting and redeploying
|
||||||
|
|
||||||
|
```bash
|
||||||
|
heroku ps:restart worker --app YOUR_APP_NAME
|
||||||
|
git push heroku main # or push to GitHub with automatic deploys enabled
|
||||||
|
heroku ps:scale worker=1 --app YOUR_APP_NAME
|
||||||
|
```
|
||||||
|
|
||||||
|
On `SIGTERM`, Heroku allows 30 seconds before it kills the process. The bot:
|
||||||
|
1. stops at the next download or upload chunk boundary;
|
||||||
|
2. saves state;
|
||||||
|
3. disconnects.
|
||||||
|
|
||||||
|
On the next start it:
|
||||||
|
1. returns interrupted downloads to PENDING;
|
||||||
|
2. checks Drive for any upload that may have finished, using the hidden tag;
|
||||||
|
3. re-downloads files lost with the old dyno's disk;
|
||||||
|
4. **continues the saved upload session**, when the re-downloaded file is byte-identical (MD5);
|
||||||
|
5. resumes processing if it was running.
|
||||||
|
|
||||||
|
**Scaling:** run **exactly one** worker. A second worker is blocked by a PostgreSQL advisory lock.
|
||||||
|
It waits `INSTANCE_LOCK_WAIT` seconds (90 on Heroku, which covers deploy overlap), then exits with
|
||||||
|
code 3. Scaling above 1 therefore only produces crash-looping dynos, not parallel processing. The
|
||||||
|
pipeline is intentionally sequential.
|
||||||
|
|
||||||
|
## 16. Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Fix |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_URL is not set` | Attach Heroku Postgres (§11) |
|
||||||
|
| `TELEGRAM_SESSION_STRING is required…` | Run `python main.py login --session-string` locally (§7), or use `TELEGRAM_MODE=bot` |
|
||||||
|
| `Telegram user session is not authorized` | The session was revoked (e.g. *Terminate all sessions*); create a new session string |
|
||||||
|
| `Cannot resolve SOURCE_CHANNEL_ID` | Use `@username` for public channels; make sure the account/bot is a member/admin; in bot mode post once in the channel |
|
||||||
|
| `DRIVE_ROOT_FOLDER_ID is not visible…` | `drive.file` scope can't see folders you created: use `drive-init`, or switch to `GOOGLE_DRIVE_SCOPE=drive` and redo `drive-auth` |
|
||||||
|
| `Google authorization expired or was revoked` | Redo `drive-auth` and update `GOOGLE_OAUTH_TOKEN_JSON`; publish the consent screen to *In production* (§9) |
|
||||||
|
| `invalid_scope` on refresh | The token was created with another scope; delete `credentials/token.json` and run `drive-auth` again |
|
||||||
|
| `storageQuotaExceeded` | Drive is full, or you used a service account without a Shared Drive |
|
||||||
|
| `Not enough free disk space` | A video is larger than the dyno's free disk; lower `MIN_FREE_DISK_SPACE` with care, or process that video locally |
|
||||||
|
| No progress messages | Press *Start* in your bot chat; check `ADMIN_USER_IDS` / `NOTIFY_CHAT_IDS` |
|
||||||
|
| `Another worker is already processing` | `heroku ps:scale worker=1`; locally, stop the other process |
|
||||||
|
| Item stuck in FAILED | `/check ID` shows the error; fix the cause, then `/retry` |
|
||||||
|
| Long pause after `FloodWait` | Telegram rate limit; the bot waits the time Telegram asks for |
|
||||||
|
|
||||||
|
## 17. Security and credential handling
|
||||||
|
|
||||||
|
- All secrets come from environment variables / Config Vars; nothing is hard-coded.
|
||||||
|
- `.gitignore` excludes `.env`, `credentials/`, `token.json`, `*.session`, `session_string.txt`, `data/` and `downloads/`.
|
||||||
|
- The session string and OAuth token are full-access credentials. If one leaks:
|
||||||
|
- Telegram: *Settings → Devices → terminate the session*;
|
||||||
|
- Google: <https://myaccount.google.com/permissions>.
|
||||||
|
- Uploaded files stay private by default. Drive scope defaults to least privilege (`drive.file`).
|
||||||
|
- Commands are admin-only and private-chat only. Replies never include credentials, local paths or stack traces.
|
||||||
|
- Telegram login codes and 2FA passwords are entered interactively, locally, and never stored.
|
||||||
|
|
||||||
|
## 18. Known limitations and Heroku resource constraints
|
||||||
|
|
||||||
|
- **Ephemeral disk:** each video is staged on the dyno's temporary filesystem. Heroku doesn't
|
||||||
|
guarantee a specific capacity, and the disk is wiped on every restart (at least daily).
|
||||||
|
- The bot checks free space before each download and halts with a message if a video won't fit.
|
||||||
|
- Multi-GB videos may not fit on a dyno; run the bot locally for those.
|
||||||
|
- **Memory:** one upload chunk (`UPLOAD_CHUNK_SIZE`) is held in RAM at a time. `8MB` is comfortable on 512 MB dynos.
|
||||||
|
- **Daily restarts:** in-flight transfers resume from their checkpoint. A partially downloaded file
|
||||||
|
on the old dyno is lost and is downloaded again, but the Drive upload session is reused.
|
||||||
|
- **Bot mode:** history is best effort (ID probing), and private channels need one live post before the numeric ID resolves.
|
||||||
|
- **Telegram:** flood limits apply. Downloads go through Telegram's servers at whatever speed they allow.
|
||||||
|
- **Drive:** resumable sessions expire after about a week. Older sessions are discarded and the
|
||||||
|
upload restarts after a Drive check for an already-finished copy.
|
||||||
|
- **Captions edited after discovery** are not re-read. Use `/check ID` to see what was stored.
|
||||||
|
- **Cost:** the worker dyno and Postgres add-on are paid Heroku resources.
|
||||||
|
|
||||||
|
### What has and has not been verified
|
||||||
|
|
||||||
|
- **Automated tests (171):** run against SQLite **and** a real local PostgreSQL 16, with mocked Telegram and Google Drive.
|
||||||
|
- **Not tested:** real Telegram logins and downloads, real Google Drive uploads, and an actual Heroku deployment. Follow the manual end-to-end procedure below before relying on it.
|
||||||
|
|
||||||
|
## 19. Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.12 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
cp .env.example .env # fill in values
|
||||||
|
python main.py login # user mode: creates data/sessions/user.session
|
||||||
|
python main.py drive-auth # creates credentials/token.json
|
||||||
|
python main.py check-config
|
||||||
|
python main.py run # or ./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Other subcommands:
|
||||||
|
- `python main.py sync [--rescan]`
|
||||||
|
- `python main.py process` (headless: sync, process everything, exit)
|
||||||
|
- `python main.py status`
|
||||||
|
- `python main.py drive-init --name "Telegram Videos"`
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
`pgserver` from `requirements-dev.txt` starts a throwaway PostgreSQL so every database test runs on
|
||||||
|
both backends. Without it, only SQLite is tested. The suite covers:
|
||||||
|
- caption parsing (the supplied example, multiple Index lines, missing fields, Unicode, flattened captions);
|
||||||
|
- naming and collisions;
|
||||||
|
- Drive folder reuse and dedupe;
|
||||||
|
- the resumable-upload protocol;
|
||||||
|
- state transitions and duplicate messages;
|
||||||
|
- download and upload failure recovery;
|
||||||
|
- restart during upload, and restart with a wiped disk;
|
||||||
|
- a successful upload followed by a database-write failure;
|
||||||
|
- sequential processing, pause/resume and graceful shutdown;
|
||||||
|
- admin-only commands;
|
||||||
|
- configuration and Heroku validation, env-var credentials, log redaction;
|
||||||
|
- `app.json` / Procfile consistency.
|
||||||
|
|
||||||
|
### Manual end-to-end test (real integrations)
|
||||||
|
|
||||||
|
1. Create a private test channel. Post four videos:
|
||||||
|
- two with `Topic: Test A`;
|
||||||
|
- one with `Topic: Test B`;
|
||||||
|
- one with no caption.
|
||||||
|
Give two of them the same `Title`.
|
||||||
|
2. Point the bot at a fresh Drive folder. Then `/sync`, `/run`, and watch `/progress`.
|
||||||
|
3. Check Drive:
|
||||||
|
- `Test A/` holds two files, one with a `(2)` suffix if the titles matched;
|
||||||
|
- `Test B/` holds one;
|
||||||
|
- `Uncategorized/` holds the caption-less video;
|
||||||
|
- file sizes match Telegram's.
|
||||||
|
4. Kill the process (`kill -9`, or `heroku ps:restart`) **during an upload** of a large video.
|
||||||
|
Restart it. Confirm the upload continues and Drive ends up with exactly one copy.
|
||||||
|
5. `/stop`, post a new video, `/run`: only the new video is processed.
|
||||||
|
6. Revoke Drive access temporarily and confirm the queue halts with a clear message. Restore access, then `/resume`.
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
{
|
||||||
|
"name": "Telegram Drive Organizer",
|
||||||
|
"description": "Telegram bot that downloads channel videos (whole channel or an /upload message range) into Google Drive topic folders.",
|
||||||
|
"keywords": [
|
||||||
|
"telegram",
|
||||||
|
"google-drive",
|
||||||
|
"telethon",
|
||||||
|
"video",
|
||||||
|
"worker"
|
||||||
|
],
|
||||||
|
"stack": "heroku-24",
|
||||||
|
"buildpacks": [
|
||||||
|
{
|
||||||
|
"url": "heroku/python"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"formation": {
|
||||||
|
"worker": {
|
||||||
|
"quantity": 1,
|
||||||
|
"size": "basic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"addons": [
|
||||||
|
{
|
||||||
|
"plan": "heroku-postgresql:essential-0",
|
||||||
|
"as": "DATABASE"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"TELEGRAM_MODE": {
|
||||||
|
"description": "How the bot reads the channel: 'user' (your Telegram account; full history; needs TELEGRAM_SESSION_STRING) or 'bot' (bot admin of the channel; new posts, best-effort history).",
|
||||||
|
"value": "user",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"TELEGRAM_API_ID": {
|
||||||
|
"description": "Numeric API ID from https://my.telegram.org > API development tools. Needed in both modes.",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"TELEGRAM_API_HASH": {
|
||||||
|
"description": "API hash from https://my.telegram.org > API development tools. Keep secret.",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"TELEGRAM_BOT_TOKEN": {
|
||||||
|
"description": "Bot token from @BotFather. Provides the admin command interface (/run, /status, ...); in bot mode it also reads the channel.",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"TELEGRAM_SESSION_STRING": {
|
||||||
|
"description": "Only for TELEGRAM_MODE=user: string session created locally with `python main.py login --session-string`. Grants access to your account - keep secret.",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"ADMIN_USER_IDS": {
|
||||||
|
"description": "Comma-separated numeric Telegram user IDs allowed to run commands (get yours from @userinfobot).",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"SOURCE_CHANNEL_ID": {
|
||||||
|
"description": "Source channel: @username, https://t.me/... link, or numeric -100... ID.",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"GOOGLE_AUTH_MODE": {
|
||||||
|
"description": "'oauth' for a personal Google Drive (uses GOOGLE_OAUTH_TOKEN_JSON) or 'service_account' for a Shared Drive (uses GOOGLE_SERVICE_ACCOUNT_JSON).",
|
||||||
|
"value": "oauth",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"GOOGLE_OAUTH_TOKEN_JSON": {
|
||||||
|
"description": "Required when GOOGLE_AUTH_MODE=oauth: contents of credentials/token.json produced locally by `python main.py drive-auth` (raw JSON or base64).",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"GOOGLE_SERVICE_ACCOUNT_JSON": {
|
||||||
|
"description": "Required when GOOGLE_AUTH_MODE=service_account: the service-account key JSON (raw or base64). Service accounts only work with a Shared Drive they are a member of.",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"GOOGLE_DRIVE_SCOPE": {
|
||||||
|
"description": "'drive.file' (least privilege; the bot only sees folders it created) or 'drive' (needed to use a folder you created yourself). Must match the scope used during drive-auth.",
|
||||||
|
"value": "drive.file",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"GOOGLE_DRIVE_ROOT_FOLDER_ID": {
|
||||||
|
"description": "ID of the Drive folder that will contain the topic folders (the part after /folders/ in its URL). Empty = top level of My Drive.",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"DRIVE_SHARED_DRIVE_ID": {
|
||||||
|
"description": "Shared Drive ID when the root folder lives in a Shared Drive (service-account setups).",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"DRIVE_SHARE_MODE": {
|
||||||
|
"description": "'none' keeps uploads private (default). 'anyone_with_link' makes every uploaded file readable by anyone with the link.",
|
||||||
|
"value": "none",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"PROCESS_MODE": {
|
||||||
|
"description": "'existing' (channel history only), 'new' (new posts only) or 'both'.",
|
||||||
|
"value": "both",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"START_MESSAGE_ID": {
|
||||||
|
"description": "First channel message ID to consider when scanning history.",
|
||||||
|
"value": "1",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"INCLUDE_DOCUMENT_VIDEOS": {
|
||||||
|
"description": "Also process videos that were sent as files/documents.",
|
||||||
|
"value": "true",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"FILTER_TOPICS": {
|
||||||
|
"description": "Optional comma-separated list of Topic values to process; others are recorded as SKIPPED.",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"FILTER_BATCHES": {
|
||||||
|
"description": "Optional comma-separated list of Batch values to process.",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"BATCH_SUBFOLDERS_ENABLED": {
|
||||||
|
"description": "true = Root/Batch/Topic folders; false = Root/Topic (default).",
|
||||||
|
"value": "false",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"DUPLICATE_POLICY": {
|
||||||
|
"description": "When a different file already has the same name in the topic folder: suffix_number ('Name (2).mp4'), suffix_message_id ('Name [123].mp4'), skip, or fail. Files are never overwritten.",
|
||||||
|
"value": "suffix_number",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"MAX_RETRIES": {
|
||||||
|
"description": "Automatic retries for a failing video before it is marked FAILED.",
|
||||||
|
"value": "4",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"RETRY_DELAY": {
|
||||||
|
"description": "Base retry delay in seconds (doubles on each retry, capped by RETRY_MAX_DELAY).",
|
||||||
|
"value": "30",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"PROGRESS_INTERVAL": {
|
||||||
|
"description": "Seconds between edits of the Telegram progress message.",
|
||||||
|
"value": "10",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"DOWNLOAD_DIR": {
|
||||||
|
"description": "Temporary download directory on the dyno's ephemeral disk.",
|
||||||
|
"value": "/tmp/telegram_drive_bot",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"UPLOAD_CHUNK_SIZE": {
|
||||||
|
"description": "Resumable upload chunk size (multiple of 256KB). Held in memory per chunk; 8MB suits 512MB dynos.",
|
||||||
|
"value": "8MB",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"MIN_FREE_DISK_SPACE": {
|
||||||
|
"description": "Free disk space to keep in reserve; a download is not started if it would go below this.",
|
||||||
|
"value": "512MB",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"AUTO_START": {
|
||||||
|
"description": "true = start processing at every boot. Otherwise processing starts with /run and then resumes automatically after restarts.",
|
||||||
|
"value": "false",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"LOG_LEVEL": {
|
||||||
|
"description": "debug, info, warning or error.",
|
||||||
|
"value": "info",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"UPLOAD_RESTRICT_TO_SOURCE_CHANNEL": {
|
||||||
|
"description": "true = /upload links must point to SOURCE_CHANNEL_ID; false = any channel the Telegram account or bot can access.",
|
||||||
|
"value": "true",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"UPLOAD_SETUP_TIMEOUT": {
|
||||||
|
"description": "Minutes before an unfinished /upload setup (waiting for links or /confirm) expires.",
|
||||||
|
"value": "30",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"UPLOAD_MAX_RANGE": {
|
||||||
|
"description": "Largest number of message IDs one /upload task may cover.",
|
||||||
|
"value": "5000",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"KEEP_FAILED_DOWNLOADS": {
|
||||||
|
"description": "Keep local files of failed videos on the dyno disk (default false: delete; they are downloaded again on retry).",
|
||||||
|
"value": "false",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"TEMP_CLEANUP_INTERVAL": {
|
||||||
|
"description": "Seconds between sweeps that delete abandoned temporary files.",
|
||||||
|
"value": "3600",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+542
@@ -0,0 +1,542 @@
|
|||||||
|
"""Telegram control commands (admin only), the /upload conversation, and the notifier.
|
||||||
|
|
||||||
|
The /upload conversation is persisted in the ``upload_tasks`` table (statuses
|
||||||
|
AWAITING_START -> AWAITING_END -> AWAITING_CONFIRMATION), keyed by the admin's
|
||||||
|
Telegram user ID, so every administrator has an independent setup and a restart
|
||||||
|
does not lose it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from database import iso, utcnow
|
||||||
|
from errors import BotError
|
||||||
|
from link_parser import EXAMPLE, LinkError, parse_message_link
|
||||||
|
from models import ACTIVE_TASK_STATUSES, SETUP_TASK_STATUSES, State, TaskStatus, UploadTask, VideoRecord
|
||||||
|
from progress import HtmlReply, esc
|
||||||
|
from queue_manager import QueueManager, public_error
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HELP = """Commands (administrators only):
|
||||||
|
/upload - process a range of channel messages (asks for start and end links)
|
||||||
|
/confirm - start the prepared upload task
|
||||||
|
/cancel - cancel your upload setup or your active task (/cancel ID for a specific task)
|
||||||
|
/taskstatus - status of upload tasks (/taskstatus ID for one task)
|
||||||
|
/uploadhistory - recent upload tasks and their results
|
||||||
|
/status - current processing state
|
||||||
|
/progress - processed / pending / failed counts
|
||||||
|
/run - process every discovered channel video
|
||||||
|
/pause - pause at the next safe checkpoint
|
||||||
|
/resume - continue after a pause or /stop
|
||||||
|
/stop - checkpoint the current video and stop
|
||||||
|
/sync - discover new channel videos and queue them
|
||||||
|
/retry - re-queue failed items
|
||||||
|
/failed - list failed items with error summaries
|
||||||
|
/folders - topic folders and uploaded files
|
||||||
|
/check ID - details for one Telegram message ID
|
||||||
|
/help - this message"""
|
||||||
|
|
||||||
|
START_PROMPT = (
|
||||||
|
"📥 <b>Start Upload Task</b>\n\n"
|
||||||
|
"Please send the Telegram link of the first message you want to process.\n\n"
|
||||||
|
"Example:\nhttps://t.me/c/3909337424/14"
|
||||||
|
)
|
||||||
|
END_PROMPT = (
|
||||||
|
"📌 <b>Enter the End Link</b>\n\n"
|
||||||
|
"Now send the Telegram link of the last message you want to process.\n\n"
|
||||||
|
"Example:\nhttps://t.me/c/3909337424/20"
|
||||||
|
)
|
||||||
|
_STEP = {
|
||||||
|
TaskStatus.AWAITING_START: "waiting for the start link",
|
||||||
|
TaskStatus.AWAITING_END: "waiting for the end link",
|
||||||
|
TaskStatus.AWAITING_CONFIRMATION: "waiting for /confirm",
|
||||||
|
}
|
||||||
|
|
||||||
|
_COMMAND_RE = re.compile(r"^/(?P<cmd>[A-Za-z_]+)(?:@\w+)?(?:\s+(?P<arg>.*))?$", re.DOTALL)
|
||||||
|
_LINK_HINT = re.compile(r"(?:t\.me|telegram\.me|telegram\.dog)/", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _short(text: str | None, limit: int = 160) -> str:
|
||||||
|
if not text:
|
||||||
|
return "-"
|
||||||
|
text = " ".join(text.split())
|
||||||
|
# never echo filesystem paths back to chat
|
||||||
|
text = re.sub(r"(?:/[^\s/]+)+/([^\s/]+)", r"…/\1", text)
|
||||||
|
return text if len(text) <= limit else text[: limit - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRouter:
|
||||||
|
def __init__(self, config, db, queue: QueueManager):
|
||||||
|
self.config = config
|
||||||
|
self.db = db
|
||||||
|
self.queue = queue
|
||||||
|
|
||||||
|
def is_admin(self, user_id: int | None) -> bool:
|
||||||
|
return user_id is not None and user_id in self.config.admin_ids
|
||||||
|
|
||||||
|
async def handle(self, user_id: int | None, text: str) -> str | None:
|
||||||
|
stripped = (text or "").strip()
|
||||||
|
match = _COMMAND_RE.match(stripped)
|
||||||
|
if not match:
|
||||||
|
if not self.is_admin(user_id):
|
||||||
|
return None # ignore chatter from non-admins
|
||||||
|
try:
|
||||||
|
return await self.on_text(user_id, stripped)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Handling a message from %s failed", user_id)
|
||||||
|
return f"Could not process that message: {public_error(exc)}"
|
||||||
|
if not self.is_admin(user_id):
|
||||||
|
log.warning("Rejected command from non-admin user %s", user_id)
|
||||||
|
return "You are not authorized to control this bot."
|
||||||
|
command = match.group("cmd").lower()
|
||||||
|
handler = getattr(self, f"cmd_{command}", None)
|
||||||
|
if handler is None:
|
||||||
|
return "Unknown command. Send /help for the list of commands."
|
||||||
|
try:
|
||||||
|
return await handler((match.group("arg") or "").strip(), user_id)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Command /%s failed", command)
|
||||||
|
return f"Command failed: {public_error(exc)}"
|
||||||
|
|
||||||
|
# -- /upload conversation --------------------------------------------------------------
|
||||||
|
|
||||||
|
def _active_setup(self, user_id: int) -> tuple[UploadTask | None, bool]:
|
||||||
|
"""This admin's unfinished setup, expiring it if it is older than the timeout."""
|
||||||
|
setups = self.db.tasks_with_status(*SETUP_TASK_STATUSES, admin_user_id=user_id)
|
||||||
|
if not setups:
|
||||||
|
return None, False
|
||||||
|
setup = setups[-1]
|
||||||
|
for stale in setups[:-1]: # defensive: only one setup per admin may exist
|
||||||
|
self.db.update_task(stale.id, status=TaskStatus.CANCELLED, last_error="superseded", completed_at=iso(utcnow()))
|
||||||
|
age = utcnow() - datetime.fromisoformat(setup.updated_at)
|
||||||
|
if age > timedelta(minutes=self.config.upload_setup_timeout):
|
||||||
|
self.db.update_task(
|
||||||
|
setup.id, status=TaskStatus.CANCELLED, last_error="setup expired", completed_at=iso(utcnow())
|
||||||
|
)
|
||||||
|
log.info("Upload setup #%s of admin %s expired", setup.id, user_id)
|
||||||
|
return None, True
|
||||||
|
return setup, False
|
||||||
|
|
||||||
|
async def on_text(self, user_id: int, text: str) -> str | None:
|
||||||
|
setup, expired = self._active_setup(user_id)
|
||||||
|
if setup is None:
|
||||||
|
if _LINK_HINT.search(text):
|
||||||
|
note = "Your previous upload setup expired. " if expired else ""
|
||||||
|
return f"{note}To process a message range, send /upload first."
|
||||||
|
return None
|
||||||
|
if setup.status == TaskStatus.AWAITING_START:
|
||||||
|
return await self._receive_start(setup, text)
|
||||||
|
if setup.status == TaskStatus.AWAITING_END:
|
||||||
|
return await self._receive_end(setup, text)
|
||||||
|
return f"Upload Task #{setup.id} is ready. Send /confirm to begin or /cancel to discard it."
|
||||||
|
|
||||||
|
async def _check_link(self, text: str, *, expected_channel_id: int | None = None):
|
||||||
|
"""Return (link, channel_id, title) or an error message."""
|
||||||
|
try:
|
||||||
|
link = parse_message_link(text)
|
||||||
|
except LinkError as exc:
|
||||||
|
return str(exc)
|
||||||
|
source = self.queue.source
|
||||||
|
if source is None:
|
||||||
|
return "Telegram access is not available right now."
|
||||||
|
try:
|
||||||
|
channel_id, title = await source.resolve_link(link)
|
||||||
|
except BotError as exc:
|
||||||
|
return exc.public
|
||||||
|
if expected_channel_id is not None and channel_id != expected_channel_id:
|
||||||
|
return "The end link belongs to a different channel than the start link. Both links must come from the same channel."
|
||||||
|
if (
|
||||||
|
expected_channel_id is None
|
||||||
|
and self.config.upload_restrict_to_source_channel
|
||||||
|
and channel_id != source.channel_id
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
"This link is not from the configured source channel "
|
||||||
|
f"({source.channel_title or self.config.source_channel}). "
|
||||||
|
"Set UPLOAD_RESTRICT_TO_SOURCE_CHANNEL=false to allow other channels the bot can access."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
accessible = await source.message_accessible(channel_id, link.message_id)
|
||||||
|
except BotError as exc:
|
||||||
|
return exc.public
|
||||||
|
if not accessible:
|
||||||
|
return f"Message {link.message_id} was not found in that channel (deleted, or not visible to the bot's account)."
|
||||||
|
return link, channel_id, title
|
||||||
|
|
||||||
|
async def _receive_start(self, setup: UploadTask, text: str) -> str:
|
||||||
|
result = await self._check_link(text)
|
||||||
|
if isinstance(result, str):
|
||||||
|
return HtmlReply(f"❌ {esc(result)}\n\nPlease send a valid start link, for example:\n{EXAMPLE}")
|
||||||
|
link, channel_id, title = result
|
||||||
|
self.db.update_task(
|
||||||
|
setup.id,
|
||||||
|
status=TaskStatus.AWAITING_END,
|
||||||
|
channel_ref=link.channel_ref,
|
||||||
|
channel_id=channel_id,
|
||||||
|
channel_title=title,
|
||||||
|
start_message_id=link.message_id,
|
||||||
|
)
|
||||||
|
return HtmlReply(f"Start message: {link.message_id} ✔\n\n{END_PROMPT}")
|
||||||
|
|
||||||
|
async def _receive_end(self, setup: UploadTask, text: str) -> str:
|
||||||
|
result = await self._check_link(text, expected_channel_id=setup.channel_id)
|
||||||
|
if isinstance(result, str):
|
||||||
|
return HtmlReply(
|
||||||
|
f"❌ {esc(result)}\n\nThe start link (message {setup.start_message_id}) is kept. "
|
||||||
|
"Please send a valid end link."
|
||||||
|
)
|
||||||
|
link, _, _ = result
|
||||||
|
if link.message_id < setup.start_message_id:
|
||||||
|
return HtmlReply(
|
||||||
|
f"❌ The end message ID ({link.message_id}) must be greater than or equal to the start message ID "
|
||||||
|
f"({setup.start_message_id}). Please send a valid end link."
|
||||||
|
)
|
||||||
|
size = link.message_id - setup.start_message_id + 1
|
||||||
|
if size > self.config.upload_max_range:
|
||||||
|
return HtmlReply(
|
||||||
|
f"❌ That range covers {size} messages; the maximum is {self.config.upload_max_range} "
|
||||||
|
"(UPLOAD_MAX_RANGE). Please send a closer end link."
|
||||||
|
)
|
||||||
|
task = self.db.update_task(setup.id, status=TaskStatus.AWAITING_CONFIRMATION, end_message_id=link.message_id)
|
||||||
|
return self._confirmation(task)
|
||||||
|
|
||||||
|
def _channel_label(self, task: UploadTask) -> str:
|
||||||
|
source = self.queue.source
|
||||||
|
name = task.channel_title or str(task.channel_id)
|
||||||
|
if source is not None and task.channel_id == source.channel_id:
|
||||||
|
return f"{name} (configured source channel)"
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _confirmation(self, task: UploadTask) -> HtmlReply:
|
||||||
|
return HtmlReply(
|
||||||
|
"✅ <b>Upload Task Ready</b>\n\n"
|
||||||
|
f"Start Message ID: {task.start_message_id}\n"
|
||||||
|
f"End Message ID: {task.end_message_id}\n"
|
||||||
|
f"Channel: {esc(self._channel_label(task))}\n\n"
|
||||||
|
"The bot will process eligible videos within this message range, organize them into Google Drive "
|
||||||
|
"topic folders, and clean up local temporary files after successful uploads.\n\n"
|
||||||
|
"Send /confirm to begin or /cancel to cancel this task."
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- task commands ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def cmd_upload(self, arg: str, user_id: int) -> str:
|
||||||
|
setup, expired = self._active_setup(user_id)
|
||||||
|
if setup is not None:
|
||||||
|
hint = {
|
||||||
|
TaskStatus.AWAITING_START: "Send the start link to continue.",
|
||||||
|
TaskStatus.AWAITING_END: f"Start message {setup.start_message_id} is set; send the end link to continue.",
|
||||||
|
TaskStatus.AWAITING_CONFIRMATION: "Send /confirm to begin.",
|
||||||
|
}[setup.status]
|
||||||
|
return (
|
||||||
|
f"⚠️ You already have an upload setup in progress (Task #{setup.id}, {_STEP[setup.status]}).\n"
|
||||||
|
f"{hint}\nOr send /cancel to discard it and start again."
|
||||||
|
)
|
||||||
|
self.db.create_task(user_id)
|
||||||
|
prefix = "Your previous unfinished setup expired.\n\n" if expired else ""
|
||||||
|
return HtmlReply(prefix + START_PROMPT)
|
||||||
|
|
||||||
|
async def cmd_confirm(self, arg: str, user_id: int) -> str:
|
||||||
|
setup, expired = self._active_setup(user_id)
|
||||||
|
if setup is None:
|
||||||
|
note = "Your previous setup expired. " if expired else ""
|
||||||
|
return f"{note}There is no upload task waiting for confirmation. Start one with /upload."
|
||||||
|
if setup.status != TaskStatus.AWAITING_CONFIRMATION:
|
||||||
|
return (
|
||||||
|
"Both the start and end links must be validated before /confirm. "
|
||||||
|
f"Task #{setup.id} is {_STEP[setup.status]}."
|
||||||
|
)
|
||||||
|
task = self.queue.submit_task(setup.id)
|
||||||
|
ahead = [t for t in self.db.tasks_with_status(*ACTIVE_TASK_STATUSES) if t.id < task.id]
|
||||||
|
lines = [f"🚀 <b>Upload Task #{task.id} confirmed</b>", f"Range: {task.range_text}"]
|
||||||
|
if self.queue.paused_flag:
|
||||||
|
lines.append("The queue is paused; send /resume to start processing.")
|
||||||
|
elif ahead:
|
||||||
|
lines.append("Queued after task(s) " + ", ".join(f"#{t.id}" for t in ahead) + ".")
|
||||||
|
else:
|
||||||
|
lines.append("Processing has started. Use /taskstatus to follow it.")
|
||||||
|
return HtmlReply("\n".join(lines))
|
||||||
|
|
||||||
|
async def cmd_cancel(self, arg: str, user_id: int) -> str:
|
||||||
|
if arg:
|
||||||
|
if not arg.isdigit():
|
||||||
|
return "Usage: /cancel or /cancel TASK_ID"
|
||||||
|
return self._cancel_reply(int(arg), await self.queue.cancel_task(int(arg)))
|
||||||
|
setup, _ = self._active_setup(user_id)
|
||||||
|
if setup is not None:
|
||||||
|
await self.queue.cancel_task(setup.id, reason="setup cancelled")
|
||||||
|
return f"Upload setup #{setup.id} cancelled. Nothing was downloaded or uploaded."
|
||||||
|
own = self.db.tasks_with_status(*ACTIVE_TASK_STATUSES, admin_user_id=user_id)
|
||||||
|
if own:
|
||||||
|
running = [t for t in own if t.status == TaskStatus.RUNNING]
|
||||||
|
target = running[0] if running else own[-1]
|
||||||
|
return self._cancel_reply(target.id, await self.queue.cancel_task(target.id))
|
||||||
|
others = self.db.tasks_with_status(*ACTIVE_TASK_STATUSES)
|
||||||
|
if others:
|
||||||
|
ids = ", ".join(f"#{t.id}" for t in others)
|
||||||
|
return f"You have nothing to cancel. Active task(s) {ids} belong to another administrator; use /cancel ID to cancel one explicitly."
|
||||||
|
return "Nothing to cancel."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cancel_reply(task_id: int, outcome: str) -> str:
|
||||||
|
return {
|
||||||
|
"unknown": f"Task #{task_id} does not exist.",
|
||||||
|
"finished": f"Task #{task_id} has already finished.",
|
||||||
|
"setup_cancelled": f"Upload setup #{task_id} cancelled.",
|
||||||
|
"cancelled": (
|
||||||
|
f"🛑 Task #{task_id} cancelled at a safe checkpoint. Completed uploads are kept; "
|
||||||
|
"the remaining videos were not processed and their temporary files were removed."
|
||||||
|
),
|
||||||
|
}[outcome]
|
||||||
|
|
||||||
|
async def cmd_taskstatus(self, arg: str, user_id: int) -> str:
|
||||||
|
if arg:
|
||||||
|
if not arg.isdigit():
|
||||||
|
return "Usage: /taskstatus or /taskstatus TASK_ID"
|
||||||
|
task = self.db.get_task(int(arg))
|
||||||
|
return self._describe_task(task) if task else f"Task #{arg} does not exist."
|
||||||
|
parts = []
|
||||||
|
setup, expired = self._active_setup(user_id)
|
||||||
|
if setup is not None:
|
||||||
|
parts.append(
|
||||||
|
f"Your setup: Task #{setup.id}, {_STEP[setup.status]}"
|
||||||
|
+ (f" (start {setup.start_message_id})" if setup.start_message_id else "")
|
||||||
|
)
|
||||||
|
elif expired:
|
||||||
|
parts.append("Your previous setup expired.")
|
||||||
|
active = self.db.tasks_with_status(*ACTIVE_TASK_STATUSES)
|
||||||
|
parts += [self._describe_task(t) for t in active]
|
||||||
|
if not active:
|
||||||
|
recent = self.db.recent_tasks(1)
|
||||||
|
parts.append("No active upload tasks." + (f"\n\nLast task:\n{self._describe_task(recent[0])}" if recent else ""))
|
||||||
|
return HtmlReply("\n\n".join(parts))
|
||||||
|
|
||||||
|
def _describe_task(self, task: UploadTask) -> HtmlReply:
|
||||||
|
lines = [
|
||||||
|
f"<b>Task #{task.id}</b> — {esc(task.status.value)}",
|
||||||
|
f"Range: {task.range_text}",
|
||||||
|
f"Channel: {esc(self._channel_label(task)) if task.channel_id else '-'}",
|
||||||
|
f"Requested by: {task.admin_user_id}",
|
||||||
|
]
|
||||||
|
if task.status not in SETUP_TASK_STATUSES:
|
||||||
|
lines += [
|
||||||
|
f"Eligible videos: {task.eligible_count} · Uploaded: {task.uploaded_count} · "
|
||||||
|
f"Duplicates: {task.duplicate_count} · Failed: {task.failed_count}",
|
||||||
|
f"Non-video skipped: {task.non_video_count} · Other skipped: {task.skipped_count} · "
|
||||||
|
f"Missing: {task.missing_count}",
|
||||||
|
f"Temporary files deleted: {task.deleted_files_count}",
|
||||||
|
]
|
||||||
|
if task.current_message_id and task.status == TaskStatus.RUNNING:
|
||||||
|
lines.append(f"Current message: {task.current_message_id}")
|
||||||
|
board = self.queue.reporter.board if self.queue.reporter else None
|
||||||
|
if board and board.task and board.task.get("id") == task.id:
|
||||||
|
lines.append(f"Video: {esc(board.title)} · {esc(board.status)}")
|
||||||
|
if board.upload:
|
||||||
|
lines.append(f"Upload: {esc(board.upload.describe())}")
|
||||||
|
elif board.download:
|
||||||
|
lines.append(f"Download: {esc(board.download.describe())}")
|
||||||
|
for label, value in (("Created", task.created_at), ("Started", task.started_at), ("Finished", task.completed_at)):
|
||||||
|
if value:
|
||||||
|
lines.append(f"{label}: {value}")
|
||||||
|
if task.last_error:
|
||||||
|
lines.append(f"Note: {esc(_short(task.last_error))}")
|
||||||
|
return HtmlReply("\n".join(lines))
|
||||||
|
|
||||||
|
async def cmd_uploadhistory(self, arg: str, user_id: int) -> str:
|
||||||
|
tasks = self.db.recent_tasks(10)
|
||||||
|
if not tasks:
|
||||||
|
return "No upload tasks yet. Start one with /upload."
|
||||||
|
lines = ["<b>Recent upload tasks</b>"]
|
||||||
|
for t in tasks:
|
||||||
|
lines.append(
|
||||||
|
f"#{t.id} {t.range_text} {esc(t.status.value)} — uploaded {t.uploaded_count}, "
|
||||||
|
f"duplicates {t.duplicate_count}, failed {t.failed_count}, non-video {t.non_video_count} "
|
||||||
|
f"(by {t.admin_user_id})"
|
||||||
|
)
|
||||||
|
return HtmlReply("\n".join(lines))
|
||||||
|
|
||||||
|
# -- general commands ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def cmd_start(self, arg: str, user_id: int) -> str:
|
||||||
|
return (
|
||||||
|
"Telegram -> Google Drive video organizer.\n"
|
||||||
|
"Videos are uploaded into Drive folders named after the caption Topic, "
|
||||||
|
"using the caption Title as the file name.\n\n"
|
||||||
|
"Process a message range: /upload, send the start and end links, then /confirm.\n"
|
||||||
|
"Process the whole channel: /sync then /run.\n\n" + HELP
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cmd_help(self, arg: str, user_id: int) -> str:
|
||||||
|
return HELP
|
||||||
|
|
||||||
|
async def cmd_status(self, arg: str, user_id: int) -> str:
|
||||||
|
counts = self.db.counts()
|
||||||
|
lines = [f"Queue: {self.queue.status}"]
|
||||||
|
if self.queue.halt_reason:
|
||||||
|
lines.append(f"Halted because: {self.queue.halt_reason}")
|
||||||
|
active = self.db.tasks_with_status(*ACTIVE_TASK_STATUSES)
|
||||||
|
if active:
|
||||||
|
lines.append("Upload tasks: " + ", ".join(f"#{t.id} {t.status.value} ({t.range_text})" for t in active))
|
||||||
|
current = self.queue.current
|
||||||
|
if current and self.queue.reporter:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(re.sub(r"</?b>", "", self.queue.reporter.board.render()))
|
||||||
|
elif current:
|
||||||
|
lines.append(f"Current: message {current.message_id} ({current.title or 'untitled'})")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
f"Completed {counts['COMPLETED']} · Pending {counts['PENDING'] + counts['RETRY_WAIT']} · "
|
||||||
|
f"Failed {counts['FAILED']} · Skipped {counts['SKIPPED']}"
|
||||||
|
)
|
||||||
|
lines.append(f"Whole-channel processing (/run): {'on' if self.queue.global_enabled else 'off'}")
|
||||||
|
lines.append(f"Live channel listener: {'on' if self.queue.listening else 'off'}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def cmd_progress(self, arg: str, user_id: int) -> str:
|
||||||
|
c = self.db.counts()
|
||||||
|
total = sum(c.values())
|
||||||
|
processed = c["COMPLETED"]
|
||||||
|
remaining = self.queue.remaining()
|
||||||
|
considered = total - c["SKIPPED"]
|
||||||
|
pct = f"{100 * processed / considered:.1f}%" if considered else "n/a"
|
||||||
|
return (
|
||||||
|
f"Total discovered: {total}\n"
|
||||||
|
f"Processed (uploaded & verified): {processed} ({pct})\n"
|
||||||
|
f"Pending: {c['PENDING']}\n"
|
||||||
|
f"Waiting to retry: {c['RETRY_WAIT']}\n"
|
||||||
|
f"In progress: {c['DOWNLOADING'] + c['DOWNLOADED'] + c['UPLOADING']}\n"
|
||||||
|
f"Failed: {c['FAILED']}\n"
|
||||||
|
f"Skipped: {c['SKIPPED']}\n"
|
||||||
|
f"Remaining: {remaining}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cmd_run(self, arg: str, user_id: int) -> str:
|
||||||
|
if self.queue.start():
|
||||||
|
pending = self.queue.remaining()
|
||||||
|
hint = "" if pending else "\nThe queue is empty - use /sync to discover channel videos."
|
||||||
|
return f"Processing started ({pending} item(s) waiting).{hint}"
|
||||||
|
return f"Processing is already {self.queue.status}."
|
||||||
|
|
||||||
|
async def cmd_pause(self, arg: str, user_id: int) -> str:
|
||||||
|
if self.queue.pause():
|
||||||
|
return "Pausing: the current transfer stops at the next resumable checkpoint. Use /resume to continue."
|
||||||
|
return f"Nothing to pause (queue is {self.queue.status})."
|
||||||
|
|
||||||
|
async def cmd_resume(self, arg: str, user_id: int) -> str:
|
||||||
|
if self.queue.resume():
|
||||||
|
return "Processing resumed."
|
||||||
|
return f"Nothing to resume (queue is {self.queue.status})."
|
||||||
|
|
||||||
|
async def cmd_stop(self, arg: str, user_id: int) -> str:
|
||||||
|
await self.queue.stop(timeout=60)
|
||||||
|
return (
|
||||||
|
"Stopped. Interrupted work was checkpointed; a running upload task was paused. "
|
||||||
|
"Use /resume to continue upload tasks or /run for whole-channel processing."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cmd_retry(self, arg: str, user_id: int) -> str:
|
||||||
|
count = self.queue.retry_failed()
|
||||||
|
if not count:
|
||||||
|
return "No failed items to retry."
|
||||||
|
started = self.queue.start()
|
||||||
|
return f"Re-queued {count} failed item(s)." + (" Processing started." if started else "")
|
||||||
|
|
||||||
|
async def cmd_failed(self, arg: str, user_id: int) -> str:
|
||||||
|
items = self.db.list_by_state(State.FAILED, limit=20)
|
||||||
|
if not items:
|
||||||
|
return "No failed items."
|
||||||
|
lines = [f"Failed items (showing {len(items)}):"]
|
||||||
|
for rec in items:
|
||||||
|
lines.append(f"• #{rec.message_id} {_short(rec.title, 60)} — {_short(rec.last_error)}")
|
||||||
|
lines.append("Use /retry to re-queue them, /check ID for details.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def cmd_folders(self, arg: str, user_id: int) -> str:
|
||||||
|
topics = self.db.topic_counts()
|
||||||
|
counts = self.db.counts()
|
||||||
|
lines = [f"Drive folders created/reused: {self.db.folder_count()}", f"Files uploaded: {counts['COMPLETED']}"]
|
||||||
|
if topics:
|
||||||
|
lines.append("")
|
||||||
|
lines += [f"• {topic or self.config.fallback_topic}: {n}" for topic, n in topics]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def cmd_check(self, arg: str, user_id: int) -> str:
|
||||||
|
if not arg.lstrip("-").isdigit():
|
||||||
|
return "Usage: /check MESSAGE_ID"
|
||||||
|
records = self.db.find_by_message_id(int(arg))
|
||||||
|
if not records:
|
||||||
|
return f"Message {arg} is not in the database. Use /sync or /upload to discover messages."
|
||||||
|
return "\n\n".join(self._describe(r) for r in records)
|
||||||
|
|
||||||
|
async def cmd_sync(self, arg: str, user_id: int) -> str:
|
||||||
|
result = await self.queue.sync(rescan=arg.lower() == "all")
|
||||||
|
if self.queue.running:
|
||||||
|
self.queue.wake()
|
||||||
|
return "Sync complete.\n" + result.summary()
|
||||||
|
|
||||||
|
def _describe(self, rec: VideoRecord) -> str:
|
||||||
|
lines = [
|
||||||
|
f"Message {rec.message_id} — {rec.state}",
|
||||||
|
f"Title: {rec.title or '-'}",
|
||||||
|
f"Topic: {rec.topic or '- (uses ' + self.config.fallback_topic + ')'}",
|
||||||
|
f"Batch: {rec.batch or '-'}",
|
||||||
|
f"Index: {', '.join(rec.indexes) or '-'}",
|
||||||
|
f"Extracted by: {rec.extracted_by or '-'}",
|
||||||
|
f"Download: {rec.download_status or '-'} · Upload: {rec.upload_status or '-'}",
|
||||||
|
f"Size: {rec.expected_size or rec.local_size or '?'} bytes",
|
||||||
|
f"Attempts: {rec.attempts}",
|
||||||
|
]
|
||||||
|
if rec.task_id:
|
||||||
|
lines.append(f"Upload task: #{rec.task_id}")
|
||||||
|
if rec.final_name:
|
||||||
|
lines.append(f"Drive name: {rec.final_name}")
|
||||||
|
if rec.drive_file_id:
|
||||||
|
lines.append(f"Drive file ID: {rec.drive_file_id}")
|
||||||
|
if rec.next_retry_at and rec.state == State.RETRY_WAIT:
|
||||||
|
lines.append(f"Next retry: {rec.next_retry_at}")
|
||||||
|
if rec.skip_reason:
|
||||||
|
lines.append(f"Skip reason: {_short(rec.skip_reason)}")
|
||||||
|
if rec.last_error:
|
||||||
|
lines.append(f"Last error: {_short(rec.last_error)}")
|
||||||
|
if rec.warnings:
|
||||||
|
lines.append(f"Warnings: {_short('; '.join(rec.warnings), 300)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mode(text: str) -> str | None:
|
||||||
|
return "html" if isinstance(text, HtmlReply) else None
|
||||||
|
|
||||||
|
|
||||||
|
class TelethonNotifier:
|
||||||
|
def __init__(self, client):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
async def send(self, chat_id: int, text: str) -> int | None:
|
||||||
|
message = await self.client.send_message(chat_id, str(text), parse_mode=_parse_mode(text), link_preview=False)
|
||||||
|
return message.id
|
||||||
|
|
||||||
|
async def edit(self, chat_id: int, message_id: int, text: str) -> None:
|
||||||
|
from telethon import errors
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.client.edit_message(chat_id, message_id, str(text), parse_mode=_parse_mode(text), link_preview=False)
|
||||||
|
except errors.MessageNotModifiedError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def register_command_handlers(client, router: CommandRouter) -> None:
|
||||||
|
from telethon import events
|
||||||
|
|
||||||
|
@client.on(events.NewMessage(incoming=True, func=lambda e: e.is_private))
|
||||||
|
async def _on_private_message(event) -> None: # pragma: no cover - needs a live Telegram connection
|
||||||
|
reply = await router.handle(event.sender_id, event.raw_text)
|
||||||
|
if not reply:
|
||||||
|
return
|
||||||
|
if isinstance(reply, HtmlReply):
|
||||||
|
await event.respond(str(reply), parse_mode="html", link_preview=False)
|
||||||
|
return
|
||||||
|
for start in range(0, len(reply), 4000):
|
||||||
|
await event.respond(reply[start : start + 4000], parse_mode=None, link_preview=False)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Extract structured metadata from a Telegram video caption.
|
||||||
|
|
||||||
|
Expected format (fields may appear in any order, with extra blank lines, emoji or
|
||||||
|
bullet decorations, and varying whitespace)::
|
||||||
|
|
||||||
|
Index: 001
|
||||||
|
Index: 002
|
||||||
|
Title: Class 01 Aman sir.mp4
|
||||||
|
Topic: Misc• Grammar Practice
|
||||||
|
Batch: English ( SSC MAINS )
|
||||||
|
Extracted By: @PUTINxINDIA
|
||||||
|
|
||||||
|
Parsing is line based, so the value of one field can never swallow the next field.
|
||||||
|
If a single line holds several known fields (captions flattened by a forwarder),
|
||||||
|
the line is split at each known label.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from models import ParsedCaption
|
||||||
|
|
||||||
|
REQUIRED_FIELDS = ("title", "topic")
|
||||||
|
|
||||||
|
_LABEL = r"(?P<label>index|title|topic|batch|extracted[ \t_]*by)"
|
||||||
|
# Optional decoration (emoji, bullets, markdown markers) before the label, then the
|
||||||
|
# label, optional spaces and an ASCII or full-width colon.
|
||||||
|
_LINE_FIELD = re.compile(rf"^[^\w@]*{_LABEL}[ \t]*[::]", re.IGNORECASE)
|
||||||
|
_INLINE_FIELD = re.compile(rf"(?<=\s)[^\w\s@]*{_LABEL}[ \t]*[::]", re.IGNORECASE)
|
||||||
|
_GENERIC_FIELD = re.compile(r"^(?P<key>[^::]{1,40}?)[ \t]*[::][ \t]*(?P<value>.*)$")
|
||||||
|
_LABEL_KEYS = {
|
||||||
|
"index": "index",
|
||||||
|
"title": "title",
|
||||||
|
"topic": "topic",
|
||||||
|
"batch": "batch",
|
||||||
|
"extractedby": "extracted_by",
|
||||||
|
}
|
||||||
|
_DISPLAY = {"title": "Title", "topic": "Topic", "batch": "Batch", "extracted_by": "Extracted By", "index": "Index"}
|
||||||
|
|
||||||
|
|
||||||
|
def _field_key(label: str) -> str:
|
||||||
|
return _LABEL_KEYS[re.sub(r"[\s_]+", "", label.lower())]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_fields(line: str) -> list[tuple[str, str]] | None:
|
||||||
|
"""Return ``[(field, value), ...]`` if the line starts with a known label."""
|
||||||
|
first = _LINE_FIELD.match(line)
|
||||||
|
if not first:
|
||||||
|
return None
|
||||||
|
marks = [(first.end(), first.group("label"))]
|
||||||
|
starts = [first.start("label")]
|
||||||
|
for inline in _INLINE_FIELD.finditer(line, first.end()):
|
||||||
|
starts.append(inline.start())
|
||||||
|
marks.append((inline.end(), inline.group("label")))
|
||||||
|
result = []
|
||||||
|
for i, (value_start, label) in enumerate(marks):
|
||||||
|
is_last = i + 1 == len(marks)
|
||||||
|
value = line[value_start : len(line) if is_last else starts[i + 1]].strip()
|
||||||
|
if not is_last:
|
||||||
|
value = value.rstrip(" \t|,;").strip()
|
||||||
|
result.append((_field_key(label), value))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_caption(text: str | None) -> ParsedCaption:
|
||||||
|
raw = text or ""
|
||||||
|
parsed = ParsedCaption(raw=raw)
|
||||||
|
if not raw.strip():
|
||||||
|
parsed.warnings.append("caption is empty")
|
||||||
|
parsed.missing = list(REQUIRED_FIELDS)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
for line in raw.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
fields = _split_fields(line)
|
||||||
|
if fields is None:
|
||||||
|
generic = _GENERIC_FIELD.match(line)
|
||||||
|
if generic and re.search(r"\w", generic.group("key")):
|
||||||
|
parsed.extra.setdefault(generic.group("key").strip(), generic.group("value").strip())
|
||||||
|
continue
|
||||||
|
for name, value in fields:
|
||||||
|
_assign(parsed, name, value)
|
||||||
|
|
||||||
|
parsed.missing = [name for name in REQUIRED_FIELDS if not getattr(parsed, name)]
|
||||||
|
for name in parsed.missing:
|
||||||
|
parsed.warnings.append(f"{_DISPLAY[name]} field is missing")
|
||||||
|
if not parsed.batch:
|
||||||
|
parsed.warnings.append("Batch field is missing")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _assign(parsed: ParsedCaption, name: str, value: str) -> None:
|
||||||
|
if name == "index":
|
||||||
|
if value:
|
||||||
|
parsed.indexes.append(value)
|
||||||
|
else:
|
||||||
|
parsed.warnings.append("empty Index field ignored")
|
||||||
|
return
|
||||||
|
if not value:
|
||||||
|
parsed.warnings.append(f"{_DISPLAY[name]} field is empty")
|
||||||
|
return
|
||||||
|
current = getattr(parsed, name)
|
||||||
|
if current is None:
|
||||||
|
setattr(parsed, name, value)
|
||||||
|
elif current != value:
|
||||||
|
parsed.warnings.append(f"duplicate {_DISPLAY[name]} field {value!r} ignored; kept {current!r}")
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"""Configuration loading and validation.
|
||||||
|
|
||||||
|
Every setting comes from environment variables, optionally loaded from a ``.env``
|
||||||
|
file next to this module. No secret is hard-coded anywhere in the project.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
PROJECT_DIR = Path(__file__).resolve().parent
|
||||||
|
UPLOAD_CHUNK_ALIGN = 256 * 1024 # Google requires resumable chunks in multiples of 256 KiB
|
||||||
|
|
||||||
|
_TRUE = {"1", "true", "yes", "on"}
|
||||||
|
_FALSE = {"0", "false", "no", "off"}
|
||||||
|
_SIZE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([KMGT]?I?B?)?\s*$", re.IGNORECASE)
|
||||||
|
_SIZE_UNITS = {"": 1, "B": 1, "K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
def __init__(self, problems: list[str]):
|
||||||
|
self.problems = problems
|
||||||
|
super().__init__("Configuration problems:\n" + "\n".join(f" - {p}" for p in problems))
|
||||||
|
|
||||||
|
|
||||||
|
class _Reader:
|
||||||
|
"""Typed accessors that collect every problem instead of stopping at the first."""
|
||||||
|
|
||||||
|
def __init__(self, env: Mapping[str, str]):
|
||||||
|
self.env = env
|
||||||
|
self.problems: list[str] = []
|
||||||
|
|
||||||
|
def str(self, name: str, default: str | None = None) -> str | None:
|
||||||
|
raw = self.env.get(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
raw = raw.strip()
|
||||||
|
return raw if raw else default
|
||||||
|
|
||||||
|
def int(self, name: str, default: int | None = None, *, minimum: int | None = None) -> int | None:
|
||||||
|
raw = self.str(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
self.problems.append(f"{name} must be an integer (got {raw!r})")
|
||||||
|
return default
|
||||||
|
if minimum is not None and value < minimum:
|
||||||
|
self.problems.append(f"{name} must be >= {minimum} (got {value})")
|
||||||
|
return default
|
||||||
|
return value
|
||||||
|
|
||||||
|
def float(self, name: str, default: float, *, minimum: float = 0.0) -> float:
|
||||||
|
raw = self.str(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
value = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
self.problems.append(f"{name} must be a number (got {raw!r})")
|
||||||
|
return default
|
||||||
|
if value < minimum:
|
||||||
|
self.problems.append(f"{name} must be >= {minimum} (got {value})")
|
||||||
|
return default
|
||||||
|
return value
|
||||||
|
|
||||||
|
def bool(self, name: str, default: bool) -> bool:
|
||||||
|
raw = self.str(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
low = raw.lower()
|
||||||
|
if low in _TRUE:
|
||||||
|
return True
|
||||||
|
if low in _FALSE:
|
||||||
|
return False
|
||||||
|
self.problems.append(f"{name} must be true or false (got {raw!r})")
|
||||||
|
return default
|
||||||
|
|
||||||
|
def choice(self, name: str, default: str, choices: tuple[str, ...]) -> str:
|
||||||
|
raw = self.str(name, default)
|
||||||
|
value = raw.lower()
|
||||||
|
if value not in choices:
|
||||||
|
self.problems.append(f"{name} must be one of {', '.join(choices)} (got {raw!r})")
|
||||||
|
return default
|
||||||
|
return value
|
||||||
|
|
||||||
|
def int_list(self, name: str) -> tuple[int, ...]:
|
||||||
|
raw = self.str(name)
|
||||||
|
if raw is None:
|
||||||
|
return ()
|
||||||
|
values = []
|
||||||
|
for part in raw.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
values.append(int(part))
|
||||||
|
except ValueError:
|
||||||
|
self.problems.append(f"{name} must be a comma-separated list of numeric IDs (bad value {part!r})")
|
||||||
|
return tuple(values)
|
||||||
|
|
||||||
|
def text_set(self, name: str) -> frozenset[str]:
|
||||||
|
raw = self.str(name)
|
||||||
|
if raw is None:
|
||||||
|
return frozenset()
|
||||||
|
return frozenset(" ".join(p.split()).casefold() for p in raw.split(",") if p.strip())
|
||||||
|
|
||||||
|
def path(self, name: str, default: str) -> Path:
|
||||||
|
raw = self.str(name, default)
|
||||||
|
path = Path(raw).expanduser()
|
||||||
|
return path if path.is_absolute() else PROJECT_DIR / path
|
||||||
|
|
||||||
|
def size(self, name: str, default: int) -> int:
|
||||||
|
raw = self.str(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
match = _SIZE_RE.match(raw)
|
||||||
|
if not match:
|
||||||
|
self.problems.append(f"{name} must be a size such as 512MB or 2GB (got {raw!r})")
|
||||||
|
return default
|
||||||
|
unit = (match.group(2) or "").upper().replace("I", "").replace("B", "")
|
||||||
|
return int(float(match.group(1)) * _SIZE_UNITS[unit or ""])
|
||||||
|
|
||||||
|
def datetime(self, name: str) -> datetime | None:
|
||||||
|
raw = self.str(name)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
self.problems.append(f"{name} must be an ISO date such as 2024-01-31 (got {raw!r})")
|
||||||
|
return None
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
# Telegram
|
||||||
|
telegram_mode: str
|
||||||
|
api_id: int | None
|
||||||
|
api_hash: str | None
|
||||||
|
bot_token: str | None
|
||||||
|
session_string: str | None
|
||||||
|
admin_ids: frozenset[int]
|
||||||
|
notify_chat_ids: tuple[int, ...]
|
||||||
|
source_channel: str | None
|
||||||
|
process_mode: str
|
||||||
|
start_message_id: int
|
||||||
|
start_date: datetime | None
|
||||||
|
include_document_videos: bool
|
||||||
|
filter_topics: frozenset[str]
|
||||||
|
filter_batches: frozenset[str]
|
||||||
|
bot_scan_max_empty: int
|
||||||
|
bot_scan_end_id: int | None
|
||||||
|
flood_sleep_threshold: int
|
||||||
|
session_dir: Path
|
||||||
|
# Google Drive
|
||||||
|
google_auth_mode: str
|
||||||
|
google_client_secrets_file: Path
|
||||||
|
google_token_file: Path
|
||||||
|
google_service_account_file: Path | None
|
||||||
|
google_service_account_json: str | None
|
||||||
|
google_oauth_token_json: str | None
|
||||||
|
drive_scope: str
|
||||||
|
drive_root_folder_id: str | None
|
||||||
|
shared_drive_id: str | None
|
||||||
|
share_mode: str
|
||||||
|
upload_chunk_size: int
|
||||||
|
# Organisation and naming
|
||||||
|
folder_structure: str
|
||||||
|
fallback_topic: str
|
||||||
|
fallback_batch: str
|
||||||
|
duplicate_name_policy: str
|
||||||
|
reuse_identical_files: bool
|
||||||
|
duplicate_media_policy: str
|
||||||
|
missing_title_policy: str
|
||||||
|
extension_mismatch_policy: str
|
||||||
|
strict_filenames: bool
|
||||||
|
sequence_prefix_enabled: bool
|
||||||
|
sequence_prefix_format: str
|
||||||
|
# Storage
|
||||||
|
data_dir: Path
|
||||||
|
database_url: str | None
|
||||||
|
allow_ephemeral_database: bool
|
||||||
|
is_heroku: bool
|
||||||
|
download_dir: Path
|
||||||
|
database_path: Path
|
||||||
|
log_dir: Path
|
||||||
|
max_temp_storage: int
|
||||||
|
min_free_disk_space: int
|
||||||
|
keep_local_files: bool
|
||||||
|
keep_failed_downloads: bool
|
||||||
|
temp_cleanup_interval: float
|
||||||
|
compute_sha256: bool
|
||||||
|
# Processing
|
||||||
|
max_attempts: int
|
||||||
|
retry_base_delay: float
|
||||||
|
retry_max_delay: float
|
||||||
|
http_retries: int
|
||||||
|
processing_delay: float
|
||||||
|
strict_stop: bool
|
||||||
|
auto_start: bool
|
||||||
|
instance_lock_wait: float
|
||||||
|
upload_setup_timeout: float
|
||||||
|
upload_max_range: int
|
||||||
|
upload_restrict_to_source_channel: bool
|
||||||
|
progress_interval: float
|
||||||
|
log_level: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls, env: Mapping[str, str] | None = None, *, load_dotenv_file: bool = True) -> "Config":
|
||||||
|
if env is None:
|
||||||
|
if load_dotenv_file:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(PROJECT_DIR / ".env")
|
||||||
|
env = os.environ
|
||||||
|
r = _Reader(env)
|
||||||
|
admin_ids = frozenset(r.int_list("ADMIN_USER_IDS"))
|
||||||
|
notify = r.int_list("NOTIFY_CHAT_IDS") or tuple(sorted(admin_ids))
|
||||||
|
sa_file = r.str("GOOGLE_SERVICE_ACCOUNT_FILE")
|
||||||
|
config = cls(
|
||||||
|
telegram_mode=r.choice("TELEGRAM_MODE", "user", ("user", "bot")),
|
||||||
|
api_id=r.int("TELEGRAM_API_ID"),
|
||||||
|
api_hash=r.str("TELEGRAM_API_HASH"),
|
||||||
|
bot_token=r.str("TELEGRAM_BOT_TOKEN"),
|
||||||
|
session_string=r.str("TELEGRAM_SESSION_STRING"),
|
||||||
|
admin_ids=admin_ids,
|
||||||
|
notify_chat_ids=notify,
|
||||||
|
source_channel=r.str("SOURCE_CHANNEL_ID"),
|
||||||
|
process_mode=r.choice("PROCESS_MODE", "both", ("existing", "new", "both")),
|
||||||
|
start_message_id=r.int("START_MESSAGE_ID", 1, minimum=1),
|
||||||
|
start_date=r.datetime("START_DATE"),
|
||||||
|
include_document_videos=r.bool("INCLUDE_DOCUMENT_VIDEOS", True),
|
||||||
|
filter_topics=r.text_set("FILTER_TOPICS"),
|
||||||
|
filter_batches=r.text_set("FILTER_BATCHES"),
|
||||||
|
bot_scan_max_empty=r.int("BOT_SCAN_MAX_EMPTY", 200, minimum=1),
|
||||||
|
bot_scan_end_id=r.int("BOT_SCAN_END_ID", None, minimum=1),
|
||||||
|
flood_sleep_threshold=r.int("FLOOD_SLEEP_THRESHOLD", 60, minimum=0),
|
||||||
|
session_dir=r.path("SESSION_DIR", "data/sessions"),
|
||||||
|
google_auth_mode=r.choice("GOOGLE_AUTH_MODE", "oauth", ("oauth", "service_account")),
|
||||||
|
google_client_secrets_file=r.path("GOOGLE_CLIENT_SECRETS_FILE", "credentials/client_secret.json"),
|
||||||
|
google_token_file=r.path("GOOGLE_TOKEN_FILE", "credentials/token.json"),
|
||||||
|
google_service_account_file=r.path("GOOGLE_SERVICE_ACCOUNT_FILE", sa_file) if sa_file else None,
|
||||||
|
google_service_account_json=r.str("GOOGLE_SERVICE_ACCOUNT_JSON"),
|
||||||
|
google_oauth_token_json=r.str("GOOGLE_OAUTH_TOKEN_JSON"),
|
||||||
|
drive_scope=r.choice("GOOGLE_DRIVE_SCOPE", "drive.file", ("drive.file", "drive")),
|
||||||
|
drive_root_folder_id=r.str("GOOGLE_DRIVE_ROOT_FOLDER_ID"),
|
||||||
|
shared_drive_id=r.str("DRIVE_SHARED_DRIVE_ID"),
|
||||||
|
share_mode=r.choice("DRIVE_SHARE_MODE", "none", ("none", "anyone_with_link")),
|
||||||
|
upload_chunk_size=r.size("UPLOAD_CHUNK_SIZE", 16 * 1024 * 1024),
|
||||||
|
folder_structure="batch_topic" if r.bool("BATCH_SUBFOLDERS_ENABLED", False) else "topic",
|
||||||
|
fallback_topic=r.str("FALLBACK_TOPIC_FOLDER", "Uncategorized"),
|
||||||
|
fallback_batch=r.str("FALLBACK_BATCH_FOLDER", "Unknown Batch"),
|
||||||
|
duplicate_name_policy=r.choice(
|
||||||
|
"DUPLICATE_POLICY", "suffix_number", ("suffix_number", "suffix_message_id", "skip", "fail")
|
||||||
|
),
|
||||||
|
reuse_identical_files=r.bool("REUSE_IDENTICAL_FILES", True),
|
||||||
|
duplicate_media_policy=r.choice("DUPLICATE_MEDIA_POLICY", "skip", ("skip", "upload")),
|
||||||
|
missing_title_policy=r.choice("MISSING_TITLE_POLICY", "fallback", ("fallback", "fail")),
|
||||||
|
extension_mismatch_policy=r.choice("EXTENSION_MISMATCH_POLICY", "keep", ("keep", "append")),
|
||||||
|
strict_filenames=r.bool("STRICT_FILENAMES", False),
|
||||||
|
sequence_prefix_enabled=r.bool("SEQUENCE_PREFIX_ENABLED", False),
|
||||||
|
# not stripped: trailing spaces are meaningful here
|
||||||
|
sequence_prefix_format=env.get("SEQUENCE_PREFIX_FORMAT") or "{seq:03d} - ",
|
||||||
|
data_dir=r.path("DATA_DIR", "data"),
|
||||||
|
database_url=_normalize_database_url(r.str("DATABASE_URL")),
|
||||||
|
allow_ephemeral_database=r.bool("ALLOW_EPHEMERAL_DATABASE", False),
|
||||||
|
is_heroku=bool(env.get("DYNO")),
|
||||||
|
download_dir=r.path("DOWNLOAD_DIR", "downloads"),
|
||||||
|
database_path=r.path("DATABASE_PATH", "data/bot.db"),
|
||||||
|
log_dir=r.path("LOG_DIR", "logs"),
|
||||||
|
max_temp_storage=r.size("MAX_TEMP_STORAGE", 0),
|
||||||
|
min_free_disk_space=r.size("MIN_FREE_DISK_SPACE", 1024**3),
|
||||||
|
keep_local_files=r.bool("KEEP_LOCAL_FILES", False),
|
||||||
|
keep_failed_downloads=r.bool("KEEP_FAILED_DOWNLOADS", False),
|
||||||
|
temp_cleanup_interval=r.float("TEMP_CLEANUP_INTERVAL", 3600.0, minimum=0.0),
|
||||||
|
compute_sha256=r.bool("COMPUTE_SHA256", True),
|
||||||
|
max_attempts=r.int("MAX_RETRIES", 4, minimum=0) + 1,
|
||||||
|
retry_base_delay=r.float("RETRY_DELAY", 30.0),
|
||||||
|
retry_max_delay=r.float("RETRY_MAX_DELAY", 1800.0),
|
||||||
|
http_retries=r.int("HTTP_RETRIES", 5, minimum=0),
|
||||||
|
processing_delay=r.float("PROCESSING_DELAY", 2.0),
|
||||||
|
strict_stop=r.bool("STRICT_STOP", False),
|
||||||
|
auto_start=r.bool("AUTO_START", False),
|
||||||
|
instance_lock_wait=r.float("INSTANCE_LOCK_WAIT", 90.0 if env.get("DYNO") else 0.0),
|
||||||
|
upload_setup_timeout=r.float("UPLOAD_SETUP_TIMEOUT", 30.0, minimum=1.0),
|
||||||
|
upload_max_range=r.int("UPLOAD_MAX_RANGE", 5000, minimum=1),
|
||||||
|
upload_restrict_to_source_channel=r.bool("UPLOAD_RESTRICT_TO_SOURCE_CHANNEL", True),
|
||||||
|
progress_interval=r.float("PROGRESS_INTERVAL", 10.0, minimum=1.0),
|
||||||
|
log_level=r.choice("LOG_LEVEL", "info", ("debug", "info", "warning", "error")).upper(),
|
||||||
|
)
|
||||||
|
if config.upload_chunk_size <= 0 or config.upload_chunk_size % UPLOAD_CHUNK_ALIGN:
|
||||||
|
r.problems.append("UPLOAD_CHUNK_SIZE must be a positive multiple of 256KB (e.g. 8MB, 16MB, 32MB)")
|
||||||
|
try:
|
||||||
|
config.sequence_prefix_format.format(seq=1)
|
||||||
|
except (KeyError, IndexError, ValueError):
|
||||||
|
r.problems.append("SEQUENCE_PREFIX_FORMAT must be a format string using {seq}, e.g. '{seq:03d} - '")
|
||||||
|
resolved = config.download_dir.resolve()
|
||||||
|
if resolved in {Path("/").resolve(), Path.home().resolve(), PROJECT_DIR.resolve()} or len(resolved.parts) < 3:
|
||||||
|
r.problems.append(
|
||||||
|
f"DOWNLOAD_DIR={config.download_dir} is not a dedicated directory; use e.g. 'downloads' or '/tmp/telegram_drive_bot'"
|
||||||
|
)
|
||||||
|
if r.problems:
|
||||||
|
raise ConfigError(r.problems)
|
||||||
|
return config
|
||||||
|
|
||||||
|
# -- section validation -------------------------------------------------------
|
||||||
|
|
||||||
|
def telegram_problems(self) -> list[str]:
|
||||||
|
problems = []
|
||||||
|
if not self.api_id or not self.api_hash:
|
||||||
|
problems.append(
|
||||||
|
"TELEGRAM_API_ID and TELEGRAM_API_HASH are required (create them at https://my.telegram.org -> API development tools)"
|
||||||
|
)
|
||||||
|
if self.telegram_mode == "bot" and not self.bot_token:
|
||||||
|
problems.append("TELEGRAM_BOT_TOKEN is required when TELEGRAM_MODE=bot (get one from @BotFather)")
|
||||||
|
if self.telegram_mode == "user" and self.is_heroku and not self.session_string:
|
||||||
|
problems.append(
|
||||||
|
"TELEGRAM_SESSION_STRING is required for TELEGRAM_MODE=user on Heroku (the filesystem is ephemeral, "
|
||||||
|
"so a session file would be lost). Generate it locally with `python main.py login --session-string`."
|
||||||
|
)
|
||||||
|
if self.bot_token and not self.admin_ids:
|
||||||
|
problems.append("ADMIN_USER_IDS must list at least one Telegram user ID allowed to control the bot")
|
||||||
|
if not self.source_channel:
|
||||||
|
problems.append("SOURCE_CHANNEL_ID is required (@username, https://t.me/... link, or -100... numeric ID)")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
def drive_problems(self) -> list[str]:
|
||||||
|
problems = []
|
||||||
|
if self.google_auth_mode == "oauth":
|
||||||
|
if self.google_oauth_token_json:
|
||||||
|
problems += _json_problems(self.google_oauth_token_json, "GOOGLE_OAUTH_TOKEN_JSON", ("refresh_token", "client_id", "client_secret"))
|
||||||
|
elif self.is_heroku:
|
||||||
|
problems.append(
|
||||||
|
"GOOGLE_OAUTH_TOKEN_JSON is required on Heroku when GOOGLE_AUTH_MODE=oauth "
|
||||||
|
"(create it locally with `python main.py drive-auth`, see README)"
|
||||||
|
)
|
||||||
|
elif not self.google_token_file.exists() and not self.google_client_secrets_file.exists():
|
||||||
|
problems.append(
|
||||||
|
f"Google OAuth client file not found at {self.google_client_secrets_file.name!r} "
|
||||||
|
"(set GOOGLE_CLIENT_SECRETS_FILE or GOOGLE_OAUTH_TOKEN_JSON; see README)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if self.google_service_account_json:
|
||||||
|
problems += _json_problems(
|
||||||
|
self.google_service_account_json, "GOOGLE_SERVICE_ACCOUNT_JSON", ("client_email", "private_key")
|
||||||
|
)
|
||||||
|
elif not self.google_service_account_file or not self.google_service_account_file.exists():
|
||||||
|
problems.append(
|
||||||
|
"GOOGLE_AUTH_MODE=service_account needs GOOGLE_SERVICE_ACCOUNT_JSON (Heroku) "
|
||||||
|
"or GOOGLE_SERVICE_ACCOUNT_FILE pointing to the JSON key"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
def database_problems(self) -> list[str]:
|
||||||
|
if self.is_heroku and not self.database_url and not self.allow_ephemeral_database:
|
||||||
|
return [
|
||||||
|
"DATABASE_URL is not set. On Heroku the local filesystem is wiped on every restart, so SQLite would "
|
||||||
|
"lose all progress and cause re-uploads. Attach Heroku Postgres (`heroku addons:create "
|
||||||
|
"heroku-postgresql:essential-0`) or set ALLOW_EPHEMERAL_DATABASE=true to accept volatile state."
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def validate(self, *sections: str) -> None:
|
||||||
|
problems: list[str] = []
|
||||||
|
if "telegram" in sections:
|
||||||
|
problems += self.telegram_problems()
|
||||||
|
if "drive" in sections:
|
||||||
|
problems += self.drive_problems()
|
||||||
|
if "database" in sections:
|
||||||
|
problems += self.database_problems()
|
||||||
|
if problems:
|
||||||
|
raise ConfigError(problems)
|
||||||
|
|
||||||
|
def secrets(self) -> list[str]:
|
||||||
|
"""Values that must never appear in logs."""
|
||||||
|
values = [self.api_hash, self.bot_token, self.session_string, self.database_url]
|
||||||
|
for blob in (self.google_oauth_token_json, self.google_service_account_json):
|
||||||
|
try:
|
||||||
|
data = load_json_secret(blob) if blob else {}
|
||||||
|
except ValueError:
|
||||||
|
data = {}
|
||||||
|
values += [data.get(k) for k in ("refresh_token", "client_secret", "private_key", "token")]
|
||||||
|
values.append(blob)
|
||||||
|
return [v for v in values if v and len(v) >= 8]
|
||||||
|
|
||||||
|
|
||||||
|
def load_json_secret(value: str) -> dict:
|
||||||
|
"""Parse a JSON credential given either as raw JSON or base64-encoded JSON.
|
||||||
|
|
||||||
|
Base64 is accepted because some dashboards mangle multi-line values; raw JSON
|
||||||
|
(including multi-line) works fine through `heroku config:set VAR="$(cat file)"`.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
|
||||||
|
text = value.strip()
|
||||||
|
if not text.startswith("{"):
|
||||||
|
try:
|
||||||
|
text = base64.b64decode(text, validate=False).decode("utf-8").strip()
|
||||||
|
except (binascii.Error, UnicodeDecodeError) as exc:
|
||||||
|
raise ValueError("value is neither JSON nor base64-encoded JSON") from exc
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"invalid JSON ({exc.msg} at line {exc.lineno})") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("expected a JSON object")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _json_problems(value: str, name: str, required_keys: tuple[str, ...]) -> list[str]:
|
||||||
|
try:
|
||||||
|
data = load_json_secret(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
return [f"{name} could not be parsed: {exc}"]
|
||||||
|
missing = [k for k in required_keys if not data.get(k)]
|
||||||
|
return [f"{name} is missing keys: {', '.join(missing)}"] if missing else []
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_database_url(url: str | None) -> str | None:
|
||||||
|
"""Heroku provides postgres://...; enforce TLS for remote hosts."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
if url.startswith("postgres://"):
|
||||||
|
url = "postgresql://" + url[len("postgres://"):]
|
||||||
|
host_part = url.split("@")[-1]
|
||||||
|
remote = not (host_part.startswith(("localhost", "127.0.0.1", "/")) or "host=/" in url)
|
||||||
|
if remote and "sslmode=" not in url:
|
||||||
|
url += ("&" if "?" in url else "?") + "sslmode=require"
|
||||||
|
return url
|
||||||
+752
@@ -0,0 +1,752 @@
|
|||||||
|
"""Persistent state: video records, state transitions, folder cache, cursors.
|
||||||
|
|
||||||
|
Two backends share one SQL dialect subset:
|
||||||
|
|
||||||
|
* PostgreSQL (``DATABASE_URL``) — durable production storage, e.g. Heroku Postgres.
|
||||||
|
* SQLite (``DATABASE_PATH``) — local development. On Heroku the filesystem is
|
||||||
|
ephemeral, so SQLite there would lose all history on every dyno restart; the
|
||||||
|
configuration layer refuses that unless explicitly overridden.
|
||||||
|
|
||||||
|
SQL is written with ``?`` placeholders and translated to ``%s`` for psycopg.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from models import (
|
||||||
|
IN_PROGRESS_STATES,
|
||||||
|
MediaInfo,
|
||||||
|
ParsedCaption,
|
||||||
|
SourceMessage,
|
||||||
|
State,
|
||||||
|
TaskStatus,
|
||||||
|
UploadTask,
|
||||||
|
VideoRecord,
|
||||||
|
can_transition,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Each entry is one schema version; {id_pk} differs between backends.
|
||||||
|
MIGRATIONS: list[list[str]] = [
|
||||||
|
[
|
||||||
|
"""CREATE TABLE IF NOT EXISTS videos (
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
message_id BIGINT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
caption TEXT,
|
||||||
|
indexes TEXT NOT NULL DEFAULT '[]',
|
||||||
|
title TEXT,
|
||||||
|
topic TEXT,
|
||||||
|
batch TEXT,
|
||||||
|
extracted_by TEXT,
|
||||||
|
warnings TEXT NOT NULL DEFAULT '[]',
|
||||||
|
media_kind TEXT,
|
||||||
|
media_id BIGINT,
|
||||||
|
mime_type TEXT,
|
||||||
|
original_filename TEXT,
|
||||||
|
expected_size BIGINT,
|
||||||
|
message_date TEXT,
|
||||||
|
sequence_no BIGINT,
|
||||||
|
local_path TEXT,
|
||||||
|
local_size BIGINT,
|
||||||
|
sha256 TEXT,
|
||||||
|
md5 TEXT,
|
||||||
|
final_name TEXT,
|
||||||
|
drive_folder_id TEXT,
|
||||||
|
drive_file_id TEXT,
|
||||||
|
upload_session_uri TEXT,
|
||||||
|
upload_session_created_at TEXT,
|
||||||
|
download_status TEXT,
|
||||||
|
upload_status TEXT,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
error_kind TEXT,
|
||||||
|
next_retry_at TEXT,
|
||||||
|
skip_reason TEXT,
|
||||||
|
discovered_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
download_started_at TEXT,
|
||||||
|
downloaded_at TEXT,
|
||||||
|
upload_started_at TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
PRIMARY KEY (channel_id, message_id)
|
||||||
|
)""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_videos_state ON videos(state)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_videos_media ON videos(media_id)",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS folders (
|
||||||
|
parent_id TEXT NOT NULL,
|
||||||
|
name_key TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
folder_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (parent_id, name_key)
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS transitions (
|
||||||
|
id {id_pk},
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
message_id BIGINT NOT NULL,
|
||||||
|
from_state TEXT,
|
||||||
|
to_state TEXT NOT NULL,
|
||||||
|
at TEXT NOT NULL,
|
||||||
|
note TEXT
|
||||||
|
)""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_transitions_msg ON transitions(channel_id, message_id)",
|
||||||
|
],
|
||||||
|
# v2: interactive /upload tasks
|
||||||
|
[
|
||||||
|
"ALTER TABLE videos ADD COLUMN task_id BIGINT",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_videos_task ON videos(task_id)",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS upload_tasks (
|
||||||
|
id {id_pk},
|
||||||
|
admin_user_id BIGINT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
channel_ref TEXT,
|
||||||
|
channel_id BIGINT,
|
||||||
|
channel_title TEXT,
|
||||||
|
start_message_id BIGINT,
|
||||||
|
end_message_id BIGINT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
confirmed_at TEXT,
|
||||||
|
started_at TEXT,
|
||||||
|
scanned_at TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
current_message_id BIGINT,
|
||||||
|
examined_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
eligible_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
uploaded_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duplicate_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
non_video_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
skipped_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
missing_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_files_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT
|
||||||
|
)""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON upload_tasks(status)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tasks_admin ON upload_tasks(admin_user_id)",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS task_items (
|
||||||
|
task_id BIGINT NOT NULL,
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
message_id BIGINT NOT NULL,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
folder TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (task_id, channel_id, message_id)
|
||||||
|
)""",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
_COLUMNS = frozenset(VideoRecord.column_names())
|
||||||
|
_MUTABLE = _COLUMNS - {"channel_id", "message_id", "state", "discovered_at"}
|
||||||
|
|
||||||
|
# download_status / upload_status mirrored from the state for the metadata record.
|
||||||
|
_STAGE_STATUS = {
|
||||||
|
State.PENDING: ("pending", "waiting"),
|
||||||
|
State.DOWNLOADING: ("in_progress", "waiting"),
|
||||||
|
State.DOWNLOADED: ("done", "waiting"),
|
||||||
|
State.UPLOADING: ("done", "in_progress"),
|
||||||
|
State.COMPLETED: ("done", "done"),
|
||||||
|
State.SKIPPED: ("skipped", "skipped"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_TASK_MUTABLE = frozenset(UploadTask.column_names()) - {"id", "admin_user_id", "created_at"}
|
||||||
|
_TASK_COUNTERS = frozenset({"deleted_files_count"})
|
||||||
|
TERMINAL_VIDEO_STATES = ("COMPLETED", "SKIPPED", "FAILED")
|
||||||
|
|
||||||
|
_UPSERT_KV = "INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransition(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseError(RuntimeError):
|
||||||
|
"""Backend-neutral wrapper so callers need not import sqlite3/psycopg."""
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceLockError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def iso(dt: datetime) -> str:
|
||||||
|
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def retry_time(seconds: float) -> str:
|
||||||
|
return iso(utcnow() + timedelta(seconds=seconds))
|
||||||
|
|
||||||
|
|
||||||
|
# -- backends --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SQLiteBackend:
|
||||||
|
name = "sqlite"
|
||||||
|
id_pk = "INTEGER PRIMARY KEY AUTOINCREMENT"
|
||||||
|
for_update = ""
|
||||||
|
errors: tuple[type[BaseException], ...] = (sqlite3.Error,)
|
||||||
|
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path = path
|
||||||
|
self.conn = sqlite3.connect(str(path), isolation_level=None, check_same_thread=False, timeout=30)
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self.conn.execute("PRAGMA synchronous=FULL")
|
||||||
|
|
||||||
|
def execute(self, sql: str, params: tuple = ()):
|
||||||
|
return self.conn.execute(sql, params)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction(self):
|
||||||
|
self.conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except BaseException:
|
||||||
|
self.conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.conn.execute("COMMIT")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return f"SQLite ({self.path.name})"
|
||||||
|
|
||||||
|
def insert_returning_id(self, sql: str, params: tuple) -> int:
|
||||||
|
return self.conn.execute(sql, params).lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
class _PostgresBackend:
|
||||||
|
name = "postgres"
|
||||||
|
id_pk = "BIGSERIAL PRIMARY KEY"
|
||||||
|
for_update = " FOR UPDATE"
|
||||||
|
|
||||||
|
def __init__(self, url: str):
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
self._psycopg = psycopg
|
||||||
|
self._dict_row = dict_row
|
||||||
|
self.url = url
|
||||||
|
self.errors = (psycopg.Error,)
|
||||||
|
self.conn = self._connect()
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
return self._psycopg.connect(self.url, autocommit=True, row_factory=self._dict_row, connect_timeout=15)
|
||||||
|
|
||||||
|
def _ensure_connected(self) -> None:
|
||||||
|
if self.conn.closed or self.conn.broken:
|
||||||
|
log.warning("PostgreSQL connection lost; reconnecting")
|
||||||
|
self.conn = self._connect()
|
||||||
|
|
||||||
|
def execute(self, sql: str, params: tuple = ()):
|
||||||
|
self._ensure_connected()
|
||||||
|
return self.conn.execute(sql.replace("?", "%s"), params)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction(self):
|
||||||
|
self._ensure_connected()
|
||||||
|
with self.conn.transaction():
|
||||||
|
yield
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return "PostgreSQL"
|
||||||
|
|
||||||
|
def insert_returning_id(self, sql: str, params: tuple) -> int:
|
||||||
|
return self.execute(sql + " RETURNING id", params).fetchone()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
# -- database --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, *, path: Path | str | None = None, url: str | None = None):
|
||||||
|
if url:
|
||||||
|
self._backend: Any = _PostgresBackend(url)
|
||||||
|
elif path:
|
||||||
|
self._backend = _SQLiteBackend(Path(path))
|
||||||
|
else:
|
||||||
|
raise ValueError("either a SQLite path or a PostgreSQL URL is required")
|
||||||
|
self.backend_name = self._backend.name
|
||||||
|
self.errors = self._backend.errors
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._migrate()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, config) -> "Database":
|
||||||
|
return cls(url=config.database_url) if config.database_url else cls(path=config.database_path)
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return self._backend.describe()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._backend.close()
|
||||||
|
|
||||||
|
def _migrate(self) -> None:
|
||||||
|
with self._lock, self._backend.transaction():
|
||||||
|
self._backend.execute("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)")
|
||||||
|
row = self._backend.execute("SELECT value FROM kv WHERE key='schema_version'").fetchone()
|
||||||
|
version = int(row["value"]) if row else 0
|
||||||
|
for number, statements in enumerate(MIGRATIONS, start=1):
|
||||||
|
if number <= version:
|
||||||
|
continue
|
||||||
|
for statement in statements:
|
||||||
|
self._backend.execute(statement.format(id_pk=self._backend.id_pk))
|
||||||
|
self._backend.execute(_UPSERT_KV, ("schema_version", str(number)))
|
||||||
|
log.info("Database schema migrated to version %d", number)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _tx(self) -> Iterator[Any]:
|
||||||
|
with self._lock, self._backend.transaction():
|
||||||
|
yield self._backend
|
||||||
|
|
||||||
|
def _query(self, sql: str, params: tuple = ()) -> list[Any]:
|
||||||
|
with self._lock:
|
||||||
|
return self._backend.execute(sql, params).fetchall()
|
||||||
|
|
||||||
|
# -- videos ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
def insert_video(
|
||||||
|
self,
|
||||||
|
msg: SourceMessage,
|
||||||
|
parsed: ParsedCaption,
|
||||||
|
*,
|
||||||
|
state: State = State.PENDING,
|
||||||
|
skip_reason: str | None = None,
|
||||||
|
sequence_no: int | None = None,
|
||||||
|
task_id: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Insert a newly discovered video. Returns False if it was already known."""
|
||||||
|
media: MediaInfo = msg.media # type: ignore[assignment]
|
||||||
|
now = iso(utcnow())
|
||||||
|
dl, ul = _STAGE_STATUS[state]
|
||||||
|
with self._tx() as db:
|
||||||
|
cur = db.execute(
|
||||||
|
"""INSERT INTO videos (
|
||||||
|
channel_id, message_id, state, caption, indexes, title, topic, batch, extracted_by,
|
||||||
|
warnings, media_kind, media_id, mime_type, original_filename, expected_size,
|
||||||
|
message_date, sequence_no, skip_reason, download_status, upload_status,
|
||||||
|
discovered_at, updated_at, task_id)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT (channel_id, message_id) DO NOTHING""",
|
||||||
|
(
|
||||||
|
msg.channel_id,
|
||||||
|
msg.message_id,
|
||||||
|
state.value,
|
||||||
|
msg.caption,
|
||||||
|
json.dumps(parsed.indexes, ensure_ascii=False),
|
||||||
|
parsed.title,
|
||||||
|
parsed.topic,
|
||||||
|
parsed.batch,
|
||||||
|
parsed.extracted_by,
|
||||||
|
json.dumps(parsed.warnings, ensure_ascii=False),
|
||||||
|
media.kind,
|
||||||
|
media.media_id,
|
||||||
|
media.mime_type,
|
||||||
|
media.file_name,
|
||||||
|
media.size,
|
||||||
|
iso(msg.date) if msg.date else None,
|
||||||
|
sequence_no,
|
||||||
|
skip_reason,
|
||||||
|
dl,
|
||||||
|
ul,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
task_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted = cur.rowcount == 1
|
||||||
|
if inserted:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO transitions (channel_id, message_id, from_state, to_state, at, note) VALUES (?,?,?,?,?,?)",
|
||||||
|
(msg.channel_id, msg.message_id, None, state.value, now, skip_reason or "discovered"),
|
||||||
|
)
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
def exists(self, channel_id: int, message_id: int) -> bool:
|
||||||
|
return bool(self._query("SELECT 1 FROM videos WHERE channel_id=? AND message_id=?", (channel_id, message_id)))
|
||||||
|
|
||||||
|
def get(self, key: tuple[int, int]) -> VideoRecord | None:
|
||||||
|
rows = self._query("SELECT * FROM videos WHERE channel_id=? AND message_id=?", tuple(key))
|
||||||
|
return VideoRecord.from_row(rows[0]) if rows else None
|
||||||
|
|
||||||
|
def find_by_message_id(self, message_id: int) -> list[VideoRecord]:
|
||||||
|
rows = self._query("SELECT * FROM videos WHERE message_id=? ORDER BY channel_id", (message_id,))
|
||||||
|
return [VideoRecord.from_row(r) for r in rows]
|
||||||
|
|
||||||
|
def find_by_media_id(self, media_id: int) -> list[VideoRecord]:
|
||||||
|
rows = self._query("SELECT * FROM videos WHERE media_id=? ORDER BY message_id", (media_id,))
|
||||||
|
return [VideoRecord.from_row(r) for r in rows]
|
||||||
|
|
||||||
|
def transition(self, key: tuple[int, int], new_state: State, *, note: str | None = None, **fields) -> VideoRecord:
|
||||||
|
"""Atomically move an item to ``new_state`` (validated) and update fields."""
|
||||||
|
bad = set(fields) - _MUTABLE
|
||||||
|
if bad:
|
||||||
|
raise ValueError(f"unknown or immutable fields: {sorted(bad)}")
|
||||||
|
now = iso(utcnow())
|
||||||
|
with self._tx() as db:
|
||||||
|
row = db.execute(
|
||||||
|
f"SELECT state FROM videos WHERE channel_id=? AND message_id=?{self._backend.for_update}", tuple(key)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(f"unknown video {key}")
|
||||||
|
current = State(row["state"])
|
||||||
|
if not can_transition(current, new_state):
|
||||||
|
raise InvalidTransition(f"{key}: {current} -> {new_state} is not allowed")
|
||||||
|
fields.setdefault("updated_at", now)
|
||||||
|
if new_state in _STAGE_STATUS:
|
||||||
|
fields.setdefault("download_status", _STAGE_STATUS[new_state][0])
|
||||||
|
fields.setdefault("upload_status", _STAGE_STATUS[new_state][1])
|
||||||
|
else:
|
||||||
|
failed_stage = "upload_status" if current in (State.DOWNLOADED, State.UPLOADING) else "download_status"
|
||||||
|
fields.setdefault(failed_stage, "failed" if new_state == State.FAILED else "retry_wait")
|
||||||
|
self._update(db, key, state=new_state.value, **fields)
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO transitions (channel_id, message_id, from_state, to_state, at, note) VALUES (?,?,?,?,?,?)",
|
||||||
|
(key[0], key[1], current.value, new_state.value, now, note),
|
||||||
|
)
|
||||||
|
return self.get(key) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def update_fields(self, key: tuple[int, int], **fields) -> None:
|
||||||
|
bad = set(fields) - _MUTABLE
|
||||||
|
if bad:
|
||||||
|
raise ValueError(f"unknown or immutable fields: {sorted(bad)}")
|
||||||
|
fields.setdefault("updated_at", iso(utcnow()))
|
||||||
|
with self._tx() as db:
|
||||||
|
self._update(db, key, **fields)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _update(db, key: tuple[int, int], **fields) -> None:
|
||||||
|
for name in VideoRecord.JSON_FIELDS:
|
||||||
|
if name in fields and not isinstance(fields[name], str):
|
||||||
|
fields[name] = json.dumps(fields[name], ensure_ascii=False)
|
||||||
|
assignments = ", ".join(f"{name}=?" for name in fields)
|
||||||
|
db.execute(
|
||||||
|
f"UPDATE videos SET {assignments} WHERE channel_id=? AND message_id=?",
|
||||||
|
(*[v.value if isinstance(v, State) else v for v in fields.values()], *key),
|
||||||
|
)
|
||||||
|
|
||||||
|
def history(self, key: tuple[int, int]) -> list[tuple[str | None, str, str | None]]:
|
||||||
|
rows = self._query(
|
||||||
|
"SELECT from_state, to_state, note FROM transitions WHERE channel_id=? AND message_id=? ORDER BY id", tuple(key)
|
||||||
|
)
|
||||||
|
return [(r["from_state"], r["to_state"], r["note"]) for r in rows]
|
||||||
|
|
||||||
|
def next_item(self, now: datetime | None = None, *, task_id: int | None = None) -> VideoRecord | None:
|
||||||
|
"""Next item to work on: unfinished in-progress items first, then oldest message.
|
||||||
|
|
||||||
|
With ``task_id`` only that task's items are considered. Without it, items
|
||||||
|
belonging to a paused or queued /upload task are left alone.
|
||||||
|
"""
|
||||||
|
if task_id is not None:
|
||||||
|
scope, params = "task_id = ?", (task_id,)
|
||||||
|
else:
|
||||||
|
scope = (
|
||||||
|
"(task_id IS NULL OR task_id NOT IN "
|
||||||
|
"(SELECT id FROM upload_tasks WHERE status IN ('PAUSED','QUEUED')))"
|
||||||
|
)
|
||||||
|
params = ()
|
||||||
|
rows = self._query(
|
||||||
|
f"""SELECT * FROM videos
|
||||||
|
WHERE {scope}
|
||||||
|
AND (state IN ('UPLOADING','DOWNLOADED','DOWNLOADING','PENDING')
|
||||||
|
OR (state='RETRY_WAIT' AND next_retry_at <= ?))
|
||||||
|
ORDER BY CASE state WHEN 'UPLOADING' THEN 0 WHEN 'DOWNLOADED' THEN 1
|
||||||
|
WHEN 'DOWNLOADING' THEN 2 ELSE 3 END,
|
||||||
|
message_id
|
||||||
|
LIMIT 1""",
|
||||||
|
(*params, iso(now or utcnow())),
|
||||||
|
)
|
||||||
|
return VideoRecord.from_row(rows[0]) if rows else None
|
||||||
|
|
||||||
|
def earliest_retry_at(self) -> datetime | None:
|
||||||
|
rows = self._query("SELECT MIN(next_retry_at) AS t FROM videos WHERE state='RETRY_WAIT'")
|
||||||
|
value = rows[0]["t"] if rows else None
|
||||||
|
return datetime.fromisoformat(value) if value else None
|
||||||
|
|
||||||
|
def recover_stale(self) -> dict[str, int]:
|
||||||
|
"""Called once at startup, after the single-instance lock is held.
|
||||||
|
|
||||||
|
DOWNLOADING items go back to PENDING; a partial file, if it survived, is resumed.
|
||||||
|
UPLOADING items are left as they are: the queue reconciles them against Drive
|
||||||
|
(and resumes the saved upload session) before doing anything else.
|
||||||
|
"""
|
||||||
|
recovered = {}
|
||||||
|
for state in sorted(IN_PROGRESS_STATES):
|
||||||
|
rows = self._query("SELECT channel_id, message_id FROM videos WHERE state=?", (state.value,))
|
||||||
|
recovered[state.value] = len(rows)
|
||||||
|
if state == State.DOWNLOADING:
|
||||||
|
for r in rows:
|
||||||
|
self.transition((r["channel_id"], r["message_id"]), State.PENDING, note="recovered after restart")
|
||||||
|
if any(recovered.values()):
|
||||||
|
log.warning("Recovered interrupted items after restart: %s", recovered)
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
def reset_failed(self, *, has_local) -> int:
|
||||||
|
"""Move FAILED items back into the queue with a fresh attempt budget."""
|
||||||
|
rows = self._query("SELECT * FROM videos WHERE state='FAILED' ORDER BY message_id")
|
||||||
|
for row in rows:
|
||||||
|
rec = VideoRecord.from_row(row)
|
||||||
|
target = State.DOWNLOADED if has_local(rec) else State.PENDING
|
||||||
|
self.transition(rec.key, target, note="manual retry", attempts=0, next_retry_at=None, error_kind=None)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
def counts(self) -> dict[str, int]:
|
||||||
|
counts = {s.value: 0 for s in State}
|
||||||
|
for r in self._query("SELECT state, COUNT(*) AS n FROM videos GROUP BY state"):
|
||||||
|
counts[r["state"]] = int(r["n"])
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def list_by_state(self, state: State, limit: int = 20) -> list[VideoRecord]:
|
||||||
|
rows = self._query("SELECT * FROM videos WHERE state=? ORDER BY message_id LIMIT ?", (state.value, limit))
|
||||||
|
return [VideoRecord.from_row(r) for r in rows]
|
||||||
|
|
||||||
|
def topic_counts(self, limit: int = 30) -> list[tuple[str, int]]:
|
||||||
|
rows = self._query(
|
||||||
|
"""SELECT COALESCE(topic, '') AS topic, COUNT(*) AS n FROM videos WHERE state='COMPLETED'
|
||||||
|
GROUP BY COALESCE(topic, '') ORDER BY n DESC, topic LIMIT ?""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [(r["topic"], int(r["n"])) for r in rows]
|
||||||
|
|
||||||
|
def videos_for_task(self, task_id: int) -> list[VideoRecord]:
|
||||||
|
rows = self._query("SELECT * FROM videos WHERE task_id=? ORDER BY message_id", (task_id,))
|
||||||
|
return [VideoRecord.from_row(r) for r in rows]
|
||||||
|
|
||||||
|
def task_unfinished_count(self, task_id: int) -> int:
|
||||||
|
rows = self._query(
|
||||||
|
"SELECT COUNT(*) AS n FROM videos WHERE task_id=? AND state NOT IN ('COMPLETED','SKIPPED','FAILED')",
|
||||||
|
(task_id,),
|
||||||
|
)
|
||||||
|
return int(rows[0]["n"])
|
||||||
|
|
||||||
|
# -- upload tasks -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_task(self, admin_user_id: int) -> UploadTask:
|
||||||
|
now = iso(utcnow())
|
||||||
|
with self._tx() as db:
|
||||||
|
task_id = db.insert_returning_id(
|
||||||
|
"INSERT INTO upload_tasks (admin_user_id, status, created_at, updated_at) VALUES (?,?,?,?)",
|
||||||
|
(admin_user_id, TaskStatus.AWAITING_START.value, now, now),
|
||||||
|
)
|
||||||
|
return self.get_task(task_id) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def get_task(self, task_id: int) -> UploadTask | None:
|
||||||
|
rows = self._query("SELECT * FROM upload_tasks WHERE id=?", (task_id,))
|
||||||
|
return UploadTask.from_row(rows[0]) if rows else None
|
||||||
|
|
||||||
|
def update_task(self, task_id: int, **fields) -> UploadTask:
|
||||||
|
bad = set(fields) - _TASK_MUTABLE
|
||||||
|
if bad:
|
||||||
|
raise ValueError(f"unknown or immutable task fields: {sorted(bad)}")
|
||||||
|
fields.setdefault("updated_at", iso(utcnow()))
|
||||||
|
values = [v.value if isinstance(v, TaskStatus) else v for v in fields.values()]
|
||||||
|
assignments = ", ".join(f"{name}=?" for name in fields)
|
||||||
|
with self._tx() as db:
|
||||||
|
db.execute(f"UPDATE upload_tasks SET {assignments} WHERE id=?", (*values, task_id))
|
||||||
|
return self.get_task(task_id) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def add_task_counter(self, task_id: int, column: str, amount: int) -> None:
|
||||||
|
if column not in _TASK_COUNTERS:
|
||||||
|
raise ValueError(column)
|
||||||
|
with self._tx() as db:
|
||||||
|
db.execute(f"UPDATE upload_tasks SET {column} = {column} + ? WHERE id=?", (amount, task_id))
|
||||||
|
|
||||||
|
def tasks_with_status(self, *statuses: TaskStatus, admin_user_id: int | None = None) -> list[UploadTask]:
|
||||||
|
marks = ",".join("?" for _ in statuses)
|
||||||
|
sql = f"SELECT * FROM upload_tasks WHERE status IN ({marks})"
|
||||||
|
params: tuple = tuple(s.value for s in statuses)
|
||||||
|
if admin_user_id is not None:
|
||||||
|
sql += " AND admin_user_id=?"
|
||||||
|
params += (admin_user_id,)
|
||||||
|
rows = self._query(sql + " ORDER BY id", params)
|
||||||
|
return [UploadTask.from_row(r) for r in rows]
|
||||||
|
|
||||||
|
def next_runnable_task(self) -> UploadTask | None:
|
||||||
|
"""The task the worker should run: an already RUNNING one, else the oldest QUEUED."""
|
||||||
|
rows = self._query(
|
||||||
|
"""SELECT * FROM upload_tasks WHERE status IN ('RUNNING','QUEUED')
|
||||||
|
ORDER BY CASE status WHEN 'RUNNING' THEN 0 ELSE 1 END, id LIMIT 1"""
|
||||||
|
)
|
||||||
|
return UploadTask.from_row(rows[0]) if rows else None
|
||||||
|
|
||||||
|
def recent_tasks(self, limit: int = 10) -> list[UploadTask]:
|
||||||
|
rows = self._query(
|
||||||
|
"SELECT * FROM upload_tasks WHERE status NOT IN ('AWAITING_START','AWAITING_END','AWAITING_CONFIRMATION') "
|
||||||
|
"ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [UploadTask.from_row(r) for r in rows]
|
||||||
|
|
||||||
|
def set_task_status(self, old: TaskStatus, new: TaskStatus) -> int:
|
||||||
|
with self._tx() as db:
|
||||||
|
cur = db.execute(
|
||||||
|
"UPDATE upload_tasks SET status=?, updated_at=? WHERE status=?", (new.value, iso(utcnow()), old.value)
|
||||||
|
)
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
def add_task_item(self, task_id: int, channel_id: int, message_id: int, result: str) -> None:
|
||||||
|
"""Record a message examined by a task; an existing result is kept (rescans are idempotent)."""
|
||||||
|
with self._tx() as db:
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO task_items (task_id, channel_id, message_id, result, updated_at) VALUES (?,?,?,?,?)
|
||||||
|
ON CONFLICT (task_id, channel_id, message_id) DO NOTHING""",
|
||||||
|
(task_id, channel_id, message_id, result, iso(utcnow())),
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_task_item_result(
|
||||||
|
self, task_id: int, channel_id: int, message_id: int, result: str, *, folder: str | None = None
|
||||||
|
) -> None:
|
||||||
|
with self._tx() as db:
|
||||||
|
db.execute(
|
||||||
|
"""UPDATE task_items SET result=?, folder=COALESCE(?, folder), updated_at=?
|
||||||
|
WHERE task_id=? AND channel_id=? AND message_id=?""",
|
||||||
|
(result, folder, iso(utcnow()), task_id, channel_id, message_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def task_item_results(self, task_id: int) -> dict[str, int]:
|
||||||
|
rows = self._query("SELECT result, COUNT(*) AS n FROM task_items WHERE task_id=? GROUP BY result", (task_id,))
|
||||||
|
return {r["result"]: int(r["n"]) for r in rows}
|
||||||
|
|
||||||
|
def task_items(self, task_id: int) -> list[tuple[int, str]]:
|
||||||
|
rows = self._query("SELECT message_id, result FROM task_items WHERE task_id=? ORDER BY message_id", (task_id,))
|
||||||
|
return [(int(r["message_id"]), r["result"]) for r in rows]
|
||||||
|
|
||||||
|
def task_folders(self, task_id: int) -> list[str]:
|
||||||
|
rows = self._query(
|
||||||
|
"SELECT DISTINCT folder FROM task_items WHERE task_id=? AND folder IS NOT NULL ORDER BY folder", (task_id,)
|
||||||
|
)
|
||||||
|
return [r["folder"] for r in rows]
|
||||||
|
|
||||||
|
# -- folders --------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_folder(self, parent_id: str, name_key: str) -> str | None:
|
||||||
|
rows = self._query("SELECT folder_id FROM folders WHERE parent_id=? AND name_key=?", (parent_id, name_key))
|
||||||
|
return rows[0]["folder_id"] if rows else None
|
||||||
|
|
||||||
|
def put_folder(self, parent_id: str, name_key: str, name: str, folder_id: str) -> None:
|
||||||
|
with self._tx() as db:
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO folders (parent_id, name_key, name, folder_id, created_at) VALUES (?,?,?,?,?)
|
||||||
|
ON CONFLICT (parent_id, name_key) DO UPDATE
|
||||||
|
SET name = excluded.name, folder_id = excluded.folder_id, created_at = excluded.created_at""",
|
||||||
|
(parent_id, name_key, name, folder_id, iso(utcnow())),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_folder(self, parent_id: str, name_key: str) -> None:
|
||||||
|
with self._tx() as db:
|
||||||
|
db.execute("DELETE FROM folders WHERE parent_id=? AND name_key=?", (parent_id, name_key))
|
||||||
|
|
||||||
|
def folder_count(self) -> int:
|
||||||
|
return int(self._query("SELECT COUNT(*) AS n FROM folders")[0]["n"])
|
||||||
|
|
||||||
|
# -- key/value --------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_kv(self, key: str, default: str | None = None) -> str | None:
|
||||||
|
rows = self._query("SELECT value FROM kv WHERE key=?", (key,))
|
||||||
|
return rows[0]["value"] if rows else default
|
||||||
|
|
||||||
|
def set_kv(self, key: str, value: str) -> None:
|
||||||
|
with self._tx() as db:
|
||||||
|
db.execute(_UPSERT_KV, (key, value))
|
||||||
|
|
||||||
|
def get_int(self, key: str, default: int = 0) -> int:
|
||||||
|
value = self.get_kv(key)
|
||||||
|
return int(value) if value is not None else default
|
||||||
|
|
||||||
|
def raise_int(self, key: str, value: int) -> None:
|
||||||
|
"""Store ``value`` only if it is larger than the current one (cursor semantics)."""
|
||||||
|
with self._tx() as db:
|
||||||
|
row = db.execute(f"SELECT value FROM kv WHERE key=?{self._backend.for_update}", (key,)).fetchone()
|
||||||
|
if row is None or int(row["value"]) < value:
|
||||||
|
db.execute(_UPSERT_KV, (key, str(value)))
|
||||||
|
|
||||||
|
def next_sequence(self) -> int:
|
||||||
|
with self._tx() as db:
|
||||||
|
row = db.execute(f"SELECT value FROM kv WHERE key='sequence_counter'{self._backend.for_update}").fetchone()
|
||||||
|
value = (int(row["value"]) if row else 0) + 1
|
||||||
|
db.execute(_UPSERT_KV, ("sequence_counter", str(value)))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# -- single-instance lock ----------------------------------------------------------------
|
||||||
|
|
||||||
|
_ADVISORY_LOCK_ID = 0x7464_6272 # arbitrary constant: "tdbr"
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceLock:
|
||||||
|
"""Guarantee that only one worker processes the queue.
|
||||||
|
|
||||||
|
PostgreSQL: a session-level advisory lock held on a dedicated connection, so it
|
||||||
|
also protects against two Heroku dynos (e.g. worker scaled to 2, or an old dyno
|
||||||
|
still shutting down during a deploy). SQLite: an exclusive ``flock`` on a lock
|
||||||
|
file, which only protects processes on the same machine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config, *, wait_seconds: float = 0, poll: float = 5.0):
|
||||||
|
self._config = config
|
||||||
|
self._wait = wait_seconds
|
||||||
|
self._poll = poll
|
||||||
|
self._conn = None
|
||||||
|
self._fh = None
|
||||||
|
|
||||||
|
def acquire(self) -> None:
|
||||||
|
deadline = time.monotonic() + self._wait
|
||||||
|
while True:
|
||||||
|
if self._try_acquire():
|
||||||
|
return
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise InstanceLockError(
|
||||||
|
"Another worker is already processing this queue. Run exactly one worker "
|
||||||
|
"(Heroku: `heroku ps:scale worker=1`)."
|
||||||
|
)
|
||||||
|
log.warning("Another worker holds the queue lock; waiting...")
|
||||||
|
time.sleep(self._poll)
|
||||||
|
|
||||||
|
def _try_acquire(self) -> bool:
|
||||||
|
if self._config.database_url:
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
if self._conn is None or self._conn.closed:
|
||||||
|
self._conn = psycopg.connect(self._config.database_url, autocommit=True, connect_timeout=15)
|
||||||
|
return bool(self._conn.execute("SELECT pg_try_advisory_lock(%s)", (_ADVISORY_LOCK_ID,)).fetchone()[0])
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
path = Path(str(self._config.database_path) + ".lock")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if self._fh is None:
|
||||||
|
self._fh = open(path, "w")
|
||||||
|
try:
|
||||||
|
fcntl.flock(self._fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
return True
|
||||||
|
except BlockingIOError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
if self._conn is not None:
|
||||||
|
try:
|
||||||
|
self._conn.close() # closing the session releases the advisory lock
|
||||||
|
finally:
|
||||||
|
self._conn = None
|
||||||
|
if self._fh is not None:
|
||||||
|
self._fh.close()
|
||||||
|
self._fh = None
|
||||||
+246
@@ -0,0 +1,246 @@
|
|||||||
|
"""Stream Telegram media to disk with resume, disk-space checks and hashing."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import errno
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncIterator, Callable, Protocol
|
||||||
|
|
||||||
|
from errors import DiskSpaceError, Interrupted, TransientError
|
||||||
|
from models import LocalFile, State, VideoRecord
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Partial downloads are resumed from a 1 MiB boundary: Telegram's upload.getFile
|
||||||
|
# requires offsets aligned to the requested chunk size (512 KiB).
|
||||||
|
RESUME_ALIGNMENT = 1024 * 1024
|
||||||
|
HASH_BLOCK = 4 * 1024 * 1024
|
||||||
|
# Only files with exactly this shape are ever deleted by cleanup routines.
|
||||||
|
TEMP_NAME = re.compile(r"^(\d+)_(\d+)\.(part|video)$")
|
||||||
|
|
||||||
|
|
||||||
|
class MediaSource(Protocol):
|
||||||
|
def iter_media_chunks(
|
||||||
|
self, message_id: int, *, offset: int, expected_size: int | None, channel_id: int | None = None
|
||||||
|
) -> AsyncIterator[bytes]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _Hashes:
|
||||||
|
def __init__(self, sha256: bool):
|
||||||
|
self.md5 = hashlib.md5()
|
||||||
|
self.sha256 = hashlib.sha256() if sha256 else None
|
||||||
|
|
||||||
|
def update(self, data: bytes) -> None:
|
||||||
|
self.md5.update(data)
|
||||||
|
if self.sha256:
|
||||||
|
self.sha256.update(data)
|
||||||
|
|
||||||
|
def digest(self) -> tuple[str, str | None]:
|
||||||
|
return self.md5.hexdigest(), self.sha256.hexdigest() if self.sha256 else None
|
||||||
|
|
||||||
|
|
||||||
|
def hash_file(path: Path, *, sha256: bool, limit: int | None = None, hashes: _Hashes | None = None) -> _Hashes:
|
||||||
|
hashes = hashes or _Hashes(sha256)
|
||||||
|
remaining = limit
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
while remaining is None or remaining > 0:
|
||||||
|
block = fh.read(HASH_BLOCK if remaining is None else min(HASH_BLOCK, remaining))
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
hashes.update(block)
|
||||||
|
if remaining is not None:
|
||||||
|
remaining -= len(block)
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
class Downloader:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: MediaSource,
|
||||||
|
download_dir: Path,
|
||||||
|
*,
|
||||||
|
min_free_bytes: int = 0,
|
||||||
|
max_temp_bytes: int = 0,
|
||||||
|
compute_sha256: bool = True,
|
||||||
|
disk_usage: Callable[[str], tuple] = shutil.disk_usage,
|
||||||
|
):
|
||||||
|
self.source = source
|
||||||
|
self.download_dir = download_dir
|
||||||
|
self.min_free_bytes = min_free_bytes
|
||||||
|
self.max_temp_bytes = max_temp_bytes
|
||||||
|
self.compute_sha256 = compute_sha256
|
||||||
|
self._disk_usage = disk_usage
|
||||||
|
# Optional callback that frees space by removing abandoned temp files;
|
||||||
|
# returns the number of files deleted. Set by the queue manager.
|
||||||
|
self.reclaim: Callable[[], int] | None = None
|
||||||
|
download_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._root = download_dir.resolve()
|
||||||
|
|
||||||
|
# Local files are named by message identity, never by caption text, so
|
||||||
|
# captions cannot cause path problems.
|
||||||
|
def complete_path(self, rec: VideoRecord) -> Path:
|
||||||
|
return self.download_dir / f"{abs(rec.channel_id)}_{rec.message_id}.video"
|
||||||
|
|
||||||
|
def partial_path(self, rec: VideoRecord) -> Path:
|
||||||
|
return self.download_dir / f"{abs(rec.channel_id)}_{rec.message_id}.part"
|
||||||
|
|
||||||
|
def has_complete_local(self, rec: VideoRecord) -> bool:
|
||||||
|
path = self.complete_path(rec)
|
||||||
|
if not path.is_file():
|
||||||
|
return False
|
||||||
|
expected = rec.expected_size or rec.local_size
|
||||||
|
return expected is not None and path.stat().st_size == expected
|
||||||
|
|
||||||
|
async def local_file(self, rec: VideoRecord) -> LocalFile:
|
||||||
|
"""Describe an already complete download, reusing stored hashes if valid."""
|
||||||
|
path = self.complete_path(rec)
|
||||||
|
size = path.stat().st_size
|
||||||
|
if rec.md5 and rec.local_size == size:
|
||||||
|
return LocalFile(path, size, rec.md5, rec.sha256)
|
||||||
|
hashes = await asyncio.to_thread(hash_file, path, sha256=self.compute_sha256)
|
||||||
|
md5, sha = hashes.digest()
|
||||||
|
return LocalFile(path, size, md5, sha)
|
||||||
|
|
||||||
|
def check_space(self, needed: int, *, exclude: Path | None = None) -> None:
|
||||||
|
free = self._disk_usage(str(self.download_dir))[2]
|
||||||
|
if free - needed < self.min_free_bytes:
|
||||||
|
raise DiskSpaceError(
|
||||||
|
f"not enough disk space: need {needed} bytes plus {self.min_free_bytes} reserve, {free} free",
|
||||||
|
public="Not enough free disk space for the next video; free space and /resume.",
|
||||||
|
)
|
||||||
|
if self.max_temp_bytes:
|
||||||
|
used = sum(
|
||||||
|
p.stat().st_size for p in self.download_dir.iterdir() if p.is_file() and p != exclude
|
||||||
|
)
|
||||||
|
if used + needed > self.max_temp_bytes:
|
||||||
|
raise DiskSpaceError(
|
||||||
|
f"MAX_TEMP_STORAGE exceeded: {used} used + {needed} needed > {self.max_temp_bytes}",
|
||||||
|
public="Temporary storage limit (MAX_TEMP_STORAGE) reached; /retry or clean up downloads.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def download(
|
||||||
|
self,
|
||||||
|
rec: VideoRecord,
|
||||||
|
*,
|
||||||
|
on_progress: Callable[[int, int | None], None],
|
||||||
|
should_stop: Callable[[], bool],
|
||||||
|
) -> LocalFile:
|
||||||
|
if self.has_complete_local(rec):
|
||||||
|
return await self.local_file(rec)
|
||||||
|
part = self.partial_path(rec)
|
||||||
|
final = self.complete_path(rec)
|
||||||
|
expected = rec.expected_size
|
||||||
|
offset = part.stat().st_size if part.exists() else 0
|
||||||
|
offset -= offset % RESUME_ALIGNMENT
|
||||||
|
if expected is not None and offset > expected:
|
||||||
|
offset = 0
|
||||||
|
needed = (expected - offset) if expected else 0
|
||||||
|
try:
|
||||||
|
self.check_space(needed, exclude=part)
|
||||||
|
except DiskSpaceError:
|
||||||
|
if not (self.reclaim and self.reclaim()):
|
||||||
|
raise
|
||||||
|
self.check_space(needed, exclude=part)
|
||||||
|
|
||||||
|
hashes = _Hashes(self.compute_sha256)
|
||||||
|
if offset:
|
||||||
|
log.info("Resuming download of message %s at byte %d", rec.message_id, offset)
|
||||||
|
await asyncio.to_thread(hash_file, part, sha256=self.compute_sha256, limit=offset, hashes=hashes)
|
||||||
|
written = offset
|
||||||
|
chunks = self.source.iter_media_chunks(
|
||||||
|
rec.message_id, offset=offset, expected_size=expected, channel_id=rec.channel_id
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with open(part, "r+b" if part.exists() else "wb") as fh:
|
||||||
|
fh.truncate(offset)
|
||||||
|
fh.seek(offset)
|
||||||
|
on_progress(written, expected)
|
||||||
|
async for chunk in chunks:
|
||||||
|
fh.write(chunk)
|
||||||
|
hashes.update(chunk)
|
||||||
|
written += len(chunk)
|
||||||
|
on_progress(written, expected)
|
||||||
|
if expected is not None and written > expected:
|
||||||
|
break
|
||||||
|
if should_stop():
|
||||||
|
fh.flush()
|
||||||
|
raise Interrupted(f"download paused at byte {written}")
|
||||||
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno == errno.ENOSPC:
|
||||||
|
raise DiskSpaceError("disk full while downloading", public="Local disk is full; free space and /resume.") from exc
|
||||||
|
raise TransientError(f"download failed: {exc}", public="Download failed (I/O or network error)") from exc
|
||||||
|
finally:
|
||||||
|
await chunks.aclose()
|
||||||
|
|
||||||
|
size = part.stat().st_size
|
||||||
|
if expected is not None and size != expected:
|
||||||
|
if size > expected:
|
||||||
|
part.unlink(missing_ok=True)
|
||||||
|
raise TransientError(
|
||||||
|
f"incomplete download: {size} of {expected} bytes", public="Download incomplete or corrupted; will retry"
|
||||||
|
)
|
||||||
|
if size == 0:
|
||||||
|
part.unlink(missing_ok=True)
|
||||||
|
raise TransientError("downloaded file is empty", public="Downloaded file is empty; will retry")
|
||||||
|
os.replace(part, final)
|
||||||
|
md5, sha = hashes.digest()
|
||||||
|
return LocalFile(final, size, md5, sha)
|
||||||
|
|
||||||
|
def cleanup(self, rec: VideoRecord) -> int:
|
||||||
|
"""Delete this video's temporary files. Returns how many files were removed."""
|
||||||
|
return sum(self.safe_unlink(path) for path in (self.complete_path(rec), self.partial_path(rec)))
|
||||||
|
|
||||||
|
def safe_unlink(self, path: Path) -> bool:
|
||||||
|
"""Delete ``path`` only if it is a regular temp file directly inside the download dir."""
|
||||||
|
try:
|
||||||
|
if path.is_symlink() or path.parent.resolve() != self._root or not TEMP_NAME.match(path.name):
|
||||||
|
log.error("Refusing to delete %s: not an application temp file", path.name)
|
||||||
|
return False
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
path.unlink()
|
||||||
|
log.info("Deleted temporary file %s", path.name)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Could not delete temporary file %s: %s", path.name, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cleanup_orphans(self, db, *, keep_failed: bool = False) -> int:
|
||||||
|
"""Remove temp files that no unfinished item needs any more.
|
||||||
|
|
||||||
|
A file is deleted only when it matches the application's temp-name pattern,
|
||||||
|
lives directly in the download directory (symlinks are never followed), and
|
||||||
|
its database record is missing or finished (COMPLETED / SKIPPED, or FAILED
|
||||||
|
unless ``keep_failed``). Leftover ``.part`` files of fully downloaded items
|
||||||
|
are removed too. Anything else in the directory is left untouched.
|
||||||
|
"""
|
||||||
|
deleted = 0
|
||||||
|
try:
|
||||||
|
entries = list(os.scandir(self._root))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return 0
|
||||||
|
for entry in entries:
|
||||||
|
match = TEMP_NAME.match(entry.name)
|
||||||
|
if not match or entry.is_symlink() or not entry.is_file(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
channel_id, message_id, kind = -int(match.group(1)), int(match.group(2)), match.group(3)
|
||||||
|
rec = db.get((channel_id, message_id))
|
||||||
|
if rec is None or rec.state in (State.COMPLETED, State.SKIPPED):
|
||||||
|
reason = "record finished" if rec else "no database record"
|
||||||
|
elif rec.state == State.FAILED and not keep_failed:
|
||||||
|
reason = "item failed (can be downloaded again)"
|
||||||
|
elif kind == "part" and rec.state in (State.DOWNLOADED, State.UPLOADING):
|
||||||
|
reason = "stale partial of a completed download"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if self.safe_unlink(Path(entry.path)):
|
||||||
|
deleted += 1
|
||||||
|
log.info("Orphan cleanup: removed %s (%s)", entry.name, reason)
|
||||||
|
return deleted
|
||||||
+620
@@ -0,0 +1,620 @@
|
|||||||
|
"""Google Drive access: authentication, folders, identity lookup, resumable uploads.
|
||||||
|
|
||||||
|
Metadata calls use the official ``google-api-python-client``. File uploads use
|
||||||
|
Google's documented resumable-upload protocol directly over
|
||||||
|
``google.auth.transport.requests.AuthorizedSession`` so that the session URI can be
|
||||||
|
persisted in SQLite and an interrupted upload can continue after a process restart.
|
||||||
|
https://developers.google.com/drive/api/guides/manage-uploads#resumable
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from errors import (
|
||||||
|
AuthError,
|
||||||
|
BotError,
|
||||||
|
DriveQuotaError,
|
||||||
|
Interrupted,
|
||||||
|
PermanentError,
|
||||||
|
TransientError,
|
||||||
|
UploadSessionExpired,
|
||||||
|
)
|
||||||
|
from models import DriveFile
|
||||||
|
from naming import normalize_key
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FOLDER_MIME = "application/vnd.google-apps.folder"
|
||||||
|
SCOPES = {
|
||||||
|
"drive.file": "https://www.googleapis.com/auth/drive.file",
|
||||||
|
"drive": "https://www.googleapis.com/auth/drive",
|
||||||
|
}
|
||||||
|
UPLOAD_URL = "https://www.googleapis.com/upload/drive/v3/files"
|
||||||
|
CHUNK_ALIGN = 256 * 1024
|
||||||
|
FILE_FIELDS = "id,name,size,md5Checksum,parents,mimeType,trashed,appProperties"
|
||||||
|
# appProperties key holding "<channel_id>:<message_id>"; used to recognise our own
|
||||||
|
# uploads after a crash, so a lost response never leads to a duplicate upload.
|
||||||
|
SOURCE_PROPERTY = "tg_source"
|
||||||
|
|
||||||
|
_RATE_LIMIT_REASONS = {"userRateLimitExceeded", "rateLimitExceeded", "sharingRateLimitExceeded"}
|
||||||
|
_QUOTA_REASONS = {"storageQuotaExceeded", "teamDriveFileLimitExceeded", "activeItemCreationLimitExceeded"}
|
||||||
|
|
||||||
|
|
||||||
|
def _network_errors() -> tuple[type[BaseException], ...]:
|
||||||
|
errors: list[type[BaseException]] = [OSError, TimeoutError] # requests' exceptions subclass OSError
|
||||||
|
try:
|
||||||
|
import httplib2
|
||||||
|
|
||||||
|
errors.append(httplib2.HttpLib2Error)
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from google.auth.exceptions import TransportError
|
||||||
|
|
||||||
|
errors.append(TransportError)
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
pass
|
||||||
|
return tuple(errors)
|
||||||
|
|
||||||
|
|
||||||
|
NETWORK_ERRORS = _network_errors()
|
||||||
|
|
||||||
|
|
||||||
|
def escape_query(value: str) -> str:
|
||||||
|
"""Escape a literal for the Drive ``q`` search syntax."""
|
||||||
|
return value.replace("\\", "\\\\").replace("'", "\\'")
|
||||||
|
|
||||||
|
|
||||||
|
def backoff_delay(attempt: int, base: float, cap: float = 64.0) -> float:
|
||||||
|
return min(base * (2 ** (attempt - 1)), cap) * random.uniform(0.8, 1.2)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_error_payload(payload: Any) -> tuple[str | None, str | None]:
|
||||||
|
"""Extract (reason, message) from a Google JSON error body."""
|
||||||
|
if isinstance(payload, (bytes, bytearray)):
|
||||||
|
payload = payload.decode("utf-8", "replace")
|
||||||
|
if isinstance(payload, str):
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload)
|
||||||
|
except ValueError:
|
||||||
|
return None, payload[:200] or None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None, None
|
||||||
|
error = payload.get("error")
|
||||||
|
if not isinstance(error, dict):
|
||||||
|
return None, str(error) if error else None
|
||||||
|
reason = None
|
||||||
|
details = error.get("errors")
|
||||||
|
if isinstance(details, list) and details and isinstance(details[0], dict):
|
||||||
|
reason = details[0].get("reason")
|
||||||
|
return reason or error.get("status"), error.get("message")
|
||||||
|
|
||||||
|
|
||||||
|
def classify_http_error(
|
||||||
|
status: int, reason: str | None = None, message: str | None = None, retry_after: float | None = None
|
||||||
|
) -> BotError:
|
||||||
|
text = f"Google Drive API error {status}" + (f" ({reason})" if reason else "") + (f": {message}" if message else "")
|
||||||
|
if status == 401:
|
||||||
|
return AuthError(text, public="Google authorization expired or was revoked; run `python main.py drive-auth`.")
|
||||||
|
if status == 403 and reason in _QUOTA_REASONS:
|
||||||
|
return DriveQuotaError(
|
||||||
|
text,
|
||||||
|
public="Google Drive storage quota exceeded (service accounts have no My Drive quota; use a Shared Drive).",
|
||||||
|
)
|
||||||
|
if status == 403 and reason == "dailyLimitExceeded":
|
||||||
|
return TransientError(text, retry_after=retry_after or 3600, rate_limited=True, public="Drive daily API limit reached")
|
||||||
|
if status == 429 or (status == 403 and reason in _RATE_LIMIT_REASONS):
|
||||||
|
return TransientError(text, retry_after=retry_after, rate_limited=True, public="Google Drive rate limit")
|
||||||
|
if status in (408, 500, 502, 503, 504) or status >= 500:
|
||||||
|
return TransientError(text, retry_after=retry_after, public=f"Google Drive temporary error {status}")
|
||||||
|
return PermanentError(text, public=f"Google Drive rejected the request ({status}{', ' + reason if reason else ''})")
|
||||||
|
|
||||||
|
|
||||||
|
# -- authentication --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_credentials(config):
|
||||||
|
"""Build Google credentials without needing files on disk when env vars are set.
|
||||||
|
|
||||||
|
Service account: GOOGLE_SERVICE_ACCOUNT_JSON (parsed in memory) or a key file.
|
||||||
|
OAuth (personal Drive): GOOGLE_OAUTH_TOKEN_JSON (authorized-user JSON containing
|
||||||
|
the refresh token) or the local token file written by `main.py drive-auth`.
|
||||||
|
"""
|
||||||
|
from config import load_json_secret
|
||||||
|
|
||||||
|
scopes = [SCOPES[config.drive_scope]]
|
||||||
|
if config.google_auth_mode == "service_account":
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
|
||||||
|
if config.google_service_account_json:
|
||||||
|
info = load_json_secret(config.google_service_account_json)
|
||||||
|
return service_account.Credentials.from_service_account_info(info, scopes=scopes)
|
||||||
|
return service_account.Credentials.from_service_account_file(str(config.google_service_account_file), scopes=scopes)
|
||||||
|
|
||||||
|
from google.auth.exceptions import RefreshError
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
|
||||||
|
from_env = bool(config.google_oauth_token_json)
|
||||||
|
if from_env:
|
||||||
|
creds = Credentials.from_authorized_user_info(load_json_secret(config.google_oauth_token_json), scopes)
|
||||||
|
else:
|
||||||
|
token_file: Path = config.google_token_file
|
||||||
|
if not token_file.exists():
|
||||||
|
raise AuthError(
|
||||||
|
"Google token not found", public="Google Drive is not authorized yet; run `python main.py drive-auth`."
|
||||||
|
)
|
||||||
|
creds = Credentials.from_authorized_user_file(str(token_file), scopes)
|
||||||
|
if not creds.valid:
|
||||||
|
if not creds.refresh_token:
|
||||||
|
raise AuthError("Google token has no refresh token", public="Run `python main.py drive-auth` again.")
|
||||||
|
try:
|
||||||
|
creds.refresh(Request())
|
||||||
|
except RefreshError as exc:
|
||||||
|
raise AuthError(
|
||||||
|
f"Google token refresh failed: {exc}",
|
||||||
|
public="Google authorization expired or was revoked; run `python main.py drive-auth` and update the token.",
|
||||||
|
) from exc
|
||||||
|
if not from_env:
|
||||||
|
save_token(creds, config.google_token_file)
|
||||||
|
return creds
|
||||||
|
|
||||||
|
|
||||||
|
def save_token(creds, token_file: Path) -> None:
|
||||||
|
token_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd = os.open(token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w") as fh:
|
||||||
|
fh.write(creds.to_json())
|
||||||
|
|
||||||
|
|
||||||
|
def run_oauth_flow(config, *, open_browser: bool = True) -> Path:
|
||||||
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(str(config.google_client_secrets_file), [SCOPES[config.drive_scope]])
|
||||||
|
creds = flow.run_local_server(port=0, open_browser=open_browser)
|
||||||
|
save_token(creds, config.google_token_file)
|
||||||
|
return config.google_token_file
|
||||||
|
|
||||||
|
|
||||||
|
# -- client ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class DriveClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
chunk_size: int,
|
||||||
|
shared_drive_id: str | None = None,
|
||||||
|
http_retries: int = 5,
|
||||||
|
base_delay: float = 2.0,
|
||||||
|
sleep: Callable[[float], None] = time.sleep,
|
||||||
|
):
|
||||||
|
self._service = service
|
||||||
|
self._shared_drive_id = shared_drive_id
|
||||||
|
self._retries = http_retries
|
||||||
|
self._base_delay = base_delay
|
||||||
|
self._sleep = sleep
|
||||||
|
self._lock = threading.Lock() # httplib2 (used by the discovery client) is not thread-safe
|
||||||
|
self.uploader = ResumableUploader(
|
||||||
|
session, chunk_size=chunk_size, retries=http_retries, base_delay=base_delay, sleep=sleep
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, config) -> "DriveClient":
|
||||||
|
from google.auth.transport.requests import AuthorizedSession
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
|
creds = load_credentials(config)
|
||||||
|
service = build("drive", "v3", credentials=creds, cache_discovery=False)
|
||||||
|
return cls(
|
||||||
|
service,
|
||||||
|
AuthorizedSession(creds),
|
||||||
|
chunk_size=config.upload_chunk_size,
|
||||||
|
shared_drive_id=config.shared_drive_id,
|
||||||
|
http_retries=config.http_retries,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- low level ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _execute(self, build_request: Callable[[], Any], *, retries: int | None = None, not_found_ok: bool = False):
|
||||||
|
from google.auth.exceptions import RefreshError
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
|
|
||||||
|
retries = self._retries if retries is None else retries
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
return build_request().execute()
|
||||||
|
except HttpError as exc:
|
||||||
|
status = int(exc.resp.status)
|
||||||
|
if status == 404 and not_found_ok:
|
||||||
|
return None
|
||||||
|
retry_after = exc.resp.get("retry-after")
|
||||||
|
error = classify_http_error(
|
||||||
|
status, *parse_error_payload(exc.content), retry_after=float(retry_after) if retry_after else None
|
||||||
|
)
|
||||||
|
except RefreshError as exc:
|
||||||
|
raise AuthError(
|
||||||
|
f"Google token refresh failed: {exc}",
|
||||||
|
public="Google authorization expired or was revoked; run `python main.py drive-auth`.",
|
||||||
|
) from exc
|
||||||
|
except NETWORK_ERRORS as exc:
|
||||||
|
error = TransientError(f"network error talking to Google Drive: {exc}", public="Network error (Drive)")
|
||||||
|
if not isinstance(error, TransientError) or attempt > retries:
|
||||||
|
raise error
|
||||||
|
delay = error.retry_after or backoff_delay(attempt, self._base_delay)
|
||||||
|
log.warning("Drive request failed (%s); retry %d/%d in %.1fs", error, attempt, retries, delay)
|
||||||
|
self._sleep(delay)
|
||||||
|
|
||||||
|
def _list(self, query: str, *, order_by: str | None = None) -> list[DriveFile]:
|
||||||
|
files: list[DriveFile] = []
|
||||||
|
page_token = None
|
||||||
|
while True:
|
||||||
|
kwargs: dict[str, Any] = dict(
|
||||||
|
q=query,
|
||||||
|
fields=f"nextPageToken, files({FILE_FIELDS})",
|
||||||
|
pageSize=100,
|
||||||
|
supportsAllDrives=True,
|
||||||
|
includeItemsFromAllDrives=True,
|
||||||
|
)
|
||||||
|
if order_by:
|
||||||
|
kwargs["orderBy"] = order_by
|
||||||
|
if self._shared_drive_id:
|
||||||
|
kwargs.update(corpora="drive", driveId=self._shared_drive_id)
|
||||||
|
if page_token:
|
||||||
|
kwargs["pageToken"] = page_token
|
||||||
|
response = self._execute(lambda: self._service.files().list(**kwargs))
|
||||||
|
files.extend(DriveFile.from_api(f) for f in response.get("files", []))
|
||||||
|
page_token = response.get("nextPageToken")
|
||||||
|
if not page_token:
|
||||||
|
return files
|
||||||
|
|
||||||
|
# -- queries ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_file(self, file_id: str) -> DriveFile | None:
|
||||||
|
data = self._execute(
|
||||||
|
lambda: self._service.files().get(fileId=file_id, fields=FILE_FIELDS, supportsAllDrives=True),
|
||||||
|
not_found_ok=True,
|
||||||
|
)
|
||||||
|
return DriveFile.from_api(data) if data else None
|
||||||
|
|
||||||
|
def find_folders(self, parent_id: str, name: str) -> list[DriveFile]:
|
||||||
|
query = (
|
||||||
|
f"'{escape_query(parent_id)}' in parents and name = '{escape_query(name)}' "
|
||||||
|
f"and mimeType = '{FOLDER_MIME}' and trashed = false"
|
||||||
|
)
|
||||||
|
return self._list(query, order_by="createdTime")
|
||||||
|
|
||||||
|
def find_by_name(self, parent_id: str, name: str) -> list[DriveFile]:
|
||||||
|
query = (
|
||||||
|
f"'{escape_query(parent_id)}' in parents and name = '{escape_query(name)}' "
|
||||||
|
f"and mimeType != '{FOLDER_MIME}' and trashed = false"
|
||||||
|
)
|
||||||
|
return self._list(query)
|
||||||
|
|
||||||
|
def find_by_source_key(self, key: str) -> list[DriveFile]:
|
||||||
|
query = (
|
||||||
|
f"appProperties has {{ key='{SOURCE_PROPERTY}' and value='{escape_query(key)}' }} and trashed = false"
|
||||||
|
)
|
||||||
|
return self._list(query)
|
||||||
|
|
||||||
|
# -- mutations ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_folder(self, parent_id: str, name: str) -> DriveFile:
|
||||||
|
# Single attempt: folder creation is not idempotent. FolderResolver searches
|
||||||
|
# again before retrying, so a lost response cannot create a duplicate.
|
||||||
|
body = {"name": name, "mimeType": FOLDER_MIME, "parents": [parent_id]}
|
||||||
|
data = self._execute(
|
||||||
|
lambda: self._service.files().create(body=body, fields=FILE_FIELDS, supportsAllDrives=True), retries=0
|
||||||
|
)
|
||||||
|
return DriveFile.from_api(data)
|
||||||
|
|
||||||
|
def share_with_link(self, file_id: str) -> None:
|
||||||
|
body = {"type": "anyone", "role": "reader"}
|
||||||
|
self._execute(
|
||||||
|
lambda: self._service.permissions().create(fileId=file_id, body=body, fields="id", supportsAllDrives=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
def upload_file(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
parent_id: str,
|
||||||
|
mime_type: str,
|
||||||
|
size: int,
|
||||||
|
app_properties: dict[str, str],
|
||||||
|
description: str | None = None,
|
||||||
|
session_uri: str | None,
|
||||||
|
on_session: Callable[[str], None],
|
||||||
|
on_progress: Callable[[int, int], None],
|
||||||
|
should_stop: Callable[[], bool],
|
||||||
|
) -> DriveFile:
|
||||||
|
metadata: dict[str, Any] = {"name": name, "parents": [parent_id], "appProperties": app_properties}
|
||||||
|
if description:
|
||||||
|
metadata["description"] = description
|
||||||
|
data = self.uploader.upload(
|
||||||
|
path,
|
||||||
|
metadata=metadata,
|
||||||
|
mime_type=mime_type,
|
||||||
|
size=size,
|
||||||
|
session_uri=session_uri,
|
||||||
|
on_session=on_session,
|
||||||
|
on_progress=on_progress,
|
||||||
|
should_stop=should_stop,
|
||||||
|
)
|
||||||
|
return DriveFile.from_api(data)
|
||||||
|
|
||||||
|
def check_access(self) -> None:
|
||||||
|
"""Cheap authenticated call used at startup to validate credentials."""
|
||||||
|
self._execute(lambda: self._service.about().get(fields="user(displayName)"))
|
||||||
|
|
||||||
|
def check_root_folder(self, folder_id: str) -> DriveFile:
|
||||||
|
folder = self.get_file(folder_id)
|
||||||
|
if folder is None:
|
||||||
|
raise PermanentError(
|
||||||
|
f"Drive root folder {folder_id} not found",
|
||||||
|
public=(
|
||||||
|
"DRIVE_ROOT_FOLDER_ID is not visible to these credentials. With GOOGLE_DRIVE_SCOPE=drive.file the "
|
||||||
|
"bot only sees folders it created: run `python main.py drive-init`, or set GOOGLE_DRIVE_SCOPE=drive "
|
||||||
|
"and re-run `python main.py drive-auth`. For a service account, share the folder with it."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if folder.mime_type != FOLDER_MIME or folder.trashed:
|
||||||
|
raise PermanentError(f"Drive root {folder_id} is not an active folder", public="DRIVE_ROOT_FOLDER_ID is not a usable folder")
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
# -- resumable upload ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def parse_range(header: str | None) -> int:
|
||||||
|
"""'bytes=0-1048575' -> 1048576 bytes persisted; no header -> 0."""
|
||||||
|
if not header:
|
||||||
|
return 0
|
||||||
|
match = re.search(r"(\d+)-(\d+)", header)
|
||||||
|
return int(match.group(2)) + 1 if match else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _response_error(resp) -> BotError:
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
payload = getattr(resp, "text", None)
|
||||||
|
retry_after = resp.headers.get("Retry-After") if resp.headers else None
|
||||||
|
try:
|
||||||
|
retry = float(retry_after) if retry_after else None
|
||||||
|
except ValueError:
|
||||||
|
retry = None
|
||||||
|
return classify_http_error(resp.status_code, *parse_error_payload(payload), retry_after=retry)
|
||||||
|
|
||||||
|
|
||||||
|
class ResumableUploader:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
chunk_size: int,
|
||||||
|
retries: int = 5,
|
||||||
|
base_delay: float = 2.0,
|
||||||
|
sleep: Callable[[float], None] = time.sleep,
|
||||||
|
upload_url: str = UPLOAD_URL,
|
||||||
|
):
|
||||||
|
if chunk_size <= 0 or chunk_size % CHUNK_ALIGN:
|
||||||
|
raise ValueError("chunk_size must be a positive multiple of 256 KiB")
|
||||||
|
self._session = session
|
||||||
|
self._chunk_size = chunk_size
|
||||||
|
self._retries = retries
|
||||||
|
self._base_delay = base_delay
|
||||||
|
self._sleep = sleep
|
||||||
|
self._upload_url = upload_url
|
||||||
|
|
||||||
|
def upload(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
mime_type: str,
|
||||||
|
size: int,
|
||||||
|
session_uri: str | None,
|
||||||
|
on_session: Callable[[str], None],
|
||||||
|
on_progress: Callable[[int, int], None],
|
||||||
|
should_stop: Callable[[], bool],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if size <= 0:
|
||||||
|
raise PermanentError("refusing to upload an empty file", public="Downloaded file is empty")
|
||||||
|
offset = 0
|
||||||
|
if session_uri:
|
||||||
|
status, value = self.query_status(session_uri, size)
|
||||||
|
if status == "done":
|
||||||
|
on_progress(size, size)
|
||||||
|
return value
|
||||||
|
if status == "expired":
|
||||||
|
log.info("Saved upload session expired; starting a new one")
|
||||||
|
session_uri = None
|
||||||
|
else:
|
||||||
|
offset = value
|
||||||
|
log.info("Resuming upload session at byte %d of %d", offset, size)
|
||||||
|
if not session_uri:
|
||||||
|
session_uri = self._initiate(metadata, mime_type, size)
|
||||||
|
on_session(session_uri)
|
||||||
|
offset = 0
|
||||||
|
on_progress(offset, size)
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
while True:
|
||||||
|
if should_stop():
|
||||||
|
raise Interrupted(f"upload paused at byte {offset} of {size}")
|
||||||
|
fh.seek(offset)
|
||||||
|
chunk = fh.read(min(self._chunk_size, size - offset))
|
||||||
|
if not chunk:
|
||||||
|
status, value = self.query_status(session_uri, size)
|
||||||
|
if status == "done":
|
||||||
|
return value
|
||||||
|
raise TransientError("upload finished locally but Drive did not finalize the file")
|
||||||
|
headers = {
|
||||||
|
"Content-Length": str(len(chunk)),
|
||||||
|
"Content-Range": f"bytes {offset}-{offset + len(chunk) - 1}/{size}",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = self._session.request("PUT", session_uri, data=chunk, headers=headers, timeout=(30, 600))
|
||||||
|
except NETWORK_ERRORS as exc:
|
||||||
|
error: BotError = TransientError(f"network error during upload: {exc}", public="Network error during upload")
|
||||||
|
else:
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
on_progress(size, size)
|
||||||
|
return resp.json()
|
||||||
|
if resp.status_code == 308:
|
||||||
|
offset = parse_range(resp.headers.get("Range"))
|
||||||
|
failures = 0
|
||||||
|
on_progress(offset, size)
|
||||||
|
continue
|
||||||
|
if resp.status_code in (404, 410):
|
||||||
|
raise UploadSessionExpired("upload session expired", public="Upload session expired; will restart")
|
||||||
|
error = _response_error(resp)
|
||||||
|
if not isinstance(error, TransientError):
|
||||||
|
raise error
|
||||||
|
failures += 1
|
||||||
|
if failures > self._retries:
|
||||||
|
raise error
|
||||||
|
delay = getattr(error, "retry_after", None) or backoff_delay(failures, self._base_delay)
|
||||||
|
log.warning("Upload chunk failed (%s); retry %d/%d in %.1fs", error, failures, self._retries, delay)
|
||||||
|
self._sleep(delay)
|
||||||
|
status, value = self.query_status(session_uri, size)
|
||||||
|
if status == "done":
|
||||||
|
on_progress(size, size)
|
||||||
|
return value
|
||||||
|
if status == "expired":
|
||||||
|
raise UploadSessionExpired("upload session expired", public="Upload session expired; will restart")
|
||||||
|
offset = value
|
||||||
|
|
||||||
|
def query_status(self, session_uri: str, size: int) -> tuple[str, Any]:
|
||||||
|
"""Ask Drive how much of a resumable session it has: done / partial / expired."""
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
resp = self._session.request(
|
||||||
|
"PUT", session_uri, headers={"Content-Length": "0", "Content-Range": f"bytes */{size}"}, timeout=(30, 120)
|
||||||
|
)
|
||||||
|
except NETWORK_ERRORS as exc:
|
||||||
|
error: BotError = TransientError(f"network error querying upload status: {exc}", public="Network error during upload")
|
||||||
|
else:
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
return "done", resp.json()
|
||||||
|
if resp.status_code == 308:
|
||||||
|
return "partial", parse_range(resp.headers.get("Range"))
|
||||||
|
if resp.status_code in (404, 410):
|
||||||
|
return "expired", None
|
||||||
|
error = _response_error(resp)
|
||||||
|
if not isinstance(error, TransientError):
|
||||||
|
raise error
|
||||||
|
if attempt > self._retries:
|
||||||
|
raise error
|
||||||
|
self._sleep(getattr(error, "retry_after", None) or backoff_delay(attempt, self._base_delay))
|
||||||
|
|
||||||
|
def _initiate(self, metadata: dict[str, Any], mime_type: str, size: int) -> str:
|
||||||
|
url = f"{self._upload_url}?uploadType=resumable&supportsAllDrives=true&fields={quote(FILE_FIELDS)}"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json; charset=UTF-8",
|
||||||
|
"X-Upload-Content-Type": mime_type,
|
||||||
|
"X-Upload-Content-Length": str(size),
|
||||||
|
}
|
||||||
|
body = json.dumps(metadata).encode("utf-8")
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
resp = self._session.request("POST", url, data=body, headers=headers, timeout=(30, 120))
|
||||||
|
except NETWORK_ERRORS as exc:
|
||||||
|
error: BotError = TransientError(f"network error starting upload: {exc}", public="Network error during upload")
|
||||||
|
else:
|
||||||
|
if resp.status_code == 200:
|
||||||
|
location = resp.headers.get("Location") or resp.headers.get("location")
|
||||||
|
if location:
|
||||||
|
return location
|
||||||
|
error = TransientError("Drive did not return an upload session URI")
|
||||||
|
else:
|
||||||
|
error = _response_error(resp)
|
||||||
|
if not isinstance(error, TransientError):
|
||||||
|
raise error
|
||||||
|
if attempt > self._retries:
|
||||||
|
raise error
|
||||||
|
self._sleep(getattr(error, "retry_after", None) or backoff_delay(attempt, self._base_delay))
|
||||||
|
|
||||||
|
|
||||||
|
# -- folder resolution --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FolderResolver:
|
||||||
|
"""Map folder names to Drive folder IDs without ever creating duplicates.
|
||||||
|
|
||||||
|
Lookup order: SQLite cache (verified once per process) -> Drive search (oldest
|
||||||
|
match wins if duplicates already exist) -> create. After a failed create the
|
||||||
|
search is repeated before trying again, so a create whose response was lost is
|
||||||
|
found instead of duplicated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, drive: DriveClient, db, root_id: str, *, attempts: int = 4, sleep=time.sleep):
|
||||||
|
self._drive = drive
|
||||||
|
self._db = db
|
||||||
|
self.root_id = root_id
|
||||||
|
self._attempts = attempts
|
||||||
|
self._sleep = sleep
|
||||||
|
self._verified: set[str] = set()
|
||||||
|
|
||||||
|
def resolve(self, names: list[str]) -> str:
|
||||||
|
parent = self.root_id
|
||||||
|
for name in names:
|
||||||
|
parent = self._ensure(parent, name)
|
||||||
|
return parent
|
||||||
|
|
||||||
|
def _ensure(self, parent_id: str, name: str) -> str:
|
||||||
|
key = normalize_key(name)
|
||||||
|
cached = self._db.get_folder(parent_id, key)
|
||||||
|
if cached:
|
||||||
|
if cached in self._verified:
|
||||||
|
return cached
|
||||||
|
folder = self._drive.get_file(cached)
|
||||||
|
if folder and not folder.trashed and folder.mime_type == FOLDER_MIME:
|
||||||
|
self._verified.add(cached)
|
||||||
|
return cached
|
||||||
|
log.warning("Cached folder %r (%s) is gone; looking it up again", name, cached)
|
||||||
|
self._db.delete_folder(parent_id, key)
|
||||||
|
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
matches = [f for f in self._drive.find_folders(parent_id, name) if normalize_key(f.name) == key]
|
||||||
|
if matches:
|
||||||
|
if len(matches) > 1:
|
||||||
|
log.warning("%d folders named %r exist; reusing the oldest (%s)", len(matches), name, matches[0].id)
|
||||||
|
return self._remember(parent_id, key, name, matches[0].id)
|
||||||
|
try:
|
||||||
|
created = self._drive.create_folder(parent_id, name)
|
||||||
|
except TransientError:
|
||||||
|
if attempt >= self._attempts:
|
||||||
|
raise
|
||||||
|
self._sleep(backoff_delay(attempt, 2.0))
|
||||||
|
continue
|
||||||
|
log.info("Created Drive folder %r (%s)", name, created.id)
|
||||||
|
return self._remember(parent_id, key, name, created.id)
|
||||||
|
|
||||||
|
def _remember(self, parent_id: str, key: str, name: str, folder_id: str) -> str:
|
||||||
|
self._db.put_folder(parent_id, key, name, folder_id)
|
||||||
|
self._verified.add(folder_id)
|
||||||
|
return folder_id
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Exception hierarchy.
|
||||||
|
|
||||||
|
The queue decides what to do with a failed item purely from the exception type:
|
||||||
|
|
||||||
|
* ``TransientError`` -> RETRY_WAIT with exponential backoff, bounded by MAX_ATTEMPTS.
|
||||||
|
* ``PermanentError`` -> FAILED immediately (can still be retried manually with /retry).
|
||||||
|
* ``QueueHaltError`` -> the item is parked for retry without consuming an attempt and
|
||||||
|
the whole queue halts (disk full, Drive quota, expired auth).
|
||||||
|
* ``SkipItem`` -> SKIPPED with a recorded reason (configured duplicate policy).
|
||||||
|
* ``Interrupted`` -> /pause or /stop reached a resumable checkpoint.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class BotError(Exception):
|
||||||
|
"""Base error. ``public`` is a short summary that is safe to show in Telegram."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, public: str | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.public = public or message
|
||||||
|
|
||||||
|
|
||||||
|
class TransientError(BotError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
retry_after: float | None = None,
|
||||||
|
rate_limited: bool = False,
|
||||||
|
public: str | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message, public=public)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
# Rate-limit waits do not consume one of the item's attempts.
|
||||||
|
self.rate_limited = rate_limited
|
||||||
|
|
||||||
|
|
||||||
|
class UploadSessionExpired(TransientError):
|
||||||
|
"""The saved resumable-upload session URI is no longer valid."""
|
||||||
|
|
||||||
|
|
||||||
|
class PermanentError(BotError):
|
||||||
|
"""A failure that will not fix itself by waiting."""
|
||||||
|
|
||||||
|
|
||||||
|
class QueueHaltError(BotError):
|
||||||
|
"""A failure that would affect every following item as well."""
|
||||||
|
|
||||||
|
|
||||||
|
class DiskSpaceError(QueueHaltError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DriveQuotaError(QueueHaltError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(QueueHaltError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SkipItem(Exception):
|
||||||
|
def __init__(self, reason: str):
|
||||||
|
super().__init__(reason)
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
|
class Interrupted(Exception):
|
||||||
|
"""Raised at a resumable checkpoint after /pause or /stop."""
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
"""Parse and validate Telegram message links.
|
||||||
|
|
||||||
|
Supported:
|
||||||
|
https://t.me/c/3909337424/14 private/internal channel link
|
||||||
|
https://t.me/c/3909337424/7/14 same, with a discussion-thread segment
|
||||||
|
https://t.me/channelname/14 public channel post
|
||||||
|
https://t.me/s/channelname/14 public web-preview link
|
||||||
|
|
||||||
|
Variants with ``www.``, ``telegram.me``, no scheme, a trailing slash, query
|
||||||
|
parameters (``?single``) or a fragment are accepted.
|
||||||
|
|
||||||
|
For ``/c/<id>/`` links the number is the channel's *internal* ID. The MTProto
|
||||||
|
"marked" peer ID used by Telethon is ``-(10**12 + id)`` (for 10-digit IDs this
|
||||||
|
looks like ``-100`` followed by the ID). It is only an identifier: access is
|
||||||
|
still verified by resolving it through the authenticated client.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
_HOSTS = {"t.me", "telegram.me", "telegram.dog"}
|
||||||
|
_USERNAME = re.compile(r"^[A-Za-z][A-Za-z0-9_]{3,31}$")
|
||||||
|
# Paths on t.me that are not channel usernames.
|
||||||
|
_RESERVED = {
|
||||||
|
"addstickers", "addemoji", "addtheme", "addlist", "boost", "bg", "share", "proxy", "socks", "iv",
|
||||||
|
"login", "setlanguage", "invoice", "joinchat", "contact", "m", "confirmphone", "giftcode",
|
||||||
|
}
|
||||||
|
EXAMPLE = "https://t.me/c/3909337424/14"
|
||||||
|
|
||||||
|
|
||||||
|
class LinkError(ValueError):
|
||||||
|
"""Invalid link; the message is safe to show to the user."""
|
||||||
|
|
||||||
|
|
||||||
|
def marked_channel_id(internal_id: int) -> int:
|
||||||
|
return -(10**12 + internal_id)
|
||||||
|
|
||||||
|
|
||||||
|
def internal_channel_id(marked_id: int) -> int:
|
||||||
|
return -marked_id - 10**12
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MessageLink:
|
||||||
|
kind: str # "private" or "public"
|
||||||
|
channel: str # internal numeric ID (private) or username (public)
|
||||||
|
message_id: int
|
||||||
|
url: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channel_ref(self) -> str:
|
||||||
|
"""Reference understood by telegram_client.resolve_channel."""
|
||||||
|
if self.kind == "private":
|
||||||
|
return str(marked_channel_id(int(self.channel)))
|
||||||
|
return self.channel
|
||||||
|
|
||||||
|
@property
|
||||||
|
def same_channel_key(self) -> str:
|
||||||
|
return self.channel if self.kind == "private" else self.channel.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_message_link(text: str | None) -> MessageLink:
|
||||||
|
raw = (text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise LinkError(f"Please send a Telegram message link, for example {EXAMPLE}")
|
||||||
|
if any(ch.isspace() for ch in raw):
|
||||||
|
raise LinkError("Send only the link, without any other text.")
|
||||||
|
url = raw if re.match(r"^[a-z]+://", raw, re.IGNORECASE) else "https://" + raw
|
||||||
|
try:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise LinkError(f"That is not a valid URL. Example: {EXAMPLE}") from exc
|
||||||
|
host = (parts.hostname or "").lower()
|
||||||
|
if host.startswith("www."):
|
||||||
|
host = host[4:]
|
||||||
|
if parts.scheme.lower() not in ("http", "https") or host not in _HOSTS:
|
||||||
|
raise LinkError(f"That is not a Telegram (t.me) link. Example: {EXAMPLE}")
|
||||||
|
|
||||||
|
segments = [s for s in parts.path.split("/") if s]
|
||||||
|
if not segments:
|
||||||
|
raise LinkError(f"The link does not point to a message. Example: {EXAMPLE}")
|
||||||
|
head = segments[0]
|
||||||
|
if head.startswith("+") or head.lower() == "joinchat":
|
||||||
|
raise LinkError("Invite links cannot be used. Open the channel, then copy the link of a specific post.")
|
||||||
|
|
||||||
|
if head == "c":
|
||||||
|
if len(segments) not in (3, 4) or not all(s.isdigit() for s in segments[1:]):
|
||||||
|
raise LinkError(f"Private-channel links must look like {EXAMPLE} (channel ID, then message ID).")
|
||||||
|
channel, message = segments[1], segments[-1]
|
||||||
|
kind = "private"
|
||||||
|
if int(channel) == 0:
|
||||||
|
raise LinkError("The channel ID in the link is invalid.")
|
||||||
|
else:
|
||||||
|
if head == "s":
|
||||||
|
segments = segments[1:]
|
||||||
|
if len(segments) == 1:
|
||||||
|
raise LinkError("This link points to a channel, not to a message. Copy the link of a specific post.")
|
||||||
|
if len(segments) not in (2, 3) or not segments[-1].isdigit():
|
||||||
|
raise LinkError(f"Unsupported Telegram link format. Use a post link such as {EXAMPLE}")
|
||||||
|
channel, message = segments[0], segments[-1]
|
||||||
|
if channel.lower() in _RESERVED or not _USERNAME.match(channel):
|
||||||
|
raise LinkError("Unsupported Telegram link type; send the link of a channel post.")
|
||||||
|
if len(segments) == 3 and not segments[1].isdigit():
|
||||||
|
raise LinkError(f"Unsupported Telegram link format. Use a post link such as {EXAMPLE}")
|
||||||
|
kind = "public"
|
||||||
|
|
||||||
|
message_id = int(message)
|
||||||
|
if message_id <= 0:
|
||||||
|
raise LinkError("The message ID in the link must be a positive number.")
|
||||||
|
return MessageLink(kind=kind, channel=channel, message_id=message_id, url=raw)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Logging setup: stdout (always) plus a rotating file locally, with secret redaction."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FORMAT = "%(asctime)s %(levelname)-7s %(name)s: %(message)s"
|
||||||
|
|
||||||
|
|
||||||
|
class RedactSecrets(logging.Filter):
|
||||||
|
"""Replace known secret values in every log record."""
|
||||||
|
|
||||||
|
def __init__(self, secrets: list[str]):
|
||||||
|
super().__init__()
|
||||||
|
self._secrets = sorted({s for s in secrets if s}, key=len, reverse=True)
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
if not self._secrets:
|
||||||
|
return True
|
||||||
|
message = record.getMessage()
|
||||||
|
redacted = message
|
||||||
|
for secret in self._secrets:
|
||||||
|
redacted = redacted.replace(secret, "***")
|
||||||
|
if redacted != message:
|
||||||
|
record.msg, record.args = redacted, None
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: str = "INFO", *, log_dir: Path | None = None, secrets: list[str] | None = None) -> None:
|
||||||
|
"""``log_dir=None`` logs to stdout only (Heroku collects stdout via `heroku logs`)."""
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level)
|
||||||
|
for handler in list(root.handlers):
|
||||||
|
root.removeHandler(handler)
|
||||||
|
redact = RedactSecrets(secrets or [])
|
||||||
|
handlers: list[logging.Handler] = [logging.StreamHandler()]
|
||||||
|
if log_dir is not None:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
handlers.append(
|
||||||
|
logging.handlers.RotatingFileHandler(log_dir / "bot.log", maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8")
|
||||||
|
)
|
||||||
|
for handler in handlers:
|
||||||
|
handler.setFormatter(logging.Formatter(FORMAT))
|
||||||
|
handler.addFilter(redact)
|
||||||
|
root.addHandler(handler)
|
||||||
|
for noisy in ("telethon", "googleapiclient.discovery_cache", "urllib3", "google_auth_httplib2"):
|
||||||
|
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""Entry point.
|
||||||
|
|
||||||
|
python main.py run # long-running worker (default; used by the Heroku Procfile)
|
||||||
|
python main.py process # headless: sync, process the queue once, exit
|
||||||
|
python main.py sync [--rescan]
|
||||||
|
python main.py status
|
||||||
|
python main.py check-config
|
||||||
|
python main.py login [--session-string]
|
||||||
|
python main.py drive-auth [--no-browser]
|
||||||
|
python main.py drive-init --name "Telegram Videos"
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from config import Config, ConfigError
|
||||||
|
from database import Database, InstanceLock, InstanceLockError
|
||||||
|
from errors import BotError
|
||||||
|
from logging_config import setup_logging
|
||||||
|
|
||||||
|
log = logging.getLogger("main")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class App:
|
||||||
|
config: Config
|
||||||
|
db: Database
|
||||||
|
lock: InstanceLock
|
||||||
|
queue: object
|
||||||
|
source: object
|
||||||
|
clients: list = field(default_factory=list)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
for client in self.clients:
|
||||||
|
try:
|
||||||
|
await client.disconnect()
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
log.debug("disconnect failed: %s", exc)
|
||||||
|
self.db.close()
|
||||||
|
self.lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
async def build_app(config: Config, *, control: bool) -> App:
|
||||||
|
"""Wire every component. Fails fast with actionable errors."""
|
||||||
|
from telethon import events, utils
|
||||||
|
|
||||||
|
from bot_handlers import CommandRouter, TelethonNotifier, register_command_handlers
|
||||||
|
from downloader import Downloader
|
||||||
|
from drive_client import DriveClient, FolderResolver
|
||||||
|
from progress import LogNotifier, ProgressReporter
|
||||||
|
from queue_manager import CURSOR_KEY, QueueManager
|
||||||
|
from telegram_client import ACCESS_HASH_KEY, TelethonSource, build_client, resolve_channel
|
||||||
|
|
||||||
|
lock = InstanceLock(config, wait_seconds=config.instance_lock_wait)
|
||||||
|
await asyncio.to_thread(lock.acquire)
|
||||||
|
db = Database.from_config(config)
|
||||||
|
log.info("Database: %s", db.describe())
|
||||||
|
if config.is_heroku and not config.database_url:
|
||||||
|
log.warning("ALLOW_EPHEMERAL_DATABASE=true: progress is lost whenever the dyno restarts!")
|
||||||
|
db.recover_stale()
|
||||||
|
|
||||||
|
drive = await asyncio.to_thread(DriveClient.from_config, config)
|
||||||
|
await asyncio.to_thread(drive.check_access)
|
||||||
|
root_id = config.drive_root_folder_id or "root"
|
||||||
|
if config.drive_root_folder_id:
|
||||||
|
await asyncio.to_thread(drive.check_root_folder, root_id)
|
||||||
|
else:
|
||||||
|
log.warning("GOOGLE_DRIVE_ROOT_FOLDER_ID not set; topic folders are created in the top level of My Drive")
|
||||||
|
log.info("Google Drive credentials validated")
|
||||||
|
|
||||||
|
clients = []
|
||||||
|
if config.telegram_mode == "bot":
|
||||||
|
source_client = build_client(config, "bot")
|
||||||
|
await source_client.start(bot_token=config.bot_token)
|
||||||
|
control_client = source_client
|
||||||
|
clients.append(source_client)
|
||||||
|
else:
|
||||||
|
source_client = build_client(config, "user")
|
||||||
|
await source_client.connect()
|
||||||
|
clients.append(source_client)
|
||||||
|
if not await source_client.is_user_authorized():
|
||||||
|
raise ConfigError(["Telegram user session is not authorized. Run `python main.py login` (see README)."])
|
||||||
|
control_client = None
|
||||||
|
if config.bot_token and control:
|
||||||
|
control_client = build_client(config, "bot")
|
||||||
|
await control_client.start(bot_token=config.bot_token)
|
||||||
|
clients.append(control_client)
|
||||||
|
|
||||||
|
is_bot = config.telegram_mode == "bot"
|
||||||
|
try:
|
||||||
|
entity = await resolve_channel(source_client, config.source_channel, is_bot=is_bot, db=db)
|
||||||
|
channel_id = utils.get_peer_id(entity)
|
||||||
|
except BotError as exc:
|
||||||
|
if not is_bot:
|
||||||
|
raise
|
||||||
|
# A bot learns a private channel from its first live post (handled below).
|
||||||
|
log.warning("%s", exc.public)
|
||||||
|
entity = None
|
||||||
|
channel_id = int(config.source_channel) if config.source_channel.lstrip("-").isdigit() else None
|
||||||
|
if channel_id is None:
|
||||||
|
raise
|
||||||
|
log.info("Source channel resolved (id %s, mode %s)", channel_id, config.telegram_mode)
|
||||||
|
|
||||||
|
source = TelethonSource(source_client, entity, channel_id=channel_id, mode=config.telegram_mode, config=config, db=db)
|
||||||
|
downloader = Downloader(
|
||||||
|
source,
|
||||||
|
config.download_dir,
|
||||||
|
min_free_bytes=config.min_free_disk_space,
|
||||||
|
max_temp_bytes=config.max_temp_storage,
|
||||||
|
compute_sha256=config.compute_sha256,
|
||||||
|
)
|
||||||
|
notifier = TelethonNotifier(control_client) if control_client else LogNotifier()
|
||||||
|
reporter = ProgressReporter(notifier, config.notify_chat_ids, config.progress_interval)
|
||||||
|
queue = QueueManager(
|
||||||
|
config, db, drive, downloader, FolderResolver(drive, db, root_id), source=source, reporter=reporter
|
||||||
|
)
|
||||||
|
removed = downloader.cleanup_orphans(db, keep_failed=config.keep_failed_downloads)
|
||||||
|
log.info("Startup cleanup removed %d abandoned temporary file(s)", removed)
|
||||||
|
|
||||||
|
if config.process_mode in ("new", "both"):
|
||||||
|
queue.listening = True
|
||||||
|
|
||||||
|
@source_client.on(events.NewMessage(func=lambda e: e.chat_id == channel_id))
|
||||||
|
async def _on_channel_post(event) -> None: # pragma: no cover - needs live Telegram
|
||||||
|
if source.entity is None:
|
||||||
|
source.entity = await event.get_input_chat()
|
||||||
|
db.set_kv(ACCESS_HASH_KEY.format(source.entity.channel_id), str(source.entity.access_hash))
|
||||||
|
outcome = queue.register(source.convert(event.message))
|
||||||
|
log.info("Live post %s: %s", event.message.id, outcome)
|
||||||
|
|
||||||
|
if config.process_mode == "new" and db.get_int(CURSOR_KEY, 0) == 0 and entity is not None:
|
||||||
|
latest = await source.latest_message_id()
|
||||||
|
if latest:
|
||||||
|
db.raise_int(CURSOR_KEY, latest)
|
||||||
|
|
||||||
|
if control_client:
|
||||||
|
register_command_handlers(control_client, CommandRouter(config, db, queue))
|
||||||
|
return App(config=config, db=db, lock=lock, queue=queue, source=source, clients=clients)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_service(config: Config) -> None:
|
||||||
|
app = await build_app(config, control=True)
|
||||||
|
queue = app.queue
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(sig, stop_event.set)
|
||||||
|
except NotImplementedError: # pragma: no cover - Windows
|
||||||
|
pass
|
||||||
|
|
||||||
|
from models import ACTIVE_TASK_STATUSES
|
||||||
|
|
||||||
|
desired = queue.desired_state()
|
||||||
|
if desired != "paused" and (config.auto_start or desired == "running"):
|
||||||
|
if config.process_mode != "new" and app.source.entity is not None:
|
||||||
|
try:
|
||||||
|
result = await queue.sync()
|
||||||
|
log.info("Startup sync: %s", result.summary().replace("\n", "; "))
|
||||||
|
except BotError as exc:
|
||||||
|
log.error("Startup sync failed: %s", exc)
|
||||||
|
queue.start()
|
||||||
|
booted = queue.boot() # also resumes RUNNING/QUEUED /upload tasks from the database
|
||||||
|
active_tasks = app.db.tasks_with_status(*ACTIVE_TASK_STATUSES)
|
||||||
|
if booted == "paused":
|
||||||
|
state_text = "queue is paused; send /resume to continue"
|
||||||
|
elif booted == "running":
|
||||||
|
state_text = "processing resumed automatically"
|
||||||
|
else:
|
||||||
|
state_text = "send /upload to process a message range, or /sync then /run for the whole channel"
|
||||||
|
if active_tasks:
|
||||||
|
state_text += "; upload tasks: " + ", ".join(f"#{t.id} {t.status.value}" for t in active_tasks)
|
||||||
|
if queue.reporter:
|
||||||
|
await queue.reporter.notify(f"Bot online ({app.db.describe()}); {state_text}.")
|
||||||
|
|
||||||
|
waiters = [asyncio.ensure_future(stop_event.wait())]
|
||||||
|
waiters += [asyncio.ensure_future(client.disconnected) for client in app.clients]
|
||||||
|
await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
log.info("Shutting down: checkpointing current work")
|
||||||
|
# Heroku sends SIGTERM and allows 30 seconds before SIGKILL.
|
||||||
|
await queue.shutdown(timeout=20)
|
||||||
|
for waiter in waiters:
|
||||||
|
waiter.cancel()
|
||||||
|
await app.close()
|
||||||
|
log.info("Shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_headless(config: Config, *, sync_only: bool = False, rescan: bool = False) -> None:
|
||||||
|
app = await build_app(config, control=False)
|
||||||
|
try:
|
||||||
|
if app.source.entity is not None:
|
||||||
|
result = await app.queue.sync(rescan=rescan)
|
||||||
|
print(result.summary())
|
||||||
|
if not sync_only:
|
||||||
|
app.queue.listening = False
|
||||||
|
app.queue.start()
|
||||||
|
await app.queue.wait()
|
||||||
|
finally:
|
||||||
|
await app.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def login(config: Config, *, session_string: bool) -> None:
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.sessions import StringSession
|
||||||
|
|
||||||
|
if session_string:
|
||||||
|
client = TelegramClient(StringSession(), config.api_id, config.api_hash)
|
||||||
|
else:
|
||||||
|
from telegram_client import build_client
|
||||||
|
|
||||||
|
client = build_client(config, "user")
|
||||||
|
# Telethon asks for the phone number, the login code and (only if enabled) the
|
||||||
|
# 2FA password interactively; none of them is stored by this project.
|
||||||
|
await client.start()
|
||||||
|
me = await client.get_me()
|
||||||
|
print(f"Logged in as user id {me.id}.")
|
||||||
|
if session_string:
|
||||||
|
path = config.session_dir / "session_string.txt"
|
||||||
|
config.session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w") as fh:
|
||||||
|
fh.write(client.session.save())
|
||||||
|
print(
|
||||||
|
f"Session string written to {path} (permissions 600). It grants full access to your Telegram "
|
||||||
|
"account: never share or commit it.\nSet it on Heroku with:\n"
|
||||||
|
f' heroku config:set TELEGRAM_SESSION_STRING="$(cat {path})" --app YOUR_APP_NAME\n'
|
||||||
|
"then delete the file."
|
||||||
|
)
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def drive_init(config: Config, name: str) -> None:
|
||||||
|
from drive_client import DriveClient
|
||||||
|
|
||||||
|
drive = DriveClient.from_config(config)
|
||||||
|
folder = drive.create_folder("root", name)
|
||||||
|
print(f"Created Drive folder {name!r}. Set GOOGLE_DRIVE_ROOT_FOLDER_ID={folder.id}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_status(config: Config) -> None:
|
||||||
|
db = Database.from_config(config)
|
||||||
|
print(f"Database: {db.describe()}")
|
||||||
|
for state, count in db.counts().items():
|
||||||
|
print(f" {state:<12} {count}")
|
||||||
|
paused = db.get_kv("queue_paused", "0") == "1"
|
||||||
|
print(f"Queue: {'paused' if paused else ('running' if db.get_kv('queue_global_run', '0') == '1' else 'stopped')}")
|
||||||
|
for task in db.recent_tasks(5):
|
||||||
|
print(f" task #{task.id} {task.status.value} range {task.range_text} uploaded {task.uploaded_count}")
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Telegram channel -> Google Drive organizer")
|
||||||
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
sub.add_parser("run", help="run the bot worker (default)")
|
||||||
|
sub.add_parser("process", help="headless: sync and process the queue once, then exit")
|
||||||
|
p_sync = sub.add_parser("sync", help="discover channel videos and exit")
|
||||||
|
p_sync.add_argument("--rescan", action="store_true", help="scan again from START_MESSAGE_ID")
|
||||||
|
sub.add_parser("status", help="print queue counts")
|
||||||
|
sub.add_parser("check-config", help="validate configuration and exit")
|
||||||
|
p_login = sub.add_parser("login", help="log in the Telegram user account (user mode)")
|
||||||
|
p_login.add_argument("--session-string", action="store_true", help="create a TELEGRAM_SESSION_STRING for Heroku")
|
||||||
|
p_auth = sub.add_parser("drive-auth", help="authorize Google Drive (OAuth) and save the token")
|
||||||
|
p_auth.add_argument("--no-browser", action="store_true")
|
||||||
|
p_init = sub.add_parser("drive-init", help="create a Drive root folder owned by this app")
|
||||||
|
p_init.add_argument("--name", required=True)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
command = args.command or "run"
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = Config.from_env()
|
||||||
|
except ConfigError as exc:
|
||||||
|
print(exc, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
setup_logging(config.log_level, log_dir=None if config.is_heroku else config.log_dir, secrets=config.secrets())
|
||||||
|
|
||||||
|
try:
|
||||||
|
if command in ("run", "process", "sync", "check-config"):
|
||||||
|
config.validate("telegram", "drive", "database")
|
||||||
|
if command == "check-config":
|
||||||
|
print("Configuration OK.")
|
||||||
|
elif command == "run":
|
||||||
|
asyncio.run(run_service(config))
|
||||||
|
elif command == "process":
|
||||||
|
asyncio.run(run_headless(config))
|
||||||
|
elif command == "sync":
|
||||||
|
asyncio.run(run_headless(config, sync_only=True, rescan=args.rescan))
|
||||||
|
elif command == "status":
|
||||||
|
config.validate("database")
|
||||||
|
print_status(config)
|
||||||
|
elif command == "login":
|
||||||
|
if not config.api_id or not config.api_hash:
|
||||||
|
raise ConfigError(["TELEGRAM_API_ID and TELEGRAM_API_HASH are required for login"])
|
||||||
|
asyncio.run(login(config, session_string=args.session_string))
|
||||||
|
elif command == "drive-auth":
|
||||||
|
from drive_client import run_oauth_flow
|
||||||
|
|
||||||
|
path = run_oauth_flow(config, open_browser=not args.no_browser)
|
||||||
|
print(
|
||||||
|
f"Token saved to {path} (permissions 600).\nFor Heroku run:\n"
|
||||||
|
f' heroku config:set GOOGLE_OAUTH_TOKEN_JSON="$(cat {path})" --app YOUR_APP_NAME'
|
||||||
|
)
|
||||||
|
elif command == "drive-init":
|
||||||
|
config.validate("drive")
|
||||||
|
drive_init(config, args.name)
|
||||||
|
except ConfigError as exc:
|
||||||
|
log.error("%s", exc)
|
||||||
|
return 2
|
||||||
|
except InstanceLockError as exc:
|
||||||
|
log.error("%s", exc)
|
||||||
|
return 3
|
||||||
|
except BotError as exc:
|
||||||
|
log.error("%s", exc.public)
|
||||||
|
log.debug("details: %s", exc)
|
||||||
|
return 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Shared data models and the processing state machine."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field, fields
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class State(str, Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
DOWNLOADING = "DOWNLOADING"
|
||||||
|
DOWNLOADED = "DOWNLOADED"
|
||||||
|
UPLOADING = "UPLOADING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
RETRY_WAIT = "RETRY_WAIT"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
SKIPPED = "SKIPPED"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
# Allowed state changes. Anything else is a programming error and is rejected by the
|
||||||
|
# database layer, so a bug cannot silently mark an item COMPLETED.
|
||||||
|
TRANSITIONS: dict[State, frozenset[State]] = {
|
||||||
|
State.PENDING: frozenset(
|
||||||
|
{State.DOWNLOADING, State.DOWNLOADED, State.COMPLETED, State.SKIPPED, State.RETRY_WAIT, State.FAILED}
|
||||||
|
),
|
||||||
|
State.DOWNLOADING: frozenset({State.DOWNLOADED, State.PENDING, State.RETRY_WAIT, State.FAILED}),
|
||||||
|
State.DOWNLOADED: frozenset(
|
||||||
|
{State.UPLOADING, State.PENDING, State.COMPLETED, State.SKIPPED, State.RETRY_WAIT, State.FAILED}
|
||||||
|
),
|
||||||
|
State.UPLOADING: frozenset(
|
||||||
|
{State.COMPLETED, State.DOWNLOADED, State.PENDING, State.SKIPPED, State.RETRY_WAIT, State.FAILED}
|
||||||
|
),
|
||||||
|
State.RETRY_WAIT: frozenset({State.PENDING, State.DOWNLOADED, State.FAILED, State.SKIPPED}),
|
||||||
|
State.FAILED: frozenset({State.PENDING, State.DOWNLOADED}),
|
||||||
|
State.SKIPPED: frozenset({State.PENDING}),
|
||||||
|
State.COMPLETED: frozenset(),
|
||||||
|
}
|
||||||
|
|
||||||
|
IN_PROGRESS_STATES = frozenset({State.DOWNLOADING, State.UPLOADING})
|
||||||
|
|
||||||
|
|
||||||
|
def can_transition(current: State, new: State) -> bool:
|
||||||
|
return new in TRANSITIONS[current]
|
||||||
|
|
||||||
|
|
||||||
|
def source_key(channel_id: int, message_id: int) -> str:
|
||||||
|
"""Unique identity of a Telegram video, stored on the Drive file as appProperties."""
|
||||||
|
return f"{channel_id}:{message_id}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedCaption:
|
||||||
|
raw: str
|
||||||
|
indexes: list[str] = field(default_factory=list)
|
||||||
|
title: str | None = None
|
||||||
|
topic: str | None = None
|
||||||
|
batch: str | None = None
|
||||||
|
extracted_by: str | None = None
|
||||||
|
extra: dict[str, str] = field(default_factory=dict)
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
missing: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MediaInfo:
|
||||||
|
kind: str # "video" or "document"
|
||||||
|
size: int | None
|
||||||
|
mime_type: str | None
|
||||||
|
file_name: str | None
|
||||||
|
media_id: int | None
|
||||||
|
duration: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SourceMessage:
|
||||||
|
channel_id: int
|
||||||
|
message_id: int
|
||||||
|
caption: str
|
||||||
|
media: MediaInfo | None
|
||||||
|
date: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocalFile:
|
||||||
|
path: Path
|
||||||
|
size: int
|
||||||
|
md5: str
|
||||||
|
sha256: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DriveFile:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
size: int | None = None
|
||||||
|
md5: str | None = None
|
||||||
|
parents: tuple[str, ...] = ()
|
||||||
|
mime_type: str | None = None
|
||||||
|
trashed: bool = False
|
||||||
|
app_properties: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api(cls, data: dict[str, Any]) -> "DriveFile":
|
||||||
|
size = data.get("size")
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
name=data.get("name", ""),
|
||||||
|
size=int(size) if size is not None else None,
|
||||||
|
md5=data.get("md5Checksum"),
|
||||||
|
parents=tuple(data.get("parents") or ()),
|
||||||
|
mime_type=data.get("mimeType"),
|
||||||
|
trashed=bool(data.get("trashed", False)),
|
||||||
|
app_properties=dict(data.get("appProperties") or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoRecord:
|
||||||
|
channel_id: int
|
||||||
|
message_id: int
|
||||||
|
state: State
|
||||||
|
caption: str | None = None
|
||||||
|
indexes: list[str] = field(default_factory=list)
|
||||||
|
title: str | None = None
|
||||||
|
topic: str | None = None
|
||||||
|
batch: str | None = None
|
||||||
|
extracted_by: str | None = None
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
media_kind: str | None = None
|
||||||
|
media_id: int | None = None
|
||||||
|
mime_type: str | None = None
|
||||||
|
original_filename: str | None = None
|
||||||
|
expected_size: int | None = None
|
||||||
|
message_date: str | None = None
|
||||||
|
sequence_no: int | None = None
|
||||||
|
local_path: str | None = None
|
||||||
|
local_size: int | None = None
|
||||||
|
sha256: str | None = None
|
||||||
|
md5: str | None = None
|
||||||
|
final_name: str | None = None
|
||||||
|
drive_folder_id: str | None = None
|
||||||
|
drive_file_id: str | None = None
|
||||||
|
upload_session_uri: str | None = None
|
||||||
|
upload_session_created_at: str | None = None
|
||||||
|
download_status: str | None = None
|
||||||
|
upload_status: str | None = None
|
||||||
|
attempts: int = 0
|
||||||
|
last_error: str | None = None
|
||||||
|
error_kind: str | None = None
|
||||||
|
next_retry_at: str | None = None
|
||||||
|
skip_reason: str | None = None
|
||||||
|
discovered_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
download_started_at: str | None = None
|
||||||
|
downloaded_at: str | None = None
|
||||||
|
upload_started_at: str | None = None
|
||||||
|
completed_at: str | None = None
|
||||||
|
task_id: int | None = None
|
||||||
|
|
||||||
|
JSON_FIELDS = ("indexes", "warnings")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> tuple[int, int]:
|
||||||
|
return (self.channel_id, self.message_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_key(self) -> str:
|
||||||
|
return source_key(self.channel_id, self.message_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def column_names(cls) -> list[str]:
|
||||||
|
return [f.name for f in fields(cls)]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Any) -> "VideoRecord":
|
||||||
|
data = {name: row[name] for name in cls.column_names()}
|
||||||
|
for name in cls.JSON_FIELDS:
|
||||||
|
data[name] = json.loads(data[name]) if data[name] else []
|
||||||
|
data["state"] = State(data["state"])
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# -- interactive /upload tasks ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatus(str, Enum):
|
||||||
|
AWAITING_START = "AWAITING_START"
|
||||||
|
AWAITING_END = "AWAITING_END"
|
||||||
|
AWAITING_CONFIRMATION = "AWAITING_CONFIRMATION"
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
PAUSED = "PAUSED"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
SETUP_TASK_STATUSES = frozenset({TaskStatus.AWAITING_START, TaskStatus.AWAITING_END, TaskStatus.AWAITING_CONFIRMATION})
|
||||||
|
ACTIVE_TASK_STATUSES = frozenset({TaskStatus.QUEUED, TaskStatus.RUNNING, TaskStatus.PAUSED})
|
||||||
|
FINAL_TASK_STATUSES = frozenset({TaskStatus.COMPLETED, TaskStatus.CANCELLED, TaskStatus.FAILED})
|
||||||
|
|
||||||
|
# Per-message outcome inside a task (task_items.result).
|
||||||
|
TASK_ITEM_RESULTS = ("pending", "uploaded", "duplicate", "skipped", "non_video", "missing", "failed", "cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UploadTask:
|
||||||
|
id: int
|
||||||
|
admin_user_id: int
|
||||||
|
status: TaskStatus
|
||||||
|
channel_ref: str | None = None
|
||||||
|
channel_id: int | None = None
|
||||||
|
channel_title: str | None = None
|
||||||
|
start_message_id: int | None = None
|
||||||
|
end_message_id: int | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
confirmed_at: str | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
scanned_at: str | None = None
|
||||||
|
completed_at: str | None = None
|
||||||
|
current_message_id: int | None = None
|
||||||
|
examined_count: int = 0
|
||||||
|
eligible_count: int = 0
|
||||||
|
uploaded_count: int = 0
|
||||||
|
duplicate_count: int = 0
|
||||||
|
non_video_count: int = 0
|
||||||
|
skipped_count: int = 0
|
||||||
|
missing_count: int = 0
|
||||||
|
failed_count: int = 0
|
||||||
|
deleted_files_count: int = 0
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def column_names(cls) -> list[str]:
|
||||||
|
return [f.name for f in fields(cls)]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Any) -> "UploadTask":
|
||||||
|
data = {name: row[name] for name in cls.column_names()}
|
||||||
|
data["status"] = TaskStatus(data["status"])
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def range_text(self) -> str:
|
||||||
|
if self.start_message_id is None:
|
||||||
|
return "-"
|
||||||
|
end = self.end_message_id if self.end_message_id is not None else "?"
|
||||||
|
return f"{self.start_message_id}–{end}"
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Filename and folder-name rules: sanitising, extension detection, collisions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from errors import PermanentError
|
||||||
|
|
||||||
|
VIDEO_EXTENSIONS = frozenset(
|
||||||
|
{".mp4", ".m4v", ".mov", ".mkv", ".webm", ".avi", ".flv", ".wmv", ".ts", ".m2ts", ".mts", ".3gp", ".mpg", ".mpeg", ".ogv"}
|
||||||
|
)
|
||||||
|
# Extensions that share a container format; a title saying ".mov" for an MP4 file is fine.
|
||||||
|
_FAMILIES = (
|
||||||
|
frozenset({".mp4", ".m4v", ".mov", ".3gp"}),
|
||||||
|
frozenset({".mkv", ".webm"}),
|
||||||
|
frozenset({".ts", ".m2ts", ".mts"}),
|
||||||
|
frozenset({".mpg", ".mpeg"}),
|
||||||
|
)
|
||||||
|
MIME_TO_EXTENSION = {
|
||||||
|
"video/mp4": ".mp4",
|
||||||
|
"video/x-m4v": ".m4v",
|
||||||
|
"video/quicktime": ".mov",
|
||||||
|
"video/x-matroska": ".mkv",
|
||||||
|
"video/webm": ".webm",
|
||||||
|
"video/x-msvideo": ".avi",
|
||||||
|
"video/x-flv": ".flv",
|
||||||
|
"video/x-ms-wmv": ".wmv",
|
||||||
|
"video/mp2t": ".ts",
|
||||||
|
"video/3gpp": ".3gp",
|
||||||
|
"video/mpeg": ".mpg",
|
||||||
|
"video/ogg": ".ogv",
|
||||||
|
}
|
||||||
|
EXTENSION_TO_MIME = {ext: mime for mime, ext in MIME_TO_EXTENSION.items()} | {
|
||||||
|
".mpeg": "video/mpeg",
|
||||||
|
".m2ts": "video/mp2t",
|
||||||
|
".mts": "video/mp2t",
|
||||||
|
}
|
||||||
|
MAX_NAME_LENGTH = 200
|
||||||
|
_ALWAYS_REPLACE = re.compile(r"[/\\]")
|
||||||
|
_STRICT_REPLACE = re.compile(r'[<>:"|?*]')
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_key(text: str) -> str:
|
||||||
|
"""Comparison key for folder names: NFC, collapsed whitespace, case-insensitive."""
|
||||||
|
return " ".join(unicodedata.normalize("NFC", text).split()).casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def clean_component(text: str | None, *, strict: bool = False) -> str:
|
||||||
|
"""Make a caption value safe as a Drive file/folder name while keeping Unicode.
|
||||||
|
|
||||||
|
Control characters and path separators are replaced; letters, bullets, dots,
|
||||||
|
parentheses and other punctuation are kept. ``strict`` additionally replaces
|
||||||
|
characters Windows cannot store (useful with Google Drive for desktop).
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = unicodedata.normalize("NFC", text)
|
||||||
|
text = "".join(" " if unicodedata.category(ch) == "Cc" else ch for ch in text)
|
||||||
|
text = _ALWAYS_REPLACE.sub("_", text)
|
||||||
|
if strict:
|
||||||
|
text = _STRICT_REPLACE.sub("_", text).rstrip(". ")
|
||||||
|
text = " ".join(text.split())
|
||||||
|
return truncate_name(text)
|
||||||
|
|
||||||
|
|
||||||
|
def split_video_extension(name: str) -> tuple[str, str]:
|
||||||
|
"""Split off a *video* extension only, so 'Lecture 1.5' keeps its '.5'."""
|
||||||
|
stem, ext = os.path.splitext(name)
|
||||||
|
if ext.lower() in VIDEO_EXTENSIONS and stem:
|
||||||
|
return stem, ext
|
||||||
|
return name, ""
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_name(name: str, limit: int = MAX_NAME_LENGTH) -> str:
|
||||||
|
if len(name) <= limit:
|
||||||
|
return name
|
||||||
|
stem, ext = split_video_extension(name)
|
||||||
|
return stem[: limit - len(ext)].rstrip() + ext
|
||||||
|
|
||||||
|
|
||||||
|
def same_family(a: str, b: str) -> bool:
|
||||||
|
a, b = a.lower(), b.lower()
|
||||||
|
if a == b:
|
||||||
|
return True
|
||||||
|
return any(a in family and b in family for family in _FAMILIES)
|
||||||
|
|
||||||
|
|
||||||
|
def extension_from_mime(mime_type: str | None) -> str | None:
|
||||||
|
return MIME_TO_EXTENSION.get((mime_type or "").lower())
|
||||||
|
|
||||||
|
|
||||||
|
def mime_for_name(name: str, fallback: str | None = None) -> str:
|
||||||
|
_, ext = split_video_extension(name)
|
||||||
|
return EXTENSION_TO_MIME.get(ext.lower()) or fallback or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_video_extension(path: Path) -> str | None:
|
||||||
|
"""Identify the container from magic bytes; ``None`` if unknown."""
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
head = fh.read(4096)
|
||||||
|
if len(head) >= 12 and head[4:8] == b"ftyp":
|
||||||
|
brand = head[8:12]
|
||||||
|
if brand == b"qt ":
|
||||||
|
return ".mov"
|
||||||
|
if brand.startswith(b"3g"):
|
||||||
|
return ".3gp"
|
||||||
|
if brand.startswith(b"M4V"):
|
||||||
|
return ".m4v"
|
||||||
|
return ".mp4"
|
||||||
|
if head.startswith(b"\x1a\x45\xdf\xa3"):
|
||||||
|
return ".webm" if b"webm" in head[:64] else ".mkv"
|
||||||
|
if head[:4] == b"RIFF" and head[8:12] == b"AVI ":
|
||||||
|
return ".avi"
|
||||||
|
if head[:3] == b"FLV":
|
||||||
|
return ".flv"
|
||||||
|
if head[:8] == b"\x30\x26\xb2\x75\x8e\x66\xcf\x11":
|
||||||
|
return ".wmv"
|
||||||
|
if head[:4] in (b"\x00\x00\x01\xba", b"\x00\x00\x01\xb3"):
|
||||||
|
return ".mpg"
|
||||||
|
if head[:4] == b"OggS":
|
||||||
|
return ".ogv"
|
||||||
|
if len(head) > 376 and head[0] == 0x47 and head[188] == 0x47 and head[376] == 0x47:
|
||||||
|
return ".ts"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_filename(
|
||||||
|
title: str | None,
|
||||||
|
*,
|
||||||
|
message_id: int,
|
||||||
|
original_filename: str | None,
|
||||||
|
mime_type: str | None,
|
||||||
|
detected_ext: str | None,
|
||||||
|
missing_title_policy: str = "fallback",
|
||||||
|
mismatch_policy: str = "keep",
|
||||||
|
strict: bool = False,
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
"""Return the Drive filename for a video plus any warnings worth recording.
|
||||||
|
|
||||||
|
The Title field is used verbatim (sanitised). The extension is only added when
|
||||||
|
the title has none; a title whose extension disagrees with the real container
|
||||||
|
is kept as-is unless EXTENSION_MISMATCH_POLICY=append.
|
||||||
|
"""
|
||||||
|
warnings: list[str] = []
|
||||||
|
name = clean_component(title, strict=strict)
|
||||||
|
if not name:
|
||||||
|
if missing_title_policy == "fail":
|
||||||
|
raise PermanentError(
|
||||||
|
f"message {message_id} has no usable Title", public="Caption has no usable Title (MISSING_TITLE_POLICY=fail)"
|
||||||
|
)
|
||||||
|
name = clean_component(original_filename, strict=strict)
|
||||||
|
if name:
|
||||||
|
warnings.append("Title missing; used the Telegram file name")
|
||||||
|
else:
|
||||||
|
name = f"telegram_{message_id}"
|
||||||
|
warnings.append("Title missing; used a generated name")
|
||||||
|
|
||||||
|
_, ext = split_video_extension(name)
|
||||||
|
_, original_ext = split_video_extension(original_filename or "")
|
||||||
|
actual = detected_ext or extension_from_mime(mime_type) or (original_ext.lower() or None)
|
||||||
|
if not ext:
|
||||||
|
if actual:
|
||||||
|
name = truncate_name(name, MAX_NAME_LENGTH - len(actual)) + actual
|
||||||
|
else:
|
||||||
|
warnings.append("could not determine the file extension")
|
||||||
|
elif detected_ext and not same_family(ext, detected_ext):
|
||||||
|
warnings.append(f"title extension {ext} does not match detected container {detected_ext}")
|
||||||
|
if mismatch_policy == "append":
|
||||||
|
name = truncate_name(name, MAX_NAME_LENGTH - len(detected_ext)) + detected_ext
|
||||||
|
return name, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def add_suffix(name: str, suffix: str) -> str:
|
||||||
|
"""'Class 01.mp4' + '(2)' -> 'Class 01 (2).mp4'."""
|
||||||
|
stem, ext = split_video_extension(name)
|
||||||
|
if not ext:
|
||||||
|
stem, ext = os.path.splitext(name)
|
||||||
|
if len(ext) > 6 or not stem:
|
||||||
|
stem, ext = name, ""
|
||||||
|
return truncate_name(f"{stem} {suffix}{ext}")
|
||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
"""Progress tracking and throttled Telegram progress messages.
|
||||||
|
|
||||||
|
Transfer callbacks only update in-memory counters (cheap, thread-safe enough for
|
||||||
|
plain attribute writes). A background task renders the board every
|
||||||
|
PROGRESS_INTERVAL seconds and edits one Telegram message per chat, so progress never
|
||||||
|
floods the chat with new messages.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Protocol
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlReply(str):
|
||||||
|
"""A message whose text is Telegram HTML (dynamic parts already escaped)."""
|
||||||
|
|
||||||
|
|
||||||
|
def esc(value) -> str:
|
||||||
|
return html.escape(str(value), quote=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Notifier(Protocol):
|
||||||
|
async def send(self, chat_id: int, text: str) -> int | None: ...
|
||||||
|
|
||||||
|
async def edit(self, chat_id: int, message_id: int, text: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class LogNotifier:
|
||||||
|
"""Notifier used when no control bot is configured (headless mode)."""
|
||||||
|
|
||||||
|
async def send(self, chat_id: int, text: str) -> int | None:
|
||||||
|
log.info("%s", text.replace("\n", " | "))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def edit(self, chat_id: int, message_id: int, text: str) -> None:
|
||||||
|
log.info("%s", text.replace("\n", " | "))
|
||||||
|
|
||||||
|
|
||||||
|
def format_bytes(value: float | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "?"
|
||||||
|
for unit in ("B", "KB", "MB", "GB"):
|
||||||
|
if abs(value) < 1024:
|
||||||
|
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
||||||
|
value /= 1024
|
||||||
|
return f"{value:.2f} TB"
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float | None) -> str:
|
||||||
|
if seconds is None:
|
||||||
|
return "?"
|
||||||
|
seconds = int(round(seconds))
|
||||||
|
hours, rem = divmod(seconds, 3600)
|
||||||
|
minutes, secs = divmod(rem, 60)
|
||||||
|
if hours:
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
if minutes:
|
||||||
|
return f"{minutes}m {secs}s"
|
||||||
|
return f"{secs}s"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TransferProgress:
|
||||||
|
total: int | None
|
||||||
|
done: int = 0
|
||||||
|
clock: Callable[[], float] = time.monotonic
|
||||||
|
window: float = 30.0
|
||||||
|
_samples: deque = field(default_factory=deque)
|
||||||
|
|
||||||
|
def update(self, done: int, total: int | None = None) -> None:
|
||||||
|
if total is not None:
|
||||||
|
self.total = total
|
||||||
|
self.done = done
|
||||||
|
now = self.clock()
|
||||||
|
self._samples.append((now, done))
|
||||||
|
while len(self._samples) > 2 and now - self._samples[0][0] > self.window:
|
||||||
|
self._samples.popleft()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def percent(self) -> float | None:
|
||||||
|
if not self.total:
|
||||||
|
return None
|
||||||
|
return min(100.0, 100.0 * self.done / self.total)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def speed(self) -> float | None:
|
||||||
|
if len(self._samples) < 2:
|
||||||
|
return None
|
||||||
|
(t0, b0), (t1, b1) = self._samples[0], self._samples[-1]
|
||||||
|
if t1 - t0 < 1.0 or b1 < b0:
|
||||||
|
return None
|
||||||
|
return (b1 - b0) / (t1 - t0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def eta(self) -> float | None:
|
||||||
|
speed = self.speed
|
||||||
|
if not speed or not self.total:
|
||||||
|
return None
|
||||||
|
return max(0.0, (self.total - self.done) / speed)
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
pct = self.percent
|
||||||
|
parts = [f"{pct:.0f}%" if pct is not None else format_bytes(self.done)]
|
||||||
|
parts.append(f"({format_bytes(self.done)} / {format_bytes(self.total)})")
|
||||||
|
text = " ".join(parts)
|
||||||
|
if self.speed:
|
||||||
|
text += f" · {format_bytes(self.speed)}/s"
|
||||||
|
if self.eta is not None and self.done < (self.total or 0):
|
||||||
|
text += f" · ETA {format_duration(self.eta)}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProgressBoard:
|
||||||
|
position: int = 0
|
||||||
|
total: int = 0
|
||||||
|
topic: str = ""
|
||||||
|
title: str = ""
|
||||||
|
status: str = "Idle"
|
||||||
|
download: TransferProgress | None = None
|
||||||
|
upload: TransferProgress | None = None
|
||||||
|
clock: Callable[[], float] = time.monotonic
|
||||||
|
# Set while an /upload task runs: id, start, end, current, completed, skipped, failed
|
||||||
|
task: dict | None = None
|
||||||
|
|
||||||
|
def begin_item(self, position: int, total: int, topic: str, title: str) -> None:
|
||||||
|
self.position, self.total, self.topic, self.title = position, total, topic, title
|
||||||
|
self.status = "Preparing"
|
||||||
|
self.download = None
|
||||||
|
self.upload = None
|
||||||
|
|
||||||
|
def start_download(self, total: int | None) -> None:
|
||||||
|
self.status = "Downloading"
|
||||||
|
self.download = TransferProgress(total, clock=self.clock)
|
||||||
|
|
||||||
|
def start_upload(self, total: int) -> None:
|
||||||
|
self.status = "Uploading"
|
||||||
|
self.upload = TransferProgress(total, clock=self.clock)
|
||||||
|
|
||||||
|
_TASK_STATUS = {
|
||||||
|
"Downloading": "Downloading from Telegram",
|
||||||
|
"Uploading": "Uploading to Google Drive",
|
||||||
|
"Verifying": "Verifying upload in Google Drive",
|
||||||
|
}
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
if self.task is not None:
|
||||||
|
return self._render_task()
|
||||||
|
if self.status == "Idle":
|
||||||
|
return "Queue idle"
|
||||||
|
lines = [
|
||||||
|
f"Processing video {self.position} of {self.total}",
|
||||||
|
f"Topic: {self.topic}",
|
||||||
|
f"Title: {self.title}",
|
||||||
|
f"Status: {self.status}",
|
||||||
|
f"Download: {self.download.describe() if self.download else 'Waiting'}",
|
||||||
|
f"Upload: {self.upload.describe() if self.upload else 'Waiting'}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _render_task(self) -> str:
|
||||||
|
t = self.task or {}
|
||||||
|
status = self._TASK_STATUS.get(self.status, self.status)
|
||||||
|
lines = [
|
||||||
|
"🚀 <b>Upload Task Running</b>",
|
||||||
|
"",
|
||||||
|
f"Task: #{esc(t.get('id'))}",
|
||||||
|
f"Range: {esc(t.get('start'))}–{esc(t.get('end'))}",
|
||||||
|
f"Current Message: {esc(t.get('current') or '-')}",
|
||||||
|
f"Current Video: {esc(self.title or '-')}",
|
||||||
|
f"Topic: {esc(self.topic or '-')}",
|
||||||
|
f"Status: {esc(status)}",
|
||||||
|
]
|
||||||
|
if self.download:
|
||||||
|
lines.append(f"Download: {esc(self.download.describe())}")
|
||||||
|
if self.upload:
|
||||||
|
lines.append(f"Upload: {esc(self.upload.describe())}")
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"Completed: {t.get('completed', 0)}",
|
||||||
|
f"Skipped: {t.get('skipped', 0)}",
|
||||||
|
f"Failed: {t.get('failed', 0)}",
|
||||||
|
]
|
||||||
|
return HtmlReply("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressReporter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
notifier: Notifier,
|
||||||
|
chat_ids: tuple[int, ...] | list[int],
|
||||||
|
interval: float,
|
||||||
|
*,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
):
|
||||||
|
self.notifier = notifier
|
||||||
|
self.chat_ids = tuple(chat_ids) or (0,)
|
||||||
|
self.interval = interval
|
||||||
|
self.board = ProgressBoard(clock=clock)
|
||||||
|
self._message_ids: dict[int, int] = {}
|
||||||
|
self._last_text: str | None = None
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._message_ids.clear()
|
||||||
|
self._last_text = None
|
||||||
|
if self._task is None or self._task.done():
|
||||||
|
self._task = asyncio.create_task(self._loop(), name="progress-reporter")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
await self.refresh()
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(self.interval)
|
||||||
|
await self.refresh()
|
||||||
|
|
||||||
|
async def refresh(self) -> None:
|
||||||
|
text = self.board.render()
|
||||||
|
if text == self._last_text:
|
||||||
|
return
|
||||||
|
self._last_text = text
|
||||||
|
for chat_id in self.chat_ids:
|
||||||
|
try:
|
||||||
|
message_id = self._message_ids.get(chat_id)
|
||||||
|
if message_id is None:
|
||||||
|
sent = await self.notifier.send(chat_id, text)
|
||||||
|
if sent is not None:
|
||||||
|
self._message_ids[chat_id] = sent
|
||||||
|
else:
|
||||||
|
await self.notifier.edit(chat_id, message_id, text)
|
||||||
|
except Exception as exc: # progress must never break processing
|
||||||
|
log.debug("Progress update to %s failed: %s", chat_id, exc)
|
||||||
|
|
||||||
|
def new_message(self) -> None:
|
||||||
|
"""Start a fresh progress message (e.g. for each /upload task)."""
|
||||||
|
self._message_ids.clear()
|
||||||
|
self._last_text = None
|
||||||
|
|
||||||
|
async def notify(self, text: str, *, extra_chat_ids: tuple[int, ...] = ()) -> None:
|
||||||
|
for chat_id in dict.fromkeys((*self.chat_ids, *extra_chat_ids)):
|
||||||
|
try:
|
||||||
|
await self.notifier.send(chat_id, text)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Notification to %s failed: %s", chat_id, exc)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -q
|
||||||
+1003
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==9.1.1
|
||||||
|
cryptography>=42
|
||||||
|
# Optional: runs the database tests against a throwaway local PostgreSQL as well.
|
||||||
|
pgserver==0.1.4
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Runtime dependencies (pinned to the versions the test suite was run with).
|
||||||
|
Telethon==1.45.0
|
||||||
|
cryptg==0.5.0.post0
|
||||||
|
google-api-python-client==2.201.0
|
||||||
|
google-auth==2.62.0
|
||||||
|
google-auth-httplib2==0.4.4
|
||||||
|
google-auth-oauthlib==1.5.0
|
||||||
|
requests==2.34.2
|
||||||
|
python-dotenv==1.2.4
|
||||||
|
psycopg[binary]==3.3.6
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local convenience launcher: creates a virtualenv, installs dependencies, runs main.py.
|
||||||
|
# Usage: ./start.sh (runs the bot worker)
|
||||||
|
# ./start.sh login (any main.py subcommand)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
PYTHON="${PYTHON:-python3.12}"
|
||||||
|
if [ ! -d .venv ]; then
|
||||||
|
"$PYTHON" -m venv .venv
|
||||||
|
fi
|
||||||
|
.venv/bin/pip install -q -r requirements.txt
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "No .env found. Copy .env.example to .env and fill it in first." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
exec .venv/bin/python main.py "${@:-run}"
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""Telegram access through Telethon (MTProto).
|
||||||
|
|
||||||
|
Both operating modes use MTProto, not the HTTP Bot API:
|
||||||
|
|
||||||
|
* TELEGRAM_MODE=user — your own account (TELEGRAM_SESSION_STRING or a local session
|
||||||
|
file). Can read full channel history the account can see; downloads up to the
|
||||||
|
largest file size Telegram allows.
|
||||||
|
* TELEGRAM_MODE=bot — a bot (TELEGRAM_BOT_TOKEN) that is an admin of the channel.
|
||||||
|
Receives new posts live. Bots cannot list history; past posts are fetched by
|
||||||
|
probing message IDs (best effort). Because this is MTProto rather than the HTTP
|
||||||
|
Bot API, the HTTP API's 20 MB getFile limit does not apply.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import AsyncIterator
|
||||||
|
|
||||||
|
from errors import BotError, PermanentError, TransientError
|
||||||
|
from link_parser import marked_channel_id
|
||||||
|
from models import MediaInfo, SourceMessage
|
||||||
|
from naming import VIDEO_EXTENSIONS, split_video_extension
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REQUEST_SIZE = 512 * 1024 # Telegram's maximum upload.getFile chunk
|
||||||
|
ACCESS_HASH_KEY = "channel_access_hash:{}"
|
||||||
|
|
||||||
|
|
||||||
|
def classify_media(message, include_documents: bool) -> MediaInfo | None:
|
||||||
|
"""Return MediaInfo for a supported video message, else None.
|
||||||
|
|
||||||
|
Excludes photos, stickers, GIF animations, round video notes, voice/audio and
|
||||||
|
non-video documents (unless they are video files and documents are enabled).
|
||||||
|
"""
|
||||||
|
from telethon.tl import types
|
||||||
|
|
||||||
|
media = getattr(message, "media", None)
|
||||||
|
if not isinstance(media, types.MessageMediaDocument):
|
||||||
|
return None
|
||||||
|
doc = media.document
|
||||||
|
if not isinstance(doc, types.Document):
|
||||||
|
return None
|
||||||
|
video_attr = None
|
||||||
|
file_name = None
|
||||||
|
for attr in doc.attributes:
|
||||||
|
if isinstance(attr, (types.DocumentAttributeAnimated, types.DocumentAttributeSticker)):
|
||||||
|
return None
|
||||||
|
if isinstance(attr, types.DocumentAttributeVideo):
|
||||||
|
video_attr = attr
|
||||||
|
elif isinstance(attr, types.DocumentAttributeFilename):
|
||||||
|
file_name = attr.file_name
|
||||||
|
mime = (doc.mime_type or "").lower()
|
||||||
|
if video_attr is not None:
|
||||||
|
if getattr(video_attr, "round_message", False):
|
||||||
|
return None
|
||||||
|
kind = "video"
|
||||||
|
elif include_documents and (
|
||||||
|
mime.startswith("video/") or (file_name and split_video_extension(file_name)[1].lower() in VIDEO_EXTENSIONS)
|
||||||
|
):
|
||||||
|
kind = "document"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return MediaInfo(
|
||||||
|
kind=kind,
|
||||||
|
size=doc.size,
|
||||||
|
mime_type=doc.mime_type,
|
||||||
|
file_name=file_name,
|
||||||
|
media_id=doc.id,
|
||||||
|
duration=getattr(video_attr, "duration", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def to_source_message(message, channel_id: int, include_documents: bool) -> SourceMessage:
|
||||||
|
return SourceMessage(
|
||||||
|
channel_id=channel_id,
|
||||||
|
message_id=message.id,
|
||||||
|
caption=getattr(message, "message", None) or "",
|
||||||
|
media=classify_media(message, include_documents),
|
||||||
|
date=getattr(message, "date", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_telegram_error(exc: BaseException) -> BotError:
|
||||||
|
from telethon import errors
|
||||||
|
|
||||||
|
if isinstance(exc, errors.FloodWaitError):
|
||||||
|
return TransientError(
|
||||||
|
f"Telegram flood wait {exc.seconds}s", retry_after=exc.seconds, rate_limited=True,
|
||||||
|
public=f"Telegram rate limit; waiting {exc.seconds}s",
|
||||||
|
)
|
||||||
|
if isinstance(exc, (errors.ChannelPrivateError, errors.ChatAdminRequiredError, errors.ChannelInvalidError)):
|
||||||
|
return PermanentError(str(exc), public="No access to the source channel (check membership/admin rights)")
|
||||||
|
if isinstance(exc, (errors.AuthKeyError, errors.UnauthorizedError)):
|
||||||
|
return PermanentError(str(exc), public="Telegram session is no longer authorized; log in again")
|
||||||
|
if isinstance(exc, errors.RPCError):
|
||||||
|
return TransientError(f"Telegram error: {exc}", public=f"Telegram error ({type(exc).__name__})")
|
||||||
|
return TransientError(f"Telegram connection error: {exc}", public="Telegram connection problem")
|
||||||
|
|
||||||
|
|
||||||
|
def build_client(config, role: str):
|
||||||
|
"""Create a Telethon client. role: 'user' or 'bot'."""
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.sessions import StringSession
|
||||||
|
|
||||||
|
if role == "user" and config.session_string:
|
||||||
|
session = StringSession(config.session_string)
|
||||||
|
else:
|
||||||
|
config.session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.chmod(config.session_dir, 0o700)
|
||||||
|
session = str(config.session_dir / role)
|
||||||
|
return TelegramClient(
|
||||||
|
session,
|
||||||
|
config.api_id,
|
||||||
|
config.api_hash,
|
||||||
|
flood_sleep_threshold=config.flood_sleep_threshold,
|
||||||
|
connection_retries=10,
|
||||||
|
retry_delay=5,
|
||||||
|
auto_reconnect=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_channel_reference(ref: str) -> str | int:
|
||||||
|
ref = ref.strip()
|
||||||
|
if re.fullmatch(r"-?\d+", ref):
|
||||||
|
return int(ref)
|
||||||
|
private = re.match(r"(?:https?://)?t\.me/c/(\d+)", ref)
|
||||||
|
if private:
|
||||||
|
return marked_channel_id(int(private.group(1)))
|
||||||
|
public = re.match(r"(?:https?://)?t\.me/([A-Za-z0-9_]+)", ref)
|
||||||
|
if public:
|
||||||
|
return public.group(1)
|
||||||
|
return ref.lstrip("@")
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_channel(client, ref: str, *, is_bot: bool, db=None):
|
||||||
|
"""Resolve the configured channel to a Telethon entity.
|
||||||
|
|
||||||
|
The channel's access hash is cached in the database, so a bot (which cannot
|
||||||
|
list dialogs) still resolves a private channel after a restart on an ephemeral
|
||||||
|
filesystem.
|
||||||
|
"""
|
||||||
|
from telethon import utils
|
||||||
|
from telethon.tl import types
|
||||||
|
|
||||||
|
target = parse_channel_reference(ref)
|
||||||
|
try:
|
||||||
|
entity = await client.get_entity(target)
|
||||||
|
except (ValueError, TypeError) as first_error:
|
||||||
|
entity = None
|
||||||
|
if isinstance(target, int):
|
||||||
|
real_id, _ = utils.resolve_id(target)
|
||||||
|
cached = db.get_kv(ACCESS_HASH_KEY.format(real_id)) if db else None
|
||||||
|
if cached:
|
||||||
|
entity = await client.get_entity(types.InputPeerChannel(real_id, int(cached)))
|
||||||
|
elif not is_bot:
|
||||||
|
await client.get_dialogs() # fills the entity cache for chats the account is in
|
||||||
|
entity = await client.get_entity(target)
|
||||||
|
if entity is None:
|
||||||
|
raise PermanentError(
|
||||||
|
f"cannot resolve channel {ref!r}: {first_error}",
|
||||||
|
public=(
|
||||||
|
"Cannot resolve SOURCE_CHANNEL_ID. Use the @username for public channels. For a private channel "
|
||||||
|
"in bot mode, make the bot an admin and post one message in the channel so the bot learns it."
|
||||||
|
),
|
||||||
|
) from first_error
|
||||||
|
if db is not None and getattr(entity, "access_hash", None):
|
||||||
|
db.set_kv(ACCESS_HASH_KEY.format(entity.id), str(entity.access_hash))
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
class TelethonSource:
|
||||||
|
def __init__(self, client, entity, *, channel_id: int, mode: str, config, db=None):
|
||||||
|
self.client = client
|
||||||
|
self.entity = entity
|
||||||
|
self.channel_id = channel_id
|
||||||
|
self.mode = mode
|
||||||
|
self.config = config
|
||||||
|
self.db = db
|
||||||
|
self._entities: dict[int, object] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channel_title(self) -> str | None:
|
||||||
|
return getattr(self.entity, "title", None)
|
||||||
|
|
||||||
|
def convert(self, message, channel_id: int | None = None) -> SourceMessage:
|
||||||
|
return to_source_message(message, channel_id or self.channel_id, self.config.include_document_videos)
|
||||||
|
|
||||||
|
async def _entity_for(self, channel_id: int | None):
|
||||||
|
if channel_id is None or channel_id == self.channel_id:
|
||||||
|
if self.entity is not None:
|
||||||
|
return self.entity
|
||||||
|
if channel_id in self._entities:
|
||||||
|
return self._entities[channel_id]
|
||||||
|
entity = await resolve_channel(self.client, str(channel_id), is_bot=self.mode == "bot", db=self.db)
|
||||||
|
self._entities[channel_id] = entity
|
||||||
|
return entity
|
||||||
|
|
||||||
|
# -- /upload task support ----------------------------------------------------------
|
||||||
|
|
||||||
|
async def resolve_link(self, link) -> tuple[int, str | None]:
|
||||||
|
"""Resolve a MessageLink's channel through the authenticated client.
|
||||||
|
|
||||||
|
Raises PermanentError if this account/bot cannot access the channel.
|
||||||
|
"""
|
||||||
|
from telethon import utils
|
||||||
|
|
||||||
|
try:
|
||||||
|
entity = await resolve_channel(self.client, link.channel_ref, is_bot=self.mode == "bot", db=self.db)
|
||||||
|
except BotError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise PermanentError(
|
||||||
|
f"cannot resolve {link.url}: {exc}",
|
||||||
|
public="This channel is not accessible to the bot's Telegram account (join it, or make the bot an admin).",
|
||||||
|
) from exc
|
||||||
|
channel_id = utils.get_peer_id(entity)
|
||||||
|
self._entities[channel_id] = entity
|
||||||
|
return channel_id, getattr(entity, "title", None)
|
||||||
|
|
||||||
|
async def message_accessible(self, channel_id: int, message_id: int) -> bool:
|
||||||
|
entity = await self._entity_for(channel_id)
|
||||||
|
try:
|
||||||
|
message = await self.client.get_messages(entity, ids=message_id)
|
||||||
|
except Exception as exc:
|
||||||
|
raise map_telegram_error(exc) from exc
|
||||||
|
return message is not None and message.__class__.__name__ != "MessageEmpty"
|
||||||
|
|
||||||
|
async def iter_range(self, channel_id: int, start_id: int, end_id: int) -> AsyncIterator[tuple[int, SourceMessage | None]]:
|
||||||
|
"""Yield (message_id, SourceMessage or None) for every ID in [start_id, end_id], ascending.
|
||||||
|
|
||||||
|
Uses messages.getMessages / channels.getMessages by ID, which both user
|
||||||
|
accounts and bots (channel admins) may call; no history listing is needed.
|
||||||
|
"""
|
||||||
|
entity = await self._entity_for(channel_id)
|
||||||
|
current = start_id
|
||||||
|
try:
|
||||||
|
while current <= end_id:
|
||||||
|
ids = list(range(current, min(current + 100, end_id + 1)))
|
||||||
|
messages = await self.client.get_messages(entity, ids=ids)
|
||||||
|
for message_id, message in zip(ids, messages):
|
||||||
|
if message is None or message.__class__.__name__ in ("MessageEmpty", "MessageService"):
|
||||||
|
yield message_id, None
|
||||||
|
else:
|
||||||
|
yield message_id, self.convert(message, channel_id)
|
||||||
|
current = ids[-1] + 1
|
||||||
|
except (asyncio.CancelledError, GeneratorExit):
|
||||||
|
raise
|
||||||
|
except BotError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise map_telegram_error(exc) from exc
|
||||||
|
|
||||||
|
# -- discovery for the configured channel ------------------------------------------
|
||||||
|
|
||||||
|
async def iter_history(self, *, min_id: int, start_date: datetime | None) -> AsyncIterator[SourceMessage]:
|
||||||
|
try:
|
||||||
|
if self.mode == "user":
|
||||||
|
kwargs = {"reverse": True, "min_id": max(min_id - 1, 0)}
|
||||||
|
if start_date:
|
||||||
|
kwargs["offset_date"] = start_date
|
||||||
|
async for message in self.client.iter_messages(self.entity, **kwargs):
|
||||||
|
yield self.convert(message)
|
||||||
|
else:
|
||||||
|
async for message in self._probe_history(min_id, start_date):
|
||||||
|
yield self.convert(message)
|
||||||
|
except (asyncio.CancelledError, GeneratorExit):
|
||||||
|
raise
|
||||||
|
except BotError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise map_telegram_error(exc) from exc
|
||||||
|
|
||||||
|
async def _probe_history(self, start_id: int, start_date: datetime | None):
|
||||||
|
"""Bots cannot call messages.getHistory, but may fetch messages by ID."""
|
||||||
|
current, empty = max(start_id, 1), 0
|
||||||
|
end = self.config.bot_scan_end_id
|
||||||
|
while True:
|
||||||
|
ids = list(range(current, current + 100))
|
||||||
|
if end:
|
||||||
|
ids = [i for i in ids if i <= end]
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
messages = await self.client.get_messages(self.entity, ids=ids)
|
||||||
|
for message in messages:
|
||||||
|
if message is None or message.__class__.__name__ == "MessageEmpty":
|
||||||
|
empty += 1
|
||||||
|
continue
|
||||||
|
empty = 0
|
||||||
|
if start_date and message.date and message.date < start_date:
|
||||||
|
continue
|
||||||
|
yield message
|
||||||
|
if not end and empty >= self.config.bot_scan_max_empty:
|
||||||
|
return
|
||||||
|
current += 100
|
||||||
|
|
||||||
|
async def _fetch(self, message_id: int, channel_id: int | None = None):
|
||||||
|
entity = await self._entity_for(channel_id)
|
||||||
|
try:
|
||||||
|
message = await self.client.get_messages(entity, ids=message_id)
|
||||||
|
except Exception as exc:
|
||||||
|
raise map_telegram_error(exc) from exc
|
||||||
|
if message is None or getattr(message, "media", None) is None or not getattr(message, "document", None):
|
||||||
|
raise PermanentError(
|
||||||
|
f"message {message_id} no longer exists or has no media", public="Source message was deleted or has no video"
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def iter_media_chunks(
|
||||||
|
self, message_id: int, *, offset: int, expected_size: int | None, channel_id: int | None = None
|
||||||
|
) -> AsyncIterator[bytes]:
|
||||||
|
from telethon import errors
|
||||||
|
|
||||||
|
message = await self._fetch(message_id, channel_id)
|
||||||
|
position, refreshes = offset, 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async for chunk in self.client.iter_download(
|
||||||
|
message.document, offset=position, request_size=REQUEST_SIZE, file_size=message.document.size
|
||||||
|
):
|
||||||
|
position += len(chunk)
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
except errors.FileReferenceExpiredError:
|
||||||
|
refreshes += 1
|
||||||
|
if refreshes > 3:
|
||||||
|
raise TransientError("file reference kept expiring", public="Telegram file reference expired")
|
||||||
|
message = await self._fetch(message_id, channel_id)
|
||||||
|
except (asyncio.CancelledError, GeneratorExit):
|
||||||
|
raise
|
||||||
|
except BotError:
|
||||||
|
raise
|
||||||
|
except OSError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise map_telegram_error(exc) from exc
|
||||||
|
|
||||||
|
async def latest_message_id(self) -> int | None:
|
||||||
|
if self.mode != "user":
|
||||||
|
return None
|
||||||
|
messages = await self.client.get_messages(self.entity, limit=1)
|
||||||
|
return messages[0].id if messages else None
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
"""Shared fixtures and in-memory fakes for Telegram and Google Drive.
|
||||||
|
|
||||||
|
No test needs real credentials or network access. Database-backed tests run against
|
||||||
|
SQLite and, when the optional ``pgserver`` dev package is installed, a throwaway
|
||||||
|
local PostgreSQL server as well.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from database import Database
|
||||||
|
from downloader import Downloader
|
||||||
|
from drive_client import FOLDER_MIME, SOURCE_PROPERTY
|
||||||
|
from errors import Interrupted
|
||||||
|
from models import DriveFile, MediaInfo, SourceMessage
|
||||||
|
|
||||||
|
CHANNEL_ID = -1001234567890
|
||||||
|
EXAMPLE_CAPTION = """Index: 001
|
||||||
|
|
||||||
|
Index: 002
|
||||||
|
|
||||||
|
Title: Class 01 Aman sir.mp4
|
||||||
|
|
||||||
|
Topic: Misc• Grammar Practice
|
||||||
|
|
||||||
|
Batch: English ( SSC MAINS )
|
||||||
|
|
||||||
|
Extracted By: @PUTINxINDIA"""
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(tmp_path: Path, **env) -> Config:
|
||||||
|
base = {
|
||||||
|
"TELEGRAM_API_ID": "12345",
|
||||||
|
"TELEGRAM_API_HASH": "0123456789abcdef0123456789abcdef",
|
||||||
|
"TELEGRAM_BOT_TOKEN": "123456:TEST-TOKEN-VALUE",
|
||||||
|
"ADMIN_USER_IDS": "111",
|
||||||
|
"SOURCE_CHANNEL_ID": str(CHANNEL_ID),
|
||||||
|
"DATABASE_PATH": str(tmp_path / "bot.db"),
|
||||||
|
"DOWNLOAD_DIR": str(tmp_path / "downloads"),
|
||||||
|
"SESSION_DIR": str(tmp_path / "sessions"),
|
||||||
|
"LOG_DIR": str(tmp_path / "logs"),
|
||||||
|
"MIN_FREE_DISK_SPACE": "0",
|
||||||
|
"RETRY_DELAY": "0",
|
||||||
|
"PROCESSING_DELAY": "0",
|
||||||
|
"MAX_RETRIES": "3",
|
||||||
|
"UPLOAD_CHUNK_SIZE": "256KB",
|
||||||
|
}
|
||||||
|
base.update({k: str(v) for k, v in env.items()})
|
||||||
|
return Config.from_env(base)
|
||||||
|
|
||||||
|
|
||||||
|
def video_bytes(size: int, seed: int = 0) -> bytes:
|
||||||
|
"""Bytes that look like an MP4 container (ftyp box) followed by filler."""
|
||||||
|
header = b"\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2"
|
||||||
|
filler = hashlib.sha256(str(seed).encode()).digest()
|
||||||
|
body = (filler * (size // len(filler) + 1))[: size - len(header)]
|
||||||
|
return header + body
|
||||||
|
|
||||||
|
|
||||||
|
# -- PostgreSQL (optional) ---------------------------------------------------------------
|
||||||
|
|
||||||
|
_PG_SERVER = None
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_uri() -> str | None:
|
||||||
|
global _PG_SERVER
|
||||||
|
try:
|
||||||
|
import pgserver
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
if _PG_SERVER is None:
|
||||||
|
_PG_SERVER = pgserver.get_server(tempfile.mkdtemp(prefix="tdb-pg-"), cleanup_mode="stop")
|
||||||
|
return _PG_SERVER.get_uri()
|
||||||
|
|
||||||
|
|
||||||
|
_pg_counter = itertools.count()
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_pg_url() -> str:
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
uri = _pg_uri()
|
||||||
|
name = f"test_{os.getpid()}_{next(_pg_counter)}"
|
||||||
|
with psycopg.connect(uri, autocommit=True) as conn:
|
||||||
|
conn.execute(f"CREATE DATABASE {name}")
|
||||||
|
return uri.replace("/postgres?", f"/{name}?")
|
||||||
|
|
||||||
|
|
||||||
|
BACKENDS = ["sqlite"] + (["postgres"] if _pg_uri() else [])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=BACKENDS)
|
||||||
|
def backend(request):
|
||||||
|
return request.param
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_url(backend):
|
||||||
|
return _fresh_pg_url() if backend == "postgres" else None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(tmp_path, db_url):
|
||||||
|
env = {"DATABASE_URL": db_url} if db_url else {}
|
||||||
|
return make_config(tmp_path, **env)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(config):
|
||||||
|
database = Database.from_config(config)
|
||||||
|
yield database
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
|
||||||
|
# -- fakes -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSource:
|
||||||
|
CHUNK = 512 * 1024
|
||||||
|
|
||||||
|
def __init__(self, channel_id: int = CHANNEL_ID):
|
||||||
|
self.channel_id = channel_id
|
||||||
|
self.channel_title = "Test Channel"
|
||||||
|
# Other channels the fake account can access: marked id -> title, plus usernames.
|
||||||
|
self.accessible = {channel_id: self.channel_title}
|
||||||
|
self.usernames = {"test_channel": channel_id}
|
||||||
|
self.messages: dict[int, tuple[SourceMessage, bytes]] = {}
|
||||||
|
self.failures: dict[int, list] = {}
|
||||||
|
self.download_offsets: list[tuple[int, int]] = []
|
||||||
|
self.events: list[tuple[str, int]] = []
|
||||||
|
|
||||||
|
def add(self, message_id, caption, content: bytes | None = None, *, media_id=None, kind="video", mime="video/mp4", file_name=None):
|
||||||
|
content = content if content is not None else video_bytes(700_000, seed=message_id)
|
||||||
|
media = MediaInfo(kind, len(content), mime, file_name, media_id if media_id is not None else 10_000 + message_id)
|
||||||
|
msg = SourceMessage(self.channel_id, message_id, caption, media)
|
||||||
|
self.messages[message_id] = (msg, content)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def add_text(self, message_id, text):
|
||||||
|
msg = SourceMessage(self.channel_id, message_id, text, None)
|
||||||
|
self.messages[message_id] = (msg, b"")
|
||||||
|
return msg
|
||||||
|
|
||||||
|
async def iter_history(self, *, min_id, start_date):
|
||||||
|
for message_id in sorted(self.messages):
|
||||||
|
if message_id >= min_id:
|
||||||
|
yield self.messages[message_id][0]
|
||||||
|
|
||||||
|
async def resolve_link(self, link):
|
||||||
|
from errors import PermanentError
|
||||||
|
|
||||||
|
if link.kind == "private":
|
||||||
|
channel_id = int(link.channel_ref)
|
||||||
|
else:
|
||||||
|
channel_id = self.usernames.get(link.channel.lower())
|
||||||
|
if channel_id not in self.accessible:
|
||||||
|
raise PermanentError("no access", public="This channel is not accessible to the bot's Telegram account.")
|
||||||
|
return channel_id, self.accessible[channel_id]
|
||||||
|
|
||||||
|
async def message_accessible(self, channel_id, message_id):
|
||||||
|
return channel_id == self.channel_id and message_id in self.messages
|
||||||
|
|
||||||
|
async def iter_range(self, channel_id, start_id, end_id):
|
||||||
|
self.events.append(("scan", (start_id, end_id)))
|
||||||
|
for message_id in range(start_id, end_id + 1):
|
||||||
|
entry = self.messages.get(message_id) if channel_id == self.channel_id else None
|
||||||
|
yield message_id, (entry[0] if entry else None)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
async def iter_media_chunks(self, message_id, *, offset, expected_size, channel_id=None):
|
||||||
|
assert channel_id in (None, self.channel_id)
|
||||||
|
self.download_offsets.append((message_id, offset))
|
||||||
|
self.events.append(("download", message_id))
|
||||||
|
content = self.messages[message_id][1]
|
||||||
|
plan = self.failures.get(message_id) or []
|
||||||
|
failure = plan.pop(0) if plan else None
|
||||||
|
sent = 0
|
||||||
|
for start in range(offset, len(content), self.CHUNK):
|
||||||
|
if isinstance(failure, tuple) and failure[0] == "after" and sent >= failure[1]:
|
||||||
|
raise failure[2]
|
||||||
|
if isinstance(failure, BaseException):
|
||||||
|
raise failure
|
||||||
|
chunk = content[start : start + self.CHUNK]
|
||||||
|
sent += len(chunk)
|
||||||
|
yield chunk
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatedCrash(BaseException):
|
||||||
|
"""Stands in for the process being killed (not caught by `except Exception`)."""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDrive:
|
||||||
|
CHUNK = 256 * 1024
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.files: dict[str, DriveFile] = {}
|
||||||
|
self.sessions: dict[str, dict] = {}
|
||||||
|
self._ids = itertools.count(1)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.active_uploads = 0
|
||||||
|
self.max_active_uploads = 0
|
||||||
|
self.upload_failures: list[BaseException] = []
|
||||||
|
self.crash_at_bytes: int | None = None
|
||||||
|
self.lose_response_once = False
|
||||||
|
self.resumed: list[tuple[str, int]] = []
|
||||||
|
self.upload_calls = 0
|
||||||
|
self.events_sink: list | None = None
|
||||||
|
self.created_folders = 0
|
||||||
|
|
||||||
|
def _id(self, prefix):
|
||||||
|
return f"{prefix}{next(self._ids)}"
|
||||||
|
|
||||||
|
def add_folder(self, parent, name):
|
||||||
|
folder = DriveFile(self._id("fold"), name, parents=(parent,), mime_type=FOLDER_MIME)
|
||||||
|
self.files[folder.id] = folder
|
||||||
|
return folder
|
||||||
|
|
||||||
|
def add_file(self, parent, name, content: bytes, app_properties=None):
|
||||||
|
f = DriveFile(
|
||||||
|
self._id("file"), name, size=len(content), md5=hashlib.md5(content).hexdigest(), parents=(parent,),
|
||||||
|
mime_type="video/mp4", app_properties=dict(app_properties or {}),
|
||||||
|
)
|
||||||
|
self.files[f.id] = f
|
||||||
|
return f
|
||||||
|
|
||||||
|
def uploaded(self):
|
||||||
|
return [f for f in self.files.values() if f.mime_type != FOLDER_MIME and not f.trashed]
|
||||||
|
|
||||||
|
# DriveClient interface
|
||||||
|
def get_file(self, file_id):
|
||||||
|
return self.files.get(file_id)
|
||||||
|
|
||||||
|
def find_folders(self, parent_id, name):
|
||||||
|
return [f for f in self.files.values() if f.mime_type == FOLDER_MIME and parent_id in f.parents and f.name == name and not f.trashed]
|
||||||
|
|
||||||
|
def create_folder(self, parent_id, name):
|
||||||
|
self.created_folders += 1
|
||||||
|
return self.add_folder(parent_id, name)
|
||||||
|
|
||||||
|
def find_by_name(self, parent_id, name):
|
||||||
|
return [f for f in self.uploaded() if parent_id in f.parents and f.name == name]
|
||||||
|
|
||||||
|
def find_by_source_key(self, key):
|
||||||
|
return [f for f in self.uploaded() if f.app_properties.get(SOURCE_PROPERTY) == key]
|
||||||
|
|
||||||
|
def share_with_link(self, file_id):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def upload_file(self, path, *, name, parent_id, mime_type, size, app_properties, description=None,
|
||||||
|
session_uri, on_session, on_progress, should_stop):
|
||||||
|
with self._lock:
|
||||||
|
self.active_uploads += 1
|
||||||
|
self.max_active_uploads = max(self.max_active_uploads, self.active_uploads)
|
||||||
|
self.upload_calls += 1
|
||||||
|
if self.events_sink is not None:
|
||||||
|
self.events_sink.append(("upload", app_properties[SOURCE_PROPERTY]))
|
||||||
|
try:
|
||||||
|
data = Path(path).read_bytes()
|
||||||
|
assert len(data) == size
|
||||||
|
session = self.sessions.get(session_uri) if session_uri else None
|
||||||
|
if session is not None:
|
||||||
|
self.resumed.append((session_uri, len(session["data"])))
|
||||||
|
if session.get("file"):
|
||||||
|
return session["file"]
|
||||||
|
else:
|
||||||
|
session_uri = f"https://upload.example/session/{self._id('s')}"
|
||||||
|
session = {"data": b"", "name": name, "parent": parent_id, "props": app_properties}
|
||||||
|
self.sessions[session_uri] = session
|
||||||
|
on_session(session_uri)
|
||||||
|
if self.upload_failures:
|
||||||
|
raise self.upload_failures.pop(0)
|
||||||
|
while len(session["data"]) < size:
|
||||||
|
if should_stop():
|
||||||
|
raise Interrupted("upload paused")
|
||||||
|
start = len(session["data"])
|
||||||
|
session["data"] += data[start : start + self.CHUNK]
|
||||||
|
on_progress(len(session["data"]), size)
|
||||||
|
time.sleep(0.001)
|
||||||
|
if self.crash_at_bytes is not None and len(session["data"]) >= self.crash_at_bytes:
|
||||||
|
self.crash_at_bytes = None
|
||||||
|
raise SimulatedCrash()
|
||||||
|
f = self.add_file(session["parent"], session["name"], session["data"], session["props"])
|
||||||
|
session["file"] = f
|
||||||
|
if self.lose_response_once:
|
||||||
|
self.lose_response_once = False
|
||||||
|
from errors import TransientError
|
||||||
|
|
||||||
|
raise TransientError("connection reset after upload completed")
|
||||||
|
return f
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self.active_uploads -= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def source():
|
||||||
|
return FakeSource()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def drive():
|
||||||
|
return FakeDrive()
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingNotifier:
|
||||||
|
def __init__(self):
|
||||||
|
self.sent: list[tuple[int, str]] = []
|
||||||
|
self.edits: list[tuple[int, int, str]] = []
|
||||||
|
self._ids = itertools.count(1)
|
||||||
|
|
||||||
|
async def send(self, chat_id, text):
|
||||||
|
self.sent.append((chat_id, text))
|
||||||
|
return next(self._ids)
|
||||||
|
|
||||||
|
async def edit(self, chat_id, message_id, text):
|
||||||
|
self.edits.append((chat_id, message_id, text))
|
||||||
|
|
||||||
|
|
||||||
|
def build_queue(config, db, source, drive, *, reporter=None, disk_usage=None, root="root-folder"):
|
||||||
|
from drive_client import FolderResolver
|
||||||
|
from queue_manager import QueueManager
|
||||||
|
|
||||||
|
kwargs = {"disk_usage": disk_usage} if disk_usage else {}
|
||||||
|
downloader = Downloader(
|
||||||
|
source, config.download_dir, min_free_bytes=config.min_free_disk_space,
|
||||||
|
max_temp_bytes=config.max_temp_storage, compute_sha256=config.compute_sha256, **kwargs,
|
||||||
|
)
|
||||||
|
if root not in drive.files:
|
||||||
|
drive.files[root] = DriveFile(root, "Root", mime_type=FOLDER_MIME)
|
||||||
|
resolver = FolderResolver(drive, db, root, sleep=lambda s: None)
|
||||||
|
return QueueManager(config, db, drive, downloader, resolver, source=source, reporter=reporter, db_error_delay=0)
|
||||||
|
|
||||||
|
|
||||||
|
def run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_dotenv(monkeypatch):
|
||||||
|
# Never pick up a developer's real .env during tests.
|
||||||
|
monkeypatch.setattr("dotenv.load_dotenv", lambda *a, **k: False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_logging():
|
||||||
|
import logging
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
handlers, level = list(root.handlers), root.level
|
||||||
|
yield
|
||||||
|
root.handlers[:] = handlers
|
||||||
|
root.setLevel(level)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_sessionfinish(session, exitstatus):
|
||||||
|
if _PG_SERVER is not None:
|
||||||
|
try:
|
||||||
|
_PG_SERVER.cleanup()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
shutil.rmtree(_PG_SERVER.pgdata, ignore_errors=True)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from bot_handlers import CommandRouter
|
||||||
|
from conftest import CHANNEL_ID, EXAMPLE_CAPTION, build_queue, run
|
||||||
|
from models import State
|
||||||
|
|
||||||
|
ADMIN, STRANGER = 111, 999
|
||||||
|
|
||||||
|
COMMANDS = ["/start", "/help", "/status", "/run", "/pause", "/resume", "/stop", "/retry", "/failed",
|
||||||
|
"/progress", "/folders", "/check 1", "/sync"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def router(config, db, source, drive):
|
||||||
|
source.add(1, EXAMPLE_CAPTION)
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
return CommandRouter(config, db, queue)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("command", COMMANDS)
|
||||||
|
def test_non_admin_is_rejected(router, command):
|
||||||
|
reply = run(router.handle(STRANGER, command))
|
||||||
|
assert reply == "You are not authorized to control this bot."
|
||||||
|
assert router.queue.running is False
|
||||||
|
assert router.db.counts()["PENDING"] == 0 # /sync did not run
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_sender_is_rejected(router):
|
||||||
|
assert "not authorized" in run(router.handle(None, "/status"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_commands_are_ignored(router):
|
||||||
|
assert run(router.handle(ADMIN, "hello")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_flow(router, drive):
|
||||||
|
async def flow():
|
||||||
|
replies = {}
|
||||||
|
replies["help"] = await router.handle(ADMIN, "/help")
|
||||||
|
replies["sync"] = await router.handle(ADMIN, "/sync@MyBot")
|
||||||
|
replies["check"] = await router.handle(ADMIN, "/check 1")
|
||||||
|
replies["run"] = await router.handle(ADMIN, "/run")
|
||||||
|
await router.queue.wait()
|
||||||
|
replies["progress"] = await router.handle(ADMIN, "/progress")
|
||||||
|
replies["folders"] = await router.handle(ADMIN, "/folders")
|
||||||
|
replies["status"] = await router.handle(ADMIN, "/status")
|
||||||
|
replies["failed"] = await router.handle(ADMIN, "/failed")
|
||||||
|
replies["unknown"] = await router.handle(ADMIN, "/nope")
|
||||||
|
replies["badcheck"] = await router.handle(ADMIN, "/check abc")
|
||||||
|
return replies
|
||||||
|
|
||||||
|
r = run(flow())
|
||||||
|
assert "/status" in r["help"]
|
||||||
|
assert "New videos queued: 1" in r["sync"]
|
||||||
|
assert "Class 01 Aman sir.mp4" in r["check"] and "001, 002" in r["check"]
|
||||||
|
assert r["run"].startswith("Processing started")
|
||||||
|
assert "Processed (uploaded & verified): 1" in r["progress"]
|
||||||
|
assert "Misc• Grammar Practice: 1" in r["folders"]
|
||||||
|
assert "Queue: idle" in r["status"]
|
||||||
|
assert r["failed"] == "No failed items."
|
||||||
|
assert "Unknown command" in r["unknown"]
|
||||||
|
assert r["badcheck"].startswith("Usage")
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_listing_hides_paths(router, db):
|
||||||
|
run(router.handle(ADMIN, "/sync"))
|
||||||
|
db.transition((CHANNEL_ID, 1), State.FAILED, last_error="cannot open /Users/someone/secret/dir/file.part")
|
||||||
|
reply = run(router.handle(ADMIN, "/failed"))
|
||||||
|
assert "/Users/someone" not in reply and "file.part" in reply
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from caption_parser import parse_caption
|
||||||
|
from conftest import EXAMPLE_CAPTION
|
||||||
|
|
||||||
|
|
||||||
|
def test_supplied_example():
|
||||||
|
p = parse_caption(EXAMPLE_CAPTION)
|
||||||
|
assert p.indexes == ["001", "002"]
|
||||||
|
assert p.title == "Class 01 Aman sir.mp4"
|
||||||
|
assert p.topic == "Misc• Grammar Practice"
|
||||||
|
assert p.batch == "English ( SSC MAINS )"
|
||||||
|
assert p.extracted_by == "@PUTINxINDIA"
|
||||||
|
assert p.missing == []
|
||||||
|
assert p.warnings == []
|
||||||
|
assert p.raw == EXAMPLE_CAPTION
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_is_never_used_as_title():
|
||||||
|
p = parse_caption("Index: 007\nIndex: 008\nTopic: Nouns")
|
||||||
|
assert p.indexes == ["007", "008"]
|
||||||
|
assert p.title is None
|
||||||
|
assert "title" in p.missing
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_index_order_preserved():
|
||||||
|
p = parse_caption("Index: 3\nTitle: A\nIndex: 1\nTopic: T\nIndex: 2")
|
||||||
|
assert p.indexes == ["3", "1", "2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fields_in_any_order_and_whitespace_variants():
|
||||||
|
caption = " topic : Error Detection \n\n\nTITLE:Class 02.mp4\r\n batch: X \nExtracted by :@someone\n"
|
||||||
|
p = parse_caption(caption)
|
||||||
|
assert p.topic == "Error Detection"
|
||||||
|
assert p.title == "Class 02.mp4"
|
||||||
|
assert p.batch == "X"
|
||||||
|
assert p.extracted_by == "@someone"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decorations_and_fullwidth_colon():
|
||||||
|
p = parse_caption("🎬 Title: Class 05.mp4\n📚 Topic: Reading Comprehension\n• Batch - ignored\n**Batch:** B1")
|
||||||
|
assert p.title == "Class 05.mp4"
|
||||||
|
assert p.topic == "Reading Comprehension"
|
||||||
|
assert p.batch == "** B1" # value text is preserved verbatim apart from trimming
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_title_and_topic():
|
||||||
|
p = parse_caption("Index: 001\nBatch: English")
|
||||||
|
assert p.missing == ["title", "topic"]
|
||||||
|
assert "Title field is missing" in p.warnings
|
||||||
|
assert "Topic field is missing" in p.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_and_none_caption():
|
||||||
|
for value in (None, "", " \n "):
|
||||||
|
p = parse_caption(value)
|
||||||
|
assert p.missing == ["title", "topic"]
|
||||||
|
assert "caption is empty" in p.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_field_values_are_reported():
|
||||||
|
p = parse_caption("Title:\nTopic: \nIndex:")
|
||||||
|
assert p.title is None and p.topic is None
|
||||||
|
assert "Title field is empty" in p.warnings
|
||||||
|
assert "empty Index field ignored" in p.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_unicode_preserved():
|
||||||
|
p = parse_caption("Title: कक्षा 01 — व्याकरण (भाग १).mp4\nTopic: हिंदी • व्याकरण\nBatch: Ünïcödé ( 2024 )")
|
||||||
|
assert p.title == "कक्षा 01 — व्याकरण (भाग १).mp4"
|
||||||
|
assert p.topic == "हिंदी • व्याकरण"
|
||||||
|
assert p.batch == "Ünïcödé ( 2024 )"
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_field_is_not_swallowed():
|
||||||
|
p = parse_caption("Title: Class 01\nTopic: Grammar\nBatch: SSC")
|
||||||
|
assert p.title == "Class 01"
|
||||||
|
assert p.topic == "Grammar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_flattened_single_line_caption():
|
||||||
|
p = parse_caption("Index: 001 Title: Class 01 Aman sir.mp4 Topic: Misc• Grammar Practice | Batch: English ( SSC MAINS )")
|
||||||
|
assert p.indexes == ["001"]
|
||||||
|
assert p.title == "Class 01 Aman sir.mp4"
|
||||||
|
assert p.topic == "Misc• Grammar Practice"
|
||||||
|
assert p.batch == "English ( SSC MAINS )"
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_field_keeps_first_and_warns():
|
||||||
|
p = parse_caption("Title: First.mp4\nTitle: Second.mp4\nTopic: T")
|
||||||
|
assert p.title == "First.mp4"
|
||||||
|
assert any("duplicate Title" in w for w in p.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_fields_and_free_text_kept_in_extra_or_ignored():
|
||||||
|
p = parse_caption("Join: @channel\nSome free text line\nTitle: A.mp4\nTopic: T\nSubtitle: not a title")
|
||||||
|
assert p.title == "A.mp4"
|
||||||
|
assert p.extra["Join"] == "@channel"
|
||||||
|
assert p.extra["Subtitle"] == "not a title"
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_must_be_whole_word():
|
||||||
|
p = parse_caption("Topics: wrong\nTopic: right")
|
||||||
|
assert p.topic == "right"
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from config import Config, ConfigError, load_json_secret
|
||||||
|
from conftest import make_config
|
||||||
|
from logging_config import RedactSecrets
|
||||||
|
|
||||||
|
|
||||||
|
def _rsa_pem() -> str:
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
return key.private_bytes(
|
||||||
|
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
|
||||||
|
SA_INFO = {
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "example-project",
|
||||||
|
"private_key_id": "abc123",
|
||||||
|
"client_email": "uploader@example-project.iam.gserviceaccount.com",
|
||||||
|
"client_id": "1",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
}
|
||||||
|
TOKEN_INFO = {
|
||||||
|
"token": "ya29.placeholder-access-token",
|
||||||
|
"refresh_token": "1//placeholder-refresh-token",
|
||||||
|
"client_id": "placeholder.apps.googleusercontent.com",
|
||||||
|
"client_secret": "placeholder-client-secret",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"expiry": "2999-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_and_mapped_names(tmp_path):
|
||||||
|
c = make_config(tmp_path, MAX_RETRIES="4", RETRY_DELAY="15", DUPLICATE_POLICY="suffix_message_id",
|
||||||
|
BATCH_SUBFOLDERS_ENABLED="yes", GOOGLE_DRIVE_ROOT_FOLDER_ID="FOLDER123")
|
||||||
|
assert c.max_attempts == 5 and c.retry_base_delay == 15
|
||||||
|
assert c.duplicate_name_policy == "suffix_message_id"
|
||||||
|
assert c.folder_structure == "batch_topic"
|
||||||
|
assert c.drive_root_folder_id == "FOLDER123"
|
||||||
|
assert c.source_channel == "-1001234567890"
|
||||||
|
assert c.fallback_topic == "Uncategorized"
|
||||||
|
assert c.share_mode == "none" # never public by default
|
||||||
|
assert c.drive_scope == "drive.file"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_values_are_all_reported(tmp_path):
|
||||||
|
with pytest.raises(ConfigError) as err:
|
||||||
|
make_config(tmp_path, MAX_RETRIES="many", PROCESS_MODE="sometimes", UPLOAD_CHUNK_SIZE="1000", KEEP_LOCAL_FILES="maybe")
|
||||||
|
text = str(err.value)
|
||||||
|
for name in ("MAX_RETRIES", "PROCESS_MODE", "UPLOAD_CHUNK_SIZE", "KEEP_LOCAL_FILES"):
|
||||||
|
assert name in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_required_telegram_settings():
|
||||||
|
config = Config.from_env({})
|
||||||
|
with pytest.raises(ConfigError) as err:
|
||||||
|
config.validate("telegram")
|
||||||
|
text = str(err.value)
|
||||||
|
assert "TELEGRAM_API_ID" in text and "SOURCE_CHANNEL_ID" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_heroku_requires_durable_database_and_session_string(tmp_path):
|
||||||
|
c = make_config(tmp_path, DYNO="worker.1", TELEGRAM_MODE="user")
|
||||||
|
problems = c.database_problems() + c.telegram_problems() + c.drive_problems()
|
||||||
|
joined = " ".join(problems)
|
||||||
|
assert "DATABASE_URL" in joined
|
||||||
|
assert "TELEGRAM_SESSION_STRING" in joined
|
||||||
|
assert "GOOGLE_OAUTH_TOKEN_JSON" in joined
|
||||||
|
ok = make_config(
|
||||||
|
tmp_path, DYNO="worker.1", TELEGRAM_MODE="user", TELEGRAM_SESSION_STRING="1AaBbCc-placeholder",
|
||||||
|
DATABASE_URL="postgres://u:p@db.example.com:5432/d", GOOGLE_OAUTH_TOKEN_JSON=json.dumps(TOKEN_INFO),
|
||||||
|
)
|
||||||
|
ok.validate("telegram", "drive", "database")
|
||||||
|
assert ok.database_url == "postgresql://u:p@db.example.com:5432/d?sslmode=require"
|
||||||
|
assert ok.instance_lock_wait > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ephemeral_database_must_be_explicit(tmp_path):
|
||||||
|
c = make_config(tmp_path, DYNO="worker.1", ALLOW_EPHEMERAL_DATABASE="true")
|
||||||
|
assert c.database_problems() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_secrets_raw_multiline_and_base64():
|
||||||
|
raw = json.dumps(TOKEN_INFO, indent=2)
|
||||||
|
assert load_json_secret(raw)["refresh_token"] == TOKEN_INFO["refresh_token"]
|
||||||
|
encoded = base64.b64encode(raw.encode()).decode()
|
||||||
|
assert load_json_secret(encoded)["client_id"] == TOKEN_INFO["client_id"]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
load_json_secret("{not json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_credential_json_reported(tmp_path):
|
||||||
|
c = make_config(tmp_path, GOOGLE_AUTH_MODE="service_account", GOOGLE_SERVICE_ACCOUNT_JSON='{"type": "service_account"}')
|
||||||
|
assert any("missing keys" in p for p in c.drive_problems())
|
||||||
|
|
||||||
|
|
||||||
|
def test_credentials_load_from_env_without_files(tmp_path):
|
||||||
|
from drive_client import load_credentials
|
||||||
|
|
||||||
|
sa = dict(SA_INFO, private_key=_rsa_pem())
|
||||||
|
c = make_config(tmp_path, GOOGLE_AUTH_MODE="service_account", GOOGLE_SERVICE_ACCOUNT_JSON=json.dumps(sa))
|
||||||
|
assert not c.google_token_file.exists() and not c.google_client_secrets_file.exists()
|
||||||
|
c.validate("drive")
|
||||||
|
creds = load_credentials(c)
|
||||||
|
assert creds.service_account_email == SA_INFO["client_email"]
|
||||||
|
assert creds.scopes == ["https://www.googleapis.com/auth/drive.file"]
|
||||||
|
|
||||||
|
c = make_config(tmp_path, GOOGLE_OAUTH_TOKEN_JSON=base64.b64encode(json.dumps(TOKEN_INFO).encode()).decode())
|
||||||
|
c.validate("drive")
|
||||||
|
creds = load_credentials(c)
|
||||||
|
assert creds.refresh_token == TOKEN_INFO["refresh_token"] and creds.valid
|
||||||
|
assert not c.google_token_file.exists() # nothing written to the ephemeral disk
|
||||||
|
|
||||||
|
|
||||||
|
def test_secrets_are_redacted_from_logs(tmp_path, caplog):
|
||||||
|
sa = dict(SA_INFO, private_key="-----BEGIN PRIVATE KEY-----placeholder")
|
||||||
|
c = make_config(tmp_path, GOOGLE_SERVICE_ACCOUNT_JSON=json.dumps(sa), GOOGLE_OAUTH_TOKEN_JSON=json.dumps(TOKEN_INFO))
|
||||||
|
secrets = c.secrets()
|
||||||
|
assert c.bot_token in secrets and c.api_hash in secrets
|
||||||
|
assert TOKEN_INFO["refresh_token"] in secrets and TOKEN_INFO["client_secret"] in secrets
|
||||||
|
record = logging.LogRecord("x", logging.INFO, __file__, 1, "token=%s refresh=%s", (c.bot_token, TOKEN_INFO["refresh_token"]), None)
|
||||||
|
RedactSecrets(secrets).filter(record)
|
||||||
|
assert c.bot_token not in record.getMessage()
|
||||||
|
assert TOKEN_INFO["refresh_token"] not in record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_exits_with_clear_error_when_unconfigured(monkeypatch, tmp_path):
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setenv("LOG_DIR", str(tmp_path / "logs"))
|
||||||
|
for name in ("TELEGRAM_API_ID", "TELEGRAM_API_HASH", "TELEGRAM_BOT_TOKEN", "SOURCE_CHANNEL_ID", "DYNO", "DATABASE_URL"):
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
assert main.main(["check-config"]) == 2
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from caption_parser import parse_caption
|
||||||
|
from conftest import CHANNEL_ID, EXAMPLE_CAPTION, FakeSource
|
||||||
|
from database import Database, InstanceLock, InstanceLockError, InvalidTransition, utcnow
|
||||||
|
from models import State
|
||||||
|
|
||||||
|
|
||||||
|
def _insert(db, message_id, caption=EXAMPLE_CAPTION):
|
||||||
|
msg = FakeSource().add(message_id, caption)
|
||||||
|
return msg, db.insert_video(msg, parse_caption(caption))
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_and_metadata_roundtrip(db):
|
||||||
|
msg, inserted = _insert(db, 5)
|
||||||
|
assert inserted
|
||||||
|
rec = db.get((CHANNEL_ID, 5))
|
||||||
|
assert rec.state == State.PENDING
|
||||||
|
assert rec.indexes == ["001", "002"]
|
||||||
|
assert rec.title == "Class 01 Aman sir.mp4"
|
||||||
|
assert rec.topic == "Misc• Grammar Practice"
|
||||||
|
assert rec.batch == "English ( SSC MAINS )"
|
||||||
|
assert rec.extracted_by == "@PUTINxINDIA"
|
||||||
|
assert rec.caption == EXAMPLE_CAPTION
|
||||||
|
assert rec.expected_size == msg.media.size
|
||||||
|
assert rec.download_status == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_message_is_not_inserted_twice(db):
|
||||||
|
_, first = _insert(db, 7)
|
||||||
|
_, second = _insert(db, 7)
|
||||||
|
assert first and not second
|
||||||
|
assert db.counts()["PENDING"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_state_transitions_and_history(db):
|
||||||
|
_insert(db, 1)
|
||||||
|
key = (CHANNEL_ID, 1)
|
||||||
|
for state in (State.DOWNLOADING, State.DOWNLOADED, State.UPLOADING, State.COMPLETED):
|
||||||
|
rec = db.transition(key, state)
|
||||||
|
assert rec.state == state
|
||||||
|
assert rec.download_status == "done" and rec.upload_status == "done"
|
||||||
|
assert [h[1] for h in db.history(key)] == ["PENDING", "DOWNLOADING", "DOWNLOADED", "UPLOADING", "COMPLETED"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
[
|
||||||
|
[State.COMPLETED, State.PENDING], # completed is terminal
|
||||||
|
[State.UPLOADING], # cannot upload before downloading
|
||||||
|
[State.DOWNLOADING, State.COMPLETED], # cannot complete without upload/reconcile
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_transitions_rejected(db, path):
|
||||||
|
_insert(db, 1)
|
||||||
|
key = (CHANNEL_ID, 1)
|
||||||
|
with pytest.raises(InvalidTransition):
|
||||||
|
for state in path:
|
||||||
|
db.transition(key, state)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_status_fields(db):
|
||||||
|
_insert(db, 1)
|
||||||
|
key = (CHANNEL_ID, 1)
|
||||||
|
db.transition(key, State.DOWNLOADING)
|
||||||
|
rec = db.transition(key, State.RETRY_WAIT, attempts=1, last_error="boom")
|
||||||
|
assert rec.download_status == "retry_wait" and rec.attempts == 1 and rec.last_error == "boom"
|
||||||
|
db.transition(key, State.DOWNLOADED)
|
||||||
|
db.transition(key, State.UPLOADING)
|
||||||
|
rec = db.transition(key, State.FAILED)
|
||||||
|
assert rec.upload_status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_field_rejected(db):
|
||||||
|
_insert(db, 1)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
db.update_fields((CHANNEL_ID, 1), message_id=3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_stale(db):
|
||||||
|
for i in (1, 2, 3):
|
||||||
|
_insert(db, i)
|
||||||
|
db.transition((CHANNEL_ID, 1), State.DOWNLOADING)
|
||||||
|
db.transition((CHANNEL_ID, 2), State.DOWNLOADING)
|
||||||
|
db.transition((CHANNEL_ID, 2), State.DOWNLOADED)
|
||||||
|
db.transition((CHANNEL_ID, 2), State.UPLOADING)
|
||||||
|
recovered = db.recover_stale()
|
||||||
|
assert recovered == {"DOWNLOADING": 1, "UPLOADING": 1}
|
||||||
|
assert db.get((CHANNEL_ID, 1)).state == State.PENDING
|
||||||
|
assert db.get((CHANNEL_ID, 2)).state == State.UPLOADING # reconciled by the queue
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_item_prefers_unfinished_then_oldest(db):
|
||||||
|
for i in (10, 20, 30):
|
||||||
|
_insert(db, i)
|
||||||
|
assert db.next_item().message_id == 10
|
||||||
|
db.transition((CHANNEL_ID, 30), State.DOWNLOADING)
|
||||||
|
db.transition((CHANNEL_ID, 30), State.DOWNLOADED)
|
||||||
|
assert db.next_item().message_id == 30
|
||||||
|
db.transition((CHANNEL_ID, 30), State.RETRY_WAIT, next_retry_at=(utcnow() + timedelta(hours=1)).isoformat(timespec="seconds"))
|
||||||
|
assert db.next_item().message_id == 10
|
||||||
|
assert db.next_item(utcnow() + timedelta(hours=2)).message_id == 10
|
||||||
|
assert db.earliest_retry_at() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_folders_kv_sequence(db):
|
||||||
|
assert db.get_folder("root", "misc") is None
|
||||||
|
db.put_folder("root", "misc", "Misc", "F1")
|
||||||
|
db.put_folder("root", "misc", "Misc", "F2")
|
||||||
|
assert db.get_folder("root", "misc") == "F2"
|
||||||
|
assert db.folder_count() == 1
|
||||||
|
db.raise_int("cursor", 5)
|
||||||
|
db.raise_int("cursor", 3)
|
||||||
|
assert db.get_int("cursor") == 5
|
||||||
|
assert [db.next_sequence() for _ in range(3)] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_survives_reopen(config, db):
|
||||||
|
_insert(db, 1)
|
||||||
|
db.set_kv("queue_desired_state", "running")
|
||||||
|
db.close()
|
||||||
|
again = Database.from_config(config)
|
||||||
|
assert again.get((CHANNEL_ID, 1)).title == "Class 01 Aman sir.mp4"
|
||||||
|
assert again.get_kv("queue_desired_state") == "running"
|
||||||
|
again.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_instance_lock_blocks_second_worker(config, db):
|
||||||
|
first = InstanceLock(config)
|
||||||
|
first.acquire()
|
||||||
|
second = InstanceLock(config, wait_seconds=0)
|
||||||
|
try:
|
||||||
|
if config.database_url:
|
||||||
|
with pytest.raises(InstanceLockError):
|
||||||
|
second.acquire()
|
||||||
|
else:
|
||||||
|
# flock is per open file description, so test from a second process
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
code = (
|
||||||
|
"import fcntl,sys; f=open(sys.argv[1],'w');\n"
|
||||||
|
"try:\n fcntl.flock(f, fcntl.LOCK_EX|fcntl.LOCK_NB); print('acquired')\n"
|
||||||
|
"except BlockingIOError: print('blocked')"
|
||||||
|
)
|
||||||
|
out = subprocess.run([sys.executable, "-c", code, str(config.database_path) + ".lock"], capture_output=True, text=True)
|
||||||
|
assert out.stdout.strip() == "blocked"
|
||||||
|
finally:
|
||||||
|
first.release()
|
||||||
|
second.release()
|
||||||
|
third = InstanceLock(config)
|
||||||
|
third.acquire()
|
||||||
|
third.release()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Static deployment-readiness checks (these do NOT deploy anything to Heroku)."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
# Top-level keys documented for app.json (https://devcenter.heroku.com/articles/app-json-schema)
|
||||||
|
APP_JSON_KEYS = {
|
||||||
|
"name", "description", "keywords", "website", "repository", "logo", "success_url", "scripts", "env",
|
||||||
|
"formation", "image", "addons", "buildpacks", "environments", "stack",
|
||||||
|
}
|
||||||
|
ENV_KEYS = {"description", "value", "required", "generator"}
|
||||||
|
|
||||||
|
|
||||||
|
def _config_vars() -> set[str]:
|
||||||
|
source = (ROOT / "config.py").read_text()
|
||||||
|
return set(re.findall(r'r\.\w+\(\s*"([A-Z0-9_]+)"', source)) | set(re.findall(r'env\.get\("([A-Z0-9_]+)"\)', source))
|
||||||
|
|
||||||
|
|
||||||
|
def _app_json() -> dict:
|
||||||
|
return json.loads((ROOT / "app.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_json_structure():
|
||||||
|
manifest = _app_json()
|
||||||
|
assert set(manifest) <= APP_JSON_KEYS
|
||||||
|
assert len(manifest["name"]) <= 30 and manifest["description"]
|
||||||
|
assert manifest["buildpacks"] == [{"url": "heroku/python"}]
|
||||||
|
assert manifest["formation"] == {"worker": {"quantity": 1, "size": "basic"}}
|
||||||
|
assert manifest["addons"][0]["plan"].startswith("heroku-postgresql:")
|
||||||
|
for name, spec in manifest["env"].items():
|
||||||
|
assert set(spec) <= ENV_KEYS, name
|
||||||
|
assert spec["description"], name
|
||||||
|
assert isinstance(spec.get("required", False), bool), name
|
||||||
|
if "value" in spec:
|
||||||
|
assert isinstance(spec["value"], str), name
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_json_only_lists_supported_variables():
|
||||||
|
supported = _config_vars()
|
||||||
|
unsupported = set(_app_json()["env"]) - supported
|
||||||
|
assert not unsupported, f"app.json lists variables the app does not read: {unsupported}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_example_documents_every_variable():
|
||||||
|
documented = set(re.findall(r"^([A-Z0-9_]+)=", (ROOT / ".env.example").read_text(), re.MULTILINE))
|
||||||
|
supported = _config_vars() - {"DYNO"} # DYNO is set by Heroku itself
|
||||||
|
assert supported - documented == set()
|
||||||
|
assert documented - supported == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_procfile_matches_entry_point():
|
||||||
|
procfile = (ROOT / "Procfile").read_text().strip().splitlines()
|
||||||
|
assert procfile == ["worker: python main.py run"]
|
||||||
|
assert set(_app_json()["formation"]) == {line.split(":")[0] for line in procfile}
|
||||||
|
main_source = (ROOT / "main.py").read_text()
|
||||||
|
assert 'sub.add_parser("run"' in main_source and "signal.SIGTERM" in main_source
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_and_requirements():
|
||||||
|
assert (ROOT / ".python-version").read_text().strip() == "3.12"
|
||||||
|
assert not (ROOT / "runtime.txt").exists() # deprecated in favour of .python-version
|
||||||
|
reqs = (ROOT / "requirements.txt").read_text()
|
||||||
|
for package in ("Telethon", "google-api-python-client", "google-auth", "google-auth-oauthlib", "psycopg[binary]", "python-dotenv"):
|
||||||
|
assert re.search(rf"^{re.escape(package)}==", reqs, re.MULTILINE), package
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitignore_protects_secrets_and_state():
|
||||||
|
ignored = (ROOT / ".gitignore").read_text().splitlines()
|
||||||
|
for pattern in (".env", "*.session", "credentials/", "token.json", "session_string.txt", "data/", "downloads/", "*.db"):
|
||||||
|
assert pattern in ignored, pattern
|
||||||
|
|
||||||
|
|
||||||
|
def test_readme_deploy_button():
|
||||||
|
readme = (ROOT / "README.md").read_text()
|
||||||
|
assert "https://www.herokucdn.com/deploy/button.svg" in readme
|
||||||
|
assert re.search(r"\]\(https://heroku\.com/deploy\?template=https://github\.com/[^)\s]+\)", readme)
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httplib2
|
||||||
|
import pytest
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
|
|
||||||
|
from conftest import FakeDrive
|
||||||
|
from drive_client import (
|
||||||
|
DriveClient,
|
||||||
|
FolderResolver,
|
||||||
|
ResumableUploader,
|
||||||
|
classify_http_error,
|
||||||
|
escape_query,
|
||||||
|
parse_range,
|
||||||
|
)
|
||||||
|
from errors import AuthError, DriveQuotaError, Interrupted, PermanentError, TransientError, UploadSessionExpired
|
||||||
|
|
||||||
|
CHUNK = 256 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
def __init__(self, status, headers=None, body=None):
|
||||||
|
self.status_code = status
|
||||||
|
self.headers = headers or {}
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
if self._body is None:
|
||||||
|
raise ValueError("no body")
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUploadServer:
|
||||||
|
"""Implements Google's resumable upload protocol in memory."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sessions = {}
|
||||||
|
self.fail_next_put = [] # exceptions or Resp objects
|
||||||
|
self.initiations = 0
|
||||||
|
self.put_count = 0
|
||||||
|
|
||||||
|
def request(self, method, url, data=None, headers=None, timeout=None):
|
||||||
|
headers = headers or {}
|
||||||
|
if method == "POST":
|
||||||
|
self.initiations += 1
|
||||||
|
uri = f"https://upload/session/{self.initiations}"
|
||||||
|
meta = json.loads(data)
|
||||||
|
self.sessions[uri] = {"data": b"", "size": int(headers["X-Upload-Content-Length"]), "meta": meta, "done": None}
|
||||||
|
return Resp(200, {"Location": uri})
|
||||||
|
session = self.sessions.get(url)
|
||||||
|
if session is None:
|
||||||
|
return Resp(404)
|
||||||
|
content_range = headers["Content-Range"]
|
||||||
|
if content_range.startswith("bytes */"):
|
||||||
|
return self._status(session)
|
||||||
|
self.put_count += 1
|
||||||
|
if self.fail_next_put:
|
||||||
|
failure = self.fail_next_put.pop(0)
|
||||||
|
if isinstance(failure, BaseException):
|
||||||
|
# simulate a connection drop after the server stored half the chunk
|
||||||
|
session["data"] += data[: len(data) // 2]
|
||||||
|
raise failure
|
||||||
|
return failure
|
||||||
|
start = int(re.match(r"bytes (\d+)-", content_range).group(1))
|
||||||
|
assert start == len(session["data"]), "client must continue exactly where the server is"
|
||||||
|
session["data"] += data
|
||||||
|
return self._status(session)
|
||||||
|
|
||||||
|
def _status(self, session):
|
||||||
|
if len(session["data"]) >= session["size"]:
|
||||||
|
if session["done"] is None:
|
||||||
|
data = session["data"]
|
||||||
|
session["done"] = {
|
||||||
|
"id": "file-1",
|
||||||
|
"name": session["meta"]["name"],
|
||||||
|
"size": str(len(data)),
|
||||||
|
"md5Checksum": hashlib.md5(data).hexdigest(),
|
||||||
|
"parents": session["meta"]["parents"],
|
||||||
|
}
|
||||||
|
return Resp(200, body=session["done"])
|
||||||
|
headers = {"Range": f"bytes=0-{len(session['data']) - 1}"} if session["data"] else {}
|
||||||
|
return Resp(308, headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(server, path, *, session_uri=None, stop_after=None, retries=3):
|
||||||
|
sessions, progress = [], []
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def should_stop():
|
||||||
|
calls["n"] += 1
|
||||||
|
return stop_after is not None and calls["n"] > stop_after
|
||||||
|
|
||||||
|
uploader = ResumableUploader(server, chunk_size=CHUNK, retries=retries, sleep=lambda s: None)
|
||||||
|
result = uploader.upload(
|
||||||
|
path,
|
||||||
|
metadata={"name": "Class 01.mp4", "parents": ["F"], "appProperties": {"tg_source": "1:2"}},
|
||||||
|
mime_type="video/mp4",
|
||||||
|
size=path.stat().st_size,
|
||||||
|
session_uri=session_uri,
|
||||||
|
on_session=sessions.append,
|
||||||
|
on_progress=lambda d, t: progress.append(d),
|
||||||
|
should_stop=should_stop,
|
||||||
|
)
|
||||||
|
return result, sessions, progress
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def big_file(tmp_path):
|
||||||
|
path = tmp_path / "video.bin"
|
||||||
|
path.write_bytes(bytes(range(256)) * (CHUNK * 3 // 256 + 100)) # 3+ chunks
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_upload_in_chunks(big_file):
|
||||||
|
server = FakeUploadServer()
|
||||||
|
result, sessions, progress = _upload(server, big_file)
|
||||||
|
assert result["size"] == str(big_file.stat().st_size)
|
||||||
|
assert result["md5Checksum"] == hashlib.md5(big_file.read_bytes()).hexdigest()
|
||||||
|
assert len(sessions) == 1 and server.put_count == 4
|
||||||
|
assert progress[-1] == big_file.stat().st_size
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_then_resume_same_session(big_file):
|
||||||
|
server = FakeUploadServer()
|
||||||
|
with pytest.raises(Interrupted):
|
||||||
|
_upload(server, big_file, stop_after=2)
|
||||||
|
uri = next(iter(server.sessions))
|
||||||
|
stored = len(server.sessions[uri]["data"])
|
||||||
|
assert 0 < stored < big_file.stat().st_size
|
||||||
|
result, sessions, _ = _upload(server, big_file, session_uri=uri)
|
||||||
|
assert sessions == [] # no new session was started
|
||||||
|
assert server.initiations == 1
|
||||||
|
assert result["md5Checksum"] == hashlib.md5(big_file.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_error_mid_chunk_resumes_from_server_offset(big_file):
|
||||||
|
server = FakeUploadServer()
|
||||||
|
server.fail_next_put = [ConnectionError("reset")]
|
||||||
|
result, _, _ = _upload(server, big_file)
|
||||||
|
assert result["md5Checksum"] == hashlib.md5(big_file.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_errors_are_retried_then_raised(big_file):
|
||||||
|
server = FakeUploadServer()
|
||||||
|
server.fail_next_put = [Resp(503), Resp(500)]
|
||||||
|
result, _, _ = _upload(server, big_file)
|
||||||
|
assert result["id"] == "file-1"
|
||||||
|
server = FakeUploadServer()
|
||||||
|
server.fail_next_put = [Resp(503)] * 10
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
_upload(server, big_file, retries=2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_session(big_file):
|
||||||
|
server = FakeUploadServer()
|
||||||
|
# unknown saved session -> a new session is created transparently
|
||||||
|
result, sessions, _ = _upload(server, big_file, session_uri="https://upload/session/expired")
|
||||||
|
assert len(sessions) == 1 and result["id"] == "file-1"
|
||||||
|
server = FakeUploadServer()
|
||||||
|
server.fail_next_put = [Resp(404)]
|
||||||
|
with pytest.raises(UploadSessionExpired):
|
||||||
|
_upload(server, big_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quota_and_permission_errors_are_not_retried(big_file):
|
||||||
|
server = FakeUploadServer()
|
||||||
|
server.fail_next_put = [Resp(403, body={"error": {"errors": [{"reason": "storageQuotaExceeded"}], "message": "full"}})]
|
||||||
|
with pytest.raises(DriveQuotaError):
|
||||||
|
_upload(server, big_file)
|
||||||
|
server = FakeUploadServer()
|
||||||
|
server.fail_next_put = [Resp(403, body={"error": {"errors": [{"reason": "insufficientFilePermissions"}]}})]
|
||||||
|
with pytest.raises(PermanentError):
|
||||||
|
_upload(server, big_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunk_size_must_be_aligned():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ResumableUploader(None, chunk_size=1000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_helpers():
|
||||||
|
assert escape_query("It's a \\ test") == "It\\'s a \\\\ test"
|
||||||
|
assert parse_range("bytes=0-1048575") == 1048576
|
||||||
|
assert parse_range(None) == 0
|
||||||
|
assert isinstance(classify_http_error(401), AuthError)
|
||||||
|
assert isinstance(classify_http_error(429), TransientError)
|
||||||
|
rate = classify_http_error(403, "userRateLimitExceeded")
|
||||||
|
assert isinstance(rate, TransientError) and rate.rate_limited
|
||||||
|
assert isinstance(classify_http_error(404), PermanentError)
|
||||||
|
assert isinstance(classify_http_error(502), TransientError)
|
||||||
|
|
||||||
|
|
||||||
|
# -- DriveClient retry wrapper ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRequest:
|
||||||
|
def __init__(self, outcome):
|
||||||
|
self.outcome = outcome
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
if isinstance(self.outcome, BaseException):
|
||||||
|
raise self.outcome
|
||||||
|
return self.outcome
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error(status, reason=None):
|
||||||
|
body = json.dumps({"error": {"errors": [{"reason": reason}] if reason else [], "message": "x"}}).encode()
|
||||||
|
return HttpError(httplib2.Response({"status": status}), body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_retries_transient_then_succeeds():
|
||||||
|
outcomes = [_http_error(503), OSError("net down"), {"ok": True}]
|
||||||
|
client = DriveClient(None, None, chunk_size=CHUNK, sleep=lambda s: None)
|
||||||
|
assert client._execute(lambda: FakeRequest(outcomes.pop(0))) == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_does_not_retry_permission_errors():
|
||||||
|
calls = []
|
||||||
|
client = DriveClient(None, None, chunk_size=CHUNK, sleep=lambda s: None)
|
||||||
|
|
||||||
|
def build():
|
||||||
|
calls.append(1)
|
||||||
|
return FakeRequest(_http_error(403, "insufficientPermissions"))
|
||||||
|
|
||||||
|
with pytest.raises(PermanentError):
|
||||||
|
client._execute(build)
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert client._execute(lambda: FakeRequest(_http_error(404)), not_found_ok=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
# -- FolderResolver ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryFolderDB:
|
||||||
|
def __init__(self):
|
||||||
|
self.folders = {}
|
||||||
|
|
||||||
|
def get_folder(self, parent, key):
|
||||||
|
return self.folders.get((parent, key))
|
||||||
|
|
||||||
|
def put_folder(self, parent, key, name, folder_id):
|
||||||
|
self.folders[(parent, key)] = folder_id
|
||||||
|
|
||||||
|
def delete_folder(self, parent, key):
|
||||||
|
self.folders.pop((parent, key), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolver(drive, db=None):
|
||||||
|
return FolderResolver(drive, db or MemoryFolderDB(), "root", sleep=lambda s: None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_created_once_and_reused():
|
||||||
|
drive = FakeDrive()
|
||||||
|
resolver = _resolver(drive)
|
||||||
|
first = resolver.resolve(["Misc• Grammar Practice"])
|
||||||
|
second = resolver.resolve(["Misc• Grammar Practice"])
|
||||||
|
assert first == second and drive.created_folders == 1
|
||||||
|
# a fresh process (empty in-memory verification) still reuses the DB mapping
|
||||||
|
db = resolver._db
|
||||||
|
again = FolderResolver(drive, db, "root").resolve(["Misc• Grammar Practice"])
|
||||||
|
assert again == first and drive.created_folders == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_drive_folder_is_reused_and_duplicates_pick_oldest():
|
||||||
|
drive = FakeDrive()
|
||||||
|
oldest = drive.add_folder("root", "Error Detection")
|
||||||
|
drive.add_folder("root", "Error Detection")
|
||||||
|
resolver = _resolver(drive)
|
||||||
|
assert resolver.resolve(["Error Detection"]) == oldest.id
|
||||||
|
assert drive.created_folders == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleted_cached_folder_is_recreated():
|
||||||
|
drive = FakeDrive()
|
||||||
|
resolver = _resolver(drive)
|
||||||
|
folder_id = resolver.resolve(["Topic"])
|
||||||
|
drive.files[folder_id].trashed = True
|
||||||
|
fresh = FolderResolver(drive, resolver._db, "root")
|
||||||
|
new_id = fresh.resolve(["Topic"])
|
||||||
|
assert new_id != folder_id and drive.created_folders == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_lost_create_response_does_not_duplicate_folder():
|
||||||
|
drive = FakeDrive()
|
||||||
|
real_create = drive.create_folder
|
||||||
|
|
||||||
|
def flaky_create(parent, name):
|
||||||
|
real_create(parent, name) # Drive created it...
|
||||||
|
raise TransientError("...but the response was lost")
|
||||||
|
|
||||||
|
drive.create_folder = flaky_create
|
||||||
|
resolver = _resolver(drive)
|
||||||
|
folder_id = resolver.resolve(["Reading Comprehension"])
|
||||||
|
assert len(drive.find_folders("root", "Reading Comprehension")) == 1
|
||||||
|
assert drive.files[folder_id].name == "Reading Comprehension"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_batch_topic_path():
|
||||||
|
drive = FakeDrive()
|
||||||
|
resolver = _resolver(drive)
|
||||||
|
leaf = resolver.resolve(["English ( SSC MAINS )", "Misc• Grammar Practice"])
|
||||||
|
batch = drive.find_folders("root", "English ( SSC MAINS )")[0]
|
||||||
|
assert drive.files[leaf].parents == (batch.id,)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from link_parser import LinkError, internal_channel_id, marked_channel_id, parse_message_link
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
"https://t.me/c/3909337424/14",
|
||||||
|
"https://t.me/c/3909337424/14/",
|
||||||
|
"https://t.me/c/3909337424/14?single",
|
||||||
|
"https://www.t.me/c/3909337424/14",
|
||||||
|
"http://t.me/c/3909337424/14#frag",
|
||||||
|
"t.me/c/3909337424/14",
|
||||||
|
"https://telegram.me/c/3909337424/14",
|
||||||
|
"https://t.me/c/3909337424/7/14", # thread segment
|
||||||
|
" https://t.me/c/3909337424/14 ",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_private_links(url):
|
||||||
|
link = parse_message_link(url)
|
||||||
|
assert link.kind == "private"
|
||||||
|
assert link.channel == "3909337424"
|
||||||
|
assert link.message_id == 14
|
||||||
|
assert link.channel_ref == "-1003909337424"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", ["https://t.me/my_channel/14", "https://t.me/s/my_channel/14", "t.me/My_Channel/14?single"])
|
||||||
|
def test_public_links(url):
|
||||||
|
link = parse_message_link(url)
|
||||||
|
assert link.kind == "public" and link.message_id == 14
|
||||||
|
assert link.channel.lower() == "my_channel"
|
||||||
|
assert link.channel_ref.lower() == "my_channel"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,fragment",
|
||||||
|
[
|
||||||
|
("", "send a Telegram message link"),
|
||||||
|
("hello", "not a Telegram"),
|
||||||
|
("https://example.com/c/1/2", "not a Telegram"),
|
||||||
|
("https://t.me/c/3909337424", "must look like"),
|
||||||
|
("https://t.me/c/abc/14", "must look like"),
|
||||||
|
("https://t.me/c/3909337424/0", "positive"),
|
||||||
|
("https://t.me/c/3909337424/x14", "must look like"),
|
||||||
|
("https://t.me/+AbCdEf123", "Invite links"),
|
||||||
|
("https://t.me/joinchat/AbCdEf", "Invite links"),
|
||||||
|
("https://t.me/my_channel", "not to a message"),
|
||||||
|
("https://t.me/addstickers/pack/1", "Unsupported"),
|
||||||
|
("https://t.me/ab/14", "Unsupported"),
|
||||||
|
("ftp://t.me/c/1/2", "not a Telegram"),
|
||||||
|
("https://t.me/c/3909337424/14 https://t.me/c/3909337424/15", "only the link"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rejected_links(text, fragment):
|
||||||
|
with pytest.raises(LinkError) as err:
|
||||||
|
parse_message_link(text)
|
||||||
|
assert fragment.lower() in str(err.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_marked_id_follows_telegram_convention():
|
||||||
|
# -100 prefix is only a coincidence of 10-digit IDs; the real rule is -(10**12 + id)
|
||||||
|
assert marked_channel_id(3909337424) == -1003909337424
|
||||||
|
assert marked_channel_id(12345) == -1000000012345
|
||||||
|
assert internal_channel_id(-1000000012345) == 12345
|
||||||
|
assert parse_message_link("https://t.me/c/12345/9").channel_ref == "-1000000012345"
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from conftest import video_bytes
|
||||||
|
from errors import PermanentError
|
||||||
|
from naming import add_suffix, build_filename, clean_component, detect_video_extension, normalize_key, same_family
|
||||||
|
|
||||||
|
|
||||||
|
def _name(title, **kw):
|
||||||
|
defaults = dict(message_id=42, original_filename=None, mime_type="video/mp4", detected_ext=".mp4")
|
||||||
|
defaults.update(kw)
|
||||||
|
return build_filename(title, **defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_used_verbatim():
|
||||||
|
assert _name("Class 01 Aman sir.mp4") == ("Class 01 Aman sir.mp4", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_added_when_missing():
|
||||||
|
assert _name("Class 01 Aman sir")[0] == "Class 01 Aman sir.mp4"
|
||||||
|
# '.5' is not a video extension, so it stays part of the name
|
||||||
|
assert _name("Lecture 1.5", detected_ext=".mkv")[0] == "Lecture 1.5.mkv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_from_mime_when_undetected():
|
||||||
|
assert _name("Clip", detected_ext=None, mime_type="video/x-matroska")[0] == "Clip.mkv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compatible_extension_kept():
|
||||||
|
assert _name("Talk.mov", detected_ext=".mp4") == ("Talk.mov", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatch_kept_by_default_but_reported():
|
||||||
|
name, warnings = _name("Talk.mp4", detected_ext=".mkv")
|
||||||
|
assert name == "Talk.mp4"
|
||||||
|
assert any("does not match" in w for w in warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatch_append_policy():
|
||||||
|
assert _name("Talk.mp4", detected_ext=".mkv", mismatch_policy="append")[0] == "Talk.mp4.mkv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_title_fallbacks():
|
||||||
|
name, warnings = _name(None, original_filename="orig_video.mp4")
|
||||||
|
assert name == "orig_video.mp4" and warnings
|
||||||
|
name, warnings = _name(" ", original_filename=None)
|
||||||
|
assert name == "telegram_42.mp4" and warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_title_fail_policy():
|
||||||
|
with pytest.raises(PermanentError):
|
||||||
|
_name(None, missing_title_policy="fail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitizing():
|
||||||
|
assert clean_component("a/b\\c\x00d\n e") == "a_b_c d e"
|
||||||
|
assert clean_component("Misc• Grammar Practice") == "Misc• Grammar Practice"
|
||||||
|
assert clean_component('Q: what? "x" <y>|z*', strict=True) == "Q_ what_ _x_ _y__z_"
|
||||||
|
assert len(clean_component("x" * 500 + ".mp4")) == 200
|
||||||
|
assert clean_component("x" * 500 + ".mp4").endswith(".mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_suffix():
|
||||||
|
assert add_suffix("Class 01 Aman sir.mp4", "(2)") == "Class 01 Aman sir (2).mp4"
|
||||||
|
assert add_suffix("Class 01", "[77]") == "Class 01 [77]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_container(tmp_path):
|
||||||
|
cases = {
|
||||||
|
".mp4": video_bytes(1000),
|
||||||
|
".mkv": b"\x1a\x45\xdf\xa3" + b"\x00" * 30 + b"matroska" + b"\x00" * 100,
|
||||||
|
".webm": b"\x1a\x45\xdf\xa3" + b"\x00" * 30 + b"webm" + b"\x00" * 100,
|
||||||
|
".avi": b"RIFF\x00\x00\x00\x00AVI LIST",
|
||||||
|
None: b"hello world, not a video",
|
||||||
|
}
|
||||||
|
for expected, data in cases.items():
|
||||||
|
path = tmp_path / "f"
|
||||||
|
path.write_bytes(data)
|
||||||
|
assert detect_video_extension(path) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_families_and_keys():
|
||||||
|
assert same_family(".MP4", ".mov")
|
||||||
|
assert not same_family(".mp4", ".mkv")
|
||||||
|
assert normalize_key(" Misc• Grammar Practice ") == normalize_key("misc• grammar practice")
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from telethon import errors
|
||||||
|
from telethon.tl import types
|
||||||
|
|
||||||
|
from conftest import RecordingNotifier
|
||||||
|
from errors import PermanentError, TransientError
|
||||||
|
from progress import ProgressReporter, TransferProgress, format_bytes, format_duration
|
||||||
|
from telegram_client import classify_media, map_telegram_error, parse_channel_reference, to_source_message
|
||||||
|
|
||||||
|
|
||||||
|
# -- progress ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_formatting():
|
||||||
|
assert format_bytes(512) == "512 B"
|
||||||
|
assert format_bytes(1536) == "1.5 KB"
|
||||||
|
assert format_bytes(5 * 1024**3) == "5.0 GB"
|
||||||
|
assert format_duration(42) == "42s"
|
||||||
|
assert format_duration(125) == "2m 5s"
|
||||||
|
assert format_duration(3 * 3600 + 120) == "3h 2m"
|
||||||
|
|
||||||
|
|
||||||
|
def test_speed_and_eta():
|
||||||
|
now = [0.0]
|
||||||
|
p = TransferProgress(total=100 * 1024 * 1024, clock=lambda: now[0])
|
||||||
|
p.update(0)
|
||||||
|
now[0] = 10.0
|
||||||
|
p.update(50 * 1024 * 1024)
|
||||||
|
assert round(p.speed) == 5 * 1024 * 1024
|
||||||
|
assert round(p.eta) == 10
|
||||||
|
assert p.percent == 50
|
||||||
|
text = p.describe()
|
||||||
|
assert "50%" in text and "MB/s" in text and "ETA 10s" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_reporter_edits_single_message():
|
||||||
|
notifier = RecordingNotifier()
|
||||||
|
reporter = ProgressReporter(notifier, [1, 2], interval=60)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
reporter.board.begin_item(12, 150, "Misc• Grammar Practice", "Class 01 Aman sir.mp4")
|
||||||
|
reporter.board.start_download(1000)
|
||||||
|
reporter.board.download.update(640)
|
||||||
|
await reporter.refresh()
|
||||||
|
await reporter.refresh() # unchanged -> nothing sent
|
||||||
|
reporter.board.download.update(900)
|
||||||
|
await reporter.refresh()
|
||||||
|
|
||||||
|
asyncio.run(go())
|
||||||
|
assert len(notifier.sent) == 2 # one per chat
|
||||||
|
assert len(notifier.edits) == 2 # then edited
|
||||||
|
first = notifier.sent[0][1]
|
||||||
|
assert first.splitlines()[0] == "Processing video 12 of 150"
|
||||||
|
assert "Topic: Misc• Grammar Practice" in first and "Download: 64%" in first and "Upload: Waiting" in first
|
||||||
|
|
||||||
|
|
||||||
|
# -- telegram media classification ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _doc(attributes, mime="video/mp4"):
|
||||||
|
return types.Document(id=77, access_hash=1, file_reference=b"", date=None, mime_type=mime, size=1234, dc_id=1,
|
||||||
|
attributes=attributes)
|
||||||
|
|
||||||
|
|
||||||
|
def _msg(media, text="Title: A.mp4"):
|
||||||
|
return types.Message(id=5, peer_id=types.PeerChannel(1), date=None, message=text, media=media)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_video_message():
|
||||||
|
video = types.DocumentAttributeVideo(duration=60, w=1280, h=720)
|
||||||
|
info = classify_media(_msg(types.MessageMediaDocument(document=_doc([video]))), include_documents=False)
|
||||||
|
assert info.kind == "video" and info.size == 1234 and info.media_id == 77
|
||||||
|
sm = to_source_message(_msg(types.MessageMediaDocument(document=_doc([video]))), -100, False)
|
||||||
|
assert sm.caption == "Title: A.mp4" and sm.message_id == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_document_video_only_when_enabled():
|
||||||
|
doc = _doc([types.DocumentAttributeFilename("lecture.mkv")], mime="application/octet-stream")
|
||||||
|
message = _msg(types.MessageMediaDocument(document=doc))
|
||||||
|
assert classify_media(message, include_documents=False) is None
|
||||||
|
assert classify_media(message, include_documents=True).file_name == "lecture.mkv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_media_is_ignored():
|
||||||
|
gif = _doc([types.DocumentAttributeVideo(duration=1, w=1, h=1), types.DocumentAttributeAnimated()])
|
||||||
|
note = _doc([types.DocumentAttributeVideo(duration=1, w=1, h=1, round_message=True)])
|
||||||
|
pdf = _doc([types.DocumentAttributeFilename("notes.pdf")], mime="application/pdf")
|
||||||
|
for doc in (gif, note, pdf):
|
||||||
|
assert classify_media(_msg(types.MessageMediaDocument(document=doc)), include_documents=True) is None
|
||||||
|
photo = types.MessageMediaPhoto(photo=types.PhotoEmpty(id=1))
|
||||||
|
assert classify_media(_msg(photo), include_documents=True) is None
|
||||||
|
assert classify_media(_msg(None, text="text only"), include_documents=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_reference_parsing():
|
||||||
|
assert parse_channel_reference("-1001234567890") == -1001234567890
|
||||||
|
assert parse_channel_reference("@my_channel") == "my_channel"
|
||||||
|
assert parse_channel_reference("https://t.me/my_channel") == "my_channel"
|
||||||
|
assert parse_channel_reference("https://t.me/c/1234567890/55") == -1001234567890
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_error_mapping():
|
||||||
|
flood = map_telegram_error(errors.FloodWaitError(request=None, capture=42))
|
||||||
|
assert isinstance(flood, TransientError) and flood.retry_after == 42 and flood.rate_limited
|
||||||
|
assert isinstance(map_telegram_error(errors.ChannelPrivateError(request=None)), PermanentError)
|
||||||
|
assert isinstance(map_telegram_error(ConnectionError("x")), TransientError)
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from conftest import (
|
||||||
|
CHANNEL_ID,
|
||||||
|
EXAMPLE_CAPTION,
|
||||||
|
FakeDrive,
|
||||||
|
FakeSource,
|
||||||
|
RecordingNotifier,
|
||||||
|
SimulatedCrash,
|
||||||
|
build_queue,
|
||||||
|
make_config,
|
||||||
|
run,
|
||||||
|
video_bytes,
|
||||||
|
)
|
||||||
|
from database import Database
|
||||||
|
from drive_client import SOURCE_PROPERTY
|
||||||
|
from errors import PermanentError, TransientError
|
||||||
|
from models import State
|
||||||
|
from progress import ProgressReporter
|
||||||
|
|
||||||
|
|
||||||
|
def caption(title, topic, batch="English ( SSC MAINS )", index="001"):
|
||||||
|
return f"Index: {index}\nTitle: {title}\nTopic: {topic}\nBatch: {batch}\nExtracted By: @someone"
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_and_run(queue):
|
||||||
|
await queue.sync()
|
||||||
|
queue.start()
|
||||||
|
await queue.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def key(message_id):
|
||||||
|
return (CHANNEL_ID, message_id)
|
||||||
|
|
||||||
|
|
||||||
|
def drive_names(drive, folder_name=None):
|
||||||
|
names = []
|
||||||
|
for f in drive.uploaded():
|
||||||
|
parent = drive.files[f.parents[0]].name
|
||||||
|
if folder_name is None or parent == folder_name:
|
||||||
|
names.append(f.name)
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
# -- end to end ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_pipeline_topics_and_metadata(config, db, source, drive):
|
||||||
|
source.add(1, EXAMPLE_CAPTION)
|
||||||
|
source.add(2, caption("Class 02 Aman sir.mp4", "Misc• Grammar Practice"))
|
||||||
|
source.add(3, caption("Class 01 Aman sir.mp4", "Error Detection"))
|
||||||
|
source.add_text(4, "just an announcement")
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
run(sync_and_run(queue))
|
||||||
|
|
||||||
|
assert db.counts()["COMPLETED"] == 3
|
||||||
|
assert drive_names(drive, "Misc• Grammar Practice") == ["Class 01 Aman sir.mp4", "Class 02 Aman sir.mp4"]
|
||||||
|
assert drive_names(drive, "Error Detection") == ["Class 01 Aman sir.mp4"]
|
||||||
|
assert drive.created_folders == 2 # topic folder reused for the second video
|
||||||
|
|
||||||
|
rec = db.get(key(1))
|
||||||
|
assert rec.indexes == ["001", "002"]
|
||||||
|
assert rec.batch == "English ( SSC MAINS )" and rec.extracted_by == "@PUTINxINDIA"
|
||||||
|
assert rec.final_name == "Class 01 Aman sir.mp4"
|
||||||
|
uploaded = drive.files[rec.drive_file_id]
|
||||||
|
assert uploaded.app_properties[SOURCE_PROPERTY] == f"{CHANNEL_ID}:1"
|
||||||
|
assert uploaded.size == rec.local_size == rec.expected_size
|
||||||
|
assert uploaded.md5 == rec.md5
|
||||||
|
assert rec.sha256 and rec.completed_at
|
||||||
|
assert rec.download_status == "done" and rec.upload_status == "done"
|
||||||
|
# temporary files are removed only after completion
|
||||||
|
assert list(config.download_dir.iterdir()) == []
|
||||||
|
assert queue.stats.uploaded == 3
|
||||||
|
assert db.get_int("last_completed_message_id") == 3
|
||||||
|
assert db.get_int("last_discovered_message_id") == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_topic_and_title_use_fallbacks(config, db, source, drive):
|
||||||
|
source.add(1, "Title: Lonely.mp4")
|
||||||
|
source.add(2, "Topic: Grammar", file_name="original_upload.mp4")
|
||||||
|
source.add(3, "")
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
run(sync_and_run(queue))
|
||||||
|
assert drive_names(drive, "Uncategorized") == ["Lonely.mp4", "telegram_3.mp4"]
|
||||||
|
assert drive_names(drive, "Grammar") == ["original_upload.mp4"]
|
||||||
|
assert any("Title missing" in w for w in db.get(key(2)).warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_subfolders(tmp_path, source, drive):
|
||||||
|
config = make_config(tmp_path, BATCH_SUBFOLDERS_ENABLED="true")
|
||||||
|
db = Database.from_config(config)
|
||||||
|
source.add(1, EXAMPLE_CAPTION)
|
||||||
|
run(sync_and_run(build_queue(config, db, source, drive)))
|
||||||
|
f = drive.uploaded()[0]
|
||||||
|
topic_folder = drive.files[f.parents[0]]
|
||||||
|
assert topic_folder.name == "Misc• Grammar Practice"
|
||||||
|
assert drive.files[topic_folder.parents[0]].name == "English ( SSC MAINS )"
|
||||||
|
|
||||||
|
|
||||||
|
# -- duplicates ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_message_detection(config, db, source, drive):
|
||||||
|
msg = source.add(1, EXAMPLE_CAPTION, media_id=999)
|
||||||
|
forwarded = source.add(2, EXAMPLE_CAPTION, video_bytes(700_000, seed=1), media_id=999)
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
assert queue.register(msg) == "new"
|
||||||
|
assert queue.register(msg) == "known"
|
||||||
|
assert queue.register(forwarded) == "skipped"
|
||||||
|
assert "duplicate of message 1" in db.get(key(2)).skip_reason
|
||||||
|
run(sync_and_run(queue))
|
||||||
|
assert len(drive.uploaded()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_filename_collision_gets_suffix(config, db, source, drive):
|
||||||
|
for i in (1, 2, 3):
|
||||||
|
source.add(i, caption("Class 01 Aman sir.mp4", "Misc• Grammar Practice"))
|
||||||
|
run(sync_and_run(build_queue(config, db, source, drive)))
|
||||||
|
assert drive_names(drive) == ["Class 01 Aman sir (2).mp4", "Class 01 Aman sir (3).mp4", "Class 01 Aman sir.mp4"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_different_file_is_never_overwritten(config, db, source, drive):
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
folder_id = queue.folders.resolve(["Topic"])
|
||||||
|
existing = drive.add_file(folder_id, "Class 01.mp4", b"someone else's video")
|
||||||
|
source.add(1, caption("Class 01.mp4", "Topic"))
|
||||||
|
run(sync_and_run(queue))
|
||||||
|
assert drive.files[existing.id].md5 == hashlib.md5(b"someone else's video").hexdigest()
|
||||||
|
assert drive_names(drive) == ["Class 01 (2).mp4", "Class 01.mp4"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"policy,expected_state,expected_names",
|
||||||
|
[
|
||||||
|
("suffix_message_id", State.COMPLETED, ["X [2].mp4", "X.mp4"]),
|
||||||
|
("skip", State.SKIPPED, ["X.mp4"]),
|
||||||
|
("fail", State.FAILED, ["X.mp4"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_duplicate_policies(tmp_path, source, drive, policy, expected_state, expected_names):
|
||||||
|
config = make_config(tmp_path, DUPLICATE_POLICY=policy)
|
||||||
|
db = Database.from_config(config)
|
||||||
|
source.add(1, caption("X.mp4", "T"))
|
||||||
|
source.add(2, caption("X.mp4", "T"))
|
||||||
|
run(sync_and_run(build_queue(config, db, source, drive)))
|
||||||
|
assert db.get(key(2)).state == expected_state
|
||||||
|
assert drive_names(drive) == expected_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_existing_file_is_reused_not_duplicated(config, db, source, drive):
|
||||||
|
content = video_bytes(700_000, seed=5)
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
folder_id = queue.folders.resolve(["T"])
|
||||||
|
existing = drive.add_file(folder_id, "X.mp4", content)
|
||||||
|
source.add(1, caption("X.mp4", "T"), content)
|
||||||
|
run(sync_and_run(queue))
|
||||||
|
assert db.get(key(1)).drive_file_id == existing.id
|
||||||
|
assert len(drive.uploaded()) == 1 and drive.upload_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
# -- failures and recovery -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_download_is_retried_and_resumed(config, db, source, drive):
|
||||||
|
content = video_bytes(3 * 1024 * 1024 + 123, seed=9)
|
||||||
|
source.add(1, caption("Big.mp4", "T"), content)
|
||||||
|
source.failures[1] = [("after", 2 * 1024 * 1024, ConnectionError("network dropped"))]
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
run(sync_and_run(queue))
|
||||||
|
rec = db.get(key(1))
|
||||||
|
assert rec.state == State.COMPLETED and rec.attempts == 1
|
||||||
|
offsets = [o for m, o in source.download_offsets if m == 1]
|
||||||
|
assert offsets[0] == 0 and offsets[1] == 2 * 1024 * 1024 # resumed, not restarted
|
||||||
|
assert drive.uploaded()[0].md5 == hashlib.md5(content).hexdigest()
|
||||||
|
assert State.RETRY_WAIT.value in [h[1] for h in db.history(key(1))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_upload_is_retried(config, db, source, drive):
|
||||||
|
source.add(1, caption("A.mp4", "T"))
|
||||||
|
drive.upload_failures = [TransientError("Drive 503")]
|
||||||
|
run(sync_and_run(build_queue(config, db, source, drive)))
|
||||||
|
assert db.get(key(1)).state == State.COMPLETED
|
||||||
|
assert len(drive.uploaded()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_retries_are_bounded(config, db, source, drive):
|
||||||
|
source.add(1, caption("A.mp4", "T"))
|
||||||
|
source.add(2, caption("B.mp4", "T"))
|
||||||
|
drive.upload_failures = [TransientError("Drive 503")] * config.max_attempts
|
||||||
|
run(sync_and_run(build_queue(config, db, source, drive)))
|
||||||
|
rec = db.get(key(1))
|
||||||
|
assert rec.state == State.FAILED and rec.attempts == config.max_attempts
|
||||||
|
assert "Drive 503" in rec.last_error
|
||||||
|
assert db.get(key(2)).state == State.COMPLETED # the queue was not blocked
|
||||||
|
|
||||||
|
|
||||||
|
def test_permanent_error_fails_without_blocking_queue(config, db, source, drive):
|
||||||
|
source.add(1, caption("Gone.mp4", "T"))
|
||||||
|
source.add(2, caption("B.mp4", "T"))
|
||||||
|
source.failures[1] = [PermanentError("message deleted")]
|
||||||
|
run(sync_and_run(build_queue(config, db, source, drive)))
|
||||||
|
assert db.get(key(1)).state == State.FAILED and db.get(key(1)).attempts == 0
|
||||||
|
assert db.get(key(2)).state == State.COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_stop_halts_after_failure(tmp_path, source, drive):
|
||||||
|
config = make_config(tmp_path, STRICT_STOP="true")
|
||||||
|
db = Database.from_config(config)
|
||||||
|
source.add(1, caption("Gone.mp4", "T"))
|
||||||
|
source.add(2, caption("B.mp4", "T"))
|
||||||
|
source.failures[1] = [PermanentError("message deleted")]
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
run(sync_and_run(queue))
|
||||||
|
assert queue.status == "halted"
|
||||||
|
assert db.get(key(2)).state == State.PENDING
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_command_requeues_failed(config, db, source, drive):
|
||||||
|
source.add(1, caption("A.mp4", "T"))
|
||||||
|
source.failures[1] = [PermanentError("temporarily missing")]
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
run(sync_and_run(queue))
|
||||||
|
assert db.get(key(1)).state == State.FAILED
|
||||||
|
|
||||||
|
async def retry():
|
||||||
|
assert queue.retry_failed() == 1
|
||||||
|
queue.start()
|
||||||
|
await queue.wait()
|
||||||
|
|
||||||
|
run(retry())
|
||||||
|
assert db.get(key(1)).state == State.COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_full_halts_queue_without_using_attempts(config, db, source, drive):
|
||||||
|
source.add(1, caption("A.mp4", "T"))
|
||||||
|
queue = build_queue(config, db, source, drive, disk_usage=lambda p: (100, 100, 10))
|
||||||
|
run(sync_and_run(queue))
|
||||||
|
rec = db.get(key(1))
|
||||||
|
assert queue.status == "halted" and "disk space" in queue.halt_reason
|
||||||
|
assert rec.state == State.RETRY_WAIT and rec.attempts == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_during_upload_resumes_session(config, source, drive):
|
||||||
|
source.add(1, caption("Big.mp4", "T"), video_bytes(2_000_000, seed=3))
|
||||||
|
db = Database.from_config(config)
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
drive.crash_at_bytes = 700_000
|
||||||
|
|
||||||
|
async def crash_run():
|
||||||
|
await queue.sync()
|
||||||
|
with pytest.raises(SimulatedCrash):
|
||||||
|
await queue.process_item(db.next_item())
|
||||||
|
|
||||||
|
run(crash_run())
|
||||||
|
rec = db.get(key(1))
|
||||||
|
assert rec.state == State.UPLOADING and rec.upload_session_uri
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# "new process"
|
||||||
|
db2 = Database.from_config(config)
|
||||||
|
db2.recover_stale()
|
||||||
|
queue2 = build_queue(config, db2, source, drive)
|
||||||
|
|
||||||
|
async def resume():
|
||||||
|
queue2.start()
|
||||||
|
await queue2.wait()
|
||||||
|
|
||||||
|
run(resume())
|
||||||
|
assert db2.get(key(1)).state == State.COMPLETED
|
||||||
|
assert drive.resumed and drive.resumed[0][1] >= 700_000
|
||||||
|
assert len(drive.uploaded()) == 1
|
||||||
|
assert len([1 for m, _ in source.download_offsets if m == 1]) == 1 # local file reused, no re-download
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_with_wiped_disk_redownloads_and_resumes_upload(config, source, drive):
|
||||||
|
"""Heroku dyno restart: ephemeral disk is gone but the DB and upload session survive."""
|
||||||
|
source.add(1, caption("Big.mp4", "T"), video_bytes(2_000_000, seed=4))
|
||||||
|
db = Database.from_config(config)
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
drive.crash_at_bytes = 1_000_000
|
||||||
|
|
||||||
|
async def crash_run():
|
||||||
|
await queue.sync()
|
||||||
|
with pytest.raises(SimulatedCrash):
|
||||||
|
await queue.process_item(db.next_item())
|
||||||
|
|
||||||
|
run(crash_run())
|
||||||
|
shutil.rmtree(config.download_dir)
|
||||||
|
db2 = Database.from_config(config)
|
||||||
|
db2.recover_stale()
|
||||||
|
queue2 = build_queue(config, db2, source, drive)
|
||||||
|
run(_start_and_wait(queue2))
|
||||||
|
assert db2.get(key(1)).state == State.COMPLETED
|
||||||
|
assert len([1 for m, _ in source.download_offsets if m == 1]) == 2
|
||||||
|
assert drive.resumed and drive.resumed[0][1] >= 1_000_000
|
||||||
|
assert len(drive.uploaded()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_and_wait(queue):
|
||||||
|
queue.start()
|
||||||
|
await queue.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_success_then_database_write_failure(config, source, drive):
|
||||||
|
source.add(1, caption("A.mp4", "T"))
|
||||||
|
db = Database.from_config(config)
|
||||||
|
real_transition = db.transition
|
||||||
|
failed = {"done": False}
|
||||||
|
|
||||||
|
def flaky_transition(k, state, **kw):
|
||||||
|
if state == State.COMPLETED and not failed["done"]:
|
||||||
|
failed["done"] = True
|
||||||
|
raise db.errors[0]("database is locked")
|
||||||
|
return real_transition(k, state, **kw)
|
||||||
|
|
||||||
|
db.transition = flaky_transition
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
|
||||||
|
async def first():
|
||||||
|
await queue.sync()
|
||||||
|
await queue.process_item(db.next_item())
|
||||||
|
|
||||||
|
run(first())
|
||||||
|
rec = db.get(key(1))
|
||||||
|
assert rec.state == State.UPLOADING # completion was not recorded
|
||||||
|
assert len(drive.uploaded()) == 1
|
||||||
|
assert (config.download_dir / f"{abs(CHANNEL_ID)}_1.video").exists() # not cleaned up
|
||||||
|
|
||||||
|
# Same process continues (or a restart): it reconciles instead of re-uploading.
|
||||||
|
run(_start_and_wait(queue))
|
||||||
|
rec = db.get(key(1))
|
||||||
|
assert rec.state == State.COMPLETED
|
||||||
|
assert len(drive.uploaded()) == 1 and drive.upload_calls == 1
|
||||||
|
assert rec.drive_file_id == drive.uploaded()[0].id
|
||||||
|
|
||||||
|
|
||||||
|
def test_lost_upload_response_does_not_duplicate(config, db, source, drive):
|
||||||
|
source.add(1, caption("A.mp4", "T"))
|
||||||
|
drive.lose_response_once = True
|
||||||
|
run(sync_and_run(build_queue(config, db, source, drive)))
|
||||||
|
assert db.get(key(1)).state == State.COMPLETED
|
||||||
|
assert len(drive.uploaded()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -- control / concurrency ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sequential_processing_never_overlaps(config, db, source, drive):
|
||||||
|
for i in range(1, 6):
|
||||||
|
source.add(i, caption(f"Class {i}.mp4", "T"))
|
||||||
|
events = []
|
||||||
|
source.events = events
|
||||||
|
drive.events_sink = events
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
await queue.sync()
|
||||||
|
assert queue.start() is True
|
||||||
|
assert queue.start() is False # a second worker is never created
|
||||||
|
await queue.wait()
|
||||||
|
|
||||||
|
run(go())
|
||||||
|
assert drive.max_active_uploads == 1 and queue.max_concurrent_uploads == 1
|
||||||
|
order = [(kind, str(ident).split(":")[-1]) for kind, ident in events]
|
||||||
|
expected = []
|
||||||
|
for i in range(1, 6):
|
||||||
|
expected += [("download", str(i)), ("upload", str(i))]
|
||||||
|
assert order == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_checkpoint_and_resume(config, db, source, drive):
|
||||||
|
source.add(1, caption("Big.mp4", "T"), video_bytes(3_000_000, seed=7))
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
await queue.sync()
|
||||||
|
queue.start()
|
||||||
|
while not (config.download_dir / f"{abs(CHANNEL_ID)}_1.part").exists():
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert queue.pause()
|
||||||
|
for _ in range(200):
|
||||||
|
await asyncio.sleep(0.005)
|
||||||
|
if db.get(key(1)).state == State.PENDING:
|
||||||
|
break
|
||||||
|
assert db.get(key(1)).state == State.PENDING
|
||||||
|
assert queue.status == "paused"
|
||||||
|
assert queue.desired_state() == "paused"
|
||||||
|
assert queue.resume()
|
||||||
|
while db.get(key(1)).state != State.COMPLETED:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
await queue.stop()
|
||||||
|
assert queue.desired_state() == "stopped"
|
||||||
|
|
||||||
|
run(go())
|
||||||
|
assert len(drive.uploaded()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_graceful_shutdown_keeps_desired_state_and_recovers(config, source, drive):
|
||||||
|
source.add(1, caption("Big.mp4", "T"), video_bytes(3_000_000, seed=8))
|
||||||
|
db = Database.from_config(config)
|
||||||
|
queue = build_queue(config, db, source, drive)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
await queue.sync()
|
||||||
|
queue.start()
|
||||||
|
while db.get(key(1)).state != State.UPLOADING:
|
||||||
|
await asyncio.sleep(0.001)
|
||||||
|
await queue.shutdown(timeout=10)
|
||||||
|
|
||||||
|
run(go())
|
||||||
|
rec = db.get(key(1))
|
||||||
|
assert rec.state == State.UPLOADING and rec.upload_session_uri
|
||||||
|
assert queue.desired_state() == "running" # auto-resumes after restart
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
db2 = Database.from_config(config)
|
||||||
|
db2.recover_stale()
|
||||||
|
queue2 = build_queue(config, db2, source, drive)
|
||||||
|
run(_start_and_wait(queue2))
|
||||||
|
assert db2.get(key(1)).state == State.COMPLETED
|
||||||
|
assert len(drive.uploaded()) == 1 and drive.resumed
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_messages_are_edited_not_spammed(config, db, source, drive):
|
||||||
|
for i in (1, 2):
|
||||||
|
source.add(i, caption(f"Class {i}.mp4", "Misc• Grammar Practice"))
|
||||||
|
notifier = RecordingNotifier()
|
||||||
|
reporter = ProgressReporter(notifier, [111], interval=1)
|
||||||
|
queue = build_queue(config, db, source, drive, reporter=reporter)
|
||||||
|
run(sync_and_run(queue))
|
||||||
|
texts = [t for _, t in notifier.sent]
|
||||||
|
progress_messages = [t for t in texts if t.startswith("Processing video") or t == "Queue idle"]
|
||||||
|
assert len(progress_messages) <= 1 # one progress message, then edits
|
||||||
|
assert texts[-1].startswith("Run finished") and "Uploaded: 2" in texts[-1]
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"""Acceptance tests for the interactive /upload workflow (mocked Telegram and Drive)."""
|
||||||
|
import asyncio
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bot_handlers import CommandRouter
|
||||||
|
from conftest import (
|
||||||
|
CHANNEL_ID,
|
||||||
|
FakeDrive,
|
||||||
|
FakeSource,
|
||||||
|
RecordingNotifier,
|
||||||
|
SimulatedCrash,
|
||||||
|
build_queue,
|
||||||
|
make_config,
|
||||||
|
run,
|
||||||
|
video_bytes,
|
||||||
|
)
|
||||||
|
from database import Database, iso, utcnow
|
||||||
|
from errors import PermanentError
|
||||||
|
from link_parser import internal_channel_id
|
||||||
|
from models import State, TaskStatus
|
||||||
|
from progress import ProgressReporter
|
||||||
|
|
||||||
|
A, B, STRANGER = 111, 222, 999
|
||||||
|
INTERNAL = internal_channel_id(CHANNEL_ID) # 1234567890
|
||||||
|
OTHER_CHANNEL = -1000999999999 # accessible, but not the configured channel
|
||||||
|
|
||||||
|
|
||||||
|
def link(message_id, internal=INTERNAL):
|
||||||
|
return f"https://t.me/c/{internal}/{message_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def caption(title, topic):
|
||||||
|
return f"Index: 001\nTitle: {title}\nTopic: {topic}\nBatch: SSC\nExtracted By: @x"
|
||||||
|
|
||||||
|
|
||||||
|
def populate(source, size=600_000):
|
||||||
|
"""Messages 13..21; 15 is text, 18 is missing; 13 and 21 lie outside the 14-20 range."""
|
||||||
|
source.add(13, caption("Outside Before.mp4", "Topic X"), video_bytes(size, 13))
|
||||||
|
source.add(14, caption("Class 01 Aman sir.mp4", "Misc• Grammar Practice"), video_bytes(size, 14))
|
||||||
|
source.add_text(15, "Announcement: new classes tomorrow")
|
||||||
|
source.add(16, caption("Class 01 Aman sir.mp4", "Error Detection"), video_bytes(size, 16))
|
||||||
|
source.add(17, caption("Class 02 Aman sir.mp4", "Misc• Grammar Practice"), video_bytes(size, 17))
|
||||||
|
source.add(19, caption("Class 03 Aman sir", "Misc• Grammar Practice"), video_bytes(size, 19))
|
||||||
|
source.add(20, "", video_bytes(size, 20))
|
||||||
|
source.add(21, caption("Outside After.mp4", "Topic X"), video_bytes(size, 21))
|
||||||
|
source.accessible[OTHER_CHANNEL] = "Other Channel"
|
||||||
|
|
||||||
|
|
||||||
|
class Env:
|
||||||
|
def __init__(self, config, db, source, drive, notifier=None):
|
||||||
|
self.config, self.db, self.source, self.drive = config, db, source, drive
|
||||||
|
self.notifier = notifier or RecordingNotifier()
|
||||||
|
reporter = ProgressReporter(self.notifier, [A], interval=60)
|
||||||
|
self.queue = build_queue(config, db, source, drive, reporter=reporter)
|
||||||
|
self.router = CommandRouter(config, db, self.queue)
|
||||||
|
|
||||||
|
async def say(self, user, *texts):
|
||||||
|
replies = []
|
||||||
|
for text in texts:
|
||||||
|
replies.append(await self.router.handle(user, text))
|
||||||
|
return replies
|
||||||
|
|
||||||
|
def setup(self, user):
|
||||||
|
return self.db.tasks_with_status(
|
||||||
|
TaskStatus.AWAITING_START, TaskStatus.AWAITING_END, TaskStatus.AWAITING_CONFIRMATION, admin_user_id=user
|
||||||
|
)
|
||||||
|
|
||||||
|
def drive_names(self):
|
||||||
|
return sorted((self.drive.files[f.parents[0]].name, f.name) for f in self.drive.uploaded())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(tmp_path, db_url):
|
||||||
|
extra = {"DATABASE_URL": db_url} if db_url else {}
|
||||||
|
config = make_config(tmp_path, ADMIN_USER_IDS=f"{A},{B}", **extra)
|
||||||
|
db = Database.from_config(config)
|
||||||
|
source = FakeSource()
|
||||||
|
populate(source)
|
||||||
|
e = Env(config, db, source, FakeDrive())
|
||||||
|
yield e
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def full_task(env, start=14, end=20, user=A):
|
||||||
|
replies = await env.say(user, "/upload", link(start), link(end), "/confirm")
|
||||||
|
await env.queue.wait()
|
||||||
|
return replies
|
||||||
|
|
||||||
|
|
||||||
|
# 1-4, 7-10: conversation, inclusive ascending range, metadata, skips ------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_conversation_and_range_processing(env):
|
||||||
|
async def go():
|
||||||
|
r = await env.say(A, "/upload")
|
||||||
|
assert "📥 <b>Start Upload Task</b>" in r[0] and "https://t.me/c/3909337424/14" in r[0]
|
||||||
|
assert env.setup(A)[0].status == TaskStatus.AWAITING_START
|
||||||
|
|
||||||
|
r = await env.say(A, link(14))
|
||||||
|
assert "📌 <b>Enter the End Link</b>" in r[0]
|
||||||
|
assert env.setup(A)[0].start_message_id == 14
|
||||||
|
|
||||||
|
r = await env.say(A, link(20))
|
||||||
|
assert "✅ <b>Upload Task Ready</b>" in r[0]
|
||||||
|
assert "Start Message ID: 14" in r[0] and "End Message ID: 20" in r[0]
|
||||||
|
assert "Test Channel (configured source channel)" in r[0]
|
||||||
|
assert env.source.download_offsets == [] # nothing downloaded before /confirm
|
||||||
|
|
||||||
|
r = await env.say(A, "/confirm")
|
||||||
|
assert "Upload Task #1 confirmed" in r[0]
|
||||||
|
await env.queue.wait()
|
||||||
|
|
||||||
|
run(go())
|
||||||
|
task = env.db.get_task(1)
|
||||||
|
assert task.status == TaskStatus.COMPLETED
|
||||||
|
# inclusive range 14..20, nothing outside it
|
||||||
|
assert [m for m, _ in env.db.task_items(1)] == [14, 15, 16, 17, 18, 19, 20]
|
||||||
|
downloaded = [m for kind, m in env.source.events if kind == "download"]
|
||||||
|
assert downloaded == [14, 16, 17, 19, 20] # ascending, videos only
|
||||||
|
assert not env.db.exists(CHANNEL_ID, 13) and not env.db.exists(CHANNEL_ID, 21)
|
||||||
|
# caption metadata -> folders and filenames
|
||||||
|
assert env.drive_names() == [
|
||||||
|
("Error Detection", "Class 01 Aman sir.mp4"),
|
||||||
|
("Misc• Grammar Practice", "Class 01 Aman sir.mp4"),
|
||||||
|
("Misc• Grammar Practice", "Class 02 Aman sir.mp4"),
|
||||||
|
("Misc• Grammar Practice", "Class 03 Aman sir.mp4"),
|
||||||
|
("Uncategorized", "telegram_20.mp4"),
|
||||||
|
]
|
||||||
|
# non-video and missing messages are skipped, not failed
|
||||||
|
assert dict(env.db.task_items(1))[15] == "non_video"
|
||||||
|
assert dict(env.db.task_items(1))[18] == "missing"
|
||||||
|
assert (task.uploaded_count, task.failed_count, task.non_video_count, task.missing_count) == (5, 0, 1, 1)
|
||||||
|
assert task.examined_count == 6 and task.eligible_count == 5
|
||||||
|
assert task.deleted_files_count == 5
|
||||||
|
assert list(env.config.download_dir.iterdir()) == []
|
||||||
|
summary = [t for _, t in env.notifier.sent if "Upload Task Completed" in t][0]
|
||||||
|
for fragment in ("Requested range: 14–20", "Eligible videos found: 5", "Uploaded: 5", "Duplicates skipped: 0",
|
||||||
|
"Non-video messages skipped: 1", "Failed: 0", "Local temporary files deleted: 5",
|
||||||
|
"Misc• Grammar Practice", "Error Detection"):
|
||||||
|
assert fragment in summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_requires_both_validated_links(env):
|
||||||
|
async def go():
|
||||||
|
assert "no upload task waiting" in (await env.say(A, "/confirm"))[0]
|
||||||
|
await env.say(A, "/upload")
|
||||||
|
assert "Both the start and end links" in (await env.say(A, "/confirm"))[0]
|
||||||
|
await env.say(A, link(14))
|
||||||
|
assert "Both the start and end links" in (await env.say(A, "/confirm"))[0]
|
||||||
|
assert env.db.next_runnable_task() is None
|
||||||
|
|
||||||
|
run(go())
|
||||||
|
assert env.source.download_offsets == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad,fragment",
|
||||||
|
[
|
||||||
|
("example.com/post/14", "not a Telegram"),
|
||||||
|
("https://t.me/+invite", "Invite links"),
|
||||||
|
("https://t.me/c/1234567890", "must look like"),
|
||||||
|
("https://t.me/c/555/14", "not accessible"), # channel the account cannot access
|
||||||
|
(link(14, internal_channel_id(OTHER_CHANNEL)), "not from the configured source channel"),
|
||||||
|
(link(18), "not found"), # deleted message
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_start_links_rejected(env, bad, fragment):
|
||||||
|
replies = run(env.say(A, "/upload", bad))
|
||||||
|
assert replies[1].startswith("❌") and fragment in replies[1]
|
||||||
|
assert env.setup(A)[0].status == TaskStatus.AWAITING_START
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_link_errors_keep_start_link(env):
|
||||||
|
replies = run(env.say(
|
||||||
|
A, "/upload", link(14),
|
||||||
|
link(20, internal_channel_id(OTHER_CHANNEL)), # different channel
|
||||||
|
link(13), # before start
|
||||||
|
"https://t.me/c/1234567890/abc", # malformed
|
||||||
|
))
|
||||||
|
assert "different channel" in replies[2]
|
||||||
|
assert "greater than or equal" in replies[3]
|
||||||
|
assert replies[4].startswith("❌")
|
||||||
|
setup = env.setup(A)[0]
|
||||||
|
assert setup.status == TaskStatus.AWAITING_END and setup.start_message_id == 14
|
||||||
|
assert "Upload Task Ready" in run(env.say(A, link(14)))[0] # single-message range is allowed
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_channels_allowed_when_unrestricted(tmp_path):
|
||||||
|
config = make_config(tmp_path, ADMIN_USER_IDS=str(A), UPLOAD_RESTRICT_TO_SOURCE_CHANNEL="false")
|
||||||
|
db = Database.from_config(config)
|
||||||
|
source = FakeSource()
|
||||||
|
populate(source)
|
||||||
|
e = Env(config, db, source, FakeDrive())
|
||||||
|
|
||||||
|
async def other_exists(channel_id, message_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
source.message_accessible = other_exists
|
||||||
|
replies = run(e.say(A, "/upload", link(1, internal_channel_id(OTHER_CHANNEL)), link(5, internal_channel_id(OTHER_CHANNEL))))
|
||||||
|
assert "Channel: Other Channel" in replies[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_size_limit(tmp_path):
|
||||||
|
config = make_config(tmp_path, ADMIN_USER_IDS=str(A), UPLOAD_MAX_RANGE="3")
|
||||||
|
e = Env(config, Database.from_config(config), FakeSource(), FakeDrive())
|
||||||
|
populate(e.source)
|
||||||
|
replies = run(e.say(A, "/upload", link(14), link(20)))
|
||||||
|
assert "maximum is 3" in replies[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admins_cannot_use_upload_commands(env):
|
||||||
|
for command in ("/upload", "/confirm", "/cancel", "/taskstatus", "/uploadhistory"):
|
||||||
|
assert "not authorized" in run(env.say(STRANGER, command))[0]
|
||||||
|
assert run(env.say(STRANGER, link(14)))[0] is None # plain text from non-admins is ignored
|
||||||
|
assert env.db.recent_tasks() == [] and env.setup(STRANGER) == []
|
||||||
|
|
||||||
|
|
||||||
|
# 15: administrators are isolated ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_setups_are_isolated(env):
|
||||||
|
async def go():
|
||||||
|
await env.say(A, "/upload", link(14))
|
||||||
|
r = await env.say(B, link(16))
|
||||||
|
assert "send /upload first" in r[0]
|
||||||
|
await env.say(B, "/upload", link(16))
|
||||||
|
assert env.setup(A)[0].start_message_id == 14
|
||||||
|
assert env.setup(B)[0].start_message_id == 16
|
||||||
|
r = await env.say(B, "/cancel")
|
||||||
|
assert "cancelled" in r[0]
|
||||||
|
assert env.setup(B) == []
|
||||||
|
assert env.setup(A)[0].status == TaskStatus.AWAITING_END # untouched
|
||||||
|
await env.say(A, link(17), "/confirm")
|
||||||
|
r = await env.say(B, "/cancel")
|
||||||
|
assert "belong to another administrator" in r[0]
|
||||||
|
await env.queue.wait()
|
||||||
|
|
||||||
|
run(go())
|
||||||
|
assert env.db.get_task(1).status == TaskStatus.COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_upload_does_not_overwrite_setup(env):
|
||||||
|
replies = run(env.say(A, "/upload", link(14), "/upload"))
|
||||||
|
assert "already have an upload setup" in replies[2]
|
||||||
|
setup = env.setup(A)
|
||||||
|
assert len(setup) == 1 and setup[0].start_message_id == 14
|
||||||
|
|
||||||
|
|
||||||
|
# setup persistence / expiry ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_survives_restart(env):
|
||||||
|
run(env.say(A, "/upload", link(14)))
|
||||||
|
# new process: fresh queue/router on the same durable database
|
||||||
|
again = Env(env.config, env.db, env.source, env.drive)
|
||||||
|
replies = run(again.say(A, link(20)))
|
||||||
|
assert "Upload Task Ready" in replies[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_setup_expires(env):
|
||||||
|
run(env.say(A, "/upload", link(14)))
|
||||||
|
setup = env.setup(A)[0]
|
||||||
|
env.db.update_task(setup.id, updated_at=iso(utcnow() - timedelta(minutes=env.config.upload_setup_timeout + 1)))
|
||||||
|
reply = run(env.say(A, "/confirm"))[0]
|
||||||
|
assert "expired" in reply
|
||||||
|
assert env.db.get_task(setup.id).status == TaskStatus.CANCELLED
|
||||||
|
assert "Start Upload Task" in run(env.say(A, "/upload"))[0]
|
||||||
|
|
||||||
|
|
||||||
|
# 11, 12: cleanup ordering and failures -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_file_deleted_only_after_verified_upload_is_recorded(env):
|
||||||
|
seen = []
|
||||||
|
real_cleanup = env.queue.downloader.cleanup
|
||||||
|
|
||||||
|
def spy(rec):
|
||||||
|
current = env.db.get(rec.key)
|
||||||
|
if current.state == State.COMPLETED:
|
||||||
|
assert current.drive_file_id in env.drive.files
|
||||||
|
assert env.drive.files[current.drive_file_id].size == current.local_size
|
||||||
|
assert env.config.download_dir.joinpath(f"{abs(CHANNEL_ID)}_{rec.message_id}.video").exists()
|
||||||
|
seen.append((rec.message_id, current.state))
|
||||||
|
return real_cleanup(rec)
|
||||||
|
|
||||||
|
env.queue.downloader.cleanup = spy
|
||||||
|
run(full_task(env))
|
||||||
|
assert seen and all(state == State.COMPLETED for _, state in seen)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_upload_is_not_completed(env):
|
||||||
|
env.drive.upload_failures = [PermanentError("insufficientFilePermissions")]
|
||||||
|
run(full_task(env))
|
||||||
|
rec = env.db.get((CHANNEL_ID, 14))
|
||||||
|
assert rec.state == State.FAILED and rec.drive_file_id is None
|
||||||
|
task = env.db.get_task(1)
|
||||||
|
assert task.status == TaskStatus.COMPLETED and task.failed_count == 1 and task.uploaded_count == 4
|
||||||
|
assert dict(env.db.task_items(1))[14] == "failed"
|
||||||
|
assert not any(p.name.startswith(f"{abs(CHANNEL_ID)}_14.") for p in env.config.download_dir.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_download_file_can_be_kept(tmp_path):
|
||||||
|
config = make_config(tmp_path, ADMIN_USER_IDS=str(A), KEEP_FAILED_DOWNLOADS="true")
|
||||||
|
e = Env(config, Database.from_config(config), FakeSource(), FakeDrive())
|
||||||
|
populate(e.source)
|
||||||
|
e.drive.upload_failures = [PermanentError("denied")]
|
||||||
|
run(full_task(e))
|
||||||
|
assert (config.download_dir / f"{abs(CHANNEL_ID)}_14.video").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# 14: repeating a task ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeating_completed_task_does_not_duplicate(env):
|
||||||
|
run(full_task(env))
|
||||||
|
calls = env.drive.upload_calls
|
||||||
|
run(full_task(env))
|
||||||
|
second = env.db.get_task(2)
|
||||||
|
assert second.status == TaskStatus.COMPLETED
|
||||||
|
assert second.uploaded_count == 0 and second.duplicate_count == 5
|
||||||
|
assert env.drive.upload_calls == calls and len(env.drive.uploaded()) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# 16: cancellation ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_running_task_keeps_completed_work(tmp_path):
|
||||||
|
config = make_config(tmp_path, ADMIN_USER_IDS=str(A))
|
||||||
|
db = Database.from_config(config)
|
||||||
|
source = FakeSource()
|
||||||
|
populate(source, size=3_000_000)
|
||||||
|
e = Env(config, db, source, FakeDrive())
|
||||||
|
part16 = config.download_dir / f"{abs(CHANNEL_ID)}_16.part"
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
await e.say(A, "/upload", link(14), link(20), "/confirm")
|
||||||
|
while not part16.exists():
|
||||||
|
await asyncio.sleep(0.001)
|
||||||
|
reply = (await e.say(A, "/cancel"))[0]
|
||||||
|
assert "cancelled at a safe checkpoint" in reply
|
||||||
|
await e.queue.wait()
|
||||||
|
|
||||||
|
run(go())
|
||||||
|
task = db.get_task(1)
|
||||||
|
assert task.status == TaskStatus.CANCELLED
|
||||||
|
assert db.get((CHANNEL_ID, 14)).state == State.COMPLETED
|
||||||
|
assert len(e.drive.uploaded()) == 1 # completed upload preserved in Drive
|
||||||
|
for message_id in (16, 17, 19, 20):
|
||||||
|
rec = db.get((CHANNEL_ID, message_id))
|
||||||
|
assert rec.state == State.SKIPPED and rec.skip_reason.startswith("cancelled:")
|
||||||
|
assert list(config.download_dir.iterdir()) == []
|
||||||
|
assert "Upload Task Cancelled" in "".join(t for _, t in e.notifier.sent)
|
||||||
|
|
||||||
|
# the same range can be requested again: cancelled items are re-queued, done ones are not re-uploaded
|
||||||
|
run(full_task(e))
|
||||||
|
second = db.get_task(2)
|
||||||
|
assert second.uploaded_count == 4 and second.duplicate_count == 1
|
||||||
|
assert len(e.drive.uploaded()) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_queued_task_and_explicit_id(env):
|
||||||
|
async def go():
|
||||||
|
await env.say(A, "/pause") # nothing running -> nothing to pause
|
||||||
|
env.queue._persist_desired("paused")
|
||||||
|
await env.say(A, "/upload", link(14), link(20), "/confirm")
|
||||||
|
assert env.db.get_task(1).status == TaskStatus.QUEUED
|
||||||
|
reply = (await env.say(B, "/cancel 1"))[0]
|
||||||
|
assert "Task #1 cancelled" in reply
|
||||||
|
assert "already finished" in (await env.say(A, "/cancel 1"))[0]
|
||||||
|
|
||||||
|
run(go())
|
||||||
|
assert env.db.get_task(1).status == TaskStatus.CANCELLED
|
||||||
|
assert env.source.download_offsets == []
|
||||||
|
|
||||||
|
|
||||||
|
# 13, 18: restart recovery ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_recovers_after_crash_and_restart(tmp_path, db_url):
|
||||||
|
extra = {"DATABASE_URL": db_url} if db_url else {}
|
||||||
|
config = make_config(tmp_path, ADMIN_USER_IDS=str(A), **extra)
|
||||||
|
source = FakeSource()
|
||||||
|
populate(source, size=1_500_000)
|
||||||
|
drive = FakeDrive()
|
||||||
|
db = Database.from_config(config)
|
||||||
|
e = Env(config, db, source, drive)
|
||||||
|
drive.crash_at_bytes = 700_000 # the process is killed half-way through the first upload
|
||||||
|
|
||||||
|
async def first_run():
|
||||||
|
await e.say(A, "/upload", link(14), link(20), "/confirm")
|
||||||
|
with pytest.raises(SimulatedCrash):
|
||||||
|
await e.queue.wait()
|
||||||
|
|
||||||
|
run(first_run())
|
||||||
|
assert db.get_task(1).status == TaskStatus.RUNNING
|
||||||
|
assert db.get((CHANNEL_ID, 14)).state == State.UPLOADING
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Heroku restarts the worker: new process, same durable database
|
||||||
|
db2 = Database.from_config(config)
|
||||||
|
db2.recover_stale()
|
||||||
|
e2 = Env(config, db2, source, drive)
|
||||||
|
|
||||||
|
async def second_run():
|
||||||
|
assert e2.queue.boot() == "running"
|
||||||
|
await e2.queue.wait()
|
||||||
|
|
||||||
|
run(second_run())
|
||||||
|
task = db2.get_task(1)
|
||||||
|
assert task.status == TaskStatus.COMPLETED and task.uploaded_count == 5
|
||||||
|
assert len(drive.uploaded()) == 5 # no duplicates
|
||||||
|
assert drive.resumed # the interrupted upload session was resumed
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_resume_task_and_paused_boot(env):
|
||||||
|
async def go():
|
||||||
|
await env.say(A, "/upload", link(14), link(20), "/confirm")
|
||||||
|
while env.db.get((CHANNEL_ID, 14)) is None or env.db.get((CHANNEL_ID, 14)).state != State.COMPLETED:
|
||||||
|
await asyncio.sleep(0.001)
|
||||||
|
assert "Pausing" in (await env.say(A, "/pause"))[0]
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert env.db.get_task(1).status == TaskStatus.PAUSED
|
||||||
|
# a restart while paused stays paused
|
||||||
|
assert Env(env.config, env.db, env.source, env.drive).queue.boot() == "paused"
|
||||||
|
assert "resumed" in (await env.say(A, "/resume"))[0]
|
||||||
|
await env.queue.wait()
|
||||||
|
|
||||||
|
run(go())
|
||||||
|
assert env.db.get_task(1).status == TaskStatus.COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_and_history_commands(env):
|
||||||
|
async def go():
|
||||||
|
await env.say(A, "/upload", link(14))
|
||||||
|
status = (await env.say(A, "/taskstatus"))[0]
|
||||||
|
assert "waiting for the end link" in status
|
||||||
|
await env.say(A, link(20), "/confirm")
|
||||||
|
await env.queue.wait()
|
||||||
|
status = (await env.say(A, "/taskstatus 1"))[0]
|
||||||
|
assert "Task #1</b> — COMPLETED" in status and "Range: 14–20" in status
|
||||||
|
history = (await env.say(A, "/uploadhistory"))[0]
|
||||||
|
assert "#1 14–20 COMPLETED — uploaded 5" in history
|
||||||
|
assert "Upload tasks" not in (await env.say(A, "/status"))[0]
|
||||||
|
|
||||||
|
run(go())
|
||||||
Reference in New Issue
Block a user