ASMR Online Work Downloader

Download all(selected) folders and files for current work on asmr.one in one click, preserving folder structures

/* eslint-disable no-multi-spaces */

// ==UserScript==
// @name               ASMR Online 一键下载
// @name:zh-CN         ASMR Online 一键下载
// @name:en            ASMR Online Work Downloader
// @namespace          ASMR-ONE
// @version            0.9.1
// @description        一键下载asmr.one上的整个作品(或者选择文件下载),包括全部的文件和目录结构
// @description:zh-CN  一键下载asmr.one上的整个作品(或者选择文件下载),包括全部的文件和目录结构
// @description:en     Download all(selected) folders and files for current work on asmr.one in one click, preserving folder structures
// @author             PY-DNG
// @license            MIT
// @match              https://www.asmr.one/*
// @match              https://www.asmr-100.com/*
// @match              https://www.asmr-200.com/*
// @match              https://www.asmr-300.com/*
// @match              https://asmr.one/*
// @match              https://asmr-100.com/*
// @match              https://asmr-200.com/*
// @match              https://asmr-300.com/*
// @require            https://greasyfork.org/scripts/458132-itemselector/code/ItemSelector.js?version=1138364
// @require            https://greasyfork.org/scripts/456034-basic-functions-for-userscripts/code/script.js?version=1222141
// @require            https://greasyfork.org/scripts/460385-gm-web-hooks/code/script.js?version=1221394
// @icon               https://www.asmr.one/statics/app-logo-128x128.png
// @grant              GM_download
// @grant              GM_registerMenuCommand
// ==/UserScript==

