// ==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.min.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. */ "use strict";let $,Cookies,traktApiWrapper;const logger={_defaults:{title:(typeof moduleName<"u"?moduleName:GM_info.script.name).replace("Trakt.tv","Userscript"),toast:!0,toastrOpt:{positionClass:"toast-top-right",timeOut:1e4,progressBar:!0},toastrStyles:"#toast-container#toast-container a { color: #fff !important; border-bottom: dotted 1px #fff; }"},_print(s,e,t="",a={}){const{title:o=this._defaults.title,toast:r=this._defaults.toast,toastrOpt:i,toastrStyles:n="",consoleStyles:d="",data:l}=a,p=`${t}${l!==void 0?" See console for details.":""}`;console[s](`%c${o}: ${t}`,d,...l!==void 0?[l]:[]),r&&unsafeWindow.toastr?.[e](p,o,{...this._defaults.toastrOpt,...i})},info(s,e){this._print("info","info",s,e)},success(s,e){this._print("info","success",s,{consoleStyles:"color:#00c853;",...e})},warning(s,e){this._print("warn","warning",s,e)},error(s,e){this._print("error","error",s,e)}},getProgRuntimeText=(s,e)=>{const t=~~(s.progress/100*e/60),a=Math.round(s.progress/100*e%60),o=~~((1-s.progress/100)*e/60),r=Math.round((1-s.progress/100)*e%60);return`${s.progress.toFixed(1)}% (${t?`${t}h`:""}${a}m | -${o?`${o}h`:""}${r}m)`},pbProgItems={},menuCommands={set:null,removeAll:null,renewAll:null};let lastPausedAt;location.pathname.includes("/progress/playback")&&document.body?.classList.add("playback"),addStyles(),document.addEventListener("turbo:load",()=>{if($??=unsafeWindow.jQuery,Cookies??=unsafeWindow.Cookies,traktApiWrapper??=unsafeWindow.userscriptTraktApiWrapper,!$||!Cookies)return;if(!traktApiWrapper){logger.error('"Trakt API Wrapper" is not available. Please make sure you have it installed and/or enabled. Aborting..',{toast:!1});return}unsafeWindow.userscriptPbProgMan={set:setPbProg,remove:removePbProg,getAll:getPbProgAll,removeAll:removePbProgAll,renewAll:renewPbProgAll,items:pbProgItems},Object.entries(menuCommands).forEach(([e,t])=>{t!==null&&(GM_unregisterMenuCommand(t),menuCommands[e]=null)});const s=$(':is(.sidebar, .sidebar .btn-item-report):is([data-type="movie"], [data-type="episode"])').attr("data-url");s&&(menuCommands.set=GM_registerMenuCommand("PPM: Set New",()=>setPbProg(s))),location.pathname.includes("/progress/playback")&&$("body").addClass("playback"),new RegExp(`/users/(me|${Cookies.get("trakt_userslug")})/progress`).test(location.pathname)&&addPbProgDropdownEntries(),$(document).off("ajaxSuccess.userscript09213").on("ajaxSuccess.userscript09213",async(e,t,a,o)=>{if(a.url.includes("/me/last_activities")&&(!lastPausedAt||lastPausedAt!==o.movies.paused_at+o.episodes.paused_at?(getPbProgAll(),lastPausedAt=o.movies.paused_at+o.episodes.paused_at):$("#playback-progress-wrapper").length||($("body").is(".show_progress.is-self.playback")&&await populatePbProgPage(),addPbProgBadges())),[/\/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(r=>r.test(a.url))&&addPbProgBadges(),a.url.endsWith("/watch")){const r=a.url.match(/(.+)\/watch/)[1];pbProgItems[r]&&removePbProg(r)}})},{capture:!0});async function setPbProg(s,e,t=!0){if(e??=prompt(`Please enter your playback progress. https://trakt.tv${s} It's not possible to set a playback progress of < 1% or >= 80%. The input parsing is very lenient, valid formats include: 13 | 13.37 | 13,37% | 0:42 | 0:42:59 | : 42 | 2h42m | 42 M 59s 2 H`),e===null)return;const a=await parseProgRaw(e,s);if(!a){logger.error("Invalid playback progress input.");return}pbProgItems[s]&&await removePbProg(s,!1);try{const o=s.split("/").filter(Boolean),r=o[0].slice(0,-1);pbProgItems[s]={...await traktApiWrapper.scrobble.stop({[r]:{ids:{[r==="movie"?"slug":"trakt"]:o[1]}},progress:a}),paused_at:new Date().toISOString(),type:r},logger.success(`Playback progress for "${pbProgItems[s].show?`${pbProgItems[s].show.title} - `:""}${pbProgItems[s][pbProgItems[s].type].title}" has been set to ${pbProgItems[s].progress.toFixed(1)}%.`,{toast:t,data:pbProgItems[s]})}catch(o){throw logger.error("Failed to set playback progress!",{toast:t,data:o}),o}$("body").is(".show_progress.is-self.playback")&&await populatePbProgPage(),addPbProgBadges()}async function removePbProg(s,e=!0){try{await traktApiWrapper.sync.playback.remove({id:pbProgItems[s].id}),logger.success(`Playback progress for "${pbProgItems[s].show?`${pbProgItems[s].show.title} - `:""}${pbProgItems[s][pbProgItems[s].type].title}" has been removed.`,{toast:e,data:pbProgItems[s]}),delete pbProgItems[s]}catch(t){if(t.status===404)logger.warning("Playback progress has already been removed.",{toast:e,data:t}),delete pbProgItems[s];else throw logger.error("Failed to remove playback progress!",{toast:e,data:t}),t}$("body").is(".show_progress.is-self.playback")?($(`.grid-item[data-url="${s}"]`).remove(),$("body > .tooltip").tooltip("destroy")):$(`.pb-prog-badge[data-url="${s}"]`).remove()}async function getPbProgAll(){const s=await traktApiWrapper.sync.playback.get();return Object.keys(pbProgItems).forEach(e=>delete pbProgItems[e]),Object.assign(pbProgItems,Object.fromEntries(s.map(e=>[`/${e.type}s/${e[e.type].ids[e.type==="movie"?"slug":"trakt"]}`,e]))),$("body").is(".show_progress.is-self.playback")&&await populatePbProgPage(),$(".pb-prog-badge").remove(),addPbProgBadges(),pbProgItems}async function removePbProgAll(s=!0){await Promise.all(Object.entries(pbProgItems).sort((e,t)=>new Date(e[1].paused_at)-new Date(t[1].paused_at)).map(([e])=>removePbProg(e,!1))),logger.success("All playback progress items have been removed.",{toast:s})}async function renewPbProgAll(s=!0){const e=Object.entries(pbProgItems).filter(([,{progress:t}])=>t<80).sort((t,a)=>new Date(t[1].paused_at)-new Date(a[1].paused_at));for(const[t,{progress:a}]of e)await setPbProg(t,a,!1);e.length?logger.success(`All (${e.length}) renewable playback progress items have been renewed.`,{toast:s}):logger.warning("No renewable playback progress items found.",{toast:s})}async function parseProgRaw(s,e){s=`${s}`.trim();let t;if(/^[\d.,%]+$/.test(s))t=parseFloat(s.replace(",","."));else if(e){const a=e.split("/").filter(Boolean),o=a[0]==="movies"?(await traktApiWrapper.movies.summary({id:a[1],extended:"full"})).runtime:(await traktApiWrapper.search.id({id_type:"trakt",id:a[1],type:"episode",extended:"full"}))[0].episode.runtime;if(o){if(s.includes(":"))t=s.split(":").slice(0,3).reduce((r,i,n)=>r+i*(3600/60**n),0)/(o*60)*100;else if(/[hms]/i.test(s)){const[r,i,n]=["h","m","s"].map(d=>+s.match(new RegExp(`(\\d+)s*${d}`,"i"))?.[1]||0);t=(r*3600+i*60+n)/(o*60)*100}}}if(!isNaN(t)&&t>=1&&t<80)return t}function addPbProgBadges(){Object.keys(pbProgItems).length&&$('.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((s,e)=>{const t=$(e).attr("data-url")??$(".notable-summary").attr("data-url"),a=$(e).attr("data-runtime")??$(".notable-summary").attr("data-runtime");pbProgItems[t]&&$(`
`).appendTo($(e).is(".grid-item")?$(e):$(e).find(".poster")).tooltip({title:()=>`Playback Progress