mirror of
https://notabug.org/SuperSaltyGamer/ame
synced 2026-01-15 17:52:55 -03:00
Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.vscode/
|
||||
.idea/
|
||||
node_modules/
|
||||
out/
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# AME
|
||||
|
||||
Various user scripts for the music hoarding community.
|
||||
64
dist/applemusic.user.js
vendored
Normal file
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
1427
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
75
scripts/_userscript.ts
Normal 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
65
scripts/build.ts
Normal 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();
|
||||
12
src/applemusic/glue/routing.ts
Normal file
12
src/applemusic/glue/routing.ts
Normal 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);
|
||||
}
|
||||
123
src/applemusic/glue/ui/menu.ts
Normal file
123
src/applemusic/glue/ui/menu.ts
Normal 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);
|
||||
}
|
||||
62
src/applemusic/glue/ui/sidebar.ts
Normal file
62
src/applemusic/glue/ui/sidebar.ts
Normal 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
7
src/applemusic/icons.ts
Normal 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
49
src/applemusic/main.ts
Normal 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();
|
||||
});
|
||||
21
src/applemusic/modules/countries.css
Normal file
21
src/applemusic/modules/countries.css
Normal 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;
|
||||
}
|
||||
105
src/applemusic/modules/countries.ts
Normal file
105
src/applemusic/modules/countries.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
18
src/applemusic/modules/covers.ts
Normal file
18
src/applemusic/modules/covers.ts
Normal 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');
|
||||
});
|
||||
20
src/applemusic/modules/dev.ts
Normal file
20
src/applemusic/modules/dev.ts
Normal 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);
|
||||
});
|
||||
96
src/applemusic/modules/qualities.ts
Normal file
96
src/applemusic/modules/qualities.ts
Normal 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';
|
||||
}
|
||||
17
src/applemusic/services/auth.ts
Normal file
17
src/applemusic/services/auth.ts
Normal 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;
|
||||
}
|
||||
59
src/applemusic/services/service.ts
Normal file
59
src/applemusic/services/service.ts
Normal 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
107
src/applemusic/style.css
Normal 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
34
src/common/fetch.ts
Normal 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
57
src/common/index.ts
Normal 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
58
src/common/router.ts
Normal 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
1
src/common/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Callback = () => any;
|
||||
17
src/env.d.ts
vendored
Normal file
17
src/env.d.ts
vendored
Normal 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
16
tsconfig.json
Normal 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
12
tsconfig.scripts.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"outDir": "out/"
|
||||
},
|
||||
"include": [
|
||||
"scripts/"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user