Skip to content

Repository files navigation

Storely

Storely

Record your daily vlogs. Store them in Telegram instead of Drive.

A React Native (Expo) app that records video, slices it into parts small enough for a Telegram bot to hand back, uploads them under Telegram's rate limits, and can reassemble the original file on demand.


Why this exists

Google Drive gives you 15 GB. Daily vlogs eat that in weeks, and once it's full you can't even read back what you already stored. Telegram has no such ceiling, but its Bot API has two size limits, and the second one is the one everybody misses:

Operation Limit
Bot uploads a file (sendDocument) 50 MB
Bot downloads a file back (getFile) 20 MB

Upload a 40 MB part and Telegram accepts it happily. Then your bot can never fetch it again. You'd have a full archive you cannot watch, which is exactly the problem you were trying to escape.

So Storely splits every recording into 18 MiB parts, 2 MiB under the download ceiling, and refuses to let you configure a part size above it.


How it works

flowchart LR
    A[Camera<br/>expo-camera] --> B[File moved to<br/>permanent storage]
    B --> C[Chunk plan<br/>18 MiB parts]
    C --> D[Upload queue<br/>one part at a time]
    D -->|sendDocument| E[(Telegram<br/>private channel)]
    D --> F[(SQLite<br/>part file_ids)]
    E --> G[Manifest JSON<br/>posted to chat]
    E -->|getFile + download| H[Rebuild<br/>verify size]
    F --> H
    H --> I[Play locally]
Loading

Three important choices:

Parts are sent as documents, never as videos. sendVideo lets Telegram re-encode and strip the payload. sendDocument stores the bytes verbatim, the only way a reassembled part is still valid data.

A manifest is posted into the chat after each upload. It's JSON listing every part's file_id. Given only your bot token and your channel, every video can be rebuilt without this phone or its database.

Rebuilt files are size-verified. If the reassembled byte count doesn't match the original exactly, the file is deleted rather than played. A silently corrupt video is worse than a missing one.


Rate limits and retries

This is the part most Telegram uploaders get wrong. Telegram allows roughly 30 requests/second overall but only about 20 messages per minute into a single chat. Every part is one message, so the per-chat rule is the real constraint.

src/telegram/ handles it in three pieces:

File Responsibility
rateLimiter.ts Spacing between requests + a shared cooldown gate
retry.ts Classifies failures and decides whether to try again
errors.ts Typed errors that carry a plain-language fix

Spacing. One part every 1.5 s by default. Each 429 multiplies the gap by 1.8× (capped at 60 s); after 8 clean responses in a row it relaxes back down. A temporary throttle doesn't slow you down permanently.

The shared gate. A 429 pauses all Telegram traffic, not just the request that was rejected. Without this, other workers keep hammering an API that just asked you to stop.

Failure classification, the table that matters:

Failure What Storely does Why
429 Too Many Requests Wait exactly retry_after, then retry. Does not consume the retry budget. Being throttled isn't failing. A large archive hits this routinely.
5xx / dropped socket Exponential backoff with full jitter, up to 8 attempts Transient. Jitter stops retrying parts from re-synchronising into a herd.
401 / 403 / 400 Fail immediately with a plain-language hint A wrong token will be just as wrong on attempt eight.

Across app restarts. Attempt counts live in SQLite, so killing the app mid-upload resumes at the part that was in flight. Every confirmed part is skipped. A part that burns its whole budget three separate times parks the video as failed so you can retry it deliberately instead of it looping forever.


Where your data lives

Where
Video bytes Your Telegram chat, as 18 MiB document parts
Index (folders, titles, part file_ids) SQLite on the phone
Backup index A JSON manifest posted into the same chat
Bot token Device keychain (expo-secure-store), never in the code

There is no server and no cloud database. Nothing to pay for, nothing to keep running, and nothing that can leak your archive.


Code map

