Source: tags.js

"use strict";
// SPDX-License-Identifier: GPL-3.0-or-later
// myMPD (c) 2018-2024 Juergen Mang <mail@jcgames.de>
// https://github.com/jcorporation/mympd

/** @module tags_js */

/**
 * Marks a tag from a tag dropdown as active and sets the element with descId to its phrase
 * @param {string} containerId container id (dropdown)
 * @param {string} descId id of the descriptive element
 * @param {string} setTo tag to select
 * @returns {void}
 */
 function selectTag(containerId, descId, setTo) {
    const btns = elGetById(containerId);
    let aBtn = btns.querySelector('.active');
    if (aBtn !== null) {
        aBtn.classList.remove('active');
    }
    aBtn = btns.querySelector('[data-tag=' + setTo + ']');
    if (aBtn !== null) {
        aBtn.classList.add('active');
        if (descId !== undefined) {
            const descEl = elGetById(descId);
            if (descEl !== null) {
                descEl.textContent = aBtn.textContent;
                descEl.setAttribute('data-phrase', aBtn.getAttribute('data-phrase'));
            }
        }
    }
}

/**
 * Populates a container with buttons for tags
 * @param {string} elId id of the element to populate
 * @param {string} list name of the taglist
 * @returns {void}
 */
function addTagList(elId, list) {
    const stack = elCreateEmpty('div', {"class": ["d-grid", "gap-2"]});
    if (list === 'tagListSearch' ||
        elId === 'BrowseDatabaseAlbumListSearchTags')
    {
        if (features.featTags === true) {
            stack.appendChild(
                elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "any"}, 'Any Tag')
            );
        }
    }
    if (elId === 'QueueCurrentSortTagsList') {
        stack.appendChild(
            elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "Priority"}, 'Priority')
        );
    }
    if (settings[list] !== undefined) {
        for (let i = 0, j = settings[list].length; i < j; i++) {
            stack.appendChild(
                elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": settings[list][i]}, settings[list][i])
            );
        }
    }
    if (list === 'tagListSearch') {
        stack.appendChild(
            elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "filename"}, 'Filename')
        );
        if (elId === 'SearchSearchTags') {
            stack.appendChild(
                elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "base"}, 'Path')
            );
        }
    }
    if (list === 'tagListSearch' ||
        elId === 'BrowseDatabaseAlbumListSearchTags')
    {
        stack.appendChild(
            elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "modified-since"}, 'Modified-Since')
        );
        if (features.featDbAdded) {
            stack.appendChild(
                elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "added-since"}, 'Added-Since')
            );
        }
    }
    if (elId === 'BrowseFilesystemNavDropdown' ||
        elId === 'BrowsePlaylistListNavDropdown' ||
        elId === 'BrowseRadioFavoritesNavDropdown' ||
        elId === 'BrowseRadioWebradiodbNavDropdown')
    {
        elClear(stack);
        stack.appendChild(
            elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "Database"}, 'Database')
        );
    }
    if (elId === 'BrowseDatabaseAlbumListTagDropdown' ||
        elId === 'BrowseDatabaseTagListTagDropdown' ||
        elId === 'BrowseFilesystemNavDropdown' ||
        elId === 'BrowsePlaylistListNavDropdown' ||
        elId === 'BrowseRadioFavoritesNavDropdown' ||
        elId === 'BrowseRadioWebradiodbNavDropdown')
    {
        if (elId === 'BrowseDatabaseAlbumListTagDropdown' ||
            elId === 'BrowseDatabaseTagListTagDropdown')
        {
            stack.appendChild(
                elCreateEmpty('div', {"class": ["dropdown-divider"]})
            );
        }
        stack.appendChild(
            elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "Filesystem"}, 'Filesystem')
        );
        if (elId === 'BrowseFilesystemNavDropdown') {
            stack.lastChild.classList.add('active');
        }
        if (features.featPlaylists === true) {
            stack.appendChild(
                elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "Playlist"}, 'Playlists')
            );
            if (elId === 'BrowsePlaylistListNavDropdown') {
                stack.lastChild.classList.add('active');
            }
        }
        stack.appendChild(
            elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "Radio"}, 'Webradios')
        );
        if (elId === 'BrowseRadioFavoritesNavDropdown' ||
            elId === 'BrowseRadioWebradiodbNavDropdown')
        {
            stack.lastChild.classList.add('active');
        }
    }
    else if (elId === 'BrowseDatabaseAlbumListSortTagsList') {
        const tags = setBrowseDatabaseAlbumListSortTags(list);
        for (let i = 0, j = tags.length; i < j; i++) {
            stack.appendChild(
                elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": tags[i][0]}, tags[i][1])
            );
        }
    }
    else if (elId === 'QueueCurrentSearchTags') {
        if (features.featAdvqueue === true) {
            stack.appendChild(
                elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "prio"}, 'Priority')
            );
        }
    }
    else if (elId === 'BrowseRadioFavoritesSearchTags' ||
             elId === 'BrowseRadioFavoritesSortTagsList' ||
             elId === 'BrowseRadioWebradiodbSearchTags' ||
             elId === 'BrowseRadioWebradiodbSortTagsList')
    {
        const tags = ["Added", "Bitrate", "Codec", "Country", "Description", "Genres", "Homepage", "Languages", "Last-Modified", "Name", "Region"];
        for (let i = 0, j = tags.length; i < j; i++) {
            stack.appendChild(
                elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": tags[i]}, tags[i])
            );
        }
    }
    else if (elId === 'SearchSortTagsList' ||
             elId === 'QueueCurrentSortTagsList')
    {
        if (features.featDbAdded === true) {
            stack.appendChild(
                elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "Added"}, 'Added')
            );
        }
        stack.appendChild(
            elCreateTextTn('button', {"class": ["btn", "btn-secondary", "btn-sm"], "data-tag": "Last-Modified"}, 'Last-Modified')
        );
    }
    const el = elGetById(elId);
    elReplaceChild(el, stack);
}

