// ==UserScript==
// @name Deezier
// @namespace Violentmonkey Scripts
// @match https://www.deezer.*/*
// @grant none
// @version 1.2
// @author Kiprinite
// @description Make Deezer better enhancing it with new useful features
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/438369/Deezier.user.js
// @updateURL https://update.greasyfork.icu/scripts/438369/Deezier.meta.js
// ==/UserScript==
const ID_LIBRARY_ELMT = 'deezier-library';
const ID_SCROLL_MONITOR_ELMT = 'deezier-scrollelmt';
const ID_POPUP_ELMT = 'deezier-popup';
const ID_POPUP_HEADER = 'deezier-popup-header';
const ID_POPUP_BODY = 'deezier-popup-body';
const ID_POPUP_LIBRARY_ELMT = 'deezier-library-popup';
const ID_REFRESH_ELMT = 'deezier-refresh-btn';
class Util {
/* Collection of useful functions for general purpose */
static simplifyString(str) {
// "Les stations balnƩaires (version acoustique) [remix]" -> "lesstationsbalnaires"
return str.replace(/[\[("].*[\])"]|\W/g, '').toLowerCase();
}
static idFromUrl(url) {
return url.split('/').pop() || null;
}
static idFromHref(elmt) {
// Isolate the part after last slash '/' of the href URL for the given element
if (!elmt) { return console.error("Tried to retrieve id from href of an undefined element"); }
const href = elmt.getAttribute("href") || '';
return Util.idFromUrl(href);
}
static getElementUnderPointer() {
const elmtsUnder = document.querySelectorAll(':hover');
if (elmtsUnder.length) {
return elmtsUnder[elmtsUnder.length - 1]
}
return null;
}
static makeElementDraggable(elmt, fctShouldMove=null) {
function moveElmt(e) {
elmt.style.position = 'absolute';
elmt.style.top = e.clientY + 'px';
elmt.style.left = e.clientX - elmt.clientWidth/2 + 'px';
}
function mouseUp(e) {
window.removeEventListener('mousemove', moveElmt, true);
document.body.style.setProperty('user-select', "initial");
}
function mouseDown(e) {
if (fctShouldMove && !fctShouldMove()) { return; }
document.body.style = "user-select: none;";
window.addEventListener('mousemove', moveElmt, true);
}
elmt.addEventListener('mousedown', mouseDown, false);
window.addEventListener('mouseup', mouseUp, false);
}
}
class ElementBuilder {
/* Factory to create DOM elements to inject in deezer app (all native) */
static createElement(name, properties={}) {
// Generic snippet to create an arbitrary element along with its properties/children
const { id, classes, inner, innerHtml, attributes={}, style={}, children=[] } = properties;
var elmt = document.createElement(name);
if (id) { elmt.id = id; }
if (classes) { elmt.className = classes; }
if (inner) { elmt.innerText = inner; }
if (innerHtml) { elmt.innerHTML = innerHtml; }
Object.keys(attributes).map(k => { elmt.setAttribute(k, attributes[k]) });
Object.assign(elmt.style, style);
(Array.isArray(children) ? children : [children]).map(child => elmt.appendChild(child));
return elmt;
}
/* Diverse DOM elements */
static createInPlaylistToken(inPlaylists) {
// Create a little visual marker meaning 'already present in a playlist' in Deezer style (like the 'E' for explicit song)
var tokenContent = this.createElement('div',{
classes: "explicit outline small",
inner: inPlaylists.length == 1 ? "V" : inPlaylists.length,
style: { color: "green", 'border-color': "green" }
});
return this.createElement('div', {
classes: "datagrid-cell cell-explicit-small deezier-token",
attributes: {title: inPlaylists.join('\n')},
children: [tokenContent]
});
}
static createButton(text, cbFunction) {
var btn = this.createElement("button", {
inner: text,
style: { padding: "5px", border: "1px solid", margin: "5px", 'margin-left': "20px" }
});
btn.addEventListener('click', () => cbFunction());
return btn;
}
/* Elements related to the Deezier panel in the sidebar */
static createBtnDetectInPlaylistTracks() {
// A button to trigger the detection and adding of tokens to the already added tracks
return this.createButton("Detect Added šµ", () => DeezierArea.getInstance().appendInPlaylistTokens());
}
static createBtnDetectSimilarTracks() {
// A button to trigger the detection and adding of tokens to the already added tracks
function callback() {
const similarTracks = DeezierArea.getInstance().searchSimilarTracks();
DeezierArea.getInstance().setLibraryViewSimilarTracks(similarTracks);
}
return this.createButton("Detect Duplicate šµ", callback);
}
static createBtnGetArtistsTop() {
return this.createButton("Show Top š¤", () => {
const topArtists = DeezierArea.getInstance().getArtistsTop();
DeezierArea.getInstance().setLibraryViewTopArtists(topArtists);
});
}
static createSearchbar(forPopup=false) {
// A searchbar element that will determine the content displayed in the 'library list' below
var glass = this.createElement('div', {
inner: "š",
style: { float: "left", margin: "2px 8px 1px 2px" }
});
var searchField = this.createElement('input', {
attributes: { placeholder: "Search in playlists ...", type: "text" },
style : { 'border-style': "none", 'background-color': "#191922", color: "#a5a5ae", width: forPopup ? "300px" : "" }
});
var searchBar = this.createElement('div', {
style: { border: "1px solid", display: "inline-block", margin: forPopup ? "10px 0px 0px 10px" : "" },
children: [glass, searchField]}
);
searchField.addEventListener("keyup", e => {
const tomatch = e.target.value;
if (tomatch.length < 3) {
if (tomatch.length == 0) {
DeezierArea.getInstance().setLibraryViewPlaylists();
}
return;
}
const matches = DeezierArea.getInstance().searchInLibrary(tomatch);
DeezierArea.getInstance().setLibraryViewSearchResults(matches);
});
return searchBar;
}
static createExpandButton() {
// A button to expand the library view in a popup coming in front of the Deezer app page
const expandButton = this.createElement('button', { innerHtml: "ā¶", style: { width: "25px", color: "rgb(165, 165, 174)" } });
expandButton.addEventListener("click", () => DeezierArea.getInstance().toggleDeezierPopup());
return this.createElement('div', {
style: {
'background-color': "#2d2d2d", width: "fit-content", 'border-radius': "4px",
border: "1px solid", display: "inline-block", 'margin-left': "2px"
},
children: expandButton
});
}
static createLibraryListTopBar() {
// The bar above the library list element made up of the searchbar + expand button
return this.createElement('div', {
style: { margin: "15px 1px 5px 5px" },
children: [this.createSearchbar(), this.createExpandButton()]
});
}
static createLibraryList() {
// The frame where the library list elements will live, to be filled later with these ones
return this.createElement('div', {
style: {
height: "250px", width: "211px", 'overflow-y': "scroll",
border: "1px #aabbcc solid", padding: "10px", 'margin-left': "5px"
},
children: this.createElement('div', {
id: ID_LIBRARY_ELMT
})
});
}
static createLibraryListElmts() {
// Build a list filled with items that are the playlists known in the library
var elmts = [];
for (let [pId, playlist] of DeezierArea.getInstance().getLibrary()) {
var playlistLinkElmt = this.createElement('a', {
inner: `${playlist.title} (${playlist.length})`,
attributes: {href: playlist.url}
});
elmts.push(this.createElement('div', {
children: [playlistLinkElmt]
}));
}
return elmts;
}
static createSimilarTracksElmts(simTracks) {
var elmts = [];
var lib = DeezierArea.getInstance().getLibrary();
Object.entries(simTracks).map(([aId, simGroups]) => {
var artistName = lib.getArtistName(aId);
var children = [];
children.push(this.createElement('a', {
innerHtml:`[___${artistName} (${simGroups.length})___]`,
attributes: { href: "https://www.deezer.com/fr/artist/" + aId }
}));
simGroups.map((similars, i) => {
similars.map((track, j) => {
children.push(this.createElement('br'));
var branchStyle = 'ā£';
if (j == similars.length-1) {
branchStyle = 'ā”';
if (i == simGroups.length-1) { branchStyle = 'ā'; }
}
var playlists = lib.getPlaylistsNameFromId(track.inPlaylists, true).sort();
children.push(this.createElement('a', {
innerHtml: ` ${branchStyle} ${track.title} ā [ ${playlists.join(', ')} ]`,
attributes: { href: "https://www.deezer.com/fr/track/" + track.track_id },
style: { 'white-space': "nowrap" }
}));
});
});
elmts.push(this.createElement('div', {
children: children
}));
});
return elmts;
}
static createTopArtistElmts(topArtists) {
const elmts = [];
var lib = DeezierArea.getInstance().getLibrary();
topArtists.map(artist => {
var artistName = lib.getArtistName(artist.artist_id);
var fav = lib.isArtistFavorite(artist.artist_id) ? 'ā” ' : ' ';
var playlists = lib.getPlaylistsNameFromId(artist.inPlaylists).sort();
var line = this.createElement('a', {
innerHtml:`${fav}${artistName} (${artist.nbr_tracks} tracks) ā [ ${playlists.join(', ')} ]`,
attributes: { href: "https://www.deezer.com/fr/artist/" + artist.artist_id },
style: { 'white-space': "nowrap" }
});
elmts.push(this.createElement('div', {
children: line
}));
});
return elmts;
}
static createLibrarySearchResultsElmts(searchResults) {
// From the results of a research made in the searchbar, build the items to fill in the library list displaying matches
var elmts = [];
var lib = DeezierArea.getInstance().getLibrary();
Object.entries(searchResults).map(([pId, results]) => {
var playlist = lib.getPlaylist(pId);
var children = [];
// name of playlist we fond results in
children.push(this.createElement('a', {
innerHtml:`[___${playlist.title} (${results.title.length + results.artist.length})___]`,
attributes: {href: playlist.url}
}));
// elements in first serie under playlist name are matches on the song title
results.title.map((track, i, {length}) => {
children.push(this.createElement('br'));
var branchStyle = i == length-1 ? (results.artist.length ? 'ā”' : 'ā') : 'ā£';
children.push(this.createElement('a', {
innerHtml: ` ${branchStyle} ${track.title} - ${track.artist_name}`,
attributes: { href: track.url },
style: { 'white-space': "nowrap" }
}));
});
// elements in second serie under playlist name are matches on the artist name
results.artist.map((track, i, {length}) => {
children.push(this.createElement('br'));
var branchStyle = i == length-1 ? 'ā' : 'ā£';
children.push(this.createElement('a', {
innerHtml: ` ${branchStyle} ${track.title} - ${track.artist_name}`,
attributes: {href: track.url},
style: { 'white-space': "nowrap" }
}));
});
elmts.push(this.createElement('div', {
children: children
}));
});
return elmts;
}
static createLastRefreshElmt() {
var refreshButton = this.createElement('button', {
id: ID_REFRESH_ELMT,
innerText: "Last refresh at --:--"
});
refreshButton.onclick = () => { DeezierArea.getInstance().refreshLibraryContent().then(
() => { DeezierArea.getInstance().setLibraryViewPlaylists() }) };
return this.createElement('div', {
style: { 'text-align': "right", 'padding-right': "15px", 'color': "#52525d" },
children: refreshButton
});
}
static createDeezierPanelArea() {
// The global panel where Deezier's components live
var area = document.createElement("div");
area.appendChild(ElementBuilder.createBtnDetectInPlaylistTracks());
area.appendChild(ElementBuilder.createBtnDetectSimilarTracks());
area.appendChild(ElementBuilder.createBtnGetArtistsTop());
area.appendChild(ElementBuilder.createLibraryListTopBar());
area.appendChild(ElementBuilder.createLibraryList());
area.appendChild(ElementBuilder.createLastRefreshElmt());
return area;
}
/* Elements related to the popup spawned when expand button is triggered */
static createPopupHeader() {
const deezierTitle = this.createElement("div", {
inner: "deezier",
style: {
font: "bold 3em Deezer",
color: "white",
display: "inline-block",
'padding-left': "23px"
}
});
const closePopupButton = this.createElement("button", {
inner: "ā",
style: {
'font-size': "2em",
display: "inline-block",
float: "right",
'padding-right': "10px"
}
});
closePopupButton.addEventListener("click", () => DeezierArea.getInstance().toggleDeezierPopup());
return this.createElement("div", {
id: ID_POPUP_HEADER,
style: { height: "45px" },
children: [deezierTitle, closePopupButton, this.createElement("hr")]
});
}
static createPopupBodyTopBar() {
const searchBar = this.createSearchbar(true);
const btnSimilarTracks = this.createBtnDetectSimilarTracks();
const btnTopArtists = this.createBtnGetArtistsTop();
return this.createElement("div", {
style: { height: "5%" },
children: [searchBar, btnSimilarTracks, btnTopArtists]
});
}
static createPopupBodyLibraryList() {
// A frame similar to the library list view in sidebar but it can leverage the space offered by the popup
const libList = ElementFinder.getLibrary().cloneNode(true); // at creation consider same content as in sidebar
libList.id = ID_POPUP_LIBRARY_ELMT;
return this.createElement('div', {
style: {
height: "94%", 'overflow-y': "scroll",
border: "1px #aabbcc solid", padding: "10px", 'margin': "0px 5px 0px 5px"
},
children: libList
});
}
static createPopupBody() {
return this.createElement("div", {
id: ID_POPUP_BODY,
style: { height: "750px" },
children: [this.createPopupBodyTopBar(), this.createPopupBodyLibraryList()]
});
}
static createPopupPanel() {
// A popup that is spawned when the expand button is clicked, giving more space to display the library list items etc.
const popupHeader = this.createPopupHeader();
const popupBody = this.createPopupBody();
// build up header and body together in a popup element
const popup = this.createElement("div", {
id: ID_POPUP_ELMT,
style: {
width: "1000px",
height: "800px",
'z-index': "100",
position: "fixed",
left: "500px",
top: "100px",
'background-color': "#272731",
'border-radius': "9px"
},
children: [popupHeader, popupBody]
});
Util.makeElementDraggable(popup, () => Util.getElementUnderPointer() === popupHeader);
return popup;
}
}
class ElementFinder {
/* Find diverse DOM elements in the current view. At some point, Deezer start to obfuscate classnames, but this is not
* always used. We support both case, based on hardcoded obfuscated names. */
static OBFUSCATED = {
container_tracks: '_1uDWG',
track_toplvl: '_2OACy',
track: '_2OACy',
album: '_10fIC',
track_title: '_1R22u',
track_title_only: 'AL075' // track_title can contain explicit 'E' token or InPlaylist 'V' token + special case track unavailable
};
static getDeezerApp() {
// The root of the Deezer application
return document.getElementById("dzr-app");
}
static getProfileId() {
// Discover the user id by looking at current page
var l = document.getElementsByClassName("sidebar-nav-link is-main");
for (let e of l) {
var res = e.href.match(/.*profile\/(\d+)/);
if (res) { return res[1]; }
}
}
static getSidebar() {
// Deezer original left sidebar, present in whatever is the current app view
return document.getElementsByClassName("nano-content")[0];
}
static getPlayer() {
// The player element, expected to be always present at page bottom
return document.getElementById("page_player");
}
static getLibrary() {
// The current Deezier library list element on the sidebar
return document.getElementById(ID_LIBRARY_ELMT);
}
static getPopupLibrary() {
// The current Deezier library list expanded in the popup if it was spawned
return document.getElementById(ID_POPUP_LIBRARY_ELMT);
}
static getDeezierPopup() {
return document.getElementById(ID_POPUP_ELMT);
}
/* Tracks related elements */
static getCurrentTrackInPlayer() {
// The track currently played in the player and info about it (cannot get track id directly)
const player = this.getPlayer();
if (!player) { console.error("Unable to get global player object"); return null; }
const trackElmt = player.getElementsByClassName("track-title")[0];
if (!trackElmt) { console.error("Unable to get track object in player", player); return null; }
const [titleElmt, artistElmt] = trackElmt.getElementsByClassName("track-link");
if (!titleElmt || !artistElmt) { console.error("Unable to get info from track in player", trackElmt); return null; }
return {
track: trackElmt,
artist_id: Util.idFromHref(artistElmt),
artist_name: artistElmt.innerText,
album_id: Util.idFromHref(titleElmt), // clicking on the title redirects to album it's in actually
title: titleElmt.innerText
};
}
static getTracksInPage() {
// Build an array of tracks present in current page (beware Deezer adjusts it dynamically when scrolling)
var tracks = document.getElementsByClassName("datagrid-row song");
if (!tracks.length) {
tracks = document.getElementsByClassName(this.OBFUSCATED.track);
}
return tracks;
}
static getTrackIdFromElement(trackElement) {
// From a track element, find out its id (only usable when not obfuscated, otherwise it isn't present at all)
var titleElmts = trackElement.getElementsByClassName("datagrid-label-main title");
if (!titleElmts.length) {
return null;
}
var urlToParse = titleElmts[0].getAttribute('href');
return parseInt(urlToParse.substr(urlToParse.lastIndexOf('/')+1));
}
static getArtistInfoFromPage() {
return {
artistName: document.querySelector('meta[itemprop="name"]').content,
artistId: Util.idFromUrl(document.querySelector('meta[itemprop="url"]').content)
}
}
static getTrackInfosFromElement(trackElement) {
// Get the maximum information from a track element in the case it is obfuscated (no more id so we do the best)
const titleElmt = trackElement.getElementsByClassName(this.OBFUSCATED.track_title)[0];
// Note: Deezer implemented stupid feature to number tracks, need to strip it
const titleText = titleElmt.querySelector('.' + this.OBFUSCATED.track_title_only).innerText.replace(/^\d+\. /g, "");
const albumElmt = trackElement.querySelector("[data-testid='album']");
var albumName, albumId, artistName, artistId;
if (albumElmt === undefined) {
// We are probably on the artist's page where no album is displayed in track elements, try to get info elsewhere
var {artistName, artistId} = this.getArtistInfoFromPage();
} else {
albumName = albumElmt.innerText;
albumId = Util.idFromHref(albumElmt);
const artistElmt = trackElement.querySelector("[data-testid='artist']");
if (!artistElmt) {
// Didn't manage to get artist elmt at the left of album (Deezer removed column on pages where artist is explicit like Artist's top tracks)
var {artistName, artistId} = this.getArtistInfoFromPage();
} else {
artistName = artistElmt.innerText;
artistId = Util.idFromHref(artistElmt);
}
}
return {
title: titleText, title_elmt: titleElmt,
album_name: albumName, album_id: albumId,
artist_name: artistName, artist_id: artistId
};
}
/* Elements to monitor by observers */
static getElmtToMonitorPage() {
// Element whose class is passed temporarily to 'opened' every time user arrive to a new view
return document.getElementById("page_loader");
}
static getElmtToMonitorScrolling() {
// A container for the tracks in the current view Deezer maintains, that can be monitored to detect new ones spawned
var elmtToMonitor, isObfuscated;
const datagridElmt = document.getElementsByClassName("datagrid");
if (datagridElmt.length) {
const parent = datagridElmt[0];
if (parent.childNodes.length <= 1) { return null; }
elmtToMonitor = parent.childNodes[1];
isObfuscated = false;
} else { // Likely we are in obfuscated case
const trackContainer = document.getElementsByClassName(this.OBFUSCATED.container_tracks);
if (!trackContainer.length) { return null; }
elmtToMonitor = trackContainer[0];
isObfuscated = true;
}
elmtToMonitor.id = ID_SCROLL_MONITOR_ELMT;
return [elmtToMonitor, isObfuscated];
}
}
class DOM_Monitor {
/* Manage observers on DOM elements to track the Deezer app state and events */
static SCROLLING_OBS = 'scrolling';
static PAGE_OBS = 'pageloading';
static PLAYING_TRACK_OBS = 'playingtrack';
constructor() {
this.observers = {};
}
createObserver(name, domElmt, callback, options={}) {
// Add a new observer to the maintained one, by index (if already existing it is properly replaced)
options = Object.assign( { attributes: true, childList: false }, options);
if (this.observers[name] !== undefined) {
console.log("Disconnect listening DOM observer", name, this.observers[name]);
this.observers[name].disconnect();
}
this.observers[name] = new MutationObserver(callback);
this.observers[name].observe(domElmt, options);
console.log("Created a new listening DOM observer named", name, this.observers);
}
createPageChangeObserver() {
// Observer triggered when a new content view is loaded in deezer app
const elmtToMonitor = ElementFinder.getElmtToMonitorPage();
if (elmtToMonitor == null) {
console.error("Didn't find the DOM element page_loader to monitor page loading...");
return false;
}
const thisForCallback = this;
function cbPageChanged(mutationsList) {
mutationsList.forEach(mutation => {
if (mutation.type === "attributes" && mutation.attributeName === "class") {
if (!mutation.target.classList.contains("opened")) { // process when state is flipped back from opened
function newScrollingObs() {
if (!thisForCallback.createScrollingObserver()) {
console.log("New page view loaded but no element to monitor scrolling found in");
}
// in all cases, let's try to add inPlaylist tokens
DeezierArea.getInstance().appendInPlaylistTokens();
}
setTimeout(newScrollingObs, 500); // let the time for DOM to be filled in with components
}
}
});
}
this.createObserver(DOM_Monitor.PAGE_OBS, elmtToMonitor, cbPageChanged);
return true;
}
createScrollingObserver() {
// Observer triggered when new tracks are added by deezer (at scrolling) in the containing element
const scrollElmtFound = ElementFinder.getElmtToMonitorScrolling();
if (scrollElmtFound === null) { return false; }
var [elmtToMonitor, isObfuscated] = scrollElmtFound;
if (elmtToMonitor == null) { return false; }
function cbScrolling(mutationsList) {
var newTrackAdded = false;
for(var mutation of mutationsList) {
if (mutation.type == 'childList') {
for (var n of mutation.addedNodes) {
if (n.className === ElementFinder.OBFUSCATED.track_toplvl) {
newTrackAdded = true;
break;
}
}
}
}
if (newTrackAdded) { DeezierArea.getInstance().appendInPlaylistTokens(); }
};
var options = isObfuscated ? { childList: true, subtree: true, attributes: false } : { };
this.createObserver(DOM_Monitor.SCROLLING_OBS, elmtToMonitor, cbScrolling, options);
return true;
}
createPlayingTrackObserver() {
var trackPlayer = ElementFinder.getCurrentTrackInPlayer();
if (!trackPlayer) { return false; }
const trackElmt = trackPlayer['track'];
function cbTrackChange(mutationsList) {
var trackChanged = false;
for(var mutation of mutationsList) {
if (mutation.type == 'characterData') {
trackChanged = true;
}
}
if (trackChanged) { DeezierArea.getInstance().appendInPlaylistTokens(); }
};
const options = { childList: false, subtree: true, attributes: false, characterData: true };
this.createObserver(DOM_Monitor.PLAYING_TRACK_OBS, trackElmt, cbTrackChange, options);
return true;
}
}
class MusicLibrary {
/* For an user, maintain an index of his personal playlists and feed it with the tracks listed in, along with another structure
* indexed by artists that are in those playlists pulled from the Deezer API. */
constructor(profileId) {
this.profileId = profileId;
this.playlists = {}; // index by playlist id
this.artists = {}; // index by artist id
this.lastRefresh = null;
}
/* Pulling playlists & tracks from Deezer API and feed the library indexes (playlists + artists) */
async fetchPlaylists() {
// From the known user id, retrieve from Deezer API the list of his personal playlist and filter out interesting metadata
const response = await fetch(`https://api.deezer.com/user/${this.profileId}/playlists&limit=1000`);
const playlists = await response.json();
return playlists.data.map(p => ({
id: p.id,
url: p.link,
title: p.title,
length: p.nb_tracks,
creator: p.creator.id,
url_tracks: p.tracklist,
url_picture: p.picture,
time_lastmodif: p.time_mod
}));
}
async fetchTracks(playlistId) {
// From a playlist id, retrieve from Deezer API the list of tracks in and filter out interesting metadata
const response = await fetch(`${this.playlists[playlistId].url_tracks}&limit=1000`);
const tracks = await response.json();
return tracks.data.map(t => ({
track_id: t.id,
title: t.title,
url: t.link,
artist_id: t.artist.id,
artist_name: t.artist.name,
artist_url: t.artist.link,
album_id: t.album.id,
album_name: t.album.title,
album_url: t.album.tracklist
}));
}
async fetchFavoriteArtists() {
// From the known user id, retrieve all his favorite artists to mark them in artists library
const response = await fetch(`https://api.deezer.com/user/${this.profileId}/artists&limit=1000`);
const artists = await response.json();
return artists.data.map(a => ({
artist_id: a.id,
artist_name: a.name,
time_added: a.time_add,
nbr_fans: a.nb_fan
}));
}
async computePlaylists() {
// Fill the playlists index with metadata from the user playlists (not yet the tracks in these)
// The 'tracks' field with actual track data has to be filled afterwards calling fetchTracks()
var pList = await this.fetchPlaylists();
console.log("Fetched", pList.length, "playlists");
pList.map(p => {
this.playlists[p.id] = {
url: p.url,
title: p.title,
length: p.length,
creator: p.creator,
tracks: {}, // <- will be filled once tracks fetched as well
url_tracks: p.url_tracks,
url_picture: p.url_picture,
time_lastmodif: p.time_lastmodif
};
});
}
async computeTracks(playlistIds=[]) {
// For each playlist in the library or given list, fetch the tracks in it, create an object indexed by track ids and
// references this object in the property this.playlists.playlistId.tracks
playlistIds = playlistIds.length > 0 ? playlistIds : Object.keys(this.playlists);
for (let p of playlistIds) {
var trackList = await this.fetchTracks(p);
trackList.forEach(t => {
this.playlists[p]['tracks'][t.track_id] = t;
const artist = this.addArtist(t.artist_id, t.artist_name);
const album = this.addAlbumToArtist(t.artist_id, t.album_id, t.album_name);
const track = this.addTrackToArtistAlbum(t.artist_id, t.album_id, t.track_id, t.title, p);
if (!track['inPlaylists'].includes(p)) {
track['inPlaylists'].push(p);
}
});
}
}
async computeFavoriteArtists() {
const favArtists = await this.fetchFavoriteArtists();
console.log("Favorite artists ", favArtists);
favArtists.map(a => {
const artistEntry = this.getArtist(a.artist_id);
if (artistEntry) {
Object.assign(artistEntry, { favorite: true, time_added: a.time_added, nbr_fans: a.nbr_fans });
} else {
//console.error("A favorite artist", a.artist_name, "(id", a.artist_id, ") isn't in the library");
}
});
}
setLastRefresh() {
this.lastRefresh = new Date();
document.getElementById(ID_REFRESH_ELMT).innerText = `Last refresh at ${this.lastRefresh.getHours()}:${this.lastRefresh.getMinutes()}`;
}
/* Methods related to the playlist index */
getPlaylist(id) {
// Return an indexed playlist in the library by id
return this.playlists[id] || null;
}
isPlaylistListable(pId, lovedTracksPlaylist=false, otherUserPlaylists=false) {
// When we list some playlists, we want to omit some undesired specific ones using known criteria
const playlist = this.getPlaylist(pId);
if (playlist === null) { return false }
const isOwnUserPlaylist = (playlist.creator == ElementFinder.getProfileId());
if (otherUserPlaylists || isOwnUserPlaylist) { // consider only user's playlist if not specified
if (lovedTracksPlaylist || playlist.title != "Loved Tracks" || !isOwnUserPlaylist) {
return true;
}
}
return false;
}
getPlaylistsNameFromId(playlistIds, keepOmitted=false, fancyNames=true) {
// From a playlist ids list, return the corresponding names (maybe discarding some non listable ones)
if (!keepOmitted) {
playlistIds = playlistIds.filter(pId => this.isPlaylistListable(pId));
}
return playlistIds.map(pId => {
var title = this.getPlaylist(pId).title;
if (fancyNames) {
if (title === "Loved Tracks") { return "ā”"; }
}
return title;
});
}
getTracksInPlaylist(playlistId, onlyTrackIds=true) {
// From a playlist id, return the known tracks metadata (or only ids) we have in the index for this playlist
if (this.playlists[playlistId] !== undefined) {
return Object.entries(this.playlists[playlistId].tracks).map(([tId, track]) => onlyTrackIds ? tId : track);
}
return [];
}
getAllTracks(onlyTrackIds=true) {
// Build an array with all known tracks in the library's playlist index
var allTracks = [];
Object.keys(this.playlists).map(pId => allTracks.push(...this.getTracksInPlaylist(pId, onlyTrackIds)));
return allTracks;
}
getPlaylistsContainingTrack(trackId, lovedTracksPlaylist=false, otherUserPlaylists=false) {
// From a track id, return all playlists (title) we have containing the track in the library's playlists index
var inPlaylists = [];
Object.entries(this.playlists).map(([pId, playlist]) => {
if (this.isPlaylistListable(pId, lovedTracksPlaylist, otherUserPlaylists)) {
if (this.getTracksInPlaylist(pId).includes(String(trackId))) {
inPlaylists.push(playlist.title);
}
}
});
return inPlaylists;
}
searchMathingTracks(tomatch) {
// From the playlists, retrieve all tracks matching a pattern (used in search bar). Returns an object
// indexed by playlist id in which a match is found, either on the track title or the artist (separated in 2 arrays)
const re = RegExp(tomatch, 'i');
const matchedPlaylists = {};
Object.entries(this.playlists).map(([pId, playlist]) => {
var matches = { title: [], artist: [] };
Object.values(playlist.tracks).map(track => {
var matchCategory = null;
if (re.test(track.title) && !matches.title.filter(m => m.id === track.track_id).length) {
matchCategory = matches.title;
}
if (re.test(track.artist_name) && !matches.artist.filter(m => m.id === track.track_id).length) {
matchCategory = matches.artist;
}
matchCategory !== null && matchCategory.push(Object.assign({}, track));
});
if (matches.title.length || matches.artist.length) {
matchedPlaylists[pId] = matches;
}
});
return matchedPlaylists;
}
/* Methods related to the artist index */
getArtist(id) {
// From an artist id, return what we have in the library's artists index
return this.artists[id] || null;
}
getArtistName(artistId) {
const artist = this.getArtist(artistId);
if (!artist) { return null }
return artist['artist_name'];
}
isArtistFavorite(id) {
const artist = this.getArtist(id);
return artist ? (artist.favorite === true) : null;
}
getArtistIds() {
// Return the list of known artist ids in the library's artists index
return Object.keys(this.artists);
}
getAlbumsFromArtist(artistId) {
// From an artist id, return all the known albums in the library's artists index
const artist = this.getArtist(artistId);
if (!artist) { return null }
return artist['albums'];
}
getAlbumTracksFromArtist(artistId, albumId, albumName=null) {
// From the known artists, return the album tracks object if it exists by id, or the id of an exactly matching album title if
// the id doesn't exist anymore (it was returned by Deezer API which is inconsistent)
const artist = this.getArtist(artistId);
if (!artist) { return null }
if (!artist['albums'][albumId]) {
// Try to get best match on title because Deezer fucked up and returned obsolete id
var matchingAlbum = null;
Object.entries(artist['albums']).map(([albumId, album]) => {
if (album.album_name === albumName) {
matchingAlbum = albumId;
}
});
return matchingAlbum;
}
return artist['albums'][albumId]['album_tracks'] || null;
}
getAllAlbumsContentFromArtist(artistId) {
const albums = this.getAlbumsFromArtist(artistId);
const foundTracks = { };
if (albums === null) { return foundTracks; }
Object.values(albums).map(album => {
Object.assign(foundTracks, album['album_tracks']);
});
return foundTracks;
}
addArtist(artistId, artistName) {
// Add an artist to the library's artist index and return it (not added if already present)
const currArtist = this.artists[artistId];
if (currArtist) { return currArtist; }
const newArtist = {
artist_name: artistName,
albums: { }
};
this.artists[artistId] = newArtist;
return newArtist;
}
addAlbumToArtist(artistId, albumId, albumName) {
// Add an album to a known artist in the library's artist index and return it (not added if already present)
const currAlbum = this.artists[artistId]['albums'][albumId];
if (currAlbum) { return currAlbum; }
const newAlbum = {
album_name: albumName,
album_tracks: { }
};
this.artists[artistId]['albums'][albumId] = newAlbum;
return newAlbum;
}
addTrackToArtistAlbum(artistId, albumId, trackId, trackName, inPlaylist) {
// Add a track id to the referenced ones for a know album of an artist in the library's artist index and return it (not added if already present)
const currTrack = this.artists[artistId]['albums'][albumId]['album_tracks'][trackId];
if (currTrack) { return currTrack; }
const newTrack = {
title: trackName,
inPlaylists: [inPlaylist]
};
this.artists[artistId]['albums'][albumId]['album_tracks'][trackId] = newTrack;
return newTrack;
}
getSimilarTracksFromArtist(artistId) {
// For an artist, get the tracks that are similar by name. Return an object indexed by canonical name with as
// value an array of tracks matching this canonical name, thus to consider as 'similar' tracks
const albums = this.getAlbumsFromArtist(artistId);
if (!albums) { return null; }
const similars = {}; // indexed by a canonical representation of track's name
Object.values(albums).map(album => {
Object.entries(album.album_tracks).map(([trackId, track]) => {
var simplified = Util.simplifyString(track.title);
var newEntry = {track_id: trackId, title: track.title, inPlaylists: track.inPlaylists};
if (similars[simplified] === undefined) {
similars[simplified] = [newEntry];
} else {
similars[simplified].push(newEntry);
}
});
});
return Object.fromEntries(Object.entries(similars).filter(([_, arrSimTracks]) => arrSimTracks.length > 1));
}
getSimilarTracksGroupedByArtist(artistIds=[]) {
// For some artist ids or all, build an object indexed by artist id that contains arrays of tracks similar by
// title (similar tracks are grouped together in arrays) : aId -> [[simA1, simA2], [simB1, simB2, simB3], ...]
var artistIds = artistIds.length ? artistIds : this.getArtistIds();
const simTracksByArtist = {};
artistIds.map(artistId => {
const simTracks = this.getSimilarTracksFromArtist(artistId);
if (Object.keys(simTracks).length) {
simTracksByArtist[artistId] = Object.values(simTracks);
}
});
return simTracksByArtist;
}
getPlaylistsMatchingTrackFromArtist(artistId, trackTitle, albumId=null, albumName=null, onlySimilarTracks=false) {
// Sometimes we don't have the track id itself (only title), so we use known artist/album stuff to determine if
// the track is present in the library. Tries to perform the best, sometimes the album id doesn't exist anymore but actually
// the album name matches (likely Deezer API returns obsolete info). Returns an array of playlist names the track is in.
const inPlaylists = [];
if (albumId) {
var albumTracks = this.getAlbumTracksFromArtist(artistId, albumId, albumName);
if (typeof albumTracks === "string") {
// the album id we had in artist library was likely obsolete, but got another album id by matching album name
const matchingAlbumId = albumTracks;
albumTracks = this.getAlbumTracksFromArtist(artistId, matchingAlbumId);
console.log("Was unable to get album", albumId, albumName, "but found a match by name", matchingAlbumId, "where track", trackTitle, "is part of", albumTracks);
} else if (albumTracks === null) {
//console.error("While looking for track matching", trackTitle, ", didn't find any tracks in album", albumId, "of artist", artistId, this.getArtist(artistId));
albumTracks = {};
}
Object.entries(albumTracks).map(([id, albumTrack]) => {
if (onlySimilarTracks) {
if (Util.stringsSimilar(trackTitle, albumTrack.title)) {
inPlaylists.push(Object.assign(albumTrack, { id: id }));
}
} else if (albumTrack.title === trackTitle) {
inPlaylists.push(... albumTrack.inPlaylists);
}
});
return [... new Set(inPlaylists)];
} else { // will walk through all known albums of the given artist
Object.keys(this.getAlbumsFromArtist(artistId)).forEach(albumId => {
inPlaylists.push(... this.getPlaylistsMatchingTrackFromArtist(artistId, trackTitle, albumId, null, onlySimilarTracks));
});
}
return inPlaylists;
}
getAllTracksByArtist(artistIds=[]) {
var artistIds = artistIds.length ? artistIds : this.getArtistIds();
const tracks = { };
artistIds.map(aId => {
const albumContent = this.getAllAlbumsContentFromArtist(aId);
tracks[aId] = {
trackIds: Object.keys(albumContent),
inPlaylists: [... new Set(Object.values(albumContent).map(track => track.inPlaylists).flat())]
};
});
return tracks;
}
getStatisticsTopArtists(artistIds=[]) {
const topToOrder = Object.entries(this.getAllTracksByArtist(artistIds)).map(([aId, tracks]) => {
return { artist_id: aId, nbr_tracks: tracks.trackIds.length, inPlaylists: tracks.inPlaylists }
});
topToOrder.sort((a, b) => {
if (a.nbr_tracks < b.nbr_tracks) { return 1; }
if (a.nbr_tracks > b.nbr_tracks) { return -1; }
if (a.inPlaylists.length < b.inPlaylists.length) { return 1; }
else { return -1; }
})
return topToOrder;
}
/* Object methods */
[Symbol.iterator]() {
// Iterate over the indexed playlist in modification order (latest first)
function orderPlaylists([idA, plA], [idB, plB]) {
return plA.time_lastmodif < plB.time_lastmodif;
}
return Object.entries(this.playlists).sort(orderPlaylists)[Symbol.iterator]();
}
display() {
console.log("Music library for user", this.profileId, '\nPlaylists:', this.playlists, '\nArtists', this.artists);
}
}
class DeezierArea {
/* The place where all the stuff Deezier is working on is gathered, mapping in DOM as an additional area in the sidebar.
* Central point on which runtime methods can be called using the singleton. */
constructor(library) {
if(!DeezierArea._instance) {
DeezierArea._instance = this;
}
this.library = library; // the library gather all stuff related to user's playlists
this.domObserver = new DOM_Monitor(); // an object used to manage DOM listeners
return DeezierArea._instance;
}
static getInstance() {
return this._instance; // singleton
}
injectInPage() {
// inject the actual DOM area panel in the left side bar of Deezer interface
ElementFinder.getSidebar().appendChild(ElementBuilder.createDeezierPanelArea());
this.setLibraryViewPlaylists();
// setup observers on DOM elements
this.domObserver.createScrollingObserver(); // don't wait until we load a new page view to try it
this.domObserver.createPageChangeObserver();
this.domObserver.createPlayingTrackObserver();
}
appendInPlaylistTokens() {
// Add a 'V' token in the frontend beside every song already present in a user's playlist
// 1. Potential tracks in current page view (playlist, album)
var tracks = ElementFinder.getTracksInPage();
console.log("Found", tracks.length, "tracks on this page !", tracks);
// TODO : not very efficient to go through the whole library for each track >:(
for (let track of tracks) {
if(track && track.getAttribute('deezier-token')) { continue; } // song unavailable or already marked with a token
var titleElmt, inPlaylistsName = [];
var trackId = ElementFinder.getTrackIdFromElement(track);
if (trackId) {
titleElmt = track.querySelector(".cell-title");
inPlaylistsName = this.library.getPlaylistsContainingTrack(trackId);
} else { // likely we are in the case classnames are obfuscated
const trackInfos = ElementFinder.getTrackInfosFromElement(track); // cannot get directly track id, but we have artist/album id + name of the track
titleElmt = trackInfos.title_elmt;
var inPlaylistsId = this.library.getPlaylistsMatchingTrackFromArtist(trackInfos.artist_id, trackInfos.title, trackInfos.album_id, trackInfos.album_name);
inPlaylistsName = this.library.getPlaylistsNameFromId(inPlaylistsId);
}
if (inPlaylistsName.length) { // track is in at least one playlist
titleElmt.parentElement.insertBefore(ElementBuilder.createInPlaylistToken(inPlaylistsName), titleElmt);
track.setAttribute('deezier-token', 1);
}
}
// 2. The current track in the player at the bottom
const currTrackInfo = ElementFinder.getCurrentTrackInPlayer();
if (!currTrackInfo) {
console.error("Unable to retrieve track currently playing");
return null;
}
var titleElmt = currTrackInfo.track;
if (titleElmt.getAttribute('deezier-token')) {
titleElmt.removeAttribute('deezier-token');
titleElmt.parentNode.getElementsByClassName('deezier-token')[0].remove();
}
var inPlaylistsId = this.library.getPlaylistsMatchingTrackFromArtist(currTrackInfo.artist_id, currTrackInfo.title, currTrackInfo.album_id);
var inPlaylistsName = this.library.getPlaylistsNameFromId(inPlaylistsId);
if (inPlaylistsName.length) {
titleElmt.parentElement.insertBefore(ElementBuilder.createInPlaylistToken(inPlaylistsName), titleElmt);
titleElmt.setAttribute('deezier-token', 1);
}
}
toggleDeezierPopup() {
// (De)spawn the deezier popup, where we have more space to display library & other Deezier stuff
const popupElmt = ElementFinder.getDeezierPopup();
if(popupElmt === null) {
ElementFinder.getDeezerApp().appendChild(ElementBuilder.createPopupPanel());
} else {
popupElmt.remove();
}
}
async refreshLibraryContent() {
console.log("Retrieving playlists for user", this.library.profileId, "...");
await this.library.computePlaylists();
console.log("Retrieving tracks from all playlists in library ...");
this.library.computeTracks().then(() => {
console.log("Retrieving favorite artists ...");
this.library.computeFavoriteArtists().then(() => {
this.library.display();
this.library.setLastRefresh();
this.appendInPlaylistTokens();
});
}); // no await here to avoid blocking too much time, we can already inject in DOM what we have
}
searchInLibrary(tomatch) {
// From a given pattern, search in the built library for some matches on title/artist name
return this.library.searchMathingTracks(tomatch);
}
searchSimilarTracks(artistIds=[]) {
return this.library.getSimilarTracksGroupedByArtist(artistIds);
}
getArtistsTop(artistIds=[]) {
return this.library.getStatisticsTopArtists();
}
cleanLibraryViews() {
// Remove the content of the library view from its container
const libraryElmt = ElementFinder.getLibrary();
while (libraryElmt.firstChild) { libraryElmt.firstChild.remove(); }
const libraryPopupElmt = ElementFinder.getPopupLibrary();
if (libraryPopupElmt) { // Additionaly if the popup has been spawned ...
while (libraryPopupElmt.firstChild) { libraryPopupElmt.firstChild.remove(); }
}
return [libraryElmt, libraryPopupElmt];
}
setLibraryViewPlaylists() {
// Fill in the library view with the list of user's playlists
const [libraryElmt, libraryPopupElmt] = this.cleanLibraryViews();
ElementBuilder.createLibraryListElmts().map(p => {
libraryElmt.appendChild(p);
if (libraryPopupElmt) { libraryPopupElmt.appendChild(p.cloneNode(true)); }
});
}
setLibraryViewSearchResults(searchResults) {
// Fill the library view with the results of a research done in the dedicated Deezier searchbar
const [libraryElmt, libraryPopupElmt] = this.cleanLibraryViews();
ElementBuilder.createLibrarySearchResultsElmts(searchResults).map(p => {
libraryElmt.appendChild(p);
if (libraryPopupElmt) { libraryPopupElmt.appendChild(p.cloneNode(true)); }
});
}
setLibraryViewSimilarTracks(similarTracks) {
const [libraryElmt, libraryPopupElmt] = this.cleanLibraryViews();
ElementBuilder.createSimilarTracksElmts(similarTracks).map(elmt => {
libraryElmt.appendChild(elmt);
if (libraryPopupElmt) { libraryPopupElmt.appendChild(elmt.cloneNode(true)); }
});
}
setLibraryViewTopArtists(topArtists) {
const [libraryElmt, libraryPopupElmt] = this.cleanLibraryViews();
ElementBuilder.createTopArtistElmts(topArtists).map(elmt => {
libraryElmt.appendChild(elmt);
if (libraryPopupElmt) { libraryPopupElmt.appendChild(elmt.cloneNode(true)); }
});
}
getLibrary() {
return this.library;
}
}
/* Main function */
async function process() {
console.log("Start Deezier process ..");
const userId = ElementFinder.getProfileId();
if (!userId) {
delayStart(1000);
return;
}
var lib = new MusicLibrary(userId);
var area = new DeezierArea(lib);
area.refreshLibraryContent();
// Inject Deezier panel with a little delay to be sure to have list of playlists already pulled
console.log("Injecting Deezier area in left side panel ...");
setTimeout(() => area.injectInPage(), 1000);
}
function delayStart(delay=4000) {
setTimeout(process, delay);
}
console.log("===== DEEZIER =====");
delayStart();