diff --git a/src/lib/components/ItemPage.svelte b/src/lib/components/ItemPage.svelte index 8497edd..d8867c0 100644 --- a/src/lib/components/ItemPage.svelte +++ b/src/lib/components/ItemPage.svelte @@ -8,6 +8,8 @@ import ItemsGrid from './ItemsGrid.svelte'; import Review from './Review.svelte'; import TrailerDialog from './TrailerDialog.svelte'; + import TvScheduleCard from './TvScheduleCard.svelte'; + import TvSeasons from './TvSeasons.svelte'; import { backdropUrl, posterUrl, profileUrl } from '$lib/images'; import { loginDialog } from '$lib/login.svelte'; import { reviewDialog } from '$lib/review.svelte'; @@ -18,6 +20,7 @@ type ItemPageData = Awaited> & { did: string | null; reviews: ReviewCardModel[]; + today: string; }; function startReview() { @@ -38,6 +41,12 @@ return minutes ? `${hours}h ${minutes}m` : `${hours}h`; } + function formatSeriesStatus(status: string | null) { + if (!status) return null; + if (status === 'Returning Series') return 'Returning'; + return status[0].toUpperCase() + status.slice(1).toLowerCase(); + } + let { data }: { data: ItemPageData } = $props(); let canonicalUrl = $derived(`${page.url.origin}${page.url.pathname}`); let ogImageUrl = $derived(`${canonicalUrl.replace(/\/$/, '')}/og.png`); @@ -48,10 +57,17 @@ ? `${data.item.numberOfSeasons} ${data.item.numberOfSeasons === 1 ? 'season' : 'seasons'}` : null ); + let episodeLabel = $derived( + data.item.numberOfEpisodes + ? `${data.item.numberOfEpisodes} ${data.item.numberOfEpisodes === 1 ? 'episode' : 'episodes'}` + : null + ); let mediaFacts = $derived( [ releaseYear, + data.item.creativeWorkType === 'tv_show' ? formatSeriesStatus(data.item.status) : null, data.item.creativeWorkType === 'tv_show' ? seasonLabel : null, + data.item.creativeWorkType === 'tv_show' ? episodeLabel : null, formatRuntime(data.item.runtime) ].filter((fact): fact is string => fact !== null) ); @@ -222,6 +238,10 @@ + {#if data.item.creativeWorkType === 'tv_show' && data.nextEpisode?.airDate} + + {/if} + {#if data.item.overview}

Overview

@@ -231,6 +251,15 @@
{/if} + {#if data.item.creativeWorkType === 'tv_show'} + + {/if} + {#if writtenReviews.length > 0}

Reviews

diff --git a/src/lib/components/TvScheduleCard.svelte b/src/lib/components/TvScheduleCard.svelte new file mode 100644 index 0000000..6d52c7f --- /dev/null +++ b/src/lib/components/TvScheduleCard.svelte @@ -0,0 +1,83 @@ + + +
+

Up next

+ + {#if nextEpisode.still} + Still from {episodeLabel(nextEpisode)} of {item.title} + {/if} +
+
+ {episodeLabel(nextEpisode)} + {#if formatRuntime(nextEpisode.runtime)} + + {formatRuntime(nextEpisode.runtime)} + {/if} +
+
+ {nextEpisode.name || 'Episode title TBA'} +
+
+ {#if nextEpisode.airDate} + Airs + + {#if relativeAirDate} + + {relativeAirDate} + {/if} + {/if} +
+
+ +
+
diff --git a/src/lib/components/TvSeasons.svelte b/src/lib/components/TvSeasons.svelte new file mode 100644 index 0000000..73638ba --- /dev/null +++ b/src/lib/components/TvSeasons.svelte @@ -0,0 +1,134 @@ + + +{#if orderedSeasons.length > 0} +
+

Seasons

+ + + {#if !showAllSeasons && orderedSeasons.length > 4} + + {/if} +
+{/if} diff --git a/src/lib/dates.ts b/src/lib/dates.ts new file mode 100644 index 0000000..9baa45d --- /dev/null +++ b/src/lib/dates.ts @@ -0,0 +1,54 @@ +const FULL_DATE_FORMATTER = new Intl.DateTimeFormat('en', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC' +}); + +const SHORT_DATE_FORMATTER = new Intl.DateTimeFormat('en', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC' +}); + +const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; + +export function parseCalendarDate(value: string | null | undefined) { + if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +export function formatCalendarDate( + value: string | null | undefined, + style: 'full' | 'short' = 'full' +) { + const date = parseCalendarDate(value); + if (!date) return null; + return (style === 'full' ? FULL_DATE_FORMATTER : SHORT_DATE_FORMATTER).format(date); +} + +export function calendarDayDifference( + value: string | null | undefined, + today: string | null | undefined +) { + const date = parseCalendarDate(value); + const currentDate = parseCalendarDate(today); + if (!date || !currentDate) return null; + return Math.round((date.getTime() - currentDate.getTime()) / DAY_IN_MILLISECONDS); +} + +export function relativeCalendarDate( + value: string | null | undefined, + today: string | null | undefined +) { + const difference = calendarDayDifference(value, today); + if (difference === null) return null; + if (difference === 0) return 'today'; + if (difference === 1) return 'tomorrow'; + if (difference === -1) return 'yesterday'; + if (difference > 1) return `in ${difference} days`; + return `${Math.abs(difference)} days ago`; +} diff --git a/src/lib/images.ts b/src/lib/images.ts index 1bca546..4f5fd76 100644 --- a/src/lib/images.ts +++ b/src/lib/images.ts @@ -3,7 +3,8 @@ import { type BackdropSize, type LogoSize, type PosterSize, - type ProfileSize + type ProfileSize, + type StillSize } from '@lorenzopant/tmdb/image'; import type { MediaImage } from './types'; @@ -26,3 +27,8 @@ export function posterUrl(image: MediaImage | null | undefined, size: PosterSize export function profileUrl(path: string | null | undefined, size: ProfileSize = 'h632') { return path ? images.profile(path, size) : undefined; } + +export function stillUrl(image: MediaImage | null | undefined, size: StillSize = 'w300') { + if (!image) return undefined; + return image.source === 'tmdb' ? images.still(image.path, size) : image.url; +} diff --git a/src/lib/tmdb.server.ts b/src/lib/tmdb.server.ts index 21a7c34..7e025d8 100644 --- a/src/lib/tmdb.server.ts +++ b/src/lib/tmdb.server.ts @@ -11,6 +11,8 @@ import { type PersonCombinedCastCredit, type PersonDetails as TmdbPersonDetails, type TVDetailsWithAppends, + type TVEpisodeItem, + type TVSeasonItem, type TVSeriesDetails, type TVSeriesResultItem, type VideoItem, @@ -27,6 +29,8 @@ import type { PersonDetails, StreamingAvailability, SupportedCreativeWorkType, + TvEpisodeSummary, + TvSeasonSummary, WatchProvider } from './types'; @@ -43,6 +47,7 @@ const PERSON_APPENDS: ['combined_credits'] = ['combined_credits']; const HOUR = 60 * 60; const WEEK = 7 * 24 * HOUR; const MEDIA_DATA_TTL = WEEK; +const TV_SCHEDULE_TTL = 3 * HOUR; const DISCOVERY_TTL = 6 * HOUR; const SEARCH_TTL = HOUR; @@ -65,6 +70,7 @@ type MediaSummarySource = MovieDetails | MovieResultItem | TVSeriesDetails | TVSeriesResultItem | PersonCombinedCastCredit; type MediaDetailsSource = MovieDetailsWithAppends | TVDetailsWithAppends; +type MediaCoreDetailsSource = MovieDetails | TVSeriesDetails; type TmdbMediaType = 'movie' | 'tv'; let client: TMDB | undefined; @@ -110,6 +116,15 @@ function getMediaSource( }); } +function getTvScheduleSource( + tmdbId: number, + cache = getPublicDataCache(TV_SCHEDULE_TTL) +): Promise { + return cachePublicData(cache, `tmdb:tv-schedule:v1:${tmdbId}`, () => + getClient().tv_series.details({ series_id: tmdbId }) + ); +} + function fromTmdbMediaType(mediaType: TmdbMediaType): SupportedCreativeWorkType { return mediaType === 'tv' ? 'tv_show' : 'movie'; } @@ -131,7 +146,7 @@ function toMediaSummary( } function toMediaDetails( - source: MediaDetailsSource, + source: MediaCoreDetailsSource, creativeWorkType: SupportedCreativeWorkType ): MediaDetails { const metadata = @@ -139,12 +154,16 @@ function toMediaDetails( ? { releaseDate: source.release_date || null, runtime: source.runtime ?? null, - numberOfSeasons: null + numberOfSeasons: null, + numberOfEpisodes: null, + status: null } : { releaseDate: source.first_air_date || null, runtime: source.episode_run_time[0] ?? source.last_episode_to_air?.runtime ?? null, - numberOfSeasons: source.number_of_seasons || null + numberOfSeasons: source.number_of_seasons || null, + numberOfEpisodes: source.number_of_episodes || null, + status: source.status ?? null }; return { @@ -172,6 +191,33 @@ function toCastMember(person: Cast): CastMember { }; } +function toTvEpisode(source: TVEpisodeItem | null | undefined): TvEpisodeSummary | null { + if (!source) return null; + return { + id: source.id, + name: source.name, + overview: source.overview ?? '', + seasonNumber: source.season_number, + episodeNumber: source.episode_number, + episodeType: source.episode_type ?? null, + airDate: source.air_date || null, + runtime: source.runtime ?? null, + still: toTmdbImage(source.still_path) + }; +} + +function toTvSeason(source: TVSeasonItem): TvSeasonSummary { + return { + id: source.id, + name: source.name, + overview: source.overview ?? '', + seasonNumber: source.season_number, + episodeCount: source.episode_count, + airDate: source.air_date || null, + poster: toTmdbImage(source.poster_path) + }; +} + function toBackdropGallery(source: MediaDetailsSource): MediaImage[] { const paths = [source.backdrop_path, ...source.images.backdrops.map((image) => image.file_path)]; return [...new Set(paths.filter((path): path is string => Boolean(path)))] @@ -285,18 +331,32 @@ export async function getMediaPage( region: string | null ) { const cache = getPublicDataCache(MEDIA_DATA_TTL); - const details = await getMediaSource(tmdbId, creativeWorkType, cache); + const [details, currentTvDetails] = await Promise.all([ + getMediaSource(tmdbId, creativeWorkType, cache), + creativeWorkType === 'tv_show' + ? getTvScheduleSource(tmdbId).catch((cause) => { + console.error('Could not refresh TV schedule data from TMDB', cause); + return null; + }) + : Promise.resolve(null) + ]); const omdb = details.external_ids.imdb_id ? await getRatings(details.external_ids.imdb_id, cache) : EMPTY_OMDB_DATA; + const tvDetails = + currentTvDetails ?? ('first_air_date' in details ? (details as TVSeriesDetails) : null); return { - item: toMediaDetails(details, creativeWorkType), + item: toMediaDetails(tvDetails ?? details, creativeWorkType), recommendations: details.recommendations.results.map((item) => toMediaSummary(item, creativeWorkType) ), cast: details.credits.cast.map(toCastMember), backdrops: toBackdropGallery(details), + seasons: (tvDetails?.seasons ?? []).map(toTvSeason), + nextEpisode: toTvEpisode(tvDetails?.next_episode_to_air), + lastEpisode: toTvEpisode(tvDetails?.last_episode_to_air), + network: tvDetails?.networks?.[0]?.name ?? null, imdb_id: details.external_ids.imdb_id ?? null, imdb_votes: omdb.imdbVotes, ratings: omdb.ratings, @@ -390,6 +450,50 @@ export async function getDetails( return toMediaDetails(details, creativeWorkType); } +export async function getTvSeasonPage(tmdbId: number, seasonNumber: number) { + const cache = getPublicDataCache(TV_SCHEDULE_TTL); + const [show, seasonSource] = await Promise.all([ + getTvScheduleSource(tmdbId, cache), + cachePublicData(cache, `tmdb:tv-season:v1:${tmdbId}:${seasonNumber}`, () => + getClient().tv_seasons.details({ + series_id: tmdbId, + season_number: seasonNumber + }) + ) + ]); + const seasons = (show.seasons ?? []).map(toTvSeason); + const navigableSeasons = seasons + .filter((season) => season.seasonNumber > 0) + .sort((left, right) => left.seasonNumber - right.seasonNumber); + const navigationIndex = navigableSeasons.findIndex( + (season) => season.seasonNumber === seasonNumber + ); + const season: TvSeasonSummary = { + id: seasonSource.id, + name: seasonSource.name, + overview: seasonSource.overview ?? '', + seasonNumber: seasonSource.season_number, + episodeCount: seasonSource.episodes.length, + airDate: seasonSource.air_date || null, + poster: toTmdbImage(seasonSource.poster_path) + }; + + return { + show: toMediaDetails(show, 'tv_show'), + season, + episodes: seasonSource.episodes.flatMap((source) => { + const episode = toTvEpisode(source); + return episode ? [episode] : []; + }), + previousSeason: navigationIndex > 0 ? navigableSeasons[navigationIndex - 1] : null, + nextSeason: + navigationIndex >= 0 && navigationIndex < navigableSeasons.length - 1 + ? navigableSeasons[navigationIndex + 1] + : null, + network: show.networks?.[0]?.name ?? null + }; +} + function toRecordDate(value: string | null | undefined) { if (!value) return undefined; const date = new Date(`${value}T00:00:00.000Z`); diff --git a/src/lib/types.ts b/src/lib/types.ts index 82f02af..2744ba0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,6 +19,30 @@ export type MediaDetails = MediaSummary & { runtime: number | null; genres: string[]; numberOfSeasons: number | null; + numberOfEpisodes: number | null; + status: string | null; +}; + +export type TvEpisodeSummary = { + id: number; + name: string; + overview: string; + seasonNumber: number; + episodeNumber: number; + episodeType: string | null; + airDate: string | null; + runtime: number | null; + still: MediaImage | null; +}; + +export type TvSeasonSummary = { + id: number; + name: string; + overview: string; + seasonNumber: number; + episodeCount: number; + airDate: string | null; + poster: MediaImage | null; }; export type MediaCredit = MediaSummary & { diff --git a/src/routes/[kind]/[id]/+page.server.ts b/src/routes/[kind]/[id]/+page.server.ts index 84ba248..adf3997 100644 --- a/src/routes/[kind]/[id]/+page.server.ts +++ b/src/routes/[kind]/[id]/+page.server.ts @@ -49,7 +49,7 @@ export const load: PageServerLoad = async ({ params, request, url }) => { }) ]); - return { ...mediaPage, reviews }; + return { ...mediaPage, reviews, today: new Date().toISOString().slice(0, 10) }; } catch (cause) { if (cause instanceof TMDBError && cause.http_status_code === 404) { error(404, 'Not found'); diff --git a/src/routes/[kind]/[id]/season/[season]/+page.server.ts b/src/routes/[kind]/[id]/season/[season]/+page.server.ts new file mode 100644 index 0000000..975714b --- /dev/null +++ b/src/routes/[kind]/[id]/season/[season]/+page.server.ts @@ -0,0 +1,32 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { getTvSeasonPage, TMDBError } from '$lib/tmdb.server'; +import { parseMediaRouteKind, parseTmdbId } from '$lib/utils'; + +function parseSeasonNumber(value: string) { + if (!/^\d{1,3}$/.test(value)) return null; + const seasonNumber = Number(value); + return Number.isSafeInteger(seasonNumber) ? seasonNumber : null; +} + +export const load: PageServerLoad = async ({ params }) => { + const tmdbId = parseTmdbId(params.id); + const creativeWorkType = parseMediaRouteKind(params.kind); + const seasonNumber = parseSeasonNumber(params.season); + + if (!tmdbId || creativeWorkType !== 'tv_show' || seasonNumber === null) { + error(404, 'Not found'); + } + + try { + return { + ...(await getTvSeasonPage(tmdbId, seasonNumber)), + today: new Date().toISOString().slice(0, 10) + }; + } catch (cause) { + if (cause instanceof TMDBError && cause.http_status_code === 404) { + error(404, 'Not found'); + } + throw cause; + } +}; diff --git a/src/routes/[kind]/[id]/season/[season]/+page.svelte b/src/routes/[kind]/[id]/season/[season]/+page.svelte new file mode 100644 index 0000000..1f84638 --- /dev/null +++ b/src/routes/[kind]/[id]/season/[season]/+page.svelte @@ -0,0 +1,270 @@ + + + + {data.show.title}: {data.season.name} | atmo.watch + + + + + + + {#if seasonPoster} + + {/if} + + +
+ {#if data.show.backdrop} + + {/if} +
+ + + + +
+
+ {#if seasonPoster} + Poster for {data.season.name} of {data.show.title} + {/if} +
+ +
+
+ {#if seasonStatus}{seasonStatus}{/if} + {#if seasonStatus && seasonDateRange} + + {/if} + {#if seasonDateRange}{seasonDateRange}{/if} + {#if seasonDateRange}{/if} + {data.season.episodeCount} + {data.season.episodeCount === 1 ? 'episode' : 'episodes'} + {#if data.network} + + {data.network} + {/if} +
+

+ {data.season.name} +

+

{data.show.title}

+
+
+ + {#if data.season.overview} +
+

Overview

+

+ {data.season.overview} +

+
+ {/if} + +
+

Episodes

+ + {#if data.episodes.length > 0} +
+ {#each data.episodes as episode (episode.id)} + {@const dayDifference = calendarDayDifference(episode.airDate, data.today)} + {@const isUpcoming = dayDifference !== null && dayDifference >= 0} +
+
+ {#if episode.still} + Still from {episodeCode(
+											episode.seasonNumber,
+											episode.episodeNumber
+										)} of {data.show.title} + {:else} +
+ {episodeCode(episode.seasonNumber, episode.episodeNumber)} +
+ {/if} +
+ +
+
+ + {episodeCode(episode.seasonNumber, episode.episodeNumber)} + + {#if episode.airDate} + + + {/if} + {#if formatRuntime(episode.runtime)} + + {formatRuntime(episode.runtime)} + {/if} + {#if episode.episodeType === 'finale'} + + Finale + + {/if} +
+

+ {episode.name || 'Episode title TBA'} +

+ {#if episode.overview && !isUpcoming} +

+ {episode.overview} +

+ {:else if episode.overview && isUpcoming} +

Synopsis hidden until the episode airs.

+ {/if} +
+
+ {/each} +
+ {:else} +
+ Episode information has not been announced yet. +
+ {/if} +
+ + {#if data.previousSeason || data.nextSeason} + + {/if} +
+