3 Commits

Author SHA1 Message Date
45678d7e6a Fixed detection if youtube video is music or not 2023-06-24 03:39:42 +02:00
d57888678d Fix #31 2023-06-20 08:20:30 +02:00
db80b929ca Fix #25 2023-06-16 15:51:57 +02:00
2 changed files with 108 additions and 19 deletions

View File

@ -96,7 +96,8 @@ and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY` and `ODESLI_API_KEY`.
To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an
_API key_.
If `YOUTUBE_API_KEY` is unset, no playlist will be updated.
If `YOUTUBE_API_KEY` is unset, no playlist will be updated. Also, _all_ YouTube links will be treated as music videos,
because the API is the only way to check if a YouTube link leads to music or something else.
If `ODESLI_API_KEY` is unset, your rate limit to the song.link API will be lower.

View File

@ -1,5 +1,10 @@
import { HASHTAG_FILTER, MASTODON_INSTANCE, ODESLI_API_KEY } from '$env/static/private';
import { log } from '$lib/log';
import {
HASHTAG_FILTER,
MASTODON_INSTANCE,
ODESLI_API_KEY,
YOUTUBE_API_KEY
} from '$env/static/private';
import { enableVerboseLog, log } from '$lib/log';
import type {
Account,
AccountAvatar,
@ -27,10 +32,73 @@ import sharp from 'sharp';
import { WebSocket } from 'ws';
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);
const YOUTUBE_REGEX = new RegExp(
/https?:\/\/(www\.)?youtu((be.com\/.*?v=)|(\.be\/))(?<videoId>[a-zA-Z_0-9-]+)/gm
);
export class TimelineReader {
private static _instance: TimelineReader;
private static async isMusicVideo(videoId: string) {
if (!YOUTUBE_API_KEY || YOUTUBE_API_KEY === 'CHANGE_ME') {
// Assume that it *is* a music link when no YT API key is provided
return true;
}
const searchParams = new URLSearchParams([
['part', 'snippet'],
['id', videoId],
['key', YOUTUBE_API_KEY]
]);
const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`);
const resp = await fetch(youtubeVideoUrl);
const respObj = await resp.json();
if (!respObj.items.length) {
console.warn('Could not find video with id', videoId);
return false;
}
const item = respObj.items[0];
if (!item.snippet) {
console.warn('Could not load snippet for video', videoId, item);
return false;
}
if (item.snippet.tags?.includes('music')) {
return true;
}
const categorySearchParams = new URLSearchParams([
['part', 'snippet'],
['id', item.snippet.categoryId],
['key', YOUTUBE_API_KEY]
]);
const youtubeCategoryUrl = new URL(
`https://www.googleapis.com/youtube/v3/videoCategories?${categorySearchParams}`
);
const categoryTitle: string = await fetch(youtubeCategoryUrl)
.then((r) => r.json())
.then((r) => r.items[0]?.snippet?.title);
if (enableVerboseLog) {
log.verbose(
'Video',
videoId,
'category',
categoryTitle,
'tags',
item.snippet.tags,
'category id',
item.snippet.categoryId,
'response',
respObj,
'snippet',
item.snippet
);
} else {
log.debug('Video', videoId, 'category', categoryTitle);
}
return categoryTitle === 'Music';
}
public static async getSongInfoInPost(post: Post): Promise<SongInfo[]> {
const urlMatches = post.content.matchAll(URL_REGEX);
const songs: SongInfo[] = [];
@ -71,8 +139,12 @@ export class TimelineReader {
return null;
}
const videoId = INVIDIOUS_REGEX.exec(url.href)?.groups?.videoId;
const urlString =
videoId !== undefined ? `https://youtube.com/watch?v=${videoId}` : url.toString();
const odesliParams = new URLSearchParams();
odesliParams.append('url', url.toString());
odesliParams.append('url', urlString);
odesliParams.append('userCountry', 'DE');
odesliParams.append('songIfSingle', 'true');
if (ODESLI_API_KEY && ODESLI_API_KEY !== 'CHANGE_ME') {
@ -80,23 +152,39 @@ export class TimelineReader {
}
const odesliApiUrl = `https://api.song.link/v1-alpha.1/links?${odesliParams}`;
try {
return fetch(odesliApiUrl).then(async (response) => {
if (response.status === 429) {
throw new Error('Rate limit reached', { cause: 429 });
}
const odesliInfo: OdesliResponse = await response.json();
if (!odesliInfo || !odesliInfo.entitiesByUniqueId || !odesliInfo.entityUniqueId) {
const response = await fetch(odesliApiUrl);
log.debug('received odesli response', response.status);
if (response.status === 429) {
throw new Error('Rate limit reached', { cause: 429 });
}
const odesliInfo: OdesliResponse = await response.json();
if (!odesliInfo || !odesliInfo.entitiesByUniqueId || !odesliInfo.entityUniqueId) {
return null;
}
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
const platform: Platform = 'youtube';
log.debug(url, 'odesli response', info, 'YT URL', odesliInfo.linksByPlatform[platform]?.url);
if (info.platforms.includes(platform)) {
let youtubeId =
videoId ??
YOUTUBE_REGEX.exec(url.href)?.groups?.videoId ??
new URL(odesliInfo.pageUrl).pathname.split('/y/').pop();
if (youtubeId === undefined) {
log.warn('Looks like a youtube video, but could not extract a video id', url, odesliInfo);
return null;
}
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
const platform: Platform = 'youtube';
return {
...info,
pageUrl: odesliInfo.pageUrl,
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
postedUrl: url.toString()
} as SongInfo;
});
const isMusic = await TimelineReader.isMusicVideo(youtubeId);
if (!isMusic) {
log.debug('Probably not a music video', url, odesliInfo);
return null;
}
}
return {
...info,
pageUrl: odesliInfo.pageUrl,
youtubeUrl: odesliInfo.linksByPlatform[platform]?.url,
postedUrl: url.toString()
} as SongInfo;
} catch (e) {
if (e instanceof Error && e.cause === 429) {
log.warn('song.link rate limit reached. Trying again in 10 seconds');