Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
* text eol=lf
*.exe binary
*.exe filter=lfs diff=lfs merge=lfs -text
*.png binary
*.jpg binary
*.jpeg binary
Expand All @@ -10,3 +10,10 @@
*.ttf binary
*.woff binary
*.woff2 binary
*.mkv filter=lfs diff=lfs merge=lfs -text
*.avi filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.tar.gz filter=lfs diff=lfs merge=lfs -text
*.dll filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,6 @@ https://github.com/solidSpoon/DashPlayer/assets/39454841/c243796b-7a4c-400c-99c9

https://github.com/solidSpoon/DashPlayer/assets/39454841/55956719-306f-4046-a8b4-243f79029d26


在字幕上点击并滑动可以循环播放多行字幕

https://github.com/user-attachments/assets/82b2cb36-a44b-4729-9b4f-3a440c6deb40


---

# 安装指南
Expand Down
75 changes: 44 additions & 31 deletions scripts/download.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -166,37 +166,27 @@ const extractZip = async (zipPath, destDir) => {
}
// NOTE: `pwsh -Command <string>` consumes the remainder of the command line, so extra args are not reliably
// available in `$args` on CI shells. Use `-File` to pass zip/dest as proper script arguments.
const psTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashplayer-ps-'));
const psFile = path.join(psTmpDir, 'expand-archive.ps1');
fs.writeFileSync(
psFile,
[
'param(',
' [Parameter(Mandatory = $true)][string]$Zip,',
' [Parameter(Mandatory = $true)][string]$Dest',
')',
'Expand-Archive -Force -LiteralPath $Zip -DestinationPath $Dest',
'',
].join('\n')
);
// Avoid `\e` escape sequences when zx renders arguments through a bash-like layer on Windows.
const psFileArg = String(psFile).replaceAll('\\', '/');
const zipArg = String(zipPath).replaceAll('\\', '/');
const destArg = String(destDir).replaceAll('\\', '/');
// However, the PowerShell profile can interfere with -File (see above). To avoid this, we call
// Expand-Archive directly from Node.js child_process instead of spawning a ps1 script.
const { exec } = await import('child_process');
const escapedZip = zipPath.replace(/'/g, "''");
const escapedDest = destDir.replace(/'/g, "''");
const psCmd = `Expand-Archive -Force -LiteralPath '${escapedZip}' -DestinationPath '${escapedDest}'`;
// Try Windows PowerShell first, fall back to pwsh
const execAsync = (cmd) => new Promise((resolve, reject) => {
exec(cmd, { shell: 'cmd.exe', windowsHide: true }, (err, _stdout, stderr) => {
if (err) reject(new Error(stderr || err.message));
else resolve();
});
});
try {
await $`pwsh -NoProfile -ExecutionPolicy Bypass -File ${psFileArg} ${zipArg} ${destArg}`;
await execAsync(`powershell -NoProfile -ExecutionPolicy Bypass -Command "${psCmd}"`);
} catch {
await $`powershell -NoProfile -ExecutionPolicy Bypass -File ${psFileArg} ${zipArg} ${destArg}`;
} finally {
try {
fs.rmSync(psTmpDir, { recursive: true, force: true });
} catch {
// ignore
}
await execAsync(`pwsh -NoProfile -ExecutionPolicy Bypass -Command "${psCmd}"`);
}
return;
} else {
await $`unzip -o ${zipPath} -d ${destDir}`;
}
await $`unzip -o ${zipPath} -d ${destDir}`;
};

