Culture.ru – Unlocked [Ath]

Fixes the bug causing the "Видеозапись недоступна для просмотра по решению правообладателя" error message.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           Culture.ru – Unlocked [Ath]
// @name:ru        Culture.ru – Отпёртый [Ath]
// @name:uk        Culture.ru – Розблоковано [Ath]
// @name:be        Culture.ru – Адмыкнуты [Ath]
// @name:bg        Culture.ru – Отключен [Ath]
// @name:tt        Culture.ru – Açıq [Ath]
// @name:sl        Culture.ru – Odklenjeno [Ath]
// @name:sr        Culture.ru – Otključano [Ath]
// @name:ka        Culture.ru – განბლოკილი [Ath]
// @description    Fixes the bug causing the "Видеозапись недоступна для просмотра по решению правообладателя" error message.
// @description:ru Исправляет баг, приводящий к появлению сообщения "Видеозапись недоступна для просмотра по решению правообладателя".
// @description:uk Виправляє баг, що призводить до появи повідомлення "Видеозапись недоступна для просмотра по решению правообладателя".
// @description:be Іспраўляе баг, які прыводзіць да паўстання паведамлення "Видеозапись недоступна для просмотра по решению правообладателя".
// @description:bg Отстранява грешката, която води до появата на съобщение "Видеозапись недоступна для просмотра по решению правообладателя".
// @description:tt Хатаны төзәтә, ул "Видеозапись недоступна для просмотра по решению правообладателя" дигән хәбәр барлыкка килүгә китерә.
// @description:sl Odpravlja napako, ki povzroča pojav sporočila "Видеозапись недоступна для просмотра по решению правообладателя".
// @description:sr Ispravlja bag koji dovodi do pojavljivanja poruke "Видеозапись недоступна для просмотра по решению правообладателя".
// @description:ka ფიქსირებს ბაგს, რომელიც იწვევს "Видеозапись недоступна для просмотра по решению правообладателя" შეტყობინების გამოჩენას.
// @namespace      athari
// @author         Athari (https://github.com/Athari)
// @copyright      © Prokhorov ‘Athari’ Alexander, 2024–2025
// @license        MIT
// @homepageURL    https://github.com/Athari/AthariUserJS
// @supportURL     https://github.com/Athari/AthariUserJS/issues
// @version        1.0.4
// @icon           https://www.google.com/s2/favicons?sz=64&domain=culture.ru
// @match          https://*.culture.ru/*
// @grant          unsafeWindow
// @grant          GM_getValue
// @grant          GM_setValue
// @grant          GM_getResourceText
// @grant          GM_getResourceURL
// @grant          GM_info
// @run-at         document-start
// @require        https://cdn.jsdelivr.net/npm/[email protected]/dist/string.min.js
// @require        https://cdn.jsdelivr.net/npm/@athari/[email protected]/monkeyutils.u.min.js
// @require        https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.light.min.js
// @require        https://cdn.jsdelivr.net/npm/[email protected]/dist/plyr.min.js
// @resource       script-urlpattern https://cdn.jsdelivr.net/npm/urlpattern-polyfill/dist/urlpattern.js
// @resource       css-plyr          https://cdn.jsdelivr.net/npm/[email protected]/dist/plyr.css
// @tag            athari
// ==/UserScript==

