Qobuz Linkifier


Qobus has a few annoying issues, like wanting you to purchase stuff.
This userscript replaces everything purchase-related with copy-to-clipboard-buttons to directly access
tracks / playlists / albums / artists / labels
through your favorite downloader.


Changelog

  • 0.1.1
    • Added support for label pages
    • Added localization
    • Added translations for en, de
    • Changed some button icons
    • Changed userscript icon to Qobuz favicon
    • Cleaned up dirty code
  • 0.1.0
    • Initial release

Usage

To use this script, install TamperMonkey, ViolentMonkey or GreaseMonkey for your favorite browser and follow their respective guides.


// ==UserScript==
// @name         Qobuz Linkifier
// @version      0.1.1
// @description  make templates copy links to clipboard
// @author       You
// @match        https://www.qobuz.com/*/shop
// @match        https://www.qobuz.com/*/shop/*/*
// @match        https://www.qobuz.com/*/search*
// @match        https://www.qobuz.com/*/label/*/*/*
// @match        https://www.qobuz.com/*/interpreter/*/*
// @match        https://www.qobuz.com/*/album/*/*
// @match        https://www.qobuz.com/*/playlists/*/*
// @match        https://www.qobuz.com/*/genre/*/*
// @icon         https://www.qobuz.com/favicon.ico
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const BASE_URL = 'https://play.qobuz.com/',
        LOAD_MORE_TRACKS_DELAY = 500,
        COPY_MESSAGE_DISPLAY_DURATION = 1500,
        DEFAULT_LOC = { lang: 'en', country: '_' };

    addCustomStyle(`
        .store-wallpaper,
        .album-addtocart,
        .player__ad,
        .store-cart,
        .shop-cart,
        .price-box .price,
        .on-sale {
            display: none !important;
        }

        #store-search {
            padding-right: 0 !important;
            border-right: none !important
        }

        .shop-search {
            margin-right: 16px !important;
        }

        .track__item--button span.pct {
            position: absolute !important;
            top: 8px !important;
            left: 5px !important;
        }

        .catalog-heading__button span.pct {
            margin-right: 18px;
            font-size: 12px;
            font-weight: 700;
        }

        .product__button span.pct {
            left: 11px;
            position: absolute;
        }

        .player__webplayer span.pct {
            margin-right: 8px;
        }

        .player__webplayer span:last-child {
            padding-top: 2px;
        }

        .no-wrap {
            white-space: nowrap !important;
        }

        .color-white {
            color: #FFF !important;
        }
    `);

    const templates = infuseTemplates(window.location.pathname.split('/')[1].split('-').reverse(), {
        en: {
            _: {
                label: 'label',
                artist: 'artist',
                album: 'album',
                playlist: 'playlist',
                track: 'track',
                copyLink: function (type) {
                    return type ? `Copy ${this[type]} link` : 'Copy link'
                },
                copyLinkWithHighlight: function (type) {
                    return `<span class="color-white">Copy</span> ${this[type]} link`
                },
                linkCopied: function (type) {
                    return type ? `${this[type].toTitleCase()} link copied!` : 'Link copied!'
                },
                linkCopiedWithHighlight: function (type) {
                    return `<span class="color-white">${this[type].toTitleCase()} link</span> copied!`
                },
            },
            get uk() { return this._ },
            get ie() { return this._ },
            get us() { return this._ },
            get au() { return this._ },
            get ca() { return this._ },
            get nz() { return this._ },
            get dk() { return this._ },
            get fi() { return this._ },
            get se() { return this._ },
            get no() { return this._ }
        },
        de: {
            _: {
                label: 'Verlag',
                artist: 'Interpret',
                album: 'Album',
                playlist: 'Wiedergabeliste',
                track: 'Titel',
                copyLink: function (type) { return type ? `${this[type]}-Link kopieren` : 'Link kopieren' },
                copyLinkWithHighlight: function (type) {
                    return `<span class="color-white">${this[type]}-Link</span> kopieren`
                },
                linkCopied: function (type) {
                    return type ? `${this[type]}-Link kopiert!` : 'Link kopiert!'
                },
                linkCopiedWithHighlight: function (type) {
                    return `<span class="color-white">${this[type]}-Link</span> kopiert!`
                },
            },
            get de() { return this._ },
            get at() { return this._ },
            get ch() { return this._ },
            get lu() { return this._ }
        },
        /*         es: {
                    _: {

                    },
                    get es() { return this._ },
                    get mx() { return this._ },
                    get ar() { return this._ },
                    get cl() { return this._ },
                    get co() { return this._ }
                },
                pt: {
                    get pt() { return this._ },
                    get br() { return this._ }
                },
                nl: {
                    get nl() { return this._ },
                    get be() { return this._ }
                },
                fr: {
                    get fr() { return this._ },
                    get ch() { return this._ },
                    get lu() { return this._ },
                    get be() { return this._ },
                    get ca() { return this._ }
                },
                it: {
                    get it() { return this._ }
                } */
    }, (data) => ({
        track: {
            content: () => `
                <span class="pct pct pct-edit"></span>
                <span class="no-wrap">${data.copyLink()}</span>
            `,
            message: () => `
                <span class="pct pct pct-checkbox"></span>
                <span class="no-wrap">${data.linkCopied()}</span>
            `
        },
        albumGrid: {
            content: () => `
                <span class="pct pct pct-edit"></span>
                <span class="product__button--highlight">
                    ${data.copyLinkWithHighlight('album')}
                </span>
            `,
            message: () => `
                <span class="pct pct pct-checkbox"></span>
                <span class="product__button--highlight">
                    ${data.linkCopiedWithHighlight('album')}</span>
                </span>
            `
        },
        search: {
            content: () => `
                <span class="no-wrap">${data.copyLink('album')}</span>
            `,
            message: () => `
                <span class="no-wrap">${data.linkCopied('album')}</span>
            `
        },
        main: {
            content: (type) => `
                <span class="pct pct pct-edit"></span>
                <span class="no-wrap">${data.copyLink(type)}</span>
            `,
            message: (type) => `
                <span class="pct pct pct-checkbox"></span>
                <span class="no-wrap">${data.linkCopied(type)}</span>
            `
        }
    }));

    templates["main"].content("album")

    const selectors = {
        main: '.catalog-heading__button, .player__webplayer',
        search: '.btn__qobuz.btn__qobuz--see-album',
        album: '.product__button.add_to_cart',
        track: '.track__item.track__item--button',
        loadMore: '.player-more'
    }

    const mainButton = document.querySelector(selectors.main);

    if (mainButton) {
        const [type, id] = mainButton.attributes.href.value.split('/').slice(-2);

        replaceButton(mainButton, id, type, 'main');

        document.querySelectorAll(selectors.track).forEach((el, i) => {
            if (el.classList.contains('track__unavailable')) return;

            const trackButton = replaceButton(
                el,
                el.dataset.url.split('/').slice(-1)[0],
                'track'
            );

            trackButton.addEventListener('dblclick', ev => { ev.preventDefault(); ev.stopPropagation(); })
        });
    }

    document.querySelectorAll(selectors.album).forEach((el, i) => {
        replaceButton(
            el,
            el.dataset.url.split('/').slice(-1)[0],
            'album',
            'albumGrid'
        );
    });

    document.querySelectorAll(selectors.search).forEach((el, i) => {
        replaceButton(
            el,
            el.attributes.href.value.split('/').slice(-1)[0],
            'album',
            'search'
        );
    });

    const loadMore = document.querySelector(selectors.loadMore)

    if (loadMore) {
        loadMore.addEventListener('click', ev => {
            setTimeout(() => {
                document.querySelectorAll(selectors.track).forEach((el, i) => {
                    if (el.classList.contains('track__unavailable')) return;

                    const trackButton = replaceButton(
                        el,
                        el.dataset.url.split('/').slice(-1)[0],
                        'track'
                    );

                    trackButton.addEventListener('dblclick', ev => { ev.preventDefault(); ev.stopPropagation(); })
                });
            }, LOAD_MORE_TRACKS_DELAY);
        })
    }

    function replaceButton(button, id, type, contentType = type) {
        let timeout;

        const content = templates[contentType].content(type),
            url = BASE_URL + `${type}/${id}`,
            newButton = button.cloneNode(true);

        newButton.setAttribute('title', url);
        newButton.setAttribute('href', url);
        newButton.innerHTML = content;

        button.replaceWith(newButton);
        button.remove();

        let wasCopied = false;

        newButton.addEventListener('click', ev => {
            ev.preventDefault();

            navigator.clipboard.writeText(url);

            if (wasCopied) {
                clearTimeout(timeout);
            }
            else {
                newButton.innerHTML = templates[contentType].message(type);
                wasCopied = true;
            }

            timeout = window.setTimeout(
                () => {
                    newButton.innerHTML = content;
                    wasCopied = false;
                },
                COPY_MESSAGE_DISPLAY_DURATION
            );

            ev.stopPropagation();

            return false;
        });

        return newButton;
    }

    function addCustomStyle(style) {
        document.body.append(
            document.createElement('style')
                .appendChild(
                    document.createTextNode(style)
                )
                .parentNode
        );
    }

    function infuseTemplates([country, lang], strings, templates) {
        return templates(strings[country]?.[lang] || strings[DEFAULT_LOC.lang][DEFAULT_LOC.country]);
    }

    Object.defineProperty(String.prototype, "toTitleCase", {
        value: function () {
            return this[0].toUpperCase() + this.slice(1);
        },
        writable: true,
        configurable: true,
    });
})();
Edit
Pub: 17 Apr 2023 02:24 UTC
Edit: 22 Apr 2023 20:06 UTC
Views: 7051