mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-07 22:18:30 -04:00
401872881e
* media: add opt-in Apple Music animated album covers * media: validate itunes match, touch files for cache --------- Co-authored-by: bbedward <bbedward@gmail.com>
266 lines
10 KiB
QML
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;
|
|
}
|
|
}
|