feat: add initial recommendation data

This commit is contained in:
max_richter 2023-09-08 13:33:29 +02:00
parent 517b1ba23d
commit cc112b7554
19 changed files with 289 additions and 170 deletions

View File

@ -20,48 +20,18 @@ export type Props = {
export const MainLayout = (
{ children, url, title, context, searchResults }: Props,
) => {
const hasSearch = url.search.includes("q=");
const _url = typeof url === "string" ? new URL(url) : url;
const hasSearch = _url.search.includes("q=");
return (
<div
class="md:grid mx-auto"
style={{ gridTemplateColumns: "200px 1fr", maxWidth: "1024px" }}
>
<Head>
<style>{CSS}</style>
<style>{KATEX_CSS}</style>
{title &&
<title>{title}</title>}
</Head>
<aside class="p-4 hidden md:block">
<nav class="min-h-fit rounded-3xl p-3 grid gap-3 fixed t-0">
{Object.values(resources).map((m) => {
return (
<a
href={m.link}
class={`flex items-center gap-2 ${
m.link === url.pathname ? "bg-white text-black" : "text-white"
} p-3 text-xl w-full rounded-2xl`}
>
{<Emoji class="w-6 h-6" name={m.emoji} />} {m.name}
</a>
);
})}
</nav>
</aside>
<main
class="py-5"
style={{ fontFamily: "Work Sans" }}
>
{hasSearch && (
<Search
q={url.searchParams.get("q")}
{...context}
results={searchResults}
/>
)}
{!hasSearch && children}
</main>
</div>
);
if (hasSearch) {
return (
<Search
q={_url.searchParams.get("q")}
{...context}
results={searchResults}
/>
);
}
return <>{children}</>;
};

View File

@ -1,10 +1,11 @@
{
"lock": false,
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"start": "deno run -A --watch=static/,routes/ dev.ts",
"update": "deno run -A -r https://fresh.deno.dev/update .",
"build": "deno run -A dev.ts build",
"preview": "deno run -A main.ts"
"preview": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
"lint": {
"rules": {
@ -27,7 +28,7 @@
"@islands": "./islands",
"@islands/": "./islands/",
"zod": "https://deno.land/x/zod@v3.21.4/mod.ts",
"$fresh/": "https://deno.land/x/fresh@1.4.2/",
"$fresh/": "https://deno.land/x/fresh@1.4.3/",
"preact": "https://esm.sh/preact@10.15.1",
"preact/": "https://esm.sh/preact@10.15.1/",
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.1",
@ -46,4 +47,4 @@
"_fresh"
]
}
}
}

3
dev.ts
View File

@ -1,5 +1,6 @@
#!/usr/bin/env -S deno run -A --watch=static/,routes/
import dev from "$fresh/dev.ts";
import config from "./fresh.config.ts";
await dev(import.meta.url, "./main.ts");
await dev(import.meta.url, "./main.ts", config);

6
fresh.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { defineConfig } from "$fresh/server.ts";
import twindPlugin from "$fresh/plugins/twind.ts";
import twindConfig from "./twind.config.ts";
export default defineConfig({
plugins: [twindPlugin(twindConfig)],
});

View File

