Compare commits

...

2 Commits

5 changed files with 145 additions and 17 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

@@ -558,7 +558,7 @@ export function MoviesPage() {
const pageTitle = useMemo(() => {
if (typeFilter === 'movie') return 'Film Arşivi'
if (typeFilter === 'tvshow') return 'Dizi Arşivi'
return 'Film ve Dizi Arşivi'
return 'Ratebubble'
}, [typeFilter])
const allGenres = useMemo(() => {

View File

@@ -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<TmdbSearchResponse> = {
@@ -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<TmdbSearchResponse> = {
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<TmdbSearchResponse> = {
success: true,

View File

@@ -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<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
*/
@@ -265,7 +339,11 @@ export class TmdbService {
/**
* 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({
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<TmdbSearchResponse> {
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<TmdbSearchResponse> {
static async searchMulti(
query: string,
year?: number,
cast?: string
): Promise<TmdbSearchResponse> {
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<TmdbSearchResponse> {
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);
}
}
}

View File

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