mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-25 00:11:26 -07:00
feat(stats): improve YouTube media metadata and picker key handling
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
type: fixed
|
type: changed
|
||||||
area: immersion
|
area: launcher
|
||||||
|
|
||||||
- Hardened immersion tracker storage/session/query paths with the updated YouTube metadata flow.
|
- Added an app-owned YouTube subtitle flow that pauses mpv, lets the overlay picker choose tracks, and injects downloaded subtitle files before playback resumes.
|
||||||
- Added metadata probe support for YouTube subtitle retrieval edge cases.
|
- Added absPlayer-style YouTube timedtext parsing/conversion so downloaded subtitle tracks load as parsed cues for the sidebar, tokenization, and mining flows.
|
||||||
|
- Added yt-dlp metadata probing so YouTube playback and immersion tracking keep canonical video and channel metadata.
|
||||||
|
|||||||
@@ -1276,6 +1276,14 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
|
|||||||
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
|
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
|
||||||
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
|
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
|
||||||
|
|
||||||
|
You can also disable immersion tracking for a single session using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SUBMINER_DISABLE_IMMERSION_TRACKING=1 subminer
|
||||||
|
```
|
||||||
|
|
||||||
|
When this is set, SubMiner skips immersion-tracker startup and does not initialize or read the immersion SQLite database for that session.
|
||||||
|
|
||||||
Default behavior keeps raw events, telemetry, sessions, and rollups forever while still maintaining lifetime summary tables and daily/monthly rollups for faster reads. If you later want bounded retention, switch `retentionMode` or set explicit `retention.*` values.
|
Default behavior keeps raw events, telemetry, sessions, and rollups forever while still maintaining lifetime summary tables and daily/monthly rollups for faster reads. If you later want bounded retention, switch `retentionMode` or set explicit `retention.*` values.
|
||||||
|
|
||||||
When `dbPath` is blank or omitted, SubMiner writes telemetry and session summaries to the default app-data location:
|
When `dbPath` is blank or omitted, SubMiner writes telemetry and session summaries to the default app-data location:
|
||||||
|
|||||||
@@ -619,6 +619,33 @@ test('keyboard mode: configured stats toggle works even while popup is open', as
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.updateKeybindings([
|
||||||
|
{
|
||||||
|
key: 'Space',
|
||||||
|
command: ['cycle', 'pause'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'KeyQ',
|
||||||
|
command: ['quit'],
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
ctx.state.youtubePickerModalOpen = true;
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
|
||||||
|
testGlobals.dispatchKeydown({ key: 'q', code: 'KeyQ' });
|
||||||
|
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [['cycle', 'pause'], ['quit']]);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: h moves left when popup is closed', async () => {
|
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -843,9 +843,10 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ctx.state.youtubePickerModalOpen) {
|
if (ctx.state.youtubePickerModalOpen) {
|
||||||
options.handleYoutubePickerKeydown(e);
|
if (options.handleYoutubePickerKeydown(e)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (ctx.state.controllerSelectModalOpen) {
|
if (ctx.state.controllerSelectModalOpen) {
|
||||||
options.handleControllerSelectKeydown(e);
|
options.handleControllerSelectKeydown(e);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -348,3 +348,91 @@ test('youtube track picker surfaces rejected resolve calls as modal status', asy
|
|||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube track picker only consumes handled keys', async () => {
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createFakeElement(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
youtubePickerResolve: async () => ({ ok: true, message: '' }),
|
||||||
|
setIgnoreMouseEvents: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const dom = {
|
||||||
|
overlay: {
|
||||||
|
classList: createClassList(),
|
||||||
|
focus: () => {},
|
||||||
|
},
|
||||||
|
youtubePickerModal: createFakeElement(),
|
||||||
|
youtubePickerTitle: createFakeElement(),
|
||||||
|
youtubePickerPrimarySelect: createFakeElement(),
|
||||||
|
youtubePickerSecondarySelect: createFakeElement(),
|
||||||
|
youtubePickerTracks: createFakeElement(),
|
||||||
|
youtubePickerStatus: createFakeElement(),
|
||||||
|
youtubePickerContinueButton: createFakeElement(),
|
||||||
|
youtubePickerCloseButton: createFakeElement(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createYoutubeTrackPickerModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom,
|
||||||
|
platform: {
|
||||||
|
shouldToggleMouseIgnore: false,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => true },
|
||||||
|
restorePointerInteractionState: () => {},
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.openYoutubePickerModal({
|
||||||
|
sessionId: 'yt-1',
|
||||||
|
url: 'https://example.com',
|
||||||
|
mode: 'download',
|
||||||
|
tracks: [],
|
||||||
|
defaultPrimaryTrackId: null,
|
||||||
|
defaultSecondaryTrackId: null,
|
||||||
|
hasTracks: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
modal.handleYoutubePickerKeydown({
|
||||||
|
key: ' ',
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as KeyboardEvent),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
modal.handleYoutubePickerKeydown({
|
||||||
|
key: 'Escape',
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as KeyboardEvent),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export function createYoutubeTrackPickerModal(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireDomEvents(): void {
|
function wireDomEvents(): void {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ interface MediaCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MediaCard({ item, onClick }: MediaCardProps) {
|
export function MediaCard({ item, onClick }: MediaCardProps) {
|
||||||
|
const primaryTitle = item.videoTitle?.trim() || item.canonicalTitle;
|
||||||
|
const secondaryTitle =
|
||||||
|
item.videoTitle?.trim() && item.videoTitle !== item.canonicalTitle ? item.canonicalTitle : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -20,9 +24,9 @@ export function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
className="w-full aspect-[3/4] rounded-t-lg"
|
className="w-full aspect-[3/4] rounded-t-lg"
|
||||||
/>
|
/>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="text-sm font-medium text-ctp-text truncate">{item.canonicalTitle}</div>
|
<div className="text-sm font-medium text-ctp-text truncate">{primaryTitle}</div>
|
||||||
{item.videoTitle && item.videoTitle !== item.canonicalTitle ? (
|
{secondaryTitle ? (
|
||||||
<div className="text-xs text-ctp-subtext1 truncate mt-1">{item.videoTitle}</div>
|
<div className="text-xs text-ctp-subtext1 truncate mt-1">{secondaryTitle}</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="text-xs text-ctp-overlay2 mt-1">
|
<div className="text-xs text-ctp-overlay2 mt-1">
|
||||||
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
|
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
|
||||||
|
|||||||
41
stats/src/components/library/MediaDetailView.test.tsx
Normal file
41
stats/src/components/library/MediaDetailView.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getRelatedCollectionLabel } from './MediaDetailView';
|
||||||
|
|
||||||
|
test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
|
||||||
|
assert.equal(
|
||||||
|
getRelatedCollectionLabel({
|
||||||
|
animeId: 1,
|
||||||
|
canonicalTitle: 'Video',
|
||||||
|
totalSessions: 1,
|
||||||
|
totalActiveMs: 1,
|
||||||
|
totalCards: 0,
|
||||||
|
totalTokensSeen: 0,
|
||||||
|
totalLinesSeen: 0,
|
||||||
|
totalLookupCount: 0,
|
||||||
|
totalLookupHits: 0,
|
||||||
|
totalYomitanLookupCount: 0,
|
||||||
|
channelName: 'Creator',
|
||||||
|
}),
|
||||||
|
'View Channel',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRelatedCollectionLabel returns View Anime for non-youtube media', () => {
|
||||||
|
assert.equal(
|
||||||
|
getRelatedCollectionLabel({
|
||||||
|
animeId: 1,
|
||||||
|
canonicalTitle: 'Episode 5',
|
||||||
|
totalSessions: 1,
|
||||||
|
totalActiveMs: 1,
|
||||||
|
totalCards: 0,
|
||||||
|
totalTokensSeen: 0,
|
||||||
|
totalLinesSeen: 0,
|
||||||
|
totalLookupCount: 0,
|
||||||
|
totalLookupHits: 0,
|
||||||
|
totalYomitanLookupCount: 0,
|
||||||
|
channelName: null,
|
||||||
|
}),
|
||||||
|
'View Anime',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -5,7 +5,14 @@ import { confirmSessionDelete } from '../../lib/delete-confirm';
|
|||||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||||
import { MediaHeader } from './MediaHeader';
|
import { MediaHeader } from './MediaHeader';
|
||||||
import { MediaSessionList } from './MediaSessionList';
|
import { MediaSessionList } from './MediaSessionList';
|
||||||
import type { SessionSummary } from '../../types/stats';
|
import type { MediaDetailData, SessionSummary } from '../../types/stats';
|
||||||
|
|
||||||
|
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
|
||||||
|
if (detail?.channelName?.trim()) {
|
||||||
|
return 'View Channel';
|
||||||
|
}
|
||||||
|
return 'View Anime';
|
||||||
|
}
|
||||||
|
|
||||||
interface MediaDetailViewProps {
|
interface MediaDetailViewProps {
|
||||||
videoId: number;
|
videoId: number;
|
||||||
@@ -53,6 +60,7 @@ export function MediaDetailView({
|
|||||||
totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0),
|
totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0),
|
||||||
totalYomitanLookupCount: sessions.reduce((sum, session) => sum + session.yomitanLookupCount, 0),
|
totalYomitanLookupCount: sessions.reduce((sum, session) => sum + session.yomitanLookupCount, 0),
|
||||||
};
|
};
|
||||||
|
const relatedCollectionLabel = getRelatedCollectionLabel(detail);
|
||||||
|
|
||||||
const handleDeleteSession = async (session: SessionSummary) => {
|
const handleDeleteSession = async (session: SessionSummary) => {
|
||||||
if (!confirmSessionDelete()) return;
|
if (!confirmSessionDelete()) return;
|
||||||
@@ -87,7 +95,7 @@ export function MediaDetailView({
|
|||||||
onClick={() => onNavigateToAnime(animeId)}
|
onClick={() => onNavigateToAnime(animeId)}
|
||||||
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
||||||
>
|
>
|
||||||
View Anime →
|
{relatedCollectionLabel} →
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
57
stats/src/hooks/useMediaLibrary.test.ts
Normal file
57
stats/src/hooks/useMediaLibrary.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { MediaLibraryItem } from '../types/stats';
|
||||||
|
import { shouldRefreshMediaLibraryRows } from './useMediaLibrary';
|
||||||
|
|
||||||
|
const baseItem: MediaLibraryItem = {
|
||||||
|
videoId: 1,
|
||||||
|
canonicalTitle: 'watch?v=abc123',
|
||||||
|
totalSessions: 1,
|
||||||
|
totalActiveMs: 60_000,
|
||||||
|
totalCards: 0,
|
||||||
|
totalTokensSeen: 10,
|
||||||
|
lastWatchedMs: 1_000,
|
||||||
|
hasCoverArt: 0,
|
||||||
|
youtubeVideoId: 'abc123',
|
||||||
|
videoUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
videoTitle: null,
|
||||||
|
videoThumbnailUrl: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
|
||||||
|
channelId: null,
|
||||||
|
channelName: null,
|
||||||
|
channelUrl: null,
|
||||||
|
channelThumbnailUrl: null,
|
||||||
|
uploaderId: null,
|
||||||
|
uploaderUrl: null,
|
||||||
|
description: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('shouldRefreshMediaLibraryRows requests a follow-up fetch for incomplete youtube metadata', () => {
|
||||||
|
assert.equal(shouldRefreshMediaLibraryRows([baseItem]), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRefreshMediaLibraryRows skips follow-up fetch when youtube metadata is complete', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshMediaLibraryRows([
|
||||||
|
{
|
||||||
|
...baseItem,
|
||||||
|
videoTitle: 'Video Name',
|
||||||
|
channelName: 'Creator Name',
|
||||||
|
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshMediaLibraryRows([
|
||||||
|
{
|
||||||
|
...baseItem,
|
||||||
|
youtubeVideoId: null,
|
||||||
|
videoUrl: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -2,6 +2,18 @@ import { useState, useEffect } from 'react';
|
|||||||
import { getStatsClient } from './useStatsApi';
|
import { getStatsClient } from './useStatsApi';
|
||||||
import type { MediaLibraryItem } from '../types/stats';
|
import type { MediaLibraryItem } from '../types/stats';
|
||||||
|
|
||||||
|
const MEDIA_LIBRARY_REFRESH_DELAY_MS = 1_500;
|
||||||
|
const MEDIA_LIBRARY_MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
export function shouldRefreshMediaLibraryRows(rows: MediaLibraryItem[]): boolean {
|
||||||
|
return rows.some((row) => {
|
||||||
|
if (!row.youtubeVideoId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !row.videoTitle?.trim() || !row.channelName?.trim() || !row.channelThumbnailUrl?.trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useMediaLibrary() {
|
export function useMediaLibrary() {
|
||||||
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
|
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -9,24 +21,46 @@ export function useMediaLibrary() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let retryCount = 0;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const load = (isInitial = false) => {
|
||||||
|
if (isInitial) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
}
|
||||||
getStatsClient()
|
getStatsClient()
|
||||||
.getMediaLibrary()
|
.getMediaLibrary()
|
||||||
.then((rows) => {
|
.then((rows) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setMedia(rows);
|
setMedia(rows);
|
||||||
|
if (
|
||||||
|
shouldRefreshMediaLibraryRows(rows) &&
|
||||||
|
retryCount < MEDIA_LIBRARY_MAX_RETRIES
|
||||||
|
) {
|
||||||
|
retryCount += 1;
|
||||||
|
retryTimer = setTimeout(() => {
|
||||||
|
retryTimer = null;
|
||||||
|
load(false);
|
||||||
|
}, MEDIA_LIBRARY_REFRESH_DELAY_MS);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (cancelled) return;
|
if (cancelled || !isInitial) return;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
load(true);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,30 @@ test('groupMediaLibraryItems groups youtube videos by channel and leaves local m
|
|||||||
assert.equal(groups[1]?.items.length, 1);
|
assert.equal(groups[1]?.items.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('groupMediaLibraryItems falls back to channel metadata when youtube channel id is missing', () => {
|
||||||
|
const first = {
|
||||||
|
...youtubeEpisodeA,
|
||||||
|
videoId: 20,
|
||||||
|
youtubeVideoId: 'yt-20',
|
||||||
|
videoUrl: 'https://www.youtube.com/watch?v=yt-20',
|
||||||
|
channelId: null,
|
||||||
|
};
|
||||||
|
const second = {
|
||||||
|
...youtubeEpisodeB,
|
||||||
|
videoId: 21,
|
||||||
|
youtubeVideoId: 'yt-21',
|
||||||
|
videoUrl: 'https://www.youtube.com/watch?v=yt-21',
|
||||||
|
channelId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups = groupMediaLibraryItems([first, second]);
|
||||||
|
|
||||||
|
assert.equal(groups.length, 1);
|
||||||
|
assert.equal(groups[0]?.title, 'Creator Name');
|
||||||
|
assert.equal(groups[0]?.items.length, 2);
|
||||||
|
assert.equal(groups[0]?.channelUrl, 'https://www.youtube.com/channel/UC123');
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveMediaArtworkUrl prefers youtube thumbnails for video and channel images', () => {
|
test('resolveMediaArtworkUrl prefers youtube thumbnails for video and channel images', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveMediaArtworkUrl(youtubeEpisodeA, 'video'),
|
resolveMediaArtworkUrl(youtubeEpisodeA, 'video'),
|
||||||
@@ -147,3 +171,10 @@ test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls
|
|||||||
assert.match(markup, /src="http:\/\/127\.0\.0\.1:6969\/api\/stats\/media\/1\/cover"/);
|
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/);
|
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
|
||||||
|
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
|
||||||
|
|
||||||
|
assert.match(markup, />Video 1</);
|
||||||
|
assert.match(markup, />Episode 1</);
|
||||||
|
});
|
||||||
|
|||||||
@@ -45,9 +45,16 @@ export function groupMediaLibraryItems(items: MediaLibraryItem[]): MediaLibraryG
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const channelId = item.channelId?.trim() || null;
|
const channelId = item.channelId?.trim() || null;
|
||||||
const channelName = item.channelName?.trim() || null;
|
const channelName = item.channelName?.trim() || null;
|
||||||
|
const channelUrl = item.channelUrl?.trim() || null;
|
||||||
const uploaderId = item.uploaderId?.trim() || null;
|
const uploaderId = item.uploaderId?.trim() || null;
|
||||||
const videoTitle = item.videoTitle?.trim() || null;
|
const videoTitle = item.videoTitle?.trim() || null;
|
||||||
const key = channelId || `video:${item.videoId}`;
|
const key = channelId
|
||||||
|
? `youtube:channel:${channelId}`
|
||||||
|
: channelUrl
|
||||||
|
? `youtube:channel-url:${channelUrl}`
|
||||||
|
: channelName
|
||||||
|
? `youtube:channel-name:${channelName.toLowerCase()}`
|
||||||
|
: `video:${item.videoId}`;
|
||||||
const title = channelName || uploaderId || videoTitle || item.canonicalTitle;
|
const title = channelName || uploaderId || videoTitle || item.canonicalTitle;
|
||||||
const subtitle = channelId
|
const subtitle = channelId
|
||||||
? channelId
|
? channelId
|
||||||
|
|||||||
Reference in New Issue
Block a user