/**
 * Populates a select element with options for tags
 * @param {string} elId id of the select to populate
 * @param {string} list name of the taglist
 * @returns {void}
 */
function addTagListSelect(elId, list) {
    const select = elGetById(elId);
    elClear(select);
    if (elId === 'modalSmartPlaylistEditSortInput' ||
        elId === 'modalSettingsSmartplsSortInput')
    {
        select.appendChild(
            elCreateTextTn('option', {"value": ""}, 'Disabled')
        );
        select.appendChild(
            elCreateTextTn('option', {"value": "shuffle"}, 'Shuffle')
        );
        select.appendChild(
            elCreateTextTn('option', {"value": "Last-Modified"}, 'Last-Modified')
        );
        if (features.featDbAdded === true) {
            select.appendChild(
                elCreateTextTn('option', {"value": "Added"}, 'Added')
            );
        }
        select.appendChild(
            elCreateTextTn('option', {"value": "filename"}, 'Filename')
        );
        if (features.featTags === true) {
            const optGroup = elCreateEmpty('optgroup', {"label": tn('Sort by tag'), "data-label-phrase": "Sort by tag"});
            for (let i = 0, j = settings[list].length; i < j; i++) {
                optGroup.appendChild(
                    elCreateTextTn('option', {"value": settings[list][i]}, settings[list][i])
                );
            }
            select.appendChild(optGroup);
        }
        if (elId === 'modalSmartPlaylistEditSortInput') {
            const optGroup = elCreateEmpty('optgroup', {"id": "modalSmartPlaylistEditSortInputSticker", "label": tn('Sort by sticker'), "data-label-phrase": "Sort by sticker"});
            optGroup.appendChild(elCreateTextTn('option', {"value": "uri"}, "URI"));
            optGroup.appendChild(elCreateTextTn('option', {"value": "value"}, "Value"));
            optGroup.appendChild(elCreateTextTn('option', {"value": "value_int", "class": ["featStickerAdv"]}, "Value (Number)"));
            select.appendChild(optGroup);
        }
    }
    else if (elId === 'modalPlaybackJukeboxUniqTagInput') {
        if (settings.tagListBrowse.includes('Title') === false) {
            //Title tag should be always in the list
            select.appendChild(
                elCreateTextTn('option', {"value": "Title"}, 'Song')
            );
        }
        for (let i = 0, j = settings[list].length; i < j; i++) {
            select.appendChild(
                elCreateTextTn('option', {"value": settings[list][i]}, settings[list][i])
            );
        }
    }
    else if (elId === 'modalSettingsBrowseDatabaseAlbumListSortInput') {
        for (let i = 0, j = settings[list].length; i < j; i++) {
            select.appendChild(
                elCreateTextTn('option', {"value": settings[list][i]}, settings[list][i])
            );
        }
        const tags = setBrowseDatabaseAlbumListSortTags(list);
        for (let i = 0, j = tags.length; i < j; i++) {
            select.appendChild(
                elCreateTextTn('option', {"value": tags[i][0]}, tags[i][1])
            );
        }
    }
}

