├── bun.lockb ├── README.md ├── src ├── types │ ├── catalog.ts │ ├── subtitle.ts │ ├── libs.ts │ └── stream.ts ├── utils │ ├── string.ts │ └── torrent.ts ├── routes │ ├── catalog.ts │ └── stream.ts ├── index.ts └── libs │ ├── thePiratesBay.ts │ ├── yts.ts │ ├── imdb.ts │ ├── eztv.ts │ └── 1337x.ts ├── worker-configuration.d.ts ├── tsconfig.json ├── package.json ├── .gitignore └── wrangler.toml /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slmnsh/stremio-addon/main/bun.lockb -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | npm install 3 | npm run dev 4 | ``` 5 | 6 | ``` 7 | npm run deploy 8 | ``` 9 | -------------------------------------------------------------------------------- /src/types/catalog.ts: -------------------------------------------------------------------------------- 1 | export type CatalogObject = { 2 | type: string; 3 | id: string; 4 | name: string; 5 | poster: string; 6 | genres: string[]; 7 | }; 8 | -------------------------------------------------------------------------------- /worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types --env-interface CloudflareBindings` 2 | 3 | interface CloudflareBindings { 4 | imdb: KVNamespace; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | export const quality = ["2160p", "1080p", "720p", "480p"] as const; 2 | export const qualityRegex = new RegExp(quality.join("|"), "g"); 3 | export const hdrRegex = /HDR/i 4 | 5 | export function formatSeasonEpisode(season: string, episode: string) { 6 | season = "S" + season.padStart(2, "0"); 7 | episode = "E" + episode.padStart(2, "0"); 8 | return [season, episode]; 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "lib": [ 9 | "ESNext" 10 | ], 11 | "types": [ 12 | "@cloudflare/workers-types/2023-07-01" 13 | ], 14 | "jsx": "react-jsx", 15 | "jsxImportSource": "hono/jsx" 16 | }, 17 | } -------------------------------------------------------------------------------- /src/types/subtitle.ts: -------------------------------------------------------------------------------- 1 | export type SubtitleObject = { 2 | /** unique identifier for each subtitle, if you have more than one subtitle with the same language, the id will differentiate them */ 3 | id: string; 4 | /** url to the subtitle file */ 5 | url: string; 6 | /** language code for the subtitle, if a valid ISO 639-2 code is not sent, the text of this value will be used instead */ 7 | lang: string; 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stremio-addon", 3 | "scripts": { 4 | "dev": "wrangler dev", 5 | "deploy": "wrangler deploy --minify", 6 | "cf-typegen": "wrangler types --env-interface CloudflareBindings" 7 | }, 8 | "dependencies": { 9 | "cheerio": "^1.0.0", 10 | "hono": "^4.6.10", 11 | "xbytes": "^1.9.1" 12 | }, 13 | "devDependencies": { 14 | "@cloudflare/workers-types": "^4.20241112.0", 15 | "wrangler": "^3.90.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # prod 2 | dist/ 3 | 4 | # dev 5 | .yarn/ 6 | !.yarn/releases 7 | .vscode/* 8 | !.vscode/launch.json 9 | !.vscode/*.code-snippets 10 | .idea/workspace.xml 11 | .idea/usage.statistics.xml 12 | .idea/shelf 13 | 14 | # deps 15 | node_modules/ 16 | .wrangler 17 | 18 | # env 19 | .env 20 | .env.production 21 | .dev.vars 22 | 23 | # logs 24 | logs/ 25 | *.log 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | pnpm-debug.log* 30 | lerna-debug.log* 31 | 32 | # misc 33 | .DS_Store 34 | -------------------------------------------------------------------------------- /src/types/libs.ts: -------------------------------------------------------------------------------- 1 | import type { Quality } from "./stream"; 2 | 3 | export type Args = { 4 | title: string; 5 | year: number; 6 | quality: Quality[]; 7 | } & ( 8 | | { 9 | type: "movie"; 10 | } 11 | | { 12 | type: "tvSeries"; 13 | season: string; 14 | episode: string; 15 | } 16 | ); 17 | 18 | export type Torrent = { 19 | name: string; 20 | isHDR: boolean; 21 | seeders: number; 22 | leechers: number; 23 | provider: string; 24 | uploader: string; 25 | quality: string; 26 | size: number; 27 | infoHash: string; 28 | }; 29 | -------------------------------------------------------------------------------- /src/routes/catalog.ts: -------------------------------------------------------------------------------- 1 | import { Hono, type TypedResponse } from "hono"; 2 | import type { CatalogObject } from "../types/catalog" 3 | import { getImdbList } from "../libs/imdb"; 4 | 5 | const catalogRoute = new Hono<{ Bindings: CloudflareBindings }>(); 6 | 7 | catalogRoute.get( 8 | "/movie/wishlist-movies.json", 9 | async (c): Promise> => { 10 | const url = "https://m.imdb.com/user/ur81129100/watchlist" 11 | const catalog = await getImdbList(url, "movie") 12 | return c.json({ 13 | metas: catalog 14 | }) 15 | }, 16 | ); 17 | 18 | catalogRoute.get( 19 | "/series/wishlist-series.json", 20 | async (c): Promise> => { 21 | const url = "https://m.imdb.com/user/ur81129100/watchlist" 22 | const catalog = await getImdbList(url, "series") 23 | return c.json({ 24 | metas: catalog 25 | }) 26 | }, 27 | ); 28 | 29 | export default catalogRoute 30 | -------------------------------------------------------------------------------- /src/utils/torrent.ts: -------------------------------------------------------------------------------- 1 | import { Torrent } from "../types/libs"; 2 | 3 | const INFO_HASH_REGEX = /(?<=btih:)([A-Za-z0-9]+)/; 4 | 5 | export function getInfoHash(magnet?: string) { 6 | return magnet?.match(INFO_HASH_REGEX)?.[0].toUpperCase(); 7 | } 8 | 9 | export function sortTorrents(torrentA: Torrent, torrentB: Torrent) { 10 | const qualityA = +torrentA.quality.slice(0, -1) + Number(torrentA.isHDR); 11 | const qualityB = +torrentB.quality.slice(0, -1) + Number(torrentB.isHDR); 12 | if (qualityA > qualityB) { 13 | return -1; 14 | } else if (qualityA < qualityB) { 15 | return 1; 16 | } 17 | const seederToSizeA = torrentA.size / torrentA.seeders; 18 | const seederToSizeB = torrentB.size / torrentB.seeders; 19 | if (seederToSizeA < seederToSizeB) { 20 | return -1; 21 | } 22 | return 1; 23 | } 24 | 25 | export function filterTorrents(torrents: Torrent[]) { 26 | const hashSeed: Record = {}; 27 | 28 | for (const torrent of torrents) { 29 | hashSeed[torrent.infoHash] = Math.max( 30 | torrent.seeders, 31 | hashSeed[torrent.infoHash] ?? 0, 32 | ); 33 | } 34 | 35 | return torrents.filter( 36 | (torrent) => 37 | torrent.seeders !== 0 && hashSeed[torrent.infoHash] === torrent.seeders, 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import streamRoute from "./routes/stream"; 3 | import { cors } from "hono/cors"; 4 | import { contextStorage } from "hono/context-storage"; 5 | import { cache } from "hono/cache"; 6 | import { timing } from "hono/timing"; 7 | import catalogRoute from "./routes/catalog"; 8 | 9 | export const manifest = { 10 | version: "0.0.1", 11 | id: "com.slmn.addon", 12 | name: "Torrent Thing", 13 | description: "Powerful featurepack bs", 14 | logo: "https://www.stremio.com/website/stremio-logo-small.png", 15 | resources: ["stream", "catalog"], 16 | catalogs: [ 17 | { 18 | type: "movie", 19 | id: "wishlist-movies", 20 | name: "IMDB Wishlist" 21 | }, 22 | { 23 | type: "series", 24 | id: "wishlist-series", 25 | name: "IMDB Wishlist" 26 | }, 27 | ], 28 | types: ["movie", "series"], 29 | } as const; 30 | 31 | const app = new Hono<{ Bindings: CloudflareBindings }>(); 32 | 33 | app.use(contextStorage()); 34 | app.use(timing()); 35 | app.use( 36 | cors({ 37 | origin: "*", 38 | }), 39 | ); 40 | 41 | app.get( 42 | "/manifest.json", 43 | cache({ 44 | cacheName: "stremio-manifest", 45 | cacheControl: "max-age=360000", 46 | }), 47 | (c) => { 48 | return c.json(manifest); 49 | }, 50 | ); 51 | 52 | app.route("/stream", streamRoute); 53 | app.route("/catalog", catalogRoute); 54 | 55 | export default app; 56 | -------------------------------------------------------------------------------- /src/libs/thePiratesBay.ts: -------------------------------------------------------------------------------- 1 | import type { Quality } from "../types/stream"; 2 | import type { Args } from "../types/libs"; 3 | import { formatSeasonEpisode, hdrRegex, qualityRegex } from "../utils/string"; 4 | import { getContext } from "hono/context-storage"; 5 | import { endTime, startTime } from "hono/timing"; 6 | 7 | type Response = { 8 | id: string; 9 | name: string; 10 | info_hash: string; 11 | leechers: string; 12 | seeders: string; 13 | num_files: string; 14 | size: string; 15 | username: string; 16 | added: string; 17 | status: string; 18 | category: string; 19 | imdb: string; 20 | }; 21 | 22 | export default async function getPirateBay(args: Args) { 23 | startTime(getContext(), "PirateBay", "Torrents from The Pirate Bay"); 24 | let url = "https://apibay.org/q.php"; 25 | let q = `${args.title.replaceAll("-", "+")}+`; 26 | 27 | if (args.type === "tvSeries") { 28 | q += `${formatSeasonEpisode(args.season, args.episode).join("")}`; 29 | } else { 30 | q += args.year; 31 | } 32 | 33 | const params = { 34 | q, 35 | cat: "200", 36 | }; 37 | url += "?" + new URLSearchParams(params).toString(); 38 | 39 | const response = await fetch(url, { 40 | headers: { 41 | Origin: "https://thepiratebay.org", 42 | Referer: "https://thepiratebay.org", 43 | Priority: "u=5, i", 44 | "User-Agent": 45 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", 46 | }, 47 | }); 48 | const torrents = await response.json(); 49 | 50 | const results = torrents 51 | .map((torrent) => ({ 52 | name: torrent.name, 53 | isHDR: hdrRegex.test(torrent.name), 54 | quality: String(torrent.name.match(qualityRegex)?.[0]), 55 | seeders: Number(torrent.seeders), 56 | leechers: Number(torrent.leechers), 57 | size: Number(torrent.size), 58 | provider: "The Pirate Bay", 59 | uploader: torrent.username, 60 | infoHash: torrent.info_hash, 61 | })) 62 | .filter( 63 | (tor) => tor.quality && args.quality.includes(tor.quality as Quality), 64 | ); 65 | 66 | endTime(getContext(), "PirateBay"); 67 | return results; 68 | } 69 | -------------------------------------------------------------------------------- /src/libs/yts.ts: -------------------------------------------------------------------------------- 1 | import xbytes from "xbytes"; 2 | import * as cheerio from "cheerio"; 3 | import type { Args } from "../types/libs"; 4 | import type { Quality } from "../types/stream"; 5 | import { getContext } from "hono/context-storage"; 6 | import { endTime, startTime } from "hono/timing"; 7 | import { hdrRegex, qualityRegex } from "../utils/string"; 8 | 9 | export default async function getYts(args: Args) { 10 | if (args.type === "tvSeries") { 11 | return []; 12 | } 13 | startTime(getContext(), "YTS", "Torrents from The YTS"); 14 | const movieUrl = `https://yts.mx/rss/${args.title}/all/all/0/en`; 15 | const movieResponse = await fetch(movieUrl); 16 | let text = await movieResponse.text(); 17 | text = text.replaceAll(/<(.*)><\/\1>/g, "<$1>$2"); 18 | const $ = cheerio.load(text, { xml: true }); 19 | const { torrents } = $.extract({ 20 | torrents: [ 21 | { 22 | selector: "item", 23 | value: { 24 | title: "title", 25 | quality: { 26 | selector: "title", 27 | value: (el) => { 28 | return $(el).text().match(qualityRegex)?.[0]; 29 | }, 30 | }, 31 | size: { 32 | selector: "description", 33 | value: (el) => { 34 | let size = $(el) 35 | .text() 36 | .replace(/.*Size: (.*)Runtime.*/, "$1") 37 | .replace(" ", ""); 38 | return xbytes.parseSize(size); 39 | }, 40 | }, 41 | infoHash: { 42 | selector: "enclosure", 43 | value: (el) => el.attribs.url.split("/").at(-1), 44 | }, 45 | }, 46 | }, 47 | ], 48 | }); 49 | const results = torrents 50 | .filter( 51 | (torrent) => 52 | new RegExp(args.title).test(torrent.title!) && 53 | args.quality.includes(torrent.quality as Quality), 54 | ) 55 | .map((torrent) => ({ 56 | ...torrent, 57 | name: torrent.title!, 58 | isHDR: hdrRegex.test(torrent.title!), 59 | seeders: 10, 60 | leechers: 0, 61 | title: torrent.title!, 62 | quality: torrent.quality!, 63 | size: Number(torrent.size), 64 | infoHash: torrent.infoHash!, 65 | provider: "YTS", 66 | uploader: "yts", 67 | })); 68 | endTime(getContext(), "YTS"); 69 | return results; 70 | } 71 | -------------------------------------------------------------------------------- /src/libs/imdb.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio"; 2 | import { getContext } from "hono/context-storage"; 3 | 4 | type Response = { 5 | id: string; 6 | type: "tvSeries" | "movie"; 7 | startYear: number; 8 | primaryTitle: string; 9 | }; 10 | 11 | const creditMap: Record = { 12 | cast: "actor", 13 | }; 14 | 15 | const TYPE_FILTER = { 16 | movie: ["tvMovie", "movie"], 17 | series: ["tvSeries"], 18 | }; 19 | 20 | export default async function getImdb(id: string) { 21 | const kv = getContext<{ Bindings: CloudflareBindings }>().env.imdb; 22 | const data = await kv.get(id); 23 | if (data) { 24 | return JSON.parse(data) as Response; 25 | } 26 | 27 | const response = await fetch(`https://api.imdbapi.dev/titles/${id}`); 28 | const apiData = await response.json(); 29 | await kv.put(id, JSON.stringify(apiData)); 30 | return apiData; 31 | } 32 | 33 | export async function getImdbList(url: string, type: "movie" | "series") { 34 | const response = await fetch(url); 35 | const $ = cheerio.load(await response.text()); 36 | const data = JSON.parse($("script#__NEXT_DATA__").text()); 37 | const items = 38 | data.props.pageProps.mainColumnData.predefinedList.titleListItemSearch.edges.map( 39 | (edge) => edge.listItem, 40 | ); 41 | const result = items 42 | .filter((item) => TYPE_FILTER[type].includes(item.titleType.id)) 43 | .map((item) => { 44 | const links = item.principalCredits.flatMap((principalCredit) => 45 | principalCredit.credits.map((credit) => ({ 46 | category: 47 | creditMap[principalCredit.category.id] ?? 48 | principalCredit.category.id, 49 | name: credit.name.nameText.text, 50 | url: `stremio:///search?search=${credit.name.nameText.text}`, 51 | })), 52 | ); 53 | return { 54 | id: item.id, 55 | type, 56 | name: item.originalTitleText.text, 57 | poster: item.primaryImage.url, 58 | genres: item.titleGenres.genres.map((genre) => genre.genre.text), 59 | description: item.plot.plotText.plainText, 60 | releaseInfo: TYPE_FILTER[type].includes(item.titleType.id) 61 | ? item.releaseYear.year 62 | : `${item.releaseYear.year}-${item.releaseYear.endYear ?? ""}`, 63 | imdbRating: String(item.ratingsSummary.aggregateRating), 64 | runtime: 65 | type === "movie" ? `${item.runtime.seconds / 60} min` : undefined, 66 | cast: links 67 | .filter((link) => link.category === "actor") 68 | .map((link) => link.name), 69 | director: links 70 | .filter((link) => link.category === "director") 71 | .map((link) => link.name), 72 | links, 73 | }; 74 | }); 75 | return result; 76 | } 77 | -------------------------------------------------------------------------------- /src/libs/eztv.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio"; 2 | import type { Args } from "../types/libs"; 3 | import { formatSeasonEpisode, hdrRegex, qualityRegex } from "../utils/string"; 4 | import xbytes from "xbytes"; 5 | import { getInfoHash } from "../utils/torrent"; 6 | import { getContext } from "hono/context-storage"; 7 | import { endTime, startTime } from "hono/timing"; 8 | import { Quality } from "../types/stream"; 9 | 10 | export default async function getEZTV(args: Args) { 11 | if (args.type === "movie") { 12 | return []; 13 | } 14 | startTime(getContext(), "EZTV", "Torrents from EZTV"); 15 | const url = `https://eztvx.to/search/${args.title.replaceAll(" ", "-")}-${formatSeasonEpisode(args.season, args.episode).join("")}`; 16 | 17 | const response = await fetch(url, { 18 | body: "layout=def_wlinks", 19 | headers: { 20 | Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 21 | "Accept-Language": "en-US,en;q=0.9", 22 | "Cache-Control": "no-cache", 23 | "Content-Type": "application/x-www-form-urlencoded", 24 | Pragma: "no-cache", 25 | Priority: "u=0, i", 26 | "User-Agent": 27 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", 28 | }, 29 | method: "POST", 30 | }); 31 | const $ = cheerio.load(await response.text()); 32 | const { torrents } = $.extract({ 33 | torrents: [ 34 | { 35 | selector: 'tbody tr[name="hover"]', 36 | value: { 37 | name: { 38 | selector: "td:nth-of-type(2) a", 39 | value: (el) => el.attribs.title, 40 | }, 41 | isHDR: { 42 | selector: "td:nth-of-type(2) a", 43 | value: (el) => hdrRegex.test(el.attribs.title), 44 | }, 45 | quality: { 46 | selector: "td:nth-of-type(2) a", 47 | value: (el) => el.attribs.title?.match(qualityRegex)?.[0], 48 | }, 49 | seeders: { 50 | selector: "td font", 51 | value: (el) => +$(el).text() || 0, 52 | }, 53 | infoHash: { 54 | selector: "td a.magnet", 55 | value: (el) => getInfoHash(el.attribs.href), 56 | }, 57 | size: { 58 | selector: "td:nth-of-type(4)", 59 | value: (el) => xbytes.parseSize($(el).text().replace(" ", "")), 60 | }, 61 | }, 62 | }, 63 | ], 64 | }); 65 | 66 | const processed = torrents.filter( 67 | (tor) => tor.quality && args.quality.includes(tor.quality as Quality), 68 | ); 69 | 70 | const final = processed.map((tor) => ({ 71 | ...tor, 72 | name: tor.name!, 73 | isHDR: Boolean(tor.isHDR), 74 | seeders: Number(tor.seeders), 75 | leechers: 0, 76 | quality: String(tor.quality), 77 | uploader: "eztv", 78 | size: Number(tor.size), 79 | provider: "EZTV", 80 | infoHash: tor.infoHash!, 81 | })); 82 | 83 | endTime(getContext(), "EZTV"); 84 | return final; 85 | } 86 | -------------------------------------------------------------------------------- /src/libs/1337x.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio"; 2 | import xbytes from "xbytes"; 3 | import { formatSeasonEpisode, hdrRegex, qualityRegex } from "../utils/string"; 4 | import type { Quality } from "../types/stream"; 5 | import type { Args } from "../types/libs"; 6 | import { endTime, startTime } from "hono/timing"; 7 | import { getContext } from "hono/context-storage"; 8 | 9 | async function extractHash(url: string) { 10 | const response = await fetch(url); 11 | const $ = cheerio.load(await response.text()); 12 | const magnet = $(".infohash-box span").text(); 13 | return [url, magnet]; 14 | } 15 | 16 | export default async function get1337x(args: Args) { 17 | startTime(getContext(), "1337x", "Torrents from 1337x"); 18 | let query: string; 19 | if (args.type === "movie") { 20 | query = `${args.title} ${args.year}`; 21 | } else { 22 | query = `${args.title} ${formatSeasonEpisode(args.season, args.episode).join("")}`; 23 | } 24 | 25 | const url = `https://1337x.to/sort-category-search/${query}/${args.type === "movie" ? "Movies" : "TV"}/seeders/desc/1/`; 26 | 27 | const response = await fetch(url); 28 | const $ = cheerio.load(await response.text()); 29 | const { torrents } = $.extract({ 30 | torrents: [ 31 | { 32 | selector: "tbody tr", 33 | value: { 34 | url: { 35 | selector: "td.name a:not(.icon)", 36 | value(el) { 37 | return "https://1337x.to" + el.attribs.href; 38 | }, 39 | }, 40 | name: { 41 | selector: "td.name", 42 | value: (el) => $(el).text().replaceAll(".", " "), 43 | }, 44 | isHDR: { 45 | selector: "td.name", 46 | value: (el) => hdrRegex.test($(el).text()), 47 | }, 48 | quality: { 49 | selector: "td.name", 50 | value: (el) => $(el).text().match(qualityRegex)?.[0], 51 | }, 52 | seeders: { 53 | selector: "td.seeds", 54 | value: (el) => Number($(el).text()), 55 | }, 56 | leechers: { 57 | selector: "td.leeches", 58 | value: (el) => Number($(el).text()), 59 | }, 60 | size: { 61 | selector: "td.size", 62 | value(el) { 63 | return xbytes.parseSize( 64 | $(el) 65 | .contents() 66 | .filter((_, { nodeType }) => nodeType === 3) 67 | .text(), 68 | ); 69 | }, 70 | }, 71 | uploader: "td.uploader, td.user, td.vip, td.trial-uploader", 72 | }, 73 | }, 74 | ], 75 | }); 76 | 77 | const processed = torrents.filter( 78 | (tor) => tor.quality && args.quality.includes(tor.quality as Quality), 79 | ); 80 | const hashMap = Object.fromEntries( 81 | await Promise.all(processed.map((tor) => extractHash(tor.url!))), 82 | ); 83 | const final = processed.map(({ url, ...tor }) => ({ 84 | ...tor, 85 | name: tor.name!, 86 | isHDR: Boolean(tor.isHDR), 87 | seeders: Number(tor.seeders), 88 | leechers: Number(tor.leechers), 89 | quality: String(tor.quality), 90 | uploader: String(tor.uploader), 91 | size: Number(tor.size), 92 | provider: "1337x", 93 | infoHash: hashMap[url ?? ""] as string, 94 | })); 95 | 96 | endTime(getContext(), "1337x"); 97 | return final; 98 | } 99 | -------------------------------------------------------------------------------- /src/types/stream.ts: -------------------------------------------------------------------------------- 1 | import { quality } from "../utils/string"; 2 | import type { SubtitleObject } from "./subtitle"; 3 | 4 | export type Quality = (typeof quality)[number]; 5 | 6 | export type StreamObject = ( 7 | | { 8 | /** Direct URL to a video stream - must be an MP4 through https; others supported (other video formats over http/rtmp supported if you set notWebReady) */ 9 | url: string; 10 | } 11 | | { 12 | /** Youtube video ID, plays using the built-in YouTube player */ 13 | ytId: string; 14 | } 15 | | { 16 | /** Info hash of a torrent file, and fileIdx is the index of the video file within the torrent; if fileIdx is not specified, the largest file in the torrent will be selected */ 17 | infoHash: string; 18 | } 19 | | { 20 | /** The index of the video file within the torrent (from infoHash); if fileIdx is not specified, the largest file in the torrent will be selected */ 21 | fileIdx: number; 22 | } 23 | | { 24 | /** an external URL to the video, which should be opened in a browser (webpage), e.g. link to Netflix */ 25 | externalUrl: string; 26 | } 27 | ) & { 28 | /** Name of the stream; usually used for stream quality */ 29 | name?: string; 30 | /** Description of the stream 31 | * @deprecated 32 | * @see description 33 | */ 34 | title?: string; 35 | /** description of the stream (previously stream.title) */ 36 | description?: string; 37 | /** array of Subtitle objects representing subtitles for this stream */ 38 | subtitle?: SubtitleObject[]; 39 | /** represents a list of torrent tracker URLs and DHT network nodes. This attribute can be used to provide additional peer discovery options when infoHash is also specified, but it is not required. If used, each element can be a tracker URL (tracker:://:) where can be either http or udp. A DHT node (dht:) can also be included. 40 | * > **WARNING**: Use of DHT may be prohibited by some private trackers as it exposes torrent activity to a broader network, potentially finding more peers. 41 | */ 42 | sources?: string[]; 43 | behaviorHints?: { 44 | /** which hints it's restricted to particular countries - array of ISO 3166-1 alpha-3 country codes in lowercase in which the stream is accessible */ 45 | countryWhitelist?: string[]; 46 | /** applies if the protocol of the url is http(s); needs to be set to true if the URL does not support https or is not an MP4 file */ 47 | notWebReady?: boolean; 48 | /** if defined, addons with the same behaviorHints.bingeGroup will be chosen automatically for binge watching; this should be something that identifies the stream's nature within your addon: for example, if your addon is called "gobsAddon", and the stream is 720p, the bingeGroup should be "gobsAddon-720p"; if the next episode has a stream with the same bingeGroup, stremio should select that stream implicitly */ 49 | bingeGroup?: string; 50 | /** the calculated OpenSubtitles hash of the video, this will be used when the streaming server is not connected (so the hash cannot be calculated locally), this value is passed to subtitle addons to identify correct subtitles */ 51 | videoHash?: string; 52 | /** size of the video file in bytes, this value is passed to the subtitle addons to identify correct subtitles */ 53 | videoSize?: number; 54 | /** filename of the video file, although optional, it is highly recommended to set it when using stream.url (when possible) in order to identify correct subtitles (addon sdk will show a warning if it is not set in this case), this value is passed to the subtitle addons to identify correct subtitles */ 55 | filename?: string; 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /src/routes/stream.ts: -------------------------------------------------------------------------------- 1 | import { Hono, type TypedResponse } from "hono"; 2 | import getImdb from "../libs/imdb"; 3 | import getPirateBay from "../libs/thePiratesBay"; 4 | import getYts from "../libs/yts"; 5 | import type { StreamObject } from "../types/stream"; 6 | import xbytes from "xbytes"; 7 | import { filterTorrents, sortTorrents } from "../utils/torrent"; 8 | import getEZTV from "../libs/eztv"; 9 | 10 | const streamRoute = new Hono<{ Bindings: CloudflareBindings }>(); 11 | 12 | // http://localhost:8787/stream/movie/tt17526714 13 | streamRoute.get( 14 | "/movie/:resource", 15 | async (c): Promise> => { 16 | const { resource: imdb } = c.req.param(); 17 | const data = await getImdb(imdb.replace(".json", "")); 18 | console.log({ data }) 19 | const [_1337x, piratesBay, yts] = await Promise.all([ 20 | // get1337x({ 21 | // title: data.title.primary_title, 22 | // year: data.title.start_year, 23 | // quality: ["2160p", "1080p"], 24 | // type: "movie", 25 | // }), 26 | Promise.resolve([] as any[]), 27 | getPirateBay({ 28 | title: data.primaryTitle, 29 | year: data.startYear, 30 | quality: ["2160p", "1080p"], 31 | type: "movie", 32 | }), 33 | getYts({ 34 | title: data.primaryTitle, 35 | year: data.startYear, 36 | quality: ["2160p", "1080p"], 37 | type: "movie", 38 | }), 39 | ]); 40 | 41 | const list = filterTorrents( 42 | _1337x.concat(piratesBay).concat(yts).sort(sortTorrents), 43 | ); 44 | return c.json({ 45 | streams: list.map((l) => ({ 46 | infoHash: l.infoHash!, 47 | name: l.quality + (l.isHDR ? " HDR" : ""), 48 | description: `${l.provider}${l.seeders ? `\r\n🌱: ${l.seeders}` : ""}${l.leechers ? ` 🪱: ${l.leechers}` : ""}\r\n${xbytes(l.size ?? 0)}`, 49 | behaviorHints: { 50 | filename: l.name, 51 | videoSize: l.size, 52 | bingeGroup: l.uploader + "-" + l.quality + "-" + String(l.isHDR), 53 | }, 54 | })), 55 | }); 56 | }, 57 | ); 58 | 59 | // http://localhost:8787/stream/series/tt0312172:1:1 60 | streamRoute.get( 61 | "/series/:resource", 62 | async (c): Promise> => { 63 | const { resource } = c.req.param(); 64 | let [imdb, season, episode] = resource.split(":"); 65 | const data = await getImdb(imdb); 66 | episode = episode.replace(".json", ""); 67 | 68 | const [_1337x, piratesBay, eztv] = await Promise.all([ 69 | // get1337x({ 70 | // title: data.title.primary_title, 71 | // year: data.title.start_year, 72 | // quality: ["2160p", "1080p"], 73 | // type: "tvSeries", 74 | // season, 75 | // episode, 76 | // }), 77 | Promise.resolve([] as any[]), 78 | getPirateBay({ 79 | title: data.primaryTitle, 80 | year: data.startYear, 81 | quality: ["2160p", "1080p"], 82 | type: "tvSeries", 83 | season, 84 | episode, 85 | }), 86 | getEZTV({ 87 | title: data.primaryTitle, 88 | year: data.startYear, 89 | quality: ["2160p", "1080p"], 90 | type: "tvSeries", 91 | season, 92 | episode, 93 | }), 94 | ]); 95 | 96 | const list = filterTorrents( 97 | _1337x.concat(piratesBay).concat(eztv).sort(sortTorrents), 98 | ); 99 | 100 | return c.json({ 101 | streams: list.map((l) => ({ 102 | infoHash: l.infoHash!, 103 | name: l.quality + (l.isHDR ? " HDR" : ""), 104 | description: `${l.provider}${l.seeders ? `\r\n🌱: ${l.seeders}` : ""}${l.leechers ? ` 🪱: ${l.leechers}` : ""}\r\n${xbytes(l.size ?? 0)}`, 105 | behaviorHints: { 106 | filename: l.name, 107 | videoSize: l.size, 108 | bingeGroup: l.uploader + "-" + l.quality + "-" + String(l.isHDR), 109 | }, 110 | })), 111 | }); 112 | }, 113 | ); 114 | 115 | export default streamRoute; 116 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "stremio-addon" 3 | main = "src/index.ts" 4 | compatibility_flags = ["nodejs_compat"] 5 | compatibility_date = "2024-11-12" 6 | 7 | # Assets 8 | # [assets] 9 | # directory = "public" 10 | # binding = "ASSETS" 11 | 12 | # Workers Logs 13 | # Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/ 14 | # Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs 15 | [observability] 16 | enabled = true 17 | 18 | 19 | # Automatically place your workloads in an optimal location to minimize latency. 20 | # If you are running back-end logic in a Worker, running it closer to your back-end infrastructure 21 | # rather than the end user may result in better performance. 22 | # Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 23 | # [placement] 24 | # mode = "smart" 25 | 26 | # Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) 27 | # Docs: 28 | # - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 29 | # Note: Use secrets to store sensitive data. 30 | # - https://developers.cloudflare.com/workers/configuration/secrets/ 31 | # [vars] 32 | # MY_VARIABLE = "production_value" 33 | 34 | # Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network 35 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai 36 | # [ai] 37 | # binding = "AI" 38 | 39 | # Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. 40 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets 41 | # [[analytics_engine_datasets]] 42 | # binding = "MY_DATASET" 43 | 44 | # Bind a headless browser instance running on Cloudflare's global network. 45 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering 46 | # [browser] 47 | # binding = "MY_BROWSER" 48 | 49 | # Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. 50 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases 51 | # [[d1_databases]] 52 | # binding = "MY_DB" 53 | # database_name = "my-database" 54 | # database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 55 | 56 | # Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. 57 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms 58 | # [[dispatch_namespaces]] 59 | # binding = "MY_DISPATCHER" 60 | # namespace = "my-namespace" 61 | 62 | # Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. 63 | # Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. 64 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects 65 | # [[durable_objects.bindings]] 66 | # name = "MY_DURABLE_OBJECT" 67 | # class_name = "MyDurableObject" 68 | 69 | # Durable Object migrations. 70 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations 71 | # [[migrations]] 72 | # tag = "v1" 73 | # new_classes = ["MyDurableObject"] 74 | 75 | # Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. 76 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive 77 | # [[hyperdrive]] 78 | # binding = "MY_HYPERDRIVE" 79 | # id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 80 | 81 | # Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. 82 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces 83 | [[kv_namespaces]] 84 | binding = "imdb" 85 | id = "446750042b374ff18fa8a6dc28ad787c" 86 | 87 | # Bind an mTLS certificate. Use to present a client certificate when communicating with another service. 88 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates 89 | # [[mtls_certificates]] 90 | # binding = "MY_CERTIFICATE" 91 | # certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 92 | 93 | # Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. 94 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues 95 | # [[queues.producers]] 96 | # binding = "MY_QUEUE" 97 | # queue = "my-queue" 98 | 99 | # Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. 100 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues 101 | # [[queues.consumers]] 102 | # queue = "my-queue" 103 | 104 | # Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. 105 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets 106 | # [[r2_buckets]] 107 | # binding = "MY_BUCKET" 108 | # bucket_name = "my-bucket" 109 | 110 | # Bind another Worker service. Use this binding to call another Worker without network overhead. 111 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 112 | # [[services]] 113 | # binding = "MY_SERVICE" 114 | # service = "my-service" 115 | 116 | # Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. 117 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes 118 | # [[vectorize]] 119 | # binding = "MY_INDEX" 120 | # index_name = "my-index" 121 | --------------------------------------------------------------------------------