diff --git a/src/index.js b/src/index.js index 650971f..de90551 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ const CREDENTIALS = { - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", - refreshToken: "YOUR_REFRESH_TOKEN", + clientId: "", + clientSecret: "", + refreshToken: "", }; const CONFIG = { @@ -28,6 +28,9 @@ const CONFIG = { addonName: "GDrive", prioritiseLanguage: null, proxiedPlayback: true, + tmdbApiKey: null, + enableSearchCatalog: true, + enableVideoCatalog: true, driveQueryTerms: { episodeFormat: "fullText", titleName: "name", @@ -40,7 +43,12 @@ const MANIFEST = { name: CONFIG.addonName, description: "Stream your files from Google Drive within Stremio!", catalogs: [], - resources: ["stream"], + resources: [ + { + name: "stream", + types: ["movie", "series", "anime"], + }, + ], types: ["movie", "series"], }; @@ -53,42 +61,47 @@ const HEADERS = { const API_ENDPOINTS = { DRIVE_FETCH_FILES: "https://content.googleapis.com/drive/v3/files", + DRIVE_FETCH_FILE: "https://content.googleapis.com/drive/v3/files/{fileId}", DRIVE_STREAM_FILE: "https://www.googleapis.com/drive/v3/files/{fileId}?alt=media&file_name={filename}", DRIVE_TOKEN: "https://oauth2.googleapis.com/token", CINEMETA: "https://v3-cinemeta.strem.io/meta/{type}/{id}.json", IMDB_SUGGEST: "https://v3.sg.media-imdb.com/suggestion/a/{id}.json", + TMDB_FIND: + "https://api.themoviedb.org/3/find/{id}?api_key={apiKey}&external_source=imdb_id", }; const REGEX_PATTERNS = { validStreamRequest: /\/stream\/(movie|series)\/([a-zA-Z0-9%:]+)\.json/, validPlaybackRequest: /\/playback\/([a-zA-Z0-9_-]+)\/(.+)/, + validCatalogRequest: + /\/catalog\/movie\/([a-zA-Z0-9%:_]+)(\/search=(.+))?\.json/, + validMetaRequest: /\/meta\/(movie)\/([a-zA-Z0-9%:_]+)\.json/, resolutions: { - '2160p': /(? ( + const getIndexOfTag = (tag) => CONFIG.considerHdrTagsAsEqual && tag.startsWith("HDR") ? CONFIG.visualTags.indexOf("HDR10+") - : CONFIG.visualTags.indexOf(tag) - ) + : CONFIG.visualTags.indexOf(tag); const aVisualTagIndex = a.visualTags.reduce( - (minIndex, tag) => - Math.min(minIndex, getIndexOfTag(tag)), + (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), CONFIG.visualTags.length ); const bVisualTagIndex = b.visualTags.reduce( - (minIndex, tag) => - Math.min(minIndex, getIndexOfTag(tag)), + (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), CONFIG.visualTags.length ); // Sort by the visual tag index @@ -253,21 +264,26 @@ function compareByField(a, b, field) { } function createStream(parsedFile, accessToken) { - - let name = parsedFile.type.startsWith("audio") + let name = parsedFile.type.startsWith("audio") ? `[šŸŽµ Audio] ${MANIFEST.name} ${parsedFile.extension.toUpperCase()}` : `${MANIFEST.name} ${parsedFile.resolution}`; - let description = `šŸŽ„ ${parsedFile.quality} ${parsedFile.encode ? 'šŸŽžļø ' + parsedFile.encode : ''}`; + let description = `šŸŽ„ ${parsedFile.quality} ${ + parsedFile.encode ? "šŸŽžļø " + parsedFile.encode : "" + }`; if (parsedFile.visualTags.length > 0 || parsedFile.audioTags.length > 0) { - description += "\n"; + description += "\n"; - description += - parsedFile.visualTags.length > 0 ? `šŸ“ŗ ${parsedFile.visualTags.join(" | ")} ` : ""; - description += - parsedFile.audioTags.length > 0 ? `šŸŽ§ ${parsedFile.audioTags.join(" | ")}` : ""; - } + description += + parsedFile.visualTags.length > 0 + ? `šŸ“ŗ ${parsedFile.visualTags.join(" | ")} ` + : ""; + description += + parsedFile.audioTags.length > 0 + ? `šŸŽ§ ${parsedFile.audioTags.join(" | ")}` + : ""; + } description += `\nšŸ“¦ ${parsedFile.formattedSize}`; if (parsedFile.languages.length !== 0) { @@ -340,7 +356,8 @@ function sortParsedFiles(parsedFiles) { // move audio files to the end if (a.type.startsWith("audio") && !b.type.startsWith("audio")) return 1; - if (!a.type.startsWith("audio") && b.type.startsWith("audio")) return -1; + if (!a.type.startsWith("audio") && b.type.startsWith("audio")) + return -1; return 0; }); @@ -394,14 +411,17 @@ function parseFile(file) { id: file.id, name: file.name.trim(), size: file.size, - formattedSize: parseInt(file.size) ? formatSize(parseInt(file.size)) : "Unknown", + formattedSize: parseInt(file.size) + ? formatSize(parseInt(file.size)) + : "Unknown", resolution: resolution, quality: quality, languages: languages, encode: encode, audioTags: audioTags, visualTags: visualTags, - duration: parseInt(file.videoMediaMetadata?.durationMillis) || undefined, + duration: + parseInt(file.videoMediaMetadata?.durationMillis) || undefined, type: file.mimeType, extension: file.fileExtension, }; @@ -437,7 +457,14 @@ function isConfigValid() { const validValues = { resolutions: [...Object.keys(REGEX_PATTERNS.resolutions), "Unknown"], qualities: [...Object.keys(REGEX_PATTERNS.qualities), "Unknown"], - sortBy: ["resolution", "size", "quality", "visualTag", "durationAsc", "durationDesc"], + sortBy: [ + "resolution", + "size", + "quality", + "visualTag", + "durationAsc", + "durationDesc", + ], languages: [...Object.keys(REGEX_PATTERNS.languages), "Unknown"], visualTags: [...Object.keys(REGEX_PATTERNS.visualTags)], }; @@ -480,9 +507,45 @@ function isConfigValid() { return true; } -async function getMetadata(type, id) { - let meta; +async function getMetadata(type, fullId) { + let id = fullId + if (id.startsWith("kitsu")) { + id = id.split(":")[0] + ":" + id.split(":")[1]; // Remove the :1 at the end + const meta = await getKitsuMeta(type, id); + if (meta) { + console.log({ + message: "Successfully retrieved metadata from Kitsu", + meta, + }); + return meta; + } + + console.error({ + message: "Failed to get metadata from Kitsu, returning null", + }); + + return null; + + } + + if (CONFIG.tmdbApiKey) { + try { + meta = await getTmdbMeta(type, id); + if (meta) { + console.log({ + message: "Successfully retrieved metadata from TMDb", + meta, + }); + return meta; + } + } catch (error) { + console.error({ + message: "Error fetching metadata from TMDb", + error: error.toString(), + }); + } + } try { meta = await getCinemetaMeta(type, id); if (meta) { @@ -523,6 +586,62 @@ async function getMetadata(type, id) { return null; } + +async function getKitsuMeta(type, id) { + console.log({ message: "Fetching metadata from Kitsu", type, id }); + const url = `https://anime-kitsu.strem.fun/meta/${type}/${id}.json`; + console.log({ url }); + const response = await fetch( + url + ); + if (!response.ok) { + let err = await response.text(); + throw new Error(err); + } + const data = await response.json(); + if (!data?.meta) { + throw new Error("Meta object not found in response"); + } + if (!data.meta.name || !data.meta.year) { + throw new Error("Either name or year not found in meta object"); + } + return { + name: data.meta.name, + year: data.meta.year, + }; +} + +async function getTmdbMeta(type, id) { + const response = await fetch( + API_ENDPOINTS.TMDB_FIND.replace("{id}", id).replace( + "{apiKey}", + CONFIG.tmdbApiKey + ) + ); + if (!response.ok) { + let err = await response.text(); + throw new Error(`${response.status} - ${response.statusText}: ${err}`); + } + const data = await response.json(); + if (!data?.movie_results && !data?.tv_results) { + throw new Error("No results found in response"); + } + + const result = data.movie_results[0] || data.tv_results[0]; + if (!result) { + throw new Error("No results found in response"); + } + + if (!result?.title || !result?.release_date) { + throw new Error("Either title or release date not found in result"); + } + + return { + name: result.title, + year: result.release_date.split("-")[0], + }; +} + async function getCinemetaMeta(type, id) { const response = await fetch( API_ENDPOINTS.CINEMETA.replace("{type}", type).replace("{id}", id) @@ -625,19 +744,68 @@ async function fetchFiles(fetchUrl, accessToken) { } } +async function fetchFile(fileId, accessToken) { + try { + const fetchUrl = new URL(API_ENDPOINTS.DRIVE_FETCH_FILE.replace("{fileId}", fileId)); + const searchParams = { + supportsAllDrives: true, + fields: "id,name,mimeType,size,videoMediaMetadata,fileExtension", + } + fetchUrl.search = new URLSearchParams(searchParams).toString(); + const response = await fetch( + fetchUrl.toString(), + { + headers: { Authorization: `Bearer ${accessToken}` }, + } + ); + + if (!response.ok) { + let err = await response.text(); + throw new Error(err); + } + + const file = await response.json(); + return file; + } catch (error) { + console.error({ + message: "Could not fetch file from Google Drive", + error: error.toString(), + }); + return null; + } +} + +function buildBaseSearchQuery(query) { + let q = `name contains '${query}' and trashed=false and not name contains 'trailer' and not name contains 'sample'`; + + if (CONFIG.showAudioFiles) { + q += ` and (mimeType contains 'video/' or mimeType contains 'audio/')`; + } else { + q += ` and mimeType contains 'video/'`; + } + + return q; +} + async function buildSearchQuery(streamRequest) { const { name, year } = streamRequest.metadata; let query = "trashed=false and not name contains 'trailer' and not name contains 'sample'"; - query += CONFIG.showAudioFiles ? ` and (mimeType contains 'video/' or mimeType contains 'audio/')` : ` and mimeType contains 'video/'`; - - const sanitisedName = name.replace(/[^a-zA-Z0-9'\s]/g, "").replace(/'/g, "\\'"); + query += CONFIG.showAudioFiles + ? ` and (mimeType contains 'video/' or mimeType contains 'audio/')` + : ` and mimeType contains 'video/'`; + + const sanitisedName = name + .replace(/[^\p{L}\p{N}\s]/gu, "") + .replace(/'/g, "\\'"); const nameWithoutApostrophes = name.replace(/[^a-zA-Z0-9\s]/g, ""); - if (streamRequest.type === "movie") (query += ` and (${CONFIG.driveQueryTerms.titleName} contains '${sanitisedName} ${year}' or ${CONFIG.driveQueryTerms.titleName} contains '${nameWithoutApostrophes} ${year}')`); - if (streamRequest.type === "series") (query += ` and (${CONFIG.driveQueryTerms.titleName} contains '${sanitisedName}' or ${CONFIG.driveQueryTerms.titleName} contains '${nameWithoutApostrophes}')`); + if (streamRequest.type === "movie") + query += ` and (${CONFIG.driveQueryTerms.titleName} contains '${sanitisedName} ${year}' or ${CONFIG.driveQueryTerms.titleName} contains '${nameWithoutApostrophes} ${year}')`; + if (streamRequest.type === "series") + query += ` and (${CONFIG.driveQueryTerms.titleName} contains '${sanitisedName}' or ${CONFIG.driveQueryTerms.titleName} contains '${nameWithoutApostrophes}')`; const season = streamRequest.season; const episode = streamRequest.episode; @@ -698,8 +866,43 @@ async function handleRequest(request) { ); globalThis.playbackUrl = url.origin + "/playback"; - if (url.pathname === "/manifest.json") - return createJsonResponse(MANIFEST); + if (url.pathname === "/manifest.json") { + const manifest = MANIFEST; + manifest.catalogs = []; + manifest.resources = [{ name: "stream", types: ["movie", "series", "anime"] }]; + if (CONFIG.enableSearchCatalog) { + manifest.catalogs.push({ + type: "movie", + id: "gdrive_list", + name: "Google Drive", + }); + } + if (CONFIG.enableVideoCatalog) { + manifest.catalogs.push({ + type: "movie", + id: "gdrive_search", + name: "Google Drive Search", + extra: [ + { + name: "search", + isRequired: true, + }, + ], + }); + } + if (CONFIG.enableVideoCatalog || CONFIG.enableSearchCatalog) { + manifest.resources.push({ + name: "catalog", + types: ["movie"], + }); + manifest.resources.push({ + name: "meta", + types: ["movie", "series", "anime"], + idPrefixes: ["gdrive:"], + }); + } + return createJsonResponse(manifest); + } if (url.pathname === "/") return Response.redirect(url.origin + "/manifest.json", 301); @@ -710,8 +913,12 @@ async function handleRequest(request) { const playbackMatch = REGEX_PATTERNS.validPlaybackRequest.exec( url.pathname ); + const catalogMatch = REGEX_PATTERNS.validCatalogRequest.exec( + url.pathname + ); + const metaMatch = REGEX_PATTERNS.validMetaRequest.exec(url.pathname); - if (!(playbackMatch || streamMatch)) + if (!(playbackMatch || streamMatch || catalogMatch || metaMatch)) return new Response("Bad Request", { status: 400 }); if (!isConfigValid()) { @@ -735,16 +942,177 @@ async function handleRequest(request) { return createProxiedStreamResponse(fileId, filename, request); } - const type = streamMatch[1]; - const [imdbId, season, episode] = streamMatch[2].split(":"); + if (metaMatch) { + const fileId = metaMatch[2]; + if (!fileId) { + console.error({ + message: "Failed to extract file ID", + error: "File ID is undefined", + }); + return null; + } + const gdriveId = fileId.split(":")[1]; + const accessToken = await getAccessToken(); + if (!accessToken) { + console.error({ + message: "Failed to get access token", + error: "Access token is undefined", + }); + return null; + } + console.log({message: "Meta request", fileId, gdriveId}); + const file = await fetchFile(gdriveId, accessToken); + if (!file) { + console.error({ + message: "Failed to fetch file", + error: "File is undefined", + }); + return null; + } + console.log({message: "File fetched", file}); + const parsedFile = parseFile(file); + return createJsonResponse({ + meta: { + id: fileId, + name: parsedFile.name, + description: `Name: ${parsedFile.name}\nSize: ${parsedFile.formattedSize}`, + type: "movie", + }, + }); + } - const metadata = await getMetadata(type, imdbId); + if (catalogMatch) { + // handle catalogs + const catalogId = catalogMatch[1]; + const searchQuery = catalogMatch[2]; + const searchTerm = searchQuery ? searchQuery.split("=")[1] : null; + + console.log({ message: "Catalog request", catalogId, searchTerm }); + + if (catalogId === "gdrive_list") { + // get list of all video files + const queryParams = { + q: "mimeType contains 'video/'", + corpora: "allDrives", + includeItemsFromAllDrives: "true", + supportsAllDrives: "true", + pageSize: "1000", + fields: "files(id,name,size,videoMediaMetadata,mimeType,fileExtension)", + }; + + const fetchUrl = new URL(API_ENDPOINTS.DRIVE_FETCH_FILES); + fetchUrl.search = new URLSearchParams(queryParams).toString(); + + const accessToken = await getAccessToken(); + + if (!accessToken) { + return createJsonResponse({ + error: "Invalid Credentials\nEnable and check the logs for more information\nClick for setup instructions", + }); + } + + const results = await fetchFiles(fetchUrl, accessToken); + const metas = results.files.map((file) => ({ + id: `gdrive:${file.id}`, + name: file.name, + description: `Name: ${file.name}\nSize: ${formatSize( + file.size + )}`, + type: "movie", + })); + + return createJsonResponse({ metas }); + } + + if (catalogId === "gdrive_search") { + if (!searchTerm) { + return createJsonResponse({ metas: [] }); + } + + const queryParams = { + q: buildBaseSearchQuery(searchTerm), + corpora: "allDrives", + includeItemsFromAllDrives: "true", + supportsAllDrives: "true", + pageSize: "1000", + fields: "files(id,name,size,videoMediaMetadata,mimeType,fileExtension)", + }; + + const fetchUrl = new URL(API_ENDPOINTS.DRIVE_FETCH_FILES); + fetchUrl.search = new URLSearchParams(queryParams).toString(); + + const accessToken = await getAccessToken(); + + if (!accessToken) { + return createJsonResponse({ + error: "Invalid Credentials\nEnable and check the logs for more information\nClick for setup instructions", + }); + } + + const results = await fetchFiles(fetchUrl, accessToken); + + if (!results?.files || results.files.length === 0) { + return createJsonResponse({ metas: [] }); + } + + const metas = results.files.map((file) => ({ + id: `gdrive:${file.id}`, + name: file.name, + description: `Name: ${file.name}\nSize: ${formatSize( + file.size + )}`, + type: "movie", + })); + + return createJsonResponse({ metas }); + } + + return createJsonResponse({ metas: [] }); + } + + const type = streamMatch[1]; + + const fullId = streamMatch[2]; + let [season, episode] = fullId.split(":").slice(-2); + console.log({ message: "Stream request", type, fullId, season, episode }); + if (fullId.startsWith("kitsu")) { + season = 1; + } + + if (fullId.startsWith("gdrive")) { + const fileId = streamMatch[2].split(":")[1]; + const accessToken = await getAccessToken(); + if (!accessToken) { + console.error({ + message: "Failed to get access token", + error: "Access token is undefined", + }); + return null; + } + + const file = await fetchFile(fileId, accessToken); + if (!file) { + console.error({ + message: "Failed to fetch file", + error: "File is undefined", + }); + return null; + } + + const parsedFile = parseFile(file); + return createJsonResponse({ + streams: [createStream(parsedFile, accessToken)], + }); + + } + + const metadata = await getMetadata(type, fullId); if (!metadata) return createJsonResponse({ streams: [] }); const parsedStreamRequest = { type: type, - id: imdbId, + id: fullId, season: parseInt(season) || undefined, episode: parseInt(episode) || undefined, metadata: metadata, @@ -886,7 +1254,13 @@ async function getStreams(streamRequest) { } export default { - async fetch(request) { + async fetch(request, env, ctx) { + CREDENTIALS.clientId = CREDENTIALS.clientId || env.CLIENT_ID; + CREDENTIALS.clientSecret = + CREDENTIALS.clientSecret || env.CLIENT_SECRET; + CREDENTIALS.refreshToken = + CREDENTIALS.refreshToken || env.REFRESH_TOKEN; + CONFIG.tmdbApiKey = CONFIG.tmdbApiKey || env.TMDB_API_KEY; return handleRequest(request); }, };