/**
 * Returns additional tags for the album list sort tag elements
 * @param {string} list name of the taglist
 * @returns {Array} array of tags and descriptions
 */
function setBrowseDatabaseAlbumListSortTags(list) {
    const tags = [];
    if (settings.albumMode === 'adv') {
        if (settings.tagList.includes('Date') === true &&
            settings[list].includes('Date') === false)
        {
            tags.push(['Date','Date']);
        }
        tags.push(['Last-Modified', 'Last-Modified']);
        if (features.featDbAdded === true) {
            tags.push(['Added', 'Added']);
        }
    }
    else if (settings.albumGroupTag !== '' &&
        settings[list].includes(settings.albumGroupTag) === false)
    {
        tags.push([settings.albumGroupTag, settings.albumGroupTag]);
    }
    return tags;
}

/**
 * Parses the bits to the bitrate from mpd audioformat
 * @param {number} bits bits to parse
 * @returns {string} bitrate as string
 */
function parseBits(bits) {
    switch(bits) {
        case 224: return tn('32 bit floating');
        case 225: return tn('DSD');
        default:  return bits + ' ' + tn('bit');
    }
}

/**
 * Parses the channels information from mpd audioformat
 * @param {number} channels number of channels
 * @returns {string} the parses number of channels as text
 */
function parseChannels(channels) {
    switch(channels) {
        case 0:  return '';
        case 1:  return tn('Mono');
        case 2:  return tn('Stereo');
        default: return channels.toString();
    }
}

/**
 * Combines name and title to display extm3u name
 * @param {string} name name tag
 * @param {string} title title tag
 * @returns {string} the title to display
 */
function getDisplayTitle(name, title) {
    if (title === name) {
        return title;
    }
    return isEmptyTag(name) === false
        ? name + ': ' + title
        : title;
}

/**
 * Returns a tag value as dom element
 * @param {string} key the tag type
 * @param {string | number | object} value the tag value
 * @param {any} [userData] custom data
 * @returns {Node} the created node
 */
