├── .gitignore ├── packages ├── addon │ ├── src │ │ ├── index.ts │ │ ├── server.ts │ │ ├── meta.ts │ │ ├── sort-option.ts │ │ ├── utils.test.ts │ │ ├── manifest.ts │ │ ├── utils.ts │ │ └── addon.ts │ ├── tsconfig.json │ └── package.json ├── api │ ├── src │ │ ├── index.ts │ │ ├── utils.ts │ │ ├── api.ts │ │ └── types.ts │ ├── tsconfig.json │ └── package.json └── cloudflare-worker │ ├── src │ ├── index.ts │ └── router.ts │ ├── tsconfig.json │ ├── wrangler.toml │ ├── .gitignore │ └── package.json ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── beamup.json ├── .prettierrc ├── tsconfig.json ├── tsconfig.base.json ├── .github └── workflows │ ├── test.yml │ └── deploy.yml ├── LICENSE ├── Dockerfile ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.tsbuildinfo 4 | -------------------------------------------------------------------------------- /packages/addon/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './addon.js'; 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /beamup.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "stremio-easynews-addon", 3 | "lastCommit": "e336b54" 4 | } -------------------------------------------------------------------------------- /packages/api/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api.js'; 2 | export * from './types.js'; 3 | export * from './utils.js'; 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "es5", 6 | "endOfLine": "lf" 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "references": [{ "path": "packages/addon" }, { "path": "packages/api" }] 4 | } 5 | -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["cinemeta", "Easynews", "imdb"], 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | } 6 | -------------------------------------------------------------------------------- /packages/api/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function createBasic(username: string, password: string) { 2 | const userInfo = `${username}:${password}`; 3 | 4 | return `Basic ${btoa(userInfo)}`; 5 | } 6 | -------------------------------------------------------------------------------- /packages/addon/src/server.ts: -------------------------------------------------------------------------------- 1 | import { serveHTTP } from '@stremio-addon/compat'; 2 | import { addonInterface } from './addon.js'; 3 | 4 | serveHTTP(addonInterface, { port: +(process.env.PORT ?? 1337) }); 5 | -------------------------------------------------------------------------------- /packages/addon/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "resolveJsonModule": true 7 | }, 8 | "references": [ 9 | { 10 | "path": "../api" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@easynews/api", 3 | "version": "1.3.8", 4 | "main": "./dist/index.js", 5 | "type": "module", 6 | "scripts": { 7 | "test": "vitest run --passWithNoTests", 8 | "build": "tsc" 9 | }, 10 | "description": "Easynews API library" 11 | } 12 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import { addonInterface, landingHTML } from '@easynews/addon'; 3 | import { getRouter } from './router.js'; 4 | 5 | const addonRouter = getRouter(addonInterface, { landingHTML }); 6 | 7 | const app = new Hono(); 8 | 9 | app.route('/', addonRouter); 10 | 11 | export default app; 12 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "nodenext", 4 | "target": "esnext", 5 | "incremental": true, 6 | "composite": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Use Node.js 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: '23.x' 14 | - run: npm ci 15 | - run: npm run build 16 | - run: npm test 17 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "lib": ["ESNext"], 10 | "types": ["@cloudflare/workers-types"], 11 | "jsx": "react-jsx", 12 | "jsxImportSource": "hono/jsx" 13 | }, 14 | "references": [ 15 | { 16 | "path": "../addon" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "easynews-cloudflare-worker" 2 | compatibility_date = "2024-08-01" 3 | 4 | # [vars] 5 | # MY_VAR = "my-variable" 6 | 7 | # [[kv_namespaces]] 8 | # binding = "MY_KV_NAMESPACE" 9 | # id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 10 | 11 | # [[r2_buckets]] 12 | # binding = "MY_BUCKET" 13 | # bucket_name = "my-bucket" 14 | 15 | # [[d1_databases]] 16 | # binding = "DB" 17 | # database_name = "my-database" 18 | # database_id = "" 19 | 20 | # [ai] 21 | # binding = "AI" -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "debug addon", 6 | "type": "node", 7 | "request": "launch", 8 | "program": "src/server.ts", 9 | "localRoot": "${workspaceFolder}/packages/addon", 10 | "runtimeExecutable": "tsx", 11 | "console": "integratedTerminal", 12 | "internalConsoleOptions": "neverOpen", 13 | "skipFiles": ["/**", "${workspaceFolder}/node_modules/**"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/.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 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@easynews/cloudflare-worker", 3 | "version": "1.3.8", 4 | "type": "module", 5 | "scripts": { 6 | "test": "vitest run --passWithNoTests", 7 | "dev": "wrangler dev src/index.ts", 8 | "deploy": "wrangler deploy --minify src/index.ts", 9 | "build": "exit 0" 10 | }, 11 | "dependencies": { 12 | "@easynews/addon": "^1.0.0", 13 | "@stremio-addon/sdk": "^0.1.0", 14 | "hono": "^4.5.3" 15 | }, 16 | "devDependencies": { 17 | "@cloudflare/workers-types": "^4.20240529.0", 18 | "wrangler": "^4.45.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/addon/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@easynews/addon", 3 | "version": "1.3.8", 4 | "main": "dist/index.js", 5 | "type": "module", 6 | "scripts": { 7 | "test": "vitest run --passWithNoTests", 8 | "test:watch": "vitest watch", 9 | "build": "tsc", 10 | "prepublish": "npm run build", 11 | "start": "node dist/server.js", 12 | "start:dev": "cross-env NODE_ENV=dev tsx watch src/server.ts" 13 | }, 14 | "description": "Provides content from Easynews & includes a search catalog. This addon can also be self-hosted.", 15 | "dependencies": { 16 | "@easynews/api": "^1.0.0", 17 | "@stremio-addon/compat": "^0.0.6", 18 | "@stremio-addon/sdk": "^0.1.0", 19 | "parse-torrent-title": "^1.4.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/cloudflare-worker/src/router.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import { createRouter, type AddonInterface } from '@stremio-addon/sdk'; 3 | 4 | export type Options = { 5 | /** 6 | * Landing page HTML. 7 | */ 8 | landingHTML: string; 9 | }; 10 | 11 | export function getRouter( 12 | addonInterface: AddonInterface, 13 | { landingHTML }: Options 14 | ) { 15 | const router = createRouter(addonInterface); 16 | 17 | const honoRouter = new Hono(); 18 | honoRouter.get('/', ({ html }) => html(landingHTML)); 19 | honoRouter.get('/config', ({ html }) => html(landingHTML)); // for reverse compatibility with the old 'hono-stremio' package 20 | honoRouter.all('*', async (c) => { 21 | const req = c.req.raw; 22 | const res = await router(req); 23 | if (res) { 24 | return res; 25 | } 26 | }); 27 | 28 | return honoRouter; 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docker image to GitHub Container Registry 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | - name: Set up QEMU 15 | uses: docker/setup-qemu-action@v3 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v3 18 | - name: Log in to GitHub Container Registry 19 | uses: docker/login-action@v3 20 | with: 21 | registry: ghcr.io 22 | username: ${{ github.actor }} 23 | password: ${{ secrets.GITHUB_TOKEN }} 24 | - name: Build and push Docker image 25 | uses: docker/build-push-action@v6 26 | with: 27 | platforms: linux/amd64,linux/arm64 28 | push: true 29 | tags: | 30 | ghcr.io/${{ github.repository_owner }}/stremio-easynews-addon:${{ github.ref_name }} 31 | ghcr.io/${{ github.repository_owner }}/stremio-easynews-addon:latest 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sleeyax 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS builder 2 | 3 | WORKDIR /build 4 | 5 | # Copy LICENSE file. 6 | COPY LICENSE ./ 7 | 8 | # Copy the relevant package.json and package-lock.json files. 9 | COPY package*.json ./ 10 | COPY packages/api/package*.json ./packages/api/ 11 | COPY packages/addon/package*.json ./packages/addon/ 12 | 13 | # Install dependencies. 14 | RUN npm install 15 | 16 | # Copy source files. 17 | COPY tsconfig.*json ./ 18 | COPY packages/api ./packages/api 19 | COPY packages/addon ./packages/addon 20 | 21 | # Build the project. 22 | RUN npm run build 23 | 24 | # Remove development dependencies. 25 | RUN npm --workspaces prune --omit=dev 26 | 27 | FROM node:22-alpine AS final 28 | 29 | WORKDIR /app 30 | 31 | # Copy the built files from the builder. 32 | # The package.json files must be copied as well for NPM workspace symlinks between local packages to work. 33 | COPY --from=builder /build/package*.json /build/LICENSE ./ 34 | COPY --from=builder /build/packages/addon/package.*json ./packages/addon/ 35 | COPY --from=builder /build/packages/api/package.*json ./packages/api/ 36 | COPY --from=builder /build/packages/addon/dist ./packages/addon/dist 37 | COPY --from=builder /build/packages/api/dist ./packages/api/dist 38 | 39 | COPY --from=builder /build/node_modules ./node_modules 40 | 41 | EXPOSE 1337 42 | 43 | ENTRYPOINT ["npm", "run", "start:addon"] 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@easynews/core", 3 | "version": "1.3.8", 4 | "main": "dist/server.js", 5 | "scripts": { 6 | "test": "npm run test --workspaces", 7 | "format": "prettier --write .", 8 | "build": "npm -w packages/api run build && npm -w packages/addon run build", 9 | "build:watch": "tsc --build --watch", 10 | "start:addon": "npm -w packages/addon start", 11 | "start:addon:dev": "npm -w packages/addon run start:dev", 12 | "start:cloudflare-worker:dev": "npm -w packages/cloudflare-worker run dev", 13 | "deploy:beamup": "beamup", 14 | "deploy:cloudflare-worker": "npm -w packages/cloudflare-worker run deploy", 15 | "version": "npm version $npm_package_version --workspaces && git add **/package.json" 16 | }, 17 | "keywords": [ 18 | "stremio easynews addon", 19 | "stremio easynews" 20 | ], 21 | "author": "Sleeyax", 22 | "license": "MIT", 23 | "description": "Provides content from Easynews & includes a search catalog. This addon can also be self-hosted.", 24 | "workspaces": [ 25 | "packages/*" 26 | ], 27 | "engines": { 28 | "node": ">=20.0.0", 29 | "npm": ">=7.0.0" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^20.14.10", 33 | "beamup-cli": "^1.3.2", 34 | "cross-env": "^7.0.3", 35 | "prettier": "^3.3.2", 36 | "tsx": "^4.16.2", 37 | "typescript": "^5.5.3", 38 | "vitest": "^2.1.5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/addon/src/meta.ts: -------------------------------------------------------------------------------- 1 | import { extractDigits } from './utils.js'; 2 | 3 | export type MetaProviderResponse = { 4 | name: string; 5 | year?: number; 6 | season?: string; 7 | episode?: string; 8 | }; 9 | 10 | export async function imdbMetaProvider( 11 | id: string 12 | ): Promise { 13 | var [tt, season, episode] = id.split(':'); 14 | 15 | return fetch(`https://v2.sg.media-imdb.com/suggestion/t/${tt}.json`) 16 | .then((res) => res.json()) 17 | .then((json) => { 18 | return json.d.find((item: { id: string }) => item.id === tt); 19 | }) 20 | .then(({ l, y }) => ({ name: l, year: y, season, episode })); 21 | } 22 | 23 | export async function cinemetaMetaProvider( 24 | id: string, 25 | type: string 26 | ): Promise { 27 | var [tt, season, episode] = id.split(':'); 28 | 29 | return fetch(`https://v3-cinemeta.strem.io/meta/${type}/${tt}.json`) 30 | .then((res) => res.json()) 31 | .then((json) => { 32 | const meta = json.meta; 33 | const name = meta.name; 34 | const year = extractDigits(meta.year ?? meta.releaseInfo); 35 | 36 | return { 37 | name, 38 | year, 39 | episode, 40 | season, 41 | } satisfies MetaProviderResponse; 42 | }); 43 | } 44 | 45 | /** 46 | * Fetches metadata from IMDB and use Cinemeta as a fallback. 47 | */ 48 | export async function publicMetaProvider( 49 | id: string, 50 | type: string 51 | ): Promise { 52 | return imdbMetaProvider(id) 53 | .then((meta) => { 54 | if (meta.name) { 55 | return meta; 56 | } 57 | 58 | return cinemetaMetaProvider(id, type); 59 | }) 60 | .then((meta) => { 61 | if (meta.name) { 62 | return meta; 63 | } 64 | 65 | throw new Error('Failed to find metadata'); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /packages/addon/src/sort-option.ts: -------------------------------------------------------------------------------- 1 | import { capitalizeFirstLetter } from './utils.js'; 2 | 3 | export enum SortOption { 4 | Relevance = 'relevance', 5 | Extension = 'next', 6 | Size = 'dsize', 7 | Expire = 'xtime', 8 | DateTime = 'dtime', 9 | Filename = 'nrfile', 10 | Subject = 'nsubject', 11 | From = 'nfrom', 12 | Group = 'sgroup', 13 | Header = 'head', 14 | TPN = 'thmparnzb', 15 | Set = 'set', 16 | VideoCodec = 'svcodec', 17 | AudioCodec = 'sacodec', 18 | ImageSize = 'dpixels', 19 | AVLength = 'druntime', 20 | Bitrate = 'dbps', 21 | SampleRate = 'dhz', 22 | FramesPerSec = 'dfps', 23 | Day = 'otime', 24 | } 25 | 26 | export type SortOptionKey = keyof typeof SortOption; 27 | 28 | export const humanReadableSortOptions = Object.keys(SortOption).map((value) => 29 | toHumanReadable(value as SortOptionKey) 30 | ); 31 | 32 | export const humanReadableDirections = ['Ascending', 'Descending'] as const; 33 | export type DirectionKey = (typeof humanReadableDirections)[number]; 34 | 35 | export function toHumanReadable(value: SortOptionKey): string { 36 | const mapComplexStrings: Partial> = { 37 | AVLength: 'A/V Length', 38 | DateTime: 'Date & Time', 39 | TPN: 'TPN', 40 | }; 41 | 42 | return ( 43 | mapComplexStrings[value] ?? 44 | capitalizeFirstLetter(value.split(/(?=[A-Z])/).join(' ')) 45 | ); 46 | } 47 | 48 | export function fromHumanReadable( 49 | value: SortOptionKey | string | undefined 50 | ): SortOption | undefined { 51 | if (!value) return undefined; 52 | 53 | const key = Object.keys(SortOption).find( 54 | (key) => toHumanReadable(key as SortOptionKey) === value 55 | ); 56 | 57 | return SortOption[key as SortOptionKey]; 58 | } 59 | 60 | export function toDirection( 61 | value?: DirectionKey | string 62 | ): '+' | '-' | undefined { 63 | return value ? (value === 'Ascending' ? '+' : '-') : undefined; 64 | } 65 | -------------------------------------------------------------------------------- /packages/addon/src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, test } from 'vitest'; 2 | import { matchesTitle, sanitizeTitle } from './utils.js'; 3 | import { describe } from 'node:test'; 4 | 5 | describe('sanitizeTitle', () => { 6 | // See also: https://github.com/sleeyax/stremio-easynews-addon/issues/38#issuecomment-2467015435. 7 | it.each([ 8 | ['Three Colors: Blue (1993)', 'three colors blue 1993'], 9 | [ 10 | 'Willy Wonka & the Chocolate Factory (1973)', 11 | 'willy wonka and the chocolate factory 1973', 12 | ], 13 | ["America's got talent", 'americas got talent'], 14 | ['WALL-E (2008)', 'wall e 2008'], 15 | ['WALL·E', 'walle'], 16 | [ 17 | 'Mission: Impossible - Dead Reckoning Part One (2023)', 18 | 'mission impossible dead reckoning part one 2023', 19 | ], 20 | [ 21 | 'The Lord of the Rings: The Fellowship of the Ring', 22 | 'the lord of the rings the fellowship of the ring', 23 | ], 24 | ['Once Upon a Time ... in Hollywood', 'once upon a time in hollywood'], 25 | ['Am_er-ic.a', 'am er ic a'], 26 | ['Amérîcâ', 'amérîcâ'], 27 | ["D'où vient-il?", 'doù vient il'], 28 | ['Fête du cinéma', 'fête du cinéma'], 29 | ])("sanitizes the title '%s'", (input, expected) => { 30 | expect(sanitizeTitle(input)).toBe(expected); 31 | }); 32 | }); 33 | 34 | describe('matchesTitle', () => { 35 | it.each([ 36 | // ignore apostrophes 37 | ["America's Next Top Model", "America's", true], 38 | ["America's Next Top Model", 'Americas', true], 39 | // french characters should match exactly 40 | ['Fête du cinéma', 'cinema', false], 41 | ['Fête du cinéma', 'cinéma', true], 42 | ['Fête du cinéma', 'Fete', false], 43 | ['Fête du cinéma', 'Fête', true], 44 | // ignore special characters 45 | ['Am_er-ic.a the Beautiful', 'America the Beautiful', false], 46 | ['Am_er-ic.a the Beautiful', 'Am er ic a the Beautiful', true], 47 | ])("matches the title '%s' with query '%s'", (title, query, expected) => { 48 | expect(matchesTitle(title, query, false)).toBe(expected); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /packages/api/src/api.ts: -------------------------------------------------------------------------------- 1 | import { createBasic } from './utils.js'; 2 | import { EasynewsSearchResponse, FileData, SearchOptions } from './types.js'; 3 | 4 | export class EasynewsAPI { 5 | private readonly baseUrl = 'https://members.easynews.com'; 6 | private readonly headers: Headers; 7 | 8 | constructor(options: { username: string; password: string }) { 9 | if (!options) { 10 | throw new Error('Missing options'); 11 | } 12 | 13 | this.headers = new Headers(); 14 | const basic = createBasic(options.username, options.password); 15 | this.headers.append('Authorization', basic); 16 | } 17 | 18 | async search({ 19 | query, 20 | pageNr = 1, 21 | maxResults = 1000, 22 | sort1 = 'dsize', 23 | sort1Direction = '-', 24 | sort2 = 'relevance', 25 | sort2Direction = '-', 26 | sort3 = 'dtime', 27 | sort3Direction = '-', 28 | }: SearchOptions): Promise { 29 | const searchParams = { 30 | st: 'adv', 31 | sb: '1', 32 | fex: 'm4v,3gp,mov,divx,xvid,wmv,avi,mpg,mpeg,mp4,mkv,avc,flv,webm', 33 | 'fty[]': 'VIDEO', 34 | spamf: '1', 35 | u: '1', 36 | gx: '1', 37 | pno: pageNr.toString(), 38 | sS: '3', 39 | s1: sort1, 40 | s1d: sort1Direction, 41 | s2: sort2, 42 | s2d: sort2Direction, 43 | s3: sort3, 44 | s3d: sort3Direction, 45 | pby: maxResults.toString(), 46 | safeO: '0', 47 | gps: query, 48 | }; 49 | 50 | const url = new URL(`${this.baseUrl}/2.0/search/solr-search/advanced`); 51 | url.search = new URLSearchParams(searchParams).toString(); 52 | 53 | const res = await fetch(url, { 54 | headers: this.headers, 55 | signal: AbortSignal.timeout(20_000), // 20 seconds 56 | }); 57 | 58 | if (!res.ok) { 59 | throw new Error( 60 | `Failed to fetch search results of query '${query}': ${res.status} ${res.statusText}` 61 | ); 62 | } 63 | 64 | const json = await res.json(); 65 | 66 | return json; 67 | } 68 | 69 | async searchAll(options: SearchOptions): Promise { 70 | const data: FileData[] = []; 71 | let res: EasynewsSearchResponse; 72 | let pageNr = 1; 73 | 74 | while (true) { 75 | res = await this.search({ ...options, pageNr }); 76 | 77 | // No more results. 78 | if ( 79 | (res.data ?? []).length === 0 || 80 | data[0]?.['0'] === res.data[0]?.['0'] 81 | ) { 82 | break; 83 | } 84 | 85 | data.push(...res.data); 86 | 87 | pageNr++; 88 | } 89 | 90 | res.data = data; 91 | 92 | return res; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /packages/addon/src/manifest.ts: -------------------------------------------------------------------------------- 1 | import { Manifest, ManifestCatalog } from '@stremio-addon/sdk'; 2 | import { 3 | DirectionKey, 4 | humanReadableDirections, 5 | humanReadableSortOptions, 6 | toHumanReadable, 7 | } from './sort-option.js'; 8 | import packageJson from '../package.json' with { type: 'json' }; 9 | 10 | const { version, description } = packageJson; 11 | 12 | export const catalog: ManifestCatalog = { 13 | id: 'easynews-plus', 14 | name: 'Easynews+', 15 | type: 'tv', 16 | extra: [{ name: 'search', isRequired: true }], 17 | }; 18 | 19 | // TODO: fix in '@types/stremio-addon-sdk' 20 | const sortOptions = humanReadableSortOptions as any; 21 | const directionOptions = humanReadableDirections as any; 22 | 23 | export const manifest: Manifest & { 24 | stremioAddonsConfig: { issuer: string; signature: string }; 25 | } = { 26 | id: 'community.easynews-plus', 27 | version, 28 | description, 29 | catalogs: [catalog], 30 | resources: [ 31 | 'catalog', 32 | { name: 'meta', types: ['tv'], idPrefixes: [catalog.id] }, 33 | { name: 'stream', types: ['movie', 'series'], idPrefixes: ['tt'] }, 34 | ], 35 | types: ['movie', 'series', 'tv'], 36 | name: 'Easynews+', 37 | background: 38 | 'https://images.pexels.com/photos/518543/pexels-photo-518543.jpeg', 39 | logo: 'https://pbs.twimg.com/profile_images/479627852757733376/8v9zH7Yo_400x400.jpeg', 40 | behaviorHints: { configurable: true, configurationRequired: true }, 41 | config: [ 42 | { title: 'username', key: 'username', type: 'text' }, 43 | { title: 'password', key: 'password', type: 'password' }, 44 | { 45 | title: 'Sort 1st', 46 | key: 'sort1', 47 | type: 'select', 48 | options: sortOptions, 49 | default: toHumanReadable('Size'), 50 | }, 51 | { 52 | title: 'Sort 1st direction', 53 | key: 'sort1Direction', 54 | type: 'select', 55 | options: directionOptions, 56 | default: 'Descending' satisfies DirectionKey, 57 | }, 58 | { 59 | title: 'Sort 2nd', 60 | key: 'sort2', 61 | type: 'select', 62 | options: sortOptions, 63 | default: toHumanReadable('Relevance'), 64 | }, 65 | { 66 | title: 'Sort 2nd direction', 67 | key: 'sort2Direction', 68 | type: 'select', 69 | options: directionOptions, 70 | default: 'Descending' satisfies DirectionKey, 71 | }, 72 | { 73 | title: 'Sort 3rd', 74 | key: 'sort3', 75 | type: 'select', 76 | options: sortOptions, 77 | default: toHumanReadable('DateTime'), 78 | }, 79 | { 80 | title: 'Sort 3rd direction', 81 | key: 'sort3Direction', 82 | type: 'select', 83 | options: directionOptions, 84 | default: 'Descending' satisfies DirectionKey, 85 | }, 86 | ], 87 | stremioAddonsConfig: { 88 | issuer: 'https://stremio-addons.net', 89 | signature: 90 | 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0..sTSjsQkUq2uSSxmLIPlfVw.OvjIByO6oPi73f5KG6qKJRxiiRNv4-YVeW3ywzquK8wTh0mSKJnrdaFFiSzW7lHliaH_adh_VN-PNtH4VBSRbWfN-xbDfNw5KcRx9oRFAEAt0adV-dF1oF03dG9Oupqj.aK03nhKF4q4uc-s1ADiZfg', 91 | }, 92 | }; 93 | -------------------------------------------------------------------------------- /packages/api/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated using: https://app.quicktype.io/. 3 | * 4 | */ 5 | export type EasynewsSearchResponse = { 6 | sid: string; 7 | results: number; 8 | perPage: string; 9 | numPages: number; 10 | dlFarm: string; 11 | dlPort: number; 12 | baseURL: string; 13 | downURL: string; 14 | thumbURL: string; 15 | page: number; 16 | groups: { [key: string]: number }[]; 17 | data: FileData[]; 18 | returned: number; 19 | unfilteredResults: number; 20 | hidden: number; 21 | classicThumbs: string; 22 | fields: Fields; 23 | hthm: number; 24 | hInfo: number; 25 | st: string; 26 | sS: string; 27 | stemmed: string; 28 | largeThumb: string; 29 | largeThumbSize: string; 30 | gsColumns: GsColumn[]; 31 | }; 32 | 33 | export type FileData = { 34 | '0': string; 35 | '1': string; 36 | '2': FileExtension; 37 | '3': string; 38 | '4': string; 39 | '5': string; 40 | '6': string; 41 | '7': string; 42 | '8': string; 43 | '9': Group; 44 | '10': string; 45 | '11': FileExtension; 46 | '12': CompressionStandard; 47 | '13': string; 48 | '14': string; 49 | '15': number; 50 | '16': number; 51 | '17': number; 52 | '18': CodingFormat; 53 | '19': string; 54 | '20': The20; 55 | '35': string; 56 | type: ContentType; 57 | height: string; 58 | width: string; 59 | theight: number; 60 | twidth: number; 61 | fullres: string; 62 | alangs: string[] | null; 63 | slangs: null; 64 | passwd: boolean; 65 | virus: boolean; 66 | expires: The20; 67 | nfo: string; 68 | ts: number; 69 | rawSize: number; 70 | volume: boolean; 71 | sc: boolean; 72 | primaryURL: URL; 73 | fallbackURL: URL; 74 | sb: number; 75 | size: number; 76 | runtime: number; 77 | sig: string; 78 | }; 79 | 80 | export enum FileExtension { 81 | AVI = '.avi', 82 | Mkv = '.mkv', 83 | Mp4 = '.mp4', 84 | } 85 | 86 | export enum CompressionStandard { 87 | H264 = 'H264', 88 | Xvid = 'XVID', 89 | } 90 | 91 | export enum CodingFormat { 92 | AAC = 'AAC', 93 | Mp3 = 'MP3', 94 | Mpg123 = 'MPG123', 95 | Unknown = 'UNKNOWN', 96 | } 97 | 98 | export enum The20 { 99 | The8734 = '∞', 100 | } 101 | 102 | export enum Group { 103 | AltBinariesBonelessAltBinariesMisc = 'alt.binaries.boneless alt.binaries.misc', 104 | AltBinariesBonelessAltBinariesMiscAltBinariesNl = 'alt.binaries.boneless alt.binaries.misc alt.binaries.nl', 105 | AltBinariesBonelessAltBinariesMultimedia = 'alt.binaries.boneless alt.binaries.multimedia', 106 | AltBinariesBonelessAltBinariesNewzbin = 'alt.binaries.boneless alt.binaries.newzbin', 107 | AltBinariesDVDAltBinariesNlAltBinariesX = 'alt.binaries.dvd alt.binaries.nl alt.binaries.x', 108 | AltBinariesMiscAltBinariesNl = 'alt.binaries.misc alt.binaries.nl', 109 | AltBinariesMultimedia = 'alt.binaries.multimedia', 110 | AltBinariesMultimediaAltBinariesTeevee = 'alt.binaries.multimedia alt.binaries.teevee', 111 | AltBinariesNl = 'alt.binaries.nl', 112 | AltBinariesWtfnzbDelta = 'alt.binaries.wtfnzb.delta', 113 | } 114 | 115 | export enum URL { 116 | MembersEasynewsCOM = '//members.easynews.com', 117 | } 118 | 119 | export enum ContentType { 120 | Video = 'VIDEO', 121 | } 122 | 123 | export type Fields = { 124 | '2': string; 125 | '3': string; 126 | '4': string; 127 | '5': string; 128 | '6': string; 129 | '7': string; 130 | '9': string; 131 | '10': string; 132 | '12': string; 133 | '14': string; 134 | '15': string; 135 | '16': string; 136 | '17': string; 137 | '18': string; 138 | '20': string; 139 | FullThumb: string; 140 | }; 141 | 142 | export type GsColumn = { 143 | num: number; 144 | name: string; 145 | }; 146 | 147 | export type SortDirection = '+' | '-'; 148 | 149 | export type SearchOptions = { 150 | query: string; 151 | pageNr?: number; 152 | maxResults?: number; 153 | sort1?: string; 154 | sort1Direction?: SortDirection; 155 | sort2?: string; 156 | sort2Direction?: SortDirection; 157 | sort3?: string; 158 | sort3Direction?: SortDirection; 159 | }; 160 | -------------------------------------------------------------------------------- /packages/addon/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { EasynewsSearchResponse, FileData } from '@easynews/api'; 2 | import { MetaProviderResponse } from './meta.js'; 3 | import { ContentType } from '@stremio-addon/sdk'; 4 | import { parse as parseTorrentTitle } from 'parse-torrent-title'; 5 | 6 | export function isBadVideo(file: FileData) { 7 | const duration = file['14'] ?? ''; 8 | 9 | return ( 10 | // <= 5 minutes in duration 11 | duration.match(/^\d+s/) || 12 | duration.match('^[0-5]m') || 13 | // password protected 14 | file.passwd || 15 | // malicious 16 | file.virus || 17 | // not a video 18 | file.type.toUpperCase() !== 'VIDEO' 19 | ); 20 | } 21 | 22 | export function sanitizeTitle(title: string) { 23 | return ( 24 | title 25 | // replace common symbols with words 26 | .replaceAll('&', 'and') 27 | // replace common separators (., _, -, whitespace) with a single space 28 | .replace(/[\.\-_:\s]+/g, ' ') 29 | // remove non-alphanumeric characters except for accented characters 30 | .replace(/[^\w\sÀ-ÿ]/g, '') 31 | // to lowercase + remove spaces at the beginning and end 32 | .toLowerCase() 33 | .trim() 34 | ); 35 | } 36 | 37 | export function matchesTitle(title: string, query: string, strict: boolean) { 38 | const sanitizedQuery = sanitizeTitle(query); 39 | 40 | if (strict) { 41 | const { title: movieTitle } = parseTorrentTitle(title); 42 | if (movieTitle) { 43 | return sanitizeTitle(movieTitle) === sanitizedQuery; 44 | } 45 | } 46 | 47 | const sanitizedTitle = sanitizeTitle(title); 48 | const re = new RegExp(`\\b${sanitizedQuery}\\b`, 'i'); // match the whole word; e.g. query "deadpool 2" shouldn't match "deadpool 2016" 49 | return re.test(sanitizedTitle); 50 | } 51 | 52 | export function createStreamUrl( 53 | { 54 | downURL, 55 | dlFarm, 56 | dlPort, 57 | }: Pick, 58 | usename: String, 59 | password: String 60 | ) { 61 | const downURLWithAuth = downURL.replace('://', `://${usename}:${password}@`); 62 | 63 | return `${downURLWithAuth}/${dlFarm}/${dlPort}`; 64 | } 65 | 66 | export function createStreamPath(file: FileData) { 67 | const postHash = file['0'] ?? ''; 68 | const postTitle = file['10'] ?? ''; 69 | const ext = file['11'] ?? ''; 70 | 71 | return `${postHash}${ext}/${postTitle}${ext}`; 72 | } 73 | 74 | export function getFileExtension(file: FileData) { 75 | return file['2'] ?? ''; 76 | } 77 | 78 | export function getPostTitle(file: FileData) { 79 | return file['10'] ?? ''; 80 | } 81 | 82 | export function getDuration(file: FileData) { 83 | return file['14'] ?? ''; 84 | } 85 | 86 | export function getSize(file: FileData) { 87 | return file['4'] ?? ''; 88 | } 89 | 90 | export function getQuality( 91 | title: string, 92 | fallbackResolution?: string 93 | ): string | undefined { 94 | const { resolution } = parseTorrentTitle(title); 95 | return resolution ?? fallbackResolution; 96 | } 97 | 98 | export function createThumbnailUrl( 99 | res: EasynewsSearchResponse, 100 | file: FileData 101 | ) { 102 | const id = file['0']; 103 | const idChars = id.slice(0, 3); 104 | const thumbnailSlug = file['10']; 105 | return `${res.thumbURL}${idChars}/pr-${id}.jpg/th-${thumbnailSlug}.jpg`; 106 | } 107 | 108 | export function extractDigits(value: string) { 109 | const match = value.match(/\d+/); 110 | 111 | if (match) { 112 | return parseInt(match[0], 10); 113 | } 114 | 115 | return undefined; 116 | } 117 | 118 | export function buildSearchQuery( 119 | type: ContentType, 120 | meta: MetaProviderResponse 121 | ) { 122 | let query = `${meta.name}`; 123 | 124 | if (type === 'series') { 125 | if (meta.season) { 126 | query += ` S${meta.season.toString().padStart(2, '0')}`; 127 | } 128 | 129 | if (meta.episode) { 130 | query += `${!meta.season ? ' ' : ''}E${meta.episode.toString().padStart(2, '0')}`; 131 | } 132 | } 133 | 134 | if (meta.year) { 135 | query += ` ${meta.year}`; 136 | } 137 | 138 | return query; 139 | } 140 | 141 | export function logError(message: { 142 | message: string; 143 | error: unknown; 144 | context: unknown; 145 | }) { 146 | console.error(message); 147 | } 148 | 149 | export function capitalizeFirstLetter(str: string): string { 150 | if (!str) return str; 151 | return str.charAt(0).toUpperCase() + str.slice(1); 152 | } 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Easynews+ 2 | 3 | > [!NOTE] 4 | > I am not affiliated with Easynews in any way. This project is a fan-made addon for Stremio that provides access to Easynews content. You need an active Easynews subscription to use this addon. 5 | 6 | Provides content from Easynews & includes a search catalog. This addon can also be [self-hosted](#self-hosting). 7 | 8 | Public instance: [b89262c192b0-stremio-easynews-addon.baby-beamup.club](https://b89262c192b0-stremio-easynews-addon.baby-beamup.club/). 9 | 10 | ## FAQ 11 | 12 | ### What is Easynews? 13 | 14 | Easynews is a premium Usenet provider that offers a web-based Usenet browser. It allows you to search, preview, and download files from Usenet newsgroups without the need for a newsreader. Easynews is known for its user-friendly interface and fast download speeds. The Easynews addon for Stremio provides access to Easynews content directly within the Stremio app. You can search for and stream movies, TV shows, and other media files from Easynews using the addon. In a way it can serve as an alternative to debrid services (Real-Debrid, Premiumize, AllDebrid etc.). An Easynews account with an active subscription is required to use the addon. 15 | 16 | ### Why not extend the existing Easynews addon? 17 | 18 | Initially I wanted to simply add the search catalog to the existing Easynews addon but when the developer didn't respond to my query, and the code wasn't open source for me to add it myself, I decided to create my own addon. 19 | 20 | The goal of this addon is to provide more features, better performance, self-host-ability and an open-source codebase for anyone to contribute to. 21 | 22 | ### Why can't I find show X or movie Y? 23 | 24 | Golden rule of thumb: look it up on [Easynews web search](https://members.easynews.com/). If you can't find it there, or it's only returning bad quality results (duration < 5 minutes, marked as spam, no video etc.), you won't find it using the addon either. 25 | 26 | If you do find your content through the web search however, it may be because the addon can't match the resulting titles returned by the Easynews API names with the metadata from Stremio, or it's in the wrong format. 27 | 28 | A couple of examples where the addon won't be able to find results: 29 | 30 | - The anime series `death note` doesn't follow the conventional season number + episode number standard. The show has titles like `Death Note 02` instead of the expected format `Death Note S01E02`. 31 | - For the movie `Mission: Impossible - Dead Reckoning Part One (2023)` Stremio's metadata returns only `dead reckoning` for this title, making it impossible (pun not intended) to match. Movie titles are strictly matched by their full title. 32 | - The real title of the movie `WALL-E (2008)` contains an annoying 'dot' special character: `WALL·E`. This should be converted to a `-` character, but the addon converts that character already to a space because this sanitization is needed for 99% of the other titles. No results for `WALL E` will be returned (actually, no results for `WALL-E` either, but it still serves as a good example). 33 | 34 | There are more oddly titled file names returned by EasyNews. The good news is they are a minority. The bad news is that addon can't possibly support all of these edge cases because that would slow down the search query exponentially and put more stress on both the addon's server and Easynews API, ultimately impacting the performance. 35 | 36 | We try to match most shows, but for the remaining 10-20% of edge cases we currently require you to use the EN+ search catalog instead. Maybe this wil improve in the future. If you have any suggestions to improve this system, please [let us know](https://github.com/sleeyax/stremio-easynews-addon/discussions). 37 | 38 | In any case, feel free to [open an issue](https://github.com/sleeyax/stremio-easynews-addon/issues/new?labels=missing+content&title=Missing+content+for+%27TITLE+SEASON+EPISODE+%27) if you think the addon should be able to find a specific show or movie. It helps us improve the addon. 39 | 40 | ## Self-hosting 41 | 42 | To get results in a fast and private manner, you may wish to self-host the addon. This is easy to do, and only requires a few steps. We support multiple ways of self-hosting: 43 | 44 | ### Cloudflare workers 45 | 46 | The addon can be deployed as a [Cloudflare worker](https://workers.cloudflare.com/), which is a serverless platform that runs your code in data centers around the world. It's incredibly fast and reliable, and you can deploy the addon for free. 47 | 48 | Follow the [getting started guide](https://developers.cloudflare.com/workers/get-started/guide/) and then deploy the addon with the [wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/): 49 | 50 | ```bash 51 | $ git clone https://github.com/sleeyax/stremio-easynews-addon.git && cd stremio-easynews-addon 52 | $ npm i 53 | $ npm run build 54 | $ npm run deploy:cloudflare-worker 55 | ``` 56 | 57 | Navigate to the URL provided by Cloudflare to verify that the addon is running. It should look something like `https://stremio-easynews-addon.yourname.workers.dev/`. 58 | 59 | ### Docker 60 | 61 | You can use the provided Dockerfile to build and run the addon in a container. To do this, you need to have [Docker](https://docs.docker.com/get-docker/) installed on your system. 62 | 63 | ```bash 64 | $ docker run -p 8080:1337 ghcr.io/sleeyax/stremio-easynews-addon:latest 65 | ``` 66 | 67 | Alternatively, build the image yourself: 68 | 69 | ```bash 70 | $ git clone https://github.com/sleeyax/stremio-easynews-addon.git && cd stremio-easynews-addon 71 | $ docker build -t stremio-easynews-addon . 72 | $ docker run -p 8080:1337 stremio-easynews-addon 73 | ``` 74 | 75 | Navigate to `http://localhost:8080/` in your browser to verify that the addon is running. 76 | 77 | ### From source 78 | 79 | If you'd rather run directly from source, you can do so with [Node.js](https://nodejs.org/en/download/prebuilt-installer/current). Make sure you have NPM 7 or higher installed on your system. We also recommend Node 20 or higher, though older versions might still work. 80 | 81 | ```bash 82 | # version should be >= 20 83 | $ node -v 84 | # version must be >= 7 85 | $ npm -v 86 | $ git clone https://github.com/sleeyax/stremio-easynews-addon.git && cd stremio-easynews-addon 87 | $ npm i 88 | $ npm run build 89 | # starts the addon in production mode 90 | $ npm run start:addon 91 | ``` 92 | 93 | Navigate to `http://localhost:1337/` in your browser to verify that the addon is running. You can set the `PORT` environment variable to change the listener port. For example, to run the addon on port `8080`: 94 | 95 | ```bash 96 | $ PORT=8080 npm run start:addon 97 | ``` 98 | 99 | --- 100 | 101 | Looking for additional hosting options? Let us know which platform(s) you'd like to see supported by [creating an issue](https://github.com/sleeyax/stremio-easynews-addon/issues/new). 102 | 103 | ## Contribute 104 | 105 | Notes for contributors. 106 | 107 | ### Development 108 | 109 | Clone the repository and install the dependencies: 110 | 111 | ```bash 112 | $ git clone https://github.com/sleeyax/stremio-easynews-addon.git 113 | $ cd stremio-easynews-addon 114 | $ npm i 115 | ``` 116 | 117 | Run the easynews addon in development mode: 118 | 119 | ```bash 120 | # addon 121 | $ npm run start:addon:dev 122 | # cloudflare worker 123 | $ npm run start:cloudflare-worker:dev 124 | ``` 125 | 126 | ### Production 127 | 128 | To deploy the addon to beamup, run: 129 | 130 | ```bash 131 | $ npm run deploy:beamup 132 | ``` 133 | 134 | To release a new version of the addon: 135 | 136 | ```bash 137 | $ npm version 138 | $ git push --follow-tags 139 | ``` 140 | 141 | Finally, create a new release targeting the tag you just pushed on GitHub and include some release notes. 142 | 143 | ## License 144 | 145 | [MIT](./LICENSE) 146 | -------------------------------------------------------------------------------- /packages/addon/src/addon.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Cache, 3 | MetaDetail, 4 | MetaVideo, 5 | Stream, 6 | AddonBuilder, 7 | } from '@stremio-addon/sdk'; 8 | import { catalog, manifest } from './manifest.js'; 9 | import { 10 | buildSearchQuery, 11 | createStreamPath, 12 | createStreamUrl, 13 | createThumbnailUrl, 14 | getDuration, 15 | getFileExtension, 16 | getPostTitle, 17 | getQuality, 18 | getSize, 19 | isBadVideo, 20 | logError, 21 | matchesTitle, 22 | } from './utils.js'; 23 | import { EasynewsAPI, SearchOptions, createBasic } from '@easynews/api'; 24 | import { publicMetaProvider } from './meta.js'; 25 | import { fromHumanReadable, toDirection } from './sort-option.js'; 26 | import { landingTemplate } from '@stremio-addon/compat/landing-template'; 27 | 28 | type Config = { 29 | username: string; 30 | password: string; 31 | sort1?: string; 32 | sort1Direction?: string; 33 | sort2?: string; 34 | sort2Direction?: string; 35 | sort3?: string; 36 | sort3Direction?: string; 37 | }; 38 | 39 | const builder = new AddonBuilder(manifest); 40 | 41 | const prefix = `${catalog.id}:`; 42 | 43 | builder.defineCatalogHandler(async ({ extra: { search } }) => { 44 | if (!search) { 45 | return { 46 | metas: [], 47 | }; 48 | } 49 | 50 | return { 51 | metas: [ 52 | { 53 | id: `${prefix}${encodeURIComponent(search)}`, 54 | name: search, 55 | type: 'tv', 56 | logo: manifest.logo, 57 | background: manifest.background, 58 | posterShape: 'square', 59 | poster: manifest.logo, 60 | description: `Provides search results from Easynews for '${search}'`, 61 | }, 62 | ], 63 | cacheMaxAge: 3600 * 24 * 30, // The returned data is static so it may be cached for a long time (30 days). 64 | }; 65 | }); 66 | 67 | builder.defineMetaHandler( 68 | async ({ id, type, config: { username, password } }) => { 69 | try { 70 | if (!id.startsWith(catalog.id)) { 71 | return { meta: null as unknown as MetaDetail }; 72 | } 73 | 74 | const search = decodeURIComponent(id.replace(prefix, '')); 75 | 76 | const videos: MetaVideo[] = []; 77 | 78 | const api = new EasynewsAPI({ username, password }); 79 | const res = await api.searchAll({ query: search }); 80 | 81 | for (const file of res?.data ?? []) { 82 | const title = getPostTitle(file); 83 | 84 | if (isBadVideo(file) || !matchesTitle(title, search, false)) { 85 | continue; 86 | } 87 | 88 | videos.push({ 89 | id: `${prefix}${file.sig}`, 90 | released: new Date(file['5']).toISOString(), 91 | title, 92 | overview: file['6'], 93 | thumbnail: createThumbnailUrl(res, file), 94 | streams: [ 95 | mapStream({ 96 | title, 97 | fullResolution: file.fullres, 98 | fileExtension: getFileExtension(file), 99 | duration: getDuration(file), 100 | size: getSize(file), 101 | url: `${createStreamUrl(res, username, password)}/${createStreamPath(file)}`, 102 | videoSize: file.rawSize, 103 | }), 104 | ], 105 | }); 106 | } 107 | 108 | return { 109 | meta: { 110 | id, 111 | name: search, 112 | type: 'tv', 113 | logo: manifest.logo, 114 | background: manifest.background, 115 | poster: manifest.logo, 116 | posterShape: 'square', 117 | description: `Provides search results from Easynews for '${search}'`, 118 | videos, 119 | }, 120 | ...getCacheOptions(videos.length), 121 | }; 122 | } catch (error) { 123 | logError({ 124 | message: 'failed to handle meta', 125 | error, 126 | context: { resource: 'meta', id, type }, 127 | }); 128 | return { meta: null as unknown as MetaDetail }; 129 | } 130 | } 131 | ); 132 | 133 | builder.defineStreamHandler( 134 | async ({ id, type, config: { username, password, ...options } }) => { 135 | try { 136 | if (!id.startsWith('tt')) { 137 | return { streams: [] }; 138 | } 139 | 140 | // Sort options are profiled as human-readable strings in the manifest. 141 | // so we need to convert them back to their internal representation 142 | // before passing them to the search function below. 143 | const sortOptions: Partial = { 144 | sort1: fromHumanReadable(options.sort1), 145 | sort2: fromHumanReadable(options.sort2), 146 | sort3: fromHumanReadable(options.sort3), 147 | sort1Direction: toDirection(options.sort1Direction), 148 | sort2Direction: toDirection(options.sort2Direction), 149 | sort3Direction: toDirection(options.sort3Direction), 150 | }; 151 | 152 | const meta = await publicMetaProvider(id, type); 153 | 154 | const api = new EasynewsAPI({ username, password }); 155 | 156 | let query = buildSearchQuery(type, { ...meta, year: undefined }); 157 | let res = await api.search({ 158 | ...sortOptions, 159 | query, 160 | }); 161 | 162 | if (res?.data?.length <= 1 && meta.year !== undefined) { 163 | query = buildSearchQuery(type, meta); 164 | res = await api.search({ 165 | ...sortOptions, 166 | query, 167 | }); 168 | } 169 | 170 | if (!res || !res.data) { 171 | return { streams: [] }; 172 | } 173 | 174 | const streams: Stream[] = []; 175 | 176 | for (const file of res.data ?? []) { 177 | const title = getPostTitle(file); 178 | 179 | if (isBadVideo(file)) { 180 | continue; 181 | } 182 | 183 | // For series there are multiple possible queries that could match the title. 184 | // We check if at least one of them matches. 185 | if (type === 'series') { 186 | const queries = [ 187 | // full query with season and episode (and optionally year) 188 | query, 189 | // query with episode only 190 | buildSearchQuery(type, { name: meta.name, episode: meta.episode }), 191 | ]; 192 | 193 | if (!queries.some((query) => matchesTitle(title, query, false))) { 194 | continue; 195 | } 196 | } 197 | 198 | // Movie titles should match the query strictly. 199 | // Other content types are loosely matched. 200 | if (!matchesTitle(title, query, type === 'movie')) { 201 | continue; 202 | } 203 | 204 | streams.push( 205 | mapStream({ 206 | fullResolution: file.fullres, 207 | fileExtension: getFileExtension(file), 208 | duration: getDuration(file), 209 | size: getSize(file), 210 | title, 211 | url: `${createStreamUrl(res, username, password)}/${createStreamPath(file)}`, 212 | videoSize: file.rawSize, 213 | }) 214 | ); 215 | } 216 | 217 | return { streams, ...getCacheOptions(streams.length) }; 218 | } catch (error) { 219 | logError({ 220 | message: 'failed to handle stream', 221 | error, 222 | context: { resource: 'stream', id, type }, 223 | }); 224 | return { streams: [] }; 225 | } 226 | } 227 | ); 228 | 229 | function mapStream({ 230 | duration, 231 | size, 232 | fullResolution, 233 | title, 234 | fileExtension, 235 | videoSize, 236 | url, 237 | }: { 238 | title: string; 239 | url: string; 240 | fileExtension: string; 241 | videoSize: number | undefined; 242 | duration: string | undefined; 243 | size: string | undefined; 244 | fullResolution: string | undefined; 245 | }): Stream { 246 | const quality = getQuality(title, fullResolution); 247 | 248 | return { 249 | name: `Easynews+${quality ? `\n${quality}` : ''}`, 250 | description: [ 251 | `${title}${fileExtension}`, 252 | `🕛 ${duration ?? 'unknown duration'}`, 253 | `📦 ${size ?? 'unknown size'}`, 254 | ].join('\n'), 255 | url: url, 256 | behaviorHints: { 257 | fileName: title, 258 | videoSize, 259 | bingeGroup: 'Easynews+-' + (quality || 'default'), 260 | } as Stream['behaviorHints'], 261 | }; 262 | } 263 | 264 | function getCacheOptions(itemsLength: number): Partial { 265 | if (itemsLength === 0) { 266 | return {}; 267 | } 268 | 269 | const oneDay = 3600 * 24; 270 | const oneWeek = oneDay * 7; 271 | 272 | return { 273 | cacheMaxAge: oneWeek, 274 | staleError: oneDay, 275 | staleRevalidate: oneDay, 276 | }; 277 | } 278 | 279 | export const addonInterface = builder.getInterface(); 280 | export const landingHTML = landingTemplate(addonInterface.manifest); 281 | --------------------------------------------------------------------------------