feat(tmdb): cast alanina gore katı eslesmeli arama destegi ekle

This commit is contained in:
2026-03-01 01:57:57 +03:00
parent 79f90cb287
commit 8c66fa9b82
4 changed files with 144 additions and 16 deletions

View File

@@ -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`

View File

@@ -19,6 +19,7 @@ const tmdbSearchSchema = z.object({
type: z.enum(['movie', 'tv', 'multi']).optional(), type: z.enum(['movie', 'tv', 'multi']).optional(),
seasonYear: 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(), 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; return;
} }
const { query, year, type, seasonYear, seasonNumber } = result.data; const { query, year, type, seasonYear, seasonNumber, cast } = result.data;
try { try {
const searchResult = await TmdbService.search({ const searchResult = await TmdbService.search({
@@ -68,6 +69,7 @@ router.post(
type: type || 'multi', type: type || 'multi',
seasonYear, seasonYear,
seasonNumber, seasonNumber,
cast,
}); });
const response: ApiResponse<TmdbSearchResponse> = { const response: ApiResponse<TmdbSearchResponse> = {
@@ -106,6 +108,7 @@ router.post(
const movieSearchSchema = z.object({ const movieSearchSchema = z.object({
query: z.string().trim().min(1).max(200), query: z.string().trim().min(1).max(200),
year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), 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); const result = movieSearchSchema.safeParse(req.body);
@@ -128,10 +131,10 @@ router.post(
return; return;
} }
const { query, year } = result.data; const { query, year, cast } = result.data;
try { try {
const searchResult = await TmdbService.searchMovies(query, year); const searchResult = await TmdbService.searchMovies(query, year, cast);
const response: ApiResponse<TmdbSearchResponse> = { const response: ApiResponse<TmdbSearchResponse> = {
success: true, success: true,
@@ -171,6 +174,7 @@ router.post(
year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), 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(), seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
seasonNumber: z.coerce.number().int().min(1).max(100).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); const result = tvSearchSchema.safeParse(req.body);
@@ -193,10 +197,10 @@ router.post(
return; return;
} }
const { query, year, seasonYear, seasonNumber } = result.data; const { query, year, seasonYear, seasonNumber, cast } = result.data;
try { try {
const searchResult = await TmdbService.searchTv(query, year, seasonNumber, seasonYear); const searchResult = await TmdbService.searchTv(query, year, seasonNumber, seasonYear, cast);
const response: ApiResponse<TmdbSearchResponse> = { const response: ApiResponse<TmdbSearchResponse> = {
success: true, success: true,

View File

@@ -52,11 +52,85 @@ const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
* TMDB Image Base URL * TMDB Image Base URL
*/ */
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/original'; 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 * TMDB Service for movie/TV show search
*/ */
export class TmdbService { 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<string[]> {
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<TmdbSearchResult[]> {
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 * Get common headers for TMDB API requests
*/ */
@@ -265,7 +339,11 @@ export class TmdbService {
/** /**
* Search for movies * Search for movies
*/ */
static async searchMovies(query: string, year?: number): Promise<TmdbSearchResponse> { static async searchMovies(
query: string,
year?: number,
cast?: string
): Promise<TmdbSearchResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
query, query,
language: 'tr-TR', language: 'tr-TR',
@@ -292,15 +370,19 @@ export class TmdbService {
const data: TmdbRawResponse = await response.json(); const data: TmdbRawResponse = await response.json();
const results = data.results const normalizedResults = data.results
.map((r) => this.normalizeMovie(r as TmdbRawMovie)) .map((r) => this.normalizeMovie(r as TmdbRawMovie))
.filter((r): r is TmdbSearchResult => r !== null); .filter((r): r is TmdbSearchResult => r !== null);
const results = cast
? await this.filterResultsByCast(normalizedResults, cast)
: normalizedResults;
return { return {
page: data.page, page: data.page,
results, results,
totalPages: data.total_pages, totalPages: data.total_pages,
totalResults: data.total_results, totalResults: results.length,
}; };
} }
@@ -315,7 +397,8 @@ export class TmdbService {
query: string, query: string,
year?: number, year?: number,
seasonNumber?: number, seasonNumber?: number,
seasonYear?: number seasonYear?: number,
cast?: string
): Promise<TmdbSearchResponse> { ): Promise<TmdbSearchResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
query, query,
@@ -354,6 +437,10 @@ export class TmdbService {
results = await this.filterAndEnrichTvResultsBySeason(results, seasonNumber, seasonYear); results = await this.filterAndEnrichTvResultsBySeason(results, seasonNumber, seasonYear);
} }
if (cast) {
results = await this.filterResultsByCast(results, cast);
}
return { return {
page: data.page, page: data.page,
results, results,
@@ -365,7 +452,11 @@ export class TmdbService {
/** /**
* Multi search (movies, TV shows, and people) * Multi search (movies, TV shows, and people)
*/ */
static async searchMulti(query: string, year?: number): Promise<TmdbSearchResponse> { static async searchMulti(
query: string,
year?: number,
cast?: string
): Promise<TmdbSearchResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
query, query,
language: 'tr-TR', language: 'tr-TR',
@@ -393,16 +484,20 @@ export class TmdbService {
const data: TmdbRawResponse = await response.json(); const data: TmdbRawResponse = await response.json();
// Filter out person results and normalize // Filter out person results and normalize
const results = data.results const normalizedResults = data.results
.filter((r) => r.media_type !== 'person') .filter((r) => r.media_type !== 'person')
.map((r) => this.normalizeResult(r)) .map((r) => this.normalizeResult(r))
.filter((r): r is TmdbSearchResult => r !== null); .filter((r): r is TmdbSearchResult => r !== null);
const results = cast
? await this.filterResultsByCast(normalizedResults, cast)
: normalizedResults;
return { return {
page: data.page, page: data.page,
results, results,
totalPages: data.total_pages, 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 * @param request Search request with query, year, type, and optional season parameters
*/ */
static async search(request: TmdbSearchRequest): Promise<TmdbSearchResponse> { static async search(request: TmdbSearchRequest): Promise<TmdbSearchResponse> {
const { query, year, type = 'multi', seasonYear, seasonNumber } = request; const { query, year, type = 'multi', seasonYear, seasonNumber, cast } = request;
switch (type) { switch (type) {
case 'movie': case 'movie':
return this.searchMovies(query, year); return this.searchMovies(query, year, cast);
case 'tv': case 'tv':
// For TV shows, use season parameters if provided // 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': case 'multi':
default: default:
return this.searchMulti(query, year); return this.searchMulti(query, year, cast);
} }
} }
} }

View File

@@ -219,6 +219,7 @@ export interface TmdbSearchRequest {
type?: 'movie' | 'tv' | 'multi'; type?: 'movie' | 'tv' | 'multi';
seasonYear?: number; seasonYear?: number;
seasonNumber?: number; seasonNumber?: number;
cast?: string;
} }
export interface TmdbSearchResult { export interface TmdbSearchResult {