// ==UserScript==
// @name The Pirate Calendar (for trakt.tv)
// @version 0.7.0
// @description Adds torrent links to trakt.tv. Now with a settings menu!
// @author luffier
// @namespace PirateCalendar
// @license MIT
// @match https://trakt.tv/*
// @require https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant GM_getValue
// @grant GM_setValue
// @grant GM.getValue
// @grant GM.setValue
// @run-at document-idle
// @sandbox raw
// @homepageURL https://github.com/Luffier/the-pirate-calendar
// @supportURL https://github.com/Luffier/the-pirate-calendar/issues
// ==/UserScript==
/* globals GM_config */
/* jshint esversion: 8 */
(() => {
'use strict';
/* VARIABLES */
// Global styles
const STYLE = `
<style>
iframe#PirateCalendarConfig {
height: 525px !important;
width: 500px !important;
}
.actions .tpc {
width: 1px;
transition: background-color .2s ease 0s;
font-size: 22px !important;
color: rgb(56, 96, 187);
}
.actions .tpc:hover {
background-color: rgb(255, 255, 255, 0.25);
transition: background-color .2s ease 0s;
color: rgb(18, 40, 89);
}
.action-buttons .btn-tpc {
margin-top: 5px;
color: rgb(56, 96, 187);
background-color: #fff;
border-color: rgb(56, 96, 187);
border: solid 1px rgb(56, 96, 187);
transition: all .5s;
}
.action-buttons .btn-tpc:hover {
background-color: rgb(18, 40, 89);
color: white;
transition: all .5s;
}
.tcp-icon {
font-size: 40px !important;
color: rgb(237, 28, 36);
}
.tcp-jump-icon {
position: fixed;
font-size: 30px !important;
bottom: 25px;
right: 25px;
z-index: 999;
}
.quick-icons.smallest-tcp .actions > a {
width: 17px !important;
}
.quick-icons.smaller-tcp .actions > a {
width: 21px !important;
}
</style>
`;
// RegEx patterns
// For pages: all
// For action list: show, season, episode, movie
// For media cards: show, season, episode, movie
const REGEX = {
calendar: /^\/calendars\/my\/shows/,
shows: /^\/shows(?:\/(?:trending|popular|favorited|recommended|watched|collected|anticipated)?(?:\/[^/]+)?)$/,
show: /^\/shows\/([^/]+)(\/)?$/,
season: /^\/shows\/([^/]+)\/seasons\/([^/]+)(\/)?$/,
episode: /^\/shows\/([^/]+)\/seasons\/([^/]+)\/episodes\/([^/]+)(\/)?$/,
movies: /^\/movies(?:\/(?:trending|popular|favorited|recommended|watched|collected|anticipated|boxoffice)?(?:\/[^/]+)?)$/,
movie: /^\/movies\/([^/]+(?<!-[0-9]{4}))(?:(?:-)([0-9]{4}))?$/m,
list: /^\/users\/[^/]+\/((?:favorites|watchlist)|(?:lists\/[^/]+))$/,
};
// Default search engines parameters
const SEARCH_ENGINES = {
'1337x': {
'defaultUrl': 'https://1337x.to/',
'defaultSearch': 'sort-search/%s/size/desc/1/',
'cleanQuery': (query) => encodeURIComponent(query).replace(/%20/g, '+'),
},
'Torrent Galaxy': {
'defaultUrl': 'https://torrentgalaxy.to/',
'defaultSearch': 'torrents.php?search=%s&lang=0&nox=2&sort=size&order=desc',
'cleanQuery': (query) => encodeURIComponent(query).replace(/%20/g, '+'),
},
'Custom': {
'defaultUrl': 'Write a custom URL',
'defaultSearch': 'Write a custom query string',
'cleanQuery': (query) => encodeURIComponent(query).replace(/%20/g, '+'),
},
};
// Helper for whenPageReady function
const PAGE_READY = {
timeout: true,
startTimer: null,
};
/* SETTINGS MENU */
GM_config.init({
'id': 'PirateCalendarConfig',
'title': 'The Pirate Calendar Settings',
'fields': {
'openInNewTab': {
'label': 'Open links in new tab:',
'type': 'checkbox',
'default': true,
'section': ['General'],
},
'autoscrollToday': {
'label': 'Auto scroll to current day:',
'type': 'checkbox',
'default': true,
'section': ['Calendar'],
},
'hideCollectIcon': {
'label': 'Hide collect icon:',
'type': 'checkbox',
'default': false,
},
'hideListIcon': {
'label': 'Hide list icon:',
'type': 'checkbox',
'default': false,
},
'hideWatchtIcon': {
'label': 'Hide watch-now icon:',
'type': 'checkbox',
'default': false,
},
'listPageIconsMode': {
'label': 'How to handle lack of space in icon list:',
'type': 'select',
'options': [
'Squeeze icons!',
'Bigger posters',
'Hide heart icon and squeeze',
'Hide collect and "watch on" icons'],
'default': 'Squeeze icons!',
'section': ['List pages'],
},
'torrentSearchEngine': {
'label': 'Preferred torrent search engine:',
'type': 'select',
'options': Object.keys(SEARCH_ENGINES),
'default': Object.keys(SEARCH_ENGINES)[0],
'section': ['Search engine'],
},
'customUrl': {
'label': '· URL:',
'title': 'For a custom URL (like a proxy)',
'type': 'text',
'default': SEARCH_ENGINES[Object.keys(SEARCH_ENGINES)[0]].defaultUrl,
},
'customSearch': {
'label': '· Search query:',
'title': 'For a custom search query. Place "%s" where the query should be',
'type': 'text',
'default': SEARCH_ENGINES[Object.keys(SEARCH_ENGINES)[0]].defaultSearch,
},
},
'css':
`
body#PirateCalendarConfig {
position: relative !important;
font-family: 'proxima nova', 'Helvetica', 'Arial', 'sans-serif' !important;
margin: 0 !important;
}
#PirateCalendarConfig .config_var {
margin: 8px 8px 8px 12px !important;
}
#PirateCalendarConfig .config_var input[type="text"] {
border: 2px inset black !important;
}
#PirateCalendarConfig_buttons_holder {
position: relative !important;
}
#PirateCalendarConfig_header {
background-color: #f7f7f7;
border-bottom: 1px solid #ebebeb;
padding: 20px 0 10px 0;
}
#PirateCalendarConfig_buttons_holder {
right: 20px;
}
#PirateCalendarConfig_buttons_holder button {
color: #fff;
font-size: 12px;
padding: 4px 9px !important;
height: auto !important;
cursor: pointer;
border: 1px solid transparent;
}
#PirateCalendarConfig_buttons_holder .reset_holder {
position: absolute;
right: 12px;
bottom: -20px;
}
#PirateCalendarConfig_saveBtn {
background-color: #ed1c24;
border-color: #de1219;
}
#PirateCalendarConfig_closeBtn {
background-color: #aaa;
border: 1px solid transparent;
}
#PirateCalendarConfig_field_customSearch {
width: 48ex;
}
.config_var#PirateCalendarConfig_customUrl_var,
.config_var#PirateCalendarConfig_customSearch_var {
display: flex;
align-items: center;
}
.config_var input[type="text"] {
flex-grow: 1;
}
`,
'events': {
'init': init,
'open': function() {
// Set default URL and search path when the search engine changes
this.fields.torrentSearchEngine.node.addEventListener('change', function() {
const searchEngine = SEARCH_ENGINES[this.value];
const section = this.parentElement.parentElement;
section.querySelector('#PirateCalendarConfig_field_customUrl').value = searchEngine.defaultUrl;
section.querySelector('#PirateCalendarConfig_field_customSearch').value = searchEngine.defaultSearch;
});
},
'save': function() {
applySettings();
this.close();
},
},
});
/* FUNCTIONS */
// Single element selector shorthand
const $ = document.querySelector.bind(document);
// Multiple elements selector shorthand
const $$ = document.querySelectorAll.bind(document);
// Create element
function createElement(html) {
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content.firstChild;
}
// Function to replicate the `toggle` function in jQuery
function toggle(el, option) {
if (typeof option === 'boolean') {
if (option) {
el.style.display = '';
} else {
el.style.display = 'none';
}
} else {
if (el.style.display === 'none') {
el.style.display = '';
} else {
el.style.display = 'none';
}
}
}
// Function to replicate the `on` function in jQuery
function addEventListener(el, eventName, eventHandler, selector) {
if (selector) {
const wrappedHandler = (e) => {
if (e.target && e.target.matches(selector)) {
eventHandler(e);
}
};
el.addEventListener(eventName, wrappedHandler);
return wrappedHandler;
} else {
el.addEventListener(eventName, eventHandler);
return eventHandler;
}
}
// Pad number with leading zeros
function zeroPad(number, places = 2) {
return String(number).padStart(places, '0');
}
// Validate settings in case stored settings no longer exist in the current script version
function validateSettings() {
const searchEngine = GM_config.get('torrentSearchEngine');
if (!Object.prototype.hasOwnProperty.call(SEARCH_ENGINES, searchEngine)) {
GM_config.set('torrentSearchEngine', GM_config.fields.torrentSearchEngine.default);
}
}
// Apply settings from the settings menu
function applySettings() {
// Apply calendar settings
if (REGEX.calendar.test(location.pathname)) {
// Hide unwanted icons
for (const el of [...$$('.quick-icons .collect')]) {
toggle(el, !GM_config.get('hideCollectIcon'));
}
for (const el of [...$$('.quick-icons .list')]) {
toggle(el, !GM_config.get('hideListIcon'));
}
for (const el of [...$$('.quick-icons .watch-now')]) {
toggle(el, !GM_config.get('hideWatchtIcon'));
}
// Remove and add all the links again
for (const el of [...$$('.grid-item[data-type="episode"] a.tpc')]) {
el.remove();
}
addLinksToGrid();
}
}
function makeTorrentURL(query) {
const baseURL = GM_config.get('customUrl');
const queryPath = GM_config.get('customSearch');
const searchEngine = GM_config.get('torrentSearchEngine');
const queryCleaned = SEARCH_ENGINES[searchEngine].cleanQuery(query);
const url = baseURL + queryPath.replace(/%s/g, queryCleaned);
return url;
}
function extractQueryFromLink(link, type) {
let query = link;
const itemLinkMatches = link.match(REGEX[type]);
if (itemLinkMatches !== null) {
query = itemLinkMatches[1];
if (type === 'movie' && itemLinkMatches[2] !== undefined) {
query += ' ' + itemLinkMatches[2];
}
if (type === 'season') {
query += ` S${zeroPad(itemLinkMatches[2])}`;
}
if (type === 'episode') {
query += ` S${zeroPad(itemLinkMatches[2])}E${zeroPad(itemLinkMatches[3])}`;
}
}
return query.replace(/-/g, ' ').replace(/\//g, ' ');
}
// Adds search links to all items (posters) in a grid
function addLinksToGrid() {
for (const gridItem of [...$$('.grid-item')]) {
const type = gridItem.dataset.type;
const actions = gridItem.querySelector(`:scope ${'> div.quick-icons > div.actions'}`);
if (actions !== null) {
const itemLink = gridItem.querySelector(`:scope ${'a'}`).getAttribute('href');
const query = extractQueryFromLink(itemLink, type);
const urlSearch = makeTorrentURL(query);
const target = GM_config.get('openInNewTab') ? '_blank' : '_self';
const searchEngineName = GM_config.get('torrentSearchEngine');
actions.append(createElement(
`
<a class="tpc" href="${urlSearch}" target="${target}" title="Search on ${searchEngineName}">
<div class="trakt-icon-skull-bones"></div>
</a>
`,
));
}
}
}
// Adds search links to an actions (buttons) list
function addLinksToActionList() {
for (const actionList of [...$$('.action-buttons')]) {
const type = actionList.querySelector(`:scope ${'.btn-block[data-type]'}`).dataset.type;
const itemLink = location.pathname;
const query = extractQueryFromLink(itemLink, type);
const urlSearch = makeTorrentURL(query);
const target = GM_config.get('openInNewTab') ? '_blank' : '_self';
const searchEngineName = GM_config.get('torrentSearchEngine');
actionList.append(createElement(
`
<a class="btn btn-block btn-summary btn-tpc" href="${urlSearch}" target="${target}">
<div class="fa fa-fw trakt-icon-skull-bones"></div>
<div class="text">
<div class="main-info">Search on ${searchEngineName}</div>
</div>
</a>
`,
));
}
}
function isCalendarPageCurrentMonth() {
const today = new Date();
// Extract the calendar date from the URL
const calendarDate = new Date(window.location.href.substring(window.location.href.lastIndexOf('/') + 1));
// If there's no date (current month) or it's the current month then return true
return (isNaN(calendarDate) || (calendarDate.getMonth() === today.getMonth() && calendarDate.getYear() === today.getYear()));
}
// Autoscroll to current date
function scrollCurrentDate() {
if (isCalendarPageCurrentMonth()) {
const todayCard = [...$$('.date-separator:not(.filler) .date')].filter((el) => {
return el.textContent == (new Date()).getDate();
})[0];
if (todayCard) {
todayCard.scrollIntoView(true);
// Scroll up to compensate top navbar
const topNav = $('#top-nav');
const offset = -window.getComputedStyle(topNav).getPropertyValue('height').slice(0, -2);
window.scrollBy(0, offset);
}
}
}
// Generic actions for every page
function processGenericPage() {
addLinksToActionList();
addLinksToGrid();
}
// Special actions for calendar pages
function processCalendarPage() {
if (GM_config.get('autoscrollToday')) {
scrollCurrentDate();
}
// Settings menu icon
let menuIcon = createElement(
`
<a class="tcp-icon" title="The Pirate Calendar Settings">
<div class="fa fa-gear"></div>
</a>
`,
);
menuIcon = $('.sidenav-inner').appendChild(menuIcon);
addEventListener(menuIcon, 'click', () => GM_config.open());
// Jump icon
if (isCalendarPageCurrentMonth()) {
let jumpIcon = createElement(
`
<a class="tcp-icon tcp-jump-icon" title="Jump to current day">
<div class="fa fa-calendar-xmark"></div>
</a>
`,
);
jumpIcon = $('body').appendChild(jumpIcon);
addEventListener(jumpIcon, 'click', () => scrollCurrentDate());
}
}
// Special actions for list pages
function processListPage() {
if (GM_config.get('listPageIconsMode') === 'Squeeze icons!') {
for (const el of [...$$('.quick-icons')]) {
el.classList.add('smallest-tcp');
}
}
if (GM_config.get('listPageIconsMode') === 'Bigger posters') {
for (const el of [...$$('.grid-item')]) {
el.classList.replace('col-md-2', 'col-md-4');
el.classList.replace('col-sm-3', 'col-sm-4');
}
}
if (GM_config.get('listPageIconsMode') === 'Hide heart icon and squeeze') {
for (const el of [...$$('.quick-icons .fa.fa-heart')]) {
toggle(el, false);
}
for (const el of [...$$('.quick-icons')]) {
el.classList.add('smaller-tcp');
}
}
if (GM_config.get('listPageIconsMode') === 'Hide collect and "watch on" icons') {
for (const el of [...$$('.quick-icons .collect')]) {
toggle(el, false);
}
for (const el of [...$$('.quick-icons .watch-now')]) {
toggle(el, false);
}
}
}
// Main function
function processPage() {
processGenericPage();
if (REGEX.calendar.test(location.pathname)) {
processCalendarPage();
} else if (REGEX.list.test(location.pathname)) {
processListPage();
}
}
// Executes the callback after the page finishes loading
// Using a MutationObserver, a timout is set every time a new mutation happens,
// if either the elapsed time bewteen mutations is greater than intervalTime or
// the full elapsed time is greater than maxWaitTime the callback is executed
function whenPageReady(callback, intervalTime, maxWaitTime = 3000) {
PAGE_READY.startTimer = Date.now();
console.debug('[The Pirate Calendar] Waiting for page to load');
const observerCallback = (mutationList, observer) => {
if (PAGE_READY.timeout) {
clearTimeout(PAGE_READY.timeout);
if ((Date.now() - PAGE_READY.startTimer) > maxWaitTime) {
console.debug(`[The Pirate Calendar] Max wait time exceded, loading script anyway!`);
clearTimeout(PAGE_READY.timeout);
PAGE_READY.timeout = null;
observer.disconnect();
callback();
} else {
PAGE_READY.timeout = setTimeout(() => {
console.debug(`[The Pirate Calendar] Page ready in ${Date.now() - PAGE_READY.startTimer}ms!`);
clearTimeout(PAGE_READY.timeout);
PAGE_READY.timeout = null;
observer.disconnect();
callback();
}, intervalTime);
}
} else {
observer.disconnect();
}
};
const observer = new MutationObserver(observerCallback);
observer.observe($('body'), {attributes: true, childList: true, subtree: true});
}
function init() {
whenPageReady(() => {
// Apply styles
$('head').append(createElement(STYLE));
validateSettings();
processPage();
applySettings();
}, 250);
}
})();