Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Option to export/import watch history to db. #2118

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions src/components/ExportHistoryModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<template>
<ModalComponent>
<div class="min-w-max flex flex-col">
<h2 class="text-xl font-bold mb-4 text-center">Export History</h2>
<form>
<div>
<label class="mr-2" for="export-format">Export as:</label>
<select class="select" id="export-format" v-model="exportAs">
<option
v-for="option in exportOptions"
:key="option"
:value="option"
v-text="formatField(option)"
/>
</select>
</div>
<div v-if="exportAs === 'history'">
<label v-for="field in fields" :key="field" class="flex gap-2 items-center">
<input
class="checkbox"
type="checkbox"
:value="field"
v-model="selectedFields"
:disabled="field === 'videoId'"
/>
<span v-text="formatField(field)" />
</label>
Comment on lines +18 to +27
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We likely don't want this to be user selectable, as we want to stick to a standard format between frontend implementations.

We would potentially align to a new format in the distant future to allow inter-compatibility with other frontends once a format is standardized - UniversalPipeWrench/unified-user-data-format#1.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see one problem with this: What if the format we decide on is not what users want?
For example, if we include watchedAt and currentTime by default this might be a privacy concern for some users. On the other hand, if we decide to leave them out we are throwing out features which I would assume most users want, myself included.
The only solution I can think of is for the standard format to allow these to be optional.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel currentTime is always necessary since otherwise, we can't show any indicators on videos (just the watch history page).

Regarding watchedAt, that's a fair point, but we don't know how to sort videos then.

However, I feel this is less of an issue since watch history is stored entirely locally - so, it's unlikely any third party gets access to it in the first place.

</div>
</form>
<button class="btn mt-4" @click="handleExport">Export</button>
</div>
</ModalComponent>
</template>

<script>
import ModalComponent from "./ModalComponent.vue";

export default {
components: {
ModalComponent,
},
data() {
return {
exportOptions: ["playlist", "history"],
exportAs: "playlist",
fields: [
"videoId",
"title",
"uploaderName",
"uploaderUrl",
"duration",
"thumbnail",
"watchedAt",
"currentTime",
],
selectedFields: [
"videoId",
"title",
"uploaderName",
"uploaderUrl",
"duration",
"thumbnail",
"watchedAt",
"currentTime",
],
};
},
methods: {
async fetchAllVideos() {
if (window.db) {
var tx = window.db.transaction("watch_history", "readonly");
var store = tx.objectStore("watch_history");
const request = store.getAll();
return new Promise((resolve, reject) => {
(request.onsuccess = e => {
const videos = e.target.result;
this.exportVideos = videos;
resolve();
}),
(request.onerror = e => {
reject(e);
});
});
}
},
handleExport() {
if (this.exportAs === "playlist") {
this.fetchAllVideos()
.then(() => {
this.exportAsPlaylist();
})
.catch(e => {
console.error(e);
});
} else if (this.exportAs === "history") {
this.fetchAllVideos()
.then(() => {
this.exportAsHistory();
})
.catch(e => {
console.error(e);
});
}
},
exportAsPlaylist() {
const dateStr = new Date().toISOString().split(".")[0];
let json = {
format: "Piped",
version: 1,
playlists: [
{
name: `Piped History ${dateStr}`,
type: "history",
visibility: "private",
videos: this.exportVideos.map(video => "https://youtube.com" + video.url),
},
],
};
this.download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
},
exportAsHistory() {
const dateStr = new Date().toISOString().split(".")[0];
let json = {
format: "Piped",
version: 1,
watchHistory: this.exportVideos.map(video => {
let obj = {};
this.selectedFields.forEach(field => {
obj[field] = video[field];
});
return obj;
}),
};
this.download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
},
formatField(field) {
// camelCase to Title Case
return field.replace(/([A-Z])/g, " $1").replace(/^./, str => str.toUpperCase());
},
},
};
</script>
42 changes: 18 additions & 24 deletions src/components/HistoryPage.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
<template>
<h1 class="font-bold text-center" v-t="'titles.history'" />
<h1 class="font-bold text-center my-2" v-t="'titles.history'" />

<div class="flex">
<div>
<button class="btn" v-t="'actions.clear_history'" @click="clearHistory" />
<div class="flex flex-col gap-2 items-center lg:flex-row">
<div class="flex flex-wrap gap-2 justify-center">
<button class="btn w-54" v-t="'actions.clear_history'" @click="clearHistory" />

