refactor: changed to web endpoint because youtube is breaking the android JSON API

This commit is contained in:
Alex 2022-04-04 23:33:16 +12:00
parent 6d9df0dfbf
commit 25fbeab72f
11 changed files with 431 additions and 75 deletions

View File

@ -0,0 +1,87 @@
<template>
<div id="comment">
<a
:href="this.$rendererUtils.getNavigationEndpoints(comment.authorEndpoint)"
class="avatar-link pt-2"
>
<v-img
class="avatar-thumbnail"
:src="
comment.authorThumbnail.thumbnails[
comment.authorThumbnail.thumbnails.length - 1
].url
"
/>
</a>
<v-card-text class="comment-info pt-2">
<div
v-for="title in comment.title.runs"
:key="title.text"
style="margin-top: 0.5em"
class="vid-title"
>
{{ title.text }}
</div>
<div
class="caption background--text"
:class="$vuetify.theme.dark ? 'text--lighten-4' : 'text--darken-4'"
v-text="parseBottom(comment)"
/>
</v-card-text>
</div>
</template>
<style scoped>
.entry {
width: 100%; /* Prevent Loading Weirdness */
}
.vid-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
overflow: hidden;
}
.avatar-thumbnail {
margin-top: 0.5rem;
margin-left: 0.5rem;
border-radius: 50%;
width: 50px;
height: 50px;
}
#details {
display: flex;
flex-direction: row;
flex-basis: auto;
padding: 10px;
}
@media screen and (orientation: landscape) {
.entry {
margin-bottom: 8px;
}
#details {
flex-direction: column-reverse;
}
}
</style>
<script>
export default {
props: ["comment"],
methods: {
parseBottom(comment) {
const bottomText = [
comment.subscriberCountText?.runs[0].text,
comment.videoCountText?.runs.map((run) => run.text).join(" "),
];
return bottomText.join(" · ");
},
},
};
</script>

View File

@ -32,7 +32,7 @@
<a
:href="
this.$rendererUtils.getNavigationEndpoints(
video.shortBylineText.runs[0].navigationEndpoint
video.shortBylineText.runs[0]
)
"
class="avatar-link pt-2"

View File

