From 8e603871b7c619781bee127680bd4103f85ce785 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 6 Feb 2026 20:47:27 -0800 Subject: [PATCH] add experimental ytmusic playback for external songs --- package.json | 1 + pnpm-lock.yaml | 147 +++++++++++- src/main/features/core/index.ts | 1 + src/main/features/core/youtube/index.ts | 18 ++ src/preload/index.ts | 2 + src/preload/youtube.ts | 11 + .../musicbrainz/api/musicbrainz-api.ts | 215 +----------------- .../features/musicbrainz/api/youtube-api.ts | 17 ++ src/renderer/features/musicbrainz/utils.ts | 205 ++++++++++++++++- .../audio-player/hooks/use-stream-url.tsx | 56 ++++- .../player/hooks/use-update-current-song.ts | 8 +- 11 files changed, 460 insertions(+), 221 deletions(-) create mode 100644 src/main/features/core/youtube/index.ts create mode 100644 src/preload/youtube.ts create mode 100644 src/renderer/features/musicbrainz/api/youtube-api.ts diff --git a/package.json b/package.json index 7be8f8a73..e5d56f237 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "string-to-color": "^2.2.2", "wavesurfer.js": "^7.11.1", "ws": "^8.18.2", + "ytmusic-api": "^5.3.0", "zod": "^3.22.3", "zustand": "^5.0.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e1dab437..ac998d0e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: ws: specifier: ^8.18.2 version: 8.18.2 + ytmusic-api: + specifier: ^5.3.0 + version: 5.3.0 zod: specifier: ^3.22.3 version: 3.25.76 @@ -892,53 +895,106 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@biomejs/biome@1.8.3': + resolution: {integrity: sha512-/uUV3MV+vyAczO+vKrPdOW0Iaet7UnJMU4bNMinggGJTAnBPjCoLEYcyYtYHNnUNYlv4xZMH6hVIQCAozq8d5w==} + engines: {node: '>=14.21.3'} + hasBin: true + '@biomejs/biome@1.9.4': resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} engines: {node: '>=14.21.3'} hasBin: true + '@biomejs/cli-darwin-arm64@1.8.3': + resolution: {integrity: sha512-9DYOjclFpKrH/m1Oz75SSExR8VKvNSSsLnVIqdnKexj6NwmiMlKk94Wa1kZEdv6MCOHGHgyyoV57Cw8WzL5n3A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + '@biomejs/cli-darwin-arm64@1.9.4': resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] + '@biomejs/cli-darwin-x64@1.8.3': + resolution: {integrity: sha512-UeW44L/AtbmOF7KXLCoM+9PSgPo0IDcyEUfIoOXYeANaNXXf9mLUwV1GeF2OWjyic5zj6CnAJ9uzk2LT3v/wAw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + '@biomejs/cli-darwin-x64@1.9.4': resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] + '@biomejs/cli-linux-arm64-musl@1.8.3': + resolution: {integrity: sha512-9yjUfOFN7wrYsXt/T/gEWfvVxKlnh3yBpnScw98IF+oOeCYb5/b/+K7YNqKROV2i1DlMjg9g/EcN9wvj+NkMuQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + '@biomejs/cli-linux-arm64-musl@1.9.4': resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + '@biomejs/cli-linux-arm64@1.8.3': + resolution: {integrity: sha512-fed2ji8s+I/m8upWpTJGanqiJ0rnlHOK3DdxsyVLZQ8ClY6qLuPc9uehCREBifRJLl/iJyQpHIRufLDeotsPtw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + '@biomejs/cli-linux-arm64@1.9.4': resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + '@biomejs/cli-linux-x64-musl@1.8.3': + resolution: {integrity: sha512-UHrGJX7PrKMKzPGoEsooKC9jXJMa28TUSMjcIlbDnIO4EAavCoVmNQaIuUSH0Ls2mpGMwUIf+aZJv657zfWWjA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + '@biomejs/cli-linux-x64-musl@1.9.4': resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + '@biomejs/cli-linux-x64@1.8.3': + resolution: {integrity: sha512-I8G2QmuE1teISyT8ie1HXsjFRz9L1m5n83U1O6m30Kw+kPMPSKjag6QGUn+sXT8V+XWIZxFFBoTDEDZW2KPDDw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + '@biomejs/cli-linux-x64@1.9.4': resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + '@biomejs/cli-win32-arm64@1.8.3': + resolution: {integrity: sha512-J+Hu9WvrBevfy06eU1Na0lpc7uR9tibm9maHynLIoAjLZpQU3IW+OKHUtyL8p6/3pT2Ju5t5emReeIS2SAxhkQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + '@biomejs/cli-win32-arm64@1.9.4': resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] + '@biomejs/cli-win32-x64@1.8.3': + resolution: {integrity: sha512-/PJ59vA1pnQeKahemaQf4Nyj7IKUvGQSc3Ze1uIGi+Wvr1xF7rGobSrAAG01T/gUDG21vkDsZYM03NAmPiVkqg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@biomejs/cli-win32-x64@1.9.4': resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} engines: {node: '>=14.21.3'} @@ -3365,7 +3421,7 @@ packages: glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -4553,6 +4609,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} @@ -4568,6 +4627,9 @@ packages: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4813,6 +4875,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -5446,6 +5511,10 @@ packages: resolution: {integrity: sha512-y8MN937s/HVhEoBU1SxfHC+wxCHkV1a9gW8eAdTadYh/bGyesZIVcbjI+mSpFbSVwQici/XjBjuUyri1dnXwBw==} engines: {node: '>=10.13.0'} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -5570,6 +5639,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -5596,6 +5669,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -5943,6 +6019,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + ytmusic-api@5.3.0: + resolution: {integrity: sha512-RETSp5V5Cj1OT7N4HuEpehpq/YdKlKdXbYSSYH1ks8VBN1WRRpy2CFFwZVO+kIx7nuufBqsXN4B6/nxk4GNKDw==} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -6704,6 +6783,17 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/biome@1.8.3': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.8.3 + '@biomejs/cli-darwin-x64': 1.8.3 + '@biomejs/cli-linux-arm64': 1.8.3 + '@biomejs/cli-linux-arm64-musl': 1.8.3 + '@biomejs/cli-linux-x64': 1.8.3 + '@biomejs/cli-linux-x64-musl': 1.8.3 + '@biomejs/cli-win32-arm64': 1.8.3 + '@biomejs/cli-win32-x64': 1.8.3 + '@biomejs/biome@1.9.4': optionalDependencies: '@biomejs/cli-darwin-arm64': 1.9.4 @@ -6715,27 +6805,51 @@ snapshots: '@biomejs/cli-win32-arm64': 1.9.4 '@biomejs/cli-win32-x64': 1.9.4 + '@biomejs/cli-darwin-arm64@1.8.3': + optional: true + '@biomejs/cli-darwin-arm64@1.9.4': optional: true + '@biomejs/cli-darwin-x64@1.8.3': + optional: true + '@biomejs/cli-darwin-x64@1.9.4': optional: true + '@biomejs/cli-linux-arm64-musl@1.8.3': + optional: true + '@biomejs/cli-linux-arm64-musl@1.9.4': optional: true + '@biomejs/cli-linux-arm64@1.8.3': + optional: true + '@biomejs/cli-linux-arm64@1.9.4': optional: true + '@biomejs/cli-linux-x64-musl@1.8.3': + optional: true + '@biomejs/cli-linux-x64-musl@1.9.4': optional: true + '@biomejs/cli-linux-x64@1.8.3': + optional: true + '@biomejs/cli-linux-x64@1.9.4': optional: true + '@biomejs/cli-win32-arm64@1.8.3': + optional: true + '@biomejs/cli-win32-arm64@1.9.4': optional: true + '@biomejs/cli-win32-x64@1.8.3': + optional: true + '@biomejs/cli-win32-x64@1.9.4': optional: true @@ -10631,6 +10745,10 @@ snapshots: proxy-from-env@1.1.0: {} + psl@1.15.0: + dependencies: + punycode: 2.3.1 + pump@3.0.2: dependencies: end-of-stream: 1.4.4 @@ -10646,6 +10764,8 @@ snapshots: dependencies: side-channel: 1.1.0 + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -10896,6 +11016,8 @@ snapshots: require-from-string@2.0.2: {} + requires-port@1.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -11624,6 +11746,13 @@ snapshots: dependencies: streamx: 2.22.0 + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -11747,6 +11876,8 @@ snapshots: universalify@0.1.2: {} + universalify@0.2.0: {} + universalify@2.0.1: {} unzip-crx-3@0.2.0: @@ -11773,6 +11904,11 @@ snapshots: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + use-callback-ref@1.3.3(@types/react@19.2.5)(react@19.1.0): dependencies: react: 19.1.0 @@ -12169,6 +12305,15 @@ snapshots: yocto-queue@0.1.0: {} + ytmusic-api@5.3.0: + dependencies: + '@biomejs/biome': 1.8.3 + axios: 1.13.2 + tough-cookie: 4.1.4 + zod: 3.25.76 + transitivePeerDependencies: + - debug + zod-validation-error@4.0.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/src/main/features/core/index.ts b/src/main/features/core/index.ts index fad608283..8bc2b8ef0 100644 --- a/src/main/features/core/index.ts +++ b/src/main/features/core/index.ts @@ -4,3 +4,4 @@ import './player'; import './remote'; import './settings'; import './discord-rpc'; +import './youtube'; diff --git a/src/main/features/core/youtube/index.ts b/src/main/features/core/youtube/index.ts new file mode 100644 index 000000000..cba5d5f7d --- /dev/null +++ b/src/main/features/core/youtube/index.ts @@ -0,0 +1,18 @@ +import { ipcMain } from 'electron'; +import YTMusic from 'ytmusic-api'; + +let youtubeApi: InstanceType | null = null; + +const getYoutubeApi = async (): Promise> => { + if (!youtubeApi) { + youtubeApi = new YTMusic(); + await youtubeApi.initialize(); + } + return youtubeApi; +}; + +ipcMain.handle('youtube-search', async (_event, query: string) => { + const api = await getYoutubeApi(); + const results = await api.search(query); + return results; +}); diff --git a/src/preload/index.ts b/src/preload/index.ts index 1f87f4083..6c60621af 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,6 +11,7 @@ import { mpris } from './mpris'; import { mpvPlayer, mpvPlayerListener } from './mpv-player'; import { remote } from './remote'; import { utils } from './utils'; +import { youtube } from './youtube'; // Custom APIs for renderer const api = { @@ -25,6 +26,7 @@ const api = { mpvPlayerListener, remote, utils, + youtube, }; export type PreloadApi = typeof api; diff --git a/src/preload/youtube.ts b/src/preload/youtube.ts new file mode 100644 index 000000000..c8b471e11 --- /dev/null +++ b/src/preload/youtube.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron'; + +const search = (query: string) => { + return ipcRenderer.invoke('youtube-search', query); +}; + +export const youtube = { + search, +}; + +export type Youtube = typeof youtube; diff --git a/src/renderer/features/musicbrainz/api/musicbrainz-api.ts b/src/renderer/features/musicbrainz/api/musicbrainz-api.ts index e1a402de3..816424c5c 100644 --- a/src/renderer/features/musicbrainz/api/musicbrainz-api.ts +++ b/src/renderer/features/musicbrainz/api/musicbrainz-api.ts @@ -3,11 +3,9 @@ import memoize from 'lodash/memoize'; import { IArtist, IBrowseReleasesResult, - IMedium, IRelation, IRelease, IReleaseGroup, - ITrack, IWork, MusicBrainzApi, } from 'musicbrainz-api'; @@ -15,14 +13,17 @@ import { import packageJson from '../../../../../package.json'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getImageUrl } from '/@/renderer/features/musicbrainz/utils'; +import { + collectWorksFromRelease, + getImageUrl, + normalizeReleaseToAlbum, +} from '/@/renderer/features/musicbrainz/utils'; import { Album, AlbumArtist, LibraryItem, RelatedArtist, ServerType, - Song, } from '/@/shared/types/domain-types'; export const musicbrainzApi = new MusicBrainzApi({ @@ -33,6 +34,8 @@ export const musicbrainzApi = new MusicBrainzApi({ const CACHE_TIME = 1000 * 60 * 5; +export type IRelationWithWork = IRelation & { work?: IWork }; + export type MusicBrainzArtistSelectMeta = { albumArtist: AlbumArtist; albums?: Album[]; @@ -40,8 +43,6 @@ export type MusicBrainzArtistSelectMeta = { prioritizeCountries?: string[]; }; -type IRelationWithWork = IRelation & { work?: IWork }; - const artistSelect = memoize( ({ data, @@ -222,27 +223,6 @@ const artistSelect = memoize( }, ); -function collectWorksFromRelease(release: IRelease): IWork[] { - const works: IWork[] = []; - const seenIds = new Set(); - - for (const medium of release.media ?? []) { - for (const track of medium.tracks ?? []) { - const recording = track.recording; - const relations = (recording as { relations?: IRelationWithWork[] })?.relations ?? []; - for (const rel of relations) { - const work = (rel as IRelationWithWork).work; - if (work?.id && !seenIds.has(work.id)) { - seenIds.add(work.id); - works.push(work); - } - } - } - } - - return works; -} - async function fetchMbzReleasesByArtistId(mbzArtistId: string): Promise { const PAGE_SIZE = 100; const includes: Array<'media' | 'release-groups'> = ['media', 'release-groups']; @@ -303,187 +283,6 @@ const RELEASE_INCLUDES: Array< | 'release-groups' > = ['artist-credits', 'artists', 'media', 'recording-level-rels', 'recordings', 'release-groups']; -function normalizeArtistCreditToRelatedArtists( - artistCredit: Array<{ artist: IArtist; name: string }>, -): RelatedArtist[] { - return artistCredit.map((ac) => ({ - id: `musicbrainz-${ac.artist.id}`, - imageId: null, - imageUrl: null, - name: ac.name || ac.artist.name, - userFavorite: false, - userRating: null, - })); -} - -function normalizeRecordingToSong( - release: IRelease, - medium: IMedium, - track: ITrack, - albumArtistName: string, - albumArtists: RelatedArtist[], - albumId: string, - imageUrl: null | string, - releaseDate: null | string, - releaseYear: null | number, -): Song { - const recording = track.recording; - const trackArtistCredit = track['artist-credit'] ?? recording['artist-credit'] ?? []; - - const artistName = - trackArtistCredit.map((ac) => ac.name).join('') || recording.title || track.title; - - const artists = normalizeArtistCreditToRelatedArtists( - trackArtistCredit as Array<{ artist: IArtist; name: string }>, - ); - - const durationMilliseconds = track.length || recording.length || 0; - const trackNumber = track.position || parseInt(track.number, 10) || 0; - - return { - _itemType: LibraryItem.SONG, - _serverId: 'musicbrainz', - _serverType: ServerType.EXTERNAL, - album: release.title, - albumArtistName, - albumArtists, - albumId, - artistName, - artists, - bitDepth: null, - bitRate: 0, - bpm: null, - channels: null, - comment: null, - compilation: null, - container: null, - createdAt: '', - discNumber: medium.position || 1, - discSubtitle: medium.title || null, - duration: durationMilliseconds, - explicitStatus: null, - gain: null, - genres: [], - id: `musicbrainz-${release.id}-${recording.id}-${track.position}-${track.number}`, - imageId: null, - imageUrl, - lastPlayedAt: null, - lyrics: null, - mbzRecordingId: recording.id, - mbzTrackId: track.id, - name: track.title || recording.title, - participants: {}, - path: null, - peak: null, - playCount: 0, - releaseDate, - releaseYear, - sampleRate: null, - size: 0, - sortName: track.title || recording.title, - tags: null, - trackNumber, - trackSubtitle: null, - updatedAt: '', - userFavorite: false, - userRating: null, - }; -} - -function normalizeReleaseToAlbum(release: IRelease): Album { - const releaseGroup = release['release-group']; - const artistCredit = release['artist-credit'] ?? releaseGroup?.['artist-credit'] ?? []; - const albumArtistName = artistCredit.map((ac) => ac.name).join('') || release.title; - const albumArtists: RelatedArtist[] = (artistCredit as { artist: IArtist; name: string }[]).map( - (ac) => ({ - id: `musicbrainz-${ac.artist.id}`, - imageId: null, - imageUrl: null, - name: ac.name || ac.artist.name, - userFavorite: false, - userRating: null, - }), - ); - - const hasArtwork = - release['cover-art-archive']?.artwork === true && - release['cover-art-archive']?.front === true; - const primaryReleaseType = releaseGroup?.['primary-type']?.toLowerCase() || null; - const secondaryReleaseTypes = - releaseGroup?.['secondary-types']?.map((type) => type.toLowerCase()) || []; - const releaseTypes = [primaryReleaseType, ...secondaryReleaseTypes].filter( - (type) => type !== null, - ) as string[]; - const isCompilation = releaseTypes.includes('compilation'); - const originalDate = releaseGroup?.['first-release-date'] || null; - const originalYear = originalDate ? Number(originalDate.split('-')[0]) : null; - const releaseDate = release.date ? release.date : null; - const releaseYear = release.date ? Number(release.date.split('-')[0]) : null; - const imageUrl = hasArtwork ? getImageUrl(release.id) : null; - const albumId = `musicbrainz-${release.id}`; - - const songs: Song[] = []; - for (const medium of release.media ?? []) { - for (const track of medium.tracks ?? []) { - songs.push( - normalizeRecordingToSong( - release, - medium, - track, - albumArtistName, - albumArtists, - albumId, - imageUrl, - releaseDate, - releaseYear, - ), - ); - } - } - - const totalDuration = songs.reduce((sum, s) => sum + s.duration, 0); - - return { - _itemType: LibraryItem.ALBUM, - _serverId: 'musicbrainz', - _serverType: ServerType.EXTERNAL, - albumArtistName, - albumArtists, - artists: [], - comment: null, - createdAt: '', - duration: totalDuration || null, - explicitStatus: null, - genres: [], - id: albumId, - imageId: null, - imageUrl, - isCompilation, - lastPlayedAt: null, - mbzId: release.id, - mbzReleaseGroupId: releaseGroup?.id || null, - name: release.title, - originalDate, - originalYear, - participants: {}, - playCount: null, - recordLabels: [], - releaseDate, - releaseType: primaryReleaseType, - releaseTypes, - releaseYear, - size: null, - songCount: songs.length, - songs, - sortName: release.title, - tags: {}, - updatedAt: '', - userFavorite: false, - userRating: null, - version: null, - }; -} - export const musicbrainzQueries = { artist: (args: { excludeReleaseTypes?: string[]; diff --git a/src/renderer/features/musicbrainz/api/youtube-api.ts b/src/renderer/features/musicbrainz/api/youtube-api.ts new file mode 100644 index 000000000..27412d35c --- /dev/null +++ b/src/renderer/features/musicbrainz/api/youtube-api.ts @@ -0,0 +1,17 @@ +import { queryOptions } from '@tanstack/react-query'; + +async function searchYoutube(query: string): Promise> { + if (typeof window !== 'undefined' && window.api?.youtube) { + return window.api.youtube.search(query); + } + return []; +} + +export const youtubeQueries = { + search: (args: { query: string }) => { + return queryOptions({ + queryFn: () => searchYoutube(args.query), + queryKey: ['youtube', 'search', args.query], + }); + }, +}; diff --git a/src/renderer/features/musicbrainz/utils.ts b/src/renderer/features/musicbrainz/utils.ts index a667582bb..9dc3c3584 100644 --- a/src/renderer/features/musicbrainz/utils.ts +++ b/src/renderer/features/musicbrainz/utils.ts @@ -1,4 +1,9 @@ -import { MUSICBRAINZ_ID_PREFIX } from '/@/renderer/features/musicbrainz/api/musicbrainz-api'; +import { IArtist, IRelease, IMedium, ITrack, IWork } from 'musicbrainz-api'; +import { + IRelationWithWork, + MUSICBRAINZ_ID_PREFIX, +} from '/@/renderer/features/musicbrainz/api/musicbrainz-api'; +import { RelatedArtist, Song, LibraryItem, ServerType, Album } from '/@/shared/types/domain-types'; export function getImageUrl(releaseId: string): string { return `https://coverartarchive.org/release/${releaseId}/front-250.jpg`; @@ -293,3 +298,201 @@ const MBZ_RELEASE_TYPES = { soundtrack: 'soundtrack', spokenword: 'spokenword', }; +function normalizeArtistCreditToRelatedArtists( + artistCredit: Array<{ artist: IArtist; name: string }>, +): RelatedArtist[] { + return artistCredit.map((ac) => ({ + id: `musicbrainz-${ac.artist.id}`, + imageId: null, + imageUrl: null, + name: ac.name || ac.artist.name, + userFavorite: false, + userRating: null, + })); +} +function normalizeRecordingToSong( + release: IRelease, + medium: IMedium, + track: ITrack, + albumArtistName: string, + albumArtists: RelatedArtist[], + albumId: string, + imageUrl: null | string, + releaseDate: null | string, + releaseYear: null | number, +): Song { + const recording = track.recording; + const trackArtistCredit = track['artist-credit'] ?? recording['artist-credit'] ?? []; + + const artistName = + trackArtistCredit.map((ac) => ac.name).join('') || recording.title || track.title; + + const artists = normalizeArtistCreditToRelatedArtists( + trackArtistCredit as Array<{ artist: IArtist; name: string }>, + ); + + const durationMilliseconds = track.length || recording.length || 0; + const trackNumber = track.position || parseInt(track.number, 10) || 0; + + return { + _itemType: LibraryItem.SONG, + _serverId: 'musicbrainz', + _serverType: ServerType.EXTERNAL, + album: release.title, + albumArtistName, + albumArtists, + albumId, + artistName, + artists, + bitDepth: null, + bitRate: 0, + bpm: null, + channels: null, + comment: null, + compilation: null, + container: null, + createdAt: '', + discNumber: medium.position || 1, + discSubtitle: medium.title || null, + duration: durationMilliseconds, + explicitStatus: null, + gain: null, + genres: [], + id: `musicbrainz-${release.id}-${recording.id}-${track.position}-${track.number}`, + imageId: null, + imageUrl, + lastPlayedAt: null, + lyrics: null, + mbzRecordingId: recording.id, + mbzTrackId: track.id, + name: track.title || recording.title, + participants: {}, + path: null, + peak: null, + playCount: 0, + releaseDate, + releaseYear, + sampleRate: null, + size: 0, + sortName: track.title || recording.title, + tags: null, + trackNumber, + trackSubtitle: null, + updatedAt: '', + userFavorite: false, + userRating: null, + }; +} +export function normalizeReleaseToAlbum(release: IRelease): Album { + const releaseGroup = release['release-group']; + const artistCredit = release['artist-credit'] ?? releaseGroup?.['artist-credit'] ?? []; + const albumArtistName = artistCredit.map((ac) => ac.name).join('') || release.title; + const albumArtists: RelatedArtist[] = (artistCredit as { artist: IArtist; name: string }[]).map( + (ac) => ({ + id: `musicbrainz-${ac.artist.id}`, + imageId: null, + imageUrl: null, + name: ac.name || ac.artist.name, + userFavorite: false, + userRating: null, + }), + ); + + const hasArtwork = + release['cover-art-archive']?.artwork === true && + release['cover-art-archive']?.front === true; + const primaryReleaseType = releaseGroup?.['primary-type']?.toLowerCase() || null; + const secondaryReleaseTypes = + releaseGroup?.['secondary-types']?.map((type) => type.toLowerCase()) || []; + const releaseTypes = [primaryReleaseType, ...secondaryReleaseTypes].filter( + (type) => type !== null, + ) as string[]; + const isCompilation = releaseTypes.includes('compilation'); + const originalDate = releaseGroup?.['first-release-date'] || null; + const originalYear = originalDate ? Number(originalDate.split('-')[0]) : null; + const releaseDate = release.date ? release.date : null; + const releaseYear = release.date ? Number(release.date.split('-')[0]) : null; + const imageUrl = hasArtwork ? getImageUrl(release.id) : null; + const albumId = `musicbrainz-${release.id}`; + + const songs: Song[] = []; + for (const medium of release.media ?? []) { + for (const track of medium.tracks ?? []) { + songs.push( + normalizeRecordingToSong( + release, + medium, + track, + albumArtistName, + albumArtists, + albumId, + imageUrl, + releaseDate, + releaseYear, + ), + ); + } + } + + const totalDuration = songs.reduce((sum, s) => sum + s.duration, 0); + + return { + _itemType: LibraryItem.ALBUM, + _serverId: 'musicbrainz', + _serverType: ServerType.EXTERNAL, + albumArtistName, + albumArtists, + artists: [], + comment: null, + createdAt: '', + duration: totalDuration || null, + explicitStatus: null, + genres: [], + id: albumId, + imageId: null, + imageUrl, + isCompilation, + lastPlayedAt: null, + mbzId: release.id, + mbzReleaseGroupId: releaseGroup?.id || null, + name: release.title, + originalDate, + originalYear, + participants: {}, + playCount: null, + recordLabels: [], + releaseDate, + releaseType: primaryReleaseType, + releaseTypes, + releaseYear, + size: null, + songCount: songs.length, + songs, + sortName: release.title, + tags: {}, + updatedAt: '', + userFavorite: false, + userRating: null, + version: null, + }; +} +export function collectWorksFromRelease(release: IRelease): IWork[] { + const works: IWork[] = []; + const seenIds = new Set(); + + for (const medium of release.media ?? []) { + for (const track of medium.tracks ?? []) { + const recording = track.recording; + const relations = (recording as { relations?: IRelationWithWork[] })?.relations ?? []; + for (const rel of relations) { + const work = (rel as IRelationWithWork).work; + if (work?.id && !seenIds.has(work.id)) { + seenIds.add(work.id); + works.push(work); + } + } + } + } + + return works; +} diff --git a/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx b/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx index c0462a95b..b5ab90ba5 100644 --- a/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx +++ b/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx @@ -1,8 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; import { useMemo, useRef } from 'react'; import { api } from '/@/renderer/api'; +import { youtubeQueries } from '/@/renderer/features/musicbrainz/api/youtube-api'; import { TranscodingConfig } from '/@/renderer/store'; -import { QueueSong } from '/@/shared/types/domain-types'; +import { QueueSong, ServerType } from '/@/shared/types/domain-types'; + +const YOUTUBE_WATCH_BASE = 'https://www.youtube.com/watch?v='; export function useSongUrl( song: QueueSong | undefined, @@ -11,10 +15,36 @@ export function useSongUrl( ): string | undefined { const prior = useRef(['', '']); + const isExternal = song?._serverType === ServerType.EXTERNAL; + const searchQuery = + song && isExternal ? `${song.artistName ?? ''} ${song.name ?? ''}`.trim() : ''; + + const youtubeSearch = useQuery({ + ...youtubeQueries.search({ query: searchQuery }), + enabled: Boolean(song && isExternal && searchQuery), + }); + + const externalUrl = useMemo(() => { + if (!song || !isExternal) return undefined; + if (current && prior.current[0] === song._uniqueId && prior.current[1]) { + return prior.current[1]; + } + const url = getYoutubeUrlFromSearchResults(youtubeSearch.data); + if (url) prior.current = [song._uniqueId, url]; + return url; + }, [song, isExternal, current, youtubeSearch.data]); + return useMemo(() => { - if (song?._serverId) { - // If we are the current track, we do not want a transcoding - // reconfiguration to force a restart. + if (!song) { + prior.current = ['', '']; + return undefined; + } + + if (isExternal) { + return externalUrl; + } + + if (song._serverId) { if (current && prior.current[0] === song._uniqueId) { return prior.current[1]; } @@ -29,18 +59,16 @@ export function useSongUrl( }, }); - // transcoding enabled; save the updated result prior.current = [song._uniqueId, url]; return url; } - // no track; clear result prior.current = ['', '']; return undefined; }, [ - song?._serverId, - song?._uniqueId, - song?.id, + song, + isExternal, + externalUrl, current, transcode.bitrate, transcode.format, @@ -48,6 +76,16 @@ export function useSongUrl( ]); } +function getYoutubeUrlFromSearchResults( + results: Array<{ type: string; videoId?: string }> | undefined, +): string | undefined { + if (!results?.length) return undefined; + const first = results.find((r) => r.type === 'SONG' || r.type === 'VIDEO'); + return first && 'videoId' in first && first.videoId + ? `${YOUTUBE_WATCH_BASE}${first.videoId}` + : undefined; +} + export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => { return api.controller.getStreamUrl({ apiClientProps: { serverId: song._serverId }, diff --git a/src/renderer/features/player/hooks/use-update-current-song.ts b/src/renderer/features/player/hooks/use-update-current-song.ts index 72ff1801d..8265072e2 100644 --- a/src/renderer/features/player/hooks/use-update-current-song.ts +++ b/src/renderer/features/player/hooks/use-update-current-song.ts @@ -7,7 +7,7 @@ import { queryKeys } from '/@/renderer/api/query-keys'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { updateQueueSong } from '/@/renderer/store/player.store'; import { LogCategory, logFn } from '/@/renderer/utils/logger'; -import { QueueSong, SongDetailQuery } from '/@/shared/types/domain-types'; +import { QueueSong, ServerType, SongDetailQuery } from '/@/shared/types/domain-types'; export const useUpdateCurrentSong = () => { const queryClient = useQueryClient(); @@ -16,7 +16,11 @@ export const useUpdateCurrentSong = () => { async (properties: { index: number; song: QueueSong | undefined }) => { const currentSong = properties.song; - if (!currentSong?.id || !currentSong?._serverId) { + if ( + !currentSong?.id || + !currentSong?._serverId || + currentSong?._serverType === ServerType.EXTERNAL + ) { return; }