Skip to content

Commit

Permalink
refactor: 重构视频搜索功能,支持插件化加载自定义搜索 API
Browse files Browse the repository at this point in the history
  • Loading branch information
renxia committed Aug 1, 2024
1 parent 7358a53 commit 73e6736
Show file tree
Hide file tree
Showing 9 changed files with 370 additions and 279 deletions.
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ program
.command('search [keyword]')
.alias('s')
.option('-u,--url <api...>', '影视搜索的接口地址(m3u8采集站标准接口)')
.option('-R,--remote-config-url <url>', '自定义远程配置加载地址。默认从主仓库配置读取')
.option('-d, --apidir <dirpath>', '指定自定义视频搜索 api 所在的目录或具体路径')
// .option('-R,--remote-config-url <url>', '自定义远程配置加载地址。默认从主仓库配置读取')
.description('m3u8视频在线搜索与下载')
.action(async (keyword, options: { url?: string[]; remoteConfigUrl?: string }) => {
await VideoSerachAndDL(keyword, options, getOptions());
Expand Down
6 changes: 4 additions & 2 deletions src/lib/local-play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function localPlay(m3u8Info: TsItemInfo[], options: M3u8DLOptions)
return info;
}

export function toLocalM3u8(m3u8Info: TsItemInfo[], m3u8FilePath: string, host = '.') {
export function toLocalM3u8(m3u8Info: TsItemInfo[], m3u8FilePath: string, host = '') {
const m3u8ContentList = [
`#EXTM3U`,
`#EXT-X-VERSION:3`,
Expand All @@ -37,8 +37,10 @@ export function toLocalM3u8(m3u8Info: TsItemInfo[], m3u8FilePath: string, host =
// `#EXT-X-KEY:METHOD=AES-128,URI="/api/aes/enc.key"`,
];

if (host && !host.endsWith('/')) host += '/';

m3u8Info.forEach(d => {
if (d.tsOut) m3u8ContentList.push(`#EXTINF:${Number(d.duration).toFixed(6)},`, `${host}/${basename(d.tsOut)}`);
if (d.tsOut) m3u8ContentList.push(`#EXTINF:${Number(d.duration).toFixed(6)},`, `${host}${basename(d.tsOut)}`);
});

m3u8ContentList.push(`#EXT-X-ENDLIST`);
Expand Down
95 changes: 95 additions & 0 deletions src/lib/search-api/ApiManage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { prompt } from 'enquirer';
import type { SearchApi, VideoSearchResult } from '../../types';
import { findFiles, logger } from '../utils';
import { CommSearchApi } from './CommSearchApi';

export const apiManage = {
api: new Map<string | number, SearchApi>(),
current: null as SearchApi,
load(apidir?: string, force = false) {
const files: string[] = findFiles(apidir, (filepath, s) => !s.isFile() || /\.c?js/.test(filepath));

for (const filepath of files) {
/* eslint-disable @typescript-eslint/no-var-requires */
const sApi = require(filepath);
this.add(sApi.default || sApi, force);
}
},
/** 添加 API 到列表中 */
add(sApi: SearchApi | { api: string; desc?: string; enable?: boolean; key?: string | number }, force = false) {
if (Array.isArray(sApi)) return sApi.forEach(d => this.add(d));

if (sApi.api?.startsWith('http') && !('search' in sApi)) sApi = new CommSearchApi(sApi) as SearchApi;

if (this.validate(sApi as SearchApi) && (force || !this.api.has(sApi.key))) {
this.api.set(sApi.key, sApi as SearchApi);
logger.debug('添加Api:', sApi.desc || sApi.key);
}
},
/** API 有效性校验 */
validate(sApi: SearchApi, desc?: string): sApi is SearchApi {
if (!sApi) return false;

const requiredKeys = ['enable', 'key', 'search', 'detail'];

if (!sApi.key) sApi.key = sApi.desc;

for (const key of requiredKeys) {
if (!(key in sApi)) {
logger.warn(`【API校验不通过】${desc} 缺少关键属性 ${key}`);
return false;
}

if ((key === 'search' || key === 'detail') && typeof sApi[key] !== 'function') return false;
}

return sApi.enable !== false;
},
/** 选择一个 API */
async select() {
if (!this.api.size) {
logger.error('没有可用的 API,请配置或指定 url、apidir 参数');
process.exit(-1);
}

if (this.api.size === 1) {
this.current = [...this.api.values()][0];
return;
}

const apis = [...this.api.values()];
const v = await prompt<{ k: string }>({
type: 'select',
name: 'k',
message: '请选择 API 站点',
choices: apis.map(d => ({ name: String(d.key), message: d.desc })),
validate: value => value.length >= 1,
});

this.current = apis.find(d => String(d.key) === v.k);
},
async search(wd: string, api?: SearchApi) {
const result: VideoSearchResult['list'] = [];
try {
if (api) return (await api.search(wd)).list;

for (api of this.api.values()) {
const r = await api.search(wd);
if (Array.isArray(r.list)) {
r.list.forEach(d => {
d.api_key = api.key;
result.push(d);
});
}
}
} catch (error) {
logger.error('搜索失败!', (error as Error).message);
}

return result;
},
detail(info: VideoSearchResult['list'][0]) {
const api = this.api.get(info.api_key) || this.current;
return api.detail(info.vod_id);
},
};
90 changes: 90 additions & 0 deletions src/lib/search-api/CommSearchApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Request } from '@lzwme/fe-utils';
import type { SearchApi, VideoDetailsResult, VideoSearchResult } from '../../types';
import { type M3u8StorConfig } from '../storage.js';

const req = new Request(null, {
'content-type': 'application/json; charset=UTF-8',
});

export interface VSOptions {
/** 采集站地址 */
api?: string;
/** 站点描述 */
desc?: string;
/** 是否启用 */
enable?: 0 | 1 | boolean;
}

/**
* 基于采集站点 API 的通用搜索
* @example
* ```ts
* const v = new CommSearchApi({ api: 'https://api.xinlangapi.com/xinlangapi.php/provide/vod/' });
* v.search('三体')
* .then(d => {
* console.log(d.total, d.list);
* return v.getVideoList(d.list[0].vod_id);
* })
* .then(d => {
* console.log('detail:', d.total, d.list[0]);
* });
* ```
*/
export class CommSearchApi implements SearchApi {
protected currentUrl: M3u8StorConfig['remoteConfig']['data']['apiSites'][0];
public apiMap = new Map<string, M3u8StorConfig['remoteConfig']['data']['apiSites'][0]>();
public get desc() {
return this.options.desc || this.options.api;
}
public get key() {
return this.options.api;
}
public get enable() {
return this.options.api && this.options.enable !== false;
}
constructor(protected options: VSOptions = {}) {
if (options.api) options.api = this.formatUrl(options.api)[0];
this.options = options;
}
async search(wd: string, api = this.options.api) {
let { data } = await req.get<VideoSearchResult>(api, { wd }, null, { rejectUnauthorized: false });
if (typeof data == 'string') data = JSON.parse(data) as VideoSearchResult;
return data;
}
async detail(id: string | number, api = this.options.api) {
return this.getVideoList(id, api);
}
/** 按 id 取列表(每一项中包含了更为详细的内容) */
async getVideoList(ids: number | string | (number | string)[], api = this.options.api) {
let { data } = await req.get<VideoDetailsResult>(
api,
{
ac: 'videolist',
ids: Array.isArray(ids) ? ids.join(',') : ids,
},
null,
{ rejectUnauthorized: false }
);

if (typeof data == 'string') data = JSON.parse(data) as VideoDetailsResult;

return data;
}
private formatUrl(url: string | string[]) {
const urls: string[] = [];
if (!url) return urls;
if (typeof url === 'string') url = [url];

for (let u of url) {
u = String(u || '').trim();

if (u.startsWith('http')) {
if (u.endsWith('provide/')) u += 'vod/';
if (u.endsWith('provide/vod')) u += '/';
urls.push(u.replace('/at/xml/', '/'));
}
}

return [...new Set(urls)];
}
}
10 changes: 6 additions & 4 deletions src/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { LiteStorage } from '@lzwme/fe-utils';
import { resolve } from 'node:path';
import { homedir } from 'node:os';
import { type VSOptions } from './video-search';
import type { M3u8DLOptions, VideoDetails } from '../types';

export interface M3u8StorConfig extends VSOptions {
export interface M3u8StorConfig {
/** 播放地址缓存 */
api?: string[];
/** 远程加载的配置信息 */
/**
* 远程加载的配置信息
* @deprecated
*/
remoteConfig?: {
/** 最近一次更新的时间。默认缓存1小时 */
updateTime?: number;
Expand All @@ -25,7 +27,7 @@ export interface M3u8StorConfig extends VSOptions {
latestSearchDL?: {
keyword: string;
urls: string[];
info: VideoDetails;
info: Partial<VideoDetails>;
dlOptions: M3u8DLOptions;
};
}
Expand Down
22 changes: 22 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { execSync, NLogger, color, Request, retry } from '@lzwme/fe-utils';
import { existsSync, readdirSync, Stats, statSync } from 'node:fs';
import { resolve } from 'node:path';

export const request = new Request('', {
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
Expand Down Expand Up @@ -28,3 +30,23 @@ export function isSupportFfmpeg() {
if (null == _isSupportFfmpeg) _isSupportFfmpeg = execSync('ffmpeg -version').stderr === '';
return _isSupportFfmpeg;
}

export function findFiles(apidir?: string, validate?: (filepath: string, stat: Stats) => boolean) {
const files: string[] = [];

if (apidir && existsSync(apidir)) {
const stat = statSync(apidir);

if (!validate || validate(apidir, stat)) {
if (stat.isFile()) {
files.push(resolve(apidir));
} else if (stat.isDirectory()) {
readdirSync(apidir).forEach(filename => {
findFiles(resolve(apidir, filename)).forEach(f => files.push(f));
});
}
}
}

return files;
}
Loading

0 comments on commit 73e6736

Please sign in to comment.