fix: address latest review feedback

This commit is contained in:
2026-03-22 20:09:16 -07:00
parent 809b57af44
commit d8a7ae77b0
18 changed files with 428 additions and 44 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react';
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
import { formatDuration, formatNumber } from '../../lib/formatters';
import { groupMediaLibraryItems } from '../../lib/media-library-grouping';
import { groupMediaLibraryItems, summarizeMediaLibraryGroups } from '../../lib/media-library-grouping';
import { CoverImage } from './CoverImage';
import { MediaCard } from './MediaCard';
import { MediaDetailView } from './MediaDetailView';
@@ -30,8 +30,7 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
});
}, [media, search]);
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
if (selectedVideoId !== null) {
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />;
@@ -51,8 +50,8 @@ 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">
{grouped.length} channel{grouped.length !== 1 ? 's' : ''} · {filtered.length} video
{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
{grouped.length} group{grouped.length !== 1 ? 's' : ''} · {summary.totalVideos} video
{summary.totalVideos !== 1 ? 's' : ''} · {formatDuration(summary.totalMs)}
</div>
</div>

View File

@@ -1,6 +1,5 @@
import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber } from '../../lib/formatters';
import { resolveMediaArtworkUrl } from '../../lib/media-library-grouping';
import { CoverImage } from './CoverImage';
import type { MediaLibraryItem } from '../../types/stats';
interface MediaCardProps {
@@ -18,7 +17,6 @@ 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">

View File

@@ -2,8 +2,13 @@ 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 {
groupMediaLibraryItems,
resolveMediaArtworkUrl,
summarizeMediaLibraryGroups,
} from './media-library-grouping';
import { CoverImage } from '../components/library/CoverImage';
import { MediaCard } from '../components/library/MediaCard';
const youtubeEpisodeA: MediaLibraryItem = {
videoId: 1,
@@ -85,6 +90,16 @@ test('resolveMediaArtworkUrl prefers youtube thumbnails for video and channel im
assert.equal(resolveMediaArtworkUrl(localVideo, 'channel'), null);
});
test('summarizeMediaLibraryGroups stays aligned with rendered group buckets', () => {
const groups = groupMediaLibraryItems([youtubeEpisodeA, localVideo, youtubeEpisodeB]);
const summary = summarizeMediaLibraryGroups(groups);
assert.deepEqual(summary, {
totalMs: 29_000,
totalVideos: 3,
});
});
test('CoverImage renders explicit remote artwork when src is provided', () => {
const markup = renderToStaticMarkup(
<CoverImage
@@ -97,3 +112,12 @@ test('CoverImage renders explicit remote artwork when src is provided', () => {
assert.match(markup, /src="https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg"/);
});
test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls', () => {
const markup = renderToStaticMarkup(
<MediaCard item={youtubeEpisodeA} onClick={() => {}} />,
);
assert.match(markup, /src="http:\/\/127\.0\.0\.1:6969\/api\/stats\/media\/1\/cover"/);
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
});

View File

@@ -27,21 +27,35 @@ export function resolveMediaCoverApiUrl(videoId: number): string {
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
}
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
totalMs: number;
totalVideos: number;
} {
return groups.reduce(
(summary, group) => ({
totalMs: summary.totalMs + group.totalActiveMs,
totalVideos: summary.totalVideos + group.items.length,
}),
{ totalMs: 0, totalVideos: 0 },
);
}
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 channelId = item.channelId?.trim() || null;
const channelName = item.channelName?.trim() || null;
const uploaderId = item.uploaderId?.trim() || null;
const videoTitle = item.videoTitle?.trim() || null;
const key = channelId || `video:${item.videoId}`;
const title =
item.channelName?.trim() ||
item.uploaderId?.trim() ||
item.videoTitle?.trim() ||
item.canonicalTitle;
channelName || uploaderId || videoTitle || item.canonicalTitle;
const subtitle =
item.channelId?.trim() != null && item.channelId?.trim() !== ''
? `${item.channelId}`
: item.videoTitle?.trim() && item.videoTitle !== item.canonicalTitle
? item.videoTitle
channelId
? channelId
: videoTitle && videoTitle !== item.canonicalTitle
? videoTitle
: null;
const existing = groups.get(key);
if (existing) {