Skip to content

Commit 9d7d555

Browse files
committed
feat: add local personalized trending feed
1 parent da7ab35 commit 9d7d555

4 files changed

Lines changed: 110 additions & 13 deletions

File tree

src/components/FeedPage.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
to="/subscriptions"
5151
/>
5252
<a
53+
v-if="getRssUrl"
5354
:href="getRssUrl"
5455
class="inline-block w-auto cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
5556
:aria-label="$t('actions.rss_feed')"
@@ -94,7 +95,11 @@ const channelGroups = ref([]);
9495
9596
const getRssUrl = computed(() => {
9697
if (isAuthenticated()) return authApiUrl() + "/feed/rss?authToken=" + getAuthToken();
97-
else return authApiUrl() + "/feed/unauthenticated/rss?channels=" + getUnauthenticatedChannels();
98+
else {
99+
const channels = getUnauthenticatedChannels();
100+
if (!channels) return null;
101+
return authApiUrl() + "/feed/unauthenticated/rss?channels=" + channels;
102+
}
98103
});
99104
100105
const filteredVideos = computed(() => {
@@ -112,7 +117,7 @@ const filteredVideos = computed(() => {
112117
function loadMoreVideos() {
113118
if (!videosStore.value) return;
114119
currentVideoCount = Math.min(currentVideoCount + videoStep, videosStore.value.length);
115-
if (videos.value.length != videosStore.value.length) {
120+
if (videos.value.length !== videosStore.value.length) {
116121
fetchDeArrowContent(videosStore.value.slice(videos.value.length, currentVideoCount));
117122
videos.value = videosStore.value.slice(0, currentVideoCount);
118123
}

src/components/PreferencesPage.vue

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,6 @@
158158
for="chkMinimizeChapters"
159159
>
160160
<strong v-t="'actions.chapters_layout_mobile'" />
161-
162161
<select
163162
id="ddlDefaultHomepage"
164163
v-model="mobileChapterLayout"
@@ -199,6 +198,18 @@
199198
</template>
200199
<PreferenceSwitch id="chkHideWatched" v-model="hideWatched" @change="onChange" />
201200
</PreferenceRow>
201+
<PreferenceRow v-if="watchHistory" for-id="chkPersonalizedTrending">
202+
<template #label>
203+
<strong v-t="'actions.personalized_trending'" />
204+
</template>
205+
<PreferenceSwitch id="chkPersonalizedTrending" v-model="personalizedTrending" @change="onChange" />
206+
</PreferenceRow>
207+
<PreferenceRow v-if="watchHistory && personalizedTrending" for-id="chkPersonalizedTrendingOnly">
208+
<template #label>
209+
<strong v-t="'actions.personalized_trending_only'" />
210+
</template>
211+
<PreferenceSwitch id="chkPersonalizedTrendingOnly" v-model="personalizedTrendingOnly" @change="onChange" />
212+
</PreferenceRow>
202213
<PreferenceRow for-id="ddlEnabledCodecs">
203214
<template #label>
204215
<strong v-t="'actions.enabled_codecs'" />
@@ -529,6 +540,8 @@ const searchSuggestions = usePreferenceBoolean("searchSuggestions", true);
529540
const watchHistory = usePreferenceBoolean("watchHistory", false);
530541
const searchHistory = usePreferenceBoolean("searchHistory", false);
531542
const hideWatched = usePreferenceBoolean("hideWatched", false);
543+
const personalizedTrending = usePreferenceBoolean("personalizedTrending", false);
544+
const personalizedTrendingOnly = usePreferenceBoolean("personalizedTrendingOnly", false);
532545
const selectedLanguage = usePreferenceString("hl", "en");
533546
const languages = [
534547
{ code: "ar", name: "Arabic" },

src/components/TrendingPage.vue

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
<template>
22
<h1 v-t="'titles.trending'" class="my-4 text-center font-bold" />
3-
43
<hr />
5-
64
<LoadingIndicatorPage
75
:show-content="videos.length != 0"
86
class="mx-2 grid grid-cols-1 gap-y-5 max-md:gap-x-3 sm:mx-0 sm:grid-cols-2 md:grid-cols-3 md:gap-x-6 lg:grid-cols-4 xl:grid-cols-5"
@@ -18,29 +16,108 @@ import { useI18n } from "vue-i18n";
1816
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
1917
import VideoItem from "./VideoItem.vue";
2018
import { fetchJson, apiUrl } from "@/composables/useApi.js";
21-
import { getPreferenceString } from "@/composables/usePreferences.js";
19+
import { getPreferenceString, getPreferenceBoolean } from "@/composables/usePreferences.js";
2220
import { updateWatched } from "@/composables/useMisc.js";
2321
import { fetchDeArrowContent } from "@/composables/useSubscriptions.js";
2422
import { getHomePage } from "@/composables/useMisc.js";
2523
2624
const route = useRoute();
2725
const router = useRouter();
2826
const { t } = useI18n();
29-
3027
const videos = ref([]);
3128
32-
async function fetchTrending(region) {
33-
return await fetchJson(apiUrl() + "/trending", {
34-
region: region || "US",
29+
function idbCursorToPromise(store, fn) {
30+
return new Promise((resolve, reject) => {
31+
const req = store.openCursor();
32+
req.onerror = () => reject(req.error);
33+
req.onsuccess = e => {
34+
const cursor = e.target.result;
35+
if (cursor) {
36+
fn(cursor.value);
37+
cursor.continue();
38+
} else {
39+
resolve();
40+
}
41+
};
42+
});
43+
}
44+
45+
async function getPreferredChannels() {
46+
if (!window.db || !getPreferenceBoolean("watchHistory", false)) return [];
47+
48+
const tx = window.db.transaction("watch_history", "readonly");
49+
const store = tx.objectStore("watch_history");
50+
const counts = new Map();
51+
52+
await idbCursorToPromise(store, video => {
53+
if (video.uploaderUrl) {
54+
const id = video.uploaderUrl.split("/").pop();
55+
counts.set(id, (counts.get(id) || 0) + 1);
56+
}
3557
});
58+
59+
return Array.from(counts.entries())
60+
.sort((a, b) => b[1] - a[1])
61+
.slice(0, 10)
62+
.map(([id]) => id);
63+
}
64+
65+
async function fetchChannelVideos(channelIds) {
66+
const results = await Promise.allSettled(channelIds.map(id => fetchJson(apiUrl() + "/channel/" + id)));
67+
return results
68+
.filter(r => r.status === "fulfilled" && r.value?.relatedStreams)
69+
.flatMap(r => r.value.relatedStreams.slice(0, 5));
70+
}
71+
72+
function interleave(trending, recommended) {
73+
const out = [];
74+
let ti = 0,
75+
ri = 0;
76+
while (ti < trending.length || ri < recommended.length) {
77+
if (ti < trending.length) out.push(trending[ti++]);
78+
if (ti < trending.length) out.push(trending[ti++]);
79+
if (ti < trending.length) out.push(trending[ti++]);
80+
if (ri < recommended.length) out.push(recommended[ri++]);
81+
}
82+
return out;
83+
}
84+
85+
async function fetchTrending(region) {
86+
const personalizedTrending = getPreferenceBoolean("personalizedTrending", false);
87+
const personalizedTrendingOnly = getPreferenceBoolean("personalizedTrendingOnly", false);
88+
89+
if (!personalizedTrending) {
90+
return await fetchJson(apiUrl() + "/trending", { region: region || "US" });
91+
}
92+
93+
const [trending, preferredChannels] = await Promise.all([
94+
personalizedTrendingOnly ? Promise.resolve([]) : fetchJson(apiUrl() + "/trending", { region: region || "US" }),
95+
getPreferredChannels(),
96+
]);
97+
98+
if (preferredChannels.length === 0) return trending;
99+
100+
const recommended = await fetchChannelVideos(preferredChannels);
101+
102+
const seen = new Set();
103+
const dedup = arr =>
104+
arr.filter(v => {
105+
const id = v.url?.split("v=")[1];
106+
if (!id || seen.has(id)) return false;
107+
seen.add(id);
108+
return true;
109+
});
110+
111+
if (personalizedTrendingOnly) return dedup(recommended);
112+
113+
return interleave(dedup(trending), dedup(recommended));
36114
}
37115
38116
onMounted(() => {
39117
if (route.path == import.meta.env.BASE_URL && getPreferenceString("homepage", "trending") == "feed") {
40118
return;
41119
}
42-
let region = getPreferenceString("region", "US");
43-
120+
const region = getPreferenceString("region", "US");
44121
fetchTrending(region).then(vids => {
45122
videos.value = vids;
46123
updateWatched(videos.value);
@@ -52,7 +129,7 @@ onActivated(() => {
52129
document.title = t("titles.trending") + " - Piped";
53130
if (videos.value.length > 0) updateWatched(videos.value);
54131
if (route.path == import.meta.env.BASE_URL) {
55-
let homepage = getHomePage();
132+
const homepage = getHomePage();
56133
if (homepage !== undefined) router.push(homepage);
57134
}
58135
});

src/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@
133133
"show_chapters": "Chapters",
134134
"store_search_history": "Store Search History",
135135
"hide_watched": "Hide watched videos in the feed",
136+
"personalized_trending": "Personalize trending page",
137+
"personalized_trending_only": "Show only recommendations (hide global trending)",
136138
"mark_as_watched": "Mark as Watched",
137139
"mark_as_unwatched": "Mark as Unwatched",
138140
"documentation": "Documentation",

0 commit comments

Comments
 (0)