function printValue(key, value, userData) {
    if (isEmptyTag(value) === true) {
        return document.createTextNode('');
    }
    switch(key) {
        case 'Type':
            switch(value) {
                case 'song':     return elCreateText('span', {"class": ["mi"]}, 'music_note');
                case 'smartpls': return elCreateText('span', {"class": ["mi"]}, 'queue_music');
                case 'plist':    return elCreateText('span', {"class": ["mi"]}, 'list');
                case 'dir':      return elCreateText('span', {"class": ["mi"]}, 'folder_open');
                case 'stream':   return elCreateText('span', {"class": ["mi"]}, 'stream');
                case 'webradio': return elCreateText('span', {"class": ["mi"]}, 'radio');
                default:         return elCreateText('span', {"class": ["mi"]}, 'radio_button_unchecked');
            }
        case 'Duration':
            return document.createTextNode(fmtSongDuration(value));
        case 'AudioFormat': {
            const text = [];
            text.push(parseBits(value.bits));
            text.push(value.sampleRate / 1000 + tn('kHz'));
            const channels = parseChannels(value.channels);
            if (channels !== '') {
                text.push(channels);
            }
            return document.createTextNode(text.join(smallSpace + nDash + smallSpace));
        }
        case 'Pos':
            //mpd is 0-indexed but humans wants 1-indexed lists
            return document.createTextNode(value + 1);
        case 'Added':
        case 'Last-Modified':
        case 'LastPlayed':
        case 'lastPlayed':
        case 'lastSkipped':
            return document.createTextNode(value === 0 ? tn('never') : fmtDate(value));
        case 'like':
            return elCreateText('span', {"class": ["mi"]},
                value === 0
                    ? 'thumb_down'
                    : value === 1 
                        ? 'horizontal_rule'
                        : 'thumb_up'
            );
        case 'rating': {
            return showStarRating(value);
        }
        case 'elapsed': {
            let progressEl;
            if (userData !== undefined &&
                userData.Duration !== undefined)
            {
                const prct = Math.ceil((100 / userData.Duration) * value);
                progressEl = elCreateText('div', {'class': ['my-1', 'progress', 'justify-content-center', 'align-items-center'], 'data-title-phrase': 'Resume', 'title': tn('Resume')},
                    fmtSongDuration(value) + ' / ' + fmtSongDuration(userData.Duration));
                progressEl.style.background = 'linear-gradient(90deg, var(--mympd-highlightcolor) 0%, var(--mympd-highlightcolor) ' +
                    prct + '%, var(--bs-gray) ' + prct + '%, var(--bs-gray) 100%)';
                if (prct === 100) {
                    progressEl.setAttribute('disabled', 'disabled');
                    progressEl.removeAttribute('data-title-phrase');
                    progressEl.removeAttribute('title');
                }
            }
            else {
                progressEl = document.createTextNode(fmtSongDuration(value));
            }
            return progressEl;
        }
        case 'Artist':
        case 'ArtistSort':
        case 'AlbumArtist':
        case 'AlbumArtistSort':
        case 'Composer':
        case 'ComposerSort':
        case 'Performer':
        case 'Conductor':
        case 'Ensemble':
        case 'MUSICBRAINZ_ARTISTID':
        case 'MUSICBRAINZ_ALBUMARTISTID': {
            //multi value tags - print one line per value
            const span = elCreateEmpty('span', {});
            for (let i = 0, j = value.length; i < j; i++) {
                if (i > 0) {
                    span.appendChild(
                        elCreateEmpty('br', {})
                    );
                }
                if (key.indexOf('MUSICBRAINZ') === 0) {
                    span.appendChild(
                        getMBtagLink(key, value[i])
                    );
                }
                else {
                    span.appendChild(
                        document.createTextNode(value[i])
                    );
                }
            }
            return span;
        }
        case 'Genre':
        case 'Genres':
            if (typeof value === 'string') {
                return document.createTextNode(value);
            }
            //multi value tags - return comma separated
            return document.createTextNode(
                joinArray(value)
            );
        case 'Homepage':
        case 'StreamUri':
            //webradios
            if (value === '') {
                return document.createTextNode(value);
            }
            return elCreateText('a', {"class": ["text-success", "external"],
                "href": value, "rel": "noreferrer", "target": "_blank"}, value);
        case 'Languages':
            return document.createTextNode(
                joinArray(value)
            );
        case 'Bitrate':
            return document.createTextNode(value + ' ' + tn('kbit'));
        case 'SongCount':
            return document.createTextNode(tn('Num songs', {"smartCount": value}));
        case 'Discs':
            if (value === 0) {
                return document.createTextNode('-');
            }
            return document.createTextNode(tn('Num discs', {"smartCount": value}));
        case 'Thumbnail': {
            //TODO: use intersection observer
            const img = elCreateEmpty('div', {"class": ["thumbnail"]});
            if (value !== undefined) {
                img.style.backgroundImage = value;
            }
            return img;
        }
        default:
            if (key.indexOf('MUSICBRAINZ') === 0) {
                return getMBtagLink(key, value);
            }
            else {
                return document.createTextNode(value);
            }
    }
}

