Merge pull request #219 from 404-Program-not-found/main

Added recommendation based off of watch history and home page pagination
This commit is contained in:
Kenny 2022-04-03 03:25:06 -04:00 committed by GitHub
commit c4c4d08343
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 276 additions and 57 deletions

View File

@ -9,7 +9,6 @@
<a
@click="openExternal($rendererUtils.getNavigationEndpoints(text))"
:key="index"
class="link"
>{{ text.text }}</a
>
</template>
@ -17,7 +16,6 @@
<a
@click="openInternal($rendererUtils.getNavigationEndpoints(text))"
:key="index"
class="link"
>{{ text.text }}</a
>
</template>

View File

@ -0,0 +1,25 @@
<template>
<div class="observer" />
</template>
<script>
export default {
props: ["options"],
data: () => ({
observer: null,
}),
mounted() {
const options = this.options || {};
this.observer = new IntersectionObserver(([entry]) => {
if (entry && entry.isIntersecting) {
this.$emit("intersect");
}
}, options);
this.observer.observe(this.$el);
},
destroyed() {
this.observer.disconnect();
},
};
</script>

View File

@ -80,11 +80,20 @@ export default {
methods: {
refreshRecommendations() {
this.$emit("scroll-to-top");
const continuations =
this.$store.state.recommendedVideos[
this.$store.state.recommendedVideos.length - 1
].continuations;
this.$store.commit("updateRecommendedVideos", []);
this.$youtube
.recommend()
.recommendContinuation(
continuations.find((element) => element.reloadContinuationData)
.reloadContinuationData.continuation,
"browse"
)
.then((result) => {
if (result) this.$store.commit("updateRecommendedVideos", result[0]);
console.log(result);
if (result) this.$store.commit("updateRecommendedVideos", [result]);
})
.catch((error) => this.$logger("Home Page (Nav Refresh)", error, true));
},

View File

@ -1,12 +1,14 @@
// this is an loading animation for videos
<template>
<center>
<v-sheet color="background">
<v-skeleton-loader type="card-avatar, article, actions" />
<div>
<v-sheet color="background" v-for="i in count" :key="i">
<v-skeleton-loader type="image, list-item-avatar-two-line" />
</v-sheet>
</center>
</div>
</template>
<script>
export default {};
export default {
props: ["count"],
};
</script>

View File

@ -1,38 +1,35 @@
<template>
<div>
<video
controls
autoplay
:src="vidSrc"
width="100%"
@webkitfullscreenchange="handleFullscreenChange"
ref="player"
style="max-height: 50vh"
controls
autoplay
:src="vidSrc"
width="100%"
@webkitfullscreenchange="handleFullscreenChange"
ref="player"
style="max-height: 50vh"
/>
</div>
</template>
<script>
export default {
props: [
"vidSrc"
],
export default {
props: ["vidSrc"],
methods: {
handleFullscreenChange() {
if (document.fullscreenElement === this.$refs.player) {
this.$vuetube.statusBar.hide();
this.$vuetube.navigationBar.hide();
} else {
this.$vuetube.statusBar.show();
this.$vuetube.navigationBar.show();
}
},
}
}
methods: {
handleFullscreenChange() {
if (document.fullscreenElement === this.$refs.player) {
this.$vuetube.statusBar.hide();
this.$vuetube.navigationBar.hide();
} else {
this.$vuetube.statusBar.show();
this.$vuetube.navigationBar.show();
}
},
getPlayer() {
return this.$refs.player;
},
},
};
</script>

View File

@ -106,6 +106,15 @@ export default {
window.history.back();
}
});
CapacitorApp.addListener("appUrlOpen", (event) => {
const slug = new URL(event.url);
// We only push to the route if there is a slug present
if (slug) {
console.log(slug.pathname + slug.search);
this.$router.push(slug.pathname + slug.search);
}
});
},
methods: {

View File

@ -7,16 +7,21 @@
<div>
<!-- Video Loading Animation -->
<vid-load-renderer v-if="!recommends" />
<horizontal-list-renderer v-else :render="recommends" />
<vid-load-renderer v-if="recommends.length == 0" :count="10" />
<div v-for="(section, index) in recommends" :key="index">
<horizontal-list-renderer :render="section.contents[0]" />
</div>
<vid-load-renderer v-if="!loading" :count="1" />
<observer @intersect="paginate" />
</div>
</template>
<script>
import horizontalListRenderer from "~/components/ListRenderers/horizontalListRenderer.vue";
import VidLoadRenderer from "~/components/vidLoadRenderer.vue";
import Observer from "~/components/observer.vue";
export default {
components: { horizontalListRenderer, VidLoadRenderer },
components: { horizontalListRenderer, VidLoadRenderer, Observer },
computed: {
recommends: {
@ -29,14 +34,35 @@ export default {
},
},
// The following code is only a demo for debugging purposes, note that each "shelfRenderer" has a "title" value that seems to align to the categories at the top of the vanilla yt app
data: {
loading: false,
},
methods: {
paginate() {
if (this.recommends) {
this.loading = true;
this.$youtube
.recommendContinuation(
this.recommends[this.recommends.length - 1].continuations.find(
(element) => element.nextContinuationData
).nextContinuationData.continuation,
"browse"
)
.then((result) => {
this.loading = false;
this.recommends.push(result);
});
}
},
},
mounted() {
if (!this.recommends.items || !this.recommends.items.length) {
if (!this.recommends.length) {
this.$youtube
.recommend()
.then((result) => {
if (result) this.recommends = result[0];
if (result) this.recommends = [result];
})
.catch((error) => this.$logger("Home Page", error, true));
}

View File

@ -4,6 +4,7 @@
<videoPlayer
style="position: sticky; top: 0; z-index: 696969"
:vid-src="vidSrc"
ref="player"
/>
<!-- VueTube Player V1 -->
@ -95,6 +96,7 @@
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";
export default {
@ -135,6 +137,7 @@ export default {
views: null,
recommends: null,
loaded: false,
interval: null,
};
},
watch: {
@ -144,17 +147,24 @@ export default {
handler(newRt, oldRt) {
if (newRt.query.v != oldRt.query.v) {
// Exit fullscreen if currently in fullscreen
if (this.$refs.player) this.$refs.player.webkitExitFullscreen();
// if (this.$refs.player) this.$refs.player.webkitExitFullscreen();
// Reset player and run getVideo function again
this.vidSrc = "";
this.startTime = Math.floor(Date.now() / 1000);
clearInterval(this.interval);
this.getVideo();
}
},
},
},
mounted() {
this.startTime = Math.floor(Date.now() / 1000);
this.getVideo();
},
destroyed() {
clearInterval(this.interval);
},
methods: {
getVideo() {
this.likes = 100;
@ -185,6 +195,15 @@ export default {
this.recommends = result.renderedData.recommendations;
// .catch((error) => this.$logger("Watch", error, true));
console.log("recommendations:", this.recommends);
//--- API WatchTime call ---//
this.playbackTracking = result.playbackTracking;
this.st = 0;
this.cpn = getCpn();
this.initWatchTime().then(() => {
this.sendWatchTime();
this.interval = setInterval(this.sendWatchTime, 30000);
});
});
this.$youtube.getReturnYoutubeDislike(this.$route.query.v, (data) => {
@ -207,6 +226,42 @@ export default {
dialogTitle: "Share video",
});
},
sendWatchTime() {
const player = this.$refs.player.getPlayer();
const rt = Math.floor(Date.now() / 1000) - this.startTime;
const params = {
cpn: this.cpn,
rt: rt,
rti: rt,
rtn: rt,
cmt: player.currentTime,
et: player.currentTime,
st: this.st,
state: player.paused ? "paused" : "playing",
volume: 100,
muted: 0,
fmt: 396,
};
this.st = player.currentTime;
this.$youtube.saveApiStats(
params,
this.playbackTracking.videostatsWatchtimeUrl.baseUrl
);
},
async initWatchTime() {
await this.$youtube.saveApiStats(
{
cpn: this.cpn,
fmt: 243,
rtn: Math.floor(Date.now() / 1000) - this.startTime,
rt: Math.floor(Date.now() / 1000) - this.startTime,
fmt: 243,
muted: 0,
},
this.playbackTracking.videostatsPlaybackUrl.baseUrl
);
},
},
};
</script>

View File

@ -95,6 +95,42 @@ class Innertube {
};
}
async getContinuationsAsync(continuation, type) {
let data = { context: this.context, continuation: continuation };
let url;
switch (type) {
case "browse":
url = `${constants.URLS.YT_BASE_API}/browse?key=${this.key}`;
break;
case "search":
url = `${constants.URLS.YT_BASE_API}/search?key=${this.key}`;
break;
case "next":
url = `${constants.URLS.YT_BASE_API}/next?key=${this.key}`;
break;
default:
throw "Invalid type";
}
const response = await Http.post({
url: url,
data: data,
headers: { "Content-Type": "application/json" },
}).catch((error) => error);
if (response instanceof Error) {
return {
success: false,
status_code: response.status,
message: response.message,
};
}
return {
success: true,
status_code: response.status,
data: response.data,
};
}
async getVidAsync(id) {
let data = { context: this.context, videoId: id };
const responseNext = await Http.post({
@ -152,6 +188,24 @@ class Innertube {
};
}
// WARNING: This is tracking the user's activity, but is required for recommendations to properly work
async apiStats(params, url) {
console.log(params);
await Http.get({
url: url,
params: {
...params,
...{
ver: 2,
c: constants.YT_API_VALUES.CLIENTNAME.toLowerCase(),
cbrver: constants.YT_API_VALUES.VERSION,
cver: constants.YT_API_VALUES.VERSION,
},
},
headers: this.header,
});
}
// Static methods
static getThumbnail(id, resolution) {
@ -250,6 +304,7 @@ class Innertube {
recommendationsContinuation:
columnUI?.continuations[0].reloadContinuationData?.continuation,
},
playbackTracking: responseInfo.playbackTracking,
};
console.log(vidData);

View File

@ -28,8 +28,18 @@ function rgbToHex(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
function getCpn() {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let result = "";
for (let i = 16; i > 0; --i)
result += chars[Math.round(Math.random() * (chars.length - 1))];
return result;
}
module.exports = {
getBetweenStrings,
hexToRgb,
rgbToHex,
getCpn,
};

View File

@ -4,6 +4,7 @@ import Innertube from "./innertube";
import constants from "./constants";
import rendererUtils from "./renderers";
import { Buffer } from "buffer";
import iconv from "iconv-lite";
//--- Logger Function ---//
function logger(func, data, isError = false) {
@ -19,15 +20,7 @@ function getEncoding(contentType) {
const re = /charset=([^()<>@,;:\"/[\]?.=\s]*)/i;
const content = re.exec(contentType);
console.log(content);
if (!content || content[1].toLowerCase() == "utf-8") {
return "utf8";
}
if (content[1].toLowerCase() == "iso-8859-1") {
return "latin1";
}
if (content[1].toLowerCase() == "utf16le") {
return "utf16le";
}
return content[1].toLowerCase();
}
const searchModule = {
@ -45,7 +38,7 @@ const searchModule = {
// make a new buffer object from res.data
const buffer = Buffer.from(res.data, "base64");
// convert res.data from iso-8859-1 to utf-8
const data = buffer.toString(getEncoding(contentType));
const data = iconv.decode(buffer, getEncoding(contentType));
logger(constants.LOGGER_NAMES.autoComplete, data);
callback(data);
})
@ -114,6 +107,7 @@ const innertubeModule = {
// Front page recommendation
async recommend() {
const response = await InnertubeAPI.getRecommendationsAsync();
if (!response.success)
throw new Error("An error occurred and innertube failed to respond");
@ -125,8 +119,28 @@ const innertubeModule = {
if (video) return video;
});
console.log(final);
return final;
const continuations =
response.data.contents.singleColumnBrowseResultsRenderer.tabs[0]
.tabRenderer.content.sectionListRenderer.continuations;
console.log({ continuations: continuations, contents: final });
return { continuations: continuations, contents: final };
},
async recommendContinuation(continuation, endpoint) {
const response = await InnertubeAPI.getContinuationsAsync(
continuation,
endpoint
);
const contents =
response.data.continuationContents.sectionListContinuation.contents;
const final = contents.map((shelves) => {
const video = shelves.shelfRenderer?.content?.horizontalListRenderer;
if (video) return video;
});
const continuations =
response.data.continuationContents.sectionListContinuation.continuations;
return { continuations: continuations, contents: final };
},
async search(query) {
@ -137,6 +151,10 @@ const innertubeModule = {
logger(constants.LOGGER_NAMES.search, err, true);
}
},
async saveApiStats(query, url) {
await InnertubeAPI.apiStats(query, url);
},
};
//--- Start ---//

View File

@ -21,7 +21,21 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LAUNCHER" />>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="youtu.be" />
<data android:host="m.youtube.com" />
<data android:host="youtube.com" />
<data android:host="www.youtube.com" />
<data android:pathPattern="/.*" />
</intent-filter>
</activity>

View File

@ -11,6 +11,7 @@
"@capacitor/share": "^1.1.2",
"@capacitor/splash-screen": "^1.2.2",
"@capacitor/status-bar": "^1.0.8",
"@hugotomazi/capacitor-navigation-bar": "^1.1.1"
"@hugotomazi/capacitor-navigation-bar": "^1.1.1",
"iconv-lite": "^0.6.3"
}
}