From fb3372d354d6e90bbd66d8b37bd1fd7e7565b409 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:44:20 +0200 Subject: [PATCH] commit --- src/lib/components/AccountMenuItem.svelte | 9 +- src/lib/components/ItemPage.svelte | 27 ++++-- src/lib/components/Review.svelte | 96 ++++++++++++++++++++ src/lib/contrail.config.ts | 6 +- src/lib/reviews.server.ts | 61 +++++++++++++ src/lib/types.ts | 11 +++ src/routes/[kind]/[id]/+page.server.ts | 11 ++- src/routes/profile/[actor]/+layout.server.ts | 65 ++++++------- src/routes/profile/[actor]/+page.svelte | 10 +- 9 files changed, 246 insertions(+), 50 deletions(-) create mode 100644 src/lib/components/Review.svelte create mode 100644 src/lib/reviews.server.ts diff --git a/src/lib/components/AccountMenuItem.svelte b/src/lib/components/AccountMenuItem.svelte index 6e119cf..5e218c6 100644 --- a/src/lib/components/AccountMenuItem.svelte +++ b/src/lib/components/AccountMenuItem.svelte @@ -1,4 +1,5 @@ + +
+ {#if showItem} + + {#if imageUrl} + {`Poster + {:else} + + + + {/if} + + {/if} + +
+
+ + + {#if text} + + {/if} +
+ + {#if text} +

+ {text} +

+ {:else} +
+ +
+ {/if} +
+
diff --git a/src/lib/contrail.config.ts b/src/lib/contrail.config.ts index c5371d2..d1a619b 100644 --- a/src/lib/contrail.config.ts +++ b/src/lib/contrail.config.ts @@ -4,7 +4,11 @@ export const config: ContrailConfig = { namespace: 'watch.atmo', collections: { review: { - collection: 'social.popfeed.feed.review' + collection: 'social.popfeed.feed.review', + queryable: { + creativeWorkType: {}, + 'identifiers.tmdbId': {} + } }, like: { collection: 'social.popfeed.feed.like' diff --git a/src/lib/reviews.server.ts b/src/lib/reviews.server.ts new file mode 100644 index 0000000..0ab1185 --- /dev/null +++ b/src/lib/reviews.server.ts @@ -0,0 +1,61 @@ +import { contrail } from '$lib/contrail'; +import type * as ReviewListRecords from '$lib/contrail/types/types/watch/atmo/review/listRecords'; +import type { MediaKind, Review } from '$lib/types'; + +function getMediaKind(creativeWorkType: string): MediaKind | undefined { + if (creativeWorkType === 'movie') return 'movie'; + if (creativeWorkType === 'tv_show') return 'tv'; + return undefined; +} + +export function toReview( + record: ReviewListRecords.Record, + handle: string = record.did +): Review | undefined { + const mediaType = getMediaKind(record.value.creativeWorkType); + const tmdbId = record.value.identifiers.tmdbId; + if (!mediaType || !tmdbId || !record.value.title) return undefined; + + const id = Number(tmdbId); + if (!Number.isSafeInteger(id) || id <= 0) return undefined; + + return { + uri: record.uri, + author: { + did: record.did, + handle + }, + item: { + id, + media_type: mediaType, + title: record.value.title, + poster_path: record.value.posterUrl ?? null + }, + rating: record.value.rating, + text: record.value.text ?? '' + }; +} + +export async function getMediaReviews(id: number, kind: MediaKind): Promise { + const creativeWorkType = kind === 'movie' ? 'movie' : 'tv_show'; + const params = { + creativeWorkType, + identifiersTmdbId: String(id), + limit: 200, + profiles: true + }; + const response = await contrail.get('watch.atmo.review.listRecords', { params }); + + if (!response.ok) { + throw new Error(`Could not load reviews from Contrail (${response.status})`); + } + + const handles = new Map( + (response.data.profiles ?? []).map((profile) => [profile.did, profile.handle ?? profile.did]) + ); + + return response.data.records.flatMap((record) => { + const review = toReview(record, handles.get(record.did)); + return review?.item.id === id && review.item.media_type === kind ? [review] : []; + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 88d791b..9b792a1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,6 +12,17 @@ export type Item = { order?: number; }; +export type Review = { + uri: string; + author: { + did: string; + handle: string; + }; + item: Pick; + rating: number; + text: string; +}; + export type ExternalRating = { source: string; value: string; diff --git a/src/routes/[kind]/[id]/+page.server.ts b/src/routes/[kind]/[id]/+page.server.ts index 18c633b..ec08b10 100644 --- a/src/routes/[kind]/[id]/+page.server.ts +++ b/src/routes/[kind]/[id]/+page.server.ts @@ -1,5 +1,6 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; +import { getMediaReviews } from '$lib/reviews.server'; import { getMediaPage, TMDBError } from '$lib/tmdb.server'; import { isMediaKind, parseTmdbId } from '$lib/utils'; @@ -40,7 +41,15 @@ export const load: PageServerLoad = async ({ params, request, url }) => { } try { - return await getMediaPage(id, kind, getStreamingRegion(url, request)); + const [mediaPage, reviews] = await Promise.all([ + getMediaPage(id, kind, getStreamingRegion(url, request)), + getMediaReviews(id, kind).catch((cause) => { + console.error('Could not load reviews from Contrail', cause); + return []; + }) + ]); + + return { ...mediaPage, reviews }; } catch (cause) { if (cause instanceof TMDBError && cause.http_status_code === 404) { error(404, 'Not found'); diff --git a/src/routes/profile/[actor]/+layout.server.ts b/src/routes/profile/[actor]/+layout.server.ts index e3c36c5..3a1e4e7 100644 --- a/src/routes/profile/[actor]/+layout.server.ts +++ b/src/routes/profile/[actor]/+layout.server.ts @@ -1,13 +1,9 @@ import { contrail } from '$lib/contrail'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; -import type { Item, MediaKind, TmdbRef } from '$lib/types'; +import { toReview } from '$lib/reviews.server'; import type { LayoutServerLoad } from './$types'; -function getTmdbRef(id: number, mediaType: MediaKind): TmdbRef { - return `tmdb:${mediaType === 'movie' ? 'm' : 's'}-${id}`; -} - function getBlobCid(value: unknown) { if (!value || typeof value !== 'object') return undefined; @@ -16,54 +12,53 @@ function getBlobCid(value: unknown) { return typeof blob.cid === 'string' ? blob.cid : undefined; } +function parseActor(value: string) { + if (isActorIdentifier(value)) return value; + + try { + const decoded = decodeURIComponent(value); + return isActorIdentifier(decoded) ? decoded : undefined; + } catch { + return undefined; + } +} + export const load: LayoutServerLoad = async ({ params }) => { - const actor = params.actor; - if (!isActorIdentifier(actor)) error(404, 'Profile not found'); + 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 } + params: { actor, profiles: true } }), contrail.get('watch.atmo.getProfile', { params: { actor } }) ]); - if (!reviews.ok) error(502, 'Could not load reviews'); - if (!profileResponse.ok) - error(profileResponse.status === 404 ? 404 : 502, 'Could not load profile'); - - const items: Item[] = []; - for (const review of reviews.data.records) { - const value = review.value; - if (value.creativeWorkType !== 'tv_show' && value.creativeWorkType !== 'movie') continue; - if (!value.identifiers.tmdbId || !value.posterUrl || !value.title) continue; - - const id = Number(value.identifiers.tmdbId); - if (!Number.isSafeInteger(id) || id <= 0) continue; - - const mediaType: MediaKind = value.creativeWorkType === 'tv_show' ? 'tv' : 'movie'; - items.push({ - id, - ref: getTmdbRef(id, mediaType), - media_type: mediaType, - title: value.title, - poster_path: value.posterUrl, - backdrop_path: value.backdropUrl ?? null, - overview: value.text ?? '' - }); + if (!reviews.ok) { + if (reviews.status === 400 || reviews.status === 404) error(404, 'Profile not found'); + error(502, 'Could not load reviews'); } + const profiles = profileResponse.ok + ? profileResponse.data.profiles + : (reviews.data.profiles ?? []); const profileEntry = - profileResponse.data.profiles.find((entry) => entry.collection === 'app.bsky.actor.profile') ?? - profileResponse.data.profiles[0]; + profiles.find((entry) => entry.collection === 'app.bsky.actor.profile') ?? profiles[0]; const avatarCid = getBlobCid(profileEntry?.value?.avatar); 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] : []; + }); return { - items, + reviews: reviewEntries, profile: { did, - handle: profileEntry?.handle ?? actor.replace(/^@/, ''), + handle, avatarUrl: avatarCid ? `https://cdn.bsky.app/img/avatar/plain/${did}/${avatarCid}@jpeg` : undefined diff --git a/src/routes/profile/[actor]/+page.svelte b/src/routes/profile/[actor]/+page.svelte index b83b830..74c08d5 100644 --- a/src/routes/profile/[actor]/+page.svelte +++ b/src/routes/profile/[actor]/+page.svelte @@ -1,7 +1,7 @@