@ -4,42 +4,43 @@
import * as $0 from "./routes/_404.tsx";
import * as $1 from "./routes/_app.tsx";
import * as $2 from "./routes/_middleware.ts";
import * as $3 from "./routes/admin/log/index.tsx";
import * as $4 from "./routes/admin/performance/index.tsx";
import * as $5 from "./routes/api/articles/[name].ts";
import * as $6 from "./routes/api/articles/create/index.ts";
import * as $7 from "./routes/api/articles/index.ts";
import * as $8 from "./routes/api/auth/callback.ts";
import * as $9 from "./routes/api/auth/login.ts";
import * as $10 from "./routes/api/auth/logout.ts";
import * as $11 from "./routes/api/cache/index.ts";
import * as $12 from "./routes/api/images/index.ts";
import * as $13 from "./routes/api/index.ts";
import * as $14 from "./routes/api/logs.ts";
import * as $15 from "./routes/api/movies/[name].ts";
import * as $16 from "./routes/api/movies/enhance/[name].ts";
import * as $17 from "./routes/api/movies/index.ts";
import * as $18 from "./routes/api/query/index.ts";
import * as $19 from "./routes/api/query/sync.ts";
import * as $20 from "./routes/api/recipes/[name].ts";
import * as $21 from "./routes/api/recipes/index.ts";
import * as $22 from "./routes/api/resources.ts";
import * as $23 from "./routes/api/series/[name].ts";
import * as $24 from "./routes/api/series/enhance/[name].ts";
import * as $25 from "./routes/api/series/index.ts";
import * as $26 from "./routes/api/tmdb/[id].ts";
import * as $27 from "./routes/api/tmdb/credits/[id].ts";
import * as $28 from "./routes/api/tmdb/query.ts";
import * as $29 from "./routes/articles/[name].tsx";
import * as $30 from "./routes/articles/index.tsx";
import * as $31 from "./routes/index.tsx";
import * as $32 from "./routes/movies/[name].tsx";
import * as $33 from "./routes/movies/index.tsx";
import * as $34 from "./routes/recipes/[name].tsx";
import * as $35 from "./routes/recipes/index.tsx";
import * as $36 from "./routes/series/[name].tsx";
import * as $37 from "./routes/series/index.tsx";
import * as $2 from "./routes/_layout.tsx";
import * as $3 from "./routes/_middleware.ts";
import * as $4 from "./routes/admin/log/index.tsx";
import * as $5 from "./routes/admin/performance/index.tsx";
import * as $6 from "./routes/api/articles/[name].ts";
import * as $7 from "./routes/api/articles/create/index.ts";
import * as $8 from "./routes/api/articles/index.ts";
import * as $9 from "./routes/api/auth/callback.ts";
import * as $10 from "./routes/api/auth/login.ts";
import * as $11 from "./routes/api/auth/logout.ts";
import * as $12 from "./routes/api/cache/index.ts";
import * as $13 from "./routes/api/images/index.ts";
import * as $14 from "./routes/api/index.ts";
import * as $15 from "./routes/api/logs.ts";
import * as $16 from "./routes/api/movies/[name].ts";
import * as $17 from "./routes/api/movies/enhance/[name].ts";
import * as $18 from "./routes/api/movies/index.ts";
import * as $19 from "./routes/api/query/index.ts";
import * as $20 from "./routes/api/query/sync.ts";
import * as $21 from "./routes/api/recipes/[name].ts";
import * as $22 from "./routes/api/recipes/index.ts";
import * as $23 from "./routes/api/resources.ts";
import * as $24 from "./routes/api/series/[name].ts";
import * as $25 from "./routes/api/series/enhance/[name].ts";
import * as $26 from "./routes/api/series/index.ts";
import * as $27 from "./routes/api/tmdb/[id].ts";
import * as $28 from "./routes/api/tmdb/credits/[id].ts";
import * as $29 from "./routes/api/tmdb/query.ts";
import * as $30 from "./routes/articles/[name].tsx";
import * as $31 from "./routes/articles/index.tsx";
import * as $32 from "./routes/index.tsx";
import * as $33 from "./routes/movies/[name].tsx";
import * as $34 from "./routes/movies/index.tsx";
import * as $35 from "./routes/recipes/[name].tsx";
import * as $36 from "./routes/recipes/index.tsx";
import * as $37 from "./routes/series/[name].tsx";
import * as $38 from "./routes/series/index.tsx";
import * as $$0 from "./islands/Counter.tsx";
import * as $$1 from "./islands/IngredientsList.tsx";
import * as $$2 from "./islands/KMenu.tsx";
@ -56,42 +57,43 @@ const manifest = {
routes: {
"./routes/_404.tsx": $0,
"./routes/_app.tsx": $1,
"./routes/_middleware.ts": $2,
"./routes/admin/log/index.tsx": $3,
"./routes/admin/performance/index.tsx": $4,
"./routes/api/articles/[name].ts": $5,
"./routes/api/articles/create/index.ts": $6,
"./routes/api/articles/index.ts": $7,
"./routes/api/auth/callback.ts": $8,
"./routes/api/auth/login.ts": $9,
"./routes/api/auth/logout.ts": $10,
"./routes/api/cache/index.ts": $11,
"./routes/api/images/index.ts": $12,
"./routes/api/index.ts": $13,
"./routes/api/logs.ts": $14,
"./routes/api/movies/[name].ts": $15,
"./routes/api/movies/enhance/[name].ts": $16,
"./routes/api/movies/index.ts": $17,
"./routes/api/query/index.ts": $18,
"./routes/api/query/sync.ts": $19,
"./routes/api/recipes/[name].ts": $20,
"./routes/api/recipes/index.ts": $21,
"./routes/api/resources.ts": $22,
"./routes/api/series/[name].ts": $23,
"./routes/api/series/enhance/[name].ts": $24,
"./routes/api/series/index.ts": $25,
"./routes/api/tmdb/[id].ts": $26,
"./routes/api/tmdb/credits/[id].ts": $27,
"./routes/api/tmdb/query.ts": $28,
"./routes/articles/[name].tsx": $29,
"./routes/articles/index.tsx": $30,
"./routes/index.tsx": $31,
"./routes/movies/[name].tsx": $32,
"./routes/movies/index.tsx": $33,
"./routes/recipes/[name].tsx": $34,
"./routes/recipes/index.tsx": $35,
"./routes/series/[name].tsx": $36,
"./routes/series/index.tsx": $37,
"./routes/_layout.tsx": $2,
"./routes/_middleware.ts": $3,
"./routes/admin/log/index.tsx": $4,
"./routes/admin/performance/index.tsx": $5,
"./routes/api/articles/[name].ts": $6,
"./routes/api/articles/create/index.ts": $7,
"./routes/api/articles/index.ts": $8,
"./routes/api/auth/callback.ts": $9,
"./routes/api/auth/login.ts": $10,
"./routes/api/auth/logout.ts": $11,
"./routes/api/cache/index.ts": $12,
"./routes/api/images/index.ts": $13,
"./routes/api/index.ts": $14,
"./routes/api/logs.ts": $15,
"./routes/api/movies/[name].ts": $16,
"./routes/api/movies/enhance/[name].ts": $17,
"./routes/api/movies/index.ts": $18,
"./routes/api/query/index.ts": $19,
"./routes/api/query/sync.ts": $20,
"./routes/api/recipes/[name].ts": $21,
"./routes/api/recipes/index.ts": $22,
"./routes/api/resources.ts": $23,
"./routes/api/series/[name].ts": $24,
"./routes/api/series/enhance/[name].ts": $25,
"./routes/api/series/index.ts": $26,
"./routes/api/tmdb/[id].ts": $27,
"./routes/api/tmdb/credits/[id].ts": $28,
"./routes/api/tmdb/query.ts": $29,
"./routes/articles/[name].tsx": $30,
"./routes/articles/index.tsx": $31,
"./routes/index.tsx": $32,
"./routes/movies/[name].tsx": $33,
"./routes/movies/index.tsx": $34,
"./routes/recipes/[name].tsx": $35,
"./routes/recipes/index.tsx": $36,
"./routes/series/[name].tsx": $37,
"./routes/series/index.tsx": $38,
},
islands: {
"./islands/Counter.tsx": $$0,

View File

@ -71,7 +71,7 @@ export const SearchResultItem = (
) => {
const doc = item.document;
const resourceType = resources[doc.type];
const href = (resourceType) ? `${resourceType.link}/${doc.id}` : "";
const href = resourceType ? `${resourceType.link}/${doc.id}` : "";
return (
<a
href={href}

View File

@ -1,4 +1,4 @@
import { OpenAI } from "https://deno.land/x/openai/mod.ts";
import { OpenAI } from "https://deno.land/x/openai@1.4.2/mod.ts";
import { OPENAI_API_KEY } from "@lib/env.ts";
const openAI = OPENAI_API_KEY && new OpenAI(OPENAI_API_KEY);
@ -63,6 +63,28 @@ export async function extractAuthorName(content: string) {
return author;
}
export async function createKeywords(type: string, description: string) {
if (!openAI) return;
const chatCompletion = await openAI.createChatCompletion({
model: "gpt-3.5-turbo",
messages: [
{
"role": "system",
"content":
`you create some general vibey keywords to use in a recommendation system based on a ${type} description`,
},
{ "role": "user", "content": description.slice(0, 2000) },
{
"role": "user",
"content": "return a list of keywords seperated by commas",
},
],
});
return chatCompletion.choices[0].message.content?.toLowerCase().split(", ")
.map((v) => v.replaceAll(" ", "-"));
}
export async function createTags(content: string) {
if (!openAI) return;
const chatCompletion = await openAI.createChatCompletion({

53
lib/recommendation.ts Normal file
View File

@ -0,0 +1,53 @@
import * as cache from "@lib/cache/cache.ts";
import * as openai from "@lib/openai.ts";
import { GenericResource } from "@lib/types.ts";
import { parseRating } from "@lib/helpers.ts";
type RecommendationResource = {
id: string;
type: string;
rating: number;
tags?: string[];
keywords?: string[];
author?: string;
year?: number;
};
export async function createRecommendationResource(
res: GenericResource,
description?: string,
) {
const cacheId = `recommendations:${res.type}:${res.id}`;
const resource: RecommendationResource = await cache.get(cacheId) || {
id: res.id,
type: res.type,
rating: -1,
};
if (description && !resource.keywords) {
const keywords = await openai.createKeywords(res.type, description);
if (keywords?.length) {
resource.keywords = keywords;
}
}
const { author, date, rating } = res.meta || {};
if (res?.tags) {
resource.tags = res.tags;
}
if (typeof rating !== "undefined") {
resource.rating = parseRating(rating);
}
if (author) {
resource.author = author;
}
if (date) {
const d = typeof date === "string" ? new Date(date) : date;
resource.year = d.getFullYear();
}
cache.set(cacheId, JSON.stringify(resource));
}

View File

@ -12,6 +12,8 @@ export type Movie = {
tags: string[];
meta: {
date: Date;
tmdbId?: number;
keywords?: string[];
image: string;
thumbnail?: string;
average?: string;
@ -26,6 +28,11 @@ export function renderMovie(movie: Movie) {
meta.date = formatDate(meta.date) as unknown as Date;
}
delete meta.thumbnail;
delete meta.average;
const movieImage = `![](${movie.meta.image})`;
return fixRenderedMarkdown(`${
meta
? `---
@ -35,7 +42,11 @@ ${stringify(meta)}
---`
}
# ${movie.name}
${movie.meta.image ? `![](${movie.meta.image})` : ""}
${
// So we do not add a new image to the description everytime we render
(movie.meta.image && !movie.description.includes(movieImage))
? movieImage
: ""}
${movie.tags.map((t) => `#${t}`).join(" ")}
${movie.description}
`);
@ -103,6 +114,10 @@ const crud = createCrud<Movie>({
hasThumbnails: true,
});
export const getMovie = crud.read;
export const getMovie = async (id: string) => {
const movie = await crud.read(id);
return movie;
};
export const getAllMovies = crud.readAll;
export const createMovie = crud.create;

View File

@ -15,6 +15,7 @@ export type Series = {
date: Date;
image: string;
author: string;
tmdbId?: number;
rating: number;
average?: string;
thumbnail?: string;
@ -22,12 +23,17 @@ export type Series = {
};
};
function renderSeries(movie: Series) {
const meta = movie.meta;
function renderSeries(series: Series) {
const meta = series.meta;
if ("date" in meta) {
meta.date = formatDate(meta.date);
}
delete meta.thumbnail;
delete meta.average;
const movieImage = `![](${series.meta.image})`;
return fixRenderedMarkdown(`${
meta
? `---
@ -36,10 +42,14 @@ ${stringify(meta)}
: `---
---`
}
# ${movie.name}
${movie.meta.image ? `![](${movie.meta.image})` : ""}
${movie.tags.map((t) => `#${t}`).join(" ")}
${movie.description}
# ${series.name}
${
// So we do not add a new image to the description everytime we render
(series.meta.image && !series.description.includes(movieImage))
? movieImage
: ""}
${series.tags.map((t) => `#${t}`).join(" ")}
${series.description}
`);
}

View File

@ -1,4 +1,3 @@
import { BadRequestError } from "@lib/errors.ts";
import { resources } from "@lib/resources.ts";
import { SearchResult } from "@lib/types.ts";
import { getTypeSenseClient } from "@lib/typesense.ts";
@ -15,9 +14,9 @@ type SearchParams = {
query_by?: string;
};
export function parseResourceUrl(_url: string): SearchParams | undefined {
export function parseResourceUrl(_url: string | URL): SearchParams | undefined {
try {
const url = new URL(_url);
const url = typeof _url === "string" ? new URL(_url) : _url;
let query = url.searchParams.get("q") || "*";
if (!query) {
return undefined;

View File

@ -37,6 +37,7 @@ export interface TMDBSeries {
export type GenericResource = {
name: string;
id: string;
tags?: string[];
type: keyof typeof resources;
meta?: {
image?: string;

View File

@ -8,8 +8,7 @@ import "$std/dotenv/load.ts";
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import config from "./fresh.config.ts";
import twindPlugin from "$fresh/plugins/twind.ts";
import twindConfig from "./twind.config.ts";
await start(manifest, config);
await start(manifest, { plugins: [twindPlugin(twindConfig)] });

41
routes/_layout.tsx Normal file
View File

@ -0,0 +1,41 @@
import { LayoutProps } from "$fresh/server.ts";
import { resources } from "@lib/resources.ts";
import { CSS, KATEX_CSS } from "https://deno.land/x/gfm@0.2.5/mod.ts";
import { Head } from "$fresh/runtime.ts";
import { Emoji } from "@components/Emoji.tsx";
export default function MyLayout({ Component, url }: LayoutProps) {
return (
<div
class="md:grid mx-auto"
style={{ gridTemplateColumns: "200px 1fr", maxWidth: "1024px" }}
>
<Head>
<style>{CSS}</style>
<style>{KATEX_CSS}</style>
</Head>
<aside class="p-4 hidden md:block">
<nav class="min-h-fit rounded-3xl p-3 grid gap-3 fixed t-0">
{Object.values(resources).map((m) => {
return (
<a
href={m.link}
class={`flex items-center gap-2 ${
m.link === url.pathname ? "bg-white text-black" : "text-white"
} p-3 text-xl w-full rounded-2xl`}
>
{<Emoji class="w-6 h-6" name={m.emoji} />} {m.name}
</a>
);
})}
</nav>
</aside>
<main
class="py-5"
style={{ fontFamily: "Work Sans" }}
>
<Component />
</main>
</div>
);
}

View File

@ -25,8 +25,11 @@ export const handler: Handlers = {
const releaseDate = movieDetails.release_date;
const posterPath = movieDetails.poster_path;
const director =
movieCredits?.crew?.filter?.((person) => person.job === "Director")[0];
const director = movieCredits?.crew?.filter?.((person) =>
person.job === "Director"
)[0];
movieDetails.overview;
let finalPath = "";
const name = movieDetails.title || movieDetails.original_title ||
@ -41,7 +44,9 @@ export const handler: Handlers = {
await createDocument(finalPath, poster);
}
const metadata = {} as Movie["meta"];
const metadata = {
tmdbId,
} as Movie["meta"];
if (releaseDate) {
metadata.date = new Date(releaseDate);
}

View File

@ -11,6 +11,7 @@ import {
NotFoundError,
} from "@lib/errors.ts";
import * as cache from "@lib/cache/cache.ts";
import { createRecommendationResource } from "@lib/recommendation.ts";
const POST = async (
req: Request,
@ -42,8 +43,9 @@ const POST = async (
movie.meta.date = new Date(releaseDate);
}
const director =
movieCredits?.crew?.filter?.((person) => person.job === "Director")[0];
const director = movieCredits?.crew?.filter?.((person) =>
person.job === "Director"
)[0];
if (director && !movie.meta.author) {
movie.meta.author = director.name;
}
@ -57,6 +59,10 @@ const POST = async (
];
}
if (!movie.meta.tmdbId) {
movie.meta.tmdbId = tmdbId;
}
let finalPath = "";
const posterPath = movieDetails.poster_path;
if (posterPath && !movie.meta.image) {
@ -72,6 +78,8 @@ const POST = async (
cache.del(`documents:Media:movies:${name}.md`);
createRecommendationResource(movie, movieDetails.overview);
return json(movie);
};

View File

@ -42,7 +42,7 @@ export const handler: Handlers = {
await createDocument(finalPath, poster);
}
const metadata = {} as Series["meta"];
const metadata = { tmdbId } as Series["meta"];
if (releaseDate) {
metadata.date = new Date(releaseDate);
}

View File

@ -1,4 +1,4 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { Handlers, PageProps, RouteContext } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { getMovie, Movie } from "@lib/resource/movies.ts";
import { RecipeHero } from "@components/RecipeHero.tsx";
@ -7,17 +7,12 @@ import { renderMarkdown } from "@lib/documents.ts";
import { KMenu } from "@islands/KMenu.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
export const handler: Handlers<Movie | null> = {
async GET(_, ctx) {
const movie = await getMovie(ctx.params.name);
return ctx.render({ movie, session: ctx.state.session });
},
};
export default function Greet(
export default async function Greet(
props: PageProps<{ movie: Movie; session: Record<string, string> }>,
ctx: RouteContext,
) {
const { movie, session } = props.data;
const movie = await getMovie(ctx.params.name);
const session = ctx.state.session;
const { author = "", date = "" } = movie.meta;

View File

@ -1,4 +1,3 @@
import { MainLayout } from "@components/layouts/main.tsx";
import { getAllMovies, Movie } from "@lib/resource/movies.ts";
import { ResourceCard } from "@components/Card.tsx";
@ -8,26 +7,18 @@ import { KMenu } from "@islands/KMenu.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { SearchResult } from "@lib/types.ts";
import { PageProps } from "$fresh/server.ts";
export const handler: Handlers<
{ movies: Movie[] | null; searchResults?: SearchResult }
> = {
async GET(req, ctx) {
const movies = await getAllMovies();
const searchParams = parseResourceUrl(req.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, type: "movie" });
return ctx.render({
movies: movies.sort((a, b) => a?.meta?.rating > b?.meta?.rating ? -1 : 1),
searchResults,
});
},
};
export default function Greet(
export default async function Greet(
props: PageProps<{ movies: Movie[] | null; searchResults: SearchResult }>,
) {
const { movies, searchResults } = props.data;
const allMovies = await getAllMovies();
const searchParams = parseResourceUrl(props.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, type: "movie" });
const movies = allMovies.sort((a, b) =>
a?.meta?.rating > b?.meta?.rating ? -1 : 1
);
return (
<MainLayout