From fbcb891b916ab440c2045f60a29d06bbc1923574 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:46:08 +0300 Subject: [PATCH] Tighten TV browse to scripted shows with real audiences Popular and Recent TV both go through /discover/tv with scripted and miniseries types, a vote floor and a 90-day window for Recent, and drop Chinese-origin titles from browse while search still finds them. --- app/server/src/sources/tmdb/tmdb.service.ts | 57 ++++++++------- app/server/test/tmdb.service.spec.ts | 78 ++++++++++++++++++++- 2 files changed, 109 insertions(+), 26 deletions(-) diff --git a/app/server/src/sources/tmdb/tmdb.service.ts b/app/server/src/sources/tmdb/tmdb.service.ts index b7ef6ee..9d24826 100644 --- a/app/server/src/sources/tmdb/tmdb.service.ts +++ b/app/server/src/sources/tmdb/tmdb.service.ts @@ -155,18 +155,6 @@ export class TmdbService { if (category === 'movie' && !this.hasFilters(filters)) { return section === 'recent' ? this.recentMovies(page) : this.popularMovies(page); } - if ( - category === 'tv' && - section === 'popular' && - !this.hasFilters(filters) - ) { - return this.normalizeTvList( - await this.request( - '/tv/popular', - { page: String(page) }, - ), - ); - } if (category === 'movie') { return this.normalizeMovieList( await this.request( @@ -183,19 +171,25 @@ export class TmdbService { ); } if (category === 'tv') { - return this.normalizeTvList( - await this.request( - '/discover/tv', - this.discoverFilters( - category, - section, - page, - filters, - 'first_air_date', - this.requiredGenreId(tvGenres, filters.genre), - ), + const response = await this.request( + '/discover/tv', + this.discoverFilters( + category, + section, + page, + filters, + 'first_air_date', + this.requiredGenreId(tvGenres, filters.genre), ), ); + return this.normalizeTvList({ + ...response, + results: response.results.filter( + (show) => + !['zh', 'cn'].includes(show.original_language) && + !show.origin_country?.includes('CN'), + ), + }); } if (category === 'anime') { const movieGenre = this.genreId(movieGenres, filters.genre); @@ -647,7 +641,9 @@ export class TmdbService { filters.sort === 'first_air_date.desc' ? `${dateField}.desc` : filters.sort ?? - (section === 'recent' ? `${dateField}.desc` : 'popularity.desc'), + (section === 'recent' && category !== 'tv' + ? `${dateField}.desc` + : 'popularity.desc'), }; if (genreId) { parameters.with_genres = genreId; @@ -674,6 +670,19 @@ export class TmdbService { pilot: '5', }[filters.status.toLowerCase()] ?? filters.status; } + if (category === 'tv') { + if (!filters.genre) { + parameters.with_type = '2|4'; + } + if (!['planned', 'production', 'pilot'].includes(filters.status?.toLowerCase() ?? '')) { + parameters['vote_count.gte'] = section === 'recent' ? '10' : '100'; + } + if (section === 'recent' && !filters.year) { + parameters['first_air_date.gte'] = new Date(Date.now() - 90 * 86_400_000) + .toISOString() + .slice(0, 10); + } + } return parameters; } diff --git a/app/server/test/tmdb.service.spec.ts b/app/server/test/tmdb.service.spec.ts index a0d15e8..96e065a 100644 --- a/app/server/test/tmdb.service.spec.ts +++ b/app/server/test/tmdb.service.spec.ts @@ -97,7 +97,7 @@ describe('TmdbService', () => { expect((fetchMock.mock.calls[0] as [URL])[0].pathname).toBe(`/3${path}`); }); - it('orders recent TV and anime by their release fields', async () => { + it('orders recent anime by release date and recent TV by popularity', async () => { const fetchMock = vi.fn().mockImplementation((input: URL) => { const isMovie = input.pathname.endsWith('/discover/movie'); return Promise.resolve( @@ -158,14 +158,88 @@ describe('TmdbService', () => { const movieUrl = urls[1]!; const animeTvUrl = urls[2]!; expect(tvUrl.pathname).toBe('/3/discover/tv'); - expect(tvUrl.searchParams.get('sort_by')).toBe('first_air_date.desc'); + expect(tvUrl.searchParams.get('sort_by')).toBe('popularity.desc'); + expect(tvUrl.searchParams.get('first_air_date.gte')).toBeTruthy(); expect(tvUrl.searchParams.get('first_air_date.lte')).toBeTruthy(); + expect(tvUrl.searchParams.get('vote_count.gte')).toBe('10'); expect(movieUrl.searchParams.get('sort_by')).toBe('primary_release_date.desc'); expect(movieUrl.searchParams.get('primary_release_date.lte')).toBeTruthy(); expect(animeTvUrl.searchParams.get('sort_by')).toBe('first_air_date.desc'); expect(animeTvUrl.searchParams.get('first_air_date.lte')).toBeTruthy(); }); + it('browses scripted TV above a vote floor without Chinese-origin titles', async () => { + const show = (name: string, original_language: string, origin_country: string[]) => ({ + id: name.length, + name, + original_name: name, + overview: '', + original_language, + origin_country, + first_air_date: '2026-01-01', + }); + const response = { + page: 1, + total_pages: 1, + total_results: 4, + results: [ + show('Kept', 'en', ['US']), + show('Mandarin', 'zh', ['TW']), + show('Cantonese', 'cn', ['HK']), + show('Co-production', 'ko', ['KR', 'CN']), + ], + }; + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(response), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + const service = new TmdbService( + new ConfigService({ TMDB_READ_ACCESS_TOKEN: 'server-token' }), + new ConnectorHttpService(), + ); + + const popular = await service.browse('tv', 'popular', 1, {}); + const searched = await service.search('tv', 'drama', 1, {}); + + expect(popular.results.map(({ title }) => title)).toEqual(['Kept']); + expect(searched.results).toHaveLength(4); + const [url] = fetchMock.mock.calls[0] as [URL]; + expect(url.pathname).toBe('/3/discover/tv'); + expect(url.searchParams.get('sort_by')).toBe('popularity.desc'); + expect(url.searchParams.get('with_type')).toBe('2|4'); + expect(url.searchParams.get('vote_count.gte')).toBe('100'); + expect(url.searchParams.get('first_air_date.gte')).toBeNull(); + }); + + it('keeps chosen TV genres and unaired statuses reachable', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ page: 1, total_pages: 1, total_results: 0, results: [] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + const service = new TmdbService( + new ConfigService({ TMDB_READ_ACCESS_TOKEN: 'server-token' }), + new ConnectorHttpService(), + ); + + await service.browse('tv', 'popular', 1, { genre: 'Reality', status: 'planned' }); + + const [url] = fetchMock.mock.calls[0] as [URL]; + expect(url.searchParams.get('with_genres')).toBe('10764'); + expect(url.searchParams.get('with_status')).toBe('1'); + expect(url.searchParams.get('with_type')).toBeNull(); + expect(url.searchParams.get('vote_count.gte')).toBeNull(); + }); + it('loads a normalized movie detail by external ID', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(