A self hosted solution for privately rating and reviewing different sorts of media
1import { Category, Entry } from '@prisma/client';
2import { Meilisearch } from 'meilisearch';
3import prisma from './db';
4import { validateSessionToken } from './auth/validateSession';
5import { getDefaultWhereForTranslations } from './api/routers/dashboard_';
6
7export const meilisearchClient = new Meilisearch({
8 host: process.env.MEILISEARCH_URL!,
9 apiKey: process.env.MEILISEARCH_MASTER_KEY,
10});
11
12export type MeilisearchEntry = {
13 id: number;
14 category: Category;
15 releaseYear: string;
16 titles: string[];
17 people: string[];
18 genres: string[];
19 companies: string[];
20};
21
22export const MeilisearchEntriesUid = 'entries';
23
24export const deleteAllDocuments = async () => {
25 const index = meilisearchClient.index(MeilisearchEntriesUid);
26 await index.deleteAllDocuments();
27};
28
29export const createIndexes = async () => {
30 const task = await meilisearchClient.createIndex(MeilisearchEntriesUid);
31 await meilisearchClient.tasks.waitForTask(task.taskUid);
32
33 const index = meilisearchClient.index(MeilisearchEntriesUid);
34 index.updateSearchableAttributes([
35 'titles',
36 'releaseYear',
37 'people',
38 'companies',
39 'genres',
40 ]);
41 index.updateFilterableAttributes(['category']);
42};
43
44export const addMeilisearchEntries = async (entries: MeilisearchEntry[]) => {
45 const index = meilisearchClient.index<MeilisearchEntry>(
46 MeilisearchEntriesUid
47 );
48 await index.addDocuments(entries);
49};
50
51export const searchEntries = async (
52 query: string,
53 limit: number,
54 categories: Category[]
55) => {
56 const authUser = await validateSessionToken();
57 const index = meilisearchClient.index<MeilisearchEntry>(
58 MeilisearchEntriesUid
59 );
60
61 const categoryFilter = categories
62 .map(cat => `category = "${cat}"`)
63 .join(' OR ');
64
65 const searchEntries = await index.search(query, {
66 filter: [categoryFilter],
67 limit,
68 showRankingScore: true,
69 });
70 let prismaEntries = await prisma.entry.findMany({
71 where: {
72 id: {
73 in: searchEntries.hits.map(e => e.id),
74 },
75 },
76 include: {
77 translations: getDefaultWhereForTranslations(authUser),
78 userEntries: authUser
79 ? {
80 where: {
81 userId: authUser.id,
82 },
83 }
84 : undefined,
85 },
86 });
87
88 const prismaEntryMap = new Map(prismaEntries.map(entry => [entry.id, entry]));
89
90 const orderedEntries = searchEntries.hits.map(hit => ({
91 ...prismaEntryMap.get(hit.id)!,
92 _rankingScore: hit._rankingScore ?? null,
93 }));
94
95 return orderedEntries;
96};
97
98export const addMeilisearchEntryByEntryId = async (entryId: number) => {
99 const entry = await prisma.entry.findFirst({
100 where: {
101 id: entryId,
102 },
103 include: {
104 genres: {
105 include: {
106 genre: true,
107 },
108 },
109 productionCompanies: {
110 include: {
111 company: true,
112 },
113 },
114 translations: {
115 include: {
116 language: true,
117 },
118 },
119 alternativeTitles: {
120 include: {
121 language: true,
122 },
123 },
124 cast: {
125 include: {
126 person: true,
127 },
128 },
129 crew: {
130 include: {
131 person: true,
132 },
133 },
134 },
135 });
136
137 if (!entry) {
138 return;
139 }
140
141 const document: MeilisearchEntry = {
142 id: entry.id,
143 category: entry.category,
144 releaseYear: entry.releaseDate.getUTCFullYear().toString(),
145 titles: Array.from(
146 new Set([
147 entry.originalTitle,
148 ...entry.translations.map(e => e.name),
149 ...entry.alternativeTitles.map(e => e.title),
150 ])
151 ),
152 people: Array.from(
153 new Set([
154 ...entry.cast.map(e => e.person.name),
155 ...entry.crew.map(e => e.person.name),
156 ])
157 ),
158 genres: Array.from(new Set([...entry.genres.map(e => e.genre.name)])),
159 companies: Array.from(
160 new Set([...entry.productionCompanies.map(e => e.company.name)])
161 ),
162 };
163
164 await addMeilisearchEntries([document]);
165};