mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
feat(plugins): add versioned plugin API with media snapshots and provena
- Add standalone src/plugin-api.ts types and manifest apiVersion check - Add ctx.media playback snapshots, ctx.signal, ctx.onDispose, and awaitable ctx.mpv.request - Bound async activation; actions wait only for their own plugin - card-mined now carries per-note provenance (media + subtitle captured at mining start) through merges and YouTube queues - Port episode-tags and auto-jimaku to provenance and media snapshots; drop mining-goal docs
This commit is contained in:
28 files changed
+1235
-421
No files matched your search
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "AI Explain",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "Anki Sync",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -17,8 +17,7 @@ function activate(ctx) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||
const languages = new Set(settings.languages.map((lang) => lang.toLowerCase()));
|
||||
let cancelCheck = null;
|
||||
// Bumped on every file change so a check still waiting on mpv knows it is stale.
|
||||
let generation = 0;
|
||||
let playbackId = null;
|
||||
|
||||
function hasJapaneseSubtitle(trackList) {
|
||||
return trackList.some(
|
||||
@@ -29,29 +28,23 @@ function activate(ctx) {
|
||||
);
|
||||
}
|
||||
|
||||
async function check(mediaPath, checkGeneration) {
|
||||
async function check(media) {
|
||||
cancelCheck = null;
|
||||
const [currentPath, trackList] = await Promise.all([
|
||||
ctx.mpv.getProperty('path'),
|
||||
ctx.mpv.getProperty('track-list'),
|
||||
]);
|
||||
// Another file started while we waited, even if it was this one again; its own check is
|
||||
// already scheduled.
|
||||
if (checkGeneration !== generation) return;
|
||||
if (currentPath !== mediaPath || !Array.isArray(trackList)) return;
|
||||
const trackList = await ctx.mpv.getProperty('track-list');
|
||||
if (ctx.media.current()?.playbackId !== media.playbackId || !Array.isArray(trackList)) return;
|
||||
if (hasJapaneseSubtitle(trackList)) return;
|
||||
ctx.log.info(`No Japanese subtitles for ${mediaPath}; running ${settings.action}.`);
|
||||
ctx.log.info(`No Japanese subtitles for ${media.path}; running ${settings.action}.`);
|
||||
await ctx.actions.run(settings.action);
|
||||
}
|
||||
|
||||
ctx.mpv.on('media-path-change', ({ path: mediaPath }) => {
|
||||
generation += 1;
|
||||
ctx.media.on('change', (media) => {
|
||||
if (media?.playbackId === playbackId) return;
|
||||
playbackId = media?.playbackId ?? null;
|
||||
cancelCheck?.();
|
||||
cancelCheck = null;
|
||||
if (!mediaPath) return;
|
||||
if (!settings.streams && /^[a-z]+:\/\//i.test(mediaPath)) return;
|
||||
const checkGeneration = generation;
|
||||
cancelCheck = ctx.after(settings.delayMs, () => check(mediaPath, checkGeneration));
|
||||
if (!media) return;
|
||||
if (!settings.streams && /^[a-z]+:\/\//i.test(media.path)) return;
|
||||
cancelCheck = ctx.after(settings.delayMs, () => check(media));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "Auto Jimaku",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -59,9 +59,6 @@ function renderTag(template, values) {
|
||||
return levels.join('::');
|
||||
}
|
||||
|
||||
// Files remembered for matching cards to what was playing; older cards are not tagged.
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
function mediaName(mediaPath, title) {
|
||||
// A stream URL's last segment is usually an id, so streams use the title mpv shows.
|
||||
if (typeof mediaPath === 'string' && mediaPath && !/^[a-z]+:\/\//i.test(mediaPath)) {
|
||||
@@ -79,51 +76,17 @@ function renderTags(templates, name) {
|
||||
|
||||
function activate(ctx) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||
// Files played since activation, oldest first. An Anki note id is the time the note was
|
||||
// added in milliseconds, so each card is tagged with the file playing when it was added,
|
||||
// even when card-mined fires after playback moved on (YouTube media, field grouping).
|
||||
const history = [];
|
||||
|
||||
function remember(mediaPath, since) {
|
||||
history.push({ since, path: mediaPath || null, title: null });
|
||||
if (history.length > MAX_HISTORY) history.shift();
|
||||
}
|
||||
|
||||
function nameAt(noteId) {
|
||||
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||
const entry = history[i];
|
||||
if (entry.since <= noteId) return mediaName(entry.path, entry.title);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ctx.mpv.on('media-path-change', ({ path: mediaPath }) => remember(mediaPath, Date.now()));
|
||||
// SubMiner reads the title back after each path change, so it lands on the new file.
|
||||
ctx.mpv.on('media-title-change', ({ title }) => {
|
||||
const latest = history.at(-1);
|
||||
if (latest) latest.title = title;
|
||||
});
|
||||
|
||||
// A file may already be playing when the plugin starts.
|
||||
const startedAt = Date.now();
|
||||
Promise.all([ctx.mpv.getProperty('path'), ctx.mpv.getProperty('media-title')])
|
||||
.then(([mediaPath, title]) => {
|
||||
if (history.length > 0 || typeof mediaPath !== 'string' || !mediaPath) return;
|
||||
remember(mediaPath, startedAt);
|
||||
history[0].title = typeof title === 'string' ? title : null;
|
||||
})
|
||||
.catch((error) => ctx.log.debug(`Could not read the playing media: ${error.message}`));
|
||||
|
||||
ctx.anki.on('card-mined', async ({ noteIds }) => {
|
||||
ctx.anki.on('card-mined', async ({ notes }) => {
|
||||
// Notes from the same file share one addTags call.
|
||||
const notesByName = new Map();
|
||||
for (const noteId of noteIds) {
|
||||
const name = nameAt(noteId);
|
||||
for (const { noteId, provenance } of notes) {
|
||||
const name = mediaName(provenance?.media?.path, provenance?.media?.title);
|
||||
if (!name) {
|
||||
ctx.log.debug(`Note ${noteId} was not added during a file SubMiner saw; not tagging it.`);
|
||||
ctx.log.debug(`Note ${noteId} has no known media source; not tagging it.`);
|
||||
continue;
|
||||
}
|
||||
notesByName.set(name, [...(notesByName.get(name) ?? []), noteId]);
|
||||
if (!notesByName.has(name)) notesByName.set(name, new Set());
|
||||
notesByName.get(name).add(noteId);
|
||||
}
|
||||
|
||||
for (const [name, notes] of notesByName) {
|
||||
@@ -133,8 +96,8 @@ function activate(ctx) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await ctx.anki.invoke('addTags', { notes, tags: tags.join(' ') });
|
||||
ctx.log.info(`Tagged ${notes.length} note(s): ${tags.join(' ')}`);
|
||||
await ctx.anki.invoke('addTags', { notes: [...notes], tags: tags.join(' ') });
|
||||
ctx.log.info(`Tagged ${notes.size} note(s): ${tags.join(' ')}`);
|
||||
if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`);
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Tagging failed: ${error.message}`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "Episode Tags",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
Reference in new issue
Block a user