feat(anime): add anime browser powered by Aniyomi extensions

- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached
- Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config
- Support searching all sources at once with streaming, per-source results and status
- Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese
- Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon
- Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
This commit is contained in:
2026-07-31 17:21:12 -07:00
parent b204d4dd6e
commit e64ff1a0ee
117 changed files with 7565 additions and 529 deletions
@@ -15,13 +15,16 @@
## File Structure
**Backend (`src/core/services/immersion-tracker/`):**
- `query-trends.ts` — add `LibrarySummaryRow` type, `buildLibrarySummary` helper, wire into `getTrendsDashboard`, drop `animePerDay` from `TrendsDashboardQueryResult`, delete now-unused `buildPerAnimeFromSessions` and `buildLookupsPerHundredPerAnime`.
- `__tests__/query.test.ts` — update existing `getTrendsDashboard` test (drop `animePerDay` assertion, add `librarySummary` assertion); add new tests for summary-specific behavior (empty window, multi-title, null lookupsPerHundred).
**Backend test fixtures:**
- `src/core/services/__tests__/stats-server.test.ts` — update `TRENDS_DASHBOARD` fixture (remove `animePerDay`, add `librarySummary`), fix `assert.deepEqual` that references `body.animePerDay.watchTime`.
**Frontend (`stats/src/`):**
- `types/stats.ts` — add `LibrarySummaryRow` interface, add `librarySummary` field to `TrendsDashboardData`, remove `animePerDay` field.
- `lib/api-client.test.ts` — update the two inline fetch-mock fixtures (remove `animePerDay`, add `librarySummary`).
- `components/trends/LibrarySummarySection.tsx`**new** file. Owns the header content: leaderboard Recharts chart + sortable HTML table. Takes `{ rows, hiddenTitles }` as props.
@@ -29,6 +32,7 @@
- `components/trends/anime-visibility.ts` — unchanged. The existing helpers operate on `PerAnimeDataPoint[]`; we'll adapt by passing a derived `PerAnimeDataPoint[]` built from `librarySummary` (or add an overload — see Task 7 for the final decision).
**Changelog:**
- `changes/stats-library-summary.md`**new** changelog fragment.
---
@@ -36,6 +40,7 @@
## Task 1: Backend — Add `LibrarySummaryRow` type and empty stub field
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- [ ] **Step 1: Add the row type and add `librarySummary: []` to the returned object**
@@ -82,6 +87,7 @@ git commit -m "feat(stats): scaffold LibrarySummaryRow type and empty field"
## Task 2: Backend — TDD the `buildLibrarySummary` helper
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
@@ -160,16 +166,7 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
).run(
`${startedAtMs + activeMs}`,
activeMs,
activeMs,
10,
tokens,
cards,
lookups,
sessionId,
);
).run(`${startedAtMs + activeMs}`, activeMs, activeMs, 10, tokens, cards, lookups, sessionId);
}
for (const [day, active, tokens, cards] of [
@@ -289,8 +286,7 @@ function buildLibrarySummary(
cards: acc.cards,
words: acc.words,
lookups: acc.lookups,
lookupsPerHundred:
acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
lookupsPerHundred: acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
firstWatched: acc.firstWatched,
lastWatched: acc.lastWatched,
});
@@ -334,6 +330,7 @@ git commit -m "feat(stats): build per-title librarySummary from daily rollups an
## Task 3: Backend — Add null-lookupsPerHundred and empty-window tests
**Files:**
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
- [ ] **Step 1: Write a failing test for `lookupsPerHundred: null` when words == 0**
@@ -402,16 +399,7 @@ test('getTrendsDashboard librarySummary returns null lookupsPerHundred when word
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
).run(
`${startMs + 20 * 60_000}`,
20 * 60_000,
20 * 60_000,
5,
0,
0,
0,
session.sessionId,
);
).run(`${startMs + 20 * 60_000}`, 20 * 60_000, 20 * 60_000, 5, 0, 0, 0, session.sessionId);
db.prepare(
`
@@ -464,6 +452,7 @@ git commit -m "test(stats): cover librarySummary null-lookups and empty-window c
## Task 4: Backend — Drop `animePerDay` from the response type and clean up dead helpers
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
- Modify: `src/core/services/__tests__/stats-server.test.ts`
@@ -489,61 +478,61 @@ animePerDay: {
In `getTrendsDashboard` (around lines 649-668 and 694-699), keep the internal `animePerDay` construction (it's still used by `animeCumulative`) but do NOT include it in the returned object. Also drop the now-unused `lookups` and `lookupsPerHundred` fields from the internal `animePerDay` object. Replace the block starting with `const animePerDay = {` through the return statement:
```ts
const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalActiveMin,
),
cards: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalCards,
),
words: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalTokensSeen,
),
};
const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalActiveMin,
),
cards: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalCards,
),
words: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalTokensSeen,
),
};
return {
activity,
progress: {
watchTime: accumulatePoints(activity.watchTime),
sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words),
newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
),
cards: accumulatePoints(activity.cards),
episodes: accumulatePoints(
useMonthlyBuckets
? buildEpisodesPerMonthFromRollups(monthlyRollups)
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
),
lookups: accumulatePoints(
useMonthlyBuckets
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
),
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
},
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
episodes: buildCumulativePerAnime(animePerDay.episodes),
cards: buildCumulativePerAnime(animePerDay.cards),
words: buildCumulativePerAnime(animePerDay.words),
},
patterns: {
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
watchTimeByHour: buildWatchTimeByHour(sessions),
},
};
return {
activity,
progress: {
watchTime: accumulatePoints(activity.watchTime),
sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words),
newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
),
cards: accumulatePoints(activity.cards),
episodes: accumulatePoints(
useMonthlyBuckets
? buildEpisodesPerMonthFromRollups(monthlyRollups)
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
),
lookups: accumulatePoints(
useMonthlyBuckets
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
),
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
},
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
episodes: buildCumulativePerAnime(animePerDay.episodes),
cards: buildCumulativePerAnime(animePerDay.cards),
words: buildCumulativePerAnime(animePerDay.words),
},
patterns: {
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
watchTimeByHour: buildWatchTimeByHour(sessions),
},
};
```
- [ ] **Step 3: Delete now-unused helpers**
@@ -622,6 +611,7 @@ git commit -m "refactor(stats): drop animePerDay from trends response in favor o
## Task 5: Frontend — Update types and api-client test fixtures
**Files:**
- Modify: `stats/src/types/stats.ts`
- Modify: `stats/src/lib/api-client.test.ts`
@@ -710,6 +700,7 @@ git commit -m "refactor(stats): replace animePerDay type with librarySummary"
## Task 6: Frontend — Create `LibrarySummarySection` skeleton with empty state
**Files:**
- Create: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Create the file with the empty state and props plumbing**
@@ -765,6 +756,7 @@ git commit -m "feat(stats): scaffold LibrarySummarySection with empty state"
## Task 7: Frontend — Add the leaderboard bar chart to `LibrarySummarySection`
**Files:**
- Modify: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Replace the skeleton body with the leaderboard chart**
@@ -772,15 +764,7 @@ git commit -m "feat(stats): scaffold LibrarySummarySection with empty state"
Replace the entire contents of `stats/src/components/trends/LibrarySummarySection.tsx` with:
```tsx
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { LibrarySummaryRow } from '../../types/stats';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
@@ -821,9 +805,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
return (
<>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">
Top Titles by Watch Time (min)
</h3>
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
<BarChart
data={leaderboard}
@@ -881,6 +863,7 @@ git commit -m "feat(stats): add top-titles leaderboard chart to LibrarySummarySe
## Task 8: Frontend — Add the sortable table to `LibrarySummarySection`
**Files:**
- Modify: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Add sort state, column definitions, and the table markup**
@@ -889,15 +872,7 @@ Replace the entire file with the version below. The change vs. Task 7: imports `
```tsx
import { useMemo, useState } from 'react';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { LibrarySummaryRow } from '../../types/stats';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
import { epochDayToDate, formatDuration, formatNumber } from '../../lib/formatters';
@@ -1023,9 +998,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
if (visibleRows.length === 0) {
return (
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<div className="text-xs text-ctp-overlay2">
No library activity in the selected window.
</div>
<div className="text-xs text-ctp-overlay2">No library activity in the selected window.</div>
</div>
);
}
@@ -1042,9 +1015,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
return (
<>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">
Top Titles by Watch Time (min)
</h3>
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
<BarChart
data={leaderboard}
@@ -1081,10 +1052,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
</div>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
<div
className="overflow-auto"
style={{ maxHeight: TABLE_MAX_HEIGHT }}
>
<div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}>
<table className="w-full text-xs">
<thead className="sticky top-0 bg-ctp-surface0">
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
@@ -1138,9 +1106,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
{formatNumber(row.lookups)}
</td>
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
{row.lookupsPerHundred === null
? '—'
: row.lookupsPerHundred.toFixed(1)}
{row.lookupsPerHundred === null ? '—' : row.lookupsPerHundred.toFixed(1)}
</td>
<td className="px-2 py-2 text-right text-ctp-subtext0 tabular-nums">
{formatDateRange(row.firstWatched, row.lastWatched)}
@@ -1173,6 +1139,7 @@ git commit -m "feat(stats): add sortable per-title table to LibrarySummarySectio
## Task 9: Frontend — Wire `LibrarySummarySection` into `TrendsTab` and remove the per-day block
**Files:**
- Modify: `stats/src/components/trends/TrendsTab.tsx`
- [ ] **Step 1: Delete the per-day filtered locals and imports**
@@ -1192,10 +1159,7 @@ const filteredWatchTimePerAnime = filterHiddenAnimeData(
);
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
const filteredLookupsPerAnime = filterHiddenAnimeData(
data.animePerDay.lookups,
activeHiddenAnime,
);
const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime);
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
data.animePerDay.lookupsPerHundred,
activeHiddenAnime,
@@ -1286,6 +1250,7 @@ git commit -m "feat(stats): replace per-day trends section with library summary"
## Task 10: Add changelog fragment and run the full handoff gate
**Files:**
- Create: `changes/stats-library-summary.md`
- [ ] **Step 1: Check the existing changelog fragment format**