├── 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 | 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 | 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 | 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 |
129 |
等待操作...
130 |
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 | 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 | [![Deploy with Vercel](https://vercel.com/button)](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} - { data, usedProxy, latency } 109 | */ 110 | async function fetchWithProxyFallback(url, options = {}, siteKey = '') { 111 | const timeout = options.timeout || 8000; 112 | 113 | // 如果该站点之前需要代理且未过期,直接使用代理 114 | if (CORS_PROXY_URL && siteKey && shouldUseProxy(siteKey)) { 115 | try { 116 | const startTime = Date.now(); 117 | const proxyUrl = `${CORS_PROXY_URL}/?url=${encodeURIComponent(url)}`; 118 | const response = await axios.get(proxyUrl, { ...options, timeout }); 119 | const latency = Date.now() - startTime; 120 | return { data: response.data, usedProxy: true, latency }; 121 | } catch (proxyError) { 122 | // 代理也失败,移除记忆,下次重新尝试直连 123 | proxyRequiredSites.delete(siteKey); 124 | console.log(`[Proxy Fallback] ${siteKey} 代理失败,已清除记录`); 125 | throw proxyError; 126 | } 127 | } 128 | 129 | // 尝试直接请求 130 | const startTime = Date.now(); 131 | try { 132 | const response = await axios.get(url, { ...options, timeout }); 133 | const directLatency = Date.now() - startTime; 134 | 135 | // 检查是否太慢,如果配置了代理,尝试代理看是否更快 136 | if (CORS_PROXY_URL && directLatency > SLOW_THRESHOLD_MS) { 137 | console.log(`[Proxy Fallback] ${siteKey || url} 直连较慢 (${directLatency}ms),尝试代理对比...`); 138 | 139 | try { 140 | const proxyStartTime = Date.now(); 141 | const proxyUrl = `${CORS_PROXY_URL}/?url=${encodeURIComponent(url)}`; 142 | const proxyResponse = await axios.get(proxyUrl, { ...options, timeout: timeout + 2000 }); 143 | const proxyLatency = Date.now() - proxyStartTime; 144 | 145 | // 如果代理更快(至少快 30%),使用代理结果并记住 146 | if (proxyLatency < directLatency * 0.7) { 147 | console.log(`[Proxy Fallback] ${siteKey || url} 代理更快 (${proxyLatency}ms vs ${directLatency}ms),使用代理`); 148 | if (siteKey) { 149 | markSiteNeedsProxy(siteKey, `代理更快: ${proxyLatency}ms vs 直连 ${directLatency}ms`); 150 | } 151 | return { data: proxyResponse.data, usedProxy: true, latency: proxyLatency }; 152 | } else { 153 | console.log(`[Proxy Fallback] ${siteKey || url} 直连仍更快 (${directLatency}ms vs ${proxyLatency}ms),继续使用直连`); 154 | } 155 | } catch (proxyError) { 156 | // 代理失败,继续使用直连结果 157 | console.log(`[Proxy Fallback] ${siteKey || url} 代理测试失败,继续使用直连`); 158 | } 159 | } 160 | 161 | return { data: response.data, usedProxy: false, latency: directLatency }; 162 | } catch (directError) { 163 | // 直接请求失败,如果配置了代理,尝试通过代理 164 | if (CORS_PROXY_URL) { 165 | try { 166 | console.log(`[Proxy Fallback] ${siteKey || url} 直连失败,尝试代理...`); 167 | const proxyStartTime = Date.now(); 168 | const proxyUrl = `${CORS_PROXY_URL}/?url=${encodeURIComponent(url)}`; 169 | const response = await axios.get(proxyUrl, { ...options, timeout: timeout + 2000 }); 170 | const proxyLatency = Date.now() - proxyStartTime; 171 | 172 | // 记住该站点需要代理(带过期时间) 173 | if (siteKey) { 174 | markSiteNeedsProxy(siteKey, '直连失败'); 175 | } 176 | 177 | return { data: response.data, usedProxy: true, latency: proxyLatency }; 178 | } catch (proxyError) { 179 | console.error(`[Proxy Fallback] ${siteKey || url} 代理请求也失败:`, proxyError.message); 180 | throw proxyError; 181 | } 182 | } 183 | throw directError; 184 | } 185 | } 186 | 187 | // 缓存配置 188 | const CACHE_TYPE = process.env.CACHE_TYPE || 'json'; // json, sqlite, memory, none 189 | const SEARCH_CACHE_JSON = path.join(__dirname, 'cache_search.json'); 190 | const DETAIL_CACHE_JSON = path.join(__dirname, 'cache_detail.json'); 191 | const CACHE_DB_FILE = path.join(__dirname, 'cache.db'); 192 | 193 | console.log(`[System] Cache Type: ${CACHE_TYPE}`); 194 | 195 | // 初始化数据库文件 (仅本地/Docker 环境) 196 | if (!process.env.VERCEL && !fs.existsSync(DATA_FILE)) { 197 | if (fs.existsSync(TEMPLATE_FILE)) { 198 | fs.copyFileSync(TEMPLATE_FILE, DATA_FILE); 199 | console.log('[Init] 已从模板创建 db.json'); 200 | } else { 201 | const initialData = { sites: [] }; 202 | fs.writeFileSync(DATA_FILE, JSON.stringify(initialData, null, 2)); 203 | console.log('[Init] 已创建默认 db.json'); 204 | } 205 | } 206 | 207 | // ========== 缓存抽象层 ========== 208 | class CacheManager { 209 | constructor(type) { 210 | this.type = type; 211 | this.searchCache = {}; 212 | this.detailCache = {}; 213 | this.db = null; 214 | this.init(); 215 | } 216 | 217 | init() { 218 | if (this.type === 'json') { 219 | if (fs.existsSync(SEARCH_CACHE_JSON)) { 220 | try { this.searchCache = JSON.parse(fs.readFileSync(SEARCH_CACHE_JSON)); } catch (e) { } 221 | } 222 | if (fs.existsSync(DETAIL_CACHE_JSON)) { 223 | try { this.detailCache = JSON.parse(fs.readFileSync(DETAIL_CACHE_JSON)); } catch (e) { } 224 | } 225 | } else if (this.type === 'sqlite') { 226 | try { 227 | const Database = require('better-sqlite3'); 228 | this.db = new Database(CACHE_DB_FILE); 229 | 230 | // 创建缓存表 231 | this.db.exec(` 232 | CREATE TABLE IF NOT EXISTS cache ( 233 | category TEXT NOT NULL, 234 | key TEXT NOT NULL, 235 | value TEXT NOT NULL, 236 | expire INTEGER NOT NULL, 237 | PRIMARY KEY (category, key) 238 | ) 239 | `); 240 | 241 | // 创建用户历史记录表(用于多用户同步) 242 | this.db.exec(` 243 | CREATE TABLE IF NOT EXISTS user_history ( 244 | user_token TEXT NOT NULL, 245 | item_id TEXT NOT NULL, 246 | item_data TEXT NOT NULL, 247 | updated_at INTEGER NOT NULL, 248 | PRIMARY KEY (user_token, item_id) 249 | ) 250 | `); 251 | 252 | // 创建索引加速过期查询 253 | this.db.exec(`CREATE INDEX IF NOT EXISTS idx_expire ON cache(expire)`); 254 | this.db.exec(`CREATE INDEX IF NOT EXISTS idx_history_user ON user_history(user_token)`); 255 | 256 | // 清理过期数据 257 | this.db.prepare('DELETE FROM cache WHERE expire < ?').run(Date.now()); 258 | 259 | console.log(`[SQLite Cache] Database initialized: ${CACHE_DB_FILE}`); 260 | } catch (e) { 261 | console.error('[SQLite Cache] Init failed, falling back to memory:', e.message); 262 | this.type = 'memory'; 263 | } 264 | } 265 | } 266 | 267 | get(category, key) { 268 | if (this.type === 'memory') { 269 | const data = category === 'search' ? this.searchCache[key] : this.detailCache[key]; 270 | if (data && data.expire > Date.now()) return data.value; 271 | return null; 272 | } else if (this.type === 'json') { 273 | const data = category === 'search' ? this.searchCache[key] : this.detailCache[key]; 274 | if (data && data.expire > Date.now()) return data.value; 275 | return null; 276 | } else if (this.type === 'sqlite' && this.db) { 277 | try { 278 | const row = this.db.prepare( 279 | 'SELECT value FROM cache WHERE category = ? AND key = ? AND expire > ?' 280 | ).get(category, key, Date.now()); 281 | return row ? JSON.parse(row.value) : null; 282 | } catch (e) { 283 | console.error('[SQLite Cache] Get error:', e.message); 284 | return null; 285 | } 286 | } 287 | return null; 288 | } 289 | 290 | set(category, key, value, ttlSeconds = 600) { 291 | const expire = Date.now() + ttlSeconds * 1000; 292 | 293 | if (this.type === 'memory') { 294 | const item = { value, expire }; 295 | if (category === 'search') this.searchCache[key] = item; 296 | else this.detailCache[key] = item; 297 | } else if (this.type === 'json') { 298 | const item = { value, expire }; 299 | if (category === 'search') this.searchCache[key] = item; 300 | else this.detailCache[key] = item; 301 | this.saveDisk(); 302 | } else if (this.type === 'sqlite' && this.db) { 303 | try { 304 | this.db.prepare(` 305 | INSERT OR REPLACE INTO cache (category, key, value, expire) 306 | VALUES (?, ?, ?, ?) 307 | `).run(category, key, JSON.stringify(value), expire); 308 | } catch (e) { 309 | console.error('[SQLite Cache] Set error:', e.message); 310 | } 311 | } 312 | } 313 | 314 | saveDisk() { 315 | if (this.type === 'json') { 316 | fs.writeFileSync(SEARCH_CACHE_JSON, JSON.stringify(this.searchCache)); 317 | fs.writeFileSync(DETAIL_CACHE_JSON, JSON.stringify(this.detailCache)); 318 | } 319 | } 320 | 321 | // 定期清理过期缓存 (SQLite) 322 | cleanup() { 323 | if (this.type === 'sqlite' && this.db) { 324 | try { 325 | const result = this.db.prepare('DELETE FROM cache WHERE expire < ?').run(Date.now()); 326 | if (result.changes > 0) { 327 | console.log(`[SQLite Cache] Cleaned ${result.changes} expired entries`); 328 | } 329 | } catch (e) { 330 | console.error('[SQLite Cache] Cleanup error:', e.message); 331 | } 332 | } 333 | } 334 | } 335 | 336 | const cacheManager = new CacheManager(CACHE_TYPE); 337 | 338 | // 定期清理过期缓存 (每小时执行一次) 339 | setInterval(() => { 340 | cacheManager.cleanup(); 341 | }, 60 * 60 * 1000); 342 | 343 | // ========== 中间件配置 ========== 344 | 345 | // 启用 Gzip/Brotli 压缩 346 | const compression = require('compression'); 347 | app.use(compression({ 348 | level: 6, // 压缩级别 1-9,6 是性能与压缩率的平衡点 349 | threshold: 1024, // 只压缩大于 1KB 的响应 350 | filter: (req, res) => { 351 | // 不压缩 SSE 事件流 352 | if (req.headers['accept'] === 'text/event-stream') { 353 | return false; 354 | } 355 | return compression.filter(req, res); 356 | } 357 | })); 358 | 359 | app.use(cors()); 360 | app.use(bodyParser.json()); 361 | 362 | // ========== API 速率限制 ========== 363 | const rateLimit = require('express-rate-limit'); 364 | 365 | // 通用 API 限流:每 IP 每分钟最多 600 次请求 366 | // 注意:页面加载时会发送大量图片和 API 请求,需要足够高的限制 367 | const apiLimiter = rateLimit({ 368 | windowMs: 60 * 1000, // 1 分钟窗口 369 | max: 600, // 每 IP 最多 600 次(约 10 次/秒) 370 | standardHeaders: true, // 返回 RateLimit-* 标准头 371 | legacyHeaders: false, // 禁用 X-RateLimit-* 旧头 372 | message: { error: '请求过于频繁,请稍后再试 (Rate limit exceeded)' }, 373 | skip: (req) => { 374 | // 跳过静态资源请求 375 | if (!req.path.startsWith('/api/')) return true; 376 | // 配置、认证、站点列表请求不限流(页面加载必需) 377 | if (req.path === '/api/config' || req.path.startsWith('/api/auth/') || req.path === '/api/sites') return true; 378 | // 图片代理请求不限流(前端有大量图片) 379 | if (req.path.startsWith('/api/tmdb-image/')) return true; 380 | // TMDB 代理请求不限流 381 | if (req.path === '/api/tmdb-proxy') return true; 382 | return false; 383 | } 384 | }); 385 | 386 | // 搜索 API 更严格的限流:每 IP 每分钟最多 120 次搜索 387 | const searchLimiter = rateLimit({ 388 | windowMs: 60 * 1000, 389 | max: 120, 390 | message: { error: '搜索请求过于频繁,请稍后再试' } 391 | }); 392 | 393 | // 应用通用限流 394 | app.use(apiLimiter); 395 | 396 | // 对搜索 API 应用更严格的限流 397 | app.use('/api/search', searchLimiter); 398 | 399 | // ========== 静态资源配置 ========== 400 | 401 | // 静态资源 30天缓存 (libs 目录 - CSS/JS) - 这些文件不会变化 402 | app.use('/libs', express.static('public/libs', { 403 | maxAge: '30d', 404 | immutable: true, 405 | etag: true, 406 | lastModified: true 407 | })); 408 | 409 | // 图片缓存目录 - 30天缓存 410 | app.use('/cache', express.static('public/cache', { 411 | maxAge: '30d', 412 | immutable: true, 413 | etag: true 414 | })); 415 | 416 | // ⚠️ 关键:HTML 和 Service Worker 不缓存,确保用户获取最新版本 417 | app.get(['/', '/index.html', '/sw.js'], (req, res, next) => { 418 | res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); 419 | res.set('Pragma', 'no-cache'); 420 | res.set('Expires', '0'); 421 | next(); 422 | }); 423 | 424 | // 其他静态文件 - 1小时缓存 425 | app.use(express.static('public', { 426 | maxAge: '1h', 427 | etag: true, 428 | lastModified: true 429 | })); 430 | 431 | // ========== 路由定义 ========== 432 | 433 | const IS_VERCEL = !!process.env.VERCEL; 434 | 435 | app.get('/api/config', (req, res) => { 436 | // 检查请求中的 token 是否支持同步 437 | const userToken = req.query.token || ''; 438 | const userInfo = PASSWORD_HASH_MAP[userToken]; 439 | const syncEnabled = userInfo ? userInfo.syncEnabled : false; 440 | 441 | res.json({ 442 | tmdb_api_key: process.env.TMDB_API_KEY, 443 | tmdb_proxy_url: process.env['TMDB_PROXY_URL'], 444 | // CORS 代理 URL(用于中转无法直接访问的资源站 API) 445 | cors_proxy_url: CORS_PROXY_URL || null, 446 | // Vercel 环境下禁用本地图片缓存,防止写入报错 447 | enable_local_image_cache: !IS_VERCEL, 448 | // 多用户同步功能 449 | sync_enabled: syncEnabled, 450 | multi_user_mode: ACCESS_PASSWORDS.length > 1 451 | }); 452 | }); 453 | 454 | // 诊断端点:检查环境变量配置状态(用于 Vercel 调试) 455 | app.get('/api/debug', (req, res) => { 456 | res.json({ 457 | environment: IS_VERCEL ? 'Vercel Serverless' : 'Local/VPS', 458 | node_version: process.version, 459 | env_status: { 460 | TMDB_API_KEY: process.env.TMDB_API_KEY ? 'configured' : 'missing', 461 | TMDB_PROXY_URL: process.env['TMDB_PROXY_URL'] ? 'configured' : 'not_set', 462 | ACCESS_PASSWORD: ACCESS_PASSWORDS.length > 0 ? `${ACCESS_PASSWORDS.length} password(s)` : 'not_set', 463 | REMOTE_DB_URL: REMOTE_DB_URL ? 'configured' : 'not_set', 464 | CACHE_TYPE: process.env.CACHE_TYPE || 'json (default)' 465 | }, 466 | cache_type: cacheManager.type, 467 | timestamp: new Date().toISOString() 468 | }); 469 | }); 470 | 471 | // ========== 历史记录同步 API ========== 472 | 473 | // 获取服务器上的历史记录 474 | app.get('/api/history/pull', (req, res) => { 475 | const userToken = req.query.token; 476 | 477 | if (!userToken) { 478 | return res.status(400).json({ error: 'Missing token' }); 479 | } 480 | 481 | // 验证 token 是否有效且启用同步 482 | const userInfo = PASSWORD_HASH_MAP[userToken]; 483 | if (!userInfo) { 484 | return res.status(401).json({ error: 'Invalid token' }); 485 | } 486 | if (!userInfo.syncEnabled) { 487 | return res.json({ sync_enabled: false, history: [] }); 488 | } 489 | 490 | // 从 SQLite 获取历史记录 491 | if (cacheManager.type !== 'sqlite' || !cacheManager.db) { 492 | return res.json({ sync_enabled: true, history: [], message: 'SQLite not available' }); 493 | } 494 | 495 | try { 496 | const stmt = cacheManager.db.prepare('SELECT item_id, item_data, updated_at FROM user_history WHERE user_token = ?'); 497 | const rows = stmt.all(userToken); 498 | 499 | const history = rows.map(row => ({ 500 | id: row.item_id, 501 | data: JSON.parse(row.item_data), 502 | updated_at: row.updated_at 503 | })); 504 | 505 | res.json({ sync_enabled: true, history: history }); 506 | } catch (e) { 507 | console.error('[History Pull Error]', e.message); 508 | res.status(500).json({ error: 'Database error' }); 509 | } 510 | }); 511 | 512 | // 推送历史记录到服务器 513 | app.post('/api/history/push', (req, res) => { 514 | const { token, history } = req.body; 515 | 516 | if (!token || !Array.isArray(history)) { 517 | return res.status(400).json({ error: 'Missing token or history' }); 518 | } 519 | 520 | // 验证 token 521 | const userInfo = PASSWORD_HASH_MAP[token]; 522 | if (!userInfo) { 523 | return res.status(401).json({ error: 'Invalid token' }); 524 | } 525 | if (!userInfo.syncEnabled) { 526 | return res.json({ sync_enabled: false, saved: 0 }); 527 | } 528 | 529 | // 保存到 SQLite 530 | if (cacheManager.type !== 'sqlite' || !cacheManager.db) { 531 | return res.json({ sync_enabled: true, saved: 0, message: 'SQLite not available' }); 532 | } 533 | 534 | try { 535 | const insertStmt = cacheManager.db.prepare(` 536 | INSERT OR REPLACE INTO user_history (user_token, item_id, item_data, updated_at) 537 | VALUES (?, ?, ?, ?) 538 | `); 539 | 540 | let saved = 0; 541 | const transaction = cacheManager.db.transaction((items) => { 542 | for (const item of items) { 543 | if (item.id && item.data) { 544 | insertStmt.run( 545 | token, 546 | item.id, 547 | JSON.stringify(item.data), 548 | item.updated_at || Date.now() 549 | ); 550 | saved++; 551 | } 552 | } 553 | }); 554 | 555 | transaction(history); 556 | 557 | res.json({ sync_enabled: true, saved: saved }); 558 | } catch (e) { 559 | console.error('[History Push Error]', e.message); 560 | res.status(500).json({ error: 'Database error' }); 561 | } 562 | }); 563 | 564 | // 清除用户历史记录 (服务器端) 565 | app.post('/api/history/clear', (req, res) => { 566 | const { token } = req.body; 567 | 568 | if (!token) { 569 | return res.status(400).json({ error: 'Missing token' }); 570 | } 571 | 572 | // 验证 token 573 | const userInfo = PASSWORD_HASH_MAP[token]; 574 | if (!userInfo) { 575 | return res.status(401).json({ error: 'Invalid token' }); 576 | } 577 | 578 | // 从 SQLite 删除该用户的所有历史 579 | if (cacheManager.type !== 'sqlite' || !cacheManager.db) { 580 | return res.json({ success: true, message: 'SQLite not available' }); 581 | } 582 | 583 | try { 584 | const deleteStmt = cacheManager.db.prepare(` 585 | DELETE FROM user_history WHERE user_token = ? 586 | `); 587 | const result = deleteStmt.run(token); 588 | console.log(`[History Clear] 用户 ${token.substring(0, 8)}... 删除了 ${result.changes} 条记录`); 589 | res.json({ success: true, deleted: result.changes }); 590 | } catch (e) { 591 | console.error('[History Clear Error]', e.message); 592 | res.status(500).json({ error: 'Database error' }); 593 | } 594 | }); 595 | 596 | // TMDB 通用代理与缓存 API 597 | const TMDB_CACHE_TTL = 3600 * 10; // 缓存 10 小时 598 | app.get('/api/tmdb-proxy', async (req, res) => { 599 | const { path: tmdbPath, ...params } = req.query; 600 | 601 | if (!tmdbPath) return res.status(400).json({ error: 'Missing path' }); 602 | 603 | const TMDB_API_KEY = process.env.TMDB_API_KEY; 604 | if (!TMDB_API_KEY) return res.status(500).json({ error: 'API Key not configured' }); 605 | 606 | // 构建唯一的缓存 Key (排序参数以确保 Key 稳定) 607 | const sortedParams = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('&'); 608 | const cacheKey = `tmdb_proxy_${tmdbPath}_${sortedParams}`; 609 | 610 | const cached = cacheManager.get('detail', cacheKey); 611 | if (cached) { 612 | // console.log(`[TMDB Proxy] Cache Hit: ${cacheKey}`); 613 | return res.json(cached); 614 | } 615 | 616 | try { 617 | const TMDB_BASE = 'https://api.themoviedb.org/3'; 618 | const response = await axios.get(`${TMDB_BASE}${tmdbPath}`, { 619 | params: { 620 | ...params, 621 | api_key: TMDB_API_KEY, 622 | language: 'zh-CN' 623 | }, 624 | timeout: 10000 625 | }); 626 | 627 | // 缓存结果 628 | cacheManager.set('detail', cacheKey, response.data, TMDB_CACHE_TTL); 629 | res.json(response.data); 630 | } catch (error) { 631 | console.error(`[TMDB Proxy Error] ${tmdbPath}:`, error.message); 632 | res.status(error.response?.status || 500).json({ error: 'Proxy request failed' }); 633 | } 634 | }); 635 | 636 | // 1. 获取站点列表 637 | app.get('/api/sites', async (req, res) => { 638 | let sitesData = null; 639 | 640 | // 尝试从远程加载 641 | if (REMOTE_DB_URL) { 642 | const now = Date.now(); 643 | if (remoteDbCache && now - remoteDbLastFetch < REMOTE_DB_CACHE_TTL) { 644 | sitesData = remoteDbCache; 645 | } else { 646 | try { 647 | const response = await axios.get(REMOTE_DB_URL, { timeout: 5000 }); 648 | if (response.data && Array.isArray(response.data.sites)) { 649 | sitesData = response.data; 650 | remoteDbCache = sitesData; 651 | remoteDbLastFetch = now; 652 | console.log('[Remote] Config loaded successfully'); 653 | } 654 | } catch (err) { 655 | console.error('[Remote] Failed to load config:', err.message); 656 | } 657 | } 658 | } 659 | 660 | // 回退到本地 661 | if (!sitesData) { 662 | sitesData = JSON.parse(fs.readFileSync(DATA_FILE)); 663 | } 664 | 665 | res.json(sitesData); 666 | }); 667 | 668 | // 2. 搜索 API - SSE 流式版本 (GET, 用于实时搜索) 669 | app.get('/api/search', async (req, res) => { 670 | const keyword = req.query.wd; 671 | const stream = req.query.stream === 'true'; 672 | 673 | if (!keyword) { 674 | return res.status(400).json({ error: 'Missing keyword' }); 675 | } 676 | 677 | const sites = getDB().sites; 678 | 679 | if (!stream) { 680 | // 非流式模式:返回普通 JSON 681 | return res.json({ error: 'Use stream=true for GET requests' }); 682 | } 683 | 684 | // SSE 流式模式 685 | res.setHeader('Content-Type', 'text/event-stream'); 686 | res.setHeader('Cache-Control', 'no-cache'); 687 | res.setHeader('Connection', 'keep-alive'); 688 | res.setHeader('X-Accel-Buffering', 'no'); // 禁用 Nginx 缓冲 689 | 690 | // 并行搜索所有站点 691 | const searchPromises = sites.map(async (site) => { 692 | const cacheKey = `${site.key}_${keyword}`; 693 | const cached = cacheManager.get('search', cacheKey); 694 | 695 | if (cached && cached.list) { 696 | // 命中缓存,立即发送 697 | const items = cached.list.map(item => ({ 698 | ...item, 699 | site_key: site.key, 700 | site_name: site.name 701 | })); 702 | if (items.length > 0) { 703 | res.write(`data: ${JSON.stringify(items)}\n\n`); 704 | } 705 | return items; 706 | } 707 | 708 | try { 709 | console.log(`[SSE Search] ${site.name} -> ${keyword}`); 710 | 711 | // 构建请求 URL(带参数) 712 | const searchUrl = `${site.api}?ac=detail&wd=${encodeURIComponent(keyword)}`; 713 | 714 | // 使用带代理回退的请求 715 | const { data, usedProxy } = await fetchWithProxyFallback(searchUrl, { timeout: 8000 }, site.key); 716 | 717 | if (usedProxy) { 718 | console.log(`[SSE Search] ${site.name} 通过代理获取结果`); 719 | } 720 | 721 | const list = data.list ? data.list.map(item => ({ 722 | vod_id: item.vod_id, 723 | vod_name: item.vod_name, 724 | vod_pic: item.vod_pic, 725 | vod_remarks: item.vod_remarks, 726 | vod_year: item.vod_year, 727 | type_name: item.type_name, 728 | vod_content: item.vod_content, 729 | vod_play_from: item.vod_play_from, 730 | vod_play_url: item.vod_play_url, 731 | site_key: site.key, 732 | site_name: site.name 733 | })) : []; 734 | 735 | // 缓存结果 (1小时) 736 | cacheManager.set('search', cacheKey, { list }, 3600); 737 | 738 | // 发送结果到客户端 739 | if (list.length > 0) { 740 | res.write(`data: ${JSON.stringify(list)}\n\n`); 741 | } 742 | return list; 743 | } catch (error) { 744 | console.error(`[SSE Search Error] ${site.name}:`, error.message); 745 | return []; 746 | } 747 | }); 748 | 749 | // 等待所有搜索完成 750 | await Promise.all(searchPromises); 751 | 752 | // 发送完成事件 753 | res.write('event: done\ndata: {}\n\n'); 754 | res.end(); 755 | }); 756 | 757 | // 2b. 搜索 API - POST 版本 (用于单站点搜索) 758 | app.post('/api/search', async (req, res) => { 759 | const { keyword, siteKey } = req.body; 760 | const sites = getDB().sites; 761 | const site = sites.find(s => s.key === siteKey); 762 | 763 | if (!site) return res.status(404).json({ error: 'Site not found' }); 764 | 765 | const cacheKey = `${siteKey}_${keyword}`; 766 | const cached = cacheManager.get('search', cacheKey); 767 | if (cached) { 768 | console.log(`[Cache] Hit search: ${cacheKey}`); 769 | return res.json(cached); 770 | } 771 | 772 | try { 773 | console.log(`[Search] ${site.name} -> ${keyword}`); 774 | 775 | // 构建请求 URL 776 | const searchUrl = `${site.api}?ac=detail&wd=${encodeURIComponent(keyword)}`; 777 | const { data } = await fetchWithProxyFallback(searchUrl, { timeout: 8000 }, site.key); 778 | 779 | // 简单的数据清洗 780 | const result = { 781 | list: data.list ? data.list.map(item => ({ 782 | vod_id: item.vod_id, 783 | vod_name: item.vod_name, 784 | vod_pic: item.vod_pic, 785 | vod_remarks: item.vod_remarks, 786 | vod_year: item.vod_year, 787 | type_name: item.type_name 788 | })) : [] 789 | }; 790 | 791 | cacheManager.set('search', cacheKey, result, 3600); // 缓存1小时 792 | res.json(result); 793 | } catch (error) { 794 | console.error(`[Search Error] ${site.name}:`, error.message); 795 | res.status(500).json({ error: 'Search failed' }); 796 | } 797 | }); 798 | 799 | // 3. 详情 API (带缓存) - GET 版本 800 | app.get('/api/detail', async (req, res) => { 801 | const id = req.query.id; 802 | const siteKey = req.query.site_key; 803 | const sites = getDB().sites; 804 | const site = sites.find(s => s.key === siteKey); 805 | 806 | if (!site) return res.status(404).json({ error: 'Site not found' }); 807 | 808 | const cacheKey = `${siteKey}_detail_${id}`; 809 | const cached = cacheManager.get('detail', cacheKey); 810 | if (cached) { 811 | console.log(`[Cache] Hit detail: ${cacheKey}`); 812 | // 返回格式:{ list: [detail] },与前端期望一致 813 | return res.json({ list: [cached] }); 814 | } 815 | 816 | try { 817 | console.log(`[Detail] ${site.name} -> ID: ${id}`); 818 | 819 | // 构建请求 URL 820 | const detailUrl = `${site.api}?ac=detail&ids=${encodeURIComponent(id)}`; 821 | const { data } = await fetchWithProxyFallback(detailUrl, { timeout: 8000 }, site.key); 822 | 823 | if (data.list && data.list.length > 0) { 824 | const detail = data.list[0]; 825 | cacheManager.set('detail', cacheKey, detail, 3600); // 缓存1小时 826 | // 返回格式:{ list: [detail] },与前端期望一致 827 | res.json({ list: [detail] }); 828 | } else { 829 | res.status(404).json({ error: 'Not found', list: [] }); 830 | } 831 | } catch (error) { 832 | console.error(`[Detail Error] ${site.name}:`, error.message); 833 | res.status(500).json({ error: 'Detail fetch failed', list: [] }); 834 | } 835 | }); 836 | 837 | // 3b. 详情 API (带缓存) - POST 版本 838 | app.post('/api/detail', async (req, res) => { 839 | const { id, siteKey } = req.body; 840 | const sites = getDB().sites; 841 | const site = sites.find(s => s.key === siteKey); 842 | 843 | if (!site) return res.status(404).json({ error: 'Site not found' }); 844 | 845 | const cacheKey = `${siteKey}_detail_${id}`; 846 | const cached = cacheManager.get('detail', cacheKey); 847 | if (cached) { 848 | console.log(`[Cache] Hit detail: ${cacheKey}`); 849 | return res.json(cached); 850 | } 851 | 852 | try { 853 | console.log(`[Detail] ${site.name} -> ID: ${id}`); 854 | 855 | // 构建请求 URL 856 | const detailUrl = `${site.api}?ac=detail&ids=${encodeURIComponent(id)}`; 857 | const { data } = await fetchWithProxyFallback(detailUrl, { timeout: 8000 }, siteKey); 858 | 859 | if (data.list && data.list.length > 0) { 860 | const detail = data.list[0]; 861 | cacheManager.set('detail', cacheKey, detail, 3600); // 缓存1小时 862 | res.json(detail); 863 | } else { 864 | res.status(404).json({ error: 'Not found' }); 865 | } 866 | } catch (error) { 867 | console.error(`[Detail Error] ${site.name}:`, error.message); 868 | res.status(500).json({ error: 'Detail fetch failed' }); 869 | } 870 | }); 871 | 872 | // 4. 图片代理与缓存 API (Server-Side Image Caching) 873 | app.get('/api/tmdb-image/:size/:filename', async (req, res) => { 874 | const { size, filename } = req.params; 875 | const allowSizes = ['w300', 'w342', 'w500', 'w780', 'w1280', 'original']; 876 | 877 | // 安全检查 878 | if (!allowSizes.includes(size) || !/^[a-zA-Z0-9_\-\.]+$/.test(filename)) { 879 | return res.status(400).send('Invalid parameters'); 880 | } 881 | 882 | const tmdbUrl = `https://image.tmdb.org/t/p/${size}/${filename}`; 883 | 884 | // Vercel环境或Serverless环境:不可写文件系统,直接转发流 885 | if (process.env.VERCEL) { 886 | try { 887 | // 支持自定义反代 URL 888 | let targetUrl = tmdbUrl; 889 | if (process.env['TMDB_PROXY_URL']) { 890 | const proxyBase = process.env['TMDB_PROXY_URL'].replace(/\/$/, ''); 891 | targetUrl = `${proxyBase}/t/p/${size}/${filename}`; 892 | } 893 | 894 | console.log(`[Vercel Image] Proxying: ${targetUrl}`); 895 | const response = await axios({ 896 | url: targetUrl, 897 | method: 'GET', 898 | responseType: 'stream', 899 | timeout: 10000 900 | }); 901 | // 缓存控制:公共缓存,有效期1天 902 | res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=86400'); 903 | response.data.pipe(res); 904 | } catch (error) { 905 | console.error(`[Vercel Image Error] ${tmdbUrl}:`, error.message); 906 | res.status(404).send('Image not found'); 907 | } 908 | return; 909 | } 910 | 911 | // --- 本地/VPS 环境下启用磁盘缓存 --- 912 | const localPath = path.join(IMAGE_CACHE_DIR, size, filename); 913 | const localDir = path.dirname(localPath); 914 | 915 | // 1. 如果本地存在且文件大小 > 0,更新访问时间并返回 916 | if (fs.existsSync(localPath) && fs.statSync(localPath).size > 0) { 917 | // 更新文件的访问时间 (atime) 和修改时间 (mtime),用于 LRU 清理 918 | try { 919 | const now = new Date(); 920 | fs.utimesSync(localPath, now, now); 921 | } catch (e) { } // 忽略权限错误 922 | return res.sendFile(localPath); 923 | } 924 | 925 | // 2. 下载并缓存 926 | if (!fs.existsSync(localDir)) { 927 | try { 928 | fs.mkdirSync(localDir, { recursive: true }); 929 | } catch (e) { 930 | console.error('[Cache Mkdir Error]', e.message); 931 | // 如果创建目录失败,降级为直接流式转发 932 | try { 933 | const response = await axios({ url: tmdbUrl, method: 'GET', responseType: 'stream' }); 934 | return response.data.pipe(res); 935 | } catch (err) { return res.status(404).send('Image not found'); } 936 | } 937 | } 938 | 939 | try { 940 | console.log(`[Image Proxy] Fetching: ${tmdbUrl}`); 941 | const response = await axios({ 942 | url: tmdbUrl, 943 | method: 'GET', 944 | responseType: 'stream', 945 | timeout: 10000 946 | }); 947 | 948 | const writer = fs.createWriteStream(localPath); 949 | 950 | // 使用 pipeline 处理流 951 | await pipeline(response.data, writer); 952 | 953 | // 下载完成后,检查缓存总大小并清理 954 | cleanCacheIfNeeded(); 955 | 956 | // 发送文件 957 | res.sendFile(localPath); 958 | } catch (error) { 959 | console.error(`[Image Proxy Error] ${tmdbUrl}:`, error.message); 960 | if (fs.existsSync(localPath)) { 961 | try { fs.unlinkSync(localPath); } catch (e) { } 962 | } 963 | res.status(404).send('Image not found'); 964 | } 965 | }); 966 | 967 | // ========== 缓存清理逻辑 ========== 968 | const MAX_CACHE_SIZE_MB = 1024; // 1GB 缓存上限 969 | const CLEAN_TRIGGER_THRESHOLD = 50; // 每添加50张新图检查一次 (减少IO压力) 970 | let newItemCount = 0; 971 | 972 | function cleanCacheIfNeeded() { 973 | newItemCount++; 974 | if (newItemCount < CLEAN_TRIGGER_THRESHOLD) return; 975 | newItemCount = 0; 976 | 977 | // 异步执行清理,不阻塞主线程 978 | setTimeout(() => { 979 | try { 980 | let totalSize = 0; 981 | let files = []; 982 | 983 | // 递归遍历缓存目录 984 | function traverseDir(dir) { 985 | if (!fs.existsSync(dir)) return; 986 | const items = fs.readdirSync(dir); 987 | items.forEach(item => { 988 | const fullPath = path.join(dir, item); 989 | const stats = fs.statSync(fullPath); 990 | if (stats.isDirectory()) { 991 | traverseDir(fullPath); 992 | } else { 993 | totalSize += stats.size; 994 | files.push({ path: fullPath, size: stats.size, time: stats.mtime.getTime() }); 995 | } 996 | }); 997 | } 998 | 999 | traverseDir(IMAGE_CACHE_DIR); 1000 | 1001 | const maxBytes = MAX_CACHE_SIZE_MB * 1024 * 1024; 1002 | console.log(`[Cache Trim] Current size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`); 1003 | 1004 | if (totalSize > maxBytes) { 1005 | // 按时间排序,最旧的在前 1006 | files.sort((a, b) => a.time - b.time); 1007 | 1008 | let deletedSize = 0; 1009 | let targetDelete = totalSize - (maxBytes * 0.9); // 清理到 90% 1010 | 1011 | for (const file of files) { 1012 | if (deletedSize >= targetDelete) break; 1013 | try { 1014 | fs.unlinkSync(file.path); 1015 | deletedSize += file.size; 1016 | } catch (e) { console.error('Delete failed:', e); } 1017 | } 1018 | console.log(`[Cache Trim] Cleaned ${(deletedSize / 1024 / 1024).toFixed(2)} MB`); 1019 | } 1020 | } catch (err) { 1021 | console.error('[Cache Trim Error]', err); 1022 | } 1023 | }, 100); 1024 | } 1025 | 1026 | // 5. 认证检查 API 1027 | app.get('/api/auth/check', (req, res) => { 1028 | // 检查是否需要密码 1029 | res.json({ 1030 | requirePassword: ACCESS_PASSWORDS.length > 0, 1031 | multiUserMode: ACCESS_PASSWORDS.length > 1 1032 | }); 1033 | }); 1034 | 1035 | // 6. 验证密码 API(支持多密码) 1036 | app.post('/api/auth/verify', (req, res) => { 1037 | const { password, passwordHash } = req.body; 1038 | 1039 | // 无密码保护时直接通过 1040 | if (ACCESS_PASSWORDS.length === 0) { 1041 | return res.json({ success: true, syncEnabled: false }); 1042 | } 1043 | 1044 | // 计算输入的哈希值 1045 | let inputHash; 1046 | if (passwordHash) { 1047 | inputHash = passwordHash; 1048 | } else if (password) { 1049 | inputHash = crypto.createHash('sha256').update(password).digest('hex'); 1050 | } else { 1051 | return res.json({ success: false }); 1052 | } 1053 | 1054 | // 检查是否匹配任一密码 1055 | const userInfo = PASSWORD_HASH_MAP[inputHash]; 1056 | if (userInfo !== undefined) { 1057 | // 密码有效 1058 | res.json({ 1059 | success: true, 1060 | passwordHash: inputHash, 1061 | // 同步功能状态 1062 | syncEnabled: userInfo.syncEnabled, 1063 | userIndex: userInfo.index 1064 | }); 1065 | } else { 1066 | res.json({ success: false }); 1067 | } 1068 | }); 1069 | 1070 | // Helper: Get DB data (Local or Remote) 1071 | function getDB() { 1072 | if (remoteDbCache) return remoteDbCache; 1073 | return JSON.parse(fs.readFileSync(DATA_FILE)); 1074 | } 1075 | 1076 | // 本地/Docker 环境:启动服务器监听 1077 | // Vercel 环境下不需要调用 listen(),它会自动处理 1078 | if (!process.env.VERCEL) { 1079 | app.listen(PORT, () => { 1080 | console.log(`Server running on http://localhost:${PORT}`); 1081 | console.log(`Image Cache Directory: ${IMAGE_CACHE_DIR}`); 1082 | }); 1083 | } 1084 | 1085 | // 始终导出 app 模块 (Vercel Serverless 需要) 1086 | module.exports = app; 1087 | --------------------------------------------------------------------------------