[RED/OPS] Upload Assistant

Accurate filling of new upload/request and group/request edit forms based on foobar2000's playlist selection or web link, offline and online release integrity check, tracklist format customization, featured artists extraction, classical works formatting, cover art fetching from store, checking for previous upload, form enhancements and more

2021-12-04 या दिनांकाला. सर्वात नवीन आवृत्ती पाहा.

// ==UserScript==
// @name         [RED/OPS] Upload Assistant
// @namespace    https://greatest.deepsurf.us/users/321857-anakunda
// @version      1.385
// @description  Accurate filling of new upload/request and group/request edit forms based on foobar2000's playlist selection or web link, offline and online release integrity check, tracklist format customization, featured artists extraction, classical works formatting, cover art fetching from store, checking for previous upload, form enhancements and more
// @author       Anakunda
// @copyright    2019-21, Anakunda (https://greatest.deepsurf.us/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @icon         data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADYAAAA+CAYAAABgFuiwAAAKN2lDQ1BzUkdCIElFQzYxOTY2LTIuMQAAeJydlndUU9kWh8+9N71QkhCKlNBraFICSA29SJEuKjEJEErAkAAiNkRUcERRkaYIMijggKNDkbEiioUBUbHrBBlE1HFwFBuWSWStGd+8ee/Nm98f935rn73P3Wfvfda6AJD8gwXCTFgJgAyhWBTh58WIjYtnYAcBDPAAA2wA4HCzs0IW+EYCmQJ82IxsmRP4F726DiD5+yrTP4zBAP+flLlZIjEAUJiM5/L42VwZF8k4PVecJbdPyZi2NE3OMErOIlmCMlaTc/IsW3z2mWUPOfMyhDwZy3PO4mXw5Nwn4405Er6MkWAZF+cI+LkyviZjg3RJhkDGb+SxGXxONgAoktwu5nNTZGwtY5IoMoIt43kA4EjJX/DSL1jMzxPLD8XOzFouEiSniBkmXFOGjZMTi+HPz03ni8XMMA43jSPiMdiZGVkc4XIAZs/8WRR5bRmyIjvYODk4MG0tbb4o1H9d/JuS93aWXoR/7hlEH/jD9ld+mQ0AsKZltdn6h21pFQBd6wFQu/2HzWAvAIqyvnUOfXEeunxeUsTiLGcrq9zcXEsBn2spL+jv+p8Of0NffM9Svt3v5WF485M4knQxQ143bmZ6pkTEyM7icPkM5p+H+B8H/nUeFhH8JL6IL5RFRMumTCBMlrVbyBOIBZlChkD4n5r4D8P+pNm5lona+BHQllgCpSEaQH4eACgqESAJe2Qr0O99C8ZHA/nNi9GZmJ37z4L+fVe4TP7IFiR/jmNHRDK4ElHO7Jr8WgI0IABFQAPqQBvoAxPABLbAEbgAD+ADAkEoiARxYDHgghSQAUQgFxSAtaAYlIKtYCeoBnWgETSDNnAYdIFj4DQ4By6By2AE3AFSMA6egCnwCsxAEISFyBAVUod0IEPIHLKFWJAb5AMFQxFQHJQIJUNCSAIVQOugUqgcqobqoWboW+godBq6AA1Dt6BRaBL6FXoHIzAJpsFasBFsBbNgTzgIjoQXwcnwMjgfLoK3wJVwA3wQ7oRPw5fgEVgKP4GnEYAQETqiizARFsJGQpF4JAkRIauQEqQCaUDakB6kH7mKSJGnyFsUBkVFMVBMlAvKHxWF4qKWoVahNqOqUQdQnag+1FXUKGoK9RFNRmuizdHO6AB0LDoZnYsuRlegm9Ad6LPoEfQ4+hUGg6FjjDGOGH9MHCYVswKzGbMb0445hRnGjGGmsVisOtYc64oNxXKwYmwxtgp7EHsSewU7jn2DI+J0cLY4X1w8TogrxFXgWnAncFdwE7gZvBLeEO+MD8Xz8MvxZfhGfA9+CD+OnyEoE4wJroRIQiphLaGS0EY4S7hLeEEkEvWITsRwooC4hlhJPEQ8TxwlviVRSGYkNimBJCFtIe0nnSLdIr0gk8lGZA9yPFlM3kJuJp8h3ye/UaAqWCoEKPAUVivUKHQqXFF4pohXNFT0VFysmK9YoXhEcUjxqRJeyUiJrcRRWqVUo3RU6YbStDJV2UY5VDlDebNyi/IF5UcULMWI4kPhUYoo+yhnKGNUhKpPZVO51HXURupZ6jgNQzOmBdBSaaW0b2iDtCkVioqdSrRKnkqNynEVKR2hG9ED6On0Mvph+nX6O1UtVU9Vvuom1TbVK6qv1eaoeajx1UrU2tVG1N6pM9R91NPUt6l3qd/TQGmYaYRr5Grs0Tir8XQObY7LHO6ckjmH59zWhDXNNCM0V2ju0xzQnNbS1vLTytKq0jqj9VSbru2hnaq9Q/uE9qQOVcdNR6CzQ+ekzmOGCsOTkc6oZPQxpnQ1df11Jbr1uoO6M3rGelF6hXrtevf0Cfos/ST9Hfq9+lMGOgYhBgUGrQa3DfGGLMMUw12G/YavjYyNYow2GHUZPTJWMw4wzjduNb5rQjZxN1lm0mByzRRjyjJNM91tetkMNrM3SzGrMRsyh80dzAXmu82HLdAWThZCiwaLG0wS05OZw2xljlrSLYMtCy27LJ9ZGVjFW22z6rf6aG1vnW7daH3HhmITaFNo02Pzq62ZLde2xvbaXPJc37mr53bPfW5nbse322N3055qH2K/wb7X/oODo4PIoc1h0tHAMdGx1vEGi8YKY21mnXdCO3k5rXY65vTW2cFZ7HzY+RcXpkuaS4vLo3nG8/jzGueNueq5clzrXaVuDLdEt71uUnddd457g/sDD30PnkeTx4SnqWeq50HPZ17WXiKvDq/XbGf2SvYpb8Tbz7vEe9CH4hPlU+1z31fPN9m31XfKz95vhd8pf7R/kP82/xsBWgHcgOaAqUDHwJWBfUGkoAVB1UEPgs2CRcE9IXBIYMj2kLvzDecL53eFgtCA0O2h98KMw5aFfR+OCQ8Lrwl/GGETURDRv4C6YMmClgWvIr0iyyLvRJlESaJ6oxWjE6Kbo1/HeMeUx0hjrWJXxl6K04gTxHXHY+Oj45vipxf6LNy5cDzBPqE44foi40V5iy4s1licvvj4EsUlnCVHEtGJMYktie85oZwGzvTSgKW1S6e4bO4u7hOeB28Hb5Lvyi/nTyS5JpUnPUp2Td6ePJninlKR8lTAFlQLnqf6p9alvk4LTduf9ik9Jr09A5eRmHFUSBGmCfsytTPzMoezzLOKs6TLnJftXDYlChI1ZUPZi7K7xTTZz9SAxESyXjKa45ZTk/MmNzr3SJ5ynjBvYLnZ8k3LJ/J9879egVrBXdFboFuwtmB0pefK+lXQqqWrelfrry5aPb7Gb82BtYS1aWt/KLQuLC98uS5mXU+RVtGaorH1futbixWKRcU3NrhsqNuI2ijYOLhp7qaqTR9LeCUXS61LK0rfb+ZuvviVzVeVX33akrRlsMyhbM9WzFbh1uvb3LcdKFcuzy8f2x6yvXMHY0fJjpc7l+y8UGFXUbeLsEuyS1oZXNldZVC1tep9dUr1SI1XTXutZu2m2te7ebuv7PHY01anVVda926vYO/Ner/6zgajhop9mH05+x42Rjf2f836urlJo6m06cN+4X7pgYgDfc2Ozc0tmi1lrXCrpHXyYMLBy994f9Pdxmyrb6e3lx4ChySHHn+b+O31w0GHe4+wjrR9Z/hdbQe1o6QT6lzeOdWV0iXtjusePhp4tLfHpafje8vv9x/TPVZzXOV42QnCiaITn07mn5w+lXXq6enk02O9S3rvnIk9c60vvG/wbNDZ8+d8z53p9+w/ed71/LELzheOXmRd7LrkcKlzwH6g4wf7HzoGHQY7hxyHui87Xe4Znjd84or7ldNXva+euxZw7dLI/JHh61HXb95IuCG9ybv56Fb6ree3c27P3FlzF3235J7SvYr7mvcbfjT9sV3qID0+6j068GDBgztj3LEnP2X/9H686CH5YcWEzkTzI9tHxyZ9Jy8/Xvh4/EnWk5mnxT8r/1z7zOTZd794/DIwFTs1/lz0/NOvm1+ov9j/0u5l73TY9P1XGa9mXpe8UX9z4C3rbf+7mHcTM7nvse8rP5h+6PkY9PHup4xPn34D94Tz+49wZioAAAAJcEhZcwAACxIAAAsSAdLdfvwAAA9/SURBVHiczVoJdFTndb5vmTe7ZiQhhARC0GIn6BizJOAkJtRubBYDKcjEaWwdbHJasH1y0uUkUOy4pydObeO0dRK3TQ05sVGEaTDGMnuQ7XoBXINNLYwRi81i0L7NPm/e2vv/773RrNIMSLRXZzRv3vv/9//fvfe/9/vve7wOoyOLH13sOPjvB8UlG1f5VLd9nOxxVKgl9nEKB7xstzkZlmHJWLqmaYKoxDkVZC4k9gnRRB+HnwNP7wxa9xiN+fCjcZOlP7mvQva5Jn596w+mRJyMJwbK72RdA0VNgK4jHCkGlgJ18sczwNoY4Nwc2MADLvA3zHvp0ainN3pp2U/ua9/7sx291zun6wK2+Kf318arfXWDJXxZiJGbEoko6HENGETBkD/G+maSfQhOXSHwNFAlFSRdhQgDTT0cC/YaO/hr3A988zePBNwdgU8P/v32yzcU2JK/u7ckVFs2d6DCUR2U4o1aPAbEz9DbEAQLOpPePs3dEaROzxjAOZ0zWmgAciwOXUx8W6/DBt4v+1fPf2Fdd8mlvg/2P/1qcMyBLfrnNbM7J3nq+rV4kxYJIRgG+BQwRa9ZxrAiPWRZdE38LasQkIKNATcPlTPHP7Don9ac/sOPXvy4mNsWBWz+5ocXdZSy42OxUCOHM+JwIjQgFHOTEYTeC8HyDIeuqkCnFNgWqvGs/sbmdZVH177wh0LvUxCwpY9/Z1z/9Mo7r/AJuxaJN/Lc6APKFOquxBsQZSwcbjzrtjd8tfHReyvP9r2z7x939I3Uf0RgS5/4bmX3jKq7rkrBJlbUgTNBXY+QgKKbd0k9ziXkCsdxoIty02d8EKQZ4x/AOb2578nfdw83xrDAFm+od/fcMuGujkSwidNodBhTK+UTOiaObVMBvhAHtkHduPuXbKjffWDTrmi+PsMC66+bsKhDDjXxCEpnmOGaFjlRPefxiP1wCjaMnpelwMvc9IqVeKo5X9u8wBZsfnhhh112caIGOgaJ/y9CFGzDPHjJHnPf/sLahUfWbT6Uq11OYAt//tDMK6VMpRZNNHIs93/ifsMKuqUWF5s+89oa7v75gzNbfry1NbNJFrClG+o9XZPcdfF4tJEfA1BZrpdngOHGJTSNKDwWjzZdrvJ97571K8/uf/a1NI6ZBSw0pfy2Pki8zOmQxSDGQvKuMX14lZKrPP7r0qLbyyb778Cf76ReTwO25PFVZd1+rlKX4pQejYUL6iNMONmugKuUg0oytPvsVUseW1V64Kmdg1aLNGChat/MsC5vY2FswnqmdfJZa6RIqad8kwQe1MTtgWrvHZBitSSwe3BtDfhtlaCIVBMjASMWLVR03MLoKTOy+tLzOdIItSpjNNawTY4GQ8ekv6xAV4mzkmDYv2lXJA2YWOG9Kcyp21mlsLUVRQVoBbqVkxMoWaabFewTVeIUZb7+BAyBw2Ef0jcLV8Yx4a0BRvp9dJx7Nv78OA3YoIerUFSVmraQ6c4pnQou3j4iOKKj08GrEFFFagTSZ275NLKVHsbldGrVmJyA1sAXxk2sKznGI7RMURXotbM+6xwFtmR9vSPq4nyMKhfEMBi8+fPzvg/Ty2pBVBLUGtYAmZMlLH3hjifghHQJdBsH0901sPtbG0DBDeYQ9CFA5DeZvN1mhzM9F+HO1x8HxckjSbBaZAMjHsYgOwo4oDQNWFxgfXEb7GCkjHHyCLm1iEoQFQli+GHzKIO0syGBhbgMtkEZpEqG7pxJH1kzgNGuGXPVaCvshmOwompEP5eRU/NFVQIs4WCdf/LEqqp3ntzZSYFF7YxPxW7FpC0yFgFkffIBoxGWtEVqJgziAvYb4yT7MJClTFpaQHZB+xKEEoLjkW0I+WdIrqAXvBzm9To8NIDJTptPLxJYUUJXODpqQgM2pBhasXbNWWtaT8dqeCcwMp4niLncQ1j3Ee1sCflNgakeoVTX9ByDjK5ojJaDURQyormGLZ3kyTS6poHisXnIMX/32oWlwYGQxvnLQFcUKGiRFStkMoxuLqjM+2f+HmbTSSynGk30DMsRj+N4HsKBEL9w7SIfL2ETxcW3kMU3pkI2qUTTzDWOwwzlV2J4yLIcQxIgSA7+YAKzMh+XFB9j54eqKKMoRsAzgFiFgKKHYIzeWfzDJCdpZIK4ucBhrNFcvMfGBfVADKDUiWFFAxKLChFa+CRJVk/PLOnFUT25+Gk6ZowISfsk1xo5ZozAgNcZ3cyFKYQwS+fmMYttVYrQbEhSS1AEN8eFeRwRIWrGmtaNCi1j9cwRhskESYIWOB4EXqA3ZpIJGmh+ovwuhRxaE6OVYfwn8DbgNDZ5jXJTvKeiqUMJeJg6pakr41vLcMmEArqqCbym6TaISem0xbpdnj0h+e4KD4CXc2LulSinI0JAlzq94MCJa8nhTSvSuMGgmyhwNdCDOccAz5gFU1LSK3f5kiBzAUkCNu+VbGeipIRA1sg1jed5Nqb1R5fitX05lJNb8KYPvfevwEfRuqqeDOGiKELTyvWwYOosCCdiwJKCKvECTTFoEgK+EOqCO3dsxByDfVRjUglFhj/ylsPr33sSHIIDLEJP+B8J4UxK8tKZdHfPXDlsTLmb5XiJf2NLy8DE5bMEKZ4AnlZ29SFXzCGWNSXcvkpOFdi4Sl2UsASRV01LWTmZhVA8Av2xQXR/niqAeKBIEpK5+klfkUV6xikpY2hAyhIdkX6kXwlwMh7Q0sw2RK2SkZJuOnH87rCnZcfxIL98/Qq3+8QFiHWFgK8tBz1RIBEm/+w8rWAxcZI58Zs4O2uohawzJxLZo5c/gSvhfnC7PaAQ7bJmeCKuxJlexaFVbFwyaGq0psHDYexLnsa4TFoGJtVKJnnLWtQHWVB7I+CJyITxAk8CUonH2dt5sR/gjytQfemayW85MAawMUS/oKFva2Z+V82BiRU3nziIVmIppbImopmasdxKT0kDMm6dPIITLvVfhVfPHgG30w0qiRBkc2oqXE/pb5qPKkbrCIGb4aRlG1Z6+L2bmiMLH17UKpy9DOp82dBIMUKWGIJjSEVLMjggiXrjbGXwi8M74NDFj8CP1pJZdDAz3INpMSsI4IUkML/TAyLuw/6mZTN0JULg8RiWpv0Y3TBWDlpFK1ft4QZfuffC3k2vRShXtPtc4JegofdsT5N7RjVoJEoWA5AWH3AkwXCTzmAf/Mv7r8BvWw9BicsNCnoZWVtWqTqru3lKQ2sdv9IGP32vCd68egoV4qYKAWJxNru9JQyOLWP+8vTFlXff/OgCOUeB7UGE37x/wYmuIxdAv7W6cEBJXPiHk+Icdvjxsa0QCISgNxqgliLG0EhQY5j0xQ8GfyQf8qCjQ4rAkuZ/gIvBbnRHBfxew8pEYWQtJhMApWUpg5Oo4rKBcqIdxvFCmLhh0mJE/DVlV8rPXV0TPNnxomsEq6XRJH3oN2l9QRoAQWDBx/mMhGtsvtB1GAqCRk0WzMhr8DtynAAFPhf7wO4QwAZ2IM+wgTNBWdNgs61F3RIJBtfaCRU31X5EQJHTSWB7cK0tWH3Hu8cPnAb7l8ajpofyUzFCchWZjETomcwY5WgyGc6YRXJeGqQlWFLj4DCyDdVQWOq+SQaSaSl6D1SKSwDxkw4Yr3ENrnJPxLqUVlf0TSzrn3iuY3X7/lNQsmp2o4IckuGKeyChWdxNIOGcNRJxhuhaSsEzaTjLktmSExQY50gC19+7CJOnTj6299nm5GOlNGB7nt5FHmL/zttd8cPElyaAMB0tF5GMUF2s0LqAFRXSwaVG3tQ6BptGoMGIgvn0ivdkPXaIv38JKkRY45tUmvYgMPuhxPoVjhntAy3Ht34A/Pq7MMnZMWkrxUXJVGENts7kqnsmmzAZ5/U0N8zZEb1BDYnAvH0Bpt02fe+eZ14LpTbJArbv2WZx+cb6jun9k+9p+7d39vv/9lvACCRHqUWDI9saa83Q4k/Gys/eO+vGtmakYdD9WK8TIi+fWDOtzB92j/PKmU1yPh8jLvntjfX/rSXkVW3PvbWz7K//FBhS24vLVFPDg9GNCWYydHNPlgnOYBG6Gf5HAERBGS6Y+J92cJ3q8X76efdL8PaprGZ5n2jufnrX4PLH6t9SDp/588+ebflP/6MLgK3yghYW08AZAPRha36Z4Ky9VNGbdpM6qYNxkHeehNnz6/bA57mfsQ/7DHrPU7sGv/3YvS22Y+dXfPrMoeaSh74GwpyJIEfiRrS7hnWnM4XVpbI7grHD5jmIvnQcpk+tWu6vLhvM13zE1yF2P/XqwLKN9W87Sz1zT245epf4jSldnhW3vqi7OSOJp0a/sRTCBdEFQ1uPf78GBLV27rSje57K/ypSQS+w7MU1t2z9ijaxP/Jh9WeBdT0/OwSu5beAfe5kpEsYwcja0/QhUjvagsGCQz4b2XnyQW9bv+/CpZ7n4fj5YbsU/MqRlfzm/Nnc7V1n2k+d23VyXuC/zscdd970a2Em8kuvDbcuCt3sJfNW2n4eiltPlpigogfaGpwffFHef7nvV4V0K/olsb2YL5ZtWNH6YfOxI7fWzVp66ZXWHwV2n1RtcyY/50CA3CQ/MB7eCA7k/RDVrP6SE2qOZJZPzOKSAepMg/2N8xWDVwZ+UWj3a3qtj+zhyHftnKnvnTz08b6vrJx3e9fZznX9Ry76FI+QYKpKfsnXlAKHUZQvc9NaH2A0Y9CqBbmqblR5OI8Doq98vNp+9PL4QPvgc8XM8bpexLSyfdXN1a1VX550iqzFufO/dlugfQCCx67+QFFULq6oPDJinp1Stkl45OsjE2vN2Kpwgg2CLx77C8/pnvLBIkERGZVXZy0LEqmcNuHTCTdPLEPWoe41gS/8y7vLuuzsnssce5qVlfxWQ1clbB1iCgSefwsqIpq980p/we6XKqMCLFVSQVpyaEvLwK1/tbgio0I4JOYaZH1OUM71QWTLUaitLH+wrn5GMzzz2jXNY9SB5RNkJbm5GEn0do4+KYntawPlQBvcMvemRbWzpn6IOTSUs08BcsOAZYmZ91iye2gPQ2Db8Ue83THnvBW3NXnLvYnrAUXkxgMz3Y5x2YC8ehHbdwakg6dh6rSJkfPtV/8DtrwxKsPcOGA62esjiUW3Y9HtpNZOiL1+Evyi/sPZS76yv3xS+SC8f3bUhrthwFielUle0s70QPhA21r+XG9p3VenfVIzs/bdfZua874peq1yw4Bp0YQz8OvDIKClptw8MX6+L7xl+awpJZgLRx0UkRsGzBWWtGlxtqHmvttfd/lxa4Bul7mdH025YcA+2H74E9zbtWO0y8pzYyH/C38GEwO0/dZWAAAAAElFTkSuQmCC
// @match        https://redacted.ch/upload.php*
// @match        https://redacted.ch/torrents.php?action=editgroup&*
// @match        https://redacted.ch/torrents.php?action=edit&*
// @match        https://redacted.ch/requests.php?action=new*
// @match        https://redacted.ch/requests.php?action=edit*
// @match        https://notwhat.cd/upload.php*
// @match        https://notwhat.cd/torrents.php?action=editgroup&*
// @match        https://notwhat.cd/torrents.php?action=edit&*
// @match        https://notwhat.cd/requests.php?action=new*
// @match        https://notwhat.cd/requests.php?action=edit*
// @match        https://orpheus.network/upload.php*
// @match        https://orpheus.network/torrents.php?action=editgroup&*
// @match        https://orpheus.network/torrents.php?action=edit&*
// @match        https://orpheus.network/requests.php?action=new*
// @match        https://orpheus.network/requests.php?action=edit*
// @match        https://dicmusic.club/upload.php*
// @match        https://dicmusic.club/torrents.php?action=editgroup&*
// @match        https://dicmusic.club/torrents.php?action=edit&*
// @match        https://dicmusic.club/requests.php?action=new*
// @match        https://dicmusic.club/requests.php?action=edit*
// @connect      file://*
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// @grant        GM_info
// @require      https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/bencode-min.js
// @require      https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/progressBars.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/imageHostUploader.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/QobuzLib.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/GazelleTagManager.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/langCodes.min.js
// @require      https://openuserjs.org/src/libs/Anakunda/libStringDistance.min.js
// ==/UserScript==

'use strict';

let userAuth = document.body.querySelector('input[name="auth"]');
if (userAuth != null) userAuth = userAuth.value; else throw 'User auth could not be located';
const urlParams = new URLSearchParams(document.location.search),
			action = urlParams.get('action'),
			artistEdit = Boolean(action) && action.toLowerCase() == 'edit',
			artistId = parseInt(urlParams.get('artistid') || urlParams.get('id'));
if (!(artistId > 0)) throw 'Assertion failed: could not extract artist id';
let userId = document.body.querySelector('li#nav_userinfo > a.username');
if (userId != null) {
	userId = new URLSearchParams(userId.search);
	userId = parseInt(userId.get('id')) || null;
}
const isRED = document.location.hostname == 'redacted.ch';
function hasStyleSheet(name) {
	if (name) name = name.toLowerCase(); else throw 'Invalid argument';
	const hrefRx = new RegExp('\\/' + name + '\\b', 'i');
	if (document.styleSheets) for (let styleSheet of document.styleSheets)
		if (styleSheet.title && styleSheet.title.toLowerCase() == name) return true;
			else if (styleSheet.href && hrefRx.test(styleSheet.href)) return true;
	return false;
}
const isLightTheme = ['postmod', 'shiro', 'layer_cake', 'proton', 'red_light'].some(hasStyleSheet);
if (isLightTheme) console.log('Light Gazelle theme detected');
const isDarkTheme = ['kuro', 'minimal', 'red_dark'].some(hasStyleSheet);
if (isDarkTheme) console.log('Dark Gazelle theme detected');
const createElements = (...tagNames) => tagNames.map(Document.prototype.createElement.bind(document));
let siteBlacklist = GM_getValue('site_blacklist');
if (siteBlacklist == undefined) GM_setValue('site_blacklist', [
	'www.myspace.com', 'myspace.com',
	'www.facebook.com', 'm.facebook.com', 'facebook.com',
	'www.twitter.com', 'twitter.com',
	'www.instagram.com', 'instagram.com',
	'www.vk.com', 'vk.com',
]);

