atris 3.49.0 → 3.50.0
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.
- package/commands/youtube.js +326 -1
- package/package.json +1 -1
package/commands/youtube.js
CHANGED
|
@@ -26,9 +26,14 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
|
|
|
26
26
|
output('');
|
|
27
27
|
output(`Usage: ${commandName} notes <youtube-url> [engine]`);
|
|
28
28
|
output(` ${commandName} process <youtube-url> [options]`);
|
|
29
|
+
output(` ${commandName} watch add <channel-url-or-@handle>`);
|
|
30
|
+
output(` ${commandName} watch list`);
|
|
31
|
+
output(` ${commandName} watch remove <number>`);
|
|
32
|
+
output(` ${commandName} watch tick`);
|
|
29
33
|
output(` ${commandName} <youtube-url> [options]`);
|
|
30
34
|
output('');
|
|
31
35
|
output('notes = free local notes, process = 5 credits cloud knowledge');
|
|
36
|
+
output('watch = subscribed channels turn into briefs without a human');
|
|
32
37
|
output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
|
|
33
38
|
output('Falls back to cloud video processing when local captions are unavailable.');
|
|
34
39
|
output('');
|
|
@@ -47,6 +52,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
|
|
|
47
52
|
output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
|
|
48
53
|
output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
|
|
49
54
|
output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
|
|
55
|
+
output(` ${commandName} watch add @veritasium`);
|
|
56
|
+
output(` ${commandName} watch tick`);
|
|
50
57
|
output('');
|
|
51
58
|
}
|
|
52
59
|
|
|
@@ -82,7 +89,7 @@ function parseYoutubeArgs(argv = []) {
|
|
|
82
89
|
return options;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
if (['process', 'analyze'
|
|
92
|
+
if (['process', 'analyze'].includes(args[0])) {
|
|
86
93
|
args.shift();
|
|
87
94
|
}
|
|
88
95
|
|
|
@@ -506,6 +513,309 @@ function fileBriefFromNotes({ cwd, url, workDir, now } = {}) {
|
|
|
506
513
|
}
|
|
507
514
|
}
|
|
508
515
|
|
|
516
|
+
function watchStatePath(cwd = process.cwd()) {
|
|
517
|
+
return path.join(cwd, '.atris', 'state', 'youtube_watch.json');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function emptyWatchState() {
|
|
521
|
+
return { channels: [], seen: {}, seeded: {}, seenByChannel: {} };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function asObject(value) {
|
|
525
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function loadWatchState(statePath) {
|
|
529
|
+
try {
|
|
530
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
531
|
+
const channels = Array.isArray(parsed?.channels) ? parsed.channels : [];
|
|
532
|
+
return {
|
|
533
|
+
channels,
|
|
534
|
+
seen: asObject(parsed?.seen),
|
|
535
|
+
seeded: asObject(parsed?.seeded),
|
|
536
|
+
seenByChannel: asObject(parsed?.seenByChannel),
|
|
537
|
+
};
|
|
538
|
+
} catch {
|
|
539
|
+
return emptyWatchState();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function saveWatchState(statePath, state) {
|
|
544
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
545
|
+
const payload = {
|
|
546
|
+
channels: (state.channels || []).map((row) => ({
|
|
547
|
+
channel: row.channel,
|
|
548
|
+
added: row.added,
|
|
549
|
+
})),
|
|
550
|
+
seen: asObject(state.seen),
|
|
551
|
+
seeded: asObject(state.seeded),
|
|
552
|
+
seenByChannel: asObject(state.seenByChannel),
|
|
553
|
+
};
|
|
554
|
+
fs.writeFileSync(statePath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function stampNow(now) {
|
|
558
|
+
if (typeof now === 'function') now = now();
|
|
559
|
+
if (typeof now === 'string' && now) return now;
|
|
560
|
+
const value = now instanceof Date ? now : new Date(now || Date.now());
|
|
561
|
+
if (Number.isNaN(value.getTime())) return new Date().toISOString();
|
|
562
|
+
return value.toISOString();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function normalizeWatchChannel(input) {
|
|
566
|
+
const raw = String(input || '').trim();
|
|
567
|
+
if (!raw) throw new Error('Missing channel url or @handle. Run "atris youtube watch --help".');
|
|
568
|
+
|
|
569
|
+
let text = raw;
|
|
570
|
+
if (text.startsWith('@')) {
|
|
571
|
+
text = `https://www.youtube.com/${text}`;
|
|
572
|
+
} else if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text)) {
|
|
573
|
+
if (/^(www\.)?youtube\.com\//i.test(text) || /^youtu\.be\//i.test(text)) {
|
|
574
|
+
text = `https://${text}`;
|
|
575
|
+
} else if (/^@?[\w.-]+$/.test(text)) {
|
|
576
|
+
text = `https://www.youtube.com/@${text.replace(/^@/, '')}`;
|
|
577
|
+
} else {
|
|
578
|
+
throw new Error(`Invalid channel: ${raw}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let parsed;
|
|
583
|
+
try {
|
|
584
|
+
parsed = new URL(text);
|
|
585
|
+
} catch {
|
|
586
|
+
throw new Error(`Invalid channel: ${raw}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
parsed.hash = '';
|
|
590
|
+
parsed.search = '';
|
|
591
|
+
let href = parsed.toString().replace(/\/+$/, '');
|
|
592
|
+
href = href.replace(/\/(videos|featured|streams|shorts)$/i, '');
|
|
593
|
+
return href;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function channelVideosUrl(channel) {
|
|
597
|
+
const base = String(channel || '').replace(/\/+$/, '');
|
|
598
|
+
if (/\/videos$/i.test(base)) return base;
|
|
599
|
+
return `${base}/videos`;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function parseFlatPlaylist(stdout) {
|
|
603
|
+
const videos = [];
|
|
604
|
+
for (const line of String(stdout || '').split(/\r?\n/)) {
|
|
605
|
+
const trimmed = line.trim();
|
|
606
|
+
if (!trimmed || !trimmed.includes('|')) continue;
|
|
607
|
+
const idx = trimmed.indexOf('|');
|
|
608
|
+
const id = trimmed.slice(0, idx).trim();
|
|
609
|
+
const title = trimmed.slice(idx + 1).trim();
|
|
610
|
+
if (id && id !== 'NA') videos.push({ id, title });
|
|
611
|
+
}
|
|
612
|
+
return videos;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function defaultChannelFetcher(videosUrl, deps = {}) {
|
|
616
|
+
const spawn = deps.spawnSync || spawnSync;
|
|
617
|
+
const result = spawn('yt-dlp', [
|
|
618
|
+
'--no-update',
|
|
619
|
+
'--flat-playlist',
|
|
620
|
+
'--playlist-end',
|
|
621
|
+
'3',
|
|
622
|
+
'--print',
|
|
623
|
+
'%(id)s|%(title)s',
|
|
624
|
+
videosUrl,
|
|
625
|
+
], {
|
|
626
|
+
encoding: 'utf8',
|
|
627
|
+
timeout: 60000,
|
|
628
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
629
|
+
});
|
|
630
|
+
if (result.error || result.status !== 0) {
|
|
631
|
+
const detail = String(result.stderr || result.error?.message || 'fetch failed').trim();
|
|
632
|
+
throw new Error(detail || 'fetch failed');
|
|
633
|
+
}
|
|
634
|
+
return parseFlatPlaylist(result.stdout);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function defaultNotesRunner(url, deps = {}) {
|
|
638
|
+
const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
|
|
639
|
+
const spawn = deps.spawnSync || spawnSync;
|
|
640
|
+
return spawn(script, [url], { stdio: 'inherit' });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function resolveWatchStatePath(deps = {}) {
|
|
644
|
+
if (deps.statePath) return deps.statePath;
|
|
645
|
+
return watchStatePath(deps.cwd || process.cwd());
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function channelSeenMap(state, channel) {
|
|
649
|
+
return asObject(asObject(state.seenByChannel)[channel]);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function markSeen(state, channel, id, timestamp) {
|
|
653
|
+
if (!state.seen || typeof state.seen !== 'object') state.seen = {};
|
|
654
|
+
if (!state.seenByChannel || typeof state.seenByChannel !== 'object') state.seenByChannel = {};
|
|
655
|
+
if (!state.seenByChannel[channel] || typeof state.seenByChannel[channel] !== 'object') {
|
|
656
|
+
state.seenByChannel[channel] = {};
|
|
657
|
+
}
|
|
658
|
+
state.seen[id] = timestamp;
|
|
659
|
+
state.seenByChannel[channel][id] = timestamp;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function addWatchChannel(channelInput, deps = {}) {
|
|
663
|
+
const output = deps.output || ((line = '') => console.log(line));
|
|
664
|
+
if (!channelInput) {
|
|
665
|
+
output('usage: atris youtube watch add <channel-url-or-@handle>');
|
|
666
|
+
return 2;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
let channel;
|
|
670
|
+
try {
|
|
671
|
+
channel = normalizeWatchChannel(channelInput);
|
|
672
|
+
} catch (err) {
|
|
673
|
+
output(err.message);
|
|
674
|
+
return 2;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const statePath = resolveWatchStatePath(deps);
|
|
678
|
+
const state = loadWatchState(statePath);
|
|
679
|
+
if (state.channels.some((row) => row.channel === channel)) {
|
|
680
|
+
output(`already watching ${channel}`);
|
|
681
|
+
return 0;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
state.channels.push({
|
|
685
|
+
channel,
|
|
686
|
+
added: stampNow(deps.now),
|
|
687
|
+
});
|
|
688
|
+
saveWatchState(statePath, state);
|
|
689
|
+
output(`watching ${channel}`);
|
|
690
|
+
return 0;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function listWatchChannels(deps = {}) {
|
|
694
|
+
const output = deps.output || ((line = '') => console.log(line));
|
|
695
|
+
const state = loadWatchState(resolveWatchStatePath(deps));
|
|
696
|
+
if (!state.channels.length) {
|
|
697
|
+
output('no channels watched');
|
|
698
|
+
return 0;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
state.channels.forEach((row, index) => {
|
|
702
|
+
const count = Object.keys(channelSeenMap(state, row.channel)).length;
|
|
703
|
+
output(`${index + 1}. ${row.channel} (${count} seen)`);
|
|
704
|
+
});
|
|
705
|
+
return 0;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function removeWatchChannel(rawNumber, deps = {}) {
|
|
709
|
+
const output = deps.output || ((line = '') => console.log(line));
|
|
710
|
+
const index = Number(rawNumber);
|
|
711
|
+
const statePath = resolveWatchStatePath(deps);
|
|
712
|
+
const state = loadWatchState(statePath);
|
|
713
|
+
if (!Number.isInteger(index) || index < 1 || index > state.channels.length) {
|
|
714
|
+
output('usage: atris youtube watch remove <number>');
|
|
715
|
+
return 2;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const [removed] = state.channels.splice(index - 1, 1);
|
|
719
|
+
saveWatchState(statePath, state);
|
|
720
|
+
output(`removed ${removed.channel}`);
|
|
721
|
+
return 0;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function tickWatch(deps = {}) {
|
|
725
|
+
const output = deps.output || ((line = '') => console.log(line));
|
|
726
|
+
const statePath = resolveWatchStatePath(deps);
|
|
727
|
+
const fetcher = deps.fetcher || ((videosUrl) => defaultChannelFetcher(videosUrl, deps));
|
|
728
|
+
const runner = deps.runner || ((url) => defaultNotesRunner(url, deps));
|
|
729
|
+
const briefFiler = deps.briefFiler || fileBriefFromNotes;
|
|
730
|
+
const cwd = deps.cwd || process.cwd();
|
|
731
|
+
const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
|
|
732
|
+
const timestamp = stampNow(deps.now);
|
|
733
|
+
const now = deps.now || timestamp;
|
|
734
|
+
|
|
735
|
+
const state = loadWatchState(statePath);
|
|
736
|
+
let totalNew = 0;
|
|
737
|
+
let totalBriefed = 0;
|
|
738
|
+
|
|
739
|
+
for (const row of state.channels) {
|
|
740
|
+
const videosUrl = channelVideosUrl(row.channel);
|
|
741
|
+
let videos;
|
|
742
|
+
try {
|
|
743
|
+
videos = await Promise.resolve(fetcher(videosUrl, row));
|
|
744
|
+
} catch {
|
|
745
|
+
output(`warning: channel ${row.channel} fetch failed`);
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (!Array.isArray(videos)) {
|
|
749
|
+
output(`warning: channel ${row.channel} fetch failed`);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const localSeen = channelSeenMap(state, row.channel);
|
|
754
|
+
const isFresh = !state.seeded[row.channel];
|
|
755
|
+
const unseen = videos.filter((video) => video?.id && !localSeen[video.id] && !state.seen[video.id]);
|
|
756
|
+
const newest = videos[0];
|
|
757
|
+
const toBrief = isFresh
|
|
758
|
+
? (newest?.id ? [newest] : [])
|
|
759
|
+
: unseen;
|
|
760
|
+
|
|
761
|
+
if (isFresh) {
|
|
762
|
+
state.seeded[row.channel] = true;
|
|
763
|
+
for (const video of videos) {
|
|
764
|
+
if (video?.id) markSeen(state, row.channel, video.id, timestamp);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
let briefed = 0;
|
|
769
|
+
for (const video of toBrief) {
|
|
770
|
+
if (!video?.id) continue;
|
|
771
|
+
const url = `https://www.youtube.com/watch?v=${video.id}`;
|
|
772
|
+
try {
|
|
773
|
+
runner(url, deps);
|
|
774
|
+
} catch {
|
|
775
|
+
// notes failure must not stop the rest of the tick
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
briefFiler({ cwd, url, workDir, now });
|
|
779
|
+
} catch {
|
|
780
|
+
// brief filing must never break the watch tick
|
|
781
|
+
}
|
|
782
|
+
markSeen(state, row.channel, video.id, timestamp);
|
|
783
|
+
briefed += 1;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (!isFresh) {
|
|
787
|
+
for (const video of unseen) {
|
|
788
|
+
if (video?.id) markSeen(state, row.channel, video.id, timestamp);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const newCount = isFresh ? (newest?.id ? 1 : 0) : unseen.length;
|
|
793
|
+
output(`channel ${row.channel}: ${newCount} new, ${briefed} briefed`);
|
|
794
|
+
totalNew += newCount;
|
|
795
|
+
totalBriefed += briefed;
|
|
796
|
+
saveWatchState(statePath, state);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
output(`total: ${totalNew} new, ${totalBriefed} briefed`);
|
|
800
|
+
saveWatchState(statePath, state);
|
|
801
|
+
return 0;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function watchCommand(args = [], deps = {}) {
|
|
805
|
+
const output = deps.output || ((line = '') => console.log(line));
|
|
806
|
+
const sub = args[0];
|
|
807
|
+
if (!sub || ['help', '--help', '-h'].includes(sub)) {
|
|
808
|
+
showYoutubeHelp(output, deps.commandName || 'atris youtube');
|
|
809
|
+
return sub ? 0 : 2;
|
|
810
|
+
}
|
|
811
|
+
if (sub === 'add') return addWatchChannel(args[1], deps);
|
|
812
|
+
if (sub === 'list') return listWatchChannels(deps);
|
|
813
|
+
if (sub === 'remove') return removeWatchChannel(args[1], deps);
|
|
814
|
+
if (sub === 'tick') return tickWatch(deps);
|
|
815
|
+
output(`unknown watch command: ${sub}`);
|
|
816
|
+
return 2;
|
|
817
|
+
}
|
|
818
|
+
|
|
509
819
|
function runYoutubeNotes(args = [], deps = {}) {
|
|
510
820
|
const output = deps.output || ((line = '') => console.error(line));
|
|
511
821
|
const url = args[0];
|
|
@@ -539,6 +849,11 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
|
|
|
539
849
|
if (!deps.output && !deps.spawnSync) process.exit(code);
|
|
540
850
|
return code;
|
|
541
851
|
}
|
|
852
|
+
if (argv[0] === 'watch') {
|
|
853
|
+
const code = await watchCommand(argv.slice(1), { ...deps, output });
|
|
854
|
+
if (!deps.output && !deps.fetcher && !deps.runner && !deps.briefFiler) process.exit(code);
|
|
855
|
+
return code;
|
|
856
|
+
}
|
|
542
857
|
const options = parseYoutubeArgs(argv);
|
|
543
858
|
if (options.help) {
|
|
544
859
|
showYoutubeHelp(output, deps.commandName || 'atris youtube');
|
|
@@ -559,5 +874,15 @@ module.exports = {
|
|
|
559
874
|
shouldRetryWithLocalTranscript,
|
|
560
875
|
formatYoutubeResult,
|
|
561
876
|
fileBriefFromNotes,
|
|
877
|
+
normalizeWatchChannel,
|
|
878
|
+
channelVideosUrl,
|
|
879
|
+
parseFlatPlaylist,
|
|
880
|
+
loadWatchState,
|
|
881
|
+
saveWatchState,
|
|
882
|
+
addWatchChannel,
|
|
883
|
+
listWatchChannels,
|
|
884
|
+
removeWatchChannel,
|
|
885
|
+
tickWatch,
|
|
886
|
+
watchCommand,
|
|
562
887
|
youtubeCommand,
|
|
563
888
|
};
|