From ad63a7b1afc4dd7d35650a8ac22ab93987995098 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 3 Mar 2026 06:50:03 +0000 Subject: [PATCH] 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 --- .../app/src/components/chat/ChatMessage.vue | 37 +++- .../app/src/components/chat/PromptIndex.vue | 1 + .../src/components/content/ContentPanel.vue | 20 +- .../src/components/content/TVSeriesCard.vue | 107 ++++++++++ .../src/components/content/TVSeriesDetail.vue | 190 +++++++++++++++++ .../src/components/content/TVSeriesGrid.vue | 199 ++++++++++++++++++ packages/app/src/composables/useAI.ts | 4 + .../app/src/composables/useContentPanel.ts | 186 +++++++++++++++- .../app/src/composables/useImageFallback.ts | 21 +- packages/app/src/pages/ChatPage.vue | 91 +++++++- packages/app/vite-tmdb.ts | 13 +- packages/core/src/types/content.ts | 27 +++ 12 files changed, 870 insertions(+), 26 deletions(-) create mode 100644 packages/app/src/components/content/TVSeriesCard.vue create mode 100644 packages/app/src/components/content/TVSeriesDetail.vue create mode 100644 packages/app/src/components/content/TVSeriesGrid.vue diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue index ddc39c1c..c3d47fff 100644 --- a/packages/app/src/components/chat/ChatMessage.vue +++ b/packages/app/src/components/chat/ChatMessage.vue @@ -30,6 +30,15 @@ /> +
+ +
+
View all {{ inlineBooks.length }} books → +
@@ -102,6 +112,50 @@ + + + + + + chatStore.panelSide) +const TAB_LABELS: Record = { + film: 'Films', + book: 'Books', + tvshow: 'TV', + song: 'Songs', + podcast: 'Podcasts', + news: 'News', + websites: 'Websites', + magazine: 'Brief', +} + +function tabLabel(tab: ContentTab): string { + return TAB_LABELS[tab] ?? tab +} + // Infer loader type from last user message when streaming (before tags are parsed) const loaderContextType = computed(() => { if (panelOpen.value) return contentType.value const lastUser = [...chatStore.messages].reverse().find((m) => m.role === 'user') const q = (lastUser?.content ?? '').toLowerCase() if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song' + if (/\b(book|novel|read|author|fiction|nonfiction|memoir)\b/.test(q)) return 'book' + if (/\b(tv show|tv series|series|television|binge|season)\b/.test(q)) return 'tvshow' if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast' if (/\b(news|latest|recent|current|what'?s happening|what are people saying)\b/.test(q)) return 'news' if (/\b(bip|protocol|debate|sentiment|bearish|bull case|macro)\b/.test(q)) return 'magazine' diff --git a/packages/app/vite-tmdb.ts b/packages/app/vite-tmdb.ts index 0fac2610..4e781f8e 100644 --- a/packages/app/vite-tmdb.ts +++ b/packages/app/vite-tmdb.ts @@ -5,7 +5,7 @@ import { loadEnv } from 'vite' const TMDB_POSTER = 'https://image.tmdb.org/t/p/w342' const TMDB_BACKDROP = 'https://image.tmdb.org/t/p/w780' -function createTmdbMiddleware(tmdbKey: string | undefined) { +function createTmdbSearchMiddleware(tmdbKey: string | undefined, type: 'movie' | 'tv') { return async (req: Connect.IncomingMessage, res: any, next: () => void) => { if (req.method !== 'GET') return next() const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`) @@ -22,13 +22,14 @@ function createTmdbMiddleware(tmdbKey: string | undefined) { return } try { + const yearParam = type === 'movie' ? 'primary_release_year' : 'first_air_date_year' const params = new URLSearchParams({ api_key: tmdbKey, query: q, - ...(y ? { primary_release_year: y } : {}), + ...(y ? { [yearParam]: y } : {}), }) const tmdbRes = await fetch( - `https://api.themoviedb.org/3/search/movie?${params}` + `https://api.themoviedb.org/3/search/${type}?${params}` ) const data = (await tmdbRes.json()) as { results?: { poster_path?: string; backdrop_path?: string }[] @@ -58,10 +59,12 @@ export function tmdbPlugin(): Plugin { tmdbKey = env.TMDB_API_KEY ?? env.VITE_TMDB_API_KEY }, configureServer(server) { - server.middlewares.use('/api/tmdb/search', createTmdbMiddleware(tmdbKey)) + server.middlewares.use('/api/tmdb/search', createTmdbSearchMiddleware(tmdbKey, 'movie')) + server.middlewares.use('/api/tmdb/search-tv', createTmdbSearchMiddleware(tmdbKey, 'tv')) }, configurePreviewServer(server) { - server.middlewares.use('/api/tmdb/search', createTmdbMiddleware(tmdbKey)) + server.middlewares.use('/api/tmdb/search', createTmdbSearchMiddleware(tmdbKey, 'movie')) + server.middlewares.use('/api/tmdb/search-tv', createTmdbSearchMiddleware(tmdbKey, 'tv')) }, } } diff --git a/packages/core/src/types/content.ts b/packages/core/src/types/content.ts index 68ad3c3d..9df038ed 100644 --- a/packages/core/src/types/content.ts +++ b/packages/core/src/types/content.ts @@ -78,6 +78,33 @@ export interface PodcastRendererData { totalResults?: number } +export interface TVSeries { + id: string + title: string + year?: number + endYear?: number + posterUrl?: string + backdropUrl?: string + synopsis?: string + genres?: string[] + rating?: number + seasons?: number + episodes?: number + status?: 'ongoing' | 'ended' | 'cancelled' | 'upcoming' + network?: string + creator?: string + cast?: string[] + sources?: TVSeriesSource[] +} + +export interface TVSeriesSource { + type: 'plex' | 'nextcloud' | 'youtube' | 'netflix' | 'free-web' | 'local' + name: string + url: string + quality?: string + icon?: string +} + export interface Book { id: string title: string