// ==UserScript== // @name Trakt.tv | Playback Progress Manager // @description Adds playback progress badges to in-progress movies/episodes and allows for setting and removing playback progress states. Also adds playback progress overview pages to the "Progress" tab and allows for bulk deletion and renewal. DOES NOT WORK WITHOUT THE "TRAKT API WRAPPER" USERSCRIPT! See README for details. // @version 1.0.3 // @namespace https://github.com/Fenn3c401 // @author Fenn3c401 // @license GPL-3.0-or-later // @homepageURL https://github.com/Fenn3c401/Trakt.tv-Userscript-Collection#readme // @supportURL https://github.com/Fenn3c401/Trakt.tv-Userscript-Collection/issues // @updateURL https://update.greasyfork.org/scripts/564749.meta.js // @downloadURL https://raw.githubusercontent.com/Fenn3c401/Trakt.tv-Userscript-Collection/main/userscripts/dist/swtn5c9q.user.js // @icon https://trakt.tv/assets/logos/logomark.square.gradient-b644b16c38ff775861b4b1f58c1230f6a097a2466ab33ae00445a505c33fcb91.svg // @match https://trakt.tv/* // @match https://classic.trakt.tv/* // @run-at document-start // @grant unsafeWindow // @grant GM_info // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // ==/UserScript== /* README > Inspired by sharkykh's [Trakt.tv Playback Progress Manager](https://sharkykh.github.io/tppm/). ### General - This script does not work without the [Trakt API Wrapper](f785bub0.md) userscript, so you'll need to install that one as well (or the [Megascript](zzzzzzzz.md)). - By clicking on a playback progress badge, you can access options to either set a new playback progress state or remove it entirely. - There are three context menu commands. "Set New" is only available on movie and episode summary pages and allows for setting a new playback progress state for that title. "Delete All" and "Renew All" are only available on the [Playback Progress - All Types](https://trakt.tv/users/me/progress/playback) page as those affect all stored playback progress states. From my testing the context menu commands are added reliably in Chrome, but not so much in Firefox. Fortunately Tampermonkey allows for triggering context menu commands via its extension popup window as well (see the screenshots below), so you can just use that as alternative. - Playback progress states are automatically removed by Trakt after 6 months. Renewing them postpones the auto-removal by first removing and then setting the playback progress states again, while preserving the current order. - Marking an in-progress movie or episode as watched will also remove the corresponding playback progress state. ### Playback Progress on Trakt Trakt has supported storing playback progress states for movies and episodes via their api for many years now, however for some reason they never actually bothered to add support for this to their website, so if you wanted to access those progress states you had to either do it through whichever 3rd party application saved them in the first place, or use sharkykh's [TPPM](https://sharkykh.github.io/tppm/). This has changed now, they've finally added native support for this to the new lite version of the website. Specifically on the "continue watching" page you can now see and remove the playback progress states of movies. From what I can tell there's no episode support, no bulk actions, no option to set a new state and most importantly there are no playback progress indicators on movie summary pages or any of the other grid views outside of the "continue watching" page. It's a rather lackluster implementation, though at least it's in line with the rest of their new version. */ /* global moduleName */ 'use strict'; let $, Cookies, traktApiWrapper; const logger = { _defaults: { title: (typeof moduleName !== 'undefined' ? moduleName : GM_info.script.name).replace('Trakt.tv', 'Userscript'), toast: true, toastrOpt: { positionClass: 'toast-top-right', timeOut: 10000, progressBar: true }, toastrStyles: '#toast-container#toast-container a { color: #fff !important; border-bottom: dotted 1px #fff; }', }, _print(fnConsole, fnToastr, msg = '', opt = {}) { const { title = this._defaults.title, toast = this._defaults.toast, toastrOpt, toastrStyles = '', consoleStyles = '', data } = opt, fullToastrMsg = `${msg}${data !== undefined ? ' See console for details.' : ''}`; console[fnConsole](`%c${title}: ${msg}`, consoleStyles, ...(data !== undefined ? [data] : [])); if (toast) unsafeWindow.toastr?.[fnToastr](fullToastrMsg, title, { ...this._defaults.toastrOpt, ...toastrOpt }); }, info(msg, opt) { this._print('info', 'info', msg, opt) }, success(msg, opt) { this._print('info', 'success', msg, { consoleStyles: 'color:#00c853;', ...opt }) }, warning(msg, opt) { this._print('warn', 'warning', msg, opt) }, error(msg, opt) { this._print('error', 'error', msg, opt) }, }; const getProgRuntimeText = (itemData, runtime) => { const hoursIn = ~~(((itemData.progress / 100) * runtime) / 60), minsIn = Math.round(((itemData.progress / 100) * runtime) % 60), hoursLeft = ~~(((1 - (itemData.progress / 100)) * runtime) / 60), minsLeft = Math.round(((1 - (itemData.progress / 100)) * runtime) % 60); return `${itemData.progress.toFixed(1)}% (${hoursIn ? `${hoursIn}h` : ''}${minsIn}m | -${hoursLeft ? `${hoursLeft}h` : ''}${minsLeft}m)`; } const pbProgItems = {}, menuCommands = { set: null, removeAll: null, renewAll: null }; let lastPausedAt; if (location.pathname.includes('/progress/playback')) document.body?.classList.add('playback'); addStyles(); document.addEventListener('turbo:load', () => { $ ??= unsafeWindow.jQuery; Cookies ??= unsafeWindow.Cookies; traktApiWrapper ??= unsafeWindow.userscriptTraktApiWrapper; if (!$ || !Cookies) return; if (!traktApiWrapper) { logger.error('"Trakt API Wrapper" is not available. Please make sure you have it installed and/or enabled. Aborting..', { toast: false }); return; } unsafeWindow.userscriptPbProgMan ={ set: setPbProg, remove: removePbProg, getAll: getPbProgAll, removeAll: removePbProgAll, renewAll: renewPbProgAll, items: pbProgItems, }; Object.entries(menuCommands).forEach(([name, id]) => { if (id !== null) { GM_unregisterMenuCommand(id); menuCommands[name] = null; }; }); const itemUrl = $(`:is(.sidebar, .sidebar .btn-item-report):is([data-type="movie"], [data-type="episode"])`).attr('data-url'); if (itemUrl) menuCommands.set = GM_registerMenuCommand('PPM: Set New', () => setPbProg(itemUrl)); if (location.pathname.includes('/progress/playback')) $('body').addClass('playback'); if (new RegExp(`/users/(me|${Cookies.get('trakt_userslug')})/progress`).test(location.pathname)) addPbProgDropdownEntries(); $(document).off('ajaxSuccess.userscript09213').on('ajaxSuccess.userscript09213', async (_evt, _xhr, opt, resp) => { if (opt.url.includes('/me/last_activities')) { if(!lastPausedAt || lastPausedAt !== resp.movies.paused_at + resp.episodes.paused_at) { getPbProgAll(); lastPausedAt = resp.movies.paused_at + resp.episodes.paused_at; } else if (!$('#playback-progress-wrapper').length) { if ($('body').is('.show_progress.is-self.playback')) await populatePbProgPage(); addPbProgBadges(); } } if ([ /\/movies\/.*\/related_items$/, /\/shows\/.*\/recent_episodes$/, /\/dashboard\/(recently_watched|on_deck|recommendations\/movies|network_activies|list)$/, /\/users\/.*\/profile\/(recently_watched|most_watched\/movies|comments)$/, ].some((regExp) => regExp.test(opt.url))) addPbProgBadges(); if (opt.url.endsWith('/watch')) { const itemUrl = opt.url.match(/(.+)\/watch/)[1]; if (pbProgItems[itemUrl]) removePbProg(itemUrl); } }); }, { capture: true }); /////////////////////////////////////////////////////////////////////////////////////////////// async function setPbProg(itemUrl, progRaw, toast = true) { progRaw ??= prompt(`Please enter your playback progress.\nhttps://trakt.tv${itemUrl}\n\nIt's not possible to set a playback progress of < 1% or >= 80%.\n` + `The input parsing is very lenient, valid formats include:\n13 | 13.37 | 13,37% | 0:42 | 0:42:59 | : 42 | 2h42m | 42 M 59s 2 H`); if (progRaw === null) return; const prog = await parseProgRaw(progRaw, itemUrl); if (!prog) { logger.error('Invalid playback progress input.'); return; } if (pbProgItems[itemUrl]) await removePbProg(itemUrl, false); try { const itemUrlSplit = itemUrl.split('/').filter(Boolean), itemType = itemUrlSplit[0].slice(0, -1); pbProgItems[itemUrl] = { ...(await traktApiWrapper.scrobble.stop({ [itemType]: { ids: { [itemType === 'movie' ? 'slug' : 'trakt']: itemUrlSplit[1] } }, progress: prog })), paused_at: new Date().toISOString(), type: itemType, }; logger.success(`Playback progress for "${pbProgItems[itemUrl].show ? `${pbProgItems[itemUrl].show.title} - ` : ''}${pbProgItems[itemUrl][pbProgItems[itemUrl].type].title}" ` + `has been set to ${pbProgItems[itemUrl].progress.toFixed(1)}%.`, { toast, data: pbProgItems[itemUrl] }); } catch (err) { logger.error(`Failed to set playback progress!`, { toast, data: err }); throw err; } if ($('body').is('.show_progress.is-self.playback')) await populatePbProgPage(); addPbProgBadges(); } async function removePbProg(itemUrl, toast = true) { try { await traktApiWrapper.sync.playback.remove({ id: pbProgItems[itemUrl].id }); logger.success(`Playback progress for "${pbProgItems[itemUrl].show ? `${pbProgItems[itemUrl].show.title} - ` : ''}` + `${pbProgItems[itemUrl][pbProgItems[itemUrl].type].title}" has been removed.`, { toast, data: pbProgItems[itemUrl] }); delete pbProgItems[itemUrl]; } catch (err) { if (err.status === 404) { logger.warning(`Playback progress has already been removed.`, { toast, data: err }); delete pbProgItems[itemUrl]; } else { logger.error(`Failed to remove playback progress!`, { toast, data: err }); throw err; } } if ($('body').is('.show_progress.is-self.playback')) { $(`.grid-item[data-url="${itemUrl}"]`).remove(); $('body > .tooltip').tooltip('destroy'); } else $(`.pb-prog-badge[data-url="${itemUrl}"]`).remove(); } async function getPbProgAll() { const resp = await traktApiWrapper.sync.playback.get(); Object.keys(pbProgItems).forEach((itemUrl) => delete pbProgItems[itemUrl]); Object.assign(pbProgItems, Object.fromEntries(resp.map((item) => [`/${item.type}s/${item[item.type].ids[item.type === 'movie' ? 'slug' : 'trakt']}`, item]))); if ($('body').is('.show_progress.is-self.playback')) await populatePbProgPage(); $(`.pb-prog-badge`).remove(); addPbProgBadges(); return pbProgItems; } async function removePbProgAll(toast = true) { await Promise.all( Object.entries(pbProgItems) .sort((a, b) => new Date(a[1].paused_at) - new Date(b[1].paused_at)) .map(([itemUrl]) => removePbProg(itemUrl, false)) ); logger.success(`All playback progress items have been removed.`, { toast }); } async function renewPbProgAll(toast = true) { const sortedRenewableItemEntries = Object.entries(pbProgItems) .filter(([, { progress }]) => progress < 80) .sort((a, b) => new Date(a[1].paused_at) - new Date(b[1].paused_at)); for (const [itemUrl, { progress }] of sortedRenewableItemEntries) await setPbProg(itemUrl, progress, false); if (sortedRenewableItemEntries.length) logger.success(`All (${sortedRenewableItemEntries.length}) renewable playback progress items have been renewed.`, { toast }); else logger.warning(`No renewable playback progress items found.`, { toast }); } async function parseProgRaw(progRaw, itemUrl) { progRaw = `${progRaw}`.trim(); let prog; if (/^[\d.,%]+$/.test(progRaw)) { prog = parseFloat(progRaw.replace(',', '.')); } else if (itemUrl) { const itemUrlSplit = itemUrl.split('/').filter(Boolean), runtime = itemUrlSplit[0] === 'movies' ? (await traktApiWrapper.movies.summary({ id: itemUrlSplit[1], extended: 'full' })).runtime : (await traktApiWrapper.search.id({ id_type: 'trakt', id: itemUrlSplit[1], type: 'episode', extended: 'full' }))[0].episode.runtime; if (runtime) { if (progRaw.includes(':')) { prog = (progRaw.split(':').slice(0, 3).reduce((acc, v, i) => acc + v * (3600 / (60 ** i)), 0) / (runtime * 60)) * 100; } else if (/[hms]/i.test(progRaw)) { const [h, m, s] = ['h', 'm', 's'].map((unit) => +progRaw.match(new RegExp(`(\\d+)\s*${unit}`, 'i'))?.[1] || 0); prog = ((h*3600 + m*60 + s) / (runtime * 60)) * 100; } } } if (!isNaN(prog) && prog >= 1 && prog < 80) return prog; } /////////////////////////////////////////////////////////////////////////////////////////////// function addPbProgBadges() { if (!Object.keys(pbProgItems).length) return; $(`.grid-item:is([data-type="movie"], [data-type="episode"]):has(.poster, .fanart):not(:has(.pb-prog-badge)), ` + `.sidebar:is([data-type="movie"], [data-type="episode"], :has(.btn-item-report:is([data-type="movie"], [data-type="episode"]))):not(:has(.pb-prog-badge)), ` + `#summary-ratings-wrapper:has(.summary-user-rating:is([data-type="movie"], [data-type="episode"])) ~ .summary .mobile-poster:not(:has(.pb-prog-badge))`).each((_i, e) => { const itemUrl = $(e).attr('data-url') ?? $('.notable-summary').attr('data-url'), runtime = $(e).attr('data-runtime') ?? $('.notable-summary').attr('data-runtime'); if (pbProgItems[itemUrl]) { $(`
`) .appendTo($(e).is('.grid-item') ? $(e) : $(e).find('.poster')).tooltip({ title: () => `` + `Playback Progress