@ -38,12 +38,14 @@
import compactVideoRenderer from "~/components/CompactRenderers/compactVideoRenderer.vue";
import compactChannelRenderer from "~/components/CompactRenderers/compactChannelRenderer.vue";
import gridVideoRenderer from "~/components/gridRenderers/gridVideoRenderer.vue";
import videoWithContextRenderer from "~/components/gridRenderers/videoWithContextRenderer.vue";
export default {
components: {
gridVideoRenderer,
compactVideoRenderer,
compactChannelRenderer,
videoWithContextRenderer,
},
props: ["render"],

View File

@ -1,20 +1,20 @@
<template>
<div class="description">
<template v-for="(text, index) in render.description.runs">
<template
v-if="
text.navigationEndpoint && text.navigationEndpoint.webviewEndpoint
"
>
<template v-for="(text, index) in render.descriptionBodyText.runs">
<template v-if="$rendererUtils.checkInternal(text)">
<a
@click="openExternal($rendererUtils.getNavigationEndpoints(text))"
@click="openInternal($rendererUtils.getNavigationEndpoints(text))"
:key="index"
>{{ text.text }}</a
>
</template>
<template v-else-if="$rendererUtils.checkInternal(text)">
<template
v-else-if="
text.navigationEndpoint && text.navigationEndpoint.urlEndpoint
"
>
<a
@click="openInternal($rendererUtils.getNavigationEndpoints(text))"
@click="openExternal($rendererUtils.getNavigationEndpoints(text))"
:key="index"
>{{ text.text }}</a
>
@ -28,6 +28,7 @@
.description {
white-space: pre-line;
margin-bottom: 16px;
margin-top: 16px;
}
</style>

View File

@ -0,0 +1,151 @@
<template>
<v-card
class="entry videoWithContextRenderer background"
:to="`/watch?v=${video.videoId}`"
flat
>
<div style="position: relative" class="thumbnail-container">
<v-img
v-if="video.thumbnail"
:aspect-ratio="16 / 9"
:src="
$youtube.getThumbnail(
video.videoId,
'max',
video.thumbnail.thumbnails
)
"
/>
<div
v-if="video.thumbnailOverlays"
class="videoRuntimeFloat"
:class="
'style-' +
video.thumbnailOverlays[0].thumbnailOverlayTimeStatusRenderer.style
"
style="color: #fff"
v-text="
video.thumbnailOverlays[0].thumbnailOverlayTimeStatusRenderer.text
.runs[0].text
"
/>
</div>
<div id="details">
<a
:href="
$rendererUtils.getNavigationEndpoints(
video.shortBylineText.runs[0].navigationEndpoint
)
"
class="avatar-link pt-2"
>
<v-img
class="avatar-thumbnail"
:src="
video.channelThumbnail.channelThumbnailWithLinkRenderer.thumbnail
.thumbnails[0].url
"
/>
</a>
<v-card-text class="video-info pt-2">
<div
v-for="title in video.headline.runs"
:key="title.text"
style="margin-top: 0.5em"
class="font-weight-medium vid-title"
>
{{ title.text }}
</div>
<div
class="background--text text--lighten-5 caption"
:class="$vuetify.theme.dark ? 'text--lighten-4' : 'text--darken-4'"
v-text="parseBottom(video)"
/>
</v-card-text>
</div>
</v-card>
</template>
<style scoped>
.entry {
width: 100%; /* Prevent Loading Weirdness */
}
.videoRuntimeFloat {
position: absolute;
bottom: 10px;
right: 10px;
border-radius: 5px;
padding: 0px 4px 0px 4px;
}
.videoRuntimeFloat.style-DEFAULT {
background: rgba(0, 0, 0, 0.5);
}
.videoRuntimeFloat.style-LIVE {
background: rgba(255, 0, 0, 0.5);
}
.vid-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
overflow: hidden;
}
.avatar-thumbnail {
margin-top: 0.5rem;
margin-left: 0.5rem;
border-radius: 50%;
width: 35px;
height: 35px;
}
#details {
display: flex;
flex-direction: row;
flex-basis: auto;
}
@media screen and (orientation: landscape) {
.entry {
margin-bottom: 8px;
}
.thumbnail-container {
width: 50vh;
float: left;
}
#details {
flex-direction: column-reverse;
}
.avatar-thumbnail {
margin-top: 0;
margin-left: 16px;
}
.video-info {
padding-top: 0 !important;
padding-bottom: 0;
margin-top: 0;
}
}
</style>
<script>
export default {
props: ["video"],
methods: {
parseBottom(video) {
const bottomText = [
video.shortBylineText?.runs[0].text,
video.shortViewCountText?.runs[0].text,
];
if (video.publishedTimeText?.runs[0].text)
bottomText.push(video.publishedTimeText?.runs[0].text);
return bottomText.join(" · ");
},
},
};
</script>

View File

