From fd8ebb2e8361b39b5b4ee107371c45214be4277d Mon Sep 17 00:00:00 2001 From: Digimezzo Date: Fri, 3 Nov 2023 07:13:11 +0100 Subject: [PATCH] Fixes #374: Add lyrics support (#474) * Moving things around * Adds lyrics screen. Still needs actual lyrics. * Fixes a bug * Adds basic lyrics support * Improves robustness of LyricsService * Adds AZLyricsAPI * Adds integration tests for lyrics API's * Adds indication of lyrics source * Adds many more lyrics sources * Adds integration test for WebSearchLyrics * Adds basic LRC file support * Update README.md * Update README.md * Update README.md * Update README.md * Update README.md * Update README.md * Update README.md * Update README.md * Improves lyrics screen --- .eslintrc.json | 7 + CHANGELOG.md | 4 + jest.config.js | 12 +- package-lock.json | 628 +++++++++++++++++- package.json | 6 +- src/app/app.component.spec.ts | 7 +- src/app/app.component.ts | 14 +- src/app/app.module.ts | 23 +- src/app/common/api/lyrics/a-z-lyrics-api.ts | 54 ++ src/app/common/api/lyrics/chart-lyrics-api.ts | 26 + src/app/common/api/lyrics/i-lyrics-api.ts | 6 + .../common/api/lyrics/lyrics-source-type.ts | 6 + src/app/common/api/lyrics/lyrics.spec.ts | 29 + src/app/common/api/lyrics/lyrics.ts | 10 + .../sources/a-z-lyrics-source.ts | 29 + .../sources/genius-source.ts | 19 + .../sources/i-web-search-lyrics-source.ts | 4 + .../sources/lyrics-source.ts | 14 + .../sources/musixmatch-source.ts | 17 + .../web-search-lyrics/web-search-api.ts | 98 +++ .../web-search-lyrics-api.ts | 65 ++ .../web-search-result.spec.ts | 29 + .../web-search-lyrics/web-search-result.ts | 15 + src/app/common/application/constants.ts | 56 +- src/app/common/io/file-access.ts | 9 +- src/app/common/settings/base-settings.ts | 1 + src/app/common/settings/settings.ts | 13 + src/app/common/utils/promise-utils.ts | 4 +- .../collection-playback-pane.component.html | 2 + ...collection-playback-pane.component.spec.ts | 14 +- .../collection-playback-pane.component.ts | 7 +- .../playlist-browser.component.html | 2 +- .../now-playing-artist-info.component.html | 4 +- .../now-playing-artist-info.component.scss | 7 +- .../now-playing-artist-info.component.spec.ts | 32 +- .../now-playing-artist-info.component.ts | 8 +- .../now-playing-lyrics.component.html | 27 + .../now-playing-lyrics.component.scss | 27 + .../now-playing-lyrics.component.spec.ts | 222 +++++++ .../now-playing-lyrics.component.ts | 110 +++ .../now-playing-playback-pane.component.html | 6 + ...ow-playing-playback-pane.component.spec.ts | 40 +- .../now-playing-playback-pane.component.ts | 4 + .../now-playing-showcase.component.html | 4 +- .../now-playing-showcase.component.scss | 15 + .../now-playing-showcase.component.spec.ts | 5 +- .../now-playing-showcase.component.ts | 3 +- .../now-playing/now-playing.component.html | 2 + .../playback-information.component.html | 24 +- .../playback-information.component.spec.ts | 49 +- .../playback-information.component.ts | 8 +- .../services/indexing/track-filler.spec.ts | 2 +- src/app/services/indexing/track-filler.ts | 2 +- .../services/lyrics/base-lyrics.service.ts | 7 + .../lyrics/embedded-lyrics-getter.spec.ts | 58 ++ .../services/lyrics/embedded-lyrics-getter.ts | 18 + src/app/services/lyrics/i-lyrics-getter.ts | 6 + .../services/lyrics/lrc-lyrics-getter.spec.ts | 65 ++ src/app/services/lyrics/lrc-lyrics-getter.ts | 61 ++ src/app/services/lyrics/lyrics-model.spec.ts | 39 ++ src/app/services/lyrics/lyrics-model.ts | 13 + .../services/lyrics/lyrics.service.spec.ts | 146 ++++ src/app/services/lyrics/lyrics.service.ts | 59 ++ .../lyrics/online-lyrics-getter.spec.ts | 101 +++ .../services/lyrics/online-lyrics-getter.ts | 49 ++ .../now-playing-page.ts | 3 +- src/app/services/playback/playback.service.ts | 8 +- src/app/testing/integration-test-runner.ts | 43 ++ src/app/testing/mock-creator.ts | 15 +- src/assets/i18n/bg.json | 7 +- src/assets/i18n/cs.json | 7 +- src/assets/i18n/de.json | 7 +- src/assets/i18n/el.json | 7 +- src/assets/i18n/en.json | 7 +- src/assets/i18n/es.json | 7 +- src/assets/i18n/fr.json | 7 +- src/assets/i18n/hr.json | 7 +- src/assets/i18n/ja-JP.json | 7 +- src/assets/i18n/ko.json | 7 +- src/assets/i18n/ku.json | 7 +- src/assets/i18n/nl.json | 7 +- src/assets/i18n/pt-BR.json | 7 +- src/assets/i18n/ru.json | 7 +- src/assets/i18n/vi.json | 7 +- src/assets/i18n/zh-CN.json | 7 +- src/assets/i18n/zh-TW.json | 7 +- src/css/custom-classes.scss | 4 + src/css/sizing.scss | 15 +- 88 files changed, 2513 insertions(+), 147 deletions(-) create mode 100644 src/app/common/api/lyrics/a-z-lyrics-api.ts create mode 100644 src/app/common/api/lyrics/chart-lyrics-api.ts create mode 100644 src/app/common/api/lyrics/i-lyrics-api.ts create mode 100644 src/app/common/api/lyrics/lyrics-source-type.ts create mode 100644 src/app/common/api/lyrics/lyrics.spec.ts create mode 100644 src/app/common/api/lyrics/lyrics.ts create mode 100644 src/app/common/api/lyrics/web-search-lyrics/sources/a-z-lyrics-source.ts create mode 100644 src/app/common/api/lyrics/web-search-lyrics/sources/genius-source.ts create mode 100644 src/app/common/api/lyrics/web-search-lyrics/sources/i-web-search-lyrics-source.ts create mode 100644 src/app/common/api/lyrics/web-search-lyrics/sources/lyrics-source.ts create mode 100644 src/app/common/api/lyrics/web-search-lyrics/sources/musixmatch-source.ts create mode 100644 src/app/common/api/lyrics/web-search-lyrics/web-search-api.ts create mode 100644 src/app/common/api/lyrics/web-search-lyrics/web-search-lyrics-api.ts create mode 100644 src/app/common/api/lyrics/web-search-lyrics/web-search-result.spec.ts create mode 100644 src/app/common/api/lyrics/web-search-lyrics/web-search-result.ts create mode 100644 src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.html create mode 100644 src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.scss create mode 100644 src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.spec.ts create mode 100644 src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.ts create mode 100644 src/app/services/lyrics/base-lyrics.service.ts create mode 100644 src/app/services/lyrics/embedded-lyrics-getter.spec.ts create mode 100644 src/app/services/lyrics/embedded-lyrics-getter.ts create mode 100644 src/app/services/lyrics/i-lyrics-getter.ts create mode 100644 src/app/services/lyrics/lrc-lyrics-getter.spec.ts create mode 100644 src/app/services/lyrics/lrc-lyrics-getter.ts create mode 100644 src/app/services/lyrics/lyrics-model.spec.ts create mode 100644 src/app/services/lyrics/lyrics-model.ts create mode 100644 src/app/services/lyrics/lyrics.service.spec.ts create mode 100644 src/app/services/lyrics/lyrics.service.ts create mode 100644 src/app/services/lyrics/online-lyrics-getter.spec.ts create mode 100644 src/app/services/lyrics/online-lyrics-getter.ts create mode 100644 src/app/testing/integration-test-runner.ts diff --git a/.eslintrc.json b/.eslintrc.json index 9bf105021..50025484a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,4 +1,11 @@ { + "parserOptions": { + "ecmaVersion": "latest" + }, + + "env": { + "es6": true + }, "root": true, "ignorePatterns": ["projects/**/*"], "overrides": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c38bac3..dd6a52d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Clicking the volume icon now (un)mutes - Dopamine now has a logarithmic volume control, because your ears are worth it! +### Changed + +- Updated Russian translation (Thank you adem4ik) + ### Fixed - Loop one icon has no margin diff --git a/jest.config.js b/jest.config.js index a732981d1..c7cb6fa83 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,10 +9,14 @@ module.exports = { moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths || {}, { prefix: '/', }), - globals: { - 'ts-jest': { - tsConfig: '/tsconfig.spec.json', - }, + transform: { + '^.+\\.{ts|tsx}?$': [ + 'ts-jest', + { + babel: true, + tsConfig: 'tsconfig.spec.json', + }, + ], }, setupFiles: ['/jest.crypto-setup.js'], }; diff --git a/package-lock.json b/package-lock.json index a63e8f9f0..739791820 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,16 +15,19 @@ "@electron/remote": "2.0.9", "angular-split": "14.1.0", "better-sqlite3": "8.4.0", + "cheerio": "^1.0.0-rc.12", "discord-rpc": "4.0.1", "electron-log": "4.4.8", "electron-store": "8.1.0", "electron-window-state": "5.0.3", + "fast-xml-parser": "^4.3.2", "fs-extra": "11.1.1", "line-awesome": "1.3.0", "md5-typescript": "1.0.5", "moment": "2.29.4", "music-metadata": "7.13.0", "node-fetch": "2.6.11", + "node-html-parser": "^6.1.11", "node-taglib-sharp": "5.0.1", "sanitize-filename": "1.6.3", "tinycolor2": "1.6.0", @@ -64,6 +67,7 @@ "jest": "29.6.1", "jest-preset-angular": "13.1.1", "npm-run-all": "4.1.5", + "prettier": "^3.0.3", "rxjs": "6.6.3", "ts-jest": "29.1.1", "ts-node": "10.9.1", @@ -8618,8 +8622,7 @@ "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "node_modules/boolean": { "version": "3.2.0", @@ -9071,6 +9074,171 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio-select/node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio-select/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/cheerio-select/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -9831,7 +9999,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "dev": true, "engines": { "node": ">= 6" }, @@ -10363,7 +10530,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, "funding": [ { "type": "github", @@ -12076,6 +12242,27 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-xml-parser": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.2.tgz", + "integrity": "sha512-rmrXUXwbJedoXkStenj1kkljNF7ugn5ZjR9FJcwmCfcCbtOMDghPajbc+Tck6vE6F5XsDmx+Pr2le9fw8+pXBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -12865,6 +13052,14 @@ "integrity": "sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==", "dev": true }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -12977,6 +13172,75 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -17419,6 +17683,81 @@ "node": "*" } }, + "node_modules/node-html-parser": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.11.tgz", + "integrity": "sha512-FAgwwZ6h0DSDWxfD0Iq1tsDcBCxdJB1nXpLPPxX8YyVWzbfCjKWEzaynF4gZZ/8hziUmp7ZSaKylcn0iKhufUQ==", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-html-parser/node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/node-html-parser/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/node-html-parser/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/node-html-parser/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/node-html-parser/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -17804,7 +18143,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, "dependencies": { "boolbase": "^1.0.0" }, @@ -18384,7 +18722,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "devOptional": true, "dependencies": { "entities": "^4.4.0" }, @@ -18449,7 +18786,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "devOptional": true, "engines": { "node": ">=0.12" }, @@ -18896,6 +19232,21 @@ "node": ">=4" } }, + "node_modules/prettier": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", + "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -20911,6 +21262,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "node_modules/strtok3": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", @@ -29600,8 +29956,7 @@ "boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "boolean": { "version": "3.2.0", @@ -29932,6 +30287,124 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "requires": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + } + } + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "dependencies": { + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -30513,8 +30986,7 @@ "css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "dev": true + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" }, "cssesc": { "version": "3.0.0", @@ -30919,8 +31391,7 @@ "domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" }, "domexception": { "version": "4.0.0", @@ -32212,6 +32683,14 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "fast-xml-parser": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.2.tgz", + "integrity": "sha512-rmrXUXwbJedoXkStenj1kkljNF7ugn5ZjR9FJcwmCfcCbtOMDghPajbc+Tck6vE6F5XsDmx+Pr2le9fw8+pXBg==", + "requires": { + "strnum": "^1.0.5" + } + }, "fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -32812,6 +33291,11 @@ "integrity": "sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==", "dev": true }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" + }, "hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -32909,6 +33393,52 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + }, + "dependencies": { + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, "http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -36203,6 +36733,62 @@ "integrity": "sha512-24vnklJmyRS8ViBNI8KbtK/r/DmXQMRiOMXTNz2nrTnAYUwjmEEbnnpB/+kt+yWRv73bPsSPRFddrcIbAxSiMQ==", "dev": true }, + "node-html-parser": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.11.tgz", + "integrity": "sha512-FAgwwZ6h0DSDWxfD0Iq1tsDcBCxdJB1nXpLPPxX8YyVWzbfCjKWEzaynF4gZZ/8hziUmp7ZSaKylcn0iKhufUQ==", + "requires": { + "css-select": "^5.1.0", + "he": "1.2.0" + }, + "dependencies": { + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + } + } + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -36501,7 +37087,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, "requires": { "boolbase": "^1.0.0" } @@ -36923,7 +37508,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "devOptional": true, "requires": { "entities": "^4.4.0" }, @@ -36931,8 +37515,7 @@ "entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "devOptional": true + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" } } }, @@ -37277,6 +37860,12 @@ "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==" }, + "prettier": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", + "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", + "dev": true + }, "pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -38783,6 +39372,11 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "strtok3": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", diff --git a/package.json b/package.json index aa6c06f1a..1373dad7a 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "jest": "29.6.1", "jest-preset-angular": "13.1.1", "npm-run-all": "4.1.5", + "prettier": "^3.0.3", "rxjs": "6.6.3", "ts-jest": "29.1.1", "ts-node": "10.9.1", @@ -93,16 +94,19 @@ "@electron/remote": "2.0.9", "angular-split": "14.1.0", "better-sqlite3": "8.4.0", + "cheerio": "^1.0.0-rc.12", "discord-rpc": "4.0.1", "electron-log": "4.4.8", "electron-store": "8.1.0", "electron-window-state": "5.0.3", + "fast-xml-parser": "^4.3.2", "fs-extra": "11.1.1", "line-awesome": "1.3.0", "md5-typescript": "1.0.5", "moment": "2.29.4", "music-metadata": "7.13.0", "node-fetch": "2.6.11", + "node-html-parser": "^6.1.11", "node-taglib-sharp": "5.0.1", "sanitize-filename": "1.6.3", "tinycolor2": "1.6.0", @@ -114,4 +118,4 @@ "uuid": "9.0.0" } } -} \ No newline at end of file +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 1534b049a..d161d7305 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -14,6 +14,7 @@ import { BaseScrobblingService } from './services/scrobbling/base-scrobbling.ser import { BaseSearchService } from './services/search/base-search.service'; import { BaseTranslatorService } from './services/translator/base-translator.service'; import { BaseTrayService } from './services/tray/base-tray.service'; +import { IntegrationTestRunner } from './testing/integration-test-runner'; describe('AppComponent', () => { let navigationServiceMock: IMock; @@ -31,6 +32,8 @@ describe('AppComponent', () => { let loggerMock: IMock; let matDrawerMock: IMock; + let integrationTestRunnerMock: IMock; + let showNowPlayingRequestedMock: Subject; let showNowPlayingRequestedMock$: Observable; @@ -47,7 +50,8 @@ describe('AppComponent', () => { mediaSessionServiceMock.object, addToPlaylistMenuMock.object, desktopMock.object, - loggerMock.object + loggerMock.object, + integrationTestRunnerMock.object, ); } @@ -65,6 +69,7 @@ describe('AppComponent', () => { desktopMock = Mock.ofType(); loggerMock = Mock.ofType(); matDrawerMock = Mock.ofType(); + integrationTestRunnerMock = Mock.ofType(); showNowPlayingRequestedMock = new Subject(); showNowPlayingRequestedMock$ = showNowPlayingRequestedMock.asObservable(); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 77d015022..e459b9ad8 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -17,6 +17,8 @@ import { BaseScrobblingService } from './services/scrobbling/base-scrobbling.ser import { BaseSearchService } from './services/search/base-search.service'; import { BaseTranslatorService } from './services/translator/base-translator.service'; import { BaseTrayService } from './services/tray/base-tray.service'; +import { IntegrationTestRunner } from './testing/integration-test-runner'; +import { AppConfig } from '../environments/environment'; @Component({ selector: 'app-root', templateUrl: './app.component.html', @@ -37,7 +39,8 @@ export class AppComponent implements OnInit { private mediaSessionService: BaseMediaSessionService, private addToPlaylistMenu: AddToPlaylistMenu, private desktop: BaseDesktop, - private logger: Logger + private logger: Logger, + private integrationTestRunner: IntegrationTestRunner, ) { log.create('renderer'); log.transports.file.resolvePath = () => path.join(this.desktop.getApplicationDataDirectory(), 'logs', 'Dopamine.log'); @@ -54,10 +57,15 @@ export class AppComponent implements OnInit { } public async ngOnInit(): Promise { + if (!AppConfig.production) { + this.logger.info('Executing integration tests', 'AppComponent', 'ngOnInit'); + await this.integrationTestRunner.executeTestsAsync(); + } + this.logger.info( `+++ Started ${ProductInformation.applicationName} ${ProductInformation.applicationVersion} +++`, 'AppComponent', - 'ngOnInit' + 'ngOnInit', ); this.subscription.add( @@ -65,7 +73,7 @@ export class AppComponent implements OnInit { if (this.playbackQueueDrawer != undefined) { PromiseUtils.noAwait(this.playbackQueueDrawer.toggle()); } - }) + }), ); await this.addToPlaylistMenu.initializeAsync(); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 61528e384..4cf327225 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -154,6 +154,7 @@ import { ManageMusicComponent } from './components/manage-collection/manage-musi import { ManageRefreshComponent } from './components/manage-collection/manage-refresh/manage-refresh.component'; import { NowPlayingArtistInfoComponent } from './components/now-playing/now-playing-artist-info/now-playing-artist-info.component'; import { SimilarArtistComponent } from './components/now-playing/now-playing-artist-info/similar-artist/similar-artist.component'; +import { NowPlayingLyricsComponent } from './components/now-playing/now-playing-lyrics/now-playing-lyrics.component'; import { NowPlayingPlaybackPaneComponent } from './components/now-playing/now-playing-playback-pane/now-playing-playback-pane.component'; import { NowPlayingShowcaseComponent } from './components/now-playing/now-playing-showcase/now-playing-showcase.component'; import { NowPlayingComponent } from './components/now-playing/now-playing.component'; @@ -293,6 +294,16 @@ import { BaseTrayService } from './services/tray/base-tray.service'; import { TrayService } from './services/tray/tray.service'; import { BaseUpdateService } from './services/update/base-update.service'; import { UpdateService } from './services/update/update.service'; +import { BaseLyricsService } from './services/lyrics/base-lyrics.service'; +import { LyricsService } from './services/lyrics/lyrics.service'; +import { EmbeddedLyricsGetter } from './services/lyrics/embedded-lyrics-getter'; +import { LrcLyricsGetter } from './services/lyrics/lrc-lyrics-getter'; +import { OnlineLyricsGetter } from './services/lyrics/online-lyrics-getter'; +import { ChartLyricsApi } from './common/api/lyrics/chart-lyrics-api'; +import { IntegrationTestRunner } from './testing/integration-test-runner'; +import { AZLyricsApi } from './common/api/lyrics/a-z-lyrics-api'; +import { WebSearchLyricsApi } from './common/api/lyrics/web-search-lyrics/web-search-lyrics-api'; +import { WebSearchApi } from './common/api/lyrics/web-search-lyrics/web-search-api'; export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); @@ -323,7 +334,7 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call resolve(undefined); - } + }, ); }); }); @@ -421,6 +432,7 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj CollectionTracksTableHeaderComponent, NowPlayingShowcaseComponent, NowPlayingArtistInfoComponent, + NowPlayingLyricsComponent, SimilarArtistComponent, ], imports: [ @@ -507,6 +519,10 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj DocumentProxy, GitHubApi, FanartApi, + ChartLyricsApi, + AZLyricsApi, + WebSearchLyricsApi, + WebSearchApi, MetadataPatcher, ArtistOrdering, GenreOrdering, @@ -546,6 +562,10 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj LogViewer, ArtistInformationFactory, GuidFactory, + EmbeddedLyricsGetter, + LrcLyricsGetter, + OnlineLyricsGetter, + IntegrationTestRunner, { provide: MAT_TOOLTIP_DEFAULT_OPTIONS, useValue: CustomTooltipDefaults }, { provide: BaseFileAccess, useClass: FileAccess }, { provide: BaseAlbumArtworkRepository, useClass: AlbumArtworkRepository }, @@ -594,6 +614,7 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj { provide: BaseScrobblingService, useClass: ScrobblingService }, { provide: BaseNowPlayingNavigationService, useClass: NowPlayingNavigationService }, { provide: BaseArtistInformationService, useClass: ArtistInformationService }, + { provide: BaseLyricsService, useClass: LyricsService }, { provide: ErrorHandler, useClass: GlobalErrorHandler, diff --git a/src/app/common/api/lyrics/a-z-lyrics-api.ts b/src/app/common/api/lyrics/a-z-lyrics-api.ts new file mode 100644 index 000000000..70cb4dd2b --- /dev/null +++ b/src/app/common/api/lyrics/a-z-lyrics-api.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ILyricsApi } from './i-lyrics-api'; +import { Injectable } from '@angular/core'; +import { Lyrics } from './lyrics'; +import { HttpClient } from '@angular/common/http'; +import * as cheerio from 'cheerio'; +import { CheerioAPI } from 'cheerio'; + +@Injectable() +export class AZLyricsApi implements ILyricsApi { + public constructor(private httpClient: HttpClient) {} + + public get sourceName(): string { + return 'AZLyrics'; + } + + public async getLyricsAsync(artist: string, title: string): Promise { + const url: string = this.buildUrl(artist, title); + const response: string = await this.httpClient.get(url, { responseType: 'text' }).toPromise(); + + const cheerioAPI: CheerioAPI = cheerio.load(response); + + // @ts-ignore + const lyricsDiv = cheerioAPI('.col-xs-12.col-lg-8.text-center')[0].children[14].children; + + let lyrics: string = ''; + + for (let i: number = 2; i < lyricsDiv.length; i++) { + // @ts-ignore + const line: string = lyricsDiv[i].data != undefined ? `${lyricsDiv[i].data.substr(1)}\n` : ``; + + lyrics += line; + } + + lyrics = lyrics.slice(0, -2); + + return new Lyrics(this.sourceName, lyrics); + } + + private buildUrl(artist: string, title: string): string { + return `https://azlyrics.com/lyrics/${this.sanitizeForLink(artist)}/${this.sanitizeForLink(title)}.html`; + } + + private sanitizeForLink(data: string): string { + data = data.replace(/\W/g, ''); + data = data.toLowerCase(); + + return data; + } +} diff --git a/src/app/common/api/lyrics/chart-lyrics-api.ts b/src/app/common/api/lyrics/chart-lyrics-api.ts new file mode 100644 index 000000000..72f2e6938 --- /dev/null +++ b/src/app/common/api/lyrics/chart-lyrics-api.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { ILyricsApi } from './i-lyrics-api'; +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { XMLParser } from 'fast-xml-parser'; +import { Lyrics } from './lyrics'; + +@Injectable() +export class ChartLyricsApi implements ILyricsApi { + public constructor(private httpClient: HttpClient) {} + + public get sourceName(): string { + return 'ChartLyrics'; + } + + public async getLyricsAsync(artist: string, title: string): Promise { + const url: string = `http://api.chartlyrics.com/apiv1.asmx/SearchLyricDirect?artist=${artist}&song=${title}`; + const response: string = await this.httpClient.get(url, { responseType: 'text' }).toPromise(); + const parser: XMLParser = new XMLParser(); + const jsonResponse: any = parser.parse(response); + + return new Lyrics(this.sourceName, jsonResponse.GetLyricResult.Lyric as string); + } +} diff --git a/src/app/common/api/lyrics/i-lyrics-api.ts b/src/app/common/api/lyrics/i-lyrics-api.ts new file mode 100644 index 000000000..694d3763e --- /dev/null +++ b/src/app/common/api/lyrics/i-lyrics-api.ts @@ -0,0 +1,6 @@ +import { Lyrics } from './lyrics'; + +export interface ILyricsApi { + readonly sourceName: string; + getLyricsAsync(artist: string, title: string): Promise; +} diff --git a/src/app/common/api/lyrics/lyrics-source-type.ts b/src/app/common/api/lyrics/lyrics-source-type.ts new file mode 100644 index 000000000..b6bedaccf --- /dev/null +++ b/src/app/common/api/lyrics/lyrics-source-type.ts @@ -0,0 +1,6 @@ +export enum LyricsSourceType { + none = 1, + embedded = 2, + lrc = 3, + online = 4, +} diff --git a/src/app/common/api/lyrics/lyrics.spec.ts b/src/app/common/api/lyrics/lyrics.spec.ts new file mode 100644 index 000000000..b0e40fed8 --- /dev/null +++ b/src/app/common/api/lyrics/lyrics.spec.ts @@ -0,0 +1,29 @@ +import { Lyrics } from './lyrics'; + +describe('Lyrics', () => { + describe('constructor', () => { + it('should create', () => { + // Arrange, Act + const instance: Lyrics = new Lyrics('sourceName', 'text'); + + // Assert + expect(instance).toBeDefined(); + }); + + it('should set sourceName', () => { + // Arrange, Act + const instance: Lyrics = new Lyrics('sourceName', 'text'); + + // Assert + expect(instance.sourceName).toEqual('sourceName'); + }); + + it('should set text', () => { + // Arrange, Act + const instance: Lyrics = new Lyrics('sourceName', 'text'); + + // Assert + expect(instance.text).toEqual('text'); + }); + }); +}); diff --git a/src/app/common/api/lyrics/lyrics.ts b/src/app/common/api/lyrics/lyrics.ts new file mode 100644 index 000000000..7d162fc94 --- /dev/null +++ b/src/app/common/api/lyrics/lyrics.ts @@ -0,0 +1,10 @@ +export class Lyrics { + public constructor( + public sourceName: string, + public text: string, + ) {} + + public static default(): Lyrics { + return new Lyrics('', ''); + } +} diff --git a/src/app/common/api/lyrics/web-search-lyrics/sources/a-z-lyrics-source.ts b/src/app/common/api/lyrics/web-search-lyrics/sources/a-z-lyrics-source.ts new file mode 100644 index 000000000..18fd99bc3 --- /dev/null +++ b/src/app/common/api/lyrics/web-search-lyrics/sources/a-z-lyrics-source.ts @@ -0,0 +1,29 @@ +import { IWebSearchLyricsSource } from './i-web-search-lyrics-source'; +import htmlParser from 'node-html-parser'; +import { HTMLElement } from 'node-html-parser'; +import { Strings } from '../../../../strings'; + +export class AZLyricsSource implements IWebSearchLyricsSource { + public get name(): string { + return 'AZLyrics'; + } + + public parse(htmlString: string): string { + const htmlElement: HTMLElement = htmlParser(htmlString); + + let possibleContent = + htmlElement.querySelector('div.ringtone')?.nextElementSibling.nextElementSibling.nextElementSibling.nextElementSibling; + + if (htmlElement.querySelector('span.feat')) { + possibleContent = possibleContent?.nextElementSibling.nextElementSibling; + } + + const content: string | undefined = possibleContent?.textContent.trim(); + + if (Strings.isNullOrWhiteSpace(content)) { + return ''; + } + + return Strings.replaceAll(content!, '\n\n', '\n'); + } +} diff --git a/src/app/common/api/lyrics/web-search-lyrics/sources/genius-source.ts b/src/app/common/api/lyrics/web-search-lyrics/sources/genius-source.ts new file mode 100644 index 000000000..fb9a09917 --- /dev/null +++ b/src/app/common/api/lyrics/web-search-lyrics/sources/genius-source.ts @@ -0,0 +1,19 @@ +import { IWebSearchLyricsSource } from './i-web-search-lyrics-source'; +import htmlParser, { HTMLElement } from 'node-html-parser'; + +export class GeniusSource implements IWebSearchLyricsSource { + public get name(): string { + return 'Genius'; + } + + public parse(htmlString: string): string { + const htmlElement: HTMLElement = htmlParser(htmlString); + + return htmlElement + .querySelectorAll('div[data-lyrics-container=true]') + .map((x: HTMLElement) => x.structuredText) + .join('') + .replace(/\[.+\]/g, '') + .trim(); + } +} diff --git a/src/app/common/api/lyrics/web-search-lyrics/sources/i-web-search-lyrics-source.ts b/src/app/common/api/lyrics/web-search-lyrics/sources/i-web-search-lyrics-source.ts new file mode 100644 index 000000000..d2b4abbfb --- /dev/null +++ b/src/app/common/api/lyrics/web-search-lyrics/sources/i-web-search-lyrics-source.ts @@ -0,0 +1,4 @@ +export interface IWebSearchLyricsSource { + readonly name: string; + parse(htmlString: string): string; +} diff --git a/src/app/common/api/lyrics/web-search-lyrics/sources/lyrics-source.ts b/src/app/common/api/lyrics/web-search-lyrics/sources/lyrics-source.ts new file mode 100644 index 000000000..7ef03455c --- /dev/null +++ b/src/app/common/api/lyrics/web-search-lyrics/sources/lyrics-source.ts @@ -0,0 +1,14 @@ +import { IWebSearchLyricsSource } from './i-web-search-lyrics-source'; +import htmlParser, { HTMLElement } from 'node-html-parser'; + +export class LyricsSource implements IWebSearchLyricsSource { + public get name(): string { + return 'Lyrics'; + } + + public parse(htmlString: string): string { + const htmlElement: HTMLElement = htmlParser(htmlString); + + return htmlElement.querySelector('pre#lyric-body-text')?.textContent.replace(/(|<\/a>)/g, '') ?? ''; + } +} diff --git a/src/app/common/api/lyrics/web-search-lyrics/sources/musixmatch-source.ts b/src/app/common/api/lyrics/web-search-lyrics/sources/musixmatch-source.ts new file mode 100644 index 000000000..86a9fdc43 --- /dev/null +++ b/src/app/common/api/lyrics/web-search-lyrics/sources/musixmatch-source.ts @@ -0,0 +1,17 @@ +import { IWebSearchLyricsSource } from './i-web-search-lyrics-source'; +import htmlParser, { HTMLElement } from 'node-html-parser'; + +export class MusixmatchSource implements IWebSearchLyricsSource { + public get name(): string { + return 'Musixmatch'; + } + + public parse(htmlString: string): string { + const htmlElement: HTMLElement = htmlParser(htmlString); + + return htmlElement + .querySelectorAll('p.mxm-lyrics__content') + .map((x) => x.textContent) + .join(''); + } +} diff --git a/src/app/common/api/lyrics/web-search-lyrics/web-search-api.ts b/src/app/common/api/lyrics/web-search-lyrics/web-search-api.ts new file mode 100644 index 000000000..87d121213 --- /dev/null +++ b/src/app/common/api/lyrics/web-search-lyrics/web-search-api.ts @@ -0,0 +1,98 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { Injectable } from '@angular/core'; + +import { HttpClient } from '@angular/common/http'; +import { WebSearchResult } from './web-search-result'; +import { Strings } from '../../../strings'; + +@Injectable() +export class WebSearchApi { + private vqdRegex: RegExp = /vqd=([\d-]+)/; + private searchRegex: RegExp = /DDG\.pageLayout\.load\('d',(\[.+\])\);DDG\.duckbar\.load\('images'/; + + public constructor(private httpClient: HttpClient) {} + + public async webSearchAsync(query: string): Promise { + const vqd: string = await this.getVqdAsync(query); + + if (Strings.isNullOrWhiteSpace(vqd)) { + throw new Error(`Failed to get the VQD for query "${query}".`); + } + + const requestUrl: string = `https://links.duckduckgo.com/d.js?${this.getSearchRequestParams(query, vqd).toString()}`; + const responseString: string = await this.performGetRequestAsync(requestUrl); + + if (Strings.isNullOrWhiteSpace(responseString)) { + return []; + } + + const matches: RegExpExecArray | null = this.searchRegex.exec(responseString); + + if (matches == undefined || matches.length < 2) { + return []; + } + + const rawSearchResults: string = matches[1].replace(/\\t/g, ' '); + + if (Strings.isNullOrWhiteSpace(rawSearchResults)) { + return []; + } + + const parsedRawSearchResults: { c: string; i: string }[] = JSON.parse(rawSearchResults) as { + c: string; + i: string; + }[]; + + return parsedRawSearchResults.map((x: { c: string; i: string }) => new WebSearchResult(x.c, x.i)); + } + + private async getVqdAsync(query: string): Promise { + const vqdRequestUrl: URL = new URL(`https://duckduckgo.com/?${this.getVqdRequestParams(query).toString()}`); + + const html: string = await this.performGetRequestAsync(vqdRequestUrl.toString()); + const matches: RegExpExecArray | null = this.vqdRegex.exec(html); + + if (matches == undefined || matches.length < 2) { + return ''; + } + + return matches[1]; + } + + private async performGetRequestAsync(url: string): Promise { + return await this.httpClient.get(url, { responseType: 'text' }).toPromise(); + } + + private getVqdRequestParams(query: string): URLSearchParams { + return new URLSearchParams({ + q: query, + ia: 'web', + }); + } + + private getSearchRequestParams(query: string, vqd: string): URLSearchParams { + return new URLSearchParams({ + q: query, + vqd, + kl: 'wt-wt', + l: 'en-us', + dl: 'en', + ct: 'US', + sp: '1', + df: 'a', + ss_mkt: 'us', + s: '0', + bpa: '1', + biaexp: 'b', + msvrtexp: 'b', + nadse: 'b', + eclsexp: 'b', + tjsexp: 'b', + }); + } +} diff --git a/src/app/common/api/lyrics/web-search-lyrics/web-search-lyrics-api.ts b/src/app/common/api/lyrics/web-search-lyrics/web-search-lyrics-api.ts new file mode 100644 index 000000000..257da590c --- /dev/null +++ b/src/app/common/api/lyrics/web-search-lyrics/web-search-lyrics-api.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/strict-boolean-expressions */ +import { Injectable } from '@angular/core'; +import { Lyrics } from '../lyrics'; +import { ILyricsApi } from '../i-lyrics-api'; +import { WebSearchResult } from './web-search-result'; +import { WebSearchApi } from './web-search-api'; +import { IWebSearchLyricsSource } from './sources/i-web-search-lyrics-source'; +import { AZLyricsSource } from './sources/a-z-lyrics-source'; +import { Strings } from '../../../strings'; +import { HttpClient } from '@angular/common/http'; +import { GeniusSource } from './sources/genius-source'; +import { MusixmatchSource } from './sources/musixmatch-source'; +import { LyricsSource } from './sources/lyrics-source'; +import { Logger } from '../../../logger'; + +@Injectable() +export class WebSearchLyricsApi implements ILyricsApi { + private cleanupRegexp: RegExp = /\s(-.+|\[.+\]|\(.+\))/g; + private sources: Map = new Map(); + + public constructor( + private webSearchApi: WebSearchApi, + private httpClient: HttpClient, + private logger: Logger, + ) { + this.sources.set('azlyrics', new AZLyricsSource()); + this.sources.set('genius', new GeniusSource()); + this.sources.set('musixmatch', new MusixmatchSource()); + this.sources.set('lyrics', new LyricsSource()); + } + + public get sourceName(): string { + return 'WebSearchLyrics'; + } + + public async getLyricsAsync(artist: string, title: string): Promise { + const artistAndTitle: string = `${artist} ${title}`; + const cleanArtistAndTitle: string = artistAndTitle.replace(this.cleanupRegexp, '').trim().toLowerCase(); + const query: string = `${cleanArtistAndTitle} inurl:lyrics`; + const webSearchResults: WebSearchResult[] = await this.webSearchApi.webSearchAsync(query); + + const possibleSites: WebSearchResult[] = + webSearchResults.filter((x: WebSearchResult) => [...this.sources.keys()].includes(x.name)) || []; + + for (const possibleSite of possibleSites) { + if (!Strings.isNullOrWhiteSpace(possibleSite.name)) { + try { + const source: IWebSearchLyricsSource = this.sources.get(possibleSite.name)!; + const htmlString: string = await this.httpClient.get(possibleSite.fullUrl, { responseType: 'text' }).toPromise(); + const lyricsText: string = source.parse(htmlString); + + if (Strings.isNullOrWhiteSpace(lyricsText)) { + continue; + } + + return new Lyrics(source.name, lyricsText); + } catch (e: unknown) { + this.logger.error(e, `Could not get lyrics from ${possibleSite.name}`, 'WebSearchLyricsApi', 'getLyricsAsync'); + } + } + } + + return Lyrics.default(); + } +} diff --git a/src/app/common/api/lyrics/web-search-lyrics/web-search-result.spec.ts b/src/app/common/api/lyrics/web-search-lyrics/web-search-result.spec.ts new file mode 100644 index 000000000..a5cc74c3a --- /dev/null +++ b/src/app/common/api/lyrics/web-search-lyrics/web-search-result.spec.ts @@ -0,0 +1,29 @@ +import { WebSearchResult } from './web-search-result'; + +describe('WebSearchResult', () => { + describe('constructor', () => { + it('should create', () => { + // Arrange, Act + const instance: WebSearchResult = new WebSearchResult('https://www.my-lyrics.com/my/lyrics.html', 'www.my-lyrics.com'); + + // Assert + expect(instance).toBeDefined(); + }); + + it('should set fullUrl', () => { + // Arrange, Act + const instance: WebSearchResult = new WebSearchResult('https://www.my-lyrics.com/my/lyrics.html', 'www.my-lyrics.com'); + + // Assert + expect(instance.fullUrl).toEqual('https://www.my-lyrics.com/my/lyrics.html'); + }); + + it('should set name', () => { + // Arrange, Act + const instance: WebSearchResult = new WebSearchResult('https://www.my-lyrics.com/my/lyrics.html', 'www.my-lyrics.com'); + + // Assert + expect(instance.name).toEqual('my-lyrics'); + }); + }); +}); diff --git a/src/app/common/api/lyrics/web-search-lyrics/web-search-result.ts b/src/app/common/api/lyrics/web-search-lyrics/web-search-result.ts new file mode 100644 index 000000000..b2b8b37e1 --- /dev/null +++ b/src/app/common/api/lyrics/web-search-lyrics/web-search-result.ts @@ -0,0 +1,15 @@ +export class WebSearchResult { + /** + * Constructs an instance of WebSearchResult + * @param fullUrl The full url to the lyrics in format "https://www.azlyrics.com/lyrics/massiveattack/teardrop.html" + * @param domainUrl The domain url in format "www.azlyrics.com" + */ + public constructor( + public fullUrl: string, + private domainUrl: string, + ) {} + + public get name(): string { + return this.domainUrl?.replace(/(www\.)?(.*)\.\w+$/g, '$2').toLowerCase(); + } +} diff --git a/src/app/common/application/constants.ts b/src/app/common/application/constants.ts index 4c94b2985..7f40ef0ab 100644 --- a/src/app/common/application/constants.ts +++ b/src/app/common/application/constants.ts @@ -17,7 +17,7 @@ export class Constants { new Language('nl', 'Dutch', 'Nederlands', true), new Language('pt-BR', 'Brazilian Portuguese', 'Português Brasileiro', true), new Language('ja-JP', 'Japanese', '日本語', false), - new Language('ko', 'Korean', '한국어', false), + new Language('ko', 'Korean', '한국어', false), new Language('ku', 'Kurdish', 'Kurdî', true), new Language('ru', 'Russian', 'русский', false), new Language('vi', 'Vietnamese', 'Tiếng Việt', true), @@ -107,110 +107,128 @@ export class Constants { 'Angular', 'Angular is a development platform for building mobile and desktop web applications using Typescript/JavaScript and other languages.', 'https://angular.io/', - 'https://github.com/angular/angular/blob/master/LICENSE' + 'https://github.com/angular/angular/blob/master/LICENSE', ), new ExternalComponent( 'angular-split', 'Angular UI library to split views and allow dragging to resize areas using CSS flexbox layout.', 'https://angular-split.github.io/', - 'https://github.com/angular-split/angular-split/blob/main/LICENSE' + 'https://github.com/angular-split/angular-split/blob/main/LICENSE', ), new ExternalComponent( 'better-sqlite3', 'The fastest and simplest library for SQLite3 in Node.js.', 'https://github.com/JoshuaWise/better-sqlite3', - 'https://github.com/JoshuaWise/better-sqlite3/blob/master/LICENSE' + 'https://github.com/JoshuaWise/better-sqlite3/blob/master/LICENSE', + ), + new ExternalComponent( + 'cheerio', + 'The fast, flexible, and elegant library for parsing and manipulating HTML and XML.', + 'https://github.com/cheeriojs/cheerio', + 'https://github.com/cheeriojs/cheerio/blob/main/LICENSE', ), new ExternalComponent( 'Discord.js RPC Extension', 'A simple RPC client for Discord.', 'https://github.com/discordjs/RPC', - 'https://github.com/discordjs/RPC/blob/master/LICENSE' + 'https://github.com/discordjs/RPC/blob/master/LICENSE', ), new ExternalComponent( 'electron-log', 'Just a simple logging module for your Electron application.', 'https://github.com/megahertz/electron-log', - 'https://github.com/megahertz/electron-log/blob/master/LICENSE' + 'https://github.com/megahertz/electron-log/blob/master/LICENSE', ), new ExternalComponent( 'electron-store', 'Simple data persistence for your Electron app or module - Save and load user preferences, app state, cache, etc.', 'https://github.com/sindresorhus/electron-store', - 'https://github.com/sindresorhus/electron-store/blob/master/license' + 'https://github.com/sindresorhus/electron-store/blob/master/license', ), new ExternalComponent( 'electron-window-state', 'A library to store and restore window sizes and positions for your Electron app.', 'https://github.com/mawie81/electron-window-state', - 'https://github.com/mawie81/electron-window-state/blob/master/license' + 'https://github.com/mawie81/electron-window-state/blob/master/license', + ), + new ExternalComponent( + 'Fast HTML Parser', + 'A very fast HTML parser, generating a simplified DOM, with basic element query support. ', + 'https://github.com/taoqf/node-html-parser', + 'https://github.com/taoqf/node-html-parser/blob/main/LICENSE', + ), + new ExternalComponent( + 'fast-xml-parser', + 'Validate XML, Parse XML to JS Object, or Build XML from JS Object without C/C++ based libraries and no callback.', + 'https://github.com/NaturalIntelligence/fast-xml-parser', + 'https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/LICENSE', ), new ExternalComponent( 'fs-extra', `fs-extra adds file system methods that aren't included in the native fs module and adds promise support to the fs methods.`, 'https://github.com/jprichardson/node-fs-extra', - 'https://github.com/jprichardson/node-fs-extra/blob/master/LICENSE' + 'https://github.com/jprichardson/node-fs-extra/blob/master/LICENSE', ), new ExternalComponent('Icons designed by Sharlee', 'Gorgeous Dopamine icons designed by Sharlee.', 'https://www.itssharl.ee/', ''), new ExternalComponent( 'Icons8 Line Awesome', 'Replace Font Awesome with modern line icons.', 'https://github.com/icons8/line-awesome', - 'https://github.com/icons8/line-awesome/blob/master/LICENSE.md' + 'https://github.com/icons8/line-awesome/blob/master/LICENSE.md', ), new ExternalComponent( 'Material Design Color Generator', 'A tool for generating a color palette for Material Design. Supports exporting to and importing from various Material Design frameworks and toolkits.', 'https://github.com/mbitson/mcg', - 'https://github.com/mbitson/mcg/blob/master/LICENSE' + 'https://github.com/mbitson/mcg/blob/master/LICENSE', ), new ExternalComponent( 'Md5 typescript', 'Md5 typescript.', 'https://github.com/Hipparch/Md5-typescript', - 'https://github.com/Hipparch/Md5-typescript/blob/master/LICENSE' + 'https://github.com/Hipparch/Md5-typescript/blob/master/LICENSE', ), new ExternalComponent( 'Moment.js', 'Parse, validate, manipulate, and display dates in javascript.', 'https://momentjs.com/', - 'https://github.com/moment/moment/blob/develop/LICENSE' + 'https://github.com/moment/moment/blob/develop/LICENSE', ), new ExternalComponent( 'music-metadata', 'Stream and file based music metadata parser for node. Supporting a wide range of audio and tag formats.', 'https://github.com/borewit/music-metadata', - 'https://github.com/Borewit/music-metadata/blob/master/README.md' + 'https://github.com/Borewit/music-metadata/blob/master/README.md', ), new ExternalComponent( 'Node Fetch', 'A light-weight module that brings Fetch API to Node.js.', 'https://github.com/node-fetch/node-fetch', - 'https://github.com/node-fetch/node-fetch/blob/master/LICENSE.md' + 'https://github.com/node-fetch/node-fetch/blob/master/LICENSE.md', ), new ExternalComponent( 'sanitize-filename', 'Sanitize a string to be safe for use as a filename by removing directory paths and invalid characters.', 'https://github.com/parshap/node-sanitize-filename', - 'https://github.com/parshap/node-sanitize-filename/blob/master/LICENSE.md' + 'https://github.com/parshap/node-sanitize-filename/blob/master/LICENSE.md', ), new ExternalComponent( 'TagLib# for Node', 'A node.js port of mono/taglib-sharp.', 'https://github.com/benrr101/node-taglib-sharp', - 'https://github.com/benrr101/node-taglib-sharp/blob/develop/LICENSE' + 'https://github.com/benrr101/node-taglib-sharp/blob/develop/LICENSE', ), new ExternalComponent( 'TinyColor', 'TinyColor is a small, fast library for color manipulation and conversion in JavaScript. It allows many forms of input, while providing color conversions and other color utility functions. It has no dependencies.', 'https://github.com/bgrins/TinyColor', - 'https://github.com/bgrins/TinyColor/blob/master/LICENSE' + 'https://github.com/bgrins/TinyColor/blob/master/LICENSE', ), new ExternalComponent( 'uuid', 'Generate RFC-compliant UUIDs in JavaScript.', 'https://github.com/uuidjs/uuid', - 'https://github.com/uuidjs/uuid/blob/master/LICENSE.md' + 'https://github.com/uuidjs/uuid/blob/master/LICENSE.md', ), ]; } diff --git a/src/app/common/io/file-access.ts b/src/app/common/io/file-access.ts index efeda8754..c2103c5ce 100644 --- a/src/app/common/io/file-access.ts +++ b/src/app/common/io/file-access.ts @@ -15,7 +15,10 @@ export class FileAccess implements BaseFileAccess { private _musicDirectory: string = ''; private _pathSeparator: string = ''; - public constructor(private desktop: BaseDesktop, private dateTime: DateTime) { + public constructor( + private desktop: BaseDesktop, + private dateTime: DateTime, + ) { this._applicationDataDirectory = this.desktop.getApplicationDataDirectory(); this._musicDirectory = this.desktop.getMusicDirectory(); this._pathSeparator = path.sep; @@ -30,9 +33,7 @@ export class FileAccess implements BaseFileAccess { return pathPieces[0]; } - const combinedPath: string = pathPieces.join(this._pathSeparator); - - return combinedPath; + return pathPieces.join(this._pathSeparator); } public applicationDataDirectory(): string { diff --git a/src/app/common/settings/base-settings.ts b/src/app/common/settings/base-settings.ts index b504d746c..dcd85bb8a 100644 --- a/src/app/common/settings/base-settings.ts +++ b/src/app/common/settings/base-settings.ts @@ -67,4 +67,5 @@ export abstract class BaseSettings { public abstract enableMultimediaKeys: boolean; public abstract downloadArtistInformationFromLastFm: boolean; public abstract isMuted: boolean; + public abstract downloadLyricsOnline: boolean; } diff --git a/src/app/common/settings/settings.ts b/src/app/common/settings/settings.ts index 8304b8d50..e7aba98ae 100644 --- a/src/app/common/settings/settings.ts +++ b/src/app/common/settings/settings.ts @@ -619,6 +619,15 @@ export class Settings implements BaseSettings { this.settings.set('isMuted', v); } + // downloadLyricsOnline + public get downloadLyricsOnline(): boolean { + return this.settings.get('downloadLyricsOnline'); + } + + public set downloadLyricsOnline(v: boolean) { + this.settings.set('downloadLyricsOnline', v); + } + // Initialize private initialize(): void { if (!this.settings.has('language')) { @@ -884,5 +893,9 @@ export class Settings implements BaseSettings { if (!this.settings.has('isMuted')) { this.settings.set('isMuted', false); } + + if (!this.settings.has('downloadLyricsOnline')) { + this.settings.set('downloadLyricsOnline', true); + } } } diff --git a/src/app/common/utils/promise-utils.ts b/src/app/common/utils/promise-utils.ts index 21a203638..e8b5d642e 100644 --- a/src/app/common/utils/promise-utils.ts +++ b/src/app/common/utils/promise-utils.ts @@ -1,6 +1,6 @@ export class PromiseUtils { - public static noAwait(promises: Promise): void { - promises.catch(() => { + public static noAwait(promise: Promise): void { + promise.catch(() => { // Do nothing }); } diff --git a/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.html b/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.html index f3838d16f..01b46b641 100644 --- a/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.html +++ b/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.html @@ -16,6 +16,8 @@ [largeFontSize]="this.appearanceService.selectedFontSize.normalSize" [smallFontSize]="this.appearanceService.selectedFontSize.normalSize" [isCentered]="true" + [showRating]="this.settings.showRating" + [showLove]="this.settings.showLove" >
diff --git a/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.spec.ts b/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.spec.ts index 95f4af11c..ce3292c31 100644 --- a/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.spec.ts +++ b/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.spec.ts @@ -2,16 +2,19 @@ import { IMock, Mock, Times } from 'typemoq'; import { BaseAppearanceService } from '../../../services/appearance/base-appearance.service'; import { BaseNavigationService } from '../../../services/navigation/base-navigation.service'; import { CollectionPlaybackPaneComponent } from './collection-playback-pane.component'; +import { BaseSettings } from '../../../common/settings/base-settings'; describe('CollectionPlaybackPaneComponent', () => { let appearanceServiceMock: IMock; + let settingsMock: IMock; let navigationServiceMock: IMock; let component: CollectionPlaybackPaneComponent; beforeEach(() => { appearanceServiceMock = Mock.ofType(); + settingsMock = Mock.ofType(); navigationServiceMock = Mock.ofType(); - component = new CollectionPlaybackPaneComponent(appearanceServiceMock.object, navigationServiceMock.object); + component = new CollectionPlaybackPaneComponent(appearanceServiceMock.object, settingsMock.object, navigationServiceMock.object); }); describe('constructor', () => { @@ -32,6 +35,15 @@ describe('CollectionPlaybackPaneComponent', () => { // Assert expect(component.appearanceService).toBeDefined(); }); + + it('should define settings', () => { + // Arrange + + // Act + + // Assert + expect(component.settings).toBeDefined(); + }); }); describe('showPlaybackQueue', () => { diff --git a/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.ts b/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.ts index 4a48f86a7..f50ea4973 100644 --- a/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.ts +++ b/src/app/components/collection/collection-playback-pane/collection-playback-pane.component.ts @@ -1,4 +1,5 @@ import { Component, ViewEncapsulation } from '@angular/core'; +import { BaseSettings } from '../../../common/settings/base-settings'; import { BaseAppearanceService } from '../../../services/appearance/base-appearance.service'; import { BaseNavigationService } from '../../../services/navigation/base-navigation.service'; @@ -10,7 +11,11 @@ import { BaseNavigationService } from '../../../services/navigation/base-navigat encapsulation: ViewEncapsulation.None, }) export class CollectionPlaybackPaneComponent { - public constructor(public appearanceService: BaseAppearanceService, private navigationService: BaseNavigationService) {} + public constructor( + public appearanceService: BaseAppearanceService, + public settings: BaseSettings, + private navigationService: BaseNavigationService + ) {} public showPlaybackQueue(): void { this.navigationService.showPlaybackQueue(); diff --git a/src/app/components/collection/collection-playlists/playlist-browser/playlist-browser.component.html b/src/app/components/collection/collection-playlists/playlist-browser/playlist-browser.component.html index 7b3797093..e1511c4e2 100644 --- a/src/app/components/collection/collection-playlists/playlist-browser/playlist-browser.component.html +++ b/src/app/components/collection/collection-playlists/playlist-browser/playlist-browser.component.html @@ -29,7 +29,7 @@
-
+
{{ 'playlist-folder-is-empty' | translate }}
diff --git a/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.html b/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.html index 6e424d746..05baeacc8 100644 --- a/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.html +++ b/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.html @@ -13,12 +13,12 @@ [width]="300" [height]="300" draggable="false" - (load)="imageIsLoaded()" + (load)="imageIsLoadedAsync()" />
+
{{ this.artist.name }}
-

{{ this.artist.name }}

diff --git a/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.scss b/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.scss index be4056b5c..de021126a 100644 --- a/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.scss +++ b/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.scss @@ -20,7 +20,7 @@ height: 300px; margin-right: 50px; background: var(--theme-album-cover-background); - -webkit-box-reflect: below 4px -webkit-linear-gradient(transparent, transparent 80%, rgba(255, 255, 255, 0.3)); + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.2); } .app-now-playing-artist-info__text { @@ -32,4 +32,9 @@ .app-now-playing-artist-info__icon { color: var(--theme-album-cover-logo); font-size: 200px; + display: flex; + width: 300px; + height: 300px; + justify-content: center; + align-items: center; } diff --git a/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.spec.ts b/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.spec.ts index 8b63f002c..2e05f1ce4 100644 --- a/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.spec.ts +++ b/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.spec.ts @@ -8,23 +8,31 @@ import { PlaybackStarted } from '../../../services/playback/playback-started'; import { TrackModel } from '../../../services/track/track-model'; import { MockCreator } from '../../../testing/mock-creator'; import { NowPlayingArtistInfoComponent } from './now-playing-artist-info.component'; +import { BaseScheduler } from '../../../common/scheduling/base-scheduler'; describe('NowPlayingArtistInfoComponent', () => { let playbackServiceMock: IMock; let artistInformationServiceMock: IMock; let settingsMock: IMock; + let schedulerMock: IMock; let playbackServicePlaybackStartedMock: Subject; const flushPromises = () => new Promise(process.nextTick); function createComponent(): NowPlayingArtistInfoComponent { - return new NowPlayingArtistInfoComponent(playbackServiceMock.object, artistInformationServiceMock.object, settingsMock.object); + return new NowPlayingArtistInfoComponent( + playbackServiceMock.object, + artistInformationServiceMock.object, + schedulerMock.object, + settingsMock.object, + ); } beforeEach(() => { playbackServiceMock = Mock.ofType(); artistInformationServiceMock = Mock.ofType(); + schedulerMock = Mock.ofType(); settingsMock = Mock.ofType(); playbackServicePlaybackStartedMock = new Subject(); @@ -63,12 +71,12 @@ describe('NowPlayingArtistInfoComponent', () => { }); describe('imageIsLoaded', () => { - it('should set contentAnimation to fade-in', () => { + it('should set contentAnimation to fade-in', async () => { // Arrange const component: NowPlayingArtistInfoComponent = createComponent(); // Act - component.imageIsLoaded(); + await component.imageIsLoadedAsync(); // Assert expect(component.contentAnimation).toEqual('fade-in'); @@ -93,7 +101,7 @@ describe('NowPlayingArtistInfoComponent', () => { // Arrange const component: NowPlayingArtistInfoComponent = createComponent(); - const trackModel: TrackModel = MockCreator.createTrackModel('path1', ';artist1;'); + const trackModel: TrackModel = MockCreator.createTrackModel('path1', 'title', ';artist1;'); const artistInformation: ArtistInformation = MockCreator.createArtistInformation('artist1', '', '', ''); artistInformationServiceMock @@ -114,13 +122,13 @@ describe('NowPlayingArtistInfoComponent', () => { // Arrange const component: NowPlayingArtistInfoComponent = createComponent(); - const trackModel1: TrackModel = MockCreator.createTrackModel('path1', ';artist1;'); + const trackModel1: TrackModel = MockCreator.createTrackModel('path1', 'title', ';artist1;'); const artistInformation1: ArtistInformation = MockCreator.createArtistInformation('artist1', '', '', ''); artistInformationServiceMock .setup((x) => x.getArtistInformationAsync(trackModel1)) .returns(() => Promise.resolve(artistInformation1)); - const trackModel2: TrackModel = MockCreator.createTrackModel('path2', ';artist2;'); + const trackModel2: TrackModel = MockCreator.createTrackModel('path2', 'title', ';artist2;'); const artistInformation2: ArtistInformation = MockCreator.createArtistInformation('artist2', '', '', ''); artistInformationServiceMock .setup((x) => x.getArtistInformationAsync(trackModel2)) @@ -133,14 +141,10 @@ describe('NowPlayingArtistInfoComponent', () => { // Act await component.ngOnInit(); await flushPromises(); - - const artistIsEmptyBeforePlaybackStarted: boolean = component.artist.isEmpty; const artistNameBeforePlaybackStarted: string = component.artist.name; playbackServicePlaybackStartedMock.next(playbackStarted); await flushPromises(); - - const artistIsEmptyAfterPlaybackStarted: boolean = component.artist.isEmpty; const artistNameAfterPlaybackStarted: string = component.artist.name; // Assert @@ -152,13 +156,13 @@ describe('NowPlayingArtistInfoComponent', () => { // Arrange const component: NowPlayingArtistInfoComponent = createComponent(); - const trackModel1: TrackModel = MockCreator.createTrackModel('path1', ';artist1;'); + const trackModel1: TrackModel = MockCreator.createTrackModel('path1', 'title', ';artist1;'); const artistInformation1: ArtistInformation = MockCreator.createArtistInformation('artist1', '', '', ''); artistInformationServiceMock .setup((x) => x.getArtistInformationAsync(trackModel1)) .returns(() => Promise.resolve(artistInformation1)); - const trackModel2: TrackModel = MockCreator.createTrackModel('path2', ';artist1;'); + const trackModel2: TrackModel = MockCreator.createTrackModel('path2', 'title', ';artist1;'); const artistInformation2: ArtistInformation = MockCreator.createArtistInformation('artist1', '', '', ''); artistInformationServiceMock .setup((x) => x.getArtistInformationAsync(trackModel2)) @@ -171,14 +175,10 @@ describe('NowPlayingArtistInfoComponent', () => { // Act await component.ngOnInit(); await flushPromises(); - - const artistIsEmptyBeforePlaybackStarted: boolean = component.artist.isEmpty; const artistNameBeforePlaybackStarted: string = component.artist.name; playbackServicePlaybackStartedMock.next(playbackStarted); await flushPromises(); - - const artistIsEmptyAfterPlaybackStarted: boolean = component.artist.isEmpty; const artistNameAfterPlaybackStarted: string = component.artist.name; // Assert diff --git a/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.ts b/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.ts index 3b9bb965d..c18b4b51e 100644 --- a/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.ts +++ b/src/app/components/now-playing/now-playing-artist-info/now-playing-artist-info.component.ts @@ -1,6 +1,7 @@ import { animate, state, style, transition, trigger } from '@angular/animations'; import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { Subscription } from 'rxjs'; +import { Scheduler } from '../../../common/scheduling/scheduler'; import { BaseSettings } from '../../../common/settings/base-settings'; import { Strings } from '../../../common/strings'; import { PromiseUtils } from '../../../common/utils/promise-utils'; @@ -34,6 +35,7 @@ export class NowPlayingArtistInfoComponent implements OnInit, OnDestroy { public constructor( private playbackService: BasePlaybackService, private artistInformationService: BaseArtistInformationService, + private scheduler: Scheduler, public settings: BaseSettings ) {} @@ -80,15 +82,19 @@ export class NowPlayingArtistInfoComponent implements OnInit, OnDestroy { this.previousArtistName = track.rawFirstArtist; this._contentAnimation = 'fade-out'; + await this.scheduler.sleepAsync(150); + this._artist = await this.artistInformationService.getArtistInformationAsync(track); if (Strings.isNullOrWhiteSpace(this.artist.imageUrl)) { // Makes sure that the content is shown when there is no image this._contentAnimation = 'fade-in'; + await this.scheduler.sleepAsync(250); } } - public imageIsLoaded(): void { + public async imageIsLoadedAsync(): Promise { this._contentAnimation = 'fade-in'; + await this.scheduler.sleepAsync(250); } } diff --git a/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.html b/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.html new file mode 100644 index 000000000..e7b1da41c --- /dev/null +++ b/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.html @@ -0,0 +1,27 @@ +
+
+ +
+
+ {{ 'no-lyrics' | translate }} +
+
+ {{ 'embedded-lyrics' | translate }} +
+
+ {{ 'lrc-lyrics' | translate }} +
+
+ {{ 'online-lyrics' | translate }} +
+
+ +
+
+
{{ this.lyrics?.text }}
+
+
diff --git a/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.scss b/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.scss new file mode 100644 index 000000000..ef95729cb --- /dev/null +++ b/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.scss @@ -0,0 +1,27 @@ +.app-now-playing-lyrics { + padding: 120px; + display: flex; + flex-direction: row; + height: 100%; +} + +.app-now-playing-lyrics__picture { + margin-right: 50px; + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.2); +} + +.app-now-playing-lyrics__text { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.app-now-playing-lyrics__icon { + color: var(--theme-album-cover-logo); + font-size: 200px; + display: flex; + width: 300px; + height: 300px; + justify-content: center; + align-items: center; +} diff --git a/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.spec.ts b/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.spec.ts new file mode 100644 index 000000000..b998c5a71 --- /dev/null +++ b/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.spec.ts @@ -0,0 +1,222 @@ +import { IMock, Mock } from 'typemoq'; +import { BaseAppearanceService } from '../../../services/appearance/base-appearance.service'; +import { NowPlayingLyricsComponent } from './now-playing-lyrics.component'; +import { BasePlaybackService } from '../../../services/playback/base-playback.service'; +import { BaseScheduler } from '../../../common/scheduling/base-scheduler'; +import { Observable, Subject } from 'rxjs'; +import { PlaybackStarted } from '../../../services/playback/playback-started'; +import { TrackModel } from '../../../services/track/track-model'; +import { MockCreator } from '../../../testing/mock-creator'; +import { BaseLyricsService } from '../../../services/lyrics/base-lyrics.service'; +import { LyricsModel } from '../../../services/lyrics/lyrics-model'; +import { LyricsSourceType } from '../../../common/api/lyrics/lyrics-source-type'; +import { BasePlaybackInformationService } from '../../../services/playback-information/base-playback-information.service'; +import { PlaybackInformation } from '../../../services/playback-information/playback-information'; + +describe('NowPlayingLyricsComponent', () => { + let appearanceServiceMock: IMock; + let playbackInformationServiceMock: IMock; + let lyricsServiceMock: IMock; + let schedulerMock: IMock; + + let playbackInformationService_playingNextTrack_Mock: Subject; + let playbackInformationService_playingPreviousTrack_Mock: Subject; + let playbackInformationService_playingNoTrack_Mock: Subject; + + const flushPromises = () => new Promise(process.nextTick); + + beforeEach(() => { + appearanceServiceMock = Mock.ofType(); + playbackInformationServiceMock = Mock.ofType(); + lyricsServiceMock = Mock.ofType(); + schedulerMock = Mock.ofType(); + + playbackInformationService_playingNextTrack_Mock = new Subject(); + playbackInformationService_playingPreviousTrack_Mock = new Subject(); + playbackInformationService_playingNoTrack_Mock = new Subject(); + + const playbackInformationService_playingNextTrack_Mock$: Observable = + playbackInformationService_playingNextTrack_Mock.asObservable(); + const playbackInformationService_playingPreviousTrack_Mock$: Observable = + playbackInformationService_playingPreviousTrack_Mock.asObservable(); + const playbackInformationService_playingNoTrack_Mock$: Observable = + playbackInformationService_playingNoTrack_Mock.asObservable(); + + playbackInformationServiceMock.setup((x) => x.playingNextTrack$).returns(() => playbackInformationService_playingNextTrack_Mock$); + playbackInformationServiceMock + .setup((x) => x.playingPreviousTrack$) + .returns(() => playbackInformationService_playingPreviousTrack_Mock$); + playbackInformationServiceMock.setup((x) => x.playingNoTrack$).returns(() => playbackInformationService_playingNoTrack_Mock$); + }); + + function createComponent(): NowPlayingLyricsComponent { + return new NowPlayingLyricsComponent( + appearanceServiceMock.object, + playbackInformationServiceMock.object, + lyricsServiceMock.object, + schedulerMock.object, + ); + } + + describe('constructor', () => { + it('should create', () => { + // Arrange, Act + const component: NowPlayingLyricsComponent = createComponent(); + + // Assert + expect(component).toBeDefined(); + }); + + it('should define appearanceService', () => { + // Arrange + + // Act + const component: NowPlayingLyricsComponent = createComponent(); + + // Assert + expect(component.appearanceService).toBeDefined(); + }); + + it('should define lyricsSourceTypeEnum', () => { + // Arrange + + // Act + const component: NowPlayingLyricsComponent = createComponent(); + + // Assert + expect(component.lyricsSourceTypeEnum).toBeDefined(); + }); + + it('should set contentAnimation to fade-in', () => { + // Arrange + const component: NowPlayingLyricsComponent = createComponent(); + + // Act, Assert + expect(component.contentAnimation).toEqual('fade-in'); + }); + }); + + describe('ngOnInit', () => { + it('should set undefined lyrics if PlaybackService has no current track', async () => { + // Arrange + + const playbackInformation: PlaybackInformation = new PlaybackInformation(undefined, ''); + playbackInformationServiceMock + .setup((x) => x.getCurrentPlaybackInformationAsync()) + .returns(() => Promise.resolve(playbackInformation)); + const component: NowPlayingLyricsComponent = createComponent(); + + // Act + await component.ngOnInit(); + + // Assert + expect(component.lyrics).toBeUndefined(); + }); + + it('should set defined lyrics if PlaybackService has a current track', async () => { + // Arrange + const trackModel: TrackModel = MockCreator.createTrackModel('path1', 'title', ';artist1;'); + const playbackInformation: PlaybackInformation = new PlaybackInformation(trackModel, 'imageUrl'); + playbackInformationServiceMock + .setup((x) => x.getCurrentPlaybackInformationAsync()) + .returns(() => Promise.resolve(playbackInformation)); + const lyricsModelMock: LyricsModel = new LyricsModel('online source', LyricsSourceType.online, 'online text'); + lyricsServiceMock.setup((x) => x.getLyricsAsync(trackModel)).returns(() => Promise.resolve(lyricsModelMock)); + const component: NowPlayingLyricsComponent = createComponent(); + + // Act + await component.ngOnInit(); + + // Assert + expect(component.lyrics!.sourceName).toEqual('online source'); + expect(component.lyrics!.sourceType).toEqual(LyricsSourceType.online); + expect(component.lyrics!.text).toEqual('online text'); + }); + }); + + describe('lyrics', () => { + it('should return undefined if no playback is started', () => { + // Arrange + const component: NowPlayingLyricsComponent = createComponent(); + + // Act, Assert + expect(component.lyrics).toBeUndefined(); + }); + + it('should return lyrics if playing next track', async () => { + // Arrange + const trackModel: TrackModel = MockCreator.createTrackModel('path1', 'title', ';artist1;'); + const lyricsModelMock: LyricsModel = new LyricsModel('online source', LyricsSourceType.online, 'online text'); + lyricsServiceMock.setup((x) => x.getLyricsAsync(trackModel)).returns(() => Promise.resolve(lyricsModelMock)); + + const emptyCurrentPlaybackInformation: PlaybackInformation = new PlaybackInformation(undefined, ''); + playbackInformationServiceMock + .setup((x) => x.getCurrentPlaybackInformationAsync()) + .returns(() => Promise.resolve(emptyCurrentPlaybackInformation)); + + const component: NowPlayingLyricsComponent = createComponent(); + await component.ngOnInit(); + + const playbackInformation: PlaybackInformation = new PlaybackInformation(trackModel, 'imageUrl'); + playbackInformationService_playingNextTrack_Mock.next(playbackInformation); + await flushPromises(); + + // Act + const lyrics: LyricsModel | undefined = component.lyrics; + + // Assert + expect(lyrics!.sourceName).toEqual('online source'); + expect(lyrics!.sourceType).toEqual(LyricsSourceType.online); + expect(lyrics!.text).toEqual('online text'); + }); + + it('should return lyrics if playing previous track', async () => { + // Arrange + const trackModel: TrackModel = MockCreator.createTrackModel('path1', 'title', ';artist1;'); + const lyricsModelMock: LyricsModel = new LyricsModel('online source', LyricsSourceType.online, 'online text'); + lyricsServiceMock.setup((x) => x.getLyricsAsync(trackModel)).returns(() => Promise.resolve(lyricsModelMock)); + + const emptyCurrentPlaybackInformation: PlaybackInformation = new PlaybackInformation(undefined, ''); + playbackInformationServiceMock + .setup((x) => x.getCurrentPlaybackInformationAsync()) + .returns(() => Promise.resolve(emptyCurrentPlaybackInformation)); + + const component: NowPlayingLyricsComponent = createComponent(); + await component.ngOnInit(); + + const playbackInformation: PlaybackInformation = new PlaybackInformation(trackModel, 'imageUrl'); + playbackInformationService_playingPreviousTrack_Mock.next(playbackInformation); + await flushPromises(); + + // Act + const lyrics: LyricsModel | undefined = component.lyrics; + + // Assert + expect(lyrics!.sourceName).toEqual('online source'); + expect(lyrics!.sourceType).toEqual(LyricsSourceType.online); + expect(lyrics!.text).toEqual('online text'); + }); + + it('should return undefined lyrics if playing no track', async () => { + // Arrange + const trackModel: TrackModel = MockCreator.createTrackModel('path1', 'title', ';artist1;'); + const emptyCurrentPlaybackInformation: PlaybackInformation = new PlaybackInformation(undefined, ''); + playbackInformationServiceMock + .setup((x) => x.getCurrentPlaybackInformationAsync()) + .returns(() => Promise.resolve(emptyCurrentPlaybackInformation)); + + const component: NowPlayingLyricsComponent = createComponent(); + await component.ngOnInit(); + + const playbackInformation: PlaybackInformation = new PlaybackInformation(undefined, ''); + playbackInformationService_playingNoTrack_Mock.next(playbackInformation); + await flushPromises(); + + // Act + const lyrics: LyricsModel | undefined = component.lyrics; + + // Assert + expect(lyrics).toBeUndefined(); + }); + }); +}); diff --git a/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.ts b/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.ts new file mode 100644 index 000000000..f2e76fab3 --- /dev/null +++ b/src/app/components/now-playing/now-playing-lyrics/now-playing-lyrics.component.ts @@ -0,0 +1,110 @@ +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { Scheduler } from '../../../common/scheduling/scheduler'; +import { PromiseUtils } from '../../../common/utils/promise-utils'; +import { BaseAppearanceService } from '../../../services/appearance/base-appearance.service'; +import { BasePlaybackService } from '../../../services/playback/base-playback.service'; +import { PlaybackStarted } from '../../../services/playback/playback-started'; +import { TrackModel } from '../../../services/track/track-model'; +import { BaseLyricsService } from '../../../services/lyrics/base-lyrics.service'; +import { LyricsModel } from '../../../services/lyrics/lyrics-model'; +import { AlbumOrder } from '../../collection/album-order'; +import { LyricsSourceType } from '../../../common/api/lyrics/lyrics-source-type'; +import { PlaybackInformation } from '../../../services/playback-information/playback-information'; +import { BasePlaybackInformationService } from '../../../services/playback-information/base-playback-information.service'; + +@Component({ + selector: 'app-now-playing-lyrics', + host: { style: 'display: block' }, + templateUrl: './now-playing-lyrics.component.html', + styleUrls: ['./now-playing-lyrics.component.scss'], + encapsulation: ViewEncapsulation.None, + animations: [ + trigger('contentAnimation', [ + state('fade-out', style({ opacity: 0 })), + state('fade-in', style({ opacity: 1 })), + transition('fade-in => fade-out', animate('150ms ease-out')), + transition('fade-out => fade-in', animate('500ms ease-out')), + ]), + ], +}) +export class NowPlayingLyricsComponent implements OnInit, OnDestroy { + private subscription: Subscription = new Subscription(); + private _lyrics: LyricsModel | undefined; + private previousTrackPath: string = ''; + private _contentAnimation: string = 'fade-in'; + + public constructor( + public appearanceService: BaseAppearanceService, + private playbackInformationService: BasePlaybackInformationService, + private lyricsService: BaseLyricsService, + private scheduler: Scheduler, + ) {} + + public lyricsSourceTypeEnum: typeof LyricsSourceType = LyricsSourceType; + + public get contentAnimation(): string { + return this._contentAnimation; + } + + public get lyrics(): LyricsModel | undefined { + return this._lyrics; + } + + public ngOnDestroy(): void { + this.destroySubscriptions(); + } + public async ngOnInit(): Promise { + this.initializeSubscriptions(); + const currentPlaybackInformation: PlaybackInformation = await this.playbackInformationService.getCurrentPlaybackInformationAsync(); + await this.showTrackInfoAsync(currentPlaybackInformation.track); + } + + private initializeSubscriptions(): void { + this.subscription.add( + this.playbackInformationService.playingNextTrack$.subscribe((playbackInformation: PlaybackInformation) => { + PromiseUtils.noAwait(this.showTrackInfoAsync(playbackInformation.track)); + }), + ); + + this.subscription.add( + this.playbackInformationService.playingPreviousTrack$.subscribe((playbackInformation: PlaybackInformation) => { + PromiseUtils.noAwait(this.showTrackInfoAsync(playbackInformation.track)); + }), + ); + + this.subscription.add( + this.playbackInformationService.playingNoTrack$.subscribe((playbackInformation: PlaybackInformation) => { + PromiseUtils.noAwait(this.showTrackInfoAsync(playbackInformation.track)); + }), + ); + } + + private destroySubscriptions(): void { + this.subscription.unsubscribe(); + } + + private async showTrackInfoAsync(track: TrackModel | undefined): Promise { + if (track == undefined) { + this._contentAnimation = 'fade-out'; + await this.scheduler.sleepAsync(150); + this._lyrics = undefined; + return; + } + + if (this.previousTrackPath === track.path) { + return; + } + + this._contentAnimation = 'fade-out'; + await this.scheduler.sleepAsync(150); + + this._lyrics = await this.lyricsService.getLyricsAsync(track); + + this.previousTrackPath = track.path; + + this._contentAnimation = 'fade-in'; + await this.scheduler.sleepAsync(250); + } +} diff --git a/src/app/components/now-playing/now-playing-playback-pane/now-playing-playback-pane.component.html b/src/app/components/now-playing/now-playing-playback-pane/now-playing-playback-pane.component.html index 8c5825015..2d18979f0 100644 --- a/src/app/components/now-playing/now-playing-playback-pane/now-playing-playback-pane.component.html +++ b/src/app/components/now-playing/now-playing-playback-pane/now-playing-playback-pane.component.html @@ -13,6 +13,12 @@ (click)="this.navigateToShowcase()" matTooltip="{{ 'showcase' | translate }}" > + { let navigationServiceMock: IMock; let nowPlayingNavigationServiceMock: IMock; - let component: NowPlayingPlaybackPaneComponent; beforeEach(() => { navigationServiceMock = Mock.ofType(); nowPlayingNavigationServiceMock = Mock.ofType(); - component = new NowPlayingPlaybackPaneComponent(navigationServiceMock.object, nowPlayingNavigationServiceMock.object); }); + function createComponent(): NowPlayingPlaybackPaneComponent { + return new NowPlayingPlaybackPaneComponent(navigationServiceMock.object, nowPlayingNavigationServiceMock.object); + } + describe('constructor', () => { it('should create', () => { - // Arrange - - // Act + // Arrange, Act + const component: NowPlayingPlaybackPaneComponent = createComponent(); // Assert expect(component).toBeDefined(); }); }); + describe('currentNowPlayingPage', () => { + it('should return the current now playing page', () => { + // Arrange + const component: NowPlayingPlaybackPaneComponent = createComponent(); + nowPlayingNavigationServiceMock.setup((x) => x.currentNowPlayingPage).returns(() => NowPlayingPage.lyrics); + + // Act, Assert + expect(component.currentNowPlayingPage).toEqual(NowPlayingPage.lyrics); + }); + }); + describe('showPlaybackQueue', () => { it('should request to show the playback queue', () => { // Arrange + const component: NowPlayingPlaybackPaneComponent = createComponent(); // Act component.showPlaybackQueue(); @@ -41,6 +54,7 @@ describe('NowPlayingPlaybackPaneComponent', () => { describe('navigateToShowcase', () => { it('should request to navigate to showcase', () => { // Arrange + const component: NowPlayingPlaybackPaneComponent = createComponent(); // Act component.navigateToShowcase(); @@ -48,9 +62,12 @@ describe('NowPlayingPlaybackPaneComponent', () => { // Assert nowPlayingNavigationServiceMock.verify((x) => x.navigate(NowPlayingPage.showcase), Times.once()); }); + }); + describe('navigateToArtistInformation', () => { it('should request to navigate to artist information', () => { // Arrange + const component: NowPlayingPlaybackPaneComponent = createComponent(); // Act component.navigateToArtistInformation(); @@ -59,4 +76,17 @@ describe('NowPlayingPlaybackPaneComponent', () => { nowPlayingNavigationServiceMock.verify((x) => x.navigate(NowPlayingPage.artistInformation), Times.once()); }); }); + + describe('navigateToLyrics', () => { + it('should request to navigate to lyrics', () => { + // Arrange + const component: NowPlayingPlaybackPaneComponent = createComponent(); + + // Act + component.navigateToLyrics(); + + // Assert + nowPlayingNavigationServiceMock.verify((x) => x.navigate(NowPlayingPage.lyrics), Times.once()); + }); + }); }); diff --git a/src/app/components/now-playing/now-playing-playback-pane/now-playing-playback-pane.component.ts b/src/app/components/now-playing/now-playing-playback-pane/now-playing-playback-pane.component.ts index 775545511..a0ccf623e 100644 --- a/src/app/components/now-playing/now-playing-playback-pane/now-playing-playback-pane.component.ts +++ b/src/app/components/now-playing/now-playing-playback-pane/now-playing-playback-pane.component.ts @@ -30,6 +30,10 @@ export class NowPlayingPlaybackPaneComponent { this.nowPlayingNavigationService.navigate(NowPlayingPage.showcase); } + public navigateToLyrics(): void { + this.nowPlayingNavigationService.navigate(NowPlayingPage.lyrics); + } + public navigateToArtistInformation(): void { this.nowPlayingNavigationService.navigate(NowPlayingPage.artistInformation); } diff --git a/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.html b/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.html index e8d1e3986..be9bd38af 100644 --- a/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.html +++ b/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.html @@ -4,13 +4,15 @@
-
+
diff --git a/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.scss b/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.scss index 61b78e9d5..65b1d13cc 100644 --- a/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.scss +++ b/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.scss @@ -9,10 +9,25 @@ .app-now-playing-showcase__song { display: flex; flex-direction: row; + flex: 1; + margin: 200px; +} + +@media (max-width: 1200px) { + .app-now-playing-showcase__song { + margin: 100px; + } +} + +@media (max-width: 800px) { + .app-now-playing-showcase__song { + margin: 50px; + } } .app-now-playing-showcase__cover { -webkit-box-reflect: below 4px -webkit-linear-gradient(transparent, transparent 80%, rgba(255, 255, 255, 0.3)); + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.2); } .app-now-playing-showcase__songinfo { diff --git a/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.spec.ts b/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.spec.ts index 4b2bf50dd..d6be05b41 100644 --- a/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.spec.ts +++ b/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.spec.ts @@ -2,17 +2,20 @@ import { IMock, Mock } from 'typemoq'; import { BaseApplication } from '../../../common/io/base-application'; import { WindowSize } from '../../../common/io/window-size'; import { NowPlayingShowcaseComponent } from './now-playing-showcase.component'; +import { BaseSettings } from '../../../common/settings/base-settings'; describe('NowPlayingShowcaseComponent', () => { + let settingsMock: IMock; let applicationMock: IMock; const flushPromises = () => new Promise(process.nextTick); function createComponent(): NowPlayingShowcaseComponent { - return new NowPlayingShowcaseComponent(applicationMock.object); + return new NowPlayingShowcaseComponent(settingsMock.object, applicationMock.object); } beforeEach(() => { + settingsMock = Mock.ofType(); applicationMock = Mock.ofType(); applicationMock.setup((x) => x.getWindowSize()).returns(() => new WindowSize(1000, 600)); diff --git a/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.ts b/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.ts index 097a3cc97..dfa82837e 100644 --- a/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.ts +++ b/src/app/components/now-playing/now-playing-showcase/now-playing-showcase.component.ts @@ -1,6 +1,7 @@ import { Component, HostListener, OnInit, ViewEncapsulation } from '@angular/core'; import { BaseApplication } from '../../../common/io/base-application'; import { WindowSize } from '../../../common/io/window-size'; +import { BaseSettings } from '../../../common/settings/base-settings'; @Component({ selector: 'app-now-playing-showcase', @@ -10,7 +11,7 @@ import { WindowSize } from '../../../common/io/window-size'; encapsulation: ViewEncapsulation.None, }) export class NowPlayingShowcaseComponent implements OnInit { - public constructor(private application: BaseApplication) {} + public constructor(public settings: BaseSettings, private application: BaseApplication) {} public coverArtSize: number = 0; public playbackInformationHeight: number = 0; diff --git a/src/app/components/now-playing/now-playing.component.html b/src/app/components/now-playing/now-playing.component.html index d22b26541..63d4a5124 100644 --- a/src/app/components/now-playing/now-playing.component.html +++ b/src/app/components/now-playing/now-playing.component.html @@ -26,6 +26,8 @@ + + diff --git a/src/app/components/playback-information/playback-information.component.html b/src/app/components/playback-information/playback-information.component.html index c3b9fc4d2..84a435920 100644 --- a/src/app/components/playback-information/playback-information.component.html +++ b/src/app/components/playback-information/playback-information.component.html @@ -6,28 +6,28 @@ [style.min-height.px]="height" >
- {{ this.topContentTrack?.artists }} -
-
{{ this.topContentTrack?.title }}
+
+ {{ this.topContentTrack?.artists }} +
@@ -40,27 +40,27 @@ [style.min-height.px]="height" >
- {{ this.bottomContentTrack?.artists }} -
-
{{ this.bottomContentTrack?.title }}
+
+ {{ this.bottomContentTrack?.artists }} +
diff --git a/src/app/components/playback-information/playback-information.component.spec.ts b/src/app/components/playback-information/playback-information.component.spec.ts index bf65deb57..f7f30c0cf 100644 --- a/src/app/components/playback-information/playback-information.component.spec.ts +++ b/src/app/components/playback-information/playback-information.component.spec.ts @@ -3,7 +3,6 @@ import { IMock, Mock } from 'typemoq'; import { Track } from '../../common/data/entities/track'; import { DateTime } from '../../common/date-time'; import { Scheduler } from '../../common/scheduling/scheduler'; -import { BaseSettings } from '../../common/settings/base-settings'; import { BaseMetadataService } from '../../services/metadata/base-metadata.service'; import { BasePlaybackInformationService } from '../../services/playback-information/base-playback-information.service'; import { PlaybackInformation } from '../../services/playback-information/playback-information'; @@ -14,7 +13,6 @@ import { PlaybackInformationComponent } from './playback-information.component'; describe('PlaybackInformationComponent', () => { let playbackInformationServiceMock: IMock; let metadataServiceMock: IMock; - let settingsMock: IMock; let schedulerMock: IMock; let dateTimeMock: IMock; let translatorServiceMock: IMock; @@ -29,12 +27,7 @@ describe('PlaybackInformationComponent', () => { const flushPromises = () => new Promise(process.nextTick); function createComponent(): PlaybackInformationComponent { - return new PlaybackInformationComponent( - playbackInformationServiceMock.object, - metadataServiceMock.object, - settingsMock.object, - schedulerMock.object - ); + return new PlaybackInformationComponent(playbackInformationServiceMock.object, metadataServiceMock.object, schedulerMock.object); } function createTrackModel(path: string, artists: string, title: string, rating: number, love: number): TrackModel { @@ -43,21 +36,19 @@ describe('PlaybackInformationComponent', () => { track.trackTitle = title; track.rating = rating; track.love = love; - const trackModel: TrackModel = new TrackModel(track, dateTimeMock.object, translatorServiceMock.object); - return trackModel; + return new TrackModel(track, dateTimeMock.object, translatorServiceMock.object); } beforeEach(() => { playbackInformationServiceMock = Mock.ofType(); metadataServiceMock = Mock.ofType(); - settingsMock = Mock.ofType(); schedulerMock = Mock.ofType(); dateTimeMock = Mock.ofType(); translatorServiceMock = Mock.ofType(); - const component: PlaybackInformationComponent = createComponent(); + createComponent(); playbackInformationService_PlayingNextTrack = new Subject(); const playbackInformationService_PlayingNextTrack$: Observable = @@ -96,6 +87,36 @@ describe('PlaybackInformationComponent', () => { expect(component).toBeDefined(); }); + it('should initialize isCentered as false', () => { + // Arrange + + // Act + const component: PlaybackInformationComponent = createComponent(); + + // Assert + expect(component.isCentered).toBeFalsy(); + }); + + it('should initialize showRating as false', () => { + // Arrange + + // Act + const component: PlaybackInformationComponent = createComponent(); + + // Assert + expect(component.showRating).toBeFalsy(); + }); + + it('should initialize showLove as false', () => { + // Arrange + + // Act + const component: PlaybackInformationComponent = createComponent(); + + // Assert + expect(component.showLove).toBeFalsy(); + }); + it('should initialize contentAnimation as "down"', () => { // Arrange @@ -136,7 +157,7 @@ describe('PlaybackInformationComponent', () => { expect(component.height).toEqual(0); }); - it('should largeFontSize height as 0', () => { + it('should initialize largeFontSize height as 0', () => { // Arrange // Act @@ -146,7 +167,7 @@ describe('PlaybackInformationComponent', () => { expect(component.largeFontSize).toEqual(0); }); - it('should smallFontSize height as 0', () => { + it('should initialize smallFontSize height as 0', () => { // Arrange // Act diff --git a/src/app/components/playback-information/playback-information.component.ts b/src/app/components/playback-information/playback-information.component.ts index 18da43b85..f0abea655 100644 --- a/src/app/components/playback-information/playback-information.component.ts +++ b/src/app/components/playback-information/playback-information.component.ts @@ -2,7 +2,6 @@ import { animate, state, style, transition, trigger } from '@angular/animations' import { Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { Subscription } from 'rxjs'; import { Scheduler } from '../../common/scheduling/scheduler'; -import { BaseSettings } from '../../common/settings/base-settings'; import { PromiseUtils } from '../../common/utils/promise-utils'; import { BaseMetadataService } from '../../services/metadata/base-metadata.service'; import { BasePlaybackInformationService } from '../../services/playback-information/base-playback-information.service'; @@ -52,13 +51,18 @@ export class PlaybackInformationComponent implements OnInit, OnDestroy { public constructor( private playbackInformationService: BasePlaybackInformationService, private metadataService: BaseMetadataService, - public settings: BaseSettings, private scheduler: Scheduler ) {} @Input() public isCentered: boolean = false; + @Input() + public showRating: boolean = false; + + @Input() + public showLove: boolean = false; + @Input() public height: number = 0; diff --git a/src/app/services/indexing/track-filler.spec.ts b/src/app/services/indexing/track-filler.spec.ts index 5f8430860..b7b2b0114 100644 --- a/src/app/services/indexing/track-filler.spec.ts +++ b/src/app/services/indexing/track-filler.spec.ts @@ -802,7 +802,7 @@ describe('TrackFiller', () => { expect(track.genres).toEqual(''); expect(track.albumTitle).toEqual(''); expect(track.albumArtists).toEqual(''); - expect(track.albumKey).toEqual(''); + expect(track.albumKey).toEqual(';Album title;;Album artist 1;;Album artist 2;'); expect(track.mimeType).toEqual(''); expect(track.bitRate).toEqual(0); expect(track.sampleRate).toEqual(0); diff --git a/src/app/services/indexing/track-filler.ts b/src/app/services/indexing/track-filler.ts index 07cf72cad..303dd4205 100644 --- a/src/app/services/indexing/track-filler.ts +++ b/src/app/services/indexing/track-filler.ts @@ -33,6 +33,7 @@ export class TrackFiller { track.trackTitle = this.trackFieldCreator.createTextField(fileMetadata.title); track.trackNumber = this.trackFieldCreator.createNumberField(fileMetadata.trackNumber); track.fileSize = this.fileAccess.getFileSizeInBytes(track.path); + track.albumKey = this.albumKeyGenerator.generateAlbumKey(fileMetadata.album, fileMetadata.albumArtists); if (!fillOnlyEssentialMetadata) { const dateNowTicks: number = this.dateTime.convertDateToTicks(new Date()); @@ -40,7 +41,6 @@ export class TrackFiller { track.genres = this.trackFieldCreator.createMultiTextField(fileMetadata.genres); track.albumTitle = this.trackFieldCreator.createTextField(fileMetadata.album); track.albumArtists = this.trackFieldCreator.createMultiTextField(fileMetadata.albumArtists); - track.albumKey = this.albumKeyGenerator.generateAlbumKey(fileMetadata.album, fileMetadata.albumArtists); track.mimeType = this.getMimeType(track.path); track.bitRate = this.trackFieldCreator.createNumberField(fileMetadata.bitRate); track.sampleRate = this.trackFieldCreator.createNumberField(fileMetadata.sampleRate); diff --git a/src/app/services/lyrics/base-lyrics.service.ts b/src/app/services/lyrics/base-lyrics.service.ts new file mode 100644 index 000000000..0ca8d398b --- /dev/null +++ b/src/app/services/lyrics/base-lyrics.service.ts @@ -0,0 +1,7 @@ +import { TrackModel } from '../track/track-model'; +import { ILyricsGetter } from './i-lyrics-getter'; +import { LyricsModel } from './lyrics-model'; + +export abstract class BaseLyricsService implements ILyricsGetter { + public abstract getLyricsAsync(track: TrackModel): Promise; +} diff --git a/src/app/services/lyrics/embedded-lyrics-getter.spec.ts b/src/app/services/lyrics/embedded-lyrics-getter.spec.ts new file mode 100644 index 000000000..13e6c4fef --- /dev/null +++ b/src/app/services/lyrics/embedded-lyrics-getter.spec.ts @@ -0,0 +1,58 @@ +import { BaseLyricsService } from './base-lyrics.service'; +import { LyricsService } from './lyrics.service'; +import { IMock, Mock } from 'typemoq'; +import { BasePlaybackService } from '../playback/base-playback.service'; +import { BasePlaybackInformationService } from '../playback-information/base-playback-information.service'; +import { BaseMediaSessionProxy } from '../../common/io/base-media-session-proxy'; +import { Subject } from 'rxjs'; +import { PlaybackInformation } from '../playback-information/playback-information'; +import { EmbeddedLyricsGetter } from './embedded-lyrics-getter'; +import { LrcLyricsGetter } from './lrc-lyrics-getter'; +import { OnlineLyricsGetter } from './online-lyrics-getter'; +import { BaseSettings } from '../../common/settings/base-settings'; +import { BaseFileMetadataFactory } from '../../common/metadata/base-file-metadata-factory'; +import { MockCreator } from '../../testing/mock-creator'; +import { IFileMetadata } from '../../common/metadata/i-file-metadata'; +import { Lyrics } from '../../common/api/lyrics/lyrics'; +import { LyricsModel } from './lyrics-model'; +import { LyricsSourceType } from '../../common/api/lyrics/lyrics-source-type'; + +describe('EmbeddedLyricsGetter', () => { + let fileMetadataFactoryMock: IMock; + + beforeEach(() => { + fileMetadataFactoryMock = Mock.ofType(); + }); + + function createInstance(): EmbeddedLyricsGetter { + return new EmbeddedLyricsGetter(fileMetadataFactoryMock.object); + } + + describe('constructor', () => { + it('should create', () => { + // Arrange, Act + const instance: EmbeddedLyricsGetter = createInstance(); + + // Assert + expect(instance).toBeDefined(); + }); + }); + + describe('getLyricsAsync', () => { + it('should return the file metadata lyrics', async () => { + // Arrange + const track = MockCreator.createTrackModel('path', 'title', 'artists'); + const metadataMock: IFileMetadata = { lyrics: 'lyrics' } as IFileMetadata; + fileMetadataFactoryMock.setup((x) => x.createAsync(track.path)).returns(() => Promise.resolve(metadataMock)); + const instance: EmbeddedLyricsGetter = createInstance(); + + // Act + const lyrics: LyricsModel = await instance.getLyricsAsync(track); + + // Assert + expect(lyrics.sourceName).toEqual(''); + expect(lyrics.sourceType).toEqual(LyricsSourceType.embedded); + expect(lyrics.text).toEqual('lyrics'); + }); + }); +}); diff --git a/src/app/services/lyrics/embedded-lyrics-getter.ts b/src/app/services/lyrics/embedded-lyrics-getter.ts new file mode 100644 index 000000000..2986ed3c9 --- /dev/null +++ b/src/app/services/lyrics/embedded-lyrics-getter.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { TrackModel } from '../track/track-model'; +import { ILyricsGetter } from './i-lyrics-getter'; +import { BaseFileMetadataFactory } from '../../common/metadata/base-file-metadata-factory'; +import { IFileMetadata } from '../../common/metadata/i-file-metadata'; +import { LyricsModel } from './lyrics-model'; +import { LyricsSourceType } from '../../common/api/lyrics/lyrics-source-type'; + +@Injectable() +export class EmbeddedLyricsGetter implements ILyricsGetter { + public constructor(private fileMetadataFactory: BaseFileMetadataFactory) {} + + public async getLyricsAsync(track: TrackModel): Promise { + const fileMetadata: IFileMetadata = await this.fileMetadataFactory.createAsync(track.path); + + return new LyricsModel('', LyricsSourceType.embedded, fileMetadata.lyrics); + } +} diff --git a/src/app/services/lyrics/i-lyrics-getter.ts b/src/app/services/lyrics/i-lyrics-getter.ts new file mode 100644 index 000000000..1df2eaa46 --- /dev/null +++ b/src/app/services/lyrics/i-lyrics-getter.ts @@ -0,0 +1,6 @@ +import { TrackModel } from '../track/track-model'; +import { LyricsModel } from './lyrics-model'; + +export interface ILyricsGetter { + getLyricsAsync(track: TrackModel): Promise; +} diff --git a/src/app/services/lyrics/lrc-lyrics-getter.spec.ts b/src/app/services/lyrics/lrc-lyrics-getter.spec.ts new file mode 100644 index 000000000..5ce4ceaaa --- /dev/null +++ b/src/app/services/lyrics/lrc-lyrics-getter.spec.ts @@ -0,0 +1,65 @@ +import { EmbeddedLyricsGetter } from './embedded-lyrics-getter'; +import { LrcLyricsGetter } from './lrc-lyrics-getter'; +import { MockCreator } from '../../testing/mock-creator'; +import { LyricsModel } from './lyrics-model'; +import { LyricsSourceType } from '../../common/api/lyrics/lyrics-source-type'; +import { TrackModel } from '../track/track-model'; +import { IMock, Mock } from 'typemoq'; +import { BaseFileMetadataFactory } from '../../common/metadata/base-file-metadata-factory'; +import { BaseFileAccess } from '../../common/io/base-file-access'; + +describe('LrcLyricsGetter', () => { + let fileAccessMock: IMock; + + beforeEach(() => { + fileAccessMock = Mock.ofType(); + }); + + function createInstance(): LrcLyricsGetter { + return new LrcLyricsGetter(fileAccessMock.object); + } + + describe('constructor', () => { + it('should create', () => { + // Arrange, Act + const instance: LrcLyricsGetter = createInstance(); + + // Assert + expect(instance).toBeDefined(); + }); + }); + + describe('getLyricsAsync', () => { + it('should return the lrc lyrics ignoring timestamps and metadata if lrc file exists', async () => { + // Arrange + const track: TrackModel = MockCreator.createTrackModel('/path/to/audio/file.mp3', 'title', 'artists'); + fileAccessMock.setup((x) => x.getPathWithoutExtension(track.path)).returns(() => '/path/to/audio/file'); + fileAccessMock.setup((x) => x.pathExists('/path/to/audio/file.lrc')).returns(() => true); + + const lrcFileLines: string[] = [ + '[ar:Chubby Checker oppure Beatles, The]', + "[al:Hits Of The 60's - Vol. 2 – Oldies]", + "[ti:Let's Twist Again]", + '[au:Written by Kal Mann / Dave Appell, 1961]', + '[length: 2:23]', + '[00:12.00]Line 1 lyrics', + '[00:17.20]Line 2 lyrics', + '[00:21.10]Line 3 lyrics', + '[00:24.00]Line 4 lyrics', + '[00:28.25]Line 5 lyrics', + '[00:29.02]Line 6 lyrics', + ]; + + fileAccessMock.setup((x) => x.readLinesAsync('/path/to/audio/file.lrc')).returns(() => Promise.resolve(lrcFileLines)); + const instance: LrcLyricsGetter = createInstance(); + + // Act + const lyrics: LyricsModel = await instance.getLyricsAsync(track); + + // Assert + expect(lyrics.sourceName).toEqual(''); + expect(lyrics.sourceType).toEqual(LyricsSourceType.lrc); + expect(lyrics.text).toEqual('Line 1 lyrics\nLine 2 lyrics\nLine 3 lyrics\nLine 4 lyrics\nLine 5 lyrics\nLine 6 lyrics'); + }); + }); +}); diff --git a/src/app/services/lyrics/lrc-lyrics-getter.ts b/src/app/services/lyrics/lrc-lyrics-getter.ts new file mode 100644 index 000000000..e263e6ab6 --- /dev/null +++ b/src/app/services/lyrics/lrc-lyrics-getter.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { TrackModel } from '../track/track-model'; +import { ILyricsGetter } from './i-lyrics-getter'; +import { LyricsModel } from './lyrics-model'; +import { LyricsSourceType } from '../../common/api/lyrics/lyrics-source-type'; +import { BaseFileAccess } from '../../common/io/base-file-access'; +import { Strings } from '../../common/strings'; + +@Injectable() +export class LrcLyricsGetter implements ILyricsGetter { + public constructor(private fileAccess: BaseFileAccess) {} + + public async getLyricsAsync(track: TrackModel): Promise { + const lrcFilePath: string = this.getLrcFilePath(track); + + if (Strings.isNullOrWhiteSpace(lrcFilePath)) { + return LyricsModel.default(); + } + + const lines: string[] = await this.fileAccess.readLinesAsync(lrcFilePath); + let lyricsText: string = ''; + + for (let i = 0; i < lines.length; i++) { + const lineParts: string[] = lines[i].split(']'); + const lineWithoutTimestamp: string = lineParts.length > 1 ? lineParts[1] : lineParts[0]; + + if (!Strings.isNullOrWhiteSpace(lineWithoutTimestamp) && !lineWithoutTimestamp.startsWith('[')) { + lyricsText += `${lineWithoutTimestamp}`; + + if (i < lines.length - 1) { + lyricsText += '\n'; + } + } + } + + return new LyricsModel('', LyricsSourceType.lrc, lyricsText); + } + + private getLrcFilePath(track: TrackModel): string { + const trackPathWithoutExtension: string = this.fileAccess.getPathWithoutExtension(track.path); + let possibleLrcFilePath: string = `${trackPathWithoutExtension}.lrc`; + + if (this.fileAccess.pathExists(possibleLrcFilePath)) { + return possibleLrcFilePath; + } + + possibleLrcFilePath = `${trackPathWithoutExtension}.Lrc`; + + if (this.fileAccess.pathExists(possibleLrcFilePath)) { + return possibleLrcFilePath; + } + + possibleLrcFilePath = `${trackPathWithoutExtension}.LRC`; + + if (this.fileAccess.pathExists(possibleLrcFilePath)) { + return possibleLrcFilePath; + } + + return ''; + } +} diff --git a/src/app/services/lyrics/lyrics-model.spec.ts b/src/app/services/lyrics/lyrics-model.spec.ts new file mode 100644 index 000000000..eb9b78ecc --- /dev/null +++ b/src/app/services/lyrics/lyrics-model.spec.ts @@ -0,0 +1,39 @@ +import { BaseLyricsService } from './base-lyrics.service'; +import { LyricsModel } from './lyrics-model'; +import { LyricsSourceType } from '../../common/api/lyrics/lyrics-source-type'; + +describe('LyricsModel', () => { + describe('constructor', () => { + it('should create', () => { + // Arrange, Act + const instance = new LyricsModel('sourceName', LyricsSourceType.embedded, 'text'); + + // Assert + expect(instance).toBeDefined(); + }); + + it('should set sourceName', () => { + // Arrange, Act + const instance = new LyricsModel('sourceName', LyricsSourceType.embedded, 'text'); + + // Assert + expect(instance.sourceName).toEqual('sourceName'); + }); + + it('should set sourceType', () => { + // Arrange, Act + const instance = new LyricsModel('sourceName', LyricsSourceType.embedded, 'text'); + + // Assert + expect(instance.sourceType).toEqual(LyricsSourceType.embedded); + }); + + it('should set text', () => { + // Arrange, Act + const instance = new LyricsModel('sourceName', LyricsSourceType.embedded, 'text'); + + // Assert + expect(instance.text).toEqual('text'); + }); + }); +}); diff --git a/src/app/services/lyrics/lyrics-model.ts b/src/app/services/lyrics/lyrics-model.ts new file mode 100644 index 000000000..2bda12332 --- /dev/null +++ b/src/app/services/lyrics/lyrics-model.ts @@ -0,0 +1,13 @@ +import { LyricsSourceType } from '../../common/api/lyrics/lyrics-source-type'; + +export class LyricsModel { + public constructor( + public sourceName: string, + public sourceType: LyricsSourceType, + public text: string, + ) {} + + public static default(): LyricsModel { + return new LyricsModel('', LyricsSourceType.none, ''); + } +} diff --git a/src/app/services/lyrics/lyrics.service.spec.ts b/src/app/services/lyrics/lyrics.service.spec.ts new file mode 100644 index 000000000..940154a19 --- /dev/null +++ b/src/app/services/lyrics/lyrics.service.spec.ts @@ -0,0 +1,146 @@ +import { BaseLyricsService } from './base-lyrics.service'; +import { LyricsService } from './lyrics.service'; +import { IMock, Mock } from 'typemoq'; +import { EmbeddedLyricsGetter } from './embedded-lyrics-getter'; +import { LrcLyricsGetter } from './lrc-lyrics-getter'; +import { OnlineLyricsGetter } from './online-lyrics-getter'; +import { BaseSettings } from '../../common/settings/base-settings'; +import { MockCreator } from '../../testing/mock-creator'; +import { LyricsModel } from './lyrics-model'; +import { LyricsSourceType } from '../../common/api/lyrics/lyrics-source-type'; +import { Logger } from '../../common/logger'; + +describe('LyricsService', () => { + let embeddedLyricsGetterMock: IMock; + let lrcLyricsGetterMock: IMock; + let onlineLyricsGetterMock: IMock; + let settingsMock: IMock; + let loggerMock: IMock; + + beforeEach(() => { + embeddedLyricsGetterMock = Mock.ofType(); + lrcLyricsGetterMock = Mock.ofType(); + onlineLyricsGetterMock = Mock.ofType(); + settingsMock = Mock.ofType(); + loggerMock = Mock.ofType(); + }); + + function createSut(): BaseLyricsService { + return new LyricsService( + embeddedLyricsGetterMock.object, + lrcLyricsGetterMock.object, + onlineLyricsGetterMock.object, + settingsMock.object, + loggerMock.object, + ); + } + + describe('constructor', () => { + it('should create', () => { + // Arrange, Act + const sut: BaseLyricsService = createSut(); + + // Assert + expect(sut).toBeDefined(); + }); + }); + + describe('getLyricsAsync', () => { + it('should return embedded lyrics if there are embedded lyrics', async () => { + // Arrange + const trackMock = MockCreator.createTrackModel('path', 'title', 'artists'); + const lyricsMock: LyricsModel = new LyricsModel('embedded source', LyricsSourceType.embedded, 'embedded text'); + embeddedLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(lyricsMock)); + const sut: BaseLyricsService = createSut(); + + // Act + const lyrics: LyricsModel = await sut.getLyricsAsync(trackMock); + + // Assert + expect(lyrics.sourceName).toEqual('embedded source'); + expect(lyrics.sourceType).toEqual(LyricsSourceType.embedded); + expect(lyrics.text).toEqual('embedded text'); + }); + + it('should return lrc lyrics if there are no embedded lyrics but there are lrc lyrics', async () => { + // Arrange + const trackMock = MockCreator.createTrackModel('path', 'title', 'artists'); + const embeddedLyricsMock: LyricsModel = new LyricsModel('embedded source', LyricsSourceType.embedded, ''); + embeddedLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(embeddedLyricsMock)); + const lrcLyricsMock: LyricsModel = new LyricsModel('lrc source', LyricsSourceType.lrc, 'lrc text'); + lrcLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(lrcLyricsMock)); + const sut: BaseLyricsService = createSut(); + + // Act + const lyrics: LyricsModel = await sut.getLyricsAsync(trackMock); + + // Assert + expect(lyrics.sourceName).toEqual('lrc source'); + expect(lyrics.sourceType).toEqual(LyricsSourceType.lrc); + expect(lyrics.text).toEqual('lrc text'); + }); + + it('should return online lyrics if there are no embedded lyrics and no lrc lyrics but there are online lyrics and online download is enabled', async () => { + // Arrange + const trackMock = MockCreator.createTrackModel('path', 'title', 'artists'); + const embeddedLyricsMock: LyricsModel = new LyricsModel('embedded source', LyricsSourceType.embedded, ''); + embeddedLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(embeddedLyricsMock)); + const lrcLyricsMock: LyricsModel = new LyricsModel('lrc source', LyricsSourceType.lrc, ''); + lrcLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(lrcLyricsMock)); + const onlineLyricsMock: LyricsModel = new LyricsModel('online source', LyricsSourceType.online, 'online text'); + onlineLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(onlineLyricsMock)); + settingsMock.setup((x) => x.downloadLyricsOnline).returns(() => true); + const sut: BaseLyricsService = createSut(); + + // Act + const lyrics: LyricsModel = await sut.getLyricsAsync(trackMock); + + // Assert + expect(lyrics.sourceName).toEqual('online source'); + expect(lyrics.sourceType).toEqual(LyricsSourceType.online); + expect(lyrics.text).toEqual('online text'); + }); + + it('should return empty lyrics if there are no embedded lyrics and no lrc lyrics but there are online lyrics and online download is disabled', async () => { + // Arrange + const trackMock = MockCreator.createTrackModel('path', 'title', 'artists'); + const embeddedLyricsMock: LyricsModel = new LyricsModel('embedded source', LyricsSourceType.embedded, ''); + embeddedLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(embeddedLyricsMock)); + const lrcLyricsMock: LyricsModel = new LyricsModel('lrc source', LyricsSourceType.lrc, ''); + lrcLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(lrcLyricsMock)); + const onlineLyricsMock: LyricsModel = new LyricsModel('online source', LyricsSourceType.online, 'online text'); + onlineLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(onlineLyricsMock)); + settingsMock.setup((x) => x.downloadLyricsOnline).returns(() => false); + const sut: BaseLyricsService = createSut(); + + // Act + const lyrics: LyricsModel = await sut.getLyricsAsync(trackMock); + + // Assert + expect(lyrics.sourceName).toEqual(''); + expect(lyrics.sourceType).toEqual(LyricsSourceType.none); + expect(lyrics.text).toEqual(''); + }); + + it('should return empty lyrics if there are no embedded lyrics and no lrc lyrics and no online lyrics', async () => { + // Arrange + const trackMock = MockCreator.createTrackModel('path', 'title', 'artists'); + const embeddedLyricsMock: LyricsModel = new LyricsModel('embedded source', LyricsSourceType.embedded, ''); + embeddedLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(embeddedLyricsMock)); + const lrcLyricsMock: LyricsModel = new LyricsModel('lrc source', LyricsSourceType.lrc, ''); + lrcLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(lrcLyricsMock)); + const onlineLyricsMock: LyricsModel = new LyricsModel('online source', LyricsSourceType.online, ''); + onlineLyricsGetterMock.setup((x) => x.getLyricsAsync(trackMock)).returns(() => Promise.resolve(onlineLyricsMock)); + settingsMock.setup((x) => x.downloadLyricsOnline).returns(() => true); + const sut: BaseLyricsService = createSut(); + + // Act + const lyrics: LyricsModel = await sut.getLyricsAsync(trackMock); + + // Assert + expect(lyrics.sourceName).toEqual(''); + expect(lyrics.sourceType).toEqual(LyricsSourceType.none); + expect(lyrics.text).toEqual(''); + }); + }); +}); diff --git a/src/app/services/lyrics/lyrics.service.ts b/src/app/services/lyrics/lyrics.service.ts new file mode 100644 index 000000000..6a04c17b1 --- /dev/null +++ b/src/app/services/lyrics/lyrics.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; +import { BaseLyricsService } from './base-lyrics.service'; +import { TrackModel } from '../track/track-model'; +import { EmbeddedLyricsGetter } from './embedded-lyrics-getter'; +import { LrcLyricsGetter } from './lrc-lyrics-getter'; +import { OnlineLyricsGetter } from './online-lyrics-getter'; +import { Strings } from '../../common/strings'; +import { BaseSettings } from '../../common/settings/base-settings'; +import { LyricsModel } from './lyrics-model'; +import { Logger } from '../../common/logger'; + +@Injectable() +export class LyricsService implements BaseLyricsService { + public constructor( + private embeddedLyricsGetter: EmbeddedLyricsGetter, + private lrcLyricsGetter: LrcLyricsGetter, + private onlineLyricsGetter: OnlineLyricsGetter, + private settings: BaseSettings, + private logger: Logger, + ) {} + + public async getLyricsAsync(track: TrackModel): Promise { + let lyrics: LyricsModel = LyricsModel.default(); + + try { + lyrics = await this.embeddedLyricsGetter.getLyricsAsync(track); + } catch (e: unknown) { + this.logger.error(e, 'Could not get embedded lyrics', 'LyricsService', 'getLyricsAsync'); + } + + if (!Strings.isNullOrWhiteSpace(lyrics.text)) { + return lyrics; + } + + try { + lyrics = await this.lrcLyricsGetter.getLyricsAsync(track); + } catch (e: unknown) { + this.logger.error(e, 'Could not get LRC lyrics', 'LyricsService', 'getLyricsAsync'); + } + + if (!Strings.isNullOrWhiteSpace(lyrics.text)) { + return lyrics; + } + + if (this.settings.downloadLyricsOnline) { + try { + lyrics = await this.onlineLyricsGetter.getLyricsAsync(track); + } catch (e: unknown) { + this.logger.error(e, 'Could not get online lyrics', 'LyricsService', 'getLyricsAsync'); + } + } + + if (!Strings.isNullOrWhiteSpace(lyrics.text)) { + return lyrics; + } + + return LyricsModel.default(); + } +} diff --git a/src/app/services/lyrics/online-lyrics-getter.spec.ts b/src/app/services/lyrics/online-lyrics-getter.spec.ts new file mode 100644 index 000000000..12f494d1a --- /dev/null +++ b/src/app/services/lyrics/online-lyrics-getter.spec.ts @@ -0,0 +1,101 @@ +import { EmbeddedLyricsGetter } from './embedded-lyrics-getter'; +import { LrcLyricsGetter } from './lrc-lyrics-getter'; +import { MockCreator } from '../../testing/mock-creator'; +import { LyricsModel } from './lyrics-model'; +import { LyricsSourceType } from '../../common/api/lyrics/lyrics-source-type'; +import { TrackModel } from '../track/track-model'; +import { OnlineLyricsGetter } from './online-lyrics-getter'; +import { IMock, Mock } from 'typemoq'; +import { ChartLyricsApi } from '../../common/api/lyrics/chart-lyrics-api'; +import { BaseFileMetadataFactory } from '../../common/metadata/base-file-metadata-factory'; +import { Lyrics } from '../../common/api/lyrics/lyrics'; +import { AZLyricsApi } from '../../common/api/lyrics/a-z-lyrics-api'; +import { WebSearchLyricsApi } from '../../common/api/lyrics/web-search-lyrics/web-search-lyrics-api'; +import { Logger } from '../../common/logger'; + +describe('OnlineLyricsGetter', () => { + let chartLyricsApiMock: IMock; + let azLyricsApiMock: IMock; + let webSearchLyricsApiMock: IMock; + let loggerMock: IMock; + + beforeEach(() => { + chartLyricsApiMock = Mock.ofType(); + azLyricsApiMock = Mock.ofType(); + webSearchLyricsApiMock = Mock.ofType(); + loggerMock = Mock.ofType(); + }); + + function createInstance(): OnlineLyricsGetter { + return new OnlineLyricsGetter(chartLyricsApiMock.object, azLyricsApiMock.object, webSearchLyricsApiMock.object, loggerMock.object); + } + + describe('constructor', () => { + it('should create', () => { + // Arrange, Act + const instance: OnlineLyricsGetter = createInstance(); + + // Assert + expect(instance).toBeDefined(); + }); + }); + + describe('getLyricsAsync', () => { + it('should return lyrics from ChartLyrics if available', async () => { + // Arrange + const track: TrackModel = MockCreator.createTrackModel('path', 'title', 'artists'); + const lyrics: Lyrics = new Lyrics('ChartLyrics source', 'ChartLyrics text'); + chartLyricsApiMock.setup((x) => x.getLyricsAsync(track.rawFirstArtist, track.title)).returns(() => Promise.resolve(lyrics)); + const instance: OnlineLyricsGetter = createInstance(); + + // Act + const lyricsModel: LyricsModel = await instance.getLyricsAsync(track); + + // Assert + expect(lyricsModel.sourceName).toEqual('ChartLyrics source'); + expect(lyricsModel.sourceType).toEqual(LyricsSourceType.online); + expect(lyricsModel.text).toEqual('ChartLyrics text'); + }); + + it('should return lyrics from AZLyrics if no lyrics from ChartLyrics are available', async () => { + // Arrange + const track: TrackModel = MockCreator.createTrackModel('path', 'title', 'artists'); + const lyrics: Lyrics = new Lyrics('AZLyrics source', 'AZLyrics text'); + chartLyricsApiMock + .setup((x) => x.getLyricsAsync(track.rawFirstArtist, track.title)) + .returns(() => Promise.resolve(Lyrics.default())); + azLyricsApiMock.setup((x) => x.getLyricsAsync(track.rawFirstArtist, track.title)).returns(() => Promise.resolve(lyrics)); + const instance: OnlineLyricsGetter = createInstance(); + + // Act + const lyricsModel: LyricsModel = await instance.getLyricsAsync(track); + + // Assert + expect(lyricsModel.sourceName).toEqual('AZLyrics source'); + expect(lyricsModel.sourceType).toEqual(LyricsSourceType.online); + expect(lyricsModel.text).toEqual('AZLyrics text'); + }); + + it('should return lyrics from WebSearchLyrics if no lyrics from ChartLyrics and AZLyrics are available', async () => { + // Arrange + const track: TrackModel = MockCreator.createTrackModel('path', 'title', 'artists'); + const lyrics: Lyrics = new Lyrics('WebSearchLyrics source', 'WebSearchLyrics text'); + chartLyricsApiMock + .setup((x) => x.getLyricsAsync(track.rawFirstArtist, track.title)) + .returns(() => Promise.resolve(Lyrics.default())); + azLyricsApiMock + .setup((x) => x.getLyricsAsync(track.rawFirstArtist, track.title)) + .returns(() => Promise.resolve(Lyrics.default())); + webSearchLyricsApiMock.setup((x) => x.getLyricsAsync(track.rawFirstArtist, track.title)).returns(() => Promise.resolve(lyrics)); + const instance: OnlineLyricsGetter = createInstance(); + + // Act + const lyricsModel: LyricsModel = await instance.getLyricsAsync(track); + + // Assert + expect(lyricsModel.sourceName).toEqual('WebSearchLyrics source'); + expect(lyricsModel.sourceType).toEqual(LyricsSourceType.online); + expect(lyricsModel.text).toEqual('WebSearchLyrics text'); + }); + }); +}); diff --git a/src/app/services/lyrics/online-lyrics-getter.ts b/src/app/services/lyrics/online-lyrics-getter.ts new file mode 100644 index 000000000..9ba9246a1 --- /dev/null +++ b/src/app/services/lyrics/online-lyrics-getter.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@angular/core'; +import { TrackModel } from '../track/track-model'; +import { ILyricsGetter } from './i-lyrics-getter'; +import { ChartLyricsApi } from '../../common/api/lyrics/chart-lyrics-api'; +import { LyricsModel } from './lyrics-model'; +import { Lyrics } from '../../common/api/lyrics/lyrics'; +import { LyricsSourceType } from '../../common/api/lyrics/lyrics-source-type'; +import { Logger } from '../../common/logger'; +import { Strings } from '../../common/strings'; +import { AZLyricsApi } from '../../common/api/lyrics/a-z-lyrics-api'; +import { WebSearchLyricsApi } from '../../common/api/lyrics/web-search-lyrics/web-search-lyrics-api'; + +@Injectable() +export class OnlineLyricsGetter implements ILyricsGetter { + public constructor( + private chartLyricsApi: ChartLyricsApi, + private azLyricsApi: AZLyricsApi, + private webSearchLyricsApi: WebSearchLyricsApi, + private logger: Logger, + ) {} + + public async getLyricsAsync(track: TrackModel): Promise { + let lyrics: Lyrics = Lyrics.default(); + + try { + lyrics = await this.chartLyricsApi.getLyricsAsync(track.rawFirstArtist, track.title); + } catch (e) { + this.logger.error(e, 'Could not get lyrics from ChartLyrics', 'OnlineLyricsGetter', 'getLyricsAsync'); + } + + if (Strings.isNullOrWhiteSpace(lyrics.text)) { + try { + lyrics = await this.azLyricsApi.getLyricsAsync(track.rawFirstArtist, track.title); + } catch (e) { + this.logger.error(e, 'Could not get lyrics from AZLyrics', 'OnlineLyricsGetter', 'getLyricsAsync'); + } + } + + if (Strings.isNullOrWhiteSpace(lyrics.text)) { + try { + lyrics = await this.webSearchLyricsApi.getLyricsAsync(track.rawFirstArtist, track.title); + } catch (e) { + this.logger.error(e, 'Could not get lyrics from WebSearchLyricsApi', 'OnlineLyricsGetter', 'getLyricsAsync'); + } + } + + return new LyricsModel(lyrics.sourceName, LyricsSourceType.online, lyrics.text); + } +} diff --git a/src/app/services/now-playing-navigation/now-playing-page.ts b/src/app/services/now-playing-navigation/now-playing-page.ts index bf8cda576..80153fe50 100644 --- a/src/app/services/now-playing-navigation/now-playing-page.ts +++ b/src/app/services/now-playing-navigation/now-playing-page.ts @@ -1,4 +1,5 @@ export enum NowPlayingPage { showcase = 0, - artistInformation = 1, + lyrics = 1, + artistInformation = 2, } diff --git a/src/app/services/playback/playback.service.ts b/src/app/services/playback/playback.service.ts index 757a85407..a37cc6d9e 100644 --- a/src/app/services/playback/playback.service.ts +++ b/src/app/services/playback/playback.service.ts @@ -51,7 +51,7 @@ export class PlaybackService implements BasePlaybackService { private progressUpdater: ProgressUpdater, private mathExtensions: MathExtensions, private settings: BaseSettings, - private logger: Logger + private logger: Logger, ) { this.initializeSubscriptions(); this.applyVolumeFromSettings(); @@ -355,7 +355,7 @@ export class PlaybackService implements BasePlaybackService { this.progressUpdater.startUpdatingProgress(); this.playbackStarted.next(new PlaybackStarted(trackToPlay, isPlayingPreviousTrack)); - this.logger.info(`Playing '${this.currentTrack?.path}'`, 'PlaybackService', 'play'); + this.logger.info(`Playing '${this.currentTrack.path}'`, 'PlaybackService', 'play'); } private stop(): void { @@ -440,14 +440,14 @@ export class PlaybackService implements BasePlaybackService { this.subscription.add( this.audioPlayer.playbackFinished$.subscribe(() => { this.playbackFinishedHandler(); - }) + }), ); this.subscription.add( this.progressUpdater.progressChanged$.subscribe((playbackProgress: PlaybackProgress) => { this._progress = playbackProgress; this.progressChanged.next(playbackProgress); - }) + }), ); } diff --git a/src/app/testing/integration-test-runner.ts b/src/app/testing/integration-test-runner.ts new file mode 100644 index 000000000..56de8962c --- /dev/null +++ b/src/app/testing/integration-test-runner.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@angular/core'; +import { AZLyricsApi } from '../common/api/lyrics/a-z-lyrics-api'; +import { Lyrics } from '../common/api/lyrics/lyrics'; +import { ChartLyricsApi } from '../common/api/lyrics/chart-lyrics-api'; +import { WebSearchLyricsApi } from '../common/api/lyrics/web-search-lyrics/web-search-lyrics-api'; +import { WebSearchApi } from '../common/api/lyrics/web-search-lyrics/web-search-api'; + +@Injectable() +export class IntegrationTestRunner { + public constructor( + private azLyricsApi: AZLyricsApi, + private chartLyricsApi: ChartLyricsApi, + private duckDuckGoApi: WebSearchApi, + private webSearchLyricsApi: WebSearchLyricsApi, + ) {} + + public async executeTestsAsync(): Promise { + await this.getLyricsFromAZLyricsTestAsync(); + await this.getLyricsFromChartLyricsTestAsync(); + await this.getLyricsFromWebSearchLyricsTestAsync(); + } + + private async getLyricsFromChartLyricsTestAsync(): Promise { + const lyrics: Lyrics = await this.chartLyricsApi.getLyricsAsync('Massive Attack', 'Teardrop'); + this.assertIsTrue('getLyricsFromChartLyricsTestAsync', lyrics.text.startsWith('Love, love is a verb')); + } + + private async getLyricsFromAZLyricsTestAsync(): Promise { + const lyrics: Lyrics = await this.azLyricsApi.getLyricsAsync('Massive Attack', 'Teardrop'); + this.assertIsTrue('getLyricsFromAZLyricsTestAsync', lyrics.text.startsWith('Love, love is a verb')); + } + + private async getLyricsFromWebSearchLyricsTestAsync(): Promise { + const lyrics: Lyrics = await this.webSearchLyricsApi.getLyricsAsync('Massive Attack', 'Teardrop'); + this.assertIsTrue('getLyricsFromWebSearchLyricsTestAsync', lyrics.text.startsWith('Love, love is a verb')); + } + + private assertIsTrue(testName: string, condition: boolean): void { + if (!condition) { + throw new Error(`${testName} FAILED`); + } + } +} diff --git a/src/app/testing/mock-creator.ts b/src/app/testing/mock-creator.ts index 10f65088f..fb6e8d527 100644 --- a/src/app/testing/mock-creator.ts +++ b/src/app/testing/mock-creator.ts @@ -7,16 +7,15 @@ import { TrackModel } from '../services/track/track-model'; import { BaseTranslatorService } from '../services/translator/base-translator.service'; export class MockCreator { - public static createTrackModel(path: string, artists: string): TrackModel { + public static createTrackModel(path: string, trackTitle: string, artists: string): TrackModel { const dateTimeMock: IMock = Mock.ofType(); const translatorServiceMock: IMock = Mock.ofType(); const track: Track = new Track(path); + track.trackTitle = trackTitle; track.artists = artists; - const trackModel: TrackModel = new TrackModel(track, dateTimeMock.object, translatorServiceMock.object); - - return trackModel; + return new TrackModel(track, dateTimeMock.object, translatorServiceMock.object); } public static createTrackModelWithAlbumKey(path: string, albumKey: string): TrackModel { @@ -26,16 +25,12 @@ export class MockCreator { const track: Track = new Track(path); track.albumKey = albumKey; - const trackModel: TrackModel = new TrackModel(track, dateTimeMock.object, translatorServiceMock.object); - - return trackModel; + return new TrackModel(track, dateTimeMock.object, translatorServiceMock.object); } public static createArtistInformation(name: string, url: string, imageUrl: string, biography: string): ArtistInformation { const desktopMock: IMock = Mock.ofType(); - const artistInformation: ArtistInformation = new ArtistInformation(desktopMock.object, name, url, imageUrl, biography); - - return artistInformation; + return new ArtistInformation(desktopMock.object, name, url, imageUrl, biography); } } diff --git a/src/assets/i18n/bg.json b/src/assets/i18n/bg.json index 42672333a..1e22e6cc3 100644 --- a/src/assets/i18n/bg.json +++ b/src/assets/i18n/bg.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/cs.json b/src/assets/i18n/cs.json index 9858bd7a2..3f49d37d3 100644 --- a/src/assets/i18n/cs.json +++ b/src/assets/i18n/cs.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index ff1e4ae21..1ab655f7e 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/el.json b/src/assets/i18n/el.json index 1da07ba3a..0827c619f 100644 --- a/src/assets/i18n/el.json +++ b/src/assets/i18n/el.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 36f04b5c1..ad86f3beb 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index 4793ffc1b..0f8e479f0 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/fr.json b/src/assets/i18n/fr.json index 1e842cec8..6067a828a 100644 --- a/src/assets/i18n/fr.json +++ b/src/assets/i18n/fr.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Télécharger des informations sur l'artiste depuis Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Le téléchargement des informations sur les artistes depuis Last.fm est actuellement désactivé. Vous pouvez l'activer ici ou dans les paramètres.", "similar": "Similaire", - "read-more-on-lastfm": "En savoir plus sur Last.fm" + "read-more-on-lastfm": "En savoir plus sur Last.fm", + "lyrics": "Paroles", + "no-lyrics": "Pas de paroles", + "embedded-lyrics": "Paroles intégrées", + "lrc-lyrics": "Paroles LRC", + "online-lyrics": "Paroles en ligne" } diff --git a/src/assets/i18n/hr.json b/src/assets/i18n/hr.json index 3fa9c95b9..52bcccaf7 100644 --- a/src/assets/i18n/hr.json +++ b/src/assets/i18n/hr.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/ja-JP.json b/src/assets/i18n/ja-JP.json index f7bda5fa9..51e919bcb 100644 --- a/src/assets/i18n/ja-JP.json +++ b/src/assets/i18n/ja-JP.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/ko.json b/src/assets/i18n/ko.json index e05aa7640..273ae5b37 100644 --- a/src/assets/i18n/ko.json +++ b/src/assets/i18n/ko.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/ku.json b/src/assets/i18n/ku.json index ed3487b49..67c4be0ce 100644 --- a/src/assets/i18n/ku.json +++ b/src/assets/i18n/ku.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/nl.json b/src/assets/i18n/nl.json index c2c7daea8..3942200f6 100644 --- a/src/assets/i18n/nl.json +++ b/src/assets/i18n/nl.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Artiestinformatie downloaden van Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Het downloaden van artiestinformatie van Last.fm is momenteel uitgeschakeld. Je kunt het hier of in de instellingen inschakelen.", "similar": "Vergelijkbaar", - "read-more-on-lastfm": "Lees meer op Last.fm" + "read-more-on-lastfm": "Lees meer op Last.fm", + "lyrics": "Songtekst", + "no-lyrics": "Geen songteksten", + "embedded-lyrics": "Ingebedde songteksten", + "lrc-lyrics": "LRC songteksten", + "online-lyrics": "Online songteksten" } diff --git a/src/assets/i18n/pt-BR.json b/src/assets/i18n/pt-BR.json index 9c5e1b4bb..3aca37384 100644 --- a/src/assets/i18n/pt-BR.json +++ b/src/assets/i18n/pt-BR.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/ru.json b/src/assets/i18n/ru.json index 4185234f4..c5a117b6e 100644 --- a/src/assets/i18n/ru.json +++ b/src/assets/i18n/ru.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Загрузить сведения об исполнителе с Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Загрузка сведений об исполнителе с Last.fm сейчас отключена. Вы можете включить её здесь или в настройках.", "similar": "Похожие", - "read-more-on-lastfm": "Подробности в Last.fm" + "read-more-on-lastfm": "Подробности в Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/vi.json b/src/assets/i18n/vi.json index 4cda67659..2662f1896 100644 --- a/src/assets/i18n/vi.json +++ b/src/assets/i18n/vi.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Tải thông tin nghệ sĩ từ Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Việc tải thông tin nghệ sĩ từ Last.fm đang bị tắt. Hãy bật lại nó trong cài đặt.", "similar": "Tương tự", - "read-more-on-lastfm": "Đọc thêm trên Last.fm" + "read-more-on-lastfm": "Đọc thêm trên Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/zh-CN.json b/src/assets/i18n/zh-CN.json index cb414a739..5c54631f0 100644 --- a/src/assets/i18n/zh-CN.json +++ b/src/assets/i18n/zh-CN.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/assets/i18n/zh-TW.json b/src/assets/i18n/zh-TW.json index b8c1bb419..41ea24dc2 100644 --- a/src/assets/i18n/zh-TW.json +++ b/src/assets/i18n/zh-TW.json @@ -244,5 +244,10 @@ "download-artist-information-from-lastfm": "Download artist information from Last.fm", "downloading-of-artist-information-from-lastfm-disabled": "Downloading of artist information from Last.fm is currently disabled. You can enable it here or in the settings.", "similar": "Similar", - "read-more-on-lastfm": "Read more on Last.fm" + "read-more-on-lastfm": "Read more on Last.fm", + "lyrics": "Lyrics", + "no-lyrics": "No lyrics", + "embedded-lyrics": "Embedded lyrics", + "lrc-lyrics": "LRC lyrics", + "online-lyrics": "Online lyrics" } diff --git a/src/css/custom-classes.scss b/src/css/custom-classes.scss index 9dabdcf92..8ae894c62 100644 --- a/src/css/custom-classes.scss +++ b/src/css/custom-classes.scss @@ -115,3 +115,7 @@ .text-align-right-important { text-align: right !important; } + +.break-on-newline{ + white-space: pre-line; +} diff --git a/src/css/sizing.scss b/src/css/sizing.scss index 96c120816..9a44f481f 100644 --- a/src/css/sizing.scss +++ b/src/css/sizing.scss @@ -38,11 +38,22 @@ width: 100%; } -.font-m { - font-weight: lighter !important; +.font-medium { font-size: var(--fontsize-medium) !important; } +.font-large { + font-size: var(--fontsize-large) !important; +} + +.font-extra-large { + font-size: var(--fontsize-extra-large) !important; +} + +.font-thin { + font-weight: lighter !important; +} + .rotate-90ccw { transform: rotate(-90deg); }