Tuna browser script

Get song information from web players, based on NowSniper by Kıraç Armağan Önal

  1. // ==UserScript==
  2. // @name Tuna browser script
  3. // @namespace univrsal
  4. // @version 1.0.21
  5. // @description Get song information from web players, based on NowSniper by Kıraç Armağan Önal
  6. // @author univrsal
  7. // @match *://open.spotify.com/*
  8. // @match *://soundcloud.com/*
  9. // @match *://music.yandex.com/*
  10. // @match *://music.yandex.ru/*
  11. // @match *://www.deezer.com/*
  12. // @match *://play.pretzel.rocks/*
  13. // @match *://*.youtube.com/*
  14. // @match *://app.plex.tv/*
  15. // @grant unsafeWindow
  16. // @license GPLv2
  17. // ==/UserScript==
  18.  
  19. (function () {
  20. 'use strict';
  21. console.log("Loading tuna browser script");
  22.  
  23. // Configuration
  24. var port = 1608;
  25. var refresh_rate_ms = 500;
  26. var cooldown_ms = 10000;
  27.  
  28. // Tuna isn't running we sleep, because every failed request will log into the console
  29. // so we don't want to spam it
  30. var failure_count = 0;
  31. var cooldown = 0;
  32. var last_state = {};
  33.  
  34. function post(data) {
  35. if (data.status) {
  36. /* if this tab isn't playing and the status hasn't changed we don't send an update
  37. * otherwise tabs that are paused would constantly send the paused/stopped state
  38. * which interferes another tab that is playing something
  39. */
  40. if (data.status !== "playing" && last_state.status === data.status) {
  41. return; // Prevent the paused state from being continously sent, since this tab is not playing, should prevent tabs from clashing with eachother
  42. }
  43. }
  44. last_state = data;
  45. var url = 'http://localhost:' + port + '/';
  46. var xhr = new XMLHttpRequest();
  47. xhr.open('POST', url);
  48.  
  49. xhr.setRequestHeader('Accept', 'application/json');
  50. xhr.setRequestHeader('Content-Type', 'application/json');
  51. xhr.setRequestHeader('Access-Control-Allow-Headers', '*');
  52. xhr.setRequestHeader('Access-Control-Allow-Origin', '*');
  53.  
  54. xhr.onreadystatechange = function () {
  55. if (xhr.readyState === 4) {
  56. if (xhr.status !== 200) {
  57. failure_count++;
  58. }
  59. }
  60. };
  61.  
  62. xhr.send(JSON.stringify({ data, hostname: window.location.hostname, date: Date.now() }));
  63. }
  64.  
  65. // Safely query something, and perform operations on it
  66. function query(target, fun, alt = null) {
  67. var element = document.querySelector(target);
  68. if (element !== null) {
  69. return fun(element);
  70. }
  71. return alt;
  72. }
  73.  
  74. function timestamp_to_ms(ts) {
  75. var splits = ts.split(':');
  76. if (splits.length == 2) {
  77. return splits[0] * 60 * 1000 + splits[1] * 1000;
  78. } else if (splits.length == 3) {
  79. return splits[0] * 60 * 60 * 1000 + splits[1] * 60 * 1000 + splits[0] * 1000;
  80. }
  81. return 0;
  82. }
  83.  
  84. function StartFunction() {
  85. setInterval(() => {
  86. if (failure_count > 3) {
  87. console.log('Failed to connect multiple times, waiting a few seconds');
  88. cooldown = cooldown_ms;
  89. failure_count = 0;
  90. }
  91.  
  92. if (cooldown > 0) {
  93. cooldown -= refresh_rate_ms;
  94. return;
  95. }
  96.  
  97. let hostname = window.location.hostname;
  98. // TODO: maybe add more?
  99. if (hostname === 'soundcloud.com') {
  100. let status = query('.playControl', e => e.classList.contains('playing') ? "playing" : "stopped", 'unknown');
  101. let cover = query('.playbackSoundBadge span.sc-artwork', e => e.style.backgroundImage.slice(5, -2).replace('t50x50', 't500x500'));
  102. let title = query('.playbackSoundBadge__titleLink', e => e.title);
  103. let artists = [query('.playbackSoundBadge__lightLink', e => e.title)];
  104. let progress = query('.playbackTimeline__timePassed span:nth-child(2)', e => timestamp_to_ms(e.textContent));
  105. let duration = query('.playbackTimeline__duration span:nth-child(2)', e => timestamp_to_ms(e.textContent));
  106. let album_url = query('.playbackSoundBadge__titleLink', e => e.href);
  107. let album = null;
  108. // this header only exists on album/set pages so we know this is a full album
  109. album = query('.fullListenHero .soundTitle__title', e => {
  110. album_url = window.location.href;
  111. return e.innerText
  112. })
  113.  
  114. album = query('div.playlist.playing', e => {
  115. return e.getElementsByClassName('soundTitle__title')[0].innerText;
  116. })
  117.  
  118. if (title !== null) {
  119. post({ cover, title, artists, status, progress, duration, album_url, album });
  120. }
  121. } else if (hostname === 'open.spotify.com') {
  122. let data = navigator.mediaSession;
  123. let album = data.metadata.album;
  124. let status = query('.vnCew8qzJq3cVGlYFXRI', e => e === null ? 'stopped' : (e.getAttribute('aria-label') === 'Play' ? 'stopped' : 'playing'));
  125. let cover = data.metadata.artwork[0].src;
  126. let title = data.metadata.title
  127. let artists = [data.metadata.artist]
  128. let progress = query('.playback-bar__progress-time-elapsed', e => timestamp_to_ms(e.textContent));
  129. let duration = query('.npFSJSO1wsu3mEEGb5bh', e => timestamp_to_ms(e.textContent));
  130.  
  131.  
  132. if (title !== null) {
  133. post({ cover, title, artists, status, progress, duration, album });
  134. }
  135. } else if (hostname === 'music.yandex.ru') {
  136. // Yandex music support by MjKey
  137. let status = query('.player-controls__btn_play', e => e.classList.contains('player-controls__btn_pause') ? "playing" : "stopped", 'unknown');
  138. let cover = query('.track-cover .entity-cover__image', e => e.src.replace('50x50', '200x200'));
  139. let title = query('.track__title', e => e.title);
  140. let artists = [query('.track__artists', e => e.textContent)];
  141. let progress = query('.progress__left', e => timestamp_to_ms(e.textContent));
  142. let duration = query('.progress__right', e => timestamp_to_ms(e.textContent));
  143. let album_url = query('.track-cover a', e => e.title);
  144.  
  145. if (title !== null) {
  146. post({ cover, title, artists, status, progress, duration, album_url });
  147. }
  148. } else if (hostname === 'www.youtube.com') {
  149. if (!navigator.mediaSession.metadata) // if nothing is playing we don't submit anything, otherwise having two youtube tabs open causes issues
  150. return;
  151. let artists = [];
  152.  
  153. try {
  154. artists = [document.querySelector('div#upload-info').querySelector('a').innerText.trim().replace("\n", "")];
  155. } catch (e) { }
  156.  
  157. let title = query('.style-scope.ytd-video-primary-info-renderer', e => {
  158. let t = e.getElementsByClassName('title');
  159. if (t && t.length > 0)
  160. return t[0].innerText;
  161. return "";
  162. });
  163. let duration = query('video', e => e.duration * 1000);
  164. let progress = query('video', e => e.currentTime * 1000);
  165. let cover = "";
  166. let status = query('video', e => e.paused ? 'stopped' : 'playing', 'unknown');
  167. let regExp = /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
  168. let match = window.location.toString().match(regExp);
  169. if (match && match[2].length == 11) {
  170. cover = `https://i.ytimg.com/vi/${match[2]}/maxresdefault.jpg`;
  171. }
  172.  
  173.  
  174. if (title !== null) {
  175. title = title.replace(`${artists.join(", ")} - `, "");
  176. title = title.replace(` - ${artists.join(", ")}`, "");
  177. title = title.replace(`${artists.join(", ")}`, "");
  178. title = title.replace("(Official Audio)", "");
  179. title = title.replace("(Official Music Video)", "");
  180. title = title.replace("(Original Video)", "");
  181. title = title.replace("(Original Mix)", "");
  182. if (status !== 'stopped') {
  183. post({ cover, title, artists, status, progress: Math.floor(progress), duration });
  184. } else {
  185. post({ status: 'stopped', title: '', artists: [], progress: 0, duration: 0 });
  186. }
  187. }
  188. } else if (hostname === 'music.youtube.com') {
  189. if (!navigator.mediaSession.metadata) // if nothing is playing we don't submit anything, otherwise having two youtube tabs open causes issues
  190. return;
  191. // Youtube Music support by Rubecks
  192. const artistsSelectors = [
  193. '.ytmusic-player-bar.byline [href*="channel/"]:not([href*="channel/MPREb_"]):not([href*="browse/MPREb_"])', // Artists with links
  194. '.ytmusic-player-bar.byline .yt-formatted-string:nth-child(2n+1):not([href*="browse/"]):not([href*="channel/"]):not(:nth-last-child(1)):not(:nth-last-child(3))', // Artists without links
  195. '.ytmusic-player-bar.byline [href*="browse/FEmusic_library_privately_owned_artist_detaila_"]', // Self uploaded music
  196. ];
  197. const albumSelectors = [
  198. '.ytmusic-player-bar [href*="browse/MPREb_"]', // Albums from YTM with links
  199. '.ytmusic-player-bar [href*="browse/FEmusic_library_privately_owned_release_detailb_"]', // Self uploaded music
  200. ];
  201. let time = query('.ytmusic-player-bar.time-info', e => e.innerText.split(" / "));
  202.  
  203. let status = "unknown";
  204. if (document.querySelector(".ytmusic-player-bar.play-pause-button path[d^='M6 19h4V5H6v14zm8-14v14h4V5h-4z']")) {
  205. status = "playing";
  206. }
  207. if (document.querySelector(".ytmusic-player-bar.play-pause-button path[d^='M9,19H7V5H9ZM17,5H15V19h2Z']")) {
  208. status = "stopped"
  209. }
  210. let title = query('.ytmusic-player-bar.title', e => e.title);
  211. let artists = Array.from(document.querySelectorAll(artistsSelectors)).map(x => x.innerText);
  212. let album = query(albumSelectors, e => e.textContent);
  213. let artwork = navigator.mediaSession.metadata.artwork;
  214. let cover = artwork[artwork.length - 1].src;
  215. let album_url = query(albumSelectors, e => e.href);
  216. let progress = timestamp_to_ms(time[0]);
  217. let duration = timestamp_to_ms(time[1]);
  218. if (title !== null) {
  219. post({ cover, title, artists, status, progress, duration, album_url, album });
  220. }
  221. } else if (hostname === 'www.deezer.com') {
  222. let status = query('.chakra-button.css-h1gi0s', e => {
  223. return e.getAttribute('aria-label').toLowerCase() === "play" ? "paused" : "playing";
  224. }, "stopped");
  225.  
  226. if ("mediaSession" in navigator && navigator.mediaSession.metadata !== null) {
  227. let data = navigator.mediaSession;
  228. let album = data.metadata.album;
  229. let res = data.metadata.artwork[0].sizes;
  230. let cover = data.metadata.artwork[0].src.replace(res, '512x512');
  231. let title = data.metadata.title
  232. let artists = data.metadata.artist.split(",").map(x => x.trim());
  233. let progress_input = document.querySelector('input.slider-track-input.mousetrap');
  234. let progress = Math.round(progress_input.value * 1000);
  235. let duration = Math.round(progress_input.max * 1000);
  236. if (title !== null) {
  237. post({ cover, title, artists, status, progress, duration, album });
  238. }
  239.  
  240. }
  241. } else if (hostname === "play.pretzel.rocks") {
  242. // Pretzel.rocks support by Tarulia
  243. // Thanks to Rory from Pretzel for helping out :)
  244.  
  245. let status = "unknown";
  246.  
  247. if (document.querySelector("[data-testid=pause-button]")) {
  248. status = "playing";
  249. }
  250.  
  251. if (document.querySelector("[data-testid=play-button]")) {
  252. status = "stopped";
  253. }
  254.  
  255. let cover = query('[data-testid=track-artwork]', e => {
  256. let img = e.getElementsByTagName('img');
  257. if (img.length > 0) {
  258. let src = img[0].src; // https://img.pretzel.rocks/artwork/9Mf8m9/medium.jpg
  259. return src.replace('medium.jpg', 'large.jpg'); // https://img.pretzel.rocks/artwork/9Mf8m9/large.jpg
  260. }
  261. return null;
  262. });
  263.  
  264. let title = query('[data-testid=title]', e => {
  265. return e.textContent;
  266. });
  267.  
  268. let artists = query('[data-testid=artist]', e => {
  269. let elements = e.getElementsByTagName('a');
  270. if (elements.length > 0) {
  271. let artistArray = [];
  272. for (let i = 0; i < elements.length; i++) {
  273. artistArray.push(elements[i].textContent);
  274. }
  275. return artistArray;
  276. }
  277. return null;
  278. });
  279.  
  280. let album = query('[data-testid=album]', e => {
  281. return e.textContent;
  282. });
  283.  
  284. let album_url = query('[data-testid=album]', e => {
  285. return e.href;
  286. });
  287.  
  288. let duration = query('[data-testid=track-progress-bar]', e => e.max * 1000);
  289. let progress = query('[data-testid=track-progress-bar]', e => e.value * 1000);
  290.  
  291. if (title !== null) {
  292. post({ cover, title, artists, status, progress, duration, album_url, album });
  293. }
  294. } else if (hostname === "app.plex.tv") {
  295. // simple plex web support by javaarchive
  296. // this is kind of more "universal" as it reads data from the browser media session api
  297. // see https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API for more info
  298. const mediaSessionStatesToTunaStates = {
  299. "none": "unknown",
  300. "playing": "playing",
  301. "paused": "stopped"
  302. }
  303. let status = mediaSessionStatesToTunaStates[navigator.mediaSession.playbackState] || "unknown";
  304. if (navigator.mediaSession.metadata) {
  305. let title = navigator.mediaSession.metadata.title;
  306. let artists = [navigator.mediaSession.metadata.artist];
  307.  
  308. let mediaElem = document.getElementsByTagName("audio")[0]; // add || document.getElementsByTagName("video")[0] to support sites like yt music where video includes audio
  309. let progress = Math.floor(mediaElem.currentTime) * 1000;
  310. let duration = Math.floor(mediaElem.duration) * 1000;
  311.  
  312. let artworks = navigator.mediaSession.metadata.artwork;
  313. let album = navigator.mediaSession.metadata.album;
  314. let album_url = artworks[artworks.length - 1].src;
  315. let cover = album_url; // For now.
  316.  
  317. if (title !== null) {
  318. post({ cover, title, artists, status, progress, duration, album, album_url });
  319. }
  320. }
  321. }
  322. }, refresh_rate_ms);
  323.  
  324. }
  325.  
  326. StartFunction();
  327. })();