diff --git a/README.md b/README.md index 5114ec7..7de44c9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/lib/server/timeline.ts b/src/lib/server/timeline.ts index b2a68fb..680dff3 100644 --- a/src/lib/server/timeline.ts +++ b/src/lib/server/timeline.ts @@ -1,4 +1,9 @@ -import { HASHTAG_FILTER, MASTODON_INSTANCE, ODESLI_API_KEY } from '$env/static/private'; +import { + HASHTAG_FILTER, + MASTODON_INSTANCE, + ODESLI_API_KEY, + YOUTUBE_API_KEY +} from '$env/static/private'; import { log } from '$lib/log'; import type { Account, @@ -28,10 +33,50 @@ import { WebSocket } from 'ws'; const URL_REGEX = new RegExp(/href="(?[^>]+?)" target="_blank"/gm); const INVIDIOUS_REGEX = new RegExp(/invidious.*?watch.*?v=(?[a-zA-Z_0-9-]+)/gm); +const YOUTUBE_REGEX = new RegExp( + /https?:\/\/(www\.)?youtu((be.com\/.*?v=)|(\.be\/))(?[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.tags?.includes('music')) { + return true; + } + + const categorySearchParams = new URLSearchParams([ + ['part', 'snippet'], + ['id', item.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]?.title); + return categoryTitle === 'Music'; + } + public static async getSongInfoInPost(post: Post): Promise { const urlMatches = post.content.matchAll(URL_REGEX); const songs: SongInfo[] = []; @@ -85,23 +130,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');