// ==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
${getProgRuntimeText(pbProgItems[t],a)}
${unsafeWindow.formatDate(pbProgItems[t].paused_at)}
Click for options
`,placement:"right",container:"body",html:!0}).popover({template:'',title:"Playback Progress Options",content:``,trigger:"manual",placement:"bottom",container:"body",html:!0}).on("click",function(o){o.preventDefault(),$(this).tooltip("hide").popover("show")}).fadeIn()})}function addPbProgDropdownEntries(){const s=$(".subnav-wrapper .left .dropdown-menu");s.append('
  • All Types
  • Movies
  • Episodes
  • '),$("body").is(".playback")&&(s.find(".selected").removeClass("selected").end().find(`[href$="${location.pathname.split("/").pop()}"]`).addClass("selected").end().prev().contents()[0].textContent=`Playback - ${s.find(".selected").text()} `)}async function populatePbProgPage(){const s=location.pathname.split("/playback").pop(),e=await Promise.all(Object.entries(pbProgItems).filter(([,{type:t}])=>!s||s.includes(t)).sort((t,a)=>new Date(a[1].paused_at)-new Date(t[1].paused_at)).map(async([t,a])=>{const[o,r]=await Promise.all([traktApiWrapper[a.movie?"movies":"shows"].summary({id:a[a.movie?"movie":"show"].ids.trakt,extended:"full,images"}),a.episode?traktApiWrapper.episodes.summary({id:a.show.ids.trakt,season:a.episode.season,episode:a.episode.number,extended:"full,images"}):null]);return buildPbProgGridItem(t,a,o,r)}));if($(":is(#progress-wrapper, #playback-progress-wrapper)").attr("id","playback-progress-wrapper").children().html('
    '),$("body > .tooltip").tooltip("destroy"),e.length){if($("#playback-progress-wrapper .row").addClass("posters").append(e),unsafeWindow.isProgress=!1,unsafeWindow.addOverlays(),unsafeWindow.posterGridTooltips(),unsafeWindow.formatDates(),unsafeWindow.hideUnreleasedRatings(),unsafeWindow.lazyLoadImages(),!s){const t=`Remove all (${e.length}) playback progress items? This will take about ${e.length*1}s and cannot be undone!`,a=`Renew all (${e.length}) playback progress items? This will take about ${e.length*2}s and cannot be undone! 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. Due to trakt api changes playback progress items with a progress of >= 80% cannot be renewed and will be excluded.`;[["removeAll","Remove All",t,removePbProgAll],["renewAll","Renew All",a,renewPbProgAll]].forEach(([o,r,i,n])=>{menuCommands[o]??=GM_registerMenuCommand(`PPM: ${r}`,()=>confirm(i)&&n())})}}else $("#playback-progress-wrapper .row").html('

    Nothing to see here. Move along, move along.
    ')}function buildPbProgGridItem(s,e,t,a){const o=a?.first_aired??t.first_aired??t.released+"T00:00:00Z",r=Math.floor((a??t).rating*10),i=(a??t).runtime,n=e.movie?s:`/shows/${e.show.ids.slug}/seasons/${e.episode.season}/episodes/${e.episode.number}`,d=e.movie?`${e.movie.title} (${e.movie.year})`:`${e.show.title}<br>${e.episode.season}x${String(e.episode.number).padStart(2,"0")} "${e.episode.title}"`,l=e.episode?`<span class='main-title-sxe'>${e.episode.season}x${String(e.episode.number).padStart(2,"0")}</span> <span class='main-title' data-spoiler-episode-id='${e.episode.ids.trakt}' data-spoiler-show-id='${e.show.ids.trakt}'>${e.episode.title}</span>`:null,p=a?a.episode_type.replace("_","-"):null,c=a&&a.episode_type!=="standard"?a.episode_type.replace("_"," ").toUpperCase():null;return $(`

    `+(e.episode?`${e.episode.season}x${String(e.episode.number).padStart(2,"0")} ${e.episode.title}`:e.movie.title)+`

    ${getProgRuntimeText(e,i)}

    `+(e.episode?``:"")+`
    `)}function addStyles(){GM_addStyle(` .pb-prog-badge { position: absolute; width: 30px; height: 30px; border-radius: 50%; background: radial-gradient(#555 45%, transparent 45%), conic-gradient(from 180deg, #4CAF50 var(--pb-prog-percent), #333 var(--pb-prog-percent)); display: flex; justify-content: center; align-items: center; color: #ccc; z-index: 30; top: -10px; left: 0; } .grid-item:has(> .notable-badge, > .added-by) .pb-prog-badge { left: 20px; } .grid-item:has(> .notable-badge):has(> .added-by) .pb-prog-badge { left: 40px; } :is(.sidebar, .frame) :is(.pb-prog-badge, .notable-badge, .rewatching-badge) { top: 1.5% !important; margin-left: 1.5% !important; } #user-profile-comments-wrapper .grid-item :is(.pb-prog-badge, .notable-badge, .rewatching-badge) { top: 10px !important; } body.calendars .grid-item .notable-badge { left: revert !important; } body.show_progress.playback :is(#progress-wrapper, .subnav-wrapper .right, .subnav-wrapper.visible-xs-block .left) { display: none !important; } #playback-progress-wrapper .titles { margin: 10px 5px 10px !important; } #playback-progress-wrapper .titles h3 { margin-top: 0 !important; } .reports-wrapper .grid-item { position: relative; } `)}