From 8c66fa9b82063ac180a518c6dbf18e91b5356435 Mon Sep 17 00:00:00 2001 From: szbk Date: Sun, 1 Mar 2026 01:57:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(tmdb):=20cast=20alanina=20gore=20kat=C4=B1?= =?UTF-8?q?=20eslesmeli=20arama=20destegi=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...01-tmdb-cast-strict-matching-brainstorm.md | 28 +++++ src/routes/tmdb.routes.ts | 14 ++- src/services/tmdb.service.ts | 117 ++++++++++++++++-- src/types/index.ts | 1 + 4 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 docs/brainstorms/2026-03-01-tmdb-cast-strict-matching-brainstorm.md diff --git a/docs/brainstorms/2026-03-01-tmdb-cast-strict-matching-brainstorm.md b/docs/brainstorms/2026-03-01-tmdb-cast-strict-matching-brainstorm.md new file mode 100644 index 0000000..8c5263a --- /dev/null +++ b/docs/brainstorms/2026-03-01-tmdb-cast-strict-matching-brainstorm.md @@ -0,0 +1,28 @@ +--- +date: 2026-03-01 +topic: tmdb-cast-strict-matching +--- + +# TMDB Cast Bazlı Katı Eşleşme + +## What We're Building +TMDB arama akışına opsiyonel `cast` alanı eklenecek. İstekte `cast` verildiğinde sistem, mevcut `title/year/seasonYear/seasonNumber/type` ile adayları bulduktan sonra ilk 5 adayı cast bilgisi ile doğrulayacak. + +Cast doğrulaması katı olacak: verilen cast adı adayın oyuncu listesinde yoksa aday elenecek. İlk 5 adayda hiç eşleşme bulunmazsa boş sonuç dönülecek. `cast` verilmediğinde mevcut davranış korunacak. + +## Why This Approach +Kullanıcı beklentisi yanlış eşleşmeleri azaltmak ve “başlık + tek oyuncu adı” ile daha doğru içeriği seçmek. Katı filtreleme, özellikle benzer isimli yapımlarda hatalı ilk sonucu engeller. + +Top 5 doğrulama, doğruluk ve API maliyetini dengeler. `cast` alanını opsiyonel tutmak, mevcut istemcilerle geriye dönük uyumluluğu korur. + +## Key Decisions +- `cast` alanı opsiyonel: Eski entegrasyonlar bozulmaz. +- Cast eşleşmesi katı: Eşleşme yoksa sonuç dönmez. +- Doğrulama kapsamı Top 5: Aşırı API çağrısından kaçınılır. +- Eşleşme modu esnek normalize: büyük/küçük harf, Türkçe karakter varyasyonları ve boşluk farklılıkları tolere edilir. + +## Open Questions +- Cast eşleşmesi yokken yanıt sadece `results: []` mı olmalı, yoksa `reason` gibi açıklayıcı bir alan eklenmeli mi? + +## Next Steps +-> `/workflows:plan` diff --git a/src/routes/tmdb.routes.ts b/src/routes/tmdb.routes.ts index 6ed13b7..21721db 100644 --- a/src/routes/tmdb.routes.ts +++ b/src/routes/tmdb.routes.ts @@ -19,6 +19,7 @@ const tmdbSearchSchema = z.object({ type: z.enum(['movie', 'tv', 'multi']).optional(), seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), seasonNumber: z.coerce.number().int().min(1).max(100).optional(), + cast: z.string().trim().min(1).max(120).optional(), }); /** @@ -59,7 +60,7 @@ router.post( return; } - const { query, year, type, seasonYear, seasonNumber } = result.data; + const { query, year, type, seasonYear, seasonNumber, cast } = result.data; try { const searchResult = await TmdbService.search({ @@ -68,6 +69,7 @@ router.post( type: type || 'multi', seasonYear, seasonNumber, + cast, }); const response: ApiResponse = { @@ -106,6 +108,7 @@ router.post( const movieSearchSchema = z.object({ query: z.string().trim().min(1).max(200), year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), + cast: z.string().trim().min(1).max(120).optional(), }); const result = movieSearchSchema.safeParse(req.body); @@ -128,10 +131,10 @@ router.post( return; } - const { query, year } = result.data; + const { query, year, cast } = result.data; try { - const searchResult = await TmdbService.searchMovies(query, year); + const searchResult = await TmdbService.searchMovies(query, year, cast); const response: ApiResponse = { success: true, @@ -171,6 +174,7 @@ router.post( year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), seasonNumber: z.coerce.number().int().min(1).max(100).optional(), + cast: z.string().trim().min(1).max(120).optional(), }); const result = tvSearchSchema.safeParse(req.body); @@ -193,10 +197,10 @@ router.post( return; } - const { query, year, seasonYear, seasonNumber } = result.data; + const { query, year, seasonYear, seasonNumber, cast } = result.data; try { - const searchResult = await TmdbService.searchTv(query, year, seasonNumber, seasonYear); + const searchResult = await TmdbService.searchTv(query, year, seasonNumber, seasonYear, cast); const response: ApiResponse = { success: true, diff --git a/src/services/tmdb.service.ts b/src/services/tmdb.service.ts index c375940..ba420b9 100644 --- a/src/services/tmdb.service.ts +++ b/src/services/tmdb.service.ts @@ -52,11 +52,85 @@ const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; * TMDB Image Base URL */ const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/original'; +const CAST_FILTER_CANDIDATE_LIMIT = 5; /** * TMDB Service for movie/TV show search */ export class TmdbService { + private static normalizeCastName(name: string): string { + return name + .normalize('NFKC') + .trim() + .replace(/[-‐‑‒–—―'’`.]/g, ' ') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .toLocaleLowerCase('tr') + .replace(/[ıİ]/g, 'i') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + } + + private static isCastNameMatch(candidate: string, requested: string): boolean { + const normalizedCandidate = this.normalizeCastName(candidate); + const normalizedRequested = this.normalizeCastName(requested); + + if (!normalizedCandidate || !normalizedRequested) return false; + if (normalizedCandidate === normalizedRequested) return true; + + // Secondary strictness: allow spacing variants like "eun jin" vs "eunjin" + const compactCandidate = normalizedCandidate.replace(/\s+/g, ''); + const compactRequested = normalizedRequested.replace(/\s+/g, ''); + return compactCandidate === compactRequested; + } + + private static async getCreditsCastNames( + mediaType: 'movie' | 'tv', + tmdbId: number + ): Promise { + const url = `${TMDB_BASE_URL}/${mediaType}/${tmdbId}/credits`; + + try { + const response = await fetch(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + return []; + } + + const data = await response.json() as { cast?: Array<{ name?: string | null }> }; + const castNames = Array.isArray(data.cast) ? data.cast : []; + + return castNames + .map((item) => (typeof item?.name === 'string' ? item.name : '')) + .filter((name) => name.length > 0); + } catch { + return []; + } + } + + private static async filterResultsByCast( + results: TmdbSearchResult[], + castName: string + ): Promise { + const normalizedRequested = this.normalizeCastName(castName); + if (!normalizedRequested) return []; + + const candidates = results.slice(0, CAST_FILTER_CANDIDATE_LIMIT); + const matched: TmdbSearchResult[] = []; + + for (const candidate of candidates) { + const castNames = await this.getCreditsCastNames(candidate.type, candidate.id); + const hasMatch = castNames.some((name) => this.isCastNameMatch(name, castName)); + if (hasMatch) { + matched.push(candidate); + } + } + + return matched; + } /** * Get common headers for TMDB API requests */ @@ -265,7 +339,11 @@ export class TmdbService { /** * Search for movies */ - static async searchMovies(query: string, year?: number): Promise { + static async searchMovies( + query: string, + year?: number, + cast?: string + ): Promise { const params = new URLSearchParams({ query, language: 'tr-TR', @@ -292,15 +370,19 @@ export class TmdbService { const data: TmdbRawResponse = await response.json(); - const results = data.results + const normalizedResults = data.results .map((r) => this.normalizeMovie(r as TmdbRawMovie)) .filter((r): r is TmdbSearchResult => r !== null); + const results = cast + ? await this.filterResultsByCast(normalizedResults, cast) + : normalizedResults; + return { page: data.page, results, totalPages: data.total_pages, - totalResults: data.total_results, + totalResults: results.length, }; } @@ -315,7 +397,8 @@ export class TmdbService { query: string, year?: number, seasonNumber?: number, - seasonYear?: number + seasonYear?: number, + cast?: string ): Promise { const params = new URLSearchParams({ query, @@ -354,6 +437,10 @@ export class TmdbService { results = await this.filterAndEnrichTvResultsBySeason(results, seasonNumber, seasonYear); } + if (cast) { + results = await this.filterResultsByCast(results, cast); + } + return { page: data.page, results, @@ -365,7 +452,11 @@ export class TmdbService { /** * Multi search (movies, TV shows, and people) */ - static async searchMulti(query: string, year?: number): Promise { + static async searchMulti( + query: string, + year?: number, + cast?: string + ): Promise { const params = new URLSearchParams({ query, language: 'tr-TR', @@ -393,16 +484,20 @@ export class TmdbService { const data: TmdbRawResponse = await response.json(); // Filter out person results and normalize - const results = data.results + const normalizedResults = data.results .filter((r) => r.media_type !== 'person') .map((r) => this.normalizeResult(r)) .filter((r): r is TmdbSearchResult => r !== null); + const results = cast + ? await this.filterResultsByCast(normalizedResults, cast) + : normalizedResults; + return { page: data.page, results, totalPages: data.total_pages, - totalResults: data.total_results, + totalResults: results.length, }; } @@ -411,17 +506,17 @@ export class TmdbService { * @param request Search request with query, year, type, and optional season parameters */ static async search(request: TmdbSearchRequest): Promise { - const { query, year, type = 'multi', seasonYear, seasonNumber } = request; + const { query, year, type = 'multi', seasonYear, seasonNumber, cast } = request; switch (type) { case 'movie': - return this.searchMovies(query, year); + return this.searchMovies(query, year, cast); case 'tv': // For TV shows, use season parameters if provided - return this.searchTv(query, year, seasonNumber, seasonYear); + return this.searchTv(query, year, seasonNumber, seasonYear, cast); case 'multi': default: - return this.searchMulti(query, year); + return this.searchMulti(query, year, cast); } } } diff --git a/src/types/index.ts b/src/types/index.ts index 5d3da80..4473b64 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -219,6 +219,7 @@ export interface TmdbSearchRequest { type?: 'movie' | 'tv' | 'multi'; seasonYear?: number; seasonNumber?: number; + cast?: string; } export interface TmdbSearchResult {