const extractTarGz = async (tarPath, destDir) => {
Expand Down Expand Up @@ -432,10 +422,7 @@ const arch = process.env.npm_config_arch || os.arch()
const archKey = arch === 'arm64' ? 'arm64' : 'x64';
const nameRegex = new RegExp(`^whisper\\.cpp-${platKey}-${archKey}\\.(zip|tar\\.gz|tgz)$`, 'i');

let assetUrl = await getLatestReleaseAssetUrl({ owner: 'solidSpoon', repo: 'DashPlayer', nameRegex });
if (!assetUrl) {
assetUrl = await getLatestReleaseAssetUrlIncludingPrerelease({ owner: 'solidSpoon', repo: 'DashPlayer', nameRegex });
}
const assetUrl = await getLatestReleaseAssetUrlIncludingPrerelease({ owner: 'solidSpoon', repo: 'DashPlayer', nameRegex });
if (!assetUrl) {
console.warn(chalk.yellow(`⚠️ whisper.cpp release asset not found for ${platform}/${arch}, skip download`));
} else {
Expand All @@ -444,6 +431,8 @@ const arch = process.env.npm_config_arch || os.arch()
extraCopyPatterns.push(/^(libwhisper|libggml).*\.dylib$/i);
} else if (platform === 'linux') {
extraCopyPatterns.push(/^(libwhisper|libggml).*\.so(\.\d+)?$/i);
} else if (platform === 'win32') {
extraCopyPatterns.push(/\.(dll|lib)$/i);
}
console.info(chalk.blue(`=> whisper.cpp target: ${exePath}`));
await downloadAndExtractBinaryFromArchive({
Expand All @@ -458,3 +447,27 @@ const arch = process.env.npm_config_arch || os.arch()
}
}
}

{
// yt-dlp
const file = platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp';
const res = await verifyExistence({
dir,
file,
});
if (res === 'need_download') {
const downloadUrl = platform === 'win32'
? 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe'
: platform === 'darwin'
? 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos'
: 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp';

console.info(chalk.blue(`=> yt-dlp target: ${path.join(dir, file)}`));
await download({
url: downloadUrl,
dir,
file,
});
fs.chmodSync(path.join(dir, file), 0o755);
}
}
5 changes: 5 additions & 0 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import Convert from '@/fronted/pages/convert/Convert';
import Eb from '@/fronted/components/shared/common/Eb';
import Favorite from '@/fronted/pages/favourite';
import VideoLearningPage from '@/fronted/pages/video-learning';
import DownloadPage from '@/fronted/pages/download/Download';
import { startListeningToDpTasks } from '@/fronted/hooks/useDpTaskCenter';
import { toast as sonnerToast } from 'sonner';
import { backendClient } from '@/fronted/application/bootstrap/backendClient';
Expand Down Expand Up @@ -109,6 +110,10 @@ const App = () => {
path="vocabulary"
element={<Eb key="vocabulary"><VideoLearningPage /></Eb>}
/>
<Route
path="download"
element={<Eb key="download"><DownloadPage /></Eb>}
/>
<Route path="about" element={<Eb key="about"><About /></Eb>} />
<Route
path="settings"
Expand Down
25 changes: 25 additions & 0 deletions src/backend/adapters/controllers/DownloadController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import registerRoute from '@/backend/adapters/ipc/registerRoute';
import Controller from '@/backend/adapters/controllers/Controller';
import { inject, injectable } from 'inversify';
import TYPES from '@/backend/ioc/types';
import DownloadService from '@/backend/application/services/DownloadService';
import { DownloadMetadata } from '@/backend/application/ports/gateways/media/DownloadGateway';

@injectable()
export default class DownloadController implements Controller {
@inject(TYPES.DownloadService)
private downloadService!: DownloadService;

public async start(params: { url: string; savePath?: string }): Promise<number> {
return this.downloadService.startDownload(params.url, params.savePath);
}

public async getMetadata(url: string): Promise<DownloadMetadata> {
return this.downloadService.getMetadata(url);
}

registerRoutes(): void {
registerRoute('download/start', (p) => this.start(p));
registerRoute('download/get-metadata', (p) => this.getMetadata(p));
}
}
15 changes: 15 additions & 0 deletions src/backend/adapters/controllers/VocabularyController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,24 @@ export default class VocabularyController implements Controller {
return this.vocabularyService.importWords(params.filePath);
}

public async addWord(params: { word: string; translate?: string }) {
return this.vocabularyService.addWord(params);
}

public async deleteWord(params: { word: string }) {
return this.vocabularyService.deleteWord(params.word);
}

public async refreshTranslation(params: { word: string }) {
return this.vocabularyService.refreshWordTranslation(params.word);
}

registerRoutes(): void {
registerRoute('vocabulary/get-all', (p) => this.getAllWords(p));
registerRoute('vocabulary/export-template', () => this.exportTemplate());
registerRoute('vocabulary/import', (p) => this.importWords(p));
registerRoute('vocabulary/add', (p) => this.addWord(p));
registerRoute('vocabulary/delete', (p) => this.deleteWord(p));
registerRoute('vocabulary/refresh-translation', (p) => this.refreshTranslation(p));
}
}
3 changes: 0 additions & 3 deletions src/backend/adapters/ipc/registerRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,6 @@ export default function registerRoute<K extends keyof ApiMap>(path: K, func: Api
const costMs = Date.now() - start;
const message = error instanceof Error ? error.message : String(error);
logger.error(`api-error path=${String(path)} costMs=${costMs} message=${preview(message, 300)}`, { error });
container
.get<RendererEvents>(TYPES.RendererEvents)
.error(error instanceof Error ? error : new Error(String(error)));
throw error;
}
});
Expand Down
19 changes: 19 additions & 0 deletions src/backend/application/ports/gateways/media/DownloadGateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface DownloadMetadata {
title: string;
thumbnail?: string;
url: string;
duration?: number;
}

