Skip to content

Commit 4a73c67

Browse files
committed
feat: enforce adult privacy across catalog entities
1 parent 9211fc9 commit 4a73c67

37 files changed

Lines changed: 589 additions & 163 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ The two native mobile apps keep platform-specific UI and storage while sharing t
3333
- **Read catalog reviews and follow recommendations** with full review bodies, blank/duplicate removal, localized author fallbacks, optional rating/date metadata, same-media-type TMDb recommendations, current-title exclusion, a separate similar-titles shelf, and adult-title filtering unless the local PIN is unlocked on Apple, Apple TV, and every full Android/KMP catalog client.
3434
- **Understand every regional edition** through production-company/network links, region-matched certification and release date, alternative titles, localized translations, and external identifiers decoded by typed native models.
3535
- **See where to watch** by device or chosen region for stream/rent/buy offers, with TMDb links and required JustWatch attribution.
36-
- **Keep adult content private by default** with local age confirmation, a six-digit device PIN, and five-attempt lockout; companion and public surfaces never receive it.
36+
- **Keep adult content private by default** with local age confirmation, a six-digit device PIN, and five-attempt lockout. The gate partitions Search, External ID, Title, Person, Collection, Company, Network, Keyword, Credit Detail, recommendations and similar titles; every client filters again before display, while companion and public surfaces never receive restricted titles.
3737
- **Connect TMDb safely** through browser approval or TV QR without entering a password in SmartMovie. Browse paginated Movie/TV account recommendations, rate Movie/TV/Episode titles, and manage account library/lists with durable offline mutation retry.
3838
- **Manage mixed custom lists** by loading every list page, editing metadata, paging through Movie/TV contents, searching the catalog, and adding or removing titles with restart-safe optimistic synchronization.
3939
- **Build a local-first library** with independent Favorite and Watchlist actions. SwiftData keeps both readable offline; private CloudKit remains an Apple storage option.
@@ -205,7 +205,7 @@ cd ../..
205205
./scripts/verify-release.sh
206206
```
207207

208-
The current verified local baseline contains 66 Swift tests and 93 Worker tests. Coverage includes canonical `/v1` and `/v2` fixture decoding, deterministic catalog-review and recommendation presentation, non-empty editorial and Movie/TV/Season/Episode media fixtures, image/video/external-ID presentation, numeric TMDb Season/Episode external-ID normalization, typed regional release/content-rating, Movie/TV alternative-title and translation metadata, configured capability/fixture equality, fail-closed browser/TV account rollout, malformed broker configuration and return-URI allowlists, cold-start callback deferral, stale completion invalidation, durable outbox isolation, capability-gated Advanced Discover and Profile provider regions with a fail-closed `/v1` fallback, complete Movie/TV Discover queries and regional provider configuration, External ID and Credit Detail source/path mapping, account recommendations, normalized/paginated custom mixed lists, restart-safe pending item snapshots, explicit adult age confirmation, six-digit PIN validation, five-attempt lockout, local adult filtering and in-flight request invalidation, metadata/item mutations, normalized person/title credit links, exact episode companion context, unknown and missing nullable fields, success/error schema validation, repeatable D1 migrations, encryption/callback/CSRF controls, durable idempotency, TMDb Changes pagination/backlog recovery, invalid cursor recovery, verified changing-page-count fallback, D1 parameter-bound chunking, monotonic revision and cache-bypass behavior, retries, cancellation, pagination, and data behavior without live personal credentials.
208+
The current verified local baseline contains 67 Swift tests and 96 Worker tests. Coverage includes canonical `/v1` and `/v2` fixture decoding, deterministic catalog-review and recommendation presentation, non-empty editorial and Movie/TV/Season/Episode media fixtures, image/video/external-ID presentation, numeric TMDb Season/Episode external-ID normalization, typed regional release/content-rating, Movie/TV alternative-title and translation metadata, configured capability/fixture equality, fail-closed browser/TV account rollout, malformed broker configuration and return-URI allowlists, cold-start callback deferral, stale completion invalidation, durable outbox isolation, capability-gated Advanced Discover and Profile provider regions with a fail-closed `/v1` fallback, complete Movie/TV Discover queries and regional provider configuration, External ID and Credit Detail source/path mapping, account recommendations, normalized/paginated custom mixed lists, restart-safe pending item snapshots, explicit adult age confirmation, six-digit PIN validation, five-attempt lockout, Worker partitioning plus client-side fail-closed filtering for every entity-related title/credit surface, local adult filtering and in-flight request invalidation, metadata/item mutations, normalized person/title credit links, exact episode companion context, unknown and missing nullable fields, success/error schema validation, repeatable D1 migrations, encryption/callback/CSRF controls, durable idempotency, TMDb Changes pagination/backlog recovery, invalid cursor recovery, verified changing-page-count fallback, D1 parameter-bound chunking, monotonic revision and cache-bypass behavior, retries, cancellation, pagination, and data behavior without live personal credentials.
209209

210210
CI independently builds and analyzes iOS, iPad/Catalyst, tvOS, native macOS, watchOS, and visionOS, then installs and launches the iOS app in Simulator. Read [Testing](docs/TESTING.md) for destination-specific commands and the manual device matrix.
211211

SmartMovieKit/Sources/SmartMovieKit/App/FeatureModels.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ public final class SearchViewModel {
356356
query.trimmingCharacters(in: .whitespacesAndNewlines) == expectedQuery,
357357
scope == expectedScope,
358358
entityScope == expectedEntityScope else { return }
359-
entities = result.results
359+
entities = result.results.applyingAdultVisibility(includeAdult: includeAdult)
360360
page = result.page
361361
canLoadMore = result.page < result.totalPages
362362
isLoading = false
@@ -393,15 +393,16 @@ public final class SearchViewModel {
393393
let result = try await catalogV2.findExternalID(
394394
externalID,
395395
source: expectedSource,
396-
language: language
396+
language: language,
397+
includeAdult: includeAdult
397398
)
398399
guard !Task.isCancelled,
399400
searchGeneration == expectedGeneration,
400401
self.includeAdult == includeAdult,
401402
mode == .externalID,
402403
query.trimmingCharacters(in: .whitespacesAndNewlines) == externalID,
403404
externalIDSource == expectedSource else { return }
404-
entities = result.results.filter { includeAdult || !$0.isAdultTitle }
405+
entities = result.results.applyingAdultVisibility(includeAdult: includeAdult)
405406
isLoading = false
406407
} catch is CancellationError {
407408
return
@@ -472,7 +473,7 @@ public final class SearchViewModel {
472473
query.trimmingCharacters(in: .whitespacesAndNewlines) == expectedQuery,
473474
scope == expectedScope,
474475
entityScope == expectedEntityScope else { return }
475-
entities.append(contentsOf: result.results.filter { incoming in
476+
entities.append(contentsOf: result.results.applyingAdultVisibility(includeAdult: includeAdult).filter { incoming in
476477
!entities.contains(where: { $0.id == incoming.id })
477478
})
478479
page = result.page
@@ -487,13 +488,12 @@ public final class SearchViewModel {
487488
}
488489
}
489490
}
490-
491491
public func applyAdultVisibility(includeAdult: Bool) {
492492
self.includeAdult = includeAdult
493493
guard !includeAdult else { return }
494494
searchTask?.cancel()
495495
searchGeneration += 1
496-
entities.removeAll(where: \.isAdultTitle)
496+
entities = entities.applyingAdultVisibility(includeAdult: false)
497497
isLoading = false
498498
canLoadMore = false
499499
}

SmartMovieKit/Sources/SmartMovieKit/Data/CatalogRepositoryV2.swift

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ extension RemoteCatalogRepository: CatalogV2Repository {
4040
public func findExternalID(
4141
_ externalID: String,
4242
source: ExternalIDSource,
43-
language: String
43+
language: String,
44+
includeAdult: Bool
4445
) async throws -> ExternalIDFindResult {
4546
try await client.get("v2/find/\(externalID)", queryItems: [
4647
URLQueryItem(name: "source", value: source.rawValue),
47-
URLQueryItem(name: "language", value: language)
48+
URLQueryItem(name: "language", value: language),
49+
URLQueryItem(name: "include_adult", value: String(includeAdult))
4850
])
4951
}
5052

@@ -63,26 +65,40 @@ extension RemoteCatalogRepository: CatalogV2Repository {
6365
return try await client.get("v2/titles/\(mediaType.rawValue)/\(id)", queryItems: queryItems)
6466
}
6567

66-
public func person(id: Int, language: String) async throws -> PersonDetail {
67-
try await client.get("v2/entities/person/\(id)", queryItems: [URLQueryItem(name: "language", value: language)])
68+
public func person(id: Int, language: String, includeAdult: Bool) async throws -> PersonDetail {
69+
try await client.get("v2/entities/person/\(id)", queryItems: [
70+
URLQueryItem(name: "language", value: language),
71+
URLQueryItem(name: "include_adult", value: String(includeAdult))
72+
])
6873
}
6974

70-
public func collection(id: Int, language: String) async throws -> CollectionDetail {
71-
try await client.get("v2/entities/collection/\(id)", queryItems: [URLQueryItem(name: "language", value: language)])
75+
public func collection(id: Int, language: String, includeAdult: Bool) async throws -> CollectionDetail {
76+
try await client.get("v2/entities/collection/\(id)", queryItems: [
77+
URLQueryItem(name: "language", value: language),
78+
URLQueryItem(name: "include_adult", value: String(includeAdult))
79+
])
7280
}
7381

74-
public func organization(kind: EntityKind, id: Int, language: String, page: Int) async throws -> OrganizationDetail {
82+
public func organization(
83+
kind: EntityKind,
84+
id: Int,
85+
language: String,
86+
page: Int,
87+
includeAdult: Bool
88+
) async throws -> OrganizationDetail {
7589
guard kind == .company || kind == .network else { throw APIError.notFound }
7690
return try await client.get("v2/entities/\(kind.rawValue)/\(id)", queryItems: [
7791
URLQueryItem(name: "language", value: language),
78-
URLQueryItem(name: "page", value: String(page))
92+
URLQueryItem(name: "page", value: String(page)),
93+
URLQueryItem(name: "include_adult", value: String(includeAdult))
7994
])
8095
}
8196

82-
public func keyword(id: Int, language: String, page: Int) async throws -> KeywordDetail {
97+
public func keyword(id: Int, language: String, page: Int, includeAdult: Bool) async throws -> KeywordDetail {
8398
try await client.get("v2/entities/keyword/\(id)", queryItems: [
8499
URLQueryItem(name: "language", value: language),
85-
URLQueryItem(name: "page", value: String(page))
100+
URLQueryItem(name: "page", value: String(page)),
101+
URLQueryItem(name: "include_adult", value: String(includeAdult))
86102
])
87103
}
88104

@@ -97,10 +113,13 @@ extension RemoteCatalogRepository: CatalogV2Repository {
97113
)
98114
}
99115

100-
public func credit(id: String, language: String) async throws -> CreditDetail {
116+
public func credit(id: String, language: String, includeAdult: Bool) async throws -> CreditDetail {
101117
try await client.get(
102118
"v2/credits/\(id)",
103-
queryItems: [URLQueryItem(name: "language", value: language)]
119+
queryItems: [
120+
URLQueryItem(name: "language", value: language),
121+
URLQueryItem(name: "include_adult", value: String(includeAdult))
122+
]
104123
)
105124
}
106125
}

SmartMovieKit/Sources/SmartMovieKit/Domain/CatalogEntity+Adult.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,40 @@ extension CatalogEntity {
33
if case .title(let title) = self { return title.isAdult }
44
return false
55
}
6+
7+
func applyingAdultVisibility(includeAdult: Bool) -> CatalogEntity? {
8+
switch self {
9+
case .title(let title):
10+
return includeAdult || !title.isAdult ? self : nil
11+
case .person(let person):
12+
return .person(PersonSummary(
13+
id: person.id,
14+
name: person.name,
15+
profilePath: person.profilePath,
16+
knownForDepartment: person.knownForDepartment,
17+
popularity: person.popularity,
18+
knownFor: person.knownFor.filter { includeAdult || !$0.isAdult }
19+
))
20+
case .season, .episode:
21+
return includeAdult ? self : nil
22+
default:
23+
return self
24+
}
25+
}
26+
}
27+
28+
extension Array where Element == CatalogEntity {
29+
func applyingAdultVisibility(includeAdult: Bool) -> [CatalogEntity] {
30+
compactMap { $0.applyingAdultVisibility(includeAdult: includeAdult) }
31+
}
32+
}
33+
34+
public enum CatalogAdultVisibility {
35+
public static func titles(_ values: [TitleSummary], includeAdult: Bool) -> [TitleSummary] {
36+
values.filter { includeAdult || !$0.isAdult }
37+
}
38+
39+
public static func credits(_ values: [Credit], includeAdult: Bool) -> [Credit] {
40+
values.filter { includeAdult || $0.adult != true }
41+
}
642
}

SmartMovieKit/Sources/SmartMovieKit/Domain/CreditDetailModels.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ public struct CreditDetail: Codable, Hashable, Sendable {
88
public let character: String?
99
public let personSummary: PersonSummary?
1010
public let titleSummary: TitleSummary?
11+
12+
public func applyingAdultVisibility(includeAdult: Bool) -> CreditDetail {
13+
CreditDetail(
14+
creditId: creditId,
15+
creditType: creditType,
16+
department: department,
17+
job: job,
18+
character: character,
19+
personSummary: personSummary,
20+
titleSummary: titleSummary.flatMap { includeAdult || !$0.isAdult ? $0 : nil }
21+
)
22+
}
1123
}
1224

1325
public extension Credit {

SmartMovieKit/Sources/SmartMovieKit/Domain/ModelsV2.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ public struct Credit: Codable, Hashable, Sendable {
223223
public let posterPath: String?
224224
public let order: Int?
225225
public let episodeCount: Int?
226+
public let adult: Bool?
226227
}
227228

228229
public struct ImageAsset: Codable, Hashable, Sendable {

SmartMovieKit/Sources/SmartMovieKit/Domain/RepositoryProtocolsV2.swift

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,32 @@ public protocol CatalogV2Repository: Sendable {
1111
includeAdult: Bool
1212
) async throws -> PagedResult<CatalogEntity>
1313
func searchEntities(_ request: EntitySearchRequest) async throws -> PagedResult<CatalogEntity>
14-
func findExternalID(_ externalID: String, source: ExternalIDSource, language: String) async throws -> ExternalIDFindResult
14+
func findExternalID(
15+
_ externalID: String,
16+
source: ExternalIDSource,
17+
language: String,
18+
includeAdult: Bool
19+
) async throws -> ExternalIDFindResult
1520
func deepDetail(
1621
mediaType: MediaType,
1722
id: Int,
1823
language: String,
1924
region: String?,
2025
includeAdult: Bool
2126
) async throws -> TitleDetailV2
22-
func person(id: Int, language: String) async throws -> PersonDetail
23-
func collection(id: Int, language: String) async throws -> CollectionDetail
24-
func organization(kind: EntityKind, id: Int, language: String, page: Int) async throws -> OrganizationDetail
25-
func keyword(id: Int, language: String, page: Int) async throws -> KeywordDetail
27+
func person(id: Int, language: String, includeAdult: Bool) async throws -> PersonDetail
28+
func collection(id: Int, language: String, includeAdult: Bool) async throws -> CollectionDetail
29+
func organization(
30+
kind: EntityKind,
31+
id: Int,
32+
language: String,
33+
page: Int,
34+
includeAdult: Bool
35+
) async throws -> OrganizationDetail
36+
func keyword(id: Int, language: String, page: Int, includeAdult: Bool) async throws -> KeywordDetail
2637
func season(seriesID: Int, number: Int, language: String) async throws -> SeasonDetail
2738
func episode(seriesID: Int, season: Int, number: Int, language: String) async throws -> EpisodeDetail
28-
func credit(id: String, language: String) async throws -> CreditDetail
39+
func credit(id: String, language: String, includeAdult: Bool) async throws -> CreditDetail
2940
}
3041

3142
public protocol AccountRecommendationsLoading: Sendable {

SmartMovieKit/Sources/SmartMovieKit/Resources/en.lproj/Localizable.strings

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@
144144
"Translations" = "Translations";
145145
"External identifiers" = "External identifiers";
146146
"Source repositories" = "Source repositories";
147-
"Catalog metadata description" = "Explore TMDb image and video galleries, member reviews, catalog recommendations, regional release dates, certifications, localized titles, translations and external identifiers across titles, seasons and episodes.";
147+
"Catalog metadata description" = "Explore TMDb image and video galleries, member reviews, recommendations, regional release dates, certifications, localized titles, translations and external identifiers. The local adult-content PIN applies to Search and every related Person, Collection, Company, Network, Keyword and Credit Detail surface.";
148148
"Details" = "Details";
149149
"Images" = "Images";
150150
"Catalog image" = "TMDb catalog image";

SmartMovieKit/Sources/SmartMovieKit/Resources/ja.lproj/Localizable.strings

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@
144144
"Translations" = "翻訳";
145145
"External identifiers" = "外部識別子";
146146
"Source repositories" = "ソースリポジトリ";
147-
"Catalog metadata description" = "作品、シーズン、エピソードのTMDb画像・動画ギャラリー、レビュー、おすすめ、地域別公開日、年齢区分、ローカライズされたタイトル、翻訳、外部識別子を確認できます。";
147+
"Catalog metadata description" = "TMDbの画像、動画、レビュー、おすすめ、公開情報、ローカライズ、外部識別子を確認できます。端末内の成人向けPINは、検索と人物、コレクション、会社、ネットワーク、キーワード、クレジット詳細の関連画面すべてに適用されます。";
148148
"Details" = "詳細";
149149
"Images" = "画像";
150150
"Catalog image" = "TMDbカタログ画像";

SmartMovieKit/Sources/SmartMovieKit/Resources/ko.lproj/Localizable.strings

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@
144144
"Translations" = "번역";
145145
"External identifiers" = "외부 식별자";
146146
"Source repositories" = "소스 저장소";
147-
"Catalog metadata description" = "작품, 시즌, 에피소드의 TMDb 이미지·동영상 갤러리, 리뷰, 추천, 지역별 공개일, 시청 등급, 현지화 제목, 번역 및 외부 식별자를 확인하세요.";
147+
"Catalog metadata description" = "TMDb 이미지, 동영상, 리뷰, 추천, 공개 정보, 현지화 및 외부 식별자를 확인하세요. 기기 내 성인 콘텐츠 PIN은 검색과 인물, 컬렉션, 제작사, 네트워크, 키워드 및 크레딧 상세의 모든 관련 화면에 적용됩니다.";
148148
"Details" = "세부 정보";
149149
"Images" = "이미지";
150150
"Catalog image" = "TMDb 카탈로그 이미지";

0 commit comments

Comments
 (0)