fix(ci): add changelog fragment for immersion changes

This commit is contained in:
2026-03-22 19:07:07 -07:00
parent 8928bfdf7e
commit 8da3a26855
17 changed files with 1109 additions and 18 deletions

View File

@@ -1,15 +1,21 @@
import { useState } from 'react';
import { BASE_URL } from '../../lib/api-client';
import { useEffect, useState } from 'react';
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
interface CoverImageProps {
videoId: number;
title: string;
src?: string | null;
className?: string;
}
export function CoverImage({ videoId, title, className = '' }: CoverImageProps) {
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
useEffect(() => {
setFailed(false);
}, [resolvedSrc]);
if (failed) {
return (
@@ -23,8 +29,9 @@ export function CoverImage({ videoId, title, className = '' }: CoverImageProps)
return (
<img
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
src={resolvedSrc}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>

View File

@@ -1,6 +1,8 @@
import { useState, useMemo } from 'react';
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
import { formatDuration } from '../../lib/formatters';
import { formatDuration, formatNumber } from '../../lib/formatters';
import { groupMediaLibraryItems } from '../../lib/media-library-grouping';
import { CoverImage } from './CoverImage';
import { MediaCard } from './MediaCard';
import { MediaDetailView } from './MediaDetailView';
@@ -16,8 +18,18 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
const filtered = useMemo(() => {
if (!search.trim()) return media;
const q = search.toLowerCase();
return media.filter((m) => m.canonicalTitle.toLowerCase().includes(q));
return media.filter((m) => {
const haystacks = [
m.canonicalTitle,
m.videoTitle,
m.channelName,
m.uploaderId,
m.channelId,
].filter(Boolean);
return haystacks.some((value) => value!.toLowerCase().includes(q));
});
}, [media, search]);
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
@@ -26,7 +38,6 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
<MediaDetailView
videoId={selectedVideoId}
onBack={() => setSelectedVideoId(null)}
onNavigateToSession={onNavigateToSession}
/>
);
}
@@ -45,20 +56,63 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
<div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} title{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
{grouped.length} channel{grouped.length !== 1 ? 's' : ''} · {filtered.length} video
{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
</div>
</div>
{filtered.length === 0 ? (
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filtered.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
<div className="space-y-6">
{grouped.map((group) => (
<section
key={group.key}
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
>
<div className="flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40">
<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">
{group.channelUrl ? (
<a
href={group.channelUrl}
target="_blank"
rel="noreferrer"
className="text-base font-semibold text-ctp-text truncate hover:text-ctp-blue transition-colors"
>
{group.title}
</a>
) : (
<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>
</div>
<div 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>
))}
</div>
)}

View File

@@ -1,5 +1,6 @@
import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber } from '../../lib/formatters';
import { resolveMediaArtworkUrl } from '../../lib/media-library-grouping';
import type { MediaLibraryItem } from '../../types/stats';
interface MediaCardProps {
@@ -17,10 +18,14 @@ export function MediaCard({ item, onClick }: MediaCardProps) {
<CoverImage
videoId={item.videoId}
title={item.canonicalTitle}
src={resolveMediaArtworkUrl(item, 'video')}
className="w-full aspect-[3/4] rounded-t-lg"
/>
<div className="p-3">
<div className="text-sm font-medium text-ctp-text truncate">{item.canonicalTitle}</div>
{item.videoTitle && item.videoTitle !== item.canonicalTitle ? (
<div className="text-xs text-ctp-subtext1 truncate mt-1">{item.videoTitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-1">
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
</div>

View File

@@ -3,6 +3,7 @@ import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
import { getStatsClient } from '../../hooks/useStatsApi';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { resolveMediaArtworkUrl } from '../../lib/media-library-grouping';
import type { MediaDetailData } from '../../types/stats';
interface MediaHeaderProps {
@@ -45,10 +46,27 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
<CoverImage
videoId={detail.videoId}
title={detail.canonicalTitle}
src={resolveMediaArtworkUrl(detail, 'video')}
className="w-32 h-44 rounded-lg shrink-0"
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
{detail.channelName ? (
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
{detail.channelUrl ? (
<a
href={detail.channelUrl}
target="_blank"
rel="noreferrer"
className="hover:text-ctp-blue transition-colors"
>
{detail.channelName}
</a>
) : (
detail.channelName
)}
</div>
) : null}
<div className="grid grid-cols-2 gap-2 mt-3 text-sm">
<div>
<div className="text-ctp-blue font-medium">{formatDuration(detail.totalActiveMs)}</div>

View File

@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import type { MediaLibraryItem } from '../types/stats';
import { groupMediaLibraryItems, resolveMediaArtworkUrl } from './media-library-grouping';
import { CoverImage } from '../components/library/CoverImage';
const youtubeEpisodeA: MediaLibraryItem = {
videoId: 1,
canonicalTitle: 'Episode 1',
totalSessions: 2,
totalActiveMs: 12_000,
totalCards: 3,
totalTokensSeen: 120,
lastWatchedMs: 3_000,
hasCoverArt: 1,
youtubeVideoId: 'yt-1',
videoUrl: 'https://www.youtube.com/watch?v=yt-1',
videoTitle: 'Video 1',
videoThumbnailUrl: 'https://i.ytimg.com/vi/yt-1/hqdefault.jpg',
channelId: 'UC123',
channelName: 'Creator Name',
channelUrl: 'https://www.youtube.com/channel/UC123',
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88',
uploaderId: '@creator',
uploaderUrl: 'https://www.youtube.com/@creator',
description: 'desc',
};
const youtubeEpisodeB: MediaLibraryItem = {
...youtubeEpisodeA,
videoId: 2,
canonicalTitle: 'Episode 2',
youtubeVideoId: 'yt-2',
videoUrl: 'https://www.youtube.com/watch?v=yt-2',
videoTitle: 'Video 2',
videoThumbnailUrl: 'https://i.ytimg.com/vi/yt-2/hqdefault.jpg',
lastWatchedMs: 4_000,
};
const localVideo: MediaLibraryItem = {
videoId: 3,
canonicalTitle: 'Local Movie',
totalSessions: 1,
totalActiveMs: 5_000,
totalCards: 0,
totalTokensSeen: 40,
lastWatchedMs: 2_000,
hasCoverArt: 1,
youtubeVideoId: null,
videoUrl: null,
videoTitle: null,
videoThumbnailUrl: null,
channelId: null,
channelName: null,
channelUrl: null,
channelThumbnailUrl: null,
uploaderId: null,
uploaderUrl: null,
description: null,
};
test('groupMediaLibraryItems groups youtube videos by channel and leaves local media standalone', () => {
const groups = groupMediaLibraryItems([youtubeEpisodeA, localVideo, youtubeEpisodeB]);
assert.equal(groups.length, 2);
assert.equal(groups[0]?.title, 'Creator Name');
assert.equal(groups[0]?.items.length, 2);
assert.equal(groups[0]?.items[0]?.videoId, 2);
assert.equal(groups[0]?.imageUrl, 'https://yt3.googleusercontent.com/channel-avatar=s88');
assert.equal(groups[1]?.title, 'Local Movie');
assert.equal(groups[1]?.items.length, 1);
});
test('resolveMediaArtworkUrl prefers youtube thumbnails for video and channel images', () => {
assert.equal(
resolveMediaArtworkUrl(youtubeEpisodeA, 'video'),
'https://i.ytimg.com/vi/yt-1/hqdefault.jpg',
);
assert.equal(
resolveMediaArtworkUrl(youtubeEpisodeA, 'channel'),
'https://yt3.googleusercontent.com/channel-avatar=s88',
);
assert.equal(resolveMediaArtworkUrl(localVideo, 'video'), null);
assert.equal(resolveMediaArtworkUrl(localVideo, 'channel'), null);
});
test('CoverImage renders explicit remote artwork when src is provided', () => {
const markup = renderToStaticMarkup(
<CoverImage
videoId={youtubeEpisodeA.videoId}
title={youtubeEpisodeA.canonicalTitle}
src={youtubeEpisodeA.videoThumbnailUrl}
className="w-8 h-8"
/>,
);
assert.match(markup, /src="https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg"/);
});

View File

@@ -0,0 +1,74 @@
import { BASE_URL } from './api-client';
import type { MediaLibraryItem } from '../types/stats';
export interface MediaLibraryGroup {
key: string;
title: string;
subtitle: string | null;
imageUrl: string | null;
channelUrl: string | null;
items: MediaLibraryItem[];
totalActiveMs: number;
totalCards: number;
lastWatchedMs: number;
}
export function resolveMediaArtworkUrl(
item: Pick<MediaLibraryItem, 'videoThumbnailUrl' | 'channelThumbnailUrl'>,
kind: 'video' | 'channel',
): string | null {
if (kind === 'channel') {
return item.channelThumbnailUrl ?? null;
}
return item.videoThumbnailUrl ?? null;
}
export function resolveMediaCoverApiUrl(videoId: number): string {
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
}
export function groupMediaLibraryItems(items: MediaLibraryItem[]): MediaLibraryGroup[] {
const groups = new Map<string, MediaLibraryGroup>();
for (const item of items) {
const key = item.channelId?.trim() || `video:${item.videoId}`;
const title =
item.channelName?.trim() ||
item.uploaderId?.trim() ||
item.videoTitle?.trim() ||
item.canonicalTitle;
const subtitle =
item.channelId?.trim() != null && item.channelId?.trim() !== ''
? `${item.channelId}`
: item.videoTitle?.trim() && item.videoTitle !== item.canonicalTitle
? item.videoTitle
: null;
const existing = groups.get(key);
if (existing) {
existing.items.push(item);
existing.totalActiveMs += item.totalActiveMs;
existing.totalCards += item.totalCards;
existing.lastWatchedMs = Math.max(existing.lastWatchedMs, item.lastWatchedMs);
continue;
}
groups.set(key, {
key,
title,
subtitle,
imageUrl: resolveMediaArtworkUrl(item, 'channel') ?? resolveMediaArtworkUrl(item, 'video'),
channelUrl: item.channelUrl ?? null,
items: [item],
totalActiveMs: item.totalActiveMs,
totalCards: item.totalCards,
lastWatchedMs: item.lastWatchedMs,
});
}
return [...groups.values()]
.map((group) => ({
...group,
items: [...group.items].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs),
}))
.sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
}

View File

@@ -130,6 +130,17 @@ export interface MediaLibraryItem {
totalTokensSeen: number;
lastWatchedMs: number;
hasCoverArt: number;
youtubeVideoId?: string | null;
videoUrl?: string | null;
videoTitle?: string | null;
videoThumbnailUrl?: string | null;
channelId?: string | null;
channelName?: string | null;
channelUrl?: string | null;
channelThumbnailUrl?: string | null;
uploaderId?: string | null;
uploaderUrl?: string | null;
description?: string | null;
}
export interface MediaDetailData {
@@ -145,6 +156,17 @@ export interface MediaDetailData {
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
youtubeVideoId?: string | null;
videoUrl?: string | null;
videoTitle?: string | null;
videoThumbnailUrl?: string | null;
channelId?: string | null;
channelName?: string | null;
channelUrl?: string | null;
channelThumbnailUrl?: string | null;
uploaderId?: string | null;
uploaderUrl?: string | null;
description?: string | null;
} | null;
sessions: SessionSummary[];
rollups: DailyRollup[];