From 7c5d2a25ff3a3a865de65e2acaad1bbe99b4261b Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Mon, 19 May 2025 01:06:20 +0200 Subject: [PATCH] made some nice progress today! --- app.config.ts | 33 +- src/components/hero/hero.module.css | 79 +++- src/components/hero/hero.tsx | 24 +- src/components/list/list.module.css | 18 +- src/components/list/list.tsx | 2 + src/features/content/apis/jellyfin.ts | 410 ++++++++++-------- .../content/apis/tmdb.not.generated.ts | 287 ++++++++++++ src/features/content/apis/tmdb.ts | 91 ++++ src/features/content/service.ts | 65 +-- src/features/content/types.ts | 4 +- src/features/overview/list-item.module.css | 34 +- src/index.css | 323 +++++++++++++- src/routes/(shell)/index.tsx | 7 +- src/routes/(shell)/watch/[slug].tsx | 3 +- src/routes/api/stream/video.ts | 15 +- 15 files changed, 1065 insertions(+), 330 deletions(-) create mode 100644 src/features/content/apis/tmdb.not.generated.ts create mode 100644 src/features/content/apis/tmdb.ts diff --git a/app.config.ts b/app.config.ts index 2b5dca2..2efc67a 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,27 +1,9 @@ -import { defineConfig } from '@solidjs/start/config'; -import { browserslistToTargets, Features } from 'lightningcss'; -import browserslist from 'browserslist'; -import solidSvg from 'vite-plugin-solid-svg'; -import devtools from 'solid-devtools/vite'; +import { defineConfig } from "@solidjs/start/config"; +import solidSvg from "vite-plugin-solid-svg"; +import devtools from "solid-devtools/vite"; export default defineConfig({ vite: { - // css: { - // transformer: 'lightningcss', - // lightningcss: { - // targets: browserslistToTargets(browserslist('>= .25%')), - // include: Features.Nesting | Features.LightDark | Features.Colors, - // customAtRules: { - // property: { - // prelude: '', - // body: 'style-block', - // }, - // }, - // }, - // }, - // build: { - // cssMinify: 'lightningcss', - // }, plugins: [ devtools({ autoname: true, @@ -35,12 +17,9 @@ export default defineConfig({ }, }, server: { - preset: 'bun', + preset: "bun", prerender: { - routes: [ - '/sitemaps.xml', - '/watch/furiosa-a-mad-max-saga-1c829d55201c766641c4aec0346551c6' - ], + routes: ["/sitemaps.xml"], }, }, -}); \ No newline at end of file +}); diff --git a/src/components/hero/hero.module.css b/src/components/hero/hero.module.css index 46aaf91..0383eee 100644 --- a/src/components/hero/hero.module.css +++ b/src/components/hero/hero.module.css @@ -1,16 +1,23 @@ @property --thumb-image { - syntax: ''; + syntax: ""; inherits: true; } .container { + isolation: isolate; display: block grid; grid-auto-flow: column; grid-auto-columns: 100%; + container-type: inline-size; + overflow: hidden visible; scroll-snap-type: inline mandatory; - scroll-behavior: smooth; + overscroll-behavior-inline: contain; + + @media (prefers-reduced-motion: no-preference) { + scroll-behavior: smooth; + } scroll-marker-group: after; @@ -19,8 +26,10 @@ grid-auto-flow: column; grid-auto-columns: 5em; - gap: 1em; - place-content: end center; + gap: 1rem; + justify-content: start; + + padding-inline: 2rem; inline-size: 100%; block-size: 8.333333em; @@ -30,6 +39,8 @@ } .page { + --__i: var(--sibling-index); + --__c: var(--sibling-count); scroll-snap-align: center; position: relative; display: grid; @@ -41,43 +52,49 @@ "thumbnail summary summary"; align-content: end; align-items: center; - gap: 1em; - padding: 2em; + gap: 1rem; + padding: 2rem; block-size: 80vh; overflow: clip; + container-type: scroll-state; + + animation: + animate-in linear forwards, + animate-out linear forwards; + animation-timeline: view(inline); + animation-range: entry, exit; color: var(--gray-0); &::after { - content: ''; + content: ""; position: absolute; inset: 0; display: block; - background: linear-gradient(185deg, transparent 20%, var(--surface-2) 90%), linear-gradient(transparent 50%, #0007 75%); + background: linear-gradient(182.5deg, transparent 20%, var(--surface-2) 90%), + linear-gradient(transparent 50%, #0007 75%); } &::scroll-marker { display: block; - content: ' '; + content: " "; - inline-size: 15em; + inline-size: 5rem; aspect-ratio: 3 / 5; background: var(--thumb-image) center / cover no-repeat; background-color: cornflowerblue; - border-radius: var(--radius-3); + border-radius: var(--radius-2); - transform: scale(.333333); - transition: .3s; + transform: scale(1); + transform-origin: top left; + transition: 0.3s; } &::scroll-marker:target-current { /* outline: 1px solid white; */ - position: absolute; - top: -29em; - left: 2em; - - transform: scale(1); + transform: translate(calc(-0cqi - (6rem * (var(--__i) - 1))), -29rem) + scale(3); } } @@ -107,6 +124,7 @@ object-fit: cover; object-position: center; z-index: 1; + opacity: 0 !important; } .background { @@ -128,4 +146,27 @@ grid-area: summary; text-wrap: balance; z-index: 1; -} \ No newline at end of file +} + +@keyframes animate-in { + 0% { + opacity: 0; + } + /* 80% { + opacity: 0; + } */ + 100% { + opacity: 1; + } +} +@keyframes animate-out { + 0% { + opacity: 1; + } + 20% { + opacity: 0; + } + 100% { + opacity: 0; + } +} diff --git a/src/components/hero/hero.tsx b/src/components/hero/hero.tsx index e4119ef..e3bc516 100644 --- a/src/components/hero/hero.tsx +++ b/src/components/hero/hero.tsx @@ -10,12 +10,10 @@ type HeroProps = { export function Hero(props: HeroProps) { const entry = createMemo(() => props.entries.at(0)!); const slug = createMemo(() => createSlug(entry())); - + return ( -
- { - entry => - } +
+ {(entry) => }
); } @@ -24,15 +22,19 @@ const Page: Component<{ entry: Entry }> = (props) => { const slug = createMemo(() => createSlug(props.entry)); createEffect(() => { - console.log(props.entry); + // console.log(props.entry); }); return ( -
- +

{props.entry.title}

- - Continue + + + Continue + @@ -52,7 +54,7 @@ const Page: Component<{ entry: Entry }> = (props) => { -

{props.entry.synopsis}

+

{props.entry.overview}

); }; diff --git a/src/components/list/list.module.css b/src/components/list/list.module.css index 6759ff8..feaa21a 100644 --- a/src/components/list/list.module.css +++ b/src/components/list/list.module.css @@ -1,14 +1,25 @@ .container { display: grid; - grid-auto-flow: row; + grid: auto auto / auto auto; + grid-template-areas: + "heading metadata" + "list list"; + justify-content: space-between; inline-size: 100%; } .heading { + grid-area: heading; font-size: 2em; } +.metadata { + grid-area: metadata; + opacity: 0.6; +} + .list { + grid-area: list; list-style-type: none; container-type: inline-size; @@ -36,12 +47,10 @@ } &::before { - order: 0; inline-size: 15cqi; } &::after { - order: 11; inline-size: 100cqi; } @@ -52,8 +61,7 @@ position: relative; isolation: isolate; - order: calc(var(--sibling-count) - var(--sibling-index)); - z-index: var(--sibling-index); + z-index: calc(var(--sibling-count) - var(--sibling-index)); &:has(> :hover, > :focus-within) { z-index: calc(var(--sibling-count) + 1); diff --git a/src/components/list/list.tsx b/src/components/list/list.tsx index 4a52acf..fb70fa3 100644 --- a/src/components/list/list.tsx +++ b/src/components/list/list.tsx @@ -15,6 +15,8 @@ export function List(props: ListProps) { {props.label} + {props.items.length} result(s) +
    {(item) =>
  • {props.children(item)}
  • } diff --git a/src/features/content/apis/jellyfin.ts b/src/features/content/apis/jellyfin.ts index 7846770..d964916 100644 --- a/src/features/content/apis/jellyfin.ts +++ b/src/features/content/apis/jellyfin.ts @@ -5,14 +5,27 @@ import createClient from "openapi-fetch"; import { query } from "@solidjs/router"; import { Entry } from "../types"; -type ItemImageType = "Primary" | "Art" | "Backdrop" | "Banner" | "Logo" | "Thumb" | "Disc" | "Box" | "Screenshot" | "Menu" | "Chapter" | "BoxRear" | "Profile"; +type ItemImageType = + | "Primary" + | "Art" + | "Backdrop" + | "Banner" + | "Logo" + | "Thumb" + | "Disc" + | "Box" + | "Screenshot" + | "Menu" + | "Chapter" + | "BoxRear" + | "Profile"; const baseUrl = process.env.JELLYFIN_BASE_URL; const client = createClient({ baseUrl, headers: { - 'Authorization': `MediaBrowser DeviceId="Streamarr", Token="${process.env.JELLYFIN_API_KEY}"`, - 'Content-Type': 'application/json; profile="CamelCase"', + Authorization: `MediaBrowser DeviceId="Streamarr", Token="${process.env.JELLYFIN_API_KEY}"`, + "Content-Type": 'application/json; profile="CamelCase"', }, }); @@ -21,21 +34,24 @@ export const TEST = query(async () => { const itemId = "919dfa97-e4da-d275-8a92-5d056e590a28"; const seriesId = "5230ddbcd-9400-733d-c07e-5b8cb7a4f49"; - const { data: seriesData } = await client.GET("/UserItems/{itemId}/UserData", { - params: { - path: { itemId: seriesId }, - query: { userId } - } - }); + const { data: seriesData } = await client.GET( + "/UserItems/{itemId}/UserData", + { + params: { + path: { itemId: seriesId }, + query: { userId }, + }, + }, + ); const { data: epData } = await client.GET("/UserItems/{itemId}/UserData", { params: { path: { itemId }, - query: { userId } - } + query: { userId }, + }, }); - console.log(seriesData, epData) + console.log(seriesData, epData); }, "jellyfin.TEST"); export const getCurrentUser = query(async () => { @@ -43,8 +59,6 @@ export const getCurrentUser = query(async () => { params: {}, }); - console.log(data, error, response) - return data; }, "jellyfin.getCurrentUser"); @@ -56,138 +70,170 @@ export const listUsers = query(async () => { return data ?? []; }, "jellyfin.listUsers"); -export const listItems = query(async (userId: string): Promise => { - const { data, error } = await client.GET("/Items", { - params: { - query: { - userId, - hasTmdbInfo: true, - recursive: true, - includeItemTypes: ["Movie", "Series"], - fields: [ - "ProviderIds", - "Genres", - "DateLastMediaAdded", - "DateCreated", - "MediaSources", - ], +export const listItems = query( + async (userId: string): Promise => { + const { data, error } = await client.GET("/Items", { + params: { + query: { + userId, + hasTmdbInfo: true, + recursive: true, + includeItemTypes: ["Movie", "Series"], + fields: [ + "ProviderIds", + "Genres", + "DateLastMediaAdded", + "DateCreated", + "MediaSources", + ], + }, }, - }, - }); + }); + + if (data === undefined) { + return undefined; + } + + return ( + data.Items?.map((item) => ({ + // id: item.Id!, + id: item.ProviderIds!["Tmdb"]!, + title: item.Name!, + thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'), + })) ?? [] + ); + }, + "jellyfin.listItems", +); + +export const getRandomItem = query( + async (userId: string): Promise => + getRandomItems(userId, 1).then((items) => items?.at(0)), + "jellyfin.listRandomItem", +); + +export const getRandomItems = query( + async (userId: string, limit: number = 10): Promise => { + const { data, error } = await client.GET("/Items", { + params: { + query: { + userId, + hasTmdbInfo: true, + recursive: true, + limit, + sortBy: ["Random"], + includeItemTypes: ["Movie", "Series"], + imageTypes: ["Primary", "Backdrop", "Thumb"], + fields: [ + "ProviderIds", + "Genres", + "DateLastMediaAdded", + "DateCreated", + "MediaSources", + ], + }, + }, + }); + + return ( + data?.Items?.map((item) => ({ + // id: item.Id!, + id: item.ProviderIds!["Tmdb"]!, + title: item.Name!, + thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'), + image: new URL(`/Items/${item.Id!}/Images/Backdrop`, baseUrl), //await getItemImage(data.Id!, 'Primary'), + })) ?? [] + ); + }, + "jellyfin.listRandomItems", +); + +export const getItem = query( + async (userId: string, itemId: string): Promise => { + const { data, error } = await client.GET("/Items/{itemId}", { + params: { + path: { + itemId, + }, + query: { + userId, + hasTmdbInfo: true, + recursive: true, + includeItemTypes: ["Movie", "Series"], + fields: [ + "ProviderIds", + "Genres", + "DateLastMediaAdded", + "DateCreated", + "MediaSources", + ], + }, + }, + }); + + if (data === undefined) { + return undefined; + } + + return { + // id: data.Id!, + id: data.ProviderIds!["Tmdb"]!, + title: data.Name!, + overview: data.Overview!, + thumbnail: new URL(`/Items/${itemId}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'), + image: new URL(`/Items/${itemId}/Images/Backdrop`, baseUrl), + // ...data, + }; + }, + "jellyfin.getItem", +); + +export const getItemImage = query( + async ( + itemId: string, + imageType: ItemImageType, + ): Promise => { + const { data, error } = await client.GET( + "/Items/{itemId}/Images/{imageType}", + { + parseAs: "blob", + params: { + path: { + itemId, + imageType, + }, + query: {}, + }, + }, + ); + + return data; + }, + "jellyfin.getItemImage", +); + +export const getItemPlaybackInfo = query( + async (userId: string, itemId: string): Promise => { + const { data, error, response } = await client.GET( + "/Items/{itemId}/PlaybackInfo", + { + parseAs: "text", + + params: { + path: { + itemId, + }, + query: { + userId, + }, + }, + }, + ); - if (data === undefined) { return undefined; - } - - return data.Items?.map(item => ({ - id: item.Id!, - title: item.Name!, - thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'), - })) ?? []; -}, "jellyfin.listItems"); - -export const getRandomItem = query(async (userId: string): Promise => getRandomItems(userId, 1).then(items => items?.at(0)), "jellyfin.listRandomItem"); - -export const getRandomItems = query(async (userId: string, limit: number = 10): Promise => { - const { data, error } = await client.GET("/Items", { - params: { - query: { - userId, - hasTmdbInfo: true, - recursive: true, - limit, - sortBy: ["Random"], - includeItemTypes: ["Movie", "Series"], - imageTypes: ["Primary", "Backdrop", "Thumb"], - fields: [ - "ProviderIds", - "Genres", - "DateLastMediaAdded", - "DateCreated", - "MediaSources", - ], - }, - }, - }); - - return data?.Items?.map(item => ({ - id: item.Id!, - title: item.Name!, - thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'), - image: new URL(`/Items/${item.Id!}/Images/Backdrop`, baseUrl), //await getItemImage(data.Id!, 'Primary'), - })) ?? []; -}, "jellyfin.listRandomItems"); - -export const getItem = query(async (userId: string, itemId: string): Promise => { - console.log('baseUrl', baseUrl); - - const { data, error } = await client.GET("/Items/{itemId}", { - params: { - path: { - itemId, - }, - query: { - userId, - hasTmdbInfo: true, - recursive: true, - includeItemTypes: ["Movie", "Series"], - fields: [ - "ProviderIds", - "Genres", - "DateLastMediaAdded", - "DateCreated", - "MediaSources", - ], - }, - }, - }); - - if (data === undefined) { - return undefined; - } - - return { - id: data.Id!, - title: data.Name!, - synopsis: data.Overview!, - thumbnail: new URL(`/Items/${itemId}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'), - image: new URL(`/Items/${itemId}/Images/Backdrop`, baseUrl), - // ...data, - }; -}, "jellyfin.getItem"); - -export const getItemImage = query(async (itemId: string, imageType: ItemImageType): Promise => { - const { data, error } = await client.GET("/Items/{itemId}/Images/{imageType}", { - parseAs: 'blob', - params: { - path: { - itemId, - imageType - }, - query: { - }, - }, - }); - - return data; -}, "jellyfin.getItemImage"); - -export const getItemPlaybackInfo = query(async (userId: string, itemId: string): Promise => { - const { data, error, response } = await client.GET("/Items/{itemId}/PlaybackInfo", { - parseAs: 'text', - - params: { - path: { - itemId, - }, - query: { - userId, - }, - }, - }); - - return undefined; -}, "jellyfin.getItemPlaybackInfo"); + }, + "jellyfin.getItemPlaybackInfo", +); export const queryItems = query(async () => { const { data, error } = await client.GET("/Items", { @@ -204,35 +250,57 @@ export const queryItems = query(async () => { }); console.log(data); +}, "jellyfin.queryItems"); -}, 'jellyfin.queryItems'); - -export const getContinueWatching = query(async (userId: string): Promise => { - const { data, error } = await client.GET("/UserItems/Resume", { - params: { - query: { - userId, - mediaTypes: ["Video"], - // fields: ["ProviderIds", "Genres"], - // includeItemTypes: ["Series", "Movie"] +export const getContinueWatching = query( + async (userId: string): Promise => { + const { data, error } = await client.GET("/UserItems/Resume", { + params: { + query: { + userId, + mediaTypes: ["Video"], + // fields: ["ProviderIds", "Genres"], + // includeItemTypes: ["Series", "Movie"] + }, }, - }, - }); + }); - if (Array.isArray(data?.Items) !== true) { - return []; + if (Array.isArray(data?.Items) !== true) { + return []; + } + + const uniqueIds = new Set( + data.Items.map((item) => + item.Type === "Episode" ? item.SeriesId! : item.Id!, + ), + ); + const results = await Promise.allSettled( + uniqueIds + .values() + .map((id) => getItem(userId, id)) + .toArray(), + ); + + assertNoErrors(results); + + return results + .filter( + (result): result is PromiseFulfilledResult => + result.value !== undefined, + ) + .map(({ value }) => value); + }, + "jellyfin.continueWatching", +); + +function assertNoErrors( + results: PromiseSettledResult[], +): asserts results is PromiseFulfilledResult[] { + if (results.some(({ status }) => status !== "fulfilled")) { + throw new Error("one or more promices failed", { + cause: results + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => r.reason), + }); } - - const uniqueIds = new Set(data.Items.map(item => item.Type === 'Episode' ? item.SeriesId! : item.Id!)); - const results = await Promise.allSettled(uniqueIds.values().map(id => getItem(userId, id)).toArray()); - - assertNoErrors(results); - - return results.filter((result): result is PromiseFulfilledResult => result.value !== undefined).map(({ value }) => value); -}, "jellyfin.continueWatching"); - -function assertNoErrors(results: PromiseSettledResult[]): asserts results is PromiseFulfilledResult[] { - if (results.some(({ status }) => status !== 'fulfilled')) { - throw new Error('one or more promices failed', { cause: results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason) }); - } -} \ No newline at end of file +} diff --git a/src/features/content/apis/tmdb.not.generated.ts b/src/features/content/apis/tmdb.not.generated.ts new file mode 100644 index 0000000..08f993a --- /dev/null +++ b/src/features/content/apis/tmdb.not.generated.ts @@ -0,0 +1,287 @@ +export interface paths { + "/4/account/{account_object_id}/movie/recommendations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GetMovieRecommendations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/3/movie/{movie_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GetMovieById"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/3/series/{series_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GetSeriesById"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/3/discover/movie": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GetDiscovery_Movie"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/3/discover/tv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GetDiscovery_Serie"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + paginatedQueryResult: { + page: number; + results: components["schemas"]["entry"][]; + total_pages: number; + total_results: number; + }; + entry: { + backdrop_path: string; + id: number; + title: string; + overview: string; + poster_path: string; + media_type: string; + adult: boolean; + original_language: string; + gerne_ids: number[]; + popularity: number; + release_date: string; + video: boolean; + vote_average: number; + vote_count: number; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + GetMovieRecommendations: { + parameters: { + query?: { + page?: number; + language?: string; + }; + header?: never; + path: { + account_object_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["paginatedQueryResult"]; + }; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GetDiscovery_Movie: { + parameters: { + query?: { + page?: number; + language?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["paginatedQueryResult"]; + }; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GetDiscovery_Serie: { + parameters: { + query?: { + page?: number; + language?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["paginatedQueryResult"]; + }; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GetMovieById: { + parameters: { + query?: {}; + header?: never; + path: { + movie_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["entry"]; + }; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GetSeriesById: { + parameters: { + query?: {}; + header?: never; + path: { + series_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["entry"]; + }; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} diff --git a/src/features/content/apis/tmdb.ts b/src/features/content/apis/tmdb.ts new file mode 100644 index 0000000..42da72a --- /dev/null +++ b/src/features/content/apis/tmdb.ts @@ -0,0 +1,91 @@ +"use server"; + +import createClient from "openapi-fetch"; +import { query } from "@solidjs/router"; +import { Entry } from "../types"; +import { paths } from "./tmdb.not.generated"; + +const baseUrl = process.env.TMDB_BASE_URL; +const client = createClient({ + baseUrl, + headers: { + Authorization: `Bearer ${process.env.TMDB_TOKEN}`, + "Content-Type": "application/json;", + }, +}); + +export const getEntry = query( + async (id: string): Promise => { + const { data } = await client.GET("/3/movie/{movie_id}", { + params: { + path: { + movie_id: id, + }, + }, + }); + + if (data === undefined) { + return undefined; + } + + return { + id: String(data.id), + title: data.title, + overview: data.overview, + thumbnail: `http://image.tmdb.org/t/p/w342${data.poster_path}`, + image: `http://image.tmdb.org/t/p/original${data.backdrop_path}`, + }; + }, + "tmdb.getEntry", +); + +export const getRecommendations = query(async (): Promise => { + const account_object_id = "6668b76e419b28ec1a1c5aab"; + + const { data } = await client.GET( + "/4/account/{account_object_id}/movie/recommendations", + { + params: { + path: { account_object_id }, + }, + }, + ); + + if (data === null) { + return []; + } + + return data?.results.map( + ({ id, title, overview, poster_path, backdrop_path }) => ({ + id: String(id), + title, + overview, + thumbnail: `http://image.tmdb.org/t/p/w342${poster_path}`, + image: `http://image.tmdb.org/t/p/original${backdrop_path}`, + }), + ); +}, "tmdb.getRecommendations"); + +export const getDiscovery = query(async (): Promise => { + const [{ data: movies }, { data: series }] = await Promise.all([ + client.GET("/3/discover/movie"), + client.GET("/3/discover/movie"), + ]); + + if (movies === undefined || series === undefined) { + return []; + } + + // console.log({ movies: movies.results.length, series: series.results.length }); + + return movies?.results + .slice(0, 9) + .concat(series?.results.slice(0, 9)) + .map(({ id, title, overview, poster_path, backdrop_path }) => ({ + id: String(id), + title, + overview, + thumbnail: `http://image.tmdb.org/t/p/w342${poster_path}`, + image: `http://image.tmdb.org/t/p/original${backdrop_path}`, + })); +}, "tmdb.getDiscovery"); diff --git a/src/features/content/service.ts b/src/features/content/service.ts index 0e30484..a7df6e2 100644 --- a/src/features/content/service.ts +++ b/src/features/content/service.ts @@ -4,6 +4,11 @@ import type { Category, Entry } from "./types"; import { query } from "@solidjs/router"; import { entries } from "./data"; import { getContinueWatching, getItem, getRandomItems } from "./apis/jellyfin"; +import { + getDiscovery, + getRecommendations, + getEntry as getTmdbEntry, +} from "./apis/tmdb"; const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763"; @@ -13,65 +18,19 @@ export const getHighlights = () => getContinueWatching(jellyfinUserId); export const listCategories = query(async (): Promise => { return [ // { label: "Continue", entries: await getContinueWatching(jellyfinUserId) }, + { + label: "Recommendations (For you?)", + entries: await getRecommendations(), + }, + { label: "Discover", entries: await getDiscovery() }, { label: "Random", entries: await getRandomItems(jellyfinUserId) }, - { - label: "Popular", - entries: [ - entries.get("1")!, - entries.get("2")!, - entries.get("3")!, - entries.get("4")!, - entries.get("1")!, - entries.get("2")!, - entries.get("3")!, - entries.get("4")!, - ], - }, - { - label: "Drama", - entries: [ - entries.get("5")!, - entries.get("6")!, - entries.get("7")!, - entries.get("8")!, - entries.get("1")!, - entries.get("2")!, - entries.get("3")!, - entries.get("4")!, - ], - }, - { - label: "Now streaming", - entries: [ - entries.get("1")!, - entries.get("2")!, - entries.get("3")!, - entries.get("4")!, - entries.get("1")!, - entries.get("2")!, - entries.get("3")!, - entries.get("4")!, - ], - }, - { - label: "Sci-Fi & Fantasy", - entries: [ - entries.get("9")!, - entries.get("11")!, - entries.get("12")!, - entries.get("13")!, - entries.get("1")!, - entries.get("2")!, - entries.get("3")!, - entries.get("4")!, - ], - }, ]; }, "series.categories.list"); export const getEntry = query( async (id: Entry["id"]): Promise => { - return getItem(jellyfinUserId, id); + return getTmdbEntry(id); + // return getItem(jellyfinUserId, id); }, "series.get", ); diff --git a/src/features/content/types.ts b/src/features/content/types.ts index a013c47..baac8b8 100644 --- a/src/features/content/types.ts +++ b/src/features/content/types.ts @@ -1,4 +1,3 @@ - export interface Category { label: string; entries: Entry[]; @@ -7,7 +6,7 @@ export interface Category { export interface Entry { id: string; title: string; - synopsis?: string; + overview?: string; releaseDate?: string; sources?: Entry.Source[]; thumbnail?: URL | string; @@ -30,4 +29,3 @@ export namespace Entry { } } } - diff --git a/src/features/overview/list-item.module.css b/src/features/overview/list-item.module.css index 3b2d936..5af6871 100644 --- a/src/features/overview/list-item.module.css +++ b/src/features/overview/list-item.module.css @@ -5,7 +5,7 @@ grid: 100% / 100%; place-items: start center; position: relative; - inline-size: clamp(15em, 20vw, 30em); + inline-size: clamp(15em, 15cqi, 25em); aspect-ratio: var(--ratio-portrait); transform: translateY(calc(-2 * var(--padding))); z-index: 1; @@ -21,20 +21,28 @@ z-index: 1; box-shadow: var(--shadow-2); - background: - radial-gradient(circle at 25% 30%, #7772, #7774 1em, transparent 1em), - radial-gradient(circle at 85% 15%, #7772, #7774 1em, transparent 1em), - linear-gradient(165deg, transparent 60%, #555 60%, #333), - radial-gradient( - ellipse 5em 2.25em at 0.5em calc(50% - 1em), #333 100%, transparent 100% - ), - radial-gradient( - ellipse 5em 2.25em at calc(100% - 0.5em) calc(50% + 1em), #555 100%, transparent 100% - ), - linear-gradient(to bottom, #333 50%, #555 50%); + background: radial-gradient( + circle at 25% 30%, + #7772, + #7774 1em, + transparent 1em + ), + radial-gradient(circle at 85% 15%, #7772, #7774 1em, transparent 1em), + linear-gradient(165deg, transparent 60%, #555 60%, #333), + radial-gradient( + ellipse 5em 2.25em at 0.5em calc(50% - 1em), + #333 100%, + transparent 100% + ), + radial-gradient( + ellipse 5em 2.25em at calc(100% - 0.5em) calc(50% + 1em), + #555 100%, + transparent 100% + ), + linear-gradient(to bottom, #333 50%, #555 50%); transform-origin: 50% 0; - transform: scale(1.1) translateY(calc(-4 * var(--padding))); + transform: scale(1.15) translateY(calc(-4 * var(--padding))); user-select: none; } diff --git a/src/index.css b/src/index.css index 02488f7..da000c0 100644 --- a/src/index.css +++ b/src/index.css @@ -4,8 +4,8 @@ @import "open-props/normalize" layer(reset); @import "open-props/durations" layer(base); -@import 'open-props/theme.light.switch.min.css' layer(tokens); -@import 'open-props/theme.dark.switch.min.css' layer(tokens); +@import "open-props/theme.light.switch.min.css" layer(tokens); +@import "open-props/theme.dark.switch.min.css" layer(tokens); @layer base { html { @@ -44,13 +44,13 @@ @layer reset { @property --sibling-index { - syntax: ''; + syntax: ""; inherits: false; initial-value: 1; } @property --sibling-count { - syntax: ''; + syntax: ""; inherits: false; initial-value: 0; } @@ -94,26 +94,157 @@ :nth-child(10) { --sibling-index: 10; } - :nth-child(11) { --sibling-index: 11; } - :nth-child(12) { --sibling-index: 12; } - :nth-child(13) { --sibling-index: 13; } - :nth-child(14) { --sibling-index: 14; } - :nth-child(15) { --sibling-index: 15; } + :nth-child(16) { + --sibling-index: 16; + } + :nth-child(17) { + --sibling-index: 17; + } + :nth-child(18) { + --sibling-index: 18; + } + :nth-child(19) { + --sibling-index: 19; + } + + :nth-child(20) { + --sibling-index: 20; + } + :nth-child(21) { + --sibling-index: 21; + } + :nth-child(22) { + --sibling-index: 22; + } + :nth-child(23) { + --sibling-index: 23; + } + :nth-child(24) { + --sibling-index: 24; + } + :nth-child(25) { + --sibling-index: 25; + } + :nth-child(26) { + --sibling-index: 26; + } + :nth-child(27) { + --sibling-index: 27; + } + :nth-child(28) { + --sibling-index: 28; + } + :nth-child(29) { + --sibling-index: 29; + } + + :nth-child(30) { + --sibling-index: 30; + } + :nth-child(31) { + --sibling-index: 31; + } + :nth-child(32) { + --sibling-index: 32; + } + :nth-child(33) { + --sibling-index: 33; + } + :nth-child(34) { + --sibling-index: 34; + } + :nth-child(35) { + --sibling-index: 35; + } + :nth-child(36) { + --sibling-index: 36; + } + :nth-child(37) { + --sibling-index: 37; + } + :nth-child(38) { + --sibling-index: 38; + } + :nth-child(39) { + --sibling-index: 39; + } + + :nth-child(40) { + --sibling-index: 40; + } + :nth-child(41) { + --sibling-index: 41; + } + :nth-child(42) { + --sibling-index: 42; + } + :nth-child(43) { + --sibling-index: 43; + } + :nth-child(44) { + --sibling-index: 44; + } + :nth-child(45) { + --sibling-index: 45; + } + :nth-child(46) { + --sibling-index: 46; + } + :nth-child(47) { + --sibling-index: 47; + } + :nth-child(48) { + --sibling-index: 48; + } + :nth-child(49) { + --sibling-index: 49; + } + + :nth-child(50) { + --sibling-index: 50; + } + :nth-child(51) { + --sibling-index: 51; + } + :nth-child(52) { + --sibling-index: 52; + } + :nth-child(53) { + --sibling-index: 53; + } + :nth-child(54) { + --sibling-index: 54; + } + :nth-child(55) { + --sibling-index: 55; + } + :nth-child(56) { + --sibling-index: 56; + } + :nth-child(57) { + --sibling-index: 57; + } + :nth-child(58) { + --sibling-index: 58; + } + :nth-child(59) { + --sibling-index: 59; + } :has(> :last-child:nth-child(1)) > * { --sibling-count: 1; @@ -154,24 +285,186 @@ :has(> :last-child:nth-child(10)) > * { --sibling-count: 10; } - :has(> :last-child:nth-child(11)) > * { --sibling-count: 11; } - :has(> :last-child:nth-child(12)) > * { --sibling-count: 12; } - :has(> :last-child:nth-child(13)) > * { --sibling-count: 13; } - :has(> :last-child:nth-child(14)) > * { --sibling-count: 14; } - :has(> :last-child:nth-child(15)) > * { --sibling-count: 15; } -} \ No newline at end of file + :has(> :last-child:nth-child(16)) > * { + --sibling-count: 16; + } + :has(> :last-child:nth-child(17)) > * { + --sibling-count: 17; + } + :has(> :last-child:nth-child(18)) > * { + --sibling-count: 18; + } + :has(> :last-child:nth-child(19)) > * { + --sibling-count: 19; + } + + :has(> :last-child:nth-child(20)) > * { + --sibling-count: 20; + } + :has(> :last-child:nth-child(21)) > * { + --sibling-count: 21; + } + :has(> :last-child:nth-child(22)) > * { + --sibling-count: 22; + } + :has(> :last-child:nth-child(23)) > * { + --sibling-count: 23; + } + :has(> :last-child:nth-child(24)) > * { + --sibling-count: 24; + } + :has(> :last-child:nth-child(25)) > * { + --sibling-count: 25; + } + :has(> :last-child:nth-child(26)) > * { + --sibling-count: 26; + } + :has(> :last-child:nth-child(27)) > * { + --sibling-count: 27; + } + :has(> :last-child:nth-child(28)) > * { + --sibling-count: 28; + } + :has(> :last-child:nth-child(29)) > * { + --sibling-count: 29; + } + + :has(> :last-child:nth-child(30)) > * { + --sibling-count: 30; + } + :has(> :last-child:nth-child(31)) > * { + --sibling-count: 31; + } + :has(> :last-child:nth-child(32)) > * { + --sibling-count: 32; + } + :has(> :last-child:nth-child(33)) > * { + --sibling-count: 33; + } + :has(> :last-child:nth-child(34)) > * { + --sibling-count: 34; + } + :has(> :last-child:nth-child(35)) > * { + --sibling-count: 35; + } + :has(> :last-child:nth-child(36)) > * { + --sibling-count: 36; + } + :has(> :last-child:nth-child(37)) > * { + --sibling-count: 37; + } + :has(> :last-child:nth-child(38)) > * { + --sibling-count: 38; + } + :has(> :last-child:nth-child(39)) > * { + --sibling-count: 39; + } + + :has(> :last-child:nth-child(40)) > * { + --sibling-count: 40; + } + :has(> :last-child:nth-child(41)) > * { + --sibling-count: 41; + } + :has(> :last-child:nth-child(42)) > * { + --sibling-count: 42; + } + :has(> :last-child:nth-child(43)) > * { + --sibling-count: 43; + } + :has(> :last-child:nth-child(44)) > * { + --sibling-count: 44; + } + :has(> :last-child:nth-child(45)) > * { + --sibling-count: 45; + } + :has(> :last-child:nth-child(46)) > * { + --sibling-count: 46; + } + :has(> :last-child:nth-child(47)) > * { + --sibling-count: 47; + } + :has(> :last-child:nth-child(48)) > * { + --sibling-count: 48; + } + :has(> :last-child:nth-child(49)) > * { + --sibling-count: 49; + } + + :has(> :last-child:nth-child(50)) > * { + --sibling-count: 50; + } + :has(> :last-child:nth-child(51)) > * { + --sibling-count: 51; + } + :has(> :last-child:nth-child(52)) > * { + --sibling-count: 52; + } + :has(> :last-child:nth-child(53)) > * { + --sibling-count: 53; + } + :has(> :last-child:nth-child(54)) > * { + --sibling-count: 54; + } + :has(> :last-child:nth-child(55)) > * { + --sibling-count: 55; + } + :has(> :last-child:nth-child(56)) > * { + --sibling-count: 56; + } + :has(> :last-child:nth-child(57)) > * { + --sibling-count: 57; + } + :has(> :last-child:nth-child(58)) > * { + --sibling-count: 58; + } + :has(> :last-child:nth-child(59)) > * { + --sibling-count: 59; + } + + :has(> :last-child:nth-child(60)) > * { + --sibling-count: 60; + } + :has(> :last-child:nth-child(61)) > * { + --sibling-count: 61; + } + :has(> :last-child:nth-child(62)) > * { + --sibling-count: 62; + } + :has(> :last-child:nth-child(63)) > * { + --sibling-count: 63; + } + :has(> :last-child:nth-child(64)) > * { + --sibling-count: 64; + } + :has(> :last-child:nth-child(65)) > * { + --sibling-count: 65; + } + :has(> :last-child:nth-child(66)) > * { + --sibling-count: 66; + } + :has(> :last-child:nth-child(67)) > * { + --sibling-count: 67; + } + :has(> :last-child:nth-child(68)) > * { + --sibling-count: 68; + } + :has(> :last-child:nth-child(69)) > * { + --sibling-count: 69; + } +} diff --git a/src/routes/(shell)/index.tsx b/src/routes/(shell)/index.tsx index 10f62bf..37469cb 100644 --- a/src/routes/(shell)/index.tsx +++ b/src/routes/(shell)/index.tsx @@ -1,10 +1,7 @@ import { Title } from "@solidjs/meta"; import { createAsync } from "@solidjs/router"; import { Overview } from "~/features/overview"; -import { - getHighlights, - listCategories, -} from "~/features/content"; +import { getHighlights, listCategories } from "~/features/content"; import { Show } from "solid-js"; export const route = { @@ -17,7 +14,7 @@ export const route = { export default function Home() { const highlights = createAsync(() => getHighlights()); const categories = createAsync(() => listCategories()); - + return ( <> Home diff --git a/src/routes/(shell)/watch/[slug].tsx b/src/routes/(shell)/watch/[slug].tsx index 3fcdd19..1d9be6e 100644 --- a/src/routes/(shell)/watch/[slug].tsx +++ b/src/routes/(shell)/watch/[slug].tsx @@ -22,7 +22,8 @@ const healUrl = query(async (slug: string) => { return; } - throw redirect(`/watch/${actualSlug}`); + // Not entirely sure a permanent redirect is what we want in this case + throw redirect(`/watch/${actualSlug}`, { status: 308 }); }, "watch.heal"); interface ItemParams extends Params { diff --git a/src/routes/api/stream/video.ts b/src/routes/api/stream/video.ts index 31dc21f..9f83d7a 100644 --- a/src/routes/api/stream/video.ts +++ b/src/routes/api/stream/video.ts @@ -6,22 +6,24 @@ const CHUNK_SIZE = 1 * 1e6; // 1MB export const GET = async ({ request, ...event }: APIEvent) => { "use server"; - const range = request.headers.get('range'); + const range = request.headers.get("range"); if (range === null) { - return new Response('Requires Range header', { status: 400 }) + return new Response("Requires Range header", { status: 400 }); } try { - const video = Bun.file(import.meta.dirname + '/SampleVideo_1280x720_10mb.mp4'); + const video = Bun.file( + import.meta.dirname + "/SampleVideo_1280x720_10mb.mp4", + ); if ((await video.exists()) !== true) { - return new Response('File not found', { status: 404 }); + return new Response("File not found", { status: 404 }); } const videoSize = video.size; - const start = Number.parseInt(range.replace(/\D/g, '')); + const start = Number.parseInt(range.replace(/\D/g, "")); const end = Math.min(start + CHUNK_SIZE, videoSize - 1); const contentLength = end - start + 1; @@ -40,8 +42,7 @@ export const GET = async ({ request, ...event }: APIEvent) => { // 'Content-type': 'video/mp4', // }, // }); - } - catch (e) { + } catch (e) { console.error(e); throw e;