<button class="btn mx-3" v-t="'actions.export_to_json'" @click="exportHistory" />
<button class="btn w-54" v-t="'actions.export_to_json'" @click="showExportModal = !showExportModal" />

<button class="btn w-54" v-t="'actions.import_from_json'" @click="showImportModal = !showImportModal" />
</div>

<div class="right-1">
<div class="lg:right-1">
<SortingSelector by-key="watchedAt" @apply="order => videos.sort(order)" />
</div>
</div>
Expand All @@ -20,20 +22,28 @@
</div>

<br />
<ExportHistoryModal v-if="showExportModal" @close="showExportModal = false" />
<ImportHistoryModal v-if="showImportModal" @close="showImportModal = false" />
</template>

<script>
import VideoItem from "./VideoItem.vue";
import SortingSelector from "./SortingSelector.vue";
import ExportHistoryModal from "./ExportHistoryModal.vue";
import ImportHistoryModal from "./ImportHistoryModal.vue";

export default {
components: {
VideoItem,
SortingSelector,
ExportHistoryModal,
ImportHistoryModal,
},
data() {
return {
videos: [],
showExportModal: false,
showImportModal: false,
};
},
mounted() {
Expand All @@ -50,8 +60,8 @@ export default {
url: "/watch?v=" + video.videoId,
title: video.title,
uploaderName: video.uploaderName,
uploaderUrl: video.uploaderUrl,
duration: video.duration,
uploaderUrl: video.uploaderUrl ?? "", // Router doesn't like undefined
duration: video.duration ?? 0, // Undefined duration shows "Live"
thumbnail: video.thumbnail,
watchedAt: video.watchedAt,
});
Expand All @@ -73,22 +83,6 @@ export default {
}
this.videos = [];
},
exportHistory() {
const dateStr = new Date().toISOString().split(".")[0];
let json = {
format: "Piped",
version: 1,
playlists: [
{
name: `Piped History ${dateStr}`,
type: "history",
visibility: "private",
videos: this.videos.map(video => "https://youtube.com" + video.url),
},
],
};
this.download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
},
},
};
</script>
102 changes: 102 additions & 0 deletions src/components/ImportHistoryModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<template>
<ModalComponent>
<div class="text-center">
<h2 class="text-xl font-bold mb-4 text-center">Import History</h2>
<form>
<br />
<div>
<input class="btn ml-2 mb-2" ref="fileSelector" type="file" @change="fileChange" />
</div>
<div>
<strong v-text="`Found ${itemsLength} items`" />
</div>
<div>
<strong class="flex gap-2 justify-center items-center">
Override: <input v-model="override" class="checkbox" type="checkbox" />
</strong>
</div>
<br />
<div>
<progress :value="index" :max="itemsLength" />
<div v-text="`Success: ${success} Error: ${error} Skipped: ${skipped}`" />
</div>
<br />
<div>
<a class="btn w-auto" @click="handleImport">Import</a>
</div>
</form>
</div>
</ModalComponent>
</template>
<script>
import ModalComponent from "./ModalComponent.vue";

export default {
components: { ModalComponent },
data() {
return {
items: [],
override: false,
index: 0,
success: 0,
error: 0,
skipped: 0,
};
},
computed: {
itemsLength() {
return this.items.length;
},
},
methods: {
fileChange() {
const file = this.$refs.fileSelector.files[0];
file.text().then(text => {
this.items = [];
const json = JSON.parse(text);
const items = json.watchHistory.map(video => {
return {
...video,
watchedAt: video.watchedAt ?? 0,
currentTime: video.currentTime ?? 0,
};
});
this.items = items.sort((a, b) => b.watchedAt - a.watchedAt);
});
},
handleImport() {
if (window.db) {
var tx = window.db.transaction("watch_history", "readwrite");
var store = tx.objectStore("watch_history");
this.items.forEach(item => {
const dbItem = store.get(item.videoId);
dbItem.onsuccess = () => {
if (dbItem.result && dbItem.result.videoId === item.videoId) {
if (!this.override) {
this.index++;
this.skipped++;
return;
}
}
try {
const request = store.put(JSON.parse(JSON.stringify(item))); // prevent "Symbol could not be cloned." error
request.onsuccess = () => {
this.index++;
this.success++;
};
request.onerror = () => {
this.index++;
this.error++;
};
} catch (error) {
console.error(error);
this.index++;
this.error++;
}
};
});
}
},
},
};
</script>