function loadArtist() {
	const siteArtistsCache = { }, artistlessGroups = new Set;

	function decodeHTML(html) {
		const textArea = document.createElement("textarea");
		textArea.innerHTML = html;
		return textArea.value;
	}
	function decodeArtistTitles(artist) {
		if (!artist) throw 'Invalid argument';
		if (artist.titlesDecoded) return;
		//const label = `Decode release titles for ${artist.id}`;
		//console.time(label);
		if (/*!isRED && */Array.isArray(artist.torrentgroup)) for (let torrentGroup of artist.torrentgroup)
			if (torrentGroup.groupName) torrentGroup.groupName = decodeHTML(torrentGroup.groupName);
		if (/*!isRED && */Array.isArray(artist.requests)) for (let request of artist.requests)
			if (request.title) request.title = decodeHTML(request.title);
		//console.timeEnd(label);
		artist.titlesDecoded = true;
	}

	const findArtistId = artistName => artistName ? localXHR('/artist.php?' + new URLSearchParams({
		artistname: artistName,
	}).toString(), { method: 'HEAD' }).then(function(xhr) {
		const url = new URL(xhr.responseURL);
		return url.pathname == '/artist.php' && parseInt(url.searchParams.get('id')) || Promise.reject('not found');
	}) : Promise.reject('Invalid argument');

	function getSiteArtist(artistNameOrId, decodeTitles = false) {
		if (!artistNameOrId) throw 'Invalid argument';
		const titleDecoder = artist => (decodeArtistTitles(artist), artist);
		if (typeof artistNameOrId == 'number') {
			if (!(artistNameOrId > 0)) return Promise.reject('Invalid argument');
			let result = queryAjaxAPICached('artist', { id: artistNameOrId });
			return decodeTitles ? result.then(titleDecoder) : result;
		}
		if (artistNameOrId > 0)
			console.trace('[AAM] Warning: possible call of getSiteArtist(...) with stringified artist id', artistNameOrId);
		const artistNameCaseless = artistNameOrId.toLowerCase();
		const key = Object.keys(siteArtistsCache).find(artist => artist.toLowerCase() == artistNameCaseless);
		if (key) return decodeTitles ? siteArtistsCache[key].then(titleDecoder) : siteArtistsCache[key];
		const result = queryAjaxAPICached('artist', { artistname: artistNameOrId }).then(function(response) {
			if (response.name.toLowerCase() == artistNameCaseless) return response;
			return findArtistId(artistNameOrId).then(artistId => queryAjaxAPICached('artist', { id: artistId	}));
		}, reason => reason == 'not found' && artistNameOrId > 0 ?
			queryAjaxAPICached('artist', { id: parseInt(artistNameOrId) }) : Promise.reject(reason));
		siteArtistsCache[artistNameOrId] = result;
		return decodeTitles ? result.then(titleDecoder) : result;
	}

	return getSiteArtist(artistId).then(function(artist) {
		const isGenericArtist = name => ['Various Artists', 'Unknown Artist', 'Unknown Artist(s)'].includes(name);
		const tagsExclusions = tag => !/^(?:freely\.available|staff\.picks|delete\.this\.tag|\d{4}s)$/i.test(tag);
		if (artist.tags) artist.tags = new TagManager(...artist.tags.map(tag => tag.name).filter(tagsExclusions));
		siteArtistsCache[artist.name] = artist;
		const rdExtractor = /\(\s*writes\s+redirect\s+to\s+(\d+)\s*\)/i;
		let activeElement = null;

		function getAlias(li) {
			console.assert(li instanceof HTMLLIElement, 'li instanceof HTMLLIElement', li);
			if (!(li instanceof HTMLLIElement)) return;
			if (typeof li.alias == 'object') return li.alias;
			const alias = {
				id: li.querySelector(':scope > span:nth-of-type(1)'),
				name: li.querySelector(':scope > span:nth-of-type(2)'),
				redirectId: rdExtractor.exec(li.textContent),
			};
			if (alias.id == null || alias.name == null || !(alias.id = parseInt(alias.id.textContent))
				|| !(alias.name = alias.name.textContent)) return;
			if (alias.redirectId != null) alias.redirectId = parseInt(alias.redirectId[1]); else delete alias.redirectId;
			return alias;
		}

		const resolveArtistId = (artistIdOrName = artist.id) => artistIdOrName > 0 ? Promise.resolve(artistIdOrName)
			: typeof artistIdOrName == 'string' ? findArtistId(artistIdOrName) : Promise.resolve(artist.id);
		const resloveArtistName = artistIdOrName => artistIdOrName > 0 ? artistIdOrName == artist.id ? artist.name
			: getSiteArtist(artistIdOrName).then(artist => artist.name) : Promise.resolve(artistIdOrName);
		function findAlias(aliasIdOrName, resolveFinalAlias = false, document = window.document) {
			const addForm = document.body.querySelector('form.add_form');
			if (addForm == null) throw 'Invalid page structure';
			for (let li of addForm.parentNode.parentNode.querySelectorAll('div.box > div > ul > li')) {
				const alias = getAlias(li);
				if (alias && (aliasIdOrName > 0 && alias.id == aliasIdOrName
						|| typeof aliasIdOrName == 'string' && alias.name.toLowerCase() == aliasIdOrName.toLowerCase()))
					return alias.redirectId > 0 && resolveFinalAlias ? findAlias(alias.redirectId, true, document) : alias;
			}
			return null;
		}
		const findArtistAlias = (aliasIdOrName, artistIdOrName, resolveFinalAlias = false) => (function() {
			if ((!artistIdOrName || artistIdOrName < 0) && artistEdit) return Promise.resolve(window.document);
			return resolveArtistId(artistIdOrName).then(artistId => localXHR('/artist.php?' + new URLSearchParams({
				action: 'edit',
				artistid: artistId,
			}).toString()));
		})().then(document => findAlias(aliasIdOrName, resolveFinalAlias, document)
			|| Promise.reject('Alias id/name not defined for this artist'));
		const resolveAliasId = (aliasIdOrName, artistIdOrName, resolveFinalAlias = false) =>
			aliasIdOrName >= 0 ? Promise.resolve(aliasIdOrName) : typeof aliasIdOrName == 'string' ?
				findArtistAlias(aliasIdOrName, artistIdOrName, resolveFinalAlias).then(alias => alias.id)
			: Promise.reject('Invalid argument');

		const addAlias = (name, redirectTo = 0, artistIdOrName) => resolveArtistId(artistIdOrName).then(artistId =>
					resolveAliasId(redirectTo, artistIdOrName && artistId, true).then(redirectTo => localXHR('/artist.php', { responseType: null }, new URLSearchParams({
				action: 'add_alias',
				artistid: artistId,
				name: name,
				redirect: redirectTo > 0 ? redirectTo : 0,
				auth: userAuth,
			}))));
		const deleteAlias = (aliasIdOrName, artistIdOrName) => resolveAliasId(aliasIdOrName, artistIdOrName)
			.then(aliasId => localXHR('/artist.php?' + new URLSearchParams({
				action: 'delete_alias',
				aliasid: aliasId,
				auth: userAuth,
			}).toString())).then(function(document) {
				if (!/^\s*(?:Error)\b/.test(document.head.textContent)) return true;
				const box = document.body.querySelector('div#content div.box');
				if (box != null) alert(`Alias "${aliasIdOrName}" deletion failed:\n\n${box.textContent.trim()}`);
				return false;
			});

		const renameArtist = (newName, artistIdOrName = artist.id) => resolveArtistId(artistIdOrName)
			.then(artistId => localXHR('/artist.php', { responseType: null }, new URLSearchParams({
				action: 'rename',
				artistid: artistId,
				name: newName,
				auth: userAuth,
			})));
		const addSimilarArtist = (relatedIdOrName, artistIdOrName = artist.id) => resolveArtistId(artistIdOrName)
				.then(artistId => resloveArtistName(relatedIdOrName).then(artistName =>
					localXHR('/artist.php', { responseType: null }, new URLSearchParams({
			action: 'add_similar',
			artistid: artistId,
			artistname: artistName,
			auth: userAuth,
		}))));
		const addSimilarArtists = (similarArtists, artistIdOrName = artist.id) => resolveArtistId(artistIdOrName)
			.then(artistId => Promise.all(similarArtists
				.filter((name1, ndx, arr) => arr.findIndex(name2 => name2.toLowerCase() == name1.toLowerCase()) == ndx)
				.map(similarArtist => addSimilarArtist(similarArtist, artistIdOrName && artistId || undefined))));
		const changeArtistId = (newArtistIdOrName, artistIdOrName = artist.id) =>
			resolveArtistId(artistIdOrName).then(artistId => resolveArtistId(newArtistIdOrName)
				.then(newArtistId => localXHR('/artist.php', { responseType: null }, new URLSearchParams({
					action: 'change_artistid',
					artistid: artistId,
					newartistid: newArtistId,
					confirm: 1,
					auth: userAuth,
				}))));
		const editArtist = (image = artist.image, body = artist.body, summary, artistIdOrName = artist.id, editNotes) =>
				(image && unsafeWindow.imageHostHelper ? unsafeWindow.imageHostHelper.rehostImageLinks([image], true, false, false)
					.then(unsafeWindow.imageHostHelper.singleImageGetter, function(reason) {
			console.warn(reason);
			return image;
		}) : Promise.resolve(image)).then(image => resolveArtistId(artistIdOrName).then(artistId =>
				localXHR('/artist.php', { responseType: null }, new URLSearchParams({
			action: 'edit',
			artistid: artistId,
			image: image || '',
			body: body || '',
			summary: summary || '',
			artisteditnotes: editNotes || '',
			auth: userAuth,
		}))));

		function addAliasToGroup(groupId, aliasName, importances) {
			if (!(groupId > 0) || !aliasName || !Array.isArray(importances))
				return Promise.resolve('One or more arguments invalid');
			const payLoad = new URLSearchParams({
				action: 'add_alias',
				groupid: groupId,
				auth: userAuth,
			});
			for (let importance of importances) {
				payLoad.append('aliasname[]', aliasName);
				payLoad.append('importance[]', importance);
			}
			return localXHR('/torrents.php', { responseType: null }, payLoad);
		}
		const deleteArtistFromGroup = (groupId, artistIdOrName = artist.id, importances) =>
			groupId > 0 && Array.isArray(importances) ? resolveArtistId(artistIdOrName)
				.then(artistId => Promise.all(importances.map(importance => localXHR('/torrents.php?' + new URLSearchParams({
					action: 'delete_alias',
					groupid: groupId,
					artistid: artistId,
					importance: importance,
					auth: userAuth,
				}).toString(), { responseType: null })))) : Promise.reject('One or more arguments invalid');

		function gotoArtistPage(artistIdOrName = artist.id) {
			resolveArtistId(artistIdOrName)
				.then(artistId => { document.location.assign('/artist.php?id=' + artistId.toString()) });
		}
		function gotoArtistEditPage(artistIdOrName = artist.id) {
			resolveArtistId(artistIdOrName).then(function(artistId) {
				document.location.assign('/artist.php?' + new URLSearchParams({
					action: 'edit',
					artistid: artistId,
				}).toString() + '#aliases');
			});
		}
		const wait = param => new Promise(resolve => { setTimeout(param => { resolve(param) }, 200, param) });

		const clearRecoveryInfo = () => { GM_deleteValue('damage_control') };
		function hasRecoveryInfo() {
			const recoveryInfo = GM_getValue('damage_control');
			return recoveryInfo && recoveryInfo.artist.id == artist.id;
		}

		const sameArtistConfidence = GM_getValue('artist_matching_threshold', 0.90),
					sameTitleConfidence = GM_getValue('title_matching_threshold', 0.90),
					cacheSizeReserve = GM_getValue('cache_size_reserve', 1280);
		const stripRlsSuffix = title => title.replace(/\s+(?:EP|E\.\s?P\.|\((?:EP|E\.\s?P\.|[Ss]ingle|[Ll]ive)\)|-\s+(?:EP|E\.\s?P\.|[Ss]ingle|[Ll]ive))$/, '');
		const titleCmpNorm = title => stripRlsSuffix(title).replace(/[^\w\u0080-\uFFFF]/g, '').toLowerCase();

		// Discogs querying
		const dcToken = GM_getValue('discogs_token', 'fJGcklUZogHYsgHaIWtqWWcdChKvJhpNknDKFHFk'),
					dcKey = GM_getValue('discogs_key'), dcSecret = GM_getValue('discogs_secret'),
					dcApiRateControl = { }, dcRequestsCache = new Map, dcArtistReleasesCache = new Map,
					dcEntriesCache = Object.assign.apply({ }, ['artists', 'releases', 'masters', 'labels', 'user']
						.map(type => ({ [type]: new Map }))),
					dcSearchSize = GM_getValue('discogs_artist_search_size', 100);
		let dcMasterYears = new Map, dcLastCachedSuccess, dcQueriesCache;
		//try { dcMasterYears = new Map(GM_getValue('discogs_master_years')) } catch(e) { }
		const dcNameNormalizer = artist => artist.replace(/\s+/g, ' ').replace(/\s+\(\d+\)$/, '');

		function queryDiscogsAPI(endPoint, params) {
			if (endPoint) endPoint = new URL(endPoint, 'https://api.discogs.com');
				else return Promise.reject('No endpoint provided');
			if (typeof params == 'object') for (let key in params) endPoint.searchParams.set(key, params[key]);
				else if (params) endPoint.search = new URLSearchParams(params);
			const cacheKey = endPoint.pathname.slice(1) + endPoint.search;
			if (dcRequestsCache.has(cacheKey)) return dcRequestsCache.get(cacheKey);
			if (!dcQueriesCache && 'discogsQueriesCache' in sessionStorage
					&& (parseInt(sessionStorage.aamCachedArtistId) == artist.id
					|| sessionStorage.discogsQueriesCache.length < cacheSizeReserve * 2**10)) try {
				dcQueriesCache = new Map(JSON.parse(sessionStorage.getItem('discogsQueriesCache')));
			} catch(e) { console.warn(e) }
			if (!dcQueriesCache) dcQueriesCache = new Map;
			if (dcQueriesCache.has(cacheKey)) return Promise.resolve(dcQueriesCache.get(cacheKey));
			const authHeader = { };
			if (dcKey && dcSecret) authHeader.Authorization = `Discogs key=${dcKey}, secret=${dcSecret}`;
				else if (dcToken) authHeader.Authorization = `Discogs token=${dcToken}`;
					else console.warn('Discogs API: no authentication credentials are configured, the functionality related to Discogs is limited');
			let requestsMax = 60;
			const worker = new Promise((resolve, reject) => (function request(retryCounter = 0) {
				const postpone = () => { setTimeout(request, dcApiRateControl.timeFrameExpiry - now, retryCounter + 1) };
				const now = Date.now();
				if (!dcApiRateControl.timeFrameExpiry || now > dcApiRateControl.timeFrameExpiry) {
					dcApiRateControl.timeFrameExpiry = now + 60 * 1000 + 500;
					if (dcApiRateControl.requestDebt > 0) {
						dcApiRateControl.requestCounter = Math.min(requestsMax, dcApiRateControl.requestDebt);
						dcApiRateControl.requestDebt -= dcApiRateControl.requestCounter;
						console.assert(dcApiRateControl.requestDebt >= 0, 'dcApiRateControl.requestDebt >= 0');
					} else dcApiRateControl.requestCounter = 0;
				}
				if (++dcApiRateControl.requestCounter <= requestsMax) GM_xmlhttpRequest({ method: 'GET', url: endPoint,
					responseType: 'json',
					headers: Object.assign({
						'Accept': 'application/json',
						'X-Requested-With': 'XMLHttpRequest',
					}, authHeader),
					onload: function(response) {
						let requestsUsed = /^(?:x-discogs-ratelimit):\s*(\d+)\b/im.exec(response.responseHeaders);
						if (requestsUsed != null && (requestsUsed = parseInt(requestsUsed[1])) > 0) requestsMax = requestsUsed;
						requestsUsed = /^(?:x-discogs-ratelimit-used):\s*(\d+)\b/im.exec(response.responseHeaders);
						if (requestsUsed != null && (requestsUsed = parseInt(requestsUsed[1]) + 1) > dcApiRateControl.requestCounter) {
							dcApiRateControl.requestCounter = requestsUsed;
							dcApiRateControl.requestDebt = Math.max(requestsUsed - requestsMax, 0);
						}
						if (response.status >= 200 && response.status < 400) {
							dcQueriesCache.set(cacheKey, response = response.response);
							if (!domStorageLimitReached) {
								const serialized = JSON.stringify(Array.from(dcQueriesCache));
								try {
									sessionStorage.setItem('discogsQueriesCache', serialized);
									dcLastCachedSuccess = serialized;
								} catch(e) {
									console.warn(e, `(${serialized.length})`);
									if (/\b(?:NS_ERROR_DOM_QUOTA_REACHED)\b/.test(e)
										 || e instanceof DOMException && e.name == 'QuotaExceededError') {
										domStorageLimitReached = true;
										sessionStorage.setItem('discogsQueriesCache', dcLastCachedSuccess);
										dcLastCachedSuccess = undefined;
									}
								}
								sessionStorage.setItem('aamCachedArtistId', artist.id);
							}
							resolve(response);
						} else {
							if (response.status == 429/* && retryCounter < 25*/) {
								console.warn(defaultErrorHandler(response), response.response.message, '(' + retryCounter + ')',
									`Rate limit used: ${requestsUsed}/${requestsMax}`);
								postpone();
							} else reject(defaultErrorHandler(response));
						}
					},
					onerror: response => { reject(defaultErrorHandler(response)) },
					ontimeout: response => { reject(defaultTimeoutHandler(response)) },
				}); else postpone();
			})());
			dcRequestsCache.set(cacheKey, worker);
			return worker;
		}
		function discogsRedirectHandler(reason, type, id, callback) {
			if (!/^(?:HTTP error 404)\b/i.test(reason)) return Promise.reject(reason);
			if (!type || !(id > 0) || typeof callback != 'function') throw 'Invalid argument(s)';
			const rx = new RegExp(`\\/${type = type.toLowerCase().replace(/s$/, '')}s?\\/(\\d+)\\b`, 'i');
			return globalXHR(`https://www.discogs.com/${type}/${id.toString()}`, { method: 'HEAD' }).then(function(response) {
				let newId = rx.exec(response.finalUrl);
				if (newId == null || !(newId = parseInt(newId[1])) || newId == id) return Promise.reject(reason);
				return callback(type, newId);
			}, reason2 => Promise.reject(reason));
		}
		function getDiscogsEntry(type, id) {
			if (!type || !((type = type.toLowerCase() + 's') in dcEntriesCache)) throw 'Invalid item type';
			if (!(id > 0)) return Promise.reject('Invalid item id');
			if (dcEntriesCache[type].has(id)) return dcEntriesCache[type].get(id);
			const result = queryDiscogsAPI(type + '/' + id.toString())
				.catch(reason => discogsRedirectHandler(reason, type, id, getDiscogsEntry));
			dcEntriesCache[type].set(id, result);
			return result;
		}
		function getDiscogsArtistReleases(artistId) {
			if (!(artistId > 0)) return Promise.reject('Invalid artist id');
			if (dcArtistReleasesCache.has(artistId)) return dcArtistReleasesCache.get(artistId);
			const getPage = (page = 1) => queryDiscogsAPI(`artists/${artistId}/releases`, { page: page, per_page: 500 });
			const worker = getPage().then(function(response) {
				const releases = response.releases;
				if (!(response.pagination.page < response.pagination.pages)) return releases;
				const fetchers = [ ];
				for (let page = response.pagination.page; page < response.pagination.pages; ++page)
					fetchers.push(getPage(page + 1));
				return Promise.all(fetchers).then(responses =>
					Array.prototype.concat.apply(releases, responses.map(response => response.releases)));
			}, reason => discogsRedirectHandler(reason, 'srtist', artistId, (_, newId) => getDiscogsArtistReleases(newId))).catch(function(reason) {
				console.warn(`Failed to get Discogs releases of ${artistId}:`, reason);
				return [ ];
			});
			dcArtistReleasesCache.set(artistId, worker);
			return worker;
		}
		function getDiscogsMatches(artistId, torrentGroups, resolveMasterYears = true) {
			if (!(artistId > 0) || !Array.isArray(torrentGroups)) return Promise.reject('Invalid argument');
			if (torrentGroups.length <= 0) return Promise.resolve([ ]);
			return getDiscogsArtistReleases(artistId).then(function(releases) {
				const masterLookups = new Set, results = torrentGroups.filter(function(torrentGroup) {
					const titleNorm = [titleCmpNorm(torrentGroup.groupName), stripRlsSuffix(torrentGroup.groupName).toLowerCase()];
					return releases.some(function(release) {
						let strictMatch;
						if (release.year < torrentGroup.groupYear || !(strictMatch = titleCmpNorm(release.title) == titleNorm[0])
								&& jaroWrinkerSimilarity(stripRlsSuffix(release.title).toLowerCase(), titleNorm[1]) < sameTitleConfidence)
							return false;
						if (release.year == torrentGroup.groupYear) return true;
						if (release.type == 'master') {
							if (dcMasterYears.has(release.id)) return torrentGroup.groupYear == dcMasterYears.get(release.id);
							if (resolveMasterYears) masterLookups.add(release.id);
								else if (!['Main'].includes(release.role) && strictMatch) return true;
						}
						return false;
					});
				});
				if (masterLookups.size > 0) console.log(masterLookups.size, 'master release(s) to lookup on Discogs');
				return masterLookups.size <= 0 ? results : Promise.all(Array.from(masterLookups.values()).map(masterId =>
						getDiscogsEntry('master', masterId).then(function(master) {
					dcMasterYears.set(masterId, master.year);
					return { [masterId]: master.year };
				}))).then(results => Object.assign.apply({ }, results.filter(Boolean))).then(function(masterYears) {
					//GM_setValue('discogs_master_years', Array.from(dcMasterYears).slice(-1000));
					return results.concat(torrentGroups.filter(function(torrentGroup) {
						const titleNorm = [titleCmpNorm(torrentGroup.groupName), stripRlsSuffix(torrentGroup.groupName).toLowerCase()];
						return releases.some(release => release.year > torrentGroup.groupYear && release.type == 'master'
							&& masterYears[release.id] == torrentGroup.groupYear && (titleCmpNorm(release.title) == titleNorm[0]
							|| jaroWrinkerSimilarity(stripRlsSuffix(release.title).toLowerCase(), titleNorm[1]) >= sameTitleConfidence));
					}));
				});
			}).then(torrentGroups => torrentGroups.map(torrentGroup => torrentGroup.groupId));
		}

		const discogsSearchArtist = (searchTerm = artist.name, anvs) => searchTerm ? queryDiscogsAPI('database/search', {
			query: searchTerm,
			type: 'artist',
			sort: 'score',
			sort_order: 'desc',
			strict: !Array.isArray(anvs),
			per_page: dcSearchSize || '',
		}).then(function(response) {
			const results = response.results.filter(result => result.type == 'artist' && (function() {
				const anvMatch = anv => dcNameNormalizer(result.title).toLowerCase() == anv.toLowerCase()
					|| jaroWrinkerSimilarity(dcNameNormalizer(result.title).toLowerCase(), anv.toLowerCase()) >= sameArtistConfidence;
				return anvMatch(searchTerm) || Array.isArray(anvs) && anvs.map(dcNameNormalizer).some(anvMatch);
			})());
			if (results.length <= 0) {
				const m = /^(.+?)\s*\((.+)\)$/.exec(searchTerm);
				return m != null ? discogsSearchArtist(m[1], anvs).catch(function(reason) {
					if (reason == 'No matches' && isNaN(parseInt(m[2]))) return discogsSearchArtist(m[2], anvs);
					return Promise.reject(reason);
				}) : Promise.reject('No matches');
			}
			console.log('[AAM] Discogs search results for "' + searchTerm + '":', results);
			return results;
		}) : Promise.reject('Invalid argument');

		// MusicBrainz querying
		const mbRequestsCache = new Map, mbArtistCache = new Map, mbArtistReleasesCache = new Map;
		let mbLastRequest = null, mbLastCachedSuccess, mbQueriesCache;

		function mbQueryAPI(endPoint, params) {
			if (!endPoint) throw 'Endpoint is missing';
			const url = new URL('http://musicbrainz.org/ws/2/' + endPoint.replace(/^\/+|\/+$/g, ''));
			url.search = new URLSearchParams(Object.assign({ fmt: 'json' }, params));
			const cacheKey = url.pathname.slice(6) + url.search;
			if (mbRequestsCache.has(cacheKey)) return mbRequestsCache.get(cacheKey);
			if (!mbQueriesCache && 'mbQueriesCache' in sessionStorage
					&& (parseInt(sessionStorage.aamCachedArtistId) == artist.id
					|| sessionStorage.mbQueriesCache.length < cacheSizeReserve * 2**10)) try {
				mbQueriesCache = new Map(JSON.parse(sessionStorage.getItem('mbQueriesCache')));
			} catch(e) { console.warn(e) }
			if (!mbQueriesCache) mbQueriesCache = new Map;
			if (mbQueriesCache.has(cacheKey)) return Promise.resolve(mbQueriesCache.get(cacheKey));
			const worker = new Promise((resolve, reject) => { (function request() {
				if (mbLastRequest == Infinity) return setTimeout(request, 100);
				const now = Date.now();
				if (now <= mbLastRequest + 1000) return setTimeout(request, mbLastRequest + 1000 - now);
				mbLastRequest = Infinity;
				globalXHR(url, { responseType: 'json' }).then(function({response}) {
					mbLastRequest = Date.now();
					mbQueriesCache.set(cacheKey, response);
					if (!domStorageLimitReached) {
						const serialized = JSON.stringify(Array.from(mbQueriesCache));
						try {
							sessionStorage.setItem('mbQueriesCache', serialized);
							mbLastCachedSuccess = serialized;
						} catch(e) {
							console.warn(e, `(${serialized.length})`);
							if (/\b(?:NS_ERROR_DOM_QUOTA_REACHED)\b/.test(e)
									|| e instanceof DOMException && e.name == 'QuotaExceededError') {
								domStorageLimitReached = true;
								sessionStorage.setItem('mbQueriesCache', mbLastCachedSuccess);
								mbLastCachedSuccess = undefined;
							}
						}
						sessionStorage.setItem('aamCachedArtistId', artist.id);
					}
					resolve(response);
				}, function(reason) {
					mbLastRequest = Date.now();
					if (/^(?:HTTP error 503)\b/.test(reason)) request(); else reject(reason);
				});
			})() });
			mbRequestsCache.set(cacheKey, worker);
			return worker;
		}

		function mbGetArtist(artistId) {
			if (!artistId) return Promise.reject('Invalid artist id');
			if (mbArtistCache.has(artistId)) return mbArtistCache.get(artistId);
			const worker = mbQueryAPI('artist/' + artistId, { inc: ['url-rels', 'artist-rels', 'release-group-rels'].join('+') });
			mbArtistCache.set(artistId, worker);
			return worker;
		}

		function mbGetArtistReleases(artistId) {
			if (!artistId) return Promise.reject('Invalid artist id');
			if (mbArtistReleasesCache.has(artistId)) return mbArtistReleasesCache.get(artistId);
			const worker = Promise.all([(function loadPage(offset = 0) {
				return mbQueryAPI('release-group', { artist: artistId, offset: offset, limit: 9999 }).then(function(response) {
					if (!Array.isArray(response['release-groups'])) return [ ];
					return (offset += response['release-groups'].length) < response['release-group-count'] ?
						loadPage(offset).then(releaseGroups => response['release-groups'].concat(releaseGroups))
							: response['release-groups'];
				});
			})(),/* (function loadPage(offset = 0) {
				return mbQueryAPI('recording', { artist: artistId, offset: offset, limit: 9999 }).then(function(response) {
					if (!Array.isArray(response.recordings)) return [ ];
					return (offset += response.recordings.length) < response['recording-count'] ?
						loadPage(offset).then(recordings => response.recordings.concat(recordings)) : response.recordings;
				});
			})()*/]).then(results => Array.prototype.concat.apply([ ], results));
			mbArtistReleasesCache.set(artistId, worker);
			return worker;
		}
		const mbGetArtistMatches = (artistId, torrentGroups) => artistId && Array.isArray(torrentGroups) ?
				torrentGroups.length > 0 ? mbGetArtistReleases(artistId).then(releaseGroups => torrentGroups.filter(function(torrentGroup) {
			const titleNorm = [titleCmpNorm(torrentGroup.groupName), stripRlsSuffix(torrentGroup.groupName).toLowerCase()];
			return releaseGroups.some(function(releaseGroup) {
				const firstReleaseDate = new Date(releaseGroup['first-release-date']);
				let strictMatch;
				if (!(firstReleaseDate.getFullYear() >= torrentGroup.groupYear)
						|| !(strictMatch = titleCmpNorm(releaseGroup.title) == titleNorm[0])
						&& jaroWrinkerSimilarity(stripRlsSuffix(releaseGroup.title).toLowerCase(), titleNorm[1]) < sameTitleConfidence) return false;
				if (firstReleaseDate.getFullYear() == torrentGroup.groupYear) return true;
				return false;
			});
		})).then(torrentGroups => torrentGroups.map(torrentGroup => torrentGroup.groupId)) : Promise.resolve([ ]) : Promise.reject('Invalid argument');
		const mbSearchArtist = (searchTerm = artist.name, anvs) => searchTerm ? mbQueryAPI('artist', {
			query: '"' + searchTerm + '"',
			limit: 100,
		}).then(function(response) {
			if (!Array.isArray(response.artists) || response.artists.length <= 0) return Promise.reject('No matches');
			const results = response.artists.filter(function(artist) {
				function anvMatch(anv) {
					anv = anv.toLowerCase();
					const propMatch = prop => prop && (prop = prop.toLowerCase())
						&& (prop == anv || jaroWrinkerSimilarity(prop, anv) >= sameArtistConfidence);
					const entityMatch = entity => ['name', 'sort-name'].some(propName => propMatch(entity[propName]));
					return entityMatch(artist) || Array.isArray(artist.aliases) && artist.aliases.some(entityMatch);
				}
				return anvMatch(searchTerm) || Array.isArray(anvs) && anvs.some(anvMatch);
			});
			if (results.length <= 0) return Promise.reject('No matches');
			console.log('[AAM] MusicBrainz search results for "' + searchTerm + '":', results);
			return results;
		}).catch(function(reason) {
			if (reason == 'No matches') {
				const m = /^(.+?)\s*\((.+)\)$/.exec(searchTerm);
				if (m != null) return mbSearchArtist(m[1], anvs).catch(reason =>
					reason == 'No matches' && isNaN(parseInt(m[2])) ? mbSearchArtist(m[2], anvs) : Promise.reject(reason));
			}
			return Promise.reject(reason);
		}) : Promise.reject('Invalid argiment');

		// BeatPort querying
		const bpRequestsCache = new Map, bpArtistCache = new Map, bpArtistReleasesCache = new Map;
		let bpLastCachedSuccess, bpQueriesCache, bsOAuth2Token = null;

		function bpQueryAPI(endPoint, params) {
			function getOauth2Token() {
				function isTokenValid(accessToken) {
					return typeof accessToken == 'object' && accessToken.token_type && accessToken.access_token
						&& accessToken.expires_at >= Date.now() + 10 * 1000;
				}

				if ('beatsourceAccessToken' in localStorage) try {
					let accessToken = JSON.parse(localStorage.beatsourceAccessToken);
					if (isTokenValid(accessToken)) {
						console.info('Re-used Beatsource access token:', accessToken,
							'expires at', new Date(accessToken.expires_at).toTimeString(),
							'(' + ((accessToken.expires_at - Date.now()) / 1000) + ')');
						return Promise.resolve(accessToken);
					}
				} catch(e) { }
				const root = 'https://www.beatsource.com/';
				const timeStamp = Date.now();
				return globalXHR(root, { method: 'HEAD' }).then(function(response) {
					let matches = /\b(?:btsrcSession)=([^\s\;]+)/m.exec(response.responseHeaders);
					if (matches == null) return Promise.reject('cookie already set');
					let result = JSON.parse(decodeURIComponent(matches[1]));
					matches = /\b(?:sessionId)=([^\s\;]+)/m.exec(response.responseHeaders);
					if (matches != null) try { result.sessionId = decodeURIComponent(matches[1]) } catch(e) { console.warn(e) }
					return result;
				}).catch(reason => globalXHR(root).then(function(response) {
					let nextData = response.document.getElementById('__NEXT_DATA__');
					if (nextData != null) nextData = JSON.parse(nextData.text); else return Promise.reject('object is missing');
					return Object.assign(nextData.props.rootStore.authStore.user, {
						apiHost: nextData.runtimeConfig.API_HOST,
						clientId: nextData.runtimeConfig.API_CLIENT_ID,
						recurlyPublicKey: nextData.runtimeConfig.RECURLY_PUBLIC_KEY,
					});
				})).then(function(accessToken) {
					const tzOffset = new Date().getTimezoneOffset() * 60 * 1000;
					if (!accessToken.timestamp) accessToken.timestamp = timeStamp;
					if (!accessToken.expires_at) accessToken.expires_at = accessToken.timestamp + accessToken.expires_in * 1000;
						else accessToken.expires_at -= tzOffset;
					if (!isTokenValid(accessToken)) {
						console.warn('Received invalid Beatsource token:', accessToken);
						return Promise.reject('invalid token received');
					}
					localStorage.beatsourceAccessToken = JSON.stringify(accessToken);
					console.log('Beatsource access token successfully set:',
						accessToken, (Date.now() - accessToken.timestamp) / 1000);
					return accessToken;
				});
			}

			if (!endPoint) throw 'Endpoint is missing';
			const url = new URL('https://api.beatport.com/v4/' + endPoint.replace(/^\/+/g, ''));
			url.search = new URLSearchParams(Object.assign({ }, params));
			const cacheKey = url.pathname.slice(4) + url.search;
			if (bpRequestsCache.has(cacheKey)) return bpRequestsCache.get(cacheKey);
			if (!bpQueriesCache && 'bpQueriesCache' in sessionStorage
					&& (parseInt(sessionStorage.aamCachedArtistId) == artist.id
					|| sessionStorage.bpQueriesCache.length < cacheSizeReserve * 2**10)) try {
				bpQueriesCache = new Map(JSON.parse(sessionStorage.getItem('bpQueriesCache')));
			} catch(e) { console.warn(e) }
			if (!bpQueriesCache) bpQueriesCache = new Map;
			if (bpQueriesCache.has(cacheKey)) return Promise.resolve(bpQueriesCache.get(cacheKey));
			const worker = (bsOAuth2Token || (bsOAuth2Token = getOauth2Token())).then(oauth2Token => globalXHR(url, {
				responseType: 'json',
				headers: { 'Authorization': oauth2Token.token_type + ' ' + oauth2Token.access_token },
			}).then(function({response}) {
				// bpQueriesCache.set(cacheKey, response);
				// if (!domStorageLimitReached) {
				// 	const serialized = JSON.stringify(Array.from(bpQueriesCache));
				// 	try {
				// 		sessionStorage.setItem('bpQueriesCache', serialized);
				// 		bpLastCachedSuccess = serialized;
				// 	} catch(e) {
				// 		console.warn(e, `(${serialized.length})`);
				// 		if (/\b(?:NS_ERROR_DOM_QUOTA_REACHED)\b/.test(e)
				// 				|| e instanceof DOMException && e.name == 'QuotaExceededError') {
				// 			domStorageLimitReached = true;
				// 			sessionStorage.setItem('bpQueriesCache', bpLastCachedSuccess);
				// 			bpLastCachedSuccess = undefined;
				// 		}
				// 	}
				// 	sessionStorage.setItem('aamCachedArtistId', artist.id);
				// }
				return response;
			}));
			bpRequestsCache.set(cacheKey, worker);
			return worker;
		}

		function bpGetArtist(artistId) {
			if (!(artistId > 0)) return Promise.reject('Invalid artist id');
			if (bpArtistCache.has(artistId)) return bpArtistCache.get(artistId);
			const worker = bpQueryAPI('catalog/artists/' + artistId + '/');
			bpArtistCache.set(artistId, worker);
			return worker;
		}
		const bpReflowArtistBio = bpArtist => bpArtist && bpArtist.bio
			&& bpArtist.bio.replace(/(?:\r?\n)+/g, ' ').replace(/\s+/g, ' ');
		function bpGetArtistReleases(artistId) {
			if (!(artistId > 0)) return Promise.reject('Invalid artist id');
			if (bpArtistReleasesCache.has(artistId)) return bpArtistReleasesCache.get(artistId);
			const worker = bpQueryAPI('catalog/releases/', {
				artist_id: artistId,
				//page: page,
				per_page: 9999,
			}).then(response => response.results);
			bpArtistReleasesCache.set(artistId, worker);
			return worker;
		}
		const bpGetArtistMatches = (artistId, torrentGroups) => artistId > 0 && Array.isArray(torrentGroups) ?
				torrentGroups.length > 0 ? bpGetArtistReleases(artistId).then(releases => torrentGroups.filter(function(torrentGroup) {
			const titleNorm = [titleCmpNorm(torrentGroup.groupName), stripRlsSuffix(torrentGroup.groupName).toLowerCase()];
			return releases.some(function(release) {
				const newReleaseDate = new Date(release.new_release_date), publishDate = new Date(release.publish_date);
				console.assert(!isNaN(newReleaseDate), 'Invalid release date in MusicBrainz data: ' + release.new_release_date);
				console.assert(!isNaN(publishDate), 'Invalid publish date in MusicBrainz data: ' + release.publish_date);
				const yearMatch = newReleaseDate.getFullYear() == torrentGroup.groupYear;
				if (!yearMatch && !(newReleaseDate.getFullYear() >= torrentGroup.groupYear)) return false;
				return titleCmpNorm(release.name) == titleNorm[0] || yearMatch
					&& jaroWrinkerSimilarity(stripRlsSuffix(release.name).toLowerCase(), titleNorm[1]) >= sameTitleConfidence;
			});
		})).then(torrentGroups => torrentGroups.map(torrentGroup => torrentGroup.groupId)) : Promise.resolve([ ]) : Promise.reject('Invalid argument');
		const bpSearchArtist = (searchTerm = artist.name, anvs) => searchTerm ? bpQueryAPI('catalog/search/', {
			q: '"' + searchTerm + '"',
			type: 'artists',
			per_page: 100,
		}).then(function(response) {
			if (response.count <= 0) return Promise.reject('No matches');
			const results = response.artists.filter(function(artist) {
			if (!artist.name) return false;
				const name = artist.name.toLowerCase();
				function anvMatch(anv) {
					if (!anv) return false; else if ((anv = anv.toLowerCase()) == name) return true;
					const score = jaroWrinkerSimilarity(anv, name);
					if (score < sameArtistConfidence) return false;
					//console.log('[AAM] Jaro-Wrinker fuzzy match:', name, anv, `(${score.toFixed(3)})`)
					return true;
				}
				return anvMatch(searchTerm) || Array.isArray(anvs) && anvs.some(anvMatch);
			});
			if (results.length <= 0) return Promise.reject('No matches');
			console.log('[AAM] BeatPort search results for "' + searchTerm + '":', results);
			return results;
		}).catch(function(reason) {
			if (reason == 'No matches') {
				const m = /^(.+?)\s*\((.+)\)$/.exec(searchTerm);
				if (m != null) return bpSearchArtist(m[1], anvs).catch(reason =>
					reason == 'No matches' && isNaN(parseInt(m[2])) ? bpSearchArtist(m[2], anvs) : Promise.reject(reason));
			}
			return Promise.reject(reason);
		}) : Promise.reject('Invalid argiment');

		// Apple Music querying
		const amRequestsCache = new Map, amArtistCache = new Map, amArtistReleasesCache = new Map;
		let amLastCachedSuccess, amQueriesCache, amDesktopEnvironment = null;

		const amQueryAPI = (endPoint, params) => endPoint ? (amDesktopEnvironment || (amDesktopEnvironment = (function() {
			if ('appleMusicDesktopConfig' in sessionStorage) try {
				return Promise.resolve(JSON.parse(sessionStorage.getItem('appleMusicDesktopConfig')));
			} catch(e) { console.warn('Apple Music invalid cached desktop config:', e) }
			return globalXHR('https://music.apple.com/').then(function({document}) {
				let config = document.head.querySelector('meta[name="desktop-music-app/config/environment"][content]');
				if (config != null) config = JSON.parse(decodeURIComponent(config.content));
					else return Promise.reject('Apple desktop environment missing');
				if (!config.MEDIA_API.token) {
					console.warn('Apple Music received invalid desktop config:', config);
					return Promise.reject('Apple API token missing')
				}
				console.log('Sucecssfully extracted Apple desktop environment:', config);
				sessionStorage.setItem('appleMusicDesktopConfig', JSON.stringify(config));
				return config;
			});
		})())).then(function(environment) {
			const url = new URL(environment.MUSIC.BASE_URL + '/catalog/us/' + endPoint.replace(/^\/+/g, ''));
			if (params) url.search = new URLSearchParams(params);
			const cacheKey = url.pathname.slice(15) + url.search;
			if (amRequestsCache.has(cacheKey)) return amRequestsCache.get(cacheKey);
			if (!amQueriesCache && 'amQueriesCache' in sessionStorage
					&& (parseInt(sessionStorage.aamCachedArtistId) == artist.id
					|| sessionStorage.amQueriesCache.length < cacheSizeReserve * 2**10)) try {
				amQueriesCache = new Map(JSON.parse(sessionStorage.getItem('amQueriesCache')));
			} catch(e) { console.warn(e) }
			if (!amQueriesCache) amQueriesCache = new Map;
			if (amQueriesCache.has(cacheKey)) return Promise.resolve(amQueriesCache.get(cacheKey));
			url.searchParams.set('omit[resource]', 'relationships,views,meta,autos');
			url.searchParams.set('l', 'en-us');
			url.searchParams.set('platform', 'web');
			const worker = globalXHR(url, {
				responseType: 'json',
				headers: { 'Authorization': 'Bearer ' + environment.MEDIA_API.token },
			}).then(function({response}) {
				// amQueriesCache.set(cacheKey, response);
				// if (!domStorageLimitReached) {
				// 	const serialized = JSON.stringify(Array.from(amQueriesCache));
				// 	try {
				// 		sessionStorage.setItem('amQueriesCache', serialized);
				// 		amLastCachedSuccess = serialized;
				// 	} catch(e) {
				// 		console.warn(e, `(${serialized.length})`);
				// 		if (/\b(?:NS_ERROR_DOM_QUOTA_REACHED)\b/.test(e)
				// 				|| e instanceof DOMException && e.name == 'QuotaExceededError') {
				// 			domStorageLimitReached = true;
				// 			sessionStorage.setItem('amQueriesCache', amLastCachedSuccess);
				// 			amLastCachedSuccess = undefined;
				// 		}
				// 	}
				// 	sessionStorage.setItem('aamCachedArtistId', artist.id);
				// }
				return response;
			});
			amRequestsCache.set(cacheKey, worker);
			return worker;
		}) : Promise.reject('Endpoint is missing');

		function amGetArtist(artistId) {
			if (!(artistId > 0)) return Promise.reject('Invalid artist id');
			if (amArtistCache.has(artistId)) return amArtistCache.get(artistId);
			const worker = amQueryAPI('artists/' + artistId, { extend: 'artistBio,bornOrFormed,isGroup,origin' })
				.then(response => response.data[0]);
			amArtistCache.set(artistId, worker);
			return worker;
		}
		function amGetArtistAlbums(artistId) {
			if (!(artistId > 0)) return Promise.reject('Invalid artist id');
			if (amArtistReleasesCache.has(artistId)) return amArtistReleasesCache.get(artistId);
			const worker = (function getPage(offset = 0) {
				return amQueryAPI(`artists/${artistId}/albums`, { offset: offset, limit: 100 }).then(response =>
					!response.next ? response.data : getPage(offset + response.data.length).then(data => response.data.concat(data)));
			})();
			amArtistReleasesCache.set(artistId, worker);
			return worker;
		}
		const amGetArtistMatches = (artistId, torrentGroups) => artistId > 0 && Array.isArray(torrentGroups) ?
				torrentGroups.length > 0 ? amGetArtistAlbums(artistId).then(albums => torrentGroups.filter(function(torrentGroup) {
			const titleNorm = [titleCmpNorm(torrentGroup.groupName), stripRlsSuffix(torrentGroup.groupName).toLowerCase()];
			return albums.some(function(album) {
				const releaseDate = new Date(album.attributes.releaseDate);
				console.assert(!isNaN(releaseDate), 'Invalid release date in Apple Music data: ' + album.releaseDate);
				const yearMatch = releaseDate.getFullYear() == torrentGroup.groupYear;
				if (!yearMatch && !(releaseDate.getFullYear() >= torrentGroup.groupYear)) return false;
				return titleCmpNorm(album.attributes.name) == titleNorm[0] || yearMatch
					&& jaroWrinkerSimilarity(stripRlsSuffix(album.attributes.name).toLowerCase(), titleNorm[1]) >= sameTitleConfidence;
			});
		})).then(torrentGroups => torrentGroups.map(torrentGroup => torrentGroup.groupId)) : Promise.resolve([ ]) : Promise.reject('Invalid argument');
		const amSearchArtist = (searchTerm = artist.name, anvs) => searchTerm ? amQueryAPI('search', {
			term: '"' + searchTerm + '"',
			types: 'artists',
		}).then(function(response) {
			if (!response.results || !response.results.artists || !response.results.artists.data
					|| response.results.artists.data.length <= 0) return Promise.reject('No matches');
			const results = response.results.artists.data.filter(function(artist) {
				if (artist.type != 'artists' || !artist.attributes) return false;
				const artistName = artist.attributes.name.toLowerCase();
				function anvMatch(anv) {
					if (!anv) return false; else if ((anv = anv.toLowerCase()) == artistName) return true;
					const score = jaroWrinkerSimilarity(anv, artistName);
					if (score < sameArtistConfidence) return false;
					//console.log('[AAM] Jaro-Wrinker fuzzy match:', artistName, anv, `(${score.toFixed(3)})`)
					return true;
				}
				return anvMatch(searchTerm) || Array.isArray(anvs) && anvs.some(anvMatch);
			});
			if (results.length <= 0) return Promise.reject('No matches');
			console.log('[AAM] Apple Music search results for "' + searchTerm + '":', results);
			return results;
		}).catch(function(reason) {
			if (reason == 'No matches') {
				const m = /^(.+?)\s*\((.+)\)$/.exec(searchTerm);
				if (m != null) return amSearchArtist(m[1], anvs).catch(reason =>
					reason == 'No matches' && isNaN(parseInt(m[2])) ? amSearchArtist(m[2], anvs) : Promise.reject(reason));
			}
			return Promise.reject(reason);
		}) : Promise.reject('Invalid argiment');

		if (artistEdit) {
			String.prototype.toASCII = function() {
				return this.normalize("NFKD").replace(/[\x00-\x1F\u0080-\uFFFF]/g, '');
			};
			String.prototype.properTitleCase = function() {
				return [this.toUpperCase(), this.toLowerCase()].some(str => this == str) ? this
					: caseFixes.reduce((result, replacer) => result.replace(...replacer), this);
			};

			const caseFixes = [
				[
					new RegExp(`(\\w+|[\\,\\)\\]\\}\\"\\'\\‘\\’\\“\\‟\\”]) +(${[
						'And In', /*'And His', 'And Her', */'And', 'By A', 'By An', 'By The', 'By', 'Feat.', 'Ft.', 'For A',
						'For An', 'For', 'From', 'If', 'In To', 'In', 'Into', 'Nor', 'Not', 'Of An', 'Of The', 'Of',
						'Off', 'On', 'Onto', 'Or', 'Out Of', 'Out', 'Over', 'With', 'Without', 'Yet',
						'Y Su', 'Y Sua', 'Y Suo', 'De', 'Y', 'E La Sua', 'E Sua', 'E Il Suo', 'La Sua', 'E Le Sue', 'Le Sue', 'E Sue',
						'Et Son', 'Et Ses', 'Et Le', 'Et Sa', 'Et Sua', 'E Seu', 'Di',
						'Und Sein', 'Und Seine', 'Und', 'Mit Seinem', 'Mit Seiner', 'Mit',
						'En Zijn', 'Og',
					].join('|')})(?=\\s+)`, 'g'), (match, preWord, shortWord) => preWord + ' ' + shortWord.toLowerCase(),
				], [
					new RegExp(`\\b(${['by', 'in', 'of', 'on', 'or', 'to', 'for', 'out', 'into', 'from', 'with'].join('|')})$`, 'g'),
					(match, shortWord) => ' ' + shortWord[0].toUpperCase() + shortWord.slice(1).toLowerCase(),
				],
				[/([\-\:\&\;]) +(the|an?)(?=\s+)/g, (match, sym, article) => sym + ' ' + article[0].toUpperCase() + article.slice(1).toLowerCase()],
			];

			function rmDelLink(li) {
				for (let a of li.getElementsByTagName('A')) if (a.textContent.trim() == 'X') li.removeChild(a);
			}

			let aliasesRoot = document.body.querySelector('form.add_form');
			if (aliasesRoot != null) (aliasesRoot = aliasesRoot.parentNode.parentNode).id = 'aliases';
				else throw 'Add alias form could not be located';
			console.assert(aliasesRoot.nodeName == 'DIV' && aliasesRoot.className == 'box pad',
				"aliasesRoot.nodeName == 'DIV' && aliasesRoot.className == 'box pad'");
			const aliases = aliasesRoot.querySelectorAll('div.box > div > ul > li'),
						dropDown = aliasesRoot.querySelector('select[name="redirect"]');
			const epitaph = `

Don't navigate away, close or reload current page, till it reloads self.

The operation can take longer to complete and can be reverted only
by hand, sure to proceed?`;
			let mainIdentityId, inProgress = false;

			// Combined search with cross identifications... time expensive!
			const useMusicBrainz = GM_getValue('use_musicbrainz', true);
			const useAppleMusic = GM_getValue('use_applemusic', true);
			const useBeatPort = GM_getValue('use_beatport', true);

			function addSearchResultsTotals(results) {
				if (!Array.isArray(results)) throw 'Invalid argument';
				if (results.length <= 0) return results;
				const hasMG = result => result && 'matchedGroups' in result && Array.isArray(result.matchedGroups);
				const maxMatches = Math.max(...results.map(result => hasMG(result) ? result.matchedGroups.length : -1));
				const maxIndex = maxMatches > 0 ? results.findIndex(result =>
					hasMG(result) && result.matchedGroups.length == maxMatches) : 0;
				return Object.assign(results, { bestMatch: results[maxIndex] });
			}
			function stripDcRelativesFromResults(results, pivot) {
				if (!results) throw 'Invalid argument';
				return pivot && results.length > 1 ? addSearchResultsTotals(results.filter(function(result) {
					if (!result.discogsArtist) return true;
					return !['aliases', 'groups', 'members'].some(propName => Array.isArray(pivot[propName])
						&& pivot[propName].some(linkedArtist => linkedArtist.id == result.discogsArtist.id));
				})) : results;
			}
			function searchArtist(searchTerm = artist.name, consolidateDcRelatives = false, torrentGroups, anvs) {
				if (!searchTerm) return Promise.reject('Invalid argiment');
				const dcLookup = discogsSearchArtist(searchTerm, anvs).then(results => results.map(function(result) {
					result = Object.assign({ }, result, { uri: 'https://www.discogs.com' + result.uri });
					for (let key of ['user_data']) if (key in result) delete result[key];
					return { discogsArtist: result };
				}));
				const mbLookup = useMusicBrainz ? mbSearchArtist(searchTerm, anvs).then(results => Promise.all(results.map(result =>
						mbGetArtist(result.id).catch(reason => result).then(function(artist, ndx) {
					const result = { mbArtist: Object.defineProperty(Object.assign({ }, artist), 'uri', {
						get: function() { return 'https://musicbrainz.org/artist/' + this.id },
					}) };
					const discogsIds = artist.relations.map(function(relation) {
						if (relation['target-type'] != 'url') return false;
						if (!relation.type || relation.type.toLowerCase() != 'discogs') return false;
						const discogsId = /\/artist\/(\d+)\b/i.exec(relation.url.resource);
						return discogsId != null && parseInt(discogsId[1]);
					}).filter(Boolean);
					if (discogsIds.length > 1) console.warn('[AAM] MusicBrainz artist profile bound to more Discogs profiles, keeping the first:',
						artist, discogsIds.map(discogsId => 'https://www.discogs.com/artist/' + discogsId));
					if (discogsIds.length > 0) result.discogsArtist = {
						id: discogsIds[0],
						title: artist.name,
						uri: 'https://www.discogs.com/artist/' + discogsIds[0],
					};
					return result;
				})))) : Promise.reject('Not used');
				const amLookup = useAppleMusic ? amSearchArtist(searchTerm, anvs).then(results => Promise.all(results.map(result =>
						amGetArtist(parseInt(result.id)).catch(reason => result).then(artist => ({
					amArtist: Object.defineProperty(Object.assign({ }, artist), 'uri', {
						get: function() { return this.attributes.url.replace(/\?.*$/, '') },
					}),
				}))))) : Promise.reject('Not used');
				const bpLookup = useBeatPort ? bpSearchArtist(searchTerm, anvs).then(results => Promise.all(results.map(result =>
						bpGetArtist(result.id).catch(reason => result).then(artist => ({
					bpArtist: Object.defineProperty(Object.assign({ }, artist), 'uri', {
						get: function() { return 'https://www.beatport.com/artist/' + this.slug + '/' + this.id/* + '/releases'*/ },
					}),
				}))))) : Promise.reject('Not used');
				return Promise.all([dcLookup.catch(function(reason) {
					console.log('[AAM] Discogs:', reason);
					return Promise.resolve(null);
				}), mbLookup.catch(function(reason) {
					console.log('[AAM] MusicBrainz:', reason);
					return Promise.resolve(null);
				}), amLookup.catch(function(reason) {
					console.log('[AAM] Apple Music:', reason);
					return Promise.resolve(null);
				}), bpLookup.catch(function(reason) {
					console.log('[AAM] BeatPort:', reason);
					return Promise.resolve(null);
				})]).then(function(results) {
					function mergeResults(target, source) {
						for (let key in source) if (!(key in target)) target[key] = source[key];
					}

					const combinedResults = results[0] || [ ];
					// MusicBrainz
					if (results[1]) for (let mbResult of results[1]) {
						const linkedResult = mbResult.discogsArtist && combinedResults.find(result =>
							result.discogsArtist && result.discogsArtist.id == mbResult.discogsArtist.id);
						if (linkedResult) mergeResults(linkedResult, mbResult); else combinedResults.push(mbResult);
					}
					for (let index of [2, 3]) Array.prototype.push.apply(combinedResults, results[index]);
					return combinedResults.length > 0 ? combinedResults : Promise.reject('No matches');
				}).then(function(results) {
					function consolidateResults(preserveIndex, ditchIndex, mergeGroups = true) {
						if (!(preserveIndex >= 0 && preserveIndex < results.length)
								|| !(ditchIndex >= 0 && ditchIndex < results.length)) throw 'Index out of range';
						if (!Array.isArray(results[preserveIndex].matchedGroups) || Array.isArray(results[ditchIndex].matchedGroups)
								&& results[ditchIndex].matchedGroups.length > results[preserveIndex].matchedGroups.length)
							[preserveIndex, ditchIndex] = [ditchIndex, preserveIndex]
						console.log('[AAM] Consolidating search results:', results[ditchIndex], `[${ditchIndex}]`, '=>',
							results[preserveIndex], `[${preserveIndex}]`);
						if (mergeGroups && [preserveIndex, ditchIndex].every(index => Array.isArray(results[index].matchedGroups)))
							Array.prototype.push.apply(results[preserveIndex].matchedGroups,
								results[ditchIndex].matchedGroups.filter(groupId => !results[preserveIndex].matchedGroups.includes(groupId)));
						for (let key in results[ditchIndex]) if (!(key in results[preserveIndex]))
							results[preserveIndex][key] = results[ditchIndex][key];
						results.splice(ditchIndex, 1);
					}
					function consolidateResults3(scoreFunc) {
						if (typeof scoreFunc != 'function') throw 'The parameter must be a valid callback';
						do {
							const scores = results.map((result1, ndx1) => results.map(function(result2, ndx2) {
								if (ndx2 == ndx1) return -Infinity;
								if (Object.keys(result2).some(siteKey => siteKey != 'matchedGroups'
										&& Object.keys(result1).includes(siteKey))) return -1;
								return scoreFunc(ndx1, ndx2) || 0;
							}));
							const scores2 = scores.map(scores => Math.max(...scores)), hiScore = Math.max(...scores2);
							if (!(hiScore > 0)) break;
							const ndx1 = scores2.indexOf(hiScore), ndx2 = scores[ndx1].indexOf(hiScore);
							console.assert(ndx1 >= 0, 'ndx1 >= 0'); console.assert(ndx2 >= 0, 'ndx2 >= 0');
							console.assert(ndx2 != ndx1, 'ndx2 != ndx1');
							console.log(`[AAM] Matching results by highest score (${hiScore}):`,
								results[ndx1], `[${ndx1}]`, results[ndx2], `[${ndx2}]`);
							consolidateResults(ndx1, ndx2);
						} while (true);
					}

					return (!isGenericArtist(searchTerm) && Array.isArray(torrentGroups) && torrentGroups.length > 0 ? Promise.all([
						// Discogs
						Promise.all(results.map(result => result.discogsArtist ?
							getDiscogsMatches(result.discogsArtist.id, torrentGroups).catch(reason => null) : null)),
						// MusicBrainz
						Promise.all(results.map(result => result.mbArtist ?
							mbGetArtistMatches(result.mbArtist.id, torrentGroups).catch(reason => null) : null)),
						// Apple Music
						Promise.all(results.map(result => result.amArtist ?
							amGetArtistMatches(parseInt(result.amArtist.id), torrentGroups).catch(reason => null) : null)),
						// BeatPort
						Promise.all(results.map(result => result.bpArtist ?
							bpGetArtistMatches(result.bpArtist.id, torrentGroups).catch(reason => null) : null)),
					]).then(function(matchedGroups) {
						if ((results = results.map((result, ndx) => Object.assign(result, { matchedGroups: (function() {
							const result = new Set;
							for (let mg of matchedGroups) if (Array.isArray(mg[ndx])) for (let groupId of mg[ndx]) result.add(groupId);
							return Array.from(result);
						})() }))).length < 2) return results;
						consolidateResults3(function(ndx1, ndx2) {
							if ([ndx1, ndx2].some(ndx => !Array.isArray(results[ndx].matchedGroups))) return 0;
							const commonGroups = results[ndx2].matchedGroups.filter(groupId =>
								results[ndx1].matchedGroups.includes(groupId));
							if (commonGroups.length > 0) console.log('[AAM] Matching results by matching same torrent groups:',
								results[ndx1], `[${ndx1}]`, results[ndx2], `[${ndx2}]`, commonGroups);
							return commonGroups.length;
						});
						return results.length > 1 ? Promise.all(results.map(result => [
							/* 0 */ result.discogsArtist ? getDiscogsArtistReleases(result.discogsArtist.id) : Promise.resolve(null),
							/* 1 */ result.mbArtist ? mbGetArtistReleases(result.mbArtist.id) : Promise.resolve(null),
							/* 2 */ result.amArtist ? amGetArtistAlbums(result.amArtist.id) : Promise.resolve(null),
							/* 3 */ result.bpArtist ? bpGetArtistReleases(result.bpArtist.id) : Promise.resolve(null),
						].map(promise => promise.catch(reason => null))).map(Promise.all.bind(Promise))).then(function(releaseLists) {
							let dcMasterRequests = new Set;

							function getNormalizedResult(release, index) {
								switch (index) {
									case 0: return [ // Discogs
										release.title ? titleCmpNorm(release.title) : null,
										release.title ? stripRlsSuffix(release.title).toLowerCase() : null,
										release.type == 'master' && dcMasterYears.get(release.id) || release.year,
										release.type != 'master' || dcMasterYears.has(release.id) ? 0 : 2,
									];
									case 1: return [ // MusicBrainz
										release.title ? titleCmpNorm(release.title) : null,
										release.title ? stripRlsSuffix(release.title).toLowerCase() : null,
										new Date(release['first-release-date']).getFullYear(),
										0,
									];
									case 2: return [ // Apple Music
										release.attributes.name ? titleCmpNorm(release.attributes.name) : null,
										release.attributes.name ? stripRlsSuffix(release.attributes.name).toLowerCase() : null,
										new Date(release.attributes.releaseDate).getFullYear(),
										1,
									];
									case 3: { // BeatPort
										const newReleaseDate = new Date(release.new_release_date),
													publishDate = new Date(release.publish_date);
										return [
											release.name ? titleCmpNorm(release.name) : null,
											release.name ? stripRlsSuffix(release.name).toLowerCase() : null,
											newReleaseDate.getFullYear(),
											1,
										];
									}
									default: throw 'Assertion failed: index out of range';
								}
							}

							function matchesByReleaseList(ndx1, ndx2) {
								if (releaseLists[ndx1].some((releaseList1, ndx) => releaseList1 && releaseLists[ndx2][ndx]))
									return -1;
								return Math.max(...releaseLists[ndx1].map(function(releaseList1, ndx12) {
									if (releaseLists[ndx2][ndx12]) return -1; // avoid consolidation between distinct artists of the same source?
									return releaseList1 ? releaseLists[ndx2].map(function(releaseList2, ndx22) {
										return releaseList2 ? releaseList1.map(function(release1) {
											const matchedCount = releaseList2.filter(function(release2) {
												const normResults = [
													getNormalizedResult(release1, ndx12),
													getNormalizedResult(release2, ndx22),
												];
												if (normResults[0][3] == 0 && !(normResults[0][2] <= normResults[1][2])
														|| normResults[1][3] == 0 && !(normResults[1][2] <= normResults[0][2])) return false;
												const exactMatch = normResults[0][0] && normResults[1][0] && normResults[0][0] == normResults[1][0];
												if (!exactMatch && jaroWrinkerSimilarity(normResults[0][1], normResults[1][1]) < sameTitleConfidence)
													return false;
												if (normResults[0][3] == 2 && (normResults[1][3] == 2 || normResults[0][2] >= normResults[1][2]))
													dcMasterRequests.add(release1.id);
												if (normResults[1][3] == 2 && (normResults[0][3] == 2 || normResults[1][2] >= normResults[0][2]))
													dcMasterRequests.add(release2.id);
												if ((normResults[0][3] != 0 || normResults[1][3] != 0 || normResults[0][2] != normResults[1][2])
														&& (normResults[0][3] != 1 || normResults[1][3] == 1 || !(normResults[0][2] >= normResults[1][2]))
														&& (normResults[1][3] != 1 || normResults[0][3] == 1 || !(normResults[1][2] >= normResults[0][2])))
													return false;
												if (!exactMatch && (normResults[0][3] > 0 && normResults[0][1] != stripRlsSuffix(searchTerm).toLowerCase()
														|| normResults[1][3] > 0 && normResults[1][1] != stripRlsSuffix(searchTerm).toLowerCase())) return false;
												console.log('[AAM] Matching releases:', release1, release2)
												return true;
											}).length;
											const listSize = Math.min(releaseList1.length, releaseList2.length),
														matchRatio = matchedCount / listSize;
											if (matchedCount > 0) console.log('[AAM] Matching results by having common releases:',
												results[ndx1], `[${ndx1}:${ndx12}]`, results[ndx2], `[${ndx2}:${ndx22}]`, `(${matchedCount}/${listSize})`);
											return matchedCount;
										}) : 0;
									}) : 0;
								}));
							}
							consolidateResults3(matchesByReleaseList);
							if (results.length < 2 || dcMasterRequests.size <= 0) return results;
							console.log(dcMasterRequests.size, 'master release(s) to lookup on Discogs');
							dcMasterRequests = Array.from(dcMasterRequests).map(masterId => getDiscogsEntry('master', masterId)
								.then(master => { dcMasterYears.set(masterId, master.year) }), console.warn.bind(console));
							return Promise.all(dcMasterRequests).then(() => (consolidateResults3(matchesByReleaseList), results));
						}) : results;
					}) : Promise.resolve(results)).then(function(results) {
						if (results.length < 2 || !consolidateDcRelatives) return results;
						const dcArtistIds = results.map(result => result.discogsArtist && result.discogsArtist.id).filter(Boolean);
						return dcArtistIds.length > 1 ? Promise.all(dcArtistIds.map(dcArtistid =>
								getDiscogsEntry('artist', dcArtistid).then(discogsArtist =>
									({ [dcArtistid]: discogsArtist }), reason => null))).then(results =>
										(results = results.filter(Boolean)).length > 0 ? Object.assign.apply({ }, results) : null).then(function(discogsArtists) {
							if (discogsArtists) (function iterate(offset = 0) { for (let index = offset; index < results.length; ++index) {
								if (!results[index].discogsArtist) continue;
								let relatives = discogsArtists[results[index].discogsArtist.id];
								if (!relatives) continue; // assertion failed
								relatives = Array.prototype.concat.apply([ ], ['aliases', 'groups'/*, 'members'*/].map(propName =>
									propName in relatives ? relatives[propName].map(alias => alias.id) : null).filter(Boolean));
								if (relatives.length <= 0) continue;
								const relativeNdx = results.findIndex((result, relativeNdx) =>
									relativeNdx != index && result.discogsArtist && relatives.includes(result.discogsArtist.id));
								if (relativeNdx < 0) continue;
								console.log('[AAM] Consolidating relative results:', results[index], results[relativeNdx]);
								consolidateResults(index, relativeNdx);
								return iterate(Math.min(index, relativeNdx));
							} })();
							return results;
						}) : results;
					}).then(function(results) {
						const cacheSizes = ['discogsQueriesCache', 'mbQueriesCache', 'bpQueriesCache', 'amQueriesCache']
							.map(key => key in sessionStorage ? sessionStorage[key].length : 0);
						console.log(`[AAM] Combined search for '${searchTerm}' completed, final cache sizes:`,
							cacheSizes.map(size => `${(size / 2**20).toFixed(2)}MiB`).join(' / '),
							`(${(cacheSizes.reduce((acc, size) => acc + size, 0) / 2**20).toFixed(2)}MiB), DOM quota exceeded? ${domStorageLimitReached}`);
						return results;
					});
				}).then(addSearchResultsTotals);
			}
			function getMatchedArtists(results) {
				results = results.filter(result => Array.isArray(result.matchedGroups) && result.matchedGroups.length > 0);
				if (results.length > 1) {
					function matchName(result, name) {
						name = name.toLowerCase();
						if (result.discogsArtist && dcNameNormalizer(result.discogsArtist.title).toLowerCase() == name) return true;
						if (result.mbArtist && result.mbArtist.name.toLowerCase() == name) return true;
						if (result.amArtist && result.amArtist.attributes.name.toLowerCase() == name) return true;
						if (result.bpArtist && result.bpArtist.name.toLowerCase() == name) return true;
						return false;
					}

					const nameMatches = name => results.some(result => matchName(result, name));
					const stripNameFromResults = name => { results = results.filter(result => !matchName(result, name)) };
					for (let alias of aliases) {
						if (!(alias = getAlias(alias)) || alias.name == artist.name) continue;
						//stripNameFromResults(alias.name);
					}
				}
				return addSearchResultsTotals(results);
			}

			decodeArtistTitles(artist);
			for (let torrentGroup of artist.torrentgroup) {
				if (torrentGroup.extendedArtists && Array.isArray(torrentGroup.extendedArtists[1])
						&& torrentGroup.extendedArtists[1].length > 0 || artistlessGroups.has(torrentGroup.groupId)) continue;
				let container = document.getElementById('artistless-groups');
				if (container == null) {
					const ref = aliasesRoot.querySelector(':scope > br:first-of-type'), hdr = document.createElement('H4');
					hdr.innerHTML = 'List of artistless groups<br><span style="font-size: 8pt; font-weight: normal;">(Bold printed groups are missing artist info entirely and are potential source for complex operations to fail; review and fix these groups first to avoid later problems)</span>';
					hdr.style = 'color: red; font-weight: bold;';
					aliasesRoot.insertBefore(hdr, ref);
					container = document.createElement('DIV');
					container.id = 'artistless-groups';
					container.style = 'padding: 1em;';
					aliasesRoot.insertBefore(container, ref);
				}
				if (container.childElementCount > 0) container.append(', ');
				const a = document.createElement('A');
				a.href = '/torrents.php?id=' + torrentGroup.groupId;
				a.target = '_blank';
				a.textContent = torrentGroup.groupName || torrentGroup.groupId.toString();
				a.style.fontWeight = !torrentGroup.extendedArtists ? 'bold' : 'normal';
				container.append(a);
				artistlessGroups.add(torrentGroup.groupId);
			}

			class TorrentGroupsManager {
				constructor(aliasId) {
					if (!(aliasId > 0)) throw 'Invalid argument';
					this.groups = { };
					for (let torrentGroup of artist.torrentgroup) {
						console.assert(!(torrentGroup.groupId in this.groups), '!(torrentGroup.groupId in this.groups)');
						if (!torrentGroup.extendedArtists) continue;
						const importances = Object.keys(torrentGroup.extendedArtists)
							.filter(importance => Array.isArray(torrentGroup.extendedArtists[importance])
								&& torrentGroup.extendedArtists[importance].some(artist => artist.aliasid == aliasId))
							.map(key => parseInt(key)).filter((el, ndx, arr) => arr.indexOf(el) == ndx);
						if (importances.length > 0) this.groups[torrentGroup.groupId] = importances;
					}
				}

				get size() {
					return this.groups ? Object.keys(this.groups).filter(groupId =>
						Array.isArray(this.groups[groupId]) && this.groups[groupId].length > 0).length : 0;
				}
				get aliasUsed() { return this.size > 0 }

				removeAliasFromGroups() {
					if (!this.aliasUsed) return Promise.resolve('No groups block this alias');
					const groupIds = Object.keys(this.groups), removeAliasFromGroup = [
						groupId => deleteArtistFromGroup(groupId, artist.id, this.groups[groupId]),
						function(index = 0) {
							if (!(index >= 0 && index < groupIds.length))
								return Promise.resolve('Artist alias removed from all groups');
							const importances = this.groups[groupIds[index]];
							console.assert(Array.isArray(importances) && importances.length > 0,
								'Array.isArray(importances) && importances.length > 0');
							return Array.isArray(importances) && importances.length > 0 ?
								deleteArtistFromGroup(groupIds[index], artist.id, importances)
									.then(results => removeAliasFromGroup[1].call(this, index + 1))
								: removeAliasFromGroup[1].call(this, index + 1);
						},
					];
					return (groupIds.length > 100 ? removeAliasFromGroup[1].call(this) : groupIds.length > 1 ?
							Promise.all(groupIds.slice(0, -1).map(removeAliasFromGroup[0])).then(() =>
								wait(groupIds[groupIds.length - 1]).then(removeAliasFromGroup[0])).catch(function(reason) {
						console.warn('TorrentGroupsManager.removeAliasFromGroups parallely failed, trying serially:', reason);
						return removeAliasFromGroup[1].call(this);
					}) : removeAliasFromGroup[0](groupIds[groupIds.length - 1])).then(wait);
				}
				addAliasToGroups(aliasName = artist.name) {
					if (!this.aliasUsed) return Promise.resolve('No groups block this alias');
					if (!aliasName) return Promise.reject('Argument is invalid');
					const groupIds = Object.keys(this.groups), _addAliasToGroup = [
						groupId => addAliasToGroup(groupId, aliasName, this.groups[groupId]),
						function(index = 0) {
							if (!(index >= 0 && index < groupIds.length)) return Promise.resolve('Artist alias re-added to all groups');
							const importances = this.groups[groupIds[index]];
							console.assert(Array.isArray(importances) && importances.length > 0,
								'Array.isArray(importances) && importances.length > 0');
							return Array.isArray(importances) && importances.length > 0 ?
								addAliasToGroup(groupIds[index], aliasName, importances)
									.then(result => _addAliasToGroup[1].call(this, index + 1))
								: _addAliasToGroup[1].call(this, index + 1);
						}
					];
					return groupIds.length > 100 ? _addAliasToGroup[1].call(this).then(wait) : groupIds.length > 1 ?
							_addAliasToGroup[0](groupIds[0]).then(wait).then(() =>
								Promise.all(groupIds.slice(1).map(_addAliasToGroup[0]))).catch(function(reason) {
						console.warn('TorrentGroupsManager.addAliasToGroups parallely failed, trying serially:', reason);
						return _addAliasToGroup[1].call(this).then(wait);
					}) : _addAliasToGroup[0](groupIds[0]).then(wait);
				}
			}

			class AliasDependantsManager {
				constructor(aliasId) {
					console.assert(aliasId > 0, 'aliasId > 0');
					if (aliasId > 0) this.redirectTo = aliasId; else throw 'Invalid argument';
					if ((this.aliases = Array.from(aliases).map(function(li) {
						const alias = getAlias(li);
						if (alias && alias.redirectId == aliasId) return alias;
					}).filter(Boolean)).length <= 0) delete this.aliases;
				}

				get size() { return Array.isArray(this.aliases) ? this.aliases.length : 0 }
				get hasDependants() { return this.size > 0 }

				removeAll() {
					return this.hasDependants ? Promise.all(this.aliases.map(function(alias) {
						let worker = Promise.resolve();
						if (alias.tgm.aliasUsed) worker = alias.tgm.removeAliasFromGroups();
						return worker.then(() => deleteAlias(alias.id));
					})) : Promise.resolve('No dependants');
				}
				restoreAll(redirectTo = this.redirectTo, artistIdOrName) {
					return this.hasDependants ? resolveArtistId(artistIdOrName)
							.then(artistId => resolveAliasId(redirectTo, (artistIdOrName || !(redirectTo >= 0)) && artistId, true)
								.then(redirectTo => Promise.all(this.aliases.map(alias => {
						let worker = addAlias(alias.name, redirectTo, artistIdOrName ? artistId : undefined).then(wait);
						if (alias.tgm.aliasUsed) worker = worker.then(() => findArtistAlias(redirectTo, artistId))
							.then(newAlias => alias.tgm.addAliasToGroups(newAlias.name));
						return worker;
					})))) : Promise.resolve('No dependants');
				}
			}

			class ArtistGroupKeeper {
				constructor() {
					for (let torrentGroup of artist.torrentgroup) if (torrentGroup.extendedArtists) for (let importance in torrentGroup.extendedArtists) {
						const artists = torrentGroup.extendedArtists[importance];
						if (Array.isArray(artists) && artists.length > 0) continue;
						this.artistId = artist.id;
						this.aliasName = `__${artist.id.toString()}__${Date.now().toString(16)}`;
						this.groupId = torrentGroup.groupId;
						this.importance = parseInt(importance);
						this.locked = false;
						return this;
					}
					throw 'Unable to find a spare group';
				}

				hold() {
					if (this.locked) return Promise.reject('Not available');
					if (!this.groupId) throw 'Unable to find a spare group';
					this.locked = true;
					return addAlias(this.aliasName).then(wait)
						.then(() => addAliasToGroup(this.groupId, this.aliasName, [this.importance]));
				}
				release(artistIdOrName = this.artistId) {
					if (!this.locked) return Promise.reject('Not available');
					return resolveArtistId(artistIdOrName).then(artistId =>
						deleteArtistFromGroup(this.groupId, artistId, [this.importance]).then(wait)
							.then(() => deleteAlias(this.aliasName, artistIdOrName && artistId))).then(() => { this.locked = false });
				}
			}

			function getSelectedRedirect(defaultsToMain = false) {
				let redirect = aliasesRoot.querySelector('select[name="redirect"]');
				if (redirect == null) throw 'Assertion failed: can not locate redirect selector';
				redirect = {
					id: parseInt(redirect.options[redirect.selectedIndex].value),
					name: redirect.options[redirect.selectedIndex].label,
				};
				console.assert(redirect.id >= 0 && redirect.name, 'redirect.id >= 0 && redirect.name');
				if (defaultsToMain && redirect.id == 0) {
					redirect.id = mainIdentityId;
					redirect.name = artist.name;
				}
				return Object.freeze(redirect);
			}
			function failHandler(reason) {
				if (activeElement instanceof HTMLElement && activeElement.parentNode != null) {
					activeElement.style.color = null;
					if (activeElement.dataset.caption) activeElement.value = activeElement.dataset.caption;
					activeElement.disabled = false;
					activeElement = null;
					inProgress = false;
				}
				alert(reason);
			}

			// Damage control
			function setRecoveryInfo(action, aliases, param) {
				console.assert(aliases && (typeof aliases == 'object' || Array.isArray(aliases)) && action,
					"aliases && (typeof aliases == 'object' || Array.isArray(aliases)) && action");
				const damageControl = {
					artist: artist,
					action: action,
					aliases: aliases,
				};
				if (param) damageControl.param = param;
				GM_setValue('damage_control', damageControl);
			}
			function recoverFromFailure() {
				const recoveryInfo = GM_getValue('damage_control');
				if (!recoveryInfo) return Promise.reject('No unfinished operation present');
				if (recoveryInfo.artist.id != artist.id)
					return Promise.reject('Unfinished operation for this artist not present');
				//artist = recoveryInfo.artist; // ?
				return eval(recoveryInfo.action)(recoveryInfo.artist, aliases, recoveryInfo.param).then(clearRecoveryInfo);
			}

			function isBadRDA(alias) {
				if (!alias) throw 'Invalid argument';
				if (!alias.redirectId) return false; //throw 'Not a redirecting alias';
				if (alias.tgm.aliasUsed) return true;
				const target = findAlias(alias.redirectId, true);
				return !target || target.id != alias.redirectId;
			}
			function dupesCleanup(alias) {
				if (!alias || !alias.id) throw 'Invalid argument';
				const target = findAlias(alias.redirectId) || alias, workers = [ ], dupes = [ ];
				aliases.forEach(function(li) {
					const dupe = getAlias(li);
					if (!dupe || dupe.id == alias.id || dupe.name.toLowerCase() != alias.name.toLowerCase()) return;
					let index;
					const ancestor = findAlias(dupe.redirectId);
					if (ancestor && 'dependants' in ancestor && ancestor.dependants.hasDependants) {
						while ((index = ancestor.dependants.aliases.indexOf(alias => alias.id == dupe.id)) >= 0)
							ancestor.dependants.aliases.splice(index, 1);
					}
					if ('dependants' in dupe && dupe.dependants.hasDependants) {
						while ((index = dupe.dependants.aliases.indexOf(dependant => dependant.name.toLowerCase() == alias.name.toLowerCase())) >= 0)
							dupe.dependants.aliases.splice(index, 1);
						if (dupe.dependants.hasDependants) workers.push(dupe.dependants.removeAll());
					}
					workers.push(dupe.tgm.aliasUsed ? dupe.tgm.removeAliasFromGroups().then(() => deleteAlias(dupe.id))
						: deleteAlias(dupe.id));
					dupes.push(dupe);
				});
				return workers.length > 0 ? Promise.all(workers).then(() => Promise.all(dupes.map(function(dupe) {
					const workers = [ ];
					if (dupe.tgm.aliasUsed) workers.push(dupe.tgm.addAliasToGroups(target.name)
						.then(() => { Object.assign(target.tgm.groups, dupe.tgm.groups) }));
					if ('dependants' in dupe && dupe.dependants.hasDependants) workers.push(dupe.dependants.restoreAll(target.id)
							.then(wait).then(() => Promise.all(dupe.dependants.aliases.map(alias => resolveAliasId(alias.name, artist.id)
								.then(aliasId => (alias.id = aliasId, alias))))).then(function(aliases) {
						if (!('dependants' in target)) target.dependants = new AliasDependantsManager(target.id);
						if (!Array.isArray(target.dependants.aliases)) target.dependants.aliases = [ ];
						Array.prototype.push.apply(target.dependants.aliases, aliases);
					}));
					if (workers.length > 0) return Promise.all(workers);
				}))) : Promise.resolve('No duplicate aliases');
			}
			function prologue(alias, agk) {
				let worker = dupesCleanup(alias).then(function() {
					const workers = [ ];
					if (agk && alias.tgm.size >= artist.torrentgroup.length) workers.push(agk.hold());
					if ('dependants' in alias && alias.dependants.hasDependants) workers.push(alias.dependants.removeAll());
					if (workers.length > 0) return Promise.all(workers);
				});
				if (alias.tgm.aliasUsed) worker = worker.then(() => alias.tgm.removeAliasFromGroups());
				return worker;
			}
			function epilogue(alias, agk, id1, id2 = id1) {
				function finish() {
					const workers = [ ];
					if ('dependants' in alias && alias.dependants.hasDependants) workers.push(alias.dependants.restoreAll(id2));
					if (agk && alias.tgm.size >= artist.torrentgroup.length) workers.push(agk.release());
					return Promise.all(workers);
				}

				if (!alias || !id1) throw 'Invalid argument';
				return alias.tgm.aliasUsed ? alias.tgm.addAliasToGroups(id1).then(finish) : finish();
			}
			function redirectAliasTo(alias, redirectIdOrName) {
				if (!alias) throw 'Invalid argument';
				return resolveAliasId(redirectIdOrName, -1, true).then(function(redirectId) {
					if (redirectId == alias.id) return Promise.reject('Alias can\'t redirect to itself');
					if (alias.redirectId == redirectId) return Promise.resolve('Redirect doesnot change');
					const agk = new ArtistGroupKeeper, workers = [ ];
					if (alias.tgm.size >= artist.torrentgroup.length) workers.push(agk.hold());
					if ('dependants' in alias && alias.dependants.hasDependants) workers.push(alias.dependants.removeAll());
					let worker = Promise.all(workers);
					if (alias.tgm.aliasUsed) worker = worker.then(() => alias.tgm.removeAliasFromGroups());
					return worker.then(() => deleteAlias(alias.id)).then(wait)
						.then(() => addAlias(alias.name, redirectId)).then(wait)
						.then(() => epilogue(alias, agk, alias.name, redirectId));
				});
			}
			function renameAlias(alias, newName) {
				if (!alias) throw 'Invalid argument';
				const agk = new ArtistGroupKeeper;
				return prologue(alias, agk).then(() => deleteAlias(alias.id)).then(() => wait(newName).then(addAlias))
					.then(wait).then(() => epilogue(alias, agk, newName));
			}
			function resolveRDA(alias) {
				if (!alias || !alias.id || !alias.redirectId) return Promise.reject('Invalid argument');
				if (!isBadRDA(alias)) return Promise.resolve('Alias fully resolved');
				const target = findAlias(alias.redirectId, true);
				return alias.tgm.removeAliasFromGroups().then(() => alias.tgm.addAliasToGroups((target || artist).name)).then(function() {
					if (target && target.tgm) Object.assign(target.tgm.groups, alias.tgm.groups);
					alias.tgm.groups = { };
					//if (!target) return deleteAlias(alias.id);
					if (!target || alias.redirectId != target.id)
						return deleteAlias(alias.id).then(() => addAlias(alias.name, target ? target.id : mainIdentityId));
				});
			}

			const recoveryQuestion = `Last operation for current artist was not successfull,
if you continue, recovery information will be invalidated or lost.`;

			// NRA actions

			function makeItMain(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				console.assert(alias.id != mainIdentityId, 'alias.id != mainIdentityId');
				let nagText = `CAUTION

This action makes alias "${alias.name}" the main identity for artist ${artist.name},
while "${artist.name}" becomes it's subordinate N-R alias.`;
				if (alias.tgm.aliasUsed) nagText += '\n\nBlocked by ' + alias.tgm.size + ' groups';
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				const agk = new ArtistGroupKeeper;
				if (alias.tgm.aliasUsed) prologue(alias, agk).then(() => deleteAlias(alias.id)).then(wait)
					.then(() => alias.tgm.addAliasToGroups(alias.name)).then(() => findArtistId(alias.name))
					.then(function(newArtistId) {
						let worker = changeArtistId(newArtistId).then(wait);
						if (alias.tgm.size >= artist.torrentgroup.length) worker = worker.then(() => agk.release(newArtistId));
						if ('dependants' in alias && alias.dependants.hasDependants)
							worker = worker.then(() => alias.dependants.restoreAll(alias.name, newArtistId));
						return worker.then(function() {
							let body = document.getElementById('body');
							body = body != null && body.value.trim() || artist.body;
							let image = document.body.querySelector('input[type="text"][name="image"]');
							image = image != null && image.value.trim() || artist.image;
							const workers = [ ];
							if (body || image) workers.push(editArtist(image, body, 'Wiki transfer (AAM)', newArtistId));
							const similarArtists = artist.similarArtists ?
								artist.similarArtists.map(similarArtist => similarArtist.name) : [ ];
							if (similarArtists.length > 0) workers.push(addSimilarArtists(similarArtists, newArtistId)
								.then(() => { console.log(`${similarArtists.length} similar artists were transfered to new id`) }));
							if (workers.length > 0) return Promise.all(workers);
						}).then(() => { gotoArtistEditPage(newArtistId) });
					}).catch(failHandler);
				else {
					const mainIdentity = findAlias(mainIdentityId);
					console.assert(mainIdentity != null, 'mainIdentity != null');
					let worker = dupesCleanup(mainIdentity).then(function() {
						const workers = [mainIdentity, alias].filter(alias => 'dependants' in alias && alias.dependants.hasDependants)
							.map(alias => alias.dependants.removeAll());
						if (mainIdentity.tgm.size >= artist.torrentgroup.length) workers.push(agk.hold());
						if (workers.length > 0) return Promise.all(workers);
					});
					if (mainIdentity.tgm.aliasUsed) worker = worker.then(() => mainIdentity.tgm.removeAliasFromGroups());
					worker = worker.then(() => renameArtist(alias.name)).then(wait)
						.then(() => deleteAlias(artist.name)).then(wait).then(() => addAlias(artist.name)).then(wait);
					if (mainIdentity.tgm.aliasUsed) worker = worker.then(() => mainIdentity.tgm.addAliasToGroups(artist.name));
					if (mainIdentity.tgm.size >= artist.torrentgroup.length) worker = worker.then(() => agk.release());
					worker = worker.then(function() {
						const workers = [ ];
						if ('dependants' in alias && alias.dependants.hasDependants)
							workers.push(alias.dependants.restoreAll(alias.name));
						if ('dependants' in mainIdentity && mainIdentity.dependants.hasDependants)
							workers.push(mainIdentity.dependants.restoreAll(artist.name));
						if (workers.length > 0) return Promise.all(workers);
					}).then(() => { document.location.reload() }, failHandler);
				}
				return false;
			}

			function _redirectNRA(currentTarget, redirect) {
				const alias = getAlias(currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (redirect.id == alias.id) return;
				let nagText = `CAUTION

This action makes alias "${alias.name}" redirect to artist\'s variant "${redirect.name}",
and replaces the alias in all involved groups (if any) with this variant.`;
				if (alias.tgm.aliasUsed) nagText += '\n\nBlocked by ' + alias.tgm.size + ' groups';
				if (!confirm(nagText + epitaph)) return;
				inProgress = true;
				activeElement = currentTarget;
				currentTarget.textContent = 'processing ...';
				currentTarget.style.color = 'red';
				redirectAliasTo(alias, redirect.id).then(() => { document.location.reload() }, failHandler);
			}

			function changeToRedirect(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				_redirectNRA(evt.currentTarget, getSelectedRedirect(true));
				return false;
			}

			function renameNRA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				let nagText = `CAUTION

This action renames alias
"${alias.name}",
and replaces the alias in all involved groups (if any) with the new name.
New name can't be artist name or alias already taken on the site.`;
				if (alias.tgm.aliasUsed) nagText += '\n\nBlocked by ' + alias.tgm.size + ' groups';
				let newName = prompt(nagText + '\n\nThe operation can be reverted only by hand, to proceed enter and confirm new name\n\n', alias.name);
				if (newName) newName = newName.trim(); else return false;
				if (!newName || newName == alias.name) return false;
				const currentTarget = evt.currentTarget;
				(newName.toLowerCase() == alias.name.toLowerCase() ? Promise.reject('Case change')
				 		: findArtistAlias(newName, 0, true).then(function(alias) {
					_redirectNRA(currentTarget, alias);
					//alert(`Name is already taken by alias id ${alias.id}, the operation is aborted`);
				}, reason => getSiteArtist(newName).then(function(dupeTo) {
					siteArtistsCache[dupeTo.name] = dupeTo;
					alert(`Name is already taken by artist "${dupeTo.name}" (${dupeTo.id}), the operation is aborted`);
					GM_openInTab(document.location.origin + '/artist.php?id=' + dupeTo.id.toString(), false);
				}))).catch(function(reason) {
					inProgress = true;
					activeElement = currentTarget;
					currentTarget.textContent = 'processing ...';
					currentTarget.style.color = 'red';
					//setRecoveryInfo('renameAlias', alias, newName);
					renameAlias(alias, newName).then(() => { document.location.reload() }, failHandler);
				});
				return false;
			}

			function cutOffNRA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				console.assert('dependants' in alias, "'dependants' in alias");
				let nagText = 'CAUTION\n\nThis action ';
				nagText += alias.tgm.aliasUsed ? `cuts off identity "${alias.name}"
from artist ${artist.name} and leaves it in separate group.

Blocked by ${alias.tgm.size} groups`
					: `deletes identity "${alias.name}" and all it's dependants (${alias.dependants.size}).

(Not used in any release)`;
				if (artist.torrentgroup.length <= alias.tgm.size) nagText += `

This action also vanishes this artist group as no other name variants
are used in any release`;
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				const agk = new ArtistGroupKeeper;
				let worker = prologue(alias, agk).then(() => deleteAlias(alias.id)).then(wait);
				if (alias.tgm.aliasUsed) worker = worker.then(() => alias.tgm.addAliasToGroups(alias.name));
				worker = worker.then(alias.tgm.aliasUsed ? () => findArtistId(alias.name).then(function(newArtistId) {
					let worker = Promise.resolve();
					if ('dependants' in alias && alias.dependants.hasDependants)
						worker = worker.then(() => alias.dependants.restoreAll(alias.name, newArtistId));
					return worker.then(function() {
						if (artist.torrentgroup.length > alias.tgm.size) {
							GM_openInTab(document.location.origin + '/artist.php?id=' + newArtistId.toString(), true);
							document.location.reload();
						} else gotoArtistPage(newArtistId);
					});
				}) : () => { document.location.reload() }).catch(failHandler);
				return false;
			}

			function split(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				let newName, newNames = [ ];
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!alias.tgm.aliasUsed) return false;
				const prologue = () => {
					let result = `CAUTION

This action splits artist's identity "${alias.name}" into two or more names
and replaces the identity in all involved groups with new names. No linking of new names
to current artist will be performed, profile pages of names that aren't existing aliases already
will open in separate tabs for review.`;
					if (alias.tgm.aliasUsed) result += '\n\nBlocked by ' + alias.tgm.size + ' groups';
					if (newNames.length > 0) result += '\n\n' + newNames.map(n => '\t' + n).join('\n');
					return result;
				};
				do {
					if ((newName = prompt(prologue().replace(/^CAUTION\s*/, '') +
						`\n\nEnter carefully new artist name #${newNames.length + 1}, to finish submit empty input\n\n`,
						newNames.length < 2 ? alias.name : undefined)) == undefined) return false;
					if ((newName = newName.trim()) && !newNames.includes(newName)) newNames.push(newName);
				} while (newName);
				if (newNames.length < 2 || !confirm(prologue() + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				console.info(alias.name, 'present in these groups:', alias.tgm.groups);
				function openInTab(artistId) {
					if (artistId > 0) GM_openInTab(document.location.origin + '/artist.php?id=' + artistId.toString(), true);
				}
				//alias.dependants.removeAll();
				alias.tgm.removeAliasFromGroups().then(() => deleteAlias(alias.id))
					.then(() => Promise.all(newNames.map(TorrentGroupsManager.prototype.addAliasToGroups.bind(alias.tgm))))
					.then(() => findArtistId(newNames[0]).then(function(artistId) {
						if (artistId != artist.id && artist.torrentgroup.length > alias.tgm.size) openInTab(artistId);
						newNames.slice(1).forEach(newName => { findArtistId(newName).then(openInTab) });
						if (artistId == artist.id) document.location.reload(); else gotoArtistPage(artistId);
					})).catch(failHandler);
				return false;
			}

			function select(evt) {
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				console.assert(dropDown instanceof HTMLSelectElement, 'dropDown instanceof HTMLSelectElement');
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!alias.redirectId && dropDown != null) {
					dropDown.value = alias.id;
					if (typeof dropDown.onchange == 'function') dropDown.onchange();
				}
				return false;
			}

			// RDA actions

			function changeToNra(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!confirm(`This action makes artist's identity "${alias.name}" distinct`)) return false;
				console.assert(alias && typeof alias == 'object');
				inProgress = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				let worker = alias.tgm.aliasUsed ? alias.tgm.removeAliasFromGroups() : Promise.resolve();
				worker = worker.then(() => deleteAlias(alias.id)).then(() => wait(alias.name).then(addAlias));
				if (alias.tgm.aliasUsed) worker = worker.then(() => alias.tgm.addAliasToGroups(alias.name));
				worker = worker.then(() => { document.location.reload() }, failHandler);
				return false;
			}

			function changeRedirectRDA(currentTarget, alias, target) {
				console.assert(currentTarget instanceof HTMLAnchorElement, 'currentTarget instanceof HTMLAnchorElement');
				console.assert(alias && alias.id > 0, 'alias && alias.id > 0');
				console.assert(target && target.id > 0, 'target && target.id > 0');
				inProgress = true;
				activeElement = currentTarget;
				currentTarget.textContent = 'processing ...';
				currentTarget.style.color = 'red';
				(alias.tgm.aliasUsed ? resolveRDA(alias) : Promise.resolve('Resolved'))
					.then(() => redirectAliasTo(alias, target.id)).then(() => { document.location.reload() }, failHandler);
			}

			function changeRedirect(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode), redirect = getSelectedRedirect();
				console.assert(alias && typeof alias == 'object');
				if (redirect.id == 0) return changeToNra(evt); else if (redirect.id == alias.redirectId) return false;
				if (confirm(`This action changes alias "${alias.name}"'s to resolve to "${redirect.name}"`))
					changeRedirectRDA(evt.currentTarget, alias, redirect);
				return false;
			}

			function renameRDA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				console.assert(alias.redirectId, 'alias.redirectId');
				let newName = prompt(`This action renames alias "${alias.name}"`, alias.name);
				if (newName) newName = newName.trim(); else return false;
				if (!newName || newName == alias.name) return false;
				const currentTarget = evt.currentTarget;
				findArtistAlias(newName, 0, true).then(function(target) {
					if (target.id != alias.id && target.id != alias.redirectId) changeRedirectRDA(currentTarget, alias, target);
						else alert(`Name is already taken by alias id ${alias.id}, the operation is aborted`);
				}, reason => getSiteArtist(newName).then(function(dupeTo) {
					siteArtistsCache[dupeTo.name] = dupeTo;
					alert(`Name is already taken by artist "${dupeTo.name}" (${dupeTo.id}), the operation is aborted`);
					GM_openInTab(document.location.origin + '/artist.php?id=' + dupeTo.id.toString(), false);
				})).catch(function(reason) {
					inProgress = true;
					activeElement = currentTarget;
					currentTarget.textContent = 'processing ...';
					currentTarget.style.color = 'red';
					renameAlias(alias, newName).then(() => { document.location.reload() }, failHandler);
				});
				return false;
			}

			function fixRDA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!alias.redirectId) return false;
				const target = findAlias(alias.redirectId, true);
				if (!target) throw 'Assertion failed: redirecting alias was not found';
				if (!confirm(`This action forces alias "${alias.name}"'s to resolve to "${target.name}" in all still linked releases.`))
					return false;
				inProgress = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				resolveRDA(alias).then(() => { document.location.reload() }, failHandler);
				return false;
			}

			function X(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!confirm('Delete this alias?')) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				deleteAlias(alias.id).then(() => { document.location.reload() }, failHandler);
				return false;
			}

			// batch actions
			function batchAction(actions, condition, onlySelected = true) {
				console.assert(typeof actions == 'function', "typeof actions == 'function'");
				if (typeof actions != 'function') throw 'Invalid argument';
				if (onlySelected) {
					var selAliases = aliasesRoot.querySelectorAll('div > ul > li > input.aam[type="checkbox"]:checked');
					if (selAliases.length <= 0) return Promise.reject('No aliases selected');
					selAliases = Array.from(selAliases).map(checkbox => getAlias(checkbox.parentNode));
				} else selAliases = Array.from(aliases).map(getAlias).filter(alias => alias.id != mainIdentityId);
				console.assert(selAliases.every(Boolean), 'selAliases.every(Boolean)', selAliases);
				if (!selAliases.every(Boolean)) throw 'Assertion failed: element(s) without linked alias';
				if (typeof condition == 'function') selAliases = selAliases.filter(condition);
				if (selAliases.length <= 0) return Promise.reject('No alias fulfils for this action');
				//setRecoveryInfo('batchRecovery', selAliases, actions.toString());
				let worker = alias => dupesCleanup(alias).then(() => actions(alias));
				return (artist.torrentgroup.every(torrentGroup => selAliases.some(alias =>
						alias.tgm && Object.keys(alias.tgm).includes(torrentGroup.groupId))) ? (function() {
					const agk = new ArtistGroupKeeper;
					return agk.hold().then(() => Promise.all(selAliases.map(worker))).then(() => agk.release());
				})() : Promise.all(selAliases.map(actions))).then(function() {
					clearRecoveryInfo();
					document.location.reload();
				}, failHandler);
			}
			function batchRecovery(artist, aliases, actions) {
				if (typeof actions == 'string') actions = eval(actions);
				if (typeof actions != 'function') return Promise.reject('Action not valid callback');
				console.assert(Array.isArray(aliases) && aliases.length > 0, 'Array.isArray(aliases) && aliases.length > 0');
				return Promise.all(aliases.map(actions));
			}

			function batchChangeToRDA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return;
				const redirect = getSelectedRedirect();
				if (redirect.id == 0) return batchChangeToNRA(evt);
				let nagText = `CAUTION

This action makes all selected aliases redirect to artist\'s variant
"${redirect.name}",
and replaces all non-redirect aliases in their involved groups (if any) with this variant.`;
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.disabled = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'goldenrod';
				batchAction(alias => redirectAliasTo(alias, redirect.id), alias => alias.id != redirect.id).catch(failHandler);
			}

			function batchChangeToNRA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return;
				let nagText = `This action makes all selected RDAs distinct within artist`;
				if (!confirm(nagText)) return;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.disabled = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'goldenrod';
				batchAction(alias => deleteAlias(alias.id).then(() => wait(alias.name).then(addAlias)),
					alias => alias.redirectId > 0).catch(failHandler);
			}

			function batchRemove(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return;
				let nagText = `This action deletes all selected RDAs and unused NRAs`;
				if (!confirm(nagText)) return;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'goldenrod';
				batchAction(alias => deleteAlias(alias.id), alias => alias.redirectId > 0 || !alias.tgm.aliasUsed)
					.catch(failHandler);
			}

			function batchFixRDA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return;
				// let nagText = `This action fixes all broken redirecting aliases`;
				// if (!confirm(nagText)) return;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'goldenrod';
				batchAction(alias => resolveRDA(alias), isBadRDA, false).catch(failHandler);
			}

			if (hasRecoveryInfo()) for (let h2 of document.body.querySelectorAll('div#content h2')) {
				if (!h2.textContent.includes('Artist aliases')) continue;
				let input = document.createElement('INPUT');
				input.type = 'button';
				input.dataset.caption = input.value = 'Recover from unfinished operation';
				window.tooltipster.then(() => { input.tooltipster({
					content: 'Unfinished operation information was found for this artist<br>Recovery will try to finish.',
				}) });
				input.style.marginLeft = '2em';
				input.value = '[ processing ... ]';
				input.onclick = function(evt) {
					if (inProgress || !confirm('This will try to finalize last interrputed operation. Continue?')) return;
					(activeElement = evt.currentTarget).disabled = inProgress = true;
					activeElement.style.color = 'goldenrod';
					recoverFromFailure().then(function() {
						activeElement.value = 'Recovery successfull, reloading...';
						//document.location.reload();
					}, function(reason) {
						activeElement.style.color = null;
						activeElement.value = input.dataset.caption;
						activeElement.disabled = false;
						alert('Recovery was not successfull: ' + reason);
						document.location.reload();
					});
				};
				h2.insertAdjacentElement('afterend', input);
			}

			for (let li of aliases) if (!rdExtractor.test(li.textContent)) {
				const alias = {
					id: li.querySelector(':scope > span:nth-of-type(1)'),
					name: li.querySelector(':scope > span:nth-of-type(2)'),
				};
				if (Object.keys(alias).some(key => key == null) || !(alias.id = parseInt(alias.id.textContent))) continue;
				const elem = alias.name;
				if (!(alias.name = alias.name.textContent) || alias.name != artist.name) continue;
				mainIdentityId = alias.id;
				rmDelLink(li);
				elem.style.fontWeight = 900;
				break;
			}
			console.assert(mainIdentityId > 0, 'mainIdentityId > 0');

			function applyDynaFilter(str) {
				const filterById = Number.isInteger(str), norm = str => str.toLowerCase();
				if (!filterById) str = str ? /^\d+$/.test(str) && parseInt(str) || (function() {
					const rx = /^\s*\/(.+)\/([dgimsuy]+)?\s*$/i.exec(str);
					if (rx != null) try { return new RegExp(...rx) } catch(e) { /*console.info(e)*/ }
				})() || norm(str.trim()) : undefined;

				function isHidden(li) {
					if (!str) return false;
					let elem = li.querySelector(':scope > span:nth-of-type(2)');
					console.assert(elem != null, 'elem != null');
					if (!filterById && (elem == null || (str instanceof RegExp ? str.test(elem.textContent)
							: norm(elem.textContent).includes(str)))) return false;
					if (!Number.isInteger(str)) return true;
					elem = li.querySelector(':scope > span:nth-of-type(1)');
					if (elem != null && str == parseInt(elem.textContent)) return false;
					return (elem = rdExtractor.exec(li.textContent)) == null || str != parseInt(elem[1]);
				}
				for (let li of aliases) li.hidden = isHidden(li);
			}

			for (let li of aliases) {
				li.alias = {
					id: li.querySelector(':scope > span:nth-of-type(1)'),
					name: li.querySelector(':scope > span:nth-of-type(2)'),
					redirectId: rdExtractor.exec(li.textContent),
				};
				if (li.alias.id == null || li.alias.name == null) {
					delete li.alias;
					continue;
				}
				if (li.alias.redirectId == null) {
					li.alias.id.style.cursor = 'pointer';
					li.alias.id.onclick = function(evt) {
						const aliasId = parseInt(evt.currentTarget.textContent);
						console.assert(aliasId >= 0, 'aliasId >= 0');
						if (!(aliasId >= 0)) throw 'Invalid node value';
						applyDynaFilter(aliasId);
						const dynaFilter = document.getElementById('aliases-dynafilter');
						if (dynaFilter != null) dynaFilter.value = aliasId;
					};
					li.alias.id.title = 'Click to filter';
					(elem => { window.tooltipster.then(() => { $(elem).tooltipster() }) })(li.alias.id);
				}
				if (!(li.alias.id = parseInt(li.alias.id.textContent)) || !(li.alias.name = li.alias.name.textContent)) continue; // assertion failed
				li.alias.tgm = new TorrentGroupsManager(li.alias.id);
				let buttonIndex = 0;

				function addButton(caption, tooltip, cls, callback, highlight = false) {
					const a = document.createElement('A');
					a.className = 'brackets';
					if (cls) a.classList.add(cls);
					a.style.marginLeft = buttonIndex > 0 ? '5pt' : '3pt';
					if (highlight) a.style.color = 'red';
					a.href = '#';
					if (caption) a.dataset.caption = a.textContent = caption.toUpperCase();
					if (tooltip) window.tooltipster.then(() => { $(a).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) }).catch(function(reason) {
						a.title = tooltip;
						console.warn(reason);
					});
					if (typeof callback == 'function') a.onclick = callback;
					li.append(a);
					++buttonIndex;
				}

				if (li.alias.redirectId != null) { // RDA
					li.alias.redirectId = parseInt(li.alias.redirectId[1]);
					console.assert(li.alias.redirectId > 0, 'li.alias.redirectId > 0');
					for (let span of li.getElementsByTagName('SPAN')) if (parseInt(span.textContent) == li.alias.redirectId) {
						const deref = findAlias(li.alias.redirectId);
						if (deref) window.tooltipster.then(function() {
							const tooltip = '<span style="font-size: 10pt; padding: 1em;">' + deref.name + '</span>';
							if ($(span).data('plugin_tooltipster'))
								$(span).tooltipster('update', tooltip).data('plugin_tooltipster').options.delay = 100;
							else $(span).tooltipster({ delay: 100, content: tooltip });
						}).catch(function(reason) {
							//span.textContent = deref + ' (' + span.textContent + ')';
							span.title = deref.name;
							console.warn(reason);
						});
						span.style.cursor = 'pointer';
						span.onclick = function(evt) {
							applyDynaFilter(li.alias.redirectId);
							const dynaFilter = document.getElementById('aliases-dynafilter');
							if (dynaFilter != null) dynaFilter.value = li.alias.redirectId;
						};
					}

					addButton('NRA', 'Change to non-redirecting alias', 'make-nra', changeToNra);
					addButton('CHG', 'Change redirect', 'redirect-to', changeRedirect);
					addButton('RN', 'Rename this alias', 'rename', renameRDA);
					if (isBadRDA(li.alias)) {
						li.style.backgroundColor = '#FF000020';
						addButton('FIX', 'This alias is still linked to torrent groups, doesn\'t reolve to true alias or resolves to non-existing alias. Fix forces resolve the alias to it\'s true target, aliases redirecting to invalid id will resolve to main artist name.',
							'fix-rda', fixRDA, true);
					}
				} else { // NRA
					delete li.alias.redirectId;
					li.style.color = isLightTheme ? 'peru' : isDarkTheme ? 'antiquewhite' : 'darkorange';
					if (li.alias.name != artist.name) {
						addButton('MAIN', 'Make this alias main artist\'s identity', 'make-main', makeItMain);
						addButton('RD', 'Change to redirecting alias to artist\'s identity selected in dropdown below',
							'redirect-to', changeToRedirect);
						addButton('RN', 'Rename this alias while keeping it distinguished from the main identity',
							'rename', renameNRA);
						addButton('CUT', 'Just unlink this alias from the artist and leave it in separate group; unused aliases will be deleted',
							'cut-off', cutOffNRA);
					}
					if (li.alias.tgm.aliasUsed) addButton('S', 'Split this ' + (li.alias.name == artist.name ? 'artist': 'alias') +
						' to two or more names', 'split', split);
					addButton('SEL', 'Select as redirect target', 'select', select);
				}
				if (li.alias.tgm.aliasUsed) {
					rmDelLink(li);
					const span = document.createElement('span');
					span.textContent = '(' + li.alias.tgm.size + ')';
					span.style.marginLeft = '5pt';
					if (li.alias.redirectId > 0) span.style.color = 'red';
					window.tooltipster.then(() => { $(span).tooltipster({ content: 'Amount of groups blocking this alias' }) }).catch(function(reason) {
						span.title = 'Amount of groups blocking this alias';
						console.warn(reason);
					});
					li.append(span);
				}
				for (let a of li.getElementsByTagName('A')) if (a.textContent.trim() == 'X') {
					a.href = '#';
					a.dataset.caption = a.textContent;
					a.onclick = X;
					a.style.marginLeft = '3pt';
				}
				for (let a of li.getElementsByTagName('A')) if (a.textContent.trim() == 'User') {
					const href = new URL(a.href);
					if (userId > 0 && parseInt(href.searchParams.get('id')) == userId) {
						const span = document.createElement('SPAN');
						span.className = 'brackets';
						span.style.color = 'skyblue';
						span.textContent = 'Me';
						li.replaceChild(span, a);
					}
				}
			}
			for (let li of aliases) if ('alias' in li && !(li.alias.redirectId > 0))
				li.alias.dependants = new AliasDependantsManager(li.alias.id);

			const h3 = aliasesRoot.getElementsByTagName('H3');
			if (h3.length > 0 && aliases.length > 1) {
				const elems = createElements('LABEL', 'INPUT', 'INPUT', 'DIV', 'LABEL', 'INPUT', 'IMG', 'SPAN');
				elems[3].style = 'transition: height 0.5s; height: 0; overflow: hidden;';
				elems[3].id = 'batch-controls';
				elems[4].style = 'margin-left: 15pt; padding: 5pt; line-height: 0;';
				elems[5].type = 'checkbox';
				elems[5].onclick = function(evt) {
					for (let input of aliasesRoot.querySelectorAll('div > ul > li > input.aam[type="checkbox"]'))
						if (!input.parentNode.hidden) input.checked = evt.currentTarget.checked;
				};
				elems[4].append(elems[5]);
				elems[3].append(elems[4]);

				function addButton(caption, callback, tooltip, margin = '5pt', highlight = false) {
					const input = document.createElement('INPUT');
					input.type = 'button';
					if (caption) input.dataset.caption = input.value = caption;
					if (margin) input.style.marginLeft = margin;
					if (highlight) input.style.color = 'red';
					if (tooltip) window.tooltipster.then(() => { $(input).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) })
						.catch(reason => { console.warn(reason) });
					if (typeof callback == 'function') input.onclick = callback;
					elems[3].append(input);
				}
				addButton('Redirect', batchChangeToRDA, 'Make selected aliases redirect to selected identity', '1em');
				addButton('Distinct', batchChangeToNRA, 'Make selected aliases distinct (make them NRA)');
				addButton('Delete', batchRemove, 'Remove all selected aliases (except used NRAs)');
				if (aliasesRoot.querySelector('a.fix-rda') != null)
					addButton('Fix broken RDAs', batchFixRDA, 'Fixes all broken redirecting aliases; aliases resolving to non-existing id will resolve to main identity', undefined, true);

				h3[0].insertAdjacentElement('afterend', elems[3]);
				elems[2].type = 'button';
				elems[2].value = 'Show batch controls';
				elems[2].style.marginLeft = '2em';
				elems[2].onclick = function(evt) {
					if ((elems[3] = document.getElementById('batch-controls')) != null) elems[3].style.height = 'auto';
					evt.currentTarget.remove();
					let tabIndex = 0;
					for (let li of aliasesRoot.querySelectorAll('div > ul > li')) {
						let elem = li.querySelector(':scope > span:nth-of-type(2)');
						if (elem == null || elem.textContent == artist.name) continue;
						elem = document.createElement('INPUT');
						elem.type = 'checkbox';
						elem.className = 'aam';
						elem.tabIndex = ++tabIndex;
						elem.style = 'margin-right: 2pt; position: relative; left: -2pt;';
						li.prepend(elem);
						li.style.listStyleType = 'none';
					}
				};
				h3[0].insertAdjacentElement('afterend', elems[2]);
				elems[0].textContent = 'Filter by';
				elems[1].type = 'text';
				elems[1].id = 'aliases-dynafilter';
				elems[1].style = 'margin-left: 1em; width: 20em; padding-right: 20pt;';
				elems[1].ondblclick = evt => { applyDynaFilter(evt.currentTarget.value = '') };
				elems[1].oninput = evt => { applyDynaFilter(evt.currentTarget.value) };
				elems[1].ondragover = elems[1].onpaste = evt => { evt.currentTarget.value = '' };
				elems[6].height = 17;
				elems[6].style = 'position: relative; left: -18pt; top: 2pt;';
				elems[6].src = GM_getResourceURL('input-clear-button'); //'https://ptpimg.me/d005eu.png';
				elems[6].onclick = evt => {
					applyDynaFilter();
					const input = document.getElementById('aliases-dynafilter');
					if (input != null) input.value = '';
				};
				elems[0].append(elems[1]);
				elems[0].append(elems[6]);
				h3[0].insertAdjacentElement('afterend', elems[0]);
				elems[7].textContent = '(' + aliases.length + ')';
				elems[7].style = 'margin-left: 1em; font: normal 9pt Helvetica, Arial, sans-serif;';
				h3[0].append(elems[7]);
			}
			if (dropDown != null) dropDown.onchange = function(evt) {
				const redirectId = parseInt((evt instanceof Event ? evt.currentTarget : dropDown).value);
				if (!(redirectId >= 0)) throw 'Unknown selection';
				for (let li of aliases) {
					const alias = getAlias(li);
					if (alias == null || alias.redirectId > 0) continue;
					li.style.backgroundColor = alias.id == redirectId ?
						isLightTheme ? '#ffde004d' : isDarkTheme ? 'darkslategray' : 'orange' : null;
				}
			};
			if (typeof dropDown.onchange == 'function') dropDown.onchange();

			function addDiscogsImport() {
				const dcEntryTypes = {
					a: 'artist',
					r: 'release',
					m: 'master',
					l: 'label',
					u: 'users',
				};
				const sitesFilter = url =>
					url && !siteBlacklist.some(pattern => url.toLowerCase().includes(pattern.toLowerCase()));
				const dcArtistLink = artist =>
					`[align=left][url=${artist.uri}][img]https://ptpimg.me/v27891.png[/img][/url][/align]`;
				const useLinkFriendlyNames = GM_getValue('discogs_friendly_urls', false);

				function dcUrlToBB(url) {
					if (!url || !(url = url.trim())) return null;
					let friendlyName = /^(.+?):\s*(https?:\/\/.+)$/i.exec(url);
					if (friendlyName != null) {
						url = friendlyName[2];
						friendlyName = friendlyName[1];
					} else try {
						const _url = new URL(url);
						if (!['https:', 'http:'].includes(_url.protocol)) throw 'Unsupported protocol';
						for (let entry of Object.entries({
							'Discogs': 'discogs.com', 'Bandcamp': '.bandcamp.com', 'SoundCloud': 'soundcloud.com',
							'Last.fm': 'last.fm', 'YouTube': 'youtube.com', 'Wikipedia': 'wikipedia.org', 'IMDb': 'imdb.com',
							'MusicBrainz': 'musicbrainz.org', 'Spotify': 'spotify.com', 'Tidal': 'tidal.com',
							'Tumblr': 'tumblr.com', 'Twitter': 'twitter.com', 'Facebook': 'facebook.com',
						})) if (_url.hostname.endsWith(entry[1])) friendlyName = entry[0];
					} catch(e) {
						console.log(`Not a valid URL (${e}):`, url);
						return url;
					}
					return friendlyName && useLinkFriendlyNames ? `[url=${url}]${friendlyName}[/url]` : '[url]' + url + '[/url]';
				}

				function dcResolveLinks(wikiBody, replacer) {
					if (typeof wikiBody != 'string' || typeof replacer != 'function') throw 'Invalid argument';
					let lookupWorkers = [ ];
					wikiBody = wikiBody.replace(/\[([armlu])=([^\[\]\r\n]+)\]/ig,
						(match, key, id) => !/^\d+$/.test(id) ? replacer(key, id, dcNameNormalizer(id)) : match);
					const entryExtractor = /\[([armlu])=?(\d+)\]/ig;
					let match;
					while ((match = entryExtractor.exec(wikiBody)) != null) {
						const en1 = { key: match[1].toLowerCase(), id: parseInt(match[2]) };
						if (!lookupWorkers.some(en2 => en2.key == en1.key && en2.id == en1.id)) lookupWorkers.push(en1);
					}
					lookupWorkers = lookupWorkers.map(entry => getDiscogsEntry(dcEntryTypes[entry.key], entry.id).then(result => ({
						key: entry.key,
						id: entry.id,
						resolvedId: result.id,
						name: (result.name ? dcNameNormalizer(result.name) : result.title).trim(),
					})).catch(function(reason) {
						alert(`Discogs lookup for ${match.key}${match.id} failed: ` + reason);
						return null;
					}));
					return lookupWorkers.length > 0 ? Promise.all(lookupWorkers).then(function(entries) {
						if ((entries = entries.filter(Boolean)).length > 0) return entries;
						return Promise.reject('No entries were resolved');
					}).then(entries => Object.assign.apply({ }, Object.keys(dcEntryTypes).map(key => ({ [key]: (function() {
						const items = entries.filter(entry => entry.key == key).map(entry => ({ [entry.id]: entry.name }));
						return items.length > 0 ? Object.assign.apply({ }, items) : { };
					})() })))).then(lookupTable => wikiBody.replace(entryExtractor, function(match, key, id) {
						const name = lookupTable[key = key.toLowerCase()][id = parseInt(id)];
						if (!name) console.warn('Discogs item not resolved:', match);
						return replacer(key, id, name);
					})) : Promise.resolve(wikiBody);
				}

				function setProgressInfo(content, destructive = false, asHTML = false) {
					function destroy() {
						if (!(info instanceof HTMLElement)) return;
						if (info.hTimer) clearTimeout(info.hTimer);
						info.remove();
					}

					const id = 'discogs-progress-info';
					let info = document.getElementById(id);
					if (!content) return destroy();
					if (info == null) {
						info = document.createElement('DIV');
						info.id = id;
						info.style = 'margin-top: 1em;';
						dcForm.append(info);
					} else if (info.hTimer) clearTimeout(info.hTimer);
					info[asHTML ? 'innerHTML' : 'textContent'] = content;
					if (destructive) info.hTimer = setTimeout(destroy, 10000); else if (info.hTimer) delete info.hTimer;
				}

				function getDcArtistId() {
					console.assert(dcInput instanceof HTMLInputElement, 'dcInput instanceof HTMLInputElement');
					let m = /^(https?:\/\/(?:\w+\.)?discogs\.com\/artist\/(\d+))\b/i.exec(dcInput.value.trim());
					if (m != null) return parseInt(m[2]);
					console.warn('Discogs link isnot valid:', dcInput.value);
					return (m = /^\/artist\/(\d+)\b/i.exec(dcInput.value)) != null ? parseInt(m[1]) : undefined;
				}

				function reliabilityColorValue(matched, total, colors = [0xccbf00, 0x008000]) {
					if (!total) return;
					console.assert(matched > 0, 'matched > 0');
					if (matched <= 0) return '#' + colors[0].toString(16).padStart(6, '0');
					if (matched >= total) return '#' + colors[1].toString(16).padStart(6, '0');
					const colorIndexRate = Math.min(matched / total / 0.80, 1);
					const colorsAsRGB = colors.map(color => [2, 1, 0].map(index => (color >> (index << 3)) & 0xFF));
					const compositeValue = index => colorsAsRGB[0][index] +
						Math.round(colorIndexRate * (colorsAsRGB[1][index] - colorsAsRGB[0][index]));
					return `rgb(${compositeValue(0)}, ${compositeValue(1)}, ${compositeValue(2)})`;
				}

				function updateArtistWiki(bbCode, summary, editNotes, overwrite = 1) {
					if (!bbCode || !(bbCode = bbCode.trim())) return false;
					let elem = document.getElementById('body');
					console.assert(elem != null, 'body != null');
					if (elem == null || elem.value.includes(bbCode.replace(/\r?\n/g, '\n'))) return false;
					if (elem.value.length <= 0 || overwrite > 1) {
						if (isRED) bbCode = '[pad=6|0|0|0]' + bbCode + '[/pad]';
						elem.value = bbCode;
					} else if (overwrite <= 0) return false; else elem.value += '\n\n' + bbCode;
					if (summary && (elem = document.body.querySelector('input[type="text"][name="summary"]')) != null)
						elem.value = summary;
					if (editNotes && (elem = document.getElementById('artisteditnotes')) != null
							&& !elem.value.toLowerCase().includes(editNotes.toLowerCase()))
						if (!elem.value) elem.value = editNotes; else elem.value += '\n\n' + editNotes;
					return true
				}

				function genDcArtistDescriptionBB(artist) {
					function link(key, id, title) {
						if (!key || !id) throw 'Invalid argument';
						const link = (title = key + id) =>
							`[url=${encodeURI(`https://www.discogs.com/${dcEntryTypes[key]}/${id}`)}][plain]${title}[/plain][/url]`;
						if (title) switch (key = key.toLowerCase()) {
							case 'a': return `[artist]${dcNameNormalizer(title)}[/artist]${link('')}`;
							// case 'l': return `[url=${document.location.origin}/torrents.php?${new URLSearchParams({
							// 		action: 'advanced',
							// 		remasterrecordlabel: dcNameNormalizer(title),
							// 	}).toString()}]${dcNameNormalizer(title)}[/url]${link('')}`;
							// case 'm': case 'r': return `[url=${document.location.origin}/torrents.php?${new URLSearchParams({
							// 		action: 'advanced',
							// 		groupname: dcNameNormalizer(title),
							// 	}).toString()}]${dcNameNormalizer(title)}[/url]${link('')}`;
						}
						return link(title);
					}
					function addRelations(bbCode) {
						const artistFormatter = (label, key) => `\n[b]${label}:[/b] ${artist[key].filter(artist => artist.active)
								.concat(artist[key].filter(artist => isRED && !artist.active)).map(function(artist) {
							const a = link('a', artist.id, artist.name);
							return artist.active ? a : `[s]${a}[/s]`;
						}).join(', ')}`;
						if (members) bbCode += artistFormatter('Members', 'members');
						if (groups) bbCode += artistFormatter('Member of'/*'In groups'*/, 'groups');
						return bbCode.trim() || Promise.reject('no profile data');
					}

					if (!artist) throw 'The parameter is required';
					const members = Array.isArray(artist.members) && artist.members.length > 0,
								groups = Array.isArray(artist.groups) && artist.groups.length > 0;
					let bbCode = !members && artist.realname && artist.realname != dcNameNormalizer(artist.name) ?
						`[b]Real name:[/b] [plain]${artist.realname}[/plain]` : '';
					if (artist.profile) {
						const profile = artist.profile.trim()
							.replace(/(?:[ \t]*\r?\n){2,}/g, '\n\n')
							.replace(/\s*^(?:[Ff]or .+ (?:use|see|visit)|[Ss]ee also) \[a(?:=.+?|\d+)\].?$/gm, '')
							.replace(/\[url=([^\[\]\r\n]+)\]([^\[\]\r\n]+)\[\/url\]/ig,
								(m, url, title) => `[url=${url.trim()}]${title}[/url]`)
							.replace(/\[url\]([^\[\]\r\n]+)\[\/url\]/ig, (m, url) => `[url]${url.trim()}[/url]`)
							.replace(/\[img=([^\[\]\r\n]+)\]/ig, (m, url) => `[img]${url.trim()}[/img]`)
							.replace(/\[t=?(\d+)\]/ig, '[url=https://www.discogs.com/help/forums/topic?topic_id=$1]topic $1[/url]')
							.replace(/\[g=?([^\[\]\r\n]+)\]/ig, '[url=https://www.discogs.com/help/guidelines/$1]guideline $1[/url]');
						return dcResolveLinks(profile, link).catch(reason => profile)
							.then(profile => addRelations(bbCode + '\n\n' + profile + '\n'));
					}
					return Promise.resolve(bbCode).then(addRelations);
				}
				function genDcArtistTooltipHTML(artist, resolveIds = false) {
					if (!artist) throw 'The parameter is required';
					const linkColor = isLightTheme ? '#0A84AF' : isDarkTheme ? 'aqua' : 'cadetblue';
					const link = (key, id, caption = key + id) =>
						`<a href="${encodeURI(`https://www.discogs.com/${dcEntryTypes[key.toLowerCase()]}/${id}`)}" target="_blank" style="color: ${linkColor};">${caption}</a>`;
					let html = `<div style="font-size: 10pt;"><b style="color: tomato;">${artist.name}</b>`;
					if (artist.realname && artist.realname != dcNameNormalizer(artist.name)) html += ` (${artist.realname})`;
					html += '</div>';
					if (Array.isArray(artist.images) && artist.images.length > 0)
						html += `<img style="margin-left: 1em; float: right;" src="${artist.images[0].uri150}" />`;
					if (Array.isArray(artist.members)) {
						const members = artist.members.filter(artist => artist.active);
						if (members.length > 0) html += `<div style="margin-top: 5pt;"><b>Active members:</b> ${members
							.map(artist => link('a', artist.id, dcNameNormalizer(artist.name))).join(', ')}</div>`;
					}
					if (Array.isArray(artist.groups)) {
						const groups = artist.groups.filter(group => group.active);
						if (groups.length > 0) html += `<div style="margin-top: 5pt;"><b>Active in groups:</b> ${groups
							.map(group => link('a', group.id, dcNameNormalizer(group.name))).join(', ')}</div>`;
					}
					if (artist.profile) {
						let profile = artist.profile.trim()
							.replace(/\[url=([^\[\]]+)\]([^\[\]\r\n]+)\[\/url\]/ig, (m, url, caption) =>
								`<a href="${url.trim()}" target="_blank" style="color: ${linkColor};">${caption}</a>`)
							.replace(/\[url\]([^\[\]\r\n]+)\[\/url\]/ig, (m, url) =>
								`<a href="${url.trim()}" target="_blank" style="color: ${linkColor};">${url.trim()}</a>`)
							.replace(/\[img=([^\[\]\r\n]+)\]/ig, (m, url) => `<img src="${url.trim()}" />`)
							.replace(/\[quote\]([\S\s]+)\[\/quote\]/ig, '<blockquote>$2</blockquote>')
							.replace(/\[quote=([^\[\]\r\n]+)\]([\S\s]+)\[\/quote\]/ig, '<blockquote cite="$1">$2</blockquote>')
							.replace(/\[g=?([^\[\]\r\n]+)\]/ig, '<a href="https://www.discogs.com/help/guidelines/$1" target="_blank">guideline $1</a>')
							.replace(/\[t=?(\d+)\]/ig, '<a href="https://www.discogs.com/help/forums/topic?topic_id=$1" target="_blank">topic $1</a>');
						if (!resolveIds) profile = profile.replace(/\[([armlu])=?(\d+)\]/ig, (m, key, id) => link(key, id));
						const tagConversions = {
							b: 'font-weight: bold;',
							i: 'font-style: italic;',
							u: 'text-decoration: underline;',
							s: 'text-decoration: line-through;',
						};
						const BB2Html = str => '<p style="margin-top: 1em;">' + Object.keys(tagConversions).reduce((str, key) =>
							str.replace(new RegExp(`\\[${key}\\](.*?)\\[\\/${key}\\]`, 'ig'),
								`<span style="${tagConversions[key]}">$1</span>`), str).replace(/(?:[ \t]*\r?\n)/g, '<br>') + '</p>';
						return dcResolveLinks(profile, link).catch(reason => profile).then(bbCode => html + BB2Html(bbCode));
					} else return Promise.resolve(html);
				}

				function addDisambiguationInfo(button, asymmetric = false, includeMatchRatio = true) {
					console.assert(button instanceof HTMLInputElement, 'button instanceof HTMLInputElement');
					if (!(button instanceof HTMLInputElement)) throw 'Invalid argument';
					console.assert(Array.isArray(button.matchedArtists) && button.matchedArtists.length > 1,
						'Array.isArray(button.matchedArtists) && button.matchedArtists.length > 1');
					if (!Array.isArray(button.matchedArtists) || button.matchedArtists.length < 2) return;
					button.disabled = true;
					const minorArtistsTotal = button.matchedArtists.reduce((acc, artist) =>
						artist != button.matchedArtists.bestMatch ? acc + artist.matchedGroups.length : acc, 0);
					const uncertain = !button.matchedArtists.some(result => Object.keys(result).some(siteKey =>
						siteKey != 'matchedGroups' && results.filter(result => siteKey in result).length > 1));
					if (!asymmetric && button.matchedArtists.length <= 10 && minorArtistsTotal <= 15
							&& button.matchedArtists.filter(artist => artist.matchedGroups.length >= 8).length == 1
							&& button.matchedArtists.filter(artist => artist.matchedGroups.length > 1).length == 1
							|| button.matchedArtists.bestMatch.matchedGroups.length >= Math.max(minorArtistsTotal * 10, 10))
						asymmetric = true;
					else if (asymmetric && (button.matchedArtists.length > 15 || minorArtistsTotal > 30
							|| button.matchedArtists.filter(artist => artist.matchedGroups.length >= 10).length > 2
							|| button.matchedArtists.filter(artist => artist.matchedGroups.length >= 5).length > 5
							|| button.matchedArtists.filter(artist => artist.matchedGroups.length > 1).length > 20
							|| minorArtistsTotal * 3 > button.matchedArtists.bestMatch.matchedGroups.length))
						asymmetric = false;
					Promise.all(button.matchedArtists.map(function(result) {
						if (result.discogsArtist) return getDiscogsEntry('artist', result.discogsArtist.id).then(artist =>
								genDcArtistDescriptionBB(artist).catch(reason => result.amArtist && result.amArtist.attributes.artistBio
									|| bpReflowArtistBio(result.bpArtist) || result.mbArtist && result.mbArtist.disambiguation
									|| undefined).then(bbCode => Object.assign(artist, {
							source: 'Discogs',
							bbCode: bbCode,
						})));
						if (result.mbArtist) return Promise.resolve(Object.assign({
							uri: result.mbArtist.uri,
							source: 'MusicBrainz',
							bbCode: result.mbArtist.disambiguation || undefined,
						}, result.mbArtist));
						if (result.amArtist) return Promise.resolve(Object.assign({
							uri: result.amArtist.uri,
							source: 'Apple Music',
							bbCode: result.amArtist.attributes.artistBio || undefined,
						}, result.amArtist));
						if (result.bpArtist) return Promise.resolve(Object.assign({
							uri: result.bpArtist.uri,
							source: 'BeatPort',
							bbCode: result.bpArtist.bio || undefined,
						}, result.bpArtist));
						throw 'Assertion failed: Incomplete artist';
					})).then(function(artists) {
						console.assert(artists.length > 1, 'artists.length > 1');
						if (asymmetric && !artists[0].bbCode) asymmetric = false;
						let hasWiki = asymmetric && document.getElementById('body');
						hasWiki = Boolean(hasWiki) && hasWiki.textLength > 0;
						let bbCode = artists.map(function(refArtist, index) {
							if (asymmetric && index < 1) {
								bbCode = refArtist.bbCode || '';
								const sites = Array.isArray(refArtist.urls) && refArtist.urls.filter(sitesFilter);
								if (sites && sites.length > 0) bbCode = (bbCode + '\n\n' + sites.map(dcUrlToBB).join('\n')).trim();
								if (bbCode) bbCode = '[size=3]' + bbCode + '\n\n[/size]';
								switch (refArtist.source) {
									case 'Discogs': bbCode += dcArtistLink(refArtist); break;
									case 'MusicBrainz': bbCode += `[align=left][url=${refArtist.uri}][img]https://ptpimg.me/50s6cw.png[/img][/url][/align]`; break;
									case 'Apple Music': bbCode += `[align=left][url=${refArtist.uri}]Apple Music[/url][/align]`; break;
									case 'BeatPort': bbCode += `[align=left][url=${refArtist.uri}]BeatPort[/url][/align]`; break;
									default: throw 'Assertion failed: incomplete artist';
								}
								return bbCode;
							}
							let header = '[size=3][b]';
							if (!asymmetric || button.matchedArtists.length > 2) header += (index + 1) + '. ';
							switch (refArtist.source) {
								case 'Discogs': header += `[url=${refArtist.uri}][plain]${dcNameNormalizer(refArtist.name)}[/plain][/url][/b][/size]`; break;
								case 'MusicBrainz': header += `[url=${refArtist.uri}][plain]${refArtist.name}[/plain][/url][/b][/size]`; break;
								case 'Apple Music': header += `[url=${refArtist.uri}][plain]${refArtist.attributes.name}[/plain][/url][/b][/size]`; break;
								case 'BeatPort': header += `[url=${refArtist.uri}][plain]${refArtist.name}[/plain][/url][/b][/size]`; break;
								default: throw 'Assertion failed: incomplete artist';
							}
							if (includeMatchRatio && !asymmetric)
								header += ` [size=2][color=#888888](${button.matchedArtists[index].matchedGroups.length}/${artist.torrentgroup.length})[/color][/size]`;
							var bbCode = '';
							if (Array.isArray(refArtist.images) && refArtist.images.length > 0)
								bbCode = '[img]' + refArtist.images[0].uri150 + '[/img]';
							else if (refArtist.image && refArtist.image.dynamic_uri && ![
								'/0dc61986-bccf-49d4-8fad-6b147ea8f327.jpg',
								'/d02c012b-67d4-4058-a75f-3fbabdb8d19d.jpg',
							].some(id => refArtist.image.dynamic_uri.endsWith(id)))
								bbCode = '[img]' + refArtist.image.dynamic_uri.replace(/\{[wh]\}/g, '150') + '[/img]';
							else if (refArtist.attributes && refArtist.attributes.artwork)
								bbCode = '[img]' + refArtist.attributes.artwork.url.replace(/\{[wh]\}/g, '150') + '[/img]';
							if (refArtist.bbCode) bbCode += '\n' + refArtist.bbCode.replace(/\s*(?:\r?\n)+/g, '\n');
							if (button.matchedArtists[index].matchedGroups.length < 50) {
								const matchedGroups = button.matchedArtists[index].matchedGroups.map(groupId =>
									'[torrent]' + groupId + '[/torrent]');
								bbCode += '\n[hide=Release groups]' + matchedGroups.join(', ') + '[/hide]';
							}
							if (bbCode && isRED) bbCode = (asymmetric && button.matchedArtists.length < 3 ?
								'[pad=6|0|0]' : '[pad=6|0|0|13]') + bbCode.trim() + '[/pad]';
							return bbCode ? header + '\n' + bbCode : header;
						});
						const joiner = str => str.join(/*isRED ? '[hr]' : */'\n\n'),
									editSummary = uncertain ? 'Wiki update' : 'Wiki update (disambiguation info)',
									editNote = !uncertain && 'Ambiguous name';
						let updateSuccess;
						if (asymmetric) {
							const s = '[size=3][b]' + (button.matchedArtists.length > 2 ?
								(uncertain ? 'Possibly more distinct artists' : 'More distinct artists' ) +
									' present under this name:[/b][/size]\n\n'
								: (uncertain ? 'Possibly another distinct artist' : 'Another distinct artist') +
									' present for this name:[/b][/size] ') + joiner(bbCode.slice(1));
							if (hasWiki) bbCode = isRED ? '[hr]\n' + s : s;
								else bbCode = bbCode[0] + (isRED ? '\n[hr]\n' : '\n\n') + s;
							updateSuccess = updateArtistWiki(bbCode, editSummary, editNote);
						} else updateSuccess = updateArtistWiki('[size=3][b]Multiple distinct artists present for this name:[/b][/size]\n\n' +
							joiner(bbCode), editSummary, editNote);
						if (updateSuccess) {
							button.style.backgroundColor = 'green';
							setTimeout(() => { button.style.backgroundColor = null }, 1000);
						}
						button.disabled = false;
					}, function(reason) {
						console.warn('Error on disambiguation info:', reason);
						button.disabled = false;
					});
					const image = document.body.querySelector('input[type="text"][name="image"]');
					if (image == null || asymmetric && image.value.length > 0) return;
					if (asymmetric && button.matchedArtists[0].cover_image) {
						image.value = button.matchedArtists[0].cover_image;
						if (unsafeWindow.imageHostHelper) unsafeWindow.imageHostHelper
								.rehostImageLinks([button.matchedArtists[0].cover_image], true, false, false)
							.then(unsafeWindow.imageHostHelper.singleImageGetter).then(imageUrl => { image.value = imageUrl });
					} else image.value = 'https://ptpimg.me/6qap57.png';
				}

				function updateForumPost(threadId, distinctArtists) {
					if (!(threadId > 0)) throw 'Invalid argument';
					const postId = GM_getValue('forum_post_id');
					if (!(postId > 0)) throw 'Post id not defined';
					const updateFormat = GM_getValue('forum_post_update');
					if (!updateFormat) throw 'Post update format not defined';
					if ('aamArtistsAdded' in localStorage) try {
						var artistsAdded = JSON.parse(localStorage.getItem('aamArtistsAdded'));
						if (artistsAdded.includes(artist.id)) {
							alert('Reference to this artist was already added!');
							return;
						}
					} catch(e) { console.warn(e) }
					const url = '/forums.php?' + new URLSearchParams({
						action: 'viewthread',
						threadid: threadId,
						postid: postId,
					}).toString();
					return localXHR(url).then(function(document) {
						let editKey = document.body.querySelector(`div#content table#post${postId} a[onclick][href="#post${postId}"]`);
						if (editKey != null && (editKey = editKey.getAttribute('onclick'))
								&& (editKey = /\b(?:Edit_Form)\s*\(\s*'.+?'\s*,\s*'(\d+)'\s*\)/i.exec(editKey)) != null)
							return parseInt(editKey[1]); else throw 'Unexpected page format';
					}).then(editKey => localXHR('/forums.php?action=get_post&post=' + postId, { responseType: 'text' }).then(function(bbCode) {
						const formData = new FormData;
						formData.set('post', postId);
						formData.set('body', bbCode + eval('`' + updateFormat + '`'));
						formData.set('key', editKey);
						formData.set('auth', userAuth);
						return localXHR('/forums.php?action=takeedit', { responseType: null }, formData).then(function() {
							if (confirm('Artist group reference was successfully added to defined forum post.\nReview the thread?'))
								GM_openInTab(document.location.origin + url + '#post' + postId, false);
							if (!artistsAdded) artistsAdded = [ ];
							artistsAdded.push(artist.id);
							localStorage.setItem('aamArtistsAdded', JSON.stringify(artistsAdded));
						});
					})).catch(alert);
				}

				function getAliases(evt) {
					function cleanUp() {
						if (button.dataset.caption) button.value = button.dataset.caption;
						button.style.color = null;
						button.disabled = false;
						inProgress = false;
					}
					function weakAlias(anv, mainDent = button.artist.name) {
						if (!anv) throw 'Assertion failed: invalid argument (anv)';
						const norm = str => str.toASCII().toLowerCase(), anl = norm(mainDent), alnv = norm(anv);
						return alnv == anl || 'the ' + alnv == anl || alnv == 'the ' + anl ? 1
							: /^(?:[a-z](?:\.\s*|\s+))+(\w{2,})\w/.test(alnv) ? anl.includes(norm(RegExp.$1)) ? 3 : 2 : 0;
					}

					if (inProgress) return false;
					const button = evt.currentTarget;
					if (!button.artist) throw 'No artist attached';
					button.disabled = true;
					//button.style.color = 'red';
					setProgressInfo('Please wait...');
					setAjaxApiLogger(function(action, apiTimeFrame, timeStamp) {
						setProgressInfo(`Please wait... (${apiTimeFrame.requestCounter - 5} name queries queued)`);
					});
					const rxGenericName = /^(?:(?:(?:And|With|La|\&)\s+)?(?:(?:The|His|Her|Sua)\s+)?(?:Orch(?:estra|ester|\.?)|Orquestra|Ensemble|Orkester|(?:(?:Big|Brass)\s+)?Band|All[\s\-]Stars|Chorus|Choir|Friends|Trio|Quartet(?:te)?|Quintet(?:te?)?|Sextet(?:te?)?|Septet(?:te?)?|Octet(?:te?)?|Nonet(?:te?)?|Tentet(?:te?)?)(?:\s+(?:Members))?|(?:Feat(?:\.?|uring)|Ft\.))$/i;
					const notGenericName = name => !rxGenericName.test(name);
					const resultsAdaptor = results => Array.isArray(results) && (results = results.filter(Boolean)).length > 0 ?
						Object.assign.apply({ }, results) : null;
					const querySiteStatus = (arrRef, artistId = button.artist.id) => Array.isArray(arrRef) && arrRef.length > 0 ?
							Promise.all(arrRef.map(dcNameNormalizer).filter((name, ndx, arr) => arr.indexOf(name) == ndx).map(function(anv) {
						const result = value => ({ [anv]: value });
						return findArtistAlias(anv).then(alias => result(alias.id), function(reason) {
							if (evt.ctrlKey) return result(null);
							return getSiteArtist(anv).then(a => result(a.id != artist.id ? !isGenericArtist(a.name) ? (function() {
								decodeArtistTitles(a);
								return Object.assign(a, {
									dcMatches: getDiscogsMatches(artistId, a.torrentgroup),
									lookup: searchArtist(a.name, false, a.torrentgroup).catch(reason => reason != 'No matches' ?
										Promise.reject(reason) : searchArtist(a.name, false, a.torrentgroup, arrRef)),
								});
							})() : (delete a.torrentgroup, a) : 0 /* implicit RDA */), reason => result(null) /* not found on site */);
						});
					})).then(resultsAdaptor) : Promise.resolve(null);
					const findAliasRedirectId = alias => findArtistAlias(dcNameNormalizer(alias.name))
							.then(alias => alias.redirectId > 0 ? Promise.reject('Redirecting alias') : alias.id)
							.catch(reason => mainIdentityId);
					const relationsAdaptor = (alias, exploreANVs = true) => getDiscogsEntry('artist', alias.id).then(function(artist) {
						const aliases = [artist.name];
						if (exploreANVs && !evt.shiftKey && Array.isArray(artist.namevariations))
							Array.prototype.push.apply(aliases, artist.namevariations);
						return findAliasRedirectId(alias).then(redirectId => querySiteStatus(aliases.filter(notGenericName)
								.filter((alias, ndx, arr) => arr.indexOf(alias) == ndx), artist.id).then(aliases => ({ [artist.id]: {
							discogsArtist: artist,
							image: artist.images && artist.images.length > 0 ? artist.images[0].uri150 : undefined,
							tooltip: genDcArtistTooltipHTML(artist),
							anvs: aliases,
							redirectTo: redirectId,
							matchByRealName: Boolean(button.artist.realname && (artist.realname
								&& artist.realname.toLowerCase() == button.artist.realname.toLowerCase()
								|| dcNameNormalizer(artist.name).toLowerCase() == button.artist.realname.toLowerCase())
								|| artist.realname && artist.realname.toLowerCase() == dcNameNormalizer(button.artist.name).toLowerCase()),
							matchByMembers: Array.isArray(artist.members) && Array.isArray(button.artist.members) && (function() {
								const memberIds = [artist, button.artist].map(obj => obj.members
									.filter(member => member.active).map(member => member.id).sort());
								return memberIds[0].length == memberIds[1].length
									&& memberIds[0].every((id, ndx) => memberIds[1].indexOf(id) == ndx);
							})(),
						} })));
					}, function(reason) {
						console.warn(`${alias.name}: ${reason}`);
						return null;
					});
					const basedOnArtist = group => {
						const cmpNorm = str => dcNameNormalizer(str).toASCII().replace(/\W+/g, '').toLowerCase();
						const testForName = n => (n = cmpNorm(n)).length > 0 && (an.startsWith(n + ' ') || an.endsWith(' ' + n)
							|| an.includes(' ' + n + ' ') || n.length > 4 && an.includes(n));
						const an = cmpNorm(group.name);
						return an.length > 0 && (testForName(button.artist.name) || Array.isArray(button.artist.namevariations)
							&& button.artist.namevariations.some(testForName));
					};
					const isImported = cls => ['everything', cls + '-only'].some(cls => button.id == 'fetch-' + cls);
					const anvs = [button.artist.name];
					if (Array.isArray(button.artist.namevariations)) Array.prototype.push.apply(anvs, button.artist.namevariations);
					if (button.artist.realname && !anvs.includes(button.artist.realname)
							&& (!Array.isArray(button.artist.members) || button.artist.members.length <= 0))
						anvs.push(button.artist.realname);
					return Promise.all([
						// Artist's ANVs
						isImported('anvs') ? querySiteStatus(anvs.filter(notGenericName)
							.filter((anv, ndx, arr) => arr.indexOf(anv) == ndx)) : Promise.resolve(null),
						// Music groups based on artist
						isImported('groups') && Array.isArray(button.artist.groups) ?
							Promise.all(button.artist.groups.filter(group => group.active && basedOnArtist(group))
							.map(group => relationsAdaptor(group))).then(resultsAdaptor) : Promise.resolve(null),
						// Artist's aliases
						isImported('aliases') && Array.isArray(button.artist.aliases) ? Promise.all(button.artist.aliases.map(alias =>
							relationsAdaptor(alias))).then(resultsAdaptor) : Promise.resolve(null),
						// Other music groups
						isImported('groups') && Array.isArray(button.artist.groups) ?
							Promise.all(button.artist.groups.filter(group => basedOnArtist(group) ? !group.active : evt.altKey).map(group =>
								relationsAdaptor(group, false))).then(resultsAdaptor) : Promise.resolve(null),
						// Group members
						button.id == 'fetch-members-only' && Array.isArray(button.artist.members) ?
							Promise.all(button.artist.members.filter(member => evt.altKey || member.active)
								.map(member => relationsAdaptor(member))).then(resultsAdaptor) : Promise.resolve(null),
					]).then(function(dcAliases) {
						console.debug('Discogs fetched aliases:', dcAliases);
						cleanUp();
						if (dcAliases.every((el, ndx) => !el)) return setProgressInfo('Nothing to import', true);
						setProgressInfo();

						function showModal() {
							if (dropDown == null) throw 'Unexpected document structure';

							function addIconToBay(container, source, opacity, tooltip) {
								console.assert(container instanceof HTMLDivElement);
								if (!(container instanceof HTMLDivElement)) return false;
								console.assert(container.className == 'icon-bay');
								if (!source) return false;
								const img = document.createElement('IMG');
								img.src = source.length <= 64 && GM_getResourceURL(source) || source;
								img.height = 10;
								//img.style = 'position: relative; top: 1px;';
								if (tooltip) window.tooltipster.then(() => { $(img).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) }, () => { img.title = tooltip });
								//if (tooltip) img.title = tooltip;
								if (container.children.length > 0) img.style.marginLeft = '2pt';
								if (opacity < 1) img.style.opacity = opacity;
								container.append(img);
								container.style.display = 'inline';
							}
							const addLinkIcon = container =>
								{ addIconToBay(container, 'link-icon2', undefined, 'Linked as similar artist') };
							function visualizeProgress(target) {
								console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
								function visualizeProgress(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									const span = document.createElement('SPAN');
									span.textContent = 'Processing ...';
									span.style.color = 'darkorchid';
									for (let child of Array.from(target.parentNode.childNodes))
										if (child == target) child.hidden = true;
											else target.parentNode.removeChild(child);
									target.parentNode.append(span);
								}
								window.tooltipster.then(() => { $(target).tooltipster('hide') });
								visualizeProgress(target);
								const artistId = parseInt(target.dataset.artistId);
								if (!artistId) return; //throw 'Artist identification missing';
								for (let a of document.body.querySelectorAll('table#dicogs-aliases > tbody > tr > td > a.local-artist-group'))
									if (a != target && parseInt(a.dataset.artistId) == artistId) visualizeProgress(a);
							}
							function visualizeMerge(target, method, color) {
								console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
								if (method) {
									const span = document.createElement('SPAN');
									span.textContent = 'Merged via ' + method;
									span.style.color = color;
									const parentNode = target.parentNode;
									while (parentNode.firstChild != null) parentNode.removeChild(parentNode.lastChild);
									parentNode.append(span);
								}
							}
							function visualizeMerges(target, method, color) {
								console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
								visualizeMerge(target, method, color);
								const artistId = parseInt(target.dataset.artistId);
								if (!artistId) return; //throw 'Artist identification missing';
								for (let a of document.body.querySelectorAll('table#dicogs-aliases > tbody > tr > td > a.local-artist-group'))
									if (parseInt(a.dataset.artistId) == artistId) visualizeMerge(a, method, color);
							}
							function adoptSimilarArtists(target) {
								if ('similarArtists' in target.dataset) try {
									let similarArtists = JSON.parse(target.dataset.similarArtists);
									if (artist.similarArtists) similarArtists = similarArtists.map(function(name) {
										name = name.toLowerCase();
										return !artist.similarArtists.some(similarArtist => similarArtist.name.toLowerCase() == name);
									});
									if (similarArtists.length > 0) addSimilarArtists(similarArtists)
										.then(() => { console.log(`${similarArtists.length} similar artists of ${target.name} were adopted`) });
								} catch(e) { console.warn(e) }
							}
							function mergeNRA(target) {
								console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
								const artistId = parseInt(target.dataset.artistId), artistName = target.dataset.artistName;
								if (!artistId || !artistName) throw 'Artist identification missing';
								if (!confirm(`Artist "${artistName}" is going to be merged via non-redirecting alias`))
									return;
								visualizeProgress(target);
								changeArtistId(artist.id, artistId).then(function() {
									target.onclick = null;
									if ('similarArtists' in target.dataset) adoptSimilarArtists(target);
									visualizeMerges(target, 'non-redirecting alias', 'lightseagreen');
								}, alert);
							}
							function mergeRD(target) {
								console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
								const artistId = parseInt(target.dataset.artistId), artistName = target.dataset.artistName;
								if (!artistId || !artistName) throw 'Artist identification missing';
								if (!confirm(`Artist "${artistName}" is going to be merged via redirect to ${artist.name}`))
									return;
								visualizeProgress(target);
								renameArtist(artist.name, artistId).then(function() {
									target.onclick = null;
									if ('similarArtists' in target.dataset) adoptSimilarArtists(target);
									visualizeMerges(target, 'redirect', 'limegreen');
								}, alert);
							}
							function makeSimilar(target) {
								console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
								const artistId = parseInt(target.dataset.artistId), artistName = target.dataset.artistName;
								if (!artistId || !artistName) throw 'Artist identification missing';
								if ('similar' in target.dataset) alert('This artist is already linked or merged');
								else if (confirm(`Artist "${artistName}" is going to be added as similar artist`))
									addSimilarArtist(artistName).then(function() {
										target.dataset.similar = true;
										addLinkIcon(target.parentNode.querySelector('div.icon-bay'));
										for (let a of document.body.querySelectorAll('table#dicogs-aliases > tbody > tr > td > a.local-artist-group'))
											if (parseInt(a.dataset.artistId) == artistId && !('similar' in a.dataset)) {
												a.dataset.similar = true;
												addLinkIcon(a.parentNode.querySelector('div.icon-bay'));
											}
									}, alert);
							}

							const menu = new ContextMenu('16bbbc39-65a0-4c21-b6af-3d6c6ff79166');
							menu.addItem('Merge with current artist via non-redirecting alias', mergeNRA);
							menu.addItem('Merge with current artist via redirect to main identity', mergeRD);
							menu.addItem('Link as similar artist', makeSimilar);

							function clickHandler(evt) {
								const artistId = parseInt(evt.currentTarget.dataset.artistId),
											artistName = evt.currentTarget.dataset.artistName;
								if (!artistId || !artistName) throw 'Artist identification missing';
								console.assert(artistId != artist.id, 'artistId != artist.id');
								if (evt.altKey && !evt.ctrlKey && !evt.shiftKey) return makeSimilar(evt.currentTarget), false;
								else if (evt.ctrlKey && !evt.shiftKey && !evt.altKey) return mergeNRA(evt.currentTarget), false;
								else if (evt.shiftKey && !evt.ctrlKey && !evt.altKey) return mergeRD(evt.currentTarget), false;
							}
							function addImportEntry(aliasName, dcArtist, status, redirectTo = -1, tooltip, mergeWithId = mainIdentityId, prefix) {
								const elems = createElements('TR', 'TD', 'A', 'SPAN', 'TD');
								elems[1].style = 'width: 100%; padding: 0 7pt;';
								elems[2].className = 'alias-name';
								elems[2].dataset.aliasName = elems[2].textContent = aliasName.properTitleCase();
								elems[2].style = aliasName == dcNameNormalizer(dcArtist.name) ?
									'font-weight: bold;' : 'font-weight: normal;';
								elems[2].href = `https://www.discogs.com/artist/${dcArtist.id}?` + new URLSearchParams({
									anv: aliasName,
									filter_anv: 1,
								}).toString();
								elems[2].target = '_blank';
								if (tooltip instanceof Promise) window.tooltipster.then(() => tooltip.then(tooltip => {
									$(elems[2]).tooltipster({ content: tooltip.slice(0, 2**10), maxWidth: 500, interactive: true })
										.tooltipster('reposition');
								}));
								elems[1].append(elems[2]);
								elems[3].textContent = '[' + (prefix ? prefix + '/' + dcArtist.id : dcArtist.id) + ']';
								elems[3].style = 'font-weight: 100; font-stretch: condensed; margin-left: 4pt;';
								if (dcArtist.id == button.artist.id) elems[3].hidden = true;
								elems[1].append(elems[3]);
								elems[0].append(elems[1]);
								if (!status) {
									elems[0].style.height = null;
									elems[4].style = 'padding: 0;';
									elems[5] = dropDown.cloneNode(true);
									elems[5].className = 'redirect-to';
									elems[5].style = 'max-width: 25em; margin: 1pt 3pt 1pt 0;';
									if (mergeWithId > 0) elems[5].dataset.defaultRedirectId = mergeWithId;
									for (let option of elems[5].children) if (option.nodeName == 'OPTION')
										option.text = parseInt(option.value) == 0 ?
											'Make it non-redirecting alias' : 'Redirect to ' + option.text;
									elems[6] = document.createElement('OPTION');
									elems[6].text = status == null ? 'Do not import' : 'Keep implicit redirect';
									elems[6].value = -1;
									elems[5].prepend(elems[6]);
									elems[5].value = status == 0 ? -1 : redirectTo;
									elems[5].tabIndex = ++tabIndex;
									elems[4].append(elems[5]);
								} else {
									elems[4].style = 'height: 18pt; padding: 0 7pt 0 3pt; text-align: left;';
									elems[4].style.minWidth = 'max-content';
									if (!elems[4].style.minWidth) elems[4].style.minWidth = '-moz-max-content';
									if (status > 0) {
										elems[4].textContent = 'Defined (' + status + ')';
									} else if (typeof status == 'object') {
										elems[4].textContent = 'Taken by artist ';
										elems[5] = document.createElement('A');
										elems[5].className = 'local-artist-group';
										elems[5].href = '/artist.php?id=' + status.id;
										elems[5].target = '_blank';
										elems[5].textContent = status.name;
										if (isGenericArtist(status.name)) {
											elems[4].append(elems[5]);
											elems[0].append(elems[4]);
											modal[5].append(elems[0]);
											return;
										}
										elems[5].onclick = clickHandler;
										menu.attach(elems[5]);
										elems[5].dataset.artistName = status.name;
										elems[5].dataset.artistId = status.id;
										if (status.similarArtists) {
											const similarArtists =  status.similarArtists.filter(sa1 => sa1.id != artist.id
													&& (!artist.similarArtists || !artist.similarArtists.some(sa2 => sa2.id == sa1.id)))
												.map(similarArtist => similarArtist.name);
											if (similarArtists.length > 0) elems[5].dataset.similarArtists = JSON.stringify(similarArtists);
										}
										let tooltip = `Ctrl + click to merge with current artist via non-redirecting alias
Shift + click to merge with current artist via redirect to main identity`;
										if (artist.similarArtists
												&& artist.similarArtists.some(similarArtist => similarArtist.artistId == status.id))
											elems[5].dataset.similar = true;
										else tooltip += '\nAlt + click to link as similar artist';
										window.tooltipster.then(function() {
											if (status.image) tooltip +=
												`<img style="margin-left: 5pt; float: right; max-width: 90px; max-height: 90px;" src="${status.image}" />`;
											tooltip += `<div style="margin-top: 0.5em;"><b>Groups:</b> ${status.statistics.numGroups}</div>`;
											if (status.tags && status.tags.length > 0) {
												const statusTags = new TagManager(...status.tags.map(tag => tag.name).filter(tagsExclusions));
												if (artist.tags && artist.tags.length > 0) {
													const commonTags = Array.from(statusTags).filter(tag => artist.tags.includes(tag)),
																setSize = Math.min(artist.tags.length, statusTags.length),
																matchRate = commonTags.length / setSize;
													const color = matchRate >= 0.75 ? 'green' : matchRate >= 0.50 ? '#9d9d00'
														: matchRate >= 0.25 ? '#ffb100' : 'red';
													tooltip += `<div style="margin-top: 0.5em;"><b>Common tags:</b> ${commonTags.join(', ')} (<span style="color: ${color};">${commonTags.length}/${setSize}</span>)`;
													const unmatchedTags = [
														Array.from(statusTags).filter(tag => !artist.tags.includes(tag)),
														Array.from(artist.tags).filter(tag => !statusTags.includes(tag)),
													];
													if (unmatchedTags.some(arr => arr.length > 0))
														tooltip += `\n<b>Unmatched tags:</b> ${unmatchedTags[0].join(', ') || '∅'} <b>↔</b> ${unmatchedTags[1].join(', ') || '<>'}`;
													tooltip += '</div>';
												} else tooltip += `<div style="margin-top: 0.5em;"><b>Tags:</b> ${statusTags.toString()}</div>`;
											}
											if (status.body) tooltip += '<p style="margin-top: 1em;">' + status.body.slice(0, 2**10) + '</p>';
											$(elems[5]).tooltipster({ content: tooltip.replace(/[ \t]*\r?\n/g, '<br>'), maxWidth: 500, interactive: true });
										}).catch(() => { elems[5].title = tooltip });
										elems[4].append(elems[5]);
										elems[6] = document.createElement('DIV');
										elems[6].className = 'icon-bay';
										elems[6].style = 'display: none; margin-left: 5pt; position: relative;';
										const issues = [ ];
										let warnLevel;
										if (status.tags && status.tags.length > 0 && artist.tags && artist.tags.length > 0) {
											const statusTags = new TagManager(...status.tags.map(tag => tag.name).filter(tagsExclusions)),
														commonTags = Array.from(statusTags).filter(tag => artist.tags.includes(tag)),
														setSize = Math.min(artist.tags.length, statusTags.length),
														matchRate = commonTags.length / setSize,
														ratioSuffix = ` (${Math.round(matchRate * 100)}%)`;
											if (matchRate * 4 < 1) issues.push('Tags incompatible' + ratioSuffix);
												else if (matchRate * 4 < 3) issues.push('Tags not quite compatible' + ratioSuffix);
											if (matchRate * 4 < 3) warnLevel = 1/4 + Math.min(3/4 - matchRate, 2/4) * 3/2;
										}
										if (status.body && [
											/\b(?:merged?\b|(?:avoid|not|don\'t)(\s+(?:mak|creat)(?:e|ing)\b)?(?:alias)\b)/i,
											/\[important\][\S\s]+\[\/important\]/,
										].some(rx => rx.test(status.body))) {
											issues.push('Specific pattern found in wiki body, review it full');
											warnLevel = 1;
										}
										if (issues.length > 0) addIconToBay(elems[6], 'warn-icon2',
											warnLevel, issues.length > 0 ? issues.join('\n') : undefined);
										if ('similar' in elems[5].dataset) addLinkIcon(elems[6]);
										elems[4].append(elems[6]);
										if (status.dcMatches) status.dcMatches.then(function(matchedGroups) {
											const span = document.createElement('SPAN');
											const counterStyle = matchedGroups.length <= 0 ? 'color: red;' : undefined;
											span.innerHTML = `[<span class="release-match-counter"${counterStyle ? ` style="${counterStyle}"` : ''}>${matchedGroups.length}</span>/${Object.keys(status.torrentgroup).length}]`;
											span.style.marginLeft = '4pt';
											if (Object.keys(status.torrentgroup).length <= 0) {
												span.style.color = 'green';
											} else if (matchedGroups.length >= Object.keys(status.torrentgroup).length) {
												span.style.color = 'green';
												window.tooltipster.then(() => { $(span).tooltipster({
													content: 'Id is exclusive to this physical artist'
												}) });
											} else if (status.lookup) {
												span.style.opacity = 0.5;
												status.lookup.then(function(results) {
													results = stripDcRelativesFromResults(results, dcArtist);
													const matchedArtists = getMatchedArtists(results);

													function getArtistRef(searchResult) {
														const siteRefs = [ ], commonAttributes = 'target="_blank" style="color: cadetblue;"';
														if (searchResult.discogsArtist)
															siteRefs.push(`<a href="${searchResult.discogsArtist.uri}" ${commonAttributes}>${searchResult.discogsArtist.title}</a>`);
														if (searchResult.mbArtist)
															siteRefs.push(`<a href="${searchResult.mbArtist.uri}" ${commonAttributes}>${searchResult.mbArtist.name}</a>`);
														if (searchResult.amArtist)
															siteRefs.push(`<a href="${searchResult.amArtist.uri}" ${commonAttributes}>${searchResult.amArtist.attributes.name}</a>`);
														if (searchResult.bpArtist)
															siteRefs.push(`<a href="${searchResult.bpArtist.uri}" ${commonAttributes}>${searchResult.bpArtist.name}</a>`);
														if (siteRefs.length <= 0) {
															console.warn('Assertion failed: incomplete artist', results.bestMatch);
															return;
														}
														let artistRef = siteRefs.join(' / ');
														if (searchResult == matchedArtists.bestMatch) artistRef = '<b>' + artistRef + '</b>';
														return artistRef + ' [' + searchResult.matchedGroups.length + ']';
													}

													//console.debug('Lookup results for', status.name, results);
													span.style.opacity = 1;
													if (matchedArtists.length > 1) {
														span.style.color = matchedGroups.length > 0 ? 'orange' : 'red';
														let artistRefs = matchedArtists.map(getArtistRef).filter(Boolean);
														artistRefs = artistRefs.length > 0 ? '<br><br><b>Colliding artists:</b><br>' + artistRefs.join('<br>') : '';
														window.tooltipster.then(() => { $(span).tooltipster({
															content: (matchedGroups.length > 0 ?
																`'Related name belongs to at least ${matchedArtists.length} distinct artists sharing this id (do not merge)`
																	: `'This id is shared by at least ${matchedArtists.length} distinct artists (do not merge)`) + artistRefs,
															interactive: true,
														}) });
														console.info(`[AAM] Multiple matching artists for alias '${status.name}":`, matchedArtists);
													} else if (matchedArtists.length == 1) {
														let tooltip;
														if (matchedArtists.bestMatch.discogsArtist) if (matchedArtists.bestMatch.discogsArtist.id == dcArtist.id) {
															span.style.color = reliabilityColorValue(matchedGroups.length, Object.keys(status.torrentgroup).length);
															tooltip = 'Site id is likely exclusive to this artist identity';
														} else {
															const artistRef = getArtistRef(matchedArtists.bestMatch);
															if (matchedGroups.length > 0) {
																span.style.color = 'orange';
																tooltip = 'This site id is shared by at least one different artist (do not merge)<br><br>' + artistRef;
															} else {
																span.style.color = 'red';
																tooltip = `This site id is entirely used by different artist ${artistRef}, do not merge`;
															}
															console.info(`[AAM] Discogs artist mismatch for alias '${status.name}":`, matchedArtists);
														} else tooltip = 'The only matching artist couldnot be reliably linked to Discogs alias';
														if (tooltip) window.tooltipster.then(function() { $(span).tooltipster({
															content: tooltip,
															interactive: !matchedArtists.bestMatch.discogsArtist
																|| matchedArtists.bestMatch.discogsArtist.id != dcArtist.id,
														}) });
													} else window.tooltipster.then(function() { $(span).tooltipster({
														content: `No matching releases for any of ${results.length} artists found`,
													}) });
												}).catch(function(reason) {
													span.style.opacity = 1;
													if (reason == 'No matches') return;
													addIconToBay(elems[6], 'warn-icon2', undefined, reason);
												});
											}
											elems[5].insertAdjacentElement('afterend', span);
										}); else elems[5].title = 'Not found';
									} // status: artist on site
								} // alias already used
								elems[0].append(elems[4]);
								modal[5].append(elems[0]);
							} // addImportEntry

							window.tooltipster.then(() => { $(button).tooltipster('hide') });
							const redirect = getSelectedRedirect(true);
							const modal = createElements('DIV', 'DIV', 'DIV', 'TABLE', 'THEAD', 'TBODY', 'DIV', 'INPUT', 'INPUT');
							modal[0].className = 'modal discogs-import';
							modal[0].style = 'position: fixed; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); opacity: 0; visibility: hidden; transform: scale(1.1); transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s; z-index: 999999;';
							modal[0].onclick = evt => { if (evt.target == evt.currentTarget) closeModal() };
							modal[1].className = 'modal-content';
							modal[1].style = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); max-width: 65em; border-radius: 0.5rem; padding: 1rem 1rem 1rem 1rem;';
							if (isLightTheme) modal[1].style.color = 'black';
							modal[1].style.backgroundColor = isDarkTheme ? 'darkslategrey' : 'FloralWhite';
							modal[1].style.width = 'max-content';
							if (!modal[1].style.width) modal[1].style.width = '-moz-max-content';
							// Header
							modal[2].textContent = 'Review how to import ANVs found';
							modal[2].style = 'margin-bottom: 1em; font-weight: bold; font-size: 12pt;'
							const RYMlink = document.createElement('A');
							RYMlink.href = 'https://rateyourmusic.com/search?' + new URLSearchParams({
								searchterm: '"' + artist.name + '"',
								searchtype: 'a',
							}).toString();
							RYMlink.target = '_blank';
							RYMlink.textContent = 'RYM';
							RYMlink.style = 'float: right; font-size: 8pt; color: cadetblue;';
							modal[2].append(RYMlink);
							modal[1].append(modal[2]);
							// Table
							modal[3].id = 'dicogs-aliases';
							modal[3].style = 'display: block; max-height: 45em; padding: 3pt 0px 2pt; overflow-y: scroll; scroll-behavior: auto;';
							modal[3].append(modal[4]);
							const nonLatin = artistName =>
								dcNameNormalizer(artistName).replace(/[\s\.\-\,\&]+/g, '').toASCII().length <= 0;
							let tabIndex = 0;
							function findMatchingAliasId(anv) {
								const cmpNorm = name => name.toLowerCase().replace(/[\s]+/g, '');
								if (anv) for (let li of aliases) {
									const alias = getAlias(li);
									if (!alias || alias.redirectId > 0) continue;
									if (cmpNorm(alias.name) == cmpNorm(anv)) return alias.id;
								}
							}
							// Artist's NVAs
							if (dcAliases[0]) {
								const tooltip = genDcArtistTooltipHTML(button.artist);
								for (let anv in dcAliases[0]) addImportEntry(anv, button.artist, dcAliases[0][anv],
									nonLatin(anv) ? -1 : weakAlias(anv) > 0 ? findMatchingAliasId(anv) || mainIdentityId : 0,
									tooltip);
							}
							// Music groups based on artist
							if (dcAliases[1]) for (let artistId in dcAliases[1]) for (let anv in dcAliases[1][artistId].anvs)
								addImportEntry(anv, dcAliases[1][artistId].discogsArtist, dcAliases[1][artistId].anvs[anv],
									/*weakAlias(anv) > 1 ? -1 : */0, dcAliases[1][artistId].tooltip,
									findMatchingAliasId(anv) || dcAliases[1][artistId].redirectTo || redirect.id, 'G');
							// Artist's aliases
							if (dcAliases[2]) for (let artistId in dcAliases[2]) for (let anv in dcAliases[2][artistId].anvs)
								addImportEntry(anv, dcAliases[2][artistId].discogsArtist, dcAliases[2][artistId].anvs[anv],
									dcAliases[2][artistId].matchByRealName || dcAliases[2][artistId].matchByMembers ?
										0 : /*weakAlias(anv) == 1 ? 0 : */-1,
									dcAliases[2][artistId].tooltip,
									findMatchingAliasId(anv) || dcAliases[2][artistId].redirectTo || redirect.id, 'A');
							// Other music groups possibly involved in
							if (dcAliases[3]) for (let artistId in dcAliases[3]) for (let anv in dcAliases[3][artistId].anvs)
								addImportEntry(anv, dcAliases[3][artistId].discogsArtist, dcAliases[3][artistId].anvs[anv],
									weakAlias(anv) == 1 ? 0 : -1, dcAliases[3][artistId].tooltip,
									findMatchingAliasId(anv) || dcAliases[3][artistId].redirectTo || redirect.id, 'G');
							// Group members
							if (dcAliases[4]) for (let artistId in dcAliases[4]) for (let anv in dcAliases[4][artistId].anvs)
								addImportEntry(anv, dcAliases[4][artistId].discogsArtist, dcAliases[4][artistId].anvs[anv],
									-1, dcAliases[4][artistId].tooltip,
									findMatchingAliasId(anv) || dcAliases[4][artistId].redirectTo || redirect.id, 'M');
							modal[3].append(modal[5]);
							modal[1].append(modal[3]);
							const allDropdowns = modal[3].querySelectorAll('tbody > tr > td:nth-of-type(2) > select');
							// Buttonbar
							modal[6].style = 'margin-top: 1em;';
							modal[7].type = 'button';
							modal[7].value = 'Import now';
							if (allDropdowns.length <= 0) modal[7].disabled = true;
							modal[7].onclick = function(evt) {
								const importTable = document.body.querySelectorAll('table#dicogs-aliases > tbody > tr');
								closeModal();
								Promise.all(Array.from(importTable).map(function(tr) {
									let aliasName = tr.querySelector('a.alias-name'),
											redirectId = tr.querySelector('select.redirect-to');
									return aliasName != null && redirectId != null && (redirectId = parseInt(redirectId.value)) >= 0 ?
										addAlias(aliasName.dataset.aliasName, redirectId) : null;
								}).filter(Boolean)).then(function(results) {
									console.info('Total', results.length, 'artist aliases imported from Discogs');
									if (results.length > 0) document.location.reload();
								});
							};
							modal[7].tabIndex = ++tabIndex;
							modal[6].append(modal[7]);
							modal[8].type = 'button';
							modal[8].value = 'Close';
							modal[8].onclick = closeModal;
							modal[8].tabIndex = ++tabIndex;
							modal[6].append(modal[8]);

							function addQSBtn(caption, value, margin, tooltip) {
								const a = document.createElement('A');
								a.textContent = caption;
								a.href = '#';
								a.style.color = isDarkTheme ? 'lightgrey' : '#0A84AF';
								if (margin) a.style.marginLeft = margin;
								if (tooltip) {
									a.title = 'Resolve all to ' + tooltip;
									window.tooltipster.then(() => { $(a).tooltipster() }).catch(reason => { console.warn(reason) });
								}
								a.onclick = function(evt) {
									for (let select of allDropdowns) switch (typeof value) {
										case 'number': select.value = value; break;
										case 'function': select.value = value(select); break;
									}
									return false;
								};
								modal[6].append(a);
							}
							addQSBtn('Import none', -1, '3em');
							addQSBtn('All NRA', 0, '10pt');
							addQSBtn('All RD', select => select instanceof HTMLElement && select.dataset.defaultRedirectId ?
								parseInt(select.dataset.defaultRedirectId) : redirect.id, '10pt');

							modal[1].append(modal[6]);
							modal[0].append(modal[1]);
							document.body.style.overflow = 'hidden';
							document.body.append(modal[0]);
							modal[0].style.opacity = 1;
							modal[0].style.visibility = 'visible';
							modal[0].style.transform = 'scale(1.0)';
							modal[0].style.transition = 'visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s';
							if ((elem = modal[5].querySelector('select[tabindex="1"]')) != null) elem.focus();
						}
						function closeModal() {
							document.body.removeChild(document.body.querySelector('div.modal.discogs-import'));
							document.body.style.overflow = 'auto';
						}

						showModal();
					}).catch(function(reason) {
						setProgressInfo();
						cleanUp();
						alert(reason);
					});
				}

				function linkRelated(evt) {
					function cleanUp() {
						if (button.dataset.caption) button.value = button.dataset.caption;
						//button.style.color = null;
						button.disabled = false;
						inProgress = false;
					}

					if (inProgress) return false;
					const button = evt.currentTarget;
					if (!button.artist) throw 'No artist attached';

					function addNames(artistId, ...names) {
						if (Array.isArray(names)) Array.prototype.push.apply(relatedArtists[artistId], names.filter(function(name) {
							if (isGenericArtist(name)) return false;
							name = [dcNameNormalizer(name)];
							name.push(name[0].toLowerCase());
							return (!artist.similarArtists || !artist.similarArtists.some(artist =>
								artist.name.toLowerCase() == name[1])) && !findAlias(name[0]);
						}).map(dcNameNormalizer));
					}

					const relatedArtists = { [button.artist.id]: [ ] }, anvWorkers = [ ];
					addNames(button.artist.id, dcNameNormalizer(button.artist.name));
					if (evt.ctrlKey && Array.isArray(button.artist.namevariations))
						addNames(button.artist.id, ...button.artist.namevariations);
					for (let key of ['aliases', 'members', 'groups'])
						if (Array.isArray(button.artist[key])) for (let a1 of button.artist[key]) {
							if (evt.altKey && a1.active == false) continue;
							relatedArtists[a1.id] = [ ];
							addNames(a1.id, a1.name);
							if (evt.ctrlKey) anvWorkers.push(getDiscogsEntry('artist', a1.id).then(function(dcArtist) {
								console.assert(dcArtist.id == a1.id, `Ids mismatch (${dcArtist.id} ≠ ${a1.id})`);
								if (Array.isArray(dcArtist.namevariations)) addNames(a1.id, ...dcArtist.namevariations);
							}).catch(alert));
						}
					if (Object.keys(relatedArtists).length <= 0 && anvWorkers.length < 0) return;
					button.disabled = true;
					//button.style.color = 'red';
					setProgressInfo('Please wait...');
					setAjaxApiLogger(function(action, apiTimeFrame, timeStamp) {
						setProgressInfo(`Please wait... (${apiTimeFrame.requestCounter - 5} name queries queued)`);
					});
					let minMatchRatio = GM_getValue('link_related_min_match_ratio', 1/5);
					if (minMatchRatio > 1) minMatchRatio = 1;
					Promise.all(anvWorkers).then(function() {
						return Promise.all(Object.keys(relatedArtists).map(function(artistId) {
							return Promise.all(relatedArtists[artistId].map(name => getSiteArtist(name).then(function(siteArtist) {
								if (siteArtist.id == artist.id) return Promise.reject('Same artist');
								if (artist.similarArtists && artist.similarArtists.some(a2 => a2.artistId == siteArtist.id))
									return Promise.reject('Already similar');
								if (!Array.isArray(siteArtist.torrentgroup) || siteArtist.torrentgroup.length <= 0)
									return Promise.reject('Nothing to match');
								decodeArtistTitles(siteArtist);
								return getDiscogsMatches(artistId, siteArtist.torrentgroup).then(matchedGroups => (function() {
									if (matchedGroups.length <= 0) return false;
									if (minMatchRatio > 0 && matchedGroups.length / siteArtist.torrentgroup.length < minMatchRatio) {
										console.log(`[AAM] Not making similar artist ${siteArtist.name} (${siteArtist.id}) with ${matchedGroups.length} matched releases due to low confirmation rate (${Math.round(matchedGroups.length * 100 / siteArtist.torrentgroup.length)}%)`);
										return false;
									}
									return true;
								})() ? siteArtist.name : Promise.reject('Insufficient matching with this artist'));
							}).catch(reason => null))).then(results => results.filter(Boolean));
						})).then(function(results) {
							return Promise.all(Array.prototype.concat.apply([ ], results).filter(function(artist1, ndx, arr) {
								return arr.findIndex(artist2 => artist2.toLowerCase() == artist1.toLowerCase()) == ndx;
							}).map(artist => addSimilarArtist(artist)));
						});
					}).then(function(results) {
						setProgressInfo(`Total ${results.length} artists were linked as similar`, true);
						if (results.length > 0) document.location.reload(); else cleanUp();
					}, function(reason) {
						setProgressInfo();
						cleanUp();
						alert(reason);
					});
				}

				function addFetchButton(caption, id = caption) {
					const button = document.createElement('INPUT');
					button.type = 'button';
					if (id) button.id = 'fetch-' + id;
					if (caption) button.value = button.dataset.caption = 'Fetch ' + caption;
					button.style.display = 'none';
					button.onclick = getAliases;
					const tooltip = `Available keyboard modifiers (can be combined):
+CTRL: don't perform site names lookup (faster and less API requests consuming, doesn't reveal local separated identities)
+SHIFT: don't include aliases', groups' and members' name variants
+ALT: include also groups not based on artist's name (only applies if fetching groups) / include also inactive members (only applies if fetching members)`;
					window.tooltipster.then(() => { $(button).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) }).catch(function(reason) {
						button.title = tooltip;
						console.warn(reason);
					});
					dcForm.append(button);
				}

				function dcInputUpdate(evt) {
					function updateButton(id, artist = null) {
						if (!id) throw 'Invalid argument';
						const button = document.getElementById(id);
						if (button == null) return;
						button.style.display = artist ? 'inline' : 'none';
						if (artist) button.artist = artist; else if ('artist' in button) delete button.artist;
					}

					window.tooltipster.then(() => { $(dcInput).tooltipster('disable') });
					getDiscogsEntry('artist', getDcArtistId()).then(function(dcArtist) {
						console.log(`Discogs data for ${dcArtist.id}:`, dcArtist);
						if ((button = document.getElementById('dc-view')) != null) button.disabled = false;
						if ((button = document.getElementById('dc-update-wiki')) != null) button.updateStatus(dcArtist);
						const hasCat = key => Array.isArray(dcArtist[fetchers[key][0]]) && dcArtist[fetchers[key][0]].length > 0;
						updateButton('fetch-everything', Object.keys(fetchers).slice(0, 3).some(hasCat)
							|| dcArtist.realname && dcArtist.realname.toLowerCase() != dcNameNormalizer(dcArtist.name).toLowerCase() ?
								dcArtist : null);
						for (let key in fetchers) updateButton(`fetch-${key}-only`, hasCat(key) ? dcArtist : null);
						updateButton('link-related', Object.keys(fetchers).slice(1).some(hasCat) ? dcArtist : null);
						window.tooltipster.then(() => genDcArtistTooltipHTML(dcArtist, false).then(function(tooltip) {
							$(dcInput).tooltipster('update', tooltip.slice(0, 4 * 2**10)).tooltipster('enable').tooltipster('reposition');
						})).catch(reason => { console.warn(reason) });
					}, function(reason) {
						if ((button = document.getElementById('dc-view')) != null) button.disabled = true;
						if ((button = document.getElementById('dc-update-wiki')) != null) button.updateStatus(false);
						updateButton('fetch-everything');
						for (let key in fetchers) updateButton(`fetch-${key}-only`);
						updateButton('link-related');
					});
				}
				function autoLookup() {
					searchArtist(artist.name, true, artist.torrentgroup).catch(function(reason) {
						if (reason != 'No matches') return Promise.reject(reason);
						return searchArtist(artist.name, true, artist.torrentgroup, Array.from(aliases).map(getAlias)
							.filter(alias => alias && !alias.redirectId && alias.id != mainIdentityId).map(alias => alias.name));
					})/*.then(results => results.length < 0 || consolidateDcRelatives || !results.bestMatch
						|| !results.bestMatch.discogsArtist ? results : getDiscogsEntry('artist', results.bestMatch.discogsArtist.id)
							.then(dcArtist => stripDcRelativesFromResults(results, dcArtist), function(reason) {
						console.warn('[AAM] Stripping relatives from search result failed:', reason);
						return results;
					}))*/.then(function(results) {
						const matchedArtists = getMatchedArtists(results);
						console.log('Combined search results for "' + artist.name + '":', results);
						if (matchedArtists.length > 1) {
							console.log(`[AAM] Multiple matching artists for id '${artist.name}':`, matchedArtists);
							const otherArtistsReleases = [ ];
							for (let result of matchedArtists.filter(result => result != matchedArtists.bestMatch))
								Array.prototype.push.apply(otherArtistsReleases, result.matchedGroups
									.map(groupId => document.location.origin + '/torrents.php?id=' + groupId.toString()));
							console.log('[AAM] Other artists\' releases:', ...otherArtistsReleases);
						}
						const button = document.getElementById('dc-search');
						if (button != null) {
							if (button.progress) clearInterval(button.progress);
							button.value = 'Search artist [' + results.length.toString() + ']';
							button.matchedArtists = matchedArtists.length > 0 ?
								matchedArtists.sort((a, b) => b.matchedGroups.length - a.matchedGroups.length) : null;
							let tooltip;
							if (matchedArtists.length > 1) {
								button.style.color = 'red';
								const menu = new ContextMenu('e5c435fd-edf9-4b25-9305-e90785b5a06b');
								menu.addItem('Update wiki by disambiguation info', elem => addDisambiguationInfo(elem, false));
								menu.addItem('Update wiki by disambiguation info (asymmetric)', elem => addDisambiguationInfo(elem, true));
								let threadId;
								switch (document.location.hostname) {
									case 'redacted.ch': threadId = 773; break;
								}
								if (threadId > 0) {
									const forumURL = new URL('forums.php', document.location.origin);
									forumURL.searchParams.set('action', 'search');
									forumURL.searchParams.set('threadid', threadId);
									forumURL.searchParams.set('search', artist.name);
									localXHR(forumURL).then(function(document) {
										let noMatch = document.body.querySelector('table.forum_list > tbody > tr > td[colspan="4"]');
										if (noMatch = noMatch != null && noMatch.textContent.trim() == 'Nothing found!') {
											forumURL.searchParams.set('action', 'viewthread');
											forumURL.searchParams.delete('search');
											const postId = GM_getValue('forum_post_id');
											if (postId > 0) {
												forumURL.searchParams.set('postid', postId);
												forumURL.hash = 'post' + postId;
											}
										} else button.threadPosts =
											document.body.querySelectorAll('table.forum_list > tbody > tr[id] > td[colspan="4"]');
										button.forumAction = function() {
											if ('aamArtistsAdded' in localStorage) try {
												var artistAdded = JSON.parse(localStorage.getItem('aamArtistsAdded')).includes(artist.id);
											} catch(e) { console.warn(e) }
											if (noMatch && GM_getValue('forum_post_update') && !artistAdded)
												updateForumPost(threadId, matchedArtists); else GM_openInTab(forumURL.href, false);
										};
										menu.addItem(noMatch ? 'Add artist reference to forum thread'
											: 'Review the forum thread search results', button.forumAction);
									});
								}
								menu.attach(button);
								tooltip = `Shared ID!<br><br>This artist profile unites at least ${matchedArtists.length} distinct artists`;
								if (matchedArtists.length <= 15)
									tooltip += ` (${matchedArtists.map(artist => artist.matchedGroups.length).join('-')})`;
								tooltip += `.<br>Be careful about adding aliases and do not merge with other artists<br><br><b>Alt + click</b> to update wiki by disambiguation info (<b>Ctrl + Alt</b> for asymmetric)<br><b>Ctrl + click</b> to open relevant forum thread (if exists)`;
							} else if (matchedArtists.length == 1) {
								button.style.color = reliabilityColorValue(matchedArtists.bestMatch.matchedGroups.length,
									Object.keys(artist.torrentgroup).length, isLightTheme ? [0xFFD700, 0x32CD32] : undefined);
								//button.style.color = isLightTheme ? 'lightgreen' : 'green';
								tooltip = `Id is likely homogenoeus (${matchedArtists.bestMatch.matchedGroups.length}/${Object.keys(artist.torrentgroup).length} releases matched)`;
								if (!matchedArtists.bestMatch.discogsArtist) {
									tooltip += '<br>The bast match artist has not it\'s counterpart on Discogs, or could not be reliably paired';
									if (matchedArtists.bestMatch.bpArtist && matchedArtists.bestMatch.bpArtist.bio)
										updateArtistWiki(`[size=3]${bpReflowArtistBio(matchedArtists.bestMatch.bpArtist)}\n\n[url=${matchedArtists.bestMatch.bpArtist.uri}]BeatPort[/url][/size]`, 'Wiki update (BeatPort)', undefined, 0);
									else if (matchedArtists.bestMatch.amArtist && matchedArtists.bestMatch.amArtist.attributes.artistBio)
										updateArtistWiki(`[size=3]${matchedArtists.bestMatch.amArtist.attributes.artistBio}\n\n[url=${matchedArtists.bestMatch.amArtist.uri}]Apple Music[/url][/size]`, 'Wiki update (Apple)', undefined, 0);
									else if (matchedArtists.bestMatch.mbArtist && matchedArtists.bestMatch.mbArtist.disambiguation)
										updateArtistWiki(`[size=3]${matchedArtists.bestMatch.mbArtist.disambiguation}\n\n[url=${matchedArtists.bestMatch.mbArtist.uri}]MusicBrainz[/url][/size]`, 'Wiki update (MusicBrainz)', undefined, 0);
								}
							} else {
								if (results.length != 1) button.style.color = 'tan';
								tooltip = 'No matching releases for any of artists found';
							}
							window.tooltipster.then(function() {
								if ($(button).data('plugin_tooltipster'))
									if (tooltip) $(button).tooltipster('update', tooltip).tooltipster('enable');
										else $(button).tooltipster('disable');
								else if (tooltip) $(button).tooltipster({ delay: 100, content: tooltip });
							});
						}
						if (dcInput.value.length > 0) return;
						const bestMatch = (matchedArtists.length > 0 ? matchedArtists : results).bestMatch;
						if (!bestMatch.discogsArtist) return;
						dcInput.value = bestMatch.discogsArtist.uri;
						dcInputUpdate();
					}).catch(function(reason) {
						const button = document.getElementById('dc-search');
						if (button == null) throw 'Assertion failed: search button not found';
						if (button.progress) clearInterval(button.progress);
						button.value = `Search artist [${reason}]`;
					});
				}

				const fetchers = {
					anvs: ['namevariations', 'ANVs'],
					aliases: ['aliases'],
					groups: ['groups'],
					members: ['members'],
				};
				aliasesRoot.append(document.createElement('BR'));
				let elem = document.createElement('H3');
				elem.textContent = 'Import from Discogs';
				aliasesRoot.append(elem);
				elem = document.createElement('DIV');
				elem.className = 'pad';
				const dcForm = document.createElement('FORM');
				dcForm.name = dcForm.className = 'discogs-import';
				const dcInput = document.createElement('INPUT');
				dcInput.type = 'text';
				dcInput.className = 'discogs_link tooltip';
				dcInput.style.width = '30em';
				dcInput.ondragover = dcInput.onpaste = evt => { evt.currentTarget.value = '' };
				dcInput.oninput = dcInputUpdate;
				window.tooltipster.then(function() {
					$(dcInput).tooltipster({ maxWidth: 640, content: '</>', interactive: true, delay: 1000 }).tooltipster('disable');
				}).catch(reason => { console.warn(reason) });
				dcForm.append(dcInput);
				let button = document.createElement('INPUT');
				button.type = 'button';
				button.id = 'dc-view';
				button.value = 'View';
				button.disabled = true;
				button.onclick = function(evt) {
					const artistId = getDcArtistId();
					if (artistId) GM_openInTab('https://www.discogs.com/artist/' + artistId.toString(), false);
				};
				dcForm.append(button);
				button = document.createElement('INPUT');
				button.type = 'button';
				button.id = 'dc-update-wiki';
				button.value = 'Update wiki';
				button.disabled = true;
				button.onclick = function(evt) {
					if (!evt.currentTarget.artist) return false;
					(button = evt.currentTarget).disabled = true;
					const dcLink = dcArtistLink(evt.currentTarget.artist);
					const sites = Array.isArray(evt.currentTarget.artist.urls) && evt.currentTarget.artist.urls.filter(sitesFilter);
					genDcArtistDescriptionBB(evt.currentTarget.artist).then(function(bbCode) {
						console.assert(Boolean(bbCode), 'Assertion failed: Got to make full description with empty bbCode');
						if (sites && sites.length > 0) bbCode += '\n\n' + sites.map(dcUrlToBB).join('\n');
						if (updateArtistWiki('[size=3]' + bbCode + '\n\n[/size]' + dcLink, 'Wiki update (adopted from Discogs)')) {
							button.style.backgroundColor = 'green';
							setTimeout(() => { button.style.backgroundColor = null }, 1000);
						}
						button.disabled = false;
					}, function(reason) {
						if (sites && sites.length > 0)
							updateArtistWiki('[size=3]' + sites + '\n\n[/size]' + dcLink, 'Wiki update (external links)');
						else updateArtistWiki(dcLink, 'Wiki update (Discogs link)');
						button.disabled = false;
					});
					if (Array.isArray(evt.currentTarget.artist.images)
							&& evt.currentTarget.artist.images.length > 0) {
						const image = document.body.querySelector('input[type="text"][name="image"]');
						if (image != null && !image.value) {
							image.value = evt.currentTarget.artist.images[0].uri;
							if (unsafeWindow.imageHostHelper) unsafeWindow.imageHostHelper
									.rehostImageLinks([evt.currentTarget.artist.images[0].uri], true, false, false)
								.then(unsafeWindow.imageHostHelper.singleImageGetter).then(imageUrl => { image.value = imageUrl });
						}
					}
				};
				button.updateStatus = function(artist) {
					if (artist) {
						this.artist = artist;
						const hasPhoto = Array.isArray(artist.images) && artist.images.length > 0,
									hasMembers = Array.isArray(artist.members)
										&& artist.members.filter(artist => artist.active).length > 0,
									hasGroups = Array.isArray(artist.groups)
										&& artist.groups.filter(group => group.active).length > 0,
									hasRealName = artist.realname && artist.realname != dcNameNormalizer(artist.name),
									hasXtrnLinks = Array.isArray(artist.urls) && artist.urls.filter(sitesFilter).length > 0,
									body = document.getElementById('body'),
									image = document.body.querySelector('input[type="text"][name="image"]');
						let tooltip = `Image: ${hasPhoto ? '<b>yes</b>' : 'no'}<br>Real name: ${hasRealName ? '<b>yes</b>' : 'no'}<br>Profile info: ${!artist.profile ? 'no' : '<b>' + (artist.profile.length > 800 ? 'long' : artist.profile.length > 400 ? 'moderate' : 'short') + '</b>'}<br>Active members: ${hasMembers ? '<b>yes</b>' : 'no'}<br>Active in groups: ${hasGroups ? '<b>yes</b>' : 'no'}<br>External links: ${hasXtrnLinks ? '<b>yes</b>' : 'no'}<br><br>Image set: ${image != null && /^https?:\/\//.test(image.value) ? 'yes' : '<b>no</b>'}<br>Wiki set: ${body != null && body.value.length > 0 ? 'yes' : '<b>no</b>'}`;
						window.tooltipster.then(() => { if ($(this).data('plugin_tooltipster'))
							$(this).tooltipster('update', tooltip).tooltipster('enable');
								else $(this).tooltipster({ content: tooltip }) });
						button.style.color = artist.profile && body != null && body.value.length <= 0 ? '#ddffdd' : null;
						this.disabled = false;
					} else {
						window.tooltipster.then(() => { if ($(this).data('plugin_tooltipster')) $(this).tooltipster('disable') });
						button.style.color = null;
						this.disabled = true;
						if ('artist' in this) delete this.artist;
					}
				};
				dcForm.append(button);
				button = document.createElement('INPUT');
				button.type = 'button';
				button.id = 'dc-search';
				button.value = 'Search artist';
				button.onclick = function(evt) {
					if (evt.altKey && evt.currentTarget.matchedArtists && evt.currentTarget.matchedArtists.length > 1)
						addDisambiguationInfo(evt.currentTarget, evt.ctrlKey);
					else if (evt.ctrlKey && !evt.altKey && typeof evt.currentTarget.forumAction == 'function')
						evt.currentTarget.forumAction();
					else if (evt.shiftKey && !evt.altKey && !evt.ctrlKey)
						autoLookup();
					else GM_openInTab('https://www.discogs.com/search/?' + new URLSearchParams({
						q: artist.name,
						type: 'artist',
						layout: 'med',
					}).toString(), false);
				};
				button.progress = setInterval(function(elem) {
					const phases = '|/-\\';
					let index = /\[(.)\]$/.exec(elem.value);
					index = index != null ? phases.indexOf(index[1]) : -1;
					elem.value = 'Search artist [' + phases[(index + 1) % 4] + ']';
				}, 200, button);
				dcForm.append(button);
				dcForm.append(document.createElement('BR'));
				addFetchButton('everything');
				for (let key in fetchers) addFetchButton((fetchers[key][1] || fetchers[key][0]) + ' only', key + '-only');
				button = document.createElement('INPUT');
				button.type = 'button';
				button.id = 'link-related';
				button.value = button.dataset.caption = 'Link related';
				button.style = 'display: none; margin-left: 1em;';
				button.onclick = linkRelated;
				const tooltip = `Make similar to all aliases/members/groups with at least one matched release\n\nCTRL + click: include name variants\nALT + click: only active (applies to groups and members)`;
				window.tooltipster.then(() => { $(button).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) }).catch(function(reason) {
					button.title = tooltip;
					console.warn(reason);
				});
				dcForm.append(button);
				elem.append(dcForm);
				aliasesRoot.append(elem);
				if (GM_getValue('auto_artist_lookup', true)) autoLookup(); else window.tooltipster.then(function() {
					$(button).tooltipster({ delay: 100, content: 'Shift+click: perform automatic artist lookup and identification' });
				});
			}

			addDiscogsImport();
		} else {
			const selBase = 'div#discog_table > table > tbody > tr.group > td:first-of-type';
			const selCheckboxes = selBase + ' input[type="checkbox"][name="separate"]';
			const check = () => input.value.trim().length > 0
				&& document.body.querySelectorAll(selCheckboxes + ':checked').length > 0;

			function changeArtist(evt) {
				if (!check()) return false;
				const button = evt.currentTarget;
				let newArtist = /^\s*\d+\s*$/.test(input.value) && parseInt(input.value);
				if (!(newArtist > 0) && (newArtist = input.value.trim())) try {
					let url = new URL(newArtist);
					if (url.origin == document.location.origin && url.pathname == '/artist.php'
							&& (url = parseInt(url.searchParams.get('id'))) > 0) newArtist = url;
				} catch(e) { }
				(newArtist > 0 ? getSiteArtist(newArtist) : getSiteArtist(newArtist)).catch(reason => reason).then(function(targetArtist) {
					if (newArtist > 0 && !(newArtist = targetArtist.name))
						return Promise.reject('Artist with this ID doesn\'t exist');
					const selectedGroups = Array.from(document.body.querySelectorAll(selCheckboxes + ':checked'))
						.map(checkbox => checkbox.parentNode.parentNode.parentNode.querySelector('div.group_info > strong > a:last-of-type'))
						.filter(a => a instanceof HTMLElement).map(a => parseInt(new URLSearchParams(a.search).get('id')))
						.filter(groupId => groupId > 0);
					const torrentGroups = { };
					for (let torrentGroup of artist.torrentgroup.filter(tg => selectedGroups.includes(tg.groupId))) {
						console.assert(!(torrentGroup.groupId in torrentGroups), '!(torrentGroup.groupId in this.groups)');
						if (!torrentGroup.extendedArtists) {
							if (!artistlessGroups.has(torrentGroup.groupId)) {
								console.warn(`Warning: artistless group "${torrentGroup.groupName}" found; if any script's operation fails, add some artists first`,
									document.location.origin + '/torrents.php?id=' + torrentGroup.groupId.toString());
								// GM_openInTab('https://redacted.ch/torrents.php?id=' + torrentGroup.groupId.toString(), true);
								artistlessGroups.add(torrentGroup.groupId);
							}
							continue;
						}
						const importances = Object.keys(torrentGroup.extendedArtists)
							.filter(importance => Array.isArray(torrentGroup.extendedArtists[importance])
								&& torrentGroup.extendedArtists[importance].some(_artist => _artist.id == artist.id))
							.map(key => parseInt(key)).filter((el, ndx, arr) => arr.indexOf(el) == ndx);
						console.assert(importances.length > 0, 'importances.length > 0');
						if (importances.length > 0) torrentGroups[torrentGroup.groupId] = importances;
					}
					const groupIds = Object.keys(torrentGroups);
					console.assert(selectedGroups.length == groupIds.length, 'selectedGroups.length == groupIds.length',
						selectedGroups, groupIds);
					if (groupIds.length <= 0) throw 'Assertion failed: none of selected releases include this artist';
					let nagText = `
You're going to replace all instances of ${artist.name}
in ${groupIds.length} releases by identity "${newArtist}"`;
					if (targetArtist.id) nagText += ' (' + targetArtist.id + ')';
					if (!confirm(nagText + '\n\nConfirm your choice to proceed')) return;
					button.disabled = true;
					button.style.color = 'red';
					button.value = '[ processing... ]';
					button.title = 'Don\'t break the operation, navigate away, reload or close current page';
					const changeArtistInGroup = groupId => torrentGroups[groupId] ?
						deleteArtistFromGroup(groupId, artist.id, torrentGroups[groupId])
							.then(() => addAliasToGroup(groupId, newArtist, torrentGroups[groupId]))
						: Promise.reject('Invalid group id');
					const finalize = () => resolveArtistId(targetArtist.id || newArtist).then(function(newArtistId) {
						if (groupIds.length < artist.torrentgroup.length) {
							if (newArtistId != artist.id)
								GM_openInTab(document.location.origin + '/artist.php?id=' + newArtistId.toString(), false);
							document.location.reload();
						} else if (newArtistId != artist.id) gotoArtistPage(newArtistId); else document.location.reload();
					});
					return (groupIds.length > 0 ? changeArtistInGroup(groupIds[0]).then(wait)
								.then(() => Promise.all(groupIds.slice(1).map(changeArtistInGroup)))
							: changeArtistInGroup(groupIds[0])).then(finalize, function(reason) {
						alert(reason);
						// Old serial method (fallback)
						return (function changeArtistInGroup(index = 0) {
							if (!(index >= 0 && index < groupIds.length)) return Promise.resolve('Current artist removed from all groups');
							const importances = torrentGroups[groupIds[index]];
							console.assert(Array.isArray(importances) && importances.length > 0,
								'Array.isArray(importances) && importances.length > 0');
							return Array.isArray(importances) && importances.length > 0 ?
								deleteArtistFromGroup(groupIds[index], artist.id, importances)
									.then(() => addAliasToGroup(groupIds[index], newArtist, importances))
									.then(() => changeArtistInGroup(index + 1))
								: changeArtistInGroup(index + 1);
						})().then(finalize);
					});
				}).catch(function(reason) {
					button.removeAttribute('title');
					button.value = button.dataset.caption;
					button.style.color = null;
					button.disabled = false;
					alert(reason);
				});
			}

			function applyState(state, testFunc) {
				for (let input of document.body.querySelectorAll(selCheckboxes)) {
					if (input.checked == state || input.parentNode.parentNode.parentNode.offsetWidth <= 0) return;
					let groupInfo = input.parentNode.parentNode.parentNode.querySelector('div.group_info');
					if (groupInfo != null) groupInfo = {
						id: groupInfo.querySelector(':scope > strong > a:last-of-type'),
						year: groupInfo.querySelector(':scope > strong'),
						tags: Array.from(groupInfo.querySelectorAll('div.tags > a')).map(a => a.textContent.trim()),
					}; else { // assertion failed
						console.warn('Assertion failed: group info not found (', input.parentNode.parentNode.parentNode, ')');
						continue;
					}
					if (groupInfo.id != null && groupInfo.year != null) {
						groupInfo.titleNorm = titleCmpNorm(groupInfo.title = groupInfo.id.textContent.trim());
						groupInfo.titleCaseless = stripRlsSuffix(groupInfo.title).toLowerCase();
						groupInfo.id = new URLSearchParams(groupInfo.id.search);
						if (!((groupInfo.id = parseInt(groupInfo.id.get('id'))) > 0)) continue; // assertion failed
						if ((groupInfo.year = /\b(\d{4})\b/.exec(groupInfo.year.firstChild.textContent)) == null) continue; // assertyion failed
						if (!((groupInfo.year = parseInt(groupInfo.year[1])) > 0)) continue; // assertyion failed
					} else continue; // assertion failed
					groupInfo.torrentGroup = artist.torrentgroup.find(torrentGroup => torrentGroup.groupId == groupInfo.id);
					console.assert(groupInfo.torrentGroup, `Torrent group id ${groupInfo.id} not found in API data`);
					if (!testFunc(groupInfo)) continue;
					input.checked = typeof state == 'boolean' ? state : !input.checked;
					input.dispatchEvent(new Event('change'));
				}
			}
			function selAll(state) {
				for (let input of document.body.querySelectorAll(selCheckboxes)) {
					if (input.checked == state || input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue;
					input.checked = typeof state == 'boolean' ? state : !input.checked;
					input.dispatchEvent(new Event('change'));
				}
			}
			function selByDiscogs(state, elem) {
				let dcInput = prompt('Enter Discogs artist id or URL; site releases found in artist\'s Discogs releases will be matched\n\n');
				if (!dcInput) return;
				let artistId = parseInt(dcInput);
				if (!artistId) {
					artistId = /\/artist\/(\d+)\b/i.exec(dcInput.trim());
					if (artistId == null || !(artistId = parseInt(artistId[1]))) return;
				}
				const releases = [ ];
				getDiscogsArtistReleases(artistId).then(function(releases) {
					if (elem instanceof HTMLElement) elem.disabled = true;
					const globalWorkers = [ ];
					document.body.querySelectorAll(selCheckboxes).forEach(function(input) {
						function applyState() {
							input.checked = typeof state == 'boolean' ? state : !input.checked;
							input.dispatchEvent(new Event('change'));
						}

						if (input.checked == state || input.parentNode.parentNode.parentNode.offsetWidth <= 0) return;
						let groupInfo = input.parentNode.parentNode.parentNode.querySelector('div.group_info > strong');
						if (groupInfo == null) { // assertion failed
							console.warn('Assertion failed: group info not found (', input.parentNode.parentNode.parentNode, ')');
							return;
						}
						groupInfo = {
							year: /\b(\d{4})\b/.exec(groupInfo.firstChild.textContent),
							title: groupInfo.getElementsByTagName('A'),
						};
						if (groupInfo.year != null && groupInfo.title.length > 0) {
							groupInfo.year = parseInt(groupInfo.year[1]),
							groupInfo.title = groupInfo.title[0].textContent.trim();
						} else return; // assertion failed
						const titleNorm = [titleCmpNorm(groupInfo.title), stripRlsSuffix(groupInfo.title).toLowerCase()];
						let masterLookups = new Set;
						if (releases.some(function(release) {
							if (release.year < groupInfo.year || titleCmpNorm(release.title) != titleNorm[0]
									&& jaroWrinkerSimilarity(stripRlsSuffix(release.title).toLowerCase(), titleNorm[1]) < sameTitleConfidence) return false;
							if (release.year == groupInfo.year) return true;
							if (release.type == 'master') {
								if (dcMasterYears.has(release.id)) return groupInfo.year == dcMasterYears.get(release.id);
								masterLookups.add(release.id);
							}
							return false;
						})) applyState(); else if (masterLookups.size > 0) {
							console.log(masterLookups.size, 'master release(s) to lookup on Discogs');
							globalWorkers.push(Promise.all(Array.from(masterLookups.values()).map(masterId =>
									getDiscogsEntry('master', masterId).then(function(master) {
								dcMasterYears.set(master.id, master.year);
								return { [master.id]: master.year };
							}))).then(results => Object.assign.apply({ }, results.filter(Boolean))).then(function(masterYears) {
								//GM_setValue('discogs_master_years', Array.from(dcMasterYears).slice(-1000));
								if (releases.some(release => release.year > groupInfo.year && release.type == 'master'
										&& masterYears[release.id] == groupInfo.year && (titleCmpNorm(release.title) == titleNorm[0]
										|| jaroWrinkerSimilarity(stripRlsSuffix(release.title).toLowerCase(), titleNorm[1]) >= sameTitleConfidence)))
									applyState();
							}));
						}
					});
					Promise.all(globalWorkers).then(function() {
						if (!(elem instanceof HTMLElement)) return;
						elem.disabled = false;
						elem.style.color = 'green';
						elem.style.fontWeight = 'bold';
						setTimeout(function() {
							elem.style.fontWeight = null;
							elem.style.color = null;
						}, 1000);
					});
				}, alert);
			}
			function selByBeatPort(state, elem) {
				let bpInput = prompt('Enter BeatPort artist id or URL; site releases found in artist\'s releases will be matched\n\n');
				if (!bpInput) return;
				let artistId = parseInt(bpInput);
				if (!artistId) { // https://www.beatport.com/artist/razmik-makhsudyan/484262/releases
					artistId = /\/artist(?:\/.+?)*\/(\d+)\b/i.exec(bpInput.trim());
					if (artistId == null || !(artistId = parseInt(artistId[1]))) return;
				}
				bpGetArtistReleases(artistId).then(function(releases) {
					if (releases.length > 0) applyState(state, groupInfo => releases.some(function(release) {
						const newReleaseDate = new Date(release.new_release_date), publishDate = new Date(release.publish_date);
						console.assert(!isNaN(newReleaseDate), 'Invalid release date in BeatPort data: ' + release.new_release_date);
						console.assert(!isNaN(publishDate), 'Invalid publish date in BeatPort data: ' + release.publish_date);
						const yearMatch = newReleaseDate.getFullYear() == groupInfo.year;
						if (!yearMatch && !(newReleaseDate.getFullYear() >= groupInfo.year)) return false;
						if (titleCmpNorm(release.name) == groupInfo.titleNorm) return true; else if (!yearMatch) return false;
						return jaroWrinkerSimilarity(stripRlsSuffix(release.name).toLowerCase(), groupInfo.titleCaseless) >= sameTitleConfidence;
					}));
					if (!(elem instanceof HTMLElement)) return;
					elem.disabled = false;
					elem.style.color = 'green';
					elem.style.fontWeight = 'bold';
					setTimeout(function() {
						elem.style.fontWeight = null;
						elem.style.color = null;
					}, 1000);
				}, alert);
			}
			function selByApple(state, elem) {
				let amInput = prompt('Enter Apple artist id or URL; site releases found in artist\'s releases will be matched\n\n');
				if (!amInput) return;
				let artistId = parseInt(amInput);
				if (!artistId) {
					artistId = /\/artist(?:\/.+?)*\/(\d+)\b/i.exec(amInput.trim());
					if (artistId == null || !(artistId = parseInt(artistId[1]))) return;
				}
				amGetArtistAlbums(artistId).then(function(albums) {
					if (albums.length > 0) applyState(state, groupInfo => albums.some(function(album) {
						const releaseDate = new Date(album.attributes.releaseDate);
						console.assert(!isNaN(releaseDate), 'Invalid release date in Apple Music data: ' + album.releaseDate);
						const yearMatch = releaseDate.getFullYear() == groupInfo.year;
						if (!yearMatch && !(releaseDate.getFullYear() >= groupInfo.year)) return false;
						if (titleCmpNorm(album.attributes.name) == groupInfo.titleNorm) return true; else if (!yearMatch) return false;
						return jaroWrinkerSimilarity(stripRlsSuffix(album.attributes.name).toLowerCase(), groupInfo.titleCaseless) >= sameTitleConfidence;
					}));
					if (!(elem instanceof HTMLElement)) return;
					elem.disabled = false;
					elem.style.color = 'green';
					elem.style.fontWeight = 'bold';
					setTimeout(function() {
						elem.style.fontWeight = null;
						elem.style.color = null;
					}, 1000);
				}, alert);
			}
			function selByMusicBrainz(state, elem) {
				let mbInput = prompt('Enter MusicBrainz artist id or URL; site releases found in artist\'s releases will be matched\n\n');
				if (!mbInput || !(mbInput = mbInput.trim())) return;
				let artistId = /\b([a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12})\b/i.exec(mbInput);
				if (artistId != null) artistId = artistId[1]; else return;
				mbGetArtistReleases(artistId).then(function(releaseGroups) {
					if (releaseGroups.length <= 0) return;
					applyState(state, groupInfo => releaseGroups.some(function(releaseGroup) {
						const firstReleaseDate = new Date(releaseGroup['first-release-date']);
						console.assert(!isNaN(firstReleaseDate), 'Invalid release date in MusicBrainz data: ' + releaseGroup['first-release-date']);
						if (firstReleaseDate.getFullYear() != groupInfo.year) return false;
						if (titleCmpNorm(releaseGroup.title) == groupInfo.titleNorm) return true;
						return jaroWrinkerSimilarity(stripRlsSuffix(releaseGroup.title).toLowerCase(), groupInfo.titleCaseless) >= sameTitleConfidence;
					}));
					if (!(elem instanceof HTMLElement)) return;
					elem.disabled = false;
					elem.style.color = 'green';
					elem.style.fontWeight = 'bold';
					setTimeout(function() {
						elem.style.fontWeight = null;
						elem.style.color = null;
					}, 1000);
				}, alert);
			}
			function selByTags(state) {
				let tags = prompt('Enter gazelle tags(s) or list of genres separated by comma; all tags will be matched (AND); to select groups with any of list of tags (OR), repeat the selector more times\n\n');
				if (!tags || (tags = new TagManager(tags)).length <= 0) return;
				applyState(state, groupInfo => tags.every(tag => groupInfo.tags.includes(tag)));
			}
			function selByGroupIds(state) {
				let groupIds = prompt('Enter torrent group link(s) or id(s) separated by comma or whitespace\n\n');
				if (groupIds && (groupIds = groupIds.split(/[\s\,\;]+/).map(function(expr) {
					let groupId = /^(?:\d+)$/.test(expr = expr.trim()) && parseInt(expr);
					if (groupId > 0) return groupId;
					try {
						if ((groupId = new URL(expr)).hostname == document.location.hostname
								&& groupId.pathname == '/torrents.php'
								&& (groupId = groupId.searchParams.get('id')) > 0) return groupId;
					} catch(e) { }
				}).filter(Boolean)).length > 0) applyState(state, groupInfo => groupIds.includes(groupInfo.id));
			}
			function selByArtistIds(state) {
				let artistIds = prompt('Enter site artist name(s)/alias(es), link(s) or id(s) separated by comma; all releases from their profiles will be matched\n\n');
				if (artistIds) Promise.all(artistIds.split(/(?:\r?\n|[\,\;])+/).map(function(expr) {
					let artistId = /^(?:\d+)$/.test(expr = expr.trim()) && parseInt(expr);
					if (artistId > 0) return getSiteArtist(artistId);
					if (/^https?:\/\//i.test(expr)) try {
						if ((artistId = new URL(expr)).hostname == document.location.hostname && artistId.pathname == '/artist.php'
								&& (artistId = parseInt(artistId.searchParams.get('id'))) > 0)
							return getSiteArtist(artistId);
					} catch(e) { }
					return getSiteArtist(expr);
				}).map(result => result.then(artist => Array.isArray(artist.torrentgroup) ?
						artist.torrentgroup.map(torrentGroup => torrentGroup.groupId) : [ ], reason => [ ]))).then(function(groupIds) {
					groupIds = Array.prototype.concat.apply([ ], groupIds);
					groupIds = groupIds.filter((groupId, ndx, arr) => arr.indexOf(groupId) == ndx);
					if (groupIds.length > 0) applyState(state, groupInfo => groupIds.includes(groupInfo.id));
				});
			}
			function selByArtists(state) {
				if (!Array.isArray(artist.torrentgroup) || artist.torrentgroup.length <= 0) return;
				let artists = prompt('Enter site artist/alias name(s) or artist id(s) separated by comma; all releases with their appearance will be matched\n\nNote: if literal name is used for an artist unifying more identities, only corresponding alias will be matched. To match any instance of multi-identity artist, enter that artist id instead\n\n');
				if (artists) Promise.all(artists.split(/(?:\r?\n|[\,\;])+/).map(function(expr) {
					let artistId = /^(?:\d+)$/.test(expr = expr.trim()) && parseInt(expr);
					if (artistId > 0) return getSiteArtist(artistId).then(artist => artist.id);
					if (/^https?:\/\//i.test(expr)) try {
						if ((artistId = new URL(expr)).hostname == document.location.hostname && artistId.pathname == '/artist.php'
								&& (artistId = artistId.searchParams.get('id')) > 0)
							return getSiteArtist(artistId).then(artist => artist.id);
					} catch(e) { }
					return getSiteArtist(expr).then(artist => resolveAliasId(expr, artist.id, true).then(aliasId => ({
						artistId: artist.id,
						aliasId: aliasId,
					})));
				}).map(result => result.catch(reason => null))).then(function(ids) {
					if ((ids = ids.filter(Boolean)).length > 0) applyState(state, function(groupInfo) {
						const torrentGroup = artist.torrentgroup.find(torrentGroup => torrentGroup.groupId == groupInfo.id);
						if (!torrentGroup || !torrentGroup.extendedArtists) return; // assertion failed!
						return Object.keys(torrentGroup.extendedArtists).some(function(importance) {
							if (!Array.isArray(torrentGroup.extendedArtists[importance])) return false;
							return torrentGroup.extendedArtists[importance].some(groupArtist => ids.some(function(id) {
								if (typeof id == 'number') return groupArtist.id == id;
								if (typeof id == 'object') return groupArtist.id == id.artistId && groupArtist.aliasid == id.aliasId;
								return false;
							}));
						});
					});
				});
			}

			function getGroupsInfo() {
				if (!Array.isArray(artist.torrentgroup) || artist.torrentgroup.length <= 0) return null;
				const groupsInfo = artist.torrentgroup.map(function(torrentGroup) {
					if (!torrentGroup.extendedArtists) return;
					const importances = Object.keys(torrentGroup.extendedArtists).map(function(importance) {
						if (!Array.isArray(torrentGroup.extendedArtists[importance])) return;
						const artists = torrentGroup.extendedArtists[importance]
							.filter(alias => alias.id == artist.id);
						if (artists.length > 0) return { [importance]: artists };
					}).filter(Boolean);
					if (importances.length > 0)
						return { [torrentGroup.groupId]: Object.assign.apply({ }, importances) };
				}).filter(Boolean);
				return groupsInfo.length > 0 ? Object.assign.apply({ }, groupsInfo) : null;
			}

			const form = document.getElementById('artist-replacer');
			if (form == null) throw 'Assertion failed: form cannot be found';
			let elem, div = document.createElement('DIV');
			div.className = 'selecting';
			div.style.padding = '0 5pt 5pt 5pt';

			function addQS(caption, title) {
				elem = document.createElement('A');
				elem.className = 'brackets'
				if (div.childElementCount > 0) elem.style.marginLeft = '6pt';
				if (caption) {
					elem.textContent = caption;
					if (!title) if (caption.endsWith('+')) elem.title = 'Selects matched releases';
						else if (caption.endsWith('-')) elem.title = 'Unselects matched releases';
							else if (caption.endsWith('*')) elem.title = 'Inverts selection on matched releases';
				}
				if (title) elem.title = title;
				elem.href = '#';
				div.append(elem);
				return elem;
			}
			addQS('All+').onclick = evt => (selAll(true), false);
			addQS('All-').onclick = evt => (selAll(false), false);
			addQS('All*').onclick = evt => (selAll(), false);
			addQS('Discogs+').onclick = evt => (selByDiscogs(true, evt.currentTarget), false);
			addQS('Discogs-').onclick = evt => (selByDiscogs(false, evt.currentTarget), false);
			// addQS('Discogs*').onclick = evt => (selByDiscogs(), false);
			addQS('MusicBrainz+').onclick = evt => (selByMusicBrainz(true, evt.currentTarget), false);
			addQS('MusicBrainz-').onclick = evt => (selByMusicBrainz(false, evt.currentTarget), false);
			// addQS('MusicBrainz*').onclick = evt => (selByMusicBrainz(), false);
			addQS('BeatPort+').onclick = evt => (selByBeatPort(true, evt.currentTarget), false);
			addQS('BeatPort-').onclick = evt => (selByBeatPort(false, evt.currentTarget), false);
			// addQS('BeatPort*').onclick = evt => (selByBeatPort(), false);
			addQS('GroupIds+').onclick = evt => (selByGroupIds(true), false);
			addQS('GroupIds-').onclick = evt => (selByGroupIds(false), false);
			// addQS('GroupIds*').onclick = evt => (selByGroupIds(), false);
			addQS('Tags+').onclick = evt => (selByTags(true), false);
			addQS('Tags-').onclick = evt => (selByTags(false), false);
			// addQS('Tags*').onclick = evt => (selByTags(), false);
			addQS('ArtRlss+').onclick = evt => (selByArtistIds(true), false);
			addQS('ArtRlss-').onclick = evt => (selByArtistIds(false), false);
			// addQS('ArtistIds*').onclick = evt => (selByArtistIds(), false);
			addQS('Artists+').onclick = evt => (selByArtists(true), false);
			addQS('Artists-').onclick = evt => (selByArtists(false), false);
			// addQS('Artists*').onclick = evt => (selByArtists(), false);
			form.append(div);

			const input = document.createElement('INPUT');
			input.type = 'text';
			input.placeholder = 'New artist/alias name or artist id';
			input.style.width = '94%';
			input.dataset.gazelleAutocomplete = true;
			input.autocomplete = 'off';
			input.spellcheck = false;
			try { $(input).autocomplete({ serviceUrl: 'artist.php?action=autocomplete' }) } catch(e) { console.error(e) }
			form.append(input);
			form.append(document.createElement('BR'));
			elem = document.createElement('INPUT');
			elem.type = 'button';
			elem.value = elem.dataset.caption = 'GO';
			elem.onclick = changeArtist;
			form.append(elem);
			elem = document.createElement('INPUT');
			elem.type = 'button';
			elem.style = 'margin-left: 1em;';
			elem.value = 'C';
			elem.title = 'Clipboard copy recovery info for case of failure';
			elem.onclick = function(evt) {
				const groupsInfo = getGroupsInfo();
				if (groupsInfo) GM_setClipboard(JSON.stringify(groupsInfo)); else return;
				evt.currentTarget.style.color = 'lightgreen';
				setTimeout(elem => { elem.style.color = null }, 1000, evt.currentTarget);
			};
			form.append(elem);
			elem = document.createElement('INPUT');
			elem.type = 'button';
			elem.style = 'margin-left: 2pt;';
			elem.value = 'R';
			elem.title = 'Use recovery info to restore this artist on all involved releases';
			elem.onclick = function(evt) {
				let groupsInfo = prompt('Paste previously copied object (leave blank to obtain from current page snapshot):\n\n');
				if (groupsInfo == undefined) return;
				if (groupsInfo) try { groupsInfo = JSON.parse(groupsInfo) } catch(e) {
					console.warn(e);
					return;
				} else if (!(groupsInfo = getGroupsInfo())) return;
				const names = new Set;
				for (let torrentGroup in groupsInfo) for (let importance in groupsInfo[torrentGroup])
					for (let alias of groupsInfo[torrentGroup][importance]) names.add(alias.name);
				groupsInfo = Array.from(names.keys()).map(function(name) {
					let groups = Object.keys(groupsInfo).map(function(groupId) {
						let importances = Object.keys(groupsInfo[groupId])
							.filter(importance => groupsInfo[groupId][importance].some(alias => alias.name == name))
							.map(importance => parseInt(importance));
						if (importances.length > 0) return { [groupId]: importances };
					}).filter(Boolean);
					if (groups.length > 0) return { [name]: Object.assign.apply({ }, groups) };
				}).filter(Boolean);
				if (groupsInfo.length > 0) groupsInfo = Object.assign.apply({ }, groupsInfo); else return;
				console.info('groupsInfo:', groupsInfo);
				const currentTarget = evt.currentTarget;
				Promise.all(Object.keys(groupsInfo).map(function(name) {
					const groupIds = Object.keys(groupsInfo[name]);
					const _addAliasToGroup = groupId =>
						addAliasToGroup(groupId, name, groupsInfo[name][groupId]);
						//Promise.resolve(console.log(`addAliasToGroup(${groupId}, '${name}', [${groupsInfo[name][groupId]}]);`));
					return groupIds.length > 1 ? _addAliasToGroup(groupIds[0]).then(wait)
							.then(() => Promise.all(groupIds.slice(1).map(_addAliasToGroup))).catch(function(reason) {
						console.warn('addAliasToGroups parallely failed, trying serially:', reason);
						return (function _addAliasToGroup(index = 0) {
							if (!(index >= 0 && index < groupIds.length))
								return Promise.resolve('Artist alias re-added to all groups');
							const importances = groupsInfo[name][groupIds[index]];
							console.assert(Array.isArray(importances) && importances.length > 0,
								'Array.isArray(importances) && importances.length > 0');
							return Array.isArray(importances) && importances.length > 0 ?
								addAliasToGroup(groupIds[index], name, importances)
									.then(result => _addAliasToGroup(index + 1))
								: _addAliasToGroup(index + 1);
						})();
					}) : _addAliasToGroup(groupIds[0]);
				})).then(function() {
					currentTarget.style.color = 'lightgreen';
					document.location.reload();
				});
			};
			form.append(elem);
			elem = document.createElement('SPAN');
			elem.class = 'totals';
			elem.style = 'float: right; margin: 5pt 1em 0 0;';
			const counter = document.createElement('SPAN');
			counter.id = 'selection-counter';
			counter.textContent = 0;
			elem.append(counter, ' / ' + document.body.querySelectorAll(selBase).length.toString());
			form.append(elem);

			function isOutside(target, related) {
				if (target instanceof HTMLElement) {
					target = target.parentNode;
					while (related instanceof HTMLElement) if ((related = related.parentNode) == target) return false;
				}
				return true;
			}
			for (let td of document.body.querySelectorAll('div#discog_table > table > tbody > tr.colhead_dark > td.small')) {
				const label = document.createElement('LABEL');
				label.style = 'padding: 1pt 5pt; cursor: pointer; transition: 0.25s;';
				elem = document.createElement('INPUT');
				elem.type = 'checkbox';
				elem.name = 'select-category';
				elem.style.cursor = 'pointer';
				elem.onchange = function(evt) {
					for (let input of evt.currentTarget.parentNode.parentNode.parentNode.parentNode
							 .querySelectorAll('tr.group > td:first-of-type input[type="checkbox"][name="separate"]')) {
						if (input.checked == evt.currentTarget.checked
								|| input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue;
						input.checked = evt.currentTarget.checked;
						input.dispatchEvent(new Event('change'));
					}
				};
				label.onmouseenter = evt => { label.style.backgroundColor = 'orange' };
				label.onmouseleave = evt =>
					{ if (isOutside(evt.currentTarget, evt.relatedTarget)) label.style.backgroundColor = null };
				label.append(elem);
				td.append(label);
			}
			for (let tr of document.body.querySelectorAll(['edition', 'torrent_row']
				.map(cls => 'div#discog_table > table > tbody > tr.' + cls).join(', '))) tr.remove();
			for (let td of document.body.querySelectorAll(selBase)) {
				while (td.firstChild != null) td.removeChild(td.firstChild);
				const label = document.createElement('LABEL');
				label.style = 'padding: 7pt; cursor: pointer; opacity: 1; transition: 0.25s;';
				elem = document.createElement('INPUT');
				elem.type = 'checkbox';
				elem.name = 'separate';
				elem.style.cursor = 'pointer';
				elem.onchange = function(evt) {
					evt.currentTarget.parentNode.parentNode.parentNode.style.opacity = evt.currentTarget.checked ? 1 : 0.75;
					if (evt.currentTarget.checked) ++counter.textContent; else --counter.textContent;
				};
				label.onmouseenter = evt => { label.style.backgroundColor = 'orange' };
				label.onmouseleave = evt =>
					{ if (isOutside(evt.currentTarget, evt.relatedTarget)) label.style.backgroundColor = null };
				label.append(elem);
				td.append(label);
				td.parentNode.style.opacity = 0.75;
			}
		}
	});
}

