AssetService (src/service/asset_service.py) handles the per-drone media library — images, HLS video, and DataFlash logs — while ArchiveService (src/service/archive_service.py) packages a mission execution’s assets into a downloadable ZIP. Both are plain singletons built in the composition root (src/application/app.py:45 and :59) and injected into route blueprints; neither is a drone-type service, so they are not created through the get_service factory covered in Service Layer Overview.
Both write to the same bucket, settings.ASSET_BUCKET (default skyhub-prod-assets, src/application/settings.py:128), using boto3 with SigV4 presigning (Config(signature_version="s3v4")). Only the presign lifetimes differ: assets expire after 3 hours, archives after 7 days.
AssetService.__init__ reads REGION into a local the_region variable but the boto3 client is created without region_name (src/service/asset_service.py:24-29) — the region actually comes from the standard AWS env/config chain, not from that variable. If you tighten S3 configuration, don’t assume REGION is what selects the endpoint here.S3 key scheme
Every asset key is derived deterministically from the drone id, asset type, and file name ingenerate_presigned_url (src/service/asset_service.py:169). Thumbnails and archives live in their own sibling folders under the same drone prefix.
| Content | S3 key pattern | Written by |
|---|---|---|
| Image / video / log asset | drone/{drone_id}/{asset_type}/{file_name} | generate_presigned_url (presigned PUT) |
| HLS segment | drone/{drone_id}/video/{segment}.ts | client PUT (no DB row) |
| Image thumbnail | drone/{drone_id}/thumbnail/{file_name} | create_thumbnail → __get_thumbnail_path |
| Execution archive | drone/{drone_id}/archives/execution_{id}_{mission}_{ts}.zip | ArchiveService.generate_archive |
asset_type is one of image, video, logs (the AssetType enum in src/models/asset.py:16). Note the thumbnail folder is the singular thumbnail/ and is keyed by bare file_name, not the full asset key — this matters for the reconciliation gotcha at the end of this page.
Presigned upload
Uploads are a two-call handshake so the drone (or UI) streams bytes straight to S3 and never through the Gateway. The HTTP endpoints live insrc/routes/asset_routes.py and are documented in Executions, Assets & Reports API; this section is the service-layer contract.
request_upload → presigned PUT
request_upload(drone, file_name, data) (src/service/asset_service.py:125) resolves the MIME type (explicit, else guessed from the extension), builds the drone/{id}/{asset_type}/{file} key, and returns a presigned put_object URL. It also inserts an Asset row in pending status.The ContentType baked into the presign must match the Content-Type header the client sends on the PUT, or S3 rejects the signature (see the # NOTE: must match! at line 172).HLS segments skip the DB
When
mime_type == "video/mp2t" (an HLS .ts segment), request_upload returns (None, url) and creates no Asset row (src/service/asset_service.py:146). Only the .m3u8 playlist gets a DB row — it is the single canonical “video” asset, and segment files are re-derived from it on demand (see below).request_upload defaults asset_type to "video" when the caller omits it, so always pass the real type. There is no server-side size or file-type enforcement yet (see the TODO comments at src/service/asset_service.py:127-129).
Thumbnails
create_thumbnail(asset_id, size=(192, 108)) (src/service/asset_service.py:80) pulls the original image from S3, downscales it with Pillow (Image.thumbnail preserves aspect ratio inside the 192×108 box), re-encodes JPEG, and PUTs it to drone/{drone_id}/thumbnail/{file_name}. The whole method is wrapped in a broad try/except that only logs — a thumbnail failure never blocks complete_upload.
Read paths that hand thumbnail URLs to the UI (get_asset_by_vehicle, src/service/asset_service.py:202) presign the same __get_thumbnail_path, so listings served by AssetService are internally consistent.
HLS video: playback rewriting
Recorded flight video is stored as an HLS playlist (.m3u8) plus a set of .ts segments — the on-drone GStreamer pipeline uploads them (see On-Drone Video Streaming). The stored .m3u8 references its segments with file:// paths, which a browser cannot fetch. get_video_asset_by_asset_id_and_vehicle_id (src/service/asset_service.py:241) rewrites the playlist on the fly for each request:
- Every line starting with
file://is matched by/([^/]+\.ts)$to pull the bare segment name. - That name is turned into a fresh 3-hour presigned GET for
drone/{drone_id}/video/{segment}.ts. - All other lines (
#EXTM3U,#EXTINF, …) pass through untouched.
Atomic two-phase delete
delete_asset(user_id, vehicle_id, asset_ids) (src/service/asset_service.py:273) is deliberately ordered S3 first, DB second to avoid orphaned rows pointing at deleted objects:
Video deletion fetches the .m3u8, extracts segment names via __get_m3u8_segment_filenames, and issues a single delete_objects bulk call; a non-empty Errors array raises and aborts before Phase 2. The failure modes are asymmetric and worth preserving:
delete_all_drone_assets (src/service/asset_service.py:375) wraps delete_asset for drone teardown and swallows exceptions so a failed S3 cleanup can’t block drone deletion. Content-Disposition download headers everywhere use encode_filename_rfc5987 (src/service/asset_service.py:38) to prevent CRLF/quote header injection.
Execution archives (ZIP)
ArchiveService bundles one execution’s assets into a single ZIP for export. It is invoked from the background report pipeline (ReportService.enqueue_archive_and_report, see Executions, Log Analysis & Reports) and on-demand from the executions API.
generate_archive(execution_id) (src/service/archive_service.py:35):
- Loads the
MissionExecutionand all its assets filtered tostatus == ready. No ready assets → clears any stale archive reference and returnsNone. - Builds a filename
execution_{id}_{safe_mission_name}_{timestamp}.zip(mission name sanitized withre.sub(r"[^\w\-]", "_", …)[:50]) and the keydrone/{drone_id}/archives/{filename}. - Streams each asset into a
ZIP_DEFLATEDtemp file, thenupload_files it withContentType: application/zip. - Commits
archive_s3_key+archive_generated_aton the execution, then deferred-deletes the previous archive only after the new upload+commit succeed.
photos/, videos/, logs/, or other/ (_add_asset_to_zip, src/service/archive_service.py:125). Every filename is passed through Path(...).name to defuse path-traversal (../) attacks.
HLS videos in the ZIP
For an HLS video the archive can’t just drop the streaming.m3u8 (its file:// paths won’t resolve offline). _add_video_asset_to_zip (src/service/archive_service.py:157) instead:
- Downloads the original playlist, extracts segment names, and derives the segment prefix from the playlist’s own S3 parent dir (
Path(asset.s3_key).parent). - Writes a rewritten local playlist (
_create_local_m3u8) whose segment lines are bare relative filenames, intovideos/{video_stem}/{name}.m3u8. - Downloads each
.tsnext to it.
Download URLs & lifecycle
get_archive_download_url(execution_id, regenerate=False) (src/service/archive_service.py:308) is the read entry point. It regenerates the ZIP when regenerate=True, when no archive exists, or when the existing one is older than 7 days (ARCHIVE_VALIDITY_DAYS), then returns a status dict:
status | Meaning | url |
|---|---|---|
ready | Archive present/fresh | 7-day presigned GET |
no_assets | Execution has no ready assets | null |
error | Regeneration or presign failed | null |
ARCHIVE_URL_EXPIRATION_SECONDS (7 days) and an attachment; filename="..." disposition. delete_archive (src/service/archive_service.py:370) removes the object and clears both archive_s3_key and archive_generated_at.
Demo assets (SITL)
When a SITL drone is created,AssetService.create_demo_assets (src/service/asset_service.py:413) seeds one demo image (a 640×480 solid-blue JPEG plus thumbnail) and one demo HLS video (a 2-segment .m3u8 with synthetic .ts files) so the fleet UI has something to render immediately. Failures are logged, not raised — demo-asset problems never fail SITL creation. See SITL Drone Lifecycle.
Gotcha: two different thumbnail paths
Configuration
| Env var | Default | Used by |
|---|---|---|
ASSET_BUCKET | skyhub-prod-assets | Both services’ S3 bucket |
REGION | eu-central-1 | Read by AssetService but not applied to its client; used elsewhere for boto3 |
| Constant | Value | Location |
|---|---|---|
| Asset presign expiry | 3 hours (60*60*3) | src/service/asset_service.py:30 |
| Archive presign expiry | 7 days | src/service/archive_service.py:20 |
| Archive validity before regen | 7 days | src/service/archive_service.py:21 |
| Thumbnail size | 192×108 | src/service/asset_service.py:80 |
Related pages: Executions, Assets & Reports API for the HTTP surface, Executions, Log Analysis & Reports for how archives feed the report pipeline, Janus Video Rooms for live video, and the Database Schema Overview for the
Asset and MissionExecution models.
