))) {
#activity .users-wrapper .plus-more .text {
display: none;
}
#activity .users-wrapper .plus-more::after {
content: "+" counter(plusMoreCounter) "\\Amore";
white-space: pre;
line-height: 1;
font-weight: var(--headings-font-weight);
font-family: var(--headings-font-family);
font-size: 16px;
}
}
#activity .users-wrapper .row {
grid-area: 1 / 1 / 2 / -1;
display: grid;
grid-template-columns: subgrid;
row-gap: 10px;
max-height: revert !important;
margin: revert !important;
}
#activity .users-wrapper .row::before,
#activity .users-wrapper .row::after {
content: revert !important;
}
#activity .users-wrapper .row > div {
counter-increment: plusMoreCounter -1;
width: revert !important;
padding: revert !important;
}
#activity .users-wrapper .row > div img {
aspect-ratio: 1; /* for bg while img is loading */
margin-bottom: revert !important;
}
@media (width <= 767px) {
#activity .users-wrapper {
padding-bottom: 10px !important;
}
}
@media (width <= 991px) {
#activity .users-wrapper .row > :nth-child(n + 6) {
display: none;
}
}
@media (991px < width <= 1200px) {
#activity .users-wrapper {
grid-template-columns: repeat(9, 1fr);
}
#activity .users-wrapper .row > :nth-child(n + 9),
#activity .users-wrapper:not(:has(> .row > :nth-child(9))) .plus-more {
display: none;
}
}
@media (width > 1200px) {
#activity .users-wrapper {
grid-template-columns: repeat(12, 1fr);
}
#activity .users-wrapper .row > :nth-child(n + 12),
#activity .users-wrapper:not(:has(> .row > :nth-child(12))) .plus-more {
display: none;
}
}
#activity .users-wrapper .row:has(+ .plus-more[style*="display: none;"]) > div,
#activity .users-wrapper .row:not(:has(+ .plus-more)) > :nth-child(-n + 12) { /* downsizing with 7-12 items (no btn in that case) */
display: block;
}
#actors .posters {
container-type: inline-size;
}
#actors .posters ul {
width: max-content !important;
display: flex;
--gap: 10px;
gap: var(--gap);
}
#actors .posters ul li {
width: calc((100cqi - ((var(--visible-items) - 1) * var(--gap))) / var(--visible-items)) !important;
}
#actors .posters ul li :is(.poster, .titles) {
margin-right: revert !important;
}
@media (width <= 767px) {
#actors .posters ul {
--gap: 0px;
--visible-items: 4;
}
}
@media (767px < width <= 991px) {
#actors .posters ul {
--visible-items: 6;
}
}
@media (991px < width <= 1200px) {
#actors .posters ul {
--visible-items: 8;
}
}
@media (1200px < width) {
#actors .posters ul {
--visible-items: 10;
}
}
.actor-tooltip {
margin-top: 5px;
margin-left: revert !important;
}
`);
// .readmore elems on a user's /lists page (list descriptions) lack data-sortable attr, also getSortableGrid() is undefined in that scope, which prevents the readmore afterToggle and blockProcessed
// callbacks from working as intended, resulting in text overflow when clicking on "read more" (or gaps when clicking on "read less") AFTER isotope instance has been initialized (due to sorting/filtering)
const optimizedRenderReadmore = () => {
const $readmore = unsafeWindow.jQuery('.readmore:not([id^="rmjs-"])').filter((_i, e) => unsafeWindow.jQuery(e).height() > 350); // height() filtering because readmore plugin is prone to layout thrashing
$readmore.readmore({
embedCSS: false,
collapsedHeight: 300,
speed: 200,
moreLink: 'Read more... ',
lessLink: 'Read less... ',
afterToggle: (_trigger, $el, _expanded) => $el.closest('#sortable-grid').length && unsafeWindow.$grid?.isotope(),
});
requestAnimationFrame(() => unsafeWindow.$grid?.isotope());
};
Object.defineProperty(unsafeWindow, 'renderReadmore', {
get: () => optimizedRenderReadmore,
set: () => {}, // native renderReadmore() gets assigned on turbo:load
configurable: true,
});
// replaces malihu scrollbars on season and episode pages (bar with links to other seasons/episodes) with regular css scrollbars because malihu scrollbars: are less responsive, don't support panning,
// "all" link gets cut off on tablet and mobile-layout if lots of items present, fixed inline width messes up layout when resizing, touch scrolling is jerky and doesn't work directly on scrollbar
GM_addStyle(`
#info-wrapper .season-links .links {
overflow-x: auto !important;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.2s;
width: revert !important;
}
#info-wrapper .season-links .links:hover {
scrollbar-color: rgb(102 102 102 / 0.4) transparent;
}
#info-wrapper .season-links .links > ul {
width: max-content !important;
}
`);
((fn) => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn())(() => {
if (unsafeWindow.jQuery) unsafeWindow.jQuery.fn.mCustomScrollbar = function() { return this; }; // malihu scrollbars are not used anywhere else
});
document.addEventListener('turbo:load', () => {
document.querySelector('#info-wrapper .season-links .links .selected')?.scrollIntoView({ block: 'nearest', inline: 'start' });
}, { capture: true });
// The filter dropdown menu on /people pages has three hidden display type entries [seasons, episodes, people] which have the .selected class and are therefore included in the count for the
// .filter-counter indicator (=> off by three), despite not actually being accessible and having no effect on the filtered titles, as only shows and movies are listed on /people pages.
document.addEventListener('turbo:load', () => {
if (/^\/people\/[^\/]+$/.test(location.pathname)) unsafeWindow.jQuery?.('#filter-fade-hide .dropdown-menu li.typer:is(.season, .episode, .person) a.selected').removeClass('selected');
}, { capture: true });
// on click handler for csv btn throws as this one (unlike the other feed btns) is not intended to have a popover
window.addEventListener('turbo:load', () => unsafeWindow.jQuery?.('.feed-icon.csv').off('click'));
// on single-comment-view pages .above-comment has a variable height depending on its content, .above-comment-bg (full-width bg bar up top underneath .above-comment)
// however always has the same fixed height, so .above-comment and its own bg sometimes extend beyond .above-comment-bg
GM_addStyle(`
@media (767px < width) {
body.comments:has(#read) {
overflow-x: clip !important;
}
body.comments #read > .comment-wrapper > .above-comment::before,
body.comments #read > .comment-wrapper > .above-comment::after {
content: "";
position: absolute;
top: 0;
height: 100%;
background-color: inherit;
width: 100vw;
}
body.comments #read > .comment-wrapper > .above-comment::before {
right: 100%;
}
body.comments #read > .comment-wrapper > .above-comment::after {
left: 100%;
}
}
`);
// prevent comments on discover page from being cut off at the bottom on mobile-layout
GM_addStyle(`
@media (width <= 767px) {
body.discover .comment-wrapper .comment {
padding-bottom: 30px !important;
}
}
`);
// Set #links-wrapper (category selection on user and settings pages) height to a fixed 40px with dynamic vertical spacing for the links, to prevent both
// vertical overflow (with scrollbar) inside the #links-wrapper and overlap with the #watching-now-wrapper when there's a horizontal scrollbar.
GM_addStyle(`
#links-wrapper {
height: 40px !important;
}
#links-wrapper .container {
height: 100% !important;
display: flex !important;
align-items: center;
}
#links-wrapper .container a {
line-height: inherit !important;
}
`);
// - watch-now buttons of "today" column in "upcoming schedule" section of dashboard lack data-source-counts attr which makes them throw on click
// - when marking an episode as watched on the dashboard's "up next" section with activated auto-refresh, no poster tooltip gets added to the new grid-item because posterGridTooltips() doesn't get called
((fn) => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn())(() => {
unsafeWindow.jQuery?.(document).on('ajaxSuccess', (_evt, _xhr, opt) => {
if (opt.url.endsWith('/dashboard/schedule')) unsafeWindow.jQuery('#schedule-wrapper .btn-watch-now:not([data-source-counts])').attr('data-source-counts', '{}');
if (/\/(dashboard\/on_deck|progress_item\/watched)\/\d+$/.test(opt.url)) unsafeWindow.posterGridTooltips?.();
});
});
// There's horizontal overflow on the body in a variety of different scenarios, too many to handle individually (e.g. many charts after downsizing and mobile-layout pages in general).
GM_addStyle(`
body {
overflow-x: clip !important;
}
`);
// - add missing margin to "add comment" button on tablet-layout comment pages
// - fix styling of mobile layout "add comment" button on the watchlist comment page (default selectors don't cover that one specific edge case)
GM_addStyle(`
@media (767px < width < 992px) {
.comment-wrapper.list.keep-inline .interactions {
margin-left: revert !important;
}
}
@media (width <= 767px) {
body.watchlist_comments .comment-wrapper.lists {
padding-left: 10px;
}
body.watchlist_comments .comment-wrapper.lists .count-text {
display: none;
}
}
`);
// add missing dark-mode bg color for focused dropdown-menu anchor tags (e.g. after mouse-middle-click), otherwise defaults to white from light-mode
GM_addStyle(`
.dark-knight .dropdown-menu a:focus {
background-color: #222 !important;
}
`);
// Adds a scrollbar to the #summary-ratings-wrapper on title pages (bar up top with ratings, plays, watchers etc.), which usually only allows for panning. This is handled per row on mobile-layout,
// where by default you can only scroll the whole container as one. Also prevents the text-shadow of the rating icons from getting cut off at the top.
GM_addStyle(`
#summary-ratings-wrapper > .container {
padding-top: revert !important;
}
@media (width <= 767px) {
#summary-ratings-wrapper {
border-top: revert !important;
}
#summary-ratings-wrapper .ul-wrapper {
padding: revert !important;
margin-bottom: revert !important;
}
#summary-ratings-wrapper .ul-wrapper ul {
height: 50px;
line-height: 39px;
overflow-x: auto;
scrollbar-width: none;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.2s;
}
#summary-ratings-wrapper .ul-wrapper ul:hover {
scrollbar-width: thin;
scrollbar-color: rgb(102 102 102 / 0.4) transparent;
}
#summary-ratings-wrapper .ul-wrapper ul.ratings {
padding: 0 10px !important;
border-block: solid 1px #333;
}
#summary-ratings-wrapper .ul-wrapper ul.stats {
margin: 0 10px !important;
padding: 0 !important;
border-top: revert !important;
}
#summary-ratings-wrapper .ul-wrapper ul li {
vertical-align: -37%;
}
}
@media (767px < width) {
#summary-ratings-wrapper .ul-wrapper {
height: 60px;
line-height: 49px;
scrollbar-width: none;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.2s;
padding-bottom: revert !important;
margin-bottom: revert !important;
}
#summary-ratings-wrapper .ul-wrapper:hover {
scrollbar-width: thin;
scrollbar-color: rgb(102 102 102 / 0.4) transparent;
}
#summary-ratings-wrapper .ul-wrapper li {
vertical-align: -33%;
}
}
`);
// Fix for missing event listeners on mobile-layout "expand options" btns for the grids on user, list and settings pages, if window was downsized after initial page load.
document.addEventListener('click', (evt) => {
if (evt.target.closest('.toggle-feeds')) {
evt.stopPropagation();
document.querySelector('.toggle-feeds-wrapper')?.classList.toggle('open');
} else if (evt.target.closest('.toggle-subnav-options')) {
evt.stopPropagation();
document.querySelector('.toggle-subnav-wrapper')?.classList.toggle('open');
}
}, { capture: true });
// Make added Array.prototype props/functions non-enumerable. Otherwise causes issues with for..in loops and the props also get listed as event listeners in chrome dev tools.
((fn) => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn())(() => {
['remove', 'intersection', 'move', 'uniq'].forEach((fnName) => {
const desc = Object.getOwnPropertyDescriptor(Array.prototype, fnName);
if (desc) {
desc.enumerable = false;
Object.defineProperty(Array.prototype, fnName, desc);
}
});
});
// Fix table overflow on /releases pages for mobile and tablet-layout.
GM_addStyle(`
body.releases .panel-body {
overflow-x: auto !important;
scrollbar-width: thin;
scrollbar-color: #666 #333;
}
body.releases .panel-body tr :is(th, td):last-of-type {
min-width: revert !important;
}
`);
// Fix incorrect and inconsistent application of grayscale filter on summary pages of dropped shows, for example by default
// fanart is in grayscale on title summary pages, but not on title summary subpages (/stats, /comments, /lists, /credits, /seasons/all),
// it's the opposite for the images inside of watch-now buttons (not their bg color though) and profile pictures and emoji in the comment sections are also handled inconsistently.
GM_addStyle(`
body.shows :is(#comments, .sidebar .streaming-links) img {
filter: none !important;
}
body.shows #summary-wrapper:has(> .summary .poster.dropped-show) :is(.full-screenshot, .delta, img) {
filter: grayscale(100%) !important;
}
`);
// Add missing margin between #actors section and #episodes header on mobile-layout season pages. Seems like the spacer from show summary pages is missing here.
GM_addStyle(`
@media (width <= 767px) {
body.season #episodes {
margin-top: 35px !important;
}
}
`);
// FIXED
/////////////////////////////////////////////////////////////////////////////////////////////
// people search results (including header search and both with the standard and experimental search engine) sort order is not great,
// well known people tend to be ranked much lower than other people with the same or a similar name,
// e.g. right now "Will Smith" ranks 15th, after 14 other "Will Smith"s and "Jack Black" ranks 5th after three other "Jack Black"s and a "Black Jack"
// @require https://cdn.jsdelivr.net/gh/stdlib-js/string-base-distances-levenshtein@v0.2.2-umd/browser.js
// if (isSearchPeoplePage) {
// ((fn) => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn())(() => {
// const $ = unsafeWindow.jQuery;
// if (!$) return;
// const query = $('#filter-query').val().trim().toLowerCase(),
// $peopleGridItems = $('.frame.people .grid-item[data-type="person"]'),
// $container = $peopleGridItems.first().parent();
// if (!query || $peopleGridItems.length < 2) return;
// const getMatchScore = (name, query) => {
// if (name === query) return 5;
// if (name.endsWith(query)) return 4; // search for surname is more likely
// if (name.startsWith(query)) return 3;
// if (query.split(' ').filter(Boolean).every((queryPart) => name.includes(queryPart))) return 2;
// return 1 / window.levenshteinDistance(name, query);
// }
// $peopleGridItems.each(function() {
// const name = $(this).find('meta[itemprop="name"]').attr('content').toLowerCase(),
// hasImg = $(this).find('.titles').hasClass('has-worded-image');
// $(this)
// .prop('matchScore', getMatchScore(name, query))
// .prop('hasImg', hasImg);
// }).sort((a, b) => {
// const hasImgA = $(a).prop('hasImg'),
// hasImgB = $(b).prop('hasImg');
// if (hasImgA !== hasImgB) return hasImgB - hasImgA;
// const scoreA = $(a).prop('matchScore'),
// scoreB = $(b).prop('matchScore');
// if (scoreA !== scoreB) return scoreB - scoreA;
// const idA = +$(a).attr('data-person-id'),
// idB = +$(b).attr('data-person-id');
// return idA - idB;
// });
// $container.prepend($peopleGridItems);
// });
// }
})('Trakt.tv | Bug Fixes And Optimizations');
gmStorage['cs1u5z40'] && (async (moduleName) => {
/* global Chart */
'use strict';
let $, traktApiWrapper;
let $grid, isSeasonChart, filterSpecials, labelsCallback, chart, datasetsData, firstRunDelay;
Chart.defaults.borderColor = 'rgb(44 44 44 / 0.5)';
const numFormatCompact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });
numFormatCompact.formatTLC = (n) => numFormatCompact.format(n).toLowerCase();
addStyles();
document.addEventListener('turbo:load', async () => {
if (!/^\/shows\/[^/]+\/seasons\/[^/]+$/.test(location.pathname)) return;
$ ??= unsafeWindow.jQuery;
traktApiWrapper ??= unsafeWindow.userscriptTraktApiWrapper;
if (!$) return;
$grid = $('#seasons-episodes-sortable');
if (!$grid.length) return;
isSeasonChart = location.pathname.includes('/seasons/');
filterSpecials = !location.pathname.includes('/seasons/0');
labelsCallback = isSeasonChart ? (e) => `${e.seasonNum}x${String(e.episodeNum).padStart(2, '0')} ${e.watched ? '\u2714' : '\u2718'}` : (e) => `S. ${e.seasonNum} ${e.watched ? (e.watched == 100 ? '\u2714' : `(${e.watched}%)`) : '\u2718'}`;
chart = null;
datasetsData = [];
firstRunDelay = true;
if (!isSeasonChart && +$('.season-count').text().split(' ')[0] < 4 ||
location.pathname.includes('/alternate/') && location.pathname.split('/').filter(Boolean).length < 6) return;
$grid.on('arrangeComplete', () => {
if ($grid.data('isotope')) {
if (!chart) initializeChart();
else updateChart();
}
});
$(document).off('ajaxSuccess.userscript48372').on('ajaxSuccess.userscript48372', (_evt, _xhr, opt) => {
if (opt.url.includes('/rate') && chart) updateChart();
});
}, { capture: true });
function initializeChart() {
const canvas = $('
').insertBefore($grid).children()[0];
chart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: getChartData(),
options: getChartOptions(),
});
const intObs = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
intObs.disconnect();
if (!document.hidden) updateChart();
else $(document).one('visibilitychange', updateChart);
};
});
}, { threshold: 1.0 });
intObs.observe(canvas);
canvas.addEventListener('click', (event) => { // TODO integrate into chart options
const points = chart.getElementsAtEventForMode(event, 'nearest', { axis: 'x', intersect: false }, true);
if (!points.length) return;
const closestPoint = points.sort((p1, p2) => Math.abs(p1.element.y - event.layerY) - Math.abs(p2.element.y - event.layerY))[0];
if (Math.abs(closestPoint.element.y - event.layerY) < 10) {
const url = `${datasetsData[closestPoint.index].urlFull}${closestPoint.datasetIndex === 3 ? '/comments' : ''}`;
GM_openInTab(url, { active: true });
} else {
if (chart.isZoomedOrPanned()) {
chart.resetZoom('active');
}
}
});
}
async function updateChart() {
const newDatasetsData = await getDatasetsData();
if (JSON.stringify(datasetsData) !== JSON.stringify(newDatasetsData)) {
datasetsData = newDatasetsData;
chart.data = getChartData();
chart.options = getChartOptions();
chart.update();
if (firstRunDelay) firstRunDelay = false;
}
}
function getDatasetsData() {
const datasetsData = $grid.data('isotope').filteredItems.filter((i) => filterSpecials ? i.element.dataset.seasonNumber !== '0' : true).map(async (i) => {
const itemData = {
generalRating: i.sortData.percentage,
votes: i.sortData.votes,
watchers: i.sortData.watchers,
episodeNum: i.element.dataset.number || null,
seasonNum: i.element.dataset.seasonNumber,
urlFull: $(i.element).find('meta[itemprop="url"]').attr('content'),
personalRating: $(i.element).find('.corner-rating > .text').text() || null,
watched: $(i.element).find('a.watch.selected').attr('data-percentage') ?? 0,
releaseDate: $(i.element).find('.percentage').attr('data-earliest-release-date'),
};
if (isSeasonChart) {
itemData.mainTitle = $(i.element).find('.under-info .main-title').text();
itemData.comments = $(i.element).find('.episode-stats > a[data-original-title="Comments"]').text() || 0;
} else {
itemData.mainTitle = $(i.element).find('div[data-type="season"] .titles-link h3').text();
if (traktApiWrapper) { // TODO
itemData.comments = (await traktApiWrapper.seasons.comments({ id: i.element.dataset.showId, season: itemData.seasonNum, limit: 1 })).item_count;;
} else {
const resp = await fetch(i.element.dataset.url + '.json');
if (!resp.ok) throw new Error(`XHR for: ${resp.url} failed with status: ${resp.status}`);
itemData.comments = (await resp.json()).stats.comment_count;
}
}
return itemData;
});
return Promise.all(isSeasonChart ? datasetsData : datasetsData.reverse());
}
function getGradientY(context, callerID, yAxisID, ...colors) {
if (!context) return colors.pop().color;
const {ctx, chartArea, scales} = context.chart;
if (!chartArea) return;
ctx[callerID] ??= { };
if (!ctx[callerID].gradient || ctx[callerID].height !== chartArea.height ||
ctx[callerID].yAxisMin !== scales[yAxisID].min || ctx[callerID].yAxisMax !== scales[yAxisID].max) {
ctx[callerID].height = chartArea.height;
ctx[callerID].yAxisMin = scales[yAxisID].min;
ctx[callerID].yAxisMax = scales[yAxisID].max;
let newBottom = scales[yAxisID].max - scales[yAxisID].min;
newBottom = newBottom ? scales[yAxisID].max / newBottom : 1;
newBottom = chartArea.bottom * newBottom;
ctx[callerID].gradient = ctx.createLinearGradient(0, newBottom, 0, chartArea.top);
colors.forEach((c) => ctx[callerID].gradient.addColorStop(c.offset, c.color));
}
return ctx[callerID].gradient;
}
function getChartData() {
return {
labels: datasetsData.map(labelsCallback),
datasets: [
{
label: 'Personal Rating',
data: datasetsData.map((e) => e.personalRating ? e.personalRating * 10 : null),
yAxisID: 'yAxisRating',
borderColor: (context) => getGradientY(context, '_ratingPersonal', 'yAxisRating',
{ offset: 0, color: 'rgb(97 97 97 / 0.6)' },
{ offset: 0.1, color: 'rgb(97 97 97 / 0.6)' },
{ offset: 1, color: 'rgb(175 2 0)' }),
backgroundColor: (context) => getGradientY(context, '_ratingPersonal', 'yAxisRating',
{ offset: 0, color: 'rgb(97 97 97 / 0.6)' },
{ offset: 0.1, color: 'rgb(97 97 97 / 0.6)' },
{ offset: 1, color: 'rgb(175 2 0)' }),
},
{
label: 'General Rating',
data: datasetsData.map((e) => e.generalRating),
yAxisID: 'yAxisRating',
fill: {
target: '-1',
above: `rgb(255 0 0 / ${$('body').hasClass('dark-knight') ? 0.15 : 0.3})`,
below: `rgb(0 255 0 / ${$('body').hasClass('dark-knight') ? 0.15 : 0.3})`,
},
borderColor: (context) => getGradientY(context, '_ratingGeneral', 'yAxisRating',
{ offset: 0, color: 'rgb(97 97 97 / 0.6)' },
{ offset: 0.1, color: 'rgb(97 97 97 / 0.6)' },
{ offset: 1, color: 'rgb(225 31 117)' }),
backgroundColor: (context) => getGradientY(context, '_ratingGeneral', 'yAxisRating',
{ offset: 0, color: 'rgb(97 97 97 / 0.6)' },
{ offset: 0.1, color: 'rgb(97 97 97 / 0.6)' },
{ offset: 1, color: 'rgb(225 31 117)' }),
},
{
label: 'Watchers',
data: datasetsData.map((e) => e.watchers),
yAxisID: 'yAxisWatchers',
borderColor: (context) => getGradientY(context, '_watchers', 'yAxisWatchers',
{ offset: 0, color: 'rgb(154 67 201 / 0.2)' },
{ offset: 1, color: 'rgb(154 67 201)' }),
backgroundColor: (context) => getGradientY(context, '_watchers', 'yAxisWatchers',
{ offset: 0, color: 'rgb(154 67 201 / 0.2)' },
{ offset: 1, color: 'rgb(154 67 201)' }),
},
{
label: 'Comments',
data: datasetsData.map((e) => e.comments),
yAxisID: 'yAxisComments',
borderColor: (context) => getGradientY(context, '_comments', 'yAxisComments',
{ offset: 0, color: 'rgb(54 157 226 / 0.2)' },
{ offset: 1, color: 'rgb(54 157 226)' }),
backgroundColor: (context) => getGradientY(context, '_comments', 'yAxisComments',
{ offset: 0, color: 'rgb(54 157 226 / 0.2)' },
{ offset: 1, color: 'rgb(54 157 226)' }),
},
],
};
}
function getChartOptions() {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
animation: {
delay: (context) => (context.type === 'data' && context.mode === 'default') ?
(firstRunDelay ? 500 : 0) + context.dataIndex * (750 / Math.max(datasetsData.length - 1, 1)) + context.datasetIndex * 100 : 0,
},
scales: {
x: {
offset: true,
},
yAxisRating: {
type: 'linear',
position: 'left',
offset: true,
suggestedMin: 60,
max: 100,
title: {
display: true,
text: 'Rating',
},
grid: {
color: (context) => !(context.tick.value % 10) ? 'rgb(55 55 55)' : Chart.defaults.borderColor,
},
ticks: {
callback: (tickValue) => `${tickValue}%`,
},
},
yAxisWatchers: {
type: 'linear',
position: 'right',
offset: true,
min: 0,
suggestedMax: 10,
title: {
display: true,
text: 'Watchers',
},
grid: {
drawOnChartArea: false,
},
ticks: {
callback: (tickValue) => numFormatCompact.formatTLC(tickValue),
}
},
yAxisComments: {
type: 'linear',
position: 'right',
offset: true,
min: 0,
suggestedMax: 10,
title: {
display: true,
text: 'Comments',
},
grid: {
drawOnChartArea: false,
},
},
},
plugins: {
tooltip: {
usePointStyle: true,
boxPadding: 5,
backgroundColor: 'rgb(0 0 0 / 0.5)',
caretSize: 10,
padding: {
x: 18,
y: 6,
},
titleFont: {
size: 13,
weight: 'bold',
},
callbacks: {
title: (tooltipItems) => {
let mainTitle = datasetsData[tooltipItems[0].parsed.x].mainTitle;
mainTitle = mainTitle.length > 20 ? mainTitle.slice(0, 20).trim() + '...' : mainTitle;
return `${tooltipItems[0].label}${mainTitle ? `\n${mainTitle}` : ''}`;
},
label: (tooltipItem) => {
const x = tooltipItem.parsed.x,
y = tooltipItem.parsed.y,
avgRatings = unsafeWindow.userscriptAvgSeasonEpisodeRatings;
if (tooltipItem.datasetIndex === 0) {
return `${y / 10}` +
`${avgRatings?.personal?.average ? ` (avg: ${avgRatings.personal.average.toFixed(1)})` : ''}`;
} else if (tooltipItem.datasetIndex === 1) {
return `${y}% (${numFormatCompact.formatTLC(datasetsData[x].votes)} v.)` +
`${avgRatings?.general ? ` (avg: ${avgRatings.general.average ? Math.round(avgRatings.general.average) : '--'}%)` : ''}`;
} else if (tooltipItem.datasetIndex === 2) {
return `${numFormatCompact.formatTLC(y)}${datasetsData[0].watchers ? ` (${Math.round(y * 100 / datasetsData[0].watchers)}%)`: ''}`;
} else {
return `${y}`;
}
},
labelColor: (tooltipItem) => {
return {
borderColor: tooltipItem.dataset.borderColor(),
backgroundColor: tooltipItem.dataset.backgroundColor(),
};
},
footer: (tooltipItems) => {
const releaseDate = datasetsData[tooltipItems[0].parsed.x].releaseDate;
return releaseDate ? unsafeWindow.formatDate?.(releaseDate) || releaseDate : undefined;
},
},
},
legend: {
labels: {
usePointStyle: true,
filter: (legendItem, chartData) => chartData.datasets[legendItem.datasetIndex].data.some((v) => v !== null),
},
},
filler: {
propagate: false,
},
zoom: {
zoom: {
mode: 'x',
drag: {
enabled: true,
threshold: 0,
},
},
limits: {
x: {
minRange: 8,
},
},
},
},
};
}
function addStyles() {
GM_addStyle(`
#seasons-episodes-chart-wrapper {
position: relative;
margin-top: 20px;
width: 100%;
height: 250px;
}
@media (width <= 767px) {
#seasons-episodes-chart-wrapper {
margin-left: -10px;
margin-right: -10px;
width: calc(100% + 20px);
}
}
@media (991px < width) {
#seasons-episodes-chart-wrapper {
height: 300px;
}
}
`);
}
})('Trakt.tv | Charts - Seasons');
gmStorage['f785bub0'] && (async (moduleName) => {
/* global moduleName */
'use strict';
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 gmStorage = { logApiRequests: false, apiUrl: 'https://api.trakt.tv', app: {}, auth: {}, ...(GM_getValue('traktApiWrapper')) };
GM_setValue('traktApiWrapper', gmStorage);
const userslug = document.cookie.match(/(?:^|; )trakt_userslug=([^;]*)/)?.[1];
let authedNonGetReqChain = Promise.resolve(),
activeFetchApp = Promise.resolve(),
activeFetchAuth = Promise.resolve(),
methods;
if (userslug) unsafeWindow.userscriptTraktApiWrapper = methods = buildMethods(getRawMethods());
///////////////////////////////////////////////////////////////////////////////////////////////
async function callMethod(args) {
const groupedArgs = Object.groupBy(Object.entries(args), ([key]) => key.startsWith('_') ? 'opts' : 'params'),
opts = Object.fromEntries(groupedArgs.opts ?? []),
params = Object.fromEntries(groupedArgs.params ?? []);
Object.assign(gmStorage, GM_getValue('traktApiWrapper'));
const req = {
method: opts._method,
...(opts._revalidate != null && { revalidate: Boolean(opts._revalidate) }),
responseType: 'json',
headers: {
'content-type': 'application/json',
'user-agent': 'TraktApiWrapper/v1',
'trakt-api-version': '2',
},
};
req.url = gmStorage.apiUrl + opts._path.replaceAll(/:(\?)?(\w+)/g, (_m, isOptional, key) => {
if (params[key] != null) { delete params[key]; return args[key]; }
if (isOptional) return '';
throw new Error(`Missing mandatory path parameter: ${key}`);
});
if (/GET|DELETE/.test(opts._method)) req.url = req.url + (Object.keys(params).length ? `?${new URLSearchParams(params)}` : '');
else if (/POST|PUT/.test(opts._method)) req.data = JSON.stringify(params);
await activeFetchApp;
if (!gmStorage.app.clientId || !gmStorage.app.clientSecret || !gmStorage.app.redirectUri) {
activeFetchApp = fetchAppClientCreds();
await activeFetchApp;
}
req.headers['trakt-api-key'] = gmStorage.app.clientId;
if (opts._auth) {
await activeFetchAuth;
if (!gmStorage.auth.accessToken || !gmStorage.auth.expiresAt ||
gmStorage.auth.expiresAt < Date.now() + 5*60*1000 ||
gmStorage.auth.userslug !== userslug) {
activeFetchAuth = fetchAuthTokens();
await activeFetchAuth;
}
req.headers.authorization = `Bearer ${gmStorage.auth.accessToken}`;
}
if (opts._auth && opts._method !== 'GET') {
const rateLimitDelay = 1100; // +100ms for network jitter; though the rate limit doesn't actually seem to be enforced
return new Promise((resolveCaller) => {
authedNonGetReqChain = authedNonGetReqChain.then(() => {
const reqPromise = sendApiRequest(req, { _retry: { limit: 5, req_delay: rateLimitDelay, resp_delay: rateLimitDelay }, ...opts });
resolveCaller(reqPromise);
return new Promise((resolveChain) => {
setTimeout(() => reqPromise.finally(resolveChain), rateLimitDelay);
});
});
});
} else {
return sendApiRequest(req, opts);
}
}
function sendApiRequest(req, opts) {
return GM.xmlHttpRequest(req).then((resp) => {
if (gmStorage.logApiRequests) logger.info(`${req.method}: ${req.url}`, { toast: false, data: { req, resp } });
resp.parsedTraktHeaders = parseTraktHeaders(resp.responseHeaders);
if (resp.status >= 200 && resp.status < 300) {
return opts._meta ? { data: resp.response, meta: resp.parsedTraktHeaders } : resp.response;
}
if (resp.status === 429 && opts._retry?.limit) {
const newOpts = { ...opts, _retry: { limit: opts._retry.limit - 1, req_delay: opts._retry.req_delay * 2 } }
return new Promise((resolve) => setTimeout(() => resolve(sendApiRequest(req, newOpts)), opts._retry.req_delay))
.then((resp) => new Promise((resolve) => setTimeout(() => resolve(resp), opts._retry.resp_delay)));
}
if (resp.status === 401 && !resp.parsedTraktHeaders.private_user) {
logger.warning('Auth tokens might be invalid and have been cleared.', { data: gmStorage.auth });
gmStorage.auth = {};
GM_setValue('traktApiWrapper', gmStorage);
}
if (resp.status === 403) {
logger.warning('Client credentials might be invalid and have been cleared.', { data: gmStorage });
gmStorage.app = { id: gmStorage.app.id };
gmStorage.auth = {};
GM_setValue('traktApiWrapper', gmStorage);
}
throw resp;
});
}
function parseTraktHeaders(headers) {
const normalizedHeaderEntries = headers.split(/\r?\n/).map((header) => header.split(':')).map(([key, val]) => [key.trim().toLowerCase(), val?.trim()]),
traktHeaderKeys = normalizedHeaderEntries.find(([key]) => key === 'access-control-expose-headers')[1].toLowerCase().split(',');
return Object.fromEntries(
normalizedHeaderEntries
.filter(([key]) => traktHeaderKeys.includes(key))
.map(([key, val]) => {
return [(key.startsWith('x-') ? key.slice(2) : key).replaceAll('-', '_'),
val === 'true' ? true : val === 'false' ? false : /^-?\d*\.?\d+$/.test(val) ? +val : val];
})
);
}
///////////////////////////////////////////////////////////////////////////////////////////////
async function fetchAppClientCreds() {
try {
logger.info('No valid client credentials found. Checking for matching application..');
const appName = 'Trakt API Wrapper for Userscripts';
let appDoc;
const appsDoc = await fetch('/oauth/applications').then((r) => r.text()).then((r) => new DOMParser().parseFromString(r, 'text/html'));
const appEl = [...appsDoc.querySelectorAll('#authorized-applications > .row:has(.label-success) h4 a[href^="/oauth/applications/"]')].find((el) => {
const hasMatchingId = gmStorage.app.id ? el.getAttribute('href').endsWith(gmStorage.app.id) : false,
hasMatchingName = el.textContent.trim().toLowerCase() === appName.toLowerCase();
return hasMatchingId || hasMatchingName;
});
if (appEl) appDoc = await fetch(appEl.getAttribute('href')).then((r) => r.text()).then((r) => new DOMParser().parseFromString(r, 'text/html'));
if (!appDoc) {
const formData = new FormData();
[
['authenticity_token', appsDoc.querySelector('head meta[name="csrf-token"]').content],
['doorkeeper_application[name]', appName],
['doorkeeper_application[description]', 'A userscript which provides authenticated Trakt API access to other userscripts. https://github.com/Fenn3c401/Trakt.tv-Userscript-Collection'],
['doorkeeper_application[redirect_uri]', 'https://trakt.tv/dashboard'],
['doorkeeper_application[origins]', 'https://trakt.tv'],
['doorkeeper_application[icon]', new Blob([], { type: 'application/octet-stream' }), ''],
['doorkeeper_application[checkin]', '0'],
['doorkeeper_application[checkin]', '1'],
['doorkeeper_application[scrobble]', '0'],
['doorkeeper_application[scrobble]', '1'],
['commit', 'Save App'],
].forEach((args) => formData.append(...args));
const resp = await GM.xmlHttpRequest({ url: '/oauth/applications', method: 'POST', data: formData, headers: { referer: 'https://trakt.tv/oauth/applications/new' } });
if (resp.status >= 200 && resp.status < 300) {
appDoc = new DOMParser().parseFromString(resp.responseText, 'text/html');
logger.info(`No matching application found. New Trakt API application has been created. ${resp.finalUrl} `);
}
}
GM_setValue('traktApiWrapper', Object.assign(gmStorage, {
app: {
clientId: appDoc.querySelector('#authorized-applications .credentials:nth-child(1 of .credentials) code').textContent,
clientSecret: appDoc.querySelector('#authorized-applications .credentials:nth-child(2 of .credentials) code').textContent,
redirectUri: appDoc.querySelector('.redirect-uris code').textContent,
id: +appDoc.querySelector('.btn.update[href$="edit"]').getAttribute('href').match(/\d+/)[0],
},
}));
logger.success('Successfully fetched client credentials!', { data: gmStorage.app });
} catch (err) {
logger.error('Failed to fetch client credentials!');
throw err;
}
}
async function fetchAuthTokens() {
try {
const isAuthorization = !gmStorage.auth.refreshToken || gmStorage.auth.userslug !== userslug;
let authCode;
if (isAuthorization) {
logger.info('No valid refresh token found. Authorizing application..');
const authSearchParams = new URLSearchParams({ response_type: 'code', client_id: gmStorage.app.clientId, redirect_uri: gmStorage.app.redirectUri }),
authUrl = `${gmStorage.apiUrl.replace(/api[.-]/, '')}/oauth/authorize?${authSearchParams}`,
authDoc = await fetch(authUrl).then((r) => r.text()).then((r) => new DOMParser().parseFromString(r, 'text/html'));
const resp = await GM.xmlHttpRequest({
method: 'POST',
url: '/oauth/authorize',
headers: { referer: authUrl },
data: new URLSearchParams([
['authenticity_token', authDoc.querySelector('head meta[name="csrf-token"]').content],
['client_id', gmStorage.app.clientId],
['redirect_uri', gmStorage.app.redirectUri],
['state', ''],
['response_type', 'code'],
['response_mode', 'query'],
['scope', 'public'],
['nonce', ''],
['code_challenge', ''],
['code_challenge_method', ''],
['commit', 'Yes'],
]),
});
if (resp.status >= 200 && resp.status < 300) {
authCode = new URL(resp.finalUrl).searchParams.get('code');
logger.success('Application successfully authorized!', { data: { code: authCode } });
} else {
gmStorage.app = { id: gmStorage.app.id };
GM_setValue('traktApiWrapper', gmStorage);
logger.warning('Client credentials might be invalid and have been cleared.');
}
}
const resp = await methods.oauth.get({
client_id: gmStorage.app.clientId,
client_secret: gmStorage.app.clientSecret,
redirect_uri: gmStorage.app.redirectUri,
grant_type: isAuthorization ? 'authorization_code' : 'refresh_token',
...(isAuthorization ? { code: authCode } : { refresh_token: gmStorage.auth.refreshToken }),
});
GM_setValue('traktApiWrapper', Object.assign(gmStorage, {
auth: {
accessToken: resp.access_token,
expiresAt: (resp.created_at + resp.expires_in) * 1000,
refreshToken: resp.refresh_token,
userslug: userslug,
},
}));
} catch (err) {
logger.error('Failed to fetch authentication tokens!');
throw err;
}
}
///////////////////////////////////////////////////////////////////////////////////////////////
function buildMethods(obj, pathPrefix) {
obj._path = (pathPrefix ?? '') + (obj._path ?? '');
if (obj._method) return (args) => callMethod({ ...obj, ...args });
else {
for (const key in obj) if (!key.startsWith('_')) obj[key] = buildMethods(obj[key], obj._path);
delete obj._path;
return obj;
}
}
function getRawMethods() {
return {
oauth: {
_path: '/oauth',
get: { _method: 'POST', _path: '/token' },
revoke: { _method: 'POST', _path: '/revoke' },
},
calendars: {
_path: '/calendars',
my: {
_path: '/my',
shows: { _method: 'GET', _path: '/shows/:?start_date/:?days', _auth: true },
new_shows: { _method: 'GET', _path: '/shows/new/:?start_date/:?days', _auth: true },
season_premieres: { _method: 'GET', _path: '/shows/premieres/:?start_date/:?days', _auth: true },
finales: { _method: 'GET', _path: '/shows/finales/:?start_date/:?days', _auth: true },
movies: { _method: 'GET', _path: '/movies/:?start_date/:?days', _auth: true },
streaming: { _method: 'GET', _path: '/streaming/:?start_date/:?days', _auth: true },
dvd: { _method: 'GET', _path: '/dvd/:?start_date/:?days', _auth: true },
},
all: {
_path: '/all',
shows: { _method: 'GET', _path: '/shows/:?start_date/:?days' },
new_shows: { _method: 'GET', _path: '/shows/new/:?start_date/:?days' },
season_premieres: { _method: 'GET', _path: '/shows/premieres/:?start_date/:?days' },
finales: { _method: 'GET', _path: '/shows/finales/:?start_date/:?days' },
movies: { _method: 'GET', _path: '/movies/:?start_date/:?days' },
streaming: { _method: 'GET', _path: '/streaming/:?start_date/:?days' },
dvd: { _method: 'GET', _path: '/dvd/:?start_date/:?days' },
},
},
checkin: {
_path: '/checkin',
add: { _method: 'POST', _auth: true },
remove: { _method: 'DELETE', _auth: true },
},
certifications: { _method: 'GET', _path: '/certifications/:type' },
comments: {
_path: '/comments',
comment: {
add: { _method: 'POST', _auth: true },
get: { _method: 'GET', _path: '/:id' },
update: { _method: 'PUT', _path: '/:id', _auth: true },
remove: { _method: 'DELETE', _path: '/:id', _auth: true },
},
replies: {
_path: '/:id/replies',
get: { _method: 'GET' },
add: { _method: 'POST', _auth: true },
},
item: { _method: 'GET', _path: '/:id/item' },
likes: {
_path: '/:id',
get: { _method: 'GET', _path: '/likes' },
add: { _method: 'POST', _path: '/like', _auth: true },
remove: { _method: 'DELETE', _path: '/like', _auth: true },
},
trending: { _method: 'GET', _path: '/trending/:?comment_type/:?type' },
recent: { _method: 'GET', _path: '/recent/:?comment_type/:?type' },
updates: { _method: 'GET', _path: '/updates/:?comment_type/:?type' },
},
countries: { _method: 'GET', _path: '/countries/:type' },
genres: { _method: 'GET', _path: '/genres/:type', },
languages: { _method: 'GET', _path: '/languages/:type' },
lists: {
_path: '/lists',
trending: { _method: 'GET', _path: '/trending/:?type' },
popular: { _method: 'GET', _path: '/popular/:?type' },
list: {
_path: '/:id',
get: { _method: 'GET' },
items: { _method: 'GET', _path: '/items/:?type/:?sort_by/:?sort_how' },
comments: { _method: 'GET', _path: '/comments/:?sort' },
likes: {
get: { _method: 'GET', _path: '/likes' },
add: { _method: 'POST', _path: '/like', _auth: true },
remove: { _method: 'DELETE', _path: '/like', _auth: true },
},
},
},
movies: {
_path: '/movies',
trending: { _method: 'GET', _path: '/trending' },
popular: { _method: 'GET', _path: '/popular' },
favorited: { _method: 'GET', _path: '/favorited/:?period' },
played: { _method: 'GET', _path: '/played/:?period' },
watched: { _method: 'GET', _path: '/watched/:?period' },
collected: { _method: 'GET', _path: '/collected/:?period' },
anticipated: { _method: 'GET', _path: '/anticipated' },
boxoffice: { _method: 'GET', _path: '/boxoffice' },
updates: { _method: 'GET', _path: '/updates/:?start_date' },
updated_ids: { _method: 'GET', _path: '/updates/id/:?start_date' },
summary: { _method: 'GET', _path: '/:id' },
aliases: { _method: 'GET', _path: '/:id/aliases' },
releases: { _method: 'GET', _path: '/:id/releases/:?country' },
translations: { _method: 'GET', _path: '/:id/translations/:?language' },
comments: { _method: 'GET', _path: '/:id/comments/:?sort' },
lists: { _method: 'GET', _path: '/:id/lists/:?type/:?sort' },
people: { _method: 'GET', _path: '/:id/people' },
ratings: { _method: 'GET', _path: '/:id/ratings' },
related: { _method: 'GET', _path: '/:id/related' },
stats: { _method: 'GET', _path: '/:id/stats' },
studios: { _method: 'GET', _path: '/:id/studios' },
watching: { _method: 'GET', _path: '/:id/watching' },
videos: { _method: 'GET', _path: '/:id/videos' },
refresh: { _method: 'POST', _path: '/:id/refresh', _auth: true },
},
networks: { _method: 'GET', _path: '/networks' },
notes: {
_path: '/notes',
add: { _method: 'POST', _auth: true },
get: { _method: 'GET', _path: '/:id', _auth: true },
update: { _method: 'PUT', _path: '/:id', _auth: true },
remove: { _method: 'DELETE', _path: '/:id', _auth: true },
item: { _method: 'GET', _path: '/:id/item' },
},
people: {
_path: '/people',
updates: { _method: 'GET', _path: '/updates/:?start_date' },
updated_ids: { _method: 'GET', _path: '/updates/id/:?start_date' },
summary: { _method: 'GET', _path: '/:id' },
movies: { _method: 'GET', _path: '/:id/movies' },
shows: { _method: 'GET', _path: '/:id/shows' },
lists: { _method: 'GET', _path: '/:id/lists/:?type/:?sort' },
refresh: { _method: 'POST', _path: '/:id/refresh', _auth: true },
},
recommendations: {
_path: '/recommendations',
movies: {
_path: '/movies',
get: { _method: 'GET', _auth: true },
hide: { _method: 'DELETE', _path: '/:id', _auth: true },
},
shows: {
_path: '/shows',
get: { _method: 'GET', _auth: true },
hide: { _method: 'DELETE', _path: '/:id', _auth: true },
},
},
scrobble: {
_path: '/scrobble',
start: { _method: 'POST', _path: '/start', _auth: true },
stop: { _method: 'POST', _path: '/stop', _auth: true },
},
search: {
_path: '/search',
text: { _method: 'GET', _path: '/:type' },
id: { _method: 'GET', _path: '/:id_type/:id' },
},
shows: {
_path: '/shows',
trending: { _method: 'GET', _path: '/trending' },
popular: { _method: 'GET', _path: '/popular' },
favorited: { _method: 'GET', _path: '/favorited/:?period' },
played: { _method: 'GET', _path: '/played/:?period' },
watched: { _method: 'GET', _path: '/watched/:?period' },
collected: { _method: 'GET', _path: '/collected/:?period' },
anticipated: { _method: 'GET', _path: '/anticipated' },
updates: { _method: 'GET', _path: '/updates/:?start_date' },
updated_ids: { _method: 'GET', _path: '/updates/id/:?start_date' },
summary: { _method: 'GET', _path: '/:id' },
aliases: { _method: 'GET', _path: '/:id/aliases' },
certifications: { _method: 'GET', _path: '/:id/certifications' },
translations: { _method: 'GET', _path: '/:id/translations/:?language' },
comments: { _method: 'GET', _path: '/:id/comments/:?sort' },
lists: { _method: 'GET', _path: '/:id/lists/:?type/:?sort' },
progress: {
_path: '/:id/progress',
collection: { _method: 'GET', _path: '/collection', _auth: true },
watched: { _method: 'GET', _path: '/watched', _auth: true },
reset: { _method: 'POST', _path: '/watched/reset', _auth: true },
undo_reset: { _method: 'DELETE', _path: '/watched/reset', _auth: true },
},
people: { _method: 'GET', _path: '/:id/people' },
ratings: { _method: 'GET', _path: '/:id/ratings' },
related: { _method: 'GET', _path: '/:id/related' },
stats: { _method: 'GET', _path: '/:id/stats' },
studios: { _method: 'GET', _path: '/:id/studios' },
watching: { _method: 'GET', _path: '/:id/watching' },
next_episode: { _method: 'GET', _path: '/:id/next_episode' },
last_episode: { _method: 'GET', _path: '/:id/last_episode' },
videos: { _method: 'GET', _path: '/:id/videos' },
refresh: { _method: 'POST', _path: '/:id/refresh', _auth: true },
},
seasons: {
_path: '/shows/:id/seasons',
summary: { _method: 'GET' },
season: { _method: 'GET', _path: '/:season/info' },
episodes: { _method: 'GET', _path: '/:season' },
translations: { _method: 'GET', _path: '/:season/translations/:?language' },
comments: { _method: 'GET', _path: '/:season/comments/:?sort' },
lists: { _method: 'GET', _path: '/:season/lists/:?type/:?sort' },
people: { _method: 'GET', _path: '/:season/people' },
ratings: { _method: 'GET', _path: '/:season/ratings' },
stats: { _method: 'GET', _path: '/:season/stats' },
watching: { _method: 'GET', _path: '/:season/watching' },
videos: { _method: 'GET', _path: '/:season/videos' },
},
episodes: {
_path: '/shows/:id/seasons/:season/episodes/:episode',
summary: { _method: 'GET' },
translations: { _method: 'GET', _path: '/translations/:?language' },
comments: { _method: 'GET', _path: '/comments/:?sort' },
lists: { _method: 'GET', _path: '/lists/:?type/:?sort' },
people: { _method: 'GET', _path: '/people' },
ratings: { _method: 'GET', _path: '/ratings' },
stats: { _method: 'GET', _path: '/stats' },
watching: { _method: 'GET', _path: '/watching' },
videos: { _method: 'GET', _path: '/videos' },
},
sync: {
_path: '/sync',
last_activities: { _method: 'GET', _path: '/last_activities', _auth: true },
playback: {
_path: '/playback',
get: { _method: 'GET', _path: '/:?type', _auth: true },
remove: { _method: 'DELETE', _path: '/:id', _auth: true },
},
collection: {
_path: '/collection',
get: { _method: 'GET', _path: '/:type', _auth: true },
add: { _method: 'POST', _auth: true },
remove: { _method: 'POST', _path: '/remove', _auth: true },
},
watched: { _method: 'GET', _path: '/watched/:type', _auth: true },
history: {
_path: '/history',
get: { _method: 'GET', _path: '/:?type/:?id', _auth: true },
add: { _method: 'POST', _auth: true },
remove: { _method: 'POST', _path: '/remove', _auth: true },
},
ratings: {
_path: '/ratings',
get: { _method: 'GET', _path: '/:?type/:?rating', _auth: true },
add: { _method: 'POST', _auth: true },
remove: { _method: 'POST', _path: '/remove', _auth: true },
},
watchlist: {
_path: '/watchlist',
get: { _method: 'GET', _path: '/:?type/:?sort_by/:?sort_how', _auth: true },
update: { _method: 'PUT', _auth: true },
add: { _method: 'POST', _auth: true },
remove: { _method: 'POST', _path: '/remove', _auth: true },
reorder: { _method: 'POST', _path: '/reorder', _auth: true },
update_item: { _method: 'PUT', _path: '/:list_item_id', _auth: true },
},
favorites: {
_path: '/favorites',
get: { _method: 'GET', _path: '/:?type/:?sort_by/:?sort_how', _auth: true },
update: { _method: 'PUT', _auth: true },
add: { _method: 'POST', _auth: true },
remove: { _method: 'POST', _path: '/remove', _auth: true },
reorder: { _method: 'POST', _path: '/reorder', _auth: true },
update_item: { _method: 'PUT', _path: '/:list_item_id', _auth: true },
},
},
users: {
_path: '/users',
settings: { _method: 'GET', _path: '/settings', _auth: true },
requests: {
_path: '/requests',
following: { _method: 'GET', _path: '/following', _auth: true },
get: { _method: 'GET', _auth: true },
approve: { _method: 'POST', _path: '/:id', _auth: true },
deny: { _method: 'DELETE', _path: '/:id', _auth: true },
},
saved_filters: { _method: 'GET', _path: '/saved_filters/:?section', _auth: true },
hidden: {
_path: '/hidden',
get: { _method: 'GET', _path: '/:section', _auth: true },
add: { _method: 'POST', _path: '/:section', _auth: true },
remove: { _method: 'POST', _path: '/:section/remove', _auth: true },
},
profile: { _method: 'GET', _path: '/:id' },
likes: { _method: 'GET', _path: '/:id/likes/:?type' },
collection: { _method: 'GET', _path: '/:id/collection/:type' },
comments: { _method: 'GET', _path: '/:id/comments/:?comment_type/:?type' },
notes: { _method: 'GET', _path: '/:id/notes/:?type' },
lists: {
_path: '/:id/lists',
get: { _method: 'GET' },
create: { _method: 'POST', _auth: true },
reorder: { _method: 'POST', _path: '/reorder', _auth: true },
collaborations: { _method: 'GET', _path: '/collaborations' },
},
list: {
_path: '/:id/lists/:list_id',
get: { _method: 'GET' },
update: { _method: 'PUT', _auth: true },
delete: { _method: 'DELETE', _auth: true },
likes: {
get: { _method: 'GET', _path: '/likes' },
add: { _method: 'POST', _path: '/like', _auth: true },
remove: { _method: 'DELETE', _path: '/like', _auth: true },
},
items: {
_path: '/items',
get: { _method: 'GET', _path: '/:?type/:?sort_by/:?sort_how' },
add: { _method: 'POST', _auth: true },
remove: { _method: 'POST', _path: '/remove', _auth: true },
reorder: { _method: 'POST', _path: '/reorder', _auth: true },
update: { _method: 'PUT', _path: '/:list_item_id', _auth: true },
},
comments: { _method: 'GET', _path: '/comments/:?sort' },
},
follow: {
_path: '/:id/follow',
add: { _method: 'POST', _auth: true },
remove: { _method: 'DELETE', _auth: true },
},
followers: { _method: 'GET', _path: '/:id/followers' },
following: { _method: 'GET', _path: '/:id/following' },
friends: { _method: 'GET', _path: '/:id/friends' },
history: { _method: 'GET', _path: '/:id/history/:?type/:?item_id' },
ratings: { _method: 'GET', _path: '/:id/ratings/:?type/:?rating' },
watchlist: { _method: 'GET', _path: '/:id/watchlist/:?type/:?sort_by/:?sort_how' },
watchlist_comments: { _method: 'GET', _path: '/:id/watchlist/comments/:?sort' },
favorites: { _method: 'GET', _path: '/:id/favorites/:?type/:?sort_by/:?sort_how', _auth: true },
favorites_comments: { _method: 'GET', _path: '/:id/favorites/comments/:?sort' },
watching: { _method: 'GET', _path: '/:id/watching' },
watched: { _method: 'GET', _path: '/:id/watched/:type' },
stats: { _method: 'GET', _path: '/:id/stats' },
},
};
}
})('Trakt.tv | Trakt API Wrapper');
gmStorage['fyk2l3vj'] && (async (moduleName) => {
/* global moduleName */
'use strict';
let $, 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 gmStorage = { ...(GM_getValue('enhancedTitleMetadata')) };
GM_setValue('enhancedTitleMetadata', gmStorage);
addStyles();
document.addEventListener('turbo:load', async () => {
if (!/^\/(shows|movies)\//.test(location.pathname)) return;
$ ??= unsafeWindow.jQuery;
traktApiWrapper ??= unsafeWindow.userscriptTraktApiWrapper;
if (!$) return;
const $additionalStatsLi = $('#overview .additional-stats > li'),
pathSplit = location.pathname.split('/').filter(Boolean);
if (!$additionalStatsLi.length) return;
// YEAR
const $year = $('#summary-wrapper .year');
if ($year.parent().is('a')) $year.insertAfter($year.parent()); // year is part of link to title summary page on e.g. /comments pages
$year.wrapAll(` `); // year range on /seasons/all
// CERTIFICATION
const $certification = $('#summary-wrapper div.certification');
$certification.wrap(` `);
// WRITERS
const $writers = $additionalStatsLi.filter((_, e) => $(e).find('label').text().toLowerCase() === 'writers');
$writers.find('label').wrap(` `);
// GENRES
const $genres = $additionalStatsLi.filter(':has([itemprop="genre"])'),
matchingGenres = [];
$genres.find('[itemprop="genre"]').each((i, e) => {
matchingGenres[i] = $(e).text().toLowerCase().replaceAll(' ', '-');
$(e).wrap(` `);
});
if (matchingGenres.length > 1) $genres.find('label').wrap(` `);
// COUNTRY
const $country = $additionalStatsLi.filter((_, e) => $(e).find('label').text().toLowerCase() === 'country'); // countryOfOrigin + name meta tags are unreliable
let matchingCountry; // also used for networks and studios
if ($country.length) {
const allCountriesMap = await getAllCountriesMap(),
countryText = $country.contents().get(-1)?.textContent;
matchingCountry = allCountriesMap[countryText];
if (matchingCountry) {
// flags seem to only be available for countries that are also watch-now countries (~139)
const countryFlag = unsafeWindow.watchnowAllCountries?.[matchingCountry]?.image;
if (countryFlag) $country.children().last().after(` `);
$country.contents().filter((_, e) => !$(e).is('meta, label')).wrapAll(` `);
} else {
gmStorage.allCountriesMap = null;
GM_setValue('enhancedTitleMetadata', gmStorage);
logger.error(`Failed to match title country. Cached countries have been cleared. Reload page to try again.`);
}
}
///////////////////////////////////////////////////////////////////////////////////////////////
// LANGUAGES
const $languages = $additionalStatsLi.filter((_, e) => $(e).find('label').text().toLowerCase().startsWith('language')),
matchingLanguages = {}; // also used for networks and studios
if ($languages.length) {
const allLanugagesArrSorted = await getAllLanguagesArrSorted(),
allLanugagesMap = Object.fromEntries(allLanugagesArrSorted);
let languagesText = $languages.contents().get(-1).textContent;
allLanugagesArrSorted.forEach(([id, name], i) => {
const regExp = new RegExp(`${RegExp.escape(name)}(, |$)`);
if (regExp.test(languagesText)) {
matchingLanguages[languagesText.indexOf(name)] = id;
languagesText = languagesText.replace(regExp, (m) => ' '.repeat(m.length));
}
});
if (!languagesText.trim()) {
const matchingLanguagesIds = Object.values(matchingLanguages);
$languages.contents().last().replaceWith(
matchingLanguagesIds
.map((id) => `${allLanugagesMap[id]} `)
.join(', ')
);
if (matchingLanguagesIds.length > 1) $languages.find('label').wrap(` `);
} else {
gmStorage.allLanguagesArrSorted = null;
GM_setValue('enhancedTitleMetadata', gmStorage);
logger.error(`Failed to match all title languages (ORIGINAL: ${$languages.contents().get(-1).textContent} REMAINDER: ${languagesText.trim()}). ` +
`Cached languages have been cleared. Reload page to try again.`);
}
}
///////////////////////////////////////////////////////////////////////////////////////////////
// NETWORKS
const $networks = $additionalStatsLi.filter((_, e) => $(e).find('label').text().toLowerCase().startsWith('network')), // .stat class is unreliable
$networkAlt = $additionalStatsLi.filter((_, e) => /airs|aired|premiered/i.test($(e).find('label').text())).first(); // can have one network as suffix
if ($networks.length && pathSplit[3] !== 'all') { // network names on /seasons/all are invalid (memory addresses instead of names)
const matchingNetworks = {},
allNetworksArrSorted = await getAllNetworksArrSorted(),
allNetworksMap = Object.fromEntries(allNetworksArrSorted);
let networksText = $networks.contents().get(-1).textContent; // text is not sanitized and can contain tabs and stray spaces from network names
allNetworksArrSorted.forEach(([id, { name, countryId }], i) => {
const regExp = new RegExp(`${RegExp.escape(name)}(, |$)`);
if (regExp.test(networksText) && (
// !countryId || // TODO
countryId === matchingCountry || Object.hasOwn(matchingLanguages, countryId) ||
name !== allNetworksArrSorted[i+1]?.[1].name
)) {
matchingNetworks[networksText.indexOf(name)] = id;
networksText = networksText.replace(regExp, (m) => ' '.repeat(m.length));
}
});
if (!networksText.trim()) {
const matchingNetworksIds = Object.values(matchingNetworks);
$networks.contents().last().replaceWith(
matchingNetworksIds
.map((id) => `${allNetworksMap[id].name}${allNetworksMap[id].countryId ? ` (${allNetworksMap[id].countryId.toUpperCase()})` : ''} `)
.join('')
);
if (matchingNetworksIds.length > 1) {
$networks.find('label').wrap(` `);
$(` + ${matchingNetworksIds.length - 1} more `)
.insertAfter($networks.children().eq(1))
.nextAll().wrapAll(` `);
}
$networks.find('a:not(:has(label), [onclick])').slice(1).before(', '); // comma insertion done here because nextAll() doesn't support text nodes
} else {
gmStorage.allNetworksArrSorted = null;
GM_setValue('enhancedTitleMetadata', gmStorage);
logger.error(`Failed to match all title networks (ORIGINAL: ${$networks.contents().get(-1).textContent} REMAINDER: ${networksText.trim()}). ` +
`Cached networks have been cleared. Reload page to try again.`);
}
} else if ($networkAlt.text().includes(' on ') && pathSplit[3] !== 'all') {
const allNetworksArrSorted = await getAllNetworksArrSorted(),
networkText = $networkAlt.contents().last().text().split(' on ')[1];
const matchingNetwork = networkText ? allNetworksArrSorted.find(([id, { name, countryId }], i) =>
new RegExp(`${RegExp.escape(name)}(, |$)`).test(networkText) && (
// !countryId || // TODO
countryId === matchingCountry || Object.hasOwn(matchingLanguages, countryId) ||
name !== allNetworksArrSorted[i+1]?.[1].name
)
) : null;
if (matchingNetwork) {
$networkAlt.contents().last().remove();
$networkAlt.append(` on ${matchingNetwork[1].name}` +
`${matchingNetwork[1].countryId ? ` (${matchingNetwork[1].countryId.toUpperCase()})` : ''} `)
} else {
gmStorage.allNetworksArrSorted = null;
GM_setValue('enhancedTitleMetadata', gmStorage);
logger.error(`Failed to match title network (${networkText}). Cached networks have been cleared. Reload page to try again.`);
}
}
///////////////////////////////////////////////////////////////////////////////////////////////
// STUDIOS
const $studios = $additionalStatsLi.filter((_, e) => $(e).find('label').text().toLowerCase().startsWith('studio'));
if ($studios.length) {
if (traktApiWrapper) {
let hasRun = false;
const matchStudioFromElemContext = async function(evt) {
if (hasRun) return;
hasRun = true;
evt?.preventDefault();
unsafeWindow.showLoading?.();
const dataStudios = await traktApiWrapper[pathSplit[0]].studios({ id: $('.summary-user-rating').attr(`data-${pathSplit[0].slice(0, -1)}-id`) }), // has the same order as $studios
allStudioIdsJoined = dataStudios.map((dataStudio) => dataStudio.ids.trakt).join();
unsafeWindow.hideLoading?.();
if (evt) {
const url = `/search/${pathSplit[0]}?studio_ids=${$(this).find('label').length ? allStudioIdsJoined : dataStudios[0].ids.trakt}`;
if (evt.type === 'click') location.href = url;
else if (evt.originalEvent.button === 1) GM_openInTab(location.origin + url, { setParent: true });
}
$studios.children().eq(0).attr('href', `/search/${pathSplit[0]}?studio_ids=${allStudioIdsJoined}`);
$studios.children().eq(1).attr('href', `/search/${pathSplit[0]}?studio_ids=${dataStudios[0].ids.trakt}`);
$studios.find('.studios-more').html(dataStudios.slice(1).map((dataStudio) => `, ${dataStudio.name} `));
}
// wrap names with unresolved anchor tags to minimize api requests
$studios.find('label').wrap($(` `).one('click auxclick', matchStudioFromElemContext));
$studios.contents().eq(1).wrap($(` `).one('click auxclick', matchStudioFromElemContext));
$studios.find('.studios-expand').one('click', () => matchStudioFromElemContext());
} else {
const matchingStudios = new Set(),
$studiosMore = $studios.find('.studios-more'),
$studiosExpand = $studios.find('.studios-expand'),
studiosMoreSplit = $studiosMore.text().split(', ').slice(1),
studiosMoreCount = +$studiosExpand.text().match(/\d+/)?.[0] || null;
// use studio search endpoint from advanced filters modal (~250.000 studios total; several thousand studio names contain commas; returns max. of 1000 results per request sorted lexicographically)
const queryStudioNameMatches = (query) => {
return fetch('/autocomplete/studios?query=' + encodeURIComponent(query))
.then((r) => r.json())
.then((r) => Object.fromEntries(
r.map(({ label: name, value: studioId, tag: countryId }) => [name, +studioId, countryId?.toLowerCase() ?? null])
.sort(([nameA, studioIdA, countryIdA], [nameB, studioIdB, countryIdB]) => nameA === nameB
? (countryIdA && (countryIdA === matchingCountry || Object.hasOwn(matchingLanguages, countryIdA))) -
(countryIdB && (countryIdB === matchingCountry || Object.hasOwn(matchingLanguages, countryIdB))) ||
// (countryIdB && 1) - (countryIdA && 1) || // TODO
studioIdB - studioIdA // the lower the studio id, the more major the studio tends to be
: 0)
));
};
// executed from the context of an unresolved anchor tag (no lookup on page load to minimize api requests)
const matchStudioFromElemContext = async function(evt) {
evt?.preventDefault();
$(this).off();
unsafeWindow.showLoading?.();
const studioName = $(this).text(),
queryResult = await queryStudioNameMatches(studioName),
studioId = queryResult[studioName];
unsafeWindow.hideLoading?.();
if (studioId) {
matchingStudios.add(studioId);
const url = `/search/${pathSplit[0]}?studio_ids=${studioId}`;
if (evt) {
if (evt.type === 'click') location.href = url;
else if (evt.originalEvent.button === 1) GM_openInTab(location.origin + url, { setParent: true });
}
$(this).attr('href', url);
} else {
logger.error('Failed to match title studio: ' + studioName, { data: queryResult });
}
};
// algorithm to deal with getting ids for a list of studio names, separated by commas, which by themseves can contain commas:
// for split(', ') part at index i try to find longest possible match in part's result list by looking for results[parts(i)], then results[parts(i) + parts(i+1)] etc. longest match wins
const matchStudiosMoreSplit = async () => {
if (matchingStudios.size > 1) return;
unsafeWindow.showLoading?.();
const partsQueryResults = await Promise.all(studiosMoreSplit.map((part) => queryStudioNameMatches(part).then((results) => [part, results])));
let consumedUntilIndex = -1;
unsafeWindow.hideLoading?.();
$studiosMore.html(partsQueryResults.map(([part, results], i) => {
if (i <= consumedUntilIndex) return null;
let longestMatch;
for (let j = i; j < partsQueryResults.length; j++) {
if (j !== i) part += ', ' + partsQueryResults[j][0];
if (results[part]) {
consumedUntilIndex = j;
longestMatch = [part, results[part]];
}
};
if (longestMatch) {
matchingStudios.add(longestMatch[1]);
return `, ${longestMatch[0]} `;
} else {
logger.error('Failed to match all title studios. Could not match: ' + partsQueryResults[i][0], { data: results });
throw new Error('Failed to match all title studios.'); // don't mutate original elem
}
}).join(''));
}
$studios.contents().eq(1).wrap($(` `).on('click auxclick', matchStudioFromElemContext));
if (studiosMoreCount) {
// matchStudiosMoreSplit() always works, but it's overkill in most cases as only a small subset of studios have names containing commas
if (studiosMoreCount === 1) {
$studiosMore
.text(', ')
.append($(`${studiosMoreSplit.join(', ')} `).on('click auxclick', matchStudioFromElemContext));
} else if (studiosMoreCount === studiosMoreSplit.length) {
$studiosMore.empty();
studiosMoreSplit.forEach((part) => $studiosMore.append(', ', $(`${part} `).on('click auxclick', matchStudioFromElemContext)));
} else {
$studiosExpand.one('click', matchStudiosMoreSplit);
}
$studios.find('label')
.wrap(` `)
.parent()
.on('click auxclick', async function(evt) {
evt.preventDefault();
$(this).off();
await Promise.all([...$studios.find('a[href="#"]:not(:has(label), .studios-expand)').get().map((e) => matchStudioFromElemContext.call(e)), matchStudiosMoreSplit()]);
const url = `/search/${pathSplit[0]}?studio_ids=${Array.from(matchingStudios).join(',')}`;
if (evt.type === 'click') location.href = url;
else if (evt.originalEvent.button === 1) GM_openInTab(location.origin + url, { setParent: true });
$(this).attr('href', url);
});
}
}
}
}, { capture: true });
///////////////////////////////////////////////////////////////////////////////////////////////
async function getAllCountriesMap() { // ~235
if (!gmStorage.allCountriesMap) {
const doc = await fetch('/search/movies').then((r) => r.text()).then((r) => new DOMParser().parseFromString(r, 'text/html')); // movie countries are superset of show countries
gmStorage.allCountriesMap = JSON.stringify(Object.fromEntries(
$(doc).find('#filter-countries').children().get()
.map((e) => [$(e).text(), $(e).attr('value').toLowerCase()])
));
GM_setValue('enhancedTitleMetadata', gmStorage);
}
return JSON.parse(gmStorage.allCountriesMap);
}
async function getAllLanguagesArrSorted() { // ~179
if (!gmStorage.allLanguagesArrSorted) {
const doc = await fetch('/search/movies').then((r) => r.text()).then((r) => new DOMParser().parseFromString(r, 'text/html')); // movie langs are superset of show langs
gmStorage.allLanguagesArrSorted = JSON.stringify(
$(doc).find('#filter-languages').children().get()
.map((e) => [$(e).attr('value'), $(e).text()])
.sort(([, nameA], [, nameB]) => nameB.length - nameA.length) // ensure longest names get matched first, necessary because language names can include other language names and commas
);
GM_setValue('enhancedTitleMetadata', gmStorage);
}
return JSON.parse(gmStorage.allLanguagesArrSorted);
}
async function getAllNetworksArrSorted() { // ~4000; trakt api only returns one network (not full list) and only the name, not id
if (!gmStorage.allNetworksArrSorted) {
const doc = await fetch('/search/shows').then((r) => r.text()).then((r) => new DOMParser().parseFromString(r, 'text/html')),
collator = new Intl.Collator();
gmStorage.allNetworksArrSorted = JSON.stringify(
$(doc).find('#filter-network_ids').children().get()
.map((e) => $(e).text() ? [+$(e).attr('value'), { name: $(e).text(), countryId: $(e).attr('data-tag')?.toLowerCase() }] : null) // names can contain leading/trailing whitespace (even tabs)
.filter(Boolean)
.sort(([networkIdA, { name: nameA, countryId: countryIdA }], [networkIdB, { name: nameB, countryId: countryIdB }]) => {
return nameB.length - nameA.length || // ensure longest names get matched first, necessary because network names can include names of other networks and commas
collator.compare(nameA, nameB) || // make sure all those with the same name are neighbors
(countryIdB && 1) - (countryIdA && 1) || // prioritize those with country code
networkIdB - networkIdA; // the lower the network id, the more major the network tends to be
})
);
GM_setValue('enhancedTitleMetadata', gmStorage);
}
return JSON.parse(gmStorage.allNetworksArrSorted);
}
///////////////////////////////////////////////////////////////////////////////////////////////
function addStyles() {
GM_addStyle(`
#overview .additional-stats .country-flag {
width: 20px !important;
margin: -2px 5px 0 0 !important;
transition: transform .5s ease;
}
#overview .additional-stats a:hover > .country-flag {
transform: scale(1.1);
}
:is(#info-wrapper .additional-stats a > label, #summary-wrapper a > .year):hover {
color: var(--link-color) !important;
cursor: pointer !important;
}
#summary-wrapper a:has(> .certification):hover {
color: #fff !important;
}
`);
}
})('Trakt.tv | Enhanced Title Metadata');
gmStorage['kji85iek'] && (async (moduleName) => {
'use strict';
let $;
addStyles();
document.addEventListener('turbo:load', () => {
$ ??= unsafeWindow.jQuery;
if (!$) return;
unsafeWindow.ratingOverlay = ratingOverlay;
addLinksToPosters();
$(document).off('ajaxSuccess.userscript12944').on('ajaxSuccess.userscript12944', (_evt, _xhr, opt) => {
if (opt.url.endsWith('/popular_lists')) {
addLinksToPosters();
unsafeWindow.addOverlays();
}
});
}, { capture: true });
function ratingOverlay($e, rating) { // addOverlays() natively calls ratingOverlay() for list preview posters (with wrong selection) and handles .corner-rating removal if necessary
if (!$e.length) {
const $prevSelection = $e.end();
if ($prevSelection.closest('.personal-list').length && $prevSelection.hasClass('poster')) $e = $prevSelection;
}
if (!$e.find('.corner-rating').length) {
$e.prepend(``);
}
}
function addLinksToPosters() {
$('.personal-list .poster[data-url]:not(:has(> a))').each(function() {
$(this).children().wrapAll(` `);
});
};
unsafeWindow.userscriptAddLinksToListPreviewPosters = addLinksToPosters; // exposed for "Trakt.tv | All-in-One Lists View" userscript
function addStyles() {
GM_addStyle(`
@media not (767px < width <= 991px) {
.personal-list .poster .corner-rating {
border-width: 0 24px 24px 0 !important;
}
.personal-list .poster .corner-rating > .text {
height: 24px !important;
width: 12px !important;
right: -18px !important;
font-size: 11px !important;
line-height: 11px !important;
}
}
.personal-list .poster.dropped-show .dropped-badge-wrapper {
top: 50% !important; /* otherwise covers up summary page anchor tag */
height: auto !important;
}
`);
}
})('Trakt.tv | Enhanced List Preview Posters');
gmStorage['p2o98x5r'] && (async (moduleName) => {
'use strict';
addStyles();
document.addEventListener('turbo:load', () => {
if (!/^\/users\/[^\/]+\/lists$/.test(location.pathname)) return;
const $ = unsafeWindow.jQuery;
if (!$) return;
const $sortableGrid = $('#sortable-grid'),
$spacer = $sortableGrid.children().length ? $(` `).insertAfter($sortableGrid) : undefined,
$btn = $(`All-In-One Lists View `).insertAfter($spacer ?? $sortableGrid);
$btn.on('click', async () => {
$btn.text('Loading...').prop('disabled', true);
const fetchListElems = async (pathSuffix) => fetch(location.pathname + pathSuffix).then((r) => r.text())
.then((r) => $(new DOMParser().parseFromString(r, 'text/html')).find('.personal-list'));
let $fetchedLists = $((await Promise.all(['/collaborations', '/liked', '/liked/official'].map(fetchListElems))).flatMap(($listElems) => $listElems.get()));
const $personalLists = $('.personal-list'),
personalListsIds = $personalLists.map((_i, e) => $(e).attr('data-list-id')).get();
$fetchedLists = $fetchedLists.filter((_i, e) => !personalListsIds.includes($(e).attr('data-list-id'))); // duplicate removal because a user can like his own personal lists
if (!$fetchedLists.length) {
$btn.text('No other lists found.')
return;
}
const rankOffset = +$personalLists.last().attr('data-rank');
$fetchedLists.each((i, e) => $(e).attr('data-rank', rankOffset + i + 1));
$fetchedLists
.find('.btn-list-progress').click(function() {
unsafeWindow.showLoading();
const dataListId = $(this).attr('data-list-id');
if(dataListId && unsafeWindow.userSettings?.user.vip) unsafeWindow.redirect(unsafeWindow.userURL('progress?list=' + dataListId));
else unsafeWindow.redirect('/vip/list-progress');
})
.end().find('.btn-list-subscribe').click(function() {
unsafeWindow.showLoading();
const dataListId = $(this).attr('data-list-id');
if(dataListId && unsafeWindow.userSettings?.user.vip) {
$.post(`/lists/${dataListId}/subscribe`, function(response) {
unsafeWindow.redirect(response.url);
}).fail(function() {
unsafeWindow.hideLoading();
unsafeWindow.toastr.error('Doh! We ran into some sort of error.');
});
}
else unsafeWindow.redirect('/vip/calendars');
})
.end().find('.collaborations-deny').on('ajax:success', function(_e, response) {
$('#collaborations-deny-' + response.id).children().addClass('trakt-icon-delete-thick');
$('#collaborations-approve-' + response.id).addClass('off');
$('#collaborations-block-' + response.id).addClass('off');
});
const $btnListEditLists = $('#btn-list-edit-lists');
if ($btnListEditLists.hasClass('active')) $btnListEditLists.trigger('click');
$btnListEditLists.hide();
$sortableGrid.append($fetchedLists);
$spacer?.remove();
$btn.remove();
unsafeWindow.genericTooltips();
unsafeWindow.vipTooltips();
unsafeWindow.shareIcons();
unsafeWindow.convertEmojis();
unsafeWindow.userscriptAddLinksToListPreviewPosters?.();
unsafeWindow.addOverlays();
unsafeWindow.$grid?.isotope('insert', $fetchedLists); // isotope instance is only initialized after first filtering/sorting action
unsafeWindow.updateListsCount();
unsafeWindow.lazyLoadImages();
unsafeWindow.renderReadmore();
});
}, { capture: true });
function addStyles() {
GM_addStyle(`
#all-in-one-lists-view-btn {
margin: 20px auto 0;
padding: 8px 16px;
border-radius: var(--btn-radius);
border: 1px solid hsl(0deg 0% 20% / 65%);
background-color: #fff;
color: #333;
font-size: 18px;
font-weight: var(--headings-font-weight);
font-family: var(--headings-font-family);
transition: all 0.2s;
}
#all-in-one-lists-view-btn:hover {
color: var(--brand-primary);
}
#all-in-one-lists-view-btn:active {
background-color: #ccc;
}
body.dark-knight #all-in-one-lists-view-btn {
border: none;
background-color: #333;
color: #fff;
}
body.dark-knight #all-in-one-lists-view-btn:hover {
background-color: var(--brand-primary);
}
body.dark-knight #all-in-one-lists-view-btn:active {
background-color: #666;
}
@media (min-width: 768px) {
body:has(> .bottom[id*="content-page"]) #all-in-one-lists-view-btn {
margin-bottom: -20px;
}
}
:is(#all-in-one-lists-view-btn, #all-in-one-lists-view-spacer) {
display: block !important;
}
body:has(#btn-list-edit-lists.active) :is(#all-in-one-lists-view-btn, #all-in-one-lists-view-spacer) {
display: none !important;
}
`);
}
})('Trakt.tv | All-In-One Lists View');
gmStorage['pmdf6nr9'] && (async (moduleName) => {
/* global Chart */
'use strict';
let $, traktApiWrapper;
const numFormatCompact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
numFormatCompact.formatTLC = (n) => numFormatCompact.format(n).toLowerCase();
addStyles();
document.addEventListener('turbo:load', async () => {
if (!/^\/(shows|movies)\//.test(location.pathname)) return;
$ ??= unsafeWindow.jQuery;
traktApiWrapper ??= unsafeWindow.userscriptTraktApiWrapper;
if (!$) return;
const $summaryWrapper = $('#summary-wrapper'),
$summaryRatingsWrapper = $summaryWrapper.find('#summary-ratings-wrapper'),
statsPath = $summaryRatingsWrapper.find('.trakt-rating > a').attr('href');
if (!statsPath) return;
const $canvas = $(`
`)
.appendTo($summaryWrapper.find('.shadow-base'))
.find('canvas');
const [ratingsData, fanartBrightness] = await Promise.all([getRatingsData(statsPath), getFanartBrightness($summaryWrapper)]);
const newChart = () => {
new Chart($canvas[0].getContext('2d'), {
type: 'bar',
data: getChartData(ratingsData, fanartBrightness),
options: getChartOptions(ratingsData, $summaryRatingsWrapper),
});
};
if (!document.hidden) newChart();
else $(document).one('visibilitychange', newChart);
}, { capture: true });
async function getRatingsData(statsPath) {
let ratingsData;
if (traktApiWrapper) {
const statsPathSplit = statsPath.split('/').slice(1, -1),
id = isNaN(statsPathSplit[1]) ? statsPathSplit[1] : $('.summary-user-rating').attr(`data-${statsPathSplit[0].slice(0, -1)}-id`), // /shows/1883 numeric slugs are interpreted as trakt-id by api
resp = await traktApiWrapper[(statsPathSplit[4] ?? statsPathSplit[2] ?? statsPathSplit[0])].ratings({ id, season: statsPathSplit[3], episode: statsPathSplit[5] });
ratingsData = { distribution: Object.values(resp.distribution), votes: resp.votes };
} else {
const resp = await fetch(statsPath),
statsDoc = new DOMParser().parseFromString(await resp.text(), 'text/html'),
ratDist = JSON.parse($(statsDoc).find('#charts-wrapper script').text().match(/ratingsDistribution = (\[.*\])/)[1]);
ratingsData = { distribution: ratDist, votes: $('#summary-ratings-wrapper').data('vote-count') };
}
if (ratingsData.distribution.length === 11) { // bg logging of titles with malformed (length = 11, [0] === 1 or more, only movs/shows no seasons/eps) ratings distribution data e.g. /shows/chainsaw-man
// GM_setValue(statsPath, ratingsData.distribution.toString());
console.warn(GM_info.script.name.replace('Trakt.tv', 'Userscript') + ': Malformed ratings distribution data.', ratingsData.distribution.toString());
ratingsData.distribution.shift();
}
return ratingsData;
}
function getFanartBrightness($summaryWrapper) {
const $fullScreenshot = $summaryWrapper.find('> .full-screenshot');
const onBgImgSet = async () => {
const url = $fullScreenshot.css('background-image').match(/https.*webp/)?.[0];
if (!url) return 0.5;
const resp = await GM.xmlHttpRequest({ url, responseType: 'blob', fetch: true });
if (resp.status !== 200) throw new Error(`XHR for: ${resp.finalUrl} failed with status: ${resp.status}`);
const blobUrl = URL.createObjectURL(resp.response),
img = new Image();
img.src = blobUrl;
await img.decode();
URL.revokeObjectURL(blobUrl);
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const cropWidth = img.naturalWidth / 4, cropHeight = img.naturalHeight / 4,
data = ctx.getImageData(3*cropWidth, 2*cropHeight, cropWidth, cropHeight).data;
let sum = 0, px = data.length / 16;
for (let i = 0; i < data.length; i += 16) {
sum += (0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2]) / 255;
}
return sum / px;
}
if ($fullScreenshot.attr('style')) return onBgImgSet();
else {
return new Promise((res) => {
new MutationObserver((_mutations, mutObs) => {
mutObs.disconnect();
res(onBgImgSet());
}).observe($fullScreenshot[0], { attributeFilter: ['style'] });
});
}
}
function getGradientY(context, callerId, yAxisId, ...colors) {
if (!context) return colors.pop().color;
const {ctx, chartArea, scales} = context.chart;
if (!chartArea) return;
ctx[callerId] ??= {};
if (!ctx[callerId].gradient ||
ctx[callerId].height !== chartArea.height ||
ctx[callerId].yAxisMin !== scales[yAxisId].min ||
ctx[callerId].yAxisMax !== scales[yAxisId].max) {
let newBottom = scales[yAxisId].max - scales[yAxisId].min;
newBottom = newBottom ? scales[yAxisId].max / newBottom : 1;
newBottom = chartArea.bottom * newBottom;
ctx[callerId].gradient = ctx.createLinearGradient(0, newBottom, 0, chartArea.top);
colors.forEach((c) => ctx[callerId].gradient.addColorStop(c.offset, c.color));
ctx[callerId].height = chartArea.height;
ctx[callerId].yAxisMin = scales[yAxisId].min;
ctx[callerId].yAxisMax = scales[yAxisId].max;
}
return ctx[callerId].gradient;
}
function getChartData(ratingsData, fanartBrightness) {
return {
labels: [...Array(10)].map((_, i) => String(i + 1)),
datasets: [{
label: 'Votes',
data: ratingsData.distribution,
categoryPercentage: 1,
barPercentage: 0.97,
backgroundColor: `rgba(${Array(3).fill(Math.min(fanartBrightness+0.35, 1)*255).join(', ')}, ${Math.min(fanartBrightness+0.3, 0.7)})`,
hoverBackgroundColor: (context) => getGradientY(context, '_votes', 'y',
{ offset: 0, color: `rgba(155, 66, 200, ${Math.min(fanartBrightness+0.3, 0.7)})` },
{ offset: 0.9, color: `rgba(255, 0, 0, ${Math.min(fanartBrightness+0.3, 0.7)})` }),
}],
};
}
function getChartOptions(ratingsData, $summaryRatingsWrapper) {
return {
responsive: true,
maintainAspectRatio: false,
minBarLength: 2,
interaction: {
mode: 'index',
intersect: false,
},
animation: {
delay: (context) => (context.type === 'data' && context.mode === 'default') ? 250 + context.dataIndex * (750 / (ratingsData.distribution.length - 1)) : 0,
},
scales: {
x: {
display: false,
},
y: {
display: false,
suggestedMax: 10,
},
},
plugins: {
tooltip: {
displayColors: false,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
caretSize: 10,
padding: {
x: 12,
y: 5,
},
titleAlign: 'center',
titleMarginBottom: 2,
titleFont: {
weight: 'bold',
},
bodyAlign: 'center',
bodyColor: 'rgb(170, 170, 170)',
bodyFont: {
size: 11,
},
footerAlign: 'center',
footerColor: (context) => `hsl(0, ${context.tooltip.dataPoints[0].parsed.x * 11}%, 35%)`, // approximation
footerMarginTop: 2,
footerFont: {
size: 18,
},
callbacks: {
title: (tooltipItems) => {
const label = tooltipItems[0].label;
return `${label} - ${unsafeWindow.ratingsText?.[label]}`;
},
label: (tooltipItem) => {
const y = tooltipItem.parsed.y;
return `${ratingsData.votes > 0 ? (y*100 / ratingsData.votes).toFixed(1) : '--'}% (${numFormatCompact.formatTLC(y)} v.)`;
},
footer: (tooltipItems) => {
const personalRating = $summaryRatingsWrapper.find('.summary-user-rating > :not([style="display: none;"]) > [class*="rating-"]').first().attr('class')?.match(/rating-(\d+)/)?.[1];
return tooltipItems[0].parsed.x === personalRating - 1 ? '\u2764' : '';
},
},
},
legend: {
display: false,
},
},
onClick: (_evt, activeElems) => {
if (!activeElems.length) return;
const rating = activeElems[0].index + 1;
$summaryRatingsWrapper.find('.summary-user-rating:not(.popover-on)').trigger('click');
setTimeout(() => $(`.needsclick.rating-${rating}`).trigger('mouseover').trigger('click'), 500);
},
};
}
function addStyles() {
GM_addStyle(`
#summary-wrapper {
container-type: inline-size;
--rat-dist-chart-width: 28cqi;
}
#summary-wrapper .shadow-base {
display: flex;
justify-content: flex-end;
align-items: flex-end;
}
#ratings-distribution-chart-wrapper {
position: relative;
z-index: 30;
height: 100%;
width: var(--rat-dist-chart-width);
}
#summary-wrapper:has(#summary-ratings-wrapper) .summary .mobile-title {
padding-right: calc(var(--rat-dist-chart-width) - ((100cqi - 100%) / 2) + 5px) !important;
}
@media (width <= 767px) {
#ratings-distribution-chart-wrapper {
height: 65%;
}
}
#summary-wrapper .summary .mobile-title .year {
white-space: nowrap;
}
#summary-wrapper .summary .mobile-title .year::after {
content: "\\2060";
}
`);
}
})('Trakt.tv | Charts - Ratings Distribution');
gmStorage['swtn5c9q'] && (async (moduleName) => {
/* 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 ` +
`${getProgRuntimeText(pbProgItems[itemUrl], runtime)} ` +
`${unsafeWindow.formatDate(pbProgItems[itemUrl].paused_at)} ` +
`Click for options ` +
` `,
placement: 'right',
container: 'body',
html: true,
}).popover({
template: '',
title: 'Playback Progress Options',
content:
`Set New ` +
`Remove ` +
`Cancel `,
trigger: 'manual',
placement: 'bottom',
container: 'body',
html: true,
}).on('click', function(evt) {
evt.preventDefault();
$(this).tooltip('hide').popover('show');
}).fadeIn();
}
});
}
///////////////////////////////////////////////////////////////////////////////////////////////
function addPbProgDropdownEntries() {
const $dropdownMenu = $('.subnav-wrapper .left .dropdown-menu');
$dropdownMenu.append(
` ` +
`` +
`All Types ` +
`Movies ` +
`Episodes `
);
if ($('body').is('.playback')) {
$dropdownMenu
.find('.selected').removeClass('selected')
.end().find(`[href$="${location.pathname.split('/').pop()}"]`).addClass('selected')
.end().prev().contents()[0].textContent = `Playback - ${$dropdownMenu.find('.selected').text()} `;
}
}
async function populatePbProgPage() {
const typeFilter = location.pathname.split('/playback').pop();
const $gridItems = await Promise.all(
Object.entries(pbProgItems)
.filter(([, { type }]) => !typeFilter || typeFilter.includes(type))
.sort((a, b) => new Date(b[1].paused_at) - new Date(a[1].paused_at))
.map(async ([itemUrl, itemData]) => {
const [summary, summaryEp] = await Promise.all([
traktApiWrapper[itemData.movie ? 'movies' : 'shows'].summary({ id: itemData[itemData.movie ? 'movie' : 'show'].ids.trakt, extended: 'full,images' }),
itemData.episode ? traktApiWrapper.episodes.summary({ id: itemData.show.ids.trakt, season: itemData.episode.season, episode: itemData.episode.number, extended: 'full,images' }) : null,
]);
return buildPbProgGridItem(itemUrl, itemData, summary, summaryEp);
})
);
$(':is(#progress-wrapper, #playback-progress-wrapper)').attr('id', 'playback-progress-wrapper') // id change to bypass native progress watched actions (e.g. auto refresh)
.children().html('
')
$('body > .tooltip').tooltip('destroy');
if ($gridItems.length) {
$('#playback-progress-wrapper .row').addClass('posters').append($gridItems);
unsafeWindow.isProgress = false; // needed for correct note-badge insertion/placement in addOverlays() => addNotesOverlays()
unsafeWindow.addOverlays();
unsafeWindow.posterGridTooltips();
unsafeWindow.formatDates();
unsafeWindow.hideUnreleasedRatings();
unsafeWindow.lazyLoadImages();
if (!typeFilter) {
const removeAllMsg = `Remove all (${$gridItems.length}) playback progress items?\nThis will take about ${$gridItems.length * 1}s and cannot be undone!`,
renewAllMsg = `Renew all (${$gridItems.length}) playback progress items?\nThis will take about ${$gridItems.length * 2}s and cannot be undone!\n\n` +
`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', removeAllMsg, removePbProgAll], ['renewAll', 'Renew All', renewAllMsg, renewPbProgAll]].forEach(([idKey, name, msg, fn]) => {
menuCommands[idKey] ??= GM_registerMenuCommand(`PPM: ${name}`, () => confirm(msg) && fn());
});
}
} else {
$('#playback-progress-wrapper .row').html(`Nothing to see here. Move along, move along.
`);
}
}
function buildPbProgGridItem(itemUrl, itemData, summary, summaryEp) {
const releaseDate = summaryEp?.first_aired ?? summary.first_aired ?? summary.released + 'T00:00:00Z';
const dataPercentage = Math.floor((summaryEp ?? summary).rating * 10);
const runtime = (summaryEp ?? summary).runtime;
const longPath = itemData.movie
? itemUrl
: `/shows/${itemData.show.ids.slug}/seasons/${itemData.episode.season}/episodes/${itemData.episode.number}`;
const longTitle = itemData.movie
? `${itemData.movie.title} (${itemData.movie.year})`
: `${itemData.show.title}<br>${itemData.episode.season}x${String(itemData.episode.number).padStart(2, '0')} "${itemData.episode.title}"`;
const dataEpisodeTitle = itemData.episode
? `<span class='main-title-sxe'>${itemData.episode.season}x${String(itemData.episode.number).padStart(2, '0')}</span> <span class='main-title' data-spoiler-episode-id='${itemData.episode.ids.trakt}' data-spoiler-show-id='${itemData.show.ids.trakt}'>${itemData.episode.title}</span>`
: null;
const episodeTypeClass = summaryEp
? summaryEp.episode_type.replace('_', '-')
: null;
const episodeTypeLabel = summaryEp && summaryEp.episode_type !== 'standard'
? summaryEp.episode_type.replace('_', ' ').toUpperCase()
: null;
return $(
``
);
}
///////////////////////////////////////////////////////////////////////////////////////////////
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;
}
`);
}
})('Trakt.tv | Playback Progress Manager');
gmStorage['txw82860'] && (async (moduleName) => {
'use strict';
const userslug = document.cookie.match(/(?:^|; )trakt_userslug=([^;]*)/)?.[1];
const menuTemplates = {
historySorting: (hrefPrefix, [anchorIndex = 1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'SORT BY' },
{ text: 'Watched Date', href: '/added' },
{ text: 'Plays', href: '/plays' },
{ text: 'Time Spent', href: '/time' },
{ text: 'Title', href: '/title' },
{ text: 'Release Date', href: '/released' },
{ text: 'Runtime', href: '/runtime' },
{ text: 'Popularity', href: '/popularity' },
{ text: 'Percentage', href: '/percentage' },
{ text: 'Votes', href: '/votes' },
]),
}),
progressSorting: (hrefPrefix, [anchorIndex = 1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'SORT BY' },
{ text: 'Watched Date', href: '/added' },
{ text: 'Completion %', href: '/completed' },
{ text: 'Episodes Left', href: '/episodes' },
{ text: 'Time Left', href: '/time' },
{ text: 'Plays', href: '/plays' },
{ text: 'Release Date', href: '/released' },
{ text: 'Premiere Date', href: '/premiered' },
{ text: 'Title', href: '/title' },
{ text: 'Popularity', href: '/popularity' },
{ text: 'Episode Runtime', href: '/runtime' },
{ text: 'Total Runtime', href: '/total-runtime' },
{ text: 'Random', href: '/random' },
]),
}),
librarySorting: (hrefPrefix, [anchorIndex = 1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'SORT BY' },
{ text: 'Added Date', href: '/added' },
{ text: 'Title', href: '/title' },
{ text: 'Release Date', href: '/released' },
...(/\/shows/.test(hrefPrefix) ? [
{ text: 'Episode Count', href: '/episodes' },
] : []),
...(!/\/episodes/.test(hrefPrefix) ? [
{ text: 'Runtime', href: '/runtime' },
{ text: 'Popularity', href: '/popularity' },
] : []),
{ text: 'Percentage', href: '/percentage' },
{ text: 'Votes', href: '/votes' },
]),
}),
ratingSelection: (hrefPrefix, [anchorIndex = 1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'RATING' },
{ text: 'All Ratings', href: '/all', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/all', submenuAnchorIndexes) },
{ text: '10 - Totally Ninja!', href: '/10', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/10', submenuAnchorIndexes) },
{ text: '9 - Superb', href: '/9', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/9', submenuAnchorIndexes) },
{ text: '8 - Great', href: '/8', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/8', submenuAnchorIndexes) },
{ text: '7 - Good', href: '/7', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/7', submenuAnchorIndexes) },
{ text: '6 - Fair', href: '/6', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/6', submenuAnchorIndexes) },
{ text: '5 - Meh', href: '/5', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/5', submenuAnchorIndexes) },
{ text: '4 - Poor', href: '/4', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/4', submenuAnchorIndexes) },
{ text: '3 - Bad', href: '/3', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/3', submenuAnchorIndexes) },
{ text: '2 - Terrible', href: '/2', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/2', submenuAnchorIndexes) },
{ text: '1 - Weak Sauce :(', href: '/1', submenu: menuTemplates.ratingsSorting(hrefPrefix + '/1', submenuAnchorIndexes) },
]),
}),
ratingsSorting: (hrefPrefix, [anchorIndex = 1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'SORT BY' },
{ text: 'Rated Date', href: '/added' },
{ text: 'Rating', href: '/rating' },
...(!/\/ratings\/all/.test(hrefPrefix) ? [
{ text: 'Title', href: '/title' },
{ text: 'Release Date', href: '/released' },
...(/\/(movies|shows)/.test(hrefPrefix) ? [
{ text: 'Runtime', href: '/runtime' },
{ text: 'Popularity', href: '/popularity' },
] : []),
{ text: 'Percentage', href: '/percentage' },
{ text: 'Votes', href: '/votes' },
] : []),
]),
}),
listsViewSorting: (hrefPrefix, [anchorIndex = 1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'SORT BY' },
...(/\/lists\?/.test(hrefPrefix) ? [
{ text: 'Rank', href: 'rank' },
] : []),
...(/\/liked/.test(hrefPrefix) ? [
{ text: 'Like Date', href: 'liked' },
] : []),
{ text: 'Updated Date', href: 'updated' },
{ text: 'Title', href: 'title' },
{ text: 'Likes', href: 'likes' },
{ text: 'Comments', href: 'comments' },
{ text: 'Items', href: 'items' },
{ text: 'Random', href: 'random' },
]),
}),
listSorting: (hrefPrefix, [anchorIndex = 1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'SORT BY' },
{ text: 'Rank', href: 'rank' },
{ text: 'Added Date', href: 'added' },
{ text: 'Title', href: 'title' },
{ text: 'Release Date', href: 'released' },
{ text: 'Runtime', href: 'runtime' },
{ text: 'Popularity', href: 'popularity' },
{ text: 'Random', href: 'random' },
{},
{ text: 'Trakt Percentage', href: 'percentage' },
{ text: 'Trakt Votes', href: 'votes' },
...(/\/watchlist\?sort=/.test(hrefPrefix) && userslug ? [
{ text: 'Rotten Tomatoes (mdb) ', href: `https://mdblist.com/watchlist/${userslug}?sort=rtomatoes&sortorder=asc`, useHrefPrefix: false },
{ text: 'Metacritic (mdb) ', href: `https://mdblist.com/watchlist/${userslug}?sort=metacritic&sortorder=asc`, useHrefPrefix: false },
{ text: 'MyAnimeList (mdb) ', href: `https://mdblist.com/watchlist/${userslug}?sort=myanimelist&sortorder=asc`, useHrefPrefix: false },
] : []),
{},
{ text: 'My Rating', href: 'my_rating' },
{ text: 'Watched Date', href: 'watched' },
{ text: 'Collected Date', href: 'collected' },
]),
}),
commentType: (hrefPrefix, [anchorIndex = 1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'TYPE' },
{ text: 'All Types', href: '/all', submenu: menuTemplates.commentSorting(hrefPrefix + '/all', submenuAnchorIndexes) },
{ text: 'Movies', href: '/movies', submenu: menuTemplates.commentSorting(hrefPrefix + '/movies', submenuAnchorIndexes) },
{ text: 'Shows', href: '/shows', submenu: menuTemplates.commentSorting(hrefPrefix + '/shows', submenuAnchorIndexes) },
{ text: 'Seasons', href: '/seasons', submenu: menuTemplates.commentSorting(hrefPrefix + '/seasons', submenuAnchorIndexes) },
{ text: 'Episodes', href: '/episodes', submenu: menuTemplates.commentSorting(hrefPrefix + '/episodes', submenuAnchorIndexes) },
{ text: 'Lists', href: '/lists', submenu: menuTemplates.commentSorting(hrefPrefix + '/lists', submenuAnchorIndexes) },
]),
}),
commentSorting: (hrefPrefix, [anchorIndex = 1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'SORT BY' },
{ text: 'Added Date', href: '/added' },
{ text: 'Reactions (30 Days) ', href: '/likes_30' }, // TODO change href once/if they switch to /reactions_30
{ text: 'Reactions (All Time) ', href: '/likes' },
{ text: 'Replies (30 Days) ', href: '/replies_30' },
{ text: 'Replies (All Time) ', href: '/replies' },
{ text: 'Plays', href: '/plays' },
{ text: 'Rating', href: '/rating' },
]),
}),
hiddenItemsSorting: (hrefPrefix, [anchorIndex = 1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'SORT BY' },
{ text: 'Title', href: '/title' },
{ text: 'Date', href: '/data' },
]),
}),
showsMoviesCatTimePeriod: (hrefPrefix, [anchorIndex = -1, ...submenuAnchorIndexes] = []) => ({
hrefPrefix,
entries: ((arr) => arr.with(anchorIndex, { ...arr.at(anchorIndex), anchor: true }))([
{ text: 'PERIOD' },
{ text: 'Day', href: '/daily' },
{ text: 'Week', href: '/weekly' },
{ text: 'Month', href: '/monthly' },
...(!/\/streaming/.test(hrefPrefix) ? [
{ text: 'All Time', href: '/all' },
] : []),
]),
}),
};
const menus = {
'.btn-profile a[href$="/history"]': {
hrefPrefix: '/users/me/history',
entries: [
{ text: 'TYPE' },
{ text: 'All Types', href: '/all', anchor: true },
{ text: 'Movies', href: '/movies', submenu: menuTemplates.historySorting('/users/me/history/movies') },
{ text: 'Shows', href: '/shows', submenu: menuTemplates.historySorting('/users/me/history/shows') },
{ text: 'Episodes', href: '/episodes', submenu: menuTemplates.historySorting('/users/me/history/episodes') },
],
},
'.btn-profile a[href$="/library"]': {
hrefPrefix: '/users/me/library',
entries: [
{ text: 'TYPE' },
{ text: 'All Types', href: '/all', anchor: true },
{ text: 'Movies', href: '/movies', submenu: menuTemplates.librarySorting('/users/me/library/movies') },
{ text: 'Shows', href: '/shows', submenu: menuTemplates.librarySorting('/users/me/library/shows') },
{ text: 'Episodes', href: '/episodes', submenu: menuTemplates.librarySorting('/users/me/library/episodes') },
],
},
'.btn-profile a[href$="/progress"]': {
hrefPrefix: '/users/me/progress',
entries: [
{ text: 'SHOWS' },
{ text: 'Watched', href: '/watched', anchor: true, submenu: menuTemplates.progressSorting('/users/me/progress/watched') },
{ text: 'Dropped', href: '/dropped', submenu: menuTemplates.progressSorting('/users/me/progress/dropped') },
{ text: 'Library', href: '/library', submenu: menuTemplates.progressSorting('/users/me/progress/library') },
{},
{ text: 'PLAYBACK' },
{ text: 'All Types', href: '/playback' },
{ text: 'Movies', href: '/playback/movies' },
{ text: 'Episodes', href: '/playback/episodes' },
],
},
'.btn-profile a[href$="/ratings"]': {
hrefPrefix: '/users/me/ratings',
entries: [
{ text: 'TYPE' },
{ text: 'All Types', href: '/all', anchor: true, submenu: menuTemplates.ratingSelection('/users/me/ratings/all') },
{ text: 'Movies', href: '/movies', submenu: menuTemplates.ratingSelection('/users/me/ratings/movies', [ , 4]) },
{ text: 'Shows', href: '/shows', submenu: menuTemplates.ratingSelection('/users/me/ratings/shows', [ , 4]) },
{ text: 'Seasons', href: '/seasons', submenu: menuTemplates.ratingSelection('/users/me/ratings/seasons', [ , -1]) },
{ text: 'Episodes', href: '/episodes', submenu: menuTemplates.ratingSelection('/users/me/ratings/episodes', [ , -1]) },
],
},
'.btn-profile a[href$="/lists"]': {
hrefPrefix: '/users/me/lists',
entries: [
{ text: 'Watchlist', href: '/users/me/watchlist', useHrefPrefix: false, submenu: {
hrefPrefix: '/users/me/watchlist?display=',
entries: [
{ text: 'TYPE' },
{ text: 'All Types', href: '/users/me/watchlist', useHrefPrefix: false, anchor: true, submenu: menuTemplates.listSorting('/users/me/watchlist?sort=', [3]) },
{ text: 'Movies', href: 'movie', submenu: menuTemplates.listSorting('/users/me/watchlist?display=movie&sort=', [3]) },
{ text: 'Shows', href: 'show', submenu: menuTemplates.listSorting('/users/me/watchlist?display=show&sort=', [3]) },
{ text: 'Seasons', href: 'season', submenu: menuTemplates.listSorting('/users/me/watchlist?display=season&sort=', [3]) },
{ text: 'Episodes', href: 'episode', submenu: menuTemplates.listSorting('/users/me/watchlist?display=episode&sort=', [3]) },
],
}},
{ text: 'Favorites', href: '/users/me/favorites', useHrefPrefix: false, submenu: {
hrefPrefix: '/users/me/favorites?display=',
entries: [
{ text: 'TYPE' },
{ text: 'All Types', href: '/users/me/favorites', useHrefPrefix: false, anchor: true, submenu: menuTemplates.listSorting('/users/me/favorites?sort=', [3]) },
{ text: 'Movies', href: 'movie', submenu: menuTemplates.listSorting('/users/me/favorites?display=movie&sort=', [3]) },
{ text: 'Shows', href: 'show', submenu: menuTemplates.listSorting('/users/me/favorites?display=show&sort=', [3]) },
],
}},
{},
{ text: 'YOUR LISTS' },
{ text: 'Personal Lists', href: '', submenu: menuTemplates.listsViewSorting('/users/me/lists?sort=') },
{ text: 'Collaborations', href: '/collaborations', submenu: menuTemplates.listsViewSorting('/users/me/lists/collaborations?sort=') },
{},
{ text: 'LIKED LISTS' },
{ text: 'Personal Lists', href: '/liked', submenu: menuTemplates.listsViewSorting('/users/me/lists/liked?sort=') },
{ text: 'Official Lists', href: '/liked/official', submenu: menuTemplates.listsViewSorting('/users/me/lists/liked/official?sort=') },
],
},
'.btn-profile a[href$="/comments"]': {
hrefPrefix: '/users/me/comments',
entries: [
{ text: 'YOUR COMMENTS' },
{ text: 'All Comments', href: '/all', anchor: true, submenu: menuTemplates.commentType('/users/me/comments/all') },
{ text: 'Reviews', href: '/reviews', submenu: menuTemplates.commentType('/users/me/comments/reviews') },
{ text: 'Shouts', href: '/shouts', submenu: menuTemplates.commentType('/users/me/comments/shouts') },
{ text: 'Replies', href: '/replies', submenu: menuTemplates.commentType('/users/me/comments/replies') },
{},
{ text: 'REACTIONS' },
{ text: 'All Comments', href: '/liked/all', submenu: menuTemplates.commentType('/users/me/comments/liked/all', [-1, -1]) },
{ text: 'Reviews', href: '/liked/reviews', submenu: menuTemplates.commentType('/users/me/comments/liked/reviews', [-1, -1]) },
{ text: 'Shouts', href: '/liked/shouts', submenu: menuTemplates.commentType('/users/me/comments/liked/shouts', [-1, -1]) },
{ text: 'Replies', href: '/liked/replies', submenu: menuTemplates.commentType('/users/me/comments/liked/replies', [-1, -1]) },
],
},
'.btn-profile a[href$="/notes"]': {
hrefPrefix: '/users/me/notes',
entries: [
{ text: 'All Types', href: '/all' },
{},
{ text: 'MEDIA ITEMS' },
{ text: 'Movies', href: '/movies' },
{ text: 'Shows', href: '/shows' },
{ text: 'Seasons', href: '/seasons' },
{ text: 'Episodes', href: '/episodes' },
{ text: 'People', href: '/people' },
{},
{ text: 'YOUR ACTIVITIES' },
{ text: 'History', href: '/history' },
{ text: 'Library', href: '/collection' }, // TODO switch to /library once /users/me/notes/library works
{ text: 'Ratings', href: '/ratings' },
],
},
'.btn-profile a[href$="/network"]': {
hrefPrefix: '/users/me/network',
entries: [
{ text: 'Following', href: '/following/added' },
{ text: 'Following (Pending) ', href: '/following_pending/added' },
{ text: 'Followers', href: '/followers/added' },
],
},
'.btn-profile a[href="/widgets"]': {
hrefPrefix: '/widgets',
entries: [
{ text: 'Watched', href: '/watched' },
{ text: 'Library', href: '/library' },
{ text: 'Profile', href: '/profile' },
],
},
'.btn-profile a:contains("Quick Actions")': {
entries: [
{ text: 'Toggle Dark Mode ', onclick: 'toggleDarkMode(); return false;' },
{ text: 'Clear Search History', onclick: 'showLoading(); $.post(`/users/me/clear_search_history`).done(() => { toastr.success(`Your search history was cleared.`); cacheUserData(true); }).always(hideLoading); return false;' },
{ text: 'Re-cache Progress Data', onclick: 'showLoading(); $.post(`/users/me/reset_progress_cache`).done(() => { toastr.success(`Your progress cache will be updated in a few minutes.`); }).always(hideLoading); return false;' },
{ text: 'Re-cache Browser Data', onclick: 'window.reopenOverlays = [null]; window.afterLoadingBottomMessage = `Your browser data is reset!`; showLoading(`Please wait for the caching to fully complete.`); resetUserData(); return false;' },
],
},
'.btn-profile a[href="/settings"]': {
hrefPrefix: '/settings',
entries: [
{ text: 'Advanced', href: '/advanced' },
{ text: 'Your API Apps', href: '/oauth/applications', useHrefPrefix: false, submenu: {
entries: [
{ text: ' API Docs', href: '/b/api-docs' },
{ text: ' Developer Forum', href: '/b/dev-forum' },
{ text: ' Branding', href: '/branding' },
{ text: ' New Application', href: '/oauth/applications/new' },
],
}},
{ text: 'Connected Apps', href: '/oauth/authorized_applications', useHrefPrefix: false, submenu: {
entries: [
{ text: 'Activate Device', href: '/activate' },
],
}},
{ text: 'Reports', href: '/reports', submenu: {
hrefPrefix: '/reports',
entries: [
{ text: 'STATUS' },
{ text: 'All Reports', href: '/all', anchor: true },
{ text: 'Approved', href: '/approved' },
{ text: 'Paused', href: '/paused' },
{ text: 'Rejected', href: '/rejected' },
{ text: 'Pending', href: '/pending' },
],
}},
{ text: 'Hidden Items', href: '/hidden', submenu: {
hrefPrefix: '/settings/hidden',
entries: [
{ text: 'Dropped Shows', href: '/dropped', submenu: menuTemplates.hiddenItemsSorting('/settings/hidden/dropped') },
{},
{ text: 'Progress', href: '/watched', submenu: menuTemplates.hiddenItemsSorting('/settings/hidden/watched') },
{ text: 'Library', href: '/collected', submenu: menuTemplates.hiddenItemsSorting('/settings/hidden/collected') }, // TODO switch to library once /settings/hidden/library works
{ text: 'Calendar', href: '/calendars', submenu: menuTemplates.hiddenItemsSorting('/settings/hidden/calendars') },
{},
{ text: 'Rewatching', href: '/rewatching', submenu: menuTemplates.hiddenItemsSorting('/settings/hidden/rewatching') },
{ text: 'Blocked Users', href: '/comments', submenu: menuTemplates.hiddenItemsSorting('/settings/hidden/comments') },
],
}},
{ text: 'Plex Sync', href: '/plex' },
{ text: 'Streaming Sync', href: '/scrobblers' },
{ text: 'Notifications', href: '/notifications' },
{ text: 'Sharing', href: '/sharing' },
{ text: 'Data', href: '/data' },
{ text: 'General', href: '', anchor: true, submenu: {
hrefPrefix: '/settings',
entries: [
{ text: 'Change Password', href: '#password' },
{ text: 'Appearance', href: '#appearance' },
{ text: 'Search', href: '#search' },
{ text: 'Progress', href: '#progress' },
{ text: 'Profile', href: '#profile' },
{ text: 'Year in Review', href: '#yir' },
{ text: 'Calendars', href: '#calendars' },
{ text: 'Dashboard', href: '#dashboard' },
{ text: 'Spoilers', href: '#spoilers' },
{ text: 'Watch Now', href: '#watchnow' },
{ text: 'Rewatching', href: '#rewatching' },
{ text: 'Global', href: '#global' },
{ text: 'Date & Time', href: '#datetime' },
{ text: 'Account', href: '#account', anchor: true },
],
}},
],
},
':is(.btn-mobile-links, .links-wrapper) a[href^="/shows"]': {
hrefPrefix: '/shows',
entries: [
{ text: 'Trending', href: '/trending' },
{ text: 'Recommendations', href: '/recommendations' },
{ text: 'Streaming Charts', href: '/streaming', submenu: menuTemplates.showsMoviesCatTimePeriod('/shows/streaming', [1]) },
{ text: 'Anticipated', href: '/anticipated' },
{ text: 'Popular', href: '/popular' },
{ text: 'Favorited', href: '/favorited', submenu: menuTemplates.showsMoviesCatTimePeriod('/shows/favorited') },
{ text: 'Watched', href: '/watched', submenu: menuTemplates.showsMoviesCatTimePeriod('/shows/watched') },
{ text: 'Libraries', href: '/library', submenu: menuTemplates.showsMoviesCatTimePeriod('/shows/library') },
],
},
':is(.btn-mobile-links, .links-wrapper) a[href^="/movies"]': {
hrefPrefix: '/movies',
entries: [
{ text: 'Trending', href: '/trending' },
{ text: 'Recommendations', href: '/recommendations' },
{ text: 'Streaming Charts', href: '/streaming', submenu: menuTemplates.showsMoviesCatTimePeriod('/movies/streaming', [1]) },
{ text: 'Anticipated', href: '/anticipated' },
{ text: 'Popular', href: '/popular' },
{ text: 'Favorited', href: '/favorited', submenu: menuTemplates.showsMoviesCatTimePeriod('/movies/favorited') },
{ text: 'Watched', href: '/watched', submenu: menuTemplates.showsMoviesCatTimePeriod('/movies/watched') },
{ text: 'Libraries', href: '/library', submenu: menuTemplates.showsMoviesCatTimePeriod('/movies/library') },
{ text: 'Box Office', href: '/boxoffice' },
],
},
':is(.btn-mobile-links, .links-wrapper) a[href="/calendars"]': {
hrefPrefix: '/calendars',
entries: [
{ text: 'Personal', href: '/my/shows-movies', submenu: {
hrefPrefix: '/calendars/my',
entries: [
{ text: 'Shows & Movies', href: '/shows-movies' },
{ text: 'Shows', href: '/shows' },
{ text: 'Premieres', href: '/premieres' },
{ text: 'New Shows', href: '/new-shows' },
{ text: 'Finales', href: '/finales' },
{ text: 'Movies', href: '/movies' },
{ text: 'Streaming', href: '/streaming' },
{ text: 'DVD & Blu-ray', href: '/dvd' },
],
}},
{ text: 'General', href: '/shows', submenu: {
hrefPrefix: '/calendars',
entries: [
{ text: 'Shows', href: '/shows' },
{ text: 'Premieres', href: '/premieres' },
{ text: 'New Shows', href: '/new-shows' },
{ text: 'Finales', href: '/finales' },
{ text: 'Movies', href: '/movies' },
{ text: 'Streaming', href: '/streaming' },
{ text: 'DVD & Blu-ray', href: '/dvd' },
],
}},
],
},
':is(.btn-mobile-links, .links-wrapper) a[href="/discover"]': {
hrefPrefix: '/discover',
entries: [
{ text: 'Trends', href: '#trends' },
{ text: 'Featured Lists', href: '#lists' },
{ text: 'Summer TV Shows', href: '#featured-shows' },
{ text: 'Comments', href: '#comments' },
],
},
':is(.btn-mobile-links, .btn-tablet-links, .links-wrapper) a[href="/apps"]': {
hrefPrefix: '/apps',
entries: [
{ text: 'Android (official) ', href: "/a/trakt-android", useHrefPrefix: false, anchor: true },
{ text: 'iOS (official) ', href: "/a/trakt-ios", useHrefPrefix: false },
{ text: 'Android & iOS (3rd Party) ', href: "#community-apps" },
{ text: 'Android TV (official) ', href: "/a/trakt-android-tv", useHrefPrefix: false },
{ text: 'tvOS (official) ', href: "/a/trakt-tvos", useHrefPrefix: false },
{},
{ text: 'INTEGRATIONS' },
{ text: 'Media Centers', href: "#watching-wrapper" },
{ text: 'Plex Sync', href: "#plex-scrobblers-wrapper" },
{ text: 'Streaming Sync', href: "#streaming-scrobbler-wrapper" },
],
},
':is(.btn-mobile-links, .btn-tablet-links, .links-wrapper) a[href="https://forums.trakt.tv"]': {
hrefPrefix: 'https://forums.trakt.tv',
entries: [
{ text: 'Latest', href: '/latest' },
{ text: 'Top', href: '/top', submenu: {
hrefPrefix: 'https://forums.trakt.tv/top?period=',
entries: [
{ text: 'PERIOD' },
{ text: 'Day', href: 'daily', anchor: true },
{ text: 'Week', href: 'weekly' },
{ text: 'Month', href: 'monthly' },
{ text: 'Quarter', href: 'quarterly' },
{ text: 'Year', href: 'yearly' },
{ text: 'All Time', href: 'all' },
],
}},
{ text: 'Categories', href: '/categories', submenu: {
hrefPrefix: 'https://forums.trakt.tv',
entries: [
{ text: 'Announcements', href: '/c/announcements' },
{ text: 'Discussions', href: '/c/discussions', submenu: {
hrefPrefix: 'https://forums.trakt.tv/c/discussions',
entries: [
{ text: 'General', href: '/general' },
{ text: 'TV Shows', href: '/tv-shows' },
{ text: 'Movies', href: '/movies' },
{ text: 'Off Topic', href: '/off-topic' },
],
}},
{ text: 'Trakt', href: '/c/trakt', submenu: {
hrefPrefix: 'https://forums.trakt.tv/c/trakt',
entries: [
{ text: 'Product Updates', href: '/product-updates' },
{ text: 'Questions & Help', href: '/questions' },
{ text: 'Feature Requests', href: '/feature-requests' },
{ text: 'Lite', href: '/trakt-lite', anchor: true },
{ text: 'Release Notes', href: '/release-notes' },
{ text: 'VIP Beta Features', href: '/vip-beta-features' },
{ text: 'How To', href: '/how-to' },
],
}},
{ text: '3rd Party', href: '/c/3rd-party', submenu: {
hrefPrefix: 'https://forums.trakt.tv/c/3rd-party',
entries: [
{ text: 'Media Centers', href: '/media-centers' },
{ text: 'Mobile Apps', href: '/mobile-apps' },
{ text: 'Other', href: '/other', anchor: true },
],
}},
{ text: 'Support', href: '/c/support', submenu: {
hrefPrefix: 'https://forums.trakt.tv/c/support',
entries: [
{ text: 'Tutorials', href: '/tutorials' },
{ text: 'VIP Features', href: '/vip-features' },
{ text: 'Features', href: '/support-features' },
{ text: 'FAQ', href: '/faq', anchor: true },
],
}},
],
}},
{},
{ text: 'EXTERNAL' },
{ text: ' r/trakt', href: 'https://reddit.com/r/trakt', useHrefPrefix: false, submenu: {
hrefPrefix: 'https://reddit.com/r/trakt',
entries: [
{ text: 'SORT BY' },
{ text: 'Best', href: '/best' },
{ text: 'Hot', href: '/hot' },
{ text: 'New', href: '/new' },
{ text: 'Top', href: '/top', submenu: {
hrefPrefix: 'https://reddit.com/r/trakt/top?t=',
entries: [
{ text: 'PERIOD' },
{ text: 'Hour', href: 'hour' },
{ text: 'Day', href: 'day' },
{ text: 'Week', href: 'week', anchor: true },
{ text: 'Month', href: 'month' },
{ text: 'Year', href: 'year' },
{ text: 'All Time', href: 'all' },
],
}},
{ text: 'Rising', href: '/rising', anchor: true },
],
}},
{ text: ' Twitter', href: 'https://twitter.com/trakt', useHrefPrefix: false },
{ text: ' Mastodon', href: 'https://ruby.social/@trakt', useHrefPrefix: false },
],
},
};
///////////////////////////////////////////////////////////////////////////////////////////////
const buildMenuHtml = ({ hrefPrefix, entries }) =>
entries.reduce((acc, { text, href, useHrefPrefix = true, onclick, submenu }, i) =>
acc + (
text !== undefined && (href !== undefined || onclick !== undefined) ?
`` :
text !== undefined ? `` :
' '
), `';
const menuSelectorsAndHtml = Object.entries(menus).map(([targetSelector, menu]) => [targetSelector, buildMenuHtml(menu)]);
addStyles();
window.addEventListener('turbo:load', () => {
const $ = unsafeWindow.jQuery;
if (!$) return;
const $topNav = $('#top-nav');
$topNav.find('.links-wrapper > a').wrap('
');
$topNav.find('.links-wrapper a[href="/apps"]')
.next().remove()
.end().parent().removeClass('with-top-arrow').addClass('with-solid-bg');
$topNav.find('.btn-mobile-links li:has(> a[href="/apps"])')
.next().remove()
.end().unwrap();
$topNav.find('li.dark-knight')
.removeClass('dark-knight').html('Quick Actions ')
.before(' ').next().remove();
$topNav.find('.btn-profile li:has(> a.yir-loader)')
.wrapAll('')
.parent().before('Stats ')
$topNav.find('a[href="https://forums.trakt.tv"]').removeAttr('target');
menuSelectorsAndHtml.forEach(([targetSelector, menuHtml]) => $topNav.find(targetSelector).closest('li, div').addClass('with-ul-menu').append(menuHtml));
const $withUlMenu = $topNav.find(':is(.user-wrapper, .links-wrapper) .with-ul-menu');
$withUlMenu.off('click mouseover mouseout').on('touchend', function(evt) { // TODO :hover state gets set on second touchend
evt.stopPropagation();
if ($(evt.originalEvent.target).closest($(this).children().first()).length) {
if (!$(this).hasClass('selected')) {
evt.preventDefault();
$withUlMenu.not($(this).parents()).removeClass('selected');
$(this).addClass('selected');
} else {
$(this).removeClass('selected');
}
}
});
$('body').on('touchend', () => $withUlMenu.removeClass('selected'));
});
///////////////////////////////////////////////////////////////////////////////////////////////
function addStyles() {
GM_addStyle(`
#top-nav :is(.user-wrapper, .links-wrapper) > .with-ul-menu {
border-radius: 8px 8px 0 0 !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) > .with-ul-menu > a {
transition: color .2s !important;
}
#top-nav#top-nav :is(.user-wrapper, .links-wrapper) > .with-ul-menu > a:hover {
color: var(--brand-primary-300) !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) ul {
height: max-content;
width: max-content !important;
overflow-y: revert !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) > .with-ul-menu > ul {
top: 100% !important;
min-width: max(130px, 100%) !important;
}
#top-nav .links-wrapper > .with-ul-menu > ul {
border-radius: 8px 0 8px 8px !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) ul ul {
min-width: 100px !important;
border-radius: 8px !important;
border-top: revert !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) :is(ul a, .dropdown-header) {
padding: 6px 16px !important;
font-size: 14px !important;
margin: revert !important;
text-shadow: revert !important;
}
@media (width <= 767px) {
#top-nav :is(.user-wrapper, .links-wrapper) :is(ul a, .dropdown-header) {
padding: 6px 12px !important;
}
}
#top-nav#top-nav :is(.user-wrapper, .links-wrapper) ul a,
#top-nav .user-wrapper :is(.btn-mobile-links, .btn-tablet-links) > .icon {
color: #fff !important; /* light mode override */
}
#top-nav#top-nav :is(.user-wrapper, .links-wrapper) ul a:hover {
background-color: rgb(from var(--brand-primary) r g b / 92%) !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) .dropdown-header {
font-weight: bold;
text-transform: uppercase;
}
#top-nav :is(.user-wrapper, .links-wrapper) span.left {
width: 18px;
margin-right: 8px;
text-align: center;
}
#top-nav :is(.user-wrapper, .links-wrapper) span.right {
margin-left: 8px;
}
body.dark-knight #top-nav#top-nav .btn-profile a:has(> span.toggle-dark-mode):not(:hover) {
color: var(--brand-secondary) !important;
}
#top-nav .user-wrapper .btn > .menu {
cursor: initial; /* .btns set cursor: pointer; which is inheritable => applies to .dividers */
}
#top-nav :is(.user-wrapper, .links-wrapper) li > a:has(+ ul)::after {
content: "\\25B6";
display: inline-block;
float: right;
margin-left: 10px;
transform: scale(0.75) rotate(0deg);
transition: transform 0.2s;
}
#top-nav :is(.user-wrapper, .links-wrapper) :is(:hover, .selected) > a::after {
transform: rotate(180deg) scale(1);
}
#top-nav :is(.user-wrapper, .links-wrapper) ul {
display: none !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) :is(:hover, .selected) > ul {
display: block !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) ul ul {
--menu-columns: 5;
--menu-overlap: min(97%, calc((100vw - 155px * var(--menu-columns)) / var(--menu-columns) + 100%));
right: var(--menu-overlap) !important;
}
@media (767px < width <= 991px) {
#top-nav .links-wrapper ul ul {
--menu-columns: 3;
}
#top-nav .links-wrapper ul ul ul ul {
left: var(--menu-overlap) !important;
right: revert !important;
}
#top-nav .links-wrapper ul ul ul a::after {
transform: scale(0.75) rotate(180deg);
}
#top-nav .links-wrapper ul ul ul :is(:hover, .selected) > a::after {
transform: rotate(0deg) scale(1);
}
}
@media (width <= 767px) {
#top-nav :is(.user-wrapper, .links-wrapper) ul ul {
--menu-columns: 3;
}
#top-nav :is(.user-wrapper, .links-wrapper) ul ul ul ul {
left: var(--menu-overlap) !important;
right: revert !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) ul ul ul a::after {
transform: scale(0.75) rotate(180deg);
}
#top-nav :is(.user-wrapper, .links-wrapper) ul ul ul :is(:hover, .selected) > a::after {
transform: rotate(0deg) scale(1);
}
}
#top-nav :is(.user-wrapper, .links-wrapper) ul,
#top-nav :is(.user-wrapper, .links-wrapper) > .with-ul-menu:is(:hover, .selected) {
--nesting-depth: 0;
z-index: var(--nesting-depth);
background-color: hsl(0deg 0% calc(20% + var(--nesting-depth) * 2.5%) / 92%) !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) .divider {
background-color: hsl(0deg 0% calc(27% + var(--nesting-depth) * 2.5%)) !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) :is(.dropdown-header, em) {
color: hsl(0deg 0% calc(57% + var(--nesting-depth) * 2.5%)) !important;
}
#top-nav :is(.user-wrapper, .links-wrapper) ul ul {
--nesting-depth: 1;
}
#top-nav :is(.user-wrapper, .links-wrapper) ul ul ul {
--nesting-depth: 2;
}
#top-nav :is(.user-wrapper, .links-wrapper) ul ul ul ul {
--nesting-depth: 3;
}
#top-nav :is(.user-wrapper, .links-wrapper) ul ul ul ul ul {
--nesting-depth: 4;
}
`);
}
})('Trakt.tv | Nested Header Navigation Menus');
gmStorage['wkt34fcz'] && (async (moduleName) => {
/* global levenshteinDistance */
'use strict';
const customLinkHelperFns = {
encodeRfc3986: (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()),
getDefaultTorrentQuery: (i) =>
`${customLinkHelperFns.encodeRfc3986(i.title)}${i.type === 'movies' ? ` ${i.year}` : ''}` +
`${i.season !== undefined ? ` s${String(i.season).padStart(2, '0')}${i.episode ? `e${String(i.episode).padStart(2, '0')}` : ''}` : ''}`,
getDefaultDirectStreamingPath: (i) => `/${i.type === 'movies' ? `movie/${i.ids.tmdb}` : `tv/${i.ids.tmdb}/${i.season !== undefined ? i.season : '1'}/${i.episode ? i.episode : '1'}`}`,
getWnInnerHtml: ({ btnStyle = '', img, imgStyle = '', text, textStyle = '' }) =>
`` +
(img ? `
` : '') +
(text ? `
${text}
` : '') +
`
`,
getWnCategoryHtml: (category) => `[${watchNowCategories[category]}]`,
getDdgFaviconHtml: (site, style = '') => ` `,
getFaBrandsHtml: (brand, style = '') => ` `,
isAdultFemale: (i) => /female|non_binary/.test(i.gender) && i.birthday && Date.now() - new Date(i.birthday) > 18 * 365.25 * 24 * 60 * 60 * 1000,
fetchAnimeId: (i, site) =>
`fetch('https://arm.haglund.dev/api/v2/themoviedb?id=${i.ids.tmdb}').then((r) => r.json())` + // cached on disk for 6 hours
`.then((arr) => arr.map((e) => (e.levDist = userscriptLevDist('${i.ids.slug}${i.season > 1 ? `-${i.season_title.toLowerCase().replaceAll(/[ '"]/g, '-')}` : ''}', e['anime-planet'] ?? ''), e))` +
`.sort((a, b) => a.levDist - b.levDist)` +
`.find((e) => e['${site}'])?.['${site}'])`,
fetchWikidataClaim: (i, claimId) => // only for movies + shows
`fetch('https://query.wikidata.org/sparql?format=json&query=${customLinkHelperFns.encodeRfc3986( // cached on disk for 5 mins
`SELECT ?value WHERE { ` +
`?item wdt:${i.type === 'movies' ? 'P4947' : 'P4983'} "${i.ids.tmdb}" . ` +
`?item wdt:P31/wdt:P279* wd:${i.type === 'movies' ? 'Q11424' : 'Q5398426'} . ` +
`?item wdt:${claimId} ?value . ` +
`} LIMIT 1`
)}').then((r) => r.json())` +
`.then((r) => r.results.bindings[0]?.value?.value)`,
hideNativeExternalLink: (idSuffix) => `#external-link-${idSuffix} { display: none !important; }`,
getDdgTopResultRedirectUrl: (site, query) => `https://duckduckgo.com/?q=%5Csite%3A${site} ${customLinkHelperFns.encodeRfc3986(query)}`,
};
const watchNowCategories = {
animeAggregator: 'Anime Aggregator',
animeStreaming: 'Anime Streaming',
debrid: 'Debrid',
streaming: 'Streaming',
torrentAggregator: 'Torrent Aggregator',
usenetIndexer: 'Usenet Indexer',
};
const customWatchNowLinks = [
{ // https://ext.to/advanced/
buildHref: (i) => `https://ext.to/browse/?q=${customLinkHelperFns.getDefaultTorrentQuery(i)} ${customLinkHelperFns.encodeRfc3986(gmStorage.torrentResolution)} 265${/shows|seasons/.test(i.type) ? '&sort=size&order=desc' : '&sort=seeds&order=desc'}&with_adult=1`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #242730;', text: 'EXT', textStyle: 'background-image: linear-gradient(90deg, #3990f6 48.2%, #2c67a6 48.2% 66.2%, #3990f6 66.2%); background-clip: text; color: transparent; font-size: 50cqi; font-weight: 850; letter-spacing: -0.5px; padding-right: 3%;' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('torrentAggregator'),
},
{ // to open in desktop app use: buildHref: (i) => `stremio:///detail/...
buildHref: (i) => `https://web.stremio.com/#/detail/${i.type === 'movies' ? `movie/${i.ids.imdb}/${i.ids.imdb}` : `series/${i.ids.imdb}${i.type === 'seasons' ? `?season=${i.season}` : i.type === 'episodes' ? customLinkHelperFns.encodeRfc3986(`/${i.ids.imdb}:${i.season}:${i.episode}`) : ''}`}`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #19163a;', img: 'stremio', text: 'Stremio' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('debrid'),
},
{
buildHref: (i) => `${customLinkHelperFns.fetchAnimeId(i, 'myanimelist')}` +
`.then((id) => id ?? userscriptGmXhrCustomLinks({ url: 'https://kuroiru.co/backend/search', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: 'q=${customLinkHelperFns.encodeRfc3986(i.title)}', responseType: 'json' }).then((r) => r.response[0]?.id))` +
`.then((id) => 'https://kuroiru.co/anime/' + id + '/ep${i.episode ?? '1'}')`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #191919;', img: 'kuroiru' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('animeAggregator'),
includeIf: (i) => i.genres.includes('anime'),
},
{
buildHref: (i) => `${customLinkHelperFns.fetchAnimeId(i, 'anilist')}.then((id) => 'https://animetsu.cc' + (id ? '/watch/' + id + '?ep=${i.episode ?? '1'}&subType=dub&server=' : '/search?query=${customLinkHelperFns.encodeRfc3986(i.title)}'))`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #111;', text: 'Animetsu', textStyle: 'font-family: GangOfThree; font-size: 18cqi;' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('animeStreaming'),
includeIf: (i) => i.genres.includes('anime'),
addStyles: `@font-face { font-family: "GangOfThree"; src: url("${GM_getResourceURL('animetsu')}") format("woff2"); font-display: block; }`,
},
{ // type=dub is bugged
buildHref: (i) => `${customLinkHelperFns.fetchAnimeId(i, 'anilist')}.then((id) => 'https://anidap.se' + (id ? '/watch?ep=${i.episode ?? '1'}&type=dub&provider=&id=' + id : '/search?q=${customLinkHelperFns.encodeRfc3986(i.title)}'))`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #1f2728;', img: 'anidap', imgStyle: 'transform: scale(2.2);' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('animeStreaming'),
includeIf: (i) => i.genres.includes('anime'),
},
{
buildHref: (i) => `${customLinkHelperFns.fetchAnimeId(i, 'anilist')}.then((id) => 'https://www.miruro.to' + (id ? '/watch/' + id + '/episode-${i.episode ?? '1'}' : '/search?query=${customLinkHelperFns.encodeRfc3986(i.title)}'))`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #0e0e0e;', img: 'miruro' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('animeStreaming'),
includeIf: (i) => i.genres.includes('anime'),
},
{
buildHref: (i) => `https://knaben.org/search/${customLinkHelperFns.getDefaultTorrentQuery(i)} ${customLinkHelperFns.encodeRfc3986(gmStorage.torrentResolution)} (265|av1)/${i.type === 'movies' ? '3000000' : i.genres.includes('anime') ? '6000000' : '2000000'}/1/${/shows|seasons/.test(i.type) ? 'bytes' : 'seeders'}`,
innerHtml: `${GM_getResourceText('knaben').replace('KNABEN DATABASE
`,
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('torrentAggregator'),
},
{
buildHref: (i) => `https://iframe.pstream.mov/embed/tmdb-${i.type === 'movies' ? `movie-${i.ids.tmdb}` : `tv-${i.ids.tmdb}/${i.season !== undefined ? i.season : '1'}/${i.episode ? i.episode : '1'}`}`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #110d1b;', img: 'pstream', text: 'P-Stream', textStyle: 'font-size: 11cqi;' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('streaming'),
},
{
buildHref: (i) => `https://www.cineby.gd${customLinkHelperFns.getDefaultDirectStreamingPath(i)}?play=true`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #440000;', img: 'cineby', text: 'Cineby', textStyle: 'font-family: system-ui; font-size: 17cqi;' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('streaming'),
},
{
buildHref: (i) => `https://hexa.su/watch${customLinkHelperFns.getDefaultDirectStreamingPath(i)}`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #111317;', img: 'hexa' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('streaming'),
},
{
buildHref: (i) => `https://www.fmovies.gd/watch${customLinkHelperFns.getDefaultDirectStreamingPath(i)}`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #18252b;', text: 'FMOVIES+', textStyle: 'background-image: linear-gradient(to right, rgb(13 202 240), rgb(13 202 240 / 35%)); background-clip: text; color: transparent; font-family: system-ui; font-size: 15cqi; font-weight: 800; letter-spacing: 0.3px; border: 2px solid rgb(13 202 240 / 25%); border-radius: 5px; padding: 5%;' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('streaming'),
},
{ // https://scenenzbs.com/search#adv-subtabs
buildHref: (i) => `https://scenenzbs.com/search/${customLinkHelperFns.getDefaultTorrentQuery(i)} ${customLinkHelperFns.encodeRfc3986(gmStorage.torrentResolution)} (265|av1)`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #212529;', img: 'scenenzbs', imgStyle: 'transform: scale(1.8) translateY(-1px);' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('usenetIndexer'),
},
{
buildHref: (i) => `https://x.debridmediamanager.com/${i.ids.imdb}`,
innerHtml: customLinkHelperFns.getWnInnerHtml({ btnStyle: 'background: #2e3e51;', img: 'dmm', imgStyle: 'transform: scale(1.7);', text: 'Debrid Media Manager', textStyle: 'font-size: 12cqi;' }),
tooltipHtml: customLinkHelperFns.getWnCategoryHtml('debrid'),
},
];
const customExternalLinks = [
{
buildHref: (i) => `/${/seasons|episodes/.test(i.type) ? 'shows' : i.type}/${i.ids.slug}${i.season !== undefined ? `/seasons/${i.season}${i.episode ? `/episodes/${i.episode}` : ''}` : ''}/wikipedia`,
innerHtml: customLinkHelperFns.getFaBrandsHtml('wikipedia-w'),
tooltipHtml: 'Wikipedia',
addStyles: customLinkHelperFns.hideNativeExternalLink('wikipedia'),
},
{
buildHref: (i) => `https://duckduckgo.com/?q=site%3Areddit.com Discussion ${customLinkHelperFns.encodeRfc3986(i.title)}${i.type === 'movies' ? ` ${i.year}` : ''}${i.season !== undefined ? ` Season ${i.season}${i.episode ? ` Episode ${i.episode}` : ''}` : ''}`,
innerHtml: customLinkHelperFns.getFaBrandsHtml('reddit'),
tooltipHtml: 'Reddit',
includeIf: (i) => i.type !== 'people',
},
{
buildHref: (i) => `https://letterboxd.com/tmdb/${i.ids.tmdb}`,
innerHtml: customLinkHelperFns.getFaBrandsHtml('letterboxd'),
tooltipHtml: 'Letterboxd',
includeIf: (i) => i.type === 'movies',
},
{
buildHref: (i) => `https://reversetv.enzon19.com/${/seasons|episodes/.test(i.type) ? 'shows' : i.type}/${i.ids.slug}${i.season !== undefined ? `/seasons/${i.season_old ?? i.season}${i.episode ? `/episodes/${i.episode_old ?? i.episode}` : ''}` : ''}`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('reversetv.enzon19.com', '--extra-filters: invert(1);'),
tooltipHtml: 'ReverseTV',
includeIf: (i) => i.type !== 'people',
},
{
buildHref: (i) => `userscriptGmXhrCustomLinks({ url: 'https://moviemaps.org/ajax/search?token=${customLinkHelperFns.encodeRfc3986(i.title)}&max_matches=1&use_similar=1', responseType: 'json' })` +
`.then((r) => 'https://moviemaps.org' + (r.response[0]?.url ?? '/search?q=${customLinkHelperFns.encodeRfc3986(i.title)}'))`,
innerHtml: ` `,
tooltipHtml: 'MovieMaps',
includeIf: (i) => i.type !== 'people' && !['animation', 'anime'].some((g) => i.genres.includes(g)),
},
{
buildHref: (i) => customLinkHelperFns.getDdgTopResultRedirectUrl('fandom.com', i.title),
innerHtml: customLinkHelperFns.getDdgFaviconHtml('fandom.com', '--extra-filters: invert(1);'),
tooltipHtml: 'Fandom',
includeIf: (i) => i.type !== 'people',
},
{
buildHref: (i) => `https://aznude.com/search.html?q=${customLinkHelperFns.encodeRfc3986(i.name ?? i.title)}`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('aznude.com', 'transform: scale(1.1);'),
tooltipHtml: 'AZNude',
includeIf: (i) => gmStorage.includeNsfwLinks && (i.type === 'people' && customLinkHelperFns.isAdultFemale(i) || i.type !== 'people' && !['animation', 'anime'].some((g) => i.genres.includes(g))),
},
{
buildHref: (i) => `userscriptGmXhrCustomLinks({ url: 'https://celeb.gate.cc/search.json?q=${customLinkHelperFns.encodeRfc3986(i.name)}', responseType: 'json' })` +
`.then((r) => 'https://celeb.gate.cc/' + (r.response[0] ? r.response[0].url + '?s=i.clicks.total&cdir=desc#images' : 'search?q=${customLinkHelperFns.encodeRfc3986(i.title)}'))`,
innerHtml: ' ',
tooltipHtml: 'CelebGate',
includeIf: (i) => gmStorage.includeNsfwLinks && i.type === 'people' && customLinkHelperFns.isAdultFemale(i),
},
{
buildHref: (i) => `https://rule34.xxx/index.php?page=post&s=list&tags=sort:score ${i.title.toLowerCase().replaceAll(/[^a-z0-9-:; ]/g, '').replaceAll(' ', '_')}`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('rule34.xxx'),
tooltipHtml: 'Rule 34',
includeIf: (i) => gmStorage.includeNsfwLinks && i.type !== 'people',
},
{
buildHref: (i) => `${customLinkHelperFns.fetchAnimeId(i, 'myanimelist')}.then((id) => 'https://myanimelist.net' + (id ? '/anime/' + id${i.episode ? ` + '/x/episode/${i.episode}'` : ''} : '/search/all?q=${customLinkHelperFns.encodeRfc3986(i.title)}'))`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('myanimelist.net'),
tooltipHtml: 'MyAnimeList',
includeIf: (i) => i.genres?.includes('anime'),
},
{
buildHref: (i) => `${customLinkHelperFns.fetchAnimeId(i, 'anilist')}.then((id) => 'https://anilist.co' + (id ? '/anime/' + id : '/search/anime?search=${customLinkHelperFns.encodeRfc3986(i.title)}'))`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('anilist.co'),
tooltipHtml: 'AniList',
includeIf: (i) => i.genres?.includes('anime'),
},
{
buildHref: (i) => `${customLinkHelperFns.fetchAnimeId(i, 'anidb')}.then((id) => 'https://anidb.net/anime/' + (id ?? '?adb.search=${customLinkHelperFns.encodeRfc3986(i.title)}'))`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('anidb.net'),
tooltipHtml: 'AniDB',
includeIf: (i) => i.genres?.includes('anime'),
},
{
buildHref: (i) => `${customLinkHelperFns.fetchAnimeId(i, 'livechart')}.then((id) => 'https://livechart.me' + (id ? '/anime/' + id : '/search?q=${customLinkHelperFns.encodeRfc3986(i.title)}'))`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('livechart.me'),
tooltipHtml: 'LiveChart',
includeIf: (i) => i.genres?.includes('anime'),
},
{
buildHref: (i) => `https://www.themoviedb.org/${i.type === 'people' ? 'person' : i.type === 'movies' ? 'movie' : 'tv'}/${i.ids.tmdb}${i.season !== undefined ? `/season/${i.season}${i.episode ? `/episode/${i.episode}` : ''}` : ''}`,
innerHtml: ' ',
tooltipHtml: 'TMDB',
addStyles: customLinkHelperFns.hideNativeExternalLink('tmdb'),
},
{
buildHref: (i) => `https://www.imdb.com/${i.type === 'people' ? 'name' : 'title'}/${i.episode_ids?.imdb ?? i.ids.imdb}${i.season && !i.episode ? `/episodes/?season=${i.season}` : ''}`,
innerHtml: customLinkHelperFns.getFaBrandsHtml('imdb', 'font-size: 24px;'),
tooltipHtml: 'IMDb',
addStyles: customLinkHelperFns.hideNativeExternalLink('imdb'),
},
{
buildHref: (i) => `${customLinkHelperFns.fetchWikidataClaim(i, i.type === 'movies' ? 'P12196' : 'P4835')}.then((id) => id ? 'https://www.thetvdb.com/dereferrer/${i.type === 'movies' ? 'movie' : 'series'}/' + id : '${customLinkHelperFns.getDdgTopResultRedirectUrl('thetvdb.com', i.title)}')`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('thetvdb.com'),
tooltipHtml: 'TheTVDB',
includeIf: (i) => i.type !== 'people',
},
{
buildHref: (i) => i.type === 'people' ?
`fetch('https://api.tvmaze.com/search/people?q=${customLinkHelperFns.encodeRfc3986(i.name)}').then((r) => r.json()).then((r) => r[0]?.person.url ?? 'https://www.tvmaze.com/search?q=${customLinkHelperFns.encodeRfc3986(i.name)}')` :
`fetch('https://api.tvmaze.com/lookup/shows?imdb=${i.ids.imdb}').then((r) => ${!i.season ?
`r.url.replace('api.', '')` :
`fetch(r.url + '${i.episode ? `/episodebynumber?season=${i.season}&number=${i.episode}` : `/seasons`}').then((r2) => r2.json()).then((r2) => r2${i.episode ? '' : `[${i.season-1}]`}.url)`})`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('tvmaze.com'),
tooltipHtml: 'TVmaze',
includeIf: (i) => /shows|seasons|episodes|people/.test(i.type),
},
{
buildHref: (i) => i.season_trailer ?? (i.type !== 'episodes' ? i.trailer : null) ?? customLinkHelperFns.getDdgTopResultRedirectUrl('youtube.com', `${i.title}${i.type === 'movies' ? ` ${i.year}` : ''}${i.season ? ` Season ${i.season}` : ''} Official Trailer`),
innerHtml: customLinkHelperFns.getFaBrandsHtml('youtube'),
tooltipHtml: 'YouTube Trailer',
includeIf: (i) => i.type !== 'people',
},
{
buildHref: (i) => `https://www.youtube.com/results?search_query=${customLinkHelperFns.encodeRfc3986(i.name)} Interview`,
innerHtml: customLinkHelperFns.getFaBrandsHtml('youtube'),
tooltipHtml: 'YouTube Interviews',
includeIf: (i) => i.type === 'people',
},
{
buildHref: (i) => `${customLinkHelperFns.fetchWikidataClaim(i, 'P1258')}.then((id) => id ? ` +
`'https://www.rottentomatoes.com/' + id${i.season ? ` + '/s${String(i.season).padStart(2, '0')}${i.episode ? `/e${String(i.episode).padStart(2, '0')}` : ''}'` : ''} : ` +
`'${customLinkHelperFns.getDdgTopResultRedirectUrl('rottentomatoes.com', i.title + (i.season ? ` Season ${i.season}${i.episode ? ` Episode ${i.episode}` : ''}` : ''))}')`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('rottentomatoes.com', '--extra-filters: brightness(1.15) contrast(1.3);'),
tooltipHtml: 'Rotten Tomatoes',
includeIf: (i) => i.type !== 'people',
},
{
buildHref: (i) => `${customLinkHelperFns.fetchWikidataClaim(i, 'P1712')}.then((id) => id ? ` +
`'https://www.metacritic.com/' + id${i.season ? ` + '/season-${i.season}${i.episode ? `/episode-${i.episode}-${i.episode_title.toLowerCase().replaceAll(/[^a-z0-9- ]/g, '').replaceAll(' ', '-')}` : ''}'` : ''} : ` +
`'${customLinkHelperFns.getDdgTopResultRedirectUrl('metacritic.com', i.title + (i.season ? ` Season ${i.season}${i.episode ? ` Episode ${i.episode}` : ''}` : ''))}')`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('metacritic.com'),
tooltipHtml: 'Metacritic',
includeIf: (i) => i.type !== 'people',
},
{
buildHref: (i) => $(`.btn-watch-now[data-url="${i.item_url}"] ~ .external #external-link-justwatch`).attr('href') ?? $('#powered_by_url').attr('value'),
innerHtml: ' ',
tooltipHtml: 'JustWatch',
includeIf: (i) => i.type !== 'people' && $(`.btn-watch-now[data-url="${i.item_url}"] ~ .external #external-link-justwatch, #watch-now-content[data-url="${i.item_url}"] > #powered_by_url`).length,
addStyles: customLinkHelperFns.hideNativeExternalLink('justwatch'),
},
{
buildHref: (i) => `https://open.spotify.com/search/${customLinkHelperFns.encodeRfc3986(i.title)} Soundtrack`,
innerHtml: customLinkHelperFns.getFaBrandsHtml('spotify'),
tooltipHtml: 'Spotify',
includeIf: (i) => i.type !== 'people',
},
{
buildHref: (i) => i.type === 'movies' ?
`https://fanart.tv/movie/${i.ids.tmdb}` :
`fetch('https://api.tvmaze.com/lookup/shows?imdb=${i.ids.imdb}').then((r) => r.ok ? ` +
`r.json().then((r) => 'https://fanart.tv/series/' + r.externals.thetvdb) : ` +
`userscriptGmXhrCustomLinks({ url: 'https://fanart.tv/api/search.php?section=tv&s=${customLinkHelperFns.encodeRfc3986(i.title)}', responseType: 'json' }).then((r) => r.response[0]?.link))`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('fanart.tv'),
tooltipHtml: 'Fanart.tv',
includeIf: (i) => i.type !== 'people',
addStyles: customLinkHelperFns.hideNativeExternalLink('fanart'),
},
{
buildHref: (i) => `https://mediux.pro/${i.type === 'movies' ? 'movies' : 'shows'}/${i.ids.tmdb}`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('mediux.pro'),
tooltipHtml: 'MediUX',
includeIf: (i) => i.type !== 'people',
},
{
buildHref: (i) => `https://youglish.com/pronounce/${customLinkHelperFns.encodeRfc3986(i.name)}/english`,
innerHtml: customLinkHelperFns.getDdgFaviconHtml('youglish.com'),
tooltipHtml: 'YouGlish',
includeIf: (i) => i.type === 'people',
},
{
buildHref: (i) => [...new DOMParser().parseFromString(GM_getResourceText('oracleofbacon'), 'text/html').querySelectorAll('#main .top-1000 li')].some((li) => li.textContent.split('(')[0].trim() === i.name) ?
'https://oracleofbacon.org/graph.php?who=' + customLinkHelperFns.encodeRfc3986(i.name) :
`https://oracleofbacon.org/movielinks.php?a=Kevin+Bacon&b=${customLinkHelperFns.encodeRfc3986(i.name)}&use_using=1&u0=on&u1=on&use_role_types=1&rt0=on&rt1=on&rt3=on&company=&use_genres=1&g0=on&g4=on&g8=on&g12=on&g20=on&g24=on&g1=on&g5=on&g9=on&g13=on&g21=on&g25=on&g2=on&g10=on&g14=on&g18=on&g22=on&g26=on&g3=on&g7=on&g11=on&g15=on&g19=on&g27=on`,
innerHtml: ` `,
tooltipHtml: 'Oracle of Bacon',
includeIf: (i) => i.type === 'people',
},
{
buildHref: (i) => i.homepage,
innerHtml: ' ',
tooltipHtml: 'Official Site',
includeIf: (i) => i.homepage,
addStyles: customLinkHelperFns.hideNativeExternalLink('official'),
},
{
buildHref: (i) => $('#external-link-instagram').attr('href'),
innerHtml: customLinkHelperFns.getFaBrandsHtml('instagram'),
tooltipHtml: 'Instagram',
includeIf: (i) => $(`:is(.btn-watch-now, .poster[data-person-id])[data-url="${i.item_url}"] ~ .external #external-link-instagram`).length,
addStyles: customLinkHelperFns.hideNativeExternalLink('instagram'),
},
{
buildHref: (i) => $('#external-link-twitter').attr('href'),
innerHtml: customLinkHelperFns.getFaBrandsHtml('x-twitter'),
includeIf: (i) => $(`:is(.btn-watch-now, .poster[data-person-id])[data-url="${i.item_url}"] ~ .external #external-link-twitter`).length,
addStyles: customLinkHelperFns.hideNativeExternalLink('twitter'),
},
{
buildHref: (i) => $('#external-link-facebook').attr('href'),
innerHtml: customLinkHelperFns.getFaBrandsHtml('facebook'),
tooltipHtml: 'Facebook',
includeIf: (i) => $(`:is(.btn-watch-now, .poster[data-person-id])[data-url="${i.item_url}"] ~ .external #external-link-facebook`).length,
addStyles: customLinkHelperFns.hideNativeExternalLink('facebook'),
},
];
///////////////////////////////////////////////////////////////////////////////////////////////
let $, traktApiWrapper;
unsafeWindow.userscriptLevDist = levenshteinDistance;
unsafeWindow.userscriptGmOpenInTab = GM_openInTab;
unsafeWindow.userscriptGmXhrCustomLinks = GM.xmlHttpRequest;
unsafeWindow.userscriptItemDataCache = {};
const gmStorage = { maxSidebarWnLinks: 4, torrentResolution: '1080p', includeNsfwLinks: false, ...(GM_getValue('customLinks')) };
GM_setValue('customLinks', gmStorage);
addStyles();
document.addEventListener('turbo:load', async () => {
$ ??= unsafeWindow.jQuery;
traktApiWrapper ??= unsafeWindow.userscriptTraktApiWrapper;
if (!$) return;
const $watchNowContent = $('#watch-now-content'),
$searchResults = $('#header-search-autocomplete-results'),
itemUrl = $('.notable-summary').attr('data-url') || $('.sidebar').attr('data-url');
$(document).off('ajaxSuccess.userscript83278').on('ajaxSuccess.userscript83278', (_evt, _xhr, opt) => {
if ($watchNowContent.length && opt.url.includes('/streaming_links?country=')) addCustomLinksToModal($watchNowContent);
if ($searchResults.length && /^\/search\/autocomplete(?!\/(people|lists|users))/.test(opt.url)) addWatchNowLinksToSearchResults($searchResults);
});
if (/^\/(movies|shows|seasons|episodes|people)\/[^\/]+$/.test(itemUrl)) { // regex filters list + alternate season itemUrls
const pathBeforeFetch = location.pathname,
itemData = await getItemData(itemUrl);
if (pathBeforeFetch === location.pathname) {
addExternalLinksToSidebar(itemData);
addExternalLinksToAdditionalStats(itemData);
if (itemData.type !== 'people') {
addWatchNowLinksToSidebar(itemData);
addWatchNowLinksToActionButtons(itemData);
}
}
}
}, { capture: true });
///////////////////////////////////////////////////////////////////////////////////////////////
const getCustomLinkHtml = (l, itemData, innerHtmlOverride) => {
const buildHref = l.buildHref(itemData);
return ` { $(this).attr('href', href); userscriptGmOpenInTab(href, { active: true, setParent: true }); });" ` +
`onauxclick="event.preventDefault(); ` +
`$(this).removeAttr('onclick onauxclick'); ` +
`${buildHref}.then((href) => { $(this).attr('href', href); if (event.button === 1) userscriptGmOpenInTab(href, { setParent: true }); });"`} ` +
`target="_blank" rel="noreferrer" data-original-title="${l.tooltipHtml ?? ''}">${innerHtmlOverride ?? l.innerHtml} `;
};
function addExternalLinksToSidebar(itemData) {
$(customExternalLinks
.filter((l) => l.includeIf ? l.includeIf(itemData) : true)
.map((l) => getCustomLinkHtml(l, itemData))
.join('')
).prependTo('#info-wrapper .sidebar .external > li').tooltip({
container: 'body',
placement: 'bottom',
html: true,
});
}
function addExternalLinksToAdditionalStats(itemData) {
$(customExternalLinks
.filter((l) => l.includeIf ? l.includeIf(itemData) : true)
.map((l) => getCustomLinkHtml(l, itemData, $(l.innerHtml).attr('alt') ?? l.tooltipHtml) + ', ')
.join('')
).insertAfter('.additional-stats.with-external-links label:contains("Links")');
}
function addWatchNowLinksToSidebar(itemData) {
const $sidebar = $('#info-wrapper .sidebar');
if ($sidebar.has('.btn-watch-now').length && !$sidebar.has('.streaming-links').length) {
$sidebar.find('.btn-watch-now').before(
``
);
}
$(customWatchNowLinks
.filter((l) => l.includeIf ? l.includeIf(itemData) : true)
.map((l) => getCustomLinkHtml(l, itemData))
.join('')
).prependTo($sidebar.find('.streaming-links .sources'))
.attr('data-container', 'body').attr('data-html', 'true').tooltip(); // loadWatchnowModal() calls $('.streaming-links a').tooltip('destroy').tooltip({ html: true })
}
function addWatchNowLinksToActionButtons(itemData) {
const $actionButtons = $('#overview .action-buttons');
if ($actionButtons.length && !$actionButtons.has('.btn-watch-now').length) {
const $sidebarBtnWatchNow = $('#info-wrapper .sidebar .btn-watch-now'),
dataSourceCounts = $sidebarBtnWatchNow.attr('data-source-counts'),
itemUrl = $sidebarBtnWatchNow.attr('data-url');
if (!dataSourceCounts || !itemUrl) return;
$actionButtons.prepend(
`` +
`` +
` ` +
`` +
`
Watch Now
` +
`
0 streaming services
` +
`
` +
` `
);
}
$(customWatchNowLinks
.filter((l) => l.includeIf ? l.includeIf(itemData) : true)
.map((l) => getCustomLinkHtml(l, itemData))
.join('')
).prependTo($actionButtons.find('.sources'))
.attr('data-html', 'true').tooltip();
}
async function addWatchNowLinksToSearchResults($searchResults) {
$searchResults.find('> .search-result:not([data-type="people"])').each(async function() { // search-by-id endpoints can return people
const itemData = await getItemData($(this).attr('data-url'));
if (!$(this).has('.streaming-links').length) {
$(this).append(
``
);
}
$(customWatchNowLinks
.filter((l) => l.includeIf ? l.includeIf(itemData) : true)
.map((l) => getCustomLinkHtml(l, itemData))
.join('')
).prependTo($(this).find('.streaming-links .sources')).tooltip({
placement: 'bottom',
html: true,
}).on('click', (evt) => evt.stopPropagation()); // native click handler on .search-result
});
}
async function addCustomLinksToModal($watchNowContent) {
const itemData = await getItemData($watchNowContent.attr('data-url'));
$watchNowContent.find('> .streaming-links').prepend(
`Custom Links
` +
`
` +
`
` +
($watchNowContent.has('.no-links').length ? `
` : '')
).end().find('> .title-wrapper .titles').append(
`${itemData.episode_overview ?? itemData.season_overview ?? itemData.overview ?? 'No overview available.'}
`
);
$(customExternalLinks
.filter((l) => l.includeIf ? l.includeIf(itemData) : true)
.map((l) => getCustomLinkHtml(l, itemData))
.join('')
).appendTo($watchNowContent.find('.section.external')).tooltip({
placement: 'bottom',
html: true,
});
$(customWatchNowLinks
.filter((l) => l.includeIf ? l.includeIf(itemData) : true)
.map((l) => getCustomLinkHtml(l, itemData, l.innerHtml + (l.tooltipHtml ? `${l.tooltipHtml}
` : '')))
.join('')
).appendTo($watchNowContent.find('.section.external + .section'));
}
///////////////////////////////////////////////////////////////////////////////////////////////
async function getItemData(itemUrl) {
return (unsafeWindow.userscriptItemDataCache[itemUrl] ??= await (
(traktApiWrapper ? getItemDataFromTraktApi : getItemDataFromSummaryPage)(itemUrl)
.then((i) => i.type === 'episodes' && i.genres.includes('anime') ? verifyEpisodeGroup(i) : i)
));
}
async function getItemDataFromTraktApi(itemUrl) {
const itemUrlSplit = itemUrl.split('/').filter(Boolean),
type = itemUrlSplit[0];
let itemDoc, $notableSummary, showData, seasonData, episodeData;
// itemUrlSplit[1] is trakt-id for seasons + eps and slug for shows + movs + people, can be numeric for shows e.g. /shows/1883 which gets interpreted as trakt-id by api
if (type === 'seasons' || type === 'shows' && !isNaN(itemUrlSplit[1])) {
const resp = await fetch(itemUrl);
if (!resp.ok) throw new Error(`getItemData: Fetching ${resp.url} failed with status: ${resp.status}`);
itemDoc = new DOMParser().parseFromString(await resp.text(), 'text/html');
$notableSummary = $(itemDoc).find('.notable-summary');
}
if (type === 'episodes') {
[{ show: showData, episode: episodeData }] = await traktApiWrapper.search.id({ id_type: 'trakt', id: itemUrlSplit[1], type: 'episode', extended: 'full' }); // doesn't work with slugs or seasons
seasonData = await traktApiWrapper.seasons.season({ id: showData.ids.trakt, season: episodeData.season, extended: 'full' });
};
const itemData = {
item_url: itemUrl,
type,
...(type !== 'episodes' && {
...(await traktApiWrapper[type === 'seasons' ? 'shows' : type].summary({ id: $notableSummary?.attr('data-show-id') ?? itemUrlSplit[1], extended: 'full' })), // cached on disk for 8 hours
}),
...(type === 'seasons' && {
season: +$notableSummary.attr('data-season-number'),
season_title: $(itemDoc).find('#level-up-link[href*="/seasons/"]').text() || $(itemDoc).find('#summary-wrapper .mobile-title h1').contents()[0]?.textContent.trim(),
season_overview: $(itemDoc).find('#overview #overview').text() || null,
season_trailer: $(itemDoc).find('#overview .affiliate-links .trailer').attr('href') || null,
}),
...(type === 'episodes' && {
...showData,
season: seasonData.number,
season_title: seasonData.title,
season_original_title: seasonData.original_title,
season_ids: seasonData.ids,
season_first_aired: seasonData.first_aired,
season_overview: seasonData.overview,
season_episode_count: seasonData.episode_count,
episode: episodeData.number,
episode_title: episodeData.title,
episode_original_title: episodeData.original_title,
episode_ids: episodeData.ids,
episode_first_aired: episodeData.first_aired,
episode_overview: episodeData.overview,
episode_type: episodeData.episode_type,
}),
};
return itemData;
}
async function getItemDataFromSummaryPage(itemUrl) {
let itemDoc, itemDoc2;
const resp = await fetch(itemUrl);
if (!resp.ok) throw new Error(`getItemData: Fetching ${resp.url} failed with status: ${resp.status}`);
itemDoc = new DOMParser().parseFromString(await resp.text(), 'text/html');
if (resp.url.includes('/seasons/')) {
const resp2 = await fetch(resp.url.split('/seasons/')[0]);
if (!resp2.ok) throw new Error(`getItemData: Fetching ${resp2.url} failed with status: ${resp2.status}`);
itemDoc2 = new DOMParser().parseFromString(await resp2.text(), 'text/html');
}
const type = itemUrl.split('/').filter(Boolean)[0],
$notableSummary = $(itemDoc).find('.notable-summary'),
$additionalStatsLi = $(itemDoc).find('.additional-stats > li'),
$additionalStatsLi2 = itemDoc2 ? $(itemDoc2).find('.additional-stats > li') : undefined,
filterStatsElemsByLabel = (labelText, $statsElems = $additionalStatsLi) => $statsElems.filter((_, e) => $(e).find('label').text().toLowerCase() === labelText);
const itemData = {
item_url: itemUrl,
type,
ids: {
trakt: +($notableSummary.attr('data-movie-id') ?? $notableSummary.attr('data-show-id') ?? $notableSummary.attr('data-person-id')),
imdb: $(itemDoc2 ?? itemDoc).find('#external-link-imdb').attr('href')?.match(/(?:tt|nm)\d+/)?.[0],
tmdb: +$(itemDoc).find('#external-link-tmdb').attr('href')?.match(/\d+/)?.[0] || null,
slug: resp.url.split('/')[4],
},
homepage: $(itemDoc2 ?? itemDoc).find('#external-link-official').attr('href') ?? null,
...(type !== 'people' && {
title: $(itemDoc).find(':is(body > [itemtype$="Movie"], body > [itemtype$="TVSeries"], body > [itemtype] > [itemtype$="TVSeries"]) > meta[itemprop="name"]').attr('content')?.match(/(.+?)(?: \(\d{4}\))?$/)?.[1],
original_title: filterStatsElemsByLabel('original title', $additionalStatsLi2).contents().get(-1)?.textContent,
year: +$(itemDoc2 ?? itemDoc).find('#summary-wrapper .mobile-title .year')[0]?.textContent || null,
genres: $additionalStatsLi.find('[itemprop="genre"]').map((_, e) => $(e).text().toLowerCase()).get(),
overview: $(itemDoc2 ?? itemDoc).find('#overview #overview').text() || null,
trailer: $(itemDoc2 ?? itemDoc).find('#overview .affiliate-links .trailer').attr('href') || null,
}),
...(/seasons|episodes/.test(type) && {
season: +$notableSummary.attr('data-season-number'),
season_title: $(itemDoc).find('#level-up-link[href*="/seasons/"]').text() || $(itemDoc).find('#summary-wrapper .mobile-title h1').contents()[0]?.textContent.trim(),
}),
...(type === 'seasons' && {
season_overview: $(itemDoc).find('#overview #overview').text() || null,
season_trailer: $(itemDoc).find('#overview .affiliate-links .trailer').attr('href') || null,
}),
...(type === 'episodes' && {
episode: +$notableSummary.attr('data-episode-number'),
episode_title: $(itemDoc).find('body > [itemtype$="TVEpisode"] > meta[itemprop="name"]').attr('content'),
episode_overview: $(itemDoc).find('#overview #overview').text() || null,
episode_ids: {
imdb: $(itemDoc).find('#external-link-imdb').attr('href')?.match(/tt\d+/)?.[0],
},
}),
...(type === 'people' && {
name: $(itemDoc).find('body > [itemtype$="Person"] > meta[itemprop="name"]').attr('content'),
gender: filterStatsElemsByLabel('gender').contents().get(-1)?.textContent.toLowerCase().replace('-', '_'),
birthday: filterStatsElemsByLabel('birthday').children().last().attr('data-date'),
}),
};
if (Object.hasOwn(itemData, 'original_title')) itemData.original_title ??= itemData.title;
return itemData;
}
async function verifyEpisodeGroup(itemData) { // some anime don't default to the "correct" episode group e.g. /shows/cowboy-bebop (eps out of order), /shows/solo-leveling (1 instead of 2 seasons)
const normalizeTitle = (title) => title.trim().toLowerCase().replaceAll(/[.,]/g, '').replace(/\((\d)\)$/, (_m, p1) => 'i'.repeat(+p1)),
showData = await fetch(`https://api.tvmaze.com/lookup/shows?imdb=${itemData.ids.imdb}`) // max 20 calls / 10s; cached on disk for 1 hour
.then((r) => r.ok ? fetch(r.url + '?embed[]=seasons&embed[]=episodes') : null).then((r) => r?.ok ? r.json() : null),
episodeData = showData?._embedded.episodes.find((ep) => normalizeTitle(ep.name) === normalizeTitle(itemData.episode_title)); // can fail in case of completely different titles e.g. /shows/jujutsu-kaisen s02e21 "Transformation" vs "Metamorphosis"
if (episodeData && (itemData.season !== episodeData.season || itemData.episode !== episodeData.number)) { // ep group used by tvmaze is usually the "correct" one
itemData.season_old = itemData.season;
itemData.episode_old = itemData.episode;
itemData.season = episodeData.season;
itemData.episode = episodeData.number;
itemData.season_title = showData._embedded.seasons.find((s) => s.number == episodeData.season).name || `Season ${episodeData.season}`;
['season_original_title', 'season_ids', 'season_first_aired', 'season_episode_count'].forEach((prop) => delete itemData[prop]);
}
return itemData;
}
///////////////////////////////////////////////////////////////////////////////////////////////
function addStyles() {
GM_addStyle(`
#info-wrapper .additional-stats.with-external-links .visible-xs {
font-size: 0;
user-select: none;
}
#info-wrapper .additional-stats.with-external-links .visible-xs > :is(label, a) {
font-size: 14px;
user-select: text;
}
#info-wrapper .additional-stats.with-external-links .visible-xs > a:has(+ a)::after {
content: ", ";
}
.no-watchnow-sources:not([data-url^="/people"], [data-url^="/lists"]) {
display: block !important;
}
[data-source-counts] > .fa-play::before {
transition: color 0.3s;
}
[data-source-counts] > .fa-play::before {
color: #777 !important;
}
[data-source-counts*="'${document.cookie.match(/(?:^|; )watchnow_country=([^;]*)/)?.[1] ?? new Intl.Locale(navigator.language).region}'" i] > .fa-play::before {
color: #ccc !important;
}
:is([data-source-counts="{}"], [data-source-counts="{'none':1}"]) > .fa-play::before {
color: #333 !important;
}
[data-source-counts] > .fa-play:hover::before {
color: #fff !important;
}
#info-wrapper :is(.sidebar, .action-buttons) .streaming-links a:is(:nth-child(3n), :nth-child(4n)) {
display: inline-block !important;
}
#info-wrapper .sidebar .streaming-links a:nth-child(n+${gmStorage.maxSidebarWnLinks + 1} of a),
#info-wrapper .action-buttons .streaming-links a:nth-child(n+3 of a),
#header-search-autocomplete-results .streaming-links a:nth-child(n+3 of a) {
display: none !important;
}
.streaming-links a > .icon.btn-custom {
display: flex;
justify-content: space-evenly;
align-items: center;
gap: 3%;
padding: 4% 6% !important;
border-width: 0px !important;
overflow: hidden;
container-type: inline-size;
}
.streaming-links a:hover > .icon.btn-custom {
filter: contrast(1.2);
}
.streaming-links .icon.btn-custom > img {
max-height: 100%;
object-fit: contain;
}
.streaming-links .icon.btn-custom > .text {
position: revert;
transform: revert;
-webkit-transform: revert;
max-height: revert;
padding: revert;
overflow: revert;
text-transform: revert;
white-space: pre;
font-size: 14cqi;
}
:is(#info-wrapper .sidebar, #watch-now-content) .external a {
height: 27px;
padding: 3px 5px !important;
line-height: 18px !important;
font-size: 14px !important;
vertical-align: middle;
color: #ccc !important;
background-color: #333 !important;
border: solid 1px #333 !important;
border-radius: 3px !important;
width: revert !important;
}
:is(#info-wrapper .sidebar, #watch-now-content) .external a:has(> *) {
padding: 1.5px !important;
}
:is(#info-wrapper .sidebar, #watch-now-content) .external a > :is(div, i) {
padding: 0 2px !important;
font-size: 19px;
vertical-align: -5px;
}
:is(#info-wrapper .sidebar, #watch-now-content) .external a > img {
height: 100%;
border-radius: inherit;
filter: grayscale(1) var(--extra-filters, grayscale(1));
}
:is(#info-wrapper .sidebar, #watch-now-content) .external a:hover {
color: #fff !important;
background-color: #555 !important;
}
:is(#info-wrapper .sidebar, #watch-now-content) .external a:hover > img {
filter: grayscale(1) var(--extra-filters, grayscale(1)) brightness(1.3);
}
#watch-now-content .title-wrapper {
margin-bottom: revert !important;
}
#watch-now-content .title-wrapper .titles {
padding-bottom: revert !important;
}
#watch-now-content .title-wrapper .titles .overview {
height: 60px;
margin-top: 5px;
padding: 5px 0 10px;
mask: linear-gradient(to bottom, transparent, white 5px 45px, transparent);
overflow-y: auto;
scrollbar-width: none;
color: #ccc;
}
#watch-now-modal {
top: 35px !important;
max-height: calc(100% - 70px);
flex-direction: column;
}
#watch-now-modal[style*="display: block;"] {
display: flex !important;
}
#watch-now-content {
display: contents;
}
#watch-now-content .streaming-links {
margin: 10px 0 !important;
mask: linear-gradient(to bottom, transparent, white 10px calc(100% - 10px), transparent);
overflow: auto;
scrollbar-width: none;
}
#watch-now-content .title {
margin: 10px 0 15px !important;
}
#watch-now-content .section.external {
margin: 0 30px 15px !important;
display: flex;
gap: 5px;
overflow-x: auto;
scrollbar-width: none;
}
#watch-now-content .section:not(.external) a {
padding-bottom: 10px !important;
}
@media (width <= 767px) {
#watch-now-content .section.external {
margin: 0 15px 15px !important;
}
}
@media (767px < width) {
#info-wrapper .sidebar:has(> .external) {
height: calc(100vh - 25px - var(--header-height));
overflow: auto;
scrollbar-width: none;
}
}
${customWatchNowLinks.concat(customExternalLinks).map((l) => l.addStyles).filter(Boolean).join('\n')}
`); // font data-uris are so long that everything below them doesn't get shown in style tag
}
})('Trakt.tv | Custom Links (Watch-Now + External)');
gmStorage['x70tru7b'] && (async (moduleName) => {
/* global moduleName */
'use strict';
let $, compressedCache, Cookies;
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 gmStorage = { ...(GM_getValue('vipUnlock')) };
GM_setValue('vipUnlock', gmStorage);
const token = null; // atob(GM_info.script.icon.split(',')[1]).match(//)[1];
addStyles();
document.addEventListener('click', (evt) => {
const listBtnEl = evt.target.closest('.quick-icons .list, .btn-summary.btn-list, .btn-summary.btn-list .side-btn .icon-add'),
popoverEl = evt.target.closest('.popover');
if (listBtnEl && !popoverEl) {
evt.stopPropagation();
evt.preventDefault();
addToListBtnOverride.call(listBtnEl);
}
}, { capture: true });
document.addEventListener('turbo:load', async () => {
$ ??= unsafeWindow.jQuery;
compressedCache ??= unsafeWindow.compressedCache;
Cookies ??= unsafeWindow.Cookies;
if (!$ || !compressedCache || !Cookies) return;
unsafeWindow.actionList = addToListPopupOverride;
$('body').removeAttr('data-turbo');
patchUserSettings();
if (token) $('body:not(.dashboard) .feed-icon.csv').attr('href', location.pathname + '.csv?slurm=' + token + location.search.replace('?', '&'));
$(document).off('ajaxSuccess.userscript38793').on('ajaxSuccess.userscript38793', (_evt, _xhr, opt) => {
if (opt.url.endsWith('/settings.json')) patchUserSettings();
if (token && /\/dashboard\/(on_deck|recently_watched)$/.test(opt.url)) {
$('.feed-icon.csv[href="/vip/csv"]').attr('href', function() {
return $(this).prev().attr('data-path') + '.csv?' + ['slurm=' + token, $(this).prev().attr('data-query')].join('&');
});
}
});
$('.frame-wrapper .sidenav.advanced-filters .buttons')
.addClass('vip')
.find('.btn.vip')
.text('').removeClass('vip').removeAttr('href')
.addClass('disabled disabled-init').attr('id', 'filter-apply').attr('data-apply-text', 'Apply Filters')
.before('Close ')
.append('Configure Filters ');
if (/^\/users\/[^\/]+\/progress(?!\/playback)/.test(location.pathname) && /list=\d+/.test(location.search) && !location.search.includes('terms=')) {
unsafeWindow.showLoading?.();
const searchParams = new URLSearchParams(location.search),
listId = searchParams.get('list'),
listDoc = await fetch('/lists/' + listId)
.then((r) => fetch(r.url + '?display=show&hide=unwatched&limit=10000')).then((r) => r.text())
.then((r) => new DOMParser().parseFromString(r, 'text/html')),
watchedShowsOnListTitles = [...listDoc.querySelectorAll('.grid-item')].map((e) => e.querySelector('.titles-link')?.textContent).filter(Boolean);
searchParams.set('terms', `^${watchedShowsOnListTitles.join('$|^')}$`);
['airing', 'completed', 'ended', 'not-completed', 'rewatching'].forEach((cookieSuffix) => {
Cookies.remove('filter-hide-progress-' + cookieSuffix, { path: '/' });
Cookies.remove('filter-hide-progress-' + cookieSuffix, { path: '/users/' + Cookies.get('trakt_userslug') });
});
location.search = searchParams.toString();
}
}, { capture: true });
///////////////////////////////////////////////////////////////////////////////////////////////
function patchUserSettings() {
const userSettings = compressedCache.get('settings');
if (userSettings && (!userSettings.user.vip || (token && userSettings.account.token !== token))) {
userSettings.user.vip = true;
if (token) userSettings.account.token = token;
compressedCache.set('settings', userSettings);
if (unsafeWindow.userSettings) unsafeWindow.userSettings = userSettings;
}
}
///////////////////////////////////////////////////////////////////////////////////////////////
async function addToListBtnOverride() {
if(unsafeWindow.listPopupPressed) { unsafeWindow.listPopupPressed = false; return; }
const isSideBtn = $(this).hasClass('side-btn') || $(this).parent().hasClass('side-btn'),
isSummaryMode = $(this).hasClass('btn-list'),
$gridItem = isSideBtn ? $(this).closest('.btn-summary') : isSummaryMode ? $(this) : $(this).closest('.grid-item'),
itemUrl = $gridItem.attr('data-url'),
itemType = $gridItem.attr('data-type'),
itemId = +$gridItem.attr(`data-${itemType}-id`),
hasLists = Object.values(compressedCache.get('lists') ?? {}).some((l) => l.type === 'list'),
listPopupAction = (unsafeWindow.isPersonPage && isSummaryMode || $gridItem.attr('data-type') === 'person') ? 'list' : unsafeWindow.userSettings.browsing.list_popup_action;
if(unsafeWindow.isPersonPage && isSummaryMode || hasLists && (listPopupAction !== 'watchlist' || $(this).hasClass('selected')) || isSideBtn) {
unsafeWindow.actionListPopup(isSideBtn ? $gridItem : $(this));
} else {
$gridItem.find('.loading').show();
const isRemoval = $(this).hasClass('selected'),
watchListData = Object.values(compressedCache.get('lists')).find((l) => l.name === 'Watchlist'),
$pseudoLi = $(` `);
const wasSuccessful = await addToListPopupOverride($gridItem, $pseudoLi, isRemoval);
if (wasSuccessful) {
$(`[data-${itemType}-id="${itemId}"]:is(.btn-summary.btn-list, [data-type="${itemType}"]) .list`)[isRemoval ? 'removeClass' : 'addClass']('selected');
unsafeWindow.cacheUserData();
};
$gridItem.find('.loading').hide();
}
}
async function addToListPopupOverride($gridItem, $li, isRemoval) {
$li.addClass('spinner').find('.icon').addClass('fa-spin');
const itemUrl = $gridItem.attr('data-url'),
itemType = $gridItem.attr('data-type'),
itemId = +$gridItem.attr(`data-${itemType}-id`),
targetListId = +$li.attr('data-list-id') || Object.values(compressedCache.get('lists')).find((l) => l.name === 'Watchlist').ids.trakt,
targetListType = $li.attr('data-list-type'),
targetListItemCount = +$li.attr('data-item-count');
try {
if ($li.hasClass('maxed-out') && !isRemoval) {
const durationEstimate = (45 / 1000) * targetListItemCount;
logger.info(`Target list is maxed-out, attempting bypass.. This will take about ${~~(durationEstimate / 60)}m${~~(durationEstimate % 60)}s .`,
{ toastrOpt: { timeOut: durationEstimate * 1000 } });
const tempListId = await getTempListId(),
cachedLists = compressedCache.get('lists');
if (cachedLists[tempListId] && cachedLists[tempListId].item_count > 0) { logger.error(`Temp list is not empty. Aborting..`, { data: cachedLists[tempListId] }); return; }
const resp1 = await fetch(itemUrl + '/list', {
method: 'POST',
headers: { 'X-CSRF-Token': unsafeWindow.csrfToken },
body: new URLSearchParams({ type: itemType, trakt_id: itemId, list_id: tempListId }),
});
if (!resp1.ok) { logger.error(`Failed to add item to temp list: id=${tempListId}.`, { data: resp1 }); return; }
logger.info('Added item to temp list.');
for (const [list1Id, list2Id] of [[targetListId, tempListId], [tempListId, targetListId]]) {
const list1AllItemIds = await fetch('/lists/' + list1Id).then((r) => r.text())
.then((r) => new DOMParser().parseFromString(r, 'text/html').querySelector('#listable-all-item-ids').value.split(',').map(Number));
if (!list1AllItemIds || !list1AllItemIds.length) { logger.error(`Failed to fetch all list item ids for list: id=${list1Id}.`); return; }
const resp2 = await fetch(`/lists/${list1Id}/move_items/${list2Id}`, {
method: 'POST',
headers: { 'X-CSRF-Token': unsafeWindow.csrfToken },
body: new URLSearchParams([['sort_by', 'rank'], ['sort_how', 'asc'], ...list1AllItemIds.map((id) => ['order[]', id])]),
});
if (!resp2.ok) { logger.error(`Failed to move all items from ${list1Id === targetListId ? 'target to temp' : 'temp to target'} list.`, { data: resp2 }); return; }
logger.info(`Moved all items from ${list1Id === targetListId ? 'target to temp' : 'temp to target'} list.`);
}
logger.success(`Success. Item was added to target list .`);
} else {
const resp = await fetch(`${itemUrl}/${/(watchlist|favorites|recommendations)/.test(targetListType) ? targetListType : 'list'}${isRemoval ? '/remove' : ''}`, {
method: 'POST',
headers: { 'X-CSRF-Token': unsafeWindow.csrfToken },
body: new URLSearchParams({ type: itemType, trakt_id: itemId, list_id: targetListId }),
});
if (!resp.ok) { await resp.json().then((r) => logger.error('Failed to add item to list.' + (r.message ? ' Response: ' + r.message : ''), { data: resp })); return; }
logger.success('Success. ' + (await resp.json()).message);
}
$li.toggleClass('selected');
return true;
} finally {
$li.removeClass('spinner').find('.icon').removeClass('fa-spin');
}
}
async function getTempListId() {
if (!gmStorage.tempList1Id || !compressedCache.get('lists')[gmStorage.tempList1Id]) {
const favsListId = Object.values(compressedCache.get('lists')).find((l) => l.name === 'Favorites').ids.trakt;
const resp1 = await fetch(`/lists/${favsListId}/copy_items/0`, {
method: 'POST',
headers: { 'X-CSRF-Token': unsafeWindow.csrfToken },
body: new URLSearchParams({ 'order[]': '', sort_by: 'rank', sort_how: 'asc' }),
});
if (!resp1.ok) { logger.error('Failed to create temp list.', { data: resp1 }); return; }
const tempListId = (await resp1.json()).id;
logger.info(`Created temp list: id=${tempListId}.`, { data: resp1 });
const resp2 = await fetch('/lists/' + tempListId, {
method: 'POST',
headers: { 'X-CSRF-Token': unsafeWindow.csrfToken },
body: new URLSearchParams({
authenticity_token: unsafeWindow.csrfToken,
_method: 'put',
name: `temp1_${tempListId}`,
description: 'Needed for the list limits bypass of the "Partial VIP Unlock" userscript. Keep it empty. You can edit the list title and description if you want. ' +
'If you delete it another one will be created on the next attempted list limits bypass.',
privacy_hidden: 'private',
privacy: 'private',
existing_collaborator_ids: '',
allow_comments_hidden: 1,
allow_comments: 1,
display_numbers_hidden: 1,
display_numbers: 1,
default_sort_by: 'rank',
default_sort_how: 'asc',
}),
});
if (!resp2.ok) { logger.error('Failed to update temp list metadata.', { data: resp2 }); return; }
logger.info('Updated temp list metadata.', { data: resp2 });
gmStorage.tempList1Id = tempListId;
GM_setValue('vipUnlock', gmStorage);
}
return gmStorage.tempList1Id;
}
///////////////////////////////////////////////////////////////////////////////////////////////
function addStyles() {
GM_addStyle(`
#top-nav .btn-vip,
.dropdown-menu.for-sortable > li > a.vip-only,
.alert-vip-required {
display: none !important;
}
.popover:not(.copy-list) ul.lists li.maxed-out:not(.selected) {
text-decoration: line-through dashed 2px;
}
`);
const userslug = document.cookie.match(/(?:^|; )trakt_userslug=([^;]*)/)?.[1];
if (userslug) {
GM_addStyle(`
:is(#avatar-wrapper h1, .comment-wrapper .user-name) [href="/users/${userslug}"]::after,
#results-top-wrapper [href="/users/${userslug}"] + h1::after {
content: "DIRECTOR" !important; /* competes with " (@userslug)" suffix from other script */
font-weight: var(--headings-font-weight);
font-family: var(--headings-font-family);
background-color: var(--brand-vip);
display: inline-block;
text-shadow: none;
line-height: 1;
vertical-align: middle;
color: #fff;
}
#avatar-wrapper h1 [href="/users/${userslug}"]::after,
#results-top-wrapper [href="/users/${userslug}"] + h1::after {
margin: 0px 0px 5px 10px;
padding: 5px 6px 5px 28px;
font-size: 16px;
letter-spacing: 1px;
border-radius: 20px 0px 0px 20px;
background-image: url("/assets/logos/logomark.circle.white-8541834d655f22f06c0e1707bf263e8d5be59657dba152298297dffffb1f0a11.svg");
background-size: 20px;
background-repeat: no-repeat;
background-position: 3px center;
}
.comment-wrapper .user-name [href="/users/${userslug}"]::after {
margin: -3px 0 0 5px;
padding: 2px 4px;
font-size: 11px;
letter-spacing: 0;
border-radius: 2px;
}
@media (width <= 767px) and (orientation: portrait) {
#avatar-wrapper h1 [href="/users/${userslug}"]::after,
#results-top-wrapper [href="/users/${userslug}"] + h1::after {
margin: 0px 0px 3px 7px;
padding: 3px 5px 3px 23px;
font-size: 14px;
background-size: 14px;
}
}
.personal-list .comment-wrapper .user-name [href="/users/${userslug}"] {
white-space: nowrap;
}
`);
}
}
})('Trakt.tv | Partial VIP Unlock');
gmStorage['yl9xlca7'] && (async (moduleName) => {
'use strict';
let $;
const numFormatCompact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });
numFormatCompact.formatTLC = (n) => numFormatCompact.format(n).toLowerCase();
addStyles();
document.addEventListener('turbo:load', () => {
if (!location.pathname.startsWith('/shows/') || location.pathname.includes('/episodes/')) return;
$ ??= unsafeWindow.jQuery;
if (!$) return;
const $grid = $('#seasons-episodes-sortable'),
$summaryUserRating = $('#summary-ratings-wrapper .summary-user-rating'),
$traktRating = $('#summary-ratings-wrapper .trakt-rating');
if (!$grid.length || !$summaryUserRating.length || !$traktRating.length) return;
const avgRatings = unsafeWindow.userscriptAvgSeasonEpisodeRatings = {};
let items;
$summaryUserRating[0].mutObs = new MutationObserver(() => { // .summary-user-rating mutations occur frequently and are caused by all sorts of things
if (!$summaryUserRating.hasClass('popover-on')) {
updatePersRatingElem($summaryUserRating, avgRatings.personal);
}
});
updatePersRatingElem($summaryUserRating);
updateGenRatingElem($traktRating);
const filterSpecials = !location.pathname.endsWith('/seasons/0');
$grid.on('arrangeComplete', () => {
if ($grid.data('isotope')) {
items = $grid.data('isotope').filteredItems.filter((i) => filterSpecials ? i.element.dataset.seasonNumber !== '0' : true);
avgRatings.personal = calcAvgPersRating(items);
avgRatings.general = calcAvgGenRating(items);
updatePersRatingElem($summaryUserRating, avgRatings.personal);
updateGenRatingElem($traktRating, avgRatings.general);
}
});
$(document).off('ajaxSuccess.userscript32985').on('ajaxSuccess.userscript32985', (_evt, _xhr, opt) => {
if (items && /\/ratings\/(seasons|episodes)\.json$|\/rate/.test(opt.url)) { // title was (un)rated OR cached personal ratings (and .corner-ratings) were updated
avgRatings.personal = calcAvgPersRating(items);
updatePersRatingElem($summaryUserRating, avgRatings.personal);
}
});
}, { capture: true });
function calcAvgPersRating(items) {
const persRatings = items.map((i) => +$(i.element).find('.corner-rating > .text').text()).filter(Boolean);
return {
average: persRatings.length ? persRatings.reduce((acc, persRating) => acc + persRating, 0) / persRatings.length : undefined,
votes: persRatings.length,
};
}
function calcAvgGenRating(items) {
const genRatingsVotesSum = items.reduce((acc, i) => acc + i.sortData.votes, 0);
return {
average: genRatingsVotesSum ? items.reduce((acc, i) => acc + (i.sortData.percentage * (i.sortData.votes / genRatingsVotesSum)), 0) : undefined,
votes: genRatingsVotesSum,
};
}
function updatePersRatingElem($summaryUserRating, avgPersRating) {
$summaryUserRating[0].mutObs.disconnect();
$summaryUserRating
.find('.rating')
.each(function() {
const rating = $(this).parent().prev().attr('class').match(/rating-(\d+)/)?.[1];
if (rating) $(this).html(`${rating}${unsafeWindow.ratingsText?.[rating] ?? ''}
`);
});
$summaryUserRating
.find('.number > .votes')
.removeClass('alt')
.text(`avg: ${avgPersRating?.average ? `${avgPersRating.average.toFixed(1)}` : '--'} ` +
`(${avgPersRating?.votes !== undefined ? numFormatCompact.formatTLC(avgPersRating.votes) : '--'} v.)`);
$summaryUserRating[0].mutObs.observe($summaryUserRating[0], { subtree: true, childList: true });
}
function updateGenRatingElem($traktRating, avgGenRating) {
if (!$traktRating.has('.rating .votes').length) {
$traktRating
.find('.votes')
.clone()
.appendTo($traktRating.find('.rating'))
.text((_i, text) => `(${text.match(/^.*? v/)?.[0] ?? '0 v'}.)`);
}
$traktRating
.find('.number > .votes')
.text(`avg: ${avgGenRating?.average ? `${Math.round(avgGenRating.average)}` : '--'}% ` +
`(${avgGenRating?.votes !== undefined ? numFormatCompact.formatTLC(avgGenRating.votes) : '--'} v.)`);
}
function addStyles() {
GM_addStyle(`
#summary-ratings-wrapper .ratings .rating {
display: flex !important;
justify-content: space-between;
align-items: center;
}
#summary-ratings-wrapper .ratings .rating .votes {
margin-left: 7px !important;
color: #fff !important;
}
`);
}
})('Trakt.tv | Average Season And Episode Ratings');