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
ListObjectsAPI (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 prefixchapter-screenshots/{youtubeContentId}/ - Map each key to a public URL:
https://s3.travel-tube.com/{key} - Return sorted list
Files to Create/Modify
geospatial-service/pom.xml— add AWS S3 SDK dependencygeospatial-service/src/.../S3StorageService.java— MinIO S3 client,listChapterThumbnails(youtubeContentId)geospatial-service/src/.../ChapterController.java— add GET endpointapplication.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
- Cell appears in list.
- If story
isPublished == trueAND not all chapters already havethumbnailin Core Data → call the backend endpoint. - Parse response, match each URL back to its chapter by the
chNprefix (N = 1-based chapter index). - Update
ChapterEntity.thumbnailfor each matching chapter and save context. - Existing reactive Core Data publisher pushes updated
AdventureTubeDatato the cell → strip renders the real image(s). - 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 ChapterEntity → AdventureTubeChapter, pass chapterEntity.thumbnail into the new thumbnail field.
6. MyStoryCommonDetailViewVM.swift
- Add
fetchChapterThumbnailsIfNeeded()called frominit(or viaonAppear). - Guard:
adventureTubeData?.isPublished == trueAND any chapter hasthumbnail == nil. - Call API; on success, open a CoreData background context, fetch the
StoryEntity, iteratechapters(NSOrderedSet), parse each URL’schNto find the target index, assignthumbnail = url, save.
7. MyStoryCellView.swift — below the main Image, add a horizontal ScrollView:
ForEachover chapters ordered byyoutubeTime- Each tile: fixed size (e.g. 80×50), rounded corner, grey fill as placeholder
- If
chapter.thumbnailis non-nil → overlayAsyncImagewith.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.thumbnailis populated, the endpoint is not called again for that story on future launches. AsyncImagehandles 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
