追加者とVote, フィルタの追加

This commit is contained in:
Keisuke Hirata 2026-04-09 01:06:46 +09:00
parent 503f5a6e44
commit 6f186dcf31
5 changed files with 307 additions and 19 deletions

View File

@ -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;
});

View File

@ -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;
}

View File

@ -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<HTMLSelectElement>(".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");
});

View File

@ -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<string, string> = 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<string, string>,
): Promise<Map<string, string>> {
const params = extractCollabParams(initialData);
if (!params) return new Map<string, string>();
// 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<string, string>();
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, string>): 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,

View File

@ -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 {