diff --git a/next.config.js b/next.config.js index 007c293..42d4585 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,21 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'image.tmdb.org', + port: '', + pathname: '/t/p/w200/**', + }, + { + protocol: 'https', + hostname: 'picsum.photos', + port: '', + pathname: '/**', + }, + ], + }, reactStrictMode: false, publicRuntimeConfig: { // Will be available on both server and client diff --git a/package.json b/package.json index 9b6bad8..431591c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "debrid-media-manager", - "version": "2.6.0", + "version": "2.7.0", "private": false, "scripts": { "dev": "next dev", diff --git a/src/components/poster.tsx b/src/components/poster.tsx new file mode 100644 index 0000000..825702c --- /dev/null +++ b/src/components/poster.tsx @@ -0,0 +1,38 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import Image from 'next/image'; +import { getTmdbKey } from '@/utils/freekeys'; + +const TMDBPoster = ({ imdbId }: Record) => { + const [posterUrl, setPosterUrl] = useState(''); + + useEffect(() => { + const fetchData = async () => { + const response = await axios.get(`https://api.themoviedb.org/3/find/${imdbId}?api_key=${getTmdbKey()}&external_source=imdb_id`); + const baseUrl = 'https://image.tmdb.org/t/p/w200'; + if (response.data.movie_results.length > 0 && response.data.movie_results[0].poster_path) { + setPosterUrl(baseUrl + response.data.movie_results[0].poster_path); + } if (response.data.tv_results.length > 0 && response.data.tv_results[0].poster_path) { + setPosterUrl(baseUrl + response.data.tv_results[0].poster_path); + } else { + // If no poster_path, set a placeholder image URL + setPosterUrl(`https://picsum.photos/seed/${imdbId}/200/300`); + } + }; + + try { + if (imdbId) fetchData(); + else setPosterUrl(`https://picsum.photos/seed/${imdbId}/200/300`); + } catch (error: any) { + setPosterUrl(`https://picsum.photos/seed/${imdbId}/200/300`); + } + }, [imdbId]); + + return ( +
+ {posterUrl && Movie poster} +
+ ); +}; + +export default TMDBPoster; diff --git a/src/pages/api/keywordsearch.ts b/src/pages/api/keywordsearch.ts new file mode 100644 index 0000000..f020472 --- /dev/null +++ b/src/pages/api/keywordsearch.ts @@ -0,0 +1,50 @@ +import { PlanetScaleCache } from '@/services/planetscale'; +import axios from 'axios'; +import { NextApiHandler } from 'next'; + +const mdblistKey = process.env.MDBLIST_KEY; +const searchMdb = (keyword: string) => `https://mdblist.com/api/?apikey=${mdblistKey}&s=${keyword}`; +const getMdbInfo = (imdbId: string) => `https://mdblist.com/api/?apikey=${mdblistKey}&i=${imdbId}`; +const db = new PlanetScaleCache(); + +const handler: NextApiHandler = async (req, res) => { + const { keyword } = req.query; + + if (!keyword || !(typeof keyword === 'string')) { + res.status(400).json({ status: 'error', errorMessage: 'Missing "keyword" query parameter' }); + return; + } + + try { + const searchResults = await db.getSearchResults(keyword.toString().trim()); + if (searchResults) { + res.status(200).json({ results: searchResults.filter(r => r.imdbid) }); + return; + } + + const searchResponse = await axios.get(searchMdb(keyword.toString().trim())); + const results = ([...searchResponse.data.search]).filter((result: any) => result.imdbid); + + for (let i = 0; i < results.length; i++) { + if (results[i].type === 'show') { + const showResponse = await axios.get(getMdbInfo(results[i].imdbid)); + const seasons = showResponse.data.seasons.filter((season: any) => season.season_number > 0) + .map((season: any) => { + return season.season_number; + }); + results[i].season_count = Math.max(...seasons); + } + } + + console.log('search results', results.length); + + await db.saveSearchResults(keyword.toString().trim(), results); + + res.status(200).json({ results }); + } catch (error: any) { + console.error('encountered a search issue', error); + res.status(500).json({ status: 'error', errorMessage: error.message }); + } +}; + +export default handler; diff --git a/src/pages/api/moviesearch.ts b/src/pages/api/moviesearch.ts new file mode 100644 index 0000000..d3a04f7 --- /dev/null +++ b/src/pages/api/moviesearch.ts @@ -0,0 +1,28 @@ +import { PlanetScaleCache } from '@/services/planetscale'; +import { NextApiHandler } from 'next'; + +const db = new PlanetScaleCache(); + +const handler: NextApiHandler = async (req, res) => { + const { imdbId } = req.query; + + if (!imdbId || !(typeof imdbId === 'string')) { + res.status(400).json({ errorMessage: 'Missing "imdbId" query parameter' }); + return; + } + + try { + const searchResults = await db.getScrapedResults(`movie:${imdbId.toString().trim()}`); + if (searchResults) { + res.status(200).json({ results: searchResults }); + return; + } + + res.status(204).json({ results: [] }); + } catch (error: any) { + console.error('encountered a db issue', error); + res.status(500).json({ errorMessage: error.message }); + } +}; + +export default handler; diff --git a/src/pages/api/scrape.ts b/src/pages/api/scrape.ts index 24eda46..c046632 100644 --- a/src/pages/api/scrape.ts +++ b/src/pages/api/scrape.ts @@ -70,7 +70,7 @@ export default async function handler( let itemType: 'movie' | 'tv' = 'movie'; if (tmdbResponse.data.movie_results.length > 0) { - if (override && override !== 'true') { + if (!override || override !== 'true') { const keyExists = await db.keyExists(`movie:${imdbId}`); if (keyExists) { res.status(200).json({ status: 'skipped' }); @@ -128,7 +128,7 @@ export default async function handler( } if (tmdbResponse.data.tv_results.length > 0) { - if (override && override !== 'true') { + if (!override || override !== 'true') { const keyExists = await db.keyExists(`tv:${imdbId}:1`); if (keyExists) { res.status(200).json({ status: 'skipped' }); diff --git a/src/pages/api/tvsearch.ts b/src/pages/api/tvsearch.ts new file mode 100644 index 0000000..7cbda47 --- /dev/null +++ b/src/pages/api/tvsearch.ts @@ -0,0 +1,36 @@ +import { PlanetScaleCache } from '@/services/planetscale'; +import { NextApiHandler } from 'next'; + +const db = new PlanetScaleCache(); + +const handler: NextApiHandler = async (req, res) => { + const { imdbId, seasonNum } = req.query; + + if (!imdbId || !(typeof imdbId === 'string')) { + res.status(400).json({ errorMessage: 'Missing "imdbId" query parameter' }); + return; + } + if (!seasonNum || !(typeof seasonNum === 'string')) { + res.status(400).json({ + errorMessage: 'Missing "seasonNum" query parameter', + }); + return; + } + + try { + const searchResults = await db.getScrapedResults( + `tv:${imdbId.toString().trim()}:${parseInt(seasonNum.toString().trim(), 10)}` + ); + if (searchResults) { + res.status(200).json({ results: searchResults }); + return; + } + + res.status(204).json({ results: [] }); + } catch (error: any) { + console.error('encountered a db issue', error); + res.status(500).json({ errorMessage: error.message }); + } +}; + +export default handler; diff --git a/src/pages/movies/[imdbid].tsx b/src/pages/movies/[imdbid].tsx new file mode 100644 index 0000000..ef556b1 --- /dev/null +++ b/src/pages/movies/[imdbid].tsx @@ -0,0 +1,574 @@ +import { useAllDebridApiKey, useRealDebridAccessToken } from '@/hooks/auth'; +import { useDownloadsCache } from '@/hooks/cache'; +import useLocalStorage from '@/hooks/localStorage'; +import { + AdInstantAvailabilityResponse, + MagnetFile, + adInstantCheck, + deleteMagnet, + uploadMagnet, +} from '@/services/allDebrid'; +import { Availability, HashAvailability } from '@/services/availability'; +import { + RdInstantAvailabilityResponse, + addHashAsMagnet, + deleteTorrent, + getTorrentInfo, + rdInstantCheck, + selectFiles, +} from '@/services/realDebrid'; +import { getTmdbKey } from '@/utils/freekeys'; +import { groupBy } from '@/utils/groupBy'; +import { getSelectableFiles, isVideo } from '@/utils/selectable'; +import { withAuth } from '@/utils/withAuth'; +import axios from 'axios'; +import Head from 'next/head'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import toast, { Toaster } from 'react-hot-toast'; +import { FaDownload, FaFastForward, FaTimes } from 'react-icons/fa'; +import { SearchApiResponse } from '../api/search'; + +type SearchResult = { + title: string; + fileSize: number; + hash: string; + available: Availability; +}; + +type TmdbResponse = { + movie_results: { + title: string; + overview: string; + release_date: string; + poster_path: string; + }[]; +}; + +function Search() { + const [searchResults, setSearchResults] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const rdKey = useRealDebridAccessToken(); + const adKey = useAllDebridApiKey(); + const [loading, setLoading] = useState(false); + const [masterAvailability, setMasterAvailability] = useState({}); + const [rdCache, rd, rdCacheAdder, removeFromRdCache] = useDownloadsCache('rd'); + const [adCache, ad, adCacheAdder, removeFromAdCache] = useDownloadsCache('ad'); + const [rdAutoInstantCheck, setRdAutoInstantCheck] = useLocalStorage( + 'rdAutoInstantCheck', + false + ); + const [adAutoInstantCheck, setAdAutoInstantCheck] = useLocalStorage( + 'adAutoInstantCheck', + false + ); + const [movieInfo, setMovieInfo] = useState(null); + + const router = useRouter(); + const { imdbid } = router.query; + + const fetchMovieInfo = async (imdbId: string) => { + try { + const response = await axios.get( + `https://api.themoviedb.org/3/find/${imdbId}?api_key=${getTmdbKey()}&external_source=imdb_id` + ); + setMovieInfo(response.data); + } catch (error) { + setErrorMessage('There was an error fetching movie info. Please try again.'); + console.error(`error fetching movie data`, error); + } + }; + + useEffect(() => { + if (imdbid) { + fetchMovieInfo(imdbid as string); + fetchData(imdbid as string); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [imdbid]); + + const fetchData = async (imdbId: string) => { + setSearchResults([]); + setErrorMessage(''); + setLoading(true); + try { + let endpoint = `/api/moviesearch?imdbId=${encodeURIComponent(imdbId)}`; + const response = await axios.get(endpoint); + + setSearchResults( + response.data.results?.map((r) => ({ + ...r, + available: 'unavailable', + })) || [] + ); + + if (response.data.results?.length) { + toast(`Found ${response.data.results.length} results`, { icon: '🔍' }); + + // instant checks + const hashArr = response.data.results.map((r) => r.hash); + if (rdKey && rdAutoInstantCheck) await instantCheckInRd(hashArr); + if (adKey && adAutoInstantCheck) await instantCheckInAd(hashArr); + } else { + toast(`No results found`, { icon: '🔍' }); + } + } catch (error) { + console.error(error); + setErrorMessage('There was an error searching for the query. Please try again.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + setSearchResults((prev) => + prev.map((r) => ({ + ...r, + available: masterAvailability[r.hash], + })) + ); + }, [masterAvailability]); + + const instantCheckInRd = async (hashes: string[]): Promise => { + const rdAvailability = {} as HashAvailability; + + const setInstantFromRd = (resp: RdInstantAvailabilityResponse) => { + for (const masterHash in resp) { + if ('rd' in resp[masterHash] === false) continue; + if (masterAvailability[masterHash] === 'no_videos') continue; + const variants = resp[masterHash]['rd']; + if (!variants.length) rdAvailability[masterHash] = 'no_videos'; + for (const variant of variants) { + for (const fileId in variant) { + const file = variant[fileId]; + if (isVideo({ path: file.filename })) { + rdAvailability[masterHash] = + masterAvailability[masterHash] === 'ad:available' || + masterAvailability[masterHash] === 'all:available' + ? 'all:available' + : 'rd:available'; + + break; + } + } + } + } + }; + + try { + for (const hashGroup of groupBy(100, hashes)) { + if (rdKey) await rdInstantCheck(rdKey, hashGroup).then(setInstantFromRd); + } + toast( + `Found ${ + Object.values(rdAvailability).filter((a) => a.includes(':available')).length + } available in RD`, + { icon: '🔍' } + ); + setMasterAvailability({ ...masterAvailability, ...rdAvailability }); + } catch (error) { + toast.error( + 'There was an error checking availability in Real-Debrid. Please try again.' + ); + throw error; + } + }; + + const instantCheckInAd = async (hashes: string[]): Promise => { + const adAvailability = {} as HashAvailability; + + const setInstantFromAd = (resp: AdInstantAvailabilityResponse) => { + for (const magnetData of resp.data.magnets) { + const masterHash = magnetData.hash; + if (masterAvailability[masterHash] === 'no_videos') continue; + if (magnetData.instant) { + adAvailability[masterHash] = magnetData.files?.reduce( + (acc: boolean, curr: MagnetFile) => { + if (isVideo({ path: curr.n })) { + return true; + } + return acc; + }, + false + ) + ? masterAvailability[masterHash] === 'rd:available' || + masterAvailability[masterHash] === 'all:available' + ? 'all:available' + : 'ad:available' + : 'no_videos'; + } + } + }; + + try { + for (const hashGroup of groupBy(30, hashes)) { + if (adKey) await adInstantCheck(adKey, hashGroup).then(setInstantFromAd); + } + toast( + `Found ${ + Object.values(adAvailability).filter((a) => a.includes(':available')).length + } available in AD`, + { icon: '🔍' } + ); + setMasterAvailability({ ...masterAvailability, ...adAvailability }); + } catch (error) { + toast.error('There was an error checking availability in AllDebrid. Please try again.'); + throw error; + } + }; + + const handleAddAsMagnetInRd = async ( + hash: string, + instantDownload: boolean = false, + disableToast: boolean = false + ) => { + try { + if (!rdKey) throw new Error('no_rd_key'); + const id = await addHashAsMagnet(rdKey, hash); + if (!disableToast) toast.success('Successfully added as magnet!'); + rdCacheAdder.single(`rd:${id}`, hash, instantDownload ? 'downloaded' : 'downloading'); + handleSelectFiles(`rd:${id}`, true); // add rd: to account for substr(3) in handleSelectFiles + } catch (error) { + if (!disableToast) + toast.error('There was an error adding as magnet. Please try again.'); + throw error; + } + }; + + const handleAddAsMagnetInAd = async ( + hash: string, + instantDownload: boolean = false, + disableToast: boolean = false + ) => { + try { + if (!adKey) throw new Error('no_ad_key'); + const resp = await uploadMagnet(adKey, [hash]); + if (resp.data.magnets.length === 0 || resp.data.magnets[0].error) + throw new Error('no_magnets'); + if (!disableToast) toast.success('Successfully added as magnet!'); + adCacheAdder.single( + `ad:${resp.data.magnets[0].id}`, + hash, + instantDownload ? 'downloaded' : 'downloading' + ); + } catch (error) { + if (!disableToast) + toast.error('There was an error adding as magnet. Please try again.'); + throw error; + } + }; + + const isAvailableInRd = (result: SearchResult) => + result.available === 'rd:available' || result.available === 'all:available'; + const isAvailableInAd = (result: SearchResult) => + result.available === 'ad:available' || result.available === 'all:available'; + const hasNoVideos = (result: SearchResult) => result.available === 'no_videos'; + + const handleDeleteTorrent = async (id: string, disableToast: boolean = false) => { + try { + if (!rdKey && !adKey) throw new Error('no_keys'); + if (rdKey && id.startsWith('rd:')) await deleteTorrent(rdKey, id.substring(3)); + if (adKey && id.startsWith('ad:')) await deleteMagnet(adKey, id.substring(3)); + if (!disableToast) toast.success(`Download canceled (${id})`); + if (id.startsWith('rd:')) removeFromRdCache(id); + if (id.startsWith('ad:')) removeFromAdCache(id); + } catch (error) { + if (!disableToast) toast.error(`Error deleting torrent (${id})`); + throw error; + } + }; + + const handleSelectFiles = async (id: string, disableToast: boolean = false) => { + try { + if (!rdKey) throw new Error('no_rd_key'); + const response = await getTorrentInfo(rdKey, id.substring(3)); + if (response.filename === 'Magnet') return; // no files yet + + const selectedFiles = getSelectableFiles(response.files.filter(isVideo)).map( + (file) => file.id + ); + if (selectedFiles.length === 0) { + handleDeleteTorrent(id, true); + throw new Error('no_files_for_selection'); + } + + await selectFiles(rdKey, id.substring(3), selectedFiles); + } catch (error) { + if ((error as Error).message === 'no_files_for_selection') { + if (!disableToast) + toast.error(`No files for selection, deleting (${id})`, { + duration: 5000, + }); + } else { + if (!disableToast) toast.error(`Error selecting files (${id})`); + } + throw error; + } + }; + + return ( +
+ + + Debrid Media Manager - Movie - {movieInfo?.movie_results[0].title} ( + {movieInfo?.movie_results[0].release_date.substring(0, 4)}) + + + +
+

+ {movieInfo?.movie_results[0].title}{' '} + {movieInfo?.movie_results[0].release_date.substring(0, 4)} +

+ + Go Home + +
+ {/* Display basic movie info */} + {movieInfo && ( +
+
+ Movie poster +
+
+

{movieInfo.movie_results[0].title}

+

{movieInfo.movie_results[0].overview}

+
+
+ )} + +
+ + {loading && ( +
+
+
+ )} + {errorMessage && ( +
+ Error: + {errorMessage} +
+ )} + {searchResults.length > 0 && ( + <> + {!loading && ( +
+ + { + const isChecked = event.target.checked; + setRdAutoInstantCheck(isChecked); + }} + />{' '} + + + { + const isChecked = event.target.checked; + setAdAutoInstantCheck(isChecked); + }} + />{' '} + +
+ )} +
+
+ {!loading && + searchResults.map((r: SearchResult) => ( +
+
+

+ {r.title} +

+

+ Size: {(r.fileSize / 1024).toFixed(2)} GB +

+
+ {rd.isDownloading(r.hash) && + rdCache![r.hash].id && ( + + )} + {rdKey && rd.notInLibrary(r.hash) && ( + + )} + {ad.isDownloading(r.hash) && + adCache![r.hash].id && ( + + )} + {adKey && ad.notInLibrary(r.hash) && ( + + )} +
+
+
+ ))} +
+
+ + )} + {Object.keys(router.query).length !== 0 && searchResults.length === 0 && !loading && ( + <> +

+ Processing search request for "{movieInfo?.movie_results[0].title} + ". Try again in a few minutes. +

+ + )} +
+ ); +} + +export default withAuth(Search); diff --git a/src/pages/search-v2.tsx b/src/pages/search-v2.tsx new file mode 100644 index 0000000..cfe2420 --- /dev/null +++ b/src/pages/search-v2.tsx @@ -0,0 +1,162 @@ +import TMDBPoster from '@/components/poster'; +import { withAuth } from '@/utils/withAuth'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useCallback, useEffect, useState } from 'react'; +import { Toaster } from 'react-hot-toast'; + +type SearchResult = { + id: string; + type: 'movie' | 'show'; + year: number; + score: number; + title: string; + imdbid: string; + tmdbid: number; + tvdbid?: number; + traktid?: number; + season_count?: number; + score_average: number; +}; + +function Search() { + const [query, setQuery] = useState(''); + const [typedQuery, setTypedQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + + const router = useRouter(); + + const fetchData = async (query: string) => { + setLoading(true); + try { + const res = await fetch(`/api/keywordsearch?keyword=${query}`); + const data = await res.json(); + setSearchResults(data.results); + } catch (error: any) { + setErrorMessage(error.message); + } finally { + setLoading(false); + } + }; + + const handleSubmit = useCallback( + (e?: React.FormEvent) => { + if (e) e.preventDefault(); + if (!typedQuery) return; + router.push({ + query: { query: typedQuery }, + }); + }, + [router, typedQuery] + ); + + useEffect(() => { + const { query: searchQuery } = router.query; + if (!searchQuery) return; + const decodedQuery = decodeURIComponent(searchQuery as string); + if (decodedQuery === query) return; + setTypedQuery(decodedQuery); + setQuery(decodedQuery); + fetchData(decodedQuery); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.query]); + + return ( +
+ + Debrid Media Manager - Search: {query} + + +
+

Search

+ + Go Home + +
+
+
+ setTypedQuery(e.target.value)} + /> + +
+
+ {loading && ( +
+
+
+ )} + {errorMessage && ( +
+ Error: + {errorMessage} +
+ )} + {searchResults.length > 0 && ( + <> +

+ Search Results for "{query}" +

+
+ {searchResults.map((result) => ( +
+ +

{result.title}

+

Year: {result.year}

+

Score: {result.score}

+ {result.type === 'movie' ? ( + + View More + + ) : ( + <> + {Array.from( + { length: result.season_count || 0 }, + (_, i) => i + 1 + ).map((season) => ( + + View Season {season} + + ))} + + )} +
+ ))} +
+ + )} + {Object.keys(router.query).length !== 0 && searchResults.length === 0 && !loading && ( + <> +

+ No results found for "{query}" +

+ + )} +
+ ); +} + +export default withAuth(Search); diff --git a/src/services/btdigg-v2.ts b/src/services/btdigg-v2.ts index 1be55cb..144f221 100644 --- a/src/services/btdigg-v2.ts +++ b/src/services/btdigg-v2.ts @@ -43,40 +43,9 @@ export const flattenAndRemoveDuplicates = (arr: SearchResult[][]): SearchResult[ }; export const groupByParsedTitle = (results: SearchResult[]): SearchResult[] => { - const frequency: Record = {}; - const infoCache: Record = {}; - const getInfoOrTitle = (result: SearchResult) => { - if (result.hash in infoCache && infoCache[result.hash].title) { - return infoCache[result.hash]; - } - return result.title; - } - - for (const result of results) { - // Use the cached info object if it exists, otherwise compute it and store it in the cache - if (!(result.hash in infoCache)) { - infoCache[result.hash] = getMediaType(result.title) === 'movie' ? filenameParse(result.title) : filenameParse(result.title, true); - } - - const mediaId = getMediaId(getInfoOrTitle(result), getMediaType(result.title), true); - - if (mediaId in frequency) { - frequency[mediaId] += result.fileSize; - } else { - frequency[mediaId] = result.fileSize; - } - } - results.sort((a, b) => { - const frequencyCompare = - frequency[getMediaId(getInfoOrTitle(b), getMediaType(b.title), true)] - - frequency[getMediaId(getInfoOrTitle(a), getMediaType(a.title), true)]; - if (frequencyCompare === 0) { - return b.fileSize - a.fileSize; - } - return frequencyCompare; + return b.fileSize - a.fileSize; }); - return results; }; diff --git a/src/services/planetscale.ts b/src/services/planetscale.ts index 09de222..cca38ce 100644 --- a/src/services/planetscale.ts +++ b/src/services/planetscale.ts @@ -1,70 +1,98 @@ import { PrismaClient } from '@prisma/client'; export class PlanetScaleCache { - private prisma: PrismaClient; - - constructor() { - this.prisma = new PrismaClient(); - this.prisma.$queryRaw`SET @@boost_cached_queries = true` - } - - public async cacheJsonValue(key: string[], value: T) { - const sortedKey = key.sort(); - const planetScaleKey = sortedKey.join(':'); - - await this.prisma.cache.upsert({ - where: { key: planetScaleKey }, - update: { value } as any, - create: { key: planetScaleKey, value } as any, - }); - } - - public async getCachedJsonValue(key: string[]): Promise { - const sortedKey = key.sort(); - const planetScaleKey = sortedKey.join(':'); - - const cacheEntry = await this.prisma.cache.findUnique({ where: { key: planetScaleKey } }); - return cacheEntry?.value as T | undefined; - } - - public async deleteCachedJsonValue(key: string[]): Promise { - const sortedKey = key.sort(); - const planetScaleKey = sortedKey.join(':'); - - await this.prisma.cache.delete({ where: { key: planetScaleKey } }); - } - - public async getDbSize(): Promise { - const count = await this.prisma.cache.count(); - return count; - } - - /// - - public async saveScrapedResults(key: string, value: T) { - await this.prisma.scraped.upsert({ - where: { key }, - update: { value } as any, - create: { key, value } as any, - }); - } - - public async getScrapedResults(key: string): Promise { - const cacheEntry = await this.prisma.scraped.findUnique({ where: { key } }); - return cacheEntry?.value as T | undefined; - } - - public async getScrapedDbSize(): Promise { - const count = await this.prisma.scraped.count(); - return count; - } - - public async keyExists(key: string): Promise { - const cacheEntry = await this.prisma.scraped.findFirst({ - where: { key }, - select: { key: true }, - }); - return cacheEntry !== null; - } + private prisma: PrismaClient; + constructor() { + this.prisma = new PrismaClient(); + this.prisma.$queryRaw`SET @@boost_cached_queries = true`; + } + + public async cacheJsonValue(key: string[], value: T) { + const sortedKey = key.sort(); + const planetScaleKey = sortedKey.join(':'); + + await this.prisma.cache.upsert({ + where: { key: planetScaleKey }, + update: { value } as any, + create: { key: planetScaleKey, value } as any, + }); + } + + public async getCachedJsonValue(key: string[]): Promise { + const sortedKey = key.sort(); + const planetScaleKey = sortedKey.join(':'); + + const cacheEntry = await this.prisma.cache.findUnique({ where: { key: planetScaleKey } }); + return cacheEntry?.value as T | undefined; + } + + public async deleteCachedJsonValue(key: string[]): Promise { + const sortedKey = key.sort(); + const planetScaleKey = sortedKey.join(':'); + + await this.prisma.cache.delete({ where: { key: planetScaleKey } }); + } + + public async getDbSize(): Promise { + const count = await this.prisma.cache.count(); + return count; + } + + /// scraped results + + public async saveScrapedResults(key: string, value: T) { + await this.prisma.scraped.upsert({ + where: { key }, + update: { value } as any, + create: { key, value } as any, + }); + } + + public async getScrapedResults(key: string): Promise { + const cacheEntry = await this.prisma.scraped.findUnique({ where: { key } }); + return cacheEntry?.value as T | undefined; + } + + public async getScrapedDbSize(): Promise { + const count = await this.prisma.scraped.count(); + return count; + } + + public async keyExists(key: string): Promise { + const cacheEntry = await this.prisma.scraped.findFirst({ + where: { key }, + select: { key: true }, + }); + return cacheEntry !== null; + } + + // search results + + public async saveSearchResults(key: string, value: T) { + await this.prisma.search.upsert({ + where: { key }, + update: { value } as any, + create: { key, value } as any, + }); + } + + public async getSearchResults(key: string): Promise { + const cacheEntry = await this.prisma.search.findUnique({ where: { key } }); + + if (cacheEntry) { + const updatedAt = cacheEntry.updatedAt; + const now = new Date(); + const differenceInHours = + Math.abs(now.getTime() - updatedAt.getTime()) / 1000 / 60 / 60; + + if (differenceInHours > 24) { + return undefined; + } else { + return cacheEntry.value as T; + } + } + + return undefined; + } } diff --git a/src/utils/freekeys.ts b/src/utils/freekeys.ts new file mode 100644 index 0000000..a869710 --- /dev/null +++ b/src/utils/freekeys.ts @@ -0,0 +1,21 @@ +let tmdb_keys = [ + 'fb7bb23f03b6994dafc674c074d01761', + 'e55425032d3d0f371fc776f302e7c09b', + '8301a21598f8b45668d5711a814f01f6', + '8cf43ad9c085135b9479ad5cf6bbcbda', + 'da63548086e399ffc910fbc08526df05', + '13e53ff644a8bd4ba37b3e1044ad24f3', + '269890f657dddf4635473cf4cf456576', + 'a2f888b27315e62e471b2d587048f32e', + '8476a7ab80ad76f0936744df0430e67c', + '5622cafbfe8f8cfe358a29c53e19bba0', + 'ae4bd1b6fce2a5648671bfc171d15ba4', + '257654f35e3dff105574f97fb4b97035', + '2f4038e83265214a0dcd6ec2eb3276f5', + '9e43f45f94705cc8e1d5a0400d19a7b7', + 'af6887753365e14160254ac7f4345dd2', + '06f10fc8741a672af455421c239a1ffc', + 'fb7bb23f03b6994dafc674c074d01761', + '09ad8ace66eec34302943272db0e8d2c' +]; +export const getTmdbKey = () => tmdb_keys[Math.floor(Math.random() * tmdb_keys.length)];