Compare commits
2 Commits
ad65453fcf
...
8c66fa9b82
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c66fa9b82 | |||
| 79f90cb287 |
@@ -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`
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ export interface TmdbSearchRequest {
|
||||
type?: 'movie' | 'tv' | 'multi';
|
||||
seasonYear?: number;
|
||||
seasonNumber?: number;
|
||||
cast?: string;
|
||||
}
|
||||
|
||||
export interface TmdbSearchResult {
|
||||
|
||||
Reference in New Issue
Block a user