feat: add loading of recommendations to movie page
This commit is contained in:
parent
04c3578d61
commit
67c2785b80
78
fresh.gen.ts
78
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,
|
||||
};
|
||||
|
43
islands/Recommendations.tsx
Normal file
43
islands/Recommendations.tsx
Normal file
@ -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 (
|
||||
<ul class="gap-5">
|
||||
{results.map((res) => {
|
||||
return (
|
||||
<div class="flex gap-5 items-center mb-4">
|
||||
<img
|
||||
class="w-12 h-12 rounded-full object-cover"
|
||||
src={`https://image.tmdb.org/t/p/original${res.poster_path}`}
|
||||
/>
|
||||
<p>{res.title}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "loading") {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
return <button onClick={startFetch}>load recommendations</button>;
|
||||
}
|
@ -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<ArrayBufferView>;
|
||||
const body = new ReadableStream({
|
||||
|
@ -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(" ", "-"));
|
||||
}
|
||||
|
||||
|
@ -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<RecommendationResource | null> {
|
||||
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))
|
||||
|
@ -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,
|
||||
},
|
||||
|
37
routes/api/recommendation/data.ts
Normal file
37
routes/api/recommendation/data.ts
Normal file
@ -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<string, number> = {};
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
17
routes/api/recommendation/movie/[id].ts
Normal file
17
routes/api/recommendation/movie/[id].ts
Normal file
@ -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);
|
||||
},
|
||||
};
|
@ -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<string, string> }>,
|
||||
@ -52,6 +53,8 @@ export default async function Greet(
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
|
||||
<Recommendations id={movie.id} type="movie"></Recommendations>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user