mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-12 11:01:45 +00:00
feat: Better Scrobbler Plugin (#1640)
Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
14
src/plugins/scrobbler/services/base.ts
Normal file
14
src/plugins/scrobbler/services/base.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { ScrobblerPluginConfig } from '../index';
|
||||
import { SetConfType } from '../main';
|
||||
|
||||
import type { SongInfo } from '@/providers/song-info';
|
||||
|
||||
export abstract class ScrobblerBase {
|
||||
public abstract isSessionCreated(config: ScrobblerPluginConfig): boolean;
|
||||
|
||||
public abstract createSession(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<ScrobblerPluginConfig>;
|
||||
|
||||
public abstract setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void;
|
||||
|
||||
public abstract addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void;
|
||||
}
|
||||
216
src/plugins/scrobbler/services/lastfm.ts
Normal file
216
src/plugins/scrobbler/services/lastfm.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { net, shell } from 'electron';
|
||||
|
||||
import { ScrobblerBase } from './base';
|
||||
|
||||
import { ScrobblerPluginConfig } from '../index';
|
||||
import { SetConfType } from '../main';
|
||||
|
||||
import type { SongInfo } from '@/providers/song-info';
|
||||
|
||||
interface LastFmData {
|
||||
method: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface LastFmSongData {
|
||||
track?: string;
|
||||
duration?: number;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
api_key: string;
|
||||
sk?: string;
|
||||
format: string;
|
||||
method: string;
|
||||
timestamp?: number;
|
||||
api_sig?: string;
|
||||
}
|
||||
|
||||
export class LastFmScrobbler extends ScrobblerBase {
|
||||
isSessionCreated(config: ScrobblerPluginConfig): boolean {
|
||||
return !!config.scrobblers.lastfm.session_key;
|
||||
}
|
||||
|
||||
async createSession(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<ScrobblerPluginConfig> {
|
||||
// Get and store the session key
|
||||
const data = {
|
||||
api_key: config.scrobblers.lastfm.api_key,
|
||||
format: 'json',
|
||||
method: 'auth.getsession',
|
||||
token: config.scrobblers.lastfm.token,
|
||||
};
|
||||
const apiSignature = createApiSig(data, config.scrobblers.lastfm.secret);
|
||||
const response = await net.fetch(
|
||||
`${config.scrobblers.lastfm.api_root}${createQueryString(data, apiSignature)}`,
|
||||
);
|
||||
const json = (await response.json()) as {
|
||||
error?: string;
|
||||
session?: {
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
if (json.error) {
|
||||
config.scrobblers.lastfm.token = await createToken(config);
|
||||
await authenticate(config);
|
||||
setConfig(config);
|
||||
}
|
||||
if (json.session) {
|
||||
config.scrobblers.lastfm.session_key = json.session.key;
|
||||
}
|
||||
setConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void {
|
||||
if (!config.scrobblers.lastfm.session_key) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This sets the now playing status in last.fm
|
||||
const data = {
|
||||
method: 'track.updateNowPlaying',
|
||||
};
|
||||
this.postSongDataToAPI(songInfo, config, data, setConfig);
|
||||
}
|
||||
|
||||
addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void {
|
||||
if (!config.scrobblers.lastfm.session_key) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This adds one scrobbled song to last.fm
|
||||
const data = {
|
||||
method: 'track.scrobble',
|
||||
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
|
||||
};
|
||||
this.postSongDataToAPI(songInfo, config, data, setConfig);
|
||||
}
|
||||
|
||||
async postSongDataToAPI(
|
||||
songInfo: SongInfo,
|
||||
config: ScrobblerPluginConfig,
|
||||
data: LastFmData,
|
||||
setConfig: SetConfType,
|
||||
): Promise<void> {
|
||||
// This sends a post request to the api, and adds the common data
|
||||
if (!config.scrobblers.lastfm.session_key) {
|
||||
await this.createSession(config, setConfig);
|
||||
}
|
||||
|
||||
const postData: LastFmSongData = {
|
||||
track: songInfo.title,
|
||||
duration: songInfo.songDuration,
|
||||
artist: songInfo.artist,
|
||||
...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video
|
||||
api_key: config.scrobblers.lastfm.api_key,
|
||||
sk: config.scrobblers.lastfm.session_key,
|
||||
format: 'json',
|
||||
...data,
|
||||
};
|
||||
|
||||
postData.api_sig = createApiSig(postData, config.scrobblers.lastfm.secret);
|
||||
const formData = createFormData(postData);
|
||||
net
|
||||
.fetch('https://ws.audioscrobbler.com/2.0/', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.catch(
|
||||
async (error: {
|
||||
response?: {
|
||||
data?: {
|
||||
error: number;
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
if (error?.response?.data?.error === 9) {
|
||||
// Session key is invalid, so remove it from the config and reauthenticate
|
||||
config.scrobblers.lastfm.session_key = undefined;
|
||||
config.scrobblers.lastfm.token = await createToken(config);
|
||||
await authenticate(config);
|
||||
setConfig(config);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const createFormData = (parameters: LastFmSongData) => {
|
||||
// Creates the body for in the post request
|
||||
const formData = new URLSearchParams();
|
||||
for (const key in parameters) {
|
||||
formData.append(key, String(parameters[key as keyof LastFmSongData]));
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
const createQueryString = (
|
||||
parameters: Record<string, unknown>,
|
||||
apiSignature: string,
|
||||
) => {
|
||||
// Creates a querystring
|
||||
const queryData = [];
|
||||
parameters.api_sig = apiSignature;
|
||||
for (const key in parameters) {
|
||||
queryData.push(
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(
|
||||
String(parameters[key]),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return '?' + queryData.join('&');
|
||||
};
|
||||
|
||||
const createApiSig = (parameters: LastFmSongData, secret: string) => {
|
||||
// This function creates the api signature, see: https://www.last.fm/api/authspec
|
||||
const keys = Object.keys(parameters);
|
||||
|
||||
keys.sort();
|
||||
let sig = '';
|
||||
for (const key of keys) {
|
||||
if (key === 'format') {
|
||||
continue;
|
||||
}
|
||||
|
||||
sig += `${key}${parameters[key as keyof LastFmSongData]}`;
|
||||
}
|
||||
|
||||
sig += secret;
|
||||
sig = crypto.createHash('md5').update(sig, 'utf-8').digest('hex');
|
||||
return sig;
|
||||
};
|
||||
|
||||
const createToken = async ({
|
||||
scrobblers: {
|
||||
lastfm: {
|
||||
api_key: apiKey,
|
||||
api_root: apiRoot,
|
||||
secret,
|
||||
}
|
||||
}
|
||||
}: ScrobblerPluginConfig) => {
|
||||
// Creates and stores the auth token
|
||||
const data = {
|
||||
method: 'auth.gettoken',
|
||||
api_key: apiKey,
|
||||
format: 'json',
|
||||
};
|
||||
const apiSigature = createApiSig(data, secret);
|
||||
const response = await net.fetch(
|
||||
`${apiRoot}${createQueryString(data, apiSigature)}`,
|
||||
);
|
||||
const json = (await response.json()) as Record<string, string>;
|
||||
return json?.token;
|
||||
};
|
||||
|
||||
const authenticate = async (config: ScrobblerPluginConfig) => {
|
||||
// Asks the user for authentication
|
||||
await shell.openExternal(
|
||||
`https://www.last.fm/api/auth/?api_key=${config.scrobblers.lastfm.api_key}&token=${config.scrobblers.lastfm.token}`,
|
||||
);
|
||||
};
|
||||
92
src/plugins/scrobbler/services/listenbrainz.ts
Normal file
92
src/plugins/scrobbler/services/listenbrainz.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { net } from 'electron';
|
||||
|
||||
import { ScrobblerBase } from './base';
|
||||
|
||||
import { SetConfType } from '../main';
|
||||
|
||||
import type { SongInfo } from '@/providers/song-info';
|
||||
|
||||
import type { ScrobblerPluginConfig } from '../index';
|
||||
|
||||
interface ListenbrainzRequestBody {
|
||||
listen_type?: string;
|
||||
payload: {
|
||||
track_metadata?: {
|
||||
artist_name?: string;
|
||||
track_name?: string;
|
||||
release_name?: string;
|
||||
additional_info?: {
|
||||
media_player?: string;
|
||||
submission_client?: string;
|
||||
origin_url?: string;
|
||||
duration?: number;
|
||||
};
|
||||
};
|
||||
listened_at?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export class ListenbrainzScrobbler extends ScrobblerBase {
|
||||
isSessionCreated(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
createSession(config: ScrobblerPluginConfig, _setConfig: SetConfType): Promise<ScrobblerPluginConfig> {
|
||||
return Promise.resolve(config);
|
||||
}
|
||||
|
||||
setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void {
|
||||
if (!config.scrobblers.listenbrainz.api_root || !config.scrobblers.listenbrainz.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = createRequestBody('playing_now', songInfo);
|
||||
submitListen(body, config);
|
||||
}
|
||||
|
||||
addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void {
|
||||
if (!config.scrobblers.listenbrainz.api_root || !config.scrobblers.listenbrainz.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = createRequestBody('single', songInfo);
|
||||
body.payload[0].listened_at = Math.trunc(Date.now() / 1000);
|
||||
|
||||
submitListen(body, config);
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestBody(listenType: string, songInfo: SongInfo): ListenbrainzRequestBody {
|
||||
const trackMetadata = {
|
||||
artist_name: songInfo.artist,
|
||||
track_name: songInfo.title,
|
||||
release_name: songInfo.album ?? undefined,
|
||||
additional_info: {
|
||||
media_player: 'YouTube Music Desktop App',
|
||||
submission_client: 'YouTube Music Desktop App - Scrobbler Plugin',
|
||||
origin_url: songInfo.url,
|
||||
duration: songInfo.songDuration,
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
listen_type: listenType,
|
||||
payload: [
|
||||
{
|
||||
track_metadata: trackMetadata,
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function submitListen(body: ListenbrainzRequestBody, config: ScrobblerPluginConfig) {
|
||||
net.fetch(config.scrobblers.listenbrainz.api_root + 'submit-listens',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'Authorization': 'Token ' + config.scrobblers.listenbrainz.token,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}).catch(console.error);
|
||||
}
|
||||
Reference in New Issue
Block a user