Culture.ru – Unlocked [Ath]

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

  1. // ==UserScript==
  2. // @name Culture.ru – Unlocked [Ath]
  3. // @name:ru Culture.ru – Отпёртый [Ath]
  4. // @name:uk Culture.ru – Розблоковано [Ath]
  5. // @name:be Culture.ru – Адмыкнуты [Ath]
  6. // @name:bg Culture.ru – Отключен [Ath]
  7. // @name:tt Culture.ru – Açıq [Ath]
  8. // @name:sl Culture.ru – Odklenjeno [Ath]
  9. // @name:sr Culture.ru – Otključano [Ath]
  10. // @name:ka Culture.ru – განბლოკილი [Ath]
  11. // @description Fixes the bug causing the "Видеозапись недоступна для просмотра по решению правообладателя" error message.
  12. // @description:ru Исправляет баг, приводящий к появлению сообщения "Видеозапись недоступна для просмотра по решению правообладателя".
  13. // @description:uk Виправляє баг, що призводить до появи повідомлення "Видеозапись недоступна для просмотра по решению правообладателя".
  14. // @description:be Іспраўляе баг, які прыводзіць да паўстання паведамлення "Видеозапись недоступна для просмотра по решению правообладателя".
  15. // @description:bg Отстранява грешката, която води до появата на съобщение "Видеозапись недоступна для просмотра по решению правообладателя".
  16. // @description:tt Хатаны төзәтә, ул "Видеозапись недоступна для просмотра по решению правообладателя" дигән хәбәр барлыкка килүгә китерә.
  17. // @description:sl Odpravlja napako, ki povzroča pojav sporočila "Видеозапись недоступна для просмотра по решению правообладателя".
  18. // @description:sr Ispravlja bag koji dovodi do pojavljivanja poruke "Видеозапись недоступна для просмотра по решению правообладателя".
  19. // @description:ka ფიქსირებს ბაგს, რომელიც იწვევს "Видеозапись недоступна для просмотра по решению правообладателя" შეტყობინების გამოჩენას.
  20. // @namespace athari
  21. // @author Athari (https://github.com/Athari)
  22. // @copyright © Prokhorov ‘Athari’ Alexander, 2024–2025
  23. // @license MIT
  24. // @homepageURL https://github.com/Athari/AthariUserJS
  25. // @supportURL https://github.com/Athari/AthariUserJS/issues
  26. // @version 1.0.4
  27. // @icon https://www.google.com/s2/favicons?sz=64&domain=culture.ru
  28. // @match https://*.culture.ru/*
  29. // @grant unsafeWindow
  30. // @grant GM_getValue
  31. // @grant GM_setValue
  32. // @grant GM_getResourceText
  33. // @grant GM_getResourceURL
  34. // @grant GM_info
  35. // @run-at document-start
  36. // @require https://cdn.jsdelivr.net/npm/string@3.3.3/dist/string.min.js
  37. // @require https://cdn.jsdelivr.net/npm/@athari/monkeyutils@0.2.2/monkeyutils.u.min.js
  38. // @require https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.light.min.js
  39. // @require https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.min.js
  40. // @resource script-urlpattern https://cdn.jsdelivr.net/npm/urlpattern-polyfill/dist/urlpattern.js
  41. // @resource css-plyr https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.css
  42. // @tag athari
  43. // ==/UserScript==
  44.  
  45. (async () => {
  46. 'use strict'
  47.  
  48. // Test URL: https://www.culture.ru/live/movies/3183/zimnyaya-skazka
  49.  
  50. const { waitForEvent, h, attempt, ress, scripts, els, opts } =
  51. athari.monkeyutils;
  52.  
  53. const res = ress(), script = scripts(res);
  54. const el = els(document, {
  55. mainHeader: "main > div:has(h1)",
  56. footer: "footer",
  57. titleDivs: "div:has(> h1) div",
  58. lstVideos: "#ath-videos", videos: "#ath-videos video",
  59. lstImages: "#ath-images", images: "#ath-images img",
  60. lblPlyrAuto: ".plyr__menu__container [data-plyr='quality'][value='0'] span",
  61. });
  62. const opt = opts({
  63. hideOriginal: true, thumbHeight: 240,
  64. });
  65. const strs = {
  66. en: {
  67. opt: {
  68. hideOriginal: "Hide original media",
  69. thumbHeight: "Thumbnail height",
  70. },
  71. },
  72. ru: {
  73. opt: {
  74. hideOriginal: "Скрыть исходные материалы",
  75. thumbHeight: "Высота превьюшек",
  76. },
  77. },
  78. g: {
  79. videoErrorMessage: "Видеозапись недоступна для просмотра по решению правообладателя",
  80. }
  81. };
  82. const language = navigator.languages.filter(l => strs[l] != null)[0] ?? strs[navigator.language] ?? 'ru';
  83. const str = strs[language];
  84.  
  85. S.extendPrototype();
  86. Object.assign(globalThis, globalThis.URLPattern ? null : await script.urlpattern);
  87.  
  88. await waitForEvent(document, 'DOMContentLoaded');
  89. const data = unsafeWindow.__NEXT_DATA__;
  90.  
  91. el.tag.head.insertAdjacentHTML('beforeEnd', /*html*/`
  92. <style>
  93. :root {
  94. color-scheme: dark;
  95. --ath-hide-original: ${+opt.hideOriginal};
  96. --ath-thumb-height: ${opt.thumbHeight}px;
  97. }
  98. body {
  99. container: if;
  100. }
  101. * {
  102. opacity: 1;
  103. }
  104.  
  105. @container if style(--ath-hide-original: 1) {
  106. main > div:not(:has(h2)) .swiper-container-horizontal {
  107. display: none;
  108. }
  109. }
  110.  
  111. #ath-main {
  112. display: flex;
  113. flex-flow: column;
  114. gap: 8px;
  115. padding: 8px;
  116. #ath-videos {
  117. display: flex;
  118. flex-flow: column;
  119. align-items: center;
  120. gap: 8px;
  121. video {
  122. min-width: 800px;
  123. min-height: 600px;
  124. }
  125. }
  126. #ath-images {
  127. display: flex;
  128. flex-flow: row wrap;
  129. justify-content: center;
  130. gap: 8px;
  131. .ath-image img {
  132. height: var(--ath-thumb-height);
  133. max-width: calc(var(--ath-thumb-height) * 2.5);
  134. }
  135. }
  136. }
  137.  
  138. #ath-options {
  139. margin: 8px auto;
  140. display: flex;
  141. flex-flow: row wrap;
  142. gap: 8px 32px;
  143. }
  144.  
  145. ${res.css.plyr.text}
  146.  
  147. @media screen and (max-width: 480px) {
  148. .plyr .plyr__controls button:is([data-plyr=pip], [data-plyr=mute], [data-plyr=volume]) {
  149. display: none;
  150. }
  151. }
  152. </style>`);
  153.  
  154. attempt("add options", () => {
  155. const inputs = [];
  156. const meta = (prop) => GM_info.script[`${prop}_i18n`]?.[language] ?? GM_info.script[prop];
  157. const formatAttrs = attrs => Object.entries(attrs).map(([k, v]) => `${k}="${h(v)}"`).join(" ");
  158. const tplInput = (id, attrs = { type: 'checkbox' }) => (
  159. inputs.push({ id, ...attrs }),
  160. /*html*/`<label>${
  161. attrs.type == 'checkbox'
  162. ? /*html*/`<input id="ath-${id}" ${opt[id] ? 'checked' : ""} ${formatAttrs(attrs)}> ${str.opt[id]}`
  163. : /*html*/`${str.opt[id]} <input id="ath-${id}" value="${h(opt[id])}" ${formatAttrs(attrs)}>`
  164. }</label>`);
  165. el.footer.insertAdjacentHTML('beforeBegin', /*html*/`
  166. <div id="ath-options">
  167. <label title="${h(meta('description'))}">${h(meta('name'))} ${GM_info.script.version}</label>
  168. ${tplInput('hideOriginal')}
  169. ${tplInput('thumbHeight', { type: 'range', min: 40, max: 400, step: 10, 'data-unit': " pixels" })}
  170. </div>`);
  171. for (let { id, type } of inputs) {
  172. const elInput = el.id[`ath-${id}`];
  173. elInput.onchange = () =>
  174. opt[id] = type == 'checkbox' ? elInput.checked : elInput.value;
  175. if (type == 'range') {
  176. elInput.insertAdjacentHTML('afterEnd', /*html*/`<output for="ath-${id}">`);
  177. const updateValue = () => elInput.nextElementSibling.value = ` ${elInput.value}${elInput.dataset.unit}`;
  178. elInput.oninput = updateValue;
  179. updateValue();
  180. }
  181. }
  182. });
  183.  
  184. attempt("publish raw materials", () => {
  185. const getImageUrl = (id, name = null, transform = null) =>
  186. `https://${data.runtimeConfig.services.storage.main.host}/images/${id}/${transform ?? "_"}/${name ?? "thumb.jpg"}`;
  187. const getImageThumbUrl = (id, name = null) =>
  188. getImageUrl(id, name, `h_${opt.thumbHeight},c_fill,g_center`);
  189. const getPlaylistUrl = id =>
  190. `https://video-playlist.culture.ru:443${id}`;
  191. el.mainHeader.insertAdjacentHTML('afterEnd', /*html*/`
  192. <div id="ath-main">
  193. <div id="ath-videos"></div>
  194. <div id="ath-images"></div>
  195. </div>`);
  196. const { movie } = data.props.pageProps;
  197. for (let mat of movie.materials) {
  198. console.debug("material", mat.type, mat);
  199. const file = mat.files[0];
  200. switch (mat.type) {
  201. case 'video':
  202. el.lstVideos.insertAdjacentHTML('beforeEnd', /*html*/`
  203. <div class="ath-video">
  204. <video width="800" height="600" controls crossorigin playsinline disablepictureinpicture
  205. data-src="${getPlaylistUrl(file.publicId)}"
  206. poster="${getImageUrl(movie.thumbnailFile.publicId, null, "h_600,w_800,c_fill")}">
  207. </video>
  208. </div>`);
  209. break;
  210. case 'photo':
  211. el.lstImages.insertAdjacentHTML('beforeEnd', /*html*/`
  212. <a class="ath-image" href="${getImageUrl(file.publicId, file.originalName)}">
  213. <img src="${getImageThumbUrl(file.publicId, file.originalName)}">
  214. </a>`);
  215. break;
  216. }
  217. }
  218. for (let video of el.all.videos) {
  219. if (Hls.isSupported()) {
  220. const options = {
  221. controls: [
  222. 'play-large', 'play', 'rewind', 'fast-forward',
  223. 'current-time', 'progress', 'duration',
  224. 'mute', 'volume', 'captions', 'settings', 'airplay', /*'download',*/ 'fullscreen',
  225. ],
  226. i18n: {
  227. qualityLabel: {
  228. 0: "Auto",
  229. },
  230. },
  231. settings: [ /*'captions',*/ 'quality', 'speed', 'loop' ],
  232. 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 ] },
  233. quality: { default: 1080, options: [ 1080 ] },
  234. urls: { download: video.dataset.src },
  235. disableContextMenu: false,
  236. playsinline: true,
  237. };
  238. let player = null;
  239. const hls = new Hls();
  240. hls.on(Hls.Events.MANIFEST_PARSED, () => {
  241. attempt("init video player", () => {
  242. const qualities = [0].concat(hls.levels.map(l => l.height).reverse());
  243. player = new Plyr(video, {
  244. ...options,
  245. quality: {
  246. default: 0, options: qualities, forced: true,
  247. onChange: (v) => hls.currentLevel = v == 0 ? -1 : qualities.findIndex(l => l.height == v),
  248. },
  249. listeners: {
  250. play: () => hls.startLoad(),
  251. qualitychange: () => player.currentTime != 0 && hls.startLoad(),
  252. },
  253. });
  254. console.debug({ hls, player });
  255. });
  256. });
  257. hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) =>
  258. el.lblPlyrAuto.innerText = hls.autoLevelEnabled ? `Auto (${hls.levels[data.level].height}p)` : "Auto");
  259. hls.loadSource(video.dataset.src);
  260. hls.attachMedia(video);
  261. } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
  262. video.src = video.dataset.src;
  263. }
  264. }
  265. });
  266.  
  267. attempt("fuck up error message", () => {
  268. const elError = el.all.titleDivs.filter(d => d.innerText == strs.g.videoErrorMessage)[0];
  269. if (elError != null)
  270. elError.innerHTML = /*html*/`<s>${h(strs.g.videoErrorMessage)}</s> 😜`;
  271. });
  272. })();