export interface DownloadOptions {
taskId: number;
url: string;
savePath: string;
onProgress?: (percent: number, speed?: string, eta?: string) => void;
onCancelable?: (cancel: () => void) => void;
}

export default interface DownloadGateway {
getMetadata(url: string): Promise<DownloadMetadata>;
download(options: DownloadOptions): Promise<void>;
}
21 changes: 21 additions & 0 deletions src/backend/application/ports/repositories/WordsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,25 @@ export interface GetAllWordsQuery {
export default interface WordsRepository {
getAll(query?: GetAllWordsQuery): Promise<Word[]>;
replaceAll(values: InsertWord[]): Promise<void>;
/**
* 添加单词到生词本。
* word 字段在 DB 层有 UNIQUE 约束,重复插入使用 onConflictDoNothing 静默忽略。
*
* @param word 单词原文(调用方负责归一化处理)。
* @param translate 可选释义。
*/
addWord(word: string, translate?: string): Promise<void>;

/**
* 更新单词释义。
* @param word 单词原文。
* @param translate 释义内容。
*/
updateWord(word: string, translate: string): Promise<void>;

/**
* 从生词本中删除单词。
* @param word 单词原文。
*/
deleteWord(word: string): Promise<void>;
}
6 changes: 3 additions & 3 deletions src/backend/application/services/CheckUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,10 @@ export const checkUpdate = async (): Promise<UpdateCheckResult> => {
return cache;
}

const releases: Release[] = listResponse.data
.filter(isStableRelease)
const releases: Release[] = (listResponse.data as any[])
.filter((r: any) => isStableRelease(r))
.map((release: { html_url: string; tag_name: string; body: string }) => toRelease(release))
.filter(release => isNewerVersion(release.version, currentVersion))
.filter((release: Release) => isNewerVersion(release.version, currentVersion))
.sort(sortByVersionDesc);

logger.info('fetched releases from github', { count: releases.length });
Expand Down
6 changes: 6 additions & 0 deletions src/backend/application/services/DownloadService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { DownloadMetadata } from '@/backend/application/ports/gateways/media/DownloadGateway';

export default interface DownloadService {
getMetadata(url: string): Promise<DownloadMetadata>;
startDownload(url: string, savePath?: string): Promise<number>;
}
7 changes: 6 additions & 1 deletion src/backend/application/services/FfmpegService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,12 @@ export default interface FfmpegService {
/**
* Convert audio file to WAV format
*/
convertToWav(inputPath: string, outputPath: string): Promise<void>;
convertToWav(args: {
taskId?: number,
inputPath: string,
outputPath: string,
onProgress?: (progress: number) => void
}): Promise<void>;

/**
* NEW: Trim audio by time range (re-encode to mp3 for compatibility)
Expand Down
2 changes: 1 addition & 1 deletion src/backend/application/services/TtsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class TtsService {
if (StrUtil.isBlank(storeGet('apiKeys.openAi.key')) || StrUtil.isBlank(storeGet('apiKeys.openAi.endpoint'))) {
throw new Error('OpenAI API key or endpoint is not set');
}
const url = this.joinUrl(storeGet('apiKeys.openAi.endpoint'), '/v1/audio/speech');
const url = this.joinUrl(storeGet('apiKeys.openAi.endpoint').replace(/\/v1$/, ''), '/v1/audio/speech');
const headers = {
'Authorization': `Bearer ${storeGet('apiKeys.openAi.key')}`,
'User-Agent': 'Apifox/1.0.0 (https://apifox.com)',
Expand Down
20 changes: 20 additions & 0 deletions src/backend/application/services/VocabularyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,24 @@ export default interface VocabularyService {
getAllWords(params: GetAllWordsParams): Promise<GetAllWordsResult>;
exportTemplate(): Promise<ExportTemplateResult>;
importWords(filePath: string): Promise<ImportWordsResult>;
/**
* 将单词加入收藏生词本。
*
* @param params.word 单词原文(建议已归一化)。
* @param params.translate 可选释义。
* @returns 操作结果。
*/
addWord(params: { word: string; translate?: string }): Promise<{ success: boolean; message?: string }>;

/**
* 从生词本中删除选定单词。
* @param word 单词原文。
*/
deleteWord(word: string): Promise<{ success: boolean; message?: string }>;

/**
* 重新获取并刷新指定单词的翻译。
* @param word 单词原文。
*/
refreshWordTranslation(word: string): Promise<{ success: boolean; translate?: string; message?: string }>;
}
69 changes: 69 additions & 0 deletions src/backend/application/services/impl/DownloadServiceImpl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { inject, injectable } from 'inversify';
import DownloadService from '@/backend/application/services/DownloadService';
import DownloadGateway, { DownloadMetadata } from '@/backend/application/ports/gateways/media/DownloadGateway';
import DpTaskService from '@/backend/application/services/DpTaskService';
import TYPES from '@/backend/ioc/types';
import StorageDirectoryProvider, { StorageDirectoryTarget } from '@/backend/application/ports/gateways/storage/StorageDirectoryProvider';
import path from 'path';
import fs from 'fs';
import { getMainLogger } from '@/backend/infrastructure/logger';

@injectable()
export default class DownloadServiceImpl implements DownloadService {
private logger = getMainLogger('DownloadServiceImpl');

constructor(
@inject(TYPES.DownloadGateway) private downloadGateway: DownloadGateway,
@inject(TYPES.DpTaskService) private dpTaskService: DpTaskService,
@inject(TYPES.StorageDirectoryProvider) private storageDirectoryProvider: StorageDirectoryProvider,
) {}

public async getMetadata(url: string): Promise<DownloadMetadata> {
return this.downloadGateway.getMetadata(url);
}

public async startDownload(url: string, savePath?: string): Promise<number> {
const taskId = await this.dpTaskService.create();
const metadata = await this.getMetadata(url);

const libraryPath = await this.storageDirectoryProvider.provideDirectory(StorageDirectoryTarget.VIDEOS);
const safeTitle = metadata.title.replace(/[\\/:*?"<>|]/g, '_');
const finalSavePath = savePath || path.join(libraryPath, `${safeTitle}.mp4`);

// Ensure directory exists
const dir = path.dirname(finalSavePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}

this.downloadGateway.download({
taskId,
url,
savePath: finalSavePath,
onProgress: (percent, speed, eta) => {
this.dpTaskService.process(taskId, {
progress: `下载中: ${percent}% ${speed ? `(${speed})` : ''} ${eta ? `ETA: ${eta}` : ''}`,
result: JSON.stringify({ percent, speed, eta, path: finalSavePath })
});
},
onCancelable: (cancel) => {
this.dpTaskService.registerTask(taskId, {
cancel: () => cancel()
});
}
}).then(() => {
this.dpTaskService.finish(taskId, {
progress: '下载完成',
result: JSON.stringify({ percent: 100, path: finalSavePath })
});
}).catch((err) => {
this.logger.error('download failed', { error: err.message });
this.dpTaskService.fail(taskId, {
progress: `下载失败: ${err.message}`,
result: JSON.stringify({ error: err.message })
});
});

return taskId;
}
}
Loading