tuna-agent 0.1.137 → 0.1.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,49 @@ const OPENAI_KEY = process.env.OPENAI_API_KEY || '';
14
14
  const YT_DLP = process.env.YT_DLP_BIN || '/home/gatoasang94/.local/bin/yt-dlp';
15
15
  const FFMPEG = process.env.FFMPEG_BIN || '/usr/bin/ffmpeg';
16
16
  const FFPROBE = process.env.FFPROBE_BIN || '/usr/bin/ffprobe';
17
+ // Downloaded source videos are cached by URL hash so re-analyze doesn't
18
+ // re-download (saves bandwidth + time on long clips). relabs01 shares disk
19
+ // with Demucs + the local media server, so the cache is bounded: drop files
20
+ // older than 7 days, then if the total still exceeds 15 GB evict oldest-first.
21
+ const CACHE_DIR = path.join(os.homedir(), '.tuna-analyze-cache');
22
+ const CACHE_MAX_AGE_MS = 7 * 24 * 3600 * 1000;
23
+ const CACHE_MAX_BYTES = 15 * 1024 * 1024 * 1024;
24
+ async function pruneVideoCache() {
25
+ try {
26
+ await fs.mkdir(CACHE_DIR, { recursive: true });
27
+ const names = await fs.readdir(CACHE_DIR);
28
+ const now = Date.now();
29
+ const live = [];
30
+ for (const name of names) {
31
+ const p = path.join(CACHE_DIR, name);
32
+ try {
33
+ const st = await fs.stat(p);
34
+ if (!st.isFile())
35
+ continue;
36
+ if (now - st.mtimeMs > CACHE_MAX_AGE_MS) {
37
+ await fs.rm(p, { force: true });
38
+ continue;
39
+ }
40
+ live.push({ p, size: st.size, mtime: st.mtimeMs });
41
+ }
42
+ catch { /* race with another run deleting it — ignore */ }
43
+ }
44
+ let total = live.reduce((s, f) => s + f.size, 0);
45
+ if (total > CACHE_MAX_BYTES) {
46
+ live.sort((a, b) => a.mtime - b.mtime); // oldest first
47
+ for (const f of live) {
48
+ if (total <= CACHE_MAX_BYTES)
49
+ break;
50
+ try {
51
+ await fs.rm(f.p, { force: true });
52
+ total -= f.size;
53
+ }
54
+ catch { /* ignore */ }
55
+ }
56
+ }
57
+ }
58
+ catch { /* cache pruning is best-effort; never block analysis */ }
59
+ }
17
60
  function run(cmd, args, opts = {}) {
18
61
  return new Promise((resolve, reject) => {
19
62
  const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts });
@@ -224,14 +267,41 @@ export async function analyzeVideo(url, onProgress) {
224
267
  const progress = onProgress || (() => { });
225
268
  const tmpDir = path.join(os.tmpdir(), 'tuna-analyze-' + crypto.randomBytes(6).toString('hex'));
226
269
  await fs.mkdir(tmpDir, { recursive: true });
227
- const videoPath = path.join(tmpDir, 'video.mp4');
270
+ // Video lives in the persistent URL-keyed cache (NOT tmpDir) so re-analyze
271
+ // reuses it. Only audio/frames are per-run + cleaned up in `finally`.
272
+ const urlHash = crypto.createHash('sha1').update(url).digest('hex');
273
+ const videoPath = path.join(CACHE_DIR, `${urlHash}.mp4`);
228
274
  const audioPath = path.join(tmpDir, 'audio.mp3');
229
275
  const framesDir = path.join(tmpDir, 'frames');
230
276
  await fs.mkdir(framesDir, { recursive: true });
231
277
  try {
232
- progress('Đang tải video...');
233
- console.log('[analyze_video] Downloading:', url);
234
- await run(YT_DLP, ['-f', 'best[height<=720]/best', '-o', videoPath, '--no-playlist', '--quiet', url]);
278
+ await pruneVideoCache();
279
+ const cached = await fs.stat(videoPath).then(st => st.isFile() && st.size > 0).catch(() => false);
280
+ if (cached) {
281
+ progress('Dùng video đã tải (cache)...');
282
+ console.log('[analyze_video] Cache HIT:', videoPath);
283
+ // Bump mtime so an actively re-analyzed video isn't evicted by age.
284
+ try {
285
+ const now = new Date();
286
+ await fs.utimes(videoPath, now, now);
287
+ }
288
+ catch { /* ignore */ }
289
+ }
290
+ else {
291
+ progress('Đang tải video...');
292
+ console.log('[analyze_video] Cache MISS, downloading:', url);
293
+ // Download to a temp name then atomically rename in, so a concurrent
294
+ // analyze of the same URL never reads a half-written file.
295
+ const dlTmp = path.join(CACHE_DIR, `${urlHash}.dl-${crypto.randomBytes(4).toString('hex')}.mp4`);
296
+ try {
297
+ await run(YT_DLP, ['-f', 'best[height<=720]/best', '-o', dlTmp, '--no-playlist', '--quiet', url]);
298
+ await fs.rename(dlTmp, videoPath);
299
+ }
300
+ catch (e) {
301
+ await fs.rm(dlTmp, { force: true }).catch(() => { });
302
+ throw e;
303
+ }
304
+ }
235
305
  // Grab the original video title (metadata only, no extra download) so the
236
306
  // clone idea gets a real name instead of "Clone: www.youtube.com".
237
307
  let source_title = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuna-agent",
3
- "version": "0.1.137",
3
+ "version": "0.1.138",
4
4
  "description": "Tuna Agent - Run AI coding tasks on your machine",
5
5
  "bin": {
6
6
  "tuna-agent": "dist/cli/index.js"