Episode 4: Chapter Thumbnail

This documents the Chapter Thumbnail feature — displaying chapter screenshots from MinIO S3 as a horizontal scroll row on published story cards in MyStoryListView. The backend screenshot generation pipeline (Episode 2) provides the images; this episode covers the REST endpoint for iOS to poll screenshot status and the iOS integration to fetch and display them.


Design / Architecture

Depends on Episode 2 (Chapter Screenshots). The screenshot generation pipeline must complete before thumbnails can be displayed. This episode adds the polling endpoint and iOS UI.

Backend (already done in Episode 2)

  • Screenshots stored in MinIO S3 at https://s3.travel-tube.com/chapter-screenshots/{youtubeContentID}/ch{i}_{time}s.jpg
  • ScreenshotJobStatus in MongoDB tracks generation progress
  • Bucket policy set to public read

Backend (new in this episode)

  • Screenshot status REST endpoint: GET /geo/data/screenshot-status/{youtubeContentID}

iOS Design

  1. Published story card always reserves space for the chapter screenshot row
  2. On appear, check Core Data for screenshotUrl on chapters:
    • Has URLs → AsyncImage loads directly from MinIO
    • No URLs → poll GET /auth/geo/data/screenshot-status/{youtubeContentID} every 5s
  3. As screenshots become available, they appear one by one in the horizontal scroll
  4. Save URLs to Core Data — no polling needed next time

UI Design

┌─────────────────────────────────┐
│  [YouTube thumbnail]     📕     │
│  "Mornington Peninsula..."      │
│  📅 multipleday        📍 5    │
│                                 │
│  ┌─────┐┌─────┐┌─────┐┌─────┐  │
│  │ ch1 ││ ch2 ││ ch3 ││ ch4 │→ │  ← horizontal scroll
│  │ 📷  ││ 📷  ││ 🔄  ││ ⬜  │  │
│  └─────┘└─────┘└─────┘└─────┘  │
│  🏕️ 🌏 🚶 🚴 🏊                │
└─────────────────────────────────┘

📷 = loaded from MinIO
🔄 = loading (AsyncImage placeholder)
⬜ = not yet available (polling in progress)

Why polling, not SSE? Screenshot generation takes 15-20s and happens async after story save. iOS already received SSE COMPLETED for the story save. Screenshots are a secondary, non-blocking enhancement — polling every 5s is sufficient and simpler than opening another SSE stream.


Sequence Diagram

Chapter Thumbnail Sequence Diagram


iOS Call Stack: Thumbnail Polling Flow (TODO)

MyStoryListView.onAppear                            // MyStoryListView.swift
│
├─ for each published story without screenshotUrls:
│  └─ storyViewModel.startScreenshotPolling(youtubeContentID:)
│
startScreenshotPolling()                            // StoryViewModel (new)
│
├─ screenshotStatus = .polling(0, total)
│
├─ Timer.publish(every: 5.0)
│  └─ pollScreenshotStatus(youtubeContentID:)
│     └─ GET /auth/geo/data/screenshot-status/{id}
│
└─ .sink receiveValue:
   ├─ .COMPLETED → fetchUpdatedData(youtubeContentID:)
   │              → save screenshotUrls to Core Data
   │              → cancel timer
   ├─ .FAILED    → screenshotStatus = .failed, cancel timer
   └─ .PROCESSING → keep polling

--- UI rendering ---

ChapterThumbnailRow(chapters:)                      // ChapterThumbnailRow.swift (new)
│
├─ ScrollView(.horizontal)
│  └─ LazyHStack
│     └─ ForEach(chapters) { chapter in
│        ├─ if screenshotUrl != nil:
│        │  └─ AsyncImage(url: minioBaseURL + screenshotUrl)
│        │     ├─ .placeholder { ProgressView() }
│        │     └─ .failure { placeholderImage }
│        └─ else:
│           └─ Rectangle().fill(.gray.opacity(0.2))
│        }

Java Call Stack: Screenshot Status Endpoint (TODO)

AdventureTubeDataController
│  GET /geo/data/screenshot-status/{youtubeContentID}
│
├─ screenshotJobStatusRepository
│     .findByYoutubeContentID(youtubeContentID)
│  └─ Optional<ScreenshotJobStatus>
│
├─ .map(jobStatus -> ResponseEntity.ok(
│     ServiceResponse.builder()
│        .success(true)
│        .data(jobStatus)
│        .build()
│  ))
│  .orElse(ResponseEntity.notFound().build())
│
└─ return response

API Specification

GET /geo/data/screenshot-status/{youtubeContentID}

Response Meaning iOS Action
{ "status": "PENDING" } Job created, not started Keep polling
{ "status": "PROCESSING", "completedChapters": 3, "totalChapters": 5 } In progress Update progress UI, keep polling
{ "status": "COMPLETED" } All screenshots ready Fetch updated data, stop polling
{ "status": "FAILED", "errorMessage": "..." } Generation failed Show placeholder, stop polling
404 No screenshot job Show placeholder, don’t poll

Screenshot URL Construction

Base URL: https://s3.travel-tube.com/chapter-screenshots/

Full URL: {baseURL}{chapter.screenshotUrl}

Example: https://s3.travel-tube.com/chapter-screenshots/WsghFCuoZ6Q/ch1_176s.jpg


TODO

Backend

  • Add GET /geo/data/screenshot-status/{youtubeContentID} REST endpoint
  • Add route in auth-service to proxy the endpoint
  • Add route in gateway-service

iOS

  • Add screenshotUrl property to Chapter Core Data model
  • Create ChapterThumbnailRow SwiftUI view (horizontal scroll with AsyncImage)
  • Add screenshot polling logic (Timer + API call)
  • Integrate into MyStoryListView published story cards
  • Cache screenshotUrl in Core Data after first successful fetch

Deployment

  • Install yt-dlp + ffmpeg in geospatial-service Docker image for Pi
  • Update Jenkins credentials with MinIO env vars
  • Deploy to K3s and test on Pi

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top