Created
May 8, 2026 17:26
-
-
Save plmi/d03c22f820277ec8764aafb33848a5c5 to your computer and use it in GitHub Desktop.
Marks matching xREL releases that were pred after their product-page release date.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name xREL Pre Date Indicator | |
| // @namespace https://www.xrel.to/ | |
| // @version 0.2.0 | |
| // @description Marks matching xREL releases that were pred after their product-page release date. | |
| // @match https://www.xrel.to/releases.html* | |
| // @connect bluray-disc.de | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @grant GM_registerMenuCommand | |
| // @grant GM_xmlhttpRequest | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const SETTING_RELEASE_REGEX = 'xpti.releaseRegex'; | |
| const SETTING_DATE_LABEL_REGEX = 'xpti.dateLabelRegex'; | |
| const SETTING_BLURAY_DISC_HIGH_DELTA_DAYS = 'xpti.blurayDiscHighDeltaDays'; | |
| const SETTING_BLURAY_DISC_TRIGGER_REGEX = 'xpti.blurayDiscTriggerRegex'; | |
| const CACHE_KEY = 'xpti.productDateCache.v1'; | |
| const BLURAY_DISC_CACHE_KEY = 'xbdi.imdbBlurayCache.v1'; | |
| const CACHE_TTL_MS = 12 * 60 * 60 * 1000; | |
| const BLURAY_DISC_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; | |
| const BLURAY_DISC_NULL_CACHE_TTL_MS = 60 * 60 * 1000; | |
| const DEFAULT_RELEASE_REGEX = 'German.*BluRay'; | |
| const DEFAULT_DATE_LABEL_REGEX = | |
| 'Retail|Blu-?ray|DVD|UHD|HD|VoD|VOD|Video on Demand|Digital|Steam|GOG|Epic|Windows|Linux|Mac|PlayStation|Xbox|Switch|Nintendo'; | |
| const DEFAULT_BLURAY_DISC_HIGH_DELTA_DAYS = 500; | |
| const DEFAULT_BLURAY_DISC_TRIGGER_REGEX = | |
| "\\b(?:REMASTERED|THEATRICAL|UNCUT|UNRATED|EXTENDED|RESTORED|DIRECTOR'?S?[._ -]?CUT|FINAL[._ -]?CUT)\\b"; | |
| const EXCLUDED_DATE_LABEL_REGEX = /\b(?:cine|cinema|kino|kinostart|theater|theatrical|web)\b/i; | |
| const processedRows = new WeakSet(); | |
| const productDateRequests = new Map(); | |
| const imdbRequests = new Map(); | |
| const blurayDiscDateRequests = new Map(); | |
| const productDateCache = loadCache(); | |
| const blurayDiscCache = loadBlurayDiscCache(); | |
| const taskQueue = []; | |
| let activeTasks = 0; | |
| const maxConcurrentRequests = 4; | |
| let releaseRegex = compileSettingRegex( | |
| SETTING_RELEASE_REGEX, | |
| DEFAULT_RELEASE_REGEX, | |
| 'i', | |
| 'release regex', | |
| ); | |
| let dateLabelRegex = compileSettingRegex( | |
| SETTING_DATE_LABEL_REGEX, | |
| DEFAULT_DATE_LABEL_REGEX, | |
| 'i', | |
| 'date label regex', | |
| ); | |
| let blurayDiscTriggerRegex = compileSettingRegex( | |
| SETTING_BLURAY_DISC_TRIGGER_REGEX, | |
| DEFAULT_BLURAY_DISC_TRIGGER_REGEX, | |
| 'i', | |
| 'bluray-disc trigger regex', | |
| ); | |
| let blurayDiscHighDeltaDays = getNumberSetting( | |
| SETTING_BLURAY_DISC_HIGH_DELTA_DAYS, | |
| DEFAULT_BLURAY_DISC_HIGH_DELTA_DAYS, | |
| ); | |
| injectStyles(); | |
| registerMenuCommands(); | |
| scanReleaseRows(); | |
| observeReleaseList(); | |
| function registerMenuCommands() { | |
| registerMenuCommand('Set release regex', () => { | |
| promptForRegex( | |
| SETTING_RELEASE_REGEX, | |
| DEFAULT_RELEASE_REGEX, | |
| 'Only rows whose dirname matches this regex are checked. You can use /pattern/flags syntax.', | |
| ); | |
| }); | |
| registerMenuCommand('Set date label regex', () => { | |
| promptForRegex( | |
| SETTING_DATE_LABEL_REGEX, | |
| DEFAULT_DATE_LABEL_REGEX, | |
| 'Product-page dates whose label matches this regex are preferred. If none match, all dates are considered.', | |
| ); | |
| }); | |
| registerMenuCommand('Set bluray-disc high-delta days', () => { | |
| promptForNumber( | |
| SETTING_BLURAY_DISC_HIGH_DELTA_DAYS, | |
| DEFAULT_BLURAY_DISC_HIGH_DELTA_DAYS, | |
| 'Late indicators above this number of days are rechecked on bluray-disc.de.', | |
| ); | |
| }); | |
| registerMenuCommand('Set bluray-disc trigger regex', () => { | |
| promptForRegex( | |
| SETTING_BLURAY_DISC_TRIGGER_REGEX, | |
| DEFAULT_BLURAY_DISC_TRIGGER_REGEX, | |
| 'Rows whose dirname matches this regex are rechecked on bluray-disc.de when they are late.', | |
| ); | |
| }); | |
| registerMenuCommand('Clear product date cache', () => { | |
| productDateCache.entries = {}; | |
| saveCache(); | |
| location.reload(); | |
| }); | |
| } | |
| function promptForRegex(settingKey, fallback, message) { | |
| const current = getSetting(settingKey, fallback); | |
| const next = prompt(message + '\n\nCurrent value:', current); | |
| if (next === null) { | |
| return; | |
| } | |
| try { | |
| parseUserRegex(next.trim() || fallback, 'i'); | |
| } catch (error) { | |
| alert('Invalid regex: ' + error.message); | |
| return; | |
| } | |
| setSetting(settingKey, next.trim() || fallback); | |
| location.reload(); | |
| } | |
| function promptForNumber(settingKey, fallback, message) { | |
| const current = getSetting(settingKey, String(fallback)); | |
| const next = prompt(message + '\n\nCurrent value:', current); | |
| if (next === null) { | |
| return; | |
| } | |
| const value = Number(next.trim() || fallback); | |
| if (!Number.isFinite(value) || value < 0) { | |
| alert('Invalid number: ' + next); | |
| return; | |
| } | |
| setSetting(settingKey, String(Math.floor(value))); | |
| location.reload(); | |
| } | |
| function scanReleaseRows() { | |
| document.querySelectorAll('.release_item').forEach((row) => { | |
| if (processedRows.has(row)) { | |
| return; | |
| } | |
| processedRows.add(row); | |
| enqueueTask(() => processReleaseRow(row)); | |
| }); | |
| } | |
| function observeReleaseList() { | |
| const target = document.querySelector('#middle_spawn') || document.body; | |
| const observer = new MutationObserver(() => scanReleaseRows()); | |
| observer.observe(target, { childList: true, subtree: true }); | |
| } | |
| async function processReleaseRow(row) { | |
| const release = readReleaseRow(row); | |
| if (!release) { | |
| return; | |
| } | |
| releaseRegex.lastIndex = 0; | |
| if (!releaseRegex.test(release.dirname)) { | |
| return; | |
| } | |
| try { | |
| const productDates = await getProductDates(release.productUrl); | |
| const officialDate = selectOfficialDate(productDates); | |
| if (!officialDate) { | |
| return; | |
| } | |
| const dayDelta = dateOrdinal(release.preDateKey) - dateOrdinal(officialDate.dateKey); | |
| if (dayDelta > 0) { | |
| const lateDate = await resolveLateDate(release, officialDate, dayDelta); | |
| if (lateDate.dayDelta > 0) { | |
| showLateIndicator( | |
| row, | |
| lateDate.officialDate, | |
| lateDate.dayDelta, | |
| lateDate.isBlurayDiscDate, | |
| lateDate.blurayDiscDate, | |
| ); | |
| } | |
| } | |
| } catch (error) { | |
| console.warn('[xREL Pre Date Indicator] Could not check release', release.dirname, error); | |
| } | |
| } | |
| function readReleaseRow(row) { | |
| const titleCell = row.querySelector('.release_title, .release_title_p2p'); | |
| const dateCell = row.querySelector('.release_date'); | |
| if (!titleCell || !dateCell) { | |
| return null; | |
| } | |
| const dirnameLink = | |
| titleCell.querySelector('.dirname-truncated a[title]') || | |
| titleCell.querySelector('a.sub[href*="-nfo/"]') || | |
| titleCell.querySelector('a.sub[href$="/nfo.html"]') || | |
| titleCell.querySelector('a.sub'); | |
| const dirname = readDirname(dirnameLink); | |
| if (!dirname) { | |
| return null; | |
| } | |
| const productLink = Array.from(titleCell.querySelectorAll('a[href]')).find((link) => { | |
| const href = link.getAttribute('href') || ''; | |
| const url = new URL(href, location.href); | |
| return ( | |
| url.origin === location.origin && | |
| /^\/(?:movie|title|tv|game|software|console)\/\d+\//.test(url.pathname) && | |
| !url.pathname.includes('-nfo/') | |
| ); | |
| }); | |
| if (!productLink) { | |
| return null; | |
| } | |
| const preDateKey = parseXrelListDate(dateCell.textContent || ''); | |
| if (!preDateKey) { | |
| return null; | |
| } | |
| return { | |
| dirname, | |
| imdbId: readImdbId(row), | |
| preDateKey, | |
| productUrl: new URL(productLink.getAttribute('href'), location.href).href, | |
| }; | |
| } | |
| function readDirname(link) { | |
| if (!link) { | |
| return null; | |
| } | |
| return (link.getAttribute('title') || link.textContent || '').trim() || null; | |
| } | |
| function readImdbId(root) { | |
| const link = root.querySelector('a[href*="imdb.com/title/tt"], a[title*="IMDb"]'); | |
| if (!link) { | |
| return null; | |
| } | |
| return extractImdbId((link.getAttribute('href') || '') + ' ' + (link.getAttribute('title') || '')); | |
| } | |
| function parseXrelListDate(text) { | |
| const match = text.match(/(\d{2})\.(\d{2})\.(\d{2})(?:\s+(\d{2}):(\d{2}))?/); | |
| if (!match) { | |
| return null; | |
| } | |
| const [, day, month, shortYear] = match; | |
| const yearNumber = Number(shortYear); | |
| const fullYear = yearNumber >= 70 ? 1900 + yearNumber : 2000 + yearNumber; | |
| return fullYear + '-' + month + '-' + day; | |
| } | |
| async function getProductDates(productUrl) { | |
| const cached = productDateCache.entries[productUrl]; | |
| if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) { | |
| return cached.dates; | |
| } | |
| if (productDateRequests.has(productUrl)) { | |
| return productDateRequests.get(productUrl); | |
| } | |
| const request = fetch(productUrl, { | |
| credentials: 'include', | |
| headers: { accept: 'text/html,application/xhtml+xml' }, | |
| }) | |
| .then((response) => { | |
| if (!response.ok) { | |
| throw new Error('Product page request failed with ' + response.status); | |
| } | |
| return response.text(); | |
| }) | |
| .then((html) => { | |
| const doc = new DOMParser().parseFromString(html, 'text/html'); | |
| const dates = extractProductDates(doc); | |
| productDateCache.entries[productUrl] = { | |
| cachedAt: Date.now(), | |
| dates, | |
| }; | |
| saveCache(); | |
| return dates; | |
| }) | |
| .finally(() => productDateRequests.delete(productUrl)); | |
| productDateRequests.set(productUrl, request); | |
| return request; | |
| } | |
| function extractProductDates(doc) { | |
| const datesBox = doc.querySelector('.extinfo_box_dates'); | |
| if (!datesBox) { | |
| return []; | |
| } | |
| return Array.from(datesBox.querySelectorAll('.extinfo_box_date')) | |
| .map((dateNode) => { | |
| const dateMatch = (dateNode.textContent || '').match(/(\d{2})\.(\d{2})\.(\d{4})/); | |
| if (!dateMatch) { | |
| return null; | |
| } | |
| const [, day, month, year] = dateMatch; | |
| const labels = []; | |
| let next = dateNode.nextElementSibling; | |
| while (next && !next.classList.contains('extinfo_box_date')) { | |
| if (next.matches('ul')) { | |
| labels.push( | |
| ...Array.from(next.querySelectorAll('li')) | |
| .map((item) => (item.textContent || '').trim()) | |
| .filter(Boolean), | |
| ); | |
| } | |
| next = next.nextElementSibling; | |
| } | |
| return { | |
| dateKey: year + '-' + month + '-' + day, | |
| labels, | |
| }; | |
| }) | |
| .filter(Boolean); | |
| } | |
| function selectOfficialDate(productDates) { | |
| const comparableDates = productDates.filter(isComparableProductDate); | |
| if (!comparableDates.length) { | |
| return null; | |
| } | |
| const matchingDates = comparableDates.filter((date) => | |
| date.labels.some((label) => { | |
| dateLabelRegex.lastIndex = 0; | |
| return dateLabelRegex.test(label); | |
| }), | |
| ); | |
| const candidates = matchingDates.length ? matchingDates : comparableDates; | |
| const sortedCandidates = candidates | |
| .slice() | |
| .sort((left, right) => dateOrdinal(left.dateKey) - dateOrdinal(right.dateKey)); | |
| return sortedCandidates[sortedCandidates.length - 1]; | |
| } | |
| function isComparableProductDate(productDate) { | |
| return productDate.labels.every((label) => !EXCLUDED_DATE_LABEL_REGEX.test(label)); | |
| } | |
| async function resolveLateDate(release, xrelOfficialDate, xrelDayDelta) { | |
| const fallback = { | |
| officialDate: xrelOfficialDate, | |
| dayDelta: xrelDayDelta, | |
| isBlurayDiscDate: false, | |
| blurayDiscDate: null, | |
| }; | |
| if (!shouldCheckBlurayDisc(release, xrelDayDelta)) { | |
| return fallback; | |
| } | |
| let blurayDiscDate; | |
| try { | |
| blurayDiscDate = await getBlurayDiscReleaseDate(release); | |
| } catch (error) { | |
| console.warn('[xREL Pre Date Indicator] Could not check bluray-disc.de', release.dirname, error); | |
| return fallback; | |
| } | |
| if (!blurayDiscDate || dateOrdinal(blurayDiscDate.dateKey) <= dateOrdinal(xrelOfficialDate.dateKey)) { | |
| return { | |
| ...fallback, | |
| blurayDiscDate: blurayDiscDate | |
| ? { | |
| dateKey: blurayDiscDate.dateKey, | |
| dayDelta: dateOrdinal(release.preDateKey) - dateOrdinal(blurayDiscDate.dateKey), | |
| } | |
| : null, | |
| }; | |
| } | |
| const blurayDiscDayDelta = dateOrdinal(release.preDateKey) - dateOrdinal(blurayDiscDate.dateKey); | |
| return { | |
| officialDate: { | |
| dateKey: blurayDiscDate.dateKey, | |
| labels: ['bluray-disc.de release date'], | |
| source: 'bluray-disc.de', | |
| sourceTitle: blurayDiscDate.title, | |
| sourceUrl: blurayDiscDate.url, | |
| xrelDateKey: xrelOfficialDate.dateKey, | |
| xrelDayDelta, | |
| }, | |
| dayDelta: blurayDiscDayDelta, | |
| isBlurayDiscDate: true, | |
| blurayDiscDate: { | |
| dateKey: blurayDiscDate.dateKey, | |
| dayDelta: blurayDiscDayDelta, | |
| }, | |
| }; | |
| } | |
| function shouldCheckBlurayDisc(release, dayDelta) { | |
| if (dayDelta > blurayDiscHighDeltaDays) { | |
| return true; | |
| } | |
| blurayDiscTriggerRegex.lastIndex = 0; | |
| return blurayDiscTriggerRegex.test(release.dirname); | |
| } | |
| async function getBlurayDiscReleaseDate(release) { | |
| const match = await findBlurayDiscMatch(release); | |
| if (!match) { | |
| return null; | |
| } | |
| const date = await getBlurayDiscPageReleaseDate(match.url); | |
| if (!date) { | |
| return null; | |
| } | |
| return { | |
| dateKey: date.dateKey, | |
| title: match.title, | |
| url: match.url, | |
| }; | |
| } | |
| async function findBlurayDiscMatch(release) { | |
| const directKey = 'direct:' + release.productUrl; | |
| const directCached = blurayDiscCache.entries[directKey]; | |
| if (directCached) { | |
| const directTtl = directCached.bdUrl ? BLURAY_DISC_CACHE_TTL_MS : BLURAY_DISC_NULL_CACHE_TTL_MS; | |
| if (Date.now() - directCached.cachedAt < directTtl) { | |
| return directCached.bdUrl | |
| ? { url: directCached.bdUrl, title: directCached.bdTitle || directCached.bdUrl } | |
| : null; | |
| } | |
| } | |
| const imdbId = release.imdbId || (await fetchProductImdbId(release.productUrl)); | |
| if (!imdbId) { | |
| cacheDirectResult(directKey, null, null); | |
| return null; | |
| } | |
| const match = await findBlurayDiscMovie(imdbId); | |
| cacheDirectResult(directKey, imdbId, match); | |
| return match; | |
| } | |
| function cacheDirectResult(directKey, imdbId, match) { | |
| blurayDiscCache.entries[directKey] = { | |
| cachedAt: Date.now(), | |
| imdbId, | |
| bdUrl: match ? match.url : null, | |
| bdTitle: match ? match.title : null, | |
| }; | |
| saveBlurayDiscCache(); | |
| } | |
| async function fetchProductImdbId(productUrl) { | |
| const cacheKey = 'product:' + productUrl; | |
| const cached = blurayDiscCache.entries[cacheKey]; | |
| if (cached) { | |
| const ttl = cached.imdbId ? BLURAY_DISC_CACHE_TTL_MS : BLURAY_DISC_NULL_CACHE_TTL_MS; | |
| if (Date.now() - cached.cachedAt < ttl) { | |
| return cached.imdbId || null; | |
| } | |
| } | |
| try { | |
| const response = await fetch(productUrl, { | |
| credentials: 'include', | |
| headers: { accept: 'text/html,application/xhtml+xml' }, | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Product page request failed with ' + response.status); | |
| } | |
| const html = await response.text(); | |
| const imdbId = extractImdbId(html); | |
| blurayDiscCache.entries[cacheKey] = { | |
| cachedAt: Date.now(), | |
| imdbId, | |
| }; | |
| saveBlurayDiscCache(); | |
| return imdbId; | |
| } catch (error) { | |
| console.warn('[xREL Pre Date Indicator] Could not fetch xREL product IMDb id', productUrl, error); | |
| return null; | |
| } | |
| } | |
| function extractImdbId(text) { | |
| const match = String(text || '').match(/tt\d{7,9}/i); | |
| return match ? match[0].toLowerCase() : null; | |
| } | |
| async function findBlurayDiscMovie(imdbId) { | |
| const cacheKey = 'imdb:' + imdbId; | |
| const cached = blurayDiscCache.entries[cacheKey]; | |
| if (cached) { | |
| const ttl = cached.url ? BLURAY_DISC_CACHE_TTL_MS : BLURAY_DISC_NULL_CACHE_TTL_MS; | |
| if (Date.now() - cached.cachedAt < ttl) { | |
| return cached.url ? { url: cached.url, title: cached.title || imdbId } : null; | |
| } | |
| } | |
| if (imdbRequests.has(imdbId)) { | |
| return imdbRequests.get(imdbId); | |
| } | |
| const request = searchBlurayDisc(imdbId) | |
| .then((match) => { | |
| blurayDiscCache.entries[cacheKey] = { | |
| cachedAt: Date.now(), | |
| url: match ? match.url : null, | |
| title: match ? match.title : null, | |
| }; | |
| saveBlurayDiscCache(); | |
| return match; | |
| }) | |
| .finally(() => imdbRequests.delete(imdbId)); | |
| imdbRequests.set(imdbId, request); | |
| return request; | |
| } | |
| async function searchBlurayDisc(imdbId) { | |
| const body = | |
| 'SearchString=' + | |
| encodeURIComponent(imdbId) + | |
| '&x=57&y=25§ion_movie=on&imports_all=on'; | |
| const html = await requestText({ | |
| method: 'POST', | |
| url: 'https://bluray-disc.de/suche', | |
| headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, | |
| data: body, | |
| }); | |
| const doc = new DOMParser().parseFromString(html, 'text/html'); | |
| const movieResult = doc.querySelector('#movie-result'); | |
| const item = movieResult && movieResult.querySelector('.result-item'); | |
| if (!item) { | |
| return null; | |
| } | |
| const link = item.querySelector('a[href*="/blu-ray-filme/"]'); | |
| if (!link) { | |
| return null; | |
| } | |
| const url = new URL(link.getAttribute('href'), 'https://bluray-disc.de/').href; | |
| const titleNode = item.querySelector('.item-info b') || link; | |
| const title = (titleNode.textContent || '').trim() || url; | |
| return { url, title }; | |
| } | |
| async function getBlurayDiscPageReleaseDate(url) { | |
| const cacheKey = 'bdDate:' + url; | |
| const cached = blurayDiscCache.entries[cacheKey]; | |
| if (cached) { | |
| const ttl = cached.dateKey ? BLURAY_DISC_CACHE_TTL_MS : BLURAY_DISC_NULL_CACHE_TTL_MS; | |
| if (Date.now() - cached.cachedAt < ttl) { | |
| return cached.dateKey ? { dateKey: cached.dateKey } : null; | |
| } | |
| } | |
| if (blurayDiscDateRequests.has(url)) { | |
| return blurayDiscDateRequests.get(url); | |
| } | |
| const request = requestText({ | |
| method: 'GET', | |
| url, | |
| headers: { accept: 'text/html,application/xhtml+xml' }, | |
| }) | |
| .then((html) => { | |
| const doc = new DOMParser().parseFromString(html, 'text/html'); | |
| const dateKey = extractBlurayDiscReleaseDate(doc); | |
| blurayDiscCache.entries[cacheKey] = { | |
| cachedAt: Date.now(), | |
| dateKey, | |
| }; | |
| saveBlurayDiscCache(); | |
| return dateKey ? { dateKey } : null; | |
| }) | |
| .finally(() => blurayDiscDateRequests.delete(url)); | |
| blurayDiscDateRequests.set(url, request); | |
| return request; | |
| } | |
| function extractBlurayDiscReleaseDate(doc) { | |
| const releaseKey = Array.from(doc.querySelectorAll('.key')).find((node) => | |
| /ver.+ffentlichung/i.test(normalizeText(node.textContent || '')), | |
| ); | |
| if (releaseKey) { | |
| const valueNode = releaseKey.nextElementSibling; | |
| const dateKey = valueNode ? parseBlurayDiscDate(valueNode.textContent || '') : null; | |
| if (dateKey) { | |
| return dateKey; | |
| } | |
| } | |
| const pageText = doc.body ? doc.body.textContent || '' : doc.textContent || ''; | |
| const markerMatch = pageText.match(/ver.+ffentlichung/i); | |
| if (markerMatch && typeof markerMatch.index === 'number') { | |
| const markerIndex = markerMatch.index; | |
| return parseBlurayDiscDate(pageText.slice(markerIndex, markerIndex + 200)); | |
| } | |
| return null; | |
| } | |
| function parseBlurayDiscDate(text) { | |
| const match = String(text || '').match(/\b(?:ab\s*)?(\d{1,2})\.(\d{1,2})\.(\d{4})\b/i); | |
| if (!match) { | |
| return null; | |
| } | |
| const dayNumber = Number(match[1]); | |
| const monthNumber = Number(match[2]); | |
| const yearNumber = Number(match[3]); | |
| if (dayNumber < 1 || dayNumber > 31 || monthNumber < 1 || monthNumber > 12 || yearNumber < 1900) { | |
| return null; | |
| } | |
| return ( | |
| String(yearNumber).padStart(4, '0') + | |
| '-' + | |
| String(monthNumber).padStart(2, '0') + | |
| '-' + | |
| String(dayNumber).padStart(2, '0') | |
| ); | |
| } | |
| function normalizeText(text) { | |
| return String(text || '').replace(/\s+/g, ' ').trim(); | |
| } | |
| function showLateIndicator(row, officialDate, dayDelta, isBlurayDiscDate, blurayDiscDate) { | |
| const titleCell = row.querySelector('.release_title, .release_title_p2p'); | |
| if (!titleCell || titleCell.querySelector('.xpti-late-indicator')) { | |
| return; | |
| } | |
| const oldDateBadge = row.querySelector('.release_date .xpti-late-indicator'); | |
| if (oldDateBadge) { | |
| oldDateBadge.remove(); | |
| } | |
| const badge = document.createElement('span'); | |
| badge.className = 'xpti-late-indicator'; | |
| badge.textContent = '+' + dayDelta + 'd' + (isBlurayDiscDate ? '!' : ''); | |
| badge.title = buildLateIndicatorTitle(officialDate, dayDelta, blurayDiscDate); | |
| const firstBreak = titleCell.querySelector('br'); | |
| titleCell.insertBefore(document.createTextNode(' '), firstBreak); | |
| titleCell.insertBefore(badge, firstBreak); | |
| row.classList.add('xpti-late-row'); | |
| } | |
| function buildLateIndicatorTitle(officialDate, dayDelta, blurayDiscDate) { | |
| const xrelDateKey = officialDate.xrelDateKey || officialDate.dateKey; | |
| const xrelDayDelta = | |
| typeof officialDate.xrelDayDelta === 'number' ? officialDate.xrelDayDelta : dayDelta; | |
| const lines = ['xREL: ' + formatDateKey(xrelDateKey) + ' (' + formatDayDelta(xrelDayDelta) + ')']; | |
| if (blurayDiscDate) { | |
| lines.push( | |
| 'Unsicherheitsprüfung, bluray-disc.de: ' + | |
| formatDateKey(blurayDiscDate.dateKey) + | |
| ' (' + | |
| formatDayDelta(blurayDiscDate.dayDelta) + | |
| ')', | |
| ); | |
| } | |
| return lines.join('\n'); | |
| } | |
| function formatDayDelta(dayDelta) { | |
| return (dayDelta > 0 ? '+' : '') + dayDelta + 'd'; | |
| } | |
| function formatDateKey(dateKey) { | |
| const match = String(dateKey || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); | |
| if (!match) { | |
| return dateKey; | |
| } | |
| return match[3] + '.' + match[2] + '.' + match[1]; | |
| } | |
| function requestText(options) { | |
| return new Promise((resolve, reject) => { | |
| if (typeof GM_xmlhttpRequest === 'function') { | |
| GM_xmlhttpRequest({ | |
| method: options.method, | |
| url: options.url, | |
| headers: options.headers || {}, | |
| data: options.data, | |
| onload: (response) => { | |
| if (response.status >= 200 && response.status < 300) { | |
| resolve(response.responseText); | |
| return; | |
| } | |
| reject(new Error('Request failed with ' + response.status)); | |
| }, | |
| onerror: () => reject(new Error('Request failed')), | |
| ontimeout: () => reject(new Error('Request timed out')), | |
| }); | |
| return; | |
| } | |
| fetch(options.url, { | |
| method: options.method, | |
| headers: options.headers, | |
| body: options.data, | |
| credentials: 'include', | |
| }) | |
| .then((response) => { | |
| if (!response.ok) { | |
| throw new Error('Request failed with ' + response.status); | |
| } | |
| return response.text(); | |
| }) | |
| .then(resolve) | |
| .catch(reject); | |
| }); | |
| } | |
| function enqueueTask(task) { | |
| taskQueue.push(task); | |
| drainQueue(); | |
| } | |
| function drainQueue() { | |
| while (activeTasks < maxConcurrentRequests && taskQueue.length) { | |
| const task = taskQueue.shift(); | |
| activeTasks += 1; | |
| Promise.resolve() | |
| .then(task) | |
| .catch((error) => console.warn('[xREL Pre Date Indicator] Task failed', error)) | |
| .finally(() => { | |
| activeTasks -= 1; | |
| drainQueue(); | |
| }); | |
| } | |
| } | |
| function dateOrdinal(dateKey) { | |
| const [year, month, day] = dateKey.split('-').map(Number); | |
| return Math.floor(Date.UTC(year, month - 1, day) / 86_400_000); | |
| } | |
| function compileSettingRegex(settingKey, fallback, defaultFlags, label) { | |
| const value = getSetting(settingKey, fallback); | |
| try { | |
| return parseUserRegex(value, defaultFlags); | |
| } catch (error) { | |
| console.warn('[xREL Pre Date Indicator] Invalid ' + label + '; using default', error); | |
| return parseUserRegex(fallback, defaultFlags); | |
| } | |
| } | |
| function parseUserRegex(value, defaultFlags) { | |
| const trimmed = value.trim(); | |
| if (trimmed.startsWith('/')) { | |
| const lastSlash = trimmed.lastIndexOf('/'); | |
| if (lastSlash > 0) { | |
| const pattern = trimmed.slice(1, lastSlash); | |
| const flags = sanitizeRegexFlags(trimmed.slice(lastSlash + 1)); | |
| return new RegExp(pattern, flags); | |
| } | |
| } | |
| return new RegExp(trimmed, sanitizeRegexFlags(defaultFlags)); | |
| } | |
| function sanitizeRegexFlags(flags) { | |
| return Array.from(new Set(flags.replace(/[gy]/g, '').split(''))).join(''); | |
| } | |
| function getNumberSetting(key, fallback) { | |
| const value = Number(getSetting(key, String(fallback))); | |
| if (!Number.isFinite(value) || value < 0) { | |
| return fallback; | |
| } | |
| return Math.floor(value); | |
| } | |
| function getSetting(key, fallback) { | |
| try { | |
| if (typeof GM_getValue === 'function') { | |
| return GM_getValue(key, fallback); | |
| } | |
| } catch (error) { | |
| // Fall back to localStorage. | |
| } | |
| return localStorage.getItem(key) || fallback; | |
| } | |
| function setSetting(key, value) { | |
| try { | |
| if (typeof GM_setValue === 'function') { | |
| GM_setValue(key, value); | |
| return; | |
| } | |
| } catch (error) { | |
| // Fall back to localStorage. | |
| } | |
| localStorage.setItem(key, value); | |
| } | |
| function registerMenuCommand(label, callback) { | |
| if (typeof GM_registerMenuCommand === 'function') { | |
| GM_registerMenuCommand(label, callback); | |
| } | |
| } | |
| function loadCache() { | |
| try { | |
| const parsed = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}'); | |
| if (parsed && typeof parsed === 'object' && parsed.entries) { | |
| return parsed; | |
| } | |
| } catch (error) { | |
| // Ignore malformed cache entries. | |
| } | |
| return { entries: {} }; | |
| } | |
| function saveCache() { | |
| try { | |
| localStorage.setItem(CACHE_KEY, JSON.stringify(productDateCache)); | |
| } catch (error) { | |
| console.warn('[xREL Pre Date Indicator] Could not save product date cache', error); | |
| } | |
| } | |
| function loadBlurayDiscCache() { | |
| try { | |
| if (typeof GM_getValue === 'function') { | |
| const gmCache = parseCacheValue(GM_getValue(BLURAY_DISC_CACHE_KEY, null)); | |
| if (gmCache) { | |
| return gmCache; | |
| } | |
| } | |
| } catch (error) { | |
| // Fall back to localStorage. | |
| } | |
| try { | |
| const localCache = parseCacheValue(localStorage.getItem(BLURAY_DISC_CACHE_KEY)); | |
| if (localCache) { | |
| try { | |
| if (typeof GM_setValue === 'function') { | |
| GM_setValue(BLURAY_DISC_CACHE_KEY, JSON.stringify(localCache)); | |
| } | |
| } catch (error) { | |
| // Keep using the localStorage copy. | |
| } | |
| return localCache; | |
| } | |
| } catch (error) { | |
| // Ignore malformed cache entries. | |
| } | |
| return { entries: {} }; | |
| } | |
| function parseCacheValue(value) { | |
| if (!value) { | |
| return null; | |
| } | |
| try { | |
| const parsed = typeof value === 'string' ? JSON.parse(value) : value; | |
| if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') { | |
| return parsed; | |
| } | |
| } catch (error) { | |
| // Ignore malformed cache entries. | |
| } | |
| return null; | |
| } | |
| function saveBlurayDiscCache() { | |
| const serialized = JSON.stringify(blurayDiscCache); | |
| let saved = false; | |
| try { | |
| if (typeof GM_setValue === 'function') { | |
| GM_setValue(BLURAY_DISC_CACHE_KEY, serialized); | |
| saved = true; | |
| } | |
| } catch (error) { | |
| // Fall back to localStorage. | |
| } | |
| try { | |
| localStorage.setItem(BLURAY_DISC_CACHE_KEY, serialized); | |
| saved = true; | |
| } catch (error) { | |
| if (saved) { | |
| return; | |
| } | |
| console.warn('[xREL Pre Date Indicator] Could not save bluray-disc cache', error); | |
| } | |
| } | |
| function injectStyles() { | |
| const style = document.createElement('style'); | |
| style.textContent = [ | |
| '.xpti-late-indicator {', | |
| ' display: inline;', | |
| ' margin-left: 4px;', | |
| ' color: #9d3328;', | |
| ' font-size: 10px;', | |
| ' font-weight: 700;', | |
| ' line-height: 14px;', | |
| ' vertical-align: 1px;', | |
| ' white-space: nowrap;', | |
| '}', | |
| ].join('\n'); | |
| document.head.appendChild(style); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment