// ==UserScript== // @name IMDb to Seerr // @name:fr IMDb vers Seerr // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description Add a button to request movies and TV shows directly on Seerr from IMDb // @description:fr Ajoute un bouton pour demander films et séries sur Seerr directement depuis IMDb // @description:de Fügt einen Button hinzu, um Filme und Serien direkt von IMDb auf Seerr anzufragen // @description:es Añade un botón para solicitar películas y series en Seerr directamente desde IMDb // @description:it Aggiunge un pulsante per richiedere film e serie su Seerr direttamente da IMDb // @description:pt Adiciona um botão para solicitar filmes e séries no Seerr diretamente do IMDb // @description:ja IMDbからSeerrに映画やドラマを直接リクエストするボタンを追加します // @author MAT-GRC // @match https://www.imdb.com/title/* // @match https://www.imdb.com/*/title/* // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM.getValue // @grant GM.setValue // @grant GM_registerMenuCommand // @connect * // @updateURL https://raw.githubusercontent.com/MAT-GRC/imdb-seerr/main/imdb-seerr.user.js // @downloadURL https://raw.githubusercontent.com/MAT-GRC/imdb-seerr/main/imdb-seerr.user.js // @license MIT // ==/UserScript== 'use strict'; const LANG = navigator.language.startsWith('fr') ? 'fr' : navigator.language.startsWith('de') ? 'de' : navigator.language.startsWith('es') ? 'es' : navigator.language.startsWith('it') ? 'it' : navigator.language.startsWith('pt') ? 'pt' : navigator.language.startsWith('ja') ? 'ja' : 'en'; const MESSAGES = { en: { added: '✅ Added to Seerr', exists: '⚠️ Already requested', available: '📺 Already available', notFound: '❌ Not found on Seerr', error: '❌ Error', unreachable: '❌ Cannot reach Seerr', button: 'Seerr', loading: 'Adding...', configure: 'Configure Seerr', promptUrl: 'Seerr URL (e.g. http://192.168.1.x:5055):', promptKey: 'API key (Seerr → Settings → General):', configSaved: '✅ Configuration saved', notConfigured: '⚙️ Set your Seerr URL and API key first', }, fr: { added: '✅ Ajouté à Seerr', exists: '⚠️ Déjà demandé', available: '📺 Déjà disponible', notFound: '❌ Introuvable sur Seerr', error: '❌ Erreur', unreachable: '❌ Seerr inaccessible', button: 'Seerr', loading: 'Ajout...', configure: 'Configurer Seerr', promptUrl: 'URL de Seerr (ex. http://192.168.1.x:5055) :', promptKey: 'Clé API (Seerr → Paramètres → Général) :', configSaved: '✅ Configuration enregistrée', notConfigured: '⚙️ Renseigne d\'abord l\'URL et la clé API Seerr', }, de: { added: '✅ Zu Seerr hinzugefügt', exists: '⚠️ Bereits angefragt', available: '📺 Bereits verfügbar', notFound: '❌ Auf Seerr nicht gefunden', error: '❌ Fehler', unreachable: '❌ Seerr nicht erreichbar', button: 'Seerr', loading: 'Wird hinzugefügt...', configure: 'Seerr konfigurieren', promptUrl: 'Seerr-URL (z. B. http://192.168.1.x:5055):', promptKey: 'API-Key (Seerr → Einstellungen → Allgemein):', configSaved: '✅ Konfiguration gespeichert', notConfigured: '⚙️ Zuerst Seerr-URL und API-Key eintragen', }, es: { added: '✅ Añadido a Seerr', exists: '⚠️ Ya solicitado', available: '📺 Ya disponible', notFound: '❌ No encontrado en Seerr', error: '❌ Error', unreachable: '❌ Seerr no disponible', button: 'Seerr', loading: 'Añadiendo...', configure: 'Configurar Seerr', promptUrl: 'URL de Seerr (ej. http://192.168.1.x:5055):', promptKey: 'Clave API (Seerr → Ajustes → General):', configSaved: '✅ Configuración guardada', notConfigured: '⚙️ Configura primero la URL y la clave API', }, it: { added: '✅ Aggiunto a Seerr', exists: '⚠️ Già richiesto', available: '📺 Già disponibile', notFound: '❌ Non trovato su Seerr', error: '❌ Errore', unreachable: '❌ Seerr non raggiungibile', button: 'Seerr', loading: 'Aggiunta...', configure: 'Configura Seerr', promptUrl: 'URL di Seerr (es. http://192.168.1.x:5055):', promptKey: 'Chiave API (Seerr → Impostazioni → Generale):', configSaved: '✅ Configurazione salvata', notConfigured: '⚙️ Imposta prima URL e chiave API', }, pt: { added: '✅ Adicionado ao Seerr', exists: '⚠️ Já solicitado', available: '📺 Já disponível', notFound: '❌ Não encontrado no Seerr', error: '❌ Erro', unreachable: '❌ Seerr indisponível', button: 'Seerr', loading: 'A adicionar...', configure: 'Configurar Seerr', promptUrl: 'URL do Seerr (ex. http://192.168.1.x:5055):', promptKey: 'Chave API (Seerr → Definições → Geral):', configSaved: '✅ Configuração guardada', notConfigured: '⚙️ Define primeiro o URL e a chave da API', }, ja: { added: '✅ Seerrに追加しました', exists: '⚠️ すでにリクエスト済み', available: '📺 視聴可能です', notFound: '❌ Seerrで見つかりません', error: '❌ エラー', unreachable: '❌ Seerrに接続できません', button: 'Seerr', loading: '追加中...', configure: 'Seerrを設定', promptUrl: 'SeerrのURL (例: http://192.168.1.x:5055):', promptKey: 'APIキー (Seerr → 設定 → 一般):', configSaved: '✅ 設定を保存しました', notConfigured: '⚙️ まずSeerrのURLとAPIキーを設定してください', }, }; const MSG = MESSAGES[LANG]; // --- Compat Tampermonkey (GM_*) / Safari & iOS Userscripts (GM.*) --- function gmRequest(opts) { if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest(opts); if (typeof GM !== 'undefined' && GM.xmlHttpRequest) return GM.xmlHttpRequest(opts); opts.onerror && opts.onerror(new Error('No GM request API available')); } async function storeGet(key, def) { if (typeof GM_getValue === 'function') return GM_getValue(key, def); if (typeof GM !== 'undefined' && GM.getValue) return await GM.getValue(key, def); return def; } async function storeSet(key, value) { if (typeof GM_setValue === 'function') return GM_setValue(key, value); if (typeof GM !== 'undefined' && GM.setValue) return await GM.setValue(key, value); } // --- Configuration persistante (survit aux mises à jour du script) --- const config = { url: '', key: '' }; async function loadConfig() { config.url = (await storeGet('seerr_url', '')) || ''; config.key = (await storeGet('seerr_key', '')) || ''; } function isConfigured() { return config.url !== '' && config.key !== ''; } async function promptConfig() { const url = prompt(MSG.promptUrl, config.url || 'http://'); if (url === null) return false; const key = prompt(MSG.promptKey, config.key); if (key === null) return false; config.url = url.trim().replace(/\/+$/, ''); config.key = key.trim(); await storeSet('seerr_url', config.url); await storeSet('seerr_key', config.key); if (isConfigured()) showNotif(MSG.configSaved, '#16a34a'); return isConfigured(); } if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand(MSG.configure, () => promptConfig()); } // --- Page IMDb : identifiant et type de média --- function getPageInfo() { const m = location.pathname.match(/\/title\/(tt\d+)/); if (!m) return null; let imdbId = m[1]; let type = 'movie'; try { const ld = document.querySelector('script[type="application/ld+json"]'); if (ld) { const data = JSON.parse(ld.textContent); const t = data['@type']; if (t === 'TVSeries') { type = 'tv'; } else if (t === 'TVEpisode') { // Page d'épisode : on remonte à la série type = 'tv'; const parentUrl = data.partOfSeries && data.partOfSeries.url; const pm = parentUrl && parentUrl.match(/tt\d+/); if (pm) imdbId = pm[0]; } } } catch (e) { } return { imdbId, type }; } function getTitle() { const el = document.querySelector('h1[data-testid="hero__pageTitle"]'); return el ? el.textContent.trim() : ''; } // --- Notifications --- function showNotif(msg, color, subtitle) { const existing = document.getElementById('is-notif'); if (existing) existing.remove(); const n = document.createElement('div'); n.id = 'is-notif'; n.style.cssText = ` position: fixed; bottom: 24px; right: 24px; max-width: 320px; background: ${color}; color: white; padding: 12px 18px; border-radius: 10px; z-index: 99999; font-family: -apple-system, sans-serif; box-shadow: 0 4px 16px rgba(0,0,0,0.4); opacity: 0; transform: translateY(8px); transition: opacity 0.2s ease, transform 0.2s ease; `; const main = document.createElement('div'); main.textContent = msg; main.style.cssText = 'font-size: 13px; font-weight: 600; line-height: 1.3;'; n.appendChild(main); if (subtitle) { const sub = document.createElement('div'); sub.textContent = subtitle; sub.style.cssText = 'font-size: 12px; font-weight: 400; opacity: 0.85; margin-top: 2px; line-height: 1.3;'; n.appendChild(sub); } document.body.appendChild(n); requestAnimationFrame(() => { n.style.opacity = '1'; n.style.transform = 'translateY(0)'; }); setTimeout(() => { n.style.opacity = '0'; n.style.transform = 'translateY(8px)'; setTimeout(() => n.remove(), 300); }, 3500); } // --- États visuels du bouton --- function setBtnState(btn, state) { btn.style.opacity = '1'; btn.style.cursor = 'pointer'; if (state === 'requested') { btn.textContent = '✓ ' + MSG.button; btn.style.background = '#16a34a'; btn.style.color = '#fff'; btn.dataset.state = 'requested'; } else if (state === 'available') { btn.textContent = '✓ ' + MSG.button; btn.style.background = '#3b82f6'; btn.style.color = '#fff'; btn.dataset.state = 'available'; } else { btn.textContent = '+ ' + MSG.button; btn.style.background = '#f59e0b'; btn.style.color = '#000'; btn.dataset.state = 'idle'; } } function shakeBtn(btn) { btn.style.transition = 'transform 0.1s ease'; const shake = [0, -4, 4, -4, 4, -2, 2, 0]; shake.forEach((x, i) => { setTimeout(() => { btn.style.transform = `translateX(${x}px)`; }, i * 60); }); setTimeout(() => { btn.style.transform = ''; }, shake.length * 60); } // mediaInfo.status Seerr : 4 = partiellement dispo, 5 = disponible function mediaState(data) { const status = data.mediaInfo?.status || 0; if (status >= 4) return 'available'; const requests = data.mediaInfo?.requests || []; if (requests.some(r => !r.is4k && r.status !== 3)) return 'requested'; return 'idle'; } // --- Appels Seerr --- function seerrGet(path, onDone, onFail) { gmRequest({ method: 'GET', url: `${config.url}/api/v1${path}`, anonymous: true, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Api-Key': config.key }, onload: (res) => { let data = {}; try { data = JSON.parse(res.responseText); } catch (e) { } onDone(res.status, data); }, onerror: onFail }); } // Résolution ID IMDb -> ID TMDB via la recherche Seerr (format "imdb:tt...") const resolveCache = {}; function resolveTmdb(info, onDone, onFail) { const cached = resolveCache[info.imdbId]; if (cached) return onDone(cached); seerrGet(`/search?query=${encodeURIComponent('imdb:' + info.imdbId)}&page=1`, (status, data) => { if (status !== 200) return onDone(null, status, data); const results = data.results || []; const hit = results.find(r => r.mediaType === info.type) || results.find(r => r.mediaType === 'movie' || r.mediaType === 'tv'); if (!hit) return onDone(null, status, data); const resolved = { type: hit.mediaType, id: hit.id }; resolveCache[info.imdbId] = resolved; onDone(resolved); }, onFail); } // Affiche l'état (demandé / disponible) dès le chargement de la page function checkInitialState(info, btn) { resolveTmdb(info, (resolved) => { if (!resolved) return; seerrGet(`/${resolved.type}/${resolved.id}`, (status, data) => { if (status === 200) setBtnState(btn, mediaState(data)); }, () => { }); }, () => { }); } function requestMedia(info, btn) { // Le bouton vit désormais dans le