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:16:49 -07:00
parent b204d4dd6e
commit e64ff1a0ee
117 changed files with 7565 additions and 529 deletions
@@ -43,6 +43,7 @@
## Task 1: 365d range — backend type extension
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts:16` and `src/core/services/immersion-tracker/query-trends.ts:84-88`
- Test: `src/core/services/immersion-tracker/__tests__/query.test.ts`
@@ -101,13 +102,14 @@
## Task 2: 365d range — server route allow-list
**Files:**
- Modify: `src/core/services/stats-server.ts` (search for trends route handler — look for `/api/stats/trends` or `getTrendsDashboard`)
- Test: `src/core/services/__tests__/stats-server.test.ts`
- [ ] **Step 1: Locate the trends route in `stats-server.ts`**
Run: `grep -n 'trends\|TrendRange' src/core/services/stats-server.ts`
Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there *is* an allow-list (e.g. a `validRanges` array), continue.
Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there _is_ an allow-list (e.g. a `validRanges` array), continue.
- [ ] **Step 2: Add a failing test for `range=365d`**
@@ -145,6 +147,7 @@
## Task 3: 365d range — frontend client and selector
**Files:**
- Modify: `stats/src/lib/api-client.ts`
- Modify: `stats/src/lib/api-client.test.ts`
- Modify: `stats/src/hooks/useTrends.ts:5`
@@ -175,10 +178,13 @@
- [ ] **Step 6: Add `365d` to the `DateRangeSelector` segmented control**
In `stats/src/components/trends/DateRangeSelector.tsx:56`, change:
```tsx
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
```
to:
```tsx
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
```
@@ -206,6 +212,7 @@
## Task 4: Vocabulary Top 50 — collapse word/reading column
**Files:**
- Modify: `stats/src/components/vocabulary/FrequencyRankTable.tsx:110-144`
- Test: create `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` if not present (check first with `ls stats/src/components/vocabulary/`)
@@ -217,6 +224,7 @@
- [ ] **Step 2: Write the failing test**
Create or extend `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` with:
```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'bun:test';
@@ -271,6 +279,7 @@
Replace the `<th>Reading</th>` header column and the corresponding `<td>` in the body. The new shape:
Header (around line 113-119):
```tsx
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
@@ -283,6 +292,7 @@
```
Body row (around line 122-141):
```tsx
<tr
key={w.wordId}
@@ -297,16 +307,10 @@
{(() => {
const reading = fullReading(w.headword, w.reading);
if (!reading || reading === w.headword) return null;
return (
<span className="text-ctp-subtext0 text-xs ml-1.5">
【{reading}】
</span>
);
return <span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>;
})()}
</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 pr-3">{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
@@ -336,6 +340,7 @@
## Task 5: Episode detail — filter Anki-deleted cards
**Files:**
- Modify: `stats/src/components/anime/EpisodeDetail.tsx:109-147`
- Test: create `stats/src/components/anime/EpisodeDetail.test.tsx` if not present
@@ -410,11 +415,13 @@
Then change the JSX iteration from `cardEvents.map(...)` to `filteredCardEvents.map(...)` (one occurrence around line 113), and after the `</div>` closing the cards-mined section, add:
```tsx
{hiddenCardCount > 0 && (
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
</div>
)}
{
hiddenCardCount > 0 && (
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
</div>
);
}
```
Place that footer immediately before the closing `</div>` of the bordered cards-mined section, so it stays scoped to that block.
@@ -422,12 +429,14 @@
**Important:** the filter only fires once `noteInfos` has been populated. While `noteInfos` is still empty (initial load before the second fetch resolves), every card with noteIds would be filtered out — that's wrong. Guard the filter so that it only runs after the noteInfos fetch has completed. The simplest signal: track `noteInfosLoaded: boolean` next to `noteInfos`, set it `true` in the `.then` callback, and only apply filtering when `noteInfosLoaded || allNoteIds.length === 0`.
Concrete change near line 22:
```tsx
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
```
Inside the existing `useEffect` (around line 36-46), set the loaded flag:
```tsx
if (allNoteIds.length > 0) {
getStatsClient()
@@ -452,6 +461,7 @@
```
And gate the filter:
```tsx
const filteredCardEvents = noteInfosLoaded
? cardEvents
@@ -496,6 +506,7 @@
## Task 6: Library detail — delete episode action
**Files:**
- Modify: `stats/src/components/library/MediaHeader.tsx`
- Modify: `stats/src/components/library/MediaDetailView.tsx`
- Modify: `stats/src/hooks/useMediaLibrary.ts`
@@ -553,9 +564,7 @@
```tsx
<div className="flex items-start gap-2">
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">
{detail.canonicalTitle}
</h2>
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">{detail.canonicalTitle}</h2>
{onDeleteEpisode && (
<button
type="button"
@@ -718,6 +727,7 @@
## Task 7: Library — collapsible series groups
**Files:**
- Modify: `stats/src/components/library/LibraryTab.tsx`
- Test: create `stats/src/components/library/LibraryTab.test.tsx`
@@ -758,11 +768,13 @@
- [ ] **Step 3: Add collapsible state and toggle to `LibraryTab.tsx`**
Modify imports:
```tsx
import { useState, useMemo, useCallback } from 'react';
```
Inside the component, after the existing `useState` calls:
```tsx
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
@@ -780,6 +792,7 @@
```
Actually, the cleanest pattern is **initialize once on first data load via `useEffect`**:
```tsx
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
const [hasInitializedCollapsed, setHasInitializedCollapsed] = useState(false);
@@ -814,71 +827,71 @@
Replace the section block (around line 64-115) so the header is a `<button>`:
```tsx
{grouped.map((group) => {
const isCollapsed = collapsedGroups.has(group.key);
const isSingleVideo = group.items.length === 1;
return (
<section
key={group.key}
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
>
<button
type="button"
onClick={() => !isSingleVideo && toggleGroup(group.key)}
aria-expanded={!isCollapsed}
aria-controls={`group-body-${group.key}`}
disabled={isSingleVideo}
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
}`}
{
grouped.map((group) => {
const isCollapsed = collapsedGroups.has(group.key);
const isSingleVideo = group.items.length === 1;
return (
<section
key={group.key}
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
>
{!isSingleVideo && (
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
isCollapsed ? '' : 'rotate-90'
}`}
>
{'\u25B6'}
</span>
<button
type="button"
onClick={() => !isSingleVideo && toggleGroup(group.key)}
aria-expanded={!isCollapsed}
aria-controls={`group-body-${group.key}`}
disabled={isSingleVideo}
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
}`}
>
{!isSingleVideo && (
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
isCollapsed ? '' : 'rotate-90'
}`}
>
{'\u25B6'}
</span>
)}
<CoverImage
videoId={group.items[0]!.videoId}
title={group.title}
src={group.imageUrl}
className="w-16 h-16 rounded-2xl shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-ctp-text truncate">{group.title}</h3>
</div>
{group.subtitle ? (
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-2">
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
</div>
</div>
</button>
{!isCollapsed && (
<div id={`group-body-${group.key}`} className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
))}
</div>
</div>
)}
<CoverImage
videoId={group.items[0]!.videoId}
title={group.title}
src={group.imageUrl}
className="w-16 h-16 rounded-2xl shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-ctp-text truncate">
{group.title}
</h3>
</div>
{group.subtitle ? (
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-2">
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
</div>
</div>
</button>
{!isCollapsed && (
<div id={`group-body-${group.key}`} className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
))}
</div>
</div>
)}
</section>
);
})}
</section>
);
});
}
```
**Watch out:** the previous header had a clickable `<a>` for the channel URL. Wrapping the whole header in a `<button>` makes nested anchors invalid. The simplest fix: drop the channel URL link from inside the header (it's still reachable from the individual `MediaCard`s), or move it to a separate row outside the button. Choose the first — minimum visual disruption.
@@ -906,6 +919,7 @@
## Task 8: Session grouping helper
**Files:**
- Create: `stats/src/lib/session-grouping.ts`
- Create: `stats/src/lib/session-grouping.test.ts`
@@ -1012,7 +1026,9 @@
for (const session of sessions) {
const hasVideoId =
typeof session.videoId === 'number' && Number.isFinite(session.videoId) && session.videoId > 0;
typeof session.videoId === 'number' &&
Number.isFinite(session.videoId) &&
session.videoId > 0;
const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`;
const existing = byVideo.get(key);
if (existing) {
@@ -1066,6 +1082,7 @@
## Task 9: Sessions tab — episode rollup UI
**Files:**
- Modify: `stats/src/components/sessions/SessionsTab.tsx`
- Modify: `stats/src/lib/delete-confirm.ts` (add `confirmBucketDelete`)
- Modify: `stats/src/lib/delete-confirm.test.ts`
@@ -1161,114 +1178,120 @@
Skeleton:
```tsx
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const buckets = groupSessionsByVideo(daySessions);
return (
<div key={dayLabel}>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2">
{buckets.map((bucket) => {
if (bucket.sessions.length === 1) {
const s = bucket.sessions[0]!;
const detailsId = `session-details-${s.sessionId}`;
{
Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const buckets = groupSessionsByVideo(daySessions);
return (
<div key={dayLabel}>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2">
{buckets.map((bucket) => {
if (bucket.sessions.length === 1) {
const s = bucket.sessions[0]!;
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={bucket.key}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
}
const isOpen = expandedBuckets.has(bucket.key);
return (
<div key={bucket.key}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
<div
key={bucket.key}
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40"
>
<button
type="button"
onClick={() => toggleBucket(bucket.key)}
aria-expanded={isOpen}
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors"
>
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`}
>
{'\u25B6'}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">
{bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'}
</div>
<div className="text-xs text-ctp-overlay2">
{bucket.sessions.length} sessions · {formatDuration(bucket.totalActiveMs)} ·{' '}
{bucket.totalCardsMined} cards
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteBucket(bucket);
}}
className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors"
title="Delete all sessions in this group"
>
Delete
</button>
</button>
{isOpen && (
<div className="pl-8 pr-2 pb-2 space-y-2">
{bucket.sessions.map((s) => {
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
const isOpen = expandedBuckets.has(bucket.key);
return (
<div key={bucket.key} className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40">
<button
type="button"
onClick={() => toggleBucket(bucket.key)}
aria-expanded={isOpen}
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors"
>
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`}
>
{'\u25B6'}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">
{bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'}
</div>
<div className="text-xs text-ctp-overlay2">
{bucket.sessions.length} sessions ·{' '}
{formatDuration(bucket.totalActiveMs)} ·{' '}
{bucket.totalCardsMined} cards
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteBucket(bucket);
}}
className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors"
title="Delete all sessions in this group"
>
Delete
</button>
</button>
{isOpen && (
<div className="pl-8 pr-2 pb-2 space-y-2">
{bucket.sessions.map((s) => {
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
})}
</div>
</div>
</div>
);
})}
);
});
}
```
**Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container *outside* the toggle button. Pick the second option — it's accessible without role gymnastics:
**Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container _outside_ the toggle button. Pick the second option — it's accessible without role gymnastics:
```tsx
<div className="flex items-center">
@@ -1281,7 +1304,7 @@
</div>
```
Use that pattern in the actual implementation. The skeleton above shows the *intent*; the final code must have sibling buttons, not nested ones.
Use that pattern in the actual implementation. The skeleton above shows the _intent_; the final code must have sibling buttons, not nested ones.
Add `handleDeleteBucket`:
@@ -1336,6 +1359,7 @@
## Task 10: Chart clarity pass
**Files:**
- Modify: `stats/src/lib/chart-theme.ts`
- Modify: `stats/src/components/trends/TrendChart.tsx`
- Modify: `stats/src/components/trends/StackedTrendChart.tsx`
@@ -1528,6 +1552,7 @@
## Task 11: Changelog fragment
**Files:**
- Create: `changes/2026-04-09-stats-dashboard-feedback-pass.md`
- [ ] **Step 1: Read the existing changelog format**