// ==UserScript==
// @name Tuna browser script
// @namespace univrsal
// @version 1.0.21
// @description Get song information from web players, based on NowSniper by Kıraç Armağan Önal
// @author univrsal
// @match *://open.spotify.com/*
// @match *://soundcloud.com/*
// @match *://music.yandex.com/*
// @match *://music.yandex.ru/*
// @match *://www.deezer.com/*
// @match *://play.pretzel.rocks/*
// @match *://*.youtube.com/*
// @match *://app.plex.tv/*
// @grant unsafeWindow
// @license GPLv2
// ==/UserScript==
(function () {
'use strict';
console.log("Loading tuna browser script");
// Configuration
var port = 1608;
var refresh_rate_ms = 500;
var cooldown_ms = 10000;
// Tuna isn't running we sleep, because every failed request will log into the console
// so we don't want to spam it
var failure_count = 0;
var cooldown = 0;
var last_state = {};
function post(data) {
if (data.status) {
/* if this tab isn't playing and the status hasn't changed we don't send an update
* otherwise tabs that are paused would constantly send the paused/stopped state
* which interferes another tab that is playing something
*/
if (data.status !== "playing" && last_state.status === data.status) {
return; // Prevent the paused state from being continously sent, since this tab is not playing, should prevent tabs from clashing with eachother
}
}
last_state = data;
var url = 'http://localhost:' + port + '/';
var xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Access-Control-Allow-Headers', '*');
xhr.setRequestHeader('Access-Control-Allow-Origin', '*');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status !== 200) {
failure_count++;
}
}
};
xhr.send(JSON.stringify({ data, hostname: window.location.hostname, date: Date.now() }));
}
// Safely query something, and perform operations on it
function query(target, fun, alt = null) {
var element = document.querySelector(target);
if (element !== null) {
return fun(element);
}
return alt;
}
function timestamp_to_ms(ts) {
var splits = ts.split(':');
if (splits.length == 2) {
return splits[0] * 60 * 1000 + splits[1] * 1000;
} else if (splits.length == 3) {
return splits[0] * 60 * 60 * 1000 + splits[1] * 60 * 1000 + splits[0] * 1000;
}
return 0;
}
function StartFunction() {
setInterval(() => {
if (failure_count > 3) {
console.log('Failed to connect multiple times, waiting a few seconds');
cooldown = cooldown_ms;
failure_count = 0;
}
if (cooldown > 0) {
cooldown -= refresh_rate_ms;
return;
}
let hostname = window.location.hostname;
// TODO: maybe add more?
if (hostname === 'soundcloud.com') {
let status = query('.playControl', e => e.classList.contains('playing') ? "playing" : "stopped", 'unknown');
let cover = query('.playbackSoundBadge span.sc-artwork', e => e.style.backgroundImage.slice(5, -2).replace('t50x50', 't500x500'));
let title = query('.playbackSoundBadge__titleLink', e => e.title);
let artists = [query('.playbackSoundBadge__lightLink', e => e.title)];
let progress = query('.playbackTimeline__timePassed span:nth-child(2)', e => timestamp_to_ms(e.textContent));
let duration = query('.playbackTimeline__duration span:nth-child(2)', e => timestamp_to_ms(e.textContent));
let album_url = query('.playbackSoundBadge__titleLink', e => e.href);
let album = null;
// this header only exists on album/set pages so we know this is a full album
album = query('.fullListenHero .soundTitle__title', e => {
album_url = window.location.href;
return e.innerText
})
album = query('div.playlist.playing', e => {
return e.getElementsByClassName('soundTitle__title')[0].innerText;
})
if (title !== null) {
post({ cover, title, artists, status, progress, duration, album_url, album });
}
} else if (hostname === 'open.spotify.com') {
let data = navigator.mediaSession;
let album = data.metadata.album;
let status = query('.vnCew8qzJq3cVGlYFXRI', e => e === null ? 'stopped' : (e.getAttribute('aria-label') === 'Play' ? 'stopped' : 'playing'));
let cover = data.metadata.artwork[0].src;
let title = data.metadata.title
let artists = [data.metadata.artist]
let progress = query('.playback-bar__progress-time-elapsed', e => timestamp_to_ms(e.textContent));
let duration = query('.npFSJSO1wsu3mEEGb5bh', e => timestamp_to_ms(e.textContent));
if (title !== null) {
post({ cover, title, artists, status, progress, duration, album });
}
} else if (hostname === 'music.yandex.ru') {
// Yandex music support by MjKey
let status = query('.player-controls__btn_play', e => e.classList.contains('player-controls__btn_pause') ? "playing" : "stopped", 'unknown');
let cover = query('.track-cover .entity-cover__image', e => e.src.replace('50x50', '200x200'));
let title = query('.track__title', e => e.title);
let artists = [query('.track__artists', e => e.textContent)];
let progress = query('.progress__left', e => timestamp_to_ms(e.textContent));
let duration = query('.progress__right', e => timestamp_to_ms(e.textContent));
let album_url = query('.track-cover a', e => e.title);
if (title !== null) {
post({ cover, title, artists, status, progress, duration, album_url });
}
} else if (hostname === 'www.youtube.com') {
if (!navigator.mediaSession.metadata) // if nothing is playing we don't submit anything, otherwise having two youtube tabs open causes issues
return;
let artists = [];
try {
artists = [document.querySelector('div#upload-info').querySelector('a').innerText.trim().replace("\n", "")];
} catch (e) { }
let title = query('.style-scope.ytd-video-primary-info-renderer', e => {
let t = e.getElementsByClassName('title');
if (t && t.length > 0)
return t[0].innerText;
return "";
});
let duration = query('video', e => e.duration * 1000);
let progress = query('video', e => e.currentTime * 1000);
let cover = "";
let status = query('video', e => e.paused ? 'stopped' : 'playing', 'unknown');
let regExp = /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
let match = window.location.toString().match(regExp);
if (match && match[2].length == 11) {
cover = `https://i.ytimg.com/vi/${match[2]}/maxresdefault.jpg`;
}
if (title !== null) {
title = title.replace(`${artists.join(", ")} - `, "");
title = title.replace(` - ${artists.join(", ")}`, "");
title = title.replace(`${artists.join(", ")}`, "");
title = title.replace("(Official Audio)", "");
title = title.replace("(Official Music Video)", "");
title = title.replace("(Original Video)", "");
title = title.replace("(Original Mix)", "");
if (status !== 'stopped') {
post({ cover, title, artists, status, progress: Math.floor(progress), duration });
} else {
post({ status: 'stopped', title: '', artists: [], progress: 0, duration: 0 });
}
}
} else if (hostname === 'music.youtube.com') {
if (!navigator.mediaSession.metadata) // if nothing is playing we don't submit anything, otherwise having two youtube tabs open causes issues
return;
// Youtube Music support by Rubecks
const artistsSelectors = [
'.ytmusic-player-bar.byline [href*="channel/"]:not([href*="channel/MPREb_"]):not([href*="browse/MPREb_"])', // Artists with links
'.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
'.ytmusic-player-bar.byline [href*="browse/FEmusic_library_privately_owned_artist_detaila_"]', // Self uploaded music
];
const albumSelectors = [
'.ytmusic-player-bar [href*="browse/MPREb_"]', // Albums from YTM with links
'.ytmusic-player-bar [href*="browse/FEmusic_library_privately_owned_release_detailb_"]', // Self uploaded music
];
let time = query('.ytmusic-player-bar.time-info', e => e.innerText.split(" / "));
let status = "unknown";
if (document.querySelector(".ytmusic-player-bar.play-pause-button path[d^='M6 19h4V5H6v14zm8-14v14h4V5h-4z']")) {
status = "playing";
}
if (document.querySelector(".ytmusic-player-bar.play-pause-button path[d^='M9,19H7V5H9ZM17,5H15V19h2Z']")) {
status = "stopped"
}
let title = query('.ytmusic-player-bar.title', e => e.title);
let artists = Array.from(document.querySelectorAll(artistsSelectors)).map(x => x.innerText);
let album = query(albumSelectors, e => e.textContent);
let artwork = navigator.mediaSession.metadata.artwork;
let cover = artwork[artwork.length - 1].src;
let album_url = query(albumSelectors, e => e.href);
let progress = timestamp_to_ms(time[0]);
let duration = timestamp_to_ms(time[1]);
if (title !== null) {
post({ cover, title, artists, status, progress, duration, album_url, album });
}
} else if (hostname === 'www.deezer.com') {
let status = query('.chakra-button.css-h1gi0s', e => {
return e.getAttribute('aria-label').toLowerCase() === "play" ? "paused" : "playing";
}, "stopped");
if ("mediaSession" in navigator && navigator.mediaSession.metadata !== null) {
let data = navigator.mediaSession;
let album = data.metadata.album;
let res = data.metadata.artwork[0].sizes;
let cover = data.metadata.artwork[0].src.replace(res, '512x512');
let title = data.metadata.title
let artists = data.metadata.artist.split(",").map(x => x.trim());
let progress_input = document.querySelector('input.slider-track-input.mousetrap');
let progress = Math.round(progress_input.value * 1000);
let duration = Math.round(progress_input.max * 1000);
if (title !== null) {
post({ cover, title, artists, status, progress, duration, album });
}
}
} else if (hostname === "play.pretzel.rocks") {
// Pretzel.rocks support by Tarulia
// Thanks to Rory from Pretzel for helping out :)
let status = "unknown";
if (document.querySelector("[data-testid=pause-button]")) {
status = "playing";
}
if (document.querySelector("[data-testid=play-button]")) {
status = "stopped";
}
let cover = query('[data-testid=track-artwork]', e => {
let img = e.getElementsByTagName('img');
if (img.length > 0) {
let src = img[0].src; // https://img.pretzel.rocks/artwork/9Mf8m9/medium.jpg
return src.replace('medium.jpg', 'large.jpg'); // https://img.pretzel.rocks/artwork/9Mf8m9/large.jpg
}
return null;
});
let title = query('[data-testid=title]', e => {
return e.textContent;
});
let artists = query('[data-testid=artist]', e => {
let elements = e.getElementsByTagName('a');
if (elements.length > 0) {
let artistArray = [];
for (let i = 0; i < elements.length; i++) {
artistArray.push(elements[i].textContent);
}
return artistArray;
}
return null;
});
let album = query('[data-testid=album]', e => {
return e.textContent;
});
let album_url = query('[data-testid=album]', e => {
return e.href;
});
let duration = query('[data-testid=track-progress-bar]', e => e.max * 1000);
let progress = query('[data-testid=track-progress-bar]', e => e.value * 1000);
if (title !== null) {
post({ cover, title, artists, status, progress, duration, album_url, album });
}
} else if (hostname === "app.plex.tv") {
// simple plex web support by javaarchive
// this is kind of more "universal" as it reads data from the browser media session api
// see https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API for more info
const mediaSessionStatesToTunaStates = {
"none": "unknown",
"playing": "playing",
"paused": "stopped"
}
let status = mediaSessionStatesToTunaStates[navigator.mediaSession.playbackState] || "unknown";
if (navigator.mediaSession.metadata) {
let title = navigator.mediaSession.metadata.title;
let artists = [navigator.mediaSession.metadata.artist];
let mediaElem = document.getElementsByTagName("audio")[0]; // add || document.getElementsByTagName("video")[0] to support sites like yt music where video includes audio
let progress = Math.floor(mediaElem.currentTime) * 1000;
let duration = Math.floor(mediaElem.duration) * 1000;
let artworks = navigator.mediaSession.metadata.artwork;
let album = navigator.mediaSession.metadata.album;
let album_url = artworks[artworks.length - 1].src;
let cover = album_url; // For now.
if (title !== null) {
post({ cover, title, artists, status, progress, duration, album, album_url });
}
}
}
}, refresh_rate_ms);
}
StartFunction();
})();