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}
+
+ {: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 @@