mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-09 17:51:46 +00:00
287 lines
7.9 KiB
TypeScript
287 lines
7.9 KiB
TypeScript
import { type BrowserWindow, ipcMain, nativeImage, net } from 'electron';
|
|
|
|
import { Mutex } from 'async-mutex';
|
|
|
|
import config from '@/config';
|
|
|
|
import type { GetPlayerResponse } from '@/types/get-player-response';
|
|
|
|
export enum MediaType {
|
|
/**
|
|
* Audio uploaded by the original artist
|
|
*/
|
|
Audio = 'AUDIO',
|
|
/**
|
|
* Official music video uploaded by the original artist
|
|
*/
|
|
OriginalMusicVideo = 'ORIGINAL_MUSIC_VIDEO',
|
|
/**
|
|
* Normal YouTube video uploaded by a user
|
|
*/
|
|
UserGeneratedContent = 'USER_GENERATED_CONTENT',
|
|
/**
|
|
* Podcast episode
|
|
*/
|
|
PodcastEpisode = 'PODCAST_EPISODE',
|
|
OtherVideo = 'OTHER_VIDEO',
|
|
}
|
|
|
|
export interface SongInfo {
|
|
title: string;
|
|
alternativeTitle?: string;
|
|
artist: string;
|
|
artistUrl?: string;
|
|
views: number;
|
|
uploadDate?: string;
|
|
imageSrc?: string | null;
|
|
image?: Electron.NativeImage | null;
|
|
isPaused?: boolean;
|
|
songDuration: number;
|
|
elapsedSeconds?: number;
|
|
url?: string;
|
|
album?: string | null;
|
|
videoId: string;
|
|
playlistId?: string;
|
|
mediaType: MediaType;
|
|
tags?: string[];
|
|
}
|
|
|
|
// Grab the native image using the src
|
|
export const getImage = async (src: string): Promise<Electron.NativeImage> => {
|
|
const result = await net.fetch(src);
|
|
const output = nativeImage.createFromBuffer(
|
|
Buffer.from(await result.arrayBuffer()),
|
|
);
|
|
if (output.isEmpty() && !src.endsWith('.jpg') && src.includes('.jpg')) {
|
|
// Fix hidden webp files (https://github.com/th-ch/youtube-music/issues/315)
|
|
return getImage(src.slice(0, src.lastIndexOf('.jpg') + 4));
|
|
}
|
|
|
|
return output;
|
|
};
|
|
|
|
const handleData = async (
|
|
data: GetPlayerResponse,
|
|
win: Electron.BrowserWindow,
|
|
): Promise<SongInfo | null> => {
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
|
|
// Fill songInfo with empty values
|
|
const songInfo: SongInfo = {
|
|
title: '',
|
|
alternativeTitle: '',
|
|
artist: '',
|
|
artistUrl: '',
|
|
views: 0,
|
|
uploadDate: '',
|
|
imageSrc: '',
|
|
image: null,
|
|
isPaused: undefined,
|
|
songDuration: 0,
|
|
elapsedSeconds: 0,
|
|
url: '',
|
|
album: undefined,
|
|
videoId: '',
|
|
playlistId: '',
|
|
mediaType: MediaType.Audio,
|
|
tags: [],
|
|
} satisfies SongInfo;
|
|
|
|
const microformat = data.microformat?.microformatDataRenderer;
|
|
if (microformat) {
|
|
songInfo.uploadDate = microformat.uploadDate;
|
|
songInfo.url = microformat.urlCanonical?.split('&')[0];
|
|
songInfo.playlistId =
|
|
new URL(microformat.urlCanonical).searchParams.get('list') ?? '';
|
|
if (microformat.pageOwnerDetails?.externalChannelId) {
|
|
songInfo.artistUrl = `https://music.youtube.com/channel/${microformat.pageOwnerDetails.externalChannelId}`;
|
|
}
|
|
// Used for options.resumeOnStart
|
|
config.set('url', microformat.urlCanonical);
|
|
songInfo.alternativeTitle = microformat.linkAlternates.find(
|
|
(link) => link.title,
|
|
)?.title;
|
|
songInfo.tags = Array.isArray(microformat.tags) ? microformat.tags : [];
|
|
}
|
|
|
|
const { videoDetails } = data;
|
|
if (videoDetails) {
|
|
songInfo.title = cleanupName(videoDetails.title);
|
|
songInfo.artist = cleanupName(videoDetails.author);
|
|
songInfo.views = Number(videoDetails.viewCount);
|
|
songInfo.songDuration = Number(videoDetails.lengthSeconds);
|
|
songInfo.elapsedSeconds = videoDetails.elapsedSeconds;
|
|
songInfo.isPaused = videoDetails.isPaused;
|
|
songInfo.videoId = videoDetails.videoId;
|
|
songInfo.album = videoDetails.album; // Will be undefined if video exist
|
|
|
|
switch (videoDetails?.musicVideoType) {
|
|
case 'MUSIC_VIDEO_TYPE_ATV':
|
|
songInfo.mediaType = MediaType.Audio;
|
|
break;
|
|
case 'MUSIC_VIDEO_TYPE_OMV':
|
|
songInfo.mediaType = MediaType.OriginalMusicVideo;
|
|
break;
|
|
case 'MUSIC_VIDEO_TYPE_UGC':
|
|
songInfo.mediaType = MediaType.UserGeneratedContent;
|
|
break;
|
|
case 'MUSIC_VIDEO_TYPE_PODCAST_EPISODE':
|
|
songInfo.mediaType = MediaType.PodcastEpisode;
|
|
// HACK: Podcast's participant is not the artist
|
|
if (!config.get('options.usePodcastParticipantAsArtist')) {
|
|
songInfo.artist = cleanupName(
|
|
data.microformat.microformatDataRenderer.pageOwnerDetails.name,
|
|
);
|
|
}
|
|
break;
|
|
default:
|
|
songInfo.mediaType = MediaType.OtherVideo;
|
|
// HACK: This is a workaround for "podcast" types where "musicVideoType" doesn't exist. Google :facepalm:
|
|
if (
|
|
!config.get('options.usePodcastParticipantAsArtist') &&
|
|
(data.responseContext.serviceTrackingParams
|
|
?.at(0)
|
|
?.params?.find((it) => it.key === 'ipcc')?.value ?? '1') != '0'
|
|
) {
|
|
songInfo.artist = cleanupName(
|
|
data.microformat.microformatDataRenderer.pageOwnerDetails.name,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
|
|
const thumbnails = videoDetails.thumbnail?.thumbnails;
|
|
songInfo.imageSrc = thumbnails?.at(-1)?.url?.split('?')?.at(0);
|
|
|
|
if (
|
|
songInfo.imageSrc &&
|
|
!(await net.fetch(songInfo.imageSrc, { method: 'HEAD' })).ok
|
|
) {
|
|
songInfo.imageSrc = thumbnails.at(-1)?.url;
|
|
}
|
|
|
|
if (songInfo.imageSrc) songInfo.image = await getImage(songInfo.imageSrc);
|
|
|
|
win.webContents.send('ytmd:update-song-info', songInfo);
|
|
}
|
|
|
|
return songInfo;
|
|
};
|
|
|
|
export enum SongInfoEvent {
|
|
VideoSrcChanged = 'ytmd:video-src-changed',
|
|
PlayOrPaused = 'ytmd:play-or-paused',
|
|
TimeChanged = 'ytmd:time-changed',
|
|
}
|
|
|
|
// This variable will be filled with the callbacks once they register
|
|
export type SongInfoCallback = (
|
|
songInfo: SongInfo,
|
|
event: SongInfoEvent,
|
|
) => void;
|
|
const callbacks: Set<SongInfoCallback> = new Set();
|
|
|
|
// This function will allow plugins to register callback that will be triggered when data changes
|
|
const registerCallback = (callback: SongInfoCallback) => {
|
|
callbacks.add(callback);
|
|
};
|
|
|
|
const registerProvider = (win: BrowserWindow) => {
|
|
const dataMutex = new Mutex();
|
|
let songInfo: SongInfo | null = null;
|
|
|
|
// This will be called when the song-info-front finds a new request with song data
|
|
ipcMain.on('ytmd:video-src-changed', async (_, data: GetPlayerResponse) => {
|
|
const tempSongInfo = await dataMutex.runExclusive<SongInfo | null>(
|
|
async () => {
|
|
songInfo = await handleData(data, win);
|
|
return songInfo;
|
|
},
|
|
);
|
|
|
|
if (tempSongInfo) {
|
|
for (const c of callbacks) {
|
|
c(tempSongInfo, SongInfoEvent.VideoSrcChanged);
|
|
}
|
|
}
|
|
});
|
|
ipcMain.on(
|
|
'ytmd:play-or-paused',
|
|
async (
|
|
_,
|
|
{
|
|
isPaused,
|
|
elapsedSeconds,
|
|
}: { isPaused: boolean; elapsedSeconds: number },
|
|
) => {
|
|
const tempSongInfo = await dataMutex.runExclusive<SongInfo | null>(() => {
|
|
if (!songInfo) {
|
|
return null;
|
|
}
|
|
|
|
songInfo.isPaused = isPaused;
|
|
songInfo.elapsedSeconds = elapsedSeconds;
|
|
|
|
return songInfo;
|
|
});
|
|
|
|
if (tempSongInfo) {
|
|
for (const c of callbacks) {
|
|
c(tempSongInfo, SongInfoEvent.PlayOrPaused);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
ipcMain.on('ytmd:time-changed', async (_, seconds: number) => {
|
|
const tempSongInfo = await dataMutex.runExclusive<SongInfo | null>(() => {
|
|
if (!songInfo) {
|
|
return null;
|
|
}
|
|
|
|
songInfo.elapsedSeconds = seconds;
|
|
|
|
return songInfo;
|
|
});
|
|
|
|
if (tempSongInfo) {
|
|
for (const c of callbacks) {
|
|
c(tempSongInfo, SongInfoEvent.TimeChanged);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const suffixesToRemove = [
|
|
// Artist names
|
|
/\s*(- topic)$/i,
|
|
/\s*vevo$/i,
|
|
|
|
// Video titles
|
|
/\s*[(|[]official(.*?)[)|\]]/i, // (Official Music Video), [Official Visualizer], etc...
|
|
/\s*[(|[]((lyrics?|visualizer|audio)\s*(video)?)[)|\]]/i,
|
|
/\s*[(|[](performance video)[)|\]]/i,
|
|
/\s*[(|[](clip official)[)|\]]/i,
|
|
/\s*[(|[](video version)[)|\]]/i,
|
|
/\s*[(|[](HD|HQ)\s*?(?:audio)?[)|\]]$/i,
|
|
/\s*[(|[](live)[)|\]]$/i,
|
|
/\s*[(|[]4K\s*?(?:upgrade)?[)|\]]$/i,
|
|
];
|
|
|
|
export function cleanupName(name: string): string {
|
|
if (!name) {
|
|
return name;
|
|
}
|
|
|
|
for (const suffix of suffixesToRemove) {
|
|
name = name.replace(suffix, '');
|
|
}
|
|
|
|
return name;
|
|
}
|
|
|
|
export default registerCallback;
|
|
export const setupSongInfo = registerProvider;
|