UNPKG

touhou-tagger

Version:

从 THBWiki 自动填写东方 Project CD 曲目信息.

279 lines (278 loc) 12.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.CliTagger = void 0; const path_1 = require("path"); const promises_1 = require("fs/promises"); const debug_1 = require("../core/debug"); const options_1 = require("./options"); const readline_1 = require("../core/readline"); const command_base_1 = require("./command-base"); const default_album_name_1 = require("./default-album-name"); const album_options_1 = require("./album-options"); const helper_1 = require("./helper"); const leadingNumberSort = (a, b) => { const infinityPrase = (str) => { const number = parseInt(str); if (Number.isNaN(number)) { return Infinity; } return number; }; const intA = infinityPrase(a); const intB = infinityPrase(b); const intCompare = intA - intB; if (intCompare === 0) { return a.localeCompare(b); } return intCompare; }; const TimeoutError = Symbol('timeout'); class CliTagger extends command_base_1.CliCommandBase { spinner; metadataSource; metadataConfig; constructor(spinner) { super(); this.spinner = spinner; this.metadataConfig = (0, options_1.getMetadataConfig)(this.options); } async getLocalCover() { const localCoverFiles = (await (0, promises_1.readdir)(this.workingDir, { withFileTypes: true })) .filter(f => f.isFile() && f.name.match(/^cover\.(jpg|jpeg|jpe|tif|tiff|bmp|png)$/)) .map(f => f.name); if (localCoverFiles.length === 0) { return undefined; } const [coverFile] = localCoverFiles; const buffer = await (0, promises_1.readFile)((0, path_1.resolve)(this.workingDir, coverFile)); return buffer; } async getLocalJson() { const localMetadataFiles = (await (0, promises_1.readdir)(this.workingDir, { withFileTypes: true })) .filter(f => f.isFile() && f.name.match(/^metadata\.jsonc?$/)) .map(f => f.name); if (localMetadataFiles.length === 0) { return undefined; } const [localMetadata] = localMetadataFiles; const json = await (0, promises_1.readFile)((0, path_1.resolve)(this.workingDir, localMetadata), { encoding: 'utf8' }); (0, debug_1.log)('localJson get'); (0, debug_1.log)(json); const { expandMetadataInfo: normalize } = await Promise.resolve().then(() => __importStar(require('../core/metadata/normalize/normalize'))); return normalize({ metadatas: JSON.parse(json), cover: await this.getLocalCover(), }); } async downloadMetadata(album, cover) { const { sourceMappings } = await Promise.resolve().then(() => __importStar(require(`../core/metadata/source-mappings`))); const metadataSource = sourceMappings[this.options.source]; metadataSource.config = this.metadataConfig; this.metadataSource = metadataSource; return this.metadataSource.getMetadata(album, cover); } async createFiles(metadata) { const { dirname } = await Promise.resolve().then(() => __importStar(require('path'))); const { writerMappings } = await Promise.resolve().then(() => __importStar(require('../core/writer/writer-mappings'))); const fileTypes = Object.keys(writerMappings); const fileTypeFilter = (file) => fileTypes.some(type => file.endsWith(type)); const dir = (await (0, promises_1.readdir)(this.workingDir)).sort(leadingNumberSort); const discFiles = (await (0, helper_1.asyncFlatMap)(dir.filter(f => f.match(/^Disc (\d+)/)), async (f) => { return (await (0, promises_1.readdir)((0, path_1.resolve)(this.workingDir, f))) .sort(leadingNumberSort) .map(inner => `${f}/${inner}`); })).filter(fileTypeFilter); const files = dir .filter(fileTypeFilter) .concat(discFiles) .slice(0, metadata.length) .map(f => (0, path_1.resolve)(this.workingDir, f)); if (files.length === 0) { const message = '未找到任何支持的音乐文件.'; this.spinner.fail(message); throw new Error(message); } const targetFiles = files.map((file, index) => { const maxLength = Math.max(Math.trunc(Math.log10(metadata.length)) + 1, 2); const filename = `${metadata[index].trackNumber.padStart(maxLength, '0')} ${metadata[index].title}${(0, path_1.extname)(file)}`.replace(/[/\\:*?"<>|]/g, ''); return (0, path_1.resolve)(dirname(file), filename); }); (0, debug_1.log)(files, targetFiles); await Promise.all(files.map((file, index) => { return (0, promises_1.rename)(file, targetFiles[index]); })); return targetFiles; } async writeMetadataToFile(metadata, targetFiles) { const { writerMappings } = await Promise.resolve().then(() => __importStar(require('../core/writer/writer-mappings'))); for (let i = 0; i < targetFiles.length; i++) { const file = targetFiles[i]; (0, debug_1.log)(file); const type = (0, path_1.extname)(file); const writer = writerMappings[type]; writer.config = this.metadataConfig; await writer.write(metadata[i], file); if (this.options.lyric && this.options['lyric-output'] === 'lrc' && metadata[i].lyric) { await (0, promises_1.writeFile)(`${file.substring(0, file.lastIndexOf(type))}.lrc`, metadata[i].lyric); } } // FLAC 那个库放 Promise.all 里就只有最后一个会运行??? // await Promise.all(targetFiles.map((file, index) => { // log(file) // const type = extname(file) // return writerMappings[type].write(metadata[index], file) // })) const coverBuffer = metadata[0].coverImage; if (this.options.cover && coverBuffer) { const { default: imageType } = await Promise.resolve().then(() => __importStar(require('image-type'))); const type = imageType(coverBuffer); if (type !== null) { const coverFilename = (0, path_1.resolve)(this.workingDir, `cover.${type.ext}`); (0, debug_1.log)('cover file', coverFilename); await (0, promises_1.writeFile)(coverFilename, coverBuffer); } } } async withRetry(action) { let retryCount = 0; while (retryCount < this.options.retry) { try { const result = await Promise.race([ action(), new Promise((resolve, reject) => { setTimeout(() => reject(TimeoutError), this.options.timeout * 1000); }), ]); return result; } catch (error) { retryCount += 1; const reason = (() => { if (error === TimeoutError) { return `操作超时(${this.options.timeout}秒)`; } if (!error) { return '发生未知错误'; } if (error.stack) { return error.stack; } return error.toString(); })(); (0, debug_1.log)('\nretry get error', retryCount, reason); if (reason.stack) { (0, debug_1.log)(`\n${reason.stack}`); } if (retryCount < this.options.retry) { this.spinner.fail(`${reason}, 进行第${retryCount}次重试...`); } else { throw new Error(reason); } } } throw new Error('发生未知错误'); } async fetchMetadata(album) { return this.withRetry(async () => { const { batch } = this.options; this.spinner.start(batch ? '下载专辑信息中' : `下载专辑信息中: ${album}`); const localCover = await this.getLocalCover(); const localJson = await this.getLocalJson(); const metadata = localJson || (await this.downloadMetadata(album, localCover)); (0, debug_1.log)('final metadata', metadata); this.spinner.text = '创建文件中'; const targetFiles = await this.createFiles(metadata); this.spinner.text = '写入专辑信息中'; await this.writeMetadataToFile(metadata, targetFiles); const defaultAlbumName = await (0, default_album_name_1.getDefaultAlbumName)(this.workingDir); if (album !== defaultAlbumName && !localJson) { await (0, album_options_1.setAlbumOptions)(this.workingDir, { defaultAlbumHint: album, }); } this.spinner.succeed(batch ? '成功写入了专辑信息' : `成功写入了专辑信息: ${album}`); }); } async run(album) { await this.loadAlbumOptions(); const { sourceMappings } = await Promise.resolve().then(() => __importStar(require(`../core/metadata/source-mappings`))); const metadataSource = sourceMappings[this.options.source]; const noInteractive = this.options['no-interactive']; if (!metadataSource) { const message = `未找到与'${this.options.source}'相关联的数据源.`; this.spinner.fail(message); throw new Error(message); } metadataSource.config = this.metadataConfig; (0, debug_1.log)('searching'); const handleError = (error) => { if (error instanceof Error) { this.spinner.fail(`错误: ${error.message}`); } else { throw error; } }; const localJson = await this.getLocalJson(); const searchResult = await this.withRetry(async () => { this.spinner.start('搜索中'); if (localJson !== undefined && localJson.length > 0) { return localJson[0].album; } const remoteResults = await metadataSource.resolveAlbumName(album); const hasOnlyOneResult = Array.isArray(remoteResults) && remoteResults.length === 1; if (hasOnlyOneResult && noInteractive) { return remoteResults[0]; } return remoteResults; }).catch(error => { handleError(error); return []; }); (0, debug_1.log)('fetching metadata'); if (typeof searchResult === 'string') { await this.fetchMetadata(searchResult).catch(handleError); } else if (noInteractive) { this.spinner.fail('未找到匹配专辑或有多个搜索结果'); } else if (searchResult.length > 0) { this.spinner.fail('未找到匹配专辑, 以下是搜索结果:'); console.log(searchResult.map((it, index) => `${index + 1}\t${it}`).join('\n')); const answer = await (0, readline_1.readline)('输入序号可选择相应条目, 或输入其他任意字符取消本次操作: '); const index = parseInt(answer); if (isNaN(index) || index < 1 || index > searchResult.length) { return; } await this.fetchMetadata(searchResult[index - 1]).catch(handleError); } else { this.spinner.fail('未找到匹配专辑, 且没有搜索结果, 请尝试使用更准确的专辑名称.'); } } } exports.CliTagger = CliTagger;