From 6f186dcf31e1665d7a7c7432ea22cc4c43e18eaa Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Apr 2026 01:06:46 +0900 Subject: [PATCH] =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E8=80=85=E3=81=A8Vote,=20?= =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=AB=E3=82=BF=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/index.ts | 2 + src/content/ui/styles.ts | 63 +++++++++++++++ src/content/ui/table-renderer.ts | 122 ++++++++++++++++++++++++++-- src/injected/page-script.ts | 135 ++++++++++++++++++++++++++++--- src/types/playlist.ts | 4 + 5 files changed, 307 insertions(+), 19 deletions(-) diff --git a/src/content/index.ts b/src/content/index.ts index 092e4b1..f5913bd 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -70,6 +70,8 @@ function handlePlaylistData(event: Event): void { (b: any) => b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW", ) ?? false, + addedBy: v.addedBy ?? null, + voteCount: v.voteCount ?? null, } satisfies PlaylistVideo; }); diff --git a/src/content/ui/styles.ts b/src/content/ui/styles.ts index 32340b8..7a9155e 100644 --- a/src/content/ui/styles.ts +++ b/src/content/ui/styles.ts @@ -43,6 +43,67 @@ const CSS = ` transform: rotate(-90deg); } +.ytpf-filters { + display: flex; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--yt-spec-10-percent-layer); + background: var(--yt-spec-base-background); + flex-wrap: wrap; +} + +.ytpf-filter-input, +.ytpf-filter-select { + padding: 6px 10px; + border: 1px solid var(--yt-spec-10-percent-layer); + border-radius: 8px; + background: var(--yt-spec-badge-chip-background); + color: var(--yt-spec-text-primary); + font-size: 13px; + font-family: "Roboto", "Arial", sans-serif; + outline: none; +} + +.ytpf-filter-input { + flex: 1; + min-width: 120px; +} + +.ytpf-filter-select { + cursor: pointer; + min-width: 140px; +} + +.ytpf-filter-input:focus, +.ytpf-filter-select:focus { + border-color: var(--yt-spec-call-to-action); +} + +.ytpf-filter-input::placeholder { + color: var(--yt-spec-text-secondary); +} + +.ytpf-filter-select option { + background: var(--yt-spec-base-background, #fff); + color: var(--yt-spec-text-primary, #0f0f0f); +} + +html[dark] .ytpf-filter-select option { + background: #282828; + color: #f1f1f1; +} + +.ytpf-filters--hidden { + display: none; +} + +.ytpf-filter-count { + font-size: 12px; + color: var(--yt-spec-text-secondary); + align-self: center; + white-space: nowrap; +} + .ytpf-body { overflow-x: auto; } @@ -106,6 +167,8 @@ const CSS = ` .ytpf-col-title { width: auto; } .ytpf-col-channel { width: 180px; } .ytpf-col-duration { width: 80px; font-family: "Roboto Mono", monospace; } +.ytpf-col-addedby { width: 140px; } +.ytpf-col-votes { width: 60px; text-align: center; font-family: "Roboto Mono", monospace; } .ytpf-tr--unplayable { opacity: 0.45; } diff --git a/src/content/ui/table-renderer.ts b/src/content/ui/table-renderer.ts index 5c42705..8591cd8 100644 --- a/src/content/ui/table-renderer.ts +++ b/src/content/ui/table-renderer.ts @@ -1,13 +1,22 @@ import type { PlaylistData, PlaylistVideo } from "../../types/playlist"; -type SortKey = "index" | "title" | "channel" | "duration"; +type SortKey = "index" | "title" | "channel" | "duration" | "addedBy" | "votes"; type SortDir = "asc" | "desc"; -const columns: { label: string; cls: string; key: SortKey }[] = [ +interface Column { + label: string; + cls: string; + key: SortKey; + collab?: boolean; // only shown for collaborative playlists +} + +const allColumns: Column[] = [ { label: "#", cls: "ytpf-col-index", key: "index" }, { label: "Title", cls: "ytpf-col-title", key: "title" }, { label: "Channel", cls: "ytpf-col-channel", key: "channel" }, { label: "Duration", cls: "ytpf-col-duration", key: "duration" }, + { label: "Added by", cls: "ytpf-col-addedby", key: "addedBy", collab: true }, + { label: "Votes", cls: "ytpf-col-votes", key: "votes", collab: true }, ]; function compareFn(key: SortKey, dir: SortDir) { @@ -22,11 +31,19 @@ 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 "addedBy": + return (a.addedBy ?? "").localeCompare(b.addedBy ?? "") * m; + case "votes": + return ((a.voteCount ?? 0) - (b.voteCount ?? 0)) * m; } }; } -function buildRow(video: PlaylistVideo, playlistId: string): HTMLTableRowElement { +function buildRow( + video: PlaylistVideo, + playlistId: string, + isCollab: boolean, +): HTMLTableRowElement { const tr = document.createElement("tr"); tr.className = "ytpf-tr"; if (!video.isPlayable) tr.classList.add("ytpf-tr--unplayable"); @@ -75,10 +92,29 @@ function buildRow(video: PlaylistVideo, playlistId: string): HTMLTableRowElement } tr.appendChild(tdDuration); + if (isCollab) { + // Added by + const tdAddedBy = document.createElement("td"); + tdAddedBy.className = "ytpf-td ytpf-col-addedby"; + tdAddedBy.textContent = video.addedBy ?? "--"; + tr.appendChild(tdAddedBy); + + // Votes + const tdVotes = document.createElement("td"); + tdVotes.className = "ytpf-td ytpf-col-votes"; + tdVotes.textContent = video.voteCount != null ? String(video.voteCount) : "--"; + tr.appendChild(tdVotes); + } + return tr; } export function renderPlaylistTable(data: PlaylistData): HTMLElement { + const isCollab = data.videos.some( + (v) => v.addedBy != null || v.voteCount != null, + ); + const columns = allColumns.filter((c) => !c.collab || isCollab); + const wrapper = document.createElement("div"); wrapper.className = "ytpf-wrapper"; @@ -103,6 +139,50 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement { header.append(titleSpan, metaSpan, toggle); wrapper.appendChild(header); + // Filter bar + const filters = document.createElement("div"); + filters.className = "ytpf-filters"; + + const titleInput = document.createElement("input"); + titleInput.className = "ytpf-filter-input"; + titleInput.type = "text"; + titleInput.placeholder = "タイトル検索..."; + filters.appendChild(titleInput); + + const channelInput = document.createElement("input"); + channelInput.className = "ytpf-filter-input"; + channelInput.type = "text"; + channelInput.placeholder = "チャンネル検索..."; + filters.appendChild(channelInput); + + if (isCollab) { + const addedBySelect = document.createElement("select"); + addedBySelect.className = "ytpf-filter-select"; + const allOption = document.createElement("option"); + allOption.value = ""; + allOption.textContent = "追加者: すべて"; + addedBySelect.appendChild(allOption); + + const addedByNames = [...new Set( + data.videos.map((v) => v.addedBy).filter((n): n is string => n != null), + )].sort(); + for (const name of addedByNames) { + const opt = document.createElement("option"); + opt.value = name; + opt.textContent = name; + addedBySelect.appendChild(opt); + } + filters.appendChild(addedBySelect); + + addedBySelect.addEventListener("change", applyFilters); + } + + const filterCount = document.createElement("span"); + filterCount.className = "ytpf-filter-count"; + filters.appendChild(filterCount); + + wrapper.appendChild(filters); + // Body (table container) const body = document.createElement("div"); body.className = "ytpf-body"; @@ -131,13 +211,38 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement { const playlistId = data.metadata.playlistId; const videos = [...data.videos]; - function renderRows() { - tbody.textContent = ""; - for (const video of videos) { - tbody.appendChild(buildRow(video, playlistId)); - } + function getFilteredVideos(): PlaylistVideo[] { + const titleQuery = titleInput.value.toLowerCase(); + const channelQuery = channelInput.value.toLowerCase(); + const addedBySelect = filters.querySelector(".ytpf-filter-select"); + const addedByFilter = addedBySelect?.value ?? ""; + + return videos.filter((v) => { + if (titleQuery && !v.title.toLowerCase().includes(titleQuery)) return false; + if (channelQuery && !v.channel.name.toLowerCase().includes(channelQuery)) return false; + if (addedByFilter && v.addedBy !== addedByFilter) return false; + return true; + }); } + function renderRows() { + const filtered = getFilteredVideos(); + tbody.textContent = ""; + for (const video of filtered) { + tbody.appendChild(buildRow(video, playlistId, isCollab)); + } + filterCount.textContent = filtered.length < videos.length + ? `${filtered.length} / ${videos.length}` + : ""; + } + + function applyFilters() { + renderRows(); + } + + titleInput.addEventListener("input", applyFilters); + channelInput.addEventListener("input", applyFilters); + renderRows(); // Sort state @@ -181,6 +286,7 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement { // Toggle collapse header.addEventListener("click", () => { body.classList.toggle("ytpf-body--hidden"); + filters.classList.toggle("ytpf-filters--hidden"); toggle.classList.toggle("ytpf-toggle--collapsed"); }); diff --git a/src/injected/page-script.ts b/src/injected/page-script.ts index 3520c70..bacc434 100644 --- a/src/injected/page-script.ts +++ b/src/injected/page-script.ts @@ -15,23 +15,30 @@ if (w.__yt_playlist_ext_injected) return; w.__yt_playlist_ext_injected = true; - // Run extraction for the current page if it's a playlist - if (isPlaylistUrl()) { + let extractingUrl: string | null = null; + let collabCache: Map = new Map(); + let collabCachePlaylistId: string | null = null; + + function runExtraction() { + if (!isPlaylistUrl()) return; + const url = window.location.href; + if (url === extractingUrl) return; + extractingUrl = url; extractAndSend(); } + // Run extraction for the current page if it's a playlist + runExtraction(); + // Listen for SPA navigation - document.addEventListener("yt-navigate-finish", () => { - if (isPlaylistUrl()) { - extractAndSend(); - } - }); + document.addEventListener("yt-navigate-finish", runExtraction); // Listen for explicit trigger from content script - document.addEventListener("__yt_playlist_ext_trigger", () => { - if (isPlaylistUrl()) { - extractAndSend(); - } + document.addEventListener("__yt_playlist_ext_trigger", runExtraction); + + // Reset on navigation away + document.addEventListener("yt-navigate-start", () => { + extractingUrl = null; }); function isPlaylistUrl(): boolean { @@ -146,6 +153,9 @@ } } + // Fetch collaborator names for collaborative playlists + const avatarToName = await fetchCollaboratorMap(initialData, cfg, baseContext, authHeaders); + const videos = allRenderers.map((d, i) => ({ videoId: d.videoId, title: textOf(d.title), @@ -156,6 +166,8 @@ shortBylineText: d.shortBylineText, isPlayable: d.isPlayable !== false, badges: d.badges ?? [], + voteCount: typeof d.voteCount === "number" ? d.voteCount : null, + addedBy: resolveAddedBy(d, avatarToName), })); console.log(LOG, `Extraction: ${videos.length} videos (${page - 1} continuation pages)`); @@ -170,6 +182,107 @@ // --- helpers --- + function normalizeAvatarUrl(url: string): string { + // Strip size parameter to make URLs comparable (e.g. =s48 vs =s176) + return url.replace(/=s\d+/, "=s0"); + } + + function extractCollabParams(initialData: any): string | null { + const vm = initialData?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel; + const rows = vm?.metadata?.contentMetadataViewModel?.metadataRows; + if (!rows) return null; + for (const row of rows) { + for (const part of row.metadataParts ?? []) { + const cmd = part?.avatarStack?.avatarStackViewModel?.rendererContext + ?.commandContext?.onTap?.innertubeCommand; + const params = cmd?.showEngagementPanelEndpoint?.globalConfiguration?.params; + if (params) return params; + } + } + return null; + } + + async function fetchCollaboratorMap( + initialData: any, + cfg: any, + baseContext: any, + authHeaders: Record, + ): Promise> { + const params = extractCollabParams(initialData); + if (!params) return new Map(); + + // Return cached map if same playlist + const playlistId = new URL(window.location.href).searchParams.get("list"); + if (playlistId && playlistId === collabCachePlaylistId && collabCache.size > 0) { + return collabCache; + } + + const map = new Map(); + + try { + const res = await fetch( + "https://www.youtube.com/youtubei/v1/get_panel?prettyPrint=false", + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders, + "X-Origin": window.location.origin, + "X-Youtube-Bootstrap-Logged-In": "true", + "X-Youtube-Client-Name": String(cfg.INNERTUBE_CONTEXT_CLIENT_NAME ?? "1"), + "X-Youtube-Client-Version": cfg.INNERTUBE_CLIENT_VERSION ?? "", + }, + credentials: "same-origin", + body: JSON.stringify({ + context: baseContext, + panelId: "PAplaylist_collaborate", + params, + }), + }, + ); + if (!res.ok) return map; + const data = await res.json(); + const collaborators = + data?.content?.engagementPanelSectionListRenderer?.content + ?.playlistCollaborationViewModel?.playlistCollaborators ?? []; + for (const c of collaborators) { + const item = c.contentListItemViewModel; + if (!item) continue; + const name = item.title?.content ?? ""; + const avatarUrl = + item.avatar?.avatarViewModel?.image?.sources?.[0]?.url; + if (name && avatarUrl) { + map.set(normalizeAvatarUrl(avatarUrl), name); + } + } + console.log(LOG, `Fetched ${map.size} collaborators`); + collabCache = map; + collabCachePlaylistId = playlistId; + } catch (e) { + console.error(LOG, "Failed to fetch collaborators:", e); + } + return map; + } + + function resolveAddedBy(renderer: any, avatarToName: Map): string | null { + if (avatarToName.size === 0) return null; + const overlays = renderer.thumbnailOverlays ?? []; + for (const o of overlays) { + const avatars = + o.thumbnailOverlayAvatarStackViewModel?.avatarStack + ?.avatarStackViewModel?.avatars; + if (!avatars) continue; + for (const a of avatars) { + const url = a.avatarViewModel?.image?.sources?.[0]?.url; + if (url) { + const name = avatarToName.get(normalizeAvatarUrl(url)); + if (name) return name; + } + } + } + return null; + } + async function fetchBrowseData( browseId: string, baseContext: any, diff --git a/src/types/playlist.ts b/src/types/playlist.ts index d6f7101..26b5b4e 100644 --- a/src/types/playlist.ts +++ b/src/types/playlist.ts @@ -21,6 +21,10 @@ export interface PlaylistVideo { }; isPlayable: boolean; isLive: boolean; + /** Name of the collaborator who added this video (collaborative playlists only) */ + addedBy: string | null; + /** Vote count / approvals (collaborative playlists only) */ + voteCount: number | null; } export interface PlaylistMetadata {