if (artistEdit) {
	if (!window.tooltipster) window.tooltipster = typeof jQuery.fn.tooltipster == 'function' ?
			Promise.resolve(jQuery.fn.tooltipster) : new Promise(function(resolve, reject) {
		const script = document.createElement('SCRIPT');
		script.src = '/static/functions/tooltipster.js';
		script.type = 'text/javascript';
		script.onload = function(evt) {
			console.log('tooltipster.js was successfully loaded', evt);
			if (typeof jQuery.fn.tooltipster == 'function') resolve(jQuery.fn.tooltipster);
				else reject('tooltipster.js loaded but core function was not found');
		};
		script.onerror = evt => { reject('Error loading tooltipster.js') };
		document.head.append(script);
		['style.css'/*, 'custom.css', 'reset.css'*/].forEach(function(css) {
			const link = document.createElement('LINK');
			link.rel = 'stylesheet';
			link.type = 'text/css';
			link.href = '/static/styles/tooltipster/' + css;
			//link.onload = evt => { console.log('style.css was successfully loaded', evt) };
			link.onerror = evt => { (css == 'style.css' ? reject : console.warn)('Error loading ' + css) };
			document.head.append(link);
		});
	});
	loadArtist();
} else {
	function copyGroupIds(root = document.body) {
		if (!(root instanceof HTMLElement)) return; // assertion failed
		const groupIds = Array.from(root.querySelectorAll('tbody > tr.group div.group_info > strong > a:last-of-type')).map(function(a) {
			if (a.parentNode.parentNode.parentNode.parentNode.offsetWidth <= 0) return false;
			a = new URLSearchParams(a.search);
			if ((a = parseInt(a.get('id'))) > 0) return a;
		}).filter(Boolean);
		if (groupIds.length > 0) GM_setClipboard(groupIds.join('\n'), 'text');
	}
	const hdr = document.body.querySelector('div#content div.header > h2');
	if (hdr != null) {
		hdr.style.cursor = 'pointer';
		hdr.onclick = evt => (copyGroupIds(document.getElementById('discog_table')), false);
	}
	for (let strong of document.body.querySelectorAll('table > tbody > tr.colhead_dark > td > strong')) {
		strong.style.cursor = 'pointer';
		strong.onclick = evt => (copyGroupIds(evt.currentTarget.parentNode.parentNode.parentNode.parentNode), false);
	}

	const sidebar = document.body.querySelector('div#content div.sidebar');
	if (sidebar == null) throw 'Assertion failed: sidebar couldnot be located';
	const elems = createElements('DIV', 'FORM', 'DIV', 'INPUT');
	elems.push(document.body.querySelector('div#content div.header > h2'));
	elems[0].className = 'box box_replace_artist';
	elems[0].innerHTML = '<div class="head"><strong>Artist replacer</strong></div>';
	elems[1].id = 'artist-replacer';
	elems[1].style.padding = '6pt';
	elems[2].textContent = 'This tool will replace all instances of ' +
		(elems[4] != null ? elems[4].textContent.trim() : 'artist') +
		' in selected releases with different name, whatever existing or new.';
	elems[2].style = 'margin-bottom: 1em; font-size: 9pt;';
	elems[1].append(elems[2]);
	elems[3].type = 'button';
	elems[3].value = 'Enter selection mode';
	elems[3].onclick = function(evt) {
		while (elems[1].firstChild != null) elems[1].removeChild(elems[1].firstChild);
		loadArtist().catch(function(reason) {
			const span = document.createElement('SPAN');
			span.textContent = 'Error loading artist releases: ' + reason;
			span.style.color = 'red';
			elems[1].append(span);
		});
	}
	elems[1].append(elems[3]);
	elems[0].append(elems[1]);
	sidebar.append(elems[0]);
}