From 64ff3a60a2559726029fba79f30dc4aeabb9b02b Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:28:40 +0200 Subject: [PATCH] commit --- README.md | 4 + contrail.lock.json | 4 +- src/lib/atproto/images.ts | 38 ++- src/lib/cache.server.ts | 50 +++ src/lib/components/ExternalRatings.svelte | 10 +- src/lib/components/ItemPage.svelte | 50 ++- src/lib/components/Review.svelte | 79 ++++- src/lib/contrail/index.ts | 2 +- .../watch/atmo/review/listRecords.json | 24 ++ .../types/watch/atmo/review/listRecords.ts | 16 + src/lib/review-interactions.remote.ts | 112 +++++++ src/lib/reviews.server.ts | 124 +++++++- src/lib/tmdb.server.ts | 222 +++++++------ src/lib/types.ts | 9 + src/routes/[kind]/[id]/+page.server.ts | 4 +- src/routes/profile/[actor]/+layout.server.ts | 39 +-- src/routes/profile/[actor]/+page.server.ts | 20 ++ .../[actor]/review/[rkey]/+page.server.ts | 49 +++ .../[actor]/review/[rkey]/+page.svelte | 294 ++++++++++++++++++ 19 files changed, 990 insertions(+), 160 deletions(-) create mode 100644 src/lib/cache.server.ts create mode 100644 src/lib/review-interactions.remote.ts create mode 100644 src/routes/profile/[actor]/+page.server.ts create mode 100644 src/routes/profile/[actor]/review/[rkey]/+page.server.ts create mode 100644 src/routes/profile/[actor]/review/[rkey]/+page.svelte diff --git a/README.md b/README.md index 9d4e950..5128f5c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ npm run dev npm run dev -- --open ``` +## Cloudflare KV + +Bind a Cloudflare KV namespace as `MEDIA_CACHE` to cache TMDB and OMDb data. Without the binding, external data is fetched normally. + ## Building To create a production version of your app: diff --git a/contrail.lock.json b/contrail.lock.json index d76ac5f..e1652cc 100644 --- a/contrail.lock.json +++ b/contrail.lock.json @@ -3,8 +3,8 @@ "version": 1, "endpoint": "http://127.0.0.1:8787", "namespace": "watch.atmo", - "contractDigest": "sha256:401ce47ba01fb8c1ba1c34821782214c2c95eb1752784630f064c659dd6b1b0f", - "lexiconDigest": "sha256:c979563d792495eda7062f0a4748b83d3724ba5b0d98991de0d7868092eda8b7", + "contractDigest": "sha256:cb70003452bdb5fe4baf2c5c41f932a52ebf74ddc6d03ed3d833d0e34cc452ad", + "lexiconDigest": "sha256:fc63a6640eaff6cb71b35045fbb5c8ec3f8b178a64bae46c9512bc0bc0aeb73b", "methods": [ "watch.atmo.comment.getRecord", "watch.atmo.comment.listRecords", diff --git a/src/lib/atproto/images.ts b/src/lib/atproto/images.ts index c2a7b9b..7da65f4 100644 --- a/src/lib/atproto/images.ts +++ b/src/lib/atproto/images.ts @@ -1,21 +1,41 @@ -import { isBlob, type Blob, type LegacyBlob } from '@atcute/lexicons/interfaces'; import { getCDNImageBlobUrl, type CDNPreset } from '@svelte-atproto/oauth/bsky'; +function getBlobCid(value: unknown): string | undefined { + if (typeof value === 'string') { + try { + return getBlobCid(JSON.parse(value)); + } catch { + return undefined; + } + } + + if (!value || typeof value !== 'object') return undefined; + + const blob = value as { + ref?: { $link?: unknown }; + cid?: unknown; + original?: unknown; + }; + if (typeof blob.ref?.$link === 'string') return blob.ref.$link; + if (typeof blob.cid === 'string') return blob.cid; + return getBlobCid(blob.original); +} + export function getAtprotoCdnImageUrl({ did, blob, preset }: { did: string; - blob: Blob | LegacyBlob; + blob: unknown; preset: CDNPreset; }) { - const normalizedBlob = isBlob(blob) - ? blob - : { - $type: 'blob' as const, - ref: { $link: blob.cid } - }; + const cid = getBlobCid(blob); + if (!cid) return undefined; - return getCDNImageBlobUrl({ did, blob: normalizedBlob, preset }); + return getCDNImageBlobUrl({ + did, + blob: { $type: 'blob', ref: { $link: cid } }, + preset + }); } diff --git a/src/lib/cache.server.ts b/src/lib/cache.server.ts new file mode 100644 index 0000000..6b9418b --- /dev/null +++ b/src/lib/cache.server.ts @@ -0,0 +1,50 @@ +import { cloudflareKV } from '@svelte-atproto/oauth/server/stores/cloudflare'; + +type Awaitable = T | PromiseLike; + +type PublicDataCache = { + get(key: string): Awaitable; + set(key: string, value: unknown): Awaitable; +}; + +const CACHE_BINDING = 'MEDIA_CACHE'; +const CACHE_VERSION = 'v1'; + +/** Capture the request-scoped KV binding before the first await. */ +export function getPublicDataCache(ttl: number): PublicDataCache | undefined { + try { + return cloudflareKV(CACHE_BINDING, { ttl })(); + } catch { + return undefined; + } +} + +export async function cachePublicData( + cache: PublicDataCache | undefined, + key: string, + load: () => Promise, + shouldCache: (value: T) => boolean = () => true +): Promise { + const versionedKey = `${CACHE_VERSION}:${key}`; + + if (cache) { + try { + const cached = await cache.get(versionedKey); + if (cached !== undefined) return cached as T; + } catch { + // Treat KV errors as cache misses. + } + } + + const value = await load(); + + if (cache && shouldCache(value)) { + try { + await cache.set(versionedKey, value); + } catch { + // A failed cache write should not fail the request. + } + } + + return value; +} diff --git a/src/lib/components/ExternalRatings.svelte b/src/lib/components/ExternalRatings.svelte index 884bf32..61589de 100644 --- a/src/lib/components/ExternalRatings.svelte +++ b/src/lib/components/ExternalRatings.svelte @@ -11,7 +11,7 @@ imdbId: string | null; imdbVotes: string | null; ratings: ExternalRating[]; - streaming: StreamingAvailability; + streaming: StreamingAvailability | null; } = $props(); let imdbRating = $derived(ratings.find((rating) => rating.source === 'Internet Movie Database')); @@ -36,7 +36,9 @@ } -{#if (imdbId && (imdbScore || compactImdbVotes)) || rottenTomatoesRating || streaming.providers.length > 0} +{#if (imdbId && (imdbScore || compactImdbVotes)) || + rottenTomatoesRating || + (streaming && streaming.providers.length > 0)}
{#if (imdbId && (imdbScore || compactImdbVotes)) || rottenTomatoesRating}
@@ -90,7 +92,7 @@
{/if} - {#if streaming.providers.length > 0} + {#if streaming && streaming.providers.length > 0}
import { resolve } from '$app/paths'; import { page } from '$app/state'; + import { untrack } from 'svelte'; import Avatar from './Avatar.svelte'; import Container from './Container.svelte'; import ExternalRatings from './ExternalRatings.svelte'; import ItemsGrid from './ItemsGrid.svelte'; + import Rating from './Rating.svelte'; import Review from './Review.svelte'; import TabSelect from './TabSelect.svelte'; import TrailerDialog from './TrailerDialog.svelte'; @@ -19,13 +21,28 @@ reviews: ReviewCardModel[]; }; + function getDefaultDetailSection(data: ItemPageData): DetailSection { + if (data.reviews.length > 0) return 'reviews'; + if (data.recommendations.length > 0) return 'similar'; + return 'cast'; + } + let { data }: { data: ItemPageData } = $props(); let canonicalUrl = $derived(`${page.url.origin}${page.url.pathname}`); let ogImageUrl = $derived(`${canonicalUrl.replace(/\/$/, '')}/og.png`); - let selectedSection = $state('reviews'); + let averageReviewRating = $derived( + data.reviews.length > 0 + ? data.reviews.reduce((total, review) => total + review.rating, 0) / data.reviews.length + : 0 + ); + let selectedSection = $state(untrack(() => getDefaultDetailSection(data))); + let showWrittenReviewsOnly = $state(true); + let visibleReviews = $derived( + showWrittenReviewsOnly ? data.reviews.filter((review) => review.text.trim()) : data.reviews + ); let detailTabs = $derived( [ - { value: 'reviews' as const, label: 'reviews' }, + data.reviews.length > 0 ? { value: 'reviews' as const, label: 'reviews' } : null, data.recommendations.length > 0 ? { value: 'similar' as const, label: 'similar' } : null, data.cast.length > 0 ? { value: 'cast' as const, label: 'cast' } : null ].filter((option): option is { value: DetailSection; label: string } => option !== null) @@ -126,15 +143,34 @@

More about {data.item.title}

- {#if selectedSection === 'reviews'} - {#if data.reviews.length > 0} -
- {#each data.reviews as review (review.uri)} + {#if selectedSection === 'reviews' && data.reviews.length > 0} +
+
+ + {(averageReviewRating / 2).toFixed(1)} + + average from {data.reviews.length} + {data.reviews.length === 1 ? 'review' : 'reviews'} + +
+ +
+ + {#if visibleReviews.length > 0} +
+ {#each visibleReviews as review (review.uri)} {/each}
{:else} -

No reviews yet.

+

No reviews with text.

{/if} {:else if selectedSection === 'similar' && data.recommendations.length > 0} diff --git a/src/lib/components/Review.svelte b/src/lib/components/Review.svelte index df68ceb..434f759 100644 --- a/src/lib/components/Review.svelte +++ b/src/lib/components/Review.svelte @@ -1,5 +1,6 @@
@@ -31,7 +44,7 @@ {#if imageUrl} {#if text} -

- {text} -

+
+

+ {text} +

+ + {#if spoilerHidden} + + {/if} +
+ + {#if reviewUrl} +
+ view review + + + {/if} {:else}
diff --git a/src/lib/contrail/index.ts b/src/lib/contrail/index.ts index 3567d50..e84c46d 100644 --- a/src/lib/contrail/index.ts +++ b/src/lib/contrail/index.ts @@ -5,7 +5,7 @@ import type {} from "./types/index.js"; export const contrail = createPublicServiceClient({ endpoint: "http://127.0.0.1:8787", allowInsecureHttp: true, - contractDigest: "sha256:401ce47ba01fb8c1ba1c34821782214c2c95eb1752784630f064c659dd6b1b0f", + contractDigest: "sha256:cb70003452bdb5fe4baf2c5c41f932a52ebf74ddc6d03ed3d833d0e34cc452ad", serviceMethods: [ "watch.atmo.comment.getRecord", "watch.atmo.comment.listRecords", diff --git a/src/lib/contrail/lexicons/watch/atmo/review/listRecords.json b/src/lib/contrail/lexicons/watch/atmo/review/listRecords.json index 85193cb..8fd9895 100644 --- a/src/lib/contrail/lexicons/watch/atmo/review/listRecords.json +++ b/src/lib/contrail/lexicons/watch/atmo/review/listRecords.json @@ -37,18 +37,42 @@ "format": "at-identifier", "type": "string" }, + "creativeWorkType": { + "description": "Filter by creativeWorkType", + "type": "string" + }, "cursor": { "type": "string" }, + "identifiersTmdbId": { + "description": "Filter by identifiers.tmdbId", + "type": "string" + }, "limit": { "default": 50, "maximum": 200, "minimum": 1, "type": "integer" }, + "order": { + "description": "Sort direction", + "knownValues": [ + "asc", + "desc" + ], + "type": "string" + }, "profiles": { "description": "Include indexed profile and identity information", "type": "boolean" + }, + "sort": { + "description": "Field to sort by (default: time_us)", + "knownValues": [ + "creativeWorkType", + "identifiersTmdbId" + ], + "type": "string" } }, "type": "params" diff --git a/src/lib/contrail/types/types/watch/atmo/review/listRecords.ts b/src/lib/contrail/types/types/watch/atmo/review/listRecords.ts index e539642..f5fff80 100644 --- a/src/lib/contrail/types/types/watch/atmo/review/listRecords.ts +++ b/src/lib/contrail/types/types/watch/atmo/review/listRecords.ts @@ -13,7 +13,15 @@ const _mainSchema = /*#__PURE__*/ v.query( * Filter by an indexed DID or cached handle */ "actor": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.actorIdentifierString()), + /** + * Filter by creativeWorkType + */ + "creativeWorkType": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), "cursor": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * Filter by identifiers.tmdbId + */ + "identifiersTmdbId": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), /** * @minimum 1 * @maximum 200 @@ -26,10 +34,18 @@ const _mainSchema = /*#__PURE__*/ v.query( ), 50 ), + /** + * Sort direction + */ + "order": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>()), /** * Include indexed profile and identity information */ "profiles": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), + /** + * Field to sort by (default: time_us) + */ + "sort": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string<"creativeWorkType" | "identifiersTmdbId" | (string & {})>()), } ), "output": { diff --git a/src/lib/review-interactions.remote.ts b/src/lib/review-interactions.remote.ts new file mode 100644 index 0000000..03ec38f --- /dev/null +++ b/src/lib/review-interactions.remote.ts @@ -0,0 +1,112 @@ +import { command, getRequestEvent } from '$app/server'; +import { error } from '@sveltejs/kit'; +import { isCanonicalResourceUri, parseCanonicalResourceUri } from '@atcute/lexicons'; +import type { CanonicalResourceUri, Did } from '@atcute/lexicons'; +import { createTID } from '@svelte-atproto/oauth/helper'; +import * as v from 'valibot'; +import { contrail } from '$lib/contrail'; + +const REVIEW_COLLECTION = 'social.popfeed.feed.review'; +const LIKE_COLLECTION = 'social.popfeed.feed.like'; + +const uriSchema = v.pipe( + v.string(), + v.maxLength(500), + v.check((value: string) => isCanonicalResourceUri(value), 'Invalid AT URI') +); + +function requireUri(value: string, collection: string): CanonicalResourceUri { + if (!isCanonicalResourceUri(value)) error(400, 'Invalid AT URI'); + const parsed = parseCanonicalResourceUri(value); + if (parsed.collection !== collection) error(400, 'Invalid record collection'); + return value; +} + +function responseMessage(data: unknown, fallback: string) { + if (data && typeof data === 'object' && 'message' in data && typeof data.message === 'string') { + return data.message; + } + return fallback; +} + +async function findLike(reviewUri: CanonicalResourceUri, did: Did) { + let cursor: string | undefined; + + do { + const response = await contrail.get('watch.atmo.like.listRecords', { + params: { actor: did, cursor, limit: 200 } + }); + if (!response.ok) return null; + + const like = response.data.records.find((record) => record.value.subjectUri === reviewUri); + if (like) return like.uri; + cursor = response.data.cursor; + } while (cursor); + + return null; +} + +export const likeReview = command(v.object({ reviewUri: uriSchema }), async ({ reviewUri }) => { + const { locals } = getRequestEvent(); + const { client, did } = locals; + if (!client || !did) error(401, 'Log in to like this review'); + + const subjectUri = requireUri(reviewUri, REVIEW_COLLECTION); + const existing = await findLike(subjectUri, did); + if (existing) return { uri: existing, created: false }; + + const response = await contrail.authenticated(client).post('com.atproto.repo.createRecord', { + input: { + repo: did, + collection: LIKE_COLLECTION, + rkey: createTID(), + record: { + $type: LIKE_COLLECTION, + subjectUri, + subjectType: 'review', + createdAt: new Date().toISOString() + } + } + }); + + if (!response.ok) { + error(response.status, responseMessage(response.data, 'Could not like review')); + } + + return { uri: response.data.uri, created: true }; +}); + +export const unlikeReview = command( + v.object({ reviewUri: uriSchema, likeUri: uriSchema }), + async ({ reviewUri, likeUri }) => { + const { locals } = getRequestEvent(); + const { client, did } = locals; + if (!client || !did) error(401, 'Log in to unlike this review'); + + const subjectUri = requireUri(reviewUri, REVIEW_COLLECTION); + const parsedLike = parseCanonicalResourceUri(requireUri(likeUri, LIKE_COLLECTION)); + if (parsedLike.repo !== did) error(403, 'You can only remove your own like'); + + const existing = await client.get('com.atproto.repo.getRecord', { + params: { repo: did, collection: LIKE_COLLECTION, rkey: parsedLike.rkey } + }); + if (!existing.ok) { + error(existing.status, responseMessage(existing.data, 'Could not find like')); + } + + const value = existing.data.value as { $type?: unknown; subjectUri?: unknown }; + if (value.$type !== LIKE_COLLECTION || value.subjectUri !== subjectUri) { + error(400, 'Like does not belong to this review'); + } + + const response = await contrail.authenticated(client).post('com.atproto.repo.deleteRecord', { + input: { repo: did, collection: LIKE_COLLECTION, rkey: parsedLike.rkey }, + as: null + }); + if (!response.ok) { + error(response.status, responseMessage(response.data, 'Could not remove like')); + } + + return { deleted: true }; + } +); diff --git a/src/lib/reviews.server.ts b/src/lib/reviews.server.ts index 96ee918..cb79892 100644 --- a/src/lib/reviews.server.ts +++ b/src/lib/reviews.server.ts @@ -1,30 +1,38 @@ import { getAtprotoCdnImageUrl } from '$lib/atproto/images'; import { contrail } from '$lib/contrail'; +import type * as CommentListRecords from '$lib/contrail/types/types/watch/atmo/comment/listRecords'; +import type * as LikeListRecords from '$lib/contrail/types/types/watch/atmo/like/listRecords'; import type * as ReviewListRecords from '$lib/contrail/types/types/watch/atmo/review/listRecords'; -import type { MediaImage, ReviewCardModel, SupportedCreativeWorkType } from '$lib/types'; +import type { + ActorSummary, + MediaImage, + ReviewCardModel, + ReviewCommentModel, + SupportedCreativeWorkType +} from '$lib/types'; + +type ReviewRecord = Pick; function getCreativeWorkType(value: string): SupportedCreativeWorkType | undefined { if (value === 'movie' || value === 'tv_show') return value; return undefined; } -function getPoster(record: ReviewListRecords.Record): MediaImage | null { +function getPoster(record: ReviewRecord): MediaImage | null { if (record.value.poster) { - return { - source: 'remote', - url: getAtprotoCdnImageUrl({ - did: record.did, - blob: record.value.poster, - preset: 'feed_thumbnail' - }) - }; + const url = getAtprotoCdnImageUrl({ + did: record.did, + blob: record.value.poster, + preset: 'feed_thumbnail' + }); + if (url) return { source: 'remote', url }; } return record.value.posterUrl ? { source: 'remote', url: record.value.posterUrl } : null; } export function toReview( - record: ReviewListRecords.Record, + record: ReviewRecord, handle: string = record.did ): ReviewCardModel | undefined { const creativeWorkType = getCreativeWorkType(record.value.creativeWorkType); @@ -47,7 +55,99 @@ export function toReview( poster: getPoster(record) }, rating: record.value.rating, - text: record.value.text ?? '' + text: record.value.text ?? '', + containsSpoilers: record.value.containsSpoilers ?? false + }; +} + +function getCommentAuthor( + did: string, + profiles: Map +): ActorSummary { + const profile = profiles.get(did); + return { + did, + handle: profile?.handle ?? did, + displayName: profile?.value?.displayName, + avatarUrl: profile?.value?.avatar + ? getAtprotoCdnImageUrl({ did, blob: profile.value.avatar, preset: 'avatar' }) + : undefined + }; +} + +export async function getReviewInteractions(reviewUri: string, viewerDid: string | null) { + const likes: LikeListRecords.Record[] = []; + const comments: CommentListRecords.Record[] = []; + const commentProfiles = new Map(); + let likeCursor: string | undefined; + let commentCursor: string | undefined; + + do { + const response = await contrail.get('watch.atmo.like.listRecords', { + params: { cursor: likeCursor, limit: 200 } + }); + if (!response.ok) { + throw new Error(`Could not load review likes from Contrail (${response.status})`); + } + + likes.push(...response.data.records.filter((record) => record.value.subjectUri === reviewUri)); + likeCursor = response.data.cursor; + } while (likeCursor); + + do { + const response = await contrail.get('watch.atmo.comment.listRecords', { + params: { cursor: commentCursor, limit: 200, profiles: true } + }); + if (!response.ok) { + throw new Error(`Could not load review comments from Contrail (${response.status})`); + } + + comments.push(...response.data.records); + for (const profile of response.data.profiles ?? []) { + if (!commentProfiles.has(profile.did) || profile.collection === 'app.bsky.actor.profile') { + commentProfiles.set(profile.did, profile); + } + } + commentCursor = response.data.cursor; + } while (commentCursor); + + const uniqueLikers = new Set(likes.map((like) => like.did)); + const viewerLikeUri = viewerDid + ? (likes.find((like) => like.did === viewerDid)?.uri ?? null) + : null; + + const threadUris = new Set([reviewUri]); + const threadComments: CommentListRecords.Record[] = []; + let foundComments = true; + while (foundComments) { + foundComments = false; + for (const comment of comments) { + if (threadUris.has(comment.uri)) continue; + if ( + threadUris.has(comment.value.subjectUri) || + (comment.value.rootUri && threadUris.has(comment.value.rootUri)) + ) { + threadUris.add(comment.uri); + threadComments.push(comment); + foundComments = true; + } + } + } + + const reviewComments: ReviewCommentModel[] = threadComments + .map((comment) => ({ + uri: comment.uri, + author: getCommentAuthor(comment.did, commentProfiles), + text: comment.value.text, + createdAt: comment.value.createdAt + })) + .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)); + + return { + likeCount: uniqueLikers.size, + commentCount: reviewComments.length, + viewerLikeUri, + comments: reviewComments }; } diff --git a/src/lib/tmdb.server.ts b/src/lib/tmdb.server.ts index e297146..a6404cf 100644 --- a/src/lib/tmdb.server.ts +++ b/src/lib/tmdb.server.ts @@ -1,13 +1,16 @@ import { env } from '$env/dynamic/private'; +import { cachePublicData, getPublicDataCache } from '$lib/cache.server'; import { TMDB, TMDBError, type Cast, type MediaWatchProviders, type MovieDetails, + type MovieDetailsWithAppends, type MovieResultItem, type PersonCombinedCastCredit, type PersonDetails as TmdbPersonDetails, + type TVDetailsWithAppends, type TVSeriesDetails, type TVSeriesResultItem, type VideoItem, @@ -36,6 +39,12 @@ const MEDIA_APPENDS: ['credits', 'recommendations', 'external_ids', 'videos', 'w ]; const PERSON_APPENDS: ['combined_credits'] = ['combined_credits']; +const HOUR = 60 * 60; +const WEEK = 7 * 24 * HOUR; +const MEDIA_DATA_TTL = WEEK; +const DISCOVERY_TTL = 6 * HOUR; +const SEARCH_TTL = HOUR; + const EMPTY_OMDB_DATA: OmdbData = { ratings: [], imdbVotes: null @@ -46,9 +55,15 @@ type OmdbData = { imdbVotes: string | null; }; +type OmdbFetchResult = { + data: OmdbData; + cacheable: boolean; +}; + type MediaSummarySource = MovieDetails | MovieResultItem | TVSeriesDetails | TVSeriesResultItem | PersonCombinedCastCredit; -type MediaDetailsSource = MovieDetails | TVSeriesDetails; +type MediaDetailsSource = + MovieDetailsWithAppends | TVDetailsWithAppends; type TmdbMediaType = 'movie' | 'tv'; let client: TMDB | undefined; @@ -65,8 +80,7 @@ function getClient() { client = new TMDB(token, { language: 'en-US', region: 'US', - retry: true, - cache: { ttl: 300_000, max_size: 250 } + retry: true }); clientToken = token; } @@ -74,6 +88,25 @@ function getClient() { return client; } +function getMediaSource( + tmdbId: number, + creativeWorkType: SupportedCreativeWorkType, + cache: ReturnType +): Promise { + return cachePublicData(cache, `tmdb:media:${creativeWorkType}:${tmdbId}`, async () => { + const tmdb = getClient(); + return creativeWorkType === 'movie' + ? tmdb.movies.details({ + movie_id: tmdbId, + append_to_response: MEDIA_APPENDS + }) + : tmdb.tv_series.details({ + series_id: tmdbId, + append_to_response: MEDIA_APPENDS + }); + }); +} + function fromTmdbMediaType(mediaType: TmdbMediaType): SupportedCreativeWorkType { return mediaType === 'tv' ? 'tv_show' : 'movie'; } @@ -173,75 +206,63 @@ function toPersonDetails(person: TmdbPersonDetails): PersonDetails { }; } -export async function getRatings(imdbId: string): Promise { +export async function getRatings( + imdbId: string, + cache = getPublicDataCache(MEDIA_DATA_TTL) +): Promise { if (!env.OMDB_API_KEY) return EMPTY_OMDB_DATA; - const url = new URL('https://www.omdbapi.com/'); - url.searchParams.set('i', imdbId); - url.searchParams.set('apikey', env.OMDB_API_KEY); - - try { - const response = await fetch(url); - if (!response.ok) return EMPTY_OMDB_DATA; - - const data = (await response.json()) as { - Response: 'True' | 'False'; - Ratings?: Array<{ Source: string; Value: string }>; - imdbVotes?: string; - }; - - if (data.Response === 'False') return EMPTY_OMDB_DATA; - - return { - ratings: (data.Ratings ?? []).map((rating) => ({ - source: rating.Source, - value: rating.Value - })), - imdbVotes: data.imdbVotes && data.imdbVotes !== 'N/A' ? data.imdbVotes : null - }; - } catch { - return EMPTY_OMDB_DATA; - } + const result = await cachePublicData( + cache, + `omdb:ratings:${imdbId}`, + async () => { + const url = new URL('https://www.omdbapi.com/'); + url.searchParams.set('i', imdbId); + url.searchParams.set('apikey', env.OMDB_API_KEY); + + try { + const response = await fetch(url); + if (!response.ok) return { data: EMPTY_OMDB_DATA, cacheable: false }; + + const data = (await response.json()) as { + Response: 'True' | 'False'; + Ratings?: Array<{ Source: string; Value: string }>; + imdbVotes?: string; + }; + + if (data.Response === 'False') { + return { data: EMPTY_OMDB_DATA, cacheable: true }; + } + + return { + data: { + ratings: (data.Ratings ?? []).map((rating) => ({ + source: rating.Source, + value: rating.Value + })), + imdbVotes: data.imdbVotes && data.imdbVotes !== 'N/A' ? data.imdbVotes : null + }, + cacheable: true + }; + } catch { + return { data: EMPTY_OMDB_DATA, cacheable: false }; + } + }, + (value) => value.cacheable + ); + + return result.data; } export async function getMediaPage( tmdbId: number, creativeWorkType: SupportedCreativeWorkType, - region = 'US' + region: string | null ) { - const tmdb = getClient(); - - if (creativeWorkType === 'movie') { - const details = await tmdb.movies.details({ - movie_id: tmdbId, - append_to_response: MEDIA_APPENDS - }); - - const omdb = details.external_ids.imdb_id - ? await getRatings(details.external_ids.imdb_id) - : EMPTY_OMDB_DATA; - - return { - item: toMediaDetails(details, creativeWorkType), - recommendations: details.recommendations.results.map((item) => - toMediaSummary(item, creativeWorkType) - ), - cast: details.credits.cast.map(toCastMember), - imdb_id: details.external_ids.imdb_id ?? null, - imdb_votes: omdb.imdbVotes, - ratings: omdb.ratings, - trailer_url: getTrailerUrl(details.videos.results), - streaming: toStreamingAvailability(details['watch/providers'], region) - }; - } - - const details = await tmdb.tv_series.details({ - series_id: tmdbId, - append_to_response: MEDIA_APPENDS - }); - + const cache = getPublicDataCache(MEDIA_DATA_TTL); + const details = await getMediaSource(tmdbId, creativeWorkType, cache); const omdb = details.external_ids.imdb_id - ? await getRatings(details.external_ids.imdb_id) + ? await getRatings(details.external_ids.imdb_id, cache) : EMPTY_OMDB_DATA; return { @@ -254,11 +275,11 @@ export async function getMediaPage( imdb_votes: omdb.imdbVotes, ratings: omdb.ratings, trailer_url: getTrailerUrl(details.videos.results), - streaming: toStreamingAvailability(details['watch/providers'], region) + streaming: region ? toStreamingAvailability(details['watch/providers'], region) : null }; } -export async function getHomePage() { +async function loadHomePage() { const empty = { currentlyInTheaters: [] as MediaSummary[], popular: [] as MediaSummary[] @@ -304,45 +325,60 @@ export async function getHomePage() { return { currentlyInTheaters, popular }; } -export async function searchMedia(query: string): Promise { - const response = await getClient().search.multi({ - query, - include_adult: false, - page: 1 - }); +export function getHomePage() { + const cache = getPublicDataCache(DISCOVERY_TTL); + return cachePublicData(cache, 'tmdb:home', loadHomePage, (data) => + Boolean(data.currentlyInTheaters.length || data.popular.length) + ); +} - return response.results - .flatMap((result) => { - if (result.media_type !== 'movie' && result.media_type !== 'tv') return []; - return [toMediaSummary(result, fromTmdbMediaType(result.media_type))]; - }) - .filter((item) => item.poster) - .slice(0, 12); +export function searchMedia(query: string): Promise { + const normalizedQuery = query.trim(); + const cache = getPublicDataCache(SEARCH_TTL); + + return cachePublicData( + cache, + `tmdb:search:${normalizedQuery.toLocaleLowerCase('en-US')}`, + async () => { + const response = await getClient().search.multi({ + query: normalizedQuery, + include_adult: false, + page: 1 + }); + + return response.results + .flatMap((result) => { + if (result.media_type !== 'movie' && result.media_type !== 'tv') return []; + return [toMediaSummary(result, fromTmdbMediaType(result.media_type))]; + }) + .filter((item) => item.poster) + .slice(0, 12); + } + ); } export async function getDetails( tmdbId: number, creativeWorkType: SupportedCreativeWorkType ): Promise { - const tmdb = getClient(); - const details = - creativeWorkType === 'movie' - ? await tmdb.movies.details({ movie_id: tmdbId }) - : await tmdb.tv_series.details({ series_id: tmdbId }); - + const cache = getPublicDataCache(MEDIA_DATA_TTL); + const details = await getMediaSource(tmdbId, creativeWorkType, cache); return toMediaDetails(details, creativeWorkType); } -export async function getPersonPage(personId: number) { - const person = await getClient().people.details({ - person_id: personId, - append_to_response: PERSON_APPENDS - }); +export function getPersonPage(personId: number) { + const cache = getPublicDataCache(MEDIA_DATA_TTL); + return cachePublicData(cache, `tmdb:person:${personId}`, async () => { + const person = await getClient().people.details({ + person_id: personId, + append_to_response: PERSON_APPENDS + }); - return { - personDetails: toPersonDetails(person), - combinedCredits: person.combined_credits.cast.map(toMediaCredit) - }; + return { + personDetails: toPersonDetails(person), + combinedCredits: person.combined_credits.cast.map(toMediaCredit) + }; + }); } export { TMDBError }; diff --git a/src/lib/types.ts b/src/lib/types.ts index e782854..c88faed 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -24,6 +24,7 @@ export type MediaCredit = MediaSummary & { export type ActorSummary = { did: string; handle: string; + displayName?: string; avatarUrl?: string; }; @@ -33,6 +34,14 @@ export type ReviewCardModel = { media: MediaSummary; rating: number; text: string; + containsSpoilers: boolean; +}; + +export type ReviewCommentModel = { + uri: string; + author: ActorSummary; + text: string; + createdAt: string; }; export type ExternalRating = { diff --git a/src/routes/[kind]/[id]/+page.server.ts b/src/routes/[kind]/[id]/+page.server.ts index c3ebf99..84ba248 100644 --- a/src/routes/[kind]/[id]/+page.server.ts +++ b/src/routes/[kind]/[id]/+page.server.ts @@ -22,14 +22,14 @@ function getStreamingRegion(url: URL, request: Request) { for (const language of (request.headers.get('accept-language') ?? '').split(',')) { try { const locale = new Intl.Locale(language.split(';')[0].trim().replace('_', '-')); - const region = normalizeRegion(locale.region ?? locale.maximize().region); + const region = normalizeRegion(locale.region); if (region) return region; } catch { // Ignore malformed language tags. } } - return 'US'; + return null; } export const load: PageServerLoad = async ({ params, request, url }) => { diff --git a/src/routes/profile/[actor]/+layout.server.ts b/src/routes/profile/[actor]/+layout.server.ts index 5547434..d437c74 100644 --- a/src/routes/profile/[actor]/+layout.server.ts +++ b/src/routes/profile/[actor]/+layout.server.ts @@ -2,7 +2,6 @@ import { getAtprotoCdnImageUrl } from '$lib/atproto/images'; import { contrail } from '$lib/contrail'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; -import { toReview } from '$lib/reviews.server'; import type { LayoutServerLoad } from './$types'; function parseActor(value: string) { @@ -19,39 +18,27 @@ function parseActor(value: string) { export const load: LayoutServerLoad = async ({ params }) => { const actor = parseActor(params.actor); if (!actor) error(404, 'Profile not found'); - const [reviews, profileResponse] = await Promise.all([ - contrail.get('watch.atmo.review.listRecords', { - params: { actor, profiles: true } - }), - contrail.get('watch.atmo.getProfile', { - params: { actor } - }) - ]); - if (!reviews.ok) { - if (reviews.status === 400 || reviews.status === 404) error(404, 'Profile not found'); - error(502, 'Could not load reviews'); + const response = await contrail.get('watch.atmo.getProfile', { + params: { actor } + }); + if (!response.ok) { + if (response.status === 400 || response.status === 404) error(404, 'Profile not found'); + error(502, 'Could not load profile'); } - const profiles = profileResponse.ok - ? profileResponse.data.profiles - : (reviews.data.profiles ?? []); const profileEntry = - profiles.find((entry) => entry.collection === 'app.bsky.actor.profile') ?? profiles[0]; - const did = profileEntry?.did ?? actor; - const handle = profileEntry?.handle ?? actor.replace(/^@/, ''); - - const reviewEntries = reviews.data.records.flatMap((record) => { - const review = toReview(record, handle); - return review ? [review] : []; - }); + response.data.profiles.find((entry) => entry.collection === 'app.bsky.actor.profile') ?? + response.data.profiles[0]; + if (!profileEntry) error(404, 'Profile not found'); + const did = profileEntry.did; return { - reviews: reviewEntries, profile: { did, - handle, - avatarUrl: profileEntry?.value?.avatar + handle: profileEntry.handle ?? did, + displayName: profileEntry.value?.displayName, + avatarUrl: profileEntry.value?.avatar ? getAtprotoCdnImageUrl({ did, blob: profileEntry.value.avatar, preset: 'avatar' }) : undefined } diff --git a/src/routes/profile/[actor]/+page.server.ts b/src/routes/profile/[actor]/+page.server.ts new file mode 100644 index 0000000..ccef8d6 --- /dev/null +++ b/src/routes/profile/[actor]/+page.server.ts @@ -0,0 +1,20 @@ +import { error } from '@sveltejs/kit'; +import { contrail } from '$lib/contrail'; +import { toReview } from '$lib/reviews.server'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const { profile } = await parent(); + const response = await contrail.get('watch.atmo.review.listRecords', { + params: { actor: profile.did } + }); + + if (!response.ok) error(502, 'Could not load reviews'); + + return { + reviews: response.data.records.flatMap((record) => { + const review = toReview(record, profile.handle); + return review ? [review] : []; + }) + }; +}; diff --git a/src/routes/profile/[actor]/review/[rkey]/+page.server.ts b/src/routes/profile/[actor]/review/[rkey]/+page.server.ts new file mode 100644 index 0000000..bd6c2cd --- /dev/null +++ b/src/routes/profile/[actor]/review/[rkey]/+page.server.ts @@ -0,0 +1,49 @@ +import { error } from '@sveltejs/kit'; +import type { ResourceUri } from '@atcute/lexicons'; +import { isRecordKey } from '@atcute/lexicons/syntax'; +import { contrail } from '$lib/contrail'; +import { getReviewInteractions, toReview } from '$lib/reviews.server'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, params, parent }) => { + if (!isRecordKey(params.rkey)) error(404, 'Review not found'); + + const { profile } = await parent(); + const reviewUri = `at://${profile.did}/social.popfeed.feed.review/${params.rkey}` as ResourceUri; + const reviewResponse = await contrail.get('watch.atmo.review.getRecord', { + params: { uri: reviewUri, profiles: true } + }); + + if (!reviewResponse.ok) { + if (reviewResponse.status === 400 || reviewResponse.status === 404) { + error(404, 'Review not found'); + } + error(502, 'Could not load review'); + } + + const review = toReview(reviewResponse.data, profile.handle); + if (!review) error(404, 'Review not found'); + + const interactions = await getReviewInteractions(reviewUri, locals.did).catch((cause) => { + console.error('Could not load review interactions from Contrail', cause); + return { + likeCount: 0, + commentCount: 0, + viewerLikeUri: null, + comments: [] + }; + }); + + return { + review: { + ...review, + author: { + ...review.author, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl + }, + createdAt: reviewResponse.data.value.createdAt + }, + ...interactions + }; +}; diff --git a/src/routes/profile/[actor]/review/[rkey]/+page.svelte b/src/routes/profile/[actor]/review/[rkey]/+page.svelte new file mode 100644 index 0000000..d9a523f --- /dev/null +++ b/src/routes/profile/[actor]/review/[rkey]/+page.svelte @@ -0,0 +1,294 @@ + + + + {data.review.media.title} review by @{reviewerHandle} | atmo.watch + + + +
+ +
+ + {#if imageUrl} + {`Poster + {:else} + + + + {/if} + + + + +
+ {#if reviewText} +
+

+ {reviewText} +

+ + {#if spoilerHidden} + + {/if} +
+ {:else} +

No written review.

+ {/if} + +
+ + + + + {#if data.commentCount > 0}{data.commentCount}{/if} + +
+ + {#if interactionError} +

{interactionError}

+ {/if} +
+
+ +
+

comments

+ + {#if data.comments.length > 0} +
+ {#each data.comments as comment (comment.uri)} + + {/each} +
+ {:else} +

No comments yet.

+ {/if} +
+
+
+ + -- 2.51.2