Skip to content

2. Starting a Download

Doc 1 established that a title is a few thousand files and that the app hands each one to background_downloader as its own task. This doc follows a single tap on Download until those tasks are sitting in the native queue.

There are three places a download can start, and they apply the same checks in the same order:

Entry point File Event it sends
Download button on a film’s page content_page_button_widget.dart (_onTapDownload) DownloadContentEvent
Download icon on an episode row single_content_screen.dart DownloadEpisode
Status icon on a row in Downloads download_screen.dart (_onRowDownloadTap) DownloadContentEvent

On a film’s page the button only exists for single titles: not series, not bundles. Episodes download one at a time from their own rows.

tap
│
├─ not logged in? ─────────────────────► AuthBottomSheet
├─ not subscribed? ────────────────────► SubscriptionBottomSheet
├─ plan has allow_video_download=false? ► DownloadBottomSheet.showUnavailable
│
├─ current status is downloading or completed?
│ └──────────────────────────────► DownloadBottomSheet.show
│ ("Cancel Download" or
│ "Remove from download")
│
└─ status is absent, queued, paused or failed:
├─ ContentHelper.checkDownloadSettings
│ "Download over Wi-Fi only" is on and we're not on Wi-Fi?
│ └──► "Waiting For WiFi" sheet, nothing starts
├─ ContentHelper.checkDownloadLimit (new titles only)
│ already 50 downloads that have not failed?
│ └──► "Download limit reached" sheet
├─ ContentHelper.checkDownloadStorage
│ estimated size bigger than the free space?
│ └──► "Not enough storage" sheet
│
├─ paused or failed ──► ResumeContentEvent
└─ absent or queued ──► DownloadContentEvent / DownloadEpisode

Two details are easy to miss:

  • The status comes from a live stream, not a lookup. Each button is wrapped in a DownloadRowWatcher (doc 3), so the icon and the tap decision always reflect the latest record.
  • The storage check estimates before it blocks. It fetches the master playlist, takes the chosen rendition’s BANDWIDTH, multiplies it by the title’s duration (less whatever is already downloaded), adds a 50 MB margin and compares that with the device’s free space. Anything it cannot work out — no duration, no bandwidth, a manifest fetch slower than 3 seconds, any error — lets the download go ahead.
  • The 50-download limit is a client-side rule. It counts the downloads this account can see that have not failed. Nothing in the subscription data carries a limit.

Step 2: the bloc translates events into manager calls

Section titled “Step 2: the bloc translates events into manager calls”

DownloadScreenBloc is a thin adapter kept for older widgets. It owns no download state:

Event What it calls
DownloadContentEvent DownloadManager.start(content), then emits ContentDownloaded(download_enqueued) or DownloadScreenError
DownloadEpisode start(content), then reloads the list
ResumeContentEvent if offline: error. No record: start. Record failed: retry. Otherwise: resume
CancelContentEvent, Remove… events cancel(contentId)
RemoveAllContentEvent cancel for every record

The index arguments on the older events are accepted and ignored. Everything is keyed by contentId.

DownloadManagerImpl.start(ContentEntity) is where a tap becomes durable state:

start(content)
│
├─1─ row exists and is "completed"? → return it, nothing to do
├─2─ row exists and is "downloading"/"queued"? → return it (idempotency)
│
├─3─ first download only (Android):
│ • ask for the battery-optimization exemption (once)
│ • on aggressive OEMs, show the "keep downloads running" tip (once)
│
├─4─ create the Hive row (status "queued", totalSegments 0), or reset an
│ old row to "queued" with a fresh settlement budget
├─5─ publish the record to the UI streams
├─6─ start the Android progress notification NOW, while the app is
│ certainly in the foreground (it shows an indeterminate bar)
├─7─ iOS only: configure the per-title system notification
│
├─8─ new row → orchestrator.start(row) (plan + enqueue all)
│ existing row → orchestrator.reenqueueMissing(row) (only missing files)
│
├─9─ row = "downloading", publish, refresh the notification with real totals
│ (if nothing was missing, finalize right away)
│
└─✗─ any exception: a row still "queued" becomes "failed" with the error,
and the notification is re-synced so it can stop

