mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 23:35:17 -07:00
feat(plugins): add episode-tags and auto-jimaku example plugins
- episode-tags: tag mined cards with show/episode parsed from the file name or stream title - auto-jimaku: open Jimaku search when a file starts without a Japanese subtitle track - Hold card-mined for notes merged into a note still waiting on queued YouTube media - Route YouTube media settle reports through reportCardsReady - Document both plugins in docs-site and changelog fragment
This commit is contained in:
10 files changed
+222
-6
No files matched your search
@@ -0,0 +1,52 @@
|
||||
// Opens the Jimaku subtitle search when a file starts without a Japanese subtitle track.
|
||||
// It waits a few seconds after the file loads so external subtitles mpv or SubMiner add count.
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
// Built-in action to run, e.g. `openTsukihime` or `openSubtitleSelection`.
|
||||
action: 'openJimaku',
|
||||
// Track `lang` codes that count as Japanese. Track titles with "Japanese" or 日本語 count too.
|
||||
languages: ['ja', 'jp', 'jpn'],
|
||||
delayMs: 3_000,
|
||||
// Also check http(s) streams; Jimaku can only search by name, which streams rarely have.
|
||||
streams: false,
|
||||
};
|
||||
|
||||
const JAPANESE_TITLE = /japanese|日本語/i;
|
||||
|
||||
function activate(ctx) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||
const languages = new Set(settings.languages.map((lang) => lang.toLowerCase()));
|
||||
let cancelCheck = null;
|
||||
|
||||
function hasJapaneseSubtitle(trackList) {
|
||||
return trackList.some(
|
||||
(track) =>
|
||||
track?.type === 'sub' &&
|
||||
(languages.has(String(track.lang ?? '').toLowerCase()) ||
|
||||
JAPANESE_TITLE.test(String(track.title ?? ''))),
|
||||
);
|
||||
}
|
||||
|
||||
async function check(mediaPath) {
|
||||
cancelCheck = null;
|
||||
const [currentPath, trackList] = await Promise.all([
|
||||
ctx.mpv.getProperty('path'),
|
||||
ctx.mpv.getProperty('track-list'),
|
||||
]);
|
||||
// Another file started while we waited; its own check is already scheduled.
|
||||
if (currentPath !== mediaPath || !Array.isArray(trackList)) return;
|
||||
if (hasJapaneseSubtitle(trackList)) return;
|
||||
ctx.log.info(`No Japanese subtitles for ${mediaPath}; running ${settings.action}.`);
|
||||
await ctx.actions.run(settings.action);
|
||||
}
|
||||
|
||||
ctx.mpv.on('media-path-change', ({ path: mediaPath }) => {
|
||||
cancelCheck?.();
|
||||
cancelCheck = null;
|
||||
if (!mediaPath) return;
|
||||
if (!settings.streams && /^[a-z]+:\/\//i.test(mediaPath)) return;
|
||||
cancelCheck = ctx.after(settings.delayMs, () => check(mediaPath));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { activate };
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Auto Jimaku",
|
||||
"version": "1.0.0",
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Tags each mined card with the show and episode it came from, e.g. `show::Frieren::S01E07`.
|
||||
// The show and episode are parsed from the file name, or from mpv's media title for streams.
|
||||
const path = require('node:path');
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
// Placeholders: {show}, {season}, {episode}, and {ep} (S01E07, or E07 without a season).
|
||||
// A tag stops before the first `::` level whose placeholder has no value.
|
||||
tags: ['show::{show}::{ep}'],
|
||||
notify: false,
|
||||
};
|
||||
|
||||
// Tried in order; the first match wins. Runs on names with brackets and dots already removed.
|
||||
const EPISODE_PATTERNS = [
|
||||
/^(?<show>.*?)\s*S(?<season>\d{1,2})\s?E(?<episode>\d{1,4})\b/i,
|
||||
/^(?<show>.*?)\s*(?:Season\s?(?<season>\d{1,2})\s*)?(?:Episode|Ep)\s?(?<episode>\d{1,4})\b/i,
|
||||
/^(?<show>.*?)\s+-\s+(?<episode>\d{1,4})(?:v\d)?\b/,
|
||||
/^(?<show>.*?)\s+(?<episode>\d{1,4})(?:v\d)?$/,
|
||||
/^(?<show>.*?)\s*第(?<episode>\d{1,4})話/,
|
||||
];
|
||||
|
||||
// `[Group] Show Name - 07 (1080p).mkv` -> { show: 'Show Name', season: null, episode: '07' }
|
||||
function parseMediaName(name) {
|
||||
const cleaned = name
|
||||
.replace(/\.[a-z0-9]{2,4}$/i, '')
|
||||
.replace(/\[[^\]]*\]|\([^)]*\)|【[^】]*】/g, ' ')
|
||||
.replace(/[._]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
for (const pattern of EPISODE_PATTERNS) {
|
||||
const groups = cleaned.match(pattern)?.groups;
|
||||
const show = groups?.show.replace(/[\s-]+$/, '');
|
||||
if (!show) continue;
|
||||
return { show, season: pad(groups.season), episode: pad(groups.episode) };
|
||||
}
|
||||
return { show: cleaned || null, season: null, episode: null };
|
||||
}
|
||||
|
||||
function pad(number) {
|
||||
return number ? String(Number(number)).padStart(2, '0') : null;
|
||||
}
|
||||
|
||||
// Anki tags can't hold spaces, and a `::` in a value would add a hierarchy level.
|
||||
function tagValue(value) {
|
||||
return value.replace(/::?/g, ' ').trim().replace(/\s+/g, '_').replace(/"/g, '');
|
||||
}
|
||||
|
||||
function renderTag(template, values) {
|
||||
const levels = [];
|
||||
for (const level of template.split('::')) {
|
||||
let missing = false;
|
||||
const rendered = level.replace(/\{(\w+)\}/g, (_, key) => {
|
||||
const value = values[key];
|
||||
if (!value) missing = true;
|
||||
return value ? tagValue(value) : '';
|
||||
});
|
||||
if (missing) break;
|
||||
levels.push(rendered);
|
||||
}
|
||||
return levels.join('::');
|
||||
}
|
||||
|
||||
async function currentMediaName(ctx) {
|
||||
const [mediaPath, title] = await Promise.all([
|
||||
ctx.mpv.getProperty('path'),
|
||||
ctx.mpv.getProperty('media-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)) {
|
||||
return path.basename(mediaPath);
|
||||
}
|
||||
return typeof title === 'string' && title ? title : null;
|
||||
}
|
||||
|
||||
function activate(ctx) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||
|
||||
ctx.anki.on('card-mined', async ({ noteIds }) => {
|
||||
if (noteIds.length === 0) return;
|
||||
let name;
|
||||
try {
|
||||
name = await currentMediaName(ctx);
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Could not read the playing media: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
if (!name) return;
|
||||
|
||||
const { show, season, episode } = parseMediaName(name);
|
||||
const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null;
|
||||
const values = { show, season, episode, ep };
|
||||
const tags = [...new Set(settings.tags.map((template) => renderTag(template, values)))].filter(
|
||||
Boolean,
|
||||
);
|
||||
if (tags.length === 0) {
|
||||
ctx.log.debug(`No tags for "${name}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.anki.invoke('addTags', { notes: noteIds, tags: tags.join(' ') });
|
||||
ctx.log.info(`Tagged ${noteIds.length} note(s): ${tags.join(' ')}`);
|
||||
if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`);
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Tagging failed: ${error.message}`);
|
||||
ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { activate };
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Episode Tags",
|
||||
"version": "1.0.0",
|
||||
"main": "main.js"
|
||||
}
|
||||
Reference in new issue
Block a user