├── android
├── app
│ ├── .gitignore
│ ├── src
│ │ ├── main
│ │ │ ├── res
│ │ │ │ ├── drawable
│ │ │ │ │ ├── splash.png
│ │ │ │ │ └── ic_launcher_background.xml
│ │ │ │ ├── mipmap-hdpi
│ │ │ │ │ ├── ic_launcher.png
│ │ │ │ │ ├── ic_launcher_round.png
│ │ │ │ │ └── ic_launcher_foreground.png
│ │ │ │ ├── mipmap-mdpi
│ │ │ │ │ ├── ic_launcher.png
│ │ │ │ │ ├── ic_launcher_round.png
│ │ │ │ │ └── ic_launcher_foreground.png
│ │ │ │ ├── drawable-land-hdpi
│ │ │ │ │ └── splash.png
│ │ │ │ ├── drawable-land-mdpi
│ │ │ │ │ └── splash.png
│ │ │ │ ├── drawable-land-xhdpi
│ │ │ │ │ └── splash.png
│ │ │ │ ├── drawable-port-hdpi
│ │ │ │ │ └── splash.png
│ │ │ │ ├── drawable-port-mdpi
│ │ │ │ │ └── splash.png
│ │ │ │ ├── drawable-port-xhdpi
│ │ │ │ │ └── splash.png
│ │ │ │ ├── mipmap-xhdpi
│ │ │ │ │ ├── ic_launcher.png
│ │ │ │ │ ├── ic_launcher_round.png
│ │ │ │ │ └── ic_launcher_foreground.png
│ │ │ │ ├── mipmap-xxhdpi
│ │ │ │ │ ├── ic_launcher.png
│ │ │ │ │ ├── ic_launcher_round.png
│ │ │ │ │ └── ic_launcher_foreground.png
│ │ │ │ ├── mipmap-xxxhdpi
│ │ │ │ │ ├── ic_launcher.png
│ │ │ │ │ ├── ic_launcher_round.png
│ │ │ │ │ └── ic_launcher_foreground.png
│ │ │ │ ├── drawable-land-xxhdpi
│ │ │ │ │ └── splash.png
│ │ │ │ ├── drawable-land-xxxhdpi
│ │ │ │ │ └── splash.png
│ │ │ │ ├── drawable-port-xxhdpi
│ │ │ │ │ └── splash.png
│ │ │ │ ├── drawable-port-xxxhdpi
│ │ │ │ │ └── splash.png
│ │ │ │ ├── values
│ │ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ │ ├── strings.xml
│ │ │ │ │ └── styles.xml
│ │ │ │ ├── xml
│ │ │ │ │ └── file_paths.xml
│ │ │ │ ├── mipmap-anydpi-v26
│ │ │ │ │ ├── ic_launcher.xml
│ │ │ │ │ └── ic_launcher_round.xml
│ │ │ │ ├── layout
│ │ │ │ │ └── activity_main.xml
│ │ │ │ └── drawable-v24
│ │ │ │ │ └── ic_launcher_foreground.xml
│ │ │ ├── java
│ │ │ │ └── com
│ │ │ │ │ └── ednovas
│ │ │ │ │ └── donguatv
│ │ │ │ │ └── MainActivity.java
│ │ │ └── AndroidManifest.xml
│ │ ├── test
│ │ │ └── java
│ │ │ │ └── com
│ │ │ │ └── getcapacitor
│ │ │ │ └── myapp
│ │ │ │ └── ExampleUnitTest.java
│ │ └── androidTest
│ │ │ └── java
│ │ │ └── com
│ │ │ └── getcapacitor
│ │ │ └── myapp
│ │ │ └── ExampleInstrumentedTest.java
│ ├── capacitor.build.gradle
│ ├── proguard-rules.pro
│ └── build.gradle
├── .idea
│ ├── .gitignore
│ ├── compiler.xml
│ ├── AndroidProjectSystem.xml
│ ├── migrations.xml
│ ├── misc.xml
│ └── runConfigurations.xml
├── gradle
│ └── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
├── settings.gradle
├── capacitor.settings.gradle
├── variables.gradle
├── build.gradle
├── gradle.properties
├── .gitignore
├── gradlew.bat
└── gradlew
├── .dockerignore
├── public
├── icon.png
├── libs
│ └── webfonts
│ │ ├── fa-solid-900.ttf
│ │ ├── fa-brands-400.ttf
│ │ ├── fa-brands-400.woff2
│ │ ├── fa-regular-400.ttf
│ │ ├── fa-solid-900.woff2
│ │ └── fa-regular-400.woff2
├── manifest.json
├── sw.js
└── clear-cache.html
├── .gitattributes
├── db.template.json
├── capacitor.config.json
├── .gitignore
├── Dockerfile
├── vercel.json
├── package.json
├── .env.example
├── .github
└── workflows
│ ├── docker-publish.yml
│ └── android-build.yml
├── cloudflare-tmdb-proxy.js
├── proxy-server.js
├── install.sh
├── cloudflare-cors-proxy.js
├── api
└── index.js
├── README.md
└── server.js
/android/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build/*
2 | !/build/.npmkeep
3 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | .git
4 | .env
5 | .DS_Store
6 |
--------------------------------------------------------------------------------
/android/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/public/icon.png
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/public/libs/webfonts/fa-solid-900.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/public/libs/webfonts/fa-solid-900.ttf
--------------------------------------------------------------------------------
/public/libs/webfonts/fa-brands-400.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/public/libs/webfonts/fa-brands-400.ttf
--------------------------------------------------------------------------------
/public/libs/webfonts/fa-brands-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/public/libs/webfonts/fa-brands-400.woff2
--------------------------------------------------------------------------------
/public/libs/webfonts/fa-regular-400.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/public/libs/webfonts/fa-regular-400.ttf
--------------------------------------------------------------------------------
/public/libs/webfonts/fa-solid-900.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/public/libs/webfonts/fa-solid-900.woff2
--------------------------------------------------------------------------------
/android/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/public/libs/webfonts/fa-regular-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/public/libs/webfonts/fa-regular-400.woff2
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-land-hdpi/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable-land-hdpi/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-land-mdpi/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable-land-mdpi/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-land-xhdpi/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable-land-xhdpi/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-port-hdpi/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable-port-hdpi/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-port-mdpi/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable-port-mdpi/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-port-xhdpi/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable-port-xhdpi/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-land-xxhdpi/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable-land-xxhdpi/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-land-xxxhdpi/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-port-xxhdpi/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable-port-xxhdpi/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-port-xxxhdpi/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdNovas/dongguaTV/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 |
--------------------------------------------------------------------------------
/android/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/android/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 | include ':capacitor-cordova-android-plugins'
3 | project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
4 |
5 | apply from: 'capacitor.settings.gradle'
--------------------------------------------------------------------------------
/android/.idea/AndroidProjectSystem.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/android/app/src/main/res/xml/file_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/android/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
--------------------------------------------------------------------------------
/android/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | E视界
4 | E视界
5 | com.ednovas.donguatv
6 | com.ednovas.donguatv
7 |
8 |
--------------------------------------------------------------------------------
/db.template.json:
--------------------------------------------------------------------------------
1 | {
2 | "sites": [
3 | {
4 | "key": "example1",
5 | "name": "示例站点1 (请填写API)",
6 | "api": "",
7 | "active": true
8 | },
9 | {
10 | "key": "example2",
11 | "name": "示例站点2 (请填写API)",
12 | "api": "",
13 | "active": true
14 | }
15 | ]
16 | }
--------------------------------------------------------------------------------
/android/capacitor.settings.gradle:
--------------------------------------------------------------------------------
1 | // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
2 | include ':capacitor-android'
3 | project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
4 |
5 | include ':capacitor-status-bar'
6 | project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
7 |
--------------------------------------------------------------------------------
/capacitor.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "appId": "com.ednovas.donguatv",
3 | "appName": "E视界",
4 | "webDir": "public",
5 | "server": {
6 | "url": "https://ednovas.video",
7 | "cleartext": true
8 | },
9 | "plugins": {
10 | "StatusBar": {
11 | "overlaysWebView": false,
12 | "style": "DARK",
13 | "backgroundColor": "#000000"
14 | }
15 | }
16 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .env
3 | .DS_Store
4 | Thumbs.db
5 | search_cache.json
6 | detail_cache.json
7 | cache_search.json
8 | cache_detail.json
9 | cache.db
10 | cache.db-wal
11 | cache.db-shm
12 | *.log
13 | db.json
14 | app-debug.apk
15 | public/cache
16 |
17 | # IDE Config
18 | .idea/
19 | *.iml
20 |
21 | # Android/Capacitor Build
22 | android/app/build/
23 | android/.gradle/
24 | android/local.properties
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "E视界",
3 | "short_name": "E视界",
4 | "start_url": "./index.html",
5 | "display": "standalone",
6 | "background_color": "#141414",
7 | "theme_color": "#141414",
8 | "scope": "/",
9 | "icons": [
10 | {
11 | "src": "icon.png",
12 | "sizes": "1024x1024",
13 | "type": "image/png",
14 | "purpose": "any maskable"
15 | }
16 | ]
17 | }
--------------------------------------------------------------------------------
/android/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # 多架构支持: linux/amd64, linux/arm64, linux/arm/v7
2 | FROM node:18-alpine
3 |
4 | WORKDIR /app
5 |
6 | COPY package*.json ./
7 |
8 | # 安装构建依赖 (better-sqlite3 需要原生编译)
9 | # 注意: ARM 架构会自动使用对应的编译工具链
10 | RUN apk add --no-cache --virtual .build-deps \
11 | python3 \
12 | make \
13 | g++ \
14 | && npm install --production \
15 | && apk del .build-deps
16 |
17 | COPY . .
18 |
19 | # 创建必要的目录
20 | RUN mkdir -p /app/public/cache/images
21 |
22 | EXPOSE 3000
23 |
24 | CMD ["node", "server.js"]
25 |
--------------------------------------------------------------------------------
/android/app/capacitor.build.gradle:
--------------------------------------------------------------------------------
1 | // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
2 |
3 | android {
4 | compileOptions {
5 | sourceCompatibility JavaVersion.VERSION_21
6 | targetCompatibility JavaVersion.VERSION_21
7 | }
8 | }
9 |
10 | apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
11 | dependencies {
12 | implementation project(':capacitor-status-bar')
13 |
14 | }
15 |
16 |
17 | if (hasProperty('postBuildExtras')) {
18 | postBuildExtras()
19 | }
20 |
--------------------------------------------------------------------------------
/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java:
--------------------------------------------------------------------------------
1 | package com.getcapacitor.myapp;
2 |
3 | import static org.junit.Assert.*;
4 |
5 | import org.junit.Test;
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * @see Testing documentation
11 | */
12 | public class ExampleUnitTest {
13 |
14 | @Test
15 | public void addition_isCorrect() throws Exception {
16 | assertEquals(4, 2 + 2);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/android/variables.gradle:
--------------------------------------------------------------------------------
1 | ext {
2 | minSdkVersion = 24
3 | compileSdkVersion = 36
4 | targetSdkVersion = 36
5 | androidxActivityVersion = '1.11.0'
6 | androidxAppCompatVersion = '1.7.1'
7 | androidxCoordinatorLayoutVersion = '1.3.0'
8 | androidxCoreVersion = '1.17.0'
9 | androidxFragmentVersion = '1.8.9'
10 | coreSplashScreenVersion = '1.2.0'
11 | androidxWebkitVersion = '1.14.0'
12 | junitVersion = '4.13.2'
13 | androidxJunitVersion = '1.3.0'
14 | androidxEspressoCoreVersion = '3.7.0'
15 | cordovaAndroidVersion = '14.0.1'
16 | }
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "builds": [
4 | {
5 | "src": "api/index.js",
6 | "use": "@vercel/node"
7 | },
8 | {
9 | "src": "public/**",
10 | "use": "@vercel/static"
11 | }
12 | ],
13 | "routes": [
14 | {
15 | "src": "/api/(.*)",
16 | "dest": "/api/index.js"
17 | },
18 | {
19 | "src": "/",
20 | "dest": "/public/index.html"
21 | },
22 | {
23 | "src": "/(.*)",
24 | "dest": "/public/$1"
25 | }
26 | ]
27 | }
--------------------------------------------------------------------------------
/android/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "node-video-proxy",
3 | "version": "1.0.2",
4 | "main": "server.js",
5 | "scripts": {
6 | "start": "node server.js",
7 | "dev": "node server.js"
8 | },
9 | "dependencies": {
10 | "@capacitor/status-bar": "^8.0.0",
11 | "axios": "^1.6.0",
12 | "better-sqlite3": "^12.5.0",
13 | "body-parser": "^1.20.2",
14 | "compression": "^1.8.1",
15 | "cors": "^2.8.5",
16 | "dotenv": "^17.2.3",
17 | "express": "^4.18.2",
18 | "express-rate-limit": "^8.2.1",
19 | "node-cache": "^5.1.2"
20 | },
21 | "devDependencies": {
22 | "@capacitor/android": "^8.0.0",
23 | "@capacitor/cli": "^8.0.0",
24 | "@capacitor/core": "^8.0.0"
25 | }
26 | }
--------------------------------------------------------------------------------
/android/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 |
3 | buildscript {
4 |
5 | repositories {
6 | google()
7 | mavenCentral()
8 | }
9 | dependencies {
10 | classpath 'com.android.tools.build:gradle:8.13.0'
11 | classpath 'com.google.gms:google-services:4.4.4'
12 |
13 | // NOTE: Do not place your application dependencies here; they belong
14 | // in the individual module build.gradle files
15 | }
16 | }
17 |
18 | apply from: "variables.gradle"
19 |
20 | allprojects {
21 | repositories {
22 | google()
23 | mavenCentral()
24 | }
25 | }
26 |
27 | task clean(type: Delete) {
28 | delete rootProject.buildDir
29 | }
30 |
--------------------------------------------------------------------------------
/android/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
17 |
18 |
19 |
22 |
--------------------------------------------------------------------------------
/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java:
--------------------------------------------------------------------------------
1 | package com.getcapacitor.myapp;
2 |
3 | import static org.junit.Assert.*;
4 |
5 | import android.content.Context;
6 | import androidx.test.ext.junit.runners.AndroidJUnit4;
7 | import androidx.test.platform.app.InstrumentationRegistry;
8 | import org.junit.Test;
9 | import org.junit.runner.RunWith;
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * @see Testing documentation
15 | */
16 | @RunWith(AndroidJUnit4.class)
17 | public class ExampleInstrumentedTest {
18 |
19 | @Test
20 | public void useAppContext() throws Exception {
21 | // Context of the app under test.
22 | Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
23 |
24 | assertEquals("com.getcapacitor.app", appContext.getPackageName());
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/android/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/android/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | org.gradle.jvmargs=-Xmx1536m
13 |
14 | # When configured, Gradle will run in incubating parallel mode.
15 | # This option should only be used with decoupled projects. More details, visit
16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
17 | # org.gradle.parallel=true
18 |
19 | # AndroidX package structure to make it clearer which packages are bundled with the
20 | # Android operating system, and which are packaged with your app's APK
21 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
22 | android.useAndroidX=true
23 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # ============================================
2 | # 冬瓜TV 环境变量配置模板
3 | # 复制此文件为 .env 并填写对应的值
4 | # ============================================
5 |
6 | # ========== 必填配置 ==========
7 |
8 | # TMDB API Key (必填)
9 | # 获取方式: https://www.themoviedb.org/settings/api
10 | TMDB_API_KEY=your_api_key_here
11 |
12 | # ========== 可选配置 ==========
13 |
14 | # 运行端口 (默认 3000)
15 | PORT=3000
16 |
17 | # 访问密码 (可选)
18 | # 单密码: ACCESS_PASSWORD=mypassword
19 | # 多密码: ACCESS_PASSWORD=admin123,user456,guest789
20 | # 注意: 第一个密码为管理员,不启用历史同步;其他密码启用历史同步
21 | ACCESS_PASSWORD=
22 |
23 | # 大陆用户 TMDB 反代地址 (可选)
24 | # 如果服务器在大陆或主要面向大陆用户,需要配置此项以访问 TMDB
25 | # 可使用 Cloudflare Workers 部署反代,参考 cloudflare-tmdb-proxy.js
26 | # 示例: TMDB_PROXY_URL=https://tmdb-proxy.your-name.workers.dev
27 | TMDB_PROXY_URL=
28 |
29 | # 远程站点配置 URL (可选)
30 | # 从远程 URL 加载站点配置,支持动态更新
31 | # 留空则使用本地 db.json 文件
32 | # 示例: REMOTE_DB_URL=https://example.com/sites.json
33 | REMOTE_DB_URL=
34 |
35 | # 资源站 CORS 代理 URL (可选)
36 | # 当用户无法直接访问某些资源站 API 时,自动通过此代理中转请求
37 | # 可使用 Cloudflare Workers 部署代理,参考: https://github.com/hafrey1/LunaTV-config
38 | # 代理使用方式: CORS_PROXY_URL/?url=目标API地址
39 | # 示例: CORS_PROXY_URL=https://cors-proxy.your-name.workers.dev
40 | CORS_PROXY_URL=
41 |
42 | # ========== 缓存配置 ==========
43 |
44 | # 缓存类型 (可选)
45 | # - json: JSON 文件缓存 (默认,适合本地/VPS 部署)
46 | # - sqlite: SQLite 数据库缓存 (需要 better-sqlite3,支持历史同步)
47 | # - memory: 内存缓存 (Vercel/Serverless 推荐)
48 | # - none: 禁用缓存
49 | CACHE_TYPE=json
50 |
51 | # ========== Vercel 部署说明 ==========
52 | # 在 Vercel 部署时:
53 | # 1. CACHE_TYPE 建议设置为 memory (Serverless 无法持久化文件)
54 | # 2. 本地图片缓存会自动禁用
55 | # 3. 多用户历史同步功能需要 sqlite 缓存类型
56 |
--------------------------------------------------------------------------------
/android/app/src/main/java/com/ednovas/donguatv/MainActivity.java:
--------------------------------------------------------------------------------
1 | package com.ednovas.donguatv;
2 |
3 | import android.os.Bundle;
4 | import com.getcapacitor.BridgeActivity;
5 |
6 | public class MainActivity extends BridgeActivity {
7 | @Override
8 | public void onCreate(Bundle savedInstanceState) {
9 | super.onCreate(savedInstanceState);
10 | }
11 |
12 | @Override
13 | public void onStart() {
14 | super.onStart();
15 | // 延时执行,作为插件失效时的强制兜底方案
16 | new android.os.Handler().postDelayed(() -> {
17 | try {
18 | // 1. 设置状态栏背景为纯黑
19 | getWindow().setStatusBarColor(android.graphics.Color.BLACK);
20 |
21 | // 2. 获取状态栏高度
22 | int statusBarHeight = 0;
23 | int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android");
24 | if (resourceId > 0) {
25 | statusBarHeight = getResources().getDimensionPixelSize(resourceId);
26 | }
27 |
28 | // 3. 强制给根视图增加 Top Padding
29 | android.view.View content = findViewById(android.R.id.content);
30 | if (content != null && statusBarHeight > 0) {
31 | // 仅当没有 Padding 时才添加 (避免与插件冲突导致双倍高度)
32 | if (content.getPaddingTop() < statusBarHeight) {
33 | content.setPadding(0, statusBarHeight, 0, 0);
34 | }
35 | }
36 | } catch (Exception e) {
37 | e.printStackTrace();
38 | }
39 | }, 300);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
13 |
19 |
22 |
25 |
26 |
27 |
28 |
34 |
35 |
--------------------------------------------------------------------------------
/android/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
14 |
15 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
40 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/.github/workflows/docker-publish.yml:
--------------------------------------------------------------------------------
1 | name: Docker Build & Publish (Multi-Arch)
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | # Publish semver tags as releases.
7 | tags: [ 'v*.*.*' ]
8 | pull_request:
9 | branches: [ "main" ]
10 |
11 | env:
12 | REGISTRY: ghcr.io
13 | # ghcr.io requires lowercase image names
14 | IMAGE_NAME: ${{ github.repository_owner }}/dongguatv
15 |
16 | jobs:
17 | build:
18 | runs-on: ubuntu-latest
19 | permissions:
20 | contents: read
21 | packages: write
22 |
23 | steps:
24 | - name: Checkout repository
25 | uses: actions/checkout@v4
26 |
27 | # 设置 QEMU 用于多架构模拟构建
28 | - name: Set up QEMU
29 | uses: docker/setup-qemu-action@v3
30 | with:
31 | platforms: linux/amd64,linux/arm64,linux/arm/v7
32 |
33 | # 设置 Docker Buildx
34 | - name: Set up Docker Buildx
35 | uses: docker/setup-buildx-action@v3
36 |
37 | - name: Log into registry ${{ env.REGISTRY }}
38 | if: github.event_name != 'pull_request'
39 | uses: docker/login-action@v3
40 | with:
41 | registry: ${{ env.REGISTRY }}
42 | username: ${{ github.actor }}
43 | password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
44 |
45 | - name: Extract Docker metadata
46 | id: meta
47 | uses: docker/metadata-action@v5
48 | with:
49 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
50 | tags: |
51 | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
52 | type=semver,pattern={{version}}
53 |
54 | # 多架构构建并推送
55 | - name: Build and push Docker image (Multi-Arch)
56 | id: build-and-push
57 | uses: docker/build-push-action@v5
58 | with:
59 | context: .
60 | # 支持的架构: AMD64 (x86_64), ARM64 (Apple M1/M2, 树莓派4), ARMv7 (树莓派3, 旧版ARM设备)
61 | platforms: linux/amd64,linux/arm64,linux/arm/v7
62 | push: ${{ github.event_name != 'pull_request' }}
63 | tags: ${{ steps.meta.outputs.tags }}
64 | labels: ${{ steps.meta.outputs.labels }}
65 | cache-from: type=gha
66 | cache-to: type=gha,mode=max
67 | # 启用 provenance 和 sbom (可选,用于安全审计)
68 | provenance: false
69 |
--------------------------------------------------------------------------------
/android/.gitignore:
--------------------------------------------------------------------------------
1 | # Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
2 |
3 | # Built application files
4 | *.apk
5 | *.aar
6 | *.ap_
7 | *.aab
8 |
9 | # Files for the ART/Dalvik VM
10 | *.dex
11 |
12 | # Java class files
13 | *.class
14 |
15 | # Generated files
16 | bin/
17 | gen/
18 | out/
19 | # Uncomment the following line in case you need and you don't have the release build type files in your app
20 | # release/
21 |
22 | # Gradle files
23 | .gradle/
24 | build/
25 |
26 | # Local configuration file (sdk path, etc)
27 | local.properties
28 |
29 | # Proguard folder generated by Eclipse
30 | proguard/
31 |
32 | # Log Files
33 | *.log
34 |
35 | # Android Studio Navigation editor temp files
36 | .navigation/
37 |
38 | # Android Studio captures folder
39 | captures/
40 |
41 | # IntelliJ
42 | *.iml
43 | .idea/workspace.xml
44 | .idea/tasks.xml
45 | .idea/gradle.xml
46 | .idea/assetWizardSettings.xml
47 | .idea/dictionaries
48 | .idea/libraries
49 | # Android Studio 3 in .gitignore file.
50 | .idea/caches
51 | .idea/modules.xml
52 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you
53 | .idea/navEditor.xml
54 |
55 | # Keystore files
56 | # Uncomment the following lines if you do not want to check your keystore files in.
57 | #*.jks
58 | #*.keystore
59 |
60 | # External native build folder generated in Android Studio 2.2 and later
61 | .externalNativeBuild
62 | .cxx/
63 |
64 | # Google Services (e.g. APIs or Firebase)
65 | # google-services.json
66 |
67 | # Freeline
68 | freeline.py
69 | freeline/
70 | freeline_project_description.json
71 |
72 | # fastlane
73 | fastlane/report.xml
74 | fastlane/Preview.html
75 | fastlane/screenshots
76 | fastlane/test_output
77 | fastlane/readme.md
78 |
79 | # Version control
80 | vcs.xml
81 |
82 | # lint
83 | lint/intermediates/
84 | lint/generated/
85 | lint/outputs/
86 | lint/tmp/
87 | # lint/reports/
88 |
89 | # Android Profiling
90 | *.hprof
91 |
92 | # Cordova plugins for Capacitor
93 | capacitor-cordova-android-plugins
94 |
95 | # Copied web assets
96 | app/src/main/assets/public
97 |
98 | # Generated Config files
99 | app/src/main/assets/capacitor.config.json
100 | app/src/main/assets/capacitor.plugins.json
101 | app/src/main/res/xml/config.xml
102 |
--------------------------------------------------------------------------------
/android/app/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 |
3 | android {
4 | namespace = "com.ednovas.donguatv"
5 | compileSdk = rootProject.ext.compileSdkVersion
6 | defaultConfig {
7 | applicationId "com.ednovas.donguatv"
8 | minSdkVersion rootProject.ext.minSdkVersion
9 | targetSdkVersion rootProject.ext.targetSdkVersion
10 | versionCode 1
11 | versionName "1.0"
12 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
13 | aaptOptions {
14 | // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
15 | // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
16 | ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
17 | }
18 | // 支持所有 CPU 架构
19 | ndk {
20 | abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
21 | }
22 | }
23 | buildTypes {
24 | release {
25 | minifyEnabled false
26 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
27 | }
28 | }
29 |
30 | // 按架构分包构建 (可选:生成单独的架构 APK)
31 | // 注释掉 splits 块可生成包含所有架构的单一 APK (universal)
32 | /*
33 | splits {
34 | abi {
35 | enable true
36 | reset()
37 | include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
38 | universalApk true // 同时生成包含所有架构的通用 APK
39 | }
40 | }
41 | */
42 | }
43 |
44 | repositories {
45 | flatDir{
46 | dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
47 | }
48 | }
49 |
50 | dependencies {
51 | implementation fileTree(include: ['*.jar'], dir: 'libs')
52 | implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
53 | implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
54 | implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
55 | implementation project(':capacitor-android')
56 | testImplementation "junit:junit:$junitVersion"
57 | androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
58 | androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
59 | implementation project(':capacitor-cordova-android-plugins')
60 | }
61 |
62 | apply from: 'capacitor.build.gradle'
63 |
64 | try {
65 | def servicesJSON = file('google-services.json')
66 | if (servicesJSON.text) {
67 | apply plugin: 'com.google.gms.google-services'
68 | }
69 | } catch(Exception e) {
70 | logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
71 | }
72 |
--------------------------------------------------------------------------------
/android/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/cloudflare-tmdb-proxy.js:
--------------------------------------------------------------------------------
1 | /**
2 | * TMDB 反代脚本 - Cloudflare Workers
3 | *
4 | * 部署步骤:
5 | * 1. 登录 Cloudflare Dashboard: https://dash.cloudflare.com/
6 | * 2. 选择 "Workers & Pages" -> "Create application" -> "Create Worker"
7 | * 3. 将此代码粘贴到编辑器中
8 | * 4. 点击 "Save and Deploy"
9 | * 5. 记录您的 Worker URL (如: https://tmdb-proxy.your-name.workers.dev)
10 | * 6. 在 index.html 中配置反代地址
11 | *
12 | * 使用说明:
13 | * - API 请求: https://your-worker.workers.dev/api/3/...
14 | * - 图片请求: https://your-worker.workers.dev/t/p/w500/...
15 | */
16 |
17 | export default {
18 | async fetch(request, env, ctx) {
19 | const url = new URL(request.url);
20 | const path = url.pathname;
21 |
22 | // 设置 CORS 头
23 | const corsHeaders = {
24 | 'Access-Control-Allow-Origin': '*',
25 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
26 | 'Access-Control-Allow-Headers': 'Content-Type',
27 | };
28 |
29 | // 处理 OPTIONS 预检请求
30 | if (request.method === 'OPTIONS') {
31 | return new Response(null, { headers: corsHeaders });
32 | }
33 |
34 | let targetUrl;
35 |
36 | // 判断请求类型
37 | if (path.startsWith('/api/')) {
38 | // API 请求 - 代理到 api.themoviedb.org
39 | targetUrl = 'https://api.themoviedb.org' + path.replace('/api', '') + url.search;
40 | } else if (path.startsWith('/t/')) {
41 | // 图片请求 - 代理到 image.tmdb.org
42 | targetUrl = 'https://image.tmdb.org' + path + url.search;
43 | } else if (path === '/' || path === '') {
44 | // 根路径 - 返回使用说明
45 | return new Response(JSON.stringify({
46 | status: 'ok',
47 | message: 'TMDB Proxy is running',
48 | usage: {
49 | api: '/api/3/movie/popular?api_key=YOUR_KEY&language=zh-CN',
50 | image: '/t/p/w500/YOUR_IMAGE_PATH.jpg'
51 | }
52 | }, null, 2), {
53 | headers: {
54 | 'Content-Type': 'application/json',
55 | ...corsHeaders
56 | }
57 | });
58 | } else {
59 | // 未知路径
60 | return new Response('Not Found', { status: 404, headers: corsHeaders });
61 | }
62 |
63 | try {
64 | // 检查是否是图片请求,使用不同的缓存策略
65 | const isImageRequest = path.startsWith('/t/');
66 |
67 | // 构建缓存键(确保相同请求使用相同缓存)
68 | const cacheKey = new Request(targetUrl, {
69 | method: 'GET',
70 | headers: { 'Accept': isImageRequest ? 'image/*' : 'application/json' }
71 | });
72 |
73 | // 尝试从 Cloudflare Cache 获取
74 | const cache = caches.default;
75 | let response = await cache.match(cacheKey);
76 |
77 | if (!response) {
78 | // 缓存未命中,发起请求
79 | response = await fetch(targetUrl, {
80 | method: request.method,
81 | headers: {
82 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
83 | 'Accept': request.headers.get('Accept') || '*/*',
84 | }
85 | });
86 |
87 | // 只缓存成功的响应
88 | if (response.ok) {
89 | const responseToCache = response.clone();
90 | const cacheHeaders = new Headers(responseToCache.headers);
91 | // 图片缓存 7 天,API 缓存 10 分钟
92 | cacheHeaders.set('Cache-Control', isImageRequest ? 'public, max-age=604800' : 'public, max-age=600');
93 |
94 | const cachedResponse = new Response(responseToCache.body, {
95 | status: responseToCache.status,
96 | headers: cacheHeaders
97 | });
98 |
99 | // 异步存入缓存,不阻塞响应
100 | ctx.waitUntil(cache.put(cacheKey, cachedResponse));
101 | }
102 | }
103 |
104 | // 克隆响应并添加 CORS 头
105 | const newHeaders = new Headers(response.headers);
106 | Object.entries(corsHeaders).forEach(([key, value]) => {
107 | newHeaders.set(key, value);
108 | });
109 |
110 | // 对于图片,添加更长的缓存控制
111 | if (isImageRequest) {
112 | newHeaders.set('Cache-Control', 'public, max-age=604800'); // 7天
113 | }
114 |
115 | return new Response(response.body, {
116 | status: response.status,
117 | statusText: response.statusText,
118 | headers: newHeaders
119 | });
120 |
121 | } catch (error) {
122 | return new Response(JSON.stringify({ error: error.message }), {
123 | status: 500,
124 | headers: {
125 | 'Content-Type': 'application/json',
126 | ...corsHeaders
127 | }
128 | });
129 | }
130 | }
131 | };
132 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/public/sw.js:
--------------------------------------------------------------------------------
1 | // Service Worker with Image Caching for dongguaTV
2 | // v22: Fixed CORS proxy handling and null response errors
3 | const CACHE_VERSION = 'v22';
4 | const STATIC_CACHE = 'donggua-static-' + CACHE_VERSION;
5 | const IMAGE_CACHE = 'donggua-images-' + CACHE_VERSION;
6 |
7 | // 静态资源(应用核心文件)
8 | const STATIC_URLS = [
9 | './',
10 | './index.html',
11 | './manifest.json',
12 | './icon.png',
13 | './libs/css/bootstrap.min.css',
14 | './libs/css/animate.min.css',
15 | './libs/css/fontawesome.min.css',
16 | './libs/js/vue.global.prod.min.js',
17 | './libs/js/bootstrap.bundle.min.js',
18 | './libs/js/hls.min.js',
19 | './libs/js/DPlayer.min.js'
20 | ];
21 |
22 | // 图片缓存配置
23 | const IMAGE_HOSTS = [
24 | 'image.tmdb.org',
25 | 'i.tmdb.org'
26 | ];
27 |
28 | // 图片缓存最大数量(防止缓存无限增长)
29 | // 500张缓存估算占用 30MB 空间
30 | const MAX_IMAGE_CACHE = 500;
31 |
32 | self.addEventListener('install', event => {
33 | // console.log('[SW] Installing v17...');
34 | event.waitUntil(
35 | caches.open(STATIC_CACHE)
36 | .then(cache => {
37 | // console.log('[SW] Caching static assets');
38 | return cache.addAll(STATIC_URLS);
39 | })
40 | );
41 | // 强制立即激活新版本,不等待旧版本关闭
42 | self.skipWaiting();
43 | });
44 |
45 | self.addEventListener('activate', event => {
46 | // console.log('[SW] Activating v17...');
47 | event.waitUntil(
48 | caches.keys().then(cacheNames => {
49 | return Promise.all(
50 | cacheNames.map(cacheName => {
51 | // 删除所有旧版本缓存
52 | if (cacheName !== STATIC_CACHE && cacheName !== IMAGE_CACHE) {
53 | // console.log('[SW] Deleting old cache:', cacheName);
54 | return caches.delete(cacheName);
55 | }
56 | })
57 | );
58 | }).then(() => self.clients.claim())
59 | );
60 | });
61 |
62 | self.addEventListener('fetch', event => {
63 | const url = new URL(event.request.url);
64 |
65 | // 跳过 CORS 代理请求(workers.dev 域名)
66 | // 这些请求需要直接发送,不能被 Service Worker 干扰
67 | if (url.hostname.includes('workers.dev')) {
68 | return; // 让浏览器直接处理
69 | }
70 |
71 | // 策略1:TMDB 图片 (包含官方域名和本地反代) - Cache First
72 | if (IMAGE_HOSTS.some(host => url.hostname.includes(host)) || url.pathname.startsWith('/api/tmdb-image')) {
73 | event.respondWith(handleImageRequest(event.request));
74 | return;
75 | }
76 |
77 | // 策略2:HTML 页面 - Network First (确保获取最新版本)
78 | if (event.request.mode === 'navigate' || url.pathname.endsWith('.html') || url.pathname === '/') {
79 | event.respondWith(
80 | fetch(event.request)
81 | .then(response => {
82 | // 更新缓存
83 | const responseToCache = response.clone();
84 | caches.open(STATIC_CACHE).then(cache => {
85 | cache.put(event.request, responseToCache);
86 | });
87 | return response;
88 | })
89 | .catch(() => {
90 | return caches.match(event.request).then(cached => {
91 | // 确保返回有效的 Response,如果缓存也没有则返回离线页面
92 | return cached || new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
93 | });
94 | })
95 | );
96 | return;
97 | }
98 |
99 | // 策略3:静态资源 (CSS/JS) - Stale-While-Revalidate
100 | // 先返回缓存,同时后台更新
101 | if (STATIC_URLS.some(staticUrl => event.request.url.includes(staticUrl))) {
102 | event.respondWith(
103 | caches.open(STATIC_CACHE).then(cache => {
104 | return cache.match(event.request).then(cached => {
105 | const fetchPromise = fetch(event.request).then(response => {
106 | if (response && response.status === 200) {
107 | cache.put(event.request, response.clone());
108 | }
109 | return response;
110 | }).catch(() => cached); // 网络失败时返回缓存
111 | // 返回缓存(如果有),同时后台更新
112 | return cached || fetchPromise;
113 | });
114 | })
115 | );
116 | return;
117 | }
118 |
119 | // 策略4:只处理同源请求 - Network First
120 | // 跳过跨域请求(如 m3u8 视频流),避免 CORS 错误
121 | if (url.origin !== self.location.origin) {
122 | return; // 让浏览器直接处理跨域请求
123 | }
124 |
125 | // 跳过 POST 请求(Cache API 不支持 POST)
126 | if (event.request.method !== 'GET') {
127 | return;
128 | }
129 |
130 | event.respondWith(
131 | fetch(event.request)
132 | .then(response => {
133 | // 只缓存成功的同源 GET 请求
134 | if (response && response.status === 200) {
135 | const responseToCache = response.clone();
136 | caches.open(STATIC_CACHE).then(cache => {
137 | cache.put(event.request, responseToCache);
138 | });
139 | }
140 | return response;
141 | })
142 | .catch(() => {
143 | return caches.match(event.request).then(cached => {
144 | // 确保返回有效的 Response
145 | return cached || new Response('Network Error', { status: 503 });
146 | });
147 | })
148 | );
149 | });
150 |
151 | // 图片请求处理 - Cache First 策略
152 | async function handleImageRequest(request) {
153 | const cache = await caches.open(IMAGE_CACHE);
154 |
155 | // 1. 尝试从缓存获取
156 | const cached = await cache.match(request);
157 | if (cached) {
158 | // console.log('[SW] Image from cache:', request.url.substring(0, 60) + '...');
159 | return cached;
160 | }
161 |
162 | // 2. 从网络获取并缓存
163 | try {
164 | const response = await fetch(request);
165 | if (response && response.status === 200) {
166 | // 缓存图片
167 | cache.put(request, response.clone());
168 | // 清理过多的缓存
169 | trimImageCache(cache);
170 | // console.log('[SW] Image cached:', request.url.substring(0, 60) + '...');
171 | }
172 | return response;
173 | } catch (error) {
174 | // console.error('[SW] Image fetch failed:', error);
175 | // 返回占位图
176 | return new Response(
177 | '',
178 | { headers: { 'Content-Type': 'image/svg+xml' } }
179 | );
180 | }
181 | }
182 |
183 | // 清理过多的图片缓存
184 | async function trimImageCache(cache) {
185 | const keys = await cache.keys();
186 | if (keys.length > MAX_IMAGE_CACHE) {
187 | // 删除最早的缓存(FIFO)
188 | const deleteCount = keys.length - MAX_IMAGE_CACHE;
189 | // console.log(`[SW] Trimming ${deleteCount} old cached images`);
190 | for (let i = 0; i < deleteCount; i++) {
191 | await cache.delete(keys[i]);
192 | }
193 | }
194 | }
195 |
196 | // 监听消息(可选:手动清理缓存)
197 | self.addEventListener('message', event => {
198 | if (event.data === 'clearImageCache') {
199 | caches.delete(IMAGE_CACHE).then(() => {
200 | console.log('[SW] Image cache cleared');
201 | });
202 | }
203 | });
204 |
--------------------------------------------------------------------------------
/proxy-server.js:
--------------------------------------------------------------------------------
1 | const http = require('http');
2 | const https = require('https');
3 | const url = require('url');
4 |
5 | // 配置
6 | const PORT = process.env.PORT || 8080;
7 | const ACCESS_PASSWORD = process.env.PROXY_PASSWORD || ''; // 可选:设置访问密码
8 |
9 | // CORS 响应头
10 | const CORS_HEADERS = {
11 | 'Access-Control-Allow-Origin': '*',
12 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, HEAD',
13 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Range',
14 | 'Access-Control-Expose-Headers': 'Content-Length, Content-Range',
15 | 'Access-Control-Max-Age': '86400',
16 | };
17 |
18 | // 需要排除的响应头
19 | const EXCLUDE_HEADERS = new Set([
20 | 'access-control-allow-origin',
21 | 'access-control-allow-methods',
22 | 'access-control-allow-headers',
23 | 'access-control-expose-headers',
24 | 'access-control-max-age',
25 | 'access-control-allow-credentials',
26 | 'content-encoding',
27 | 'transfer-encoding',
28 | 'connection',
29 | 'keep-alive',
30 | 'host'
31 | ]);
32 |
33 | const server = http.createServer(async (req, res) => {
34 | // 1. 处理 CORS 预检请求
35 | if (req.method === 'OPTIONS') {
36 | res.writeHead(204, CORS_HEADERS);
37 | res.end();
38 | return;
39 | }
40 |
41 | const reqUrl = new URL(req.url, `http://${req.headers.host}`);
42 |
43 | // 2. 健康检查
44 | if (reqUrl.pathname === '/health') {
45 | res.writeHead(200, CORS_HEADERS);
46 | res.end('OK');
47 | return;
48 | }
49 |
50 | // 3. 获取目标 URL
51 | const targetUrlParam = reqUrl.searchParams.get('url');
52 | if (!targetUrlParam) {
53 | // 返回帮助页面
54 | res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...CORS_HEADERS });
55 | res.end(getHelpPage(`http://${req.headers.host}`));
56 | return;
57 | }
58 |
59 | // 4. 验证密码(如果设置了)
60 | if (ACCESS_PASSWORD) {
61 | const auth = req.headers['authorization'];
62 | if (!auth || auth !== `Bearer ${ACCESS_PASSWORD}`) {
63 | res.writeHead(403, CORS_HEADERS);
64 | res.end('Unauthorized');
65 | return;
66 | }
67 | }
68 |
69 | try {
70 | const targetURL = new URL(targetUrlParam);
71 |
72 | // 5. 构建代理请求
73 | const proxyOptions = {
74 | method: req.method,
75 | headers: {},
76 | timeout: 20000 // 20秒超时
77 | };
78 |
79 | // 伪装请求头
80 | proxyOptions.headers['Referer'] = targetURL.origin + '/';
81 | proxyOptions.headers['Origin'] = targetURL.origin;
82 | proxyOptions.headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
83 |
84 | // 复制必要的请求头
85 | ['range', 'accept', 'accept-language'].forEach(h => {
86 | if (req.headers[h]) proxyOptions.headers[h] = req.headers[h];
87 | });
88 | if (!proxyOptions.headers['Accept']) proxyOptions.headers['Accept'] = '*/*';
89 |
90 | // 发起请求
91 | const protocol = targetURL.protocol === 'https:' ? https : http;
92 |
93 | const proxyReq = protocol.request(targetURL, proxyOptions, (proxyRes) => {
94 | // 6. 处理响应
95 | const responseHeaders = { ...CORS_HEADERS };
96 |
97 | // 复制目标响应头
98 | Object.keys(proxyRes.headers).forEach(key => {
99 | if (!EXCLUDE_HEADERS.has(key.toLowerCase())) {
100 | responseHeaders[key] = proxyRes.headers[key];
101 | }
102 | });
103 |
104 | // 7. 处理 m3u8 重写
105 | const contentType = proxyRes.headers['content-type'] || '';
106 | const isM3u8 = targetURL.pathname.endsWith('.m3u8') ||
107 | contentType.includes('mpegurl') ||
108 | contentType.includes('x-mpegurl');
109 |
110 | if (isM3u8 && proxyRes.statusCode === 200) {
111 | let chunks = [];
112 | proxyRes.on('data', chunk => chunks.push(chunk));
113 | proxyRes.on('end', () => {
114 | try {
115 | const buffer = Buffer.concat(chunks);
116 | const content = buffer.toString('utf8');
117 | const currentOrigin = `http://${req.headers.host}`;
118 | const rewritten = rewriteM3u8(content, targetURL, currentOrigin);
119 |
120 | responseHeaders['Content-Type'] = 'application/vnd.apple.mpegurl';
121 | delete responseHeaders['content-length'];
122 |
123 | res.writeHead(proxyRes.statusCode, responseHeaders);
124 | res.end(rewritten);
125 | } catch (e) {
126 | console.error('M3U8 Rewrite Error:', e);
127 | res.writeHead(502, CORS_HEADERS);
128 | res.end('Proxy M3U8 Error');
129 | }
130 | });
131 | } else {
132 | // 直接透传非 m3u8 内容
133 | res.writeHead(proxyRes.statusCode, responseHeaders);
134 | proxyRes.pipe(res);
135 | }
136 | });
137 |
138 | proxyReq.on('error', (err) => {
139 | console.error('Proxy Request Error:', err.message);
140 | if (!res.headersSent) {
141 | res.writeHead(502, CORS_HEADERS);
142 | res.end('Proxy Error: ' + err.message);
143 | }
144 | });
145 |
146 | // 转发请求体(如果有)
147 | if (req.method !== 'GET' && req.method !== 'HEAD') {
148 | req.pipe(proxyReq);
149 | } else {
150 | proxyReq.end();
151 | }
152 |
153 | } catch (err) {
154 | if (!res.headersSent) {
155 | res.writeHead(400, CORS_HEADERS);
156 | res.end('Invalid URL');
157 | }
158 | }
159 | });
160 |
161 | /**
162 | * m3u8 重写逻辑(与 Workers 版本一致)
163 | */
164 | function rewriteM3u8(content, baseUrl, proxyOrigin) {
165 | const lines = content.split('\n');
166 | const baseOrigin = baseUrl.origin;
167 | const basePath = baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf('/') + 1);
168 |
169 | return lines.map(line => {
170 | const trimmedLine = line.trim();
171 | if (trimmedLine.startsWith('#') || trimmedLine === '') {
172 | if (trimmedLine.includes('URI="')) {
173 | return line.replace(/URI="([^"]+)"/g, (match, uri) => {
174 | const absoluteUrl = resolveUrl(uri, baseOrigin, basePath);
175 | return `URI="${proxyOrigin}/?url=${encodeURIComponent(absoluteUrl)}"`;
176 | });
177 | }
178 | return line;
179 | }
180 | const absoluteUrl = resolveUrl(trimmedLine, baseOrigin, basePath);
181 | return `${proxyOrigin}/?url=${encodeURIComponent(absoluteUrl)}`;
182 | }).join('\n');
183 | }
184 |
185 | function resolveUrl(url, baseOrigin, basePath) {
186 | if (url.startsWith('http://') || url.startsWith('https://')) return url;
187 | if (url.startsWith('//')) return 'https:' + url;
188 | if (url.startsWith('/')) return baseOrigin + url;
189 | return baseOrigin + basePath + url;
190 | }
191 |
192 | function getHelpPage(origin) {
193 | return `
CORS Proxy Server
Running on Node.js
${origin}/?url=https://example.com/video.m3u8`;
194 | }
195 |
196 | server.listen(PORT, () => {
197 | console.log(`CORS Proxy Server running on port ${PORT}`);
198 | });
199 |
--------------------------------------------------------------------------------
/public/clear-cache.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 清除缓存 - E视界
8 |
121 |
122 |
123 |
124 |
125 |
🧹 清除网站缓存
126 |
如果网站显示异常或版本过旧,点击下方按钮清除所有缓存数据。清除后将自动跳转到主页。
127 |
128 |
131 |
132 |
135 |
136 |
137 |
138 |
↩️ 返回主页
139 |
140 |
141 |
223 |
224 |
225 |
--------------------------------------------------------------------------------
/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # ========================================
4 | # 冬瓜TV MAX 一键安装脚本
5 | # 适用于 Ubuntu/Debian/CentOS 系统
6 | # ========================================
7 |
8 | set -e
9 |
10 | # 颜色定义
11 | RED='\033[0;31m'
12 | GREEN='\033[0;32m'
13 | YELLOW='\033[1;33m'
14 | BLUE='\033[0;34m'
15 | NC='\033[0m' # No Color
16 |
17 | # 打印带颜色的消息
18 | print_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
19 | print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
20 | print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
21 | print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
22 |
23 | # 检测系统类型
24 | detect_os() {
25 | if [ -f /etc/debian_version ]; then
26 | OS="debian"
27 | PKG_MANAGER="apt-get"
28 | elif [ -f /etc/redhat-release ]; then
29 | OS="rhel"
30 | PKG_MANAGER="yum"
31 | else
32 | print_error "不支持的操作系统"
33 | exit 1
34 | fi
35 | }
36 |
37 | # 安装编译工具 (better-sqlite3 需要)
38 | install_build_tools() {
39 | print_info "检测编译工具..."
40 |
41 | if command -v gcc &> /dev/null && command -v make &> /dev/null; then
42 | print_success "编译工具已安装"
43 | return
44 | fi
45 |
46 | print_info "正在安装编译工具 (better-sqlite3 原生模块需要)..."
47 |
48 | if [ "$OS" = "debian" ]; then
49 | sudo apt-get update
50 | sudo apt-get install -y build-essential python3
51 | else
52 | sudo yum groupinstall -y "Development Tools"
53 | sudo yum install -y python3
54 | fi
55 |
56 | print_success "编译工具安装完成"
57 | }
58 |
59 | # 安装 Node.js
60 | install_nodejs() {
61 | print_info "检测 Node.js..."
62 |
63 | if command -v node &> /dev/null; then
64 | NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
65 | if [ "$NODE_VERSION" -ge 18 ]; then
66 | print_success "Node.js $(node -v) 已安装"
67 | return
68 | else
69 | print_warning "Node.js 版本过低,需要 v18+,正在升级..."
70 | fi
71 | fi
72 |
73 | print_info "正在安装 Node.js v18..."
74 |
75 | if [ "$OS" = "debian" ]; then
76 | curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
77 | sudo apt-get install -y nodejs
78 | else
79 | curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
80 | sudo yum install -y nodejs
81 | fi
82 |
83 | print_success "Node.js $(node -v) 安装完成"
84 | }
85 |
86 | # 安装 PM2
87 | install_pm2() {
88 | print_info "检测 PM2..."
89 |
90 | if command -v pm2 &> /dev/null; then
91 | print_success "PM2 已安装"
92 | return
93 | fi
94 |
95 | print_info "正在安装 PM2..."
96 | sudo npm install -g pm2
97 | print_success "PM2 安装完成"
98 | }
99 |
100 | # 安装 Git
101 | install_git() {
102 | print_info "检测 Git..."
103 |
104 | if command -v git &> /dev/null; then
105 | print_success "Git 已安装"
106 | return
107 | fi
108 |
109 | print_info "正在安装 Git..."
110 | if [ "$OS" = "debian" ]; then
111 | sudo apt-get update
112 | sudo apt-get install -y git
113 | else
114 | sudo yum install -y git
115 | fi
116 | print_success "Git 安装完成"
117 | }
118 |
119 | # 获取用户输入
120 | get_user_input() {
121 | echo ""
122 | echo "=========================================="
123 | echo " 冬瓜TV MAX 配置向导"
124 | echo "=========================================="
125 | echo ""
126 |
127 | # TMDB API Key
128 | while true; do
129 | read -p "请输入您的 TMDB API Key (必填): " TMDB_API_KEY
130 | if [ -n "$TMDB_API_KEY" ]; then
131 | break
132 | else
133 | print_error "TMDB API Key 不能为空!"
134 | fi
135 | done
136 |
137 | # TMDB Proxy URL
138 | echo ""
139 | print_info "如果您部署在大陆服务器或主要面向大陆用户,请配置 TMDB 反代地址"
140 | print_info "反代部署说明:https://github.com/ednovas/dongguaTV#大陆用户配置"
141 | read -p "请输入 TMDB 反代地址 (可选,回车跳过): " TMDB_PROXY_URL
142 |
143 | # 端口
144 | echo ""
145 | read -p "请输入运行端口 (默认 3000): " PORT
146 | PORT=${PORT:-3000}
147 |
148 | # 缓存模式
149 | echo ""
150 | echo "请选择缓存模式:"
151 | echo "1) JSON文件 (默认, 简单易用)"
152 | echo "2) SQLite (推荐, 高性能)"
153 | echo "3) 纯内存 (重启即丢失)"
154 | echo "4) 不缓存 (开发调试用)"
155 | read -p "请输入选项 [1-4]: " CACHE_OPT
156 | case $CACHE_OPT in
157 | 2) CACHE_TYPE="sqlite";;
158 | 3) CACHE_TYPE="memory";;
159 | 4) CACHE_TYPE="none";;
160 | *) CACHE_TYPE="json";;
161 | esac
162 |
163 | # 访问密码
164 | echo ""
165 | read -p "请输入访问密码 (默认为空/不设置): " ACCESS_PASSWORD
166 |
167 | # 安装目录
168 | echo ""
169 | read -p "请输入安装目录 (默认 /opt/dongguaTV): " INSTALL_DIR
170 | INSTALL_DIR=${INSTALL_DIR:-/opt/dongguaTV}
171 |
172 | echo ""
173 | echo "=========================================="
174 | echo " 配置确认"
175 | echo "=========================================="
176 | echo "TMDB API Key: ${TMDB_API_KEY:0:8}..."
177 | echo "TMDB 反代地址: ${TMDB_PROXY_URL:-未配置}"
178 | echo "运行端口: $PORT"
179 | echo "缓存模式: $CACHE_TYPE"
180 | echo "访问密码: ${ACCESS_PASSWORD:-未设置}"
181 | echo "安装目录: $INSTALL_DIR"
182 | echo "=========================================="
183 | echo ""
184 |
185 | read -p "确认以上配置?(Y/n): " CONFIRM
186 | CONFIRM=${CONFIRM:-Y}
187 |
188 | if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
189 | print_warning "已取消安装"
190 | exit 0
191 | fi
192 | }
193 |
194 | # 下载项目
195 | download_project() {
196 | print_info "正在下载项目..."
197 |
198 | if [ -d "$INSTALL_DIR" ]; then
199 | print_warning "目录 $INSTALL_DIR 已存在"
200 | read -p "是否删除并重新安装?(y/N): " OVERWRITE
201 | if [[ "$OVERWRITE" =~ ^[Yy]$ ]]; then
202 | sudo rm -rf "$INSTALL_DIR"
203 | else
204 | print_info "使用现有目录,跳过下载"
205 | return
206 | fi
207 | fi
208 |
209 | sudo mkdir -p "$INSTALL_DIR"
210 | sudo chown $USER:$USER "$INSTALL_DIR"
211 |
212 | git clone https://github.com/ednovas/dongguaTV.git "$INSTALL_DIR"
213 | print_success "项目下载完成"
214 | }
215 |
216 | # 安装依赖
217 | install_dependencies() {
218 | print_info "正在安装项目依赖..."
219 | cd "$INSTALL_DIR"
220 | npm install
221 | print_success "依赖安装完成"
222 | }
223 |
224 | # 配置环境变量
225 | configure_env() {
226 | print_info "正在配置环境变量..."
227 |
228 | cat > "$INSTALL_DIR/.env" << EOF
229 | # 冬瓜TV MAX 配置文件
230 | # 由一键安装脚本自动生成
231 |
232 | # TMDb API Key (必填)
233 | TMDB_API_KEY=$TMDB_API_KEY
234 |
235 | # 运行端口
236 | PORT=$PORT
237 |
238 | # 大陆用户 TMDB 反代地址 (可选)
239 | TMDB_PROXY_URL=$TMDB_PROXY_URL
240 |
241 | # 缓存类型
242 | CACHE_TYPE=$CACHE_TYPE
243 |
244 | # 访问密码 (可选)
245 | ACCESS_PASSWORD=$ACCESS_PASSWORD
246 | EOF
247 |
248 | print_success "环境变量配置完成"
249 | }
250 |
251 | # 启动服务
252 | start_service() {
253 | print_info "正在启动服务..."
254 |
255 | cd "$INSTALL_DIR"
256 |
257 | # 检查是否已有运行的实例
258 | if pm2 list | grep -q "donggua-tv"; then
259 | print_warning "检测到已有运行的实例,正在重启..."
260 | pm2 restart donggua-tv
261 | else
262 | pm2 start server.js --name "donggua-tv"
263 | fi
264 |
265 | # 保存 PM2 配置并设置开机自启
266 | pm2 save
267 |
268 | # 设置开机自启 (忽略错误)
269 | sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u $USER --hp $HOME 2>/dev/null || true
270 |
271 | print_success "服务启动完成"
272 | }
273 |
274 | # 显示完成信息
275 | show_complete() {
276 | echo ""
277 | echo "=========================================="
278 | echo -e "${GREEN} 安装完成!${NC}"
279 | echo "=========================================="
280 | echo ""
281 |
282 | # 获取服务器 IP
283 | SERVER_IP=$(curl -s ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}')
284 |
285 | echo -e "访问地址: ${GREEN}http://$SERVER_IP:$PORT${NC}"
286 | echo ""
287 | echo "常用命令:"
288 | echo " 查看状态: pm2 status"
289 | echo " 查看日志: pm2 logs donggua-tv"
290 | echo " 重启服务: pm2 restart donggua-tv"
291 | echo " 停止服务: pm2 stop donggua-tv"
292 | echo ""
293 | echo "配置文件: $INSTALL_DIR/.env"
294 | echo "缓存数据: $INSTALL_DIR/cache.db (SQLite)"
295 | echo "项目目录: $INSTALL_DIR"
296 | echo ""
297 | echo "=========================================="
298 | echo -e "${YELLOW}如需使用域名访问,请配置 Nginx 反向代理${NC}"
299 | echo "=========================================="
300 | }
301 |
302 | # 主函数
303 | main() {
304 | echo ""
305 | echo "=========================================="
306 | echo " 冬瓜TV MAX 一键安装脚本 v1.1"
307 | echo "=========================================="
308 | echo ""
309 |
310 | # 检测系统
311 | detect_os
312 | print_info "检测到系统: $OS"
313 |
314 | # 获取用户输入
315 | get_user_input
316 |
317 | # 安装依赖
318 | install_git
319 |
320 | # 仅当选择 SQLite 缓存时安装编译工具
321 | if [ "$CACHE_TYPE" = "sqlite" ]; then
322 | install_build_tools
323 | fi
324 |
325 | install_nodejs
326 | install_pm2
327 |
328 | # 下载并配置项目
329 | download_project
330 | install_dependencies
331 | configure_env
332 |
333 | # 启动服务
334 | start_service
335 |
336 | # 显示完成信息
337 | show_complete
338 | }
339 |
340 | # 运行主函数
341 | main
342 |
--------------------------------------------------------------------------------
/android/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # SPDX-License-Identifier: Apache-2.0
19 | #
20 |
21 | ##############################################################################
22 | #
23 | # Gradle start up script for POSIX generated by Gradle.
24 | #
25 | # Important for running:
26 | #
27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 | # noncompliant, but you have some other compliant shell such as ksh or
29 | # bash, then to run this script, type that shell name before the whole
30 | # command line, like:
31 | #
32 | # ksh Gradle
33 | #
34 | # Busybox and similar reduced shells will NOT work, because this script
35 | # requires all of these POSIX shell features:
36 | # * functions;
37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 | # * compound commands having a testable exit status, especially «case»;
40 | # * various built-in commands including «command», «set», and «ulimit».
41 | #
42 | # Important for patching:
43 | #
44 | # (2) This script targets any POSIX shell, so it avoids extensions provided
45 | # by Bash, Ksh, etc; in particular arrays are avoided.
46 | #
47 | # The "traditional" practice of packing multiple parameters into a
48 | # space-separated string is a well documented source of bugs and security
49 | # problems, so this is (mostly) avoided, by progressively accumulating
50 | # options in "$@", and eventually passing that to Java.
51 | #
52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 | # see the in-line comments for details.
55 | #
56 | # There are tweaks for specific operating systems such as AIX, CygWin,
57 | # Darwin, MinGW, and NonStop.
58 | #
59 | # (3) This script is generated from the Groovy template
60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 | # within the Gradle project.
62 | #
63 | # You can find Gradle at https://github.com/gradle/gradle/.
64 | #
65 | ##############################################################################
66 |
67 | # Attempt to set APP_HOME
68 |
69 | # Resolve links: $0 may be a link
70 | app_path=$0
71 |
72 | # Need this for daisy-chained symlinks.
73 | while
74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 | [ -h "$app_path" ]
76 | do
77 | ls=$( ls -ld "$app_path" )
78 | link=${ls#*' -> '}
79 | case $link in #(
80 | /*) app_path=$link ;; #(
81 | *) app_path=$APP_HOME$link ;;
82 | esac
83 | done
84 |
85 | # This is normally unused
86 | # shellcheck disable=SC2034
87 | APP_BASE_NAME=${0##*/}
88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH="\\\"\\\""
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | if ! command -v java >/dev/null 2>&1
137 | then
138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
139 |
140 | Please set the JAVA_HOME variable in your environment to match the
141 | location of your Java installation."
142 | fi
143 | fi
144 |
145 | # Increase the maximum file descriptors if we can.
146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
147 | case $MAX_FD in #(
148 | max*)
149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
150 | # shellcheck disable=SC2039,SC3045
151 | MAX_FD=$( ulimit -H -n ) ||
152 | warn "Could not query maximum file descriptor limit"
153 | esac
154 | case $MAX_FD in #(
155 | '' | soft) :;; #(
156 | *)
157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
158 | # shellcheck disable=SC2039,SC3045
159 | ulimit -n "$MAX_FD" ||
160 | warn "Could not set maximum file descriptor limit to $MAX_FD"
161 | esac
162 | fi
163 |
164 | # Collect all arguments for the java command, stacking in reverse order:
165 | # * args from the command line
166 | # * the main class name
167 | # * -classpath
168 | # * -D...appname settings
169 | # * --module-path (only if needed)
170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
171 |
172 | # For Cygwin or MSYS, switch paths to Windows format before running java
173 | if "$cygwin" || "$msys" ; then
174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
176 |
177 | JAVACMD=$( cygpath --unix "$JAVACMD" )
178 |
179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
180 | for arg do
181 | if
182 | case $arg in #(
183 | -*) false ;; # don't mess with options #(
184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
185 | [ -e "$t" ] ;; #(
186 | *) false ;;
187 | esac
188 | then
189 | arg=$( cygpath --path --ignore --mixed "$arg" )
190 | fi
191 | # Roll the args list around exactly as many times as the number of
192 | # args, so each arg winds up back in the position where it started, but
193 | # possibly modified.
194 | #
195 | # NB: a `for` loop captures its iteration list before it begins, so
196 | # changing the positional parameters here affects neither the number of
197 | # iterations, nor the values presented in `arg`.
198 | shift # remove old arg
199 | set -- "$@" "$arg" # push replacement arg
200 | done
201 | fi
202 |
203 |
204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
206 |
207 | # Collect all arguments for the java command:
208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
209 | # and any embedded shellness will be escaped.
210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
211 | # treated as '${Hostname}' itself on the command line.
212 |
213 | set -- \
214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
215 | -classpath "$CLASSPATH" \
216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
217 | "$@"
218 |
219 | # Stop when "xargs" is not available.
220 | if ! command -v xargs >/dev/null 2>&1
221 | then
222 | die "xargs is not available"
223 | fi
224 |
225 | # Use "xargs" to parse quoted args.
226 | #
227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
228 | #
229 | # In Bash we could simply go:
230 | #
231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
232 | # set -- "${ARGS[@]}" "$@"
233 | #
234 | # but POSIX shell has neither arrays nor command substitution, so instead we
235 | # post-process each arg (as a line of input to sed) to backslash-escape any
236 | # character that might be a shell metacharacter, then use eval to reverse
237 | # that process (while maintaining the separation between arguments), and wrap
238 | # the whole thing up as a single "set" statement.
239 | #
240 | # This will of course break if any of these variables contains a newline or
241 | # an unmatched quote.
242 | #
243 |
244 | eval "set -- $(
245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
246 | xargs -n1 |
247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
248 | tr '\n' ' '
249 | )" '"$@"'
250 |
251 | exec "$JAVACMD" "$@"
252 |
--------------------------------------------------------------------------------
/cloudflare-cors-proxy.js:
--------------------------------------------------------------------------------
1 | // ========================================
2 | // CORS API 代理 (Cloudflare Workers)
3 | // ========================================
4 | // 用于中转无法直接访问的视频资源站
5 | //
6 | // 部署步骤:
7 | // 1. 登录 https://dash.cloudflare.com
8 | // 2. 进入 Workers & Pages → 创建 Worker
9 | // 3. 将此文件内容粘贴到编辑器
10 | // 4. 保存并部署
11 | // 5. 复制 Worker URL 到 .env 中的 CORS_PROXY_URL
12 | // ========================================
13 |
14 | export default {
15 | async fetch(request, env, ctx) {
16 | return handleRequest(request);
17 | }
18 | }
19 |
20 | // CORS 响应头
21 | const CORS_HEADERS = {
22 | 'Access-Control-Allow-Origin': '*',
23 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, HEAD',
24 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Range',
25 | 'Access-Control-Expose-Headers': 'Content-Length, Content-Range',
26 | 'Access-Control-Max-Age': '86400',
27 | }
28 |
29 | // 需要排除的响应头(这些头会影响流式传输)
30 | const EXCLUDE_HEADERS = new Set([
31 | 'content-encoding',
32 | 'transfer-encoding',
33 | 'connection',
34 | 'keep-alive'
35 | ])
36 |
37 | async function handleRequest(request) {
38 | // 处理 CORS 预检请求
39 | if (request.method === 'OPTIONS') {
40 | return new Response(null, { status: 204, headers: CORS_HEADERS });
41 | }
42 |
43 | const reqUrl = new URL(request.url);
44 | const targetUrlParam = reqUrl.searchParams.get('url');
45 |
46 | // 健康检查
47 | if (reqUrl.pathname === '/health') {
48 | return new Response('OK', { status: 200, headers: CORS_HEADERS });
49 | }
50 |
51 | // 必须有 url 参数
52 | if (!targetUrlParam) {
53 | return new Response(getHelpPage(reqUrl.origin), {
54 | status: 200,
55 | headers: { 'Content-Type': 'text/html; charset=utf-8', ...CORS_HEADERS }
56 | });
57 | }
58 |
59 | return handleProxyRequest(request, targetUrlParam, reqUrl.origin);
60 | }
61 |
62 | async function handleProxyRequest(request, targetUrlParam, currentOrigin) {
63 | // 防止递归调用
64 | if (targetUrlParam.startsWith(currentOrigin)) {
65 | return errorResponse('Loop detected: self-fetch blocked', 400);
66 | }
67 |
68 | // 验证 URL 格式
69 | if (!/^https?:\/\//i.test(targetUrlParam)) {
70 | return errorResponse('Invalid target URL', 400);
71 | }
72 |
73 | let targetURL;
74 | try {
75 | targetURL = new URL(targetUrlParam);
76 | } catch {
77 | return errorResponse('Invalid URL format', 400);
78 | }
79 |
80 | try {
81 | // 构建代理请求头 - 伪装成正常浏览器请求
82 | const headers = new Headers();
83 |
84 | // 设置 Referer 和 Origin 为目标域名(很多服务器会检查这个)
85 | headers.set('Referer', targetURL.origin + '/');
86 | headers.set('Origin', targetURL.origin);
87 |
88 | // 设置常见的浏览器 User-Agent
89 | headers.set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
90 |
91 | // 复制客户端的关键请求头
92 | const copyHeaders = ['range', 'accept', 'accept-language'];
93 | copyHeaders.forEach(h => {
94 | const val = request.headers.get(h);
95 | if (val) headers.set(h, val);
96 | });
97 |
98 | // 设置 Accept 头(如果客户端没有提供)
99 | if (!headers.has('accept')) {
100 | headers.set('Accept', '*/*');
101 | }
102 |
103 | const proxyRequest = new Request(targetURL.toString(), {
104 | method: request.method,
105 | headers: headers,
106 | body: request.method !== 'GET' && request.method !== 'HEAD'
107 | ? await request.arrayBuffer()
108 | : undefined,
109 | });
110 |
111 | // 设置超时 (20秒,视频流需要更长时间)
112 | const controller = new AbortController();
113 | const timeoutId = setTimeout(() => controller.abort(), 20000);
114 | const response = await fetch(proxyRequest, { signal: controller.signal });
115 | clearTimeout(timeoutId);
116 |
117 | // 构建响应头 - 先复制目标服务器的响应头,但排除 CORS 相关的头
118 | const responseHeaders = new Headers();
119 |
120 | // 需要排除的头(这些会影响 CORS 或传输)
121 | const excludeHeaders = new Set([
122 | 'access-control-allow-origin',
123 | 'access-control-allow-methods',
124 | 'access-control-allow-headers',
125 | 'access-control-expose-headers',
126 | 'access-control-max-age',
127 | 'access-control-allow-credentials',
128 | 'content-encoding',
129 | 'transfer-encoding',
130 | 'connection',
131 | 'keep-alive'
132 | ]);
133 |
134 | // 复制目标服务器的响应头(排除 CORS 相关)
135 | for (const [key, value] of response.headers) {
136 | if (!excludeHeaders.has(key.toLowerCase())) {
137 | responseHeaders.set(key, value);
138 | }
139 | }
140 |
141 | // 最后设置我们的 CORS 头(覆盖任何已有的)
142 | for (const [key, value] of Object.entries(CORS_HEADERS)) {
143 | responseHeaders.set(key, value);
144 | }
145 |
146 | // 检查是否是 m3u8 文件,如果是则重写里面的 URL
147 | const contentType = response.headers.get('content-type') || '';
148 | const isM3u8 = targetURL.pathname.endsWith('.m3u8') ||
149 | contentType.includes('mpegurl') ||
150 | contentType.includes('x-mpegurl');
151 |
152 | if (isM3u8 && response.ok) {
153 | // 读取 m3u8 内容并重写 URL
154 | const m3u8Content = await response.text();
155 | const rewrittenContent = rewriteM3u8(m3u8Content, targetURL, currentOrigin);
156 |
157 | responseHeaders.set('Content-Type', 'application/vnd.apple.mpegurl');
158 | responseHeaders.delete('Content-Length'); // 长度已变化
159 |
160 | return new Response(rewrittenContent, {
161 | status: response.status,
162 | statusText: response.statusText,
163 | headers: responseHeaders
164 | });
165 | }
166 |
167 | return new Response(response.body, {
168 | status: response.status,
169 | statusText: response.statusText,
170 | headers: responseHeaders
171 | });
172 | } catch (err) {
173 | const errorMsg = err.name === 'AbortError'
174 | ? 'Request timeout (20s)'
175 | : 'Proxy Error: ' + (err.message || '代理请求失败');
176 | return errorResponse(errorMsg, 502);
177 | }
178 | }
179 |
180 | /**
181 | * 重写 m3u8 内容,将里面的 URL 改为经过代理的 URL
182 | * 这样 ts 分片请求也会经过代理,解决防盗链问题
183 | */
184 | function rewriteM3u8(content, baseUrl, proxyOrigin) {
185 | const lines = content.split('\n');
186 | const baseOrigin = baseUrl.origin;
187 | const basePath = baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf('/') + 1);
188 |
189 | return lines.map(line => {
190 | const trimmedLine = line.trim();
191 |
192 | // 跳过注释行和空行
193 | if (trimmedLine.startsWith('#') || trimmedLine === '') {
194 | // 但检查 URI= 属性(如 #EXT-X-KEY 中的加密密钥 URL)
195 | if (trimmedLine.includes('URI="')) {
196 | return line.replace(/URI="([^"]+)"/g, (match, uri) => {
197 | const absoluteUrl = resolveUrl(uri, baseOrigin, basePath);
198 | return `URI="${proxyOrigin}/?url=${encodeURIComponent(absoluteUrl)}"`;
199 | });
200 | }
201 | return line;
202 | }
203 |
204 | // 处理媒体 URL(ts 分片或子 m3u8)
205 | const absoluteUrl = resolveUrl(trimmedLine, baseOrigin, basePath);
206 | return `${proxyOrigin}/?url=${encodeURIComponent(absoluteUrl)}`;
207 | }).join('\n');
208 | }
209 |
210 | /**
211 | * 解析相对 URL 为绝对 URL
212 | */
213 | function resolveUrl(url, baseOrigin, basePath) {
214 | if (url.startsWith('http://') || url.startsWith('https://')) {
215 | return url; // 已经是绝对 URL
216 | }
217 | if (url.startsWith('//')) {
218 | return 'https:' + url; // 协议相对 URL
219 | }
220 | if (url.startsWith('/')) {
221 | return baseOrigin + url; // 根相对 URL
222 | }
223 | return baseOrigin + basePath + url; // 路径相对 URL
224 | }
225 |
226 | function errorResponse(error, status = 400) {
227 | return new Response(JSON.stringify({ error }), {
228 | status,
229 | headers: { 'Content-Type': 'application/json; charset=utf-8', ...CORS_HEADERS }
230 | });
231 | }
232 |
233 | function getHelpPage(origin) {
234 | return `
235 |
236 |
237 |
238 | CORS API 代理
239 |
248 |
249 |
250 | 🌐 CORS API 代理
251 | 用于中转无法直接访问的视频资源站 API 和视频流
252 |
253 | 使用方法
254 |
255 | ${origin}/?url=目标URL
256 |
257 |
258 | 示例
259 | ${origin}/?url=https://example.com/video.m3u8
260 |
261 | 支持的功能
262 |
263 | - ✅ 代理 HLS (m3u8) 视频流
264 | - ✅ 代理资源站 API 请求
265 | - ✅ 支持 Range 请求(视频快进/快退)
266 | - ✅ 完整的 CORS 头支持
267 | - ✅ 超时保护(15秒)
268 |
269 |
270 |
271 | 配合 dongguaTV 使用:在 .env 中设置 CORS_PROXY_URL=${origin}
272 |
273 |
274 | `;
275 | }
276 |
--------------------------------------------------------------------------------
/.github/workflows/android-build.yml:
--------------------------------------------------------------------------------
1 | name: Android Build & Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*.*.*' # 只在推送版本标签时触发
7 | workflow_dispatch:
8 | inputs:
9 | server_url:
10 | description: 'Server URL (e.g. https://your-site.com)'
11 | required: true
12 | default: 'https://ednovas.video'
13 | app_name:
14 | description: 'App Name'
15 | required: true
16 | default: 'E视界'
17 | version_tag:
18 | description: 'Version Tag (e.g. v1.0.0)'
19 | required: true
20 | default: 'v1.0.0'
21 |
22 | jobs:
23 | build:
24 | runs-on: ubuntu-latest
25 |
26 | steps:
27 | - name: Checkout repository
28 | uses: actions/checkout@v4
29 |
30 | - name: Setup Node.js
31 | uses: actions/setup-node@v4
32 | with:
33 | node-version: '22'
34 | cache: 'npm'
35 |
36 | - name: Setup Java (JDK 21)
37 | uses: actions/setup-java@v4
38 | with:
39 | distribution: 'temurin'
40 | java-version: '21'
41 |
42 | - name: Setup Android SDK
43 | uses: android-actions/setup-android@v3
44 |
45 | - name: Install Node dependencies
46 | run: npm ci
47 |
48 | # 保险措施:创建 www 目录并复制 public 内容 (防止 CLI 回退默认 www 时报错)
49 | - name: Prepare Web Assets
50 | run: |
51 | mkdir -p www
52 | if [ -d "public" ]; then
53 | cp -r public/* www/
54 | else
55 | echo "E视界" > www/index.html
56 | fi
57 | echo "Created www directory manually as fallback"
58 |
59 | # 仅在手动触发时修改配置
60 | - name: Configure App (Manual Build)
61 | if: ${{ github.event_name == 'workflow_dispatch' }}
62 | run: |
63 | echo "📝 Applying manual configuration..."
64 | # 使用 jq 修改 JSON (ubuntu-latest 预装了 jq)
65 | tmp=$(mktemp)
66 | jq --arg url "${{ inputs.server_url }}" --arg name "${{ inputs.app_name }}" \
67 | '.server.url = $url | .appName = $name' capacitor.config.json > "$tmp" && mv "$tmp" capacitor.config.json
68 |
69 | echo "✅ Configuration updated:"
70 | echo " App Name: ${{ inputs.app_name }}"
71 | echo " Server URL: ${{ inputs.server_url }}"
72 |
73 | # 显示当前 Capacitor 配置
74 | - name: Show Capacitor Config
75 | run: |
76 | echo "=== Capacitor Config ==="
77 | cat capacitor.config.json
78 |
79 | # 从网站图标生成 Android App 图标(各分辨率)
80 | - name: Generate App Icons from Website Icon
81 | run: |
82 | # 安装 ImageMagick
83 | sudo apt-get update && sudo apt-get install -y imagemagick
84 |
85 | # 源图标
86 | ICON_SRC="public/icon.png"
87 |
88 | # 生成各分辨率图标
89 | # mipmap-mdpi: 48x48 (Icon: 36x36)
90 | convert "$ICON_SRC" -resize 36x36 -background none -gravity center -extent 48x48 android/app/src/main/res/mipmap-mdpi/ic_launcher.png
91 | convert "$ICON_SRC" -resize 36x36 -background none -gravity center -extent 48x48 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
92 | convert "$ICON_SRC" -resize 72x72 -background none -gravity center -extent 108x108 android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
93 |
94 | # mipmap-hdpi: 72x72 (Icon: 54x54)
95 | convert "$ICON_SRC" -resize 54x54 -background none -gravity center -extent 72x72 android/app/src/main/res/mipmap-hdpi/ic_launcher.png
96 | convert "$ICON_SRC" -resize 54x54 -background none -gravity center -extent 72x72 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
97 | convert "$ICON_SRC" -resize 108x108 -background none -gravity center -extent 162x162 android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
98 |
99 | # mipmap-xhdpi: 96x96 (Icon: 72x72)
100 | convert "$ICON_SRC" -resize 72x72 -background none -gravity center -extent 96x96 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
101 | convert "$ICON_SRC" -resize 72x72 -background none -gravity center -extent 96x96 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
102 | convert "$ICON_SRC" -resize 144x144 -background none -gravity center -extent 216x216 android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
103 |
104 | # mipmap-xxhdpi: 144x144 (Icon: 108x108)
105 | convert "$ICON_SRC" -resize 108x108 -background none -gravity center -extent 144x144 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
106 | convert "$ICON_SRC" -resize 108x108 -background none -gravity center -extent 144x144 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
107 | convert "$ICON_SRC" -resize 216x216 -background none -gravity center -extent 324x324 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
108 |
109 | # mipmap-xxxhdpi: 192x192 (Icon: 144x144)
110 | convert "$ICON_SRC" -resize 144x144 -background none -gravity center -extent 192x192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
111 | convert "$ICON_SRC" -resize 144x144 -background none -gravity center -extent 192x192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
112 | convert "$ICON_SRC" -resize 288x288 -background none -gravity center -extent 432x432 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
113 |
114 | echo "✅ App icons generated from website icon"
115 |
116 | - name: Sync Capacitor
117 | run: npx cap sync android
118 |
119 | - name: Make gradlew executable
120 | run: chmod +x android/gradlew
121 |
122 | - name: Build Release APK (Universal - All Architectures)
123 | working-directory: android
124 | run: ./gradlew assembleRelease
125 |
126 | # 签名 APK
127 | # 优先使用 GitHub Secrets 配置的 Release 密钥
128 | # 如果未配置,自动生成并使用临时 Debug 密钥签名
129 | - name: Sign APK
130 | run: |
131 | # 查找 Build Tools (优先使用较新版本)
132 | BUILD_TOOLS_VERSION=$(ls $ANDROID_HOME/build-tools/ | sort -V | tail -n 1)
133 | APKSIGNER="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION/apksigner"
134 | ZIPALIGN="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION/zipalign"
135 |
136 | echo "Using Build Tools: $BUILD_TOOLS_VERSION"
137 |
138 | UNSIGNED_APK="android/app/build/outputs/apk/release/app-release-unsigned.apk"
139 | ALIGNED_APK="android/app/build/outputs/apk/release/app-release-aligned.apk"
140 | SIGNED_APK="android/app/build/outputs/apk/release/app-release.apk"
141 |
142 | # 1. 密钥准备
143 | KEYSTORE_FILE="release.keystore"
144 | if [ -n "${{ secrets.SIGNING_KEY }}" ]; then
145 | echo "🔐 Using Release Key from Secrets"
146 | echo "${{ secrets.SIGNING_KEY }}" | base64 -d > $KEYSTORE_FILE
147 | STORE_PASS="${{ secrets.KEY_STORE_PASSWORD }}"
148 | KEY_ALIAS="${{ secrets.ALIAS }}"
149 | KEY_PASS="${{ secrets.KEY_PASSWORD }}"
150 | else
151 | echo "⚠️ No Secrets found. Generating Debug Key for signing..."
152 | # 生成临时 Debug 密钥 (Java 21 兼容格式)
153 | keytool -genkeypair -v \
154 | -keystore $KEYSTORE_FILE \
155 | -storetype PKCS12 \
156 | -storepass android \
157 | -alias androiddebugkey \
158 | -keypass android \
159 | -keyalg RSA \
160 | -keysize 2048 \
161 | -validity 10000 \
162 | -dname "CN=Android Debug,O=Android,C=US"
163 |
164 | STORE_PASS="android"
165 | KEY_ALIAS="androiddebugkey"
166 | KEY_PASS="android"
167 | fi
168 |
169 | # 2. Zipalign 对齐 (签名可以通过,但 Release 包通常建议先对齐)
170 | echo "📏 Aligning APK..."
171 | $ZIPALIGN -v -p 4 "$UNSIGNED_APK" "$ALIGNED_APK"
172 |
173 | # 3. 签名
174 | echo "✍️ Signing APK..."
175 | $APKSIGNER sign \
176 | --ks "$KEYSTORE_FILE" \
177 | --ks-pass pass:"$STORE_PASS" \
178 | --ks-key-alias "$KEY_ALIAS" \
179 | --key-pass pass:"$KEY_PASS" \
180 | --out "$SIGNED_APK" \
181 | "$ALIGNED_APK"
182 |
183 | # 清理
184 | rm "$UNSIGNED_APK" "$ALIGNED_APK" "$KEYSTORE_FILE"
185 |
186 | echo "✅ APK Signed successfully!"
187 |
188 | - name: List APK files
189 | run: |
190 | echo "=== APK Files ==="
191 | find android -name "*.apk" -type f
192 | echo "=== APK Details ==="
193 | ls -lh android/app/build/outputs/apk/release/ || echo "No release APKs found"
194 |
195 | - name: Get version from tag or input
196 | id: version
197 | run: |
198 | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
199 | echo "VERSION=${{ inputs.version_tag }}" >> $GITHUB_OUTPUT
200 | echo "Using manual version: ${{ inputs.version_tag }}"
201 | else
202 | echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
203 | echo "Using tag version: ${GITHUB_REF#refs/tags/}"
204 | fi
205 |
206 | - name: Rename APK with version
207 | run: |
208 | mv android/app/build/outputs/apk/release/app-release.apk \
209 | android/app/build/outputs/apk/release/E视界-${{ steps.version.outputs.VERSION }}-universal.apk || \
210 | echo "APK rename skipped"
211 |
212 | - name: Upload APK Artifact
213 | uses: actions/upload-artifact@v4
214 | with:
215 | name: E视界-apk
216 | path: android/app/build/outputs/apk/release/*.apk
217 | retention-days: 30
218 |
219 | - name: Create GitHub Release
220 | uses: softprops/action-gh-release@v1
221 | with:
222 | name: "E视界 ${{ steps.version.outputs.VERSION }}"
223 | body: |
224 | ## 📦 E视界 ${{ steps.version.outputs.VERSION }}
225 |
226 | ### 📱 Android APK 下载
227 |
228 | | 文件 | 说明 |
229 | |------|------|
230 | | `E视界-${{ steps.version.outputs.VERSION }}-universal.apk` | 通用版 (支持所有设备) |
231 |
232 | ### 🖥️ 支持的 CPU 架构
233 | - ✅ `armeabi-v7a` (ARM 32位 - 旧手机/盒子)
234 | - ✅ `arm64-v8a` (ARM 64位 - 大多数现代手机/盒子)
235 | - ✅ `x86` (Intel/AMD 32位 - 模拟器)
236 | - ✅ `x86_64` (Intel/AMD 64位 - 模拟器/部分平板)
237 |
238 | ### 📺 设备兼容性
239 | - ✅ 手机 (Android 5.0+)
240 | - ✅ 平板
241 | - ✅ Android TV / 电视盒子
242 | - ✅ 智能投影仪
243 |
244 | ### 🌐 内置服务器
245 | - 默认连接: `https://ednovas.video`
246 |
247 | ---
248 |
249 | > ⚠️ 首次安装可能需要允许"安装未知来源应用"
250 | files: android/app/build/outputs/apk/release/*.apk
251 | draft: false
252 | prerelease: false
253 | env:
254 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
255 |
--------------------------------------------------------------------------------
/api/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Vercel Serverless API 入口
3 | * 这是专为 Vercel 优化的精简版 API,移除了所有文件系统依赖
4 | */
5 |
6 | const express = require('express');
7 | const axios = require('axios');
8 | const bodyParser = require('body-parser');
9 | const cors = require('cors');
10 | const crypto = require('crypto');
11 |
12 | const app = express();
13 | app.use(cors());
14 | app.use(bodyParser.json());
15 |
16 | // ========== 环境变量 ==========
17 | const REMOTE_DB_URL = process.env['REMOTE_DB_URL'] || '';
18 | const TMDB_API_KEY = process.env.TMDB_API_KEY || ''; // Keep Required
19 | const TMDB_PROXY_URL = process.env['TMDB_PROXY_URL'] || '';
20 | const ACCESS_PASSWORDS = (process.env['ACCESS_PASSWORD'] || '').split(',').map(p => p.trim()).filter(Boolean);
21 |
22 | // ========== 密码哈希映射 ==========
23 | const PASSWORD_HASH_MAP = {};
24 | ACCESS_PASSWORDS.forEach((pwd, index) => {
25 | const hash = crypto.createHash('sha256').update(pwd).digest('hex');
26 | PASSWORD_HASH_MAP[hash] = { index, syncEnabled: index > 0 };
27 | });
28 |
29 | // ========== 内存缓存 ==========
30 | let remoteDbCache = null;
31 | let remoteDbLastFetch = 0;
32 | const REMOTE_DB_CACHE_TTL = 5 * 60 * 1000; // 5分钟
33 |
34 | // TMDB 请求缓存
35 | const tmdbCache = new Map();
36 | const TMDB_CACHE_TTL = 3600 * 1000; // 1小时
37 |
38 | // ========== 调试日志 ==========
39 | console.log('[Vercel API] Initializing...');
40 | console.log(`[Vercel API] TMDB_API_KEY: ${TMDB_API_KEY ? '✓ Configured' : '✗ Missing'}`);
41 | console.log(`[Vercel API] TMDB_PROXY_URL: ${TMDB_PROXY_URL || '(not set)'}`);
42 | console.log(`[Vercel API] REMOTE_DB_URL: ${REMOTE_DB_URL ? '✓ Configured' : '(not set)'}`);
43 | console.log(`[Vercel API] ACCESS_PASSWORD: ${ACCESS_PASSWORDS.length} password(s)`);
44 |
45 | // ========== API: /api/sites ==========
46 | app.get('/api/sites', async (req, res) => {
47 | try {
48 | const now = Date.now();
49 | if (remoteDbCache && now - remoteDbLastFetch < REMOTE_DB_CACHE_TTL) {
50 | return res.json(remoteDbCache);
51 | }
52 | if (REMOTE_DB_URL) {
53 | const response = await axios.get(REMOTE_DB_URL, { timeout: 5000 });
54 | if (response.data && Array.isArray(response.data.sites)) {
55 | remoteDbCache = response.data;
56 | remoteDbLastFetch = now;
57 | return res.json(remoteDbCache);
58 | }
59 | }
60 | // Vercel 环境下没有本地 db.json,返回空
61 | return res.json({ sites: [] });
62 | } catch (err) {
63 | console.error('[Remote DB Error]', err.message);
64 | return res.json({ sites: [] });
65 | }
66 | });
67 |
68 | // ========== API: /api/config ==========
69 | app.get('/api/config', (req, res) => {
70 | const userToken = req.query.token || '';
71 | const userInfo = PASSWORD_HASH_MAP[userToken];
72 | const syncEnabled = userInfo ? userInfo.syncEnabled : false;
73 |
74 | res.json({
75 | tmdb_api_key: TMDB_API_KEY,
76 | tmdb_proxy_url: TMDB_PROXY_URL,
77 | enable_local_image_cache: false, // Vercel 不支持本地缓存
78 | sync_enabled: syncEnabled,
79 | multi_user_mode: ACCESS_PASSWORDS.length > 1
80 | });
81 | });
82 |
83 | // ========== API: /api/debug ==========
84 | app.get('/api/debug', (req, res) => {
85 | res.json({
86 | environment: 'Vercel Serverless',
87 | node_version: process.version,
88 | env_status: {
89 | TMDB_API_KEY: TMDB_API_KEY ? 'configured' : 'missing',
90 | TMDB_PROXY_URL: TMDB_PROXY_URL ? 'configured' : 'not_set',
91 | ACCESS_PASSWORD: ACCESS_PASSWORDS.length > 0 ? `${ACCESS_PASSWORDS.length} password(s)` : 'not_set',
92 | REMOTE_DB_URL: REMOTE_DB_URL ? 'configured' : 'not_set'
93 | },
94 | cache_type: 'memory',
95 | timestamp: new Date().toISOString()
96 | });
97 | });
98 |
99 | // ========== API: /api/auth/check ==========
100 | app.get('/api/auth/check', (req, res) => {
101 | res.json({
102 | requirePassword: ACCESS_PASSWORDS.length > 0,
103 | multiUserMode: ACCESS_PASSWORDS.length > 1
104 | });
105 | });
106 |
107 | // ========== API: /api/auth/verify ==========
108 | app.post('/api/auth/verify', (req, res) => {
109 | const { password, passwordHash } = req.body;
110 |
111 | if (ACCESS_PASSWORDS.length === 0) {
112 | return res.json({ success: true, syncEnabled: false });
113 | }
114 |
115 | const hash = passwordHash || crypto.createHash('sha256').update(password || '').digest('hex');
116 | const userInfo = PASSWORD_HASH_MAP[hash];
117 |
118 | if (userInfo) {
119 | return res.json({
120 | success: true,
121 | passwordHash: hash,
122 | syncEnabled: userInfo.syncEnabled,
123 | userIndex: userInfo.index
124 | });
125 | } else {
126 | return res.json({ success: false });
127 | }
128 | });
129 |
130 | // ========== API: /api/tmdb-proxy ==========
131 | app.get('/api/tmdb-proxy', async (req, res) => {
132 | const { path: tmdbPath, ...params } = req.query;
133 |
134 | if (!tmdbPath) {
135 | return res.status(400).json({ error: 'Missing path' });
136 | }
137 |
138 | if (!TMDB_API_KEY) {
139 | return res.status(500).json({ error: 'TMDB API Key not configured' });
140 | }
141 |
142 | // 构建缓存 Key
143 | const sortedParams = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('&');
144 | const cacheKey = `${tmdbPath}_${sortedParams}`;
145 |
146 | // 检查缓存
147 | const cached = tmdbCache.get(cacheKey);
148 | if (cached && Date.now() - cached.time < TMDB_CACHE_TTL) {
149 | return res.json(cached.data);
150 | }
151 |
152 | try {
153 | const TMDB_BASE = 'https://api.themoviedb.org/3';
154 | const response = await axios.get(`${TMDB_BASE}${tmdbPath}`, {
155 | params: {
156 | ...params,
157 | api_key: TMDB_API_KEY,
158 | language: 'zh-CN'
159 | },
160 | timeout: 10000
161 | });
162 |
163 | // 缓存结果
164 | tmdbCache.set(cacheKey, { data: response.data, time: Date.now() });
165 |
166 | // 限制缓存大小 (防止内存溢出)
167 | if (tmdbCache.size > 1000) {
168 | const firstKey = tmdbCache.keys().next().value;
169 | tmdbCache.delete(firstKey);
170 | }
171 |
172 | res.json(response.data);
173 | } catch (err) {
174 | console.error('[TMDB Proxy Error]', err.message);
175 | res.status(err.response?.status || 500).json({ error: 'Proxy request failed' });
176 | }
177 | });
178 |
179 | // ========== API: /api/tmdb-image (图片代理 - 仅流式转发) ==========
180 | app.get('/api/tmdb-image/:size/:filename', async (req, res) => {
181 | const { size, filename } = req.params;
182 | const allowSizes = ['w300', 'w342', 'w500', 'w780', 'w1280', 'original'];
183 |
184 | // 安全检查
185 | if (!allowSizes.includes(size) || !/^[a-zA-Z0-9_\-\.]+$/.test(filename)) {
186 | return res.status(400).send('Invalid parameters');
187 | }
188 |
189 | const tmdbUrl = `https://image.tmdb.org/t/p/${size}/${filename}`;
190 |
191 | try {
192 | // 支持自定义反代 URL
193 | let targetUrl = tmdbUrl;
194 | if (TMDB_PROXY_URL) {
195 | const proxyBase = TMDB_PROXY_URL.replace(/\/$/, '');
196 | targetUrl = `${proxyBase}/t/p/${size}/${filename}`;
197 | }
198 |
199 | // console.log(`[Vercel Image] Proxying: ${targetUrl}`);
200 | const response = await axios({
201 | url: targetUrl,
202 | method: 'GET',
203 | responseType: 'stream',
204 | timeout: 10000
205 | });
206 |
207 | // 缓存控制:公共缓存,有效期1天
208 | res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=86400');
209 | response.data.pipe(res);
210 | } catch (error) {
211 | console.error(`[Vercel Image Error] ${tmdbUrl}:`, error.message);
212 | res.status(404).send('Image not found');
213 | }
214 | });
215 |
216 | // ========== API: /api/search (SSE 流式搜索) ==========
217 | app.get('/api/search', async (req, res) => {
218 | const keyword = req.query.wd;
219 | const stream = req.query.stream === 'true';
220 |
221 | if (!keyword) {
222 | return res.status(400).json({ error: 'Missing keyword' });
223 | }
224 |
225 | // 获取站点配置
226 | let sites = [];
227 | try {
228 | if (REMOTE_DB_URL) {
229 | const now = Date.now();
230 | if (remoteDbCache && now - remoteDbLastFetch < REMOTE_DB_CACHE_TTL) {
231 | sites = remoteDbCache.sites || [];
232 | } else {
233 | const response = await axios.get(REMOTE_DB_URL, { timeout: 5000 });
234 | if (response.data && Array.isArray(response.data.sites)) {
235 | remoteDbCache = response.data;
236 | remoteDbLastFetch = now;
237 | sites = response.data.sites;
238 | }
239 | }
240 | }
241 | } catch (err) {
242 | console.error('[Search] Failed to load sites:', err.message);
243 | }
244 |
245 | if (sites.length === 0) {
246 | // 即使没有站点也要返回 SSE 格式,否则 EventSource 会报错
247 | if (stream) {
248 | res.setHeader('Content-Type', 'text/event-stream');
249 | res.setHeader('Cache-Control', 'no-cache');
250 | res.write(`data: ${JSON.stringify({ error: '未配置资源站点,请在环境变量中设置 REMOTE_DB_URL' })}\n\n`);
251 | res.write('event: done\ndata: {}\n\n');
252 | return res.end();
253 | }
254 | return res.json({ error: 'No sites configured. Please set REMOTE_DB_URL.' });
255 | }
256 |
257 | if (!stream) {
258 | return res.json({ error: 'Use stream=true for search' });
259 | }
260 |
261 | // SSE 流式响应
262 | res.setHeader('Content-Type', 'text/event-stream');
263 | res.setHeader('Cache-Control', 'no-cache');
264 | res.setHeader('Connection', 'keep-alive');
265 | res.setHeader('X-Accel-Buffering', 'no');
266 |
267 | const searchPromises = sites.map(async (site) => {
268 | try {
269 | const response = await axios.get(site.api, {
270 | params: { ac: 'detail', wd: keyword },
271 | timeout: 8000
272 | });
273 |
274 | const data = response.data;
275 | const list = data.list ? data.list.map(item => ({
276 | vod_id: item.vod_id,
277 | vod_name: item.vod_name,
278 | vod_pic: item.vod_pic,
279 | vod_remarks: item.vod_remarks,
280 | vod_year: item.vod_year,
281 | type_name: item.type_name,
282 | vod_content: item.vod_content,
283 | vod_play_from: item.vod_play_from,
284 | vod_play_url: item.vod_play_url,
285 | site_key: site.key,
286 | site_name: site.name
287 | })) : [];
288 |
289 | if (list.length > 0) {
290 | res.write(`data: ${JSON.stringify(list)}\n\n`);
291 | }
292 | return list;
293 | } catch (err) {
294 | console.error(`[Search Error] ${site.name}:`, err.message);
295 | return [];
296 | }
297 | });
298 |
299 | await Promise.all(searchPromises);
300 | res.write('event: done\ndata: {}\n\n');
301 | res.end();
302 | });
303 |
304 | // ========== API: /api/detail ==========
305 | app.get('/api/detail', async (req, res) => {
306 | const id = req.query.id;
307 | const siteKey = req.query.site_key;
308 |
309 | if (!id || !siteKey) {
310 | return res.status(400).json({ error: 'Missing id or site_key' });
311 | }
312 |
313 | // 获取站点配置
314 | let sites = [];
315 | try {
316 | if (remoteDbCache) {
317 | sites = remoteDbCache.sites || [];
318 | } else if (REMOTE_DB_URL) {
319 | const response = await axios.get(REMOTE_DB_URL, { timeout: 5000 });
320 | if (response.data && Array.isArray(response.data.sites)) {
321 | remoteDbCache = response.data;
322 | remoteDbLastFetch = Date.now();
323 | sites = response.data.sites;
324 | }
325 | }
326 | } catch (err) {
327 | console.error('[Detail] Failed to load sites:', err.message);
328 | }
329 |
330 | const site = sites.find(s => s.key === siteKey);
331 | if (!site) {
332 | return res.status(404).json({ error: 'Site not found' });
333 | }
334 |
335 | try {
336 | const response = await axios.get(site.api, {
337 | params: { ac: 'detail', ids: id },
338 | timeout: 8000
339 | });
340 |
341 | const data = response.data;
342 | if (data.list && data.list.length > 0) {
343 | res.json({ list: [data.list[0]] });
344 | } else {
345 | res.status(404).json({ error: 'Not found', list: [] });
346 | }
347 | } catch (err) {
348 | console.error('[Detail Error]', err.message);
349 | res.status(500).json({ error: 'Detail fetch failed', list: [] });
350 | }
351 | });
352 |
353 | // ========== 历史同步相关 API (Vercel 不支持 SQLite,返回空) ==========
354 | app.get('/api/history/pull', (req, res) => {
355 | res.json({
356 | sync_enabled: false,
357 | history: [],
358 | message: 'History sync not available in Vercel (no persistent storage)'
359 | });
360 | });
361 |
362 | app.post('/api/history/push', (req, res) => {
363 | res.json({
364 | sync_enabled: false,
365 | saved: 0,
366 | message: 'History sync not available in Vercel (no persistent storage)'
367 | });
368 | });
369 |
370 | // ========== Vercel Serverless 导出 ==========
371 | module.exports = app;
372 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # E视界 (DongguaTV Enhanced Edition)
2 |
3 | 这是一个经过全面重构和升级的现代流媒体聚合播放器,基于 Node.js 和 Vue 3 构建。相比原版,本作引入了 Netflix 风格的沉浸式 UI、TMDb 数据驱动的动态榜单、以及智能的多源聚合搜索功能。
4 |
5 | ---
6 |
7 | ## 📚 目录 (Table of Contents)
8 |
9 | - [✨ 核心特性 (Features)](#-核心特性-features)
10 | - [🎨 界面升级 (UI Upgrade)](#-界面升级-ui-upgrade)
11 | - [🛠️ 技术栈 (Tech Stack)](#️-技术栈-tech-stack)
12 | - [🔧 前置准备 (Prerequisites)](#-前置准备-prerequisites)
13 | - [🌐 网络优化 & 代理 (Network & Proxy)](#-网络优化--代理-network--proxy)
14 | - [🔒 安全与高级功能 (Security & Advanced)](#-安全与高级功能-security--advanced)
15 | - [📦 安装与运行 (Installation)](#-安装与运行-installation)
16 | - [🚀 部署 (Deployment)](#-部署-deployment)
17 | - [🤖 Android APP 构建](#-android-app-构建-github-actions)
18 | - [💾 数据维护与备份](#-数据维护与备份)
19 | - [⚠️ 免责声明 (Disclaimer)](#️-免责声明-disclaimer)
20 |
21 | ---
22 |
23 | ## ✨ 核心特性 (Core Features)
24 |
25 | ### 1. 🎬 双引擎数据驱动
26 | - **TMDb (The Movie Database)**:提供高质量的电影/剧集元数据(海报、背景图、评分、简介、演职员表)。
27 | - **CMS 聚合源 (Maccms)**:集成 **48+** 个第三方资源站 API,自动进行**全网测速**,智能过滤失效源,确保播放流畅。
28 |
29 | ### 2. 🔍 智能搜索与聚合
30 | - **实时流式搜索 (SSE)**:采用 Server-Sent Events 技术,搜索结果**边搜边显**,即时反馈,无需等待所有源响应。
31 | - **自动分组与实时合并**:同一影片的不同线路自动聚合,新搜索到的源实时合并到已有卡片,右上角源数量实时跳动。
32 | - **SQLite 永久缓存**:内置高性能 SQLite 数据库缓存,支持无限存储,读写速度极快,热搜词秒级响应。
33 |
34 | ### 3. 📺 沉浸式播放体验
35 | - **影院模式**:全新设计的播放详情页,采用暗色系沉浸布局,支持剧集网格选择。
36 | - **智能线路测速**:支持用户端直连测速和服务器代理测速,真实反映视频可用性。
37 | - **自动故障转移**:播放失败时自动切换到下一个可用线路,无需手动操作。
38 | - **投屏支持**:集成 DLNA/AirPlay 本地投屏功能(需浏览器支持)。
39 |
40 | ### 4. 🌏 大陆用户优化
41 | - **智能 IP 双重检测**:采用 **Cloudflare Trace + ipapi.co** 双重检测机制,准确率高且无 API 速率限制,自动切换到 TMDB 反代模式。
42 | - **本地资源优先**:核心依赖库(Vue, Bootstrap, DPlayer 等)均本地化部署,彻底解决 CDN 劫持或加载缓慢问题,秒开无压力。
43 | - **一键安装脚本**:支持交互式配置 API Key、反代地址、运行端口。
44 |
45 | ### 5. 📱 多端支持
46 | - **Android TV / 盒子**:提供专属 APK 安装包,完美适配电视遥控器操作,支持 Android TV 桌面启动 (Leanback Launcher)。
47 | - **移动端 App**:基于现代 Web 技术封装,支持**沉浸式状态栏 (Immersive Status Bar)**,顶部无黑边,内容自动适配刘海屏,体验原生级流畅。
48 | - **PWA 支持**:支持添加到主屏幕,即点即用。
49 |
50 | ### 6. 🔒 安全与访问控制
51 | - **全局访问密码**:支持设置全局访问密码,且支持**记住登录状态长达 1 年**,既安全又便捷。
52 | - **远程配置加载**:支持从远程 URL 加载 `db.json` 配置文件,方便多站点统一管理。
53 |
54 | ---
55 |
56 | ## 🎨 界面与交互升级 (UI/UX Upgrades)
57 |
58 | 相比原版,我们在 UI/UX 上做了颠覆性的改进:
59 |
60 | | 功能区域 | 原版体验 | **MAX 版体验** |
61 | | :--- | :--- | :--- |
62 | | **首页视觉** | 简单的列表罗列 | **Netflix 风格 Hero 轮播**:全屏动态背景、高斯模糊遮罩、Top 10 排名特效。 |
63 | | **导航栏** | 固定顶部 | **智能融合导航**:初始透明,滚动变黑;分类点击自动平滑滚动定位。 |
64 | | **搜索框** | 顶部固定位置 | **动态交互搜索栏**:初始占满全屏,下滑自动吸顶并缩小为"胶囊"悬浮。 |
65 | | **榜单浏览** | 有限的静态列表 | **无限滚动 (Infinite Scroll)**:20+ 个细分榜单,支持向右无限加载。 |
66 | | **搜索体验** | 需等待 loading 结束 | **实时流式加载 (SSE)**:结果即时呈现,源数量实时跳动增加,拒绝枯燥等待。 |
67 | | **线路选择** | 单一延迟显示 | **双模式测速**:区分"直连"(用户端)和"代理"(服务器端) 测速,更准确。 |
68 | | **播放失败** | 需手动切换 | **自动故障转移**:检测播放失败后自动切换到下一可用线路。 |
69 | | **启动体验** | 页面分块加载 | **优雅启动屏**:新增应用级启动画面,资源加载完成后丝滑过渡,拒绝白屏。 |
70 | | **分类系统** | 仅支持搜索跳转 | **全直达榜单**:历史、冒险、综艺等分类均拥有独立的数据流榜单。 |
71 |
72 | ---
73 |
74 | ## 🛠️ 技术栈 (Tech Stack)
75 |
76 | | 类别 | 技术 |
77 | |------|------|
78 | | **Frontend** | Vue.js 3 (CDN), Bootstrap 5, FontAwesome 6, DPlayer, HLS.js |
79 | | **Backend** | Node.js, Express, Axios |
80 | | **Data Sources** | TMDb API v3, 48+ Maccms CMS APIs |
81 | | **Deployment** | Docker, Vercel, PM2, 宝塔面板 |
82 | | **Cache** | Flexible: SQLite (Recommended), JSON File, or Memory |
83 | | **Proxy** | Cloudflare Workers (for China users) |
84 |
85 | ---
86 |
87 | ## 🔧 前置准备
88 |
89 | ### 1. ⚠️ 配置采集源 (重要)
90 |
91 | 本项目**不包含**任何内置的影视资源接口。在运行项目前(或运行后),您必须自行添加合法的 Maccms V10 (JSON格式) 接口才能搜索和播放视频。
92 |
93 | **配置方法:**
94 | 所有的采集源配置均存储在根目录的 `db.json` 文件中。
95 |
96 | 1. 项目初次运行时会自动生成 `db.json`(如果未生成,可以手动创建或使用模板)。
97 | 2. 打开 `db.json`,找到 `sites` 数组。
98 | 3. 填入您找到的采集接口信息:
99 |
100 | ```json
101 | {
102 | "sites": [
103 | {
104 | "key": "unique_key1", // 唯一标识符(英文字母,不可重复)
105 | "name": "站点名称1", // 显示在界面的名称
106 | "api": "https://...", // Maccms V10/JSON 接口地址
107 | "active": true // 是否启用 (true/false)
108 | },
109 | {
110 | "key": "unique_key2", // 唯一标识符(英文字母,不可重复)
111 | "name": "站点名称2", // 显示在界面的名称
112 | "api": "https://...", // Maccms V10/JSON 接口地址
113 | "active": true // 是否启用 (true/false)
114 | }
115 | ]
116 | }
117 | ```
118 | 4. 保存文件并**重启服务**。
119 |
120 | ### 2. 获取 TMDb API Key (必需)
121 | 本项目依赖 **The Movie Database (TMDb)** 提供影视元数据。
122 |
123 | 1. 注册账户:访问 [Create Account](https://www.themoviedb.org/signup) 注册并登录。
124 | 2. 申请 API:访问 [API Settings](https://www.themoviedb.org/settings/api),点击 **"Create"** 或 **"click here"** 申请。
125 | 3. 填写信息:应用类型选择 **"Developer"**,简单填写用途(如 "Personal learning project")。
126 | 4. 获取 Key:申请通过后,复制 **"API Key (v3 auth)"** 备用。
127 |
128 | ### 3. 大陆用户:部署 TMDB 反代 (可选)
129 |
130 | 由于 TMDB 在大陆无法直接访问,需要配置反向代理以正常显示海报和影视信息。
131 |
132 | #### 方案一:部署 Cloudflare Workers 反代 (推荐)
133 |
134 | 1. **登录 Cloudflare**
135 | - 访问 [Cloudflare Dashboard](https://dash.cloudflare.com/)
136 | - 选择左侧菜单 **"Workers & Pages"** → **"Create application"** → **"Create Worker"**
137 |
138 | 2. **部署反代代码**
139 | - 给 Worker 取名(如 `tmdb-proxy`),点击 **Deploy**
140 | - 部署后点击 **"Edit code"**
141 | - 复制 `cloudflare-tmdb-proxy.js` 文件内容,粘贴到编辑器
142 | - 点击 **"Save and Deploy"**
143 |
144 | 3. **获取 Worker URL**
145 | 部署成功后获得 URL,如:`https://tmdb-proxy.your-name.workers.dev`
146 |
147 | 4. **稍后在安装时配置**
148 | 在 `.env` 文件中添加:
149 | ```env
150 | TMDB_PROXY_URL=https://tmdb-proxy.your-name.workers.dev
151 | ```
152 |
153 | ### 4. 资源站 CORS 代理 (可选/推荐)
154 |
155 | 当服务器或用户无法直接访问某些资源站时,系统会自动通过 CORS 代理中转请求。
156 |
157 | #### 支持的场景
158 |
159 | | 场景 | 描述 |
160 | |------|------|
161 | | **服务器端搜索** | 服务器无法访问资源站 API 时,自动通过代理搜索 |
162 | | **服务器端获取详情** | 同上,获取影片详情时自动回退到代理 |
163 | | **用户端视频播放** | 用户浏览器无法访问视频流时,自动代理播放 |
164 | | **慢速线路优化** | 直连延迟 >1500ms 时,自动尝试代理,选择更快的方式 |
165 |
166 | #### 核心功能
167 |
168 | - ✅ **智能学习**:自动记住需要代理的站点(24小时有效期)
169 | - ✅ **慢速检测**:直连延迟超过 1.5 秒时,自动测试代理是否更快
170 | - ✅ **m3u8 重写**:自动重写 m3u8 文件中的 ts 分片 URL,确保视频流完整代理
171 | - ✅ **防盗链绕过**:自动设置正确的 Referer 和 Origin 头
172 |
173 | #### 工作原理
174 |
175 | **服务器端(搜索/详情):**
176 | 1. 服务器先尝试直接访问资源站 API
177 | 2. 如果直连失败或延迟过高,自动通过 CORS 代理重试
178 | 3. 成功后会"记住"该站点需要代理,后续请求直接使用代理
179 |
180 | **用户端(播放):**
181 | 1. 用户端测速时,先尝试直接访问视频流
182 | 2. 如果直连失败或延迟 >1500ms,自动通过 CORS 代理重试
183 | 3. 代理会重写 m3u8 内容,将 ts 分片 URL 也改为代理 URL
184 | 4. UI 上会显示三种状态:
185 | - 🟢 **直连**:用户端可直接访问
186 | - 🟡 **中转**:通过代理访问
187 | - 🔵 **服务**:服务器端测速(无法客户端测试)
188 |
189 | #### 部署 CORS 代理 (Cloudflare Workers)
190 |
191 | > ⚠️ **关于流量限制的风险提示**:
192 | > Cloudflare 免费版 Workers 每日有请求限制 (10万次),且根据条款**不建议**用于大规模非 HTML 内容(如视频流)的代理。
193 | > - **个人自用**:通常没问题。
194 | > - **多人/高频使用**:强烈建议使用下方的 **VPS / Node.js** 部署方案,以免被封号。
195 |
196 | 1. **登录 [Cloudflare Dashboard](https://dash.cloudflare.com)**
197 | - 进入 **Workers & Pages** → **Create Worker**
198 | - 命名如 `cors-proxy`
199 |
200 | 2. **部署代理代码**
201 | - 复制 `cloudflare-cors-proxy.js` 文件**全部内容**到编辑器
202 | - 点击 **"Save and Deploy"**
203 | - 记录 Worker URL(如 `https://cors-proxy.your-name.workers.dev`)
204 |
205 | 3. **配置 .env**
206 | ```env
207 | CORS_PROXY_URL=https://cors-proxy.your-name.workers.dev
208 | ```
209 |
210 | 4. **(可选)绑定自定义域名**
211 | - Worker 设置 → Triggers → Custom Domains → 添加域名
212 |
213 | > ⚠️ **重要**:每次更新 `cloudflare-cors-proxy.js` 文件后,需要重新部署到 Cloudflare!
214 |
215 | #### 部署 CORS 代理 (VPS / Node.js)
216 |
217 | 如果您有自己的服务器,或者流量较大,建议使用此方式。
218 |
219 | 1. **准备环境**:确保 VPS 已安装 Node.js (v18+)。
220 | 2. **上传代码**:上传 `proxy-server.js` 到服务器。
221 | 3. **安装依赖 & 运行**:
222 | ```bash
223 | # 安装依赖
224 | npm install express axios cors dotenv
225 |
226 | # 启动服务
227 | PORT=8080 node proxy-server.js
228 | ```
229 | *推荐使用 PM2 守护进程:* `pm2 start proxy-server.js --name cors-proxy`
230 |
231 | 4. **配置 .env**:
232 | ```env
233 | CORS_PROXY_URL=http://your-vps-ip:8080
234 | ```
235 |
236 | #### 代理工作流程图
237 |
238 | ```
239 | 用户请求 m3u8 视频
240 | ↓
241 | 代理获取 m3u8
242 | ↓
243 | 重写 ts 分片 URL
244 | (改为经过代理)
245 | ↓
246 | 返回修改后的 m3u8
247 | ↓
248 | 播放器请求 ts 分片
249 | (通过代理,带正确 Referer)
250 | ↓
251 | 视频正常播放 ✓
252 | ```
253 |
254 | ---
255 |
256 | ## 🔒 安全配置与远程加载 (高级)
257 |
258 | 为了保护您的站点或统一管理配置,可以使用以下高级功能:
259 |
260 | ### 全局访问密码
261 | 在 `.env` 文件中设置 `ACCESS_PASSWORD` 即可开启全局密码保护。开启后,用户访问任何页面都需要输入密码。
262 | ```env
263 | ACCESS_PASSWORD=your_secure_password
264 | ```
265 |
266 | ### 远程配置文件 (db.json)
267 | 如果您有多个站点或希望远程更新配置,可以让服务器读取远程的 `db.json` 文件。
268 | 在 `.env` 文件中设置:
269 | ```env
270 | # 远程 JSON 文件地址 (需支持 GET 请求)
271 | REMOTE_DB_URL=https://example.com/my-config/db.json
272 | ```
273 | > **注意**:
274 | > 1. 配置 `REMOTE_DB_URL` 后,系统会自动优先尝试从该 URL 获取配置。
275 | > 2. 会有 5 分钟的内存缓存,避免频繁请求远程服务器。
276 | > 3. 如果远程获取失败,会自动降级使用本地的 `db.json` 文件。
277 |
278 | ### 多用户模式与观看历史同步 (新功能)
279 |
280 | 支持多个密码,每个密码代表一个独立用户,拥有独立的观看历史。历史记录可在同一用户的不同设备间自动同步。
281 |
282 | **配置方式**:在 `.env` 文件中用逗号分隔多个密码:
283 | ```env
284 | # 多密码模式
285 | ACCESS_PASSWORD=admin_password,user1_pass,user2_pass
286 | ```
287 |
288 | **规则说明**:
289 | | 密码位置 | 行为 |
290 | |---------|------|
291 | | **第一个密码** | 保持传统模式,观看历史仅存储在本地设备 |
292 | | **第二个及之后** | 启用云同步,观看历史在设备间自动同步 |
293 |
294 | **同步特性**:
295 | - ✅ 自动同步:页面加载时自动拉取和推送历史
296 | - ✅ 本地优先:本地记录不会被其他设备覆盖
297 | - ✅ 智能合并:同一影片以最新的观看记录为准
298 | - ✅ 隐蔽提示:同步状态图标显示在"继续观看"旁边
299 |
300 | > ⚠️ **重要**:观看历史同步功能**仅在 SQLite 缓存模式下可用**。
301 | >
302 | > | 缓存类型 | 历史同步 | 说明 |
303 | > |---------|---------|------|
304 | > | `sqlite` | ✅ 支持 | 推荐,数据持久化存储在数据库中 |
305 | > | `json` | ❌ 不支持 | 仅支持搜索/详情缓存,无用户数据存储 |
306 | > | `memory` | ❌ 不支持 | 服务器重启后数据丢失 |
307 | > | `none` | ❌ 不支持 | 无缓存功能 |
308 | >
309 | > 如需使用历史同步,请在 `.env` 中设置 `CACHE_TYPE=sqlite`。
310 |
311 | **使用场景示例**:
312 | ```bash
313 | # admin_password - 管理员使用,本地存储
314 | # user1_pass - 家人A使用,全设备同步
315 | # user2_pass - 家人B使用,全设备同步
316 | ACCESS_PASSWORD=admin123,familyA_pass,familyB_pass
317 | ```
318 |
319 | ---
320 |
321 | ## 📦 安装与运行 (Installation)
322 |
323 | ### 🚀 一键安装脚本 (推荐)
324 |
325 | 适用于 Ubuntu/Debian/CentOS 系统,自动安装所有依赖并配置服务。
326 |
327 | ```bash
328 | # 下载并运行一键安装脚本
329 | curl -fsSL https://raw.githubusercontent.com/ednovas/dongguaTV/main/install.sh | bash
330 | ```
331 |
332 | 或者手动下载后运行:
333 | ```bash
334 | wget https://raw.githubusercontent.com/ednovas/dongguaTV/main/install.sh
335 | chmod +x install.sh
336 | ./install.sh
337 | ```
338 |
339 | 脚本会引导您输入:
340 | - TMDB API Key (必填)
341 | - TMDB 反代地址 (可选,大陆用户需要)
342 | - 运行端口 (默认 3000)
343 | - 运行端口 (默认 3000)
344 | - 安装目录 (默认 /opt/dongguaTV)
345 |
346 | > **提示**:安装完成后,您可以随时编辑安装目录下的 `.env` 文件,修改 `CACHE_TYPE` 来切换缓存模式(需要重启服务)。
347 |
348 | ### 手动本地运行
349 |
350 | #### 1. 安装 Node.js (v18+)
351 |
352 | **Ubuntu/Debian:**
353 | ```bash
354 | curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
355 | sudo apt-get install -y nodejs
356 | ```
357 |
358 | **CentOS/RHEL:**
359 | ```bash
360 | curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
361 | sudo yum install -y nodejs
362 | ```
363 |
364 | **macOS (使用 Homebrew):**
365 | ```bash
366 | brew install node@18
367 | ```
368 |
369 | **Windows:**
370 | 下载安装包:https://nodejs.org/
371 |
372 | 验证安装:
373 | ```bash
374 | node -v # 应显示 v18.x.x
375 | npm -v # 应显示 9.x.x 或更高
376 | ```
377 |
378 | #### 2. 安装编译工具 (可选)
379 | 如果您计划使用 `sqlite` 作为缓存(默认推荐),则必须安装编译工具。如果不使用 SQLite (如使用 `json` 或 `memory` 模式),可跳过此步。
380 |
381 | **Ubuntu/Debian:**
382 | ```bash
383 | sudo apt-get install -y build-essential python3
384 | ```
385 |
386 | **CentOS/RHEL:**
387 | ```bash
388 | sudo yum groupinstall -y "Development Tools"
389 | sudo yum install -y python3
390 | ```
391 |
392 | **macOS:**
393 | ```bash
394 | xcode-select --install
395 | ```
396 |
397 | #### 3. 安装项目依赖
398 | ```bash
399 | npm install
400 | ```
401 |
402 | #### 4. 配置环境变量
403 | 复制 `.env.example` 为 `.env`,并配置如下信息:
404 | ```env
405 | # TMDb API Key (必填)
406 | TMDB_API_KEY=your_api_key_here
407 |
408 | # 可选:自定义端口 (默认 3000)
409 | PORT=3000
410 |
411 | # 可选:大陆用户 TMDB 反代地址 (详见下方说明)
412 | TMDB_PROXY_URL=
413 |
414 | # 可选:缓存类型 ('json', 'sqlite', 'memory', 'none') - 默认 json
415 | CACHE_TYPE=json
416 |
417 | # 可选:访问密码 (设置后需要密码才能访问)
418 | ACCESS_PASSWORD=
419 |
420 | # 可选:远程配置文件地址
421 | REMOTE_DB_URL=
422 | ```
423 |
424 | #### 5. 启动服务
425 | ```bash
426 | node server.js
427 | ```
428 |
429 | #### 6. 访问
430 | 打开浏览器访问 `http://localhost:3000`
431 |
432 | ---
433 |
434 | ## 🚀 部署 (Deployment)
435 |
436 | ### 🐳 Docker 部署 (推荐)
437 |
438 | #### 环境变量说明
439 |
440 | | 变量名 | 必填 | 说明 |
441 | |--------|------|------|
442 | | `TMDB_API_KEY` | ✅ 是 | TMDb API 密钥,用于获取影视信息 |
443 | | `CACHE_TYPE` | ❌ 否 | 缓存类型: `json`(默认), `sqlite`, `memory`, `none` |
444 | | `TMDB_PROXY_URL` | ❌ 否 | TMDB 反代地址,大陆用户需要配置 |
445 | | `CORS_PROXY_URL` | ❌ 否 | 视频/图片 CORS 代理地址,解决资源站播放失败问题 |
446 | | `PORT` | ❌ 否 | 服务端口,默认 3000 |
447 | | `ACCESS_PASSWORD` | ❌ 否 | 访问密码,保护站点不被公开访问 |
448 | | `REMOTE_DB_URL` | ❌ 否 | 远程 `db.json` 地址,用于统一配置管理 |
449 |
450 | #### 方案一:使用现有镜像(最快)
451 | 无需构建,一行命令直接运行。
452 |
453 | > **🎉 多架构支持**:Docker 镜像已支持以下架构,会自动选择匹配的版本:
454 | > - `linux/amd64` - x86_64 服务器、PC
455 | > - `linux/arm64` - Apple M1/M2/M3、树莓派4/5、AWS Graviton
456 | > - `linux/arm/v7` - 树莓派3、旧版 ARM 设备
457 |
458 | ```bash
459 | # 基础启动 (请替换 TMDB_API_KEY)
460 | docker run -d -p 3000:3000 \
461 | -e TMDB_API_KEY="your_api_key_here" \
462 | -e ACCESS_PASSWORD="your_password" \
463 | --name donggua-tv \
464 | --restart unless-stopped \
465 | ghcr.io/ednovas/dongguatv:latest
466 | ```
467 |
468 | ```bash
469 | # 1. ⚠️ 重要:先创建文件,防止 Docker 将其识别为目录
470 | touch db.json cache.db
471 | # 如果是 Windows PowerShell:
472 | # New-Item -ItemType File -Name db.json -Force
473 | # New-Item -ItemType File -Name cache.db -Force
474 |
475 | # 2. 写入默认配置 (可选,如果不写则为空)
476 | echo '{"sites":[]}' > db.json
477 | mkdir -p cache/images
478 |
479 | # 3. 完整配置启动
480 | docker run -d -p 3000:3000 \
481 | -e TMDB_API_KEY="your_api_key_here" \
482 | -e ACCESS_PASSWORD="your_password" \
483 | -e TMDB_PROXY_URL="https://tmdb-proxy.your-name.workers.dev" \
484 | -e CORS_PROXY_URL="https://cors-proxy.your-name.workers.dev" \
485 | -e REMOTE_DB_URL="https://example.com/db.json" \
486 | -v $(pwd)/db.json:/app/db.json \
487 | -v $(pwd)/cache.db:/app/cache.db \
488 | -v $(pwd)/cache/images:/app/public/cache/images \
489 | --name donggua-tv \
490 | --restart unless-stopped \
491 | ghcr.io/ednovas/dongguatv:latest
492 | ```
493 |
494 | > **⚠️ 常见错误警告**:如果启动失败且日志报错 `EISDIR: illegal operation on a directory`,说明您没有先创建 `db.json` 文件,Docker 自动创建了同名文件夹。请删除该文件夹 (`rm -rf db.json`) 并重新执行上述 `touch` 命令创建文件。
495 | >
496 | > **注意**:如果不挂载 `-v` 卷,您的站点配置(db.json)和缓存(cache.db)将在容器重启后丢失。请确保当前目录下有 `db.json` 文件(如果没有,第一次运行后可以从容器内复制出来)。
497 |
498 | #### 方案二:本地构建
499 | 如果您想自己修改代码或重新构建镜像:
500 |
501 | 1. **构建镜像**
502 | ```bash
503 | docker build -t donggua-tv .
504 | ```
505 | 2. **运行容器**
506 | ```bash
507 | docker run -d -p 3000:3000 \
508 | -e TMDB_API_KEY="your_api_key_here" \
509 | -e TMDB_PROXY_URL="https://tmdb-proxy.your-name.workers.dev" \
510 | -e CORS_PROXY_URL="https://cors-proxy.your-name.workers.dev" \
511 | -e REMOTE_DB_URL="https://example.com/db.json" \
512 | --name donggua-tv \
513 | --restart unless-stopped \
514 | donggua-tv
515 | ```
516 |
517 | #### 方案三:Docker Compose
518 | 如果您更喜欢使用 Compose 管理:
519 |
520 | 1. 创建 `docker-compose.yml` 文件:
521 | ```yaml
522 | version: '3'
523 | services:
524 | donggua-tv:
525 | image: ghcr.io/ednovas/dongguatv:latest
526 | container_name: donggua-tv
527 | ports:
528 | - "3000:3000"
529 | environment:
530 | - TMDB_API_KEY=your_api_key_here
531 | - TMDB_PROXY_URL=https://tmdb-proxy.your-name.workers.dev
532 | - CORS_PROXY_URL=https://cors-proxy.your-name.workers.dev
533 | - ACCESS_PASSWORD=your_secure_password
534 | - REMOTE_DB_URL=https://example.com/db.json
535 | volumes:
536 | - ./db.json:/app/db.json
537 | - ./cache.db:/app/cache.db
538 | restart: unless-stopped
539 | ```
540 |
541 | 2. **启动**
542 | ```bash
543 | # 同样需要先创建文件,防止挂载成目录
544 | touch db.json cache.db
545 |
546 | # 启动服务
547 | docker-compose up -d
548 | ```
549 |
550 | ### ▲ Vercel 部署
551 | 适合零成本快速上线。
552 |
553 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fednovas%2FdongguaTV&env=TMDB_API_KEY,REMOTE_DB_URL,ACCESS_PASSWORD,TMDB_PROXY_URL&envDescription=TMDB_API_KEY%20and%20REMOTE_DB_URL%20are%20required.%20Others%20are%20optional.&envLink=https%3A%2F%2Fgithub.com%2Fednovas%2FdongguaTV%23-vercel-%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E9%85%8D%E7%BD%AE%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9)
554 |
555 | *(请确保先将本项目fork到您自己的 GitHub 仓库,点击上方按钮即可一键导入部署)*
556 |
557 | #### ⚠️ Vercel 环境变量配置注意事项
558 |
559 | 在 Vercel 部署后,**必须正确配置环境变量**才能使用:
560 |
561 | 1. **Settings → Environment Variables** 中添加以下变量:
562 | - `TMDB_API_KEY` - TMDb API 密钥(**必填**)
563 | - `REMOTE_DB_URL` - 远程站点配置 JSON 地址(**Vercel 必填**,因为无法读取本地 db.json)
564 | - `ACCESS_PASSWORD` - 访问密码(可选)
565 | - `TMDB_PROXY_URL` - 大陆用户反代地址(可选)
566 |
567 | 2. **环境变量不生效?** 请按以下步骤排查:
568 |
569 | | 步骤 | 操作 | 说明 |
570 | |------|------|------|
571 | | ① | 检查变量名 | 确保**完全正确**且区分大小写 |
572 | | ② | 检查环境范围 | 确保勾选了 **Production** 环境 |
573 | | ③ | **重新部署** | ⚠️ 添加/修改变量后必须重新部署!进入 Deployments → 点击最新部署的 `...` → **Redeploy** |
574 | | ④ | 使用诊断端点 | 访问 `/api/debug` 查看环境变量状态 |
575 |
576 | 3. **诊断端点** - 检查配置是否生效:
577 |
578 | 访问 `https://your-domain.vercel.app/api/debug`,您会看到类似以下的返回:
579 | ```json
580 | {
581 | "environment": "Vercel Serverless",
582 | "node_version": "v18.x.x",
583 | "env_status": {
584 | "TMDB_API_KEY": "configured", // 应显示 "configured"
585 | "TMDB_PROXY_URL": "not_set",
586 | "ACCESS_PASSWORD": "1 password(s)",
587 | "REMOTE_DB_URL": "not_set",
588 | "CACHE_TYPE": "memory"
589 | },
590 | "cache_type": "memory",
591 | "timestamp": "2024-01-01T00:00:00.000Z"
592 | }
593 | ```
594 |
595 | 如果 `TMDB_API_KEY` 显示 `"missing"`,说明环境变量未正确配置或未重新部署。
596 |
597 | 4. **常见问题**:
598 | - ❌ **修改环境变量后没有重新部署** - 这是最常见的问题!
599 | - ❌ **环境变量只勾选了 Preview 没勾选 Production**
600 | - ❌ **使用了错误的变量名**(如 `tmdb_api_key` 而非 `TMDB_API_KEY`)
601 |
602 | 5. **Vercel 功能限制**:
603 | 由于 Vercel Serverless 无法写入文件系统,以下功能在 Vercel 上不可用:
604 | - ❌ 本地图片缓存(会自动禁用)
605 | - ❌ SQLite 缓存(使用内存缓存替代)
606 | - ❌ 本地 db.json(必须配置 `REMOTE_DB_URL`)
607 | - ❌ 多用户历史同步(需要持久化存储)
608 |
609 |
610 | ### 🖥️ Linux 服务器命令行部署 (PM2)
611 | 适合常规 VPS (Ubuntu/CentOS/Debian)。
612 |
613 | 1. **环境准备**
614 | ```bash
615 | # 安装 Node.js (v18+)
616 | curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
617 | sudo apt-get install -y nodejs
618 |
619 | # 安装 PM2 进程管理器
620 | npm install -g pm2
621 | ```
622 |
623 | 2. **获取代码与运行**
624 | ```bash
625 | git clone https://github.com/ednovas/dongguaTV.git
626 | cd dongguaTV
627 | npm install
628 |
629 | # 配置环境变量
630 | cp .env.example .env
631 | # 编辑 .env文件填入您的 TMDB_API_KEY
632 | nano .env
633 |
634 | # 使用 PM2 启动服务
635 | pm2 start server.js --name "donggua-tv"
636 |
637 | # 设置开机自启
638 | pm2 save && pm2 startup
639 | ```
640 |
641 | ### 🏰 宝塔面板 (aaPanel) 部署
642 | 可视化管理,不需要懂代码。
643 |
644 | 1. 在 **软件商店** 搜索并安装 **Node.js版本管理器** (建议选择 v18+)。
645 | 2. **⚠️ 重要:安装编译工具** (better-sqlite3 需要):
646 | * 登录 SSH 终端。
647 | * 执行安装命令:
648 | ```bash
649 | # CentOS/RedHat
650 | sudo yum groupinstall "Development Tools" -y
651 | sudo yum install python3 -y
652 |
653 | # Ubuntu/Debian
654 | sudo apt-get install build-essential python3 -y
655 | ```
656 | 3. 在 **网站** -> **Node项目** -> **添加Node项目**。
657 | * **项目目录**:选择上传代码的文件夹 (例如 `/www/wwwroot/dongguaTV`)。
658 | * **启动选项**:`server.js`。
659 | * **项目端口**:`3000`。
660 | 3. **配置环境变量**:
661 | * 在 **文件** 栏目进入项目目录。
662 | * 将 `.env.example` 重命名为 `.env`。
663 | * 编辑 `.env` 文件,配置以下内容:
664 | ```env
665 | # 必填:TMDb API Key
666 | TMDB_API_KEY=your_api_key_here
667 |
668 | # 可选:运行端口
669 | PORT=3000
670 |
671 | # 可选:缓存类型 ('json', 'sqlite', 'memory', 'none')
672 | CACHE_TYPE=json
673 |
674 | # 可选:大陆用户 TMDB 反代地址
675 | # 如果您的服务器在大陆,请参考"大陆用户配置"章节部署反代
676 | TMDB_PROXY_URL=https://tmdb-proxy.your-name.workers.dev
677 | ```
678 | * 保存后回到 **Node项目** 列表,点击 **重启** 服务。
679 | 4. 点击 **映射/绑定域名**,输入您的域名 (如 `movie.example.com`)。
680 | 5. 访问域名即可使用。
681 |
682 | ### 🤖 Android APP 构建 (GitHub Actions)
683 |
684 | 本项目配置了自动化构建流程,您可以轻松编译自己的 Android 客户端。
685 |
686 | 1. **Fork 本仓库** 到您的 GitHub 账号。
687 | 2. **提交 Tag**:
688 | 每当您推送一个以 `v` 开头的 Tag (例如 `v1.0.0`) 到仓库时,GitHub Actions 会自动触发构建。
689 | ```bash
690 | git tag v1.0.0
691 | git push origin v1.0.0
692 | ```
693 | 3. **下载 APK**:
694 | 构建完成后,在 GitHub 仓库的 **"Releases"** 页面即可下载生成的 `.apk` 安装包。
695 | *此 APK 包含完整的电视端 (Android TV) 适配、沉浸式状态栏支持及自动优化的应用图标。*
696 |
697 | #### 📱 构建特性
698 | - **自动图标优化**:GitHub Actions 会自动调整图标尺寸并添加安全边距,防止在圆形图标遮罩下被裁剪。
699 | - **沉浸式适配**:内置原生级状态栏适配逻辑,自动处理 Safe Area,确保刘海屏手机无遮挡。
700 |
701 | #### 📱 APK 默认配置
702 |
703 | | 配置项 | 值 |
704 | |--------|-----|
705 | | **App 名称** | E视界 |
706 | | **默认服务器** | `https://ednovas.video` |
707 | | **图标来源** | 自动从 `public/icon.png` 生成 |
708 |
709 | #### 🔧 自定义构建 (新功能)
710 | 无需修改代码,直接在 GitHub 网页上自定义并构建 App:
711 |
712 | 1. 进入仓库的 **Actions** 页面。
713 | 2. 在左侧选择 **"Android Build & Release"**。
714 | 3. 点击右侧的 **Run workflow** 按钮。
715 | 4. 输入配置信息:
716 | - **Server URL**: 您的服务器地址 (例如 `https://movie.example.com`)
717 | - **App Name**: App 名称 (例如 `我的私人影院`)
718 | - **Version Tag**: 版本号 (例如 `v1.0.0`)
719 | 5. 点击 **Run workflow** 开始构建。
720 |
721 | 等待构建完成后,在 Releases 页面即可下载您定制的 App。
722 |
723 | #### 🔧 代码修改方式 (高级)
724 |
725 | 如果您 Fork 了本项目并希望永久修改默认配置:
726 |
727 | 1. 编辑 `capacitor.config.json`,修改 `server.url` 为您的服务器地址:
728 | ```json
729 | {
730 | "appId": "com.ednovas.donguatv",
731 | "appName": "E视界",
732 | "webDir": "public",
733 | "server": {
734 | "url": "https://your-server.com",
735 | "cleartext": true
736 | }
737 | }
738 | ```
739 |
740 | 2. 提交更改并推送 Tag 触发自动构建:
741 | ```bash
742 | git add capacitor.config.json
743 | git commit -m "修改服务器地址"
744 | git tag v1.0.0
745 | git push origin main --tags
746 | ```
747 |
748 | 3. 或者本地手动构建:
749 | ```bash
750 | npm install
751 | npx cap sync android
752 | cd android && ./gradlew assembleRelease
753 | ```
754 | APK 位于 `android/app/build/outputs/apk/release/`
755 |
756 | ---
757 |
758 | ## 💾 数据维护与备份
759 |
760 | 本项目的核心数据存储在以下两个文件中,建议定期备份:
761 |
762 | 1. **`db.json`**:存储所有的采集源配置信息(重要)。
763 | 2. **`cache.db`** (SQLite模式):存储搜索结果和详情的数据库文件。
764 | 3. **`cache_search.json` / `cache_detail.json`** (JSON模式):存储缓存的 JSON 文件。
765 |
766 | ### 备份命令示例
767 | ```bash
768 | # 备份到当前用户的 backup 目录
769 | mkdir -p ~/backup
770 | cp /opt/dongguaTV/db.json ~/backup/
771 | # 如果使用 SQLite
772 | [ -f /opt/dongguaTV/cache.db ] && cp /opt/dongguaTV/cache.db ~/backup/
773 | # 如果使用 JSON
774 | [ -f /opt/dongguaTV/cache_search.json ] && cp /opt/dongguaTV/cache_search.json ~/backup/
775 | ```
776 |
777 | ### 清理缓存
778 | ```bash
779 | # SQLite 模式
780 | rm /opt/dongguaTV/cache.db
781 |
782 | # JSON 模式
783 | rm /opt/dongguaTV/cache_*.json
784 |
785 | # 重启服务生效
786 | pm2 restart donggua-tv
787 | ```
788 |
789 | ---
790 |
791 | ## 📝 贡献与致谢
792 |
793 | 本项目由 **kk爱吃王哥呆阿龟头** 设计编写, **ednovas** 优化了功能和部署流程。
794 | 数据由 **TMDb** 和各式 **Maccms** API 提供。
795 |
796 | ---
797 |
798 | ## ⚠️ 免责声明 (Disclaimer)
799 |
800 | 1. **仅供学习交流**:本项目仅作为 Node.js 和 Vue 3 的学习练手项目开源,旨在展示前后端交互、数据聚合与 UI 设计技术。
801 | 2. **API 说明**:本项目**不内置**任何有效的影视资源采集接口。README 或代码演示中可能出现的 API 地址仅为占位符或示例,不保证可用性。
802 | 3. **自行配置**:使用者需自行寻找合法的 Maccms V10/JSON 格式的采集接口,并遵守相关法律法规。
803 | 4. **内容无关**:开发者不存储、不发布、不参与任何视频内容的制作与传播,对用户配置的内容不承担任何责任。
804 |
805 | ---
806 |
807 | *Enjoy your movie night! 🍿*
808 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | // Vercel 环境会自动注入环境变量,无需加载 .env 文件
2 | if (!process.env.VERCEL) {
3 | require('dotenv').config();
4 | }
5 |
6 | const express = require('express');
7 | const axios = require('axios');
8 | const bodyParser = require('body-parser');
9 | const cors = require('cors');
10 | const fs = require('fs');
11 | const path = require('path');
12 | const crypto = require('crypto');
13 | const stream = require('stream');
14 | const { promisify } = require('util');
15 | const pipeline = promisify(stream.pipeline);
16 |
17 | const app = express();
18 | const PORT = process.env.PORT || 3000;
19 | const DATA_FILE = path.join(__dirname, 'db.json');
20 | const TEMPLATE_FILE = path.join(__dirname, 'db.template.json');
21 |
22 | // 图片缓存目录 (仅本地/Docker 环境)
23 | const IMAGE_CACHE_DIR = path.join(__dirname, 'public/cache/images');
24 | if (!process.env.VERCEL && !fs.existsSync(IMAGE_CACHE_DIR)) {
25 | fs.mkdirSync(IMAGE_CACHE_DIR, { recursive: true });
26 | }
27 |
28 | // 访问密码配置(支持多密码)
29 | // 格式:ACCESS_PASSWORD=password1 或 ACCESS_PASSWORD=password1,password2,password3
30 | const ACCESS_PASSWORD_RAW = process.env['ACCESS_PASSWORD'] || '';
31 | const ACCESS_PASSWORDS = ACCESS_PASSWORD_RAW ? ACCESS_PASSWORD_RAW.split(',').map(p => p.trim()).filter(p => p) : [];
32 |
33 | // 第一个密码的哈希(兼容旧逻辑)
34 | const PASSWORD_HASH = ACCESS_PASSWORDS.length > 0
35 | ? crypto.createHash('sha256').update(ACCESS_PASSWORDS[0]).digest('hex')
36 | : '';
37 |
38 | // 生成密码到哈希的映射(用于历史同步)
39 | const PASSWORD_HASH_MAP = {};
40 | ACCESS_PASSWORDS.forEach((pwd, index) => {
41 | const hash = crypto.createHash('sha256').update(pwd).digest('hex');
42 | PASSWORD_HASH_MAP[hash] = {
43 | index: index,
44 | // 第一个密码不启用同步(保持现有设计),其他密码启用同步
45 | syncEnabled: index > 0
46 | };
47 | });
48 |
49 | console.log(`[System] Password mode: ${ACCESS_PASSWORDS.length > 1 ? 'Multi-user' : 'Single'} (${ACCESS_PASSWORDS.length} passwords)`);
50 |
51 | // 远程配置URL
52 | const REMOTE_DB_URL = process.env['REMOTE_DB_URL'] || '';
53 |
54 | // CORS 代理 URL(用于中转无法直接访问的资源站 API)
55 | const CORS_PROXY_URL = process.env['CORS_PROXY_URL'] || '';
56 |
57 | // 环境变量加载状态日志(用于 Vercel 调试)
58 | console.log(`[System] Environment: ${process.env.VERCEL ? 'Vercel Serverless' : 'Local/VPS'}`);
59 | console.log(`[System] TMDB_API_KEY: ${process.env.TMDB_API_KEY ? '✓ Configured' : '✗ Missing'}`);
60 | console.log(`[System] TMDB_PROXY_URL: ${process.env['TMDB_PROXY_URL'] || '(not set)'}`);
61 | console.log(`[System] CORS_PROXY_URL: ${CORS_PROXY_URL || '(not set)'}`);
62 | console.log(`[System] REMOTE_DB_URL: ${REMOTE_DB_URL ? '✓ Configured' : '(not set)'}`);
63 |
64 |
65 |
66 | // 远程配置缓存
67 | let remoteDbCache = null;
68 | let remoteDbLastFetch = 0;
69 | const REMOTE_DB_CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
70 |
71 | // 记录需要使用代理的站点(自动学习,带过期时间)
72 | // 格式:{ siteKey: expireTimestamp }
73 | const proxyRequiredSites = new Map();
74 | const PROXY_MEMORY_TTL = 24 * 60 * 60 * 1000; // 24小时后重新尝试直连
75 | const SLOW_THRESHOLD_MS = 1500; // 直连延迟超过此值视为慢速,尝试代理
76 |
77 | /**
78 | * 检查站点是否需要使用代理(未过期)
79 | */
80 | function shouldUseProxy(siteKey) {
81 | if (!proxyRequiredSites.has(siteKey)) return false;
82 | const expireTime = proxyRequiredSites.get(siteKey);
83 | if (Date.now() > expireTime) {
84 | // 已过期,移除记录,下次会重新尝试直连
85 | proxyRequiredSites.delete(siteKey);
86 | console.log(`[Proxy Memory] ${siteKey} 代理记录已过期,将重新尝试直连`);
87 | return false;
88 | }
89 | return true;
90 | }
91 |
92 | /**
93 | * 标记站点需要使用代理
94 | */
95 | function markSiteNeedsProxy(siteKey, reason = '') {
96 | const expireTime = Date.now() + PROXY_MEMORY_TTL;
97 | proxyRequiredSites.set(siteKey, expireTime);
98 | const expireDate = new Date(expireTime).toLocaleString('zh-CN');
99 | console.log(`[Proxy Memory] ${siteKey} 已标记为需要代理${reason ? ` (${reason})` : ''},有效期至 ${expireDate}`);
100 | }
101 |
102 | /**
103 | * 带代理回退的请求函数
104 | * 先尝试直接请求,失败或太慢时通过 CORS 代理重试
105 | * @param {string} url - 请求 URL
106 | * @param {object} options - axios 配置
107 | * @param {string} siteKey - 站点标识(用于记忆)
108 | * @returns {Promise