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 ( + + ); + } + + 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} + + );