/**
 * Checks if tag is empty
 * @param {string | Array} tagValue tag value to check
 * @returns {boolean} true if tag matches value, else false
 */
function isEmptyTag(tagValue) {
    return tagValue === undefined || tagValue === null || tagValue.length === 0;
}

/**
 * Returns a link to MusicBrainz
 * @param {string} tag tag name
 * @param {string} value tag value
 * @returns {HTMLElement} a link or the value as text
 */
function getMBtagLink(tag, value) {
    let MBentity = '';
    switch (tag) {
        case 'MUSICBRAINZ_ALBUMARTISTID':
        case 'MUSICBRAINZ_ARTISTID':
            MBentity = 'artist';
            break;
        case 'MUSICBRAINZ_ALBUMID':
            MBentity = 'release';
            break;
        case 'MUSICBRAINZ_RELEASETRACKID':
            MBentity = 'track';
            break;
        case 'MUSICBRAINZ_TRACKID':
            MBentity = 'recording';
            break;
        case 'MUSICBRAINZ_WORKID':
            MBentity = 'work';
            break;
        case 'MUSICBRAINZ_RELEASEGROUPID':
            MBentity = 'release-group';
            break;
        // No Default
    }
    if (isEmptyTag(value) === true) {
        return elCreateText('span', {}, '');
    }
    else if (MBentity === '') {
        return elCreateText('span', {}, value);
    }
    else {
        return elCreateText('a', {"data-title-phrase": "Lookup at musicbrainz",
            "class": ["text-success", "external"], "target": "_musicbrainz",
            "href": "https://musicbrainz.org/" + MBentity + "/" + myEncodeURI(value)}, value);
    }
}

/**
 * Returns Links to MusicBrainz artist und album
 * @param {object} songObj mpd song object
 * @param {boolean} showArtists true=show artists, false=show albumartists
 * @returns {HTMLElement} dom node with musicbrainz links
 */
function addMusicbrainzFields(songObj, showArtists) {
    if (settings.webuiSettings.cloudMusicbrainz === false) {
        return null;
    }

    const artist = showArtists === false
        ? 'MUSICBRAINZ_ALBUMARTISTID'
        : 'MUSICBRAINZ_ARTISTID';

    const mbField = elCreateNode('div', {"class": ["col-xl-6"]},
        elCreateTextTn('small', {}, 'MusicBrainz')
    );

    if (isEmptyTag(songObj.MUSICBRAINZ_RELEASEGROUPID) === false) {
        //use releasegroupid
        const albumLink = getMBtagLink('MUSICBRAINZ_RELEASEGROUPID', songObj.MUSICBRAINZ_RELEASEGROUPID);
        albumLink.textContent = tn('Goto album');
        mbField.appendChild(
            elCreateNode('p', {"class": ["mb-1"]}, albumLink)
        );
    }
    else if (isEmptyTag(songObj.MUSICBRAINZ_ALBUMID) === false) {
        //fallback to albumid
        const albumLink = getMBtagLink('MUSICBRAINZ_ALBUMID', songObj.MUSICBRAINZ_ALBUMID);
        albumLink.textContent = tn('Goto album');
        mbField.appendChild(
            elCreateNode('p', {"class": ["mb-1"]}, albumLink)
        );
    }
    if (isEmptyTag(songObj[artist]) === false) {
        //show albumartists or artists
        for (let i = 0, j = songObj[artist].length; i < j; i++) {
            const artistLink = getMBtagLink(artist, songObj[artist][i]);
            artistLink.textContent = artist === 'MUSICBRAINZ_ALBUMARTISTID'
                ? songObj.AlbumArtist[i]
                : songObj.Artist[i];
            if (artistLink.textContent === '') {
                // skip empty tag values
                // count of mbids and artists are not equal
                continue;
            }
            mbField.appendChild(
                elCreateNode('p', {"class": ["mb-1"]}, artistLink)
            );
        }
    }
    return mbField.childNodes.length > 1
        ? mbField
        : null;
}