Merge pull request #96 from zimplexing/v1.2.9

fix: UI issue
This commit is contained in:
Xin
2025-07-26 14:59:33 +08:00
committed by GitHub
38 changed files with 19 additions and 3580 deletions

View File

@@ -1,5 +0,0 @@
# The port the backend server will run on
PORT=3001
# Optional: The password for the login endpoint. If not provided, login is disabled.
PASSWORD=

View File

@@ -1,5 +0,0 @@
# The port the backend server will run on
PORT=3001
# Optional: The password for the login endpoint. If not provided, login is disabled.
PASSWORD=

View File

@@ -1,38 +0,0 @@
# --- Build Stage ---
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package.json and yarn.lock first to leverage Docker cache
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Copy the rest of the source code
COPY . .
# Compile TypeScript to JavaScript
RUN yarn build
# Prune development dependencies
RUN yarn install --production --ignore-scripts --prefer-offline
# --- Production Stage ---
FROM node:18-alpine
WORKDIR /app
# Copy production dependencies and compiled code from the builder stage
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Copy config.json from the project root relative to the Docker build context
# IMPORTANT: When building, run `docker build -f backend/Dockerfile .` from the project root.
COPY src/config/config.json dist/config/
# Expose the port the app runs on
EXPOSE 3001
# The command to run the application
# You can override the port using -e PORT=... in `docker run`
CMD [ "node", "dist/index.docker.js" ]

View File

@@ -1,26 +0,0 @@
{
"name": "OrionTV-proxy",
"version": "1.0.1",
"description": "Backend service for MyTV application",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"dev": "ts-node-dev --respawn --transpile-only src/index.docker.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.14.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5"
}
}

View File

@@ -1,81 +0,0 @@
{
"cache_time": 7200,
"api_site": {
"dyttzy": {
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
"name": "电影天堂资源"
},
"ruyi": {
"api": "https://cj.rycjapi.com/api.php/provide/vod",
"name": "如意资源"
},
"mozhua": {
"api": "https://mozhuazy.com/api.php/provide/vod",
"name": "魔爪资源"
},
"heimuer": {
"api": "https://json.heimuer.xyz/api.php/provide/vod",
"name": "黑木耳"
},
"bfzy": {
"api": "https://bfzyapi.com/api.php/provide/vod",
"name": "暴风资源"
},
"tyyszy": {
"api": "https://tyyszy.com/api.php/provide/vod",
"name": "天涯资源"
},
"ffzy": {
"api": "http://ffzy5.tv/api.php/provide/vod",
"name": "非凡影视"
},
"zy360": {
"api": "https://360zy.com/api.php/provide/vod",
"name": "360资源"
},
"iqiyi": {
"api": "https://www.iqiyizyapi.com/api.php/provide/vod",
"name": "iqiyi资源"
},
"wolong": {
"api": "https://wolongzyw.com/api.php/provide/vod",
"name": "卧龙资源"
},
"hwba": {
"api": "https://cjhwba.com/api.php/provide/vod",
"name": "华为吧资源"
},
"jisu": {
"api": "https://jszyapi.com/api.php/provide/vod",
"name": "极速资源"
},
"dbzy": {
"api": "https://dbzy.tv/api.php/provide/vod",
"name": "豆瓣资源"
},
"mdzy": {
"api": "https://www.mdzyapi.com/api.php/provide/vod",
"name": "魔都资源"
},
"zuid": {
"api": "https://api.zuidapi.com/api.php/provide/vod",
"name": "最大资源"
},
"yinghua": {
"api": "https://m3u8.apiyhzy.com/api.php/provide/vod",
"name": "樱花资源"
},
"wujin": {
"api": "https://api.wujinapi.me/api.php/provide/vod",
"name": "无尽资源"
},
"wwzy": {
"api": "https://wwzy.tv/api.php/provide/vod",
"name": "旺旺短剧"
},
"ikun": {
"api": "https://ikunzyapi.com/api.php/provide/vod",
"name": "iKun资源"
}
}
}

View File

@@ -1,80 +0,0 @@
import fs from "fs";
import path from "path";
export interface ApiSite {
key: string;
api: string;
name: string;
detail?: string;
}
export interface StorageConfig {
type: "localstorage" | "database";
database?: {
host?: string;
port?: number;
username?: string;
password?: string;
database?: string;
};
}
export interface Config {
cache_time?: number;
api_site: {
[key: string]: ApiSite;
};
storage?: StorageConfig;
}
export const API_CONFIG = {
search: {
path: "?ac=videolist&wd=",
pagePath: "?ac=videolist&wd={query}&pg={page}",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
Accept: "application/json",
},
},
detail: {
path: "?ac=videolist&ids=",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
Accept: "application/json",
},
},
};
// Adjust path to read from project root, not from `backend/`
const configPath = path.join(__dirname, "config.json");
let cachedConfig: Config;
try {
cachedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as Config;
} catch (error) {
console.info(`Error reading or parsing config.json at ${configPath}`, error);
// Provide a default fallback config to prevent crashes
cachedConfig = {
api_site: {},
cache_time: 300,
};
}
export function getConfig(): Config {
return cachedConfig;
}
export function getCacheTime(): number {
const config = getConfig();
return config.cache_time || 300; // 默认5分钟缓存
}
export function getApiSites(): ApiSite[] {
const config = getConfig();
return Object.entries(config.api_site).map(([key, site]) => ({
...site,
key,
}));
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1 +0,0 @@
[]

View File

@@ -1,28 +0,0 @@
import express, { Express, Request, Response } from "express";
import cors from "cors";
import dotenv from "dotenv";
dotenv.config();
const app: Express = express();
const port = process.env.PORT || 3001;
// Middlewares
app.use(cors());
app.use(express.json());
// Health check route
app.get("/", (req: Request, res: Response) => {
res.send("MyTV Backend Service is running!");
});
import apiRouter from "./routes";
// API routes
app.use("/api", apiRouter);
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
export default app;

View File

@@ -1,24 +0,0 @@
import express, { Express, Request, Response } from "express";
import cors from "cors";
import dotenv from "dotenv";
dotenv.config();
const app: Express = express();
const port = process.env.PORT || 3001;
// Middlewares
app.use(cors());
app.use(express.json());
// Health check route
app.get("/", (req: Request, res: Response) => {
res.send("MyTV Backend Service is running!");
});
import apiRouter from "./routes";
// API routes
app.use("/api", apiRouter);
export default app;

View File

@@ -1,156 +0,0 @@
import { Router, Request, Response } from "express";
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from "../config";
import { VideoDetail } from "../types";
import { cleanHtmlTags } from "../utils";
const router = Router();
// Match m3u8 links
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
async function handleSpecialSourceDetail(id: string, apiSite: ApiSite): Promise<VideoDetail> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情页请求失败: ${response.status}`);
}
const html = await response.text();
let matches: string[] = [];
if (apiSite.key === "ffzy") {
const ffzyPattern = /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
if (matches.length === 0) {
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
matches = html.match(generalPattern) || [];
}
matches = Array.from(new Set(matches)).map((link: string) => {
link = link.substring(1);
const parenIndex = link.indexOf("(");
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : "";
const descMatch = html.match(/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/);
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : "";
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : "";
return {
id,
title: titleText,
poster: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
};
}
async function getDetailFromApi(apiSite: ApiSite, id: string): Promise<VideoDetail> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情请求失败: ${response.status}`);
}
const data = await response.json();
if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {
throw new Error("获取到的详情内容无效");
}
const videoDetail = data.list[0];
let episodes: string[] = [];
if (videoDetail.vod_play_url) {
const playSources = videoDetail.vod_play_url.split("$$$");
if (playSources.length > 0) {
const mainSource = playSources[0];
const episodeList = mainSource.split("#");
episodes = episodeList
.map((ep: string) => {
const parts = ep.split("$");
return parts.length > 1 ? parts[1] : "";
})
.filter((url: string) => url && (url.startsWith("http://") || url.startsWith("https://")));
}
}
if (episodes.length === 0 && videoDetail.vod_content) {
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
episodes = matches.map((link: string) => link.replace(/^\$/, ""));
}
return {
id,
title: videoDetail.vod_name,
poster: videoDetail.vod_pic,
desc: cleanHtmlTags(videoDetail.vod_content),
type: videoDetail.type_name,
year: videoDetail.vod_year?.match(/\d{4}/)?.[0] || "",
area: videoDetail.vod_area,
director: videoDetail.vod_director,
actor: videoDetail.vod_actor,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
};
}
async function getVideoDetail(id: string, sourceCode: string): Promise<VideoDetail> {
if (!id) {
throw new Error("缺少视频ID参数");
}
if (!/^[\w-]+$/.test(id)) {
throw new Error("无效的视频ID格式");
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
throw new Error("无效的API来源");
}
if (apiSite.detail) {
return handleSpecialSourceDetail(id, apiSite);
}
return getDetailFromApi(apiSite, id);
}
router.get("/", async (req: Request, res: Response) => {
const id = req.query.id as string;
const sourceCode = req.query.source as string;
if (!id || !sourceCode) {
return res.status(400).json({ error: "缺少必要参数" });
}
try {
const result = await getVideoDetail(id, sourceCode);
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;

View File

@@ -1,161 +0,0 @@
import { Router, Request, Response } from "express";
import { getCacheTime } from "../config";
const router = Router();
// --- Interfaces ---
interface DoubanItem {
title: string;
poster: string;
rate: string;
}
interface DoubanResponse {
code: number;
message: string;
list: DoubanItem[];
}
interface DoubanApiResponse {
subjects: Array<{
title: string;
cover: string;
rate: string;
}>;
}
// --- Helper Functions ---
async function fetchDoubanData(url: string): Promise<DoubanApiResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const fetchOptions = {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
Referer: "https://movie.douban.com/",
Accept: "application/json, text/plain, */*",
},
};
try {
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
async function handleTop250(pageStart: number, res: Response) {
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const fetchOptions = {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
Referer: "https://movie.douban.com/",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
},
};
try {
const fetchResponse = await fetch(target, fetchOptions);
clearTimeout(timeoutId);
if (!fetchResponse.ok) {
throw new Error(`HTTP error! Status: ${fetchResponse.status}`);
}
const html = await fetchResponse.text();
const moviePattern =
/<div class="item">[\s\S]*?<img[^>]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?<span class="rating_num"[^>]*>([^<]+)<\/span>[\s\S]*?<\/div>/g;
const movies: DoubanItem[] = [];
let match;
while ((match = moviePattern.exec(html)) !== null) {
const title = match[1];
const cover = match[2];
const rate = match[3] || "";
const processedCover = cover.replace(/^http:/, "https:");
movies.push({ title, poster: processedCover, rate });
}
const apiResponse: DoubanResponse = {
code: 200,
message: "获取成功",
list: movies,
};
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(apiResponse);
} catch (error) {
clearTimeout(timeoutId);
res.status(500).json({
error: "获取豆瓣 Top250 数据失败",
details: (error as Error).message,
});
}
}
// --- Main Route Handler ---
router.get("/", async (req: Request, res: Response) => {
const { type, tag } = req.query;
const pageSize = parseInt((req.query.pageSize as string) || "16");
const pageStart = parseInt((req.query.pageStart as string) || "0");
if (!type || !tag) {
return res.status(400).json({ error: "缺少必要参数: type 或 tag" });
}
if (typeof type !== "string" || !["tv", "movie"].includes(type)) {
return res.status(400).json({ error: "type 参数必须是 tv 或 movie" });
}
if (pageSize < 1 || pageSize > 100) {
return res.status(400).json({ error: "pageSize 必须在 1-100 之间" });
}
if (pageStart < 0) {
return res.status(400).json({ error: "pageStart 不能小于 0" });
}
if (tag === "top250") {
return handleTop250(pageStart, res);
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
const doubanData = await fetchDoubanData(target);
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
title: item.title,
poster: item.cover,
rate: item.rate,
}));
const response: DoubanResponse = {
code: 200,
message: "获取成功",
list: list,
};
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(response);
} catch (error) {
res.status(500).json({
error: "获取豆瓣数据失败",
details: (error as Error).message,
});
}
});
export default router;

