diff --git a/src/content/extractor.ts b/src/content/extractor.ts index d1421f3..8757d77 100644 --- a/src/content/extractor.ts +++ b/src/content/extractor.ts @@ -175,6 +175,9 @@ function parseVideo(renderer: any): PlaylistVideo | null { (b: any) => b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW", ) ?? false, + viewCountText: null, + publishedAt: null, + category: null, addedBy: null, voteCount: null, }; diff --git a/src/content/index.ts b/src/content/index.ts index beb334a..4788f9e 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -3,13 +3,43 @@ import { onPlaylistPageReady, getPlaylistId } from "./navigation"; import { parseInitialData, buildPlaylistData } from "./extractor"; import type { PlaylistVideo } from "../types/playlist"; import type { Message } from "../shared/messages"; -import { mountTable, appendToTable, setTableComplete } from "./ui/lifecycle"; +import { mountTable, appendToTable, setTableComplete, updateTableDetails } from "./ui/lifecycle"; const LOG = "[yt-playlist-features]"; let lastExtractedId: string | null = null; let pageScriptInjected = false; +function mapRawVideo(v: any): PlaylistVideo { + const bylineRun = v.shortBylineText?.runs?.[0]; + const bylineEndpoint = bylineRun?.navigationEndpoint?.browseEndpoint; + return { + videoId: v.videoId, + title: v.title, + index: v.index, + durationSeconds: v.lengthSeconds, + durationText: v.lengthText || null, + thumbnails: v.thumbnails, + channel: { + name: bylineRun?.text ?? "", + channelId: bylineEndpoint?.browseId ?? "", + url: bylineRun?.navigationEndpoint?.commandMetadata + ?.webCommandMetadata?.url ?? "", + }, + isPlayable: v.isPlayable, + isLive: + v.badges?.some( + (b: any) => + b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW", + ) ?? false, + viewCountText: v.viewCountText ?? null, + publishedAt: v.publishedAt ?? null, + category: v.category ?? null, + addedBy: v.addedBy ?? null, + voteCount: v.voteCount ?? null, + } satisfies PlaylistVideo; +} + function ensurePageScript(): void { if (pageScriptInjected) return; const script = document.createElement("script"); @@ -50,33 +80,7 @@ function handlePlaylistData(event: Event): void { } // Convert DOM-extracted videos to PlaylistVideo[] - const videos: PlaylistVideo[] = domVideos.map((v: any) => { - const bylineRun = v.shortBylineText?.runs?.[0]; - const bylineEndpoint = bylineRun?.navigationEndpoint?.browseEndpoint; - - return { - videoId: v.videoId, - title: v.title, - index: v.index, - durationSeconds: v.lengthSeconds, - durationText: v.lengthText || null, - thumbnails: v.thumbnails, - channel: { - name: bylineRun?.text ?? "", - channelId: bylineEndpoint?.browseId ?? "", - url: bylineRun?.navigationEndpoint?.commandMetadata - ?.webCommandMetadata?.url ?? "", - }, - isPlayable: v.isPlayable, - isLive: - v.badges?.some( - (b: any) => - b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW", - ) ?? false, - addedBy: v.addedBy ?? null, - voteCount: v.voteCount ?? null, - } satisfies PlaylistVideo; - }); + const videos: PlaylistVideo[] = domVideos.map(mapRawVideo); const playlistData = buildPlaylistData(metadata, videos, payload.isComplete !== false); @@ -104,33 +108,13 @@ function handlePlaylistAppend(event: Event): void { return; } + if (payload.type === "details_update") { + updateTableDetails(payload.updates); + return; + } + if (payload.type === "append") { - const newVideos: PlaylistVideo[] = payload.videos.map((v: any) => { - const bylineRun = v.shortBylineText?.runs?.[0]; - const bylineEndpoint = bylineRun?.navigationEndpoint?.browseEndpoint; - return { - videoId: v.videoId, - title: v.title, - index: v.index, - durationSeconds: v.lengthSeconds, - durationText: v.lengthText || null, - thumbnails: v.thumbnails, - channel: { - name: bylineRun?.text ?? "", - channelId: bylineEndpoint?.browseId ?? "", - url: bylineRun?.navigationEndpoint?.commandMetadata - ?.webCommandMetadata?.url ?? "", - }, - isPlayable: v.isPlayable, - isLive: - v.badges?.some( - (b: any) => - b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW", - ) ?? false, - addedBy: v.addedBy ?? null, - voteCount: v.voteCount ?? null, - } satisfies PlaylistVideo; - }); + const newVideos: PlaylistVideo[] = payload.videos.map(mapRawVideo); appendToTable(newVideos); if (payload.isComplete) { setTableComplete(payload.totalCount); diff --git a/src/content/ui/i18n.ts b/src/content/ui/i18n.ts index d6af448..a9a0e91 100644 --- a/src/content/ui/i18n.ts +++ b/src/content/ui/i18n.ts @@ -3,14 +3,21 @@ type MessageKey = | "colTitle" | "colChannel" | "colDuration" + | "colViews" + | "colPublished" + | "colCategory" | "colAddedBy" | "colVotes" | "filterTitle" | "filterChannel" | "filterAddedBy" + | "filterCategory" | "badgeLive" | "headerVideos" - | "headerLoading"; + | "headerLoading" + | "fetchViews" + | "fetchViewsProgress" + | "fetchViewsDone"; const messages: Record> = { ja: { @@ -18,28 +25,42 @@ const messages: Record> = { colTitle: "タイトル", colChannel: "チャンネル", colDuration: "長さ", + colViews: "再生数", + colPublished: "公開日", + colCategory: "カテゴリ", colAddedBy: "追加者", colVotes: "投票", filterTitle: "タイトル検索...", filterChannel: "チャンネル...", filterAddedBy: "追加者...", + filterCategory: "カテゴリ...", badgeLive: "ライブ", headerVideos: "本の動画", headerLoading: "読み込み中…", + fetchViews: "再生数を取得", + fetchViewsProgress: "取得中…", + fetchViewsDone: "取得完了", }, en: { colIndex: "#", colTitle: "Title", colChannel: "Channel", colDuration: "Duration", + colViews: "Views", + colPublished: "Published", + colCategory: "Category", colAddedBy: "Added by", colVotes: "Votes", filterTitle: "Search title...", filterChannel: "Channel...", filterAddedBy: "Added by...", + filterCategory: "Category...", badgeLive: "LIVE", headerVideos: "videos", headerLoading: "loading…", + fetchViews: "Fetch views", + fetchViewsProgress: "Fetching…", + fetchViewsDone: "Done", }, }; diff --git a/src/content/ui/lifecycle.ts b/src/content/ui/lifecycle.ts index f34b49d..86375fd 100644 --- a/src/content/ui/lifecycle.ts +++ b/src/content/ui/lifecycle.ts @@ -1,6 +1,6 @@ import type { PlaylistData, PlaylistVideo } from "../../types/playlist"; import { injectStyles } from "./styles"; -import { renderPlaylistTable, type PlaylistTableHandle } from "./table-renderer"; +import { renderPlaylistTable, type PlaylistTableHandle, type DetailUpdate } from "./table-renderer"; const CONTAINER_ID = "ytpf-playlist-table"; @@ -74,6 +74,10 @@ export function setTableComplete(extractedCount: number): void { currentHandle?.setComplete(extractedCount); } +export function updateTableDetails(updates: DetailUpdate[]): void { + currentHandle?.updateDetails(updates); +} + export function unmountTable(): void { if (pendingObserver) { pendingObserver.disconnect(); diff --git a/src/content/ui/styles.ts b/src/content/ui/styles.ts index a194046..e96df06 100644 --- a/src/content/ui/styles.ts +++ b/src/content/ui/styles.ts @@ -307,8 +307,41 @@ html[dark] .ytpf-tr:hover { .ytpf-col-title { width: auto; } .ytpf-col-channel { width: 180px; } .ytpf-col-duration { width: 80px; font-family: "Roboto Mono", monospace; } +.ytpf-col-views { width: 120px; text-align: right; font-family: "Roboto Mono", monospace; } +.ytpf-col-published { width: 110px; font-family: "Roboto Mono", monospace; } +.ytpf-col-category { width: 120px; } .ytpf-col-addedby { width: 140px; } .ytpf-col-votes { width: 60px; text-align: center; font-family: "Roboto Mono", monospace; } + +.ytpf-fetch-views-btn { + padding: 4px 12px; + border: 1px solid var(--yt-spec-10-percent-layer, rgba(0,0,0,0.2)); + border-radius: 16px; + background: transparent; + color: var(--yt-spec-text-secondary, #606060); + font-size: 12px; + font-family: "Roboto", "Arial", sans-serif; + cursor: pointer; + white-space: nowrap; +} + +html[dark] .ytpf-fetch-views-btn { + border-color: rgba(255,255,255,0.2); + color: #aaa; +} + +.ytpf-fetch-views-btn:hover:not(:disabled) { + background: var(--yt-spec-badge-chip-background, #f2f2f2); +} + +html[dark] .ytpf-fetch-views-btn:hover:not(:disabled) { + background: #3e3e3e; +} + +.ytpf-fetch-views-btn:disabled { + opacity: 0.6; + cursor: default; +} .ytpf-tr--unplayable { opacity: 0.45; } diff --git a/src/content/ui/table-renderer.ts b/src/content/ui/table-renderer.ts index 35e56dc..870104b 100644 --- a/src/content/ui/table-renderer.ts +++ b/src/content/ui/table-renderer.ts @@ -1,7 +1,7 @@ import type { PlaylistData, PlaylistVideo } from "../../types/playlist"; import { t } from "./i18n"; -type SortKey = "index" | "title" | "channel" | "duration" | "addedBy" | "votes"; +type SortKey = "index" | "title" | "channel" | "duration" | "views" | "published" | "category" | "addedBy" | "votes"; type SortDir = "asc" | "desc"; interface Column { @@ -9,14 +9,18 @@ interface Column { cls: string; key: SortKey; collab?: boolean; // only shown for collaborative playlists + detail?: boolean; // shown after detail fetch } -function getAllColumns(): Column[] { +function getAllColumns(hasDetails: boolean): Column[] { return [ { label: t("colIndex"), cls: "ytpf-col-index", key: "index" }, { label: t("colTitle"), cls: "ytpf-col-title", key: "title" }, { label: t("colChannel"), cls: "ytpf-col-channel", key: "channel" }, { label: t("colDuration"), cls: "ytpf-col-duration", key: "duration" }, + { label: t("colViews"), cls: "ytpf-col-views", key: "views" }, + { label: t("colPublished"), cls: "ytpf-col-published", key: "published", detail: true }, + { label: t("colCategory"), cls: "ytpf-col-category", key: "category", detail: true }, { label: t("colAddedBy"), cls: "ytpf-col-addedby", key: "addedBy", collab: true }, { label: t("colVotes"), cls: "ytpf-col-votes", key: "votes", collab: true }, ]; @@ -199,6 +203,18 @@ function createTagInput( }; } +function parseViewCount(text: string | null): number { + if (!text) return -1; + const cleaned = text.replace(/,/g, ""); + const m = cleaned.match(/([\d.]+)\s*(万|億)?/); + if (!m) return -1; + let num = parseFloat(m[1]); + if (m[2] === "万") num *= 10000; + else if (m[2] === "億") num *= 100000000; + return Math.round(num); +} + + function compareFn(key: SortKey, dir: SortDir) { const m = dir === "asc" ? 1 : -1; return (a: PlaylistVideo, b: PlaylistVideo): number => { @@ -211,6 +227,12 @@ function compareFn(key: SortKey, dir: SortDir) { return a.channel.name.localeCompare(b.channel.name) * m; case "duration": return ((a.durationSeconds ?? -1) - (b.durationSeconds ?? -1)) * m; + case "views": + return (parseViewCount(a.viewCountText) - parseViewCount(b.viewCountText)) * m; + case "published": + return (a.publishedAt ?? "").localeCompare(b.publishedAt ?? "") * m; + case "category": + return (a.category ?? "").localeCompare(b.category ?? "") * m; case "addedBy": return (a.addedBy ?? "").localeCompare(b.addedBy ?? "") * m; case "votes": @@ -223,6 +245,7 @@ function buildRow( video: PlaylistVideo, playlistId: string, isCollab: boolean, + hasDetails: boolean, ): HTMLTableRowElement { const tr = document.createElement("tr"); tr.className = "ytpf-tr"; @@ -272,6 +295,26 @@ function buildRow( } tr.appendChild(tdDuration); + // Views + const tdViews = document.createElement("td"); + tdViews.className = "ytpf-td ytpf-col-views"; + tdViews.textContent = video.viewCountText ?? "--"; + tr.appendChild(tdViews); + + if (hasDetails) { + // Published + const tdPublished = document.createElement("td"); + tdPublished.className = "ytpf-td ytpf-col-published"; + tdPublished.textContent = video.publishedAt ?? "--"; + tr.appendChild(tdPublished); + + // Category + const tdCategory = document.createElement("td"); + tdCategory.className = "ytpf-td ytpf-col-category"; + tdCategory.textContent = video.category ?? "--"; + tr.appendChild(tdCategory); + } + if (isCollab) { // Added by const tdAddedBy = document.createElement("td"); @@ -289,17 +332,26 @@ function buildRow( return tr; } +export interface DetailUpdate { + videoId: string; + viewCountText: string | null; + publishedAt: string | null; + category: string | null; +} + export interface PlaylistTableHandle { element: HTMLElement; appendVideos(newVideos: PlaylistVideo[]): void; setComplete(extractedCount: number): void; + updateDetails(updates: DetailUpdate[]): void; } export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { const isCollab = data.videos.some( (v) => v.addedBy != null || v.voteCount != null, ); - const columns = getAllColumns().filter((c) => !c.collab || isCollab); + let hasDetails = data.videos.some((v) => v.publishedAt != null || v.category != null); + let columns = getAllColumns(hasDetails).filter((c) => (!c.collab || isCollab) && (!c.detail || hasDetails)); const wrapper = document.createElement("div"); wrapper.className = "ytpf-wrapper"; @@ -330,7 +382,27 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { toggle.className = "ytpf-toggle"; toggle.textContent = "\u25BC"; - header.append(titleSpan, metaSpan, toggle); + // Fetch details button + const fetchBtn = document.createElement("button"); + fetchBtn.className = "ytpf-fetch-views-btn"; + fetchBtn.textContent = t("fetchViews"); + let detailsFetched = false; + fetchBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (detailsFetched) return; + const targetIds = videos.filter((v) => v.isPlayable).map((v) => v.videoId); + if (targetIds.length === 0) return; + detailsFetched = true; + fetchBtn.disabled = true; + fetchBtn.textContent = `${t("fetchViewsProgress")} 0/${targetIds.length}`; + document.dispatchEvent( + new CustomEvent("__yt_playlist_ext_fetch_details", { + detail: JSON.stringify({ videoIds: targetIds }), + }), + ); + }); + + header.append(titleSpan, metaSpan, fetchBtn, toggle); wrapper.appendChild(header); // Filter bar @@ -349,6 +421,23 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { const channelTagInput = createTagInput(t("filterChannel"), channelNames, () => applyFilters()); filters.appendChild(channelTagInput.container); + // Category tag input (shown after detail fetch) + let categoryTagInput: TagInput | null = null; + const categoryFilterContainer = document.createElement("div"); + categoryFilterContainer.style.display = "none"; + filters.appendChild(categoryFilterContainer); + + function ensureCategoryFilter() { + if (categoryTagInput) return; + const categoryNames = [...new Set( + videos.map((v) => v.category).filter((c): c is string => c != null), + )].sort(); + if (categoryNames.length === 0) return; + categoryTagInput = createTagInput(t("filterCategory"), categoryNames, () => applyFilters()); + categoryFilterContainer.appendChild(categoryTagInput.container); + categoryFilterContainer.style.display = ""; + } + // Added-by tag input (collab only) let addedByTagInput: TagInput | null = null; if (isCollab) { @@ -396,11 +485,13 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { function getFilteredVideos(): PlaylistVideo[] { const titleQuery = titleInput.value.toLowerCase(); const channelTags = channelTagInput.getTags(); + const categoryTags = categoryTagInput?.getTags() ?? []; const addedByTags = addedByTagInput?.getTags() ?? []; return videos.filter((v) => { if (titleQuery && !v.title.toLowerCase().includes(titleQuery)) return false; if (channelTags.length > 0 && !channelTags.includes(v.channel.name)) return false; + if (categoryTags.length > 0 && (!v.category || !categoryTags.includes(v.category))) return false; if (addedByTags.length > 0 && (!v.addedBy || !addedByTags.includes(v.addedBy))) return false; return true; }); @@ -410,7 +501,7 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { const filtered = getFilteredVideos(); tbody.textContent = ""; for (const video of filtered) { - tbody.appendChild(buildRow(video, playlistId, isCollab)); + tbody.appendChild(buildRow(video, playlistId, isCollab, hasDetails)); } filterCount.textContent = filtered.length < videos.length ? `${filtered.length} / ${videos.length}` @@ -496,5 +587,64 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { updateHeader(); } - return { element: wrapper, appendVideos, setComplete }; + let detailsReceived = 0; + let detailsTotal = 0; + + function updateDetails(updates: DetailUpdate[]) { + const map = new Map(updates.map((u) => [u.videoId, u])); + for (const v of videos) { + const u = map.get(v.videoId); + if (u) { + v.viewCountText = u.viewCountText ?? v.viewCountText; + v.publishedAt = u.publishedAt; + v.category = u.category; + } + } + + detailsReceived += updates.length; + if (detailsTotal === 0) { + detailsTotal = videos.filter((v) => v.isPlayable).length; + } + + // Add detail columns on first update if not present + if (!hasDetails) { + hasDetails = true; + columns = getAllColumns(true).filter((c) => (!c.collab || isCollab) && (!c.detail || hasDetails)); + // Rebuild thead + headRow.textContent = ""; + thElements.length = 0; + for (const col of columns) { + const th = document.createElement("th"); + th.className = `ytpf-th ytpf-th--sortable ${col.cls}`; + th.dataset.sortKey = col.key; + th.textContent = col.label; + thElements.push(th); + headRow.appendChild(th); + } + updateSortIndicators(); + } + + // Update category filter + ensureCategoryFilter(); + if (categoryTagInput) { + categoryTagInput.addCandidates( + updates.map((u) => u.category).filter((c): c is string => c != null), + ); + } + + // Update progress + if (detailsReceived < detailsTotal) { + fetchBtn.textContent = `${t("fetchViewsProgress")} ${detailsReceived}/${detailsTotal}`; + } else { + fetchBtn.textContent = t("fetchViewsDone"); + setTimeout(() => { fetchBtn.style.display = "none"; }, 1500); + } + + if (sortKey) { + videos.sort(compareFn(sortKey, sortDir)); + } + renderRows(); + } + + return { element: wrapper, appendVideos, setComplete, updateDetails }; } diff --git a/src/injected/page-script.ts b/src/injected/page-script.ts index 49b8a37..f2e2049 100644 --- a/src/injected/page-script.ts +++ b/src/injected/page-script.ts @@ -19,6 +19,8 @@ let isInitialLoad = true; let collabCache: Map = new Map(); let collabCachePlaylistId: string | null = null; + let lastBaseContext: any = null; + let lastAuthHeaders: Record = {}; function runExtraction() { if (!isPlaylistUrl()) return; @@ -37,6 +39,48 @@ // Listen for explicit trigger from content script document.addEventListener("__yt_playlist_ext_trigger", runExtraction); + // Handle on-demand detail fetching via player API + document.addEventListener("__yt_playlist_ext_fetch_details", async (e) => { + const { videoIds } = JSON.parse((e as CustomEvent).detail); + if (!videoIds?.length || !lastBaseContext) return; + + const concurrency = 5; + for (let i = 0; i < videoIds.length; i += concurrency) { + const batch = videoIds.slice(i, i + concurrency); + const results = await Promise.all( + batch.map(async (id: string) => { + try { + const res = await fetch( + "https://www.youtube.com/youtubei/v1/player?prettyPrint=false", + { + method: "POST", + headers: { "Content-Type": "application/json", ...lastAuthHeaders }, + credentials: "same-origin", + body: JSON.stringify({ context: lastBaseContext, videoId: id }), + }, + ); + if (!res.ok) return null; + const data = await res.json(); + const vc = data.videoDetails?.viewCount; + const mf = data.microformat?.playerMicroformatRenderer; + return { + videoId: id, + viewCountText: vc ? parseInt(vc, 10).toLocaleString() : null, + publishedAt: mf?.publishDate ?? null, + category: mf?.category ?? null, + }; + } catch { + return null; + } + }), + ); + const updates = results.filter(Boolean); + if (updates.length > 0) { + sendResult({ type: "details_update", updates }); + } + } + }); + // Reset on navigation away document.addEventListener("yt-navigate-start", () => { extractingUrl = null; @@ -64,6 +108,8 @@ async function extractAndSend(): Promise { const { cfg, apiKey, baseContext } = getConfig(); const authHeaders = buildAuthHeaders(cfg, baseContext); + lastBaseContext = baseContext; + lastAuthHeaders = authHeaders; // Try ytInitialData only on initial page load. // On SPA navigation ytInitialData is stale (still holds the previous page's data), @@ -114,20 +160,37 @@ // Fetch collaborator names for collaborative playlists const avatarToName = await fetchCollaboratorMap(initialData, cfg, baseContext, authHeaders); + function parseVideoInfo(videoInfo: any): { viewCountText: string | null } { + if (!videoInfo) return { viewCountText: null }; + if (!videoInfo.runs) { + const text = textOf(videoInfo); + return { viewCountText: text || null }; + } + const runs: string[] = videoInfo.runs.map((r: any) => r.text); + // Runs format: ["126万 回視聴", " · ", "6年前"] + return { viewCountText: runs[0]?.trim() || null }; + } + function mapRenderers(renderers: any[], startIndex: number) { - return renderers.map((d, i) => ({ - videoId: d.videoId, - title: textOf(d.title), - index: startIndex + i, - lengthSeconds: d.lengthSeconds ? parseInt(d.lengthSeconds, 10) : null, - lengthText: textOf(d.lengthText), - thumbnails: d.thumbnail?.thumbnails ?? [], - shortBylineText: d.shortBylineText, - isPlayable: d.isPlayable !== false, - badges: d.badges ?? [], - voteCount: typeof d.voteCount === "number" ? d.voteCount : null, - addedBy: resolveAddedBy(d, avatarToName), - })); + return renderers.map((d, i) => { + const { viewCountText } = parseVideoInfo(d.videoInfo); + return { + videoId: d.videoId, + title: textOf(d.title), + index: startIndex + i, + lengthSeconds: d.lengthSeconds ? parseInt(d.lengthSeconds, 10) : null, + lengthText: textOf(d.lengthText), + thumbnails: d.thumbnail?.thumbnails ?? [], + shortBylineText: d.shortBylineText, + isPlayable: d.isPlayable !== false, + badges: d.badges ?? [], + viewCountText, + publishedAt: null as string | null, + category: null as string | null, + voteCount: typeof d.voteCount === "number" ? d.voteCount : null, + addedBy: resolveAddedBy(d, avatarToName), + }; + }); } // Send first page immediately diff --git a/src/types/playlist.ts b/src/types/playlist.ts index 26b5b4e..a3bc7f2 100644 --- a/src/types/playlist.ts +++ b/src/types/playlist.ts @@ -21,6 +21,12 @@ export interface PlaylistVideo { }; isPlayable: boolean; isLive: boolean; + /** Human-readable view count text */ + viewCountText: string | null; + /** Publication date, e.g. "2019-01-15" */ + publishedAt: string | null; + /** Video category, e.g. "Music", "Gaming" */ + category: string | null; /** Name of the collaborator who added this video (collaborative playlists only) */ addedBy: string | null; /** Vote count / approvals (collaborative playlists only) */