support adding to spotify playlist

This commit is contained in:
2025-07-03 18:38:40 +02:00
parent a8b6a309f0
commit a0757ea3ff
11 changed files with 478 additions and 213 deletions

View File

@ -5,7 +5,7 @@ import {
ODESLI_API_KEY,
YOUTUBE_API_KEY
} from '$env/static/private';
import { log } from '$lib/log';
import { log, Logger } from '$lib/log';
import type {
Account,
AccountAvatar,
@ -34,6 +34,7 @@ import { console } from 'inspector/promises';
import sharp from 'sharp';
import { URL, URLSearchParams } from 'url';
import { WebSocket } from 'ws';
import { SpotifyPlaylistAdder } from './spotifyPlaylistAdder';
const URL_REGEX = new RegExp(/href="(?<postUrl>[^>]+?)" target="_blank"/gm);
const INVIDIOUS_REGEX = new RegExp(/invidious.*?watch.*?v=(?<videoId>[a-zA-Z_0-9-]+)/gm);
@ -45,6 +46,7 @@ export class TimelineReader {
private static _instance: TimelineReader;
private lastPosts: string[] = [];
private youtubePlaylistAdder: YoutubePlaylistAdder;
private spotifyPlaylistAdder: SpotifyPlaylistAdder;
private static async isMusicVideo(videoId: string) {
if (!YOUTUBE_API_KEY || YOUTUBE_API_KEY === 'CHANGE_ME') {
@ -168,10 +170,13 @@ export class TimelineReader {
return null;
}
}
const spotify: Platform = 'spotify';
return {
...info,
pageUrl: odesliInfo.pageUrl,
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
spotifyUrl: odesliInfo.linksByPlatform[spotify]?.url,
spotifyUri: odesliInfo.linksByPlatform[spotify]?.nativeAppUriDesktop,
postedUrl: url.toString()
} as SongInfo;
} catch (e) {
@ -263,8 +268,8 @@ export class TimelineReader {
}
*/
private async addToPlaylist(song: SongInfo) {
//await this.addToYoutubePlaylist(song);
await this.youtubePlaylistAdder.addToPlaylist(song);
await this.spotifyPlaylistAdder.addToPlaylist(song);
}
private static async resizeAvatar(
@ -472,78 +477,28 @@ export class TimelineReader {
}
private startWebsocket() {
const socketLogger = new Logger('Websocket');
const socket = new WebSocket(
`wss://${MASTODON_INSTANCE}/api/v1/streaming?type=subscribe&stream=public:local&access_token=${MASTODON_ACCESS_TOKEN}`
);
socket.onopen = () => {
log.log('Connected to WS');
socketLogger.log('Connected to WS');
};
socket.onmessage = async (event) => {
try {
/*
let token: OauthResponse;
try {
const youtube_token_file = await fs.readFile('yt_auth_token', { encoding: 'utf8' });
token = JSON.parse(youtube_token_file);
if (token.expires) {
if (typeof token.expires === typeof '') {
token.expires = new Date(token.expires);
}
let now = new Date();
now.setTime(now.getTime() - 15 * 60 * 1000);
log.info('token expiry', token.expires, 'vs refresh @', now);
if (token.expires.getTime() <= now.getTime()) {
log.info(
'YT token expires',
token.expires,
token.expires.getTime(),
'which is less than 15 minutes from now',
now,
now.getTime()
);
const tokenUrl = new URL('https://oauth2.googleapis.com/token');
const params = new URLSearchParams();
params.append('client_id', YOUTUBE_CLIENT_ID);
params.append('client_secret', YOUTUBE_CLIENT_SECRET);
params.append('refresh_token', token.refresh_token || '');
params.append('grant_type', 'refresh_token');
params.append('redirect_uri', `${BASE_URL}/ytauth`);
if (token.refresh_token) {
log.debug('sending token req', params);
const resp = await fetch(tokenUrl, {
method: 'POST',
body: params
}).then((r) => r.json());
if (!resp.error) {
if (!resp.refresh_token) {
resp.refresh_token = token.refresh_token;
}
let expiration = new Date();
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
resp.expires = expiration;
await fs.writeFile('yt_auth_token', JSON.stringify(resp));
} else {
log.error('token resp error', resp);
}
} else {
log.error('no refresg token');
}
}
}
} catch (e) {
log.error('onmessage Could not read youtube access token', e);
}
*/
const data: TimelineEvent = JSON.parse(event.data.toString());
log.debug('ES event', data.event);
socketLogger.debug('ES event', data.event);
if (data.event !== 'update') {
log.log('Ignoring ES event', data.event);
socketLogger.log('Ignoring ES event', data.event);
return;
}
const post: Post = JSON.parse(data.payload);
// Sometimes onmessage is called twice for the same post.
// This looks to be an issue with automatic reloading in the dev environment,
// but hard to tell
if (this.lastPosts.includes(post.id)) {
log.log('Skipping post, already handled', post.id);
socketLogger.log('Skipping post, already handled', post.id);
return;
}
this.lastPosts.push(post.id);
@ -552,21 +507,21 @@ export class TimelineReader {
}
await this.checkAndSavePost(post);
} catch (e) {
log.error('error message', event, event.data, e);
socketLogger.error('error message', event, event.data, e);
}
};
socket.onclose = (event) => {
log.warn(
socketLogger.warn(
`Websocket connection to ${MASTODON_INSTANCE} closed. Code: ${event.code}, reason: '${event.reason}'`,
event
);
setTimeout(() => {
log.info(`Attempting to reconenct to WS`);
socketLogger.info(`Attempting to reconenct to WS`);
this.startWebsocket();
}, 10000);
};
socket.onerror = (event) => {
log.error(
socketLogger.error(
`Websocket connection to ${MASTODON_INSTANCE} failed. ${event.type}: ${event.error}, message: '${event.message}'`
);
};
@ -600,6 +555,7 @@ export class TimelineReader {
private constructor() {
log.log('Constructing timeline object');
this.youtubePlaylistAdder = new YoutubePlaylistAdder();
this.spotifyPlaylistAdder = new SpotifyPlaylistAdder();
this.startWebsocket();
this.loadPostsSinceLastRun()