add experimental ytmusic playback for external songs

This commit is contained in:
jeffvli
2026-02-06 20:47:27 -08:00
parent 40ec16e191
commit 8e603871b7
11 changed files with 460 additions and 221 deletions
+1
View File
@@ -4,3 +4,4 @@ import './player';
import './remote';
import './settings';
import './discord-rpc';
import './youtube';
+18
View File
@@ -0,0 +1,18 @@
import { ipcMain } from 'electron';
import YTMusic from 'ytmusic-api';
let youtubeApi: InstanceType<typeof YTMusic> | null = null;
const getYoutubeApi = async (): Promise<InstanceType<typeof YTMusic>> => {
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;
});
+2
View File
@@ -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;
+11
View File
@@ -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;
@@ -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<string>();
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<IBrowseReleasesResult> {
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[];
@@ -0,0 +1,17 @@
import { queryOptions } from '@tanstack/react-query';
async function searchYoutube(query: string): Promise<Array<{ type: string; videoId?: string }>> {
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],
});
},
};
+204 -1
View File
@@ -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<string>();
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;
}
@@ -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 },
@@ -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;
}