From 6737ca6fb4538a47a4bb1f728366f1414a165647 Mon Sep 17 00:00:00 2001 From: alwaysmesmerizingyou Date: Wed, 12 Aug 2026 19:38:47 -0700 Subject: [PATCH] Sync project files --- .env.example | 14 + .gitattributes | 1 + .github/old_workflows/deploy_hf.yml | 49 ++ .github/workflows/deploy_vps.yml | 42 + .gitignore | 7 + DOCUMENTATION/ADDING_KEYS.md | 25 + Dockerfile | 7 + LICENSE | 407 +++++++++ README.md | 12 + add-key.mjs | 31 + config.js | 2 + db.js | 60 ++ export-db.mjs | 23 + package.json | 11 + public/assets/title.txt | 6 + public/assets/version.txt | 1 + public/favicon.ico | Bin 0 -> 15015 bytes server.js | 1258 +++++++++++++++++++++++++++ src/middleware/auth.js | 363 ++++++++ src/routes/downloads/main.js | 19 + src/routes/health.js | 31 + src/routes/subtitles.js | 19 + src/routes/test.js | 186 ++++ src/utils/helpers.js | 179 ++++ src/utils/proxy.js | 53 ++ update-db.mjs | 74 ++ 26 files changed, 2880 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/old_workflows/deploy_hf.yml create mode 100644 .github/workflows/deploy_vps.yml create mode 100644 .gitignore create mode 100644 DOCUMENTATION/ADDING_KEYS.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 add-key.mjs create mode 100644 config.js create mode 100644 db.js create mode 100644 export-db.mjs create mode 100644 package.json create mode 100644 public/assets/title.txt create mode 100644 public/assets/version.txt create mode 100644 public/favicon.ico create mode 100644 server.js create mode 100644 src/middleware/auth.js create mode 100644 src/routes/downloads/main.js create mode 100644 src/routes/health.js create mode 100644 src/routes/subtitles.js create mode 100644 src/routes/test.js create mode 100644 src/utils/helpers.js create mode 100644 src/utils/proxy.js create mode 100644 update-db.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..82ee18a --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +PROXY_URL= + +PROXY_STREAMS=true + +ENABLE_DEBUG_ROUTE=false + +TMDB_API_KEY= + +GA_MEASUREMENT_ID= +GA_API_SECRET= + +TOKEN_SECRET= + +BYPASS_AUTH= \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a9336a0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.wasm filter=lfs diff=lfs merge=lfs -text diff --git a/.github/old_workflows/deploy_hf.yml b/.github/old_workflows/deploy_hf.yml new file mode 100644 index 0000000..b80c9e5 --- /dev/null +++ b/.github/old_workflows/deploy_hf.yml @@ -0,0 +1,49 @@ +name: Deploy to Hugging Face Space + +on: + push: + branches: ["main"] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Configure Git auth + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + set -e + + git config --global user.email "github-actions@github.com" + git config --global user.name "github-actions" + + git config --global credential.helper store + echo "https://hf:${HF_TOKEN}@huggingface.co" > ~/.git-credentials + + - name: Clone HF Space + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + set -e + rm -rf hf-space + git clone https://huggingface.co/spaces/MissouriMonster/x hf-space + + - name: Sync files + run: | + rsync -av --exclude='.git' ./ hf-space/ + + - name: Commit changes + run: | + cd hf-space + git add . + + git commit -m "deploy" || echo "no changes" + + - name: Push to Hugging Face + run: | + cd hf-space + git push https://hf:${{ secrets.HF_TOKEN }}@huggingface.co/spaces/MissouriMonster/x main \ No newline at end of file diff --git a/.github/workflows/deploy_vps.yml b/.github/workflows/deploy_vps.yml new file mode 100644 index 0000000..860329f --- /dev/null +++ b/.github/workflows/deploy_vps.yml @@ -0,0 +1,42 @@ +name: Deploy to VPS + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.VPS_HOST }} + username: root + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + set -e + + git config --global --add safe.directory /var/www/stream-api + + cd /var/www/stream-api + + mkdir -p /var/backups/stream-api + if [ -f data/api_keys.db ]; then + cp data/api_keys.db "/var/backups/stream-api/api_keys-$(date +%Y%m%d%H%M%S).db" + ls -t /var/backups/stream-api/api_keys-*.db | tail -n +31 | xargs -r rm -- + fi + + git fetch origin main + git reset --hard origin/main + git clean -fd -e data + + npm install + + mkdir -p data + + pm2 restart stream-api || pm2 start server.js --name stream-api + + pm2 save \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f758e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.wrangler +.dev.vars +node_modules/ +.env +package-lock.json +data/ +db.json \ No newline at end of file diff --git a/DOCUMENTATION/ADDING_KEYS.md b/DOCUMENTATION/ADDING_KEYS.md new file mode 100644 index 0000000..8232cf8 --- /dev/null +++ b/DOCUMENTATION/ADDING_KEYS.md @@ -0,0 +1,25 @@ +Adding keys is easy. + +For example: + +```bash +node add-key.mjs standard 500 name +node add-key.mjs partner 1000 name +node add-key.mjs public 10 name +```` + +It generates a random 32-character hex key, prefixes it based on the type (`pk_` for partner, `pub_` for public, `sk_` for everything else to match the existing naming pattern), inserts it as an active key, and prints the generated key once so you can copy it. + +You can also manage keys through `db.json`: + +```bash +node export-db.mjs +``` + +Export the current SQLite database into `db.json`, edit the file manually, then apply changes with: + +```bash +node update-db.mjs +``` + +Run the scripts directly on the VPS, or work locally with a copy of the database and sync the changes afterward. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a661490 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-slim +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +EXPOSE 7860 +CMD ["node", "server.js"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..12d4ca7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,407 @@ +Attribution-NonCommercial 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-NonCommercial 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-NonCommercial 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. NonCommercial means not primarily intended for or directed towards + commercial advantage or monetary compensation. For purposes of + this Public License, the exchange of the Licensed Material for + other material subject to Copyright and Similar Rights by digital + file-sharing or similar means is NonCommercial provided there is + no payment of monetary compensation in connection with the + exchange. + + j. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + k. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + l. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part, for NonCommercial purposes only; and + + b. produce, reproduce, and Share Adapted Material for + NonCommercial purposes only. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties, including when + the Licensed Material is used other than for NonCommercial + purposes. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database for NonCommercial purposes + only; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..db1c63f --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# [DMCA](https://docs.vyla.cc/misc/dmca) + +If your source is listed here, I sincerely apologize. I understand that its inclusion may be costing you a lot of money, and that was never my intention. Please contact me as soon as possible, and I'll remove it ASAP. + +This is a small hobby project that I built for learning and personal interest, not for profit. I genuinely want to respect the wishes of source owners and will address any removal requests as quickly as I can. + +This project is developed and maintained by [Vyla Entertainment](https://github.com/vyla-entertainment) (@vyla-entertainment). + +Original work and core authorship: [@endoverdosing](https://github.com/EndOverdosing) + +Full credits and acknowledgements: +https://docs.vyla.cc/misc/credit \ No newline at end of file diff --git a/add-key.mjs b/add-key.mjs new file mode 100644 index 0000000..4efbac2 --- /dev/null +++ b/add-key.mjs @@ -0,0 +1,31 @@ +import { DatabaseSync } from 'node:sqlite'; +import crypto from 'crypto'; + +const [, , type, rpmArg, label] = process.argv; + +if (!type || !rpmArg) { + console.log('Usage: node database/add-key.mjs [label]'); + console.log('Example: node database/add-key.mjs standard 500 flixora'); + process.exit(1); +} + +const rpm = parseInt(rpmArg, 10); +if (isNaN(rpm) || rpm <= 0) { + console.log('rpm must be a positive number'); + process.exit(1); +} + +const prefix = type === 'partner' ? 'pk' : type === 'public' ? 'pub' : 'sk'; +const suffix = crypto.randomBytes(16).toString('hex'); +const key = label ? `${prefix}_${label}_${suffix}` : `${prefix}_${suffix}`; + +const db = new DatabaseSync('./data/api_keys.db'); +db.exec('PRAGMA journal_mode = WAL;'); + +db.prepare(` + INSERT INTO api_keys (key, type, rpm, active) + VALUES (?, ?, ?, 1) +`).run(key, type, rpm); + +console.log('Key created:'); +console.log(key); \ No newline at end of file diff --git a/config.js b/config.js new file mode 100644 index 0000000..23dc129 --- /dev/null +++ b/config.js @@ -0,0 +1,2 @@ +export const ALLOWED_ORIGINS = ['']; +export const CACHE_TTL = 5 * 60 * 1000; \ No newline at end of file diff --git a/db.js b/db.js new file mode 100644 index 0000000..38ced73 --- /dev/null +++ b/db.js @@ -0,0 +1,60 @@ +import { DatabaseSync } from 'node:sqlite'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const DB_DIR = path.join(__dirname, 'data'); +const DB_PATH = path.join(DB_DIR, 'api_keys.db'); + +if (!fs.existsSync(DB_DIR)) { + fs.mkdirSync(DB_DIR, { recursive: true }); +} + +const db = new DatabaseSync(DB_PATH); +db.exec('PRAGMA journal_mode = WAL;'); +db.exec('PRAGMA busy_timeout = 5000;'); + +export async function ensureApiKeysTable() { + db.exec(` + CREATE TABLE IF NOT EXISTS api_keys ( + key TEXT PRIMARY KEY, + type TEXT NOT NULL DEFAULT 'standard', + rpm INTEGER NOT NULL DEFAULT 100, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); +} + +export async function fetchDisabledApiKeys() { + return db.prepare(` + SELECT key + FROM api_keys + WHERE active = 0 + `).all(); +} + +export async function fetchActiveApiKeys() { + return db.prepare(` + SELECT key, type, rpm + FROM api_keys + WHERE active = 1 + `).all(); +} + +export async function ensurePublicKey() { + const existing = db.prepare(` + SELECT key FROM api_keys WHERE key = 'public_api_key' + `).get(); + + if (!existing) { + db.prepare(` + INSERT INTO api_keys (key, type, rpm, active) + VALUES ('public_api_key', 'public', 10, 1) + `).run(); + } +} + +export default db; \ No newline at end of file diff --git a/export-db.mjs b/export-db.mjs new file mode 100644 index 0000000..59b122d --- /dev/null +++ b/export-db.mjs @@ -0,0 +1,23 @@ +import { DatabaseSync } from "node:sqlite"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const DB_PATH = path.join(__dirname, "data", "api_keys.db"); +const OUTPUT = path.join(__dirname, "db.json"); + +const db = new DatabaseSync(DB_PATH); + +const keys = db.prepare(` + SELECT * + FROM api_keys +`).all(); + +fs.writeFileSync( + OUTPUT, + JSON.stringify(keys, null, 4) +); + +console.log(`Exported ${keys.length} API keys to db.json`); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..9555032 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "stream-api", + "version": "2.0.0", + "private": true, + "description": "Stream API — multi-provider stream scraper", + "type": "module", + "dependencies": { + "@vyla-entertainment/sdk": "^1.9.0", + "dotenv": "^17.4.2" + } +} \ No newline at end of file diff --git a/public/assets/title.txt b/public/assets/title.txt new file mode 100644 index 0000000..de8b127 --- /dev/null +++ b/public/assets/title.txt @@ -0,0 +1,6 @@ + ██████╗ ██╗ ██╗██╗ ██╗██╗ █████╗ ███████╗███╗ ██╗████████╗███████╗██████╗ ████████╗ █████╗ ██╗███╗ ██╗███╗ ███╗███████╗███╗ ██╗████████╗ +██╔═══██╗██║ ██║╚██╗ ██╔╝██║ ██╔══██╗ ██╔════╝████╗ ██║╚══██╔══╝██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██║████╗ ██║████╗ ████║██╔════╝████╗ ██║╚══██╔══╝ +██║██╗██║██║ ██║ ╚████╔╝ ██║ ███████║ █████╗ ██╔██╗ ██║ ██║ █████╗ ██████╔╝ ██║ ███████║██║██╔██╗ ██║██╔████╔██║█████╗ ██╔██╗ ██║ ██║ +██║██║██║╚██╗ ██╔╝ ╚██╔╝ ██║ ██╔══██║ ██╔══╝ ██║╚██╗██║ ██║ ██╔══╝ ██╔══██╗ ██║ ██╔══██║██║██║╚██╗██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║ ██║ +╚█║████╔╝ ╚████╔╝ ██║ ███████╗██║ ██║ ███████╗██║ ╚████║ ██║ ███████╗██║ ██║ ██║ ██║ ██║██║██║ ╚████║██║ ╚═╝ ██║███████╗██║ ╚████║ ██║ + ╚╝╚═══╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝ \ No newline at end of file diff --git a/public/assets/version.txt b/public/assets/version.txt new file mode 100644 index 0000000..9f925ba --- /dev/null +++ b/public/assets/version.txt @@ -0,0 +1 @@ +v6.0.0 \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f27a1f00f16ed7f08f39c251fa409ab86fcb112e GIT binary patch literal 15015 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 b { + try { return fs.readFileSync(path.join(__dirname, 'public/assets/title.txt'), 'utf8'); } catch { return ''; } +})(); + +const GA_MEASUREMENT_ID = process.env.GA_MEASUREMENT_ID; +const GA_API_SECRET = process.env.GA_API_SECRET; + +const M3U8_REGEX = /\.m3u8?(\?|$)|mpegurl|m3u8/i; +const TIKTOK_REGEX = /tiktokcdn\.com|ibyteimg\.com/i; +const STRIP_REGEX = /seg\.html|enproxy|letsgocdn\d+\.shop/i; +const STRIP_TEST_FAST = /seg\.html|enproxy|tiktokcdn|ibyteimg/i; +const URI_REPLACE = /URI="([^"]+)"/g; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH, HEAD', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Session-Token', + 'Access-Control-Allow-Credentials': 'false', + 'Access-Control-Max-Age': '86400', +}; + +const JSON_CORS = { 'Content-Type': 'application/json', ...CORS_HEADERS }; + +const ROUTE_PATTERNS = { + subtitleMovie: /^\/(?:api\/)?subtitles?\/movie\/([^/]+)$/, + subtitleTv: /^\/(?:api\/)?subtitles?\/tv\/([^/]+)\/([^/]+)\/([^/]+)$/, + debug: /^\/(?:api\/)?debug\/([^/]+)$/, + test: /^\/(?:api\/)?test\/([^/]+)$/, + downloadMovie: /^\/(?:api\/)?downloads?\/movie\/([^/]+)$/, + downloadTv: /^\/(?:api\/)?downloads?\/tv\/([^/]+)\/([^/]+)\/([^/]+)$/, +}; + +const TMDB_API_KEY = process.env.TMDB_API_KEY; +if (!TMDB_API_KEY) console.warn('Warning: TMDB_API_KEY not set, imdb-required sources will be unavailable'); + +// Internal VylaSDK instance +const sdk = new VylaSDK({ + tmdbApiKey: TMDB_API_KEY +}); + +if (cluster.isPrimary) { + const cpus = (await import('os')).default.cpus().length; + const workerCount = IS_HF ? Math.min(cpus, 2) : (process.env.WORKER_COUNT ? Math.max(1, parseInt(process.env.WORKER_COUNT, 10)) : 1); + const sharedCache = new Map(); + const SHARED_CACHE_MAX = 1500; + + const pruneCache = () => { + const now = Date.now(); + for (const [k, v] of sharedCache) { + if (now - v.ts > v.ttl) sharedCache.delete(k); + } + if (sharedCache.size > SHARED_CACHE_MAX) { + const overflow = sharedCache.size - SHARED_CACHE_MAX; + const it = sharedCache.keys(); + for (let i = 0; i < overflow; i++) { + const k = it.next().value; + if (k === undefined) break; + sharedCache.delete(k); + } + } + }; + + const pruneTimer = setInterval(pruneCache, 30000); + pruneTimer.unref(); + + for (let i = 0; i < workerCount; i++) { + const w = cluster.fork({ WORKER_ID: String(i) }); + w.on('online', () => w.send({ type: 'worker:id', id: i })); + } + + cluster.on('message', (worker, msg) => { + if (!msg?.type) return; + + if (msg.type === 'cache:get') { + const entry = sharedCache.get(msg.key); + const now = Date.now(); + if (entry && now - entry.ts <= entry.ttl) { + worker.send({ type: 'cache:hit', id: msg.id, value: entry.value }); + } else { + sharedCache.delete(msg.key); + worker.send({ type: 'cache:miss', id: msg.id }); + } + return; + } + + if (msg.type === 'cache:set') { + sharedCache.set(msg.key, { value: msg.value, ts: Date.now(), ttl: msg.ttl || CACHE_TTL }); + if (sharedCache.size > SHARED_CACHE_MAX) pruneCache(); + for (const id in cluster.workers) { + if (cluster.workers[id] !== worker) { + try { cluster.workers[id]?.send({ type: 'cache:push', key: msg.key, value: msg.value, ttl: msg.ttl || CACHE_TTL }); } catch { } + } + } + } + }); + + const watchPaths = [ + fileURLToPath(import.meta.url), + './config.js', + './src/routes/subtitles.js', + './src/routes/downloads/main.js', + './src/routes/health.js', + ]; + + const intentionallyKilled = new Set(); + let restarting = false; + const scheduleRestart = () => { + if (restarting) return; + restarting = true; + setTimeout(() => { + for (const id in cluster.workers) { + const w = cluster.workers[id]; + if (!w) continue; + intentionallyKilled.add(w.process.pid); + w.kill(); + } + restarting = false; + }, 500); + }; + + // fs.watch('./src/sources', { persistent: false }, scheduleRestart); + watchPaths.forEach(f => { try { fs.watch(f, scheduleRestart); } catch { } }); + + let pendingForks = 0; + let nextWorkerId = workerCount; + cluster.on('exit', (worker, code, signal) => { + const isIntentional = code === 0 || intentionallyKilled.delete(worker.process.pid); + const delay = isIntentional ? 0 : Math.min(++pendingForks * 1000, 5000); + setTimeout(() => { + pendingForks = Math.max(0, pendingForks - 1); + const id = nextWorkerId++; + const w = cluster.fork({ WORKER_ID: String(id) }); + w.on('online', () => w.send({ type: 'worker:id', id })); + }, delay); + }); + + await new Promise(() => { }); +} + +const _nativeFetch = globalThis.fetch; + +async function sendGAEvent(eventName, params = {}, clientId = null) { + if (!GA_MEASUREMENT_ID || !GA_API_SECRET) return; + const cid = clientId || `server.${Date.now()}.${Math.random().toString(36).slice(2)}`; + const payload = { + client_id: cid, + timestamp_micros: String(Date.now() * 1000), + non_personalized_ads: false, + events: [{ + name: eventName, + params: { + engagement_time_msec: 100, + session_id: cid, + ...params, + }, + }], + }; + try { + const res = await fetch( + `https://www.google-analytics.com/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } + ); + } catch (err) { + } +} + +async function validateGAConfig() { + if (!GA_MEASUREMENT_ID || !GA_API_SECRET) { + return; + } + const payload = { + client_id: 'debug.startup', + events: [{ name: 'server_start', params: { engagement_time_msec: 100, session_id: 'debug.startup' } }], + }; + try { + const validateRes = await fetch( + `https://www.google-analytics.com/debug/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } + ); + const body = await validateRes.json(); + const issues = body?.validationMessages ?? []; + if (issues.length === 0) { + await sendGAEvent('server_start', { uptime: 0 }, 'debug.startup'); + } + } catch (err) { + } +} + +let outboundFetchActive = 0; +const outboundFetchQueue = []; + +function acquireFetchSlot() { + if (outboundFetchActive < MAX_OUTBOUND_FETCH_CONCURRENCY) { outboundFetchActive++; return Promise.resolve(); } + return new Promise(resolve => outboundFetchQueue.push(resolve)); +} + +function releaseFetchSlot() { + const next = outboundFetchQueue.shift(); + if (next) next(); + else outboundFetchActive = Math.max(0, outboundFetchActive - 1); +} + +globalThis.fetch = async (url, opts) => { + const signal = opts?.signal ?? AbortSignal.timeout(HF_FETCH_TIMEOUT); + await acquireFetchSlot(); + try { + return await _nativeFetch(url, opts?.signal ? opts : { ...opts, signal }); + } finally { + releaseFetchSlot(); + } +}; + +class LRUCache { + #max; #ttl; #map; + + constructor(max, ttl) { + this.#max = max; + this.#ttl = ttl; + this.#map = new Map(); + } + + get(key) { + const entry = this.#map.get(key); + if (!entry) return undefined; + if (Date.now() - entry.ts > this.#ttl) { this.#map.delete(key); return undefined; } + this.#map.delete(key); + this.#map.set(key, entry); + return entry.val; + } + + set(key, val) { + if (this.#map.has(key)) this.#map.delete(key); + else if (this.#map.size >= this.#max) this.#map.delete(this.#map.keys().next().value); + this.#map.set(key, { val, ts: Date.now() }); + } + + has(key) { + const entry = this.#map.get(key); + if (!entry) return false; + if (Date.now() - entry.ts > this.#ttl) { this.#map.delete(key); return false; } + return true; + } + + get size() { return this.#map.size; } +} + +const mainCache = new LRUCache(600, CACHE_TTL); +const hlsVerifyCache = new LRUCache(400, 180_000); +const testResultCache = new LRUCache(400, 90_000); + +const sharedInflight = new Map(); + +let ipcIdCounter = 0; +const ipcPending = new Map(); + +const ipcSend = (msg) => new Promise(resolve => { + if (ipcPending.size >= MAX_IPC_PENDING) { resolve(null); return; } + const id = ++ipcIdCounter; + ipcPending.set(id, resolve); + try { + process.send({ ...msg, id }); + } catch { + ipcPending.delete(id); + resolve(null); + return; + } + setTimeout(() => { + if (ipcPending.has(id)) { ipcPending.delete(id); resolve(null); } + }, 150); +}); + +process.on('message', (msg) => { + if (!msg) return; + if (msg.type === 'worker:id') return; + + if (msg.type === 'cache:hit' || msg.type === 'cache:miss') { + const resolve = ipcPending.get(msg.id); + if (resolve) { ipcPending.delete(msg.id); resolve(msg); } + return; + } + + if (msg.type === 'cache:push' && msg.key && msg.value !== undefined) { + mainCache.set(msg.key, msg.value); + testResultCache.set(msg.key, msg.value); + } +}); + +async function sharedCacheGet(key) { + const local = mainCache.get(key); + if (local !== undefined) return local; + if (!process.send) return undefined; + const reply = await ipcSend({ type: 'cache:get', key }); + if (reply?.type === 'cache:hit') { mainCache.set(key, reply.value); return reply.value; } + return undefined; +} + +function sharedCacheSet(key, value, ttl) { + mainCache.set(key, value); + testResultCache.set(key, value); + if (process.send) { + try { process.send({ type: 'cache:set', key, value, ttl: ttl || CACHE_TTL }); } catch { } + } +} + +function getSharedCached(key, fn, ttl) { + const local = mainCache.get(key); + if (local !== undefined) return Promise.resolve(local); + + const inflight = sharedInflight.get(key); + if (inflight) return inflight; + + if (sharedInflight.size >= MAX_SHARED_INFLIGHT) { + return (async () => { + const shared = await sharedCacheGet(key); + if (shared !== undefined) return shared; + return fn(); + })(); + } + + const p = withTimeout((async () => { + const shared = await sharedCacheGet(key); + if (shared !== undefined) return shared; + const val = await fn(); + if (val != null) sharedCacheSet(key, val, ttl); + return val; + })(), 60_000).finally(() => sharedInflight.delete(key)); + + sharedInflight.set(key, p); + return p; +} + +const safeDecode = s => { try { return decodeURIComponent(s); } catch { return s; } }; +const jitter = ms => ms > 0 ? new Promise(r => setTimeout(r, Math.random() * ms)) : Promise.resolve(); +const withTimeout = (promise, ms) => Promise.race([promise, new Promise(r => setTimeout(() => r(null), ms))]); + +async function withRetry(fn, attempts = 2, delay = 300) { + for (let i = 0; i < attempts; i++) { + try { + const result = await fn(); + if (result != null) return result; + } catch (err) { + if (i === attempts - 1) throw err; + await new Promise(r => setTimeout(r, delay + Math.random() * delay * 0.5)); + } + } + return null; +} + +const ACTIVE_SOURCES = sdk.getSources(true); +const PROXY_PARAM_MAP = new Map(ACTIVE_SOURCES.map(cfg => [cfg.proxyParam, cfg])); + +const BLOCKED_IPS = new Set([]); + +let globalTestConcurrency = 0; +const testQueue = []; + +function runTestQueue() { + while (testQueue.length > 0 && globalTestConcurrency < MAX_GLOBAL_TEST_CONCURRENCY) { + testQueue.shift().resolve(); + globalTestConcurrency++; + } +} + +async function acquireTestSlot() { + if (globalTestConcurrency < MAX_GLOBAL_TEST_CONCURRENCY) { globalTestConcurrency++; return; } + await new Promise(resolve => testQueue.push({ resolve })); +} + +function releaseTestSlot() { + globalTestConcurrency = Math.max(0, globalTestConcurrency - 1); + runTestQueue(); +} + +const getAbsoluteBase = host => + (host.startsWith('localhost') || host.startsWith('127.0.0.1')) ? `http://${host}` : `https://${host}`; + +function buildM3u8Rewriter(rewriteSegments) { + return function rewrite(body, url, extraParam, absoluteBase) { + const safeBase = absoluteBase.replace(/^https:\/\/(localhost|127\.0\.0\.1)/, 'http://$1'); + const qmark = url.indexOf('?'); + const base = qmark === -1 ? url : url.slice(0, qmark); + const dir = base.slice(0, base.lastIndexOf('/') + 1); + const schemeEnd = url.indexOf('//') + 2; + const originBase = url.slice(0, url.indexOf('/', schemeEnd)); + const prefix = `${safeBase}/api?url=`; + const lines = body.split('\n'); + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trim(); + if (!t) continue; + if (t.charCodeAt(0) === 35) { + let uIdx = t.indexOf('URI="'); + if (uIdx !== -1) { + const end = t.indexOf('"', uIdx + 5); + if (end !== -1) { + const uri = t.slice(uIdx + 5, end); + const abs = uri.startsWith('http') ? uri : uri.startsWith('/') ? originBase + uri : dir + uri; + lines[i] = t.slice(0, uIdx + 5) + prefix + encodeURIComponent(abs) + extraParam + '"' + t.slice(end + 1); + } + } + } else { + const abs = t.startsWith('http') ? t : t.startsWith('/') ? originBase + t : dir + t; + lines[i] = rewriteSegments ? `${prefix}${encodeURIComponent(abs)}${extraParam}${STRIP_TEST_FAST.test(abs) ? '&tt=1' : ''}` : abs; + } + } + return lines.join('\n'); + }; +} + +const rewriteM3u8 = buildM3u8Rewriter(true); +const rewriteM3u8KeyOnly = buildM3u8Rewriter(false); + +async function fetchUpstream(url, extraHeaders = {}, timeoutMs = 30_000) { + let current = url.startsWith('http://') ? 'https://' + url.slice(7) : url; + const headers = { 'User-Agent': getUA(), ...extraHeaders }; + const opts = { headers, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) }; + + for (let i = 0; i <= 5; i++) { + const res = await _nativeFetch(current, opts); + + if (res.status < 300 || res.status >= 400 || !res.headers.has('location')) { + return res; + } + res.body?.cancel(); + const loc = res.headers.get('location'); + current = loc.startsWith('http') + ? (loc.startsWith('http://') ? 'https://' + loc.slice(7) : loc) + : new URL(loc, current).href; + } + + throw new Error('redirect loop'); +} + +async function verifyStream(rawUrl, sourceKey, extraHeaders = {}) { + const cfg = sdk.getSources().find(c => c.key === sourceKey); + if (cfg?.skipVerify) return true; + + const cacheKey = `vstream-${rawUrl}`; + const cached = hlsVerifyCache.get(cacheKey); + if (cached !== undefined) return cached; + + try { + const res = await _nativeFetch(rawUrl, { + method: 'HEAD', + headers: { 'User-Agent': getUA(), ...(cfg?.verifyHeaders ?? {}), ...extraHeaders }, + redirect: 'follow', + signal: AbortSignal.timeout(6_000), + }); + res.body?.cancel(); + const ok = res.status < 400; + hlsVerifyCache.set(cacheKey, ok); + return ok; + } catch { + hlsVerifyCache.set(cacheKey, false); + return false; + } +} + +async function verifyPlayable(proxiedUrl, extraHeaders = {}, skipProxyCheck = false) { + if (IS_HF && proxiedUrl.includes('.hf.space/api?url=')) { + try { + const parsed = new URL(proxiedUrl); + const rawUrl = decodeURIComponent(parsed.searchParams.get('url') || ''); + const ph = parsed.searchParams.get('proxyHeaders'); + if (ph) try { Object.assign(extraHeaders, JSON.parse(decodeURIComponent(ph))); } catch { } + if (rawUrl) { proxiedUrl = rawUrl; skipProxyCheck = true; } + } catch { } + } + const cached = hlsVerifyCache.get(proxiedUrl); + if (cached !== undefined) return cached; + const store = val => { hlsVerifyCache.set(proxiedUrl, val); return val; }; + const fail = error => ({ ok: false, error }); + try { + const fetchHeaders = extraHeaders['User-Agent'] ? extraHeaders : { 'User-Agent': getUA(), ...extraHeaders }; + const m3u8Res = await _nativeFetch(proxiedUrl, { signal: AbortSignal.timeout(12000), headers: fetchHeaders }); + if (!m3u8Res.ok) { + const val = fail(`m3u8 failed: ${m3u8Res.status}`); + if (m3u8Res.status !== 429) store(val); + return val; + } + const text = await m3u8Res.text(); + if (/\.mpd(\?|$)/i.test(proxiedUrl) || text.includes(' { + const url = s + ? `https://api.themoviedb.org/3/tv/${cleanId}/season/${s}/episode/${e || 1}?api_key=${key}` + : `https://api.themoviedb.org/3/movie/${cleanId}?api_key=${key}`; + + const res = await _nativeFetch(url, { + signal: AbortSignal.timeout(5_000), + }); + + if (!res.ok) { + res.body?.cancel(); + return { error: `TMDB API error: ${res.status}` }; + } + + return res.json(); + }, 1_800_000); +} + +function applyCdnHeaders(cleanUrl, extraHeaders, sourceKey) { + const cfg = sdk.getSources().find(c => c.key === sourceKey); + if (!cfg?.cdnHeaders) return; + for (const rule of cfg.cdnHeaders) { + if (rule.pattern.test(cleanUrl)) { Object.assign(extraHeaders, rule.headers); return; } + } +} + +function fetchSource(cfg, cacheKey, id, s, e, clientIP, absoluteBase) { + function fixSource(url) { + if (url.includes('/api?url=')) { + return `${absoluteBase}/api?url=${encodeURIComponent(url)}`; + } + return url; + } + + function applyFix(result) { + if (result && Array.isArray(result.allUrls)) { + result.allUrls = result.allUrls.map(entry => ({ + ...entry, + url: fixSource(entry.url) + })); + } + return result; + } + + if (cfg.skipCache) { + return withTimeout( + jitter(cfg.jitter).then(() => withRetry(() => sdk.getStream(cfg.key, id, s, e, clientIP), cfg.retries, 300)), + cfg.timeout + ).then(applyFix); + } + + return withTimeout( + jitter(cfg.jitter).then(() => + getSharedCached(`${cfg.key}-${cacheKey}`, () => withRetry(() => sdk.getStream(cfg.key, id, s, e, clientIP), cfg.retries, 300)) + ), + cfg.timeout + ).then(applyFix); +} + +function normalizeCandidates(rawResult) { + let candidates = []; + if (rawResult?.allUrls?.length) { + candidates = rawResult.allUrls.map(u => typeof u === 'object' ? u : { url: u }); + } else if (rawResult?.streams?.length) { + candidates = rawResult.streams.map(u => typeof u === 'object' ? u : { url: u }); + } else if (Array.isArray(rawResult)) { + candidates = rawResult.map(u => typeof u === 'object' ? u : { url: u }); + } else if (rawResult) { + candidates = [{ url: typeof rawResult === 'object' ? rawResult.url : rawResult, headers: rawResult?.headers, skipProxy: rawResult?.skipProxy, skipHlsCheck: rawResult?.skipHlsCheck }]; + } + return candidates; +} + +async function handleTestSource(sdk, sourceKey, id, s, e, clientIP, host) { + const start = Date.now(); + const sources = sdk.getSources(); + const cfg = sources.find(c => c.key === sourceKey); + const absoluteBase = getAbsoluteBase(host); + + const respond = (ok, url, raw_url, error, debug) => ({ + status: 200, + body: JSON.stringify({ + source: sourceKey, + id, + s: s || null, + e: e || null, + ok, + url: ok ? url : null, + raw_url, + elapsed_ms: Date.now() - start, + error: ok ? null : error, + ...(debug ? { debug } : {}), + }, null, 2), + contentType: 'application/json', + }); + + if (cfg?.disabled) return respond(false, null, null, 'source disabled'); + + const cacheKey = `test-${sourceKey}-${id}-${s ?? ''}-${e ?? ''}`; + const localCached = testResultCache.get(cacheKey); + if (localCached !== undefined) return respond(localCached.ok, localCached.url, localCached.raw_url, localCached.error); + + const shared = await sharedCacheGet(cacheKey); + if (shared !== undefined) { testResultCache.set(cacheKey, shared); return respond(shared.ok, shared.url, shared.raw_url, shared.error); } + + const inflightKey = `inflight-${cacheKey}`; + const existing = sharedInflight.get(inflightKey); + if (existing) { + try { + const result = await existing; + return respond(result?.ok ?? false, result?.url ?? null, result?.raw_url ?? null, result?.error ?? null); + } catch { + return respond(false, null, null, 'deduped request failed'); + } + } + + if (sharedInflight.size >= MAX_SHARED_INFLIGHT) { + return respond(false, null, null, 'server busy, try again shortly'); + } + + await acquireTestSlot(); + + const testPromise = withTimeout((async () => { + try { + let rawResult = null, fetchError = null; + const audio = /dub$/.test(cfg.key) ? 'dub' : 'sub'; + + try { + rawResult = await fetchSource(cfg, `${id}-${s ?? ''}-${e ?? ''}`, id, s, e, clientIP, absoluteBase); + if (!rawResult) rawResult = await withTimeout(sdk.getStream(sourceKey, id, s, e), 20_000); + } catch (err) { fetchError = err.message; } + + const candidates = normalizeCandidates(rawResult); + + for (const candidate of candidates) { + const wrappedUrl = wrapUrl(candidate, sourceKey, absoluteBase, sdk); + if (!wrappedUrl) continue; + + if (candidate.type === 'dash' || /\.mpd(\?|$)/i.test(candidate.url)) { + try { + const headRes = await _nativeFetch(candidate.url, { + method: 'HEAD', + headers: { 'User-Agent': getUA(), ...(candidate.headers ?? {}) }, + signal: AbortSignal.timeout(6_000), + redirect: 'follow', + }); + headRes.body?.cancel(); + if (headRes.status < 400) { + const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; + testResultCache.set(cacheKey, result); + sharedCacheSet(cacheKey, result, cfg.testCacheTtl ?? 90_000); + return result; + } + } catch { } + continue; + } + + if (candidate?.skipProxy) { + const check = await verifyPlayable(candidate.url, candidate.headers ?? {}, true); + if (check.ok || /timeout|aborted/i.test(check.error ?? '')) { + const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; + testResultCache.set(cacheKey, result); + sharedCacheSet(cacheKey, result, 90_000); + return result; + } + try { + const headRes = await _nativeFetch(candidate.url, { + method: 'HEAD', + headers: { 'User-Agent': getUA(), ...(candidate.headers ?? {}) }, + signal: AbortSignal.timeout(6_000), + redirect: 'follow', + }); + headRes.body?.cancel(); + const ct = (headRes.headers.get('content-type') || '').toLowerCase(); + if (headRes.status < 400 && (!ct || /video|octet-stream|mp4/.test(ct) && !ct.includes('mpegurl'))) { + const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; + testResultCache.set(cacheKey, result); + sharedCacheSet(cacheKey, result, 90_000); + return result; + } + } catch { } + continue; + } + + if (candidate?.skipHlsCheck) { + try { + const r = await _nativeFetch(wrappedUrl, { + signal: AbortSignal.timeout(8_000), + headers: { 'User-Agent': getUA() }, + }); + if (!r.ok) { + const body = await r.text(); + return { ok: false, url: null, raw_url: candidate.url, error: `skipHlsCheck proxy failed: ${r.status} - ${body.slice(0, 100)}` }; + } + const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; + testResultCache.set(cacheKey, result); + sharedCacheSet(cacheKey, result, 90_000); + return result; + } catch (err) { + return { ok: false, url: null, raw_url: candidate.url, error: `skipHlsCheck exception: ${err.message}` }; + } + } + + if (cfg.skipVerify || cfg.multiUrl) { + const checkUrl = IS_HF ? candidate.url : wrappedUrl; + const checkHeaders = IS_HF ? (candidate.headers ?? {}) : {}; + + const check = await verifyPlayable(checkUrl, checkHeaders, false); + + if (check.ok) { + const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; + if (!rawResult?.skipCache) { testResultCache.set(cacheKey, result); sharedCacheSet(cacheKey, result, cfg.testCacheTtl ?? 90_000); } + return result; + } + + if (/timeout|aborted/i.test(check.error ?? '')) { + const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; + if (!rawResult?.skipCache) { testResultCache.set(cacheKey, result); sharedCacheSet(cacheKey, result, 15_000); } + return result; + } + + try { + const headRes = await _nativeFetch(candidate.url, { + method: 'HEAD', + headers: { 'User-Agent': getUA(), ...(candidate.headers ?? {}) }, + signal: AbortSignal.timeout(6_000), + redirect: 'follow', + }); + headRes.body?.cancel(); + const ct = (headRes.headers.get('content-type') || '').toLowerCase(); + if (headRes.status < 400 && /video|octet-stream|mp4/.test(ct) && !ct.includes('mpegurl')) { + const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; + testResultCache.set(cacheKey, result); + sharedCacheSet(cacheKey, result, cfg.testCacheTtl ?? 90_000); + return result; + } + } catch { } + continue; + } + + if (!(await verifyStream(candidate.url, sourceKey, candidate?.headers ?? {}))) continue; + + const verifyUrl = IS_HF ? candidate.url : wrappedUrl; + const verifyHeaders = IS_HF ? (candidate.headers ?? {}) : {}; + const check = await verifyPlayable(verifyUrl, verifyHeaders, IS_HF); + + if (!check.ok) { + const rawHeaders = candidate?.headers ?? {}; + const [proxiedBody, rawCheck] = await Promise.all([ + _nativeFetch(wrappedUrl, { signal: AbortSignal.timeout(10_000), headers: { 'User-Agent': getUA() } }) + .then(r => r.text()).then(t => t.slice(0, 200)).catch(e => e.message), + verifyPlayable(candidate.url, rawHeaders, true), + ]); + return { + ok: false, url: null, raw_url: candidate.url, error: check.error, + debug: { proxy_failed: true, proxy_error: check.error, proxy_body_preview: proxiedBody, raw_reachable: rawCheck.ok, raw_error: rawCheck.error, raw_headers_used: rawHeaders, proxied_url: wrappedUrl }, + }; + } + + const result = { ok: true, url: wrappedUrl, raw_url: candidate.url }; + testResultCache.set(cacheKey, result); + sharedCacheSet(cacheKey, result, 90_000); + return result; + } + + return { ok: false, url: null, raw_url: candidates[0]?.url || null, error: fetchError }; + } finally { + releaseTestSlot(); + sharedInflight.delete(inflightKey); + } + })(), 45_000); + + sharedInflight.set(inflightKey, testPromise); + const result = await testPromise; + return respond(result?.ok ?? false, result?.url ?? null, result?.raw_url ?? null, result?.error ?? null, result?.debug ?? null); +} + +async function streamSources(sources, id, s, e, clientIP, absoluteBase, res) { + const sent = new Set(); + const host = absoluteBase.replace(/https?:\/\//, ''); + const debugResults = []; + let closed = false; + + const onClose = () => { closed = true; }; + res.on('close', onClose); + res.on('error', onClose); + + const safeWrite = data => { + if (closed || res.writableEnded || res.destroyed) return false; + try { res.write(data); return true; } catch { closed = true; return false; } + }; + + const promises = sources.map(async cfg => { + if (closed) return; + try { + const result = await handleTestSource(sdk, cfg.key, id, s, e, clientIP, host); + if (closed) return; + const parsed = JSON.parse(result.body); + debugResults.push({ source: cfg.key, ok: parsed.ok, error: parsed.error || null, elapsed_ms: parsed.elapsed_ms }); + if (parsed.ok && parsed.url && !sent.has(parsed.url)) { + sent.add(parsed.url); + safeWrite(`data: ${JSON.stringify({ type: 'source', source: { source: cfg.key, label: cfg.label ?? cfg.key, url: parsed.url } })}\n\n`); + } + } catch (err) { + debugResults.push({ source: cfg.key, ok: false, error: err.message }); + } + }); + + await Promise.race([ + Promise.all(promises), + new Promise(r => setTimeout(r, EARLY_CLOSE_MS)), + ]); + + safeWrite(`data: ${JSON.stringify({ type: 'debug', results: debugResults })}\n\n`); + return sent.size; +} + +const respondJson = (status, data, extraHeaders) => ({ + status, + body: JSON.stringify(data), + headers: extraHeaders ? { ...JSON_CORS, ...extraHeaders } : JSON_CORS, +}); + +async function handleRequest(req, res) { + const baseUrl = `http://${req.headers.host || 'localhost'}`; + const reqUrl = new URL(req.url, baseUrl); + const { pathname, searchParams } = reqUrl; + const clientIP = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket?.remoteAddress || null; + + if (BLOCKED_IPS.size && BLOCKED_IPS.has(clientIP)) return respondJson(403, { error: 'forbidden' }); + + if (req.method === 'OPTIONS') return { status: 204, body: '', headers: CORS_HEADERS }; + + const PUBLIC_ROUTES = new Set(['/', '']); + const isPublicRoute = PUBLIC_ROUTES.has(pathname) && req.method === 'GET'; + + const authResult = authenticateRequest(req); + + if (!isPublicRoute) { + if (!authResult.valid) return respondJson(401, { error: authResult.error }); + if (!canAccess(authResult.type, req, pathname)) return respondJson(403, { error: 'Access denied' }); + + const authHeader = req.headers['authorization']; + const apiKey = authHeader?.replace('Bearer ', '')?.trim() || req.headers['x-api-key']?.trim() || authResult.key; + + if (apiKey && !authResult.bypassed && !authResult.internal) { + const rateLimitResult = checkRateLimit(apiKey, clientIP); + if (!rateLimitResult.allowed) { + return respondJson(429, { error: rateLimitResult.error, resetAt: rateLimitResult.resetAt, limit: rateLimitResult.limit, window: rateLimitResult.window }); + } + } + + } + + if (pathname === '/api/auth' && req.method === 'POST') { + if (authResult.bypassed) { + return respondJson(200, { token: issueSessionToken('standard', 'bypassed') }); + } + if (authResult.type === 'player') { + return respondJson(401, { error: 'API key required for session token generation' }); + } + return respondJson(200, { token: issueSessionToken(authResult.type, authResult.key) }); + } + + if (pathname === '/api/auth/refresh' && req.method === 'POST') { + const existingToken = req.headers['x-session-token']?.trim(); + if (!existingToken) return respondJson(400, { error: 'Missing session token' }); + + const refreshed = refreshSessionToken(existingToken); + if (!refreshed) return respondJson(401, { error: 'Session token cannot be refreshed. Re-authenticate via /api/auth.' }); + + return respondJson(200, { token: refreshed }); + } + + if (pathname === '/' || pathname === '') { + return { + status: 200, + body: `${LOGO_TEXT}\n\ndeveloped_by: @vyla-entertainment\ngithub: https://github.com/vyla-entertainment\ndocs: https://docs.vyla.cc\ndmca: https://docs.vyla.cc/misc/dmca`, + headers: { 'Content-Type': 'text/plain; charset=utf-8', ...CORS_HEADERS }, + }; + } + + if (pathname === '/test' || pathname === '/api/test') { + const tests = {}; + ACTIVE_SOURCES.forEach(s => { + tests[s.key] = { + movie: `/api/test/155?source=${s.key}`, + tv: `/api/test/1396?season=1&episode=1&source=${s.key}` + }; + }); + return respondJson(200, tests); + } + + if (pathname === '/health' || pathname === '/api/health') { + const result = await handleHealth(sdk, mainCache); + return { ...result, headers: { ...result.headers, ...CORS_HEADERS } }; + } + + const absoluteBase = getAbsoluteBase(reqUrl.host); + const getRequestedSources = () => { + const raw = searchParams.get('sources')?.split(',').map(s => s.trim()).filter(Boolean) ?? []; + return raw.length ? ACTIVE_SOURCES.filter(s => raw.includes(s.key)) : ACTIVE_SOURCES; + }; + + const getRequestMeta = () => { + const raw = { + ip: clientIP, + referer: req.headers['referer'] || null, + origin: req.headers['origin'] || null, + user_agent: req.headers['user-agent'] || null, + host: req.headers['host'] || null, + country: req.headers['cf-ipcountry'] || null, + path: pathname, + query: reqUrl.search || null, + }; + return Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== null && v !== undefined)); + }; + + const googleAnalytic = (event, extra = {}) => { + if (!GA_MEASUREMENT_ID || !GA_API_SECRET) return; + const gaClientId = (clientIP && clientIP !== '127.0.0.1' && clientIP !== '::1' && clientIP !== '::ffff:127.0.0.1') + ? clientIP + : `local.${Date.now()}.${Math.random().toString(36).slice(2, 9)}`; + const safeName = event.replace(/-/g, '_'); + const meta = getRequestMeta(); + sendGAEvent(safeName, { ...meta, ...extra }, gaClientId); + }; + + if (pathname === '/movie' || pathname === '/api/movie') { + const id = searchParams.get('id'); + if (!id) return respondJson(400, { error: 'missing id', route: '/movie?id=:tmdb_id', example: '/movie?id=155' }); + + const tmdbValidation = await validateTmdbId(id, 'movie'); + if (!tmdbValidation.valid) { + return respondJson(400, { error: tmdbValidation.error }); + } + + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', ...CORS_HEADERS }); + const [meta, subtitles] = await Promise.all([ + getMetadata(id, null, null), + sdk.getSubtitles(id, null, null) + ]); + + if (!res.writableEnded && !res.destroyed) { + try { res.write(`data: ${JSON.stringify({ type: 'meta', meta, subtitles })}\n\n`); } catch { return null; } + } + + googleAnalytic('stream_movie', { id }); + const total = await streamSources(getRequestedSources(), id, null, null, clientIP, absoluteBase, res); + if (!res.writableEnded && !res.destroyed) { + try { res.write(`data: ${JSON.stringify({ type: 'done', total })}\n\n`); res.end(); } catch { } + } + return null; + } + + if (pathname === '/tv' || pathname === '/api/tv') { + const id = searchParams.get('id'), s = searchParams.get('season'), e = searchParams.get('episode'); + if (!id || !s || !e) return respondJson(400, { error: 'missing parameters', route: '/tv?id=:id&season=:s&episode=:e', example: '/tv?id=1396&season=1&episode=1' }); + + const tmdbValidation = await validateTmdbId(id, 'tv'); + if (!tmdbValidation.valid) { + return respondJson(400, { error: tmdbValidation.error }); + } + + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', ...CORS_HEADERS }); + + const [meta, subtitles] = await Promise.all([ + getMetadata(id, s, e), + sdk.getSubtitles(id, s, e) + ]); + + if (!res.writableEnded && !res.destroyed) { + try { res.write(`data: ${JSON.stringify({ type: 'meta', meta, subtitles })}\n\n`); } catch { return null; } + } + + googleAnalytic('stream_tv', { id, season: s, episode: e }); + const total = await streamSources(getRequestedSources(), id, s, e, clientIP, absoluteBase, res); + if (!res.writableEnded && !res.destroyed) { + try { res.write(`data: ${JSON.stringify({ type: 'done', total })}\n\n`); res.end(); } catch { } + } + return null; + } + + if (pathname === '/subtitle' || pathname === '/subtitles' || pathname === '/api/subtitle' || pathname === '/api/subtitles') { + return respondJson(200, { routes: { movie: '/subtitles/movie/:id', tv: '/subtitles/tv/:id/:s/:e' }, examples: { movie: '/subtitles/movie/155', tv: '/subtitles/tv/1396/1/1' } }); + } + + if (pathname === '/download' || pathname === '/downloads' || pathname === '/api/download' || pathname === '/api/downloads') { + return respondJson(200, { routes: { movie: '/downloads/movie/:id', tv: '/downloads/tv/:id/:s/:e' }, examples: { movie: '/downloads/movie/155', tv: '/downloads/tv/1396/1/1' } }); + } + + let match; + + match = ROUTE_PATTERNS.subtitleMovie.exec(pathname); + if (match) { + googleAnalytic('subtitles_movie', { id: match[1] }); + return handleSubtitleMovie(match[1], CORS_HEADERS, sdk); + } + + match = ROUTE_PATTERNS.subtitleTv.exec(pathname); + if (match) { + googleAnalytic('subtitles_tv', { id: match[1], season: match[2], episode: match[3] }); + return handleSubtitleTv(match[1], match[2], match[3], CORS_HEADERS, sdk); + } + + match = ROUTE_PATTERNS.downloadMovie.exec(pathname); + if (match) { + googleAnalytic('downloads_movie', { id: match[1] }); + return handleDownloadMovie(match[1], CORS_HEADERS, sdk); + } + + match = ROUTE_PATTERNS.downloadTv.exec(pathname); + if (match) { + googleAnalytic('downloads_tv', { id: match[1], season: match[2], episode: match[3] }); + return handleDownloadTv(match[1], match[2], match[3], CORS_HEADERS, sdk); + } + + match = ROUTE_PATTERNS.test.exec(pathname); + if (match) { + const result = await handleTestRoute(sdk, match, searchParams, clientIP, reqUrl.host, handleTestSource, googleAnalytic); + return { status: result.status, body: result.body, headers: JSON_CORS }; + } + + match = ENABLE_DEBUG_ROUTE ? ROUTE_PATTERNS.debug.exec(pathname) : null; + if (match) { + const result = await handleDebugRoute(sdk, match, searchParams, absoluteBase, _nativeFetch, verifyPlayable); + return { status: result.status, body: result.body, headers: JSON_CORS }; + } + + if (pathname === '/api' || pathname === '/api/') { + const url = searchParams.get('url') || searchParams.get('proxy'); + + if (url) { + if (IS_HF && searchParams.get('lm') === '1') { + return { status: 302, body: '', headers: { 'Location': FALLBACK_BASE + req.url, ...CORS_HEADERS } }; + } + + try { + new URL(url); + + const extraHeaders = {}; + const proxyHeaders = searchParams.get('proxyHeaders'); + if (proxyHeaders) try { Object.assign(extraHeaders, JSON.parse(safeDecode(proxyHeaders))); } catch { } + if (!extraHeaders['User-Agent'] && !extraHeaders['user-agent']) extraHeaders['User-Agent'] = getUA(); + const vlHost = extraHeaders['x-vl-host']; + delete extraHeaders['Host']; + delete extraHeaders['x-vl-host']; + if (vlHost) extraHeaders['Host'] = vlHost; + let matchedSource = null; + for (const [param, cfg] of PROXY_PARAM_MAP) { + if (searchParams.has(param)) { matchedSource = cfg; break; } + } + + let cleanUrl = url; + if (matchedSource) { + try { + const qIndex = url.indexOf('?'); + if (qIndex !== -1) { + const params = new URLSearchParams(url.slice(qIndex + 1)); + const headersParam = params.get('headers'); + if (headersParam) { + try { Object.assign(extraHeaders, JSON.parse(decodeURIComponent(headersParam))); } catch { } + params.delete('headers'); + } + cleanUrl = `${url.slice(0, qIndex)}${params.toString() ? '?' + params.toString() : ''}`; + } + } catch { } + applyCdnHeaders(cleanUrl, extraHeaders, matchedSource.key); + } + + const upstream = await fetchUpstream(cleanUrl, extraHeaders, 30_000); + const ct = (upstream.headers.get('content-type') || '').toLowerCase(); + const looksLikeM3u8 = M3U8_REGEX.test(cleanUrl) || cleanUrl.includes('/playlist/') || cleanUrl.includes('/streamsvr/') || ct.includes('mpegurl') || ct.includes('m3u8'); + + if (looksLikeM3u8) { + const text = await upstream.text(); + if (text.trim().startsWith('#EXT') || /megacloud\.animanga\.fun\/(ts-proxy|proxy)/i.test(text.slice(0, 200))) { + const isTesub = matchedSource?.proxyParam === 'tesub'; + const extraParam = matchedSource ? `&${matchedSource.proxyParam}=1&proxyHeaders=${encodeURIComponent(JSON.stringify(extraHeaders))}` : '&vn=1'; + const rewritten = isTesub + ? rewriteM3u8KeyOnly(text, cleanUrl, extraParam, absoluteBase) + : rewriteM3u8(text, matchedSource ? cleanUrl : url, extraParam, absoluteBase); + return { status: 200, body: rewritten, headers: { 'Content-Type': 'application/vnd.apple.mpegurl', ...CORS_HEADERS } }; + } + return { status: 502, body: `expected m3u8 but got: ${text.slice(0, 100)}`, headers: { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' } }; + } + + if (matchedSource) { + const isTikTok = TIKTOK_REGEX.test(cleanUrl); + const isMkv = cleanUrl.includes('.mkv') || ct.includes('matroska'); + const isPngMasked = ct === 'image/png' || ct === 'image/jpeg' || /\.png(\?|$)/i.test(cleanUrl) || /letsgocdn\d+\.shop/i.test(cleanUrl); + const needsStrip = searchParams.has('tt') || STRIP_REGEX.test(cleanUrl); + + const isKey = cleanUrl.endsWith('.key') || ct.includes('octet-stream') && cleanUrl.includes('mon.key'); + if (isKey) { + if (!upstream.ok) return { status: upstream.status, body: `upstream ${upstream.status}`, headers: { 'Content-Type': 'text/plain', ...CORS_HEADERS } }; + const keyBytes = Buffer.from(await upstream.arrayBuffer()); + return { status: 200, body: keyBytes, headers: { 'Content-Type': 'application/octet-stream', ...CORS_HEADERS } }; + } + + if (isTikTok || isPngMasked || needsStrip) { + if (!upstream.ok) return { status: upstream.status, body: `upstream ${upstream.status}`, headers: { 'Content-Type': 'text/plain', ...CORS_HEADERS } }; + const full = Buffer.from(await upstream.arrayBuffer()); + const stripped = (full[0] === 0x89 || full[0] === 0xFF || full[0] === 0x00) ? full.subarray(120) : full; + return { status: 200, body: stripped, headers: { 'Content-Type': 'video/MP2T', ...CORS_HEADERS, 'Cache-Control': 'public, max-age=3600' } }; + } + + if (!upstream.ok) return { status: upstream.status, body: `upstream ${upstream.status}`, headers: { 'Content-Type': 'text/plain', ...CORS_HEADERS } }; + + const rangeHeader = req.headers['range']; + const streamUpstream = rangeHeader + ? await _nativeFetch(cleanUrl, { headers: { 'User-Agent': getUA(), ...extraHeaders, 'Range': rangeHeader }, redirect: 'follow' }) + : upstream; + + const responseHeaders = { + 'Content-Type': isMkv || ct === 'application/octet-stream' ? 'video/mp4' : (ct || 'video/mp4'), + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store', + ...CORS_HEADERS, + }; + if (streamUpstream.headers.has('content-length')) responseHeaders['Content-Length'] = streamUpstream.headers.get('content-length'); + if (streamUpstream.headers.has('content-range')) responseHeaders['Content-Range'] = streamUpstream.headers.get('content-range'); + + return { status: rangeHeader && streamUpstream.status === 206 ? 206 : 200, stream: streamUpstream.body, headers: responseHeaders }; + } + + const full = Buffer.from(await upstream.arrayBuffer()); + const needsStrip = searchParams.has('tt') || TIKTOK_REGEX.test(url) || STRIP_REGEX.test(url); + const stripped = (needsStrip && (full[0] === 0x89 || full[0] === 0xFF || full[0] === 0x00)) ? full.subarray(120) : full; + return { status: 200, body: stripped, headers: { 'Content-Type': 'video/MP2T', ...CORS_HEADERS, 'Cache-Control': 'public, max-age=3600' } }; + } catch (e) { + return respondJson(502, { error: e.message }); + } + } + + if (searchParams.has('sources_meta')) { + return respondJson(200, { sources: ACTIVE_SOURCES.map(c => ({ key: c.key, label: c.label, timeout: c.timeout })) }); + } + + if (searchParams.has('tmdb_movie') || searchParams.has('tmdb_tv') || searchParams.has('tmdb_show') || searchParams.has('tmdb_season')) { + const k = process.env.TMDB_API_KEY; + if (!k) return respondJson(500, { error: 'no key' }); + const tmdbId = searchParams.get('id'), tmdbSeason = searchParams.get('s'); + let tmdbUrl; + if (searchParams.has('tmdb_season')) tmdbUrl = `https://api.themoviedb.org/3/tv/${tmdbId}/season/${tmdbSeason}?api_key=${k}`; + else if (searchParams.has('tmdb_movie')) tmdbUrl = `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${k}${searchParams.has('append_to_response') ? `&append_to_response=${searchParams.get('append_to_response')}` : ''}`; + else tmdbUrl = `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=${k}`; + try { const r = await _nativeFetch(tmdbUrl); return respondJson(200, await r.json()); } + catch (err) { return respondJson(500, { error: err.message }); } + } + + return respondJson(400, { error: 'missing parameters' }); + } + + return respondJson(404, { error: 'not found' }); +} + +const server = http.createServer(async (req, res) => { + req.socket.setTimeout(90_000); + req.socket.setNoDelay(true); + + try { + const result = await handleRequest(req, res); + if (result === null || res.headersSent || res.writableEnded || res.destroyed) return; + + const headers = result.headers || {}; + if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'; + res.writeHead(result.status, headers); + + if (result.stream) { + const readable = Readable.fromWeb(result.stream); + readable.on('error', () => { try { res.destroy(); } catch { } }); + res.on('error', () => { try { readable.destroy(); } catch { } }); + readable.pipe(res); + } else { + res.end(result.body ?? ''); + } + } catch (err) { + if (!res.headersSent) { + console.error(err) + try { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"internal server error"}'); } catch { } + } + } +}); + +server.keepAliveTimeout = 65_000; +server.headersTimeout = 70_000; +server.maxHeadersCount = 100; +server.timeout = 90_000; + +server.on('error', err => { if (err.code !== 'EADDRINUSE') console.error('server error', err.message); }); +await validateGAConfig(); +await initAuth(); +server.listen(PORT, '0.0.0.0', () => console.log(`http://localhost:${PORT}`)); diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..ec40409 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,363 @@ +import dotenv from 'dotenv'; +import crypto from 'crypto'; + +import { ensureApiKeysTable, fetchActiveApiKeys, fetchDisabledApiKeys, ensurePublicKey } from '../../db.js'; + +dotenv.config(); + +const BYPASS_AUTH = process.env.BYPASS_AUTH === 'true'; + +const API_KEYS = new Map(); +const DISABLED_KEYS = new Set(); +const rateLimitMap = new Map(); + +const KEY_REFRESH_INTERVAL_MS = 5 * 60 * 1000; + +function setKeysFromRows(rows) { + API_KEYS.clear(); + + for (const row of rows) { + API_KEYS.set(row.key, { + type: row.type, + rpm: Number(row.rpm) || 100 + }); + } +} + +export async function loadKeysFromDB() { + try { + const [activeRows, disabledRows] = await Promise.all([ + fetchActiveApiKeys(), + fetchDisabledApiKeys() + ]); + + setKeysFromRows(activeRows); + + DISABLED_KEYS.clear(); + for (const row of disabledRows) { + DISABLED_KEYS.add(row.key); + } + } catch (err) { + if (API_KEYS.size === 0) { + throw err; + } + } +} + +let authInitialized = false; + +export async function initAuth() { + if (authInitialized) return; + authInitialized = true; + + await ensureApiKeysTable(); + await ensurePublicKey(); + await loadKeysFromDB(); + + setInterval(() => { + loadKeysFromDB().catch(console.error); + }, KEY_REFRESH_INTERVAL_MS); +} + +const TOKEN_SECRET = process.env.TOKEN_SECRET; +if (!TOKEN_SECRET || TOKEN_SECRET.length < 32) { + throw new Error('TOKEN_SECRET must be set and at least 32 characters'); +} + +const TOKEN_TTL_MS = 30 * 60 * 1000; +const REFRESH_GRACE_MS = 10 * 60 * 1000; +const TOKEN_VERSION = 'v1'; + +function signPayload(payload) { + return crypto.createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex'); +} + +function buildToken(expires, type, sourceKey, issuedAt) { + const payload = `${TOKEN_VERSION}.${expires}.${type}.${sourceKey}.${issuedAt}`; + const sig = signPayload(payload); + return `${TOKEN_VERSION}.${expires}.${type}.${sourceKey}.${issuedAt}.${sig}`; +} + +export function issueSessionToken(type = 'player', sourceKey = '') { + const now = Date.now(); + const expires = now + TOKEN_TTL_MS; + return buildToken(expires, type, sourceKey, now); +} + +export function validateSessionToken(token) { + if (!token || typeof token !== 'string' || token.length > 512) return false; + + try { + const parts = token.split('.'); + if (parts.length !== 6) return false; + + const [version, expires, type, sourceKey, issuedAt, sig] = parts; + + if (version !== TOKEN_VERSION) return false; + if (!/^\d+$/.test(expires) || !/^\d+$/.test(issuedAt)) return false; + + const expiresNum = Number(expires); + const issuedAtNum = Number(issuedAt); + + if (issuedAtNum > Date.now()) return false; + if (expiresNum - issuedAtNum !== TOKEN_TTL_MS) return false; + + const payload = `${version}.${expires}.${type}.${sourceKey}.${issuedAt}`; + const expected = signPayload(payload); + + const sigBuf = Buffer.from(sig, 'hex'); + const expectedBuf = Buffer.from(expected, 'hex'); + + if (sigBuf.length !== expectedBuf.length) return false; + if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) return false; + + if (Date.now() > expiresNum) return false; + + return { type, sourceKey, expires: expiresNum, issuedAt: issuedAtNum }; + } catch { + return false; + } +} + +export function refreshSessionToken(token) { + if (!token || typeof token !== 'string' || token.length > 512) return null; + + try { + const parts = token.split('.'); + if (parts.length !== 6) return null; + + const [version, expires, type, sourceKey, issuedAt, sig] = parts; + + if (version !== TOKEN_VERSION) return null; + if (!/^\d+$/.test(expires) || !/^\d+$/.test(issuedAt)) return null; + + const expiresNum = Number(expires); + const issuedAtNum = Number(issuedAt); + + if (issuedAtNum > Date.now()) return null; + if (expiresNum - issuedAtNum !== TOKEN_TTL_MS) return null; + + const payload = `${version}.${expires}.${type}.${sourceKey}.${issuedAt}`; + const expected = signPayload(payload); + + const sigBuf = Buffer.from(sig, 'hex'); + const expectedBuf = Buffer.from(expected, 'hex'); + + if (sigBuf.length !== expectedBuf.length) return null; + if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) return null; + + if (Date.now() > expiresNum + REFRESH_GRACE_MS) return null; + + if (type !== 'player') { + const entry = API_KEYS.get(sourceKey); + if (!entry || entry.type !== type || DISABLED_KEYS.has(sourceKey)) return null; + } + + return issueSessionToken(type, sourceKey); + } catch { + return null; + } +} + +function parseKey(apiKey) { + if (!apiKey) return null; + return apiKey.includes(':') ? apiKey.split(':').pop().trim() : apiKey; +} + +function isLocalRequest(req) { + const ip = req.socket.remoteAddress || ''; + return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; +} + +export function authenticateRequest(req) { + if (BYPASS_AUTH && isLocalRequest(req)) { + return { + valid: true, + error: null, + type: 'standard', + bypassed: true + }; + } + + let internalToken = null; + try { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + internalToken = url.searchParams.get('internal_token'); + } catch { } + + if (internalToken) { + const decoded = validateSessionToken(internalToken); + + if (decoded && decoded.type === 'internal') { + return { + valid: true, + error: null, + type: 'standard', + key: decoded.sourceKey || null, + bypassed: false, + internal: true + }; + } + + return { + valid: false, + error: 'Invalid or expired internal token' + }; + } + + const sessionToken = req.headers['x-session-token']?.trim(); + + if (sessionToken) { + const decoded = validateSessionToken(sessionToken); + + if (decoded) { + return { + valid: true, + error: null, + type: decoded.type, + key: decoded.sourceKey || null, + bypassed: false + }; + } + + return { + valid: false, + error: 'Invalid or expired session token' + }; + } + + const authHeader = req.headers.authorization; + const rawKey = + authHeader?.replace(/^Bearer\s+/i, '').trim() || + req.headers['x-api-key']?.trim(); + + const cleanKey = parseKey(rawKey); + + if (!cleanKey) { + return { + valid: false, + error: 'Missing API key. Provide via Authorization or X-API-Key. You can get one at https://docs.vyla.cc/authentication' + }; + } + + if (DISABLED_KEYS.has(cleanKey)) { + return { + valid: false, + error: 'This API key has been disabled. If you believe this is an error, please contact support at https://vyla.cc/discord for assistance.' + }; + } + + const entry = API_KEYS.get(cleanKey); + + if (!entry) { + return { + valid: false, + error: 'Invalid API key. If you believe this is an error, please contact support at https://vyla.cc/discord for assistance.' + }; + } + + return { + valid: true, + error: null, + type: entry.type, + key: cleanKey + }; +} + +function isStreamProxy(req, pathname) { + if (pathname !== '/api' && pathname !== '/api/') return false; + + const url = new URL( + req.url, + `http://${req.headers.host || 'localhost'}` + ); + + return ( + url.searchParams.has('url') || + url.searchParams.has('proxy') + ); +} + +export function canAccess(type, req, pathname) { + if (type === 'public') { + if (pathname === '/movie' || pathname === '/api/movie' || + pathname === '/tv' || pathname === '/api/tv') { + return false; + } + if (isStreamProxy(req, pathname)) { + return false; + } + return true; + } + + return ( + type === 'standard' || + type === 'partner' || + type === 'player' + ); +} + +export function checkRateLimit(apiKey) { + if (!apiKey) { + return { allowed: true }; + } + + const cleanKey = parseKey(apiKey); + const entry = API_KEYS.get(cleanKey); + + if (!entry) { + return { + allowed: false, + error: 'Invalid API key. If you believe this is an error, please contact support at https://vyla.cc/discord for assistance.' + }; + } + + const rpm = entry.rpm; + const window = 60_000; + const now = Date.now(); + + let current = rateLimitMap.get(cleanKey); + + if (!current || now > current.resetAt) { + current = { + count: 0, + resetAt: now + window + }; + } + + if (current.count >= rpm) { + rateLimitMap.set(cleanKey, current); + + return { + allowed: false, + error: 'Rate limit exceeded. If you believe this is an error, please contact support at https://vyla.cc/discord for assistance.', + resetAt: current.resetAt, + limit: rpm, + window + }; + } + + current.count++; + + rateLimitMap.set(cleanKey, current); + + return { + allowed: true, + remaining: rpm - current.count, + resetAt: current.resetAt + }; +} + +export function clearRateLimitCache() { + rateLimitMap.clear(); +} + +setInterval(() => { + const now = Date.now(); + + for (const [key, value] of rateLimitMap) { + if (now > value.resetAt) { + rateLimitMap.delete(key); + } + } +}, 60_000).unref(); \ No newline at end of file diff --git a/src/routes/downloads/main.js b/src/routes/downloads/main.js new file mode 100644 index 0000000..5718836 --- /dev/null +++ b/src/routes/downloads/main.js @@ -0,0 +1,19 @@ +export const handleDownloadMovie = async (id, corsHeaders, sdk) => +{ + const dl = await sdk.getDownloads(id); + return { + status: 200, + body: JSON.stringify(dl), + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }; +} + +export const handleDownloadTv = async (id, season, episode, corsHeaders, sdk) => +{ + const dl = await sdk.getDownloads(id, season, episode); + return { + status: 200, + body: JSON.stringify(dl), + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }; +} \ No newline at end of file diff --git a/src/routes/health.js b/src/routes/health.js new file mode 100644 index 0000000..4604004 --- /dev/null +++ b/src/routes/health.js @@ -0,0 +1,31 @@ +import { HEALTH_PROBE_ID } from '@vyla-entertainment/sdk/src/config.js' +import VylaSDK from '@vyla-entertainment/sdk'; +/** + * @param { VylaSDK } sdk + */ +export async function handleHealth(sdk, cache) { + const sourceList = sdk.getSources(true); + + const sources = {}; + for (const cfg of sourceList) { + const key = cfg.key; + const probeResult = await sdk.probeSource(cfg.key, cache); + sources[key] = probeResult; + } + + const allOk = Object.values(sources).every(v => v.ok); + + return { + status: allOk ? 200 : 207, + body: JSON.stringify({ + note: 'This is a health check endpoint. This is extremely unreliable, be sure to test sources directly.', + status: allOk ? 'ok' : 'degraded', + timestamp: new Date().toISOString(), + tmdb: !!process.env.TMDB_API_KEY, + cache: cache.size, + probe_id: HEALTH_PROBE_ID, + sources, + }, null, 2), + headers: { 'Content-Type': 'application/json' }, + }; +} \ No newline at end of file diff --git a/src/routes/subtitles.js b/src/routes/subtitles.js new file mode 100644 index 0000000..fb62b42 --- /dev/null +++ b/src/routes/subtitles.js @@ -0,0 +1,19 @@ +export async function handleSubtitleMovie(id, corsHeaders, sdk) { + try { + const subtitles = await sdk.getSubtitles(id); + if (!subtitles.length) return { status: 404, body: JSON.stringify({ error: 'no subtitles found' }), headers: { 'Content-Type': 'application/json', ...corsHeaders } }; + return { status: 200, body: JSON.stringify(subtitles, null, 2), headers: { 'Content-Type': 'application/json', ...corsHeaders } }; + } catch (e) { + return { status: 500, body: JSON.stringify({ error: e.message }), headers: { 'Content-Type': 'application/json', ...corsHeaders } }; + } +} + +export async function handleSubtitleTv(id, season, episode, corsHeaders, sdk) { + try { + const subtitles = await sdk.getSubtitles(id, season, episode); + if (!subtitles.length) return { status: 404, body: JSON.stringify({ error: 'no subtitles found' }), headers: { 'Content-Type': 'application/json', ...corsHeaders } }; + return { status: 200, body: JSON.stringify(subtitles, null, 2), headers: { 'Content-Type': 'application/json', ...corsHeaders } }; + } catch (e) { + return { status: 500, body: JSON.stringify({ error: e.message }), headers: { 'Content-Type': 'application/json', ...corsHeaders } }; + } +} \ No newline at end of file diff --git a/src/routes/test.js b/src/routes/test.js new file mode 100644 index 0000000..2940096 --- /dev/null +++ b/src/routes/test.js @@ -0,0 +1,186 @@ +import { wrapUrl } from '../utils/proxy.js'; +import { getUA } from '../utils/helpers.js'; + +export async function handleTestRoute(sdk, match, searchParams, clientIP, host, handleTestSource, googleAnalytic) { + const sources = sdk.getSources(); + const source = searchParams.get('source'); + const cfg = sources.find(src => src.key === source) || null; + if (!source || !cfg) return { status: 400, body: JSON.stringify({ error: 'Invalid or Missing Source' }) }; + + const result = await handleTestSource( + sdk, + source, + match[1], + searchParams.get('season') || searchParams.get('s') || null, + searchParams.get('episode') || searchParams.get('e') || null, + clientIP, + host + ); + + googleAnalytic('source_test', { source, id: match[1], ok: JSON.parse(result.body).ok }); + return { status: result.status, body: result.body }; +} + +function headersToObject(h) { + const out = {}; + if (!h) return out; + for (const [k, v] of h.entries()) out[k] = v; + return out; +} + +function safeStack(err) { + if (!err) return null; + return String(err.stack || err.message || err).split('\n').slice(0, 10); +} + +export async function handleDebugRoute(sdk, match, searchParams, absoluteBase, _nativeFetch, verifyPlayable) { + const id = match[1]; + const s = searchParams.get('season') || searchParams.get('s') || null; + const e = searchParams.get('episode') || searchParams.get('e') || null; + const sourceKey = searchParams.get('source'); + const maxCandidates = Math.min(parseInt(searchParams.get('limit') || '10', 10) || 10, 25); + const previewBytes = Math.min(parseInt(searchParams.get('preview') || '800', 10) || 800, 4000); + + if (!sourceKey) return { status: 400, body: JSON.stringify({ error: 'missing source', usage: '/debug/:id?source=key&season=&episode=&limit=&preview=' }) }; + + const sources = sdk.getSources(); + const cfg = sources.find(src => src.key === sourceKey) || null; + if (!cfg) return { status: 400, body: JSON.stringify({ error: `unknown source: ${sourceKey}` }) }; + + const t0 = Date.now(); + let streamResult = null, streamError = null, streamErrorStack = null; + const fetchTrace = []; + + const tracingFetch = async (url, opts = {}) => { + const start = Date.now(); + const reqHeaders = headersToObject(new Headers(opts.headers || {})); + const entry = { + step: fetchTrace.length, + url: String(url).slice(0, 500), + method: opts.method || 'GET', + request_headers: reqHeaders, + }; + try { + const r = await _nativeFetch(url, opts); + entry.status = r.status; + entry.ok = r.ok; + entry.redirected = r.redirected; + entry.final_url = r.url && r.url !== String(url) ? r.url : undefined; + entry.response_headers = headersToObject(r.headers); + entry.ms = Date.now() - start; + fetchTrace.push(entry); + return r; + } catch (err) { + entry.error = err.message; + entry.ms = Date.now() - start; + fetchTrace.push(entry); + throw err; + } + }; + + const prev = globalThis.fetch; + globalThis.fetch = tracingFetch; + try { + streamResult = await sdk.getStream(sourceKey, id, s, e); + } catch (err) { + streamError = err.message; + streamErrorStack = safeStack(err); + } finally { + globalThis.fetch = prev; + } + + const candidates = + streamResult?.allUrls || + streamResult?.streams || + (streamResult?.url ? [streamResult] : []); + + const checks = await Promise.all(candidates.slice(0, maxCandidates).map(async (raw, i) => { + const rawUrl = typeof raw === 'object' + ? (raw.url || raw.file || raw.stream || raw.src) + : raw; + + const rawHeaders = typeof raw === 'object' && raw.headers + ? raw.headers + : {}; + + const wrappedUrl = wrapUrl( + typeof raw === 'object' + ? raw + : { url: raw }, + sourceKey, + absoluteBase, + sdk + ); + + const isSkippedProxy = !!raw?.skipProxy; + const fetchUrl = isSkippedProxy ? rawUrl : (wrappedUrl || rawUrl); + const fetchHeaders = isSkippedProxy || !wrappedUrl ? { 'User-Agent': getUA(), ...rawHeaders } : { 'User-Agent': getUA() }; + + let m3u8Preview = null, mp4Preview = null, playable_check = null; + let previewRequestHeaders = null, previewResponseHeaders = null, previewStatus = null, previewOk = null, previewFinalUrl = null; + + try { + const previewHeaders = { ...fetchHeaders, Range: `bytes=0-${previewBytes - 1}` }; + previewRequestHeaders = previewHeaders; + const r = await _nativeFetch(fetchUrl, { signal: AbortSignal.timeout(15_000), headers: previewHeaders }); + previewStatus = r.status; + previewOk = r.ok || r.status === 206; + previewFinalUrl = r.url && r.url !== fetchUrl ? r.url : undefined; + previewResponseHeaders = headersToObject(r.headers); + const ct = (r.headers.get('content-type') || '').toLowerCase(); + const isMp4 = /\.mp4(\?|$)/i.test(fetchUrl) || ct.includes('video/mp4') || ct.includes('video/mp2t') || ct.includes('octet-stream'); + if (isMp4) { + const bytes = new Uint8Array(await r.arrayBuffer()); + mp4Preview = Array.from(bytes.slice(0, 32)).map(b => b.toString(16).padStart(2, '0')).join(' '); + playable_check = { ok: previewOk, error: previewOk ? null : `mp4 fetch failed: ${r.status}` }; + } else { + m3u8Preview = (await r.text()).slice(0, previewBytes); + playable_check = await verifyPlayable(fetchUrl, fetchHeaders, isSkippedProxy || !wrappedUrl); + } + } catch (err) { + playable_check = { ok: false, error: err.message }; + } + + return { + index: i, + raw_candidate: raw, + raw_url: rawUrl, + raw_headers: rawHeaders, + proxy_url: isSkippedProxy ? rawUrl : wrappedUrl, + skip_proxy: isSkippedProxy, + fetch_url_used: fetchUrl, + preview_request: { headers: previewRequestHeaders, method: 'GET' }, + preview_response: { + status: previewStatus, + ok: previewOk, + final_url: previewFinalUrl, + headers: previewResponseHeaders, + }, + playable_check, + m3u8_preview: m3u8Preview, + mp4_preview_hex: mp4Preview, + }; + })); + + return { + status: 200, + body: JSON.stringify({ + source: sourceKey, + id, + season: s, + episode: e, + config_used: cfg, + candidates_total: candidates.length, + candidates_checked: checks.length, + checks, + elapsed_ms: Date.now() - t0, + stream_error: streamError, + stream_error_stack: streamErrorStack, + fetch_trace: fetchTrace, + fetch_count: fetchTrace.length, + got_result: streamResult !== null, + result_keys: streamResult ? Object.keys(streamResult) : null, + raw_stream_result: streamResult, + }, null, 2) + }; +} \ No newline at end of file diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 0000000..790734a --- /dev/null +++ b/src/utils/helpers.js @@ -0,0 +1,179 @@ +import crypto from 'node:crypto'; + +export const UA_LIST = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', + 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', +]; + +export const getUA = () => UA_LIST[Math.floor(Math.random() * UA_LIST.length)]; +export const USER_AGENT = UA_LIST[0]; + +let sharedDcKey = null; + +export function decryptPartner(responseText, keyEnv) { + try { + const sep = responseText.indexOf(':'); + if (sep === -1) return null; + const iv = Buffer.from(responseText.slice(0, sep), 'base64'); + const encrypted = responseText.slice(sep + 1); + if (!sharedDcKey) sharedDcKey = crypto.createHash('sha256').update(keyEnv || '').digest(); + const decipher = crypto.createDecipheriv('aes-256-cbc', sharedDcKey, iv); + return JSON.parse(decipher.update(encrypted, 'base64', 'utf8') + decipher.final('utf8')); + } catch { + return null; + } +} + +export async function fetchJson(url, options) { + const res = await fetch(url, options); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +export async function fetchText(url, options) { + const res = await fetch(url, options); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.text(); +} + +const tmdbInfoCache = new Map(); +const anilistCache = new Map(); +const tmdbValidationCache = new Map(); + +function pruneHelperCache(cache) { + const now = Date.now(); + for (const [k, v] of cache) if (now - v.ts >= v.ttl) cache.delete(k); + if (cache.size > 3000) { + const overflow = cache.size - 3000; + const it = cache.keys(); + for (let i = 0; i < overflow; i++) cache.delete(it.next().value); + } +} + +setInterval(() => { + pruneHelperCache(tmdbValidationCache); + pruneHelperCache(tmdbInfoCache); + pruneHelperCache(anilistCache); +}, 60000).unref(); + +function cacheGet(cache, key) { + const entry = cache.get(key); + if (!entry) return undefined; + if (Date.now() - entry.ts >= entry.ttl) { cache.delete(key); return undefined; } + return entry.val; +} + +function cacheSet(cache, key, val, ttl) { cache.set(key, { val, ts: Date.now(), ttl }); } + +export async function validateTmdbId(tmdbId, mediaType = 'movie') { + const key = `${tmdbId}-${mediaType}`; + const cached = cacheGet(tmdbValidationCache, key); + if (cached !== undefined) return cached; + const k = process.env.TMDB_API_KEY; + if (!k) return { valid: false, error: 'TMDB API key not configured' }; + try { + const res = await fetch(`https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${k}`, { signal: AbortSignal.timeout(5000) }); + if (res.ok) { + const data = await res.json(); + const isValid = !!(data?.id && (data?.title || data?.name)); + const result = { valid: isValid, error: isValid ? null : 'Invalid TMDB ID' }; + cacheSet(tmdbValidationCache, key, result, 300000); + return result; + } + const result = { valid: false, error: `TMDB API error: ${res.status}` }; + cacheSet(tmdbValidationCache, key, result, 60000); + return result; + } catch (error) { + const result = { valid: false, error: `Failed to validate TMDB ID: ${error.message}` }; + cacheSet(tmdbValidationCache, key, result, 60000); + return result; + } +} + +export async function getTmdbInfo(tmdbId, mediaType, season) { + const key = `${tmdbId}-${mediaType}-${season || ''}`; + const cached = cacheGet(tmdbInfoCache, key); + if (cached !== undefined) return cached; + const k = process.env.TMDB_API_KEY; + if (!k) return { isAnime: false, titles: [], year: null, imdbId: null }; + try { + const [mainRes, seasonRes] = await Promise.all([ + fetch(`https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${k}&append_to_response=external_ids`, { signal: AbortSignal.timeout(5000) }), + season && mediaType === 'tv' ? fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/season/${season}?api_key=${k}`, { signal: AbortSignal.timeout(5000) }) : Promise.resolve(null), + ]); + let mainData = null, seasonData = null; + if (mainRes.ok) mainData = await mainRes.json(); else mainRes.body?.cancel(); + if (seasonRes) { if (seasonRes.ok) seasonData = await seasonRes.json(); else seasonRes.body?.cancel(); } + const genres = mainData?.genres || []; + const isAnime = genres.some(g => g.id === 16) && ((mainData?.origin_country || []).includes('JP') || mainData?.original_language === 'ja'); + const titles = []; + if (seasonData?.name) titles.push(seasonData.name); + const t = mainData?.title || mainData?.name || ''; + const ot = mainData?.original_title || mainData?.original_name || ''; + if (t) titles.push(t); + if (ot && ot !== t) titles.push(ot); + const dateStr = seasonData?.air_date || mainData?.release_date || mainData?.first_air_date || ''; + const result = { isAnime, titles: [...new Set(titles.filter(Boolean))], year: dateStr ? parseInt(dateStr.slice(0, 4), 10) : null, imdbId: mainData?.imdb_id || mainData?.external_ids?.imdb_id || null }; + cacheSet(tmdbInfoCache, key, result, 600000); + return result; + } catch { return { isAnime: false, titles: [], year: null, imdbId: null }; } +} + +export async function tmdbToAnilist(tmdbId, mediaType, season, titles = [], year = null) { + const key = `${tmdbId}-${mediaType}-${season || ''}`; + const cached = cacheGet(anilistCache, key); + if (cached !== undefined) return cached; + try { + const res = await fetch(`https://api.ani.zip/mappings?tmdb_id=${tmdbId}&type=${mediaType}&season=${season || 1}`, { signal: AbortSignal.timeout(6000) }); + if (res.ok) { + const id = (await res.json())?.mappings?.[0]?.anilist_id; + if (id) { cacheSet(anilistCache, key, id, 600000); return id; } + } else res.body?.cancel(); + } catch { } + if (!titles.length) return null; + const query = `query ($s: String) { Page(page:1,perPage:10) { media(search:$s,type:ANIME) { id title { romaji english native } startDate { year } format } } }`; + let bestId = null, bestScore = -1; + for (const searchTitle of titles) { + try { + const res = await fetch('https://graphql.anilist.co', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ query, variables: { s: searchTitle } }), signal: AbortSignal.timeout(6000) }); + if (!res.ok) { res.body?.cancel(); continue; } + for (const entry of (await res.json())?.data?.Page?.media || []) { + const entryTitles = [entry.title?.romaji, entry.title?.english, entry.title?.native].filter(Boolean).map(t => t.toLowerCase()); + const sl = searchTitle.toLowerCase(); + let score = 0; + if (entryTitles.some(t => t === sl)) score += 5; + else if (entryTitles.some(t => t.includes(sl) || sl.includes(t))) score += 3; + else if (entryTitles.some(t => { const w = sl.split(/\s+/).filter(x => x.length > 3); return w.length > 0 && w.every(x => t.includes(x)); })) score += 2; + else continue; + if (year && entry.startDate?.year) { + const diff = Math.abs(entry.startDate.year - year); + if (diff === 0) score += 3; else if (diff === 1) score += 1; else if (diff > 2) score -= 3; + } + if (mediaType === 'tv' && ['TV', 'TV_SHORT', 'ONA', 'OVA'].includes(entry.format)) score += 1; + if (mediaType === 'movie' && entry.format === 'MOVIE') score += 1; + if (score > bestScore) { bestScore = score; bestId = entry.id; } + } + if (bestScore >= 8) break; + } catch { } + } + if (bestId) cacheSet(anilistCache, key, bestId, 600000); + return bestId; +} + +export async function imdbToTmdb(imdbId, mediaType = 'movie') { + const key = `imdb-${imdbId}`; + const cached = cacheGet(tmdbInfoCache, key); + if (cached !== undefined) return cached; + const k = process.env.TMDB_API_KEY; + if (!k) return null; + try { + const data = await fetchJson(`https://api.themoviedb.org/3/find/${imdbId}?api_key=${k}&external_source=imdb_id`, { signal: AbortSignal.timeout(5000) }); + let result = null; + if (mediaType === 'movie' && data.movie_results?.length) result = data.movie_results[0].id; + else if (mediaType === 'tv' && data.tv_results?.length) result = data.tv_results[0].id; + if (result) cacheSet(tmdbInfoCache, key, result, 600000); + return result; + } catch { return null; } +} \ No newline at end of file diff --git a/src/utils/proxy.js b/src/utils/proxy.js new file mode 100644 index 0000000..1bce9b9 --- /dev/null +++ b/src/utils/proxy.js @@ -0,0 +1,53 @@ +import { issueSessionToken } from '../middleware/auth.js'; + +const PROXY_STREAMS = process.env.PROXY_STREAMS === "true"; +const EXTERNAL_PROXY_URL = (process.env.PROXY_URL || "").replace(/\/+$/, ""); + +function buildProxyUrl(base, params) { + return `${base}?${params.toString()}`; +} + +export function wrapUrl(rawUrl, sourceKey, absoluteBase, sdk) { + if (!rawUrl) return null; + + const raw = typeof rawUrl === "object" ? rawUrl.url : rawUrl; + if (!raw) return null; + const cfg = sdk.getSources().find(c => c.key === sourceKey); + + if (!cfg || cfg.skipProxy || rawUrl?.skipProxy) { + return raw; + } + + const isLocalBase = + absoluteBase.includes("localhost") || absoluteBase.includes("127.0.0.1"); + const safeBaseCheck = isLocalBase + ? absoluteBase.replace(/^https:\/\//, "http://") + : absoluteBase.replace(/^http:\/\//, "https://"); + + if (raw.startsWith(`${safeBaseCheck}/api?`) || (EXTERNAL_PROXY_URL && raw.startsWith(`${EXTERNAL_PROXY_URL}?`))) { + return raw; + } + + const proxyParam = cfg.proxyParam || "proxy"; + + if (PROXY_STREAMS) { + const isLocal = isLocalBase; + const safeBase = safeBaseCheck; + const normalized = isLocal ? raw : raw.replace(/^http:\/\//, "https://"); + + const params = new URLSearchParams({ url: normalized, [proxyParam]: "1" }); + if (typeof rawUrl === "object" && rawUrl.headers) params.set("proxyHeaders", JSON.stringify(rawUrl.headers)); + params.set("internal_token", issueSessionToken("internal", sourceKey)); + + return buildProxyUrl(`${safeBase}/api`, params); + } + + if (EXTERNAL_PROXY_URL) { + const normalized = raw.replace(/^http:\/\//, "https://"); + const params = new URLSearchParams({ url: normalized, [proxyParam]: "1" }); + if (typeof rawUrl === "object" && rawUrl.headers) params.set("proxyHeaders", JSON.stringify(rawUrl.headers)); + return buildProxyUrl(EXTERNAL_PROXY_URL, params); + } + + return raw; +} \ No newline at end of file diff --git a/update-db.mjs b/update-db.mjs new file mode 100644 index 0000000..b9d38e0 --- /dev/null +++ b/update-db.mjs @@ -0,0 +1,74 @@ +import { DatabaseSync } from "node:sqlite"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const DB_PATH = path.join(__dirname, "data", "api_keys.db"); +const INPUT = path.join(__dirname, "db.json"); + +const db = new DatabaseSync(DB_PATH); + +const data = JSON.parse( + fs.readFileSync(INPUT, "utf8") +); + +db.exec('BEGIN TRANSACTION'); +try { + const existing = db.prepare(` + SELECT key FROM api_keys + `).all().map(x => x.key); + + const incoming = data.map(x => x.key); + + for (const key of existing) { + if (!incoming.includes(key)) { + db.prepare(` + DELETE FROM api_keys + WHERE key = ? + `).run(key); + + console.log(`Deleted ${key}`); + } + } + + const upsert = db.prepare(` + INSERT INTO api_keys ( + key, + type, + rpm, + active, + created_at + ) + VALUES ( + ?, + ?, + ?, + ?, + ? + ) + ON CONFLICT(key) DO UPDATE SET + type = excluded.type, + rpm = excluded.rpm, + active = excluded.active + `); + + for (const row of data) { + upsert.run( + row.key, + row.type ?? "standard", + row.rpm ?? 100, + row.active ?? 1, + row.created_at ?? new Date().toISOString() + ); + + console.log(`Updated ${row.key}`); + } + db.exec('COMMIT'); +} catch (err) { + db.exec('ROLLBACK'); + throw err; +} + +console.log("Database synced from db.json"); \ No newline at end of file