diff --git a/materialious/src/lib/api/dearrow.ts b/materialious/src/lib/api/dearrow.ts new file mode 100644 index 00000000..46ed4429 --- /dev/null +++ b/materialious/src/lib/api/dearrow.ts @@ -0,0 +1,25 @@ +import { get } from 'svelte/store'; +import { fetchErrorHandle } from './invidious/request'; +import { deArrowInstanceStore, deArrowThumbnailInstanceStore } from '$lib/store'; +import type { DeArrow } from './model'; + +export async function getDeArrow(videoId: string, fetchOptions?: RequestInit): Promise { + const resp = await fetchErrorHandle( + await fetch(`${get(deArrowInstanceStore)}/api/branding?videoID=${videoId}`, fetchOptions) + ); + return await resp.json(); +} + +export async function getThumbnailDeArrow( + videoId: string, + time: number, + fetchOptions?: RequestInit +): Promise { + const resp = await fetchErrorHandle( + await fetch( + `${get(deArrowThumbnailInstanceStore)}/api/v1/getThumbnail?videoID=${videoId}&time=${time}`, + fetchOptions + ) + ); + return URL.createObjectURL(await resp.blob()); +} diff --git a/materialious/src/lib/api/history.ts b/materialious/src/lib/api/history.ts new file mode 100644 index 00000000..e69de29b diff --git a/materialious/src/lib/api/index.ts b/materialious/src/lib/api/index.ts index 434816a2..09de48fd 100644 --- a/materialious/src/lib/api/index.ts +++ b/materialious/src/lib/api/index.ts @@ -1,24 +1,12 @@ import { getVideoYTjs } from '$lib/api/youtubejs/video'; import { get } from 'svelte/store'; -import { - invidiousAuthStore, - deArrowInstanceStore, - deArrowThumbnailInstanceStore, - invidiousInstanceStore, - interfaceRegionStore, - playerYouTubeJsAlways, - playerYouTubeJsFallback, - rawMasterKeyStore, - returnYTDislikesInstanceStore -} from '../store'; +import { playerYouTubeJsAlways, rawMasterKeyStore } from '../store'; import type { ChannelPage, Comments, - DeArrow, Feed, PlaylistPage, ResolvedUrl, - ReturnYTDislikes, SearchSuggestion, Subscription, Video, @@ -51,69 +39,33 @@ import { getSubscriptionsBackend, postSubscribeBackend } from './backend/subscriptions'; -import { getUserLocale } from '$lib/i18n'; - -export function buildPath(path: string): URL { - return setLocale(new URL(`${get(invidiousInstanceStore)}/api/v1/${path}`)); -} - -export function setLocale(url: URL): URL { - const region = get(interfaceRegionStore); - - if (region) { - url.searchParams.set('region', region); - } - - const locale = getUserLocale(); - - url.searchParams.set('hl', locale); - - return url; -} - -export class HTTPError { - msg: string; - response: Response; - - constructor(msg: string, response: Response) { - this.msg = msg; - this.response = response; - } - - toString() { - return this.msg; - } -} - -export async function fetchErrorHandle(response: Response): Promise { - if (!response.ok) { - let message = 'Internal error'; - - // Attempt to parse error. - try { - const json = await response.json(); - message = json.errorBacktrace || json.error || json.message; - } catch { - // Continue regardless of error - } - - throw new HTTPError( - `${response.status} - ${response.statusText}\n${decodeURIComponent(response.url)}\n${message}`, - response - ); - } - - return response; -} - -export function buildAuthHeaders(): { headers: Record } { - const authToken = get(invidiousAuthStore)?.token ?? ''; - if (authToken.startsWith('SID=')) { - return { headers: { __sid_auth: authToken } }; - } else { - return { headers: { Authorization: `Bearer ${authToken}` } }; - } -} +import { getFeedInvidious, getPopularInvidious, getSubscriptionsInvidious } from './invidious/feed'; +import { getResolveUrlInvidious } from './invidious/misc'; +import { getVideoInvidious } from './invidious/video'; +import { getCommentsInvidious } from './invidious/comments'; +import { + getChannelContentInvidious, + getChannelInvidious, + searchChannelContentInvidious +} from './invidious/channel'; +import { getSearchSuggestionsInvidious } from './invidious/searchSuggestions'; +import { getHashtagInvidious } from './invidious/hashtag'; +import { getSearchInvidious } from './invidious/search'; +import { notificationsMarkAsReadInvidious } from './invidious/notifcations'; +import { + amSubscribedInvidious, + deleteUnsubscribeInvidious, + postSubscribeInvidious +} from './invidious/subscribe'; +import { postHistoryInvidious } from './invidious/history'; +import { + addPlaylistVideoInvidious, + deletePersonalPlaylistInvidious, + getPersonalPlaylistsInvidious, + getPlaylistInvidious, + postPersonalPlaylistInvidious, + removePlaylistVideoInvidious +} from './invidious/playlist'; export async function getPopular(fetchOptions?: RequestInit): Promise { // Doesn't exist in YTjs. @@ -121,20 +73,15 @@ export async function getPopular(fetchOptions?: RequestInit): Promise { return []; } - const resp = await fetchErrorHandle(await fetch(buildPath('popular'), fetchOptions)); - return await resp.json(); + return getPopularInvidious(fetchOptions); } export async function getResolveUrl(url: string): Promise { if (isYTBackend() || useEngineFallback('ResolveUrl')) { - return await getResolveUrlYTjs(url); + return getResolveUrlYTjs(url); } - const path = buildPath('resolveurl'); - path.searchParams.set('url', url); - - const resp = await fetchErrorHandle(await fetch(path)); - return await resp.json(); + return getResolveUrlInvidious(url); } export async function getVideo( @@ -147,30 +94,10 @@ export async function getVideo( isYTBackend() || useEngineFallback('Video') ) { - return await getVideoYTjs(videoId); + return getVideoYTjs(videoId); } - const path = buildPath(`videos/${videoId}`); - path.searchParams.set('local', local.toString()); - - const resp = await fetch(path, fetchOptions); - - if (!resp.ok && get(playerYouTubeJsFallback) && isUnrestrictedPlatform()) { - return await getVideoYTjs(videoId); - } else { - await fetchErrorHandle(resp); - } - return await resp.json(); -} - -export async function getDislikes( - videoId: string, - fetchOptions?: RequestInit -): Promise { - const resp = await fetchErrorHandle( - await fetch(`${get(returnYTDislikesInstanceStore)}/votes?videoId=${videoId}`, fetchOptions) - ); - return await resp.json(); + return getVideoInvidious(videoId, local, fetchOptions); } export async function getComments( @@ -181,15 +108,10 @@ export async function getComments( commentsSetDefaults(options); if (isYTBackend() || useEngineFallback('Comments')) { - return await getCommentsYTjs(videoId, options); + return getCommentsYTjs(videoId, options); } - const path = buildPath(`comments/${videoId}`); - if (options.continuation) path.searchParams.set('continuation', options.continuation); - if (options.sort_by) path.searchParams.set('sort_by', options.sort_by); - - const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); - return await resp.json(); + return getCommentsInvidious(videoId, options, fetchOptions); } export async function getChannel( @@ -199,10 +121,8 @@ export async function getChannel( if (isYTBackend() || useEngineFallback('Channel')) { return getChannelYTjs(channelId); } - const resp = await fetchErrorHandle( - await fetch(buildPath(`channels/${channelId}`), fetchOptions) - ); - return await resp.json(); + + return getChannelInvidious(channelId, fetchOptions); } export async function getChannelContent( @@ -213,18 +133,10 @@ export async function getChannelContent( if (typeof options.type === 'undefined') options.type = 'videos'; if (isYTBackend() || useEngineFallback('ChannelContent')) { - return await getChannelContentYTjs(channelId, options); + return getChannelContentYTjs(channelId, options); } - const url = buildPath(`channels/${channelId}/${options.type}`); - - if (typeof options.continuation !== 'undefined') - url.searchParams.set('continuation', options.continuation); - - if (typeof options.sortBy !== 'undefined') url.searchParams.set('sort_by', options.sortBy); - - const resp = await fetchErrorHandle(await fetch(url.toString(), fetchOptions)); - return await resp.json(); + return getChannelContentInvidious(channelId, options, fetchOptions); } export async function searchChannelContent( @@ -239,11 +151,7 @@ export async function searchChannelContent( }; } - const path = buildPath(`channel/${channelId}/search`); - path.searchParams.set('q', search); - - const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); - return await resp.json(); + return searchChannelContentInvidious(channelId, search, fetchOptions); } export async function getSearchSuggestions( @@ -254,22 +162,14 @@ export async function getSearchSuggestions( return getSearchSuggestionsYTjs(search); } - const path = buildPath('search/suggestions'); - path.searchParams.set('q', search); - - const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); - return await resp.json(); + return getSearchSuggestionsInvidious(search, fetchOptions); } export async function getHashtag(tag: string, page: number = 0): Promise<{ results: Video[] }> { // TODO: Implement in YTjs if (isYTBackend()) return { results: [] }; - const path = buildPath(`hashtag/${tag}`); - path.searchParams.set('page', page.toString()); - - const resp = await fetchErrorHandle(await fetch(path)); - return await resp.json(); + return getHashtagInvidious(tag, page); } export async function getSearch( @@ -280,21 +180,10 @@ export async function getSearch( searchSetDefaults(options); if (isYTBackend() || useEngineFallback('Search')) { - return await getSearchYTjs(search, options); + return getSearchYTjs(search, options); } - const path = buildPath('search'); - path.searchParams.set('q', search); - - if (options.date) path.searchParams.set('date', options.date); - if (options.duration) path.searchParams.set('duration', options.duration); - if (options.features) path.searchParams.set('features', options.features); - if (options.page) path.searchParams.set('page', options.page); - if (options.sort_by) path.searchParams.set('sort_by', options.sort_by); - if (options.type) path.searchParams.set('type', options.type); - - const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); - return await resp.json(); + return getSearchInvidious(search, options, fetchOptions); } export async function getFeed( @@ -306,21 +195,14 @@ export async function getFeed( return getFeedYTjs(maxResults, page); } - const path = buildPath('auth/feed'); - path.searchParams.set('max_results', maxResults.toString()); - path.searchParams.set('page', page.toString()); - const resp = await fetchErrorHandle( - await fetch(path, { ...buildAuthHeaders(), ...fetchOptions }) - ); - return await resp.json(); + return getFeedInvidious(maxResults, page, fetchOptions); } export async function notificationsMarkAsRead(fetchOptions: RequestInit = {}) { // Not support functionality of YTjs if (isYTBackend()) return; - const path = buildPath('auth/notifications'); - await fetchErrorHandle(await fetch(path, { ...buildAuthHeaders(), ...fetchOptions })); + return notificationsMarkAsReadInvidious(fetchOptions); } export async function getSubscriptions( @@ -339,10 +221,8 @@ export async function getSubscriptions( return getSubscriptionsYTjs(); } - const resp = await fetchErrorHandle( - await fetch(buildPath('auth/subscriptions'), { ...buildAuthHeaders(), ...fetchOptions }) - ); - return await resp.json(); + + return getSubscriptionsInvidious(fetchOptions); } export async function amSubscribed( @@ -357,16 +237,7 @@ export async function amSubscribed( return amSubscribedYTjs(authorId); } - if (!get(invidiousAuthStore)) return false; - - try { - const subscriptions = (await getSubscriptions(fetchOptions)).filter( - (sub) => sub.authorId === authorId - ); - return subscriptions.length === 1; - } catch { - return false; - } + return amSubscribedInvidious(authorId, fetchOptions); } export async function postSubscribe( @@ -382,13 +253,7 @@ export async function postSubscribe( return postSubscribeYTjs(authorId); } - await fetchErrorHandle( - await fetch(buildPath(`auth/subscriptions/${authorId}`), { - method: 'POST', - ...buildAuthHeaders(), - ...fetchOptions - }) - ); + return postSubscribeInvidious(authorId, fetchOptions); } export async function deleteUnsubscribe(authorId: string, fetchOptions: RequestInit = {}) { @@ -402,68 +267,19 @@ export async function deleteUnsubscribe(authorId: string, fetchOptions: RequestI return deleteUnsubscribeYTjs(authorId); } - await fetchErrorHandle( - await fetch(buildPath(`auth/subscriptions/${authorId}`), { - method: 'DELETE', - ...buildAuthHeaders(), - ...fetchOptions - }) - ); + return deleteUnsubscribeInvidious(authorId, fetchOptions); } -export async function getHistory( - page: number = 1, - maxResults: number = 20, - fetchOptions: RequestInit = {} -): Promise { - // Not supported functionality of YTjs. - if (isYTBackend()) { - return []; - } +export async function getHistory(page: number = 1, maxResults: number = 20): Promise {} - const path = buildPath(`auth/history`); - path.searchParams.set('page', page.toString()); - path.searchParams.set('max_results', maxResults.toString()); - - const resp = await fetchErrorHandle( - await fetch(path, { - ...buildAuthHeaders(), - ...fetchOptions - }) - ); - return await resp.json(); -} - -export async function deleteHistory( - videoId: string | undefined = undefined, - fetchOptions: RequestInit = {} -) { +export async function deleteHistory(videoId: string | undefined = undefined) { if (isYTBackend()) return; - - let url = '/api/v1/auth/history'; - if (typeof videoId !== 'undefined') { - url += `/${videoId}`; - } - - await fetchErrorHandle( - await fetch(buildPath(url), { - method: 'DELETE', - ...buildAuthHeaders(), - ...fetchOptions - }) - ); } export async function postHistory(videoId: string, fetchOptions: RequestInit = {}) { if (isYTBackend()) return; - await fetchErrorHandle( - await fetch(buildPath(`auth/history/${videoId}`), { - method: 'POST', - ...buildAuthHeaders(), - ...fetchOptions - }) - ); + return postHistoryInvidious(videoId, fetchOptions); } export async function getPlaylist( @@ -475,21 +291,7 @@ export async function getPlaylist( return await getPlaylistYTjs(playlistId); } - let resp; - - const path = buildPath(`${get(invidiousAuthStore) ? 'auth/' : ''}playlists/${playlistId}`); - path.searchParams.set('page', page.toString()); - - if (get(invidiousAuthStore)) { - resp = await fetch(path, { - ...buildAuthHeaders(), - ...fetchOptions - }); - } else { - resp = await fetch(path, fetchOptions); - } - await fetchErrorHandle(resp); - return await resp.json(); + return getPlaylistInvidious(playlistId, page, fetchOptions); } export async function getPersonalPlaylists( @@ -497,21 +299,13 @@ export async function getPersonalPlaylists( ): Promise { if (isYTBackend()) return []; - const resp = await fetchErrorHandle( - await fetch(buildPath('auth/playlists'), { ...buildAuthHeaders(), ...fetchOptions }) - ); - return await resp.json(); + return getPersonalPlaylistsInvidious(fetchOptions); } export async function deletePersonalPlaylist(playlistId: string) { if (isYTBackend()) return; - await fetchErrorHandle( - await fetch(buildPath(`auth/playlists/${playlistId}`), { - method: 'DELETE', - ...buildAuthHeaders() - }) - ); + return deletePersonalPlaylistInvidious(playlistId); } export async function postPersonalPlaylist( @@ -520,21 +314,7 @@ export async function postPersonalPlaylist( fetchOptions: RequestInit = {} ) { if (isYTBackend()) return; - - const headers: Record> = buildAuthHeaders(); - headers['headers']['Content-type'] = 'application/json'; - - await fetchErrorHandle( - await fetch(buildPath('auth/playlists'), { - method: 'POST', - body: JSON.stringify({ - title: title, - privacy: privacy - }), - ...headers, - ...fetchOptions - }) - ); + return postPersonalPlaylistInvidious(title, privacy, fetchOptions); } export async function addPlaylistVideo( @@ -543,20 +323,7 @@ export async function addPlaylistVideo( fetchOptions: RequestInit = {} ) { if (isYTBackend()) return; - - const headers: Record> = buildAuthHeaders(); - headers['headers']['Content-type'] = 'application/json'; - - await fetchErrorHandle( - await fetch(buildPath(`auth/playlists/${playlistId}/videos`), { - method: 'POST', - body: JSON.stringify({ - videoId: videoId - }), - ...headers, - ...fetchOptions - }) - ); + return addPlaylistVideoInvidious(playlistId, videoId, fetchOptions); } export async function removePlaylistVideo( @@ -566,32 +333,5 @@ export async function removePlaylistVideo( ) { if (isYTBackend()) return; - await fetchErrorHandle( - await fetch(buildPath(`auth/playlists/${playlistId}/videos/${indexId}`), { - method: 'DELETE', - ...buildAuthHeaders(), - ...fetchOptions - }) - ); -} - -export async function getDeArrow(videoId: string, fetchOptions?: RequestInit): Promise { - const resp = await fetchErrorHandle( - await fetch(`${get(deArrowInstanceStore)}/api/branding?videoID=${videoId}`, fetchOptions) - ); - return await resp.json(); -} - -export async function getThumbnail( - videoId: string, - time: number, - fetchOptions?: RequestInit -): Promise { - const resp = await fetchErrorHandle( - await fetch( - `${get(deArrowThumbnailInstanceStore)}/api/v1/getThumbnail?videoID=${videoId}&time=${time}`, - fetchOptions - ) - ); - return URL.createObjectURL(await resp.blob()); + return removePlaylistVideoInvidious(playlistId, indexId, fetchOptions); } diff --git a/materialious/src/lib/api/invidious/channel.ts b/materialious/src/lib/api/invidious/channel.ts new file mode 100644 index 00000000..98212cb3 --- /dev/null +++ b/materialious/src/lib/api/invidious/channel.ts @@ -0,0 +1,40 @@ +import type { ChannelContent, ChannelOptions, ChannelPage } from '../model'; +import { buildPath, fetchErrorHandle } from './request'; + +export async function getChannelInvidious( + channelId: string, + fetchOptions?: RequestInit +): Promise { + const resp = await fetchErrorHandle( + await fetch(buildPath(`channels/${channelId}`), fetchOptions) + ); + return await resp.json(); +} + +export async function getChannelContentInvidious( + channelId: string, + options: ChannelOptions, + fetchOptions?: RequestInit +): Promise { + const url = buildPath(`channels/${channelId}/${options.type}`); + + if (typeof options.continuation !== 'undefined') + url.searchParams.set('continuation', options.continuation); + + if (typeof options.sortBy !== 'undefined') url.searchParams.set('sort_by', options.sortBy); + + const resp = await fetchErrorHandle(await fetch(url.toString(), fetchOptions)); + return await resp.json(); +} + +export async function searchChannelContentInvidious( + channelId: string, + search: string, + fetchOptions?: RequestInit +): Promise { + const path = buildPath(`channel/${channelId}/search`); + path.searchParams.set('q', search); + + const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); + return await resp.json(); +} diff --git a/materialious/src/lib/api/invidious/comments.ts b/materialious/src/lib/api/invidious/comments.ts new file mode 100644 index 00000000..3c75a5b2 --- /dev/null +++ b/materialious/src/lib/api/invidious/comments.ts @@ -0,0 +1,15 @@ +import type { Comments, CommentsOptions } from '../model'; +import { buildPath, fetchErrorHandle } from './request'; + +export async function getCommentsInvidious( + videoId: string, + options: CommentsOptions, + fetchOptions?: RequestInit +): Promise { + const path = buildPath(`comments/${videoId}`); + if (options.continuation) path.searchParams.set('continuation', options.continuation); + if (options.sort_by) path.searchParams.set('sort_by', options.sort_by); + + const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); + return await resp.json(); +} diff --git a/materialious/src/lib/api/invidious/feed.ts b/materialious/src/lib/api/invidious/feed.ts new file mode 100644 index 00000000..be2959c2 --- /dev/null +++ b/materialious/src/lib/api/invidious/feed.ts @@ -0,0 +1,30 @@ +import type { Feed, Subscription, Video } from '../model'; +import { buildAuthHeaders, buildPath, fetchErrorHandle } from './request'; + +export async function getPopularInvidious(fetchOptions?: RequestInit): Promise { + const resp = await fetchErrorHandle(await fetch(buildPath('popular'), fetchOptions)); + return await resp.json(); +} + +export async function getFeedInvidious( + maxResults: number, + page: number, + fetchOptions: RequestInit = {} +): Promise { + const path = buildPath('auth/feed'); + path.searchParams.set('max_results', maxResults.toString()); + path.searchParams.set('page', page.toString()); + const resp = await fetchErrorHandle( + await fetch(path, { ...buildAuthHeaders(), ...fetchOptions }) + ); + return await resp.json(); +} + +export async function getSubscriptionsInvidious( + fetchOptions: RequestInit = {} +): Promise { + const resp = await fetchErrorHandle( + await fetch(buildPath('auth/subscriptions'), { ...buildAuthHeaders(), ...fetchOptions }) + ); + return await resp.json(); +} diff --git a/materialious/src/lib/api/invidious/hashtag.ts b/materialious/src/lib/api/invidious/hashtag.ts new file mode 100644 index 00000000..9aa5f016 --- /dev/null +++ b/materialious/src/lib/api/invidious/hashtag.ts @@ -0,0 +1,13 @@ +import type { Video } from '../model'; +import { buildPath, fetchErrorHandle } from './request'; + +export async function getHashtagInvidious( + tag: string, + page: number = 0 +): Promise<{ results: Video[] }> { + const path = buildPath(`hashtag/${tag}`); + path.searchParams.set('page', page.toString()); + + const resp = await fetchErrorHandle(await fetch(path)); + return await resp.json(); +} diff --git a/materialious/src/lib/api/invidious/history.ts b/materialious/src/lib/api/invidious/history.ts new file mode 100644 index 00000000..02e7e798 --- /dev/null +++ b/materialious/src/lib/api/invidious/history.ts @@ -0,0 +1,11 @@ +import { buildAuthHeaders, buildPath, fetchErrorHandle } from './request'; + +export async function postHistoryInvidious(videoId: string, fetchOptions: RequestInit = {}) { + await fetchErrorHandle( + await fetch(buildPath(`auth/history/${videoId}`), { + method: 'POST', + ...buildAuthHeaders(), + ...fetchOptions + }) + ); +} diff --git a/materialious/src/lib/api/invidious/misc.ts b/materialious/src/lib/api/invidious/misc.ts new file mode 100644 index 00000000..83982ca4 --- /dev/null +++ b/materialious/src/lib/api/invidious/misc.ts @@ -0,0 +1,10 @@ +import type { ResolvedUrl } from '../model'; +import { buildPath, fetchErrorHandle } from './request'; + +export async function getResolveUrlInvidious(url: string): Promise { + const path = buildPath('resolveurl'); + path.searchParams.set('url', url); + + const resp = await fetchErrorHandle(await fetch(path)); + return await resp.json(); +} diff --git a/materialious/src/lib/api/invidious/notifcations.ts b/materialious/src/lib/api/invidious/notifcations.ts new file mode 100644 index 00000000..788ec801 --- /dev/null +++ b/materialious/src/lib/api/invidious/notifcations.ts @@ -0,0 +1,6 @@ +import { buildAuthHeaders, buildPath, fetchErrorHandle } from './request'; + +export async function notificationsMarkAsReadInvidious(fetchOptions: RequestInit = {}) { + const path = buildPath('auth/notifications'); + await fetchErrorHandle(await fetch(path, { ...buildAuthHeaders(), ...fetchOptions })); +} diff --git a/materialious/src/lib/api/invidious/playlist.ts b/materialious/src/lib/api/invidious/playlist.ts new file mode 100644 index 00000000..724596bc --- /dev/null +++ b/materialious/src/lib/api/invidious/playlist.ts @@ -0,0 +1,99 @@ +import { get } from 'svelte/store'; +import type { PlaylistPage } from '../model'; +import { buildAuthHeaders, buildPath, fetchErrorHandle } from './request'; +import { invidiousAuthStore } from '$lib/store'; + +export async function getPlaylistInvidious( + playlistId: string, + page: number = 1, + fetchOptions: RequestInit = {} +): Promise { + let resp; + + const path = buildPath(`${get(invidiousAuthStore) ? 'auth/' : ''}playlists/${playlistId}`); + path.searchParams.set('page', page.toString()); + + if (get(invidiousAuthStore)) { + resp = await fetch(path, { + ...buildAuthHeaders(), + ...fetchOptions + }); + } else { + resp = await fetch(path, fetchOptions); + } + await fetchErrorHandle(resp); + return await resp.json(); +} + +export async function getPersonalPlaylistsInvidious( + fetchOptions: RequestInit = {} +): Promise { + const resp = await fetchErrorHandle( + await fetch(buildPath('auth/playlists'), { ...buildAuthHeaders(), ...fetchOptions }) + ); + return await resp.json(); +} + +export async function deletePersonalPlaylistInvidious(playlistId: string) { + await fetchErrorHandle( + await fetch(buildPath(`auth/playlists/${playlistId}`), { + method: 'DELETE', + ...buildAuthHeaders() + }) + ); +} + +export async function postPersonalPlaylistInvidious( + title: string, + privacy: 'public' | 'private' | 'unlisted', + fetchOptions: RequestInit = {} +) { + const headers: Record> = buildAuthHeaders(); + headers['headers']['Content-type'] = 'application/json'; + + await fetchErrorHandle( + await fetch(buildPath('auth/playlists'), { + method: 'POST', + body: JSON.stringify({ + title: title, + privacy: privacy + }), + ...headers, + ...fetchOptions + }) + ); +} + +export async function addPlaylistVideoInvidious( + playlistId: string, + videoId: string, + fetchOptions: RequestInit = {} +) { + const headers: Record> = buildAuthHeaders(); + headers['headers']['Content-type'] = 'application/json'; + + await fetchErrorHandle( + await fetch(buildPath(`auth/playlists/${playlistId}/videos`), { + method: 'POST', + body: JSON.stringify({ + videoId: videoId + }), + ...headers, + ...fetchOptions + }) + ); +} + +export async function removePlaylistVideoInvidious( + playlistId: string, + indexId: string, + fetchOptions: RequestInit = {} +) { + await fetchErrorHandle( + await fetch(buildPath(`auth/playlists/${playlistId}/videos/${indexId}`), { + method: 'DELETE', + ...buildAuthHeaders(), + ...fetchOptions + }) + ); +} diff --git a/materialious/src/lib/api/invidious/request.ts b/materialious/src/lib/api/invidious/request.ts new file mode 100644 index 00000000..fd4ba9ae --- /dev/null +++ b/materialious/src/lib/api/invidious/request.ts @@ -0,0 +1,65 @@ +import { getUserLocale } from '$lib/i18n'; +import { interfaceRegionStore, invidiousAuthStore, invidiousInstanceStore } from '$lib/store'; +import { get } from 'svelte/store'; + +export function buildPath(path: string): URL { + return setLocale(new URL(`${get(invidiousInstanceStore)}/api/v1/${path}`)); +} + +export function setLocale(url: URL): URL { + const region = get(interfaceRegionStore); + + if (region) { + url.searchParams.set('region', region); + } + + const locale = getUserLocale(); + + url.searchParams.set('hl', locale); + + return url; +} + +export class HTTPError { + msg: string; + response: Response; + + constructor(msg: string, response: Response) { + this.msg = msg; + this.response = response; + } + + toString() { + return this.msg; + } +} + +export async function fetchErrorHandle(response: Response): Promise { + if (!response.ok) { + let message = 'Internal error'; + + // Attempt to parse error. + try { + const json = await response.json(); + message = json.errorBacktrace || json.error || json.message; + } catch { + // Continue regardless of error + } + + throw new HTTPError( + `${response.status} - ${response.statusText}\n${decodeURIComponent(response.url)}\n${message}`, + response + ); + } + + return response; +} + +export function buildAuthHeaders(): { headers: Record } { + const authToken = get(invidiousAuthStore)?.token ?? ''; + if (authToken.startsWith('SID=')) { + return { headers: { __sid_auth: authToken } }; + } else { + return { headers: { Authorization: `Bearer ${authToken}` } }; + } +} diff --git a/materialious/src/lib/api/invidious/search.ts b/materialious/src/lib/api/invidious/search.ts new file mode 100644 index 00000000..b1720d23 --- /dev/null +++ b/materialious/src/lib/api/invidious/search.ts @@ -0,0 +1,21 @@ +import type { SearchOptions, SearchResults } from '../model'; +import { buildPath, fetchErrorHandle } from './request'; + +export async function getSearchInvidious( + search: string, + options: SearchOptions, + fetchOptions?: RequestInit +): Promise { + const path = buildPath('search'); + path.searchParams.set('q', search); + + if (options.date) path.searchParams.set('date', options.date); + if (options.duration) path.searchParams.set('duration', options.duration); + if (options.features) path.searchParams.set('features', options.features); + if (options.page) path.searchParams.set('page', options.page); + if (options.sort_by) path.searchParams.set('sort_by', options.sort_by); + if (options.type) path.searchParams.set('type', options.type); + + const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); + return await resp.json(); +} diff --git a/materialious/src/lib/api/invidious/searchSuggestions.ts b/materialious/src/lib/api/invidious/searchSuggestions.ts new file mode 100644 index 00000000..b4083b8c --- /dev/null +++ b/materialious/src/lib/api/invidious/searchSuggestions.ts @@ -0,0 +1,13 @@ +import type { SearchSuggestion } from '../model'; +import { buildPath, fetchErrorHandle } from './request'; + +export async function getSearchSuggestionsInvidious( + search: string, + fetchOptions?: RequestInit +): Promise { + const path = buildPath('search/suggestions'); + path.searchParams.set('q', search); + + const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); + return await resp.json(); +} diff --git a/materialious/src/lib/api/invidious/subscribe.ts b/materialious/src/lib/api/invidious/subscribe.ts new file mode 100644 index 00000000..eb6e1e9f --- /dev/null +++ b/materialious/src/lib/api/invidious/subscribe.ts @@ -0,0 +1,40 @@ +import { invidiousAuthStore } from '$lib/store'; +import { get } from 'svelte/store'; +import { getSubscriptionsInvidious } from './feed'; +import { buildAuthHeaders, buildPath, fetchErrorHandle } from './request'; + +export async function amSubscribedInvidious( + authorId: string, + fetchOptions: RequestInit = {} +): Promise { + if (!get(invidiousAuthStore)) return false; + + try { + const subscriptions = (await getSubscriptionsInvidious(fetchOptions)).filter( + (sub) => sub.authorId === authorId + ); + return subscriptions.length === 1; + } catch { + return false; + } +} + +export async function postSubscribeInvidious(authorId: string, fetchOptions: RequestInit = {}) { + await fetchErrorHandle( + await fetch(buildPath(`auth/subscriptions/${authorId}`), { + method: 'POST', + ...buildAuthHeaders(), + ...fetchOptions + }) + ); +} + +export async function deleteUnsubscribeInvidious(authorId: string, fetchOptions: RequestInit = {}) { + await fetchErrorHandle( + await fetch(buildPath(`auth/subscriptions/${authorId}`), { + method: 'DELETE', + ...buildAuthHeaders(), + ...fetchOptions + }) + ); +} diff --git a/materialious/src/lib/api/invidious/video.ts b/materialious/src/lib/api/invidious/video.ts new file mode 100644 index 00000000..0bd15d5b --- /dev/null +++ b/materialious/src/lib/api/invidious/video.ts @@ -0,0 +1,24 @@ +import { get } from 'svelte/store'; +import type { VideoPlay } from '../model'; +import { buildPath, fetchErrorHandle } from './request'; +import { getVideoYTjs } from '../youtubejs/video'; +import { playerYouTubeJsFallback } from '$lib/store'; +import { isUnrestrictedPlatform } from '$lib/misc'; + +export async function getVideoInvidious( + videoId: string, + local: boolean = false, + fetchOptions?: RequestInit +): Promise { + const path = buildPath(`videos/${videoId}`); + path.searchParams.set('local', local.toString()); + + const resp = await fetch(path, fetchOptions); + + if (!resp.ok && get(playerYouTubeJsFallback) && isUnrestrictedPlatform()) { + return await getVideoYTjs(videoId); + } else { + await fetchErrorHandle(resp); + } + return await resp.json(); +} diff --git a/materialious/src/lib/api/ytd.ts b/materialious/src/lib/api/ytd.ts new file mode 100644 index 00000000..4d33517e --- /dev/null +++ b/materialious/src/lib/api/ytd.ts @@ -0,0 +1,14 @@ +import { get } from 'svelte/store'; +import type { ReturnYTDislikes } from './model'; +import { fetchErrorHandle } from './invidious/request'; +import { returnYTDislikesInstanceStore } from '$lib/store'; + +export async function getDislikesRYD( + videoId: string, + fetchOptions?: RequestInit +): Promise { + const resp = await fetchErrorHandle( + await fetch(`${get(returnYTDislikesInstanceStore)}/votes?videoId=${videoId}`, fetchOptions) + ); + return await resp.json(); +} diff --git a/materialious/src/lib/components/thumbnail/VideoThumbnail.svelte b/materialious/src/lib/components/thumbnail/VideoThumbnail.svelte index 080ba78d..348ec50d 100644 --- a/materialious/src/lib/components/thumbnail/VideoThumbnail.svelte +++ b/materialious/src/lib/components/thumbnail/VideoThumbnail.svelte @@ -6,7 +6,6 @@ import { onDestroy, onMount } from 'svelte'; import { _ } from '$lib/i18n'; import { get } from 'svelte/store'; - import { getDeArrow, getThumbnail } from '$lib/api'; import { Avatar } from 'melt/builders'; import type { Notification, @@ -26,6 +25,7 @@ import { queueGetWatchHistory } from '$lib/api/backend/historyPool'; import { page } from '$app/state'; import { isOwnBackend } from '$lib/shared'; + import { getDeArrow, getThumbnailDeArrow } from '$lib/api/dearrow'; interface Props { video: VideoBase | Video | Notification | PlaylistPageVideo | VideoWatchHistory; @@ -74,7 +74,7 @@ for (const thumbnail of deArrow.thumbnails) { if (thumbnail.locked || thumbnail.original || thumbnail.votes > 0) { if (thumbnail.timestamp !== null) { - thumbnailSrc = await getThumbnail(video.videoId, thumbnail.timestamp, { + thumbnailSrc = await getThumbnailDeArrow(video.videoId, thumbnail.timestamp, { priority: 'low' }); } diff --git a/materialious/src/lib/dexie.ts b/materialious/src/lib/dexie.ts index 090585c9..f4840bc2 100644 --- a/materialious/src/lib/dexie.ts +++ b/materialious/src/lib/dexie.ts @@ -1,6 +1,6 @@ import type { Table } from 'dexie'; import Dexie from 'dexie'; -import type { Video } from './api/model'; +import type { Video, VideoWatchHistory } from './api/model'; export interface FavouriteChannels { channelId: string; @@ -17,13 +17,15 @@ export class MaterialiousDb extends Dexie { favouriteChannels!: Table; channelSubscriptions!: Table; subscriptionFeed!: Table