追加者とVote, フィルタの追加
This commit is contained in:
parent
503f5a6e44
commit
6f186dcf31
|
|
@ -70,6 +70,8 @@ function handlePlaylistData(event: Event): void {
|
||||||
(b: any) =>
|
(b: any) =>
|
||||||
b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW",
|
b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW",
|
||||||
) ?? false,
|
) ?? false,
|
||||||
|
addedBy: v.addedBy ?? null,
|
||||||
|
voteCount: v.voteCount ?? null,
|
||||||
} satisfies PlaylistVideo;
|
} satisfies PlaylistVideo;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,67 @@ const CSS = `
|
||||||
transform: rotate(-90deg);
|
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 {
|
.ytpf-body {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +167,8 @@ const CSS = `
|
||||||
.ytpf-col-title { width: auto; }
|
.ytpf-col-title { width: auto; }
|
||||||
.ytpf-col-channel { width: 180px; }
|
.ytpf-col-channel { width: 180px; }
|
||||||
.ytpf-col-duration { width: 80px; font-family: "Roboto Mono", monospace; }
|
.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 {
|
.ytpf-tr--unplayable {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
import type { PlaylistData, PlaylistVideo } from "../../types/playlist";
|
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";
|
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: "#", cls: "ytpf-col-index", key: "index" },
|
||||||
{ label: "Title", cls: "ytpf-col-title", key: "title" },
|
{ label: "Title", cls: "ytpf-col-title", key: "title" },
|
||||||
{ label: "Channel", cls: "ytpf-col-channel", key: "channel" },
|
{ label: "Channel", cls: "ytpf-col-channel", key: "channel" },
|
||||||
{ label: "Duration", cls: "ytpf-col-duration", key: "duration" },
|
{ 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) {
|
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;
|
return a.channel.name.localeCompare(b.channel.name) * m;
|
||||||
case "duration":
|
case "duration":
|
||||||
return ((a.durationSeconds ?? -1) - (b.durationSeconds ?? -1)) * m;
|
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");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "ytpf-tr";
|
tr.className = "ytpf-tr";
|
||||||
if (!video.isPlayable) tr.classList.add("ytpf-tr--unplayable");
|
if (!video.isPlayable) tr.classList.add("ytpf-tr--unplayable");
|
||||||
|
|
@ -75,10 +92,29 @@ function buildRow(video: PlaylistVideo, playlistId: string): HTMLTableRowElement
|
||||||
}
|
}
|
||||||
tr.appendChild(tdDuration);
|
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;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
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");
|
const wrapper = document.createElement("div");
|
||||||
wrapper.className = "ytpf-wrapper";
|
wrapper.className = "ytpf-wrapper";
|
||||||
|
|
||||||
|
|
@ -103,6 +139,50 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
||||||
header.append(titleSpan, metaSpan, toggle);
|
header.append(titleSpan, metaSpan, toggle);
|
||||||
wrapper.appendChild(header);
|
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)
|
// Body (table container)
|
||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
body.className = "ytpf-body";
|
body.className = "ytpf-body";
|
||||||
|
|
@ -131,13 +211,38 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
||||||
const playlistId = data.metadata.playlistId;
|
const playlistId = data.metadata.playlistId;
|
||||||
const videos = [...data.videos];
|
const videos = [...data.videos];
|
||||||
|
|
||||||
function renderRows() {
|
function getFilteredVideos(): PlaylistVideo[] {
|
||||||
tbody.textContent = "";
|
const titleQuery = titleInput.value.toLowerCase();
|
||||||
for (const video of videos) {
|
const channelQuery = channelInput.value.toLowerCase();
|
||||||
tbody.appendChild(buildRow(video, playlistId));
|
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();
|
renderRows();
|
||||||
|
|
||||||
// Sort state
|
// Sort state
|
||||||
|
|
@ -181,6 +286,7 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
||||||
// Toggle collapse
|
// Toggle collapse
|
||||||
header.addEventListener("click", () => {
|
header.addEventListener("click", () => {
|
||||||
body.classList.toggle("ytpf-body--hidden");
|
body.classList.toggle("ytpf-body--hidden");
|
||||||
|
filters.classList.toggle("ytpf-filters--hidden");
|
||||||
toggle.classList.toggle("ytpf-toggle--collapsed");
|
toggle.classList.toggle("ytpf-toggle--collapsed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,23 +15,30 @@
|
||||||
if (w.__yt_playlist_ext_injected) return;
|
if (w.__yt_playlist_ext_injected) return;
|
||||||
w.__yt_playlist_ext_injected = true;
|
w.__yt_playlist_ext_injected = true;
|
||||||
|
|
||||||
// Run extraction for the current page if it's a playlist
|
let extractingUrl: string | null = null;
|
||||||
if (isPlaylistUrl()) {
|
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();
|
extractAndSend();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run extraction for the current page if it's a playlist
|
||||||
|
runExtraction();
|
||||||
|
|
||||||
// Listen for SPA navigation
|
// Listen for SPA navigation
|
||||||
document.addEventListener("yt-navigate-finish", () => {
|
document.addEventListener("yt-navigate-finish", runExtraction);
|
||||||
if (isPlaylistUrl()) {
|
|
||||||
extractAndSend();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for explicit trigger from content script
|
// Listen for explicit trigger from content script
|
||||||
document.addEventListener("__yt_playlist_ext_trigger", () => {
|
document.addEventListener("__yt_playlist_ext_trigger", runExtraction);
|
||||||
if (isPlaylistUrl()) {
|
|
||||||
extractAndSend();
|
// Reset on navigation away
|
||||||
}
|
document.addEventListener("yt-navigate-start", () => {
|
||||||
|
extractingUrl = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
function isPlaylistUrl(): boolean {
|
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) => ({
|
const videos = allRenderers.map((d, i) => ({
|
||||||
videoId: d.videoId,
|
videoId: d.videoId,
|
||||||
title: textOf(d.title),
|
title: textOf(d.title),
|
||||||
|
|
@ -156,6 +166,8 @@
|
||||||
shortBylineText: d.shortBylineText,
|
shortBylineText: d.shortBylineText,
|
||||||
isPlayable: d.isPlayable !== false,
|
isPlayable: d.isPlayable !== false,
|
||||||
badges: d.badges ?? [],
|
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)`);
|
console.log(LOG, `Extraction: ${videos.length} videos (${page - 1} continuation pages)`);
|
||||||
|
|
@ -170,6 +182,107 @@
|
||||||
|
|
||||||
// --- helpers ---
|
// --- 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(
|
async function fetchBrowseData(
|
||||||
browseId: string,
|
browseId: string,
|
||||||
baseContext: any,
|
baseContext: any,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ export interface PlaylistVideo {
|
||||||
};
|
};
|
||||||
isPlayable: boolean;
|
isPlayable: boolean;
|
||||||
isLive: 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 {
|
export interface PlaylistMetadata {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user