@ -19,11 +19,16 @@
font-size: 0.95rem;
line-height: 1rem;
"
v-text="title"
v-text="video.title"
/>
<v-card-text>
<div style="margin-bottom: 1rem">
{{ views }} views {{ uploaded }}
<template
v-for="text in video.metadata.contents.find(
(content) => content.slimVideoInformationRenderer
).slimVideoInformationRenderer.collapsedSubtitle.runs"
>{{ text.text }}</template
>
</div>
<!-- Scrolling Div For Interactions --->
@ -58,12 +63,7 @@
</div>
<!-- End Scrolling Div For Interactions --->
<!-- <hr /> -->
<p>Channel Stuff</p>
</v-card-text>
<div v-if="showMore" class="scroll-y ml-2 mr-2">
<slim-video-description-renderer :render="description" />
</div>
<!-- <v-bottom-sheet
v-model="showMore"
color="background"
@ -75,32 +75,88 @@
><br />
<div class="scroll-y">
{{ description }}
{{ response.renderedData.description }}
</div>
</v-sheet>
</v-bottom-sheet> -->
<!-- <v-bottom-sheet v-model="share" color="background" style="z-index: 9999999">
<v-sheet style="padding: 1em">
<div class="scroll-y">
{{ description }}
{{ response.renderedData.description }}
</div>
</v-sheet>
</v-bottom-sheet> -->
</v-card>
<vid-load-renderer v-if="!recommends" />
<shelf-renderer v-else :render="recommends" />
<v-divider />
<!-- Channel Bar -->
<v-card
class="channel-section background"
v-if="loaded"
:to="video.channelUrl"
>
<div id="details">
<div class="avatar-link mr-3">
<v-img class="avatar-thumbnail" :src="video.channelImg" />
</div>
<div class="channel-byline">
<div class="channel-name" v-text="video.channelName" />
<div
class="caption background--text"
:class="$vuetify.theme.dark ? 'text--lighten-4' : 'text--darken-4'"
v-text="video.channelSubs"
/>
</div>
</div>
<div
class="channel-buttons"
style="color: rgb(204, 0, 0); text-transform: uppercase"
>
subscribe
</div>
</v-card>
<v-divider />
<!-- Comments -->
<!-- <v-card flat class="background comment-button" v-if="loaded">
<v-text
>{{ video.commentData.title }}
{{ video.commentData.commentsCount }}</v-text
>
</v-card>
<v-divider /> -->
<!-- Description -->
<div v-if="showMore" class="scroll-y ml-4 mr-4">
<slim-video-description-renderer
:render="video.renderedData.description"
/>
</div>
<!-- Related Videos -->
<div class="loaders" v-if="!loaded">
<v-skeleton-loader
type="list-item-two-line, actions, divider, list-item-avatar, divider, list-item-three-line"
/>
<vid-load-renderer :count="5" />
</div>
<item-section-renderer v-else :render="recommends" />
</div>
</template>
<script>
import { Share } from "@capacitor/share";
import ShelfRenderer from "~/components/SectionRenderers/shelfRenderer.vue";
import VidLoadRenderer from "~/components/vidLoadRenderer.vue";
import { getCpn } from "~/plugins/utils";
import SlimVideoDescriptionRenderer from "~/components/UtilRenderers/slimVideoDescriptionRenderer.vue";
import ItemSectionRenderer from "~/components/SectionRenderers/itemSectionRenderer.vue";
export default {
components: { ShelfRenderer, VidLoadRenderer, SlimVideoDescriptionRenderer },
components: {
VidLoadRenderer,
SlimVideoDescriptionRenderer,
ItemSectionRenderer,
},
data() {
return {
interactions: [
@ -129,15 +185,12 @@ export default {
],
showMore: false,
// share: false,
title: null,
uploaded: null,
vidSrc: null,
sources: [],
description: null,
views: null,
recommends: null,
loaded: false,
interval: null,
video: null,
};
},
watch: {
@ -167,10 +220,10 @@ export default {
},
methods: {
getVideo() {
this.likes = 100;
this.loaded = false;
this.$youtube.getVid(this.$route.query.v).then((result) => {
this.video = result;
console.log("Video info data", result);
console.log(result.availableResolutions);
@ -184,14 +237,9 @@ export default {
].url; // Takes the highest available resolution with both video and Audio. Note this will be lower than the actual highest resolution
//--- Content Stuff ---//
this.title = result.title;
this.description = result.renderedData.description; // While this works, I do recommend using the rendered description instead in the future as there are some things a pure string wouldn't work with
this.views = parseInt(result.metadata.viewCount).toLocaleString();
this.likes = result.metadata.likes.toLocaleString();
this.uploaded = result.metadata.uploadDate;
this.interactions[0].value = result.metadata.likes.toLocaleString();
this.loaded = true;
this.recommends = result.renderedData.recommendations;
// .catch((error) => this.$logger("Watch", error, true));
console.log("recommendations:", this.recommends);
@ -220,8 +268,8 @@ export default {
async share() {
// this.share = !this.share;
await Share.share({
title: this.title,
text: this.title,
title: this.video.title,
text: this.video.title,
url: "https://youtu.be/" + this.$route.query.v,
dialogTitle: "Share video",
});
@ -271,4 +319,34 @@ export default {
flex-direction: column;
justify-content: space-around;
}
.channel-section,
.comment-button {
display: flex;
align-items: center;
padding: 12px;
}
.channel-section #details {
flex-grow: 1;
display: flex;
align-items: center;
min-width: 0;
}
.channel-section .channel-byline {
min-width: 0;
}
.channel-section .avatar-thumbnail {
border-radius: 50%;
width: 35px;
height: 35px;
}
.channel-section .channel-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -12,7 +12,7 @@ const url = {
const ytApiVal = {
VERSION: "16.25",
CLIENTNAME: "ANDROID",
VERSION_WEB: "2.20220318.00.00",
VERSION_WEB: "2.20220331.06.00",
CLIENT_WEB: 2,
};

View File

@ -3,6 +3,7 @@
import { Http } from "@capacitor-community/http";
import { getBetweenStrings } from "./utils";
import rendererUtils from "./renderers";
import constants from "./constants";
class Innertube {
@ -95,8 +96,11 @@ class Innertube {
};
}
async getContinuationsAsync(continuation, type) {
let data = { context: this.context, continuation: continuation };
async getContinuationsAsync(continuation, type, contextAdditional = {}) {
let data = {
context: { ...this.context, ...contextAdditional },
continuation: continuation,
};
let url;
switch (type) {
case "browse":
@ -135,7 +139,17 @@ class Innertube {
let data = { context: this.context, videoId: id };
const responseNext = await Http.post({
url: `${constants.URLS.YT_BASE_API}/next?key=${this.key}`,
data: data,
data: {
...data,
...{
context: {
client: {
clientName: constants.YT_API_VALUES.CLIENT_WEB,
clientVersion: constants.YT_API_VALUES.VERSION_WEB,
},
},
},
},
headers: constants.INNERTUBE_HEADER(this.context.client),
}).catch((error) => error);
@ -251,59 +265,67 @@ class Innertube {
const resolutions = responseInfo.streamingData;
const columnUI =
responseNext.contents.singleColumnWatchNextResults.results.results;
const vidMetadata = columnUI.contents.find(
(content) => content.slimVideoMetadataSectionRenderer
).slimVideoMetadataSectionRenderer;
const recommendations = columnUI?.contents.find(
(contents) => contents?.itemSectionRenderer?.targetId == "watch-next-feed"
).itemSectionRenderer;
const ownerData = vidMetadata.contents.find(
(content) => content.slimOwnerRenderer
)?.slimOwnerRenderer;
const vidData = {
id: details.videoId,
title: details.title,
isLive: details.isLiveContent,
channelName: details.author,
channelUrl:
columnUI?.contents[0].slimVideoMetadataSectionRenderer?.contents.find(
(contents) => contents.elementRenderer
)?.newElement?.type?.componentType?.model?.channelBarModel
?.videoChannelBarData?.onTap?.innertubeCommand?.browseEndpoint
?.canonicalBaseUrl,
channelImg:
columnUI?.contents[0].slimVideoMetadataSectionRenderer?.contents.find(
(contents) => contents.elementRenderer
)?.newElement?.type?.componentType?.model?.channelBarModel
?.videoChannelBarData?.avatar?.image?.sources[0].url,
channelSubs: ownerData?.collapsedSubtitle?.runs[0]?.text,
channelUrl: rendererUtils.getNavigationEndpoints(ownerData),
channelImg: ownerData?.thumbnail?.thumbnails[0].url,
availableResolutions: resolutions?.formats,
availableResolutionsAdaptive: resolutions?.adaptiveFormats,
metadata: {
contents: vidMetadata.contents,
description: details.shortDescription,
thumbnails: details.thumbnails?.thumbnails,
uploadDate:
columnUI?.contents[0].slimVideoMetadataSectionRenderer?.contents.find(
(contents) => contents.slimVideoDescriptionRenderer
)?.slimVideoDescriptionRenderer.publishDate.runs[0].text,
isPrivate: details.isPrivate,
viewCount: details.viewCount,
lengthSeconds: details.lengthSeconds,
likes: parseInt(
columnUI?.contents[0].slimVideoMetadataSectionRenderer?.contents
.find((contents) => contents.slimVideoScrollableActionBarRenderer)
?.slimVideoScrollableActionBarRenderer.buttons.find(
(button) => button.slimMetadataToggleButtonRenderer.isLike == true
vidMetadata.contents
.find((content) => content.slimVideoActionBarRenderer)
.slimVideoActionBarRenderer.buttons.find(
(button) => button.slimMetadataToggleButtonRenderer.isLike
)
?.slimMetadataToggleButtonRenderer?.button.toggleButtonRenderer?.defaultText?.accessibility?.accessibilityData?.label?.replace(
.slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(
/\D/g,
""
)
), // Yes. I know.
},
renderedData: {
description: columnUI?.contents
.find((contents) => contents.slimVideoMetadataSectionRenderer)
.slimVideoMetadataSectionRenderer?.contents.find(
(contents) => contents.slimVideoDescriptionRenderer
)?.slimVideoDescriptionRenderer,
recommendations: columnUI?.contents.find(
(contents) => contents.shelfRenderer
).shelfRenderer,
description: responseNext.engagementPanels
.find(
(panel) =>
panel.engagementPanelSectionListRenderer.panelIdentifier ==
"video-description-ep-identifier"
)
.engagementPanelSectionListRenderer.content.structuredDescriptionContentRenderer.items.find(
(item) => item.expandableVideoDescriptionBodyRenderer
).expandableVideoDescriptionBodyRenderer,
recommendations: recommendations,
recommendationsContinuation:
columnUI?.continuations[0].reloadContinuationData?.continuation,
recommendations.contents[recommendations.contents.length - 1]
.continuationItemRenderer?.continuationEndpoint.continuationCommand
.token,
},
// commentData:
// responseNext?.frameworkUpdates?.entityBatchUpdate?.mutations?.find(
// (mutations) => mutations.payload?.commentHeaderEntityPayload
// ).payload.commentHeaderEntityPayload,
playbackTracking: responseInfo.playbackTracking,
};

View File

@ -3,24 +3,33 @@ class rendererUtils {
static getNavigationEndpoints(base) {
const navEndpoint = base.navigationEndpoint;
if (!navEndpoint) return;
if (navEndpoint.webviewEndpoint) {
return navEndpoint.webviewEndpoint.url;
if (navEndpoint.urlEndpoint) {
const params = new Proxy(
new URLSearchParams(navEndpoint.urlEndpoint.url),
{
get: (searchParams, prop) => searchParams.get(prop),
}
);
if (params.q) return decodeURI(params.q);
else return new URL(navEndpoint.urlEndpoint.url).pathname;
} else if (navEndpoint.browseEndpoint) {
return navEndpoint.browseEndpoint.canonicalBaseUrl;
} else if (navEndpoint.watchEndpoint) {
return `/watch?v=${navEndpoint.watchEndpoint.videoId}`;
} else if (navEndpoint.navigationEndpoint) {
return; //for now
} else if (navEndpoint.searchEndpoint) {
return `/search?q=${encodeURI(navEndpoint.searchEndpoint.query)}`;
}
}
static checkInternal(base) {
const navEndpoint = base.navigationEndpoint;
if (!navEndpoint) return false;
if (navEndpoint.browseEndpoint || navEndpoint.watchEndpoint) {
return true;
} else {
const tmp = document.createElement("a");
tmp.href = this.getNavigationEndpoints(base);
if (tmp.host !== window.location.host || !base.navigationEndpoint) {
return false;
} else {
return true;
}
}
}

View File

@ -37,9 +37,15 @@ function getCpn() {
return result;
}
function getMutationByKey(key, mutations) {
if (!key || !mutations) return undefined;
return mutations.find((mutation) => mutation.entityKey === key).payload;
}
module.exports = {
getBetweenStrings,
hexToRgb,
rgbToHex,
getCpn,
getMutationByKey,
};