mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-21 00:15:26 -07:00
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:
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user