diff --git a/fresh.gen.ts b/fresh.gen.ts
index 79c3d1e..8293d80 100644
--- a/fresh.gen.ts
+++ b/fresh.gen.ts
@@ -26,23 +26,25 @@ 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/recommendation/all.ts";
-import * as $24 from "./routes/api/recommendation/index.ts";
-import * as $25 from "./routes/api/resources.ts";
-import * as $26 from "./routes/api/series/[name].ts";
-import * as $27 from "./routes/api/series/enhance/[name].ts";
-import * as $28 from "./routes/api/series/index.ts";
-import * as $29 from "./routes/api/tmdb/[id].ts";
-import * as $30 from "./routes/api/tmdb/credits/[id].ts";
-import * as $31 from "./routes/api/tmdb/query.ts";
-import * as $32 from "./routes/articles/[name].tsx";
-import * as $33 from "./routes/articles/index.tsx";
-import * as $34 from "./routes/index.tsx";
-import * as $35 from "./routes/movies/[name].tsx";
-import * as $36 from "./routes/movies/index.tsx";
-import * as $37 from "./routes/recipes/[name].tsx";
-import * as $38 from "./routes/recipes/index.tsx";
-import * as $39 from "./routes/series/[name].tsx";
-import * as $40 from "./routes/series/index.tsx";
+import * as $24 from "./routes/api/recommendation/data.ts";
+import * as $25 from "./routes/api/recommendation/index.ts";
+import * as $26 from "./routes/api/recommendation/movie/[id].ts";
+import * as $27 from "./routes/api/resources.ts";
+import * as $28 from "./routes/api/series/[name].ts";
+import * as $29 from "./routes/api/series/enhance/[name].ts";
+import * as $30 from "./routes/api/series/index.ts";
+import * as $31 from "./routes/api/tmdb/[id].ts";
+import * as $32 from "./routes/api/tmdb/credits/[id].ts";
+import * as $33 from "./routes/api/tmdb/query.ts";
+import * as $34 from "./routes/articles/[name].tsx";
+import * as $35 from "./routes/articles/index.tsx";
+import * as $36 from "./routes/index.tsx";
+import * as $37 from "./routes/movies/[name].tsx";
+import * as $38 from "./routes/movies/index.tsx";
+import * as $39 from "./routes/recipes/[name].tsx";
+import * as $40 from "./routes/recipes/index.tsx";
+import * as $41 from "./routes/series/[name].tsx";
+import * as $42 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";
@@ -54,7 +56,8 @@ import * as $$7 from "./islands/KMenu/commands/create_movie.ts";
import * as $$8 from "./islands/KMenu/commands/create_recommendations.ts";
import * as $$9 from "./islands/KMenu/commands/create_series.ts";
import * as $$10 from "./islands/KMenu/types.ts";
-import * as $$11 from "./islands/Search.tsx";
+import * as $$11 from "./islands/Recommendations.tsx";
+import * as $$12 from "./islands/Search.tsx";
const manifest = {
routes: {
@@ -82,23 +85,25 @@ const manifest = {
"./routes/api/recipes/[name].ts": $21,
"./routes/api/recipes/index.ts": $22,
"./routes/api/recommendation/all.ts": $23,
- "./routes/api/recommendation/index.ts": $24,
- "./routes/api/resources.ts": $25,
- "./routes/api/series/[name].ts": $26,
- "./routes/api/series/enhance/[name].ts": $27,
- "./routes/api/series/index.ts": $28,
- "./routes/api/tmdb/[id].ts": $29,
- "./routes/api/tmdb/credits/[id].ts": $30,
- "./routes/api/tmdb/query.ts": $31,
- "./routes/articles/[name].tsx": $32,
- "./routes/articles/index.tsx": $33,
- "./routes/index.tsx": $34,
- "./routes/movies/[name].tsx": $35,
- "./routes/movies/index.tsx": $36,
- "./routes/recipes/[name].tsx": $37,
- "./routes/recipes/index.tsx": $38,
- "./routes/series/[name].tsx": $39,
- "./routes/series/index.tsx": $40,
+ "./routes/api/recommendation/data.ts": $24,
+ "./routes/api/recommendation/index.ts": $25,
+ "./routes/api/recommendation/movie/[id].ts": $26,
+ "./routes/api/resources.ts": $27,
+ "./routes/api/series/[name].ts": $28,
+ "./routes/api/series/enhance/[name].ts": $29,
+ "./routes/api/series/index.ts": $30,
+ "./routes/api/tmdb/[id].ts": $31,
+ "./routes/api/tmdb/credits/[id].ts": $32,
+ "./routes/api/tmdb/query.ts": $33,
+ "./routes/articles/[name].tsx": $34,
+ "./routes/articles/index.tsx": $35,
+ "./routes/index.tsx": $36,
+ "./routes/movies/[name].tsx": $37,
+ "./routes/movies/index.tsx": $38,
+ "./routes/recipes/[name].tsx": $39,
+ "./routes/recipes/index.tsx": $40,
+ "./routes/series/[name].tsx": $41,
+ "./routes/series/index.tsx": $42,
},
islands: {
"./islands/Counter.tsx": $$0,
@@ -112,7 +117,8 @@ const manifest = {
"./islands/KMenu/commands/create_recommendations.ts": $$8,
"./islands/KMenu/commands/create_series.ts": $$9,
"./islands/KMenu/types.ts": $$10,
- "./islands/Search.tsx": $$11,
+ "./islands/Recommendations.tsx": $$11,
+ "./islands/Search.tsx": $$12,
},
baseUrl: import.meta.url,
};
diff --git a/islands/Recommendations.tsx b/islands/Recommendations.tsx
new file mode 100644
index 0000000..e26dbd3
--- /dev/null
+++ b/islands/Recommendations.tsx
@@ -0,0 +1,43 @@
+import { useCallback, useState } from "preact/hooks";
+
+export function Recommendations({ id, type }: { id: string; type: string }) {
+ const [state, setState] = useState<"disabled" | "loading">(
+ "disabled",
+ );
+ const [results, setResults] = useState();
+
+ const startFetch = useCallback(
+ async () => {
+ if (state === "loading") return;
+ setState("loading");
+ const res = await fetch(`/api/recommendation/${type}/${id}`);
+ const json = await res.json();
+ setResults(json);
+ },
+ [id, type],
+ );
+
+ if (results) {
+ return (
+
+ {results.map((res) => {
+ return (
+
+

+
{res.title}
+
+ );
+ })}
+
+ );
+ }
+
+ if (state === "loading") {
+ return Loading...
;
+ }
+
+ return ;
+}
diff --git a/lib/helpers.ts b/lib/helpers.ts
index a5d538a..4437988 100644
--- a/lib/helpers.ts
+++ b/lib/helpers.ts
@@ -47,6 +47,16 @@ export async function fetchStream(url: string, cb: (chunk: string) => void) {
}
}
+export function hashString(message: string) {
+ let hash = 0;
+ for (let i = 0; i < message.length; i++) {
+ const char = message.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash = hash & hash; // Convert to 32bit integer
+ }
+ return hash;
+}
+
export const createStreamResponse = () => {
let controller: ReadableStreamController;
const body = new ReadableStream({
diff --git a/lib/openai.ts b/lib/openai.ts
index fd0bdcf..652b640 100644
--- a/lib/openai.ts
+++ b/lib/openai.ts
@@ -1,5 +1,7 @@
import { OpenAI } from "https://deno.land/x/openai@1.4.2/mod.ts";
import { OPENAI_API_KEY } from "@lib/env.ts";
+import { cacheFunction } from "@lib/cache/cache.ts";
+import { hashString } from "@lib/helpers.ts";
const openAI = OPENAI_API_KEY && new OpenAI(OPENAI_API_KEY);
@@ -9,8 +11,8 @@ function extractListFromResponse(response?: string): string[] {
.split(/[\n,]/)
.map((line) => line.trim())
.filter((line) => !line.endsWith(":"))
- .map((line) => line.replace(/^[^(a-zA-Z)]*/, "").trim())
- .filter((line) => line.length > 0);
+ .map((line) => line.replace(/^[^\s]*/, "").trim())
+ .filter((line) => line.length > 2);
}
export async function summarize(content: string) {
@@ -73,7 +75,7 @@ export async function extractAuthorName(content: string) {
return author;
}
-export async function createKeywords(
+export async function createGenres(
type: string,
description: string,
title = "unknown",
@@ -85,7 +87,7 @@ export async function createKeywords(
{
"role": "system",
"content":
- `you create some keywords that can be used in a recommendation system. The keywords are based on a ${type} description or title. If you do not know the title, take into account the description aswell. Create a range of keywords from very specific ones that describe the general vibe. Also include some that describe the genre. ${
+ `you create some keywords that can be used in a recommendation system. The keywords are based on a ${type} description or title. If you do not know the title, take into account the description aswell. Create a range of keywords from very specific ones that describe the general vibe. ${
title ? `The name of the ${type} is ${title}` : ""
}`,
},
@@ -107,6 +109,82 @@ export async function createKeywords(
.map((v) => v.replaceAll(" ", "-"));
}
+export async function createKeywords(
+ type: string,
+ description: string,
+ title = "unknown",
+) {
+ if (!openAI) return;
+ const chatCompletion = await openAI.createChatCompletion({
+ model: "gpt-3.5-turbo",
+ messages: [
+ {
+ "role": "system",
+ "content":
+ `you create some keywords that can be used in a recommendation system. The keywords are based on a ${type} description or title. If you do not know the title, take into account the description aswell. Create a range of keywords from very specific ones that describe the general vibe.
+title: ${title}
+description: ${description.slice(0, 2000).replaceAll("\n", " ")}}
+`,
+ },
+ {
+ "role": "user",
+ "content": "return a list of around 20 keywords seperated by commas",
+ },
+ ],
+ });
+
+ const res = chatCompletion.choices[0].message.content?.toLowerCase();
+
+ return extractListFromResponse(res)
+ .map((v) => v.replaceAll(" ", "-"));
+}
+
+export const getMovieRecommendations = (keywords: string, exclude: string[]) =>
+ cacheFunction({
+ fn: async () => {
+ if (!openAI) return;
+ const chatCompletion = await openAI.createChatCompletion({
+ model: "gpt-3.5-turbo",
+ messages: [
+ {
+ "role": "system",
+ "content":
+ `Could you recommend me 10 movies based on the following attributes:
+
+${keywords}
+
+The movies should be similar to but not include ${
+ exclude.join(", ")
+ } or remakes of that.
+
+respond with a plain unordered list each item starting with the year the movie was released and then the title of the movie seperated by a -`,
+ },
+ ],
+ });
+
+ const res = chatCompletion.choices[0].message.content?.toLowerCase();
+
+ if (!res) return;
+
+ console.log("REsult:");
+ console.log(res);
+
+ const list = extractListFromResponse(res);
+
+ console.log({ list });
+
+ return res.split("\n").map((entry) => {
+ const [year, ...title] = entry.split("-");
+
+ return {
+ year: parseInt(year.trim()),
+ title: title.join(" ").replaceAll('"', "").trim(),
+ };
+ }).filter((y) => !Number.isNaN(y.year));
+ },
+ id: `openai:movierecs:${hashString(`${keywords}:${exclude.join()}`)}`,
+ });
+
export async function createTags(content: string) {
if (!openAI) return;
const chatCompletion = await openAI.createChatCompletion({
@@ -126,4 +204,3 @@ export async function createTags(content: string) {
return extractListFromResponse(res).map((v) => v.replaceAll(" ", "-"));
}
-
diff --git a/lib/recommendation.ts b/lib/recommendation.ts
index dea95f2..27d3993 100644
--- a/lib/recommendation.ts
+++ b/lib/recommendation.ts
@@ -1,5 +1,6 @@
import * as cache from "@lib/cache/cache.ts";
import * as openai from "@lib/openai.ts";
+import * as tmdb from "@lib/tmdb.ts";
import { GenericResource } from "@lib/types.ts";
import { parseRating } from "@lib/helpers.ts";
@@ -57,11 +58,39 @@ export async function createRecommendationResource(
cache.set(cacheId, JSON.stringify(resource));
}
-export function getRecommendation(id: string, type: string) {
- return cache.get(`recommendations:${type}:${id}`);
+export async function getRecommendation(
+ id: string,
+ type: string,
+): Promise {
+ const res = await cache.get(`recommendations:${type}:${id}`) as string;
+ try {
+ return JSON.parse(res);
+ } catch (_) {
+ return null;
+ }
}
-export async function getAllRecommendations() {
+export async function getSimilarMovies(id: string) {
+ const recs = await getRecommendation(id, "movie");
+ if (!recs?.keywords?.length) return;
+
+ const recommendations = await openai.getMovieRecommendations(
+ recs.keywords.join(),
+ [recs.id],
+ );
+ if (!recommendations) return;
+
+ const movies = await Promise.all(recommendations.map(async (rec) => {
+ const m = await tmdb.searchMovie(rec.title, rec.year);
+ return m?.results?.[0];
+ }));
+
+ return movies.filter(Boolean);
+}
+
+export async function getAllRecommendations(): Promise<
+ RecommendationResource[]
+> {
const keys = await cache.keys("recommendations:movie:*");
return Promise.all(keys.map((k) => cache.get(k))).then((res) =>
res.map((r) => JSON.parse(r))
diff --git a/lib/tmdb.ts b/lib/tmdb.ts
index 947634c..4125e96 100644
--- a/lib/tmdb.ts
+++ b/lib/tmdb.ts
@@ -4,10 +4,10 @@ const moviedb = new MovieDb(Deno.env.get("TMDB_API_KEY") || "");
const CACHE_INTERVAL = 1000 * 60 * 24 * 30;
-export const searchMovie = (query: string) =>
+export const searchMovie = (query: string, year?: number) =>
cache.cacheFunction({
- fn: () => moviedb.searchMovie({ query }),
- id: `query:moviesearch:${query}`,
+ fn: () => moviedb.searchMovie({ query, year }),
+ id: `query:moviesearch:${query}${year ? `-${year}` : ""}`,
options: {
expires: CACHE_INTERVAL,
},
diff --git a/routes/api/recommendation/data.ts b/routes/api/recommendation/data.ts
new file mode 100644
index 0000000..d260237
--- /dev/null
+++ b/routes/api/recommendation/data.ts
@@ -0,0 +1,37 @@
+import { Handlers } from "$fresh/server.ts";
+import { AccessDeniedError } from "@lib/errors.ts";
+import { getAllRecommendations } from "@lib/recommendation.ts";
+import { json } from "@lib/helpers.ts";
+
+export const handler: Handlers = {
+ async GET(_, ctx) {
+ const session = ctx.state.session;
+ if (!session) {
+ throw new AccessDeniedError();
+ }
+
+ const recs = await getAllRecommendations();
+
+ const allKeywords: Record = {};
+
+ for (const rec of recs) {
+ if (rec.keywords?.length) {
+ for (const keyword of rec.keywords) {
+ if (keyword in allKeywords) {
+ allKeywords[keyword] += 1;
+ } else {
+ allKeywords[keyword] = 1;
+ }
+ }
+ }
+ }
+
+ const keywords = Object.entries(allKeywords).sort((a, b) =>
+ a[1] > b[1] ? -1 : 1
+ ).slice(0, 100);
+
+ return json({
+ keywords,
+ });
+ },
+};
diff --git a/routes/api/recommendation/movie/[id].ts b/routes/api/recommendation/movie/[id].ts
new file mode 100644
index 0000000..0e1d587
--- /dev/null
+++ b/routes/api/recommendation/movie/[id].ts
@@ -0,0 +1,17 @@
+import { Handlers } from "$fresh/server.ts";
+import { AccessDeniedError } from "@lib/errors.ts";
+import { getSimilarMovies } from "@lib/recommendation.ts";
+import { json } from "@lib/helpers.ts";
+
+export const handler: Handlers = {
+ async GET(_, ctx) {
+ const session = ctx.state.session;
+ if (!session) {
+ throw new AccessDeniedError();
+ }
+
+ const recommendations = await getSimilarMovies(ctx.params.id);
+
+ return json(recommendations);
+ },
+};
diff --git a/routes/movies/[name].tsx b/routes/movies/[name].tsx
index fd75097..7820637 100644
--- a/routes/movies/[name].tsx
+++ b/routes/movies/[name].tsx
@@ -6,6 +6,7 @@ import { HashTags } from "@components/HashTags.tsx";
import { renderMarkdown } from "@lib/documents.ts";
import { KMenu } from "@islands/KMenu.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
+import { Recommendations } from "@islands/Recommendations.tsx";
export default async function Greet(
props: PageProps<{ movie: Movie; session: Record }>,
@@ -52,6 +53,8 @@ export default async function Greet(
>
{content}
+
+
);