/* global LogLevel DoLog Err $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager */
/* global ItemSelector GMXHRHook GMDLHook */
(function __MAIN__() {
    'use strict';

	const CONST = {
		HTML: {
			DownloadButton: `
				<button tabindex="0" type="button" id="download-btn"
						class="q-btn q-btn-item non-selectable no-outline q-btn--standard q-btn--rectangle bg-cyan q-mt-sm shadow-4 q-mx-xs q-px-sm text-white q-btn--actionable q-focusable q-hoverable q-btn--wrap q-btn--dense">
					<span class="q-focus-helper"></span><span class="q-btn__wrapper col row q-anchor--skip"><span
						class="q-btn__content text-center col items-center q-anchor--skip justify-center row"><span class="block" id="download-btn-inner">DOWNLOAD</span></span></span>
				</button>
			`
		},
		Text: {
			DownloadFolder: 'ASMR-ONE',
			WorkFolder: '{RJ} - {WorkName}',
			DownloadButton: 'Download',
			DownloadButton_Working: 'Downloading({Done}/{All})',
			DownloadButton_Done: 'Download(Finished)',
			SelectDownloadFiles: '选择下载的文件:',
			RootFolder: 'Root',
			Prefix_File: '[文件] ',
			Prefix_Folder: '[文件夹] ',
			NoTitle: 'No Title'
		},
		Number: {
			Max_Download: 2,
			GUITextChangeDelay: 1500
		}
	}
	GM_registerMenuCommand('导出调试包', debugInfo);

	// Init
	const IS = initItemSelector();

	detectDom('body', body => main());
	function main() {
		// Commons
		polyfill();
		GMDLHook(CONST.Number.Max_Download);

		// Page functions
		detectDom({
			selector: '#work-tree',
			callback: e => pageWork(),
			once: false
		});
	}

	function pageWork() {
		// Make button
		const downloadBtn = htmlElm(CONST.HTML.DownloadButton);
		const downloadBtn_inner = $(downloadBtn, '#download-btn-inner');
		$(".q-pa-sm").appendChild(downloadBtn);
		downloadBtn.addEventListener('click', batchDownload);

		function batchDownload() {
			const count = {done: 0, all: 0};
			const DATA = 'Original-Item-Properties-Data_' + randstr();
			request(getid(), function(e) {
				const list = JSON.parse(e.target.responseText);
				const json = list2json(list);
				IS.show(json, {
					title: CONST.Text.SelectDownloadFiles,
					onok: (e, json) => {
						const list = json2list(json);
						for (const item of list) {
							dealItem(item);
						}
					}
				});
			});

			function list2json(list) {
				list = structuredClone(list);
				const json = {text: CONST.Text.RootFolder, children: [], [DATA]: {}};
				for (const item of list) {
					json.children.push(convert(item));
				}
				return json;

				function convert(item) {
					const json = {};
					switch (item.type) {
						case 'folder': {
							json.text = CONST.Text.Prefix_Folder + item.title;
							json.children = item.children.map(child => convert(child));
							break;
						}
						case 'audio':
						case 'text':
						case 'image':
						case 'other': {
							json.text = CONST.Text.Prefix_File + item.title;
							break;
						}
						default:
							//debugger;
							DoLog(LogLevel.Warning, 'Unknown item type: ' + item.type);
					}
					json[DATA] = item;
					delete json[DATA].children;
					return json;
				}
			}

			function json2list(json) {
				if (json === null) {return [];}
				json = structuredClone(json);
				const root_item = convert(json);
				const list = root_item.children;
				return list;

				function convert(json) {
					const item = json[DATA];
					if (Array.isArray(json.children)) {
						item.children = [];
						for (const child of json.children) {
							item.children.push(convert(child));
						}
					}
					return item;
				}
			}

			function dealItem(item, path=[]) {
				switch (item.type) {
					case 'folder': {
						for (const child of item.children) {
							dealItem(child, path.concat([item.title]));
						}
						break;
					}
					case 'audio':
					case 'text':
					case 'image':
					case 'other': {
						const sep = getOSSep();
						const _sep = ({'/': '/', '\\': '\'})[sep];
						const url = item.mediaDownloadUrl;
						const RJ = location.pathname.split('/').pop();
						const name = [CONST.Text.DownloadFolder].concat([replaceText(CONST.Text.WorkFolder, {'{RJ}': RJ, '{WorkName}': item.workTitle || CONST.Text.NoTitle})]).concat(path).concat([item.title]).map((name) => (name.replaceAll(sep, _sep))).join(sep);
						DoLog([name, url, item]);
						dl(url, name);
						count.all++;
						display();
						break;
					}
					default:
						//debugger;
						DoLog(LogLevel.Warning, 'Unknown item type: ' + item.type);
				}
			}

			function dl(url, name, retry=3) {
				GM_download({
					method: 'GET',
					url: url,
					name: name,
					onload: function(e) {
						count.done++;
						display();
					},
					onerror: function() {
						debugger;
						--retry > 0 && dl(url, name, retry);
					},
					ontimeout: err => {debugger;},
					onabort: err => {debugger;}
				});
			}

			function display() {
				downloadBtn_inner.innerText = replaceText(CONST.Text.DownloadButton_Working, {'{Done}': count.done, '{All}': count.all});
				count.done === count.all && setTimeout(() => (downloadBtn_inner.innerText = CONST.Text.DownloadButton_Done), CONST.Number.GUITextChangeDelay);
			}
		}
	}

	function request(id, onload) {
		const url = `https://api.${location.host.match(/(?:[^.]+\.)?([^.]+\.[^.]+)/)[1]}/api/tracks/` + id;
		const xhr = new XMLHttpRequest();
		xhr.open('GET', url);
		xhr.onload = onload;
		xhr.send();
	}

	function getid() {
		return location.pathname.split('/').pop().substring(2);
	}

	function initItemSelector() {
		const IS = new ItemSelector();
		const observer = new MutationObserver(setTheme);
		observer.observe(document.body, {attributes: true, attributeFilter: ['class']});
		setTheme();
		return IS;

		function setTheme() {
			IS.setTheme([...document.body.classList].includes('body--dark') ? 'dark' : 'light');
		}
	}

	function debugInfo() {
		const win = typeof unsafeWindow === 'object' ? unsafeWindow : window;
		const DebugInfo = {
			version: GM_info.script.version,
			GM_info: GM_info,
			platform: navigator.platform,
			userAgent: navigator.userAgent,
			getOS: getOS(),
			getOSSep: getOSSep(),
			url: location.href,
			topurl: win.top.location.href,
			iframe: win.top !== win,
			languages: [...navigator.languages],
			timestamp: (new Date()).getTime()
		};

		// Log in console
		DoLog(LogLevel.Debug, '=== Userscript [' + GM_info.script.name + '] debug info ===');
		DoLog(LogLevel.Debug, DebugInfo);
		DoLog(LogLevel.Debug, '=== /Userscript [' + GM_info.script.name + '] debug info ===');

		// Save to file
		downloadText(JSON.stringify(DebugInfo), 'Debug Info_' + GM_info.script.name + '_' + (new Date()).getTime().toString() + '.json');

		// Save text to textfile
		function downloadText(text, name) {
			if (!text || !name) {return false;};

			// Get blob url
			const blob = new Blob([text],{type:"text/plain;charset=utf-8"});
			const url = URL.createObjectURL(blob);

			// Create <a> and download
			const a = $CrE('a');
			a.href = url;
			a.download = name;
			a.click();
		}
	}

	function htmlElm(html) {
		const parent = $CrE('div');
		parent.innerHTML = html;
		return parent.children.length > 1 ? Array.from(parent.children) : parent.children[0];
	}

	function getOSSep() {
		return ({
			'Windows': '\\',
			'Mac': '/',
			'Linux': '/',
			'Null': '-'
		})[getOS()];
	}

	function getOS() {
		const info = (navigator.platform || navigator.userAgent).toLowerCase();
		const test = (s) => (info.includes(s));
		const map = {
			'Windows': ['window', 'win32', 'win64', 'win86'],
			'Mac': ['mac', 'os x'],
			'Linux': ['linux']
		}
		for (const [sys, strs] of Object.entries(map)) {
			if (strs.some(test)) {
				return sys;
			}
		}
		return 'Null';
	}

	// Returns a random string
	function randstr(length=16, nums=true, cases=true) {
		const all = 'abcdefghijklmnopqrstuvwxyz' + (nums ? '0123456789' : '') + (cases ? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' : '');
		return Array(length).fill(0).reduce(pre => (pre += all.charAt(randint(0, all.length-1))), '');
	}

	function randint(min, max) {
		return Math.floor(Math.random() * (max - min + 1)) + min;
	}

	// This isn't a full polyfill, but that's not the point. What matters actually: it works for this userscript.
	function polyfill() {
		const win = typeof unsafeWindow === 'object' && unsafeWindow !== null ? unsafeWindow : window;

		if (!win.structuredClone) {
			win.structuredClone = o => JSON.parse(JSON.stringify(o));
		}
	}
})();