feat(tmdb): cast alanina gore katı eslesmeli arama destegi ekle
This commit is contained in:
@@ -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`
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user