-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathyoutube-playlist-playback-tracker.user.js
458 lines (419 loc) · 14.4 KB
/
youtube-playlist-playback-tracker.user.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
// ==UserScript==
// @name YouTube: playlists playback tracker
// @namespace https://github.com/rybak
// @homepageURL https://github.com/rybak/YouTube-playlists-playback-tracker
// @supportURL https://greasyfork.org/en/scripts/459412-youtube-playlists-playback-tracker/feedback
// @version 15
// @description This script helps watching playlists. It tracks the last video from a playlist that you've watched on this computer.
// @author Andrei Rybak
// @license MIT
// @match https://www.youtube.com/playlist?list=*
// @match https://www.youtube.com/watch?*&list=*
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.listValues
// @grant GM.deleteValue
// @grant GM_addStyle
// ==/UserScript==
/*
* Copyright (c) 2023-2024 Andrei Rybak
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/* jshint esversion: 6 */
(async function() {
'use strict';
// never change -- used as part of IDs in storage in user's browser
const STORAGE_KEY_PREFIX = "YT_PL_TRACKER_";
const STORAGE_KEY_VIDEO_SUFFIX = "_VIDEO";
const STORAGE_KEY_DATE_SUFFIX = "_DATE";
const STORAGE_KEY_VIDEO_INFO_SUFFIX = "_VIDEO_INFO";
const OTHER_PLAYLISTS_LIST_ID = "YT_PL_TRACKER_OTHER_VIDEOS_LIST";
const VIDEO_LINK_ID = 'YT_PL_TRACKER_LINK';
// number of milliseconds to wait, until a video is considered "watched"
const SAVE_DELAY = 60000;
// hack to wait for necessary parts of the UI to load, in milliseconds
const YOUTUBE_UI_LOAD_DELAY = 2000;
const WHITESPACE_REGEX = /\s+/gm;
function error(...toLog) {
console.error("[playlist tracker]", ...toLog);
}
function warn(...toLog) {
console.warn("[playlist tracker]", ...toLog);
}
function log(...toLog) {
console.log("[playlist tracker]", ...toLog);
}
function debug(...toLog) {
console.debug("[playlist tracker]", ...toLog);
}
function getCurrentPlaylistId() {
const urlParams = new URLSearchParams(document.location.search);
return urlParams.get('list');
}
function getCurrentVideoId() {
const urlParams = new URLSearchParams(document.location.search);
return urlParams.get('v');
}
function videoStorageKey(id) {
return STORAGE_KEY_PREFIX + id + STORAGE_KEY_VIDEO_SUFFIX;
}
function infoStorageKey(id) {
return STORAGE_KEY_PREFIX + id + STORAGE_KEY_VIDEO_INFO_SUFFIX;
}
function cleanUpStr(s) {
if (!s) {
return "";
}
return s.replace(WHITESPACE_REGEX, ' ').trim();
}
async function loadInfo(id) {
const infoKey = infoStorageKey(id);
const s = await GM.getValue(infoKey);
if (!s) {
return null;
}
try {
let res = JSON.parse(s);
res.channelName = cleanUpStr(res.channelName);
return res;
} catch (e) {
error(`Couldn't parse info for ${id} - ${infoKey}.`, e);
return null;
}
}
function dateStorageKey(id) {
return STORAGE_KEY_PREFIX + id + STORAGE_KEY_DATE_SUFFIX;
}
function dateToString(d) {
return d.toISOString().slice(0, "YYYY-MM-DD".length);
}
function videoInPlaylistUrl(videoId, listId) {
return `https://www.youtube.com/watch?v=${videoId}&list=${listId}`;
}
async function fallbackVideoTitle(videoId) {
// fallback to finding the video title on the playlist
let links = document.querySelectorAll("#contents.ytd-playlist-video-list-renderer h3 a");
if (!links) {
return videoId;
}
for (let i = 0; i < links.length; i++) {
const link = links[i];
if (link.href.includes(videoId)) {
return link.title;
}
}
return videoId;
}
function createLink(videoId, listId, date, videoTitle, channelName) {
const newLink = document.createElement("a");
newLink.href = videoInPlaylistUrl(videoId, listId);
newLink.innerText = `"${videoTitle}" from ${channelName} (watched on ${date}).`;
newLink.style = `color: white;`;
return newLink;
}
async function showStoredVideoLink(listId) {
log("Showing stored video link...");
if (!listId) {
warn("Can't find parameter 'list' in the URL. Aborting.");
return;
}
const maybeInfo = await loadInfo(listId);
let maybeVideoId = maybeInfo?.id;
if (!maybeVideoId) {
maybeVideoId = await GM.getValue(videoStorageKey(listId));
}
if (!maybeVideoId) {
log(`No video stored for list ${listId} yet.`);
return;
}
const videoId = maybeVideoId;
let dateStr = maybeInfo?.dateStr;
if (!dateStr) {
dateStr = await GM.getValue(dateStorageKey(listId));
}
log(`Showing stored video ${videoId} from date ${dateStr}. Waiting for ${YOUTUBE_UI_LOAD_DELAY} ms...`);
async function doShow() { // stupid way of waiting until YouTube UI loads
const headers = Array.from(
document.querySelectorAll(".metadata-buttons-wrapper, yt-flexible-actions-view-model")
).filter(h => h.offsetParent !== null);
if (headers.length === 0) {
log("UI hasn't loaded yet for showing the video. Retrying...");
setTimeout(doShow, YOUTUBE_UI_LOAD_DELAY);
return;
}
const header = headers[0];
// debug("HEADER", header);
log("Starting actual HTML edit...");
let videoTitle = maybeInfo?.title;
if (!videoTitle) {
videoTitle = await fallbackVideoTitle(videoId);
}
const channelName = maybeInfo?.channelName;
const newLink = createLink(videoId, listId, dateStr, videoTitle, channelName);
newLink.id = VIDEO_LINK_ID;
log("newLink =", newLink);
const wrapper = document.createElement("span");
wrapper.innerText = "Continue watching ";
wrapper.style.color = 'white';
wrapper.appendChild(newLink);
header.appendChild(wrapper);
log("HTML edit finished.");
}
doShow();
}
function getVideoTitle() {
let s = document.querySelector('title')?.innerText;
if (!s) {
return "unknown title";
}
s = s.slice(0, -10); // cut of " - YouTube"
return s;
}
function getVideoChannelName() {
let res = document.querySelector("#below #above-the-fold #upload-info #channel-name")?.outerText;
res = cleanUpStr(res);
log(`Extracted channel name: '${res}'`);
return res;
}
async function storeVideo() {
const listId = getCurrentPlaylistId();
const videoId = getCurrentVideoId();
const videoTitle = getVideoTitle();
const dateStr = dateToString(new Date());
const channelName = getVideoChannelName();
const info = {
'id': videoId,
'title': videoTitle,
'dateStr': dateStr,
'channelName': channelName
};
const infoToLog = JSON.stringify(info);
log(`Storing ${infoToLog} as video for list ${listId}.`);
await GM.setValue(infoStorageKey(listId), JSON.stringify(info));
/*
* Yes, this is a dumb way of storing data, but I have to keep
* storing dates separately for backwards compatibility.
*/
await GM.setValue(dateStorageKey(listId), dateStr);
}
function removePrefixSuffix(s, pref, suf) {
return s.slice(pref.length, -suf.length);
}
async function forEachStoredVideo(f) {
const keys = await GM.listValues();
for (const key of keys) {
/*
* Yes, this is a dumb way of storing data, but I have to keep
* checking stored dates for backwards compatibility.
*/
if (!key.endsWith(STORAGE_KEY_DATE_SUFFIX)) {
continue;
}
const dateKey = key;
const dateStr = await GM.getValue(dateKey);
const listId = removePrefixSuffix(dateKey, STORAGE_KEY_PREFIX, STORAGE_KEY_DATE_SUFFIX);
const videoKey = videoStorageKey(listId);
const infoKey = infoStorageKey(listId);
if (!dateStr) {
// clean up corrupted data, etc
GM.deleteValue(dateKey);
GM.deleteValue(videoKey);
GM.deleteValue(infoKey);
continue;
}
const info = await loadInfo(listId);
let videoId = info?.id;
if (!videoId) {
videoId = await GM.getValue(videoKey);
}
try {
f(listId, videoId, dateStr, info);
} catch (e) {
error(`Could not process ${key}: [${listId}, ${videoId}, ${dateStr}]`, e)
}
}
}
async function clearOldVideos() {
const keys = await GM.listValues();
log("Clearing old videos...");
const currentYear = new Date().getFullYear();
forEachStoredVideo(async (listId, videoId, dateStr, info) => {
const dateKey = dateStorageKey(listId);
const videoKey = videoStorageKey(listId);
const year = parseInt(dateStr.slice(0, "YYYY".length));
log(`Checking ${dateKey} -> ${dateStr} -> ${year} -> ${listId}`);
if (year < currentYear - 3) {
const url = videoInPlaylistUrl(videoId, listId);
log(`Deleting outdated list ${listId} -> ${url} on date ${dateStr}`);
GM.deleteValue(dateKey);
GM.deleteValue(videoKey);
}
});
}
async function showOtherPlaylists(currentListId) {
const otherPlaylistsList = document.createElement('ul');
otherPlaylistsList.id = OTHER_PLAYLISTS_LIST_ID;
let items = [];
// `await` to make sure that list `items` is populated before sorting
await forEachStoredVideo(async (listId, videoId, dateStr, info) => {
const infoToLog = JSON.stringify(info);
if (listId == currentListId) {
log(`Skipping current ${listId} -> ${infoToLog}`);
return;
}
log(`Listing ${listId} -> ${infoToLog}`);
const li = document.createElement('li');
const videoTitle = info?.title;
const channelName = info?.channelName;
const link = createLink(videoId, listId, dateStr, videoTitle ? videoTitle : videoId, channelName);
li.appendChild(link);
li.append(" "); // spacer
const deleteButton = document.createElement('a');
deleteButton.innerText = "[x]";
deleteButton.title = "Delete this video";
deleteButton.style = `color: grey;`;
deleteButton.href = "#";
deleteButton.onclick = function(e) {
e.preventDefault();
const confirmed = window.confirm(`Are you sure you want to delete video "${videoTitle}" (${videoId}) from YouTube playlist playback tracker?`);
if (!confirmed) {
log(`Aborting deletion of "${videoTitle}" (${videoId}).`);
return;
}
log(`Deleting "${videoTitle}" (${videoId}) from tracker...`);
const dateKey = dateStorageKey(listId);
const videoKey = videoStorageKey(listId);
const infoKey = infoStorageKey(listId);
GM.deleteValue(dateKey);
GM.deleteValue(videoKey);
GM.deleteValue(infoKey);
otherPlaylistsList.removeChild(li);
log(`Video "${videoTitle}" (${videoId}) was deleted.`);
};
li.appendChild(deleteButton);
items.push({
"dateStr": dateStr,
"li": li
});
});
items.sort((a, b) => {
// reverse order, so most recently viewed is on top
return a.dateStr < b.dateStr ? 1 : -1;
});
function doShow() {
const playlistHeaders = Array.from(
document.querySelectorAll('ytd-playlist-header-renderer .immersive-header-content.style-scope.ytd-playlist-header-renderer, yt-page-header-view-model .page-header-view-model-wiz__page-header-content')
).filter(h => h.offsetParent !== null);
if (playlistHeaders.length === 0) {
log("UI hasn't loaded yet for showing other playlists. Retrying...");
setTimeout(doShow, YOUTUBE_UI_LOAD_DELAY);
return;
}
GM_addStyle(`
#${OTHER_PLAYLISTS_LIST_ID} a {
text-decoration: none;
}
#${OTHER_PLAYLISTS_LIST_ID} a:hover {
text-decoration: underline;
}
#${OTHER_PLAYLISTS_LIST_ID} {
list-style-type: disclosure-closed;
list-style-position: inside;
}
#${OTHER_PLAYLISTS_LIST_ID} li {
padding: initial;
}
#${OTHER_PLAYLISTS_LIST_ID} li::marker {
font-size: initial;
color: white;
}
#${OTHER_PLAYLISTS_LIST_ID} {
color: white;
}
`);
log("Showing", items.length, "videos");
for (const item of items) {
otherPlaylistsList.appendChild(item.li);
}
const otherHeader = document.createElement('span');
otherHeader.style = "font-size: large; color: white;";
otherHeader.innerText = "Other playlists";
const playlistHeader = playlistHeaders[0];
// debug('PHEADER', playlistHeader);
playlistHeader.appendChild(otherHeader);
playlistHeader.appendChild(otherPlaylistsList);
}
doShow();
}
log("document.location.pathname =", document.location.pathname);
let timeoutId = -1;
function cancelPreviousTimeout() {
if (timeoutId > 0) {
clearTimeout(timeoutId);
}
}
async function processPlaylistPage() {
const listId = getCurrentPlaylistId();
if (!listId) {
return;
}
showStoredVideoLink(listId);
showOtherPlaylists(listId);
}
async function processWatchPage() {
if (!getCurrentPlaylistId() || !getCurrentVideoId()) {
return;
}
cancelPreviousTimeout();
// only store a video after it was watched for a minute (for debugging only 2-5 seconds)
timeoutId = setTimeout(storeVideo, SAVE_DELAY);
}
async function processPage() {
if (document.location.pathname == "/playlist") {
processPlaylistPage();
}
if (document.location.pathname == "/watch") {
processWatchPage();
}
}
await processPage();
setTimeout(clearOldVideos, 2 * SAVE_DELAY);
let currentVideoId = getCurrentVideoId();
// set up an observer to detect playlist autoplay and next/prev clicking
const observer = new MutationObserver((mutationsList) => {
const maybeNewVideoId = getCurrentVideoId();
log('Mutation to', maybeNewVideoId);
if (maybeNewVideoId !== currentVideoId) {
currentVideoId = maybeNewVideoId;
log('MutationObserver: video has changed:', document.location.href);
processPage();
return;
}
if (document.getElementById(VIDEO_LINK_ID)?.offsetParent === null) {
log('MutationObserver: UI has not loaded properly.');
processPage();
return;
}
});
// using <title> as a hack -- there's no good reliable way to detect fancy "on-the-fly" page reloads
observer.observe(document.querySelector('title'), { subtree: true, characterData: true, childList: true });
log("Waiting for async parts to complete...");
})();