├── dist ├── manifest_chrome.json ├── icons │ ├── icon-128.png │ ├── icon-48.png │ ├── icon-64.png │ └── icon-96.png ├── manifest_firefox.json ├── manifest_original.json └── settings.html ├── jest.config.js ├── src ├── @types │ └── browser-id3-writer │ │ └── index.d.ts ├── tagWriters │ ├── tagWriter.ts │ ├── mp3TagWriter.ts │ ├── wavTagWriter.ts │ └── mp4TagWriter.ts ├── utils │ ├── download.ts │ ├── logger.ts │ ├── download.spec.ts │ ├── domObserver.ts │ └── config.ts ├── settings.ts ├── soundcloudApi.ts ├── compatibilityStubs.ts ├── content.ts ├── metadataExtractor.ts ├── background.ts └── metadataExtractor.spec.ts ├── tsconfig.json ├── webpack.config.js ├── create-dist.sh ├── LICENSE ├── package.json ├── .gitignore ├── README.md └── CHANGELOG.md /dist/manifest_chrome.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /dist/icons/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotTobi/soundcloud-dl/HEAD/dist/icons/icon-128.png -------------------------------------------------------------------------------- /dist/icons/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotTobi/soundcloud-dl/HEAD/dist/icons/icon-48.png -------------------------------------------------------------------------------- /dist/icons/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotTobi/soundcloud-dl/HEAD/dist/icons/icon-64.png -------------------------------------------------------------------------------- /dist/icons/icon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotTobi/soundcloud-dl/HEAD/dist/icons/icon-96.png -------------------------------------------------------------------------------- /dist/manifest_firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "browser_specific_settings": { 3 | "gecko": { 4 | "id": "{c7a839e7-7086-4021-8176-1cfcb7f169ce}" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest", 5 | }, 6 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 7 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 8 | }; 9 | -------------------------------------------------------------------------------- /src/@types/browser-id3-writer/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "browser-id3-writer" { 2 | export default class ID3Writer { 3 | constructor(buffer: ArrayBuffer); 4 | 5 | setFrame(name: string, value: any); 6 | addTag(): void; 7 | getURL(): string; 8 | getBlob(): Blob; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "lib": ["dom", "es2016"], 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "sourceMap": true 10 | }, 11 | "exclude": ["node_modules", "**/*.spec.ts"], 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /src/tagWriters/tagWriter.ts: -------------------------------------------------------------------------------- 1 | export interface TagWriter { 2 | setTitle(title: string): void; 3 | setArtists(artists: string[]): void; 4 | setAlbum(album: string): void; 5 | setComment(comment: string): void; 6 | setTrackNumber(trackNumber: number): void; 7 | setYear(year: number): void; 8 | setArtwork(artworkBuffer: ArrayBuffer): void; 9 | getBuffer(): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 3 | 4 | module.exports = { 5 | mode: "production", 6 | 7 | entry: { 8 | content: "./src/content.ts", 9 | background: "./src/background.ts", 10 | settings: "./src/settings.ts", 11 | }, 12 | 13 | output: { 14 | path: path.resolve(__dirname, "dist/js"), 15 | filename: "[name].js", 16 | }, 17 | 18 | resolve: { 19 | extensions: [".ts", ".js"], 20 | }, 21 | 22 | module: { 23 | rules: [{ test: /\.ts$/, loader: "ts-loader" }], 24 | }, 25 | 26 | plugins: [new CleanWebpackPlugin()], 27 | 28 | optimization: { 29 | minimize: false, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /create-dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | trap "exit 1" ERR 4 | 5 | # build package 6 | 7 | yarn install 8 | 9 | yarn test 10 | 11 | yarn run build 12 | 13 | # bundle for different browsers 14 | 15 | cd dist 16 | 17 | # bundle for Chrome 18 | 19 | jq -s ".[0] * .[1]" "manifest_original.json" "manifest_chrome.json" > "manifest.json" 20 | 21 | zip -r "SoundCloud-Downloader-Chrome.zip" . -x "manifest_*" "*.zip" 22 | 23 | # bundle for Firefox 24 | 25 | jq -s ".[0] * .[1]" "manifest_original.json" "manifest_firefox.json" > "manifest.json" 26 | 27 | zip -r "SoundCloud-Downloader-Firefox.zip" . -x "manifest_*" "*.zip" 28 | 29 | # archive source code for firefox review process 30 | cd .. 31 | 32 | git archive --format zip --output "dist/SoundCloud-Downloader-Source-Code.zip" master -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 NotTobi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/download.ts: -------------------------------------------------------------------------------- 1 | import XRegExp from "xregexp"; 2 | 3 | export function concatArrayBuffers(buffers: ArrayBuffer[]): ArrayBuffer { 4 | const totalLength = buffers.reduce((acc, cur) => acc + cur.byteLength, 0); 5 | 6 | const mergedBuffer = new Uint8Array(totalLength); 7 | 8 | let bufferOffset = 0; 9 | for (const buffer of buffers) { 10 | mergedBuffer.set(new Uint8Array(buffer), bufferOffset); 11 | 12 | bufferOffset += buffer.byteLength; 13 | } 14 | 15 | return mergedBuffer.buffer; 16 | } 17 | 18 | export function sanitizeFilenameForDownload(input: string) { 19 | let sanitized = input.replace(/[<>:"/\\|?*]/g, ""); 20 | sanitized = sanitized.replace(/[\u0000-\u001f\u0080-\u009f]/g, ""); 21 | sanitized = sanitized.replace(/^\.*/, ""); 22 | sanitized = sanitized.replace(/\.*$/, ""); 23 | 24 | // \p{L}: any kind of letter from any language. 25 | // \p{N}: any kind of numeric character in any script. 26 | // \p{Zs}: a whitespace character that is invisible, but does take up space. 27 | sanitized = XRegExp.replace(sanitized, XRegExp("[^\\p{L}\\p{N}\\p{Zs}]]", "g"), ""); 28 | 29 | return sanitized.replace(/\s{2,}/, " ").trim(); 30 | } 31 | -------------------------------------------------------------------------------- /dist/manifest_original.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "SoundCloud Downloader", 4 | "version": "1.14.2", 5 | "author": "NotTobi", 6 | "description": "Adds download buttons for tracks on soundcloud.com", 7 | "homepage_url": "https://github.com/NotTobi/soundcloud-dl", 8 | "icons": { 9 | "48": "icons/icon-48.png", 10 | "64": "icons/icon-64.png", 11 | "96": "icons/icon-96.png", 12 | "128": "icons/icon-128.png" 13 | }, 14 | "permissions": [ 15 | "downloads", 16 | "webRequest", 17 | "storage", 18 | "webRequestBlocking", 19 | "*://*.soundcloud.com/*" 20 | ], 21 | "content_scripts": [ 22 | { 23 | "matches": ["*://*.soundcloud.com/*"], 24 | "js": ["js/content.js"], 25 | "run_at": "document_start" 26 | } 27 | ], 28 | "background": { 29 | "scripts": ["js/background.js"] 30 | }, 31 | "options_ui": { 32 | "page": "settings.html" 33 | }, 34 | "page_action": { 35 | "default_icon": { 36 | "48": "icons/icon-48.png", 37 | "64": "icons/icon-64.png", 38 | "96": "icons/icon-96.png", 39 | "128": "icons/icon-128.png" 40 | }, 41 | "default_title": "Soundcloud Downloader", 42 | "show_matches": ["*://*.soundcloud.com/*"] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soundcloud-dl", 3 | "version": "0.0.0", 4 | "description": "", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/NotTobi/soundcloud-dl.git" 8 | }, 9 | "author": "NotTobi", 10 | "license": "MIT", 11 | "bugs": { 12 | "url": "https://github.com/NotTobi/soundcloud-dl/issues" 13 | }, 14 | "homepage": "https://github.com/NotTobi/soundcloud-dl#readme", 15 | "scripts": { 16 | "start": "webpack --watch", 17 | "build": "webpack", 18 | "test": "jest" 19 | }, 20 | "devDependencies": { 21 | "@types/chrome": "^0.0.140", 22 | "@types/firefox-webext-browser": "^82.0.0", 23 | "@types/jest": "^26.0.23", 24 | "@types/uuid": "^8.0.1", 25 | "@types/xregexp": "^4.4.0", 26 | "clean-webpack-plugin": "^3.0.0", 27 | "jest": "^26.6.3", 28 | "ts-jest": "^26.5.1", 29 | "ts-loader": "^8.0.17", 30 | "typescript": "^4.2.4", 31 | "webpack": "^5.23.0", 32 | "webpack-cli": "^4.5.0" 33 | }, 34 | "dependencies": { 35 | "browser-id3-writer": "^4.4.0", 36 | "escape-string-regexp": "^4.0.0", 37 | "m3u8-parser": "^4.6.0", 38 | "uuid": "^8.3.0", 39 | "wavefile": "^11.0.0", 40 | "xregexp": "^5.0.2" 41 | }, 42 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | export enum LogLevel { 2 | Debug = 0, 3 | Information = 1, 4 | Warning = 2, 5 | Error = 3, 6 | None = 4, 7 | } 8 | 9 | export class Logger { 10 | constructor(private source: string, private minLogLevel: LogLevel) {} 11 | 12 | log(logLevel: LogLevel, message: string, ...args: any[]): void { 13 | if (logLevel < this.minLogLevel) return; 14 | 15 | const timestamp = `[${new Date().toJSON()}]`; 16 | const source = `[SOUNDCLOUD-DL:${this.source}]`; 17 | 18 | switch (logLevel) { 19 | case LogLevel.Error: 20 | console.error(timestamp, source, message, ...args); 21 | break; 22 | case LogLevel.Warning: 23 | console.warn(timestamp, source, message, ...args); 24 | break; 25 | case LogLevel.Information: 26 | console.info(timestamp, source, message, ...args); 27 | break; 28 | case LogLevel.Debug: 29 | console.debug(timestamp, source, message, ...args); 30 | break; 31 | } 32 | } 33 | 34 | logDebug(message: string, ...args: any[]) { 35 | this.log(LogLevel.Debug, message, ...args); 36 | } 37 | 38 | logInfo(message: string, ...args: any[]) { 39 | this.log(LogLevel.Information, message, ...args); 40 | } 41 | 42 | logWarn(message: string, ...args: any[]) { 43 | this.log(LogLevel.Warning, message, ...args); 44 | } 45 | 46 | logError(message: string, ...args: any[]) { 47 | this.log(LogLevel.Error, message, ...args); 48 | } 49 | 50 | static create(name: string, minLogLevel: LogLevel = LogLevel.Information) { 51 | return new Logger(name, minLogLevel); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/download.spec.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeFilenameForDownload } from "./download"; 2 | 3 | const illegalCharacters = ["<", ">", ":", '"', "/", "\\", "|", "?", "*", , "\u0000"]; 4 | 5 | describe("Sanitization of filenames", () => { 6 | test.each(illegalCharacters)("%s in filename", (character) => { 7 | const filename = `Foo${character}Bar`; 8 | const correctFilename = "FooBar"; 9 | 10 | expect(sanitizeFilenameForDownload(filename)).toEqual(correctFilename); 11 | }); 12 | 13 | test("Filename starts with dot", () => { 14 | const filename = ".filename"; 15 | const correctFilename = "filename"; 16 | 17 | expect(sanitizeFilenameForDownload(filename)).toEqual(correctFilename); 18 | }); 19 | 20 | test("Filename ends with dot", () => { 21 | const filename = "filename."; 22 | const correctFilename = "filename"; 23 | 24 | expect(sanitizeFilenameForDownload(filename)).toEqual(correctFilename); 25 | }); 26 | 27 | test("Filename contains multiple spaces", () => { 28 | const filename = "Foo Bar"; 29 | const correctFilename = "Foo Bar"; 30 | 31 | expect(sanitizeFilenameForDownload(filename)).toEqual(correctFilename); 32 | }); 33 | 34 | test("Filename starts with a space", () => { 35 | const filename = " filename"; 36 | const correctFilename = "filename"; 37 | 38 | expect(sanitizeFilenameForDownload(filename)).toEqual(correctFilename); 39 | }); 40 | 41 | test("Filename ends with a space", () => { 42 | const filename = "filename "; 43 | const correctFilename = "filename"; 44 | 45 | expect(sanitizeFilenameForDownload(filename)).toEqual(correctFilename); 46 | }); 47 | 48 | test("Cyrillic filename", () => { 49 | const filename = "Хмари"; 50 | const correctFilename = "Хмари"; 51 | 52 | expect(sanitizeFilenameForDownload(filename)).toEqual(correctFilename); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/tagWriters/mp3TagWriter.ts: -------------------------------------------------------------------------------- 1 | import ID3Writer from "browser-id3-writer"; 2 | import { TagWriter } from "./tagWriter"; 3 | 4 | export class Mp3TagWriter implements TagWriter { 5 | private writer: ID3Writer; 6 | 7 | constructor(buffer: ArrayBuffer) { 8 | this.writer = new ID3Writer(buffer); 9 | } 10 | 11 | setTitle(title: string): void { 12 | if (!title) throw new Error("Invalid value for title"); 13 | 14 | this.writer.setFrame("TIT2", title); 15 | } 16 | 17 | setArtists(artists: string[]): void { 18 | if (!artists || artists.length < 1) throw new Error("Invalid value for artists"); 19 | 20 | this.writer.setFrame("TPE1", artists); 21 | } 22 | 23 | setAlbum(album: string): void { 24 | if (!album) throw new Error("Invalid value for album"); 25 | 26 | this.writer.setFrame("TALB", album); 27 | } 28 | 29 | setComment(comment: string): void { 30 | if (!comment) throw new Error("Invalid value for comment"); 31 | 32 | this.writer.setFrame("COMM", { 33 | text: comment, 34 | description: "", 35 | }); 36 | } 37 | 38 | setTrackNumber(trackNumber: number): void { 39 | // not sure what the highest track number is for ID3, but let's assume it's the max value of short 40 | if (trackNumber < 1 || trackNumber > 32767) throw new Error("Invalid value for trackNumber"); 41 | 42 | this.writer.setFrame("TRCK", trackNumber); 43 | } 44 | 45 | setYear(year: number): void { 46 | if (year < 1) throw new Error("Invalud value for year"); 47 | 48 | this.writer.setFrame("TYER", year); 49 | } 50 | 51 | setArtwork(artworkBuffer: ArrayBuffer): void { 52 | if (!artworkBuffer || artworkBuffer.byteLength < 1) throw new Error("Invalid value for artworkBuffer"); 53 | 54 | this.writer.setFrame("APIC", { 55 | type: 3, 56 | data: artworkBuffer, 57 | description: "", 58 | }); 59 | } 60 | 61 | getBuffer(): Promise { 62 | this.writer.addTag(); 63 | 64 | const blob = this.writer.getBlob(); 65 | 66 | return blob.arrayBuffer(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/tagWriters/wavTagWriter.ts: -------------------------------------------------------------------------------- 1 | import { TagWriter } from "./tagWriter"; 2 | import { WaveFile } from "wavefile"; 3 | 4 | export class WavTagWriter implements TagWriter { 5 | private wav: WaveFile; 6 | 7 | constructor(buffer: ArrayBuffer) { 8 | const uint8Array = new Uint8Array(buffer); 9 | 10 | this.wav = new WaveFile(); 11 | this.wav.fromBuffer(uint8Array); 12 | } 13 | 14 | setTitle(title: string): void { 15 | if (!title) throw new Error("Invalid value for title"); 16 | 17 | this.wav.setTag("INAM", title); 18 | } 19 | 20 | setArtists(artists: string[]): void { 21 | if (!artists || artists.length < 1) throw new Error("Invalid value for artists"); 22 | 23 | this.wav.setTag("IART", artists.join(", ")); 24 | } 25 | 26 | setAlbum(album: string): void { 27 | if (!album) throw new Error("Invalid value for album"); 28 | 29 | this.wav.setTag("IPRD", album); 30 | } 31 | 32 | setComment(comment: string): void { 33 | if (!comment) throw new Error("Invalid value for comment"); 34 | 35 | this.wav.setTag("ICMT", comment); 36 | } 37 | 38 | setTrackNumber(trackNumber: number): void { 39 | // not sure what the highest track number is for RIFF, but let's assume it's the max value of short 40 | if (trackNumber < 1 || trackNumber > 32767) throw new Error("Invalid value for trackNumber"); 41 | 42 | this.wav.setTag("ITRK", trackNumber.toString()); 43 | } 44 | 45 | setYear(year: number): void { 46 | if (year < 1) throw new Error("Invalud value for year"); 47 | 48 | this.wav.setTag("ICRD", year.toString()); 49 | } 50 | 51 | setArtwork(artworkBuffer: ArrayBuffer): void { 52 | if (!artworkBuffer || artworkBuffer.byteLength < 1) throw new Error("Invalid value for artworkBuffer"); 53 | 54 | // this.writer.setFrame("APIC", { 55 | // type: 3, 56 | // data: artworkBuffer, 57 | // description: "", 58 | // }); 59 | } 60 | 61 | getBuffer(): Promise { 62 | this.wav.toRIFF(); 63 | 64 | const rawBuffer = this.wav.toBuffer(); 65 | 66 | console.log({ tags: this.wav.listTags() }); 67 | 68 | return Promise.resolve(rawBuffer.buffer); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | configKeys, 3 | loadConfiguration, 4 | storeConfigValue, 5 | getConfigValue, 6 | resetConfig, 7 | } from "./utils/config"; 8 | import { Logger } from "./utils/logger"; 9 | 10 | const logger = Logger.create("Settings"); 11 | 12 | async function resetSettings(e) { 13 | e.preventDefault(); 14 | 15 | logger.logInfo("Resetting settings..."); 16 | 17 | await resetConfig(); 18 | 19 | await restoreSettings(); 20 | } 21 | 22 | async function saveSettings(e) { 23 | e.preventDefault(); 24 | 25 | logger.logInfo("Saving settings..."); 26 | 27 | for (const configKey of configKeys) { 28 | const elem = document.querySelector(`#${configKey}`); 29 | 30 | if (elem === null) continue; 31 | 32 | let value; 33 | 34 | if (elem.type === "checkbox") value = elem.checked; 35 | else value = elem.value; 36 | 37 | await storeConfigValue(configKey, value); 38 | } 39 | 40 | await restoreSettings(); 41 | } 42 | 43 | async function restoreSettings() { 44 | logger.logInfo("Restoring settings..."); 45 | 46 | try { 47 | await loadConfiguration(); 48 | 49 | for (const configKey of configKeys) { 50 | const elem = document.querySelector(`#${configKey}`); 51 | 52 | if (elem === null) continue; 53 | 54 | const value = getConfigValue(configKey); 55 | 56 | if (typeof value === "boolean") elem.checked = value; 57 | else if (typeof value === "string") elem.value = value; 58 | 59 | const changeEvent = document.createEvent("HTMLEvents"); 60 | changeEvent.initEvent("change", false, true); 61 | elem.dispatchEvent(changeEvent); 62 | } 63 | } catch (error) { 64 | logger.logError("Failed to restore settings!", error); 65 | } 66 | } 67 | 68 | const downloadWithoutPromptElem = document.querySelector( 69 | "#download-without-prompt" 70 | ); 71 | const defaultDownloadLocationElem = document.querySelector( 72 | "#default-download-location" 73 | ); 74 | 75 | downloadWithoutPromptElem.onchange = (event: any) => { 76 | defaultDownloadLocationElem.disabled = !event.target.checked; 77 | }; 78 | 79 | document.addEventListener("DOMContentLoaded", restoreSettings); 80 | document.querySelector("form").addEventListener("submit", saveSettings); 81 | document.querySelector("form").addEventListener("reset", resetSettings); 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist/js 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and not Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # Stores VSCode versions used for testing VSCode extensions 108 | .vscode-test 109 | 110 | # yarn v2 111 | 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .pnp.* 116 | 117 | *.zip 118 | dist/manifest.json 119 | 120 | .idea -------------------------------------------------------------------------------- /src/utils/domObserver.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "./logger"; 2 | 3 | export interface ObserverEvent { 4 | name?: string; 5 | selector: string; 6 | callback: (node: Element) => void; 7 | } 8 | 9 | export class DomObserver { 10 | private observer: MutationObserver; 11 | private events: ObserverEvent[] = []; 12 | private unqiueNodeId: number = 0; 13 | private logger: Logger; 14 | 15 | constructor() { 16 | this.observer = new MutationObserver((mutations) => mutations.forEach((mutation) => this.handleMutation(mutation))); 17 | this.logger = Logger.create("Observer"); 18 | } 19 | 20 | start(node: Node) { 21 | this.observer.observe(node, { subtree: true, attributes: true, childList: true }); 22 | 23 | this.logger.logDebug("Started"); 24 | } 25 | 26 | stop() { 27 | this.observer.disconnect(); 28 | 29 | this.logger.logDebug("Stopped"); 30 | } 31 | 32 | addEvent(event: ObserverEvent) { 33 | if (!event.selector) { 34 | this.logger.logWarn("Selector was not specified"); 35 | 36 | return; 37 | } 38 | 39 | if (!event.callback) { 40 | this.logger.logWarn("Callback was not specified"); 41 | 42 | return; 43 | } 44 | 45 | this.events.push(event); 46 | 47 | this.logger.logDebug("Event added", event); 48 | } 49 | 50 | removeEvent(name: string) { 51 | this.events = this.events.filter((event) => event.name !== name); 52 | } 53 | 54 | private handleMutation(mutation: MutationRecord) { 55 | const target = mutation.target; 56 | const newNodes = mutation.addedNodes ?? []; 57 | 58 | for (const event of this.events) { 59 | if (newNodes.length > 0) { 60 | this.handleNodes(newNodes, event); 61 | } else if (mutation.type === "attributes") { 62 | this.handleNodes([target], event, false); 63 | } 64 | } 65 | } 66 | 67 | private handleNodes(nodes: any[] | NodeList, event: ObserverEvent, recursive: boolean = true) { 68 | if (!nodes) return; 69 | 70 | for (let i = 0; i < nodes.length; i++) { 71 | const node = nodes[i]; 72 | 73 | if (this.matchesSelectors(node, event.selector)) { 74 | // We only want to emmit an event once 75 | if (node._id !== undefined) return; 76 | 77 | node._id = ++this.unqiueNodeId; 78 | event.callback(node); 79 | } 80 | 81 | if (recursive && node.childNodes?.length > 0) this.handleNodes(node.childNodes, event); 82 | } 83 | } 84 | 85 | private matchesSelectors(element: any, selectors: string) { 86 | return element && element instanceof HTMLElement && element.matches(selectors); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | Download the extension from the [Firefox Add-ons page](https://addons.mozilla.org/firefox/addon/soundcloud-dl). 4 | 5 | ## Configuration 6 | 7 | When visiting `soundcloud.com`, there should be an icon of this extension next to the addressbar. 8 | 9 | Click on the icon (and click on 'Options' in Chrome) to get to the configuration section. 10 | 11 | ### What can be configured? 12 | 13 | - Download of high quality version (you need to be a GO+ user) 14 | - Download of the original version (when possible) 15 | - Downloading directly to a default location without a dialog 16 | - Normalization of the track name 17 | - Blocking of reposts in the 'Stream' feed 18 | - Blocking playlists in the 'Stream' feed 19 | - Including producers as artists 20 | 21 | ## Changelog 22 | 23 | The Changelog can be found [here](./CHANGELOG.md). 24 | 25 | ## Donations 26 | 27 | If you want to support the development of this extension, consider donating! 28 | 29 | [**Donate here**](https://www.paypal.me/nottobii) 30 | 31 | ## Known issues 32 | 33 | 1. The normalization of some track names can fail and produce wrong results 34 | 2. Sometimes the extension fails to recognize a user login/logout. A page refresh can help! 35 | 3. The block reposts feature can sometimes not work, checkout [this](https://github.com/NotTobi/soundcloud-dl/issues/12#issuecomment-753988874) for a solution. 36 | 37 | ## How to report an issue 38 | 39 | 1. When you encounter an issue, open the following link in the same browser session where the error occured: about:devtools-toolbox?type=extension&id=%7Bc7a839e7-7086-4021-8176-1cfcb7f169ce%7D (GitHub won't let me link this properly) 40 | 2. Click on the `Console` tab. 41 | 3. You should now be seeing a bunch of log messages 42 | 4. Right click on one of the messages, choose `Export Visible Messages To` > `File` and save the file 43 | 5. Click the following link to create a new issue: [Link](https://github.com/NotTobi/soundcloud-dl/issues/new) 44 | 6. Insert a fitting title for your issue, e.g "Add-On crashes my browser when XYZ" 45 | 7. Write any additional information that could be useful in the body of the issue 46 | 8. Drag the file you just downloaded in the issue body as well 47 | 9. Click on `Submit new issue` 48 | 49 | I will try and respond as soon as possible! 50 | 51 | ## Building instructions for Firefox Add-on review process 52 | 53 | ### Operating system used 54 | 55 | MacOS 15.5 56 | 57 | ### Software/Tooling used 58 | 59 | - node v21.5.0 - [Installation instructions](https://nodejs.org/en/download/) 60 | - yarn v1.22.22 - [Installation instructions](https://classic.yarnpkg.com/en/docs/install) 61 | - jq v1.7.1 [Installation instructions](https://stedolan.github.io/jq/download/) 62 | 63 | ### Build process 64 | 65 | To build the addon run the `create-dist.sh` script. 66 | 67 | The build artifact `SoundCloud-Downloader-Firefox.zip` can be found in the `dist` directory. 68 | -------------------------------------------------------------------------------- /dist/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 54 | 55 | 56 | 57 |
58 |
59 | 60 | 63 |
64 |
65 | 66 | 69 |
70 |
71 | 72 | 75 |
76 |
77 | 78 |
79 | 83 |
84 |
85 |
86 | 87 | 88 |
89 |
90 | 91 | 92 |
93 |
94 | 95 | 96 |
97 | 98 |
99 |
100 | 101 | 102 |
103 |
104 |
105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.14.1 2 | 3 | - Display download button for tracks with a custom background 4 | 5 | ## 1.14.0 6 | 7 | - Added download buttons to additional locations 8 | - Better HLS detection and file extension handling 9 | - A link to the original track is now included as comment in the file metadata 10 | - Removed repost blocking feature, since it's now native 11 | 12 | Thanks to [@araxal](https://github.com/Sergey-Baranenkov) for contributing some these changes! 13 | 14 | ## 1.13.1 15 | 16 | - Catch errors of individual tracks when downloading a set 17 | 18 | ## 1.13.0 19 | 20 | - Add support for setting .wav metadata 21 | 22 | ## 1.12,1 23 | 24 | - Correctly infer .wav extension from Content-Type 25 | 26 | ## 1.12.0 27 | 28 | - Change how filenames are sanitized 29 | - Some more metadata extraction tests 30 | 31 | ## 1.11.1 32 | 33 | - Minor tweak to the blocking of reposts 34 | 35 | ## 1.11.0 36 | 37 | - Allow all unicode letters in metadata/filename 38 | 39 | ## 1.10.0 40 | 41 | - Fallback to other download versions, if current quality + protocol fails 42 | 43 | ## 1.9.9 44 | 45 | - Handle some edge cases when extracting metadata 46 | 47 | ## 1.9.8 48 | 49 | - Handle some edge cases when extracting metadata 50 | 51 | ## 1.9.7 52 | 53 | - Remove non-printable characters from filename 54 | 55 | ## 1.9.6 56 | 57 | - Correctly surface errors to the UI 58 | - Chunk download of large playlists/albums to not hit a limit of the SoundCloud API 59 | 60 | ## 1.9.5 61 | 62 | - Ability to toggle if you want to set metadata or not 63 | - Load configuration values correctly from sync storage 64 | 65 | ## 1.9.4 66 | 67 | - Continue download if setting of m4a duration fails 68 | 69 | ## 1.9.3 70 | 71 | - Replace empty username with URL permalink 72 | 73 | ## 1.9.2 74 | 75 | - Remove Non-ASCII characters from title & artists 76 | - Prevent opening braces after features from being deleted 77 | 78 | ## 1.9.1 79 | 80 | - Remove twitter handles from usernames 81 | - Recover from errors when setting metadata 82 | 83 | ## 1.9.0 84 | 85 | - Add toggle to skip downloading of metadata 86 | 87 | ## 1.8.2 88 | 89 | - Display correct date, when filtering reposts 90 | 91 | ## 1.8.1 92 | 93 | - Update followers for repost filtering 94 | 95 | ## 1.8.0 96 | 97 | - Ability to block playlists in streams 98 | - Fix newly released reposted tracks not showing up, with repost blocker enabled 99 | 100 | ## 1.7.3 101 | 102 | - Better error handling 103 | 104 | ## 1.7.2 105 | 106 | - Correctly escape file names 107 | 108 | ## 1.7.1 109 | 110 | - Fix download of high quality tracks 111 | 112 | ## 1.7.0 113 | 114 | - Support HLS streams 115 | 116 | ## 1.6.3 117 | 118 | - Correctly clean up repost blocker after removal 119 | 120 | ## 1.6.2 121 | 122 | - Minor bug fixes / improvements 123 | 124 | ## 1.6.1 125 | 126 | - Better repost blocking 127 | 128 | ## 1.6.0 129 | 130 | - Add progressbar to buttons 131 | - Show errors to the user 132 | 133 | ## 1.5.8 134 | 135 | - Minor bug fixes 136 | 137 | ## 1.5.7 138 | 139 | - Add ability to toggle, whether producers are treated as artists or not 140 | 141 | ## 1.5.6 142 | 143 | - Add ability to block reposts in the 'Stream' feed 144 | 145 | ## 1.5.5 146 | 147 | - Treat EPs as albums as well 148 | 149 | ## 1.5.4 150 | 151 | - When downloading an album, set the correct name 152 | - Make track normalization configureable 153 | 154 | ## 1.5.3 155 | 156 | - Set Release Year metadata tag 157 | - Change design of options page 158 | 159 | ## 1.5.2 160 | 161 | - Make download procedure configureable 162 | 163 | ## 1.5.1 164 | 165 | - Change design of options page 166 | 167 | ## 1.5.0 168 | 169 | - Allow the download of playlists/albums 170 | - Add track numbers to tracks, downloaded as part of an album 171 | 172 | ## 1.4.7 173 | 174 | - Add download buttons to feeds and search results 175 | 176 | ## 1.4.6 177 | 178 | - Minor refactorings and bug fixes 179 | - Rework bundling process 180 | 181 | ## 1.4.5 182 | 183 | - Add reset button to options 184 | - Better handling of user session 185 | - Refactoring 186 | 187 | ## 1.4.4 188 | 189 | - Cache user authentication tokens 190 | - Minor bug fixes 191 | 192 | ## 1.4.3 193 | 194 | - Minor memory performance improvements 195 | 196 | ## 1.4.2 197 | 198 | - Correctly determine file extension of original downloads 199 | - Fallback to different artwork sizes, if the one we wanted does not exist 200 | 201 | ## 1.4.1 202 | 203 | - Set track duration for .m4a files 204 | - Allow the download of the original file ( if possible ) 205 | 206 | ## 1.4.0 207 | 208 | - Rework generation of .m4a metatdata 209 | 210 | ## 1.3.3 211 | 212 | - Minor performance and code improvements 213 | 214 | ## 1.3.2 215 | 216 | - Add new icons ( Thanks to [NicKoehler](https://github.com/NicKoehler) ) 217 | 218 | ## 1.3.1 219 | 220 | - Minor bug fixes 221 | 222 | ## 1.3.0 223 | 224 | - Download Artwork in original size 225 | - Make download of HQ version configurable via options page 226 | 227 | ## 1.2.1 228 | 229 | - Rework metadata extraction 230 | - Include producers as artists 231 | 232 | ## 1.2.0 233 | 234 | - Go+ Users can now download tracks in higher quality 235 | - Code base is now compatible with Chrome 236 | - Minor bug fixes 237 | 238 | ## 1.1.1 239 | 240 | - Minor bug fixes 241 | 242 | ## 1.1.0 243 | 244 | - Migrate to Typescript 245 | - Remove jQuery 246 | - Fix bug where the download button would show up for albums/playlists as well 247 | 248 | ## 1.0.2 249 | 250 | - Add additional addon metadata / icons 251 | 252 | ## 1.0.1 253 | 254 | - Minor bug fixes 255 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "./logger"; 2 | import { 3 | onStorageChanged, 4 | StorageChange, 5 | setSyncStorage, 6 | getSyncStorage, 7 | getLocalStorage, 8 | setLocalStorage, 9 | } from "../compatibilityStubs"; 10 | import { sanitizeFilenameForDownload } from "./download"; 11 | 12 | const logger = Logger.create("Config"); 13 | let isStorageMonitored = false; 14 | 15 | interface ConfigValue { 16 | value?: T; 17 | defaultValue?: T; 18 | secret?: boolean; 19 | sync?: boolean; 20 | onChanged?: (value: T) => void; 21 | sanitize?: (value: T) => T; 22 | } 23 | 24 | export interface Config { 25 | "download-hq-version": ConfigValue; 26 | "download-original-version": ConfigValue; 27 | "oauth-token": ConfigValue; 28 | "client-id": ConfigValue; 29 | "default-download-location": ConfigValue; 30 | "download-without-prompt": ConfigValue; 31 | "normalize-track": ConfigValue; 32 | "set-metadata": ConfigValue; 33 | "include-producers": ConfigValue; 34 | } 35 | 36 | type OnConfigValueChangedType = (key: keyof Config, value: any) => void; 37 | 38 | let onConfigValueChanged: OnConfigValueChangedType; 39 | 40 | export function setOnConfigValueChanged(callback: OnConfigValueChangedType) { 41 | onConfigValueChanged = callback; 42 | } 43 | 44 | const config: Config = { 45 | "download-hq-version": { sync: true, defaultValue: true }, 46 | "download-original-version": { sync: true, defaultValue: false }, 47 | "oauth-token": { secret: true }, 48 | "client-id": { secret: true }, 49 | "default-download-location": { 50 | defaultValue: "SoundCloud", 51 | sanitize: (value) => sanitizeFilenameForDownload(value), 52 | }, 53 | "download-without-prompt": { defaultValue: true }, 54 | "normalize-track": { sync: true, defaultValue: true }, 55 | "set-metadata": { sync: true, defaultValue: true }, 56 | "include-producers": { sync: true, defaultValue: true }, 57 | }; 58 | 59 | export const configKeys = Object.keys(config) as Array; 60 | 61 | function isConfigKey(key: string): key is keyof Config { 62 | return config[key] !== undefined; 63 | } 64 | 65 | export async function storeConfigValue( 66 | key: TKey, 67 | value: Config[TKey]["value"] 68 | ) { 69 | if (!isConfigKey(key)) return Promise.reject(`Invalid config key: ${key}`); 70 | 71 | const entry = config[key]; 72 | 73 | if (entry.value === value) return Promise.resolve(); 74 | 75 | const sync = entry.sync === true; 76 | 77 | if (entry.sanitize) { 78 | value = entry.sanitize(value as never); 79 | } 80 | 81 | logger.logInfo("Setting", key, "to", getDisplayValue(value, entry)); 82 | 83 | entry.value = value; 84 | 85 | try { 86 | if (sync) { 87 | await setSyncStorage({ [key]: value }); 88 | } else { 89 | await setLocalStorage({ [key]: value }); 90 | } 91 | 92 | if (entry.onChanged) entry.onChanged(value as never); 93 | } catch (error) { 94 | const reason = "Failed to store configuration value"; 95 | 96 | logger.logError(reason, { key, value, sync }); 97 | 98 | return Promise.reject(reason); 99 | } 100 | } 101 | 102 | export async function loadConfigValue( 103 | key: TKey 104 | ): Promise { 105 | if (!isConfigKey(key)) return Promise.reject(`Invalid config key: ${key}`); 106 | 107 | const entry = config[key]; 108 | 109 | const sync = entry.sync === true; 110 | 111 | let result; 112 | if (sync) result = await getSyncStorage(key); 113 | else result = await getLocalStorage(key); 114 | 115 | return result[key] ?? entry.defaultValue; 116 | } 117 | 118 | async function loadConfigValues(keys: TKey[]) { 119 | if (!keys.every(isConfigKey)) return Promise.reject("Invalid config keys"); 120 | 121 | const syncKeys = keys.filter((key) => config[key].sync === true); 122 | const localKeys = keys.filter((key) => !config[key].sync); 123 | 124 | return { 125 | ...(await getSyncStorage(syncKeys)), 126 | ...(await getLocalStorage(localKeys)), 127 | }; 128 | } 129 | 130 | export async function loadConfiguration(monitorStorage: boolean = false) { 131 | const values = await loadConfigValues(configKeys); 132 | 133 | for (const key of configKeys) { 134 | config[key].value = values[key] ?? config[key].defaultValue; 135 | } 136 | 137 | if (monitorStorage && !isStorageMonitored) { 138 | onStorageChanged(handleStorageChanged); 139 | 140 | isStorageMonitored = true; 141 | } 142 | 143 | return config; 144 | } 145 | 146 | export async function resetConfig() { 147 | for (const key of configKeys) { 148 | await storeConfigValue(key, config[key].defaultValue); 149 | } 150 | } 151 | 152 | export function getConfigValue( 153 | key: TKey 154 | ): Config[TKey]["value"] { 155 | return config[key].value; 156 | } 157 | 158 | export function registerConfigChangeHandler( 159 | key: TKey, 160 | callback: (newValue: Config[TKey]["value"]) => void 161 | ) { 162 | config[key].onChanged = callback; 163 | } 164 | 165 | const handleStorageChanged = ( 166 | changes: { [key: string]: StorageChange }, 167 | areaname: string 168 | ) => { 169 | for (const key in changes) { 170 | const { newValue } = changes[key]; 171 | 172 | if (!isConfigKey(key)) continue; 173 | 174 | const entry = config[key]; 175 | 176 | if (entry.value === newValue) continue; 177 | 178 | if (areaname !== "local") 179 | logger.logInfo( 180 | "Remote updating", 181 | key, 182 | "to", 183 | getDisplayValue(newValue, entry) 184 | ); 185 | 186 | entry.value = newValue; 187 | 188 | if (entry.onChanged) entry.onChanged(newValue as never); 189 | 190 | if (!entry.secret && onConfigValueChanged) 191 | onConfigValueChanged(key, newValue); 192 | } 193 | }; 194 | 195 | function getDisplayValue(value: T, entry: ConfigValue): T | string { 196 | if (entry.secret && value) return "***CONFIDENTIAL***"; 197 | 198 | return value; 199 | } 200 | -------------------------------------------------------------------------------- /src/soundcloudApi.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "./utils/logger"; 2 | 3 | interface MediaTranscodingFormat { 4 | protocol: "progressive" | "hls"; 5 | mime_type: string; 6 | } 7 | 8 | interface MediaTranscoding { 9 | snipped: boolean; 10 | quality: "sq" | "hq"; 11 | url: string; 12 | format: MediaTranscodingFormat; 13 | } 14 | 15 | interface Media { 16 | transcodings: MediaTranscoding[]; 17 | } 18 | 19 | interface User { 20 | id: number; 21 | username: string; 22 | avatar_url: string; 23 | permalink: string; 24 | } 25 | 26 | export interface Track { 27 | id: number; 28 | duration: number; // in ms 29 | display_date: string; 30 | kind: string; 31 | state: string; 32 | title: string; 33 | artwork_url: string; 34 | streamable: boolean; 35 | downloadable: boolean; 36 | has_downloads_left: boolean; 37 | user: User; 38 | media: Media; 39 | permalink_url: string; 40 | } 41 | 42 | interface Stream { 43 | url: string; 44 | } 45 | 46 | export interface StreamDetails { 47 | url: string; 48 | extension?: string; 49 | hls: boolean; 50 | } 51 | 52 | interface OriginalDownload { 53 | redirectUri: string; 54 | } 55 | 56 | type KeyedTracks = { [key: number]: Track }; 57 | type ProgressReport = (progress: number) => void; 58 | 59 | export class SoundCloudApi { 60 | readonly baseUrl: string = "https://api-v2.soundcloud.com"; 61 | private logger: Logger; 62 | 63 | constructor() { 64 | this.logger = Logger.create("SoundCloudApi"); 65 | } 66 | 67 | resolveUrl(url: string) { 68 | const reqUrl = `${this.baseUrl}/resolve?url=${url}`; 69 | 70 | return this.fetchJson(reqUrl); 71 | } 72 | 73 | getCurrentUser() { 74 | const url = `${this.baseUrl}/me`; 75 | 76 | return this.fetchJson(url); 77 | } 78 | 79 | async getFollowedArtistIds(userId: number): Promise { 80 | const url = `${this.baseUrl}/users/${userId}/followings/ids`; 81 | 82 | const data = await this.fetchJson(url); 83 | 84 | if (!data || !data.collection) return null; 85 | 86 | return data.collection; 87 | } 88 | 89 | async getTracks(trackIds: number[]): Promise { 90 | const url = `${this.baseUrl}/tracks?ids=${trackIds.join(",")}`; 91 | 92 | this.logger.logInfo("Fetching tracks with Ids", { trackIds }); 93 | 94 | const tracks = await this.fetchJson(url); 95 | 96 | return trackIds.reduce((acc, cur, index) => { 97 | acc[cur] = tracks[index]; 98 | 99 | return acc; 100 | }, {}); 101 | } 102 | 103 | convertMimeTypeToExtension(mimeType: string): string | null { 104 | const baseMimeType = mimeType.split(";")[0].trim().toLowerCase(); 105 | 106 | switch (baseMimeType) { 107 | case "audio/aac": 108 | return "aac"; 109 | case "audio/mp4": 110 | return "m4a"; 111 | case "audio/mpeg": 112 | return "mp3"; 113 | case "audio/ogg": 114 | return "ogg"; 115 | case "audio/opus": 116 | return "opus"; 117 | case "audio/webm": 118 | return "webm"; 119 | case "audio/wav": 120 | case "audio/x-wav": 121 | case "audio/wave": 122 | case "audio/x-pn-wav": 123 | return "wav"; 124 | case "audio/flac": 125 | case "audio/x-flac": 126 | return "flac"; 127 | case "audio/amr": 128 | return "amr"; 129 | case "audio/3gpp": 130 | return "3gp"; 131 | case "audio/3gpp2": 132 | return "3g2"; 133 | case "audio/vnd.wave": 134 | return "wav"; 135 | case "audio/x-ms-wma": 136 | return "wma"; 137 | case "audio/vnd.rn-realaudio": 138 | return "ra"; 139 | case "audio/basic": 140 | return "au"; 141 | case "audio/mpegurl": 142 | case "application/x-mpegurl": 143 | case "application/vnd.apple.mpegurl": 144 | return "m3u8"; 145 | default: 146 | return null; 147 | } 148 | } 149 | 150 | async getStreamUrl(url: string): Promise { 151 | const stream = await this.fetchJson(url); 152 | if (!stream || !stream.url) { 153 | this.logger.logError("Invalid stream response", stream); 154 | throw new Error("Invalid stream response"); 155 | } 156 | 157 | return stream.url; 158 | } 159 | 160 | async getOriginalDownloadUrl(id: number) { 161 | const url = `${this.baseUrl}/tracks/${id}/download`; 162 | 163 | this.logger.logInfo("Getting original download URL for track with Id", id); 164 | 165 | const downloadObj = await this.fetchJson(url); 166 | 167 | if (!downloadObj || !downloadObj.redirectUri) { 168 | this.logger.logError("Invalid original file response", downloadObj); 169 | 170 | return null; 171 | } 172 | 173 | return downloadObj.redirectUri; 174 | } 175 | 176 | async downloadArtwork(artworkUrl: string) { 177 | const [buffer] = await this.fetchArrayBuffer(artworkUrl); 178 | return buffer; 179 | } 180 | 181 | downloadStream(streamUrl: string, reportProgress: ProgressReport) { 182 | return this.fetchArrayBuffer(streamUrl, reportProgress); 183 | } 184 | 185 | private async fetchArrayBuffer(url: string, reportProgress?: ProgressReport): Promise<[ArrayBuffer, Headers]> { 186 | try { 187 | if (reportProgress) { 188 | return new Promise((resolve, reject) => { 189 | const req = new XMLHttpRequest(); 190 | 191 | try { 192 | const handleProgress = (event: ProgressEvent) => { 193 | const progress = Math.round((event.loaded / event.total) * 100); 194 | 195 | reportProgress(progress); 196 | }; 197 | 198 | const handleReadyStateChanged = async (event: Event) => { 199 | if (req.readyState == req.DONE) { 200 | if (req.status !== 200 || !req.response) { 201 | resolve([null, null]); 202 | 203 | return; 204 | } 205 | 206 | reportProgress(100); 207 | 208 | const headers = new Headers(); 209 | 210 | const headerString = req.getAllResponseHeaders(); 211 | const headerMap = headerString 212 | .split("\r\n") 213 | .filter((i) => !!i) 214 | .map((i) => { 215 | const [name, value] = i.split(": "); 216 | 217 | return [name, value]; 218 | }); 219 | 220 | for (const [name, value] of headerMap) { 221 | headers.set(name, value); 222 | } 223 | 224 | resolve([req.response, headers]); 225 | } 226 | }; 227 | 228 | req.responseType = "arraybuffer"; 229 | req.onprogress = handleProgress; 230 | req.onreadystatechange = handleReadyStateChanged; 231 | req.onerror = reject; 232 | req.open("GET", url, true); 233 | req.send(null); 234 | } catch (error) { 235 | this.logger.logError(`Failed to fetch ArrayBuffer with progress from: ${url}`, error); 236 | 237 | reject(error); 238 | } 239 | }); 240 | } 241 | 242 | const resp = await fetch(url); 243 | 244 | if (!resp.ok) return [null, null]; 245 | 246 | const buffer = await resp.arrayBuffer(); 247 | 248 | if (!buffer) return [null, null]; 249 | 250 | return [buffer, resp.headers]; 251 | } catch (error) { 252 | this.logger.logError(`Failed to fetch ArrayBuffer from: ${url}`, error); 253 | 254 | return [null, null]; 255 | } 256 | } 257 | 258 | private async fetchJson(url: string) { 259 | try { 260 | const resp = await fetch(url); 261 | 262 | if (!resp.ok) return null; 263 | 264 | const json = (await resp.json()) as T; 265 | 266 | if (!json) return null; 267 | 268 | return json; 269 | } catch (error) { 270 | this.logger.logError("Failed to fetch JSON from", url); 271 | 272 | return null; 273 | } 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/compatibilityStubs.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "./utils/logger"; 2 | 3 | const logger = Logger.create("Compatibility Stubs"); 4 | 5 | type BeforeSendHeadersCallback = (details: any) => any; 6 | 7 | export const onBeforeSendHeaders = (callback: BeforeSendHeadersCallback, urls?: string[], extraInfos?: string[]) => { 8 | if (typeof browser !== "undefined") { 9 | // @ts-ignore 10 | browser.webRequest.onBeforeSendHeaders.addListener(callback, { urls }, extraInfos); 11 | } else if (typeof chrome !== "undefined") { 12 | chrome.webRequest.onBeforeSendHeaders.addListener(callback, { urls }, extraInfos); 13 | } else { 14 | logger.logError("Browser does not support webRequest.onBeforeSendHeaders"); 15 | } 16 | }; 17 | 18 | type OnBeforeRequestCallback = (details: any) => any; 19 | 20 | export const onBeforeRequest = (callback: OnBeforeRequestCallback, urls: string[], extraInfos?: string[]) => { 21 | if (typeof browser !== "undefined") { 22 | // @ts-ignore 23 | browser.webRequest.onBeforeRequest.addListener(callback, { urls }, extraInfos); 24 | } else if (typeof chrome !== "undefined") { 25 | chrome.webRequest.onBeforeRequest.addListener(callback, { urls }, extraInfos); 26 | } else { 27 | logger.logError("Browser does not support webRequest.onBeforeRequest"); 28 | } 29 | }; 30 | 31 | type MessageFromTabCallback = (sender: any, message: any) => void; 32 | 33 | export const onMessage = (callback: MessageFromTabCallback) => { 34 | if (typeof browser !== "undefined") { 35 | browser.runtime.onMessage.addListener((message, sender) => { 36 | if (sender.id !== browser.runtime.id || !message) return; 37 | 38 | callback(sender, message); 39 | 40 | return true; 41 | }); 42 | } else if (typeof chrome !== "undefined") { 43 | chrome.runtime.onMessage.addListener((message, sender) => { 44 | if (sender.id !== chrome.runtime.id || !message) return; 45 | 46 | callback(sender, message); 47 | 48 | return true; 49 | }); 50 | } else { 51 | logger.logError("Browser does not support runtime.onMessage"); 52 | } 53 | }; 54 | 55 | export const downloadToFile = (url: string, filename: string, saveAs: boolean) => { 56 | const downloadOptions = { 57 | url, 58 | filename, 59 | saveAs, 60 | }; 61 | 62 | return new Promise(async (resolve, reject) => { 63 | let downloadId; 64 | 65 | if (typeof browser !== "undefined") { 66 | const onChangedHandler = (delta: { id: number; state?: { current?: string } }) => { 67 | if (delta.id === downloadId) { 68 | if (delta.state?.current === "complete") resolve(); 69 | if (delta.state?.current === "interrupted") reject("Download was interrupted"); 70 | 71 | browser.downloads.onChanged.removeListener(onChangedHandler); 72 | } 73 | }; 74 | 75 | browser.downloads.onChanged.addListener(onChangedHandler); 76 | 77 | try { 78 | downloadId = await browser.downloads.download(downloadOptions); 79 | } catch { 80 | reject(); 81 | } 82 | } else if (typeof chrome !== "undefined") { 83 | const onChangedHandler = (delta: { id: number; state?: { current?: string } }) => { 84 | if (delta.id === downloadId) { 85 | resolve(); 86 | 87 | chrome.downloads.onChanged.removeListener(onChangedHandler); 88 | } 89 | }; 90 | 91 | chrome.downloads.onChanged.addListener(onChangedHandler); 92 | 93 | chrome.downloads.download(downloadOptions, (id) => (downloadId = id)); 94 | } else { 95 | return Promise.reject("Browser does not support downloads.download"); 96 | } 97 | }); 98 | }; 99 | 100 | export const sendMessageToBackend = (message: any) => { 101 | if (typeof browser !== "undefined") { 102 | return browser.runtime.sendMessage(message); 103 | } else if (typeof chrome !== "undefined") { 104 | return new Promise((resolve) => chrome.runtime.sendMessage(message, resolve)); 105 | } else { 106 | return Promise.reject("Browser does not support runtime.sendMessage"); 107 | } 108 | }; 109 | 110 | export const sendMessageToTab = (tabId: number, message: any) => { 111 | if (typeof browser !== "undefined") { 112 | return browser.tabs.sendMessage(tabId, message); 113 | } else if (typeof chrome !== "undefined") { 114 | return new Promise((resolve) => chrome.tabs.sendMessage(tabId, message, resolve)); 115 | } else { 116 | return Promise.reject("Browser does not support tabs.sendMessage"); 117 | } 118 | }; 119 | 120 | export const onPageActionClicked = (callback: (tabId: number) => void) => { 121 | if (typeof browser !== "undefined") { 122 | browser.pageAction.onClicked.addListener((tab) => callback(tab.id)); 123 | } else if (typeof chrome !== "undefined") { 124 | chrome.pageAction.onClicked.addListener((tab) => callback(tab.id)); 125 | } else { 126 | logger.logError("Browser does not support pageAction.onClicked"); 127 | } 128 | }; 129 | 130 | export const openOptionsPage = () => { 131 | if (typeof browser !== "undefined") { 132 | browser.runtime.openOptionsPage(); 133 | } else if (typeof chrome !== "undefined") { 134 | chrome.runtime.openOptionsPage(); 135 | } else { 136 | logger.logError("Browser does not support runtime.openOptionsPage"); 137 | } 138 | }; 139 | 140 | export interface StorageChange { 141 | newValue?: any; 142 | oldValue?: any; 143 | } 144 | 145 | export const onStorageChanged = (callback: (changes: { [key: string]: StorageChange }, areaName: string) => void) => { 146 | if (typeof browser !== "undefined") { 147 | browser.storage.onChanged.addListener(callback); 148 | } else if (typeof chrome !== "undefined") { 149 | chrome.storage.onChanged.addListener(callback); 150 | } else { 151 | logger.logError("Browser does not support storage.onChanged"); 152 | } 153 | }; 154 | 155 | export const setSyncStorage = (values: { [key: string]: any }) => { 156 | if (typeof browser !== "undefined") { 157 | return browser.storage.sync.set(values); 158 | } else if (typeof chrome !== "undefined") { 159 | return new Promise((resolve) => chrome.storage.sync.set(values, resolve)); 160 | } else { 161 | return Promise.reject("Browser does not support storage.sync.set"); 162 | } 163 | }; 164 | 165 | export const getSyncStorage = (keys?: string | string[]) => { 166 | if (typeof browser !== "undefined") { 167 | return browser.storage.sync.get(keys); 168 | } else if (typeof chrome !== "undefined") { 169 | return new Promise<{ [key: string]: any }>((resolve) => chrome.storage.sync.get(keys, resolve)); 170 | } else { 171 | return Promise.reject("Browser does not support storage.sync.get"); 172 | } 173 | }; 174 | 175 | export const setLocalStorage = (values: { [key: string]: any }) => { 176 | if (typeof browser !== "undefined") { 177 | return browser.storage.local.set(values); 178 | } else if (typeof chrome !== "undefined") { 179 | return new Promise((resolve) => chrome.storage.local.set(values, resolve)); 180 | } else { 181 | return Promise.reject("Browser does not support storage.local.set"); 182 | } 183 | }; 184 | 185 | export const getLocalStorage = (keys?: string | string[]) => { 186 | if (typeof browser !== "undefined") { 187 | return browser.storage.local.get(keys); 188 | } else if (typeof chrome !== "undefined") { 189 | return new Promise<{ [key: string]: any }>((resolve) => chrome.storage.local.get(keys, resolve)); 190 | } else { 191 | return Promise.reject("Browser does not support storage.local.get"); 192 | } 193 | }; 194 | 195 | export const getExtensionManifest = () => { 196 | if (typeof browser !== "undefined") { 197 | return browser.runtime.getManifest(); 198 | } else if (typeof chrome !== "undefined") { 199 | return chrome.runtime.getManifest(); 200 | } else { 201 | logger.logError("Browser does not support runtime.getManifest"); 202 | 203 | return null; 204 | } 205 | }; 206 | 207 | export const getPathFromExtensionFile = (relativePath: string) => { 208 | if (typeof browser !== "undefined") { 209 | return browser.extension.getURL(relativePath); 210 | } else if (typeof chrome !== "undefined") { 211 | return chrome.extension.getURL(relativePath); 212 | } else { 213 | logger.logError("Browser does not support extension.getURL"); 214 | 215 | return null; 216 | } 217 | }; 218 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import { DomObserver, ObserverEvent } from "./utils/domObserver"; 2 | import { Logger } from "./utils/logger"; 3 | import { sendMessageToBackend, onMessage } from "./compatibilityStubs"; 4 | import { 5 | loadConfiguration, 6 | setOnConfigValueChanged, 7 | configKeys, 8 | } from "./utils/config"; 9 | import { v4 as uuid } from "uuid"; 10 | 11 | interface DownloadButton { 12 | elem: HTMLButtonElement; 13 | onClick: any; 14 | } 15 | 16 | type KeyedButtons = { [key: string]: DownloadButton }; 17 | type OnButtonClicked = (downloadId: string) => Promise; 18 | 19 | type SetupDownloadButtonOptions = { 20 | selector: string; 21 | getTrackUrl: (node: Element) => string | null; 22 | getButtonParent: (node: Element) => Element | null; 23 | isSmall?: boolean; 24 | }; 25 | 26 | let observer: DomObserver | null = null; 27 | const logger = Logger.create("SoundCloud-Downloader"); 28 | 29 | const downloadButtons: KeyedButtons = {}; 30 | 31 | const setButtonText = ( 32 | button: HTMLButtonElement, 33 | text: string, 34 | title?: string 35 | ) => { 36 | button.innerText = text; 37 | 38 | button.title = title ?? text; 39 | }; 40 | 41 | const resetButtonBackground = (button: HTMLButtonElement) => { 42 | button.style.backgroundColor = ""; 43 | button.style.background = ""; 44 | }; 45 | 46 | const handleMessageFromBackgroundScript = async (_, message: any) => { 47 | const { downloadId, progress, error } = message; 48 | 49 | const { elem: downloadButton, onClick: originalOnClick } = 50 | downloadButtons[downloadId]; 51 | 52 | if (!downloadButton) return; 53 | 54 | if (progress === 101) { 55 | resetButtonBackground(downloadButton); 56 | 57 | downloadButton.style.backgroundColor = "#19a352"; 58 | 59 | setButtonText(downloadButton, "Downloaded!"); 60 | 61 | setTimeout(() => { 62 | resetButtonBackground(downloadButton); 63 | 64 | setButtonText(downloadButton, "Download"); 65 | 66 | downloadButton.style.cursor = "pointer"; 67 | downloadButton.onclick = originalOnClick; 68 | 69 | delete downloadButtons[downloadId]; 70 | }, 2000); 71 | } else if (progress === 100) { 72 | setButtonText(downloadButton, "Finishing..."); 73 | 74 | downloadButton.style.background = `linear-gradient(90deg, #ff5419 ${progress}%, transparent 0%)`; 75 | } else if (progress) { 76 | setButtonText(downloadButton, "Downloading..."); 77 | 78 | downloadButton.style.background = `linear-gradient(90deg, #ff5419 ${progress}%, transparent 0%)`; 79 | } 80 | 81 | if (error) { 82 | resetButtonBackground(downloadButton); 83 | 84 | downloadButton.style.backgroundColor = "#d30029"; 85 | 86 | setButtonText(downloadButton, "ERROR", error); 87 | 88 | delete downloadButtons[downloadId]; 89 | } 90 | }; 91 | 92 | // ----------------------- 93 | // SETUP MESSAGE HANDLER 94 | // ----------------------- 95 | onMessage(handleMessageFromBackgroundScript); 96 | 97 | const createDownloadButton = (small?: boolean) => { 98 | const button = document.createElement("button"); 99 | const buttonSizeClass = small ? "sc-button-small" : "sc-button-medium"; 100 | 101 | button.className = `sc-button-download sc-button ${buttonSizeClass} sc-button-responsive`; 102 | setButtonText(button, "Download"); 103 | 104 | return button; 105 | }; 106 | 107 | const addDownloadButtonToParent = ( 108 | parent: Node & ParentNode, 109 | onClicked: OnButtonClicked, 110 | small?: boolean 111 | ) => { 112 | const downloadButtonExists = 113 | parent.querySelector("button.sc-button-download") !== null; 114 | 115 | if (downloadButtonExists) { 116 | logger.logDebug("Download button already exists"); 117 | 118 | return; 119 | } 120 | 121 | const button = createDownloadButton(small); 122 | button.onclick = async () => { 123 | const downloadId = uuid(); 124 | 125 | downloadButtons[downloadId] = { 126 | elem: button, 127 | onClick: button.onclick, 128 | }; 129 | 130 | button.style.cursor = "default"; 131 | button.onclick = null; 132 | setButtonText(button, "Preparing..."); 133 | 134 | await onClicked(downloadId); 135 | }; 136 | 137 | parent.appendChild(button); 138 | }; 139 | 140 | const createDownloadCommand = (url: string) => (downloadId: string) => { 141 | const pathname = new URL(url).pathname; 142 | const parts = pathname.split("/").filter(Boolean); // удаляем пустые сегменты 143 | 144 | const set = parts.length >= 2 && parts[parts.length - 2] === "sets"; 145 | 146 | logger.logInfo(`download command url: ${url}, is set: ${set}`); 147 | 148 | return sendMessageToBackend({ 149 | type: set ? "DOWNLOAD_SET" : "DOWNLOAD", 150 | url, 151 | downloadId, 152 | }); 153 | }; 154 | 155 | const setupDownloadButtons = ({ 156 | selector, 157 | getTrackUrl, 158 | getButtonParent, 159 | isSmall = false, 160 | }: SetupDownloadButtonOptions) => { 161 | const handler = (node: Element) => { 162 | const trackUrl = getTrackUrl(node); 163 | if (!trackUrl) { 164 | logger.logError("Failed to determine track URL"); 165 | return; 166 | } 167 | 168 | const downloadUrl = window.location.origin + trackUrl; 169 | const downloadCommand = createDownloadCommand(downloadUrl); 170 | 171 | const parent = getButtonParent(node); 172 | if (!parent) { 173 | logger.logError("Failed to determine parent element for download button"); 174 | return; 175 | } 176 | 177 | addDownloadButtonToParent(parent, downloadCommand, isSmall); 178 | }; 179 | 180 | document.querySelectorAll(selector).forEach(handler); 181 | 182 | const event: ObserverEvent = { 183 | selector, 184 | callback: handler, 185 | }; 186 | 187 | observer?.addEvent(event); 188 | }; 189 | 190 | const handlePageLoaded = async () => { 191 | observer = new DomObserver(); 192 | 193 | // Track from track page, mix / station / playlist (download all on a page) 194 | setupDownloadButtons({ 195 | selector: 196 | ".listenEngagement__footer .sc-button-group, .systemPlaylistDetails__controls", 197 | getTrackUrl: () => window.location.pathname, 198 | getButtonParent: (node) => node, 199 | }); 200 | 201 | // Track from track page (visual style) 202 | setupDownloadButtons({ 203 | selector: ".sound__footer .sc-button-group", 204 | getTrackUrl: () => window.location.pathname, 205 | getButtonParent: (node) => node, 206 | }); 207 | 208 | // Single track in playlist / mix / station (download selected track) 209 | setupDownloadButtons({ 210 | selector: ".trackItem .sc-button-group", 211 | getTrackUrl: (node) => { 212 | const trackItem = node.closest(".trackItem"); 213 | const el = trackItem?.querySelector("a.trackItem__trackTitle"); 214 | return el?.getAttribute("href") ?? null; 215 | }, 216 | getButtonParent: (node) => node, 217 | }); 218 | 219 | // Single track in feed / author's page (download selected track) 220 | setupDownloadButtons({ 221 | selector: ".soundList__item .sc-button-group", 222 | getTrackUrl: (node) => { 223 | const trackItem = node.closest(".soundList__item"); 224 | const el = trackItem?.querySelector("a.soundTitle__title"); 225 | return el?.getAttribute("href") ?? null; 226 | }, 227 | getButtonParent: (node) => node, 228 | }); 229 | 230 | setupDownloadButtons({ 231 | selector: ".searchItem .sc-button-group", 232 | getTrackUrl: (node) => { 233 | const trackItem = node.closest(".searchItem"); 234 | const el = trackItem?.querySelector("a.soundTitle__title"); 235 | return el?.getAttribute("href") ?? null; 236 | }, 237 | getButtonParent: (node) => node, 238 | }); 239 | 240 | // Next up modal (download selected track) 241 | setupDownloadButtons({ 242 | selector: ".queueItemView__actions", 243 | getTrackUrl: (node) => { 244 | const el = node 245 | .closest(".queue__itemWrapper") 246 | ?.querySelector(".queueItemView__title a"); 247 | return el?.getAttribute("href") ?? null; 248 | }, 249 | getButtonParent: (node) => node, 250 | isSmall: true, 251 | }); 252 | 253 | observer.start(document.body); 254 | 255 | logger.logInfo("Attached!"); 256 | }; 257 | 258 | const documentState = document.readyState; 259 | 260 | if (documentState === "complete" || documentState === "interactive") { 261 | setTimeout(handlePageLoaded, 0); 262 | } 263 | 264 | document.addEventListener("DOMContentLoaded", handlePageLoaded); 265 | 266 | window.onbeforeunload = () => { 267 | observer?.stop(); 268 | logger.logDebug("Unattached!"); 269 | }; 270 | 271 | function writeConfigValueToLocalStorage(key: string, value: any) { 272 | const item = JSON.stringify(value); 273 | 274 | window.localStorage.setItem("SOUNDCLOUD-DL-" + key, item); 275 | } 276 | 277 | loadConfiguration(true).then((config) => { 278 | for (const key of configKeys) { 279 | if (config[key].secret) continue; 280 | 281 | writeConfigValueToLocalStorage(key, config[key].value); 282 | } 283 | 284 | setOnConfigValueChanged(writeConfigValueToLocalStorage); 285 | }); 286 | -------------------------------------------------------------------------------- /src/metadataExtractor.ts: -------------------------------------------------------------------------------- 1 | import escapeStringRegexp from "escape-string-regexp"; 2 | import XRegExp from "xregexp"; 3 | 4 | export enum ArtistType { 5 | Main, 6 | Feature, 7 | Remixer, 8 | Producer, 9 | } 10 | 11 | export enum RemixType { 12 | Remix, 13 | Flip, 14 | Bootleg, 15 | Mashup, 16 | Edit, 17 | } 18 | 19 | export function getRemixTypeFromString(input: string) { 20 | const loweredInput = input.toLowerCase().trim(); 21 | 22 | switch (loweredInput) { 23 | case "flip": 24 | return RemixType.Flip; 25 | case "bootleg": 26 | return RemixType.Bootleg; 27 | case "mashup": 28 | return RemixType.Mashup; 29 | case "edit": 30 | return RemixType.Edit; 31 | case "remix": 32 | default: 33 | return RemixType.Remix; 34 | } 35 | } 36 | 37 | export interface Artist { 38 | name: string; 39 | type: ArtistType; 40 | remixType?: RemixType; 41 | } 42 | 43 | interface TitleSplit { 44 | artistNames: string[]; 45 | title: string; 46 | } 47 | 48 | interface RemixTitleSplit { 49 | artists: Artist[]; 50 | title: string; 51 | } 52 | 53 | function stableSort(input: T[], prop: keyof T) { 54 | const storedPositions = input.map((data, index) => ({ 55 | data, 56 | index, 57 | })); 58 | 59 | return storedPositions 60 | .sort((a, b) => { 61 | if (a.data[prop] < b.data[prop]) return -1; 62 | if (a.data[prop] > b.data[prop]) return 1; 63 | return a.index - b.index; 64 | }) 65 | .map((i) => i.data); 66 | } 67 | 68 | export class MetadataExtractor { 69 | static readonly titleSeparators = ["-", "–", "—", "~"]; 70 | static readonly featureSeparators = ["featuring", "feat.", "feat", "ft.", " ft ", "w/", " w /", " w ", "+"]; 71 | static readonly combiningFeatureSeparators = [...MetadataExtractor.featureSeparators, ", ", " & ", " x "]; 72 | static readonly remixIndicators = ["remix", "flip", "bootleg", "mashup", "edit"]; 73 | static readonly producerIndicators = [ 74 | "prod. by ", 75 | "prod by ", 76 | "prod. ", 77 | "p. ", 78 | "prod ", 79 | ]; 80 | static readonly promotions = ["free download", "video in description", "video in desc", "vid in desc", "Original Mix"]; 81 | 82 | constructor(private title: string, private username: string, private userPermalink?: string) {} 83 | 84 | getArtists(): Artist[] { 85 | const title = this.preprocessTitle(this.title); 86 | 87 | let artists: Artist[] = []; 88 | 89 | const titleSplit = this.splitByTitleSeparators(title, true); 90 | 91 | // artists before the title separator, e.g. >artist< - title 92 | artists = artists.concat( 93 | titleSplit.artistNames.map((name, index) => ({ 94 | name, 95 | type: index === 0 ? ArtistType.Main : ArtistType.Feature, 96 | })) 97 | ); 98 | 99 | // producers after the title separator, e.g. artist - title (prod. >artist<) 100 | // we expect the producer section to be last, if not everthing fails :( 101 | const producerSplit = this.splitByProducer(titleSplit.title, true); 102 | 103 | artists = artists.concat( 104 | producerSplit.artistNames.map((name) => ({ 105 | name, 106 | type: ArtistType.Producer, 107 | })) 108 | ); 109 | 110 | // remixers after the title separator, e.g. artist - title (>artist< Remix) 111 | const remixSplit = this.splitByRemix(producerSplit.title, true); 112 | 113 | artists = artists.concat(remixSplit.artists); 114 | 115 | // get producers from braces, e.g. artist - title (producer) 116 | const unsafeProducerSplit = this.splitByUnsafeProducers(remixSplit.title, true); 117 | 118 | artists = artists.concat( 119 | unsafeProducerSplit.artistNames.map((name) => ({ 120 | name, 121 | type: ArtistType.Producer, 122 | })) 123 | ); 124 | 125 | // features after the title separator, e.g. artist - title (ft. >artist<) 126 | const featureSplit = this.splitByFeatures(remixSplit.title, true); 127 | 128 | artists = artists.concat( 129 | featureSplit.artistNames.map((name) => ({ 130 | name, 131 | type: ArtistType.Feature, 132 | })) 133 | ); 134 | 135 | const hasMainArtist = artists.some((i) => i.type === ArtistType.Main); 136 | 137 | if (!hasMainArtist) { 138 | const user = { 139 | name: this.sanitizeArtistName(this.username) || this.userPermalink, 140 | type: ArtistType.Main, 141 | }; 142 | 143 | if (!!user.name) { 144 | if (artists.length > 0) { 145 | artists = [user, ...artists]; 146 | } else { 147 | artists.push(user); 148 | } 149 | } 150 | } 151 | 152 | artists = artists.map((artist) => this.removeTwitterHandle(artist)); 153 | 154 | const distinctArtists: Artist[] = []; 155 | 156 | // Only distinct artists 157 | for (const artist of artists) { 158 | if (distinctArtists.some((i) => i.name == artist.name)) continue; 159 | 160 | distinctArtists.push(artist); 161 | } 162 | 163 | // sort by importance 164 | return stableSort(distinctArtists, "type"); 165 | } 166 | 167 | getTitle(): string { 168 | let title = this.preprocessTitle(this.title); 169 | 170 | title = this.splitByTitleSeparators(title, false).title; 171 | 172 | title = this.splitByProducer(title, false).title; 173 | 174 | title = this.splitByRemix(title, false).title; 175 | 176 | title = this.splitByFeatures(title, false).title; 177 | 178 | title = this.splitByUnsafeProducers(title, false).title; 179 | 180 | return this.sanitizeTitle(title); 181 | } 182 | 183 | private removeTwitterHandle(artist: Artist) { 184 | artist.name = artist.name.replace(/^[@]+/, ""); 185 | 186 | const result = /^([^\(]+)\s?\(?\s?@.+\)?$/.exec(artist.name); 187 | 188 | if (result && result.length > 1) { 189 | artist.name = result[1].trimEnd(); 190 | } 191 | 192 | return artist; 193 | } 194 | 195 | private splitByTitleSeparators(title: string, extractArtists: boolean): TitleSplit { 196 | let artistNames: string[] = []; 197 | 198 | if (this.includes(title, MetadataExtractor.titleSeparators)) { 199 | const separators = this.escapeRegexArray(MetadataExtractor.titleSeparators); 200 | const regex = new RegExp(`^((.+)\\s[${separators}]\\s)(.+)$`); 201 | 202 | const result = regex.exec(title); 203 | 204 | if (result && result.length > 0) { 205 | const [_, artistSection, artistString] = result; 206 | 207 | if (extractArtists) { 208 | artistNames = this.getArtistNames(artistString); 209 | } 210 | 211 | title = title.replace(artistSection, ""); 212 | } 213 | } 214 | 215 | return { 216 | artistNames, 217 | title, 218 | }; 219 | } 220 | 221 | private splitByFeatures(title: string, extractArtists: boolean): TitleSplit { 222 | let artistNames: string[] = []; 223 | 224 | if (this.includes(title, MetadataExtractor.featureSeparators)) { 225 | const separators = this.escapeRegexArray(MetadataExtractor.featureSeparators).join("|"); 226 | const regex = new RegExp(`(?:${separators})([^\\[\\]\\(\\)]+)`, "i"); 227 | 228 | const result = regex.exec(title); 229 | 230 | if (result && result.length > 0) { 231 | const [featureSection, artistsString] = result; 232 | 233 | if (extractArtists) { 234 | artistNames = this.getArtistNames(artistsString); 235 | } 236 | 237 | title = title.replace(featureSection, ""); 238 | } 239 | } 240 | 241 | return { 242 | artistNames, 243 | title, 244 | }; 245 | } 246 | 247 | private splitByProducer(title: string, extractArtists: boolean): TitleSplit { 248 | let artistNames: string[] = []; 249 | 250 | if (this.includes(title, MetadataExtractor.producerIndicators)) { 251 | const separators = this.escapeRegexArray(MetadataExtractor.producerIndicators).join("|"); 252 | const regex = new RegExp(`(?:${separators})([^\\[\\]\\(\\)]+)`, "i"); 253 | 254 | const result = regex.exec(title); 255 | 256 | if (result && result.length > 0) { 257 | const [producerSection, artistsString] = result; 258 | 259 | if (extractArtists) { 260 | artistNames = this.getArtistNames(artistsString); 261 | } 262 | 263 | title = title.replace(producerSection, ""); 264 | } 265 | } 266 | 267 | return { 268 | artistNames, 269 | title, 270 | }; 271 | } 272 | 273 | private splitByUnsafeProducers(title: string, extractArtists: boolean): TitleSplit { 274 | let artistNames: string[] = []; 275 | 276 | const featureSeparators = this.escapeRegexArray(MetadataExtractor.featureSeparators).join("|"); 277 | const regex = new RegExp(`[\\(\\[](?!${featureSeparators})(.+)[\\)\\]]`, "i"); 278 | 279 | const result = regex.exec(title); 280 | 281 | if (result && result.length > 0) { 282 | const [producerSection, artistsString] = result; 283 | 284 | if (extractArtists) { 285 | artistNames = this.getArtistNames(artistsString); 286 | } 287 | 288 | title = title.replace(producerSection, ""); 289 | } 290 | return { 291 | artistNames, 292 | title, 293 | }; 294 | } 295 | 296 | private splitByRemix(title: string, extractArtists: boolean): RemixTitleSplit { 297 | let artists: Artist[] = []; 298 | 299 | if (this.includes(title, MetadataExtractor.remixIndicators)) { 300 | const separators = this.escapeRegexArray(MetadataExtractor.remixIndicators).join("|"); 301 | const regex = new RegExp(`[\\[\\(](.+)(${separators})[\\]\\)]`, "i"); 302 | 303 | const result = regex.exec(title); 304 | 305 | if (result && result.length > 0) { 306 | const [remixSection, artistsString, remixTypeString] = result; 307 | 308 | if (extractArtists) { 309 | const artistNames = this.getArtistNames(artistsString); 310 | 311 | const remixType = getRemixTypeFromString(remixTypeString); 312 | 313 | artists = artistNames.map((name) => ({ 314 | name, 315 | type: ArtistType.Remixer, 316 | remixType, 317 | })); 318 | } 319 | 320 | title = title.replace(remixSection, ""); 321 | } 322 | } 323 | 324 | return { 325 | artists, 326 | title, 327 | }; 328 | } 329 | 330 | private getArtistNames(input: string): string[] { 331 | const separators = this.escapeRegexArray(MetadataExtractor.combiningFeatureSeparators).join("|"); 332 | const regex = new RegExp(`(.+)\\s?(${separators})\\s?(.+)`, "i"); 333 | 334 | const names = []; 335 | 336 | while (true) { 337 | const result = regex.exec(input); 338 | 339 | if (!result) { 340 | names.push(this.sanitizeArtistName(input)); 341 | break; 342 | } 343 | 344 | names.push(this.sanitizeArtistName(result[3])); 345 | input = result[1]; 346 | } 347 | 348 | return names.reverse(); 349 | } 350 | 351 | private preprocessTitle(input: string) { 352 | // remove duplicated +s 353 | input = input.replace(/\+[\+]+/g, "+"); 354 | 355 | // remove promotions 356 | const promotions = MetadataExtractor.promotions.join("|"); 357 | const regex = new RegExp(`[\\[\\(]?\\s*(${promotions})\\s*[\\]\\)]?`, "i"); 358 | 359 | return input.replace(regex, ""); 360 | } 361 | 362 | private sanitizeArtistName(input: string) { 363 | return this.removeNonAsciiCharacters(input).trim(); 364 | } 365 | 366 | private sanitizeTitle(input: string) { 367 | let sanitized = this.removeNonAsciiCharacters(input); 368 | 369 | sanitized = sanitized.replace("()", "").replace("[]", ""); 370 | 371 | return sanitized.trim(); 372 | } 373 | 374 | private removeNonAsciiCharacters(input: string) { 375 | return XRegExp.replace(input, XRegExp("[^\\p{L}\\p{N}\\p{Zs}\x00-\x7F]", "g"), ""); 376 | } 377 | 378 | private includes(input: string, separators: string[]) { 379 | const loweredInput = input.toLowerCase(); 380 | 381 | return separators.some((separator) => loweredInput.includes(separator)); 382 | } 383 | 384 | private escapeRegexArray(input: string[]) { 385 | return input.map((i) => escapeStringRegexp(i)); 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /src/tagWriters/mp4TagWriter.ts: -------------------------------------------------------------------------------- 1 | import { concatArrayBuffers } from "../utils/download"; 2 | import { TagWriter } from "./tagWriter"; 3 | 4 | interface Atom { 5 | length: number; 6 | name?: string; 7 | offset?: number; 8 | children?: Atom[]; 9 | data?: ArrayBuffer; 10 | } 11 | 12 | interface AtomLevel { 13 | parent: Atom; 14 | offset: number; 15 | childIndex: number; 16 | } 17 | 18 | // length(4) + name(4) 19 | const ATOM_HEAD_LENGTH = 8; 20 | // data-length(4) + data-name(4) + data-flags(4) 21 | const ATOM_DATA_HEAD_LENGTH = 16; 22 | 23 | const ATOM_HEADER_LENGTH = ATOM_HEAD_LENGTH + ATOM_DATA_HEAD_LENGTH; 24 | 25 | class Mp4 { 26 | private readonly _metadataPath = ["moov", "udta", "meta", "ilst"]; 27 | private _buffer: ArrayBuffer | null; 28 | private _bufferView: DataView | null; 29 | private _atoms: Atom[] = []; 30 | 31 | constructor(buffer: ArrayBuffer) { 32 | this._buffer = buffer; 33 | this._bufferView = new DataView(buffer); 34 | } 35 | 36 | parse() { 37 | if (!this._buffer) throw new Error("Buffer can not be null"); 38 | if (this._atoms.length > 0) throw new Error("Buffer already parsed"); 39 | 40 | let offset = 0; 41 | let atom: Atom; 42 | 43 | while (true) { 44 | atom = this._readAtom(offset); 45 | 46 | if (!atom || atom.length < 1) break; 47 | 48 | this._atoms.push(atom); 49 | 50 | offset = atom.offset + atom.length; 51 | } 52 | 53 | if (this._atoms.length < 1) throw new Error("Buffer could not be parsed"); 54 | } 55 | 56 | setDuration(duration: number) { 57 | const mvhdAtom: Atom = this._findAtom(this._atoms, ["moov", "mvhd"]); 58 | 59 | if (!mvhdAtom) throw new Error("'mvhd' atom could not be found"); 60 | 61 | // version(4) + created(4) + modified(4) + timescale(4) 62 | const precedingDataLength = 16; 63 | this._bufferView.setUint32(mvhdAtom.offset + ATOM_HEAD_LENGTH + precedingDataLength, duration); 64 | } 65 | 66 | addMetadataAtom(name: string, data: ArrayBuffer | string | number) { 67 | if (name.length > 4 || name.length < 1) throw new Error(`Unsupported atom name: '${name}'`); 68 | 69 | let dataBuffer: ArrayBuffer; 70 | 71 | if (data instanceof ArrayBuffer) { 72 | dataBuffer = data; 73 | } else if (typeof data === "string") { 74 | dataBuffer = this._getBufferFromString(data); 75 | } else if (typeof data === "number") { 76 | dataBuffer = new ArrayBuffer(4); 77 | const dataView = new DataView(dataBuffer); 78 | dataView.setUint32(0, data); 79 | } else { 80 | throw new Error(`Unsupported data: '${data}'`); 81 | } 82 | 83 | const atom: Atom = { 84 | name, 85 | length: ATOM_HEADER_LENGTH + dataBuffer.byteLength, 86 | data: dataBuffer, 87 | }; 88 | 89 | this._insertAtom(atom, this._metadataPath); 90 | } 91 | 92 | getBuffer() { 93 | const buffers: ArrayBuffer[] = []; 94 | let bufferIndex = 0; 95 | 96 | // we don't change the offsets, since it would add needless complexity without benefit 97 | for (const atom of this._atoms) { 98 | if (!atom.children) { 99 | // nothing has been added or removed 100 | const slice = this._buffer.slice(atom.offset, atom.offset + atom.length); 101 | buffers.push(slice); 102 | bufferIndex++; 103 | 104 | continue; 105 | } 106 | 107 | atom.length = ATOM_HEAD_LENGTH; 108 | 109 | const levels: AtomLevel[] = [{ parent: atom, offset: bufferIndex, childIndex: 0 }]; 110 | let levelIndex = 0; 111 | 112 | while (true) { 113 | const { parent, offset, childIndex } = levels[levelIndex]; 114 | 115 | if (childIndex >= parent.children.length) { 116 | // move one level up 117 | levelIndex--; 118 | levels.pop(); 119 | 120 | let parentHeadLength = ATOM_HEAD_LENGTH; 121 | if (parent.name === "meta") { 122 | parent.length += 4; 123 | parentHeadLength += 4; 124 | } else if (parent.name === "stsd") { 125 | parent.length += 8; 126 | parentHeadLength += 8; 127 | } 128 | 129 | // set length of parent in buffer 130 | this._bufferView.setUint32(parent.offset, parent.length); 131 | 132 | const parentHeader = this._buffer.slice(parent.offset, parent.offset + parentHeadLength); 133 | buffers.splice(offset, 0, parentHeader); 134 | 135 | // we completed the last parent - exit 136 | if (levelIndex < 0) break; 137 | 138 | // add our current parents length to new parent and move childIndex of new parent one ahead 139 | const newParent = levels[levelIndex].parent; 140 | newParent.length += parent.length; 141 | levels[levelIndex].childIndex++; 142 | 143 | continue; 144 | } 145 | 146 | const child = parent.children[childIndex]; 147 | 148 | if (child.children) { 149 | // move one level down 150 | child.length = ATOM_HEAD_LENGTH; 151 | levels.push({ parent: child, offset: bufferIndex, childIndex: 0 }); 152 | levelIndex++; 153 | continue; 154 | } else if (child.data) { 155 | // add new data to buffer 156 | const headerBuffer = this._getHeaderBufferFromAtom(child); 157 | buffers.push(headerBuffer); 158 | buffers.push(child.data); 159 | } else { 160 | // add entire child to buffer 161 | const slice = this._buffer.slice(child.offset, child.offset + child.length); 162 | buffers.push(slice); 163 | } 164 | 165 | bufferIndex++; 166 | 167 | parent.length += child.length; 168 | 169 | // move one child ahead 170 | levels[levelIndex].childIndex++; 171 | } 172 | } 173 | 174 | this._bufferView = null; 175 | this._buffer = null; 176 | this._atoms = []; 177 | 178 | return concatArrayBuffers(buffers); 179 | } 180 | 181 | private _insertAtom(atom: Atom, path: string[]) { 182 | if (!path) throw new Error("Path can not be empty"); 183 | 184 | const parentAtom = this._findAtom(this._atoms, path); 185 | 186 | if (!parentAtom) throw new Error(`Parent atom at path '${path.join(" > ")}' could not be found`); 187 | 188 | if (parentAtom.children === undefined) { 189 | parentAtom.children = this._readChildAtoms(parentAtom); 190 | } 191 | 192 | let offset = parentAtom.offset + ATOM_HEAD_LENGTH; 193 | 194 | if (parentAtom.name === "meta") { 195 | offset += 4; 196 | } else if (parentAtom.name === "stsd") { 197 | offset += 8; 198 | } 199 | 200 | if (parentAtom.children.length > 0) { 201 | const lastChild = parentAtom.children[parentAtom.children.length - 1]; 202 | 203 | offset = lastChild.offset + lastChild.length; 204 | } 205 | 206 | atom.offset = offset; 207 | 208 | parentAtom.children.push(atom); 209 | } 210 | 211 | private _findAtom(atoms: Atom[], path: string[]): Atom | null { 212 | if (!path || path.length < 1) throw new Error("Path can not be empty"); 213 | 214 | const curPath = [...path]; 215 | const curName = curPath.shift(); 216 | const curElem = atoms.find((i) => i.name === curName); 217 | 218 | if (curPath.length < 1) return curElem; 219 | 220 | if (!curElem) return null; 221 | 222 | if (curElem.children === undefined) { 223 | curElem.children = this._readChildAtoms(curElem); 224 | } 225 | 226 | if (curElem.children.length < 1) return null; 227 | 228 | return this._findAtom(curElem.children, curPath); 229 | } 230 | 231 | private _readChildAtoms(atom: Atom): Atom[] { 232 | const children: Atom[] = []; 233 | 234 | const childEnd = atom.offset + atom.length; 235 | let childOffset = atom.offset + ATOM_HEAD_LENGTH; 236 | 237 | if (atom.name === "meta") { 238 | childOffset += 4; 239 | } else if (atom.name === "stsd") { 240 | childOffset += 8; 241 | } 242 | 243 | while (true) { 244 | if (childOffset >= childEnd) break; 245 | 246 | const childAtom = this._readAtom(childOffset); 247 | 248 | if (!childAtom || childAtom.length < 1) break; 249 | 250 | childOffset = childAtom.offset + childAtom.length; 251 | 252 | children.push(childAtom); 253 | } 254 | 255 | return children; 256 | } 257 | 258 | private _readAtom(offset: number): Atom { 259 | const begin = offset; 260 | const end = offset + ATOM_HEAD_LENGTH; 261 | 262 | const buffer = this._buffer.slice(begin, end); 263 | 264 | if (buffer.byteLength < ATOM_HEAD_LENGTH) { 265 | return { 266 | length: buffer.byteLength, 267 | offset, 268 | }; 269 | } 270 | 271 | const dataView = new DataView(buffer); 272 | 273 | let length = dataView.getUint32(0, false); 274 | 275 | let name = ""; 276 | for (let i = 0; i < 4; i++) { 277 | name += String.fromCharCode(dataView.getUint8(4 + i)); 278 | } 279 | 280 | return { 281 | name, 282 | length, 283 | offset, 284 | }; 285 | } 286 | 287 | private _getHeaderBufferFromAtom(atom: Atom) { 288 | if (!atom || atom.length < 1 || !atom.name || !atom.data) 289 | throw new Error("Can not compute header buffer for this atom"); 290 | 291 | const headerBuffer = new ArrayBuffer(ATOM_HEADER_LENGTH); 292 | const headerBufferView = new DataView(headerBuffer); 293 | 294 | // length at 0, length = 4 295 | headerBufferView.setUint32(0, atom.length); 296 | 297 | // name at 4, length = 4 298 | const nameChars = this._getCharCodes(atom.name); 299 | for (let i = 0; i < nameChars.length; i++) { 300 | headerBufferView.setUint8(4 + i, nameChars[i]); 301 | } 302 | 303 | // data length at 8, length = 4 304 | headerBufferView.setUint32(8, ATOM_DATA_HEAD_LENGTH + atom.data.byteLength); 305 | 306 | // data name at 12, length = 4 307 | const dataNameChars = this._getCharCodes("data"); 308 | for (let i = 0; i < dataNameChars.length; i++) { 309 | headerBufferView.setUint8(12 + i, dataNameChars[i]); 310 | } 311 | 312 | // data flags at 16, length = 4 313 | headerBufferView.setUint32(16, this._getFlags(atom.name)); 314 | 315 | return headerBuffer; 316 | } 317 | 318 | private _getBufferFromString(input: string): ArrayBuffer { 319 | // return new TextEncoder().encode(input).buffer; 320 | 321 | const buffer = new ArrayBuffer(input.length); 322 | const bufferView = new DataView(buffer); 323 | const chars = this._getCharCodes(input); 324 | 325 | for (let i = 0; i < chars.length; i++) { 326 | bufferView.setUint8(i, chars[i]); 327 | } 328 | 329 | return buffer; 330 | } 331 | 332 | private _getCharCodes(input: string) { 333 | const chars: number[] = []; 334 | 335 | for (let i = 0; i < input.length; i++) { 336 | chars.push(input.charCodeAt(i)); 337 | } 338 | 339 | return chars; 340 | } 341 | 342 | private _getFlags(name: string) { 343 | switch (name) { 344 | case "covr": 345 | // 13 for jpeg, 14 for png 346 | return 13; 347 | case "trkn": 348 | case "disk": 349 | return 0; 350 | case "tmpo": 351 | case "cpil": 352 | case "rtng": 353 | return 21; 354 | default: 355 | return 1; 356 | } 357 | } 358 | } 359 | 360 | export class Mp4TagWriter implements TagWriter { 361 | private _mp4: Mp4; 362 | 363 | constructor(buffer: ArrayBuffer) { 364 | this._mp4 = new Mp4(buffer); 365 | this._mp4.parse(); 366 | } 367 | 368 | setTitle(title: string): void { 369 | if (!title) throw new Error("Invalid value for title"); 370 | 371 | this._mp4.addMetadataAtom("©nam", title); 372 | } 373 | 374 | setArtists(artists: string[]): void { 375 | if (!artists || artists.length < 1) throw new Error("Invalid value for artists"); 376 | 377 | this._mp4.addMetadataAtom("©ART", artists.join(", ")); 378 | } 379 | 380 | setAlbum(album: string): void { 381 | if (!album) throw new Error("Invalid value for album"); 382 | 383 | this._mp4.addMetadataAtom("©alb", album); 384 | } 385 | 386 | setComment(comment: string): void { 387 | if (!comment) throw new Error("Invalid value for comment"); 388 | 389 | this._mp4.addMetadataAtom("©cmt", comment); 390 | } 391 | 392 | setTrackNumber(trackNumber: number): void { 393 | // max trackNumber is max of Uint8 394 | if (trackNumber < 1 || trackNumber > 32767) throw new Error("Invalid value for trackNumber"); 395 | 396 | this._mp4.addMetadataAtom("trkn", trackNumber); 397 | } 398 | 399 | setYear(year: number): void { 400 | if (year < 1) throw new Error("Invalud value for year"); 401 | 402 | this._mp4.addMetadataAtom("©day", year.toString()); 403 | } 404 | 405 | setArtwork(artworkBuffer: ArrayBuffer): void { 406 | if (!artworkBuffer || artworkBuffer.byteLength < 1) throw new Error("Invalid value for artworkBuffer"); 407 | 408 | this._mp4.addMetadataAtom("covr", artworkBuffer); 409 | } 410 | 411 | setDuration(duration: number): void { 412 | if (duration < 1) throw new Error("Invalid value for duration"); 413 | 414 | this._mp4.setDuration(duration); 415 | } 416 | 417 | getBuffer(): Promise { 418 | const buffer = this._mp4.getBuffer(); 419 | 420 | return Promise.resolve(buffer); 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import { SoundCloudApi, StreamDetails, Track } from "./soundcloudApi"; 2 | import { Logger } from "./utils/logger"; 3 | import { 4 | onBeforeSendHeaders, 5 | onBeforeRequest, 6 | downloadToFile, 7 | onMessage, 8 | onPageActionClicked, 9 | openOptionsPage, 10 | getExtensionManifest, 11 | sendMessageToTab, 12 | } from "./compatibilityStubs"; 13 | import { MetadataExtractor, ArtistType, RemixType } from "./metadataExtractor"; 14 | import { Mp3TagWriter } from "./tagWriters/mp3TagWriter"; 15 | import { 16 | loadConfiguration, 17 | storeConfigValue, 18 | getConfigValue, 19 | } from "./utils/config"; 20 | import { TagWriter } from "./tagWriters/tagWriter"; 21 | import { Mp4TagWriter } from "./tagWriters/mp4TagWriter"; 22 | import { Parser } from "m3u8-parser"; 23 | import { 24 | concatArrayBuffers, 25 | sanitizeFilenameForDownload, 26 | } from "./utils/download"; 27 | import { WavTagWriter } from "./tagWriters/wavTagWriter"; 28 | 29 | class TrackError extends Error { 30 | constructor(message: string, trackId: number) { 31 | super(`${message} (TrackId: ${trackId})`); 32 | } 33 | } 34 | 35 | const soundcloudApi = new SoundCloudApi(); 36 | const logger = Logger.create("Background"); 37 | const manifest = getExtensionManifest(); 38 | 39 | logger.logInfo("Starting with version: " + manifest.version); 40 | 41 | loadConfiguration(true); 42 | 43 | interface DownloadData { 44 | trackId: number; 45 | title: string; 46 | duration: number; 47 | uploadDate: Date; 48 | username: string; 49 | userPermalink: string; 50 | avatarUrl: string; 51 | artworkUrl: string; 52 | streamUrl: string; 53 | fileExtension?: string; 54 | trackNumber: number | undefined; 55 | albumName: string | undefined; 56 | hls: boolean; 57 | permalinkUrl: string; 58 | } 59 | 60 | async function handleDownload( 61 | data: DownloadData, 62 | reportProgress: (progress?: number) => void 63 | ) { 64 | // todo: one big try-catch is not really good error handling :/ 65 | try { 66 | logger.logInfo(`Initiating download of ${data.trackId} with payload`, { 67 | payload: data, 68 | }); 69 | 70 | let artistsString = data.username; 71 | let titleString = data.title; 72 | 73 | if (getConfigValue("normalize-track")) { 74 | const extractor = new MetadataExtractor( 75 | data.title, 76 | data.username, 77 | data.userPermalink 78 | ); 79 | 80 | let artists = extractor.getArtists(); 81 | 82 | if (!getConfigValue("include-producers")) 83 | artists = artists.filter((i) => i.type !== ArtistType.Producer); 84 | 85 | artistsString = artists.map((i) => i.name).join(", "); 86 | titleString = extractor.getTitle(); 87 | const remixers = artists.filter((i) => i.type === ArtistType.Remixer); 88 | 89 | if (remixers.length > 0) { 90 | const remixerNames = remixers.map((i) => i.name).join(" & "); 91 | const remixTypeString = 92 | RemixType[remixers[0].remixType || RemixType.Remix].toString(); 93 | 94 | titleString += ` (${remixerNames} ${remixTypeString})`; 95 | } 96 | } 97 | 98 | if (!artistsString) { 99 | artistsString = "Unknown"; 100 | } 101 | 102 | if (!titleString) { 103 | titleString = "Unknown"; 104 | } 105 | 106 | const rawFilename = sanitizeFilenameForDownload( 107 | `${artistsString} - ${titleString}` 108 | ); 109 | 110 | let artworkUrl = data.artworkUrl; 111 | 112 | if (!artworkUrl) { 113 | logger.logInfo( 114 | `No Artwork URL could be determined. Fallback to User Avatar (TrackId: ${data.trackId})` 115 | ); 116 | artworkUrl = data.avatarUrl; 117 | } 118 | 119 | logger.logInfo( 120 | `Starting download of '${rawFilename}' (TrackId: ${data.trackId})...` 121 | ); 122 | 123 | let streamBuffer: ArrayBuffer; 124 | let streamHeaders: Headers; 125 | 126 | if (data.hls) { 127 | try { 128 | const playlistReq = await fetch(data.streamUrl); 129 | const playlist = await playlistReq.text(); 130 | 131 | // @ts-ignore 132 | const parser = new Parser(); 133 | 134 | parser.push(playlist); 135 | parser.end(); 136 | 137 | const segmentUrls: string[] = parser.manifest.segments.map( 138 | (i) => i.uri 139 | ); 140 | const segments: ArrayBuffer[] = []; 141 | 142 | for (let i = 0; i < segmentUrls.length; i++) { 143 | const segmentReq = await fetch(segmentUrls[i]); 144 | const segment = await segmentReq.arrayBuffer(); 145 | 146 | segments.push(segment); 147 | 148 | const progress = Math.round((i / segmentUrls.length) * 100); 149 | 150 | reportProgress(progress); 151 | } 152 | 153 | reportProgress(100); 154 | 155 | streamBuffer = concatArrayBuffers(segments); 156 | } catch (error) { 157 | logger.logError( 158 | `Failed to download m3u8 playlist (TrackId: ${data.trackId})`, 159 | error 160 | ); 161 | 162 | throw error; 163 | } 164 | } else { 165 | try { 166 | [streamBuffer, streamHeaders] = await soundcloudApi.downloadStream( 167 | data.streamUrl, 168 | reportProgress 169 | ); 170 | } catch (error) { 171 | logger.logError( 172 | `Failed to download stream (TrackId: ${data.trackId})`, 173 | error 174 | ); 175 | 176 | throw error; 177 | } 178 | } 179 | 180 | if (!streamBuffer) { 181 | throw new TrackError("Undefined streamBuffer", data.trackId); 182 | } 183 | 184 | let contentType; 185 | if (!data.fileExtension && streamHeaders) { 186 | contentType = streamHeaders.get("content-type"); 187 | let extension = "mp3"; 188 | 189 | if (contentType === "audio/mp4") extension = "m4a"; 190 | else if (contentType === "audio/x-wav" || contentType === "audio/wav") 191 | extension = "wav"; 192 | 193 | data.fileExtension = extension; 194 | 195 | logger.logInfo( 196 | `Inferred file extension from 'content-type' header (TrackId: ${data.trackId})`, 197 | { 198 | contentType, 199 | extension, 200 | } 201 | ); 202 | } 203 | 204 | let downloadBuffer: ArrayBuffer; 205 | 206 | if (getConfigValue("set-metadata")) { 207 | try { 208 | let writer: TagWriter; 209 | 210 | if (data.fileExtension === "m4a") { 211 | const mp4Writer = new Mp4TagWriter(streamBuffer); 212 | 213 | try { 214 | mp4Writer.setDuration(data.duration); 215 | } catch (error) { 216 | logger.logError( 217 | `Failed to set duration for track (TrackId: ${data.trackId})`, 218 | error 219 | ); 220 | } 221 | 222 | writer = mp4Writer; 223 | } else if (data.fileExtension === "mp3") { 224 | writer = new Mp3TagWriter(streamBuffer); 225 | } else if (data.fileExtension === "wav") { 226 | writer = new WavTagWriter(streamBuffer); 227 | } 228 | 229 | if (writer) { 230 | writer.setTitle(titleString); 231 | // todo: sanitize album as well 232 | writer.setAlbum(data.albumName ?? titleString); 233 | writer.setArtists([artistsString]); 234 | 235 | writer.setComment(data.permalinkUrl || data.trackId.toString()); 236 | 237 | if (data.trackNumber > 0) { 238 | writer.setTrackNumber(data.trackNumber); 239 | } 240 | 241 | const releaseYear = data.uploadDate.getFullYear(); 242 | 243 | writer.setYear(releaseYear); 244 | 245 | if (artworkUrl) { 246 | const sizeOptions = ["original", "t500x500", "large"]; 247 | let artworkBuffer = null; 248 | let curArtworkUrl; 249 | 250 | do { 251 | const curSizeOption = sizeOptions.shift(); 252 | curArtworkUrl = artworkUrl.replace( 253 | "-large.", 254 | `-${curSizeOption}.` 255 | ); 256 | 257 | artworkBuffer = await soundcloudApi.downloadArtwork( 258 | curArtworkUrl 259 | ); 260 | } while (artworkBuffer === null && sizeOptions.length > 0); 261 | 262 | if (artworkBuffer) { 263 | writer.setArtwork(artworkBuffer); 264 | } 265 | } else { 266 | logger.logWarn( 267 | `Skipping download of Artwork (TrackId: ${data.trackId})` 268 | ); 269 | } 270 | 271 | downloadBuffer = await writer.getBuffer(); 272 | } 273 | } catch (error) { 274 | logger.logError( 275 | `Failed to set metadata (TrackId: ${data.trackId})`, 276 | error 277 | ); 278 | } 279 | } 280 | 281 | const blobOptions: BlobPropertyBag = {}; 282 | 283 | if (contentType) blobOptions.type = contentType; 284 | 285 | const downloadBlob = new Blob( 286 | [downloadBuffer ?? streamBuffer], 287 | blobOptions 288 | ); 289 | 290 | const saveAs = !getConfigValue("download-without-prompt"); 291 | const defaultDownloadLocation = getConfigValue("default-download-location"); 292 | let downloadFilename = rawFilename + "." + data.fileExtension; 293 | 294 | if (!saveAs && defaultDownloadLocation) { 295 | downloadFilename = defaultDownloadLocation + "/" + downloadFilename; 296 | } 297 | 298 | logger.logInfo( 299 | `Downloading track as '${downloadFilename}' (TrackId: ${data.trackId})...` 300 | ); 301 | 302 | let downloadUrl: string; 303 | 304 | try { 305 | downloadUrl = URL.createObjectURL(downloadBlob); 306 | 307 | await downloadToFile(downloadUrl, downloadFilename, saveAs); 308 | 309 | logger.logInfo( 310 | `Successfully downloaded '${rawFilename}' (TrackId: ${data.trackId})!` 311 | ); 312 | 313 | reportProgress(101); 314 | } catch (error) { 315 | logger.logError( 316 | `Failed to download track to file system (TrackId: ${data.trackId})`, 317 | { 318 | downloadFilename, 319 | saveAs, 320 | } 321 | ); 322 | 323 | throw new TrackError( 324 | `Failed to download track to file system`, 325 | data.trackId 326 | ); 327 | } finally { 328 | if (downloadUrl) URL.revokeObjectURL(downloadUrl); 329 | } 330 | } catch (error) { 331 | throw new TrackError("Unknown error during download", data.trackId); 332 | } 333 | } 334 | 335 | interface TranscodingDetails { 336 | url: string; 337 | protocol: "hls" | "progressive"; 338 | quality: "hq" | "sq"; 339 | extension: string; 340 | } 341 | 342 | function getTranscodingDetails(details: Track): TranscodingDetails[] | null { 343 | if (details?.media?.transcodings?.length < 1) return null; 344 | 345 | const mpegStreams = details.media.transcodings 346 | .filter( 347 | (transcoding) => 348 | (transcoding.format?.protocol === "progressive" || 349 | transcoding.format?.protocol === "hls") && 350 | (transcoding.format?.mime_type?.startsWith("audio/mpeg") || 351 | transcoding.format?.mime_type?.startsWith("audio/mp4")) && 352 | !transcoding.snipped 353 | ) 354 | .map((transcoding) => ({ 355 | protocol: transcoding.format.protocol, 356 | url: transcoding.url, 357 | quality: transcoding.quality, 358 | extension: soundcloudApi.convertMimeTypeToExtension( 359 | transcoding.format.mime_type 360 | ), 361 | })); 362 | 363 | if (mpegStreams.length < 1) { 364 | logger.logWarn("No transcodings streams could be determined!"); 365 | 366 | return null; 367 | } 368 | 369 | // prefer 'hqq and 'progressive' streams 370 | let streams = mpegStreams.sort((a, b) => { 371 | if (a.quality === "hq" && b.quality === "sq") { 372 | return -1; 373 | } 374 | 375 | if (a.quality === "sq" && b.quality === "hq") { 376 | return 1; 377 | } 378 | 379 | if (a.protocol === "progressive" && b.protocol === "hls") { 380 | return -1; 381 | } 382 | 383 | if (a.protocol === "hls" && b.protocol === "progressive") { 384 | return 1; 385 | } 386 | 387 | return 0; 388 | }); 389 | 390 | if (!getConfigValue("download-hq-version")) { 391 | streams = streams.filter((stream) => stream.quality !== "hq"); 392 | } 393 | 394 | if (streams.some((stream) => stream.quality === "hq")) { 395 | logger.logInfo("Including high quality streams!"); 396 | } 397 | 398 | return streams; 399 | } 400 | 401 | // -------------------- HANDLERS -------------------- 402 | const authRegex = new RegExp("OAuth (.+)"); 403 | const followerIdRegex = new RegExp("/me/followings/(\\d+)"); 404 | 405 | onBeforeSendHeaders( 406 | (details) => { 407 | let requestHasAuth = false; 408 | 409 | if (details.requestHeaders && getConfigValue("oauth-token") !== null) { 410 | for (let i = 0; i < details.requestHeaders.length; i++) { 411 | if (details.requestHeaders[i].name.toLowerCase() !== "authorization") 412 | continue; 413 | 414 | requestHasAuth = true; 415 | const authHeader = details.requestHeaders[i].value; 416 | 417 | const result = authRegex.exec(authHeader); 418 | 419 | if (!result || result.length < 2) continue; 420 | 421 | storeConfigValue("oauth-token", result[1]); 422 | } 423 | 424 | const oauthToken = getConfigValue("oauth-token"); 425 | 426 | if (!requestHasAuth && oauthToken) { 427 | logger.logDebug("Adding OAuth token to request...", { oauthToken }); 428 | 429 | details.requestHeaders.push({ 430 | name: "Authorization", 431 | value: "OAuth " + oauthToken, 432 | }); 433 | 434 | return { 435 | requestHeaders: details.requestHeaders, 436 | }; 437 | } 438 | } 439 | }, 440 | ["*://api-v2.soundcloud.com/*"], 441 | ["blocking", "requestHeaders"] 442 | ); 443 | 444 | onBeforeRequest( 445 | (details) => { 446 | const url = new URL(details.url); 447 | 448 | if ( 449 | url.pathname === "/connect/session" && 450 | getConfigValue("oauth-token") === null 451 | ) { 452 | logger.logInfo("User logged in"); 453 | 454 | storeConfigValue("oauth-token", undefined); 455 | } else if (url.pathname === "/sign-out") { 456 | logger.logInfo("User logged out"); 457 | 458 | storeConfigValue("oauth-token", null); 459 | storeConfigValue("client-id", null); 460 | } else { 461 | const clientId = url.searchParams.get("client_id"); 462 | const storedClientId = getConfigValue("client-id"); 463 | 464 | if (clientId) { 465 | storeConfigValue("client-id", clientId); 466 | } else if (storedClientId) { 467 | logger.logDebug("Adding ClientId to unauthenticated request...", { 468 | url, 469 | clientId: storedClientId, 470 | }); 471 | 472 | url.searchParams.append("client_id", storedClientId); 473 | 474 | return { 475 | redirectUrl: url.toString(), 476 | }; 477 | } 478 | } 479 | }, 480 | ["*://api-v2.soundcloud.com/*", "*://api-auth.soundcloud.com/*"], 481 | ["blocking"] 482 | ); 483 | 484 | function isValidTrack(track: Track) { 485 | return ( 486 | track && 487 | track.kind === "track" && 488 | track.state === "finished" && 489 | (track.streamable || track.downloadable) 490 | ); 491 | } 492 | 493 | function isTranscodingDetails(detail: unknown): detail is TranscodingDetails { 494 | return !!detail["protocol"]; 495 | } 496 | 497 | async function downloadTrack( 498 | track: Track, 499 | trackNumber: number | undefined, 500 | albumName: string | undefined, 501 | reportProgress: (progress?: number) => void 502 | ) { 503 | if (!isValidTrack(track)) { 504 | logger.logError( 505 | "Track does not satisfy constraints needed to be downloadable", 506 | track 507 | ); 508 | 509 | throw new TrackError( 510 | "Track does not satisfy constraints needed to be downloadable", 511 | track.id 512 | ); 513 | } 514 | 515 | const downloadDetails: Array = []; 516 | 517 | if ( 518 | getConfigValue("download-original-version") && 519 | track.downloadable && 520 | track.has_downloads_left 521 | ) { 522 | const originalDownloadUrl = await soundcloudApi.getOriginalDownloadUrl( 523 | track.id 524 | ); 525 | 526 | if (originalDownloadUrl) { 527 | const stream: StreamDetails = { 528 | url: originalDownloadUrl, 529 | hls: false, 530 | }; 531 | 532 | downloadDetails.push(stream); 533 | } 534 | } 535 | 536 | const transcodingDetails = getTranscodingDetails(track); 537 | 538 | if (transcodingDetails) { 539 | downloadDetails.push(...transcodingDetails); 540 | } 541 | 542 | if (downloadDetails.length < 1) { 543 | throw new TrackError("No download details could be determined", track.id); 544 | } 545 | 546 | for (const downloadDetail of downloadDetails) { 547 | let stream: StreamDetails; 548 | 549 | try { 550 | if (isTranscodingDetails(downloadDetail)) { 551 | logger.logDebug( 552 | "Get stream details from transcoding details", 553 | downloadDetail 554 | ); 555 | 556 | const streamUrl = await soundcloudApi.getStreamUrl(downloadDetail.url); 557 | stream = { 558 | url: streamUrl, 559 | hls: downloadDetail.protocol === "hls", 560 | extension: downloadDetail.extension, 561 | }; 562 | } else { 563 | stream = downloadDetail; 564 | } 565 | 566 | const downloadData: DownloadData = { 567 | trackId: track.id, 568 | duration: track.duration, 569 | uploadDate: new Date(track.display_date), 570 | streamUrl: stream.url, 571 | fileExtension: stream.extension, 572 | title: track.title, 573 | username: track.user.username, 574 | userPermalink: track.user.permalink, 575 | artworkUrl: track.artwork_url, 576 | avatarUrl: track.user.avatar_url, 577 | trackNumber, 578 | albumName, 579 | hls: stream.hls, 580 | permalinkUrl: track.permalink_url, 581 | }; 582 | 583 | await handleDownload(downloadData, reportProgress); 584 | 585 | return; 586 | } catch { 587 | // this is to try and download at least one of the available version 588 | continue; 589 | } 590 | } 591 | 592 | throw new TrackError( 593 | "No version of this track could be downloaded", 594 | track.id 595 | ); 596 | } 597 | 598 | interface Playlist { 599 | tracks: Track[]; 600 | set_type: string; 601 | title: string; 602 | } 603 | 604 | interface DownloadRequest { 605 | type: string; 606 | url: string; 607 | downloadId: string; 608 | } 609 | 610 | interface DownloadProgress { 611 | downloadId: string; 612 | progress?: number; 613 | error?: string; 614 | } 615 | 616 | function sendDownloadProgress( 617 | tabId: number, 618 | downloadId: string, 619 | progress?: number, 620 | error?: Error | string 621 | ) { 622 | let errorMessage: string = ""; 623 | 624 | if (error instanceof Error) { 625 | errorMessage = error.message; 626 | } else { 627 | errorMessage = error; 628 | } 629 | 630 | const downloadProgress: DownloadProgress = { 631 | downloadId, 632 | progress, 633 | error: errorMessage, 634 | }; 635 | 636 | sendMessageToTab(tabId, downloadProgress); 637 | } 638 | 639 | function chunkArray(array: T[], chunkSize: number) { 640 | if (chunkSize < 1) throw new Error("Invalid chunk size"); 641 | 642 | const chunks: T[][] = []; 643 | 644 | for (let i = 0; i < array.length; i += chunkSize) { 645 | const chunk = array.slice(i, i + chunkSize); 646 | 647 | chunks.push(chunk); 648 | } 649 | 650 | return chunks; 651 | } 652 | 653 | onMessage(async (sender, message: DownloadRequest) => { 654 | const tabId = sender.tab.id; 655 | const { downloadId, url, type } = message; 656 | 657 | if (!tabId) return; 658 | 659 | try { 660 | if (type === "DOWNLOAD_SET") { 661 | logger.logDebug("Received set download request", { url }); 662 | 663 | const set = await soundcloudApi.resolveUrl(url); 664 | const isAlbum = set.set_type === "album" || set.set_type === "ep"; 665 | 666 | const trackIds = set.tracks.map((i) => i.id); 667 | 668 | const progresses: { [key: number]: number } = {}; 669 | 670 | const reportPlaylistProgress = 671 | (trackId: number) => (progress?: number) => { 672 | if (progress) { 673 | progresses[trackId] = progress; 674 | } 675 | 676 | const totalProgress = Object.values(progresses).reduce( 677 | (acc, cur) => acc + cur, 678 | 0 679 | ); 680 | 681 | sendDownloadProgress( 682 | tabId, 683 | downloadId, 684 | totalProgress / trackIds.length 685 | ); 686 | }; 687 | 688 | const treatAsAlbum = isAlbum && trackIds.length > 1; 689 | const albumName = treatAsAlbum ? set.title : undefined; 690 | 691 | const trackIdChunkSize = 10; 692 | const trackIdChunks = chunkArray(trackIds, trackIdChunkSize); 693 | 694 | let currentTrackIdChunk = 0; 695 | for (const trackIdChunk of trackIdChunks) { 696 | const baseTrackNumber = currentTrackIdChunk * trackIdChunkSize; 697 | 698 | const keyedTracks = await soundcloudApi.getTracks(trackIdChunk); 699 | const tracks = Object.values(keyedTracks).reverse(); 700 | 701 | logger.logInfo(`Downloading ${isAlbum ? "album" : "playlist"}...`); 702 | 703 | const downloads: Promise[] = []; 704 | 705 | for (let i = 0; i < tracks.length; i++) { 706 | const trackNumber = treatAsAlbum 707 | ? baseTrackNumber + i + 1 708 | : undefined; 709 | 710 | const download = downloadTrack( 711 | tracks[i], 712 | trackNumber, 713 | albumName, 714 | reportPlaylistProgress(tracks[i].id) 715 | ); 716 | 717 | downloads.push(download); 718 | } 719 | 720 | await Promise.all( 721 | downloads.map((p) => 722 | p.catch((error) => { 723 | logger.logError("Failed to download track of set", error); 724 | }) 725 | ) 726 | ); 727 | 728 | currentTrackIdChunk++; 729 | } 730 | 731 | logger.logInfo(`Downloaded ${isAlbum ? "album" : "playlist"}!`); 732 | } else if (type === "DOWNLOAD") { 733 | logger.logDebug("Received track download request", { url }); 734 | 735 | const track = await soundcloudApi.resolveUrl(url); 736 | 737 | const reportTrackProgress = (progress?: number) => { 738 | sendDownloadProgress(tabId, downloadId, progress); 739 | }; 740 | 741 | await downloadTrack(track, undefined, undefined, reportTrackProgress); 742 | } else { 743 | throw new Error(`Unknown download type: ${type}`); 744 | } 745 | } catch (error) { 746 | sendDownloadProgress(tabId, downloadId, undefined, error); 747 | 748 | logger.logError("Download failed unexpectedly", error); 749 | } 750 | }); 751 | 752 | onPageActionClicked(() => { 753 | openOptionsPage(); 754 | }); 755 | -------------------------------------------------------------------------------- /src/metadataExtractor.spec.ts: -------------------------------------------------------------------------------- 1 | import { MetadataExtractor, Artist, ArtistType, getRemixTypeFromString, RemixType } from "./metadataExtractor"; 2 | 3 | const createExtractor = (title: string, username: string = "username") => new MetadataExtractor(title, username); 4 | 5 | const braceCombos = [ 6 | ["", ""], 7 | ["(", ")"], 8 | ["[", "]"], 9 | ]; 10 | 11 | const titleSeparators = MetadataExtractor.titleSeparators; 12 | const featureSeparators = MetadataExtractor.featureSeparators; 13 | const combiningFeatureSeparators = MetadataExtractor.combiningFeatureSeparators; 14 | const producerIndicators = MetadataExtractor.producerIndicators; 15 | const remixIndicators = MetadataExtractor.remixIndicators; 16 | 17 | describe("Different separators", () => { 18 | test.each(titleSeparators)("artist1 %s title", (separator) => { 19 | const title = `artist1 ${separator} title`; 20 | const extractor = createExtractor(title); 21 | 22 | const correctArtists: Artist[] = [ 23 | { 24 | name: "artist1", 25 | type: ArtistType.Main, 26 | }, 27 | ]; 28 | const correctTitle = "title"; 29 | 30 | expect(extractor.getArtists()).toEqual(correctArtists); 31 | expect(extractor.getTitle()).toBe(correctTitle); 32 | }); 33 | 34 | test.each(featureSeparators)("artist1%sartist2 - title", (separator) => { 35 | const title = `artist1${separator}artist2 - title`; 36 | const extractor = createExtractor(title); 37 | 38 | const correctArtists: Artist[] = [ 39 | { 40 | name: "artist1", 41 | type: ArtistType.Main, 42 | }, 43 | { 44 | name: "artist2", 45 | type: ArtistType.Feature, 46 | }, 47 | ]; 48 | const correctTitle = "title"; 49 | 50 | expect(extractor.getArtists()).toEqual(correctArtists); 51 | expect(extractor.getTitle()).toBe(correctTitle); 52 | }); 53 | 54 | test.each(featureSeparators)("artist1 - title %sartist2", (separator) => { 55 | const title = `artist1 - title ${separator}artist2`; 56 | const extractor = createExtractor(title); 57 | 58 | const correctArtists: Artist[] = [ 59 | { 60 | name: "artist1", 61 | type: ArtistType.Main, 62 | }, 63 | { 64 | name: "artist2", 65 | type: ArtistType.Feature, 66 | }, 67 | ]; 68 | const correctTitle = "title"; 69 | 70 | expect(extractor.getArtists()).toEqual(correctArtists); 71 | expect(extractor.getTitle()).toBe(correctTitle); 72 | }); 73 | 74 | test('Artist separator without spaces', () => { 75 | const title = 'Artist&Name - title'; 76 | const extractor = createExtractor(title); 77 | 78 | const correctArtists: Artist[] = [ 79 | { 80 | name: 'Artist&Name', 81 | type: ArtistType.Main, 82 | }, 83 | ]; 84 | const correctTitle = 'title'; 85 | 86 | expect(extractor.getArtists()).toEqual(correctArtists); 87 | expect(extractor.getTitle()).toBe(correctTitle); 88 | }); 89 | 90 | test('Prune "Original Mix"', () => { 91 | const title = 'Artist - title (Original Mix)'; 92 | const extractor = createExtractor(title); 93 | 94 | const correctArtists: Artist[] = [ 95 | { 96 | name: 'Artist', 97 | type: ArtistType.Main, 98 | }, 99 | ]; 100 | const correctTitle = 'title'; 101 | 102 | expect(extractor.getArtists()).toEqual(correctArtists); 103 | expect(extractor.getTitle()).toBe(correctTitle); 104 | }); 105 | 106 | braceCombos.forEach(([opening, closing]) => { 107 | producerIndicators.forEach((producerIndicator) => { 108 | test(`artist1 - title ${opening}${producerIndicator}artist2${closing}`, () => { 109 | const title = `artist1 - title ${opening}${producerIndicator}artist2${closing}`; 110 | const extractor = createExtractor(title); 111 | 112 | const correctArtists: Artist[] = [ 113 | { 114 | name: "artist1", 115 | type: ArtistType.Main, 116 | }, 117 | { 118 | name: "artist2", 119 | type: ArtistType.Producer, 120 | }, 121 | ]; 122 | const correctTitle = "title"; 123 | 124 | expect(extractor.getArtists()).toEqual(correctArtists); 125 | expect(extractor.getTitle()).toBe(correctTitle); 126 | }); 127 | 128 | combiningFeatureSeparators.forEach((combiningSeparator) => { 129 | test(`artist1 - title ${opening}${producerIndicator}artist2${combiningSeparator}artist3${closing}`, () => { 130 | const title = `artist1 - title ${opening}${producerIndicator}artist2${combiningSeparator}artist3${closing}`; 131 | const extractor = createExtractor(title); 132 | 133 | const correctArtists: Artist[] = [ 134 | { 135 | name: "artist1", 136 | type: ArtistType.Main, 137 | }, 138 | { 139 | name: "artist2", 140 | type: ArtistType.Producer, 141 | }, 142 | { 143 | name: "artist3", 144 | type: ArtistType.Producer, 145 | }, 146 | ]; 147 | const correctTitle = "title"; 148 | 149 | expect(extractor.getArtists()).toEqual(correctArtists); 150 | expect(extractor.getTitle()).toBe(correctTitle); 151 | }); 152 | }); 153 | }); 154 | 155 | featureSeparators.forEach((separator) => { 156 | test(`artist1 - title ${opening}${separator}artist2${closing}`, () => { 157 | const title = `artist1 - title ${opening}${separator}artist2${closing}`; 158 | const extractor = createExtractor(title); 159 | 160 | const correctArtists: Artist[] = [ 161 | { 162 | name: "artist1", 163 | type: ArtistType.Main, 164 | }, 165 | { 166 | name: "artist2", 167 | type: ArtistType.Feature, 168 | }, 169 | ]; 170 | const correctTitle = "title"; 171 | 172 | expect(extractor.getArtists()).toEqual(correctArtists); 173 | expect(extractor.getTitle()).toBe(correctTitle); 174 | }); 175 | 176 | combiningFeatureSeparators.forEach((combiningSeparator) => { 177 | test(`artist1 - title ${opening}${separator}artist2${combiningSeparator}artist3${closing}`, () => { 178 | const title = `artist1 - title ${opening}${separator}artist2${combiningSeparator}artist3${closing}`; 179 | const extractor = createExtractor(title); 180 | 181 | const correctArtists: Artist[] = [ 182 | { 183 | name: "artist1", 184 | type: ArtistType.Main, 185 | }, 186 | { 187 | name: "artist2", 188 | type: ArtistType.Feature, 189 | }, 190 | { 191 | name: "artist3", 192 | type: ArtistType.Feature, 193 | }, 194 | ]; 195 | const correctTitle = "title"; 196 | 197 | expect(extractor.getArtists()).toEqual(correctArtists); 198 | expect(extractor.getTitle()).toBe(correctTitle); 199 | }); 200 | }); 201 | }); 202 | 203 | if (opening === "") return; 204 | 205 | remixIndicators.forEach((remixIndicator) => { 206 | test(`artist1 - title ${opening}artist2${remixIndicator}${closing}`, () => { 207 | const title = `artist1 - title ${opening}artist2${remixIndicator}${closing}`; 208 | const extractor = createExtractor(title); 209 | 210 | const correctArtists: Artist[] = [ 211 | { 212 | name: "artist1", 213 | type: ArtistType.Main, 214 | }, 215 | { 216 | name: "artist2", 217 | type: ArtistType.Remixer, 218 | remixType: getRemixTypeFromString(remixIndicator), 219 | }, 220 | ]; 221 | const correctTitle = "title"; 222 | 223 | expect(extractor.getArtists()).toEqual(correctArtists); 224 | expect(extractor.getTitle()).toBe(correctTitle); 225 | }); 226 | 227 | combiningFeatureSeparators.forEach((combiningSeparator) => { 228 | test(`artist1 - title ${opening}artist2${combiningSeparator}artist3${remixIndicator}${closing}`, () => { 229 | const title = `artist1 - title ${opening}artist2${combiningSeparator}artist3${remixIndicator}${closing}`; 230 | const extractor = createExtractor(title); 231 | 232 | const correctArtists: Artist[] = [ 233 | { 234 | name: "artist1", 235 | type: ArtistType.Main, 236 | }, 237 | { 238 | name: "artist2", 239 | type: ArtistType.Remixer, 240 | remixType: getRemixTypeFromString(remixIndicator), 241 | }, 242 | { 243 | name: "artist3", 244 | type: ArtistType.Remixer, 245 | remixType: getRemixTypeFromString(remixIndicator), 246 | }, 247 | ]; 248 | const correctTitle = "title"; 249 | 250 | expect(extractor.getArtists()).toEqual(correctArtists); 251 | expect(extractor.getTitle()).toBe(correctTitle); 252 | }); 253 | }); 254 | }); 255 | }); 256 | }); 257 | 258 | describe("Edge cases", () => { 259 | test("no username in title", () => { 260 | const title = "title"; 261 | const extractor = createExtractor(title); 262 | 263 | const correctArtists: Artist[] = [ 264 | { 265 | name: "username", 266 | type: ArtistType.Main, 267 | }, 268 | ]; 269 | const correctTitle = "title"; 270 | 271 | expect(extractor.getArtists()).toEqual(correctArtists); 272 | expect(extractor.getTitle()).toBe(correctTitle); 273 | }); 274 | 275 | test("remove twitter handle from username", () => { 276 | const extractor = createExtractor("title", "username (@username)"); 277 | 278 | const correctArtists: Artist[] = [ 279 | { 280 | name: "username", 281 | type: ArtistType.Main, 282 | }, 283 | ]; 284 | 285 | expect(extractor.getArtists()).toEqual(correctArtists); 286 | }); 287 | 288 | test("remove twitter handle from username directly", () => { 289 | const extractor = createExtractor("title", "@username"); 290 | 291 | const correctArtists: Artist[] = [ 292 | { 293 | name: "username", 294 | type: ArtistType.Main, 295 | }, 296 | ]; 297 | 298 | expect(extractor.getArtists()).toEqual(correctArtists); 299 | }); 300 | 301 | test("braces with producer", () => { 302 | const extractor = createExtractor("title (artist)"); 303 | 304 | const correctArtists: Artist[] = [ 305 | { 306 | name: "username", 307 | type: ArtistType.Main, 308 | }, 309 | { 310 | name: "artist", 311 | type: ArtistType.Producer, 312 | }, 313 | ]; 314 | 315 | expect(extractor.getArtists()).toEqual(correctArtists); 316 | 317 | expect(extractor.getTitle()).toEqual("title"); 318 | }); 319 | 320 | test("brackets with producer", () => { 321 | const extractor = createExtractor("title [artist]"); 322 | 323 | const correctArtists: Artist[] = [ 324 | { 325 | name: "username", 326 | type: ArtistType.Main, 327 | }, 328 | { 329 | name: "artist", 330 | type: ArtistType.Producer, 331 | }, 332 | ]; 333 | 334 | expect(extractor.getArtists()).toEqual(correctArtists); 335 | 336 | expect(extractor.getTitle()).toEqual("title"); 337 | }); 338 | 339 | test("braces with producers", () => { 340 | const extractor = createExtractor("title (artist1 + artist2)"); 341 | 342 | const correctArtists: Artist[] = [ 343 | { 344 | name: "username", 345 | type: ArtistType.Main, 346 | }, 347 | { 348 | name: "artist1", 349 | type: ArtistType.Producer, 350 | }, 351 | { 352 | name: "artist2", 353 | type: ArtistType.Producer, 354 | }, 355 | ]; 356 | 357 | expect(extractor.getArtists()).toEqual(correctArtists); 358 | 359 | expect(extractor.getTitle()).toEqual("title"); 360 | }); 361 | 362 | test("brackets with producers", () => { 363 | const extractor = createExtractor("title [artist1 + artist2]"); 364 | 365 | const correctArtists: Artist[] = [ 366 | { 367 | name: "username", 368 | type: ArtistType.Main, 369 | }, 370 | { 371 | name: "artist1", 372 | type: ArtistType.Producer, 373 | }, 374 | { 375 | name: "artist2", 376 | type: ArtistType.Producer, 377 | }, 378 | ]; 379 | 380 | expect(extractor.getArtists()).toEqual(correctArtists); 381 | 382 | expect(extractor.getTitle()).toEqual("title"); 383 | }); 384 | 385 | test("braces with producer after features", () => { 386 | const extractor = createExtractor("title ft. artist1 (artist2)"); 387 | 388 | const correctArtists: Artist[] = [ 389 | { 390 | name: "username", 391 | type: ArtistType.Main, 392 | }, 393 | { 394 | name: "artist1", 395 | type: ArtistType.Feature, 396 | }, 397 | { 398 | name: "artist2", 399 | type: ArtistType.Producer, 400 | }, 401 | ]; 402 | 403 | const correctTitle = "title"; 404 | 405 | expect(extractor.getArtists()).toEqual(correctArtists); 406 | expect(extractor.getTitle()).toEqual(correctTitle); 407 | }); 408 | 409 | test("brackets with producer after features", () => { 410 | const extractor = createExtractor("title ft. artist1 [artist2]"); 411 | 412 | const correctArtists: Artist[] = [ 413 | { 414 | name: "username", 415 | type: ArtistType.Main, 416 | }, 417 | { 418 | name: "artist1", 419 | type: ArtistType.Feature, 420 | }, 421 | { 422 | name: "artist2", 423 | type: ArtistType.Producer, 424 | }, 425 | ]; 426 | 427 | const correctTitle = "title"; 428 | 429 | expect(extractor.getArtists()).toEqual(correctArtists); 430 | expect(extractor.getTitle()).toEqual(correctTitle); 431 | }); 432 | 433 | test("self produced", () => { 434 | const extractor = createExtractor("title (prod. artist)", "artist"); 435 | 436 | const correctArtists: Artist[] = [ 437 | { 438 | name: "artist", 439 | type: ArtistType.Main, 440 | }, 441 | ]; 442 | 443 | const correctTitle = "title"; 444 | 445 | expect(extractor.getArtists()).toEqual(correctArtists); 446 | expect(extractor.getTitle()).toEqual(correctTitle); 447 | }); 448 | }); 449 | 450 | describe("Real world examples", () => { 451 | test("1", () => { 452 | const extractor = createExtractor("wish 4u... +++miraie, milkoi", "yandere"); 453 | 454 | const correctArtists: Artist[] = [ 455 | { 456 | name: "yandere", 457 | type: ArtistType.Main, 458 | }, 459 | { 460 | name: "miraie", 461 | type: ArtistType.Feature, 462 | }, 463 | { 464 | name: "milkoi", 465 | type: ArtistType.Feature, 466 | }, 467 | ]; 468 | 469 | const correctTitle = "wish 4u..."; 470 | 471 | expect(extractor.getArtists()).toEqual(correctArtists); 472 | expect(extractor.getTitle()).toEqual(correctTitle); 473 | }); 474 | 475 | test("2", () => { 476 | const extractor = createExtractor("glue ft. blxty「prod. kiryano」"); 477 | 478 | const correctArtists: Artist[] = [ 479 | { 480 | name: "username", 481 | type: ArtistType.Main, 482 | }, 483 | { 484 | name: "blxty", 485 | type: ArtistType.Feature, 486 | }, 487 | { 488 | name: "kiryano", 489 | type: ArtistType.Producer, 490 | }, 491 | ]; 492 | 493 | const correctTitle = "glue"; 494 | 495 | expect(extractor.getArtists()).toEqual(correctArtists); 496 | expect(extractor.getTitle()).toEqual(correctTitle); 497 | }); 498 | 499 | test("3", () => { 500 | const extractor = createExtractor("show (emorave + mental)", "sparr00w (@sprr00w)"); 501 | 502 | const correctArtists: Artist[] = [ 503 | { 504 | name: "sparr00w", 505 | type: ArtistType.Main, 506 | }, 507 | { 508 | name: "emorave", 509 | type: ArtistType.Producer, 510 | }, 511 | { 512 | name: "mental", 513 | type: ArtistType.Producer, 514 | }, 515 | ]; 516 | 517 | const correctTitle = "show"; 518 | 519 | expect(extractor.getArtists()).toEqual(correctArtists); 520 | expect(extractor.getTitle()).toEqual(correctTitle); 521 | }); 522 | 523 | test("4", () => { 524 | const extractor = createExtractor("heart & soul (prod. lil biscuit + yeezo)", "hamilton"); 525 | 526 | const correctArtists: Artist[] = [ 527 | { 528 | name: "hamilton", 529 | type: ArtistType.Main, 530 | }, 531 | { 532 | name: "lil biscuit", 533 | type: ArtistType.Producer, 534 | }, 535 | { 536 | name: "yeezo", 537 | type: ArtistType.Producer, 538 | }, 539 | ]; 540 | 541 | const correctTitle = "heart & soul"; 542 | 543 | expect(extractor.getArtists()).toEqual(correctArtists); 544 | expect(extractor.getTitle()).toEqual(correctTitle); 545 | }); 546 | 547 | test("5", () => { 548 | const extractor = createExtractor("4:00 p. pilotkid", "@tamino404"); 549 | 550 | const correctArtists: Artist[] = [ 551 | { 552 | name: "tamino404", 553 | type: ArtistType.Main, 554 | }, 555 | { 556 | name: "pilotkid", 557 | type: ArtistType.Producer, 558 | }, 559 | ]; 560 | 561 | const correctTitle = "4:00"; 562 | 563 | expect(extractor.getArtists()).toEqual(correctArtists); 564 | expect(extractor.getTitle()).toEqual(correctTitle); 565 | }); 566 | 567 | test("6", () => { 568 | const extractor = createExtractor("outta my head (longlost)", "longlost"); 569 | 570 | const correctArtists: Artist[] = [ 571 | { 572 | name: "longlost", 573 | type: ArtistType.Main, 574 | }, 575 | ]; 576 | 577 | const correctTitle = "outta my head"; 578 | 579 | expect(extractor.getArtists()).toEqual(correctArtists); 580 | expect(extractor.getTitle()).toEqual(correctTitle); 581 | }); 582 | 583 | test("7", () => { 584 | const extractor = createExtractor("W1TCHCH4P3L (ft. Maestro) [Prod. Shinju]", "witchcraftshawty"); 585 | 586 | const correctArtists: Artist[] = [ 587 | { 588 | name: "witchcraftshawty", 589 | type: ArtistType.Main, 590 | }, 591 | { 592 | name: "Maestro", 593 | type: ArtistType.Feature, 594 | }, 595 | { 596 | name: "Shinju", 597 | type: ArtistType.Producer, 598 | }, 599 | ]; 600 | 601 | const correctTitle = "W1TCHCH4P3L"; 602 | 603 | expect(extractor.getArtists()).toEqual(correctArtists); 604 | expect(extractor.getTitle()).toEqual(correctTitle); 605 | }); 606 | 607 | test("8", () => { 608 | const extractor = createExtractor("travis scott - lonely ft. young thug & quavo [destxmido edit]", "destxmido"); 609 | 610 | const correctArtists: Artist[] = [ 611 | { 612 | name: "travis scott", 613 | type: ArtistType.Main, 614 | }, 615 | { 616 | name: "young thug", 617 | type: ArtistType.Feature, 618 | }, 619 | { 620 | name: "quavo", 621 | type: ArtistType.Feature, 622 | }, 623 | { 624 | name: "destxmido", 625 | type: ArtistType.Remixer, 626 | remixType: RemixType.Edit, 627 | }, 628 | ]; 629 | 630 | const correctTitle = "lonely"; 631 | 632 | expect(extractor.getArtists()).toEqual(correctArtists); 633 | expect(extractor.getTitle()).toEqual(correctTitle); 634 | }); 635 | 636 | test("9", () => { 637 | const extractor = createExtractor( 638 | "KNAAMEAN? ft. * BB * & DAISY DIVA [prod. CURTAINS x NIGHTCLUB20XX x POPSTARBILLS x DEATHNOTES]", 639 | "POPSTARBILLS 💫" 640 | ); 641 | 642 | const correctArtists: Artist[] = [ 643 | { 644 | name: "POPSTARBILLS", 645 | type: ArtistType.Main, 646 | }, 647 | { 648 | name: "* BB *", 649 | type: ArtistType.Feature, 650 | }, 651 | { 652 | name: "DAISY DIVA", 653 | type: ArtistType.Feature, 654 | }, 655 | { 656 | name: "CURTAINS", 657 | type: ArtistType.Producer, 658 | }, 659 | { 660 | name: "NIGHTCLUB20XX", 661 | type: ArtistType.Producer, 662 | }, 663 | { 664 | name: "DEATHNOTES", 665 | type: ArtistType.Producer, 666 | }, 667 | ]; 668 | 669 | const correctTitle = "KNAAMEAN?"; 670 | 671 | expect(extractor.getArtists()).toEqual(correctArtists); 672 | expect(extractor.getTitle()).toEqual(correctTitle); 673 | }); 674 | 675 | test("10", () => { 676 | const extractor = createExtractor("we're gonna b ok [space]", "keyblayde808"); 677 | 678 | const correctArtists: Artist[] = [ 679 | { 680 | name: "keyblayde808", 681 | type: ArtistType.Main, 682 | }, 683 | { 684 | name: "space", 685 | type: ArtistType.Producer, 686 | }, 687 | ]; 688 | 689 | const correctTitle = "we're gonna b ok"; 690 | 691 | expect(extractor.getArtists()).toEqual(correctArtists); 692 | expect(extractor.getTitle()).toEqual(correctTitle); 693 | }); 694 | 695 | test("11", () => { 696 | const extractor = createExtractor("avantgarde (wifi) video in desc", "5v"); 697 | 698 | const correctArtists: Artist[] = [ 699 | { 700 | name: "5v", 701 | type: ArtistType.Main, 702 | }, 703 | { 704 | name: "wifi", 705 | type: ArtistType.Producer, 706 | }, 707 | ]; 708 | 709 | const correctTitle = "avantgarde"; 710 | 711 | expect(extractor.getArtists()).toEqual(correctArtists); 712 | expect(extractor.getTitle()).toEqual(correctTitle); 713 | }); 714 | 715 | test("12", () => { 716 | const extractor = createExtractor("my emo shorty (northeast lights x mart)", "ultravialit"); 717 | 718 | const correctArtists: Artist[] = [ 719 | { 720 | name: "ultravialit", 721 | type: ArtistType.Main, 722 | }, 723 | { 724 | name: "northeast lights", 725 | type: ArtistType.Producer, 726 | }, 727 | { 728 | name: "mart", 729 | type: ArtistType.Producer, 730 | }, 731 | ]; 732 | 733 | const correctTitle = "my emo shorty"; 734 | 735 | expect(extractor.getArtists()).toEqual(correctArtists); 736 | expect(extractor.getTitle()).toEqual(correctTitle); 737 | }); 738 | 739 | test("13", () => { 740 | const extractor = createExtractor("outer space(+yandere)", "crescent"); 741 | 742 | const correctArtists: Artist[] = [ 743 | { 744 | name: "crescent", 745 | type: ArtistType.Main, 746 | }, 747 | { 748 | name: "yandere", 749 | type: ArtistType.Feature, 750 | }, 751 | ]; 752 | 753 | const correctTitle = "outer space"; 754 | 755 | expect(extractor.getArtists()).toEqual(correctArtists); 756 | expect(extractor.getTitle()).toEqual(correctTitle); 757 | }); 758 | 759 | test("14", () => { 760 | const extractor = createExtractor("LY2 w Lil Narnia prod Shyburial", "Yung Scuff"); 761 | 762 | const correctArtists: Artist[] = [ 763 | { 764 | name: "Yung Scuff", 765 | type: ArtistType.Main, 766 | }, 767 | { 768 | name: "Lil Narnia", 769 | type: ArtistType.Feature, 770 | }, 771 | { 772 | name: "Shyburial", 773 | type: ArtistType.Producer, 774 | }, 775 | ]; 776 | 777 | const correctTitle = "LY2"; 778 | 779 | expect(extractor.getArtists()).toEqual(correctArtists); 780 | expect(extractor.getTitle()).toEqual(correctTitle); 781 | }); 782 | 783 | test("15", () => { 784 | const extractor = createExtractor("okay ft. palmtri (snowdrive)", "sparr00w (@sprr00w)"); 785 | 786 | const correctArtists: Artist[] = [ 787 | { 788 | name: "sparr00w", 789 | type: ArtistType.Main, 790 | }, 791 | { 792 | name: "palmtri", 793 | type: ArtistType.Feature, 794 | }, 795 | { 796 | name: "snowdrive", 797 | type: ArtistType.Producer, 798 | }, 799 | ]; 800 | 801 | const correctTitle = "okay"; 802 | 803 | expect(extractor.getArtists()).toEqual(correctArtists); 804 | expect(extractor.getTitle()).toEqual(correctTitle); 805 | }); 806 | 807 | test("16", () => { 808 | const extractor = createExtractor("they/them anthem (jeremyy, shrinemaiden)", "☆amy crush☆ (@aim_crush)"); 809 | 810 | const correctArtists: Artist[] = [ 811 | { 812 | name: "amy crush", 813 | type: ArtistType.Main, 814 | }, 815 | { 816 | name: "jeremyy", 817 | type: ArtistType.Producer, 818 | }, 819 | { 820 | name: "shrinemaiden", 821 | type: ArtistType.Producer, 822 | }, 823 | ]; 824 | 825 | const correctTitle = "they/them anthem"; 826 | 827 | expect(extractor.getArtists()).toEqual(correctArtists); 828 | expect(extractor.getTitle()).toEqual(correctTitle); 829 | }); 830 | 831 | test("17", () => { 832 | const extractor = createExtractor("Liltumblrxo ~ In Your Head [+Shinju]", "icy#9 Productions"); 833 | 834 | const correctArtists: Artist[] = [ 835 | { 836 | name: "Liltumblrxo", 837 | type: ArtistType.Main, 838 | }, 839 | { 840 | name: "Shinju", 841 | type: ArtistType.Feature, 842 | }, 843 | ]; 844 | 845 | const correctTitle = "In Your Head"; 846 | 847 | expect(extractor.getArtists()).toEqual(correctArtists); 848 | expect(extractor.getTitle()).toEqual(correctTitle); 849 | }); 850 | 851 | test("18", () => { 852 | const extractor = createExtractor("Хмари", "5 Vymir (П'ятий Вимір)"); 853 | 854 | const correctArtists: Artist[] = [ 855 | { 856 | name: "5 Vymir (П'ятий Вимір)", 857 | type: ArtistType.Main, 858 | }, 859 | ]; 860 | 861 | const correctTitle = "Хмари"; 862 | 863 | expect(extractor.getArtists()).toEqual(correctArtists); 864 | expect(extractor.getTitle()).toEqual(correctTitle); 865 | }); 866 | 867 | test("19", () => { 868 | const extractor = createExtractor("Zeiten Ändern Dich"); 869 | 870 | const correctTitle = "Zeiten Ändern Dich"; 871 | 872 | expect(extractor.getTitle()).toEqual(correctTitle); 873 | }); 874 | 875 | test.skip("20", () => { 876 | const extractor = createExtractor("VIPER w/ loveUnity & KID TRASH [+kidtrashpop]", "JoshuaSageArt"); 877 | 878 | const correctArtists: Artist[] = [ 879 | { 880 | name: "JoshuaSageArt", 881 | type: ArtistType.Main, 882 | }, 883 | { 884 | name: "loveUnity", 885 | type: ArtistType.Feature, 886 | }, 887 | { 888 | name: "KID TRASH", 889 | type: ArtistType.Feature, 890 | }, 891 | { 892 | name: "kidtrashpop", 893 | type: ArtistType.Producer, 894 | }, 895 | ]; 896 | 897 | const correctTitle = "VIPER"; 898 | 899 | expect(extractor.getArtists()).toEqual(correctArtists); 900 | expect(extractor.getTitle()).toEqual(correctTitle); 901 | }); 902 | 903 | test("21", () => { 904 | const extractor = createExtractor("nothings fading (taylor morgan, saint tomorrow)", "kiryano"); 905 | 906 | const correctArtists: Artist[] = [ 907 | { 908 | name: "kiryano", 909 | type: ArtistType.Main, 910 | }, 911 | { 912 | name: "taylor morgan", 913 | type: ArtistType.Producer, 914 | }, 915 | { 916 | name: "saint tomorrow", 917 | type: ArtistType.Producer, 918 | }, 919 | ]; 920 | 921 | const correctTitle = "nothings fading"; 922 | 923 | expect(extractor.getArtists()).toEqual(correctArtists); 924 | expect(extractor.getTitle()).toEqual(correctTitle); 925 | }); 926 | 927 | test("22", () => { 928 | const extractor = createExtractor("worse (+internet joe +nefa)", "fiction57"); 929 | 930 | const correctArtists: Artist[] = [ 931 | { 932 | name: "fiction57", 933 | type: ArtistType.Main, 934 | }, 935 | { 936 | name: "internet joe", 937 | type: ArtistType.Feature, 938 | }, 939 | { 940 | name: "nefa", 941 | type: ArtistType.Feature, 942 | }, 943 | ]; 944 | 945 | const correctTitle = "worse"; 946 | 947 | expect(extractor.getArtists()).toEqual(correctArtists); 948 | expect(extractor.getTitle()).toEqual(correctTitle); 949 | }); 950 | 951 | test("23", () => { 952 | const extractor = createExtractor("blessed feat. funeral (doxia & wifi)", "mental"); 953 | 954 | const correctArtists: Artist[] = [ 955 | { 956 | name: "mental", 957 | type: ArtistType.Main, 958 | }, 959 | { 960 | name: "funeral", 961 | type: ArtistType.Feature, 962 | }, 963 | { 964 | name: "doxia", 965 | type: ArtistType.Producer, 966 | }, 967 | { 968 | name: "wifi", 969 | type: ArtistType.Producer, 970 | }, 971 | ]; 972 | 973 | const correctTitle = "blessed"; 974 | 975 | expect(extractor.getArtists()).toEqual(correctArtists); 976 | expect(extractor.getTitle()).toEqual(correctTitle); 977 | }); 978 | 979 | test("24", () => { 980 | const extractor = createExtractor("4MCLF (Ft. Flowr) [Prod. MCX]", "icarus444"); 981 | 982 | const correctArtists: Artist[] = [ 983 | { 984 | name: "icarus444", 985 | type: ArtistType.Main, 986 | }, 987 | { 988 | name: "Flowr", 989 | type: ArtistType.Feature, 990 | }, 991 | { 992 | name: "MCX", 993 | type: ArtistType.Producer, 994 | }, 995 | ]; 996 | 997 | const correctTitle = "4MCLF"; 998 | 999 | expect(extractor.getArtists()).toEqual(correctArtists); 1000 | expect(extractor.getTitle()).toEqual(correctTitle); 1001 | }); 1002 | 1003 | test("25", () => { 1004 | const extractor = createExtractor("blackwinterwells + roxas - stuck! (flood+no bands)", "helix tears"); 1005 | 1006 | const correctArtists: Artist[] = [ 1007 | { 1008 | name: "blackwinterwells", 1009 | type: ArtistType.Main, 1010 | }, 1011 | { 1012 | name: "roxas", 1013 | type: ArtistType.Feature, 1014 | }, 1015 | { 1016 | name: "flood", 1017 | type: ArtistType.Producer, 1018 | }, 1019 | { 1020 | name: "no bands", 1021 | type: ArtistType.Producer, 1022 | }, 1023 | ]; 1024 | 1025 | const correctTitle = "stuck!"; 1026 | 1027 | expect(extractor.getArtists()).toEqual(correctArtists); 1028 | expect(extractor.getTitle()).toEqual(correctTitle); 1029 | }); 1030 | 1031 | test("26", () => { 1032 | const extractor = createExtractor("walk walk walk walk w/ ovrwrld (prod. River)", "vaeo"); 1033 | 1034 | const correctArtists: Artist[] = [ 1035 | { 1036 | name: "vaeo", 1037 | type: ArtistType.Main, 1038 | }, 1039 | { 1040 | name: "ovrwrld", 1041 | type: ArtistType.Feature, 1042 | }, 1043 | { 1044 | name: "River", 1045 | type: ArtistType.Producer, 1046 | }, 1047 | ]; 1048 | 1049 | const correctTitle = "walk walk walk walk"; 1050 | 1051 | expect(extractor.getArtists()).toEqual(correctArtists); 1052 | expect(extractor.getTitle()).toEqual(correctTitle); 1053 | }); 1054 | 1055 | test("27", () => { 1056 | const extractor = createExtractor("DEAD FLOWERS (Ft. Witle$$ & HEA)(Prod. FXCKJAMiE)", "AOM (Afraid of Myself)"); 1057 | 1058 | const correctArtists: Artist[] = [ 1059 | { 1060 | name: "AOM (Afraid of Myself)", 1061 | type: ArtistType.Main, 1062 | }, 1063 | { 1064 | name: "Witle$$", 1065 | type: ArtistType.Feature, 1066 | }, 1067 | { 1068 | name: "HEA", 1069 | type: ArtistType.Feature, 1070 | }, 1071 | { 1072 | name: "FXCKJAMiE", 1073 | type: ArtistType.Producer, 1074 | }, 1075 | ]; 1076 | 1077 | const correctTitle = "DEAD FLOWERS"; 1078 | 1079 | expect(extractor.getArtists()).toEqual(correctArtists); 1080 | expect(extractor.getTitle()).toEqual(correctTitle); 1081 | }); 1082 | 1083 | test.skip("28", () => { 1084 | const extractor = createExtractor("tellmewhy (´。• ω •。`) prod wonderr+ninetyniiine", "emotionals"); 1085 | 1086 | const correctArtists: Artist[] = [ 1087 | { 1088 | name: "emotionals", 1089 | type: ArtistType.Main, 1090 | }, 1091 | { 1092 | name: "wonderr", 1093 | type: ArtistType.Producer, 1094 | }, 1095 | { 1096 | name: "ninetyniiine", 1097 | type: ArtistType.Producer, 1098 | }, 1099 | ]; 1100 | 1101 | const correctTitle = "tellmewhy"; 1102 | 1103 | expect(extractor.getArtists()).toEqual(correctArtists); 1104 | expect(extractor.getTitle()).toEqual(correctTitle); 1105 | }); 1106 | 1107 | test("29", () => { 1108 | const extractor = createExtractor("odece & 5v - spazzed ft. ninyy (5v + odece + shinju) vid in desc", "go luxury"); 1109 | 1110 | const correctArtists: Artist[] = [ 1111 | { 1112 | name: "odece", 1113 | type: ArtistType.Main, 1114 | }, 1115 | { 1116 | name: "5v", 1117 | type: ArtistType.Feature, 1118 | }, 1119 | { 1120 | name: "ninyy", 1121 | type: ArtistType.Feature, 1122 | }, 1123 | { 1124 | name: "shinju", 1125 | type: ArtistType.Producer, 1126 | }, 1127 | ]; 1128 | 1129 | const correctTitle = "spazzed"; 1130 | 1131 | expect(extractor.getArtists()).toEqual(correctArtists); 1132 | expect(extractor.getTitle()).toEqual(correctTitle); 1133 | }); 1134 | 1135 | test("30", () => { 1136 | const extractor = createExtractor("Graveyard Shift: Ludwig Abraham", "Schauspielhaus Zürich"); 1137 | 1138 | const correctArtists: Artist[] = [ 1139 | { 1140 | name: "Schauspielhaus Zürich", 1141 | type: ArtistType.Main, 1142 | }, 1143 | ]; 1144 | 1145 | const correctTitle = "Graveyard Shift: Ludwig Abraham"; 1146 | 1147 | expect(extractor.getArtists()).toEqual(correctArtists); 1148 | expect(extractor.getTitle()).toEqual(correctTitle); 1149 | }); 1150 | 1151 | test.skip("31", () => { 1152 | const extractor = createExtractor("Too Many Years Ft. PNB Rock - Prod. By J Gramm", "Kodak Black"); 1153 | 1154 | const correctArtists: Artist[] = [ 1155 | { 1156 | name: "Kodak Black", 1157 | type: ArtistType.Main, 1158 | }, 1159 | { 1160 | name: "PNB Rock", 1161 | type: ArtistType.Feature, 1162 | }, 1163 | { 1164 | name: "J Gramm", 1165 | type: ArtistType.Producer, 1166 | }, 1167 | ]; 1168 | 1169 | const correctTitle = "Too Many Years"; 1170 | 1171 | expect(extractor.getArtists()).toEqual(correctArtists); 1172 | expect(extractor.getTitle()).toEqual(correctTitle); 1173 | }); 1174 | }); 1175 | --------------------------------------------------------------------------------