src/
├── config.ts               Telegram's limits and every knob derived from them
│
├── telegram/               Talking to the Bot API
│   ├── client.ts             API calls; sendDocument as multipart
│   ├── rateLimiter.ts        Adaptive throttle + shared cooldown gate
│   ├── retry.ts              429 / 5xx / network / fatal classification
│   └── errors.ts             Typed errors with plain-language hints
│
├── db/                     Metadata only, no video bytes
│   ├── schema.ts             Tables + migrations (SQLite user_version)
│   ├── types.ts              Row shapes, sort keys, manifest shape
│   └── repo.ts               Folders, videos, chunks, sorting
│
├── services/               The actual work
│   ├── chunkPlan.ts          Pure byte arithmetic (no imports, fully tested)
│   ├── chunker.ts            Slicing files and stitching them back
│   ├── uploadManager.ts      The serial, resumable upload loop
│   ├── restoreManager.ts     Fetch parts, verify, reassemble
│   ├── library.ts            Turning a recording into a queued video
│   └── settings.ts           Token (keychain) and preferences
│
├── ui/
│   ├── theme.ts              Every colour, space and font in the app
│   ├── icons.tsx             One 24px vector icon system (no emoji)
│   ├── components.tsx        Card, Button, Chip, Tile, Field, Toggle…
│   ├── UploadBanner.tsx      Live queue status
│   └── format.ts             Bytes, durations, dates
│
├── screens/                Record · Library · Video · Settings
└── navigation.ts           Route types for the three nested navigators

tests/                      Runs on Node, against the real sources

Why chunkPlan.ts is separate

The logic deciding where byte boundaries fall is the single most dangerous code in the project: a gap or overlap produces an archive that uploads cleanly and is silently unrecoverable. Keeping it free of any Expo or filesystem import means it can be tested directly, with no native modules to stub.


Data model

erDiagram
    folders ||--o{ folders : "parent_id"
    folders ||--o{ videos : contains
    videos  ||--o{ chunks : "split into"

    folders {
        text id PK
        text name
        text parent_id FK "NULL = top level"
        text emoji "icon name"
    }
    videos {
        text id PK
        text title
        text folder_id FK
        int  size
        int  chunk_size
        int  total_chunks
        int  uploaded_chunks
        text status "queued|uploading|paused|uploaded|failed"
    }
    chunks {
        text video_id FK
        int  idx PK
        int  byte_offset
        int  size
        text status "pending|uploading|done|failed"
        text file_id "Telegram handle"
        int  attempts "survives restarts"
    }
Loading

folders is a plain adjacency list, so the tree nests as deep as you like. Deleting a folder re-parents its contents rather than cascading. Losing a video to a stray tap would be unforgivable in an archive app.


Two flows, walked through

Uploading

  1. RecordScreen stops recording → library.addRecording()
  2. The file is awaited into permanent storage, then measured (reading the size before the move settles reports 0; see Troubleshooting)
  3. chunkPlan.planChunks() produces ordered 18 MiB slices → rows in chunks
  4. uploadManager loops, one part at a time:
    • chunker.extractChunk() copies the byte range into a temp file, 1 MiB at a time, yielding between slices so the UI stays responsive
    • the extracted part is checked against its planned size
    • client.sendDocument() uploads it behind the rate limiter
    • the returned file_id is written to SQLite immediately
  5. All parts done → manifest posted → video marked uploaded
  6. Optionally the local copy is deleted to free the phone

Rebuilding

  1. restoreManager.restoreVideo() walks the parts in order
  2. Each is fetched with getFile (paths expire in ~1 hour, so they're resolved right before use) and downloaded into a per-video scratch directory
  3. An interrupted restore resumes: parts already on disk are skipped
  4. Parts are appended into one file, then the byte count is verified
  5. Scratch parts are deleted, and the video plays

Setup

1. Create a bot

  1. Message @BotFather on Telegram
  2. Send /newbot, pick a name and username
  3. Copy the token (123456789:AAE…)

2. Create a private channel

  1. Telegram → New Channel → Private
  2. Add your bot as an administrator with permission to post messages
  3. Get the channel ID: forward a message from it to @userinfobot, or check https://api.telegram.org/bot<TOKEN>/getUpdates after posting once. Channel IDs start with -100.

A channel beats a plain chat here: no "Saved Messages" quirks, and you can add other admins later.

3. Connect

Settings → paste token and chat ID → Connect & test.

The test performs a real sendMessage, so a green result means posting genuinely works, not merely that the token parses.


Running it

On your phone, quickly: Expo Go

npm install
npx expo start

Scan the QR code with Expo Go, on the same Wi-Fi. Everything Storely uses ships inside Expo Go.

As a real installed app: EAS Build

npm i -g eas-cli
eas login
npm run build:apk       # release APK you can sideload

eas.json defines four profiles:

Profile Output Use
development debug APK + dev client Native debugging with fast refresh
preview release APK, internal Sideload onto your own phone
production AAB, store distribution Play Store submission
production-apk release APK, internal Production build, installed directly

Android release builds run R8 with resource shrinking, so the APK stays small. appVersionSource is remote, meaning EAS owns the version codes.


Using it

  • Record. Tap the red dot; tap the square to stop. Name it, pick a folder, and it starts uploading.
  • Library. Folders nest as deep as you like. Each folder is a real screen, so Android back and the iOS swipe-back gesture walk up one level at a time. Breadcrumbs jump to any ancestor. Long-press a folder to delete it (contents move up). Sort by date, size or name; tap the active sort to reverse it.
  • A video's page. Play it, rebuild it from Telegram, rename, move, or free up space while keeping the cloud copy. The parts list shows exactly which slices have landed.
  • The queue banner. Reports the unglamorous states too: throttled, retrying, waiting for Wi-Fi. Pause any time.
  • Back at the top level asks before closing, so a stray tap can't end a session mid-upload.

Settings worth knowing

Setting Why you'd touch it
Delete local copy after upload The point of the app, so your phone stays empty
Wi-Fi only Holds the queue off mobile data
Gap between parts Raise if you see frequent throttling
Part size Capped below 20 MB by design; affects new recordings only

Development

npm run verify     # typecheck + lint + tests + expo-doctor
npm test           # 15 tests
npm run lint
npm run format

Tests run on Node directly against the TypeScript sources, with no build step and no compiled copy that can drift. tests/resolve-ts.mjs supplies the file extensions Node's ESM resolver wants, since app imports are extensionless for Metro.

What's covered:

  • Chunk planning. Byte-exact across exact multiples, remainders, single-byte and empty files, and a 1.4 GB recording: no gaps, no overlaps, no zero-byte part, nothing above the 20 MB ceiling, part names that sort in playback order.
  • Rate limiting and retries. retry_after honoured in full; 429s don't consume the retry budget; repeated 429s widen the interval; fatal 4xx never retried; 5xx and network errors retry then give up cleanly; a permanently throttled account eventually stops; every error carries a usable hint.

Troubleshooting

Message Meaning
Unauthorized Token is wrong or revoked; re-copy from @BotFather
chat not found Chat ID wrong, or the bot was never added to the channel
not enough rights Bot is in the chat but can't post; make it an admin
file must be non-empty A part measured 0 bytes. Retry the video; it re-measures and replans automatically
Stuck on Rate limited Working as intended; raise Gap between parts if it's constant

A note on that last one. File.move() in expo-file-system is async. An early build called it without await and read destination.size immediately; the size getter returns 0 for a file that doesn't exist yet, so recordings were planned as a single empty part. Videos affected by that bug repair themselves on retry: the upload loop re-measures against the file on disk and rebuilds the plan, as long as no part has been uploaded yet.


Design decisions

Why documents instead of videos?

sendVideo lets Telegram transcode and strip the payload. Chunks aren't playable video anyway; they're byte ranges. sendDocument stores them verbatim.

Why 18 MiB and not 50 MB?

50 MB is the upload limit. getFile caps downloads at 20 MB. A 50 MB part uploads fine and can never be retrieved, leaving an archive you can't read.

Why no cloud database?

Nothing needs one. The index is small enough for on-device SQLite, and the manifest in the chat makes the archive self-describing. A cloud DB would add a server, a bill, and a place for your data to leak, all for syncing convenience alone.

Why upload one part at a time?

The per-chat message rate is the bottleneck, not bandwidth. Parallel uploads would only collect 429s faster while competing for the same uplink.


Licence

Personal project. Use it however you like.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages