Initial commit

This commit is contained in:
SuperSaltyGamer
2022-12-30 02:03:51 +02:00
commit f2c1860215
27 changed files with 2548 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.vscode/
.idea/
node_modules/
out/

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# AME
Various user scripts for the music hoarding community.

64
dist/applemusic.user.js vendored Normal file

File diff suppressed because one or more lines are too long

1427
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "ame",
"type": "module",
"homepage": "https://notabug.org/SuperSaltyGamer/ame/dist/",
"dependencies": {
"path-to-regexp": "^6.2.1"
},
"devDependencies": {
"@types/tampermonkey": "^4.0.5",
"globby": "^13.1.3",
"typescript": "^4.9.3",
"vite": "^4.0.0"
},
"scripts": {
"start": "node out/build.js",
"build": "tsc && node out/build.js --production",
"postinstall": "tsc -p tsconfig.scripts.json"
}
}

75
scripts/_userscript.ts Normal file
View File

@@ -0,0 +1,75 @@
import { readFile } from 'fs/promises';
import { basename, dirname, join } from 'path';
import { LibraryFormats, Plugin, ResolvedConfig } from 'vite';
export interface UserScriptOptions {
entry: string;
format: LibraryFormats;
name?: string;
port?: number;
cdn?: string;
}
export function _userscript(options: UserScriptOptions): Plugin {
let config: ResolvedConfig;
return {
name: 'userscript',
config(config, env) {
const name = options.name ?? basename(dirname(options.entry));
return {
build: {
lib: {
name: name,
formats: [ options.format ],
entry: {
[name]: options.entry
}
},
rollupOptions: {
output: {
entryFileNames: '[name].user.js'
}
}
}
};
},
configResolved(resolvedConfig) {
config = resolvedConfig;
},
async generateBundle(outputOptions, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type !== 'chunk') continue;
if (!chunk.isEntry) continue;
if (!chunk.facadeModuleId) continue;
const code = await readFile(chunk.facadeModuleId, { encoding: 'utf8' });
let header = '';
for (const line of code.split('\n')) {
if (!line.startsWith('//')) break;
header += line + '\n';
}
let url = '';
if (config.mode === 'production') {
if (options.cdn) url = `${options.cdn}${chunk.name}.user.js`;
} else {
if (options.port) url = `http://localhost:${options.port}/${chunk.name}.user.js`;
}
if (url) {
header = header.replaceAll(
'// ==/UserScript==',
`// @downloadURL ${url}\n` +
`// @updateURL ${url}\n` +
'// ==/UserScript=='
);
}
chunk.code = header + '\n' + chunk.code;
}
}
};
}

65
scripts/build.ts Normal file
View File

@@ -0,0 +1,65 @@
import { readFile } from 'fs/promises';
import { globbyStream } from 'globby';
import { parseArgs } from 'util';
import { build, createServer, InlineConfig } from 'vite';
import { _userscript } from './_userscript.js';
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
const args = parseArgs({
options: {
production: {
type: 'boolean',
default: false
}
}
});
const server = await createServer({
plugins: [
{
name: 'rewrite',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
req.url = '/out' + req.url;
next();
});
}
}
],
optimizeDeps: {
disabled: true
}
});
if (!args.values.production) {
await server.listen();
await server.printUrls();
}
const entries: string[] = [];
for await (const path of await globbyStream('src/**/main.ts')) {
const code = await readFile(path, { encoding: 'utf8' });
if (!code.startsWith('// ==UserScript==')) continue;
entries.push(path.toString());
}
const configs = entries.map<InlineConfig>(entry => ({
mode: args.values.production ? 'production' : 'development',
plugins: [
_userscript({
entry: entry,
format: 'umd',
port: server.config.server.port,
cdn: args.values.production ? pkg.homepage : '',
})
],
build: {
watch: args.values.production ? undefined : {},
outDir: args.values.production ? 'dist/' : 'out/',
emptyOutDir: args.values.production
}
}));
await Promise.all(configs.map(build));
await server.close();

View File

@@ -0,0 +1,12 @@
import { offRoute, onRoute } from '../../common';
import { Callback } from '../../common/types';
const ALBUM_PATTERN = '/:country/album/:slug/:id';
export function onAlbumRoute(cb: Callback) {
onRoute(ALBUM_PATTERN, cb);
}
export function offAlbumRoute(cb: Callback) {
offRoute(ALBUM_PATTERN, cb);
}

View File

@@ -0,0 +1,123 @@
import { fromHTML, waitFor } from '../../../common';
import { Icon } from '../../icons';
export type MenuElement = Brand<HTMLElement, 'menu'>;
export type MenuItemElement = Brand<HTMLElement, 'menu-item'>;
type MenuCallback = (menuEl: MenuElement, id: string) => any;
const artistMenuCallbacks: MenuCallback[] = [];
const playlistMenuCallbacks: MenuCallback[] = [];
const albumMenuCallbacks: MenuCallback[] = [];
const trackMenuCallbacks: MenuCallback[] = [];
addEventListener('mousedown', async (e) => {
const path = e.composedPath()
.slice(0, -5)
.filter(el => el instanceof HTMLElement) as HTMLElement[];
const buttonEl = path.find(el => el.matches('amp-contextual-menu-button'));
if (!buttonEl) return;
const shadowMenuEl = await waitFor('amp-contextual-menu', undefined, 300);
if (!shadowMenuEl) return;
const menuEl = await waitFor<MenuElement>('ul', undefined, 300, shadowMenuEl.shadowRoot);
if (!menuEl) return;
const collectionId = location.href.split('/').pop();
const artistHeaderEl = path.find(el => el.classList.contains('artist-header'));
if (collectionId && artistHeaderEl) {
for (const cb of artistMenuCallbacks) cb(menuEl, collectionId);
return;
}
const collectionHeaderEl = path.find(el => el.classList.contains('container-detail-header'));
if (collectionId && collectionHeaderEl) {
if (location.href.includes('/playlist/')) {
for (const cb of playlistMenuCallbacks) cb(menuEl, collectionId);
return;
}
if (location.href.includes('/album/')) {
for (const cb of albumMenuCallbacks) cb(menuEl, collectionId);
return;
}
}
const trackEl = path.find(el => el.classList.contains('songs-list-row'));
const trackId = trackEl?.previousElementSibling?.getAttribute('href')?.split('/').pop();
if (trackId) {
for (const cb of trackMenuCallbacks) cb(menuEl, trackId);
return;
}
});
export function createMenuItem(text: string, icon?: Icon): MenuItemElement {
return fromHTML(`
<amp-contextual-menu-item hydrated>
<li class="contextual-menu__item">
<button title="Share">
<span class="contextual-menu__item-option-wrapper">
<span class="contextual-menu-item__option-text">${text}</span>
<span class="contextual-menu-item__option-text contextual-menu-item__option-text--after"></span>
<span class="contextual-menu-item__icon-container">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="context-menu__option-icon">
${icon ?? ''}
</svg>
</span>
</span>
</button>
</li>
</amp-contextual-menu-item>
`) as MenuItemElement;
}
export function showMenuItem(menuEl: MenuElement, itemEl: MenuItemElement, priority: number = Number.MAX_VALUE) {
itemEl.addEventListener('click', (e) => {
const scrimButtonEl = menuEl.previousElementSibling?.shadowRoot?.firstElementChild as HTMLButtonElement | null;
scrimButtonEl?.click();
}, { once: true });
const buttonEls = Array.from(itemEl.querySelectorAll('amp-contextual-menu-item')) as HTMLElement[];
itemEl.setAttribute('data-priority', priority.toString());
if (buttonEls.length === 0) {
menuEl.prepend(itemEl);
return;
}
let bestDist = Number.MAX_VALUE;
let refEl = buttonEls[0];
for (const buttonEl of buttonEls) {
const dist = Math.abs(Number(buttonEl.getAttribute('data-priority')) - priority);
if (dist >= bestDist) continue;
bestDist = dist;
refEl = buttonEl;
}
if (priority > Number(refEl.getAttribute('data-priority'))) {
refEl.after(itemEl);
} else {
refEl.before(itemEl);
}
}
export function onArtistMenu(cb: MenuCallback) {
artistMenuCallbacks.push(cb);
}
export function onPlaylistMenu(cb: MenuCallback) {
playlistMenuCallbacks.push(cb);
}
export function onAlbumMenu(cb: MenuCallback) {
albumMenuCallbacks.push(cb);
}
export function onTrackMenu(cb: MenuCallback) {
trackMenuCallbacks.push(cb);
}

View File

@@ -0,0 +1,62 @@
import { fromHTML, waitFor } from '../../../common';
import { Icon } from '../../icons';
export type ButtonElement = Brand<HTMLElement, 'button'>;
let navEl: HTMLElement | null = null;
export function createButtonElement(text: string, icon: Icon): ButtonElement {
return fromHTML(`
<div class="navigation__native-cta svelte-ljn7uh ame-sidebar-button">
<div slot="native-cta">
<div data-testid="native-cta" class="native-cta svelte-m7gmhh">
<button class="native-cta__button svelte-m7gmhh"
data-testid="native-cta-button">
<span class="native-cta__app-icon svelte-m7gmhh">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 44" slot="app-icon" aria-hidden="true">
${icon}
</svg>
</span>
<span class="native-cta__label svelte-m7gmhh">${text}</span>
</button>
</div>
</div>
</div>
`) as ButtonElement;
}
export async function showButtonElement(buttonEl: ButtonElement, index: number) {
if (!navEl) {
navEl = await waitFor('nav', 'amp-chrome-player');
if (!navEl) return;
}
const buttonEls = Array.from(navEl.querySelectorAll('.ame-sidebar-button')) as HTMLElement[];
buttonEl.setAttribute('data-index', index.toString());
if (buttonEls.length === 0) {
navEl.appendChild(buttonEl);
return;
}
let bestDist = Number.MAX_VALUE;
let refEl = buttonEls[0];
for (const buttonEl of buttonEls) {
const dist = Math.abs(Number(buttonEl.getAttribute('data-index')) - index);
if (dist >= bestDist) continue;
bestDist = dist;
refEl = buttonEl;
}
if (index > Number(refEl.getAttribute('data-index'))) {
refEl.after(buttonEl);
} else {
refEl.before(buttonEl);
}
}
export async function hideButtonElement(buttonEl: ButtonElement) {
buttonEl.remove();
}

7
src/applemusic/icons.ts Normal file
View File

@@ -0,0 +1,7 @@
export type Icon = Brand<string, 'icon'>;
export const flagIcon = `<path d="M11.5 42q-.65 0-1.075-.425Q10 41.15 10 40.5v-31q0-.65.425-1.075Q10.85 8 11.5 8h14.45q.55 0 .95.325.4.325.5.875l.7 3.1h10.4q.65 0 1.075.425Q40 13.15 40 13.8v15.5q0 .65-.425 1.075-.425.425-1.075.425H28.4q-.5 0-.925-.3-.425-.3-.525-.85l-.7-3.1H13V40.5q0 .65-.425 1.075Q12.15 42 11.5 42ZM25 19.4Zm4.75 8.4H37V15.3H25.55L24.6 11H13v12.55h15.8Z" />` as Icon;
export const paletteIcon = `<path d="M24 44q-4.1 0-7.75-1.575-3.65-1.575-6.375-4.3-2.725-2.725-4.3-6.375Q4 28.1 4 24q0-4.25 1.6-7.9 1.6-3.65 4.375-6.35 2.775-2.7 6.5-4.225Q20.2 4 24.45 4q3.95 0 7.5 1.325T38.175 9q2.675 2.35 4.25 5.575Q44 17.8 44 21.65q0 5.4-3.15 8.525T32.5 33.3h-3.75q-.9 0-1.55.7t-.65 1.55q0 1.35.725 2.3.725.95.725 2.2 0 1.9-1.05 2.925T24 44Zm0-20Zm-11.65 1.3q1 0 1.75-.75t.75-1.75q0-1-.75-1.75t-1.75-.75q-1 0-1.75.75t-.75 1.75q0 1 .75 1.75t1.75.75Zm6.3-8.5q1 0 1.75-.75t.75-1.75q0-1-.75-1.75t-1.75-.75q-1 0-1.75.75t-.75 1.75q0 1 .75 1.75t1.75.75Zm10.7 0q1 0 1.75-.75t.75-1.75q0-1-.75-1.75t-1.75-.75q-1 0-1.75.75t-.75 1.75q0 1 .75 1.75t1.75.75Zm6.55 8.5q1 0 1.75-.75t.75-1.75q0-1-.75-1.75t-1.75-.75q-1 0-1.75.75t-.75 1.75q0 1 .75 1.75t1.75.75ZM24 41q.55 0 .775-.225.225-.225.225-.725 0-.7-.725-1.3-.725-.6-.725-2.65 0-2.3 1.5-4.05t3.8-1.75h3.65q3.8 0 6.15-2.225Q41 25.85 41 21.65q0-6.6-5-10.625T24.45 7q-7.3 0-12.375 4.925T7 24q0 7.05 4.975 12.025Q16.95 41 24 41Z" />` as Icon;
export const shieldIcon = `<path d="M24 43.95q-7-1.75-11.5-8.125T8 21.85V9.95l16-6 16 6v11.9q0 7.6-4.5 13.975T24 43.95Zm0-3.1q5.75-1.9 9.375-7.175T37 21.85v-9.8l-13-4.9-13 4.9v9.8q0 6.55 3.625 11.825Q18.25 38.95 24 40.85ZM24 24Z" />` as Icon;
export const hqIcon = `<path d="M29.75 33.4h2.5v-3.25h2.05q.7 0 1.2-.475T36 28.5v-8.95q0-.7-.5-1.2t-1.2-.5H28q-.7 0-1.35.5-.65.5-.65 1.2v8.95q0 .7.65 1.175.65.475 1.35.475h1.75ZM12 30.15h2.5V25.7h5v4.45H22v-12.3h-2.5v5.35h-5v-5.35H12Zm16.5-2.5v-7.3h5v7.3ZM7 40q-1.2 0-2.1-.9Q4 38.2 4 37V11q0-1.2.9-2.1Q5.8 8 7 8h34q1.2 0 2.1.9.9.9.9 2.1v26q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h34V11H7v26Zm0 0V11v26Z" />` as Icon;
export const codeIcon = `<path d="m16 35.9-12-12 12.1-12.1 2.15 2.15L8.3 23.9l9.85 9.85Zm15.9.1-2.15-2.15 9.95-9.95-9.85-9.85L32 11.9l12 12Z" />` as Icon;

49
src/applemusic/main.ts Normal file
View File

@@ -0,0 +1,49 @@
// ==UserScript==
// @namespace ame-applemusic
// @name Ame (Apple Music)
// @version 1.0.0
// @author SuperSaltyGamer
// @run-at document-start
// @match https://music.apple.com/*
// @grant GM.addStyle
// @grant GM.setClipboard
// @grant GM.xmlHttpRequest
// ==/UserScript==
import { observe, waitFor } from '../common';
import { checkCountriesButtonEl } from './modules/countries';
import { searchCoversButtonEl } from './modules/covers';
import { copyAuthButtonEl } from './modules/dev';
import { checkQualitiesButtonEl } from './modules/qualities';
import './modules/qualities';
import styles from './style.css?inline';
import { offAlbumRoute, onAlbumRoute } from './glue/routing';
import { hideButtonElement, showButtonElement } from './glue/ui/sidebar';
GM.addStyle(styles);
// Add sidebar button for all pages.
waitFor('nav', 'amp-chrome-player').then((navEl) => {
if (!navEl) return;
showButtonElement(copyAuthButtonEl, 0);
});
// Add sidebar buttons for album page.
onAlbumRoute(async () => {
showButtonElement(checkCountriesButtonEl, 100);
showButtonElement(checkQualitiesButtonEl, 200);
showButtonElement(searchCoversButtonEl, 300);
});
// Remove sidebar buttons for non-album pages.
offAlbumRoute(() => {
hideButtonElement(searchCoversButtonEl);
hideButtonElement(checkCountriesButtonEl);
});
// Hide trial upselling modal.
observe('iframe[src^="/includes/commerce/subscribe"]', () => {
const backdropEl = document.querySelector<HTMLElement>('.backdrop');
backdropEl?.click();
});

View File

@@ -0,0 +1,21 @@
.ame-album-countries-header {
margin: var(--bodyGutter) var(--bodyGutter) 1em;
font-size: 1.1em;
}
.ame-album-countries-container {
margin: 1em var(--bodyGutter) var(--bodyGutter);
font-size: 0;
}
.ame-album-countries-container div {
display: contents;
}
.ame-album-countries-container a,
.ame-album-countries-container span {
display: inline-block;
margin-right: .5em;
margin-bottom: .5em;
font-size: 13px;
}

View File

@@ -0,0 +1,105 @@
import { fromHTML, sleep, waitFor } from '../../common';
import { flagIcon } from '../icons';
import { getAlbum, getStorefronts } from '../services/service';
import { offAlbumRoute, onAlbumRoute } from '../glue/routing';
import { createButtonElement } from '../glue/ui/sidebar';
import styles from './countries.css?inline';
GM.addStyle(styles);
const PREFERRED_STOREFRONTS = [ 'jp', 'us', 'de', 'fr', 'gb', 'in', 'hk', 'it', 'es', 'br', 'au', 'nz' ];
PREFERRED_STOREFRONTS.reverse();
let globalJob: AbortController | null = null;
export const checkCountriesButtonEl = createButtonElement('Check Countries', flagIcon);
// Start checking countries when the sidebar button is clicked.
checkCountriesButtonEl.addEventListener('click', async () => {
const refEl = document.querySelector<HTMLElement>('.section');
if (refEl) await checkCountries(refEl);
});
// Start checking countries when the error page is shown.
onAlbumRoute(async () => {
const errorEl = await waitFor('.page-error');
if (errorEl) checkCountries(errorEl);
});
onAlbumRoute(() => {
globalJob?.abort();
globalJob = null;
});
offAlbumRoute(() => {
globalJob?.abort();
globalJob = null;
});
async function checkCountries(refEl: HTMLElement) {
if (globalJob) return;
const job = new AbortController();
globalJob = job;
const albumId = location.pathname.split('/')[4];
const headerEl = fromHTML(`<div class="section ame-album-countries-header">Availability in the following storefronts:</div>`);
const containerEl = fromHTML(`
<div class="section ame-album-countries-container">
<div class="ame-color-primary"></div>
<div class="ame-color-secondary"></div>
<div class="ame-color-tertiary"></div>
</div>
`);
const primaryContainerEl = containerEl.children[0];
const secondaryContainerEl = containerEl.children[1];
const tertiaryContainerEl = containerEl.children[2];
refEl.append(headerEl);
refEl.append(containerEl);
const storefronts = await getStorefronts();
storefronts.sort((a, b) => {
return Math.max(PREFERRED_STOREFRONTS.indexOf(b.id), 0) - Math.max(PREFERRED_STOREFRONTS.indexOf(a.id), 0);
});
for (const storefront of storefronts) {
if (job.signal.aborted) break;
const album = await getAlbum(albumId, storefront.id);
// Album totally unavailable.
if (!album) {
tertiaryContainerEl.append(fromHTML(`
<span title="Totally unavailable">${storefront.attributes.name}, </span>
`));
await sleep(100);
continue;
}
const unavailableTracks = album.relationships.tracks.data
.filter(track => track.type === 'songs')
.map((track, i) => track.attributes.extendedAssetUrls ? 0 : i + 1)
.filter(Boolean);
// Album partially available.
if (unavailableTracks.length) {
secondaryContainerEl.append(fromHTML(`
<a target="_blank" href="https://music.apple.com/${storefront.id}/album/${albumId}" title="Partially available, missing:\n${unavailableTracks.join(', ')}">${storefront.attributes.name}, </a>
`));
await sleep(100);
continue;
}
// Album fully available.
primaryContainerEl.append(fromHTML(`
<a target="_blank" href="https://music.apple.com/${storefront.id}/album/${albumId}" title="Fully available">${storefront.attributes.name}, </a>
`));
await sleep(100);
}
}

View File

@@ -0,0 +1,18 @@
import { paletteIcon } from '../icons';
import { createButtonElement } from '../glue/ui/sidebar';
export const searchCoversButtonEl = createButtonElement('Search Covers', paletteIcon);
searchCoversButtonEl.addEventListener('click', () => {
const titleEl = document.querySelector<HTMLElement>('h1.headings__title');
if (!titleEl) return;
const title = titleEl.innerText
.replace(' - Single', '')
.replace(' - EP', '');
const artistEls = document.querySelectorAll<HTMLElement>('.headings__subtitles > a');
const artist = Array.from(artistEls).map(el => el.innerText).join(' ');
open(`https://covers.musichoarders.xyz?artist=${encodeURIComponent(artist)}&album=${encodeURIComponent(title)}`, '_blank');
});

View File

@@ -0,0 +1,20 @@
import { createMenuItem, onTrackMenu, showMenuItem } from '../glue/ui/menu';
import { codeIcon, shieldIcon } from '../icons';
import { getToken } from '../services/auth';
import { createButtonElement } from '../glue/ui/sidebar';
export const copyAuthButtonEl = createButtonElement('Copy Authorization', shieldIcon);
copyAuthButtonEl.addEventListener('click', async () => {
GM.setClipboard(await getToken());
});
onTrackMenu((menuEl, trackId) => {
const copyTrackIdButtonEl = createMenuItem('Copy ID', codeIcon);
copyTrackIdButtonEl.addEventListener('click', () => {
GM.setClipboard(trackId);
});
showMenuItem(menuEl, copyTrackIdButtonEl);
});

View File

@@ -0,0 +1,96 @@
import { fromHTML, sleep } from '../../common';
import { hqIcon } from '../icons';
import { getAlbum } from '../services/service';
import { createMenuItem } from '../glue/ui/menu';
import { offAlbumRoute, onAlbumRoute } from '../glue/routing';
import { createButtonElement } from '../glue/ui/sidebar';
interface Quality {
'AUDIO-FORMAT-ID': string;
'SAMPLE-RATE': number;
'BIT-DEPTH': number;
'BIT-RATE': number;
}
let globalJob: AbortController | null = null;
export const checkQualitiesButtonEl = createButtonElement('Check Qualities', hqIcon);
checkQualitiesButtonEl.addEventListener('click', async () => {
if (globalJob) return;
const job = new AbortController();
globalJob = job;
const country = location.pathname.split('/')[1];
const albumId = location.pathname.split('/')[4];
const album = await getAlbum(albumId, country);
if (!album) return;
const trackEls = Array.from(document.querySelectorAll<HTMLElement>('.songs-list-row__song-wrapper'));
for (const track of album.relationships.tracks.data) {
if (job.signal.aborted) break;
if (track.type !== 'songs') continue;
const trackEl = trackEls.shift();
if (!trackEl) continue;
trackEl.querySelector('.ame-track-quality')?.remove();
if (!track.attributes.extendedAssetUrls) {
trackEl.appendChild(fromHTML(`<span class="ame-track-quality ame-color-warning">[unavailable]</span>`));
continue;
}
const manifest = await (await fetch(track.attributes.extendedAssetUrls.enhancedHls)).text();
let data;
for (const line of manifest.split('\n')) {
if (!line.startsWith('#EXT-X-SESSION-DATA:DATA-ID="com.apple.hls.audioAssetMetadata"')) continue;
const encoded = line.split('VALUE=')[1].slice(1, -1);
data = JSON.parse(atob(encoded));
break;
}
const qualities = (Object.values(data) as Quality[])
.sort(sortQuality)
.map(formatQuality);
trackEl.appendChild(fromHTML(`<span class="ame-track-quality ame-color-tertiary" title="${qualities.join('\n')}">${qualities[0]}</span>`));
await sleep(150);
}
});
onAlbumRoute(() => {
globalJob?.abort();
globalJob = null;
});
offAlbumRoute(() => {
globalJob?.abort();
globalJob = null;
});
const formatOrder = [ 'alac', 'aac ', 'aach' ];
function sortQuality(a: Quality, b: Quality): number {
return formatOrder.indexOf(a['AUDIO-FORMAT-ID']) - formatOrder.indexOf(b['AUDIO-FORMAT-ID']) ||
a['BIT-DEPTH'] - b['BIT-DEPTH'] ||
a['SAMPLE-RATE'] - b['SAMPLE-RATE'] ||
a['BIT-RATE'] - b['BIT-RATE'];
}
function formatQuality(quality: Quality): string {
switch (quality['AUDIO-FORMAT-ID']) {
case 'alac':
return `ALAC ${quality['BIT-DEPTH']}bit ${Math.floor(Number(quality['SAMPLE-RATE']) / 1000)}kHz`;
case 'aac ':
return `AAC ${Math.floor(Number(quality['BIT-RATE']) / 1000)}kbps`;
case 'aach':
return `AAC-HE ${Math.floor(Number(quality['BIT-RATE']) / 1000)}kbps`;
}
return 'Unknown';
}

View File

@@ -0,0 +1,17 @@
let cachedAuthToken = '';
export async function getToken(): Promise<string> {
if (cachedAuthToken) return cachedAuthToken;
const scriptEl = document.querySelector<HTMLScriptElement>('script[type="module"]');
if (!scriptEl) throw new Error('Failed to find script with auth token.');
const res = await fetch(scriptEl.src);
const body = await res.text();
const match = body.match(/(?<=")eyJhbGciOiJ.+?(?=")/);
if (!match) throw new Error('Failed to find auth token from script.');
cachedAuthToken = match[0];
return cachedAuthToken;
}

View File

@@ -0,0 +1,59 @@
import { getToken } from './auth';
interface Response<T> {
data: Resource<T>[];
}
interface Resource<T> {
id: string;
type: string;
href: string;
attributes: T;
relationships: {
artists: Response<Artist>;
tracks: Response<Track>;
}
}
interface Artist {
name: string;
}
interface Album {
name: string;
}
interface Track {
name: string;
extendedAssetUrls: {
enhancedHls: string;
}
}
interface Storefront {
name: string;
}
export async function getAlbum(id: string, country: string): Promise<Resource<Album> | null> {
const res = await fetch(`https://amp-api.music.apple.com/v1/catalog/${country}/albums/${id}?extend=extendedAssetUrls`, {
headers: {
'Authorization': `Bearer ${await getToken()}`
}
});
if (res.status !== 200) return null;
const albums = await res.json<Response<Album>>();
return albums.data[0];
}
export async function getStorefronts(): Promise<Resource<Album>[]> {
const res = await fetch('https://api.music.apple.com/v1/storefronts', {
headers: {
'Authorization': `Bearer ${await getToken()}`
}
});
const body = await res.json<Response<Storefront>>();
return body.data;
}

107
src/applemusic/style.css Normal file
View File

@@ -0,0 +1,107 @@
.ame-color-primary {
color: var(--systemPrimary);
}
.ame-color-secondary {
color: var(--systemSecondary);
}
.ame-color-tertiary {
color: var(--systemTertiary);
}
.ame-color-warning {
color: var(--systemYellow);
}
/* Hide trial upselling banner. */
.upsell-banner {
display: none;
}
/* Hide foreign country banner */
.banner-container {
display: none;
}
/* Make page content scrollable from the sidebar. */
@media (min-width: 484px) {
.header {
pointer-events: none;
}
#navigation > * {
pointer-events: auto;
}
#scrollable-page {
grid-column-start: 1;
grid-column-end: 3;
padding-left: 33.8843vw;
}
}
@media (min-width: 767px) {
#scrollable-page {
padding-left: 260px;
}
}
/* Hide Open in Music button in the sidebar. */
.navigation__scrollable-container + .navigation__native-cta {
display: none;
}
/* Make sidebar buttons stick to the top and stack on top of each other. */
nav {
padding-bottom: .5em;
grid-template-rows: min-content min-content minmax(0, min-content) min-content min-content min-content min-content min-content min-content min-content min-content !important;
}
.navigation__scrollable-container {
margin-bottom: .5em;
}
.navigation__native-cta {
display: contents;
}
/* Add focus styles to sidebar buttons. */
.native-cta {
padding-top: 8px !important;
padding-bottom: 8px !important;
border-top: none !important;
}
.native-cta__button svg,
.native-cta__button .native-cta__label {
transition: 50ms linear color, 50ms linear fill;
}
.native-cta__button:active svg,
.native-cta__button:active .native-cta__label {
color: white !important;
fill: white !important;
}
/* Make more room for error messages and button text. */
.page-error {
width: 100% !important;
max-width: 900px !important;
}
.page-error__title + .button button {
padding-left: 1em;
padding-right: 1em;
}

34
src/common/fetch.ts Normal file
View File

@@ -0,0 +1,34 @@
export function fetchCors(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return new Promise<Response>((resolve, reject) => {
const fetchReq = new Request(input, init);
GM.xmlHttpRequest({
method: fetchReq.method as any,
url: fetchReq.url,
headers: Object.fromEntries(Array.from(fetchReq.headers)),
responseType: 'blob',
onload(res) {
const headers = res.responseHeaders
.split('\r\n')
.slice(0, -1)
.map(line => line.split(': '));
const fetchRes = new Response(res.response, {
headers: Object.fromEntries(headers),
status: res.status,
statusText: res.statusText
});
Object.defineProperty(fetchRes, 'url', { value: fetchReq.url });
resolve(fetchRes);
},
onerror() {
reject(new TypeError('Network request errored.'));
},
ontimeout() {
reject(new TypeError('Network request timed out.'));
}
});
});
}

57
src/common/index.ts Normal file
View File

@@ -0,0 +1,57 @@
export * from './fetch';
export * from './router';
export function sleep(delay: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, delay);
});
}
export function fromHTML(html: string): HTMLElement {
const div = document.createElement('div');
div.innerHTML = html;
return div.firstElementChild as HTMLElement;
}
export function waitFor<T extends HTMLElement>(selector: string, waitSelector?: string, timeout: number = 5000, refEl?: HTMLElement | Document | ShadowRoot | null): Promise<T | null> {
return new Promise<T | null>((resolve) => {
let disposeTimeout = 0;
let disposeInterval = 0;
disposeTimeout = setTimeout(() => {
clearInterval(disposeInterval);
resolve(null);
}, timeout) as any;
disposeInterval = setInterval(() => {
let el = (refEl ?? document).querySelector<T>(waitSelector ?? selector);
if (!el) return;
if (waitSelector) el = (refEl ?? document).querySelector<T>(selector);
if (!el) return;
resolve(el);
clearTimeout(disposeTimeout);
clearInterval(disposeInterval);
}, 10) as any;
});
}
export function observe<T extends HTMLElement>(selector: string, cb: (el: T) => any): void {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of Array.from(mutation.addedNodes)) {
if (!(node instanceof Element)) continue;
if (!node.matches(selector)) continue;
cb(node as T);
return;
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}

58
src/common/router.ts Normal file
View File

@@ -0,0 +1,58 @@
import { match, MatchFunction } from 'path-to-regexp';
import { Callback } from './types';
interface Route {
pattern: string;
matcher: MatchFunction;
onCallbacks: Callback[];
offCallbacks: Callback[];
}
const registeredRoutes: Route[] = [];
const pushState = history.pushState;
history.pushState = function(data: any, unused: string, url?: string | URL | null): void {
pushState.apply(history, [ data, unused, url ]);
if (url) checkRoutes(url.toString(), registeredRoutes);
}
addEventListener('popstate', () => {
checkRoutes(location.pathname, registeredRoutes);
});
function checkRoutes(url: string, routes: Route[]): void {
for (const route of routes) {
const cbs = route.matcher(url) ? route.onCallbacks : route.offCallbacks;
for (const cb of cbs) cb();
}
}
function ensureRoute(pattern: string): Route {
let route = registeredRoutes.find(route => route.pattern === pattern);
if (route) return route;
route = {
pattern,
matcher: match(pattern),
onCallbacks: [],
offCallbacks: []
};
registeredRoutes.push(route);
return route;
}
export function onRoute(pattern: string, cb: Callback): void {
const route = ensureRoute(pattern);
route.onCallbacks.push(cb);
if (route.matcher(location.pathname)) cb();
}
export function offRoute(pattern: string, cb: Callback): void {
const route = ensureRoute(pattern);
route.offCallbacks.push(cb);
if (!route.matcher(location.pathname)) cb();
}

1
src/common/types.ts Normal file
View File

@@ -0,0 +1 @@
export type Callback = () => any;

17
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
interface Branding<T> {
readonly __branding: T;
}
type Brand<T, U extends string> = T & Branding<U>;
interface Headers extends Iterable<readonly [ string, string ]> {}
interface Body {
json<T>(): Promise<T>;
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"noEmit": true,
"lib": [
"esnext",
"dom"
]
},
"include": [
"src/"
]
}

12
tsconfig.scripts.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"outDir": "out/"
},
"include": [
"scripts/"
]
}