Why each guard exists:

  • Step 2 idempotency. A double tap would otherwise enqueue the whole title twice. The package does not deduplicate tasks by ID, so both copies would download.
  • Step 6 before step 8. Parsing and enqueueing thousands of tasks takes noticeable time. Android 12+ refuses to start a foreground service once the app is in the background, so the service has to start before the user can walk away (doc 5).
  • The failure branch. A row stuck at queued would be caught by the step 2 guard forever, and the user could never start that title again. Marking it failed turns the button back into “try again”.

The row is a CacheContentEntity (all fields in doc 7). At this point it holds:

  • the title
  • the slug
  • masterUrl
  • poster and thumbnail URLs
  • subtitle references
  • episodeMetaJson: the ContentEntity fields the offline UI needs later, encoded by ContentEntitySerde.encode, so the Downloads screen can show the title with no network

DownloadOrchestrator.start serializes concurrent calls per title with an in-flight map, then runs _startImpl:

  1. Create <Documents>/<slug>/.
  2. Reuse a saved plan if the row has one (cachedPlanJson). Otherwise call HlsManifestParser.parse(masterUrl, targetResolution: …).
  3. Keep the plan in an in-memory cache.
  4. Set row.totalSegments = plan.totalRequiredCount and save the plan JSON on the row. From here on the title’s makeup is known without the network.
  5. Build the tasks and enqueue them (steps 6 and 7 below).

The target comes from the Download quality setting:

Setting downloadQuality pref Target height
first option 0 1080
second option (default) 1 720
third option 2 360

AppBootstrapImpl._restoreDownloadPreferences copies the pref into the global selected_download_quality at startup. The settings screen updates both.

DownloadOrchestrator._downloadResolution then caps the target to what the account may stream:

target = selected_download_quality
if ContentHelper.isHdAllowed() → keep target (privileged user, or
plan max_resolution = 0)
else if getMaxVideoResolution() < target → use that limit instead
on any error → keep target

HlsManifestParserImpl.parse:

  1. GET the master playlist. Any status other than 200 throws.
  2. variantsFromMaster reads every #EXT-X-STREAM-INF line and the URI on the line after it. It drops entries with no RESOLUTION (audio-only renditions) and sorts by height, then bandwidth, best first.
  3. pickVariant(variants, target) takes the tallest rendition not taller than the target. If every rendition is taller, it takes the smallest.
ladder in master: 1080p 720p 360p target 720 → 720p
target 480 → 360p
widescreen ladder: 1920x804 1280x536 target 720 → 1280x536

Matching by height rather than an exact RESOLUTION=1280x720 string is what lets widescreen masters work.

  1. GET the chosen variant playlist:
    • every non-comment line becomes an HlsSegment: an absolute URL resolved against the playlist, plus its basename as the local filename
    • the first #EXT-X-KEY URI becomes the HlsKeyPlan
  2. Audio renditions. #EXT-X-MEDIA:TYPE=AUDIO lines are kept if they are in the variant’s AUDIO group (or all of them, if it names none) and have a URI.
    • Each gets a folder name: the parent directory in its URL (for example audio_bn), otherwise its language, otherwise audio_<n>.
    • Its playlist is fetched. A failed audio fetch is silently skipped.
  3. Return an HlsParsedPlan holding:
    • the master URL and raw text
    • the chosen #EXT-X-STREAM-INF line (reused later for the local master)
    • the video plan
    • the audio plans

The count the manager tracks is:

totalRequiredCount = video segments + all audio segments + (key ? 1 : 0)

Thumbnails, posters and subtitles are downloaded, but they never count toward completion. A missing poster does not make a film unplayable.

The parser uses plain package:http with no headers: playlists and segments on the CDN are public. The key is the only request that needs signed headers.

DownloadTaskFactory.build creates one DownloadTask per file. Every task looks like this:

DownloadTask(
taskId: content_<contentId>_<kind>_<index> e.g. content_42_video_17
group: content_<contentId>
url: absolute URL from the plan
filename: segment basename | key.bin | thumbnail.jpg | poster.jpg | <sub>.vtt
directory: <slug> or <slug>/<audioFolder>
baseDirectory: applicationDocuments
updates: status only (no byte progress)
retries: 3
allowPause: true
displayName: the title
headers: only on the key task: EncryptionHelper().playerHeader()
metaData: {"contentId", "kind", "index", "totalRequired",
+ "audioGroup"/"audioFolder" for audio,
+ "language"/"label" for subtitles}
)
Field Why it is set this way
Deterministic taskId The same file always gets the same ID. Audio adds its rendition’s position (…_audio_<rendition>_<index>), so two audio tracks cannot collide.
One group per title cancelAll(group:) stops a whole title in one call. Pause, cancel, failure and post-completion cleanup all rely on it.
Relative directory + applicationDocuments Survives the iOS container path changing on reinstall.
updates: status With thousands of tasks, per-byte progress events would flood the Dart side. Progress is counted in finished files instead.
retries: 3 The package retries a failed file after 2, 4 and 8 seconds. Those retries run on the Dart side (doc 4).
allowPause: true Lets Android resume a transfer that hit the package’s 9-minute worker limit with a Range request instead of starting over.
metaData JSON How a status update is routed back to its title and kind, with no lookup table.
headers on the key only The KMS refuses a key request without the signed referer, vpsid and platform headers. The CDN needs nothing.

The order is: all video segments, then each audio rendition’s segments, then the key, thumbnail, poster and subtitles.

Re-queue paths do not compare task IDs. They compare each task’s destination (directory/filename), so files queued by an older build under different IDs are still recognised as in flight (doc 4).

DownloadTaskFactory.buildMissing builds the same kind of tasks, but only for the video segments, audio segments and key whose names are in a “missing on disk” set. Art and subtitles are not rebuilt. It is used by every re-queue path.

DownloadOrchestrator._enqueueChecked hands the tasks over in chunks of 200:

tasks (e.g. 1,804)
│
├─ enqueueAll(first 200) → [true, true, false, …] ─┐
├─ enqueueAll(next 200) → [true, …] ├─ collect every false
├─ … ─┘
│
├─ retry all failures once, again in chunks
└─ still failing → log "[orch] N/M tasks failed to enqueue after retry;
settlement will recover them"

Why chunks and per-task flags:

  • One huge enqueueAll is slow to serialize across the platform channel.
  • Individual rejections used to vanish silently, leaving holes that only the stall watchdog noticed minutes later.

Anything that still fails is not an error: the file is simply missing on disk, and settlement (doc 4) will queue it again.

Step 7: what the native side does with them

Section titled “Step 7: what the native side does with them”

The details are in docs 5 and 6. In short:

ANDROID iOS
enqueueAll enqueueAll
│ │
▼ ▼
holding queue (in the app process) background URLSession
│ releases 8 at a time; the next one every task created and
│ goes out as each finishes resumed immediately;
▼ nsurlsessiond (a system
WorkManager job per task daemon) decides how many
│ network constraint: connected run at once
▼ │
DownloadTaskRunner: GET → temp file under ▼
the app's files dir → move into <slug>/… file moved into <slug>/…
│ │
└──────► "complete" status update ◄───────────┘

Each task reports enqueued straight away, running when it starts, and finally complete, failed, notFound (HTTP 404) or canceled. Doc 3 picks up from those updates.

  • lib/presentation/widgets/content_page_widget/content_page_button_widget.dart: _onTapDownload, _titleForStatus.
  • lib/presentation/pages/content_pages/single_content_screen.dart: the episode download handler.
  • lib/presentation/pages/library_pages/download_screen.dart: _onRowDownloadTap.
  • lib/data/helpers/content_helper.dart: checkDownloadSettings, checkDownloadStorage, isHdAllowed, getMaxVideoResolution.
  • lib/presentation/bloc/download_screen/download_screen_bloc.dart: event translation.
  • lib/data/services/download_manager_impl.dart: start, _maybePromptBatteryOptimization, _failIfStillQueued.
  • lib/data/services/hls/download_orchestrator.dart: start, _startImpl, _downloadResolution, _enqueueChecked.
  • lib/data/services/hls/hls_manifest_parser.dart: parse, variantsFromMaster, pickVariant.
  • lib/data/services/hls/download_task_factory.dart: build, buildMissing, groupFor, taskIdFor.

Starting a download does four durable things quickly:

  1. It writes a Hive row.
  2. It pins the notification while the app is still in front.
  3. It saves a complete plan of the title.
  4. It hands every file to the native queue under a predictable ID.

Every later stage relies on those IDs and that saved plan, so it can always answer “what does this title need, and what is already done?” from local state alone.