(async () => {
  'use strict'

  // Test URL: https://www.culture.ru/live/movies/3183/zimnyaya-skazka

  const { waitForEvent, h, attempt, ress, scripts, els, opts } =
    athari.monkeyutils;

  const res = ress(), script = scripts(res);
  const el = els(document, {
    mainHeader: "main > div:has(h1)",
    footer: "footer",
    titleDivs: "div:has(> h1) div",
    lstVideos: "#ath-videos", videos: "#ath-videos video",
    lstImages: "#ath-images", images: "#ath-images img",
    lblPlyrAuto: ".plyr__menu__container [data-plyr='quality'][value='0'] span",
  });
  const opt = opts({
    hideOriginal: true, thumbHeight: 240,
  });
  const strs = {
    en: {
      opt: {
        hideOriginal: "Hide original media",
        thumbHeight: "Thumbnail height",
      },
    },
    ru: {
      opt: {
        hideOriginal: "Скрыть исходные материалы",
        thumbHeight: "Высота превьюшек",
      },
    },
    g: {
      videoErrorMessage: "Видеозапись недоступна для просмотра по решению правообладателя",
    }
  };
  const language = navigator.languages.filter(l => strs[l] != null)[0] ?? strs[navigator.language] ?? 'ru';
  const str = strs[language];

  S.extendPrototype();
  Object.assign(globalThis, globalThis.URLPattern ? null : await script.urlpattern);

  await waitForEvent(document, 'DOMContentLoaded');
  const data = unsafeWindow.__NEXT_DATA__;

  el.tag.head.insertAdjacentHTML('beforeEnd', /*html*/`
    <style>
      :root {
        color-scheme: dark;
        --ath-hide-original: ${+opt.hideOriginal};
        --ath-thumb-height: ${opt.thumbHeight}px;
      }
      body {
        container: if;
      }
      * {
        opacity: 1;
      }

      @container if style(--ath-hide-original: 1) {
        main > div:not(:has(h2)) .swiper-container-horizontal {
          display: none;
        }
      }

      #ath-main {
        display: flex;
        flex-flow: column;
        gap: 8px;
        padding: 8px;
        #ath-videos {
          display: flex;
          flex-flow: column;
          align-items: center;
          gap: 8px;
          video {
            min-width: 800px;
            min-height: 600px;
          }
        }
        #ath-images {
          display: flex;
          flex-flow: row wrap;
          justify-content: center;
          gap: 8px;
          .ath-image img {
            height: var(--ath-thumb-height);
            max-width: calc(var(--ath-thumb-height) * 2.5);
          }
        }
      }

      #ath-options {
        margin: 8px auto;
        display: flex;
        flex-flow: row wrap;
        gap: 8px 32px;
      }

      ${res.css.plyr.text}

      @media screen and (max-width: 480px) {
        .plyr .plyr__controls button:is([data-plyr=pip], [data-plyr=mute], [data-plyr=volume]) {
          display: none;
        }
      }
    </style>`);

  attempt("add options", () => {
    const inputs = [];
    const meta = (prop) => GM_info.script[`${prop}_i18n`]?.[language] ?? GM_info.script[prop];
    const formatAttrs = attrs => Object.entries(attrs).map(([k, v]) => `${k}="${h(v)}"`).join(" ");
    const tplInput = (id, attrs = { type: 'checkbox' }) => (
      inputs.push({ id, ...attrs }),
        /*html*/`<label>${
          attrs.type == 'checkbox'
            ? /*html*/`<input id="ath-${id}" ${opt[id] ? 'checked' : ""} ${formatAttrs(attrs)}> ${str.opt[id]}`
            : /*html*/`${str.opt[id]} <input id="ath-${id}" value="${h(opt[id])}" ${formatAttrs(attrs)}>`
        }</label>`);
    el.footer.insertAdjacentHTML('beforeBegin', /*html*/`
      <div id="ath-options">
        <label title="${h(meta('description'))}">${h(meta('name'))} ${GM_info.script.version}</label>
        ${tplInput('hideOriginal')}
        ${tplInput('thumbHeight', { type: 'range', min: 40, max: 400, step: 10, 'data-unit': " pixels" })}
      </div>`);
    for (let { id, type } of inputs) {
      const elInput = el.id[`ath-${id}`];
      elInput.onchange = () =>
        opt[id] = type == 'checkbox' ? elInput.checked : elInput.value;
      if (type == 'range') {
        elInput.insertAdjacentHTML('afterEnd', /*html*/`<output for="ath-${id}">`);
        const updateValue = () => elInput.nextElementSibling.value = ` ${elInput.value}${elInput.dataset.unit}`;
        elInput.oninput = updateValue;
        updateValue();
      }
    }
  });

  attempt("publish raw materials", () => {
    const getImageUrl = (id, name = null, transform = null) =>
        `https://${data.runtimeConfig.services.storage.main.host}/images/${id}/${transform ?? "_"}/${name ?? "thumb.jpg"}`;
    const getImageThumbUrl = (id, name = null) =>
        getImageUrl(id, name, `h_${opt.thumbHeight},c_fill,g_center`);
    const getPlaylistUrl = id =>
        `https://video-playlist.culture.ru:443${id}`;
    el.mainHeader.insertAdjacentHTML('afterEnd', /*html*/`
      <div id="ath-main">
        <div id="ath-videos"></div>
        <div id="ath-images"></div>
      </div>`);
    const { movie } = data.props.pageProps;
    for (let mat of movie.materials) {
      console.debug("material", mat.type, mat);
      const file = mat.files[0];
      switch (mat.type) {
        case 'video':
          el.lstVideos.insertAdjacentHTML('beforeEnd', /*html*/`
            <div class="ath-video">
              <video width="800" height="600" controls crossorigin playsinline disablepictureinpicture
                  data-src="${getPlaylistUrl(file.publicId)}"
                  poster="${getImageUrl(movie.thumbnailFile.publicId, null, "h_600,w_800,c_fill")}">
              </video>
            </div>`);
          break;
        case 'photo':
          el.lstImages.insertAdjacentHTML('beforeEnd', /*html*/`
            <a class="ath-image" href="${getImageUrl(file.publicId, file.originalName)}">
              <img src="${getImageThumbUrl(file.publicId, file.originalName)}">
            </a>`);
          break;
      }
    }
    for (let video of el.all.videos) {
      if (Hls.isSupported()) {
        const options = {
          controls: [
            'play-large', 'play', 'rewind', 'fast-forward',
            'current-time', 'progress', 'duration',
            'mute', 'volume', 'captions', 'settings', 'airplay', /*'download',*/ 'fullscreen',
          ],
          i18n: {
            qualityLabel: {
              0: "Auto",
            },
          },
          settings: [ /*'captions',*/ 'quality', 'speed', 'loop' ],
          speed: { selected: 1.0, options: [ 0.10, 0.75, 1.0, 1.2, 1.35, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0 ] },
          quality: { default: 1080, options: [ 1080 ] },
          urls: { download: video.dataset.src },
          disableContextMenu: false,
          playsinline: true,
        };
        let player = null;
        const hls = new Hls();
        hls.on(Hls.Events.MANIFEST_PARSED, () => {
          attempt("init video player", () => {
            const qualities = [0].concat(hls.levels.map(l => l.height).reverse());
            player = new Plyr(video, {
              ...options,
              quality: {
                default: 0, options: qualities, forced: true,
                onChange: (v) => hls.currentLevel = v == 0 ? -1 : qualities.findIndex(l => l.height == v),
              },
              listeners: {
                play: () => hls.startLoad(),
                qualitychange: () => player.currentTime != 0 && hls.startLoad(),
              },
            });
            console.debug({ hls, player });
          });
        });
        hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) =>
          el.lblPlyrAuto.innerText = hls.autoLevelEnabled ? `Auto (${hls.levels[data.level].height}p)` : "Auto");
        hls.loadSource(video.dataset.src);
        hls.attachMedia(video);
      } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
        video.src = video.dataset.src;
      }
    }
  });

  attempt("fuck up error message", () => {
    const elError = el.all.titleDivs.filter(d => d.innerText == strs.g.videoErrorMessage)[0];
    if (elError != null)
      elError.innerHTML = /*html*/`<s>${h(strs.g.videoErrorMessage)}</s> 😜`;
  });
})();