feat(content): add TV Series content type with TMDB integration
Add complete TV series content surface: extraction from AI responses, grid/detail views, TMDB TV search endpoint, and panel tab integration. Includes film_ext-to-TV-series conversion for backward compatibility and AI prompt instructions for [[tv_ext:...]] tags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
99e380e1b2
commit
ad63a7b1af
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Film, Song, Podcast, Book } from '@aiui/core/types/content'
|
||||
import type { Film, Song, Podcast, Book, TVSeries } from '@aiui/core/types/content'
|
||||
import type { WebSearchResult } from '@aiui/core/types/message'
|
||||
import { mockFilms } from '@/mocks/films'
|
||||
import { mockSongs } from '@/mocks/songs'
|
||||
@@ -7,7 +7,7 @@ import { mockPodcasts } from '@/mocks/podcasts'
|
||||
import { generatePosterFallback, generateSongCoverFallback, generateBookCoverFallback } from '@/composables/useImageFallback'
|
||||
import { fetchRssFromUrls } from '@/composables/useRssFetch'
|
||||
|
||||
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'news' | 'websites' | 'magazine'
|
||||
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'news' | 'websites' | 'magazine'
|
||||
|
||||
export interface MagazineSection {
|
||||
title: string
|
||||
@@ -23,6 +23,7 @@ export interface MagazineSection {
|
||||
const panelOpen = ref(false)
|
||||
const panelFilms = ref<Film[]>([])
|
||||
const panelBooks = ref<Book[]>([])
|
||||
const panelTVSeries = ref<TVSeries[]>([])
|
||||
const panelWebResults = ref<WebSearchResult[]>([])
|
||||
const panelRssArticles = ref<WebSearchResult[]>([])
|
||||
const panelWebsites = ref<WebSearchResult[]>([])
|
||||
@@ -32,6 +33,7 @@ const panelSongs = ref<Song[]>([])
|
||||
const panelPodcasts = ref<Podcast[]>([])
|
||||
const selectedFilm = ref<Film | null>(null)
|
||||
const selectedBook = ref<Book | null>(null)
|
||||
const selectedTVSeries = ref<TVSeries | null>(null)
|
||||
const selectedSong = ref<Song | null>(null)
|
||||
const selectedPodcast = ref<Podcast | null>(null)
|
||||
const selectedArticle = ref<WebSearchResult | null>(null)
|
||||
@@ -257,6 +259,7 @@ function preferredFirstTab(userQuery: string): ContentTab | null {
|
||||
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
|
||||
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
|
||||
if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
|
||||
if (/\b(tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
|
||||
if (isNewsQuery(q)) return 'news'
|
||||
if (isWebsitesQuery(q)) return 'websites'
|
||||
return null
|
||||
@@ -270,6 +273,7 @@ function filterTabsByContext(
|
||||
hasSongs: boolean,
|
||||
hasPodcasts: boolean,
|
||||
hasBooks: boolean,
|
||||
hasTVSeries: boolean,
|
||||
hasNews: boolean,
|
||||
hasWebsites: boolean,
|
||||
hasMagazine: boolean,
|
||||
@@ -286,17 +290,18 @@ function filterTabsByContext(
|
||||
return tabs
|
||||
}
|
||||
|
||||
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasNews && !hasWebsites) {
|
||||
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasNews && !hasWebsites) {
|
||||
return ['magazine']
|
||||
}
|
||||
|
||||
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasNews && !hasMagazine) {
|
||||
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasNews && !hasMagazine) {
|
||||
return ['websites']
|
||||
}
|
||||
|
||||
const all: ContentTab[] = []
|
||||
if (hasFilms) all.push('film')
|
||||
if (hasBooks) all.push('book')
|
||||
if (hasTVSeries) all.push('tvshow')
|
||||
if (hasSongs) all.push('song')
|
||||
if (hasPodcasts) all.push('podcast')
|
||||
if (hasMagazine) all.push('magazine')
|
||||
@@ -343,6 +348,8 @@ const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi
|
||||
const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
|
||||
const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|]*)(?:\|(\d{4}))?\]\]/gi
|
||||
|
||||
/** Reject obvious non-podcast phrases (documentation, mailing lists, etc.) */
|
||||
function looksLikePodcast(title: string, host: string): boolean {
|
||||
const t = title.toLowerCase()
|
||||
@@ -763,12 +770,122 @@ export function useContentPanel() {
|
||||
return extractBooksFromPatterns(text)
|
||||
}
|
||||
|
||||
function extractExternalTVSeries(text: string): TVSeries[] {
|
||||
const series: TVSeries[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(TV_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const creator = match[2].trim()
|
||||
const year = match[3] ? parseInt(match[3], 10) : undefined
|
||||
const key = title.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
series.push({
|
||||
id: `ext-${key.replace(/\W/g, '-')}`,
|
||||
title,
|
||||
creator: creator || undefined,
|
||||
year,
|
||||
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
|
||||
genres: [],
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return series
|
||||
}
|
||||
|
||||
function isTVQuery(q: string): boolean {
|
||||
return /\b(tv show|tv series|series|television|streaming|binge|watch|recommend.*show|best show|season)\b/i.test(q)
|
||||
}
|
||||
|
||||
function isTVLikeResponse(text: string): boolean {
|
||||
return /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC|FX|Apple TV|Disney\+)\b/i.test(text) &&
|
||||
(text.match(/\bseason\b/gi)?.length ?? 0) >= 2
|
||||
}
|
||||
|
||||
function extractTVSeriesFromPatterns(text: string): TVSeries[] {
|
||||
const series: { title: string; desc: string; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
const patterns: RegExp[] = [
|
||||
/"([^"]{2,60})"\s*[-–—]\s*(?:a |an )?(?:series|show|tv)/gi,
|
||||
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:\w+ )?(?:series|show|drama|comedy|thriller|animated)/gi,
|
||||
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*\((\d{4})(?:[-–]\d{0,4})?(?:,\s*\d+ seasons?)?\)/gm,
|
||||
]
|
||||
for (const re of patterns) {
|
||||
let m: RegExpExecArray | null
|
||||
const rx = new RegExp(re.source, re.flags)
|
||||
while ((m = rx.exec(text)) !== null) {
|
||||
const title = m[1].trim().replace(/^\*\*|\*\*$/g, '')
|
||||
if (title.length < 2) continue
|
||||
if (/\[\[(film|song|podcast|book|tv)(_ext)?:/.test(title)) continue
|
||||
const key = title.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const desc = extractDescriptionForTag(text, m.index, m[0].length)
|
||||
series.push({ title, desc, pos: m.index })
|
||||
}
|
||||
}
|
||||
return series
|
||||
.sort((a, b) => a.pos - b.pos)
|
||||
.map(({ title, desc }) => ({
|
||||
id: `ext-${title.toLowerCase().replace(/\W/g, '-')}`,
|
||||
title,
|
||||
synopsis: desc,
|
||||
genres: [],
|
||||
sources: [],
|
||||
}))
|
||||
}
|
||||
|
||||
function convertFilmExtToTVSeries(text: string): TVSeries[] {
|
||||
const series: TVSeries[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(FILM_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const year = parseInt(match[2], 10)
|
||||
const creator = match[3].trim()
|
||||
const key = title.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
series.push({
|
||||
id: `ext-${key.replace(/\W/g, '-')}`,
|
||||
title,
|
||||
year,
|
||||
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
|
||||
creator,
|
||||
genres: [],
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return series
|
||||
}
|
||||
|
||||
function extractAllTVSeries(text: string, userQuery: string): TVSeries[] {
|
||||
const external = extractExternalTVSeries(text)
|
||||
if (external.length > 0) return external
|
||||
if (!isTVQuery(userQuery) && !isTVLikeResponse(text)) return []
|
||||
if (isNewsLikeResponse(text)) return []
|
||||
// When user asked about TV but AI used film_ext tags, convert them
|
||||
if (isTVQuery(userQuery) && /\[\[film_ext:/.test(text)) {
|
||||
return convertFilmExtToTVSeries(text)
|
||||
}
|
||||
if (extractFilmIds(text).length > 0) return []
|
||||
return extractTVSeriesFromPatterns(text)
|
||||
}
|
||||
|
||||
function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) {
|
||||
panelQuery.value = userQuery.trim()
|
||||
const songs = extractAllSongs(text)
|
||||
const films = extractAllFilms(text)
|
||||
let films = extractAllFilms(text)
|
||||
const podcasts = extractAllPodcasts(text)
|
||||
const books = extractAllBooks(text, userQuery)
|
||||
const tvSeries = extractAllTVSeries(text, userQuery)
|
||||
// When film_ext tags were converted to TV series, only keep library films
|
||||
if (tvSeries.length > 0 && isTVQuery(userQuery)) {
|
||||
films = films.filter(f => !f.id.startsWith('ext-'))
|
||||
}
|
||||
const fromMarkdown = extractMarkdownLinks(text)
|
||||
const boldDomains = extractBoldDomainLinks(text)
|
||||
|
||||
@@ -806,12 +923,13 @@ export function useContentPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
availableTabs.value = tabs.length > 0 ? tabs : ['film']
|
||||
activeTab.value = tabs[0] ?? 'film'
|
||||
|
||||
const showFilms = tabs.includes('film')
|
||||
const showBooks = tabs.includes('book')
|
||||
const showTVSeries = tabs.includes('tvshow')
|
||||
const showSongs = tabs.includes('song')
|
||||
const showPodcasts = tabs.includes('podcast')
|
||||
const showNews = tabs.includes('news')
|
||||
@@ -820,6 +938,7 @@ export function useContentPanel() {
|
||||
|
||||
const visibleFilms = showFilms ? films : []
|
||||
const visibleBooks = showBooks ? books : []
|
||||
const visibleTVSeries = showTVSeries ? tvSeries : []
|
||||
const visibleSongs = showSongs ? songs : []
|
||||
const visiblePodcasts = showPodcasts ? podcasts : []
|
||||
const visibleNews = showNews ? mergedNews : []
|
||||
@@ -828,6 +947,7 @@ export function useContentPanel() {
|
||||
|
||||
panelFilms.value = visibleFilms
|
||||
panelBooks.value = visibleBooks
|
||||
panelTVSeries.value = visibleTVSeries
|
||||
panelSongs.value = visibleSongs
|
||||
panelPodcasts.value = visiblePodcasts
|
||||
panelWebResults.value = visibleNews
|
||||
@@ -838,20 +958,30 @@ export function useContentPanel() {
|
||||
: null
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
|
||||
if (visibleFilms.length > 0) contentType.value = 'film'
|
||||
else if (visibleBooks.length > 0) contentType.value = 'film'
|
||||
else if (visibleTVSeries.length > 0) contentType.value = 'film'
|
||||
else if (visibleSongs.length > 0) contentType.value = 'song'
|
||||
else if (visiblePodcasts.length > 0) contentType.value = 'podcast'
|
||||
else if (visibleNews.length > 0) contentType.value = 'film'
|
||||
else contentType.value = 'film'
|
||||
|
||||
if (visibleFilms.length === 1) panelTitle.value = visibleFilms[0].title
|
||||
// Title follows the primary (first) tab
|
||||
const primary = tabs[0]
|
||||
if (primary === 'tvshow' && visibleTVSeries.length > 0) {
|
||||
panelTitle.value = visibleTVSeries.length === 1 ? visibleTVSeries[0].title : `${visibleTVSeries.length} TV Series`
|
||||
} else if (primary === 'book' && visibleBooks.length > 0) {
|
||||
panelTitle.value = visibleBooks.length === 1 ? visibleBooks[0].title : `${visibleBooks.length} Books`
|
||||
} else if (visibleFilms.length === 1) panelTitle.value = visibleFilms[0].title
|
||||
else if (visibleFilms.length > 1) panelTitle.value = `${visibleFilms.length} Films`
|
||||
else if (visibleBooks.length === 1) panelTitle.value = visibleBooks[0].title
|
||||
else if (visibleBooks.length > 1) panelTitle.value = `${visibleBooks.length} Books`
|
||||
else if (visibleTVSeries.length === 1) panelTitle.value = visibleTVSeries[0].title
|
||||
else if (visibleTVSeries.length > 1) panelTitle.value = `${visibleTVSeries.length} TV Series`
|
||||
else if (visibleSongs.length === 1) panelTitle.value = visibleSongs[0].title
|
||||
else if (visibleSongs.length > 1) panelTitle.value = `${visibleSongs.length} Songs`
|
||||
else if (visiblePodcasts.length === 1) panelTitle.value = visiblePodcasts[0].title
|
||||
@@ -876,10 +1006,14 @@ export function useContentPanel() {
|
||||
|
||||
/** Contextual films/songs/podcasts/news/websites/magazine for inline cards (respects query+response, no presets) */
|
||||
function getContextualInlineContent(text: string, userQuery: string, webResults: WebSearchResult[] = []) {
|
||||
const films = extractAllFilms(text)
|
||||
let films = extractAllFilms(text)
|
||||
const songs = extractAllSongs(text)
|
||||
const podcasts = extractAllPodcasts(text)
|
||||
const books = extractAllBooks(text, userQuery)
|
||||
const tvSeries = extractAllTVSeries(text, userQuery)
|
||||
if (tvSeries.length > 0 && isTVQuery(userQuery)) {
|
||||
films = films.filter(f => !f.id.startsWith('ext-'))
|
||||
}
|
||||
const magazineSections = extractMagazineSections(text)
|
||||
const hasMagazine = magazineSections.length >= 1 && (isNewsQuery(userQuery) || isNewsLikeResponse(text) || /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i.test(text))
|
||||
const fromMarkdown = extractMarkdownLinks(text)
|
||||
@@ -890,10 +1024,11 @@ export function useContentPanel() {
|
||||
const websitesFromMd = hasLinkableContent ? fromMarkdown : []
|
||||
const websitesLinks = mergeNewsResults(websitesFromMd, boldDomains)
|
||||
const hasWebsites = websitesLinks.length > 0
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
return {
|
||||
films: tabs.includes('film') ? films : [],
|
||||
books: tabs.includes('book') ? books : [],
|
||||
tvSeries: tabs.includes('tvshow') ? tvSeries : [],
|
||||
songs: tabs.includes('song') ? songs : [],
|
||||
podcasts: tabs.includes('podcast') ? podcasts : [],
|
||||
newsLinks: tabs.includes('news') ? newsLinks : [],
|
||||
@@ -934,8 +1069,15 @@ export function useContentPanel() {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripTVTags(text: string): string {
|
||||
return text
|
||||
.replace(TV_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripContentTags(text: string): string {
|
||||
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(text))))
|
||||
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(text)))))
|
||||
}
|
||||
|
||||
/** Remove markdown links when surfacing as inline cards to avoid duplication */
|
||||
@@ -950,6 +1092,7 @@ export function useContentPanel() {
|
||||
function openFilmDetail(film: Film) {
|
||||
selectedFilm.value = film
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
@@ -962,6 +1105,7 @@ export function useContentPanel() {
|
||||
function openBookDetail(book: Book) {
|
||||
selectedBook.value = book
|
||||
selectedFilm.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
@@ -975,6 +1119,7 @@ export function useContentPanel() {
|
||||
selectedSong.value = song
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
}
|
||||
@@ -987,6 +1132,7 @@ export function useContentPanel() {
|
||||
selectedPodcast.value = podcast
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedSong.value = null
|
||||
selectedArticle.value = null
|
||||
}
|
||||
@@ -999,6 +1145,7 @@ export function useContentPanel() {
|
||||
selectedArticle.value = article
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
panelOpen.value = true
|
||||
@@ -1008,10 +1155,24 @@ export function useContentPanel() {
|
||||
selectedArticle.value = null
|
||||
}
|
||||
|
||||
function openTVSeriesDetail(series: TVSeries) {
|
||||
selectedTVSeries.value = series
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
}
|
||||
|
||||
function closeTVSeriesDetail() {
|
||||
selectedTVSeries.value = null
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelOpen.value = false
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
@@ -1062,6 +1223,7 @@ export function useContentPanel() {
|
||||
panelOpen,
|
||||
panelFilms,
|
||||
panelBooks,
|
||||
panelTVSeries,
|
||||
panelSongs,
|
||||
panelPodcasts,
|
||||
panelWebResults,
|
||||
@@ -1070,6 +1232,7 @@ export function useContentPanel() {
|
||||
panelMagazineHeroImage,
|
||||
selectedFilm,
|
||||
selectedBook,
|
||||
selectedTVSeries,
|
||||
selectedSong,
|
||||
selectedPodcast,
|
||||
selectedArticle,
|
||||
@@ -1083,8 +1246,11 @@ export function useContentPanel() {
|
||||
resolveFilms,
|
||||
extractAllFilms,
|
||||
extractAllBooks,
|
||||
extractAllTVSeries,
|
||||
openBookDetail,
|
||||
closeBookDetail,
|
||||
openTVSeriesDetail,
|
||||
closeTVSeriesDetail,
|
||||
extractSongIds,
|
||||
resolveSongs,
|
||||
extractAllSongs,
|
||||
|
||||
Reference in New Issue
Block a user