1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-07 22:18:30 -04:00
Files
DankMaterialShell/quickshell/Services/AppleMusicArtService.qml
T
FormalSnake 401872881e media: add opt-in Apple Music animated album covers (#2918)
* media: add opt-in Apple Music animated album covers

* media: validate itunes match, touch files for cache

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-08-06 14:47:51 -04:00

266 lines
10 KiB
QML

pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
Singleton {
id: root
readonly property var log: Log.scoped("AppleMusicArt")
readonly property bool enabled: SettingsData.appleMusicAnimatedArtEnabled
// file:// url of the current album's downloaded animated cover, or empty.
property string animatedArtUrl: ""
readonly property var _curlCmd: ["curl", "-sS", "--fail", "--connect-timeout", "5", "--max-time", "15", "--compressed"]
readonly property string _artDir: Paths.strip(Paths.cache) + "/applemusic-art"
property string _token: ""
// artist\nalbum (lowercased) -> file url or "" for a known miss; never refetched this session.
property var _cache: ({})
property int _serial: 0
readonly property string _artist: MprisController.activePlayer?.trackArtist || ""
readonly property string _album: MprisController.activePlayer?.trackAlbum || ""
readonly property string _cacheKey: _artist !== "" && _album !== "" ? (_artist + "\n" + _album).toLowerCase() : ""
on_CacheKeyChanged: _schedule()
onEnabledChanged: _schedule()
Component.onCompleted: {
_prune();
_schedule();
}
// Downloaded covers are a few MB each; drop anything untouched for 30 days so the
// cache can't grow without bound.
function _prune() {
Proc.runCommand(null, ["sh", "-c", 'test -d "$1" && find "$1" -type f -mtime +30 -delete', "sh", _artDir], () => {}, 50, 10000);
}
function _schedule() {
_serial++;
_debounce.stop();
if (!enabled || _cacheKey === "") {
animatedArtUrl = "";
return;
}
if (_cacheKey in _cache) {
animatedArtUrl = _cache[_cacheKey];
return;
}
animatedArtUrl = "";
_debounce.restart();
}
Timer {
id: _debounce
interval: 1500
onTriggered: root._lookup()
}
function _artPath(key) {
return _artDir + "/" + Qt.md5(key) + ".mp4";
}
// Disk cache first: a hit needs no network; touching keeps _prune from evicting active covers.
function _lookup() {
const key = _cacheKey;
const serial = _serial;
Proc.runCommand(null, ["sh", "-c", 'test -s "$1" && touch "$1"', "sh", _artPath(key)], (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode === 0) {
_store(key, serial, "file://" + _artPath(key));
return;
}
_search(key, serial);
}, 50, 5000);
}
// Bidirectional contains, so "Album" still matches "Album (Deluxe Edition)" either way round.
function _looseMatch(a, b) {
if (a === "" || b === "")
return false;
const la = a.toLowerCase();
const lb = b.toLowerCase();
return la.includes(lb) || lb.includes(la);
}
// US storefront only (search default and the catalog paths below); albums absent there become misses.
function _search(key, serial) {
const term = encodeURIComponent(_artist + " " + _album);
Proc.runCommand(null, _curlCmd.concat(["https://itunes.apple.com/search?media=music&entity=album&limit=1&term=" + term]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
log.warn("itunes search failed");
return;
}
let result;
try {
result = JSON.parse(output).results[0] || null;
} catch (e) {
log.warn("itunes search parse failed");
return;
}
// A fuzzy first hit for a different record would cache the wrong video under this key.
if (!result || !result.collectionId || !_looseMatch(result.artistName || "", _artist) || !_looseMatch(result.collectionName || "", _album)) {
_store(key, serial, "");
return;
}
if (_token !== "") {
_fetchEditorialVideo(key, serial, result.collectionId);
return;
}
_fetchToken("https://music.apple.com/us/album/" + result.collectionId, token => {
if (serial !== _serial)
return;
_token = token;
_fetchEditorialVideo(key, serial, result.collectionId);
});
}, 50, 20000);
}
// The anonymous web-player JWT sits in the main JS bundle referenced by any album page.
function _fetchToken(pageUrl, callback) {
const script = "p=$(curl -sSfL --compressed \"$1\" | grep -oE '/assets/index~[a-zA-Z0-9]+\\.js' | head -1) && curl -sSfL --compressed \"https://music.apple.com$p\" | grep -oE '\"eyJ[A-Za-z0-9._-]+\"' | head -1 | tr -d '\"'";
Proc.runCommand(null, ["sh", "-c", script, "sh", pageUrl], (output, exitCode) => {
const token = (output || "").trim();
if (exitCode !== 0 || token === "") {
log.warn("failed to obtain web-player token");
return;
}
callback(token);
}, 50, 30000);
}
function _fetchEditorialVideo(key, serial, albumId) {
const url = "https://amp-api.music.apple.com/v1/catalog/us/albums/" + albumId + "?extend=editorialVideo";
Proc.runCommand(null, _curlCmd.concat(["-H", "Authorization: Bearer " + _token, "-H", "Origin: https://music.apple.com", url]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
// Token may have expired; rescrape on the next lookup.
_token = "";
log.warn("editorial video lookup failed");
return;
}
let video;
try {
const ev = JSON.parse(output).data[0].attributes.editorialVideo;
video = ev ? (ev.motionDetailSquare?.video || ev.motionSquareVideo1x1?.video || "") : "";
} catch (e) {
log.warn("editorial video parse failed");
return;
}
if (video === "") {
_store(key, serial, "");
return;
}
_fetchMaster(key, serial, video);
}, 50, 20000);
}
function _fetchMaster(key, serial, m3u8Url) {
Proc.runCommand(null, _curlCmd.concat([m3u8Url]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
log.warn("master playlist fetch failed");
return;
}
const variant = _pickVariant(output);
if (!variant) {
_store(key, serial, "");
return;
}
const variantUrl = variant.startsWith("http") ? variant : m3u8Url.slice(0, m3u8Url.lastIndexOf("/") + 1) + variant;
_fetchVariant(key, serial, variantUrl);
}, 50, 20000);
}
// Highest-bandwidth avc1 rendition at or below 768px, else the smallest one above;
// hvc1 is skipped for decoder compatibility.
function _pickVariant(master) {
const lines = master.split("\n");
let best = null;
let bestBw = -1;
let smallestOver = null;
let smallestOverW = Infinity;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (!l.startsWith("#EXT-X-STREAM-INF:") || l.indexOf("avc1") === -1)
continue;
const res = /RESOLUTION=(\d+)x/.exec(l);
if (!res)
continue;
let j = i + 1;
while (j < lines.length && (lines[j].startsWith("#") || lines[j].trim() === ""))
j++;
if (j >= lines.length)
continue;
const uri = lines[j].trim();
const w = parseInt(res[1], 10);
if (w > 768) {
if (w < smallestOverW) {
smallestOverW = w;
smallestOver = uri;
}
continue;
}
const bw = /AVERAGE-BANDWIDTH=(\d+)/.exec(l);
const bwv = bw ? parseInt(bw[1], 10) : 0;
if (bwv > bestBw) {
bestBw = bwv;
best = uri;
}
}
return best || smallestOver;
}
// The rendition playlist is BYTERANGE segments over one progressive mp4 (EXT-X-MAP).
function _fetchVariant(key, serial, variantUrl) {
Proc.runCommand(null, _curlCmd.concat([variantUrl]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
log.warn("rendition playlist fetch failed");
return;
}
const m = /#EXT-X-MAP:URI="([^"]+)"/.exec(output);
if (!m) {
_store(key, serial, "");
return;
}
const mp4 = m[1].startsWith("http") ? m[1] : variantUrl.slice(0, variantUrl.lastIndexOf("/") + 1) + m[1];
_download(key, serial, mp4);
}, 50, 20000);
}
// Download once and play the local file: streaming the HLS through the ffmpeg
// backend truncates on some TLS stacks, and the cache survives restarts.
function _download(key, serial, url) {
const path = _artPath(key);
// Per-process temp then atomic rename, so a concurrent download for the same
// album (rapid track flip-flop) can't interleave writes into one file; the
// temp is removed on failure rather than stranded as a .part.
const script = 'mkdir -p "${1%/*}" && { test -s "$1" || { t="$1.$$.part"; curl -sSf --connect-timeout 5 --max-time 60 -o "$t" "$2" && mv -f "$t" "$1" || { rm -f "$t"; exit 1; }; }; }';
Proc.runCommand(null, ["sh", "-c", script, "sh", path, url], (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
log.warn("artwork download failed");
return;
}
_store(key, serial, "file://" + path);
}, 50, 90000);
}
function _store(key, serial, url) {
_cache[key] = url;
if (serial === _serial)
animatedArtUrl = url;
}
}