Merge pull request #2553 from Bnyro/local-playlists

Playlists without an account
This commit is contained in:
Bnyro 2023-06-19 14:34:32 +02:00 committed by GitHub
commit c1c8faaf5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 288 additions and 112 deletions

View file

@ -55,7 +55,7 @@ export default {
}); });
if ("indexedDB" in window) { if ("indexedDB" in window) {
const request = indexedDB.open("piped-db", 4); const request = indexedDB.open("piped-db", 5);
request.onupgradeneeded = ev => { request.onupgradeneeded = ev => {
const db = request.result; const db = request.result;
console.log("Upgrading object store."); console.log("Upgrading object store.");
@ -77,6 +77,12 @@ export default {
const store = db.createObjectStore("channel_groups", { keyPath: "groupName" }); const store = db.createObjectStore("channel_groups", { keyPath: "groupName" });
store.createIndex("groupName", "groupName", { unique: true }); store.createIndex("groupName", "groupName", { unique: true });
} }
if (!db.objectStoreNames.contains("playlists")) {
const playlistStore = db.createObjectStore("playlists", { keyPath: "playlistId" });
playlistStore.createIndex("playlistId", "playlistId", { unique: true });
const playlistVideosStore = db.createObjectStore("playlist_videos", { keyPath: "videoId" });
playlistVideosStore.createIndex("videoId", "videoId", { unique: true });
}
}; };
request.onsuccess = e => { request.onsuccess = e => {
window.db = e.target.result; window.db = e.target.result;

View file

@ -23,6 +23,10 @@ export default {
ModalComponent, ModalComponent,
}, },
props: { props: {
videoInfo: {
type: Object,
required: true,
},
videoId: { videoId: {
type: String, type: String,
required: true, required: true,
@ -62,28 +66,14 @@ export default {
this.$refs.addButton.disabled = true; this.$refs.addButton.disabled = true;
this.processing = true; this.processing = true;
this.fetchJson(this.authApiUrl() + "/user/playlists/add", null, { this.addVideosToPlaylist(playlistId, [this.videoId], [this.videoInfo]).then(json => {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
videoId: this.videoId,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
}).then(json => {
this.setPreference("selectedPlaylist" + this.hashCode(this.authApiUrl()), playlistId); this.setPreference("selectedPlaylist" + this.hashCode(this.authApiUrl()), playlistId);
this.$emit("close"); this.$emit("close");
if (json.error) alert(json.error); if (json.error) alert(json.error);
}); });
}, },
async fetchPlaylists() { async fetchPlaylists() {
this.fetchJson(this.authApiUrl() + "/user/playlists", null, { this.getPlaylists().then(json => {
headers: {
Authorization: this.getAuthToken(),
},
}).then(json => {
this.playlists = json; this.playlists = json;
}); });
}, },

View file

@ -86,14 +86,11 @@ export default {
mounted() { mounted() {
const playlistId = this.$route.query.list; const playlistId = this.$route.query.list;
if (this.authenticated && playlistId?.length == 36) if (this.authenticated && playlistId?.length == 36)
this.fetchJson(this.authApiUrl() + "/user/playlists", null, { this.getPlaylists().then(json => {
headers: {
Authorization: this.getAuthToken(),
},
}).then(json => {
if (json.error) alert(json.error); if (json.error) alert(json.error);
else if (json.some(playlist => playlist.id === playlistId)) this.admin = true; else if (json.some(playlist => playlist.id === playlistId)) this.admin = true;
}); });
else if (playlistId.startsWith("local")) this.admin = true;
this.isPlaylistBookmarked(); this.isPlaylistBookmarked();
}, },
activated() { activated() {
@ -106,6 +103,11 @@ export default {
}, },
methods: { methods: {
async fetchPlaylist() { async fetchPlaylist() {
const playlistId = this.$route.query.list;
if (playlistId.startsWith("local")) {
return this.getPlaylist(playlistId);
}
return await await this.fetchJson(this.authApiUrl() + "/playlists/" + this.$route.query.list); return await await this.fetchJson(this.authApiUrl() + "/playlists/" + this.$route.query.list);
}, },
async getPlaylistData() { async getPlaylistData() {

View file

@ -1,7 +1,7 @@
<template> <template>
<h2 v-if="authenticated" class="font-bold my-4" v-t="'titles.playlists'" /> <h2 class="font-bold my-4" v-t="'titles.playlists'" />
<div v-if="authenticated" class="flex justify-between mb-3"> <div class="flex justify-between mb-3">
<button v-t="'actions.create_playlist'" class="btn" @click="onCreatePlaylist" /> <button v-t="'actions.create_playlist'" class="btn" @click="onCreatePlaylist" />
<div class="flex"> <div class="flex">
<button <button
@ -63,7 +63,7 @@
v-if="playlistToDelete == playlist.id" v-if="playlistToDelete == playlist.id"
:message="$t('actions.delete_playlist_confirm')" :message="$t('actions.delete_playlist_confirm')"
@close="playlistToDelete = null" @close="playlistToDelete = null"
@confirm="deletePlaylist(playlist.id)" @confirm="onDeletePlaylist(playlist.id)"
/> />
</div> </div>
</div> </div>
@ -115,7 +115,7 @@ export default {
}; };
}, },
mounted() { mounted() {
if (this.authenticated) this.fetchPlaylists(); this.fetchPlaylists();
this.loadPlaylistBookmarks(); this.loadPlaylistBookmarks();
}, },
activated() { activated() {
@ -123,11 +123,7 @@ export default {
}, },
methods: { methods: {
fetchPlaylists() { fetchPlaylists() {
this.fetchJson(this.authApiUrl() + "/user/playlists", null, { this.getPlaylists().then(json => {
headers: {
Authorization: this.getAuthToken(),
},
}).then(json => {
this.playlists = json; this.playlists = json;
}); });
}, },
@ -141,50 +137,21 @@ export default {
const newName = this.newPlaylistName; const newName = this.newPlaylistName;
const newDescription = this.newPlaylistDescription; const newDescription = this.newPlaylistDescription;
if (newName != selectedPlaylist.name) { if (newName != selectedPlaylist.name) {
this.fetchJson(this.authApiUrl() + "/user/playlists/rename", null, { this.renamePlaylist(selectedPlaylist.id, newName).then(json => {
method: "POST",
body: JSON.stringify({
playlistId: selectedPlaylist.id,
newName: newName,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
}).then(json => {
if (json.error) alert(json.error); if (json.error) alert(json.error);
else selectedPlaylist.name = newName; else selectedPlaylist.name = newName;
}); });
} }
if (newDescription != selectedPlaylist.description) { if (newDescription != selectedPlaylist.description) {
this.fetchJson(this.authApiUrl() + "/user/playlists/description", null, { this.changePlaylistDescription(selectedPlaylist.id, newDescription).then(json => {
method: "PATCH",
body: JSON.stringify({
playlistId: selectedPlaylist.id,
description: newDescription,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
}).then(json => {
if (json.error) alert(json.error); if (json.error) alert(json.error);
else selectedPlaylist.description = newDescription; else selectedPlaylist.description = newDescription;
}); });
} }
this.playlistToEdit = null; this.playlistToEdit = null;
}, },
deletePlaylist(id) { onDeletePlaylist(id) {
this.fetchJson(this.authApiUrl() + "/user/playlists/delete", null, { this.deletePlaylist(id).then(json => {
method: "POST",
body: JSON.stringify({
playlistId: id,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
}).then(json => {
if (json.error) alert(json.error); if (json.error) alert(json.error);
else this.playlists = this.playlists.filter(playlist => playlist.id !== id); else this.playlists = this.playlists.filter(playlist => playlist.id !== id);
}); });
@ -198,19 +165,6 @@ export default {
else this.fetchPlaylists(); else this.fetchPlaylists();
}); });
}, },
async createPlaylist(name) {
let json = await this.fetchJson(this.authApiUrl() + "/user/playlists/create", null, {
method: "POST",
body: JSON.stringify({
name: name,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
return json;
},
async exportPlaylists() { async exportPlaylists() {
if (!this.playlists) return; if (!this.playlists) return;
let json = { let json = {
@ -223,8 +177,8 @@ export default {
this.download(JSON.stringify(json), "playlists.json", "application/json"); this.download(JSON.stringify(json), "playlists.json", "application/json");
}, },
async fetchPlaylistJson(playlistId) { async fetchPlaylistJson(playlistId) {
let playlist = await this.fetchJson(this.authApiUrl() + "/playlists/" + playlistId); let playlist = await this.getPlaylist(playlistId);
let playlistJson = { return {
name: playlist.name, name: playlist.name,
// possible other types: history, watch later, ... // possible other types: history, watch later, ...
type: "playlist", type: "playlist",
@ -233,7 +187,6 @@ export default {
// list of the videos, starting with "https://youtube.com" to clarify that those are YT videos // list of the videos, starting with "https://youtube.com" to clarify that those are YT videos
videos: playlist.relatedStreams.map(stream => "https://youtube.com" + stream.url), videos: playlist.relatedStreams.map(stream => "https://youtube.com" + stream.url),
}; };
return playlistJson;
}, },
async importPlaylists() { async importPlaylists() {
const files = this.$refs.fileSelector.files; const files = this.$refs.fileSelector.files;
@ -252,8 +205,8 @@ export default {
alert(this.$t("actions.no_valid_playlists")); alert(this.$t("actions.no_valid_playlists"));
return; return;
} }
for (var i = 0; i < playlists.length; i++) { for (let playlist of playlists) {
tasks.push(this.createPlaylistWithVideos(playlists[i])); tasks.push(this.createPlaylistWithVideos(playlist));
} }
// CSV from Google Takeout // CSV from Google Takeout
} else if (file.name.slice(-4).toLowerCase() == ".csv") { } else if (file.name.slice(-4).toLowerCase() == ".csv") {
@ -277,19 +230,6 @@ export default {
let videoIds = playlist.videos.map(url => url.substr(-11)); let videoIds = playlist.videos.map(url => url.substr(-11));
await this.addVideosToPlaylist(newPlaylist.playlistId, videoIds); await this.addVideosToPlaylist(newPlaylist.playlistId, videoIds);
}, },
async addVideosToPlaylist(playlistId, videoIds) {
await this.fetchJson(this.authApiUrl() + "/user/playlists/add", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
videoIds: videoIds,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
async loadPlaylistBookmarks() { async loadPlaylistBookmarks() {
if (!window.db) return; if (!window.db) return;
var tx = window.db.transaction("playlist_bookmarks", "readonly"); var tx = window.db.transaction("playlist_bookmarks", "readonly");
@ -298,8 +238,7 @@ export default {
cursorRequest.onsuccess = e => { cursorRequest.onsuccess = e => {
const cursor = e.target.result; const cursor = e.target.result;
if (cursor) { if (cursor) {
const bookmark = cursor.value; this.bookmarks.push(cursor.value);
this.bookmarks.push(bookmark);
cursor.continue(); cursor.continue();
} }
}; };

View file

@ -107,7 +107,7 @@
> >
<font-awesome-icon icon="headphones" /> <font-awesome-icon icon="headphones" />
</router-link> </router-link>
<button v-if="authenticated" :title="$t('actions.add_to_playlist')" @click="showModal = !showModal"> <button :title="$t('actions.add_to_playlist')" @click="showModal = !showModal">
<font-awesome-icon icon="circle-plus" /> <font-awesome-icon icon="circle-plus" />
</button> </button>
<button <button
@ -124,7 +124,12 @@
@confirm="removeVideo(item.url.substr(-11))" @confirm="removeVideo(item.url.substr(-11))"
:message="$t('actions.delete_playlist_video_confirm')" :message="$t('actions.delete_playlist_video_confirm')"
/> />
<PlaylistAddModal v-if="showModal" :video-id="item.url.substr(-11)" @close="showModal = !showModal" /> <PlaylistAddModal
v-if="showModal"
:video-id="item.url.substr(-11)"
:video-info="item"
@close="showModal = !showModal"
/>
</div> </div>
</div> </div>
</div> </div>
@ -172,17 +177,7 @@ export default {
methods: { methods: {
removeVideo() { removeVideo() {
this.$refs.removeButton.disabled = true; this.$refs.removeButton.disabled = true;
this.fetchJson(this.authApiUrl() + "/user/playlists/remove", null, { this.removeVideoFromPlaylist(this.playlistId, this.index).then(json => {
method: "POST",
body: JSON.stringify({
playlistId: this.playlistId,
index: this.index,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
}).then(json => {
if (json.error) alert(json.error); if (json.error) alert(json.error);
else this.$emit("remove"); else this.$emit("remove");
}); });

View file

@ -78,7 +78,12 @@
<!-- Verified Badge --> <!-- Verified Badge -->
<font-awesome-icon class="ml-1" v-if="video.uploaderVerified" icon="check" /> <font-awesome-icon class="ml-1" v-if="video.uploaderVerified" icon="check" />
</div> </div>
<PlaylistAddModal v-if="showModal" :video-id="getVideoId()" @close="showModal = !showModal" /> <PlaylistAddModal
v-if="showModal"
:video-id="getVideoId()"
:video-info="video"
@close="showModal = !showModal"
/>
<ShareModal <ShareModal
v-if="showShareModal" v-if="showShareModal"
:video-id="getVideoId()" :video-id="getVideoId()"
@ -89,7 +94,7 @@
/> />
<div class="flex flex-wrap gap-1 ml-auto"> <div class="flex flex-wrap gap-1 ml-auto">
<!-- Subscribe Button --> <!-- Subscribe Button -->
<button class="btn flex items-center" v-if="authenticated" @click="showModal = !showModal"> <button class="btn flex items-center" @click="showModal = !showModal">
{{ $t("actions.add_to_playlist") }}<font-awesome-icon class="ml-1" icon="circle-plus" /> {{ $t("actions.add_to_playlist") }}<font-awesome-icon class="ml-1" icon="circle-plus" />
</button> </button>
<button <button

View file

@ -293,6 +293,245 @@ const mixin = {
var store = tx.objectStore("channel_groups"); var store = tx.objectStore("channel_groups");
store.delete(groupName); store.delete(groupName);
}, },
async getLocalPlaylist(playlistId) {
return await new Promise(resolve => {
var tx = window.db.transaction("playlists", "readonly");
var store = tx.objectStore("playlists");
const req = store.openCursor(playlistId);
let playlist = null;
req.onsuccess = e => {
playlist = e.target.result.value;
playlist.videos = JSON.parse(playlist.videoIds).length;
resolve(playlist);
};
});
},
createOrUpdateLocalPlaylist(playlist) {
var tx = window.db.transaction("playlists", "readwrite");
var store = tx.objectStore("playlists");
store.put(playlist);
},
// needs to handle both, streamInfo items and streams items
createLocalPlaylistVideo(videoId, videoInfo) {
if (videoInfo === undefined || videoId === null || videoInfo?.error) return;
var tx = window.db.transaction("playlist_videos", "readwrite");
var store = tx.objectStore("playlist_videos");
const video = {
videoId: videoId,
title: videoInfo.title,
type: "stream",
shortDescription: videoInfo.shortDescription ?? videoInfo.description,
url: `/watch?v=${videoId}`,
thumbnail: videoInfo.thumbnail ?? videoInfo.thumbnailUrl,
uploaderVerified: videoInfo.uploaderVerified,
duration: videoInfo.duration,
uploaderAvatar: videoInfo.uploaderAvatar,
uploaderUrl: videoInfo.uploaderUrl,
uploaderName: videoInfo.uploaderName ?? videoInfo.uploader,
};
store.put(video);
},
async getLocalPlaylistVideo(videoId) {
return await new Promise(resolve => {
var tx = window.db.transaction("playlist_videos", "readonly");
var store = tx.objectStore("playlist_videos");
const req = store.openCursor(videoId);
req.onsuccess = e => {
resolve(e.target.result.value);
};
});
},
async getPlaylists() {
if (!this.authenticated) {
if (!window.db) return [];
return await new Promise(resolve => {
let playlists = [];
var tx = window.db.transaction("playlists", "readonly");
var store = tx.objectStore("playlists");
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = e => {
const cursor = e.target.result;
if (cursor) {
let playlist = cursor.value;
playlist.videos = JSON.parse(playlist.videoIds).length;
playlists.push(playlist);
cursor.continue();
} else {
resolve(playlists);
}
};
});
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists", null, {
headers: {
Authorization: this.getAuthToken(),
},
});
},
async getPlaylist(playlistId) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
const videoIds = JSON.parse(playlist.videoIds);
const videosFuture = videoIds.map(videoId => this.getLocalPlaylistVideo(videoId));
playlist.relatedStreams = await Promise.all(videosFuture);
return playlist;
}
return await this.fetchJson(this.authApiUrl() + "/playlists/" + playlistId);
},
async createPlaylist(name) {
if (!this.authenticated) {
const uuid = crypto.randomUUID();
const playlistId = `local-${uuid}`;
this.createOrUpdateLocalPlaylist({
playlistId: playlistId,
// remapping needed for the playlists page
id: playlistId,
name: name,
description: "",
thumbnail: "https://pipedproxy.kavin.rocks/?host=i.ytimg.com",
videoIds: "[]", // empty list
});
return { playlistId: playlistId };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/create", null, {
method: "POST",
body: JSON.stringify({
name: name,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
async deletePlaylist(playlistId) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
var tx = window.db.transaction("playlists", "readwrite");
var store = tx.objectStore("playlists");
store.delete(playlistId);
// delete videos that don't need to be store anymore
const playlists = await this.getPlaylists();
const usedVideoIds = playlists
.filter(playlist => playlist.id != playlistId)
.map(playlist => JSON.parse(playlist.videoIds))
.flat();
const potentialDeletableVideos = JSON.parse(playlist.videoIds);
var videoTx = window.db.transaction("playlist_videos", "readwrite");
var videoStore = videoTx.objectStore("playlist_videos");
for (let videoId of potentialDeletableVideos) {
if (!usedVideoIds.includes(videoId)) videoStore.delete(videoId);
}
return { message: "ok" };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/delete", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
async renamePlaylist(playlistId, newName) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
playlist.name = newName;
this.createOrUpdateLocalPlaylist(playlist);
return { message: "ok" };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/rename", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
newName: newName,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
async changePlaylistDescription(playlistId, newDescription) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
playlist.description = newDescription;
this.createOrUpdateLocalPlaylist(playlist);
return { message: "ok" };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/description", null, {
method: "PATCH",
body: JSON.stringify({
playlistId: playlistId,
description: newDescription,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
async addVideosToPlaylist(playlistId, videoIds, videoInfos) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
const currentVideoIds = JSON.parse(playlist.videoIds);
if (currentVideoIds.length == 0) playlist.thumbnail = videoInfos[0].thumbnail;
currentVideoIds.push(...videoIds);
playlist.videoIds = JSON.stringify(currentVideoIds);
this.createOrUpdateLocalPlaylist(playlist);
let streamInfos =
videoInfos ??
(await Promise.all(videoIds.map(videoId => this.fetchJson(this.apiUrl() + "/streams/" + videoId))));
for (let i in videoIds) {
this.createLocalPlaylistVideo(videoIds[i], streamInfos[i]);
}
return { message: "ok" };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/add", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
videoIds: videoIds,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
async removeVideoFromPlaylist(playlistId, index) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
const videoIds = JSON.parse(playlist.videoIds);
videoIds.splice(index, 1);
playlist.videoIds = JSON.stringify(videoIds);
if (videoIds.length == 0) playlist.thumbnail = "https://pipedproxy.kavin.rocks/?host=i.ytimg.com";
this.createOrUpdateLocalPlaylist(playlist);
return { message: "ok" };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/remove", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
index: index,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
}, },
computed: { computed: {
authenticated(_this) { authenticated(_this) {