View File

@@ -1,67 +0,0 @@
import express, { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
const router = express.Router();
const dataPath = path.join(__dirname, "..", "data", "favorites.json");
// Helper function to read data
const readFavorites = async () => {
try {
const data = await fs.readFile(dataPath, "utf-8");
return JSON.parse(data);
} catch (error) {
// If file doesn't exist or is invalid json, return empty object
return {};
}
};
// Helper function to write data
const writeFavorites = async (data: any) => {
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8");
};
// GET /api/favorites
router.get("/favorites", async (req: Request, res: Response) => {
const { key } = req.query;
const favorites = await readFavorites();
if (key) {
res.json(favorites[key as string] || null);
} else {
res.json(favorites);
}
});
// POST /api/favorites
router.post("/favorites", async (req: Request, res: Response) => {
const { key, favorite } = req.body;
if (!key || !favorite) {
return res.status(400).json({ message: "Missing key or favorite data" });
}
const favorites = await readFavorites();
favorites[key] = { ...favorite, save_time: Math.floor(Date.now() / 1000) };
await writeFavorites(favorites);
res.json({ success: true });
});
// DELETE /api/favorites
router.delete("/favorites", async (req: Request, res: Response) => {
const { key } = req.query;
let favorites = await readFavorites();
if (key) {
delete favorites[key as string];
} else {
// Clear all favorites if no key is provided
favorites = {};
}
await writeFavorites(favorites);
res.json({ success: true });
});
export default router;

View File

@@ -1,43 +0,0 @@
import { Router, Request, Response } from "express";
import { Readable } from "node:stream";
const router = Router();
router.get("/", async (req: Request, res: Response) => {
const imageUrl = req.query.url as string;
if (!imageUrl) {
return res.status(400).send("Missing image URL");
}
try {
const imageResponse = await fetch(imageUrl, {
headers: {
Referer: "https://movie.douban.com/",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
},
});
if (!imageResponse.ok) {
return res.status(imageResponse.status).send(imageResponse.statusText);
}
const contentType = imageResponse.headers.get("content-type");
if (contentType) {
res.setHeader("Content-Type", contentType);
}
if (imageResponse.body) {
const nodeStream = Readable.fromWeb(imageResponse.body as any);
nodeStream.pipe(res);
} else {
res.status(500).send("Image response has no body");
}
} catch (error) {
console.info("Image proxy error:", error);
res.status(500).send("Error fetching image");
}
});
export default router;

View File

@@ -1,24 +0,0 @@
import { Router } from "express";
import searchRouter from "./search";
import detailRouter from "./detail";
import doubanRouter from "./douban";
import imageProxyRouter from "./image-proxy";
import serverConfigRouter from "./server-config";
import loginRouter from "./login";
import favoritesRouter from "./favorites";
import playRecordsRouter from "./playrecords";
import searchHistoryRouter from "./searchhistory";
const router = Router();
router.use(serverConfigRouter);
router.use(loginRouter);
router.use(favoritesRouter);
router.use(playRecordsRouter);
router.use(searchHistoryRouter);
router.use("/search", searchRouter);
router.use("/detail", detailRouter);
router.use("/douban", doubanRouter);
router.use("/image-proxy", imageProxyRouter);
export default router;

View File

@@ -1,58 +0,0 @@
import express, { Request, Response } from "express";
import dotenv from "dotenv";
dotenv.config();
const router = express.Router();
const username = process.env.USERNAME;
const password = process.env.PASSWORD;
/**
* @api {post} /api/login User Login
* @apiName UserLogin
* @apiGroup User
*
* @apiBody {String} username User's username.
* @apiBody {String} password User's password.
*
* @apiSuccess {Boolean} ok Indicates if the login was successful.
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "ok": true
* }
*
* @apiError {String} message Error message.
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 400 Bad Request
* {
* "message": "Invalid password"
* }
*/
router.post("/login", (req: Request, res: Response) => {
const { username: inputUsername, password: inputPassword } = req.body;
// Compatibility with old versions, if username is not set, only password is required
if (!username || !password) {
if (inputPassword === password) {
res.cookie("auth", "true", { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
return res.json({ ok: true });
} else if (!password) {
// If no password is set, login is always successful.
return res.json({ ok: true });
} else {
return res.status(400).json({ message: "Invalid password" });
}
}
if (inputUsername === username && inputPassword === password) {
res.cookie("auth", "true", { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
res.json({ ok: true });
} else {
res.status(400).json({ message: "Invalid username or password" });
}
});
export default router;

View File

@@ -1,59 +0,0 @@
import express, { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
const router = express.Router();
const dataPath = path.join(__dirname, "..", "data", "playrecords.json");
// Helper function to read data
const readPlayRecords = async () => {
try {
const data = await fs.readFile(dataPath, "utf-8");
return JSON.parse(data);
} catch (error) {
return {};
}
};
// Helper function to write data
const writePlayRecords = async (data: any) => {
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8");
};
// GET /api/playrecords
router.get("/playrecords", async (req: Request, res: Response) => {
const records = await readPlayRecords();
res.json(records);
});
// POST /api/playrecords
router.post("/playrecords", async (req: Request, res: Response) => {
const { key, record } = req.body;
if (!key || !record) {
return res.status(400).json({ message: "Missing key or record data" });
}
const records = await readPlayRecords();
records[key] = { ...record, time: Math.floor(Date.now() / 1000) };
await writePlayRecords(records);
res.json({ success: true });
});
// DELETE /api/playrecords
router.delete("/playrecords", async (req: Request, res: Response) => {
const { key } = req.query;
let records = await readPlayRecords();
if (key) {
delete records[key as string];
} else {
records = {};
}
await writePlayRecords(records);
res.json({ success: true });
});
export default router;

View File

@@ -1,270 +0,0 @@
import { Router, Request, Response } from "express";
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from "../config";
import { cleanHtmlTags } from "../utils";
const router = Router();
// 根据环境变量决定最大搜索页数,默认 5
const MAX_SEARCH_PAGES: number = Number(process.env.SEARCH_MAX_PAGE) || 5;
export interface SearchResult {
id: string;
title: string;
poster: string;
episodes: string[];
source: string;
source_name: string;
class?: string;
year: string;
desc?: string;
type_name?: string;
}
interface ApiSearchItem {
vod_id: string;
vod_name: string;
vod_pic: string;
vod_remarks?: string;
vod_play_url?: string;
vod_class?: string;
vod_year?: string;
vod_content?: string;
type_name?: string;
}
async function searchFromApi(
apiSite: ApiSite,
query: string
): Promise<SearchResult[]> {
try {
const apiBaseUrl = apiSite.api;
const apiUrl =
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
const apiName = apiSite.name;
// 添加超时处理
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(apiUrl, {
headers: API_CONFIG.search.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return [];
}
const data = await response.json();
console.log(
"apiUrl",
apiSite.name,
"response status",
response.ok,
"response data",
data.list.length
);
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
return [];
}
// 处理第一页结果
const results = data.list.map((item: ApiSearchItem) => {
let episodes: string[] = [];
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
if (item.vod_play_url) {
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
// 先用 $$$ 分割
const vod_play_url_array = item.vod_play_url.split("$$$");
// 对每个分片做匹配,取匹配到最多的作为结果
vod_play_url_array.forEach((url: string) => {
const matches = url.match(m3u8Regex) || [];
if (matches.length > episodes.length) {
episodes = matches;
}
});
}
episodes = Array.from(new Set(episodes)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
const parenIndex = link.indexOf("(");
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
return {
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes,
source: apiSite.key,
source_name: apiName,
class: item.vod_class,
year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || "" : "",
desc: cleanHtmlTags(item.vod_content || ""),
type_name: item.type_name,
};
});
// 获取总页数
const pageCount = data.pagecount || 1;
// 确定需要获取的额外页数
const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 1);
// 如果有额外页数,获取更多页的结果
if (pagesToFetch > 0) {
const additionalPagePromises = [];
for (let page = 2; page <= pagesToFetch + 1; page++) {
const pageUrl =
apiBaseUrl +
API_CONFIG.search.pagePath
.replace("{query}", encodeURIComponent(query))
.replace("{page}", page.toString());
const pagePromise = (async () => {
try {
const pageController = new AbortController();
const pageTimeoutId = setTimeout(
() => pageController.abort(),
8000
);
const pageResponse = await fetch(pageUrl, {
headers: API_CONFIG.search.headers,
signal: pageController.signal,
});
clearTimeout(pageTimeoutId);
if (!pageResponse.ok) return [];
const pageData = await pageResponse.json();
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
return [];
return pageData.list.map((item: ApiSearchItem) => {
let episodes: string[] = [];
if (item.vod_play_url) {
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
episodes = item.vod_play_url.match(m3u8Regex) || [];
}
episodes = Array.from(new Set(episodes)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
const parenIndex = link.indexOf("(");
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
return {
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes,
source: apiSite.key,
source_name: apiName,
class: item.vod_class,
year: item.vod_year
? item.vod_year.match(/\d{4}/)?.[0] || ""
: "",
desc: cleanHtmlTags(item.vod_content || ""),
type_name: item.type_name,
};
});
} catch (error) {
return [];
}
})();
additionalPagePromises.push(pagePromise);
}
const additionalResults = await Promise.all(additionalPagePromises);
additionalResults.forEach((pageResults) => {
if (pageResults.length > 0) {
results.push(...pageResults);
}
});
}
return results;
} catch (error) {
return [];
}
}
router.get("/", async (req: Request, res: Response) => {
const query = req.query.q as string;
if (!query) {
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
return res.json({ results: [] });
}
const apiSites = getApiSites();
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
try {
const results = await Promise.all(searchPromises);
const flattenedResults = results.flat();
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json({ results: flattenedResults });
} catch (error) {
res.status(500).json({ error: "搜索失败" });
}
});
// 按资源 url 单个获取数据
router.get("/one", async (req: Request, res: Response) => {
const { resourceId, q } = req.query;
if (!resourceId || !q) {
return res.status(400).json({ error: "resourceId and q are required" });
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === (resourceId as string));
if (!apiSite) {
return res.status(404).json({ error: "Resource not found" });
}
try {
const results = await searchFromApi(apiSite, q as string);
const result = results.filter((r) => r.title === (q as string));
if (results) {
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json({results: result});
} else {
res.status(404).json({ error: "Resource not found with the given query" });
}
} catch (error) {
res.status(500).json({ error: "Failed to fetch resource details" });
}
});
// 获取所有可用的资源列表
router.get("/resources", async (req: Request, res: Response) => {
const apiSites = getApiSites();
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(apiSites);
});
export default router;

View File

@@ -1,66 +0,0 @@
import express, { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
const router = express.Router();
const dataPath = path.join(__dirname, "..", "data", "searchhistory.json");
// Helper function to read data
const readSearchHistory = async (): Promise<string[]> => {
try {
const data = await fs.readFile(dataPath, "utf-8");
return JSON.parse(data);
} catch (error) {
return [];
}
};
// Helper function to write data
const writeSearchHistory = async (data: string[]) => {
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8");
};
// GET /api/searchhistory
router.get("/searchhistory", async (req: Request, res: Response) => {
const history = await readSearchHistory();
res.json(history);
});
// POST /api/searchhistory
router.post("/searchhistory", async (req: Request, res: Response) => {
const { keyword } = req.body;
if (!keyword) {
return res.status(400).json({ message: "Missing keyword" });
}
let history = await readSearchHistory();
// Remove keyword if it already exists to move it to the front
history = history.filter((item) => item !== keyword);
// Add to the beginning of the array
history.unshift(keyword);
// Optional: Limit history size
if (history.length > 100) {
history = history.slice(0, 100);
}
await writeSearchHistory(history);
res.json(history);
});
// DELETE /api/searchhistory
router.delete("/searchhistory", async (req: Request, res: Response) => {
const { keyword } = req.query;
let history = await readSearchHistory();
if (keyword) {
history = history.filter((item) => item !== keyword);
} else {
history = [];
}
await writeSearchHistory(history);
res.json({ success: true });
});
export default router;

View File

@@ -1,38 +0,0 @@
import express, { Request, Response } from "express";
import { getConfig } from "../config";
const router = express.Router();
/**
* @api {get} /api/server-config Get Server Configuration
* @apiName GetServerConfig
* @apiGroup Server
*
* @apiSuccess {String} SiteName The name of the site.
* @apiSuccess {String} StorageType The storage type used by the server ("localstorage" or "database").
*
* @apiSuccessExample {json} Success-Response (LocalStorage):
* HTTP/1.1 200 OK
* {
* "SiteName": "OrionTV-Local",
* "StorageType": "localstorage"
* }
*
* @apiSuccessExample {json} Success-Response (Database):
* HTTP/1.1 200 OK
* {
* "SiteName": "OrionTV-Cloud",
* "StorageType": "database"
* }
*/
router.get("/server-config", (req: Request, res: Response) => {
const config = getConfig();
const storageType = config.storage?.type || "database"; // Default to 'database' if not specified
res.json({
SiteName: storageType === "localstorage" ? "OrionTV-Local" : "OrionTV-Cloud",
StorageType: storageType,
});
});
export default router;

View File

@@ -1,28 +0,0 @@
// Data structure for play records
export interface PlayRecord {
title: string;
source_name: string;
poster: string;
index: number; // Episode number
total_episodes: number; // Total number of episodes
play_time: number; // Play progress in seconds
total_time: number; // Total duration in seconds
save_time: number; // Timestamp of when the record was saved
user_id: number; // User ID, always 0 in this version
}
// You can add other shared types here
export interface VideoDetail {
id: string;
title: string;
poster: string;
source: string;
source_name: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
}

View File

@@ -1,10 +0,0 @@
export function cleanHtmlTags(text: string): string {
if (!text) return "";
return text
.replace(/<[^>]+>/g, "\n") // 将 HTML 标签替换为换行
.replace(/\n+/g, "\n") // 将多个连续换行合并为一个
.replace(/[ \t]+/g, " ") // 将多个连续空格和制表符合并为一个空格,但保留换行符
.replace(/^\n+|\n+$/g, "") // 去掉首尾换行
.replace(/&nbsp;/g, " ") // 将 &nbsp; 替换为空格
.trim(); // 去掉首尾空格
}

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

View File

@@ -1,18 +0,0 @@
{
"version": 2,
"builds": [
{
"src": "src/index.ts",
"use": "@vercel/node",
"config": {
"includeFiles": ["./config.json"]
}
}
],
"routes": [
{
"src": "/(.*)",
"dest": "src/index.ts"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react";
import React, { useCallback } from "react";
import { View, StyleSheet, ScrollView, Dimensions, ActivityIndicator } from "react-native";
import { ThemedText } from "@/components/ThemedText";
@@ -91,7 +91,7 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
<>
{/* Render content in a grid layout */}
{Array.from({ length: Math.ceil(data.length / numColumns) }).map((_, rowIndex) => (
<View key={rowIndex} style={{ flexDirection: "row", justifyContent: "space-between" }}>
<View key={rowIndex} style={styles.rowContainer}>
{data.slice(rowIndex * numColumns, (rowIndex + 1) * numColumns).map((item, index) => (
<View key={index} style={[styles.itemContainer, { width: ITEM_WIDTH }]}>
{renderItem({ item, index: rowIndex * numColumns + index })}
@@ -121,6 +121,11 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingBottom: 20,
},
rowContainer: {
flexDirection: "row",
justifyContent: "flex-start",
flexWrap: "wrap",
},
itemContainer: {
margin: 8,
alignItems: "center",

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import { Modal, View, TextInput, StyleSheet, ActivityIndicator } from "react-native";
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, Alert } from "react-native";
import { usePathname } from "expo-router";
import Toast from "react-native-toast-message";
import useAuthStore from "@/stores/authStore";
@@ -55,6 +55,13 @@ const LoginModal = () => {
hideLoginModal();
setUsername("");
setPassword("");
// Show disclaimer alert after successful login
Alert.alert(
"免责声明",
"本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
[{ text: "确定" }]
);
} catch {
Toast.show({ type: "error", text1: "登录失败", text2: "用户名或密码错误" });
} finally {

View File

@@ -76,7 +76,7 @@ export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSection
style={[styles.input, isInputFocused && styles.inputFocused]}
value={apiBaseUrl}
onChangeText={handleUrlChange}
placeholder="输入 API 地址"
placeholder="输入服务器地址"
placeholderTextColor="#888"
autoCapitalize="none"
autoCorrect={false}

View File

@@ -1,284 +0,0 @@
# OrionTV Android 5.0 兼容性分析报告
## 项目概述
OrionTV是一个基于React Native TVOS和Expo SDK的电视端视频流媒体应用专为Apple TV和Android TV平台设计。本文档分析了将项目降级到支持Android 5.0 (API Level 21)的兼容性风险和实施方案。
## 当前技术栈
### 核心框架版本
- **React Native**: `npm:react-native-tvos@~0.74.2-0`
- **Expo SDK**: `~51.0.13`
- **React**: `18.2.0`
- **TypeScript**: `~5.3.3`
- **最小Android API级别**: 23 (Android 6.0)
- **目标Android API级别**: 34 (Android 14)
### 关键依赖
- `expo-av`: `~14.0.7` (视频播放)
- `expo-router`: `~3.5.16` (路由导航)
- `react-native-reanimated`: `~3.10.1` (动画)
- `react-native-tcp-socket`: `^6.0.6` (网络服务)
- `zustand`: `^5.0.6` (状态管理)
## 兼容性限制分析
### React Native 0.74 限制
根据官方文档React Native 0.74已将最低Android API级别要求提升到23 (Android 6.0)不再支持Android 5.0 (API Level 21)。
### Expo SDK 51 限制
Expo SDK 51基于React Native 0.74同样不支持Android 5.0。
## 降级方案
### 推荐的版本组合
#### 方案A: 保持TV功能的最新兼容版本
```json
{
"react-native": "npm:react-native-tvos@~0.73.8-0",
"expo": "~50.0.0",
"expo-av": "~13.10.x",
"expo-router": "~3.4.x",
"react-native-reanimated": "~3.8.x"
}
```
#### 方案B: 最大向后兼容版本
```json
{
"react-native": "npm:react-native-tvos@~0.72.12-0",
"expo": "~49.0.0"
}
```
### Android配置修改
```gradle
// android/build.gradle
android {
minSdkVersion = 21 // 支持Android 5.0
targetSdkVersion = 30 // 降级到Android 11
compileSdkVersion = 33 // 对应的编译SDK版本
}
```
## 风险评估
### 🔴 高风险组件
#### 1. 视频播放功能 (expo-av)
- **影响文件**: `hooks/usePlaybackManager.ts`, `app/play.tsx`, `stores/playerStore.ts`
- **风险**: API变化可能影响播放控制
- **关键代码**:
```typescript
import { Video, AVPlaybackStatus } from "expo-av";
// 可能受影响的API调用
videoRef?.current?.replayAsync();
videoRef?.current?.pauseAsync();
videoRef?.current?.playAsync();
```
#### 2. TV遥控器功能 (react-native-tvos)
- **影响文件**: `hooks/useTVRemoteHandler.ts` + 7个组件文件
- **风险**: 遥控器事件处理可能有变化
- **关键代码**:
```typescript
import { useTVEventHandler, HWEvent } from "react-native";
// 长按事件处理可能需要调整
case "longRight":
case "longLeft":
```
#### 3. 路由导航 (expo-router)
- **影响文件**: 9个页面文件
- **风险**: 路由配置和参数传递可能有变化
- **关键代码**:
```typescript
import { useRouter, useLocalSearchParams } from "expo-router";
```
### 🟡 中等风险组件
#### 1. 远程控制服务 (react-native-tcp-socket)
- **影响文件**: `services/tcpHttpServer.ts`
- **风险**: 网络API兼容性问题
- **关键代码**:
```typescript
import TcpSocket from 'react-native-tcp-socket';
this.server = TcpSocket.createServer((socket: TcpSocket.Socket) => {});
```
#### 2. 动画效果 (react-native-reanimated)
- **影响文件**: `components/VideoCard.tv.tsx`
- **风险**: 动画性能可能下降
- **关键代码**:
```typescript
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
```
### 🟢 低风险组件
#### 1. 状态管理 (zustand)
- **影响**: 与React Native版本无直接关系
- **风险**: 极低
#### 2. 数据存储 (AsyncStorage)
- **影响文件**: `services/storage.ts`
- **风险**: 极低API稳定
## 平台特定风险
### Android API 23 → 21 降级影响
#### 1. 运行时权限模型
- **API 23+**: 需要运行时权限请求
- **API 21-22**: 安装时权限模型
- **影响**: 网络权限处理可能需要调整
#### 2. 网络安全配置
- **风险**: HTTP cleartext流量处理
- **当前配置**: `android.usesCleartextTraffic = true`
- **建议**: 保持当前配置确保向后兼容
#### 3. 后台服务限制
- **API 23+**: 更严格的后台服务限制
- **API 21-22**: 相对宽松的后台服务策略
- **影响**: 远程控制服务可能表现不同
## 实施步骤
### 1. 准备阶段
```bash
# 1. 备份当前项目
git checkout -b android-5-compatibility
# 2. 清理现有依赖
rm -rf node_modules
rm yarn.lock
```
### 2. 版本降级
```bash
# 3. 修改package.json依赖版本
# 4. 重新安装依赖
yarn install
# 5. 清理原生代码
yarn prebuild-tv --clean
```
### 3. 配置修改
```bash
# 6. 修改android/build.gradle
# 7. 更新app.json配置
# 8. 复制TV相关配置
yarn copy-config
```
### 4. 测试构建
```bash
# 9. 本地构建测试
yarn build-local
# 10. 运行测试
yarn test
```
## 测试清单
### 核心功能测试
- [ ] 视频播放、暂停、进度控制
- [ ] 遥控器所有按键响应(上下左右、选择、菜单、返回)
- [ ] 长按快进/快退功能
- [ ] 页面导航和参数传递
- [ ] 焦点管理和视觉反馈
### TV特定功能测试
- [ ] 控制条自动显示/隐藏
- [ ] 剧集切换功能
- [ ] 远程控制HTTP服务
- [ ] 设置页面各项配置
- [ ] 搜索功能
### 兼容性测试
- [ ] Android 5.0真机测试
- [ ] Android TV模拟器测试
- [ ] Apple TV模拟器测试
- [ ] 不同屏幕尺寸适配
- [ ] 内存使用情况
- [ ] 启动性能测试
## 依赖版本对照表
| 组件 | 当前版本 | 目标版本 | 风险等级 | 备注 |
|------|----------|----------|----------|------|
| react-native-tvos | ~0.74.2-0 | ~0.73.8-0 | 🔴 高 | TV功能核心 |
| expo | ~51.0.13 | ~50.0.0 | 🔴 高 | 框架基础 |
| expo-av | ~14.0.7 | ~13.10.x | 🔴 高 | 视频播放 |
| expo-router | ~3.5.16 | ~3.4.x | 🔴 高 | 路由导航 |
| react-native-reanimated | ~3.10.1 | ~3.8.x | 🟡 中 | 动画效果 |
| react-native-tcp-socket | ^6.0.6 | ^6.0.4 | 🟡 中 | 网络服务 |
| zustand | ^5.0.6 | ^5.0.6 | 🟢 低 | 状态管理 |
| @react-native-async-storage/async-storage | ^2.2.0 | ^2.1.x | 🟢 低 | 数据存储 |
## 潜在问题和解决方案
### 1. 视频播放问题
**问题**: expo-av版本降级可能导致某些视频格式不支持
**解决方案**:
- 测试主要视频格式(MP4, M3U8)
- 必要时实现格式转换
- 提供播放失败的友好提示
### 2. 遥控器响应问题
**问题**: TV事件处理可能有差异
**解决方案**:
- 仔细测试所有遥控器按键
- 调整事件处理逻辑
- 增加兼容性检查
### 3. 路由导航问题
**问题**: 页面跳转参数传递可能有变化
**解决方案**:
- 测试所有页面跳转
- 验证参数正确传递
- 必要时调整路由配置
### 4. 动画性能问题
**问题**: 动画可能在低端设备上表现不佳
**解决方案**:
- 简化动画效果
- 增加性能检测
- 提供动画开关选项
## 建议与结论
### 风险总结
- **总体风险等级**: 🔴 **高等风险**
- **主要风险点**: 视频播放、遥控器功能、路由导航
- **预计工作量**: 2-3周开发 + 1-2周测试
### 成本效益分析
- **开发成本**: 高(需要大量测试和调试)
- **维护成本**: 高(使用较旧版本,安全更新有限)
- **用户覆盖**: 低Android 5用户占比通常<2%
### 最终建议
**不建议进行降级**,原因如下:
1. 技术风险高,可能影响核心功能稳定性
2. 维护成本高,需要长期支持多个版本
3. 用户收益有限Android 5用户占比极低
4. 与业界趋势不符,各大平台都在提升最低版本要求
### 替代方案
1. **统计用户分布**: 收集实际用户设备数据确认Android 5用户占比
2. **渐进式升级**: 引导用户升级设备,提供升级指南
3. **精简版本**: 为老设备提供功能精简的独立版本
4. **Web版本**: 提供Web端访问方式作为补充
## 参考资料
- [React Native 0.74 Release Notes](https://reactnative.dev/blog/2024/04/22/release-0.74)
- [Expo SDK 51 Changelog](https://expo.dev/changelog/2024-05-07-sdk-51)
- [React Native TV OS Documentation](https://github.com/react-native-tvos/react-native-tvos)
- [Android API Level Distribution](https://developer.android.com/about/dashboards)

View File

@@ -1,313 +0,0 @@
### 服务器配置
- **接口地址**: `/api/server-config`
- **请求方法**: `GET`
- **功能说明**: 获取服务器配置信息
- **请求参数**: 无
- **返回格式**:
```json
{
"SiteName": "string",
"StorageType": "string"
}
```
StorageType 可选值:
- "localstorage"
- "redis"
localstorage 方式部署的实例,收藏、播放记录和搜索历史无服务器同步,客户端自行处理即可
localstorage 方式部署的实例,登录时只需输入密码,无用户名
### 登录校验
- **接口地址**: `/api/login`
- **请求方法**: `POST`
- **功能说明**: 用户登录认证
- **请求参数**:
```json
{
"password": "string", // 必填,用户密码
"username": "string" // 选填,用户名(非 localStorage 模式时必填)
}
```
- **返回格式**:
```json
{
"ok": true
}
```
- **错误码**:
- `400`: 参数错误或密码错误
- `500`: 服务器内部错误
response 会设置 set-cookie 的 auth 字段,用于后续请求的鉴权
后续的所有接口请求时都需要携带 auth 字段,否则会返回 401 错误
建议客户端保存用户输入的用户名和密码,在每次 app 启动时请求登录接口获取 cookie
### 视频搜索接口
- **接口地址**: `/api/search`
- **请求方法**: `GET`
- **功能说明**: 搜索视频内容
- **请求参数**:
- `q`: 搜索关键词(可选,不传返回空结果)
- **返回格式**:
```json
{
"results": [
{
"id": "string", // 视频在源站中的 id
"title": "string", // 视频标题
"poster": "string", // 视频封面
"source": "string", // 视频源站 key
"source_name": "string", // 视频源站名称
"class": "string", // 视频分类
"year": "string", // 视频年份
"desc": "string", // 视频描述
"type_name": "string", // 视频类型
"douban_id": "string" // 视频豆瓣 id
}
]
}
```
- **错误码**:
- `500`: 搜索失败
### 视频详情接口
- **接口地址**: `/api/detail`
- **请求方法**: `GET`
- **功能说明**: 获取视频详细信息
- **请求参数**:
- `id`: 视频 ID必填
- `source`: 视频来源代码(必填)
- **返回格式**:
```json
{
"id": "string", // 视频在源站中的 id
"title": "string", // 视频标题
"poster": "string", // 视频封面
"source": "string", // 视频源站 key
"source_name": "string", // 视频源站名称
"class": "string", // 视频分类
"year": "string", // 视频年份
"desc": "string", // 视频描述
"type_name": "string", // 视频类型
"douban_id": "string" // 视频豆瓣 id
}
```
- **错误码**:
- `400`: 缺少必要参数或无效参数
- `500`: 获取详情失败
### 豆瓣数据接口
- **接口地址**: `/api/douban`
- **请求方法**: `GET`
- **功能说明**: 获取豆瓣电影/电视剧数据
- **请求参数**:
- `type`: 类型,必须是 `tv` 或 `movie`(必填)
- `tag`: 标签,如 `热门`、`最新` 等(必填)
- `pageSize`: 每页数量1-100 之间(可选,默认 16
- `pageStart`: 起始位置,不能小于 0可选默认 0
- **返回格式**:
```json
{
"code": 200,
"message": "获取成功",
"list": [
{
"id": "string",
"title": "string",
"poster": "string",
"rate": "string"
}
]
}
```
- **错误码**:
- `400`: 参数错误
- `500`: 获取豆瓣数据失败
### 用户数据接口
#### 收藏管理
- **接口地址**: `/api/favorites`
- **请求方法**: `GET` / `POST` / `DELETE`
- **功能说明**: 管理用户收藏
- **认证**: 需要认证
##### GET 请求 - 获取收藏
- **请求参数**:
- `key`: 收藏项 key可选格式为 `source+id`
- **返回格式**:
```json
// 不带key参数时返回所有收藏
{
"source+id": {
"title": "string",
"poster": "string",
"source_name": "string",
"save_time": 1234567890
}
}
// 带key参数时返回单个收藏或null
{
"title": "string",
"poster": "string",
"source_name": "string",
"save_time": 1234567890
}
```
##### POST 请求 - 添加收藏
- **请求参数**:
```json
{
"key": "string", // 必填,格式为 source+id
"favorite": {
"title": "string",
"poster": "string",
"source_name": "string",
"save_time": 1234567890
}
}
```
- **返回格式**:
```json
{
"success": true
}
```
##### DELETE 请求 - 删除收藏
- **请求参数**:
- `key`: 收藏项 key可选不传则清空所有收藏
- **返回格式**:
```json
{
"success": true
}
```
- **错误码**:
- `400`: 参数错误
- `401`: 未认证
- `500`: 服务器内部错误
#### 播放记录管理
- **接口地址**: `/api/playrecords`
- **请求方法**: `GET` / `POST` / `DELETE`
- **功能说明**: 管理用户播放记录
- **认证**: 需要认证
##### GET 请求 - 获取播放记录
- **请求参数**: 无
- **返回格式**:
```json
{
"source+id": {
"title": "string",
"poster": "string",
"source_name": "string",
"index": 1,
"time": 1234567890
}
}
```
##### POST 请求 - 保存播放记录
- **请求参数**:
```json
{
"key": "string", // 必填,格式为 source+id
"record": {
"title": "string",
"poster": "string",
"source_name": "string",
"index": 1,
"time": 1234567890
}
}
```
- **返回格式**:
```json
{
"success": true
}
```
##### DELETE 请求 - 删除播放记录
- **请求参数**:
- `key`: 播放记录 key可选不传则清空所有记录
- **返回格式**:
```json
{
"success": true
}
```
- **错误码**:
- `400`: 参数错误
- `401`: 未认证
- `500`: 服务器内部错误
#### 搜索历史管理
- **接口地址**: `/api/searchhistory`
- **请求方法**: `GET` / `POST` / `DELETE`
- **功能说明**: 管理用户搜索历史
- **认证**: 需要认证
##### GET 请求 - 获取搜索历史
- **请求参数**: 无
- **返回格式**:
```json
["搜索关键词1", "搜索关键词2"]
```
##### POST 请求 - 添加搜索历史
- **请求参数**:
```json
{
"keyword": "string" // 必填,搜索关键词
}
```
- **返回格式**:
```json
["搜索关键词1", "搜索关键词2"]
```
##### DELETE 请求 - 删除搜索历史
- **请求参数**:
- `keyword`: 要删除的关键词(可选,不传则清空所有历史)
- **返回格式**:
```json
{
"success": true
}
```
- **错误码**:
- `400`: 参数错误
- `401`: 未认证
- `500`: 服务器内部错误

View File

@@ -1,305 +0,0 @@
# OrionTV Native HTTP Server Implementation Documentation
## Overview
OrionTV implements a sophisticated native HTTP server solution that enables remote control functionality for the TV application. This implementation uses TCP sockets to create a custom HTTP server directly within the React Native application, providing a web-based remote control interface accessible from mobile devices.
## Architecture
### Core Components
#### 1. TCPHttpServer (`/services/tcpHttpServer.ts`)
A custom HTTP server implementation built on top of `react-native-tcp-socket` that handles raw TCP connections and implements HTTP protocol parsing and response formatting.
**Key Features:**
- Custom HTTP request/response parsing
- Fixed port configuration (12346)
- Automatic IP address detection via `@react-native-community/netinfo`
- Support for GET and POST methods
- Error handling and connection management
**Class Structure:**
```typescript
class TCPHttpServer {
private server: TcpSocket.Server | null = null;
private isRunning = boolean;
private requestHandler: RequestHandler | null = null;
}
```
**Core Methods:**
- `start()`: Initializes server and binds to `0.0.0.0:12346`
- `stop()`: Gracefully shuts down the server
- `setRequestHandler()`: Sets the request handling logic
- `parseHttpRequest()`: Parses raw HTTP request data
- `formatHttpResponse()`: Formats HTTP responses
#### 2. RemoteControlService (`/services/remoteControlService.ts`)
A service layer that wraps the TCPHttpServer and provides the remote control functionality with predefined routes and HTML interface.
**API Endpoints:**
- `GET /` - Serves HTML remote control interface
- `POST /message` - Receives messages from mobile devices
- `POST /handshake` - Connection handshake for mobile clients
**Features:**
- Built-in HTML interface generation
- JSON message parsing
- Callback-based event handling
- Error handling and validation
#### 3. RemoteControlStore (`/stores/remoteControlStore.ts`)
Zustand store that manages the remote control server state and provides React component integration.
**State Management:**
```typescript
interface RemoteControlState {
isServerRunning: boolean;
serverUrl: string | null;
error: string | null;
isModalVisible: boolean;
lastMessage: string | null;
startServer: () => Promise<void>;
stopServer: () => void;
showModal: () => void;
hideModal: () => void;
setMessage: (message: string) => void;
}
```
## Technical Implementation Details
### HTTP Protocol Implementation
#### Request Parsing
The server implements custom HTTP request parsing that handles:
- HTTP method and URL extraction
- Header parsing with case-insensitive keys
- Body content extraction
- Malformed request detection
#### Response Formatting
Responses are formatted according to HTTP/1.1 specification:
- Status line with appropriate status codes (200, 400, 404, 500)
- Content-Length header calculation
- Connection: close header for stateless operation
- Proper CRLF line endings
### Network Configuration
#### IP Address Detection
The server automatically detects the device's IP address using `@react-native-community/netinfo`:
- Supports WiFi and Ethernet connections
- Validates network connectivity before starting
- Provides clear error messages for network issues
#### Server Binding
- Binds to `0.0.0.0:12346` for universal access
- Fixed port configuration for consistency
- Supports all network interfaces on the device
### Security Considerations
#### Current Implementation
- No authentication mechanism
- Open access on local network
- Basic request validation
- Error handling prevents information disclosure
#### Limitations
- Suitable only for local network use
- No HTTPS/TLS encryption
- No rate limiting or DDoS protection
- Assumes trusted network environment
## Web Interface
### HTML Template
The service provides a responsive web interface optimized for mobile devices:
- Dark theme design matching TV app aesthetics
- Touch-friendly controls with large buttons
- Real-time message sending capability
- Automatic handshake on page load
### JavaScript Functionality
- Automatic handshake POST request on page load
- Message submission via JSON POST requests
- Input field clearing after submission
- Error handling for network issues
## Integration with React Native App
### App Initialization
The server is automatically started when the app launches (`/app/_layout.tsx`):
```typescript
useEffect(() => {
const { setMessage, hideModal } = useRemoteControlStore.getState();
remoteControlService.init({
onMessage: setMessage,
onHandshake: hideModal,
});
useRemoteControlStore.getState().startServer();
return () => {
useRemoteControlStore.getState().stopServer();
};
}, []);
```
### Message Handling
Messages received from mobile devices are processed and displayed as Toast notifications in the TV app, providing visual feedback for remote interactions.
### QR Code Integration
The app generates QR codes containing the server URL (`http://{device_ip}:12346`) for easy mobile device connection via `RemoteControlModal.tsx`.
## Dependencies
### Required Packages
- `react-native-tcp-socket@^6.0.6` - TCP socket implementation
- `@react-native-community/netinfo@^11.3.2` - Network interface information
- `react-native-qrcode-svg@^6.3.1` - QR code generation for UI
### Platform Compatibility
- iOS (Apple TV)
- Android (Android TV)
- Requires network connectivity (WiFi or Ethernet)
## Performance Characteristics
### Connection Handling
- Single-threaded event-driven architecture
- Stateless HTTP connections with immediate closure
- Memory-efficient request buffering
- Graceful error recovery
### Resource Usage
- Minimal CPU overhead for HTTP parsing
- Low memory footprint
- Network I/O bound operations
- Automatic connection cleanup
## Error Handling
### Server Level
- Network binding failures with descriptive messages
- Socket error handling and logging
- Graceful server shutdown procedures
- IP address detection error handling
### Request Level
- Malformed HTTP request detection
- JSON parsing error handling
- 400/404/500 status code responses
- Request timeout and connection cleanup
## Debugging and Monitoring
### Logging
Comprehensive logging throughout the system:
- Server startup/shutdown events
- Client connection/disconnection
- Request processing details
- Error conditions and stack traces
### Console Output Format
```
[TCPHttpServer] Server listening on 192.168.1.100:12346
[RemoteControl] Received request: POST /message
[RemoteControlStore] Server started, URL: http://192.168.1.100:12346
```
## Usage Example
### Starting the Server
```typescript
// Automatic startup via store
const { startServer } = useRemoteControlStore();
await startServer();
// Manual service usage
await remoteControlService.startServer();
```
### Stopping the Server
```typescript
// Via store
const { stopServer } = useRemoteControlStore();
stopServer();
// Direct service call
remoteControlService.stopServer();
```
### Mobile Device Access
1. Ensure mobile device is on the same network as TV
2. Scan QR code displayed in TV app
3. Access web interface at `http://{tv_ip}:12346`
4. Send messages that appear as notifications on TV
## Comparison with Alternatives
### vs react-native-http-bridge
- **Advantages**: More control over HTTP implementation, custom error handling
- **Disadvantages**: More complex implementation, requires manual HTTP parsing
### vs External Backend Server
- **Advantages**: No additional infrastructure, embedded in app
- **Disadvantages**: Limited scalability, single device constraint
## Future Enhancement Opportunities
### Security
- Authentication token implementation
- HTTPS/TLS encryption support
- Request rate limiting
- CORS configuration
### Functionality
- Multi-device support
- WebSocket integration for real-time communication
- File upload/download capabilities
- Advanced remote control commands
### Performance
- Connection pooling
- Request caching
- Compression support
- IPv6 compatibility
## Troubleshooting
### Common Issues
#### "Unable to get IP address" Error
- Verify WiFi/Ethernet connection
- Check network interface availability
- Restart network services
#### Server Won't Start
- Check if port 12346 is already in use
- Verify network permissions
- Restart the application
#### Mobile Device Can't Connect
- Confirm both devices on same network
- Verify firewall settings
- Check IP address in QR code
### Diagnostic Commands
```bash
# Check network connectivity
yarn react-native log-ios # View iOS logs
yarn react-native log-android # View Android logs
# Network debugging
netstat -an | grep 12346 # Check port binding (debugging environment)
```
## Conclusion
The OrionTV native HTTP server implementation provides a robust, embedded solution for remote control functionality without requiring external infrastructure. The custom TCP-based approach offers flexibility and control while maintaining simplicity and performance suitable for TV applications.
The implementation demonstrates sophisticated understanding of HTTP protocol handling, React Native integration, and TV-specific user experience requirements, making it an effective solution for cross-device interaction in smart TV environments.

View File

@@ -1,136 +0,0 @@
# 手机遥控功能实现方案 (V2)
本文档详细描述了在 OrionTV 应用中集成一个基于 **HTTP 请求** 的手机遥控功能的完整方案。
---
## 1. 核心功能与流程
该功能允许用户通过手机浏览器向 TV 端发送文本消息TV 端接收后以 Toast 形式进行展示。服务将在应用启动时自动开启,用户可在设置中找到入口以显示连接二维码。
### 流程图
```mermaid
sequenceDiagram
participant App as App 启动
participant RemoteControlStore as 状态管理 (TV)
participant RemoteControlService as 遥控服务 (TV)
participant User as 用户
participant SettingsUI as 设置界面 (TV)
participant PhoneBrowser as 手机浏览器
App->>RemoteControlStore: App 启动, 触发 startHttpServer()
RemoteControlStore->>RemoteControlService: 启动 HTTP 服务
RemoteControlService-->>RemoteControlStore: 更新服务状态 (IP, Port)
User->>SettingsUI: 打开设置, 点击“手机遥控”按钮
SettingsUI->>RemoteControlStore: 获取服务 URL
RemoteControlStore-->>SettingsUI: 返回 serverUrl
SettingsUI-->>User: 显示二维码弹窗
User->>PhoneBrowser: 扫描二维码
PhoneBrowser->>RemoteControlService: (HTTP GET) 请求网页
RemoteControlService-->>PhoneBrowser: 返回 HTML 页面
User->>PhoneBrowser: 输入文本并发送
PhoneBrowser->>RemoteControlService: (HTTP POST /message) 发送消息
RemoteControlService->>RemoteControlStore: 处理消息 (显示 Toast)
```
---
## 2. 技术选型
* **HTTP 服务**: `react-native-http-bridge`
* **二维码生成**: `react-native-qrcode-svg`
* **网络信息 (IP 地址)**: `@react-native-community/netinfo`
* **状态管理**: `zustand` (项目已集成)
---
## 3. 项目结构变更
### 新增文件
* `services/remoteControlService.ts`: 封装 HTTP 服务的核心逻辑。
* `stores/remoteControlStore.ts`: 使用 Zustand 管理远程控制服务的状态。
* `components/RemoteControlModal.tsx`: 显示二维码和连接信息的弹窗组件。
* `types/react-native-http-bridge.d.ts`: `react-native-http-bridge` 的 TypeScript 类型定义。
### 修改文件
* `app/_layout.tsx`: 在应用根组件中调用服务启动逻辑。
* `components/SettingsModal.tsx`: 添加“手机遥控”按钮,用于触发二维码弹窗。
* `package.json`: 添加新依赖。
---
## 4. 实现细节
### a. 状态管理 (`stores/remoteControlStore.ts`)
创建一个 Zustand store 来管理遥控服务的状态。
* **State**:
* `isServerRunning`: `boolean` - 服务是否正在运行。
* `serverUrl`: `string | null` - 完整的 HTTP 服务 URL (e.g., `http://192.168.1.5:12346`)。
* `error`: `string | null` - 错误信息。
* **Actions**:
* `startServer()`: 异步 action调用 `remoteControlService.startServer` 并更新 state。
* `stopServer()`: 调用 `remoteControlService.stopServer` 并更新 state。
### b. 服务层 (`services/remoteControlService.ts`)
实现服务的启动、停止和消息处理。
* **`startServer()`**:
1. 使用 `@react-native-community/netinfo` 获取 IP 地址。
2. 定义一个包含 `fetch` API 调用逻辑的 HTML 字符串。
3. 使用 `react-native-http-bridge` 在固定端口(如 `12346`)启动 HTTP 服务。
4. 配置 `GET /` 路由以返回 HTML 页面。
5. 配置 `POST /message` 路由来接收手机端发送的消息,并使用 `Toast` 显示。
6. 返回服务器 URL。
* **`stopServer()`**:
1. 调用 `httpBridge.stop()`
### c. UI 集成
* **`app/_layout.tsx`**:
* 在根组件 `useEffect` 中调用 `useRemoteControlStore.getState().startServer()`,实现服务自启。
* **`components/SettingsModal.tsx`**:
* 添加一个 `<StyledButton text="手机遥控" />`
* 点击按钮时,触发 `RemoteControlModal` 的显示。
* **`components/RemoteControlModal.tsx`**:
*`remoteControlStore` 中获取 `serverUrl`
* 如果 `serverUrl` 存在,则使用 `react-native-qrcode-svg``<QRCode />` 组件显示二维码。
* 如果不存在,则显示加载中或错误信息。
### d. 网页内容 (HTML)
一个简单的 HTML 页面,包含一个输入框和一个按钮。
```html
<html>
<head>
<title>OrionTV Remote</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> /* ... some basic styles ... */ </style>
</head>
<body>
<h3>发送消息到 TV</h3>
<input id="text" />
<button onclick="send()">发送</button>
<script>
function send() {
const val = document.getElementById("text").value;
if (val) {
fetch("/message", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: val })
});
document.getElementById("text").value = '';
}
}
</script>
</body>
</html>

View File

@@ -1,131 +0,0 @@
# 设置页面重构方案
## 目标
1. 将设置从弹窗模式改为独立页面
2. 新增直播源配置功能
3. 新增远程输入开关配置
4. 新增播放源启用配置
## 现有架构分析
### 当前设置相关文件:
- `stores/settingsStore.ts` - 设置状态管理目前只有API地址配置
- `components/SettingsModal.tsx` - 设置弹窗组件
- `stores/remoteControlStore.ts` - 远程控制状态管理
### 现有功能:
- API基础地址配置
- 远程控制服务器(但未集成到设置中)
## 重构方案
### 1. 创建独立设置页面
- 新建 `app/settings.tsx` 页面
- 使用 Expo Router 的文件路由系统
- 删除现有的 `SettingsModal.tsx` 组件
### 2. 扩展设置Store
`settingsStore.ts` 中新增以下配置项:
```typescript
interface SettingsState {
// 现有配置
apiBaseUrl: string;
// 新增配置项
liveStreamSources: LiveStreamSource[]; // 直播源配置
remoteInputEnabled: boolean; // 远程输入开关
videoSourceConfig: VideoSourceConfig; // 播放源配置
}
interface LiveStreamSource {
id: string;
name: string;
url: string;
enabled: boolean;
}
interface VideoSourceConfig {
primarySource: string;
fallbackSources: string[];
enabledSources: string[];
}
```
### 3. 设置页面UI结构
```
设置页面 (app/settings.tsx)
├── API 配置区域
│ └── API 基础地址输入框
├── 直播源配置区域
│ ├── 直播源列表
│ ├── 添加直播源按钮
│ └── 编辑/删除直播源功能
├── 远程输入配置区域
│ └── 远程输入开关
└── 播放源配置区域
├── 主播放源选择
├── 备用播放源配置
└── 启用的播放源选择
```
### 4. 组件设计
- 使用 TV 适配的组件和样式
- 实现焦点管理和遥控器导航
- 遵循现有的设计规范ThemedView, ThemedText, StyledButton
### 5. 导航集成
- 在主页面添加设置入口
- 使用 Expo Router 的 router.push('/settings') 进行导航
## 实施步骤
1. **扩展 settingsStore.ts**
- 添加新的状态接口
- 实现新配置项的增删改查方法
- 集成本地存储
2. **创建设置页面**
- 新建 `app/settings.tsx`
- 实现基础页面结构和导航
3. **实现配置组件**
- API 配置组件(复用现有逻辑)
- 直播源配置组件
- 远程输入开关组件
- 播放源配置组件
4. **集成远程控制**
- 将远程控制功能集成到设置页面
- 统一管理所有设置项
5. **更新导航**
- 在主页面添加设置入口
- 移除现有的设置弹窗触发逻辑
6. **测试验证**
- 测试所有配置项的保存和加载
- 验证TV平台的交互体验
- 确保配置项生效
## 技术考虑
### TV平台适配
- 使用 `useTVRemoteHandler` 处理遥控器事件
- 实现合适的焦点管理
- 确保所有交互元素可通过遥控器操作
### 数据持久化
- 使用现有的 `SettingsManager` 进行本地存储
- 确保新配置项能正确保存和恢复
### 向后兼容
- 保持现有API配置功能不变
- 为新配置项提供默认值
- 处理旧版本设置数据的迁移
## 预期收益
1. **更好的用户体验**:独立页面提供更多空间展示配置选项
2. **功能扩展性**:为未来添加更多配置项提供良好基础
3. **代码组织**:将设置相关功能集中管理
4. **TV平台适配**:更好的遥控器交互体验

View File

@@ -1,77 +0,0 @@
# StyledButton 组件设计文档
## 1. 目的
为了统一整个应用中的按钮样式和行为,减少代码重复,并提高开发效率和一致性,我们设计了一个通用的 `StyledButton` 组件。
该组件将取代以下位置的自定义 `Pressable``TouchableOpacity` 实现:
- `app/index.tsx` (分类按钮, 头部图标按钮)
- `components/DetailButton.tsx`
- `components/EpisodeSelectionModal.tsx` (剧集分组按钮, 剧集项按钮, 关闭按钮)
- `components/SettingsModal.tsx` (取消和保存按钮)
- `app/search.tsx` (清除按钮)
- `components/MediaButton.tsx` (媒体控制按钮)
- `components/NextEpisodeOverlay.tsx` (取消按钮)
## 2. API 设计
`StyledButton` 组件将基于 React Native 的 `Pressable` 构建,并提供以下 props
```typescript
import { PressableProps, StyleProp, ViewStyle, TextStyle } from "react-native";
interface StyledButtonProps extends PressableProps {
// 按钮的主要内容,可以是文本或图标等 React 节点
children?: React.ReactNode;
// 如果按钮只包含文本,可以使用此 prop 快速设置
text?: string;
// 按钮的视觉变体,用于应用不同的预设样式
// 'default': 默认灰色背景
// 'primary': 主题色背景,用于关键操作
// 'ghost': 透明背景,通常用于图标按钮
variant?: "default" | "primary" | "ghost";
// 按钮是否处于选中状态
isSelected?: boolean;
// 覆盖容器的样式
style?: StyleProp<ViewStyle>;
// 覆盖文本的样式 (当使用 `text` prop 时生效)
textStyle?: StyleProp<TextStyle>;
}
```
## 3. 样式和行为
### 状态样式:
- **默认状态 (`default`)**:
- 背景色: `#333`
- 边框: `transparent`
- **聚焦状态 (`focused`)**:
- 背景色: `#0056b3` (深蓝色)
- 边框: `#fff`
- 阴影/光晕效果
- 轻微放大 (`transform: scale(1.1)`)
- **选中状态 (`isSelected`)**:
- 背景色: `#007AFF` (亮蓝色)
- **主操作 (`primary`)**:
- 默认背景色: `#007AFF`
- **透明背景 (`ghost`)**:
- 默认背景色: `transparent`
### 结构:
组件内部将使用 `Pressable` 作为根元素,并根据 `focused``isSelected` props 动态计算样式。如果 `children``text` prop 都提供了,`children` 将优先被渲染。
## 4. 实现计划
1. **创建 `components/StyledButton.tsx` 文件**
2. **实现上述 API 和样式逻辑**
3. **逐个重构目标文件**,将原有的 `Pressable`/`TouchableOpacity` 替换为新的 `StyledButton` 组件。
4. **删除旧的、不再需要的样式**
5. **测试所有被修改的界面**,确保按钮的功能和视觉效果符合预期。

View File

@@ -2,7 +2,7 @@
"name": "OrionTV",
"private": true,
"main": "expo-router/entry",
"version": "1.2.8",
"version": "1.2.9",
"scripts": {
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",

View File

@@ -26,7 +26,7 @@ const useAuthStore = create<AuthState>((set) => ({
try {
const serverConfig = useSettingsStore.getState().serverConfig;
if (!serverConfig?.StorageType) {
Toast.show({ type: "error", text1: "请检查网络或者 API 地址是否可用" });
Toast.show({ type: "error", text1: "请检查网络或者服务器地址是否可用" });
return
}
const cookies = await Cookies.get(api.baseURL);

View File

@@ -147,7 +147,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
}
} catch (err: any) {
if (err.message === "API_URL_NOT_SET") {
set({ error: "请点击右上角设置按钮,配置您的 API 地址" });
set({ error: "请点击右上角设置按钮,配置您的服务器地址" });
} else {
set({ error: "加载失败,请重试" });
}