Skip to content

Commit dd5aae6

Browse files
committed
feat: implement rich CLI interface with React components
- Add ConversionApp.tsx: Main interactive conversion interface - Add FileList.tsx: File status display component - Add ProgressBar.tsx: Progress tracking component - Add Spinner.tsx: Loading indicator component - Add cli-ink.ts: Rich CLI entry point using Ink framework - Implement real-time file status updates and progress tracking - Add interactive file selection and conversion management
1 parent 0166b47 commit dd5aae6

File tree

5 files changed

+487
-0
lines changed

5 files changed

+487
-0
lines changed

src/cli-ink.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { promises as fs } from 'node:fs';
2+
import { readFileSync } from 'node:fs';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
import { Command } from 'commander';
6+
import { Effect } from 'effect';
7+
import { render } from 'ink';
8+
import React from 'react';
9+
import { convertM4aToMp3 } from './converter.js';
10+
import { type CliArgs, CliArgsSchema } from './schemas.js';
11+
import { ConversionApp } from './ui/ConversionApp.js';
12+
13+
/**
14+
* 指定されたディレクトリからm4aファイルを再帰的に検索する
15+
*/
16+
const findM4aFiles = (dirPath: string, recursive = false): Effect.Effect<string[], Error, never> =>
17+
Effect.gen(function* () {
18+
const files = yield* Effect.tryPromise({
19+
try: () => fs.readdir(dirPath, { withFileTypes: true }),
20+
catch: () => new Error(`ディレクトリの読み取りに失敗しました: ${dirPath}`),
21+
});
22+
23+
const m4aFiles: string[] = [];
24+
const subdirs: string[] = [];
25+
26+
for (const file of files) {
27+
const fullPath = path.join(dirPath, file.name);
28+
29+
if (file.isFile() && path.extname(file.name).toLowerCase() === '.m4a') {
30+
m4aFiles.push(fullPath);
31+
} else if (file.isDirectory() && recursive) {
32+
subdirs.push(fullPath);
33+
}
34+
}
35+
36+
if (recursive && subdirs.length > 0) {
37+
const subdirResults = yield* Effect.all(subdirs.map((subdir) => findM4aFiles(subdir, true)));
38+
39+
for (const subdirFiles of subdirResults) {
40+
m4aFiles.push(...subdirFiles);
41+
}
42+
}
43+
44+
return m4aFiles;
45+
});
46+
47+
/**
48+
* 出力ディレクトリが存在しない場合は作成する
49+
*/
50+
const ensureOutputDir = (outputDir: string): Effect.Effect<void, Error, never> =>
51+
Effect.gen(function* () {
52+
yield* Effect.tryPromise({
53+
try: () => fs.mkdir(outputDir, { recursive: true }),
54+
catch: () => new Error(`出力ディレクトリの作成に失敗しました: ${outputDir}`),
55+
});
56+
});
57+
58+
/**
59+
* CLI引数を検証し、パースする
60+
*/
61+
const validateAndParseArgs = (
62+
inputs: string[],
63+
options: {
64+
output?: string;
65+
quality?: number;
66+
bitrate?: number;
67+
'sample-rate'?: number;
68+
channels?: number;
69+
recursive?: boolean;
70+
'output-dir'?: string;
71+
jobs?: number;
72+
}
73+
): Effect.Effect<CliArgs, Error, never> =>
74+
Effect.gen(function* () {
75+
const cliArgs: CliArgs = {
76+
inputs,
77+
output: options.output,
78+
quality: options.quality,
79+
bitrate: options.bitrate,
80+
'sample-rate': options['sample-rate'],
81+
channels: options.channels,
82+
recursive: options.recursive,
83+
'output-dir': options['output-dir'],
84+
jobs: options.jobs ?? 10,
85+
};
86+
87+
return yield* Effect.try({
88+
try: () => CliArgsSchema.parse(cliArgs),
89+
catch: (error) => new Error(`引数の検証に失敗しました: ${error}`),
90+
});
91+
});
92+
93+
/**
94+
* Inkを使ったリッチなUIで複数のファイルパスを処理する
95+
*/
96+
const processMultipleInputsWithInk = (
97+
inputPaths: string[],
98+
validatedArgs: CliArgs
99+
): Effect.Effect<void, Error, never> =>
100+
Effect.gen(function* () {
101+
// 入力パスが空の場合は何もしない
102+
if (!inputPaths || inputPaths.length === 0) {
103+
return;
104+
}
105+
106+
// ヘルプオプションが含まれている場合は何もしない
107+
if (inputPaths.some(path => path.startsWith('--'))) {
108+
return;
109+
}
110+
111+
const allM4aFiles: string[] = [];
112+
const outputDir = validatedArgs['output-dir'];
113+
114+
// 各入力パスを処理
115+
for (const inputPath of inputPaths) {
116+
const resolvedPath = path.resolve(inputPath);
117+
const stat = yield* Effect.tryPromise({
118+
try: () => fs.stat(resolvedPath),
119+
catch: () => new Error(`ファイルまたはディレクトリが見つかりません: ${resolvedPath}`),
120+
});
121+
122+
if (stat.isFile()) {
123+
if (path.extname(resolvedPath).toLowerCase() === '.m4a') {
124+
allM4aFiles.push(resolvedPath);
125+
}
126+
} else if (stat.isDirectory()) {
127+
const m4aFiles = yield* findM4aFiles(resolvedPath, validatedArgs.recursive || false);
128+
allM4aFiles.push(...m4aFiles);
129+
}
130+
}
131+
132+
if (allM4aFiles.length === 0) {
133+
throw new Error('m4aファイルが見つかりませんでした。');
134+
}
135+
136+
// 出力ディレクトリの設定と作成
137+
if (outputDir) {
138+
yield* ensureOutputDir(outputDir);
139+
}
140+
141+
// ファイルリストを初期化
142+
const initialFiles = allM4aFiles.map(filePath => ({
143+
path: path.relative(process.cwd(), filePath),
144+
status: 'pending' as const,
145+
}));
146+
147+
// Inkアプリのレンダリング
148+
const { waitUntilExit } = render(
149+
React.createElement(ConversionApp, {
150+
totalFiles: allM4aFiles.length,
151+
files: initialFiles,
152+
onComplete: () => {
153+
// アプリ終了処理
154+
},
155+
})
156+
);
157+
158+
// 並列処理用の変換タスクを作成
159+
const conversionTasks = allM4aFiles.map((inputFilePath) => {
160+
let outputFilePath: string;
161+
if (outputDir) {
162+
const fileName = `${path.basename(inputFilePath, path.extname(inputFilePath))}.mp3`;
163+
outputFilePath = path.join(outputDir, fileName);
164+
} else {
165+
const outputFileName = `${path.basename(inputFilePath, path.extname(inputFilePath))}.mp3`;
166+
outputFilePath = path.join(path.dirname(inputFilePath), outputFileName);
167+
}
168+
169+
const conversionOptions = {
170+
inputFile: inputFilePath,
171+
outputFile: outputFilePath,
172+
quality: validatedArgs.quality,
173+
bitrate: validatedArgs.bitrate,
174+
sampleRate: validatedArgs['sample-rate'],
175+
channels: validatedArgs.channels,
176+
};
177+
178+
return Effect.gen(function* () {
179+
const displayPath = path.relative(process.cwd(), inputFilePath);
180+
181+
// ファイル処理開始を通知
182+
if (global.updateFileStatus) {
183+
global.updateFileStatus(displayPath, 'processing', undefined);
184+
}
185+
186+
try {
187+
yield* convertM4aToMp3(conversionOptions);
188+
189+
// ファイル処理完了を通知
190+
if (global.updateFileStatus) {
191+
global.updateFileStatus(displayPath, 'completed', undefined);
192+
}
193+
} catch (error) {
194+
// ファイル処理エラーを通知
195+
if (global.updateFileStatus) {
196+
global.updateFileStatus(displayPath, 'error', error instanceof Error ? error.message : 'Unknown error');
197+
}
198+
throw error;
199+
}
200+
});
201+
});
202+
203+
// 全ての変換を並列実行
204+
const concurrency = validatedArgs.jobs || 10;
205+
206+
try {
207+
yield* Effect.all(conversionTasks, { concurrency });
208+
209+
// 全ての変換が完了したら少し待ってから終了
210+
yield* Effect.sleep('2 seconds');
211+
} catch (error) {
212+
// エラーが発生してもUIは継続表示
213+
console.error('変換中にエラーが発生しました:', error);
214+
}
215+
216+
// Inkアプリの終了を待つ
217+
yield* Effect.promise(() => waitUntilExit());
218+
});
219+
220+
/**
221+
* Commander.jsを使用したCLIプログラムの設定
222+
*/
223+
const program = new Command();
224+
225+
// package.jsonからバージョンを読み込み
226+
const __filename = fileURLToPath(import.meta.url);
227+
const __dirname = path.dirname(__filename);
228+
const packageJson = JSON.parse(readFileSync(path.join(__dirname, '../package.json'), 'utf8'));
229+
230+
program
231+
.name('effect-audio')
232+
.description('High-performance M4A to MP3 converter built with Effect and TypeScript')
233+
.version(packageJson.version);
234+
235+
program
236+
.argument('<inputs...>', '変換するm4aファイルまたはディレクトリのパス(複数指定可能)')
237+
.option('-o, --output <path>', '出力ファイルのパス')
238+
.option('-q, --quality <number>', '音質 (0-9, デフォルト: 2)', '2')
239+
.option('-b, --bitrate <number>', 'ビットレート (32-320 kbps)')
240+
.option('-s, --sample-rate <number>', 'サンプルレート (8000-192000 Hz)')
241+
.option('-c, --channels <number>', 'チャンネル数 (1-2, デフォルト: 2)', '2')
242+
.option('-r, --recursive', 'ディレクトリを再帰的に処理')
243+
.option('-d, --output-dir <path>', '出力ディレクトリ')
244+
.option('-j, --jobs <number>', '並列処理数 (デフォルト: 10)', '10')
245+
.action(
246+
async (
247+
inputs: string[],
248+
options: {
249+
output?: string;
250+
quality?: number;
251+
bitrate?: number;
252+
'sample-rate'?: number;
253+
channels?: number;
254+
recursive?: boolean;
255+
'output-dir'?: string;
256+
jobs?: number;
257+
}
258+
) => {
259+
const runConversion = Effect.gen(function* () {
260+
const validatedArgs = yield* validateAndParseArgs(inputs, options);
261+
262+
// InkベースのリッチUIで処理
263+
yield* processMultipleInputsWithInk(validatedArgs.inputs, validatedArgs);
264+
});
265+
266+
const result = await Effect.runPromise(
267+
runConversion.pipe(
268+
Effect.catchAll((error) => {
269+
console.error('❌ エラー:', error.message);
270+
return Effect.succeed(undefined);
271+
})
272+
)
273+
);
274+
275+
process.exit(result === undefined ? 0 : 1);
276+
}
277+
);
278+
279+
program.parse();

src/ui/ConversionApp.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type React from 'react';
2+
import { useState, useEffect, useCallback } from 'react';
3+
import { Box, Text } from 'ink';
4+
import { ProgressBar } from './ProgressBar.js';
5+
import { FileList } from './FileList.js';
6+
import { Spinner } from './Spinner.js';
7+
import type { ConversionAppProps, FileItem } from '../schemas.js';
8+
9+
export const ConversionApp: React.FC<ConversionAppProps> = ({
10+
totalFiles,
11+
files: initialFiles,
12+
onComplete,
13+
}) => {
14+
const [files, setFiles] = useState<FileItem[]>(initialFiles);
15+
const [completedCount, setCompletedCount] = useState(0);
16+
const [currentFile, setCurrentFile] = useState<string>('');
17+
const [isComplete, setIsComplete] = useState(false);
18+
19+
const progress = totalFiles > 0 ? Math.round((completedCount / totalFiles) * 100) : 0;
20+
21+
// ファイルリストの初期化
22+
useEffect(() => {
23+
setFiles(initialFiles);
24+
}, [initialFiles]);
25+
26+
// ファイル更新のハンドラー
27+
const updateFileStatus = useCallback((filePath: string, status: FileItem['status'], error?: string) => {
28+
setFiles(prevFiles =>
29+
prevFiles.map(file =>
30+
file.path === filePath
31+
? { ...file, status, error }
32+
: file
33+
)
34+
);
35+
36+
if (status === 'processing') {
37+
setCurrentFile(filePath);
38+
} else if (status === 'completed' || status === 'error') {
39+
setCurrentFile('');
40+
setCompletedCount(prev => prev + 1);
41+
}
42+
}, []);
43+
44+
// グローバルな更新関数を設定(Node.js環境用)
45+
useEffect(() => {
46+
global.updateFileStatus = updateFileStatus;
47+
}, [updateFileStatus]);
48+
49+
// 完了チェック
50+
useEffect(() => {
51+
if (completedCount === totalFiles && totalFiles > 0) {
52+
setIsComplete(true);
53+
setTimeout(() => {
54+
onComplete();
55+
}, 1000);
56+
}
57+
}, [completedCount, totalFiles, onComplete]);
58+
59+
if (isComplete) {
60+
return (
61+
<Box flexDirection="column" alignItems="center" marginY={2}>
62+
<Text color="green" bold>
63+
🎉 全ての変換が完了しました!
64+
</Text>
65+
<Text color="cyan">
66+
処理したファイル数: {totalFiles}
67+
</Text>
68+
</Box>
69+
);
70+
}
71+
72+
return (
73+
<Box flexDirection="column">
74+
<Box marginBottom={1}>
75+
<Text color="green" bold>
76+
{totalFiles}個のm4aファイルが見つかりました
77+
</Text>
78+
</Box>
79+
80+
<ProgressBar
81+
progress={progress}
82+
total={totalFiles}
83+
completed={completedCount}
84+
currentFile={currentFile}
85+
/>
86+
87+
<FileList files={files} />
88+
89+
{totalFiles === 0 && (
90+
<Box marginY={2}>
91+
<Spinner message="ファイルを検索中..." />
92+
</Box>
93+
)}
94+
</Box>
95+
);
96+
};

0 commit comments

Comments
 (0)