Episode 7 — YouTube Chapter Thumbnail: Backend API + iOS Integration

Git Branch (Both Sides)

feature/youtube-chapter-thumbnails

Use this same branch name on both the backend microservice repo and the iOS repo to keep work synchronized across sides.


Goal

Create a backend API endpoint that returns all chapter screenshot URLs stored in MinIO S3 for a given youtubeContentId, and integrate it into the iOS app to render a thumbnail strip per published story.


Background

  • Chapter screenshots are stored in MinIO at chapter-screenshots/{youtubeContentId}/ch{index}_{timestamp}s.jpg
  • Public bucket listing is blocked for security (prevents enumeration)
  • The server can list objects internally via S3 ListObjects API (server-to-server)
  • Filename pattern is predictable but this endpoint provides a canonical source of truth

Endpoint Design

GET /geo/chapters/{youtubeContentId}/thumbnails

Response

{
  "youtubeContentId": "xlumX1Wtzrg",
  "thumbnails": [
    "https://s3.travel-tube.com/chapter-screenshots/xlumX1Wtzrg/ch1_4s.jpg",
    "https://s3.travel-tube.com/chapter-screenshots/xlumX1Wtzrg/ch2_397s.jpg",
    "https://s3.travel-tube.com/chapter-screenshots/xlumX1Wtzrg/ch3_721s.jpg",
    "https://s3.travel-tube.com/chapter-screenshots/xlumX1Wtzrg/ch4_1161s.jpg"
  ]
}

Backend Implementation — geospatial-service (Port 8060)

  • Add MinIO/S3 client dependency (software.amazon.awssdk:s3)
  • Use S3Client.listObjectsV2() with prefix chapter-screenshots/{youtubeContentId}/
  • Map each key to a public URL: https://s3.travel-tube.com/{key}
  • Return sorted list

Files to Create/Modify

  1. geospatial-service/pom.xml — add AWS S3 SDK dependency
  2. geospatial-service/src/.../S3StorageService.java — MinIO S3 client, listChapterThumbnails(youtubeContentId)
  3. geospatial-service/src/.../ChapterController.java — add GET endpoint
  4. application.yml — add MinIO endpoint, bucket, credentials config

MinIO config:

  • Endpoint: https://s3.travel-tube.com
  • Bucket: chapter-screenshots
  • Credentials: from env.mac (never hardcode)

iOS Plan — Consume Thumbnail Endpoint

Goal

For any published story in MyStoryListView, fetch chapter thumbnail URLs from the new backend endpoint, persist them to ChapterEntity.thumbnail, and render a horizontal thumbnail strip below the main story image in MyStoryCellView.

Flow

  1. Cell appears in list.
  2. If story isPublished == true AND not all chapters already have thumbnail in Core Data → call the backend endpoint.
  3. Parse response, match each URL back to its chapter by the chN prefix (N = 1-based chapter index).
  4. Update ChapterEntity.thumbnail for each matching chapter and save context.
  5. Existing reactive Core Data publisher pushes updated AdventureTubeData to the cell → strip renders the real image(s).
  6. Before URLs resolve, the strip shows grey placeholder rectangles (one per chapter).

Files to Create/Modify

1. ChapterThumbnailsDTO.swift (new)

struct ChapterThumbnailsResponse: Decodable {
    let youtubeContentId: String
    let thumbnails: [String]
}

2. AdventureTubeAPIProtocol.swift — add:

func fetchChapterThumbnails(youtubeContentId: String)
    -> AnyPublisher<ServiceResponse<ChapterThumbnailsResponse>, Error>

3. AdventureTubeAPIService+Story.swift — implement call to GET {targetServerAddress}/geo/chapters/{youtubeContentId}/thumbnails with JWT auth header.

4. AdventureTubeData.swift — add thumbnail: String? to AdventureTubeChapter.

5. MyStoryListViewVM.swift — when mapping ChapterEntityAdventureTubeChapter, pass chapterEntity.thumbnail into the new thumbnail field.

6. MyStoryCommonDetailViewVM.swift

  • Add fetchChapterThumbnailsIfNeeded() called from init (or via onAppear).
  • Guard: adventureTubeData?.isPublished == true AND any chapter has thumbnail == nil.
  • Call API; on success, open a CoreData background context, fetch the StoryEntity, iterate chapters (NSOrderedSet), parse each URL’s chN to find the target index, assign thumbnail = url, save.

7. MyStoryCellView.swift — below the main Image, add a horizontal ScrollView:

  • ForEach over chapters ordered by youtubeTime
  • Each tile: fixed size (e.g. 80×50), rounded corner, grey fill as placeholder
  • If chapter.thumbnail is non-nil → overlay AsyncImage with .resizable().aspectRatio(contentMode: .fill)
  • Only render the strip if isPublished == true

URL → Chapter Matching

Extract chN via regex on the filename (ch(\d+)_\d+s\.jpg). N is 1-based, map to chapters[N-1]. If the regex fails for any URL, skip that URL.


Caching Strategy

  • Core Data is the cache. Once ChapterEntity.thumbnail is populated, the endpoint is not called again for that story on future launches.
  • AsyncImage handles HTTP-level image caching via URLSession defaults.

Edge Cases

  • Published story with zero chapters → skip.
  • Endpoint returns fewer URLs than chapters → leave missing chapters with thumbnail == nil; strip shows placeholders for those tiles.
  • Endpoint failure → silent; placeholders remain; retry on next cell appearance.

Thumbnail Strip UI Design

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

📷 = loaded from MinIO (cached locally)
🔄 = loading (AsyncImage placeholder)
⬜ = not yet available

Leave a Comment

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

Scroll to Top