Radarr JustWatch Streaming Availability

Adds a JustWatch streaming availability panel to Radarr movie detail pages

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Radarr JustWatch Streaming Availability
// @namespace    http://tampermonkey.net/
// @version      2.5.1
// @description  Adds a JustWatch streaming availability panel to Radarr movie detail pages
// @author       Dan Berkowitz
// @match        http://localhost:7878/*
// @match        http://127.0.0.1:7878/*
// @match        http://*/*:7878/*
// @match        http://*/radarr/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      apis.justwatch.com
// @connect      images.justwatch.com
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // --- Config ---

  // Monetization types to show. Remove a type to hide that category entirely.
  // Available: 'FLATRATE' (subscription), 'ADS' (free with ads), 'FREE', 'RENT', 'BUY'
  const INCLUDED_TYPES = ['FLATRATE', 'ADS', 'FREE'];

  // Add provider clearNames here to hide specific services regardless of type.
  // Check the browser console "[JW] Match:" line to see exact names for your region.
  const EXCLUDED_PROVIDERS = [
    'Netflix Standard with Ads',
  ];

  const PANEL_ID = 'jw-streaming-panel';

  // ---------------------------------------------------------------
  // Styles
  // ---------------------------------------------------------------
  GM_addStyle(`
    /* Prevent synopsis from being clipped when the Stream On panel is added */
    [class*="MovieDetails-header-"] {
      min-height: 500px !important;
      max-height: none !important;
    }
    #${PANEL_ID} {
      margin-top: 1px;
      padding: 5px 10px;
      background: rgba(255, 255, 255, 0.05);
      border: 1px solid rgba(255, 255, 255, 0.1);
      border-radius: 6px;
      font-family: inherit;
    }
    #${PANEL_ID} .jw-panel-header {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 5px;
    }
    #${PANEL_ID} .jw-panel-label {
      font-size: 11px;
      font-weight: 600;
      letter-spacing: 0.08em;
      color: rgba(255, 255, 255, 0.45);
    }
    #${PANEL_ID} .jw-panel-label a {
      color: rgba(255, 255, 255, 0.45);
    }
    #${PANEL_ID} .jw-panel-divider {
      flex: 1;
      height: 1px;
      background: rgba(255, 255, 255, 0.08);
    }
    #${PANEL_ID} .jw-provider-list {
      display: flex;
      flex-wrap: nowrap;
      gap: 10px;
      align-items: flex-start;
      overflow-x: auto;
      padding-bottom: 4px;
      scrollbar-width: thin;
      scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
    }
    #${PANEL_ID} .jw-provider-list::-webkit-scrollbar {
      height: 3px;
    }
    #${PANEL_ID} .jw-provider-list::-webkit-scrollbar-track {
      background: transparent;
    }
    #${PANEL_ID} .jw-provider-list::-webkit-scrollbar-thumb {
      background: rgba(255, 255, 255, 0.15);
      border-radius: 2px;
    }
    #${PANEL_ID} .jw-provider-item {
      display: flex;
      flex-direction: column;
      align-items: center;
      flex-shrink: 0;
      gap: 5px;
      opacity: 0.85;
    }
    #${PANEL_ID} .jw-provider-icon {
      width: 40px;
      height: 40px;
      border-radius: 8px;
      object-fit: cover;
      display: block;
    }
    #${PANEL_ID} .jw-provider-name {
      font-size: 9px;
      color: rgba(255, 255, 255, 0.5);
      text-align: center;
      max-width: 48px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    #${PANEL_ID} .jw-status {
      font-size: 12px;
      color: rgba(255, 255, 255, 0.4);
      font-style: italic;
    }
    #${PANEL_ID} .jw-not-available {
      font-size: 12px;
      color: rgba(255, 255, 255, 0.35);
    }
  `);

  // ---------------------------------------------------------------
  // Get movie title from the Radarr page
  // Radarr sets <title> to "Movie Name (2016) - Radarr"
  // ---------------------------------------------------------------
  function getMovieInfo() {
    const pageTitle = document.title || '';
    const match = pageTitle.match(/^(.+?)(?:\s*\((\d{4})\))?\s*-\s*Radarr/i);
    if (!match) return { title: null, year: null };
    return {
      title: match[1].trim(),
      year: match[2] ? parseInt(match[2], 10) : null
    };
  }

  const IMG_BASE = 'https://images.justwatch.com';

  // Single GraphQL query — search by title, return all offers
  function searchTitleGraphQL(title, year, callback) {
    const gql = `query SearchTitles($searchQuery: String!, $country: Country!, $language: Language!) {
      popularTitles(country: $country, first: 10, filter: { searchQuery: $searchQuery, objectTypes: [MOVIE] }) {
        edges {
          node {
            id
            ... on Movie {
              content(country: $country, language: $language) { title originalReleaseYear }
              offers(country: $country, platform: WEB) {
                monetizationType
                package { id clearName icon }
              }
            }
          }
        }
      }
    }`;

    GM_xmlhttpRequest({
      method: 'POST',
      url: 'https://apis.justwatch.com/graphql',
      headers: { 'Content-Type': 'application/json' },
      data: JSON.stringify({ query: gql, variables: { searchQuery: title, country: 'US', language: 'en' } }),
      onload: function (response) {
        try {
          const data = JSON.parse(response.responseText);
          const edges = (data.data && data.data.popularTitles && data.data.popularTitles.edges) || [];

          const titleLower = title.toLowerCase();
          let match = null;

          if (year) {
            match = edges.find(e =>
              e.node.content &&
              e.node.content.title.toLowerCase() === titleLower &&
              Math.abs((e.node.content.originalReleaseYear || 0) - year) <= 1
            );
          }
          if (!match) {
            match = edges.find(e => e.node.content && e.node.content.title.toLowerCase() === titleLower);
          }
          if (!match && edges.length > 0) match = edges[0];

          if (!match) { callback([], null); return; }

          const node = match.node;
          const jwUrl = 'https://www.justwatch.com/us/movie/' + node.id.replace('tm', '');

          const seen = new Set();
          const providers = (node.offers || []).filter(o => {
            if (!INCLUDED_TYPES.includes(o.monetizationType)) return false;
            if (EXCLUDED_PROVIDERS.includes(o.package.clearName)) return false;
            if (seen.has(o.package.id)) return false;
            seen.add(o.package.id);
            return true;
          }).map(o => ({
            name: o.package.clearName,
            icon: IMG_BASE + o.package.icon.replace('{profile}', 's100').replace('{format}', 'webp')
          }));

          console.log('[JW] Match:', node.content && node.content.title, '| providers:', providers.length);
          console.log('[JW] provider names:', providers.map(p => `'${p.name}'`).join(', '));
          callback(providers, jwUrl);
        } catch (e) {
          console.error('[JW] GraphQL parse error:', e);
          callback([], null);
        }
      },
      onerror: function (err) {
        console.error('[JW] GraphQL request error:', err);
        callback([], null);
      }
    });
  }

  // ---------------------------------------------------------------
  // Find the anchor element:
  // The last div whose class contains "MovieDetails-details-"
  // ---------------------------------------------------------------
  function findAnchor() {
    const all = document.querySelectorAll('[class*="MovieDetails-details-"]');
    const filtered = Array.from(all).filter(el => el.id !== PANEL_ID && !el.closest('#' + PANEL_ID));
    return filtered.length > 0 ? filtered[filtered.length - 1] : null;
  }

  // ---------------------------------------------------------------
  // Render helpers
  // ---------------------------------------------------------------
  function makeHeader() {
    const header = document.createElement('div');
    header.className = 'jw-panel-header';
    header.innerHTML = `<span class="jw-panel-label"><a href="https://www.justwatch.com/us/" target="_blank">JustWatch</a> Stream On:</span><div class="jw-panel-divider"></div>`;
    return header;
  }

  function removePanel() {
    const existing = document.getElementById(PANEL_ID);
    if (existing) existing.remove();
  }

  function insertAfterAnchor(panel) {
    const anchor = findAnchor();
    if (!anchor) { console.warn('[JW] Anchor element not found'); return false; }
    anchor.parentNode.insertBefore(panel, anchor.nextSibling);
    return true;
  }

  function renderLoading() {
    removePanel();
    const panel = document.createElement('div');
    panel.id = PANEL_ID;
    panel.appendChild(makeHeader());
    const status = document.createElement('span');
    status.className = 'jw-status';
    status.textContent = 'Checking JustWatch\u2026';
    panel.appendChild(status);
    insertAfterAnchor(panel);
  }

  function renderPanel(providers, jwUrl) {
    removePanel();
    const panel = document.createElement('div');
    panel.id = PANEL_ID;
    panel.appendChild(makeHeader());

    const body = document.createElement('div');

    if (!providers || providers.length === 0) {
      body.innerHTML = `<span class="jw-not-available">Not available for streaming in the US</span>`;
    } else {
      const list = document.createElement('div');
      list.className = 'jw-provider-list';

      for (const p of providers) {
        const item = document.createElement('div');
        item.className = 'jw-provider-item';
        item.title = p.name;

        const img = document.createElement('img');
        img.className = 'jw-provider-icon';
        img.src = p.icon;
        img.alt = p.name;
        img.loading = 'lazy';
        item.appendChild(img);

        const label = document.createElement('span');
        label.className = 'jw-provider-name';
        label.textContent = p.name;
        item.appendChild(label);

        list.appendChild(item);
      }

      body.appendChild(list);
    }

    panel.appendChild(body);
    insertAfterAnchor(panel);
  }

  // ---------------------------------------------------------------
  // Main injection logic
  // ---------------------------------------------------------------
  function inject() {
    const anchor = findAnchor();
    if (!anchor) return;

    const next = anchor.nextSibling;
    if (next && next.id === PANEL_ID) return;

    const { title, year } = getMovieInfo();
    if (!title) {
      console.warn('[JW] Could not determine movie title. document.title =', document.title);
      return;
    }

    console.log('[JW] Looking up:', title, year ? `(${year})` : '(no year)');
    renderLoading();

    searchTitleGraphQL(title, year, function (providers, jwUrl) {
      renderPanel(providers, jwUrl);
    });
  }

  // ---------------------------------------------------------------
  // Watch for Radarr's React router navigating to a movie page
  // ---------------------------------------------------------------
  let lastUrl = location.href;
  let injectTimeout = null;

  function scheduleInject() {
    clearTimeout(injectTimeout);
    injectTimeout = setTimeout(inject, 900);
  }

  const observer = new MutationObserver(function () {
    const urlChanged = location.href !== lastUrl;
    if (urlChanged) {
      lastUrl = location.href;
      removePanel();
    }
    const anchor = findAnchor();
    if (anchor && (!document.getElementById(PANEL_ID) || urlChanged)) {
      scheduleInject();
    }
  });

  observer.observe(document.body, { childList: true, subtree: true });

  scheduleInject();

})();