1. What a Download Is
1. What a Download Is
Section titled “1. What a Download Is”The problem this solves
Section titled “The problem this solves”A user on a train with no signal wants to watch a film they saved last night. For that to work, everything the player needs has to already be on the phone:
- the video
- the audio
- the decryption key
- subtitles and artwork
And it has to be arranged so the player cannot tell it is not talking to the real server.
That sounds like “save one file”. It is not, and nearly every design choice in this subsystem follows from the difference.
An HLS title is a small website, not a file
Section titled “An HLS title is a small website, not a file”Chorki streams use HLS (HTTP Live Streaming). Instead of one big .mp4,
a title is a tree of text playlists pointing at many small media files:
master.m3u8 "which qualities exist?" ├── #EXT-X-STREAM-INF RESOLUTION=1920x1080 → 1080p/media.m3u8 ├── #EXT-X-STREAM-INF RESOLUTION=1280x720 → 720p/media.m3u8 │ ├── #EXT-X-KEY METHOD=AES-128,URI="https://kms.chorki.net/…" │ ├── #EXTINF:4.0, segment_000.ts │ ├── #EXTINF:4.0, segment_001.ts │ └── … one line per 4-second segment ├── #EXT-X-STREAM-INF RESOLUTION=640x360 → 360p/media.m3u8 └── #EXT-X-MEDIA TYPE=AUDIO URI="audio_bn/media.m3u8" (optional) └── audio segments, same ideaChorki segments are four seconds long. So:
| Title length | Video segments | With a separate audio rendition |
|---|---|---|
| 45-minute episode | ~675 | ~1,350 |
| 2-hour film | ~1,800 | ~3,600 |
| 2 h 40 min film | ~2,400 | ~4,800 |
Every segment is AES-128 encrypted and stays encrypted on disk. The one
16-byte key that unlocks them all is fetched once at download time with
signed headers. ../playback/01-aes-128-and-the-kms.md explains why that
key needs special treatment.
What ends up on disk
Section titled “What ends up on disk”Everything for one title lives in a folder named after its slug, under the app’s documents directory:
<Documents>/<slug>/ ├── segment_000.ts … segment_1799.ts video segments (names from the URL) ├── key.bin the AES-128 key (16 bytes) ├── audio_bn/ one folder per audio rendition, if any │ ├── seg_000.ts … │ └── media.m3u8 written at the very end ├── thumbnail.jpg artwork for the library list ├── poster.jpg ├── <subtitle>.vtt one per subtitle track ├── video.m3u8 written at the very end └── master.m3u8 written at the very end (the entry point)<Documents> is /data/user/0/com.prothomalo/app_flutter on Android and
the app container’s Documents/ on iOS. The Hive record stores paths
relative to it (for example <slug>/master.m3u8). The iOS container
path changes on reinstall, so an absolute path would break.
The three playlists are written only after every required file is on disk. Until then the folder is just a pile of segments no player could use. That makes “the playlists exist” a clean signal that a download is usable.
Why the app downloads one file at a time
Section titled “Why the app downloads one file at a time”background_downloader downloads files. It gives each file background
execution that survives the app being minimized or the screen being locked:
- a WorkManager job on Android
- a background
URLSessiontask on iOS
It has no notion of “an HLS stream”. So the app turns a title into one
DownloadTask per file:
- every video segment
- every audio segment
- the key
- the thumbnail and poster
- each subtitle
That single decision is why a 2-hour film becomes 1,800 to 3,600 native tasks. The cost of juggling that many tasks, and of phones that do not want apps busy in the background, shapes most of the architecture:
- the holding queue and foreground service (doc 5)
- the recovery machinery (doc 4)
- the careful completion check (doc 3)
The four jobs
Section titled “The four jobs”Every download goes through the same four jobs, owned by different components:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ 1. PLAN │───►│ 2. FETCH │───►│ 3. TRACK │───►│4. FINALIZE│ └──────────┘ └──────────┘ └──────────┘ └───────────┘ read the one native turn finished prove every file manifests, task per file files into is on disk, write choose a (video, audio, progress; re- the local playlists, quality, save key, art, queue whatever mark "completed" the plan subtitles) goes missing- Plan:
HlsManifestParserfetches the playlists and produces anHlsParsedPlan: every segment URL and filename, the key URL, the audio renditions, and the raw playlist text.DownloadOrchestratorsaves that plan as JSON on the Hive row, so later steps never need the network to know what the title consists of. - Fetch:
DownloadTaskFactoryturns the plan into tasks with deterministic IDs. The orchestrator enqueues them with the package. - Track:
DownloadManagerImpllistens to the package’s status updates.AssetProgressTrackercounts finished files. When progress stops or files fail, the manager re-queues exactly what is missing. - Finalize: once every required file is verified on disk,
LocalPlaylistWriterwritesvideo.m3u8, the audiomedia.m3u8files andmaster.m3u8, and the row becomescompleted.
The components
Section titled “The components”┌────────────────────────── PRESENTATION ───────────────────────────┐│ Content page button · Episode list · Library (DownloadScreen) ││ DownloadRowWatcher (one per title) DownloadScreenBloc ││ ConnectivityCubit (Wi-Fi only) MainScaffold (Back button) │└──────────────┬──────────────────────────────────────▲──────────────┘ start/resume│/retry/cancel/pause watch(id) │ watchAll()┌──────────────▼──────────────────────────────────────┴──────────────┐│ DOMAIN DownloadManager (interface) · DownloadRecord │└──────────────┬─────────────────────────────────────────────────────┘┌──────────────▼───────────────────── DATA ──────────────────────────┐│ DownloadManagerImpl the brain: state machine, recovery, ││ │ streams, notifications ││ ├── DownloadOrchestrator ── HlsManifestParser ││ │ ├─ DownloadTaskFactory ││ │ └─ LocalPlaylistWriter ││ ├── AssetProgressTracker ││ └── DownloadProgressNotifier ──► "download_fg" channel (Android) │└──────────────┬─────────────────────────────────────▲───────────────┘ enqueueAll │ cancelAll · allTasks │ status updates┌──────────────▼─────────────────────────────────────┴───────────────┐│ background_downloader 9.5.5 ││ Android: holding queue (8 at a time) → WorkManager jobs ││ iOS: background URLSession (runs in nsurlsessiond) │└──────────────┬──────────────────────────────────────────────────────┘ ▼ CDN (playlists, segments, art, subtitles) kms.chorki.net (key)| Component | File | Responsibility |
|---|---|---|
DownloadManager |
lib/domain/services/download_manager.dart |
The contract the UI talks to: start, pause, resume, retry, cancel, get, list, watch, watchAll, plus initialize and ready. |
DownloadManagerImpl |
lib/data/services/download_manager_impl.dart |
Owns all download state. Creates and updates Hive rows, reacts to task updates, runs recovery, drives notifications, publishes record streams. |
DownloadOrchestrator |
lib/data/services/hls/download_orchestrator.dart |
Per-title workflow: plan, enqueue, re-enqueue only what is missing, verify on disk, finalize. |
HlsManifestParser |
lib/data/services/hls/hls_manifest_parser.dart |
Fetches and parses the playlists, picks the rendition, builds HlsParsedPlan. |
DownloadTaskFactory |
lib/data/services/hls/download_task_factory.dart |
Turns a plan into DownloadTasks with stable IDs, folders and metadata. |
AssetProgressTracker |
lib/data/services/hls/asset_progress_tracker.dart |
A per-title counter of finished files, backed by periodic disk counts. |
LocalPlaylistWriter |
lib/data/services/hls/local_playlist_writer.dart |
Rewrites playlists to point at local filenames and key.bin. |
DownloadProgressNotifier |
lib/data/services/download_progress_notifier.dart |
Android notifications: the foreground-service progress notification and the complete, failed and paused notices. |
DownloadForegroundService |
android/…/DownloadForegroundService.kt |
Android service that keeps the process alive and shows the progress notification. |
DownloadScreenBloc |
lib/presentation/bloc/download_screen/download_screen_bloc.dart |
Adapter: translates UI events into manager calls and records into the ContentEntity shape older widgets expect. |
DownloadRowWatcher |
lib/presentation/widgets/download/download_row_watcher.dart |
Rebuilds one title’s UI from DownloadManager.watch(contentId). |
All services are lazy singletons registered in lib/injection_container.dart.
The bloc is a factory. DownloadManagerImpl.initialize() runs once, just
after the first frame (doc 7).
One download, end to end
Section titled “One download, end to end” user taps Download │ ├─1─► gates: logged in? subscribed? download allowed? Wi-Fi rule OK? ├─2─► DownloadManager.start: Hive row = "queued"; Android progress │ notification appears ├─3─► orchestrator: parse master → pick rendition → save plan JSON on │ the row, set totalSegments ├─4─► task factory: one task per file → enqueue in chunks of 200 ├─5─► native side downloads; each finished file reports "complete" ├─6─► manager counts completes → row progress, notification, UI streams ├─7─► stalls or failures → settlement re-queues only the missing files ├─8─► count reaches total → disk checked file by file → playlists │ written → row = "completed" └─9─► user taps the title in Downloads → offline playback through the local server (../playback/03)Docs 2, 3 and 4 walk through steps 1–4, 5–8 and the recovery in step 7 in detail.
Vocabulary used in the rest of this series
Section titled “Vocabulary used in the rest of this series”| Term | Meaning here |
|---|---|
| Title / content | One film or one episode, keyed by contentId. |
| Row | The title’s CacheContentEntity record in the Hive box downloads. |
| Record | DownloadRecord, the immutable snapshot of a row that the UI receives. |
| Plan | HlsParsedPlan: everything the parser learned from the manifests, saved as JSON on the row (cachedPlanJson). |
| Task | One DownloadTask handed to background_downloader: one file. |
| Group | content_<contentId>, shared by every task of one title, so all of them can be cancelled together. |
| Counted kinds | video, audio and key tasks. Only these count toward totalSegments; art and subtitles do not. |
| Settlement | The manager’s recovery pass that compares the plan with the disk and re-queues missing files. |
| Finalize | Writing the local playlists and marking the row completed. |
| Holding queue | The package’s in-process waiting line that hands Android’s WorkManager at most 8 tasks at a time. |
| FGS | Foreground service, Android’s way of telling the OS “the user knows this app is working”. |
Where this lives in the code
Section titled “Where this lives in the code”lib/data/services/hls/: parser, task factory, orchestrator, tracker, playlist writer.lib/data/services/download_manager_impl.dart: the manager.lib/domain/services/download_manager.dartandlib/domain/entities/download_record.dart: the contract and the snapshot type.lib/injection_container.dart: registrations for all of the above.
The takeaway
Section titled “The takeaway”“Download a film” here means “reproduce a small website of a few thousand files, then rewrite its playlists”. The app does this through a file-based background downloader, so a title becomes thousands of native tasks. The other chapters are about making that cheap enough, keeping it alive long enough, and making sure the result is complete before anyone presses play.