From 5247f5a7620f1922df6bca0c5941ff9e882a4788 Mon Sep 17 00:00:00 2001 From: Liam Dyer Date: Wed, 6 Mar 2024 22:30:14 -0500 Subject: [PATCH] feat: channel playlists, history page headers, many fixes --- extension/src/manifest.chrome.json | 24 +- extension/src/manifest.firefox.json | 46 +- extension/src/routes/proxy/convert.ts | 156 +++--- package.json | 10 +- pnpm-lock.yaml | 138 ++--- src/components/Channel/Card.tsx | 6 +- src/components/ItemGrid.tsx | 2 +- src/components/NavBar.tsx | 9 +- src/components/Video/Card.tsx | 98 ++-- src/components/Video/Grid.tsx | 6 +- src/components/Video/Shared.tsx | 23 +- src/components/button/CollapsibleButton.tsx | 8 +- src/components/button/CopyLinkButton.tsx | 72 +-- src/components/button/LikeButton.tsx | 8 +- src/libs/format.ts | 1 + src/parser/std/core.ts | 32 +- src/parser/std/video.ts | 53 +- src/parser/yt/channel/api.ts | 395 +++++++------- src/parser/yt/channel/index.ts | 205 +++---- src/parser/yt/channel/types.ts | 174 +++--- src/parser/yt/components/badge.ts | 5 +- src/parser/yt/components/button.ts | 270 +++++----- src/parser/yt/core/api/continuation.ts | 3 + .../yt/core/api/declarative-net-request.ts | 107 ++-- src/parser/yt/core/helpers.ts | 91 ++-- src/parser/yt/index.ts | 2 + src/parser/yt/playlist/list.ts | 9 +- src/parser/yt/playlist/processors/grid.ts | 13 +- src/parser/yt/user/index.ts | 173 +++--- src/parser/yt/video/api.ts | 244 ++++----- src/parser/yt/video/index.ts | 142 ++--- src/parser/yt/video/processors/full.ts | 173 +++--- src/parser/yt/video/processors/grid.ts | 104 ++-- src/parser/yt/video/processors/regular.ts | 2 +- src/parser/yt/video/types/responses/video.ts | 21 +- src/settings.ts | 236 ++++----- src/views/channel/Channel.tsx | 4 + src/views/channel/tabs/Playlists.tsx | 20 + src/views/playlist/Playlist.tsx | 2 +- src/views/settings/Settings.tsx | 42 +- src/views/settings/player/Segments.tsx | 499 +++++++++--------- src/views/watch/Watch.tsx | 27 +- src/views/watch/player/Tracking.tsx | 84 ++- src/views/watch/player/controls/Quality.tsx | 122 +++-- .../watch/player/hooks/usePlayerInstance.ts | 37 +- 45 files changed, 1978 insertions(+), 1920 deletions(-) diff --git a/extension/src/manifest.chrome.json b/extension/src/manifest.chrome.json index 0f3364c..fcb4b75 100644 --- a/extension/src/manifest.chrome.json +++ b/extension/src/manifest.chrome.json @@ -1,14 +1,14 @@ { - "manifest_version": 3, - "action": {}, - "background": { - "service_worker": "service-worker.js" - }, - "permissions": ["cookies", "storage", "declarativeNetRequest"], - "host_permissions": [ - "https://*.youtube.com/*", - "https://returnyoutubedislikeapi.com/*", - "https://yt3.ggpht.com/*", - "https://i.ytimg.com/*" - ] + "manifest_version": 3, + "action": {}, + "background": { + "service_worker": "service-worker.js" + }, + "permissions": ["cookies", "storage", "declarativeNetRequest"], + "host_permissions": [ + "https://*.youtube.com/*", + "https://returnyoutubedislikeapi.com/*", + "https://yt3.ggpht.com/*", + "https://i.ytimg.com/*" + ] } diff --git a/extension/src/manifest.firefox.json b/extension/src/manifest.firefox.json index 57f7400..be53ed9 100644 --- a/extension/src/manifest.firefox.json +++ b/extension/src/manifest.firefox.json @@ -1,25 +1,25 @@ { - "browser_specific_settings": { - "gecko": { - "id": "extension@heimdall.tv" - } - }, - "action": {}, - "manifest_version": 3, - "background": { - "scripts": ["service-worker.js"] - }, - "permissions": [ - "cookies", - "storage", - "contextualIdentities", - "declarativeNetRequest", - "declarativeNetRequestFeedback" - ], - "host_permissions": [ - "https://*.youtube.com/*", - "https://returnyoutubedislikeapi.com/*", - "https://yt3.ggpht.com/*", - "https://i.ytimg.com/*" - ] + "browser_specific_settings": { + "gecko": { + "id": "extension@heimdall.tv" + } + }, + "action": {}, + "manifest_version": 3, + "background": { + "scripts": ["service-worker.js"] + }, + "permissions": [ + "cookies", + "storage", + "contextualIdentities", + "declarativeNetRequest", + "declarativeNetRequestFeedback" + ], + "host_permissions": [ + "https://*.youtube.com/*", + "https://returnyoutubedislikeapi.com/*", + "https://yt3.ggpht.com/*", + "https://i.ytimg.com/*" + ] } diff --git a/extension/src/routes/proxy/convert.ts b/extension/src/routes/proxy/convert.ts index fe71192..a4a20e9 100644 --- a/extension/src/routes/proxy/convert.ts +++ b/extension/src/routes/proxy/convert.ts @@ -6,98 +6,94 @@ /// RequestInit export interface TransferableRequestInit { - /** A Base64 string or null to set request's body. */ - body?: string | null; - /** A string indicating how the request will interact with the browser's cache to set request's cache. */ - cache?: RequestCache; - /** A string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. Sets request's credentials. */ - credentials?: RequestCredentials; - /** A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ - headers?: [string, string][] | Record; - /** A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */ - integrity?: string; - /** A boolean to set request's keepalive. */ - keepalive?: boolean; - /** A string to set request's method. */ - method?: string; - /** A string to indicate whether the request will use CORS, or will be restricted to same-origin URLs. Sets request's mode. */ - mode?: RequestMode; - /** A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */ - redirect?: RequestRedirect; - /** A string whose value is a same-origin URL, "about:client", or the empty string, to set request's referrer. */ - referrer?: string; - /** A referrer policy to set request's referrerPolicy. */ - referrerPolicy?: ReferrerPolicy; + /** A Base64 string or null to set request's body. */ + body?: string | null + /** A string indicating how the request will interact with the browser's cache to set request's cache. */ + cache?: RequestCache + /** A string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. Sets request's credentials. */ + credentials?: RequestCredentials + /** A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ + headers?: [string, string][] | Record + /** A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */ + integrity?: string + /** A boolean to set request's keepalive. */ + keepalive?: boolean + /** A string to set request's method. */ + method?: string + /** A string to indicate whether the request will use CORS, or will be restricted to same-origin URLs. Sets request's mode. */ + mode?: RequestMode + /** A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */ + redirect?: RequestRedirect + /** A string whose value is a same-origin URL, "about:client", or the empty string, to set request's referrer. */ + referrer?: string + /** A referrer policy to set request's referrerPolicy. */ + referrerPolicy?: ReferrerPolicy } export const transferableRequestInitToRequestInit = ( - transferableRequest: TransferableRequestInit, + transferableRequest: TransferableRequestInit, ): RequestInit => ({ - body: - transferableRequest.body && stringToArrayBuffer(transferableRequest.body), - cache: transferableRequest.cache, - credentials: transferableRequest.credentials, - headers: new Headers(transferableRequest.headers), - integrity: transferableRequest.integrity, - method: transferableRequest.method, - mode: transferableRequest.mode, - redirect: transferableRequest.redirect, - referrer: transferableRequest.referrer, - referrerPolicy: transferableRequest.referrerPolicy, -}); + body: transferableRequest.body && stringToArrayBuffer(transferableRequest.body), + cache: transferableRequest.cache, + credentials: transferableRequest.credentials, + headers: new Headers(transferableRequest.headers), + integrity: transferableRequest.integrity, + method: transferableRequest.method, + mode: transferableRequest.mode, + redirect: transferableRequest.redirect, + referrer: transferableRequest.referrer, + referrerPolicy: transferableRequest.referrerPolicy, +}) export const requestInitToTransferableRequestInit = async ( - requestInit: RequestInit, + requestInit: RequestInit, ): Promise => ({ - ...requestInit, - body: requestInit.body && (await new Response(requestInit.body).text()), - // @ts-expect-error Valid but Typescript doesn't recognize it - headers: - requestInit.headers instanceof Headers - ? requestInit.headers.entries() - : requestInit.headers, -}); + ...requestInit, + body: requestInit.body && (await new Response(requestInit.body).text()), + // @ts-expect-error Valid but Typescript doesn't recognize it + headers: requestInit.headers instanceof Headers ? requestInit.headers.entries() : requestInit.headers, +}) /// Response type TransferableResponse = { - /** Base64 encoded */ - body: null | string; - headers: Record; - status: number; - statusText: string; -}; -export const responseToTransferableResponse = async ( - response: Response, -): Promise => ({ - body: response.body && (await response.text()), - headers: headersToObject(response.headers), - status: response.status, - statusText: response.statusText, -}); + /** Base64 encoded */ + body: null | string + headers: Record + status: number + statusText: string +} +export const responseToTransferableResponse = async (response: Response): Promise => ({ + body: response.body && (await response.text()), + headers: headersToObject(response.headers), + status: response.status, + statusText: response.statusText, +}) -export const transferableResponseToResponse = ( - transferableResponse: TransferableResponse, -): Response => - new Response( - transferableResponse.body && stringToArrayBuffer(transferableResponse.body), - { - headers: new Headers(transferableResponse.headers), - status: transferableResponse.status, - statusText: transferableResponse.statusText, - }, - ); +export const transferableResponseToResponse = (transferableResponse: TransferableResponse): Response => + new Response( + [101, 204, 205, 304].includes(transferableResponse.status) + ? null + : transferableResponse.body + ? stringToArrayBuffer(transferableResponse.body) + : undefined, + { + headers: new Headers(transferableResponse.headers), + status: transferableResponse.status, + statusText: transferableResponse.statusText, + }, + ) /// Helpers const stringToArrayBuffer = (str: string) => { - const encoder = new TextEncoder(); - return encoder.encode(str); -}; + const encoder = new TextEncoder() + return encoder.encode(str) +} const headersToObject = (headers: Headers) => { - const headersObject: Record = {}; - // no type-safe way to do it with for of that I know of - // biome-ignore lint/complexity/noForEach: - headers.forEach((value, key) => { - headersObject[key] = value; - }); - return headersObject; -}; + const headersObject: Record = {} + // no type-safe way to do it with for of that I know of + // biome-ignore lint/complexity/noForEach: + headers.forEach((value, key) => { + headersObject[key] = value + }) + return headersObject +} diff --git a/package.json b/package.json index 5bd6cbb..978cc5f 100644 --- a/package.json +++ b/package.json @@ -32,14 +32,14 @@ "react-dom": "^18.2.0", "styled-components": "^6.1.8", "vite-tsconfig-paths": "^4.3.1", - "wouter": "^3.0.1" + "wouter": "^3.0.2" }, "devDependencies": { "@biomejs/biome": "^1.5.3", "@preact/preset-vite": "^2.8.1", "@styled/typescript-styled-plugin": "^1.0.1", - "@types/react": "^18.2.61", - "@types/react-dom": "^18.2.19", + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.20", "@types/webextension-polyfill": "^0.10.7", "@vitejs/plugin-react": "^4.2.1", "husky": "^9.0.11", @@ -48,8 +48,8 @@ "postcss-preset-mantine": "^1.13.0", "postcss-simple-vars": "^7.0.1", "prettier": "^3.2.5", - "typescript": "^5.3.3", - "vite": "^5.1.4", + "typescript": "^5.4.2", + "vite": "^5.1.5", "vite-bundle-visualizer": "^1.0.1", "vite-plugin-preload": "^0.3.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76b7a29..8eec139 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ dependencies: version: 1.2.2 '@mantine/core': specifier: ^7.6.1 - version: 7.6.1(@mantine/hooks@7.6.1)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0) + version: 7.6.1(@mantine/hooks@7.6.1)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0) '@mantine/hooks': specifier: ^7.6.1 version: 7.6.1(react@18.2.0) @@ -28,7 +28,7 @@ dependencies: version: 1.5.7 jotai: specifier: ^2.7.0 - version: 2.7.0(@types/react@18.2.61)(react@18.2.0) + version: 2.7.0(@types/react@18.2.64)(react@18.2.0) jotai-optics: specifier: ^0.3.2 version: 0.3.2(jotai@2.7.0)(optics-ts@2.4.1) @@ -52,10 +52,10 @@ dependencies: version: 6.1.8(react-dom@18.2.0)(react@18.2.0) vite-tsconfig-paths: specifier: ^4.3.1 - version: 4.3.1(typescript@5.3.3)(vite@5.1.4) + version: 4.3.1(typescript@5.4.2)(vite@5.1.5) wouter: - specifier: ^3.0.1 - version: 3.0.1(react@18.2.0) + specifier: ^3.0.2 + version: 3.0.2(react@18.2.0) devDependencies: '@biomejs/biome': @@ -63,22 +63,22 @@ devDependencies: version: 1.5.3 '@preact/preset-vite': specifier: ^2.8.1 - version: 2.8.1(@babel/core@7.23.7)(preact@10.19.6)(vite@5.1.4) + version: 2.8.1(@babel/core@7.23.7)(preact@10.19.6)(vite@5.1.5) '@styled/typescript-styled-plugin': specifier: ^1.0.1 version: 1.0.1 '@types/react': - specifier: ^18.2.61 - version: 18.2.61 + specifier: ^18.2.64 + version: 18.2.64 '@types/react-dom': - specifier: ^18.2.19 - version: 18.2.19 + specifier: ^18.2.20 + version: 18.2.20 '@types/webextension-polyfill': specifier: ^0.10.7 version: 0.10.7 '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.2.1(vite@5.1.4) + version: 4.2.1(vite@5.1.5) husky: specifier: ^9.0.11 version: 9.0.11 @@ -98,17 +98,17 @@ devDependencies: specifier: ^3.2.5 version: 3.2.5 typescript: - specifier: ^5.3.3 - version: 5.3.3 + specifier: ^5.4.2 + version: 5.4.2 vite: - specifier: ^5.1.4 - version: 5.1.4 + specifier: ^5.1.5 + version: 5.1.5 vite-bundle-visualizer: specifier: ^1.0.1 version: 1.0.1 vite-plugin-preload: specifier: ^0.3.1 - version: 0.3.1(vite@5.1.4) + version: 0.3.1(vite@5.1.5) packages: @@ -767,7 +767,7 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true - /@mantine/core@7.6.1(@mantine/hooks@7.6.1)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0): + /@mantine/core@7.6.1(@mantine/hooks@7.6.1)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-52BgYXAMD+E6vDiGIGOJlLBc0pdT2+gzrB0g+v7c7xeiNXqHEG5cEplLErfNBHh9kMQHiDHCiCb5Su9jqoUlXw==} peerDependencies: '@mantine/hooks': 7.6.1 @@ -780,8 +780,8 @@ packages: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-number-format: 5.3.1(react-dom@18.2.0)(react@18.2.0) - react-remove-scroll: 2.5.7(@types/react@18.2.61)(react@18.2.0) - react-textarea-autosize: 8.5.3(@types/react@18.2.61)(react@18.2.0) + react-remove-scroll: 2.5.7(@types/react@18.2.64)(react@18.2.0) + react-textarea-autosize: 8.5.3(@types/react@18.2.64)(react@18.2.0) type-fest: 3.13.1 transitivePeerDependencies: - '@types/react' @@ -816,7 +816,7 @@ packages: fastq: 1.15.0 dev: true - /@preact/preset-vite@2.8.1(@babel/core@7.23.7)(preact@10.19.6)(vite@5.1.4): + /@preact/preset-vite@2.8.1(@babel/core@7.23.7)(preact@10.19.6)(vite@5.1.5): resolution: {integrity: sha512-a9KV4opdj17X2gOFuGup0aE+sXYABX/tJi/QDptOrleX4FlnoZgDWvz45tHOdVfrZX+3uvVsIYPHxRsTerkDNA==} peerDependencies: '@babel/core': 7.x @@ -825,7 +825,7 @@ packages: '@babel/core': 7.23.7 '@babel/plugin-transform-react-jsx': 7.23.4(@babel/core@7.23.7) '@babel/plugin-transform-react-jsx-development': 7.22.5(@babel/core@7.23.7) - '@prefresh/vite': 2.4.5(preact@10.19.6)(vite@5.1.4) + '@prefresh/vite': 2.4.5(preact@10.19.6)(vite@5.1.5) '@rollup/pluginutils': 4.2.1 babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.23.7) debug: 4.3.4 @@ -833,7 +833,7 @@ packages: magic-string: 0.30.5 node-html-parser: 6.1.12 resolve: 1.22.8 - vite: 5.1.4 + vite: 5.1.5 transitivePeerDependencies: - preact - supports-color @@ -855,7 +855,7 @@ packages: resolution: {integrity: sha512-KtC/fZw+oqtwOLUFM9UtiitB0JsVX0zLKNyRTA332sqREqSALIIQQxdUCS1P3xR/jT1e2e8/5rwH6gdcMLEmsQ==} dev: true - /@prefresh/vite@2.4.5(preact@10.19.6)(vite@5.1.4): + /@prefresh/vite@2.4.5(preact@10.19.6)(vite@5.1.5): resolution: {integrity: sha512-iForDVJ2M8gQYnm5pHumvTEJjGGc7YNYC0GVKnHFL+GvFfKHfH9Rpq67nUAzNbjuLEpqEOUuQVQajMazWu2ZNQ==} peerDependencies: preact: ^10.4.0 @@ -867,7 +867,7 @@ packages: '@prefresh/utils': 1.2.0 '@rollup/pluginutils': 4.2.1 preact: 10.19.6 - vite: 5.1.4 + vite: 5.1.5 transitivePeerDependencies: - supports-color dev: true @@ -1055,14 +1055,14 @@ packages: /@types/prop-types@15.7.11: resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} - /@types/react-dom@18.2.19: - resolution: {integrity: sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==} + /@types/react-dom@18.2.20: + resolution: {integrity: sha512-HXN/biJY8nv20Cn9ZbCFq3liERd4CozVZmKbaiZ9KiKTrWqsP7eoGDO6OOGvJQwoVFuiXaiJ7nBBjiFFbRmQMQ==} dependencies: - '@types/react': 18.2.61 + '@types/react': 18.2.64 dev: true - /@types/react@18.2.61: - resolution: {integrity: sha512-NURTN0qNnJa7O/k4XUkEW2yfygA+NxS0V5h1+kp9jPwhzZy95q3ADoGMP0+JypMhrZBTTgjKAUlTctde1zzeQA==} + /@types/react@18.2.64: + resolution: {integrity: sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg==} dependencies: '@types/prop-types': 15.7.11 '@types/scheduler': 0.16.8 @@ -1078,7 +1078,7 @@ packages: /@types/webextension-polyfill@0.10.7: resolution: {integrity: sha512-10ql7A0qzBmFB+F+qAke/nP1PIonS0TXZAOMVOxEUsm+lGSW6uwVcISFNa0I4Oyj0884TZVWGGMIWeXOVSNFHw==} - /@vitejs/plugin-react@4.2.1(vite@5.1.4): + /@vitejs/plugin-react@4.2.1(vite@5.1.5): resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: @@ -1089,7 +1089,7 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.7) '@types/babel__core': 7.20.5 react-refresh: 0.14.0 - vite: 5.1.4 + vite: 5.1.5 transitivePeerDependencies: - supports-color dev: true @@ -1796,11 +1796,11 @@ packages: jotai: '>=1.11.0' optics-ts: '*' dependencies: - jotai: 2.7.0(@types/react@18.2.61)(react@18.2.0) + jotai: 2.7.0(@types/react@18.2.64)(react@18.2.0) optics-ts: 2.4.1 dev: false - /jotai@2.7.0(@types/react@18.2.61)(react@18.2.0): + /jotai@2.7.0(@types/react@18.2.64)(react@18.2.0): resolution: {integrity: sha512-4qsyFKu4MprI39rj2uoItyhu24NoCHzkOV7z70PQr65SpzV6CSyhQvVIfbNlNqOIOspNMdf5OK+kTXLvqe63Jw==} engines: {node: '>=12.20.0'} peerDependencies: @@ -1812,7 +1812,7 @@ packages: react: optional: true dependencies: - '@types/react': 18.2.61 + '@types/react': 18.2.64 react: 18.2.0 dev: false @@ -2264,7 +2264,7 @@ packages: engines: {node: '>=0.10.0'} dev: true - /react-remove-scroll-bar@2.3.4(@types/react@18.2.61)(react@18.2.0): + /react-remove-scroll-bar@2.3.4(@types/react@18.2.64)(react@18.2.0): resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==} engines: {node: '>=10'} peerDependencies: @@ -2274,13 +2274,13 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 18.2.61 + '@types/react': 18.2.64 react: 18.2.0 - react-style-singleton: 2.2.1(@types/react@18.2.61)(react@18.2.0) + react-style-singleton: 2.2.1(@types/react@18.2.64)(react@18.2.0) tslib: 2.6.2 dev: false - /react-remove-scroll@2.5.7(@types/react@18.2.61)(react@18.2.0): + /react-remove-scroll@2.5.7(@types/react@18.2.64)(react@18.2.0): resolution: {integrity: sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==} engines: {node: '>=10'} peerDependencies: @@ -2290,16 +2290,16 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 18.2.61 + '@types/react': 18.2.64 react: 18.2.0 - react-remove-scroll-bar: 2.3.4(@types/react@18.2.61)(react@18.2.0) - react-style-singleton: 2.2.1(@types/react@18.2.61)(react@18.2.0) + react-remove-scroll-bar: 2.3.4(@types/react@18.2.64)(react@18.2.0) + react-style-singleton: 2.2.1(@types/react@18.2.64)(react@18.2.0) tslib: 2.6.2 - use-callback-ref: 1.3.0(@types/react@18.2.61)(react@18.2.0) - use-sidecar: 1.1.2(@types/react@18.2.61)(react@18.2.0) + use-callback-ref: 1.3.0(@types/react@18.2.64)(react@18.2.0) + use-sidecar: 1.1.2(@types/react@18.2.64)(react@18.2.0) dev: false - /react-style-singleton@2.2.1(@types/react@18.2.61)(react@18.2.0): + /react-style-singleton@2.2.1(@types/react@18.2.64)(react@18.2.0): resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} engines: {node: '>=10'} peerDependencies: @@ -2309,14 +2309,14 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 18.2.61 + '@types/react': 18.2.64 get-nonce: 1.0.1 invariant: 2.2.4 react: 18.2.0 tslib: 2.6.2 dev: false - /react-textarea-autosize@8.5.3(@types/react@18.2.61)(react@18.2.0): + /react-textarea-autosize@8.5.3(@types/react@18.2.64)(react@18.2.0): resolution: {integrity: sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==} engines: {node: '>=10'} peerDependencies: @@ -2325,7 +2325,7 @@ packages: '@babel/runtime': 7.23.2 react: 18.2.0 use-composed-ref: 1.3.0(react@18.2.0) - use-latest: 1.2.1(@types/react@18.2.61)(react@18.2.0) + use-latest: 1.2.1(@types/react@18.2.64)(react@18.2.0) transitivePeerDependencies: - '@types/react' dev: false @@ -2639,7 +2639,7 @@ packages: punycode: 2.3.1 dev: true - /tsconfck@3.0.1(typescript@5.3.3): + /tsconfck@3.0.1(typescript@5.4.2): resolution: {integrity: sha512-7ppiBlF3UEddCLeI1JRx5m2Ryq+xk4JrZuq4EuYXykipebaq1dV0Fhgr1hb7CkmHt32QSgOZlcqVLEtHBG4/mg==} engines: {node: ^18 || >=20} hasBin: true @@ -2649,7 +2649,7 @@ packages: typescript: optional: true dependencies: - typescript: 5.3.3 + typescript: 5.4.2 dev: false /tslib@2.5.0: @@ -2668,8 +2668,8 @@ packages: resolution: {integrity: sha512-hN0zNkr5luPCeXTlXKxsfBPlkAzx86ZRM1vPdL7DbEqqWoeXSxplACy98NpKpLmXsdq7iePUzAXloCAoPKBV6A==} dev: true - /typescript@5.3.3: - resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + /typescript@5.4.2: + resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==} engines: {node: '>=14.17'} hasBin: true @@ -2696,7 +2696,7 @@ packages: requires-port: 1.0.0 dev: true - /use-callback-ref@1.3.0(@types/react@18.2.61)(react@18.2.0): + /use-callback-ref@1.3.0(@types/react@18.2.64)(react@18.2.0): resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==} engines: {node: '>=10'} peerDependencies: @@ -2706,7 +2706,7 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 18.2.61 + '@types/react': 18.2.64 react: 18.2.0 tslib: 2.6.2 dev: false @@ -2719,7 +2719,7 @@ packages: react: 18.2.0 dev: false - /use-isomorphic-layout-effect@1.1.2(@types/react@18.2.61)(react@18.2.0): + /use-isomorphic-layout-effect@1.1.2(@types/react@18.2.64)(react@18.2.0): resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==} peerDependencies: '@types/react': '*' @@ -2728,11 +2728,11 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 18.2.61 + '@types/react': 18.2.64 react: 18.2.0 dev: false - /use-latest@1.2.1(@types/react@18.2.61)(react@18.2.0): + /use-latest@1.2.1(@types/react@18.2.64)(react@18.2.0): resolution: {integrity: sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==} peerDependencies: '@types/react': '*' @@ -2741,12 +2741,12 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 18.2.61 + '@types/react': 18.2.64 react: 18.2.0 - use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.61)(react@18.2.0) + use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.64)(react@18.2.0) dev: false - /use-sidecar@1.1.2(@types/react@18.2.61)(react@18.2.0): + /use-sidecar@1.1.2(@types/react@18.2.64)(react@18.2.0): resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'} peerDependencies: @@ -2756,7 +2756,7 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 18.2.61 + '@types/react': 18.2.64 detect-node-es: 1.1.0 react: 18.2.0 tslib: 2.6.2 @@ -2787,7 +2787,7 @@ packages: - supports-color dev: true - /vite-plugin-preload@0.3.1(vite@5.1.4): + /vite-plugin-preload@0.3.1(vite@5.1.5): resolution: {integrity: sha512-WuaNOEoTOzQcVjrLrePJ37cNFDBdN9WMgqDtt4791DzkZVrXBJCdzlQsCqyvZ7QAuMQBQEApGRrIYOvdT9qIuA==} peerDependencies: vite: '>=4.0.0' @@ -2795,7 +2795,7 @@ packages: '@rollup/pluginutils': 5.1.0 jsdom: 22.1.0 prettier: 2.8.8 - vite: 5.1.4 + vite: 5.1.5 transitivePeerDependencies: - bufferutil - canvas @@ -2804,7 +2804,7 @@ packages: - utf-8-validate dev: true - /vite-tsconfig-paths@4.3.1(typescript@5.3.3)(vite@5.1.4): + /vite-tsconfig-paths@4.3.1(typescript@5.4.2)(vite@5.1.5): resolution: {integrity: sha512-cfgJwcGOsIxXOLU/nELPny2/LUD/lcf1IbfyeKTv2bsupVbTH/xpFtdQlBmIP1GEK2CjjLxYhFfB+QODFAx5aw==} peerDependencies: vite: '*' @@ -2814,15 +2814,15 @@ packages: dependencies: debug: 4.3.4 globrex: 0.1.2 - tsconfck: 3.0.1(typescript@5.3.3) - vite: 5.1.4 + tsconfck: 3.0.1(typescript@5.4.2) + vite: 5.1.5 transitivePeerDependencies: - supports-color - typescript dev: false - /vite@5.1.4: - resolution: {integrity: sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==} + /vite@5.1.5: + resolution: {integrity: sha512-BdN1xh0Of/oQafhU+FvopafUp6WaYenLU/NFoL5WyJL++GxkNfieKzBhM24H3HVsPQrlAqB7iJYTHabzaRed5Q==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -2920,8 +2920,8 @@ packages: isexe: 2.0.0 dev: true - /wouter@3.0.1(react@18.2.0): - resolution: {integrity: sha512-Wv2FSH56LchrKKoiGXn2F8ZA9gLgKiI3JVtI64DhZXXOtbgV/pFKSyMZ5ds94LoKxkGAyNEKK0LAnTMR0LMp3g==} + /wouter@3.0.2(react@18.2.0): + resolution: {integrity: sha512-T/rhpHjAnrJ0D056rJkezqCyKZ9NyTpdL4w43ja3l0nR+vDNnyLhtHe+7delBj0COzig6EdlB9XQbyjmyDm4gw==} peerDependencies: react: '>=16.8.0' dependencies: diff --git a/src/components/Channel/Card.tsx b/src/components/Channel/Card.tsx index c14ddb4..952d9a4 100644 --- a/src/components/Channel/Card.tsx +++ b/src/components/Channel/Card.tsx @@ -11,7 +11,6 @@ import { memo } from 'react' export const ChannelCard: React.FC<{ channel: std.Channel }> = memo(({ channel }) => { // prefetching the channel const getChannel = useDelayedEvent(() => yt.getChannel(channel.id), 400) - return ( = memo(({ channel } separation="...4px 16px" style={{ overflow: 'hidden', padding: '1rem' }} > - b.height - a.height)[0]?.url} size={128} /> + b.height - a.height)[0]?.url.replace(/^\/\//, 'https://')} + size={128} + /> {channel.user.name} diff --git a/src/components/ItemGrid.tsx b/src/components/ItemGrid.tsx index 03f80f6..cc98097 100644 --- a/src/components/ItemGrid.tsx +++ b/src/components/ItemGrid.tsx @@ -3,7 +3,7 @@ import { forwardRef } from 'react' import styled from 'styled-components' export type ItemGridProps = React.PropsWithChildren<{ - ref?: React.Ref + ref?: React.LegacyRef as?: React.ElementType header?: React.ReactElement | string size?: 'sm' | 'md' diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index b398368..7b3433d 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react' +import React, { memo, useState } from 'react' import styled from 'styled-components' import { Link } from 'wouter' import yt from '@yt' @@ -73,11 +73,13 @@ const NavBarContainer = styled.nav<{ $expanded: boolean }>` export const NavBar = memo(() => { const { isFullscreen } = useIsFullscreen() const [expanded, { toggle }] = useDisclosure(false) - const [settingsOpen, { open: openSettings, close: closeSettings }] = useDisclosure(false) + const [settingsOpen, setSettingsOpen] = useState(false) + console.log(settingsOpen) const followedUsers = usePaginated(yt.listFollowedUsers!) if (isFullscreen) return null return ( + setSettingsOpen(false)} /> {/* Ensures that the tooltip delays are synced with each other */} { - - + setSettingsOpen(true)} tooltip="Settings"> Settings diff --git a/src/components/Video/Card.tsx b/src/components/Video/Card.tsx index 0686177..3893b5f 100644 --- a/src/components/Video/Card.tsx +++ b/src/components/Video/Card.tsx @@ -13,56 +13,58 @@ import { Link } from 'wouter' import { useDelayedEvent } from '@/hooks/useDelayed' import Grid from '../lese/components/Grid' -export const VideoCard: React.FC<{ video: std.Video }> = memo(({ video }) => { - // prefecthing the player - const getPlayer = useDelayedEvent( - () => - yt.getPlayer(video.id).then((player) => { - const videoSource = player.sources - .filter(std.isVideoSource) - .find((source) => source.mimetype?.includes('vp9'))! - const audioSource = player.sources - .filter(std.isAudioSource)! - .find((source) => source.mimetype?.includes('opus'))! +export const VideoCard: React.FC<{ video: std.Video; size?: 'sm' | 'md' }> = memo( + ({ video, size = 'md' }) => { + // prefecthing the player + const getPlayer = useDelayedEvent( + () => + yt.getPlayer(video.id).then((player) => { + const videoSource = player.sources + .filter(std.isVideoSource) + .find((source) => source.mimetype?.includes('vp9'))! + const audioSource = player.sources + .filter(std.isAudioSource)! + .find((source) => source.mimetype?.includes('opus'))! - const videoPreconnect = document.createElement('link') - videoPreconnect.rel = 'preload' - videoPreconnect.as = 'video' - videoPreconnect.href = videoSource.url - document.head.appendChild(videoPreconnect) + const videoPreconnect = document.createElement('link') + videoPreconnect.rel = 'preload' + videoPreconnect.as = 'video' + videoPreconnect.href = videoSource.url + document.head.appendChild(videoPreconnect) - const audioPreconnect = document.createElement('link') - audioPreconnect.rel = 'preload' - audioPreconnect.as = 'audio' - audioPreconnect.href = audioSource.url - document.head.appendChild(audioPreconnect) - }), - 400, - ) - return ( - - - - - - {video.author?.avatar && } - - - {video.title} - - {video.author && } - - - - - ) -}) + const audioPreconnect = document.createElement('link') + audioPreconnect.rel = 'preload' + audioPreconnect.as = 'audio' + audioPreconnect.href = audioSource.url + document.head.appendChild(audioPreconnect) + }), + 400, + ) + return ( + + + + + + {video.author?.avatar && } + + + {video.title} + + {video.author && } + + + + + ) + }, +) export const VideoCardSkeleton: React.FC = () => ( diff --git a/src/components/Video/Grid.tsx b/src/components/Video/Grid.tsx index f6c776e..6bff4a2 100644 --- a/src/components/Video/Grid.tsx +++ b/src/components/Video/Grid.tsx @@ -11,13 +11,13 @@ type VideoGridProps = ItemGridProps & { getNext?: () => void } export const VideoGrid: FC = forwardRef( - ({ loading = false, loadingSkeletonCount = 32, videos, getNext, ...props }, ref) => { + ({ loading = false, loadingSkeletonCount = 32, videos, getNext, size, ...props }, ref) => { return ( - + {videos .filter((shelfOrVideo): shelfOrVideo is std.Video => 'id' in shelfOrVideo) .map((video) => ( - + ))} {loading && Array.from({ length: loadingSkeletonCount }).map((_, i) => )} diff --git a/src/components/Video/Shared.tsx b/src/components/Video/Shared.tsx index e063afb..7b0a828 100644 --- a/src/components/Video/Shared.tsx +++ b/src/components/Video/Shared.tsx @@ -5,12 +5,16 @@ import { Text } from '@mantine/core' import { Row } from 'lese' export const getVideoUrl = (video: std.Video) => - `/w/${video.id}${video.viewedLength ? `?t=${video.viewedLength}` : ''}` + `/w/${video.id}${ + video.viewedLength && (!video.length || video.viewedLength / video.length < 0.95) + ? `?t=${video.viewedLength}` + : '' + }` // todo: better name // todo: is size used? export const VideoSubLine: React.FC<{ - video: Pick + video: Pick short?: boolean size?: 'sm' | 'md' }> = ({ video, short, size = 'sm' }) => { @@ -24,11 +28,18 @@ export const VideoSubLine: React.FC<{ ) } + const viewCount = video.viewCount && formatNumberShort(video.viewCount!) + (short ? '' : ' views') + const publishDate = video.publishDate && formatDateAgo(video.publishDate!) + const text = [viewCount, publishDate].filter(Boolean).join(' • ') + return ( - - {formatNumberShort(video.viewCount!)} - {short ? '' : ' views'} - {video.publishDate ? ` • ${formatDateAgo(video.publishDate!)}` : ''} + + {video.subscriberOnly && ( + + Subs only + + )} + {text} ) } diff --git a/src/components/button/CollapsibleButton.tsx b/src/components/button/CollapsibleButton.tsx index 136843c..65286c7 100644 --- a/src/components/button/CollapsibleButton.tsx +++ b/src/components/button/CollapsibleButton.tsx @@ -3,9 +3,9 @@ import styled from 'styled-components' // todo: test for rightSection and loading export const CollapsibleButton = styled(Button)< - { collapseWidth?: string } & ButtonProps & React.HTMLAttributes + { $collapseWidth?: string } & ButtonProps & React.HTMLAttributes >` - @container (max-width: ${props => props.collapseWidth ?? '400px'}) { + @container (max-width: ${(props) => props.$collapseWidth ?? '400px'}) { padding-right: var(--_button-padding-left); & .mantine-Button-section { margin-left: 0; @@ -17,8 +17,6 @@ export const CollapsibleButton = styled(Button)< } ` -export const CollapsedButton = styled(Button)< - { collapseWidth?: string } & ButtonProps & React.HTMLAttributes ->` +export const CollapsedButton = styled(Button)>` --button-padding-x: calc(var(--button-padding-x-sm) / 1.5); ` diff --git a/src/components/button/CopyLinkButton.tsx b/src/components/button/CopyLinkButton.tsx index 1c5d458..4f30f3d 100644 --- a/src/components/button/CopyLinkButton.tsx +++ b/src/components/button/CopyLinkButton.tsx @@ -7,41 +7,41 @@ import { CollapsedButton, CollapsibleButton } from './CollapsibleButton' import { useTemporary } from '@/hooks/useTemporary' export const CopyLinkButton: FC<{ - provider: std.ProviderName - videoId: string - getCurrentTimeMS: () => number + provider: std.ProviderName + videoId: string + getCurrentTimeMS: () => number }> = ({ provider, videoId, getCurrentTimeMS }) => { - const [copied, setCopied] = useTemporary() - const [copiedAt, setCopiedAt] = useTemporary() - const CopiedIcon = copied ? IconCheck : IconClipboard - return ( - - } - collapseWidth="500px" - onClick={() => { - // TODO: use provider - navigator.clipboard.writeText(`https://youtube.com/watch?v=${videoId}`) - setCopied(true) - }} - > - {copied ? 'Copied!' : 'Copy Link'} - - - { - // TODO: use provider - const currentTimeSeconds = Math.floor(getCurrentTimeMS() / 1000) - navigator.clipboard.writeText(`https://youtube.com/watch?v=${videoId}&t=${currentTimeSeconds}`) - setCopiedAt(true) - }} - > - {copiedAt && } - {!copiedAt && } - - - - ) + const [copied, setCopied] = useTemporary() + const [copiedAt, setCopiedAt] = useTemporary() + const CopiedIcon = copied ? IconCheck : IconClipboard + return ( + + } + $collapseWidth="500px" + onClick={() => { + // TODO: use provider + navigator.clipboard.writeText(`https://youtube.com/watch?v=${videoId}`) + setCopied(true) + }} + > + {copied ? 'Copied!' : 'Copy Link'} + + + { + // TODO: use provider + const currentTimeSeconds = Math.floor(getCurrentTimeMS() / 1000) + navigator.clipboard.writeText(`https://youtube.com/watch?v=${videoId}&t=${currentTimeSeconds}`) + setCopiedAt(true) + }} + > + {copiedAt && } + {!copiedAt && } + + + + ) } diff --git a/src/components/button/LikeButton.tsx b/src/components/button/LikeButton.tsx index ff096db..cbd5aed 100644 --- a/src/components/button/LikeButton.tsx +++ b/src/components/button/LikeButton.tsx @@ -25,19 +25,19 @@ export const LikeButtons: React.FC<{ } onClick={() => setLikeStatus(std.toggleLikeStatus(std.LikeStatus.Like, likeStatus))} > - {toShortHumanReadable(likeCount!) + likeStatus === std.LikeStatus.Like ? 1 : 0} + {toShortHumanReadable(likeCount! + (likeStatus === std.LikeStatus.Like ? 1 : 0))} } onClick={() => setLikeStatus(std.toggleLikeStatus(std.LikeStatus.Dislike, likeStatus))} > - {toShortHumanReadable(dislikeCount!) + likeStatus === std.LikeStatus.Dislike ? 1 : 0} + {toShortHumanReadable(dislikeCount! + (likeStatus === std.LikeStatus.Dislike ? 1 : 0))} ) diff --git a/src/libs/format.ts b/src/libs/format.ts index 812d973..d7fd7c5 100644 --- a/src/libs/format.ts +++ b/src/libs/format.ts @@ -32,6 +32,7 @@ export const formatDayRelative = (date: Date) => { } if (daysAgo === 0) return 'Today' if (daysAgo === 1) return 'Yesterday' + if (date.getDay() < currentDate.getDay() && daysAgo < 7) return `${days[date.getDay()]}` // todo: what if their week starts on the monday if (daysAgo < 7) return `Last ${days[date.getDay()]}` return `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}` diff --git a/src/parser/std/core.ts b/src/parser/std/core.ts index 51dc969..1aa0b27 100644 --- a/src/parser/std/core.ts +++ b/src/parser/std/core.ts @@ -54,21 +54,23 @@ export enum ResourceType { Video = 'video', } -export type Resource = Type extends ResourceType.Category - ? GameCategory - : Type extends ResourceType.Channel - ? Channel - : Type extends ResourceType.Comment - ? Comment - : Type extends ResourceType.Playlist - ? Playlist - : Type extends ResourceType.Self - ? never - : Type extends ResourceType.User - ? User - : Type extends ResourceType.Video - ? Video - : never +// prettier-ignore +export type Resource = + Type extends ResourceType.Category + ? GameCategory + : Type extends ResourceType.Channel + ? Channel + : Type extends ResourceType.Comment + ? Comment + : Type extends ResourceType.Playlist + ? Playlist + : Type extends ResourceType.Self + ? never + : Type extends ResourceType.User + ? User + : Type extends ResourceType.Video + ? Video + : never; export type Provider = { listRecommended?: () => AsyncGenerator<(Video | Shelf)[]> diff --git a/src/parser/std/video.ts b/src/parser/std/video.ts index fa36422..08ef204 100644 --- a/src/parser/std/video.ts +++ b/src/parser/std/video.ts @@ -4,42 +4,43 @@ import { Playlist } from './playlist' import { RichText } from './components/rich-text' export enum VideoType { - Live = 'live', - Static = 'static', - Clip = 'clip', + Live = 'live', + Static = 'static', + Clip = 'clip', } export type Video = { - provider: ProviderName + provider: ProviderName - type: VideoType - id: string + type: VideoType + id: string - title: string - shortDescription?: string - description?: RichText - viewCount?: number + title: string + shortDescription?: string + description?: RichText + viewCount?: number + subscriberOnly?: boolean - likeStatus?: LikeStatus - likeCount?: number - dislikeCount?: number + likeStatus?: LikeStatus + likeCount?: number + dislikeCount?: number - author?: User + author?: User - /** The static and primary thumbnail for the video. An array of objects for various sizes */ - staticThumbnail: Image[] - /** The animated thumbnail for the video. An array of objects for various sizes. Can be used for on-hover for example */ - animatedThumbnail?: Image[] + /** The static and primary thumbnail for the video. An array of objects for various sizes */ + staticThumbnail: Image[] + /** The animated thumbnail for the video. An array of objects for various sizes. Can be used for on-hover for example */ + animatedThumbnail?: Image[] - /** Videos related to this video */ - related?: () => AsyncGenerator<(Video | User | Playlist)[]> + /** Videos related to this video */ + related?: () => AsyncGenerator<(Video | User | Playlist)[]> - /** Length of the video or uptime of live stream in seconds */ - length?: number - /** Length of the video in seconds that has already been viewed */ - viewedLength?: number - /** Date that the video was uploaded or that the live stream started */ - publishDate?: Date + /** Length of the video or uptime of live stream in seconds */ + length?: number + /** Length of the video in seconds that has already been viewed */ + viewedLength?: number + /** Date that the video was uploaded or that the live stream started */ + publishDate?: Date } export type HistoryVideos = { date: Date; videos: Video[] } diff --git a/src/parser/yt/channel/api.ts b/src/parser/yt/channel/api.ts index 863d525..b81b4e7 100644 --- a/src/parser/yt/channel/api.ts +++ b/src/parser/yt/channel/api.ts @@ -1,233 +1,218 @@ import { - AppendContinuationItemsAction, - AppendContinuationItemsResponse, - getContinuationResponseItems, -} from "@yt/components/continuation"; -import { MetadataBadge } from "../components/badge"; -import { Button, SubscribeButton } from "../components/button"; -import { Text } from "../components/text"; -import { Thumbnail } from "../components/thumbnail"; -import { Navigation } from "../components/utility/navigation"; -import { BrowseEndpoint, UrlEndpoint } from "../components/utility/endpoint"; + AppendContinuationItemsAction, + AppendContinuationItemsResponse, + getContinuationResponseItems, +} from '@yt/components/continuation' +import { MetadataBadge } from '../components/badge' +import { Button, SubscribeButton } from '../components/button' +import { Text } from '../components/text' +import { Thumbnail } from '../components/thumbnail' +import { Navigation } from '../components/utility/navigation' +import { BrowseEndpoint, UrlEndpoint } from '../components/utility/endpoint' import { - BaseResponse, - BrowseParams, - Endpoint, - fetchYt, - fetchEndpointContinuation, -} from "../core/api"; -import { Renderer, Some, ViewModel, isRenderer } from "../core/internals"; -import { - FullChannel, - ChannelTabName, - ChannelTabByName, - ChannelTagline, -} from "./types"; -import { Tab } from "../components/tab"; -import { Video } from "../video/processors/regular"; -import { RichItem } from "../components/item"; -import { InnertubeCommand } from "../components/utility/commands"; + BaseResponse, + BrowseParams, + Endpoint, + fetchYt, + fetchEndpointContinuation, + fetchBrowseContinuation, +} from '../core/api' +import { Renderer, Some, ViewModel, isRenderer } from '../core/internals' +import { FullChannel, ChannelTabName, ChannelTabByName, ChannelTagline } from './types' +import { Tab } from '../components/tab' +import { Video } from '../video/processors/regular' +import { RichItem } from '../components/item' +import { InnertubeCommand } from '../components/utility/commands' +import { ItemSection, SectionList } from '../components/core' +import { Grid } from '../components/grid' +import { GridPlaylist } from '../playlist/processors/grid' // Channel -export const fetchChannelHome = ( - channelId: string, -): Promise> => - fetchYt(Endpoint.Browse, { - browseId: channelId, - params: btoa(BrowseParams.ChannelHome), - }); +export const fetchChannelHome = (channelId: string): Promise> => + fetchYt(Endpoint.Browse, { + browseId: channelId, + params: btoa(BrowseParams.ChannelHome), + }) export const fetchChannelVideos = ( - channelId: string, - continuation?: string, + channelId: string, + continuation?: string, ): Promise> => - fetchYt( - Endpoint.Browse, - continuation - ? { continuation } - : { browseId: channelId, params: btoa(BrowseParams.ChannelVideos) }, - ); -type ChannelVideosContinuationResponse = BaseResponse & { - onResponseReceivedActions: [AppendContinuationItemsAction>]; -}; -export const fetchChannelVideosContinuation = ( - continuationToken: string, -): Promise => - fetchEndpointContinuation(Endpoint.Browse)(continuationToken); + fetchYt( + Endpoint.Browse, + continuation ? { continuation } : { browseId: channelId, params: btoa(BrowseParams.ChannelVideos) }, + ) +type ChannelVideosContinuationResponse = AppendContinuationItemsResponse> +export const fetchChannelVideosContinuation = fetchBrowseContinuation export const fetchChannelPlaylists = ( - channelId: string, + channelId: string, ): Promise> => - fetchYt(Endpoint.Browse, { - browseId: channelId, - params: btoa(BrowseParams.ChannelPlaylists), - }); - -export const fetchChannelLive = ( - channelId: string, -): Promise> => - fetchYt(Endpoint.Browse, { - browseId: channelId, - params: btoa(BrowseParams.ChannelLive), - }); - -export const fetchChannelChannels = ( - channelId: string, -): Promise> => - fetchYt(Endpoint.Browse, { - browseId: channelId, - params: btoa(BrowseParams.ChannelChannels), - }); + fetchYt(Endpoint.Browse, { + browseId: channelId, + params: btoa(BrowseParams.ChannelPlaylists), + }) +type ChannelPlaylistsContinuationResponse = AppendContinuationItemsResponse +export const fetchChannelPlaylistsContinuation = fetchBrowseContinuation + +export const fetchChannelLive = (channelId: string): Promise> => + fetchYt(Endpoint.Browse, { + browseId: channelId, + params: btoa(BrowseParams.ChannelLive), + }) + +export const fetchChannelChannels = (channelId: string): Promise> => + fetchYt(Endpoint.Browse, { + browseId: channelId, + params: btoa(BrowseParams.ChannelChannels), + }) export const getSelectedChannelTab = ( - channelResponse: ChannelResponse, + channelResponse: ChannelResponse, ): ChannelTabByName => - channelResponse.contents.twoColumnBrowseResultsRenderer.tabs.find( - (tab) => "tabRenderer" in tab && tab.tabRenderer.selected === true, - )! as unknown as ChannelTabByName; - -export const getTabContent = ( - tab: Tab, -): Content => tab.tabRenderer.content; - -export type ChannelResponse = - BaseResponse & { - contents: FullChannel; - metadata: ChannelMetadata; - microformat: ChannelMicroFormat; - header: ChannelHeader; - }; + channelResponse.contents.twoColumnBrowseResultsRenderer.tabs.find( + (tab) => 'tabRenderer' in tab && tab.tabRenderer.selected === true, + )! as unknown as ChannelTabByName + +export const getTabContent = (tab: Tab): Content => + tab.tabRenderer.content + +export type ChannelResponse = BaseResponse & { + contents: FullChannel + metadata: ChannelMetadata + microformat: ChannelMicroFormat + header: ChannelHeader +} type ChannelHeader = Renderer< - "c4TabbedHeader", - { - channelId: string; - - title: string; - tagline: ChannelTagline; - avatar: Thumbnail; - banner: Thumbnail; - tvBanner: Thumbnail; - mobileBanner: Thumbnail; - badges?: MetadataBadge[]; - - subscribeButton: SubscribeButton; - subscriberCountText: Some; - } & Navigation ->; + 'c4TabbedHeader', + { + channelId: string + + title: string + tagline: ChannelTagline + avatar: Thumbnail + banner: Thumbnail + tvBanner: Thumbnail + mobileBanner: Thumbnail + badges?: MetadataBadge[] + + subscribeButton: SubscribeButton + subscriberCountText: Some + } & Navigation +> type ChannelMetadata = Renderer< - "channelMetadata", - { - channelUrl: string; - vanityChannelUrl: string; - ownerUrls: string[]; - externalId: string; - - avatar: Thumbnail; - title: string; - description: string; - keywords: string; - - isFamilySafe: boolean; - availableCountryCodes: string[]; - androidDeepLink: string; - androidAppindexingLink: string; - iosAppindexingLink: string; - rssUrl: string; - } ->; + 'channelMetadata', + { + channelUrl: string + vanityChannelUrl: string + ownerUrls: string[] + externalId: string + + avatar: Thumbnail + title: string + description: string + keywords: string + + isFamilySafe: boolean + availableCountryCodes: string[] + androidDeepLink: string + androidAppindexingLink: string + iosAppindexingLink: string + rssUrl: string + } +> type ChannelMicroFormat = Renderer< - "microformatData", - { - urlCanonical: string; - title: string; - description: string; - thumbnail: Thumbnail; - noindex: boolean; - unlisted: boolean; - familySafe: boolean; - - availableCountries: string[]; - siteName: "YouTube"; - appName: "YouTube"; - androidPackage: string; - iosAppStoreId: string; - iosAppArguments: string; - ogType: string; - urlApplinksWeb: string; - urlApplinksIos: string; - urlApplinksAndroid: string; - urlTwitterIos: string; - urlTwitterAndroid: string; - twitterCardType: string; - twitterSiteHandle: string; - schemaDotOrgType: string; - linkAlternatives: { hrefUrl: string }[]; - } ->; + 'microformatData', + { + urlCanonical: string + title: string + description: string + thumbnail: Thumbnail + noindex: boolean + unlisted: boolean + familySafe: boolean + + availableCountries: string[] + siteName: 'YouTube' + appName: 'YouTube' + androidPackage: string + iosAppStoreId: string + iosAppArguments: string + ogType: string + urlApplinksWeb: string + urlApplinksIos: string + urlApplinksAndroid: string + urlTwitterIos: string + urlTwitterAndroid: string + twitterCardType: string + twitterSiteHandle: string + schemaDotOrgType: string + linkAlternatives: { hrefUrl: string }[] + } +> /// About export const fetchChannelAbout = (channelId: string) => - fetchChannelHome(channelId) - .then( - (res) => - res.header.c4TabbedHeaderRenderer.tagline.channelTaglineRenderer - .moreEndpoint.showEngagementPanelEndpoint.engagementPanel - .engagementPanelSectionListRenderer.content.sectionListRenderer - .contents[0].itemSectionRenderer.contents[0].continuationItemRenderer - .continuationEndpoint.continuationCommand.token, - ) - .then(fetchEndpointContinuation(Endpoint.Browse)) - .then(getContinuationResponseItems) - .then((res) => res.filter(isRenderer("aboutChannel"))[0]); - -type AboutChannelResponse = AppendContinuationItemsResponse; + fetchChannelHome(channelId) + .then( + (res) => + res.header.c4TabbedHeaderRenderer.tagline.channelTaglineRenderer.moreEndpoint + .showEngagementPanelEndpoint.engagementPanel.engagementPanelSectionListRenderer.content + .sectionListRenderer.contents[0].itemSectionRenderer.contents[0].continuationItemRenderer + .continuationEndpoint.continuationCommand.token, + ) + .then(fetchEndpointContinuation(Endpoint.Browse)) + .then(getContinuationResponseItems) + .then((res) => res.filter(isRenderer('aboutChannel'))[0]) + +type AboutChannelResponse = AppendContinuationItemsResponse type AboutChannel = Renderer< - "aboutChannel", - { - metadata: AboutChannelVM; - /** Action ommitted since we do not use it */ - flaggingButton: Button; - /** Action ommitted since we do not use it */ - shareChannel: Button; - } ->; + 'aboutChannel', + { + metadata: AboutChannelVM + /** Action ommitted since we do not use it */ + flaggingButton: Button + /** Action ommitted since we do not use it */ + shareChannel: Button + } +> type AboutChannelVM = ViewModel< - "aboutChannel", - { - channelId: string; - canonicalChannelUrl: string; - description: string; - subscriberCountText: string; - viewCountText: string; - joinedDateText: { content: string }; - videoCountText: string; - links: ChannelExternalLink[]; - - customUrlOnTap: { TODO: true }; - // The following describe the text and how to style the headers - additionalInfoLabel: { TODO: true }; - customLinksLabel: { TODO: true }; - descriptionLabel: { TODO: true }; - } ->; + 'aboutChannel', + { + channelId: string + canonicalChannelUrl: string + description: string + subscriberCountText: string + viewCountText: string + joinedDateText: { content: string } + videoCountText: string + links: ChannelExternalLink[] + + customUrlOnTap: { TODO: true } + // The following describe the text and how to style the headers + additionalInfoLabel: { TODO: true } + customLinksLabel: { TODO: true } + descriptionLabel: { TODO: true } + } +> type ChannelExternalLink = ViewModel< - "channelExternalLink", - { - title: { content: string }; - // todo: commandRuns is shared with the video description on watch pages - link: { - content: string; - commandRuns: { - startIndex: number; - length: number; - onTap: InnertubeCommand; - }[]; - }; - } ->; + 'channelExternalLink', + { + title: { content: string } + // todo: commandRuns is shared with the video description on watch pages + link: { + content: string + commandRuns: { + startIndex: number + length: number + onTap: InnertubeCommand + }[] + } + } +> diff --git a/src/parser/yt/channel/index.ts b/src/parser/yt/channel/index.ts index 867c917..45b5cfc 100644 --- a/src/parser/yt/channel/index.ts +++ b/src/parser/yt/channel/index.ts @@ -1,113 +1,118 @@ -import * as std from "@std"; -import { getContinuationResponseItems } from "@yt/components/continuation"; -import { makeContinuationIterator } from "@yt/core/api"; -import { isRenderer, unwrapRenderer } from "@yt/core/internals"; +import * as std from '@std' +import { getContinuationResponseItems } from '@yt/components/continuation' +import { fetchEndpointContinuation, makeContinuationIterator } from '@yt/core/api' +import { isRenderer, unwrapRenderer } from '@yt/core/internals' import { - fetchChannelAbout, - fetchChannelHome, - fetchChannelLive, - fetchChannelVideos, - fetchChannelVideosContinuation, - getSelectedChannelTab, - getTabContent, -} from "./api"; -import { isTab } from "./helpers"; -import { processChannelPage } from "./processors/channel-page"; -import { ChannelTabByName, ChannelTabName } from "./types"; -import { processVideo } from "../video/processors/regular"; -import { processShelf } from "./processors/shelf"; -import { getEndpointUrl } from "../components/utility/endpoint"; + fetchChannelAbout, + fetchChannelHome, + fetchChannelLive, + fetchChannelPlaylists, + fetchChannelPlaylistsContinuation, + fetchChannelVideos, + fetchChannelVideosContinuation, + getSelectedChannelTab, + getTabContent, +} from './api' +import { isTab } from './helpers' +import { processChannelPage } from './processors/channel-page' +import { ChannelTabByName, ChannelTabName } from './types' +import { processVideo } from '../video/processors/regular' +import { processShelf } from './processors/shelf' +import { getEndpointUrl } from '../components/utility/endpoint' +import { processGridPlaylist } from '../playlist/processors/grid' -export const getChannel = (channelId: string): Promise => - processChannelPage(channelId); +export const getChannel = (channelId: string): Promise => processChannelPage(channelId) -export async function* listChannelVideos( - channelId: string, -): AsyncGenerator { - const channelVideosIterator = makeContinuationIterator( - () => - // todo: move to separate parser - fetchChannelVideos(channelId).then((res) => { - const tabRenderer = unwrapRenderer(res.contents).tabs.find( - isTab(ChannelTabName.Videos), - ); - if (!tabRenderer) - throw Error("Failed to find video tab in YT response"); - const richGridRenderer = unwrapRenderer(tabRenderer).content; - if (!richGridRenderer) - throw Error( - "Failed to find list of videos in the video tab in YT response", - ); - const richItems = richGridRenderer.richGridRenderer.contents; - if (!richItems || richItems.length === 0) - throw Error( - "Failed to find list of videos in the video tab in YT response", - ); - return richItems; - }), - (token) => - fetchChannelVideosContinuation(token).then(getContinuationResponseItems), - ); +export async function* listChannelVideos(channelId: string): AsyncGenerator { + const channelVideosIterator = makeContinuationIterator( + () => + // todo: move to separate parser + fetchChannelVideos(channelId).then((res) => { + const tabRenderer = unwrapRenderer(res.contents).tabs.find(isTab(ChannelTabName.Videos)) + if (!tabRenderer) throw Error('Failed to find video tab in YT response') + const richGridRenderer = unwrapRenderer(tabRenderer).content + if (!richGridRenderer) throw Error('Failed to find list of videos in the video tab in YT response') + const richItems = richGridRenderer.richGridRenderer.contents + if (!richItems) throw Error('Failed to find list of videos in the video tab in YT response') + return richItems + }), + (token) => fetchChannelVideosContinuation(token).then(getContinuationResponseItems), + ) - for await (const channelVideos of channelVideosIterator) { - console.log("channelVideos", channelVideos); - yield channelVideos - .map((_) => _.richItemRenderer.content) - .map(processVideo); - } + for await (const channelVideos of channelVideosIterator) { + console.log('channelVideos', channelVideos) + yield channelVideos.map((_) => _.richItemRenderer.content).map(processVideo) + } } +// todo: only appears on the first load of a channel export const getChannelFeaturedVideo = ( - home: ChannelTabByName["tabRenderer"]["content"], + home: ChannelTabByName['tabRenderer']['content'], ): std.Video | undefined => { - // biome-ignore lint/complexity/useFlatMap: Typing issue means we have to do it this way - const featuredVideo = home.sectionListRenderer.contents - .filter(isRenderer("itemSection")) - .map((section) => section.itemSectionRenderer.contents) - .flat() - .find(isRenderer("channelFeaturedContent")) - ?.channelFeaturedContentRenderer.items.find(isRenderer("video")); - if (!featuredVideo) return; - return processVideo(featuredVideo); -}; + // biome-ignore lint/complexity/useFlatMap: Typing issue means we have to do it this way + const featuredVideo = home.sectionListRenderer.contents + .filter(isRenderer('itemSection')) + .map((section) => section.itemSectionRenderer.contents) + .flat() + .find(isRenderer('channelFeaturedContent')) + ?.channelFeaturedContentRenderer.items.find(isRenderer('video')) + if (!featuredVideo) return + return processVideo(featuredVideo) +} -export const listChannelLiveVideos = async ( - channelId: string, -): Promise => - fetchChannelLive(channelId) - .then(getSelectedChannelTab) - .then(getTabContent) - .then((live) => - live.richGridRenderer.contents - .flatMap((grid) => grid.richItemRenderer.content) - .map(processVideo), - ); +export const listChannelLiveVideos = async (channelId: string): Promise => + fetchChannelLive(channelId) + .then(getSelectedChannelTab) + .then(getTabContent) + .then((live) => + live.richGridRenderer.contents.flatMap((grid) => grid.richItemRenderer.content).map(processVideo), + ) -export const listChannelShelves = async ( - channelId: string, -): Promise => - fetchChannelHome(channelId) - .then(getSelectedChannelTab) - .then(getTabContent) - .then((home) => - // biome-ignore lint/complexity/useFlatMap: Typing issue means we have to do it this way - home.sectionListRenderer.contents - .filter(isRenderer("itemSection")) - .map((section) => section.itemSectionRenderer.contents) - .flat() - .filter(isRenderer("shelf")) - .map(processShelf), - ); +export const listChannelShelves = async (channelId: string): Promise => + fetchChannelHome(channelId) + .then(getSelectedChannelTab) + .then(getTabContent) + .then((home) => + // biome-ignore lint/complexity/useFlatMap: Typing issue means we have to do it this way + home.sectionListRenderer.contents + .filter(isRenderer('itemSection')) + .map((section) => section.itemSectionRenderer.contents) + .flat() + .filter(isRenderer('shelf')) + .map(processShelf), + ) export const listChannelLinks = async (channelId: string): Promise => - fetchChannelAbout(channelId).then(({ aboutChannelRenderer: about }) => - about.metadata.aboutChannelViewModel.links - .map( - (link) => - link.channelExternalLinkViewModel.link.commandRuns[0].onTap - .innertubeCommand, - ) - .map(getEndpointUrl) - .filter((link): link is string => Boolean(link)), - ); + fetchChannelAbout(channelId).then(({ aboutChannelRenderer: about }) => + about.metadata.aboutChannelViewModel.links + .map((link) => link.channelExternalLinkViewModel.link.commandRuns[0].onTap.innertubeCommand) + .map(getEndpointUrl) + .filter((link): link is string => Boolean(link)), + ) + +export async function* listChannelPlaylists(channelId: string): AsyncGenerator { + const channelPlaylistsIterator = makeContinuationIterator( + () => + // todo: move to separate parser + fetchChannelPlaylists(channelId).then((res) => { + const tabRenderer = getSelectedChannelTab(res) + if (!tabRenderer) throw Error('Failed to find playlist tab in YT response') + const gridRenderer = + unwrapRenderer(tabRenderer).content?.sectionListRenderer.contents?.[0]?.itemSectionRenderer + .contents?.[0]?.gridRenderer + if (!gridRenderer) { + throw Error('Failed to find list of playlists in the playlist tab in YT response') + } + const items = gridRenderer.items + if (!items) throw Error('Failed to find list of videos in the video tab in YT response') + return items + }), + (token) => fetchChannelPlaylistsContinuation(token).then(getContinuationResponseItems), + ) + + for await (const channelPlaylists of channelPlaylistsIterator) { + console.log('channelPlaylists', channelPlaylists) + yield channelPlaylists.map(processGridPlaylist) + } +} diff --git a/src/parser/yt/channel/types.ts b/src/parser/yt/channel/types.ts index 66b1f16..e556e37 100644 --- a/src/parser/yt/channel/types.ts +++ b/src/parser/yt/channel/types.ts @@ -2,11 +2,11 @@ import { ContinuationItem } from '@yt/components/continuation' import { MetadataBadge } from '../components/badge' import { SubscribeButton } from '../components/button' import { - HorizontalList, - ItemSection, - ItemSectionWithIdentifier, - SectionList, - Shelf, + HorizontalList, + ItemSection, + ItemSectionWithIdentifier, + SectionList, + Shelf, } from '../components/core' import { Grid, RichGrid } from '../components/grid' import { ExpandableTab, Tab } from '../components/tab' @@ -20,42 +20,46 @@ import { CommandMetadata, Renderer, Some } from '../core/internals' import { GridVideo } from '../video/processors/grid' import { RichItem } from '../components/item' import { Video } from '../video/processors/regular' -import { Endpoint } from '../core/internals' import { Icon } from '../components/icon' import { ShowEngagementPanelEndpoint } from '../components/engagement-panel' +import { GridPlaylist } from '../playlist/processors/grid' export type FullChannel = TwoColumnBrowseResults> export enum ChannelTabName { - Home = 'Home', - Videos = 'Videos', - Shorts = 'Shorts', - Live = 'Live', - Playlists = 'Playlists', - Community = 'Community', - Store = 'Store', - Channels = 'Channels', - About = 'About', - Search = 'Search', + Home = 'Home', + Videos = 'Videos', + Shorts = 'Shorts', + Live = 'Live', + Playlists = 'Playlists', + Community = 'Community', + Store = 'Store', + Channels = 'Channels', + About = 'About', + Search = 'Search', } type IsTabName = Received extends Expected - ? true - : false + ? true + : false export type ChannelTab = - | HomeTab> - | VideosTab> - | ShortsTab> - | LiveTab> - | PlaylistsTab> - | CommunityTab> - | StoreTab> - | ChannelsTab> - | AboutTab> - | SearchExpandableTab + | HomeTab> + | VideosTab> + | ShortsTab> + | LiveTab> + | PlaylistsTab> + | CommunityTab> + | StoreTab> + | ChannelsTab> + | AboutTab> + | SearchExpandableTab -export type ChannelTabByName = IsTabName extends true +// prettier-ignore +export type ChannelTabByName = IsTabName< + ChannelTabName.Home, + Name +> extends true ? HomeTab : IsTabName extends true ? VideosTab @@ -73,25 +77,29 @@ export type ChannelTabByName = IsTabName : IsTabName extends true ? AboutTab - : never + : never; export type HomeTab = Tab< - ChannelTabName.Home, - SectionList< - | ItemSection> - | ItemSection - | ItemSection | HorizontalList>> - >, - Selected + ChannelTabName.Home, + SectionList< + | ItemSection> + | ItemSection + | ItemSection | HorizontalList>> + >, + Selected > export type VideosTab = Tab< - ChannelTabName.Videos, - RichGrid | ContinuationItem> & Renderer<'sectionList', Record>, - Selected + ChannelTabName.Videos, + RichGrid | ContinuationItem> & Renderer<'sectionList', Record>, + Selected > export type ShortsTab = Tab, Selected> export type LiveTab = Tab>, Selected> -export type PlaylistsTab = Tab, Selected> +export type PlaylistsTab = Tab< + ChannelTabName.Playlists, + SectionList>>, + Selected +> export type CommunityTab = Tab, Selected> export type StoreTab = Tab, Selected> export type ChannelsTab = Tab, Selected> @@ -99,59 +107,59 @@ export type AboutTab = Tab export type GridChannel = Renderer< - 'gridChannel', - Navigation & { - channelId: string - ownerBadges: MetadataBadge[] - subscribeButton: SubscribeButton - subscriberCountText: Accessibility> - thumbnail: Thumbnail - title: Some - videoCountText: Some - } + 'gridChannel', + Navigation & { + channelId: string + ownerBadges: MetadataBadge[] + subscribeButton: SubscribeButton + subscriberCountText: Accessibility> + thumbnail: Thumbnail + title: Some + videoCountText: Some + } > // todo: does this still exist? type ChannelVideoPlayer = Renderer< - 'channelVideoPlayer', - { - description: Some> - publishedTimeText: Some - readMoreText: Some> - title: Some>> - videoId: string - viewCountText: Some - } + 'channelVideoPlayer', + { + description: Some> + publishedTimeText: Some + readMoreText: Some> + title: Some>> + videoId: string + viewCountText: Some + } > type ChannelFeaturedContent = Renderer< - 'channelFeaturedContent', - { title: Record; items: Item[] } + 'channelFeaturedContent', + { title: Record; items: Item[] } > export type ChannelTagline = Renderer< - 'channelTagline', - { - content: string - maxLines: number - moreEndpoint: ShowEngagementPanelEndpoint - moreIcon: Icon - } + 'channelTagline', + { + content: string + maxLines: number + moreEndpoint: ShowEngagementPanelEndpoint + moreIcon: Icon + } > export type Channel = Renderer< - 'channel', - { - channelId: string - title: Some - thumbnail: Thumbnail - descriptionSnippet: Some - shortByLineText: NavigationSome - videoCountText: Some - subscriptionButton: { subscribed: boolean } - ownerBadges?: MetadataBadge[] - subscriberCountText: Some - subscribeButton: SubscribeButton - longBylineText: NavigationSome - } & Navigation + 'channel', + { + channelId: string + title: Some + thumbnail: Thumbnail + descriptionSnippet: Some + shortByLineText: NavigationSome + videoCountText: Some + subscriptionButton: { subscribed: boolean } + ownerBadges?: MetadataBadge[] + subscriberCountText: Some + subscribeButton: SubscribeButton + longBylineText: NavigationSome + } & Navigation > diff --git a/src/parser/yt/components/badge.ts b/src/parser/yt/components/badge.ts index 03aa8e5..4d00d3a 100644 --- a/src/parser/yt/components/badge.ts +++ b/src/parser/yt/components/badge.ts @@ -18,5 +18,8 @@ export type MetadataBadge = Renderer< Tracking > -export const isVerifiedBadge = (badge: MetadataBadge) => badge.metadataBadgeRenderer.style === 'BADGE_STYLE_TYPE_VERIFIED' +export const isVerifiedBadge = (badge: MetadataBadge) => + badge.metadataBadgeRenderer.style === 'BADGE_STYLE_TYPE_VERIFIED' export const isLiveBadge = (badge: MetadataBadge) => badge.metadataBadgeRenderer.label === 'LIVE' +export const isMembersOnlyBadge = (badge: MetadataBadge) => + badge.metadataBadgeRenderer.style === 'BADGE_STYLE_TYPE_MEMBERS_ONLY' diff --git a/src/parser/yt/components/button.ts b/src/parser/yt/components/button.ts index 372b6d7..bb5619a 100644 --- a/src/parser/yt/components/button.ts +++ b/src/parser/yt/components/button.ts @@ -1,29 +1,29 @@ import { - Command, - CommandMetadata, - ExtractCommand, - ExtractRawCommand, - OptionalSubCommand, - Renderer, - Some, - SubCommand, - ViewModel, + Command, + CommandMetadata, + ExtractCommand, + ExtractRawCommand, + OptionalSubCommand, + Renderer, + Some, + SubCommand, + ViewModel, } from '../core/internals' import { Text } from './text' import { Tracking } from './utility/tracking' import { - LikeEndpoint, - OfflineVideoEndpoint, - SignalServiceEndpoint, - SubscribeEndpoint, + LikeEndpoint, + OfflineVideoEndpoint, + SignalServiceEndpoint, + SubscribeEndpoint, } from './utility/endpoints' import { OpenPopupAction } from './utility/actions' import { - GestureCommand, - InnertubeCommand, - OnAddCommand, - SerialCommand, - UpdateToggleButtonCommand, + GestureCommand, + InnertubeCommand, + OnAddCommand, + SerialCommand, + UpdateToggleButtonCommand, } from './utility/commands' import { Icon } from './icon' import { LikeStatus } from './like-status' @@ -33,158 +33,160 @@ import { Thumbnail } from './thumbnail' type Size = string // Never seen anything other than "SIZE_DEFAULT" and "BUTTON_VIEW_MODEL_SIZE_DEFAULT" type Style = { - styleType: StyleType + styleType: StyleType } type StyleType = string // 'STYLE_BLUE_TEXT' | 'STYLE_TEXT' | 'STYLE_DEFAULT_ACTIVE' | 'BUTTON_VIEW_MODEL_STYLE_MONO' type IconPosition = string // 'BUTTON_ICON_POSITION_TYPE_LEFT_OF_TEXT' export type Button = Renderer< - 'button', - { - text: Some - style?: StyleType - size?: Size - icon?: Icon - iconPosition?: IconPosition - isDisabled?: boolean - tooltip?: string - /** Defined on the "# replies" button when the creator of a video has replied to a comment */ - thumbnail?: Thumbnail - } & ExtractCommand + 'button', + { + text: Some + style?: StyleType + size?: Size + icon?: Icon + iconPosition?: IconPosition + isDisabled?: boolean + tooltip?: string + /** Defined on the "# replies" button when the creator of a video has replied to a comment */ + thumbnail?: Thumbnail + } & ExtractCommand > export type SubscribeButton = Renderer< - 'subscribeButton', - { - buttonText: Some - channelId: string - subscribed: boolean - enabled: boolean - - onSubscribeEndpoints: (SubscribeEndpoint & CommandMetadata)[] - onUnsubscribeEndpoints: (SignalServiceEndpoint<'CLIENT_SIGNAL', OpenPopupAction>> & - CommandMetadata)[] - - type: string // 'FREE' - targetId: string // 'watch-subscribe' - /** Omitted for now */ - notificationPreferenceButton: Record - /** No idea what this is */ - showPreferences: boolean - - /** No idea what this is */ - subscribedEntityKey: string - subscribedButtonText: Some - - unsubscribeButtonText: Some - unsubscribedButtonText: Some - } + 'subscribeButton', + { + buttonText: Some + channelId: string + subscribed: boolean + enabled: boolean + + onSubscribeEndpoints: (SubscribeEndpoint & CommandMetadata)[] + onUnsubscribeEndpoints: (SignalServiceEndpoint<'CLIENT_SIGNAL', OpenPopupAction>> & + CommandMetadata)[] + + type: string // 'FREE' + targetId: string // 'watch-subscribe' + /** Omitted for now */ + notificationPreferenceButton: Record + /** No idea what this is */ + showPreferences: boolean + + /** No idea what this is */ + subscribedEntityKey: string + subscribedButtonText: Some + + unsubscribeButtonText: Some + unsubscribedButtonText: Some + } > export type ToggleButton< - DefaultServiceEndpoint extends SubCommand, - ToggledServiceEndpoint extends SubCommand, + DefaultServiceEndpoint extends SubCommand, + ToggledServiceEndpoint extends SubCommand, > = Renderer< - 'toggleButton', - { - style: Style - isToggled: boolean - isDisabled?: boolean - targetId: string - - defaultIcon?: Icon - defaultServiceEndpoint: ExtractRawCommand - /** Text that shows up on the button. Ex. like count or "Share" */ - defaultText: Some> - defaultTooltip: string - - toggledIcon?: Icon - toggledServiceEndpoint: ExtractRawCommand - toggledText: Some> - toggledTooltip: string - toggledStyle?: Style - } + 'toggleButton', + { + style: Style + isToggled: boolean + isDisabled?: boolean + targetId: string + + defaultIcon?: Icon + defaultServiceEndpoint: ExtractRawCommand + /** Text that shows up on the button. Ex. like count or "Share" */ + defaultText: Some> + defaultTooltip: string + + toggledIcon?: Icon + toggledServiceEndpoint: ExtractRawCommand + toggledText: Some> + toggledTooltip: string + toggledStyle?: Style + } > export type SharePanel = Renderer<'unifiedSharePane', Tracking & { showLoadingSpinner: boolean }> // View Model version of buttons export type ButtonVM = ViewModel< - 'button', - { - type: string // 'BUTTON_VIEW_MODEL_TYPE_TONAL' - style: StyleType - buttonSize: Size - title: string - tooltip: string - iconName?: string - isFullWidth?: boolean - onTap: OnTap - } + 'button', + { + type: string // 'BUTTON_VIEW_MODEL_TYPE_TONAL' + style: StyleType + buttonSize: Size + title: string + tooltip: string + iconName?: string + isFullWidth?: boolean + onTap: OnTap + accessibilityId?: string + accessibilityText?: string + } > export type ToggleButtonVM = ViewModel< - 'toggleButton', - { - isTogglingDisabled: boolean - defaultButtonViewModel: ButtonVM - toggledButtonViewModel: ButtonVM - } + 'toggleButton', + { + isTogglingDisabled: boolean + defaultButtonViewModel: ButtonVM + toggledButtonViewModel: ButtonVM + } > export type LikeToggleButtonVM = ToggleButtonVM< - SerialCommand>, - SerialCommand> + SerialCommand>, + SerialCommand> > export type LikeButtonVM = ViewModel< - 'likeButton', - { - likeEntityKey: string - likeStatusEntity: { key: string; likeStatus: LikeStatus } - toggleButtonViewModel: LikeToggleButtonVM - } + 'likeButton', + { + likeEntityKey: string + likeStatusEntity: { key: string; likeStatus: LikeStatus } + toggleButtonViewModel: LikeToggleButtonVM + } > export type DislikeButtonVM = ViewModel< - 'dislikeButton', - { - dislikeEntityKey: string - toggleButtonViewModel: LikeToggleButtonVM - } + 'dislikeButton', + { + dislikeEntityKey: string + toggleButtonViewModel: LikeToggleButtonVM + } > export type SegmentedLikeDislikeButtonVM = ViewModel< - 'segmentedLikeDislikeButton', - { - dislikeButtonViewModel: DislikeButtonVM - likeButtonViewModel: LikeButtonVM - likeCountEntity: { TODO: true } - } + 'segmentedLikeDislikeButton', + { + dislikeButtonViewModel: DislikeButtonVM + likeButtonViewModel: LikeButtonVM + likeCountEntity: { TODO: true } + } > export type DownloadButton = Renderer< - 'download', - { - style: StyleType - size?: Size - targetId: string - command: OfflineVideoEndpoint - } + 'download', + { + style: StyleType + size?: Size + targetId: string + command: OfflineVideoEndpoint + } > export type ThumbnailOverlayToggleButton< - ToggledServiceEndpoint extends SubCommand, - UntoggledServiceEndpoint extends SubCommand, + ToggledServiceEndpoint extends SubCommand, + UntoggledServiceEndpoint extends SubCommand, > = Renderer< - 'thumbnailOverlayToggleButton', - { - isToggled: boolean - - toggledIcon?: Icon - toggledTooltip: string - toggledServiceEndpoint: ExtractRawCommand - - untoggledIcon?: Icon - untoggledTooltip: string - untoggledServiceEndpoint: ExtractRawCommand - } + 'thumbnailOverlayToggleButton', + { + isToggled: boolean + + toggledIcon?: Icon + toggledTooltip: string + toggledServiceEndpoint: ExtractRawCommand + + untoggledIcon?: Icon + untoggledTooltip: string + untoggledServiceEndpoint: ExtractRawCommand + } > diff --git a/src/parser/yt/core/api/continuation.ts b/src/parser/yt/core/api/continuation.ts index 9562cb3..f8c1209 100644 --- a/src/parser/yt/core/api/continuation.ts +++ b/src/parser/yt/core/api/continuation.ts @@ -7,6 +7,9 @@ export const fetchEndpointContinuation = (continuation: string): Promise => fetchYt(endpoint, { continuation }) +export const fetchBrowseContinuation = (continuation: string) => + fetchEndpointContinuation(Endpoint.Browse)(continuation) + export const findContinuation = (items: (Renderer | ContinuationItem)[]): string | undefined => findRenderer('continuationItem')(items)?.continuationEndpoint.continuationCommand.token diff --git a/src/parser/yt/core/api/declarative-net-request.ts b/src/parser/yt/core/api/declarative-net-request.ts index a0ef935..05b061f 100644 --- a/src/parser/yt/core/api/declarative-net-request.ts +++ b/src/parser/yt/core/api/declarative-net-request.ts @@ -4,58 +4,57 @@ import { endpoints } from '@libs/extension' // FIXME: Check if the rule exists and then run this on init // FIXME: Should be part of a startup process for the provider export const setDeclarativeNetRequestHeaderRule = memoizeAsync(() => - endpoints.declarativeNetRequest.updateDynamicRules({ - removeRuleIds: [1000, 1001, 1002], - addRules: [ - { - id: 1000, - condition: { - urlFilter: 'ggpht', - requestDomains: ['yt3.ggpht.com', 'i.ytimg.com'], - }, - action: { - type: 'modifyHeaders', - requestHeaders: [ - { - header: 'Origin', - operation: 'set', - value: 'https://www.youtube.com', - }, - { - header: 'Referer', - operation: 'set', - value: 'https://www.youtube.com', - }, - ], - }, - }, - // TODO: handle embeds - // { - // id: 1001, - // condition: { - // regexFilter: 'https://www.youtube.com/watch\\?v=(.*)', - // resourceTypes: ['main_frame'], - // }, - // action: { - // type: 'redirect', - // redirect: { - // regexSubstitution: 'http://localhost:3000/w/\\1', - // }, - // }, - // }, - // { - // id: 1002, - // condition: { - // regexFilter: 'https://www.youtube.com', - // resourceTypes: ['main_frame'], - // }, - // action: { - // type: 'redirect', - // redirect: { - // regexSubstitution: 'http://localhost:3000/', - // }, - // }, - // }, - ], - }), + endpoints.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [1000, 1001, 1002], + addRules: [ + { + id: 1000, + condition: { + requestDomains: ['yt3.ggpht.com', 'i.ytimg.com'], + }, + action: { + type: 'modifyHeaders', + requestHeaders: [ + { + header: 'Origin', + operation: 'set', + value: 'https://www.youtube.com', + }, + { + header: 'Referer', + operation: 'set', + value: 'https://www.youtube.com', + }, + ], + }, + }, + // TODO: handle embeds + // { + // id: 1001, + // condition: { + // regexFilter: 'https://www.youtube.com/watch\\?v=(.*)', + // resourceTypes: ['main_frame'], + // }, + // action: { + // type: 'redirect', + // redirect: { + // regexSubstitution: 'http://localhost:3000/w/\\1', + // }, + // }, + // }, + // { + // id: 1002, + // condition: { + // regexFilter: 'https://www.youtube.com', + // resourceTypes: ['main_frame'], + // }, + // action: { + // type: 'redirect', + // redirect: { + // regexSubstitution: 'http://localhost:3000/', + // }, + // }, + // }, + ], + }), ) diff --git a/src/parser/yt/core/helpers.ts b/src/parser/yt/core/helpers.ts index 59abdc1..34558d4 100644 --- a/src/parser/yt/core/helpers.ts +++ b/src/parser/yt/core/helpers.ts @@ -1,48 +1,39 @@ -export const divideByAndConcat = - (divisor: number, suffix: string) => (num: number) => { - const divided = num / divisor; - const stringified = - divided >= 10 ? String(Math.floor(divided)) : divided.toFixed(1); - return stringified + suffix; - }; +export const divideByAndConcat = (divisor: number, suffix: string) => (num: number) => { + const divided = num / divisor + const stringified = divided >= 10 ? String(Math.floor(divided)) : divided.toFixed(1) + return stringified + suffix +} export const toShortHumanReadable = (num: number) => { - if (Number.isNaN(num)) { - console.warn("NaN provided to toShortHumanReadable"); - return "NaN"; - } - if (num <= 1_000) return String(num); - if (num <= 1_000_000) return divideByAndConcat(1_000, "K")(num); - if (num <= 1_000_000_000) return divideByAndConcat(1_000_000, "M")(num); - return divideByAndConcat(1_000_000_000, "B")(num); -}; + if (Number.isNaN(num)) { + console.warn('NaN provided to toShortHumanReadable') + return 'NaN' + } + if (num <= 1_000) return String(num) + if (num <= 1_000_000) return divideByAndConcat(1_000, 'K')(num) + if (num <= 1_000_000_000) return divideByAndConcat(1_000_000, 'M')(num) + return divideByAndConcat(1_000_000_000, 'B')(num) +} /** Text must be in the form "123,345 views", "123k views", "1.5M views", etc. */ export function fromShortHumanReadable(text: string): number { - const relativeNumber = text - .split(/([\d\.,KMBT]+)/gi) - .filter(Boolean)[0] - .replaceAll(",", ""); + const relativeNumber = text + .split(/([\d\.,KMBT]+)/gi) + .filter(Boolean)[0] + .replaceAll(',', '') - const number = Number(relativeNumber.replaceAll(/[^\d\.]/g, "")); - if (Number.isNaN(number)) - throw Error( - `Unable to parse number in short human readable number: "${text}"`, - ); + const number = Number(relativeNumber.replaceAll(/[^\d\.]/g, '')) + if (Number.isNaN(number)) throw Error(`Unable to parse number in short human readable number: "${text}"`) - const suffix = relativeNumber.replaceAll(/[\d\.]/g, "").toLowerCase(); - if (suffix.length > 1) { - throw new Error( - `Detected more than one suffix "${suffix}" in short human readable number: "${text}"`, - ); - } - if (suffix.length === 0) return number; - if (suffix === "k") return number * 1000; - if (suffix === "m") return number * 1_000_000; - if (suffix === "b") return number * 1_000_000_000; - throw new Error( - `Unknown suffix "${suffix}" provided in short human readable number: "${text}"`, - ); + const suffix = relativeNumber.replaceAll(/[\d\.]/g, '').toLowerCase() + if (suffix.length > 1) { + throw new Error(`Detected more than one suffix "${suffix}" in short human readable number: "${text}"`) + } + if (suffix.length === 0) return number + if (suffix === 'k') return number * 1000 + if (suffix === 'm') return number * 1_000_000 + if (suffix === 'b') return number * 1_000_000_000 + throw new Error(`Unknown suffix "${suffix}" provided in short human readable number: "${text}"`) } /** @@ -50,18 +41,18 @@ export function fromShortHumanReadable(text: string): number { * Ex. 20:36 -> 20 * 60 + 36 -> 1236 */ export function durationTextToSeconds(simpleText: string): number { - return simpleText - .split(":") - .reverse() // Seconds, Minutes, Hours, etc.. - .map((val, i) => +val * 60 ** i) // Seconds 60 ** 0 (1), Minutes 60 ** 1 (60), etc... - .reduce((a, b) => a + b, 0); // Add seconds together + return simpleText + .split(':') + .reverse() // Seconds, Minutes, Hours, etc.. + .map((val, i) => +val * 60 ** i) // Seconds 60 ** 0 (1), Minutes 60 ** 1 (60), etc... + .reduce((a, b) => a + b, 0) // Add seconds together } /** Text must be in the form "123,345 views" */ -export const extractNumber = (viewCount: string) => - Number( - viewCount - .split(/([\d,]+)/) - .filter(Boolean)[0] - .replaceAll(",", ""), - ); +export const extractNumber = (number: string) => { + const numberExtracted = number.match(/[\d,]+/) + if (numberExtracted === null) { + throw new Error(`Unable to extract number from "${number}"`) + } + return Number(numberExtracted[0].replaceAll(',', '')) +} diff --git a/src/parser/yt/index.ts b/src/parser/yt/index.ts index 054f52d..6dcf3fd 100644 --- a/src/parser/yt/index.ts +++ b/src/parser/yt/index.ts @@ -5,6 +5,7 @@ import { listChannelShelves, listChannelLiveVideos, listChannelLinks, + listChannelPlaylists, } from './channel' import { listComments } from './comment' import { @@ -75,6 +76,7 @@ const provider: Provider = Object.freeze({ listChannelShelves, listChannelLiveVideos, listChannelLinks, + listChannelPlaylists, listComments, diff --git a/src/parser/yt/playlist/list.ts b/src/parser/yt/playlist/list.ts index 37dd410..66f96e8 100644 --- a/src/parser/yt/playlist/list.ts +++ b/src/parser/yt/playlist/list.ts @@ -1,8 +1,7 @@ import { AppendContinuationItemsResponse, getContinuationResponseItems } from '../components/continuation' -import { Endpoint, fetchEndpointContinuation, makeContinuationIterator } from '../core/api' +import { fetchBrowseContinuation, makeContinuationIterator } from '../core/api' import { fetchPlaylist } from './get' -import { processPlaylistVideo } from './processors/video' -import { PlaylistVideo } from './types' +import { PlaylistVideo, processPlaylistVideo } from './processors/video' export async function listUserPlaylists() { // todo: should use the getChannelPlaylists @@ -11,9 +10,7 @@ export async function listUserPlaylists() { } type PlaylistVideosContinuationResponse = AppendContinuationItemsResponse -const fetchPlaylistVideosContinuation = fetchEndpointContinuation( - Endpoint.Browse, -) +const fetchPlaylistVideosContinuation = fetchBrowseContinuation export function makeListPlaylistVideosIterator(id: string) { return makeContinuationIterator( diff --git a/src/parser/yt/playlist/processors/grid.ts b/src/parser/yt/playlist/processors/grid.ts index d6eca79..7beb43a 100644 --- a/src/parser/yt/playlist/processors/grid.ts +++ b/src/parser/yt/playlist/processors/grid.ts @@ -39,11 +39,14 @@ export const processGridPlaylist = ({ gridPlaylistRenderer: playlist }: GridPlay provider: std.ProviderName.YT, id: playlist.playlistId, title: combineSomeText(playlist.title), - author: { - id: playlist.longBylineText.runs[0].navigationEndpoint!.browseEndpoint.browseId, - name: playlist.longBylineText.runs[0].text, - verified: std.verifiedFrom(playlist.ownerBadges.some(isVerifiedBadge)), - }, + author: + 'longBylineText' in playlist + ? { + id: playlist.longBylineText.runs[0].navigationEndpoint!.browseEndpoint.browseId, + name: playlist.longBylineText.runs[0].text, + verified: std.verifiedFrom(playlist.ownerBadges.some(isVerifiedBadge)), + } + : undefined, thumbnail: playlist.thumbnail.thumbnails, // fixme: this is an assumption, is there anywhere it wouldn't be true? visibility: std.Visibility.Public, diff --git a/src/parser/yt/user/index.ts b/src/parser/yt/user/index.ts index 6f3e627..5f4e0a9 100644 --- a/src/parser/yt/user/index.ts +++ b/src/parser/yt/user/index.ts @@ -1,13 +1,13 @@ import { getChannel } from '@yt/channel' import { findRenderer, isRenderer } from '@yt/core/internals' import { - fetchGuide, - fetchHistory, - fetchHistoryContinuation, - fetchSubscribe, - fetchSubscriptions, - fetchSubscriptionsContinuation, - fetchUnsubscribe, + fetchGuide, + fetchHistory, + fetchHistoryContinuation, + fetchSubscribe, + fetchSubscriptions, + fetchSubscriptionsContinuation, + fetchUnsubscribe, } from './api' import { processChannelGuideEntry } from './processors' import { processVideo } from '../video/processors/regular' @@ -19,106 +19,109 @@ import { getContinuationResponseItems } from '../components/continuation' export const getUser = (userId: string) => getChannel(userId).then((channel) => channel.user) const indexOf = (str: string, substr: string) => { - const index = str.indexOf(substr) - if (index !== -1) return index + const index = str.indexOf(substr) + if (index !== -1) return index } -const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Nov', 'Dec'] +const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'nov', 'dec'] // todo: will break if non-english or sunday is not first day const historyHeaderToDate = (header: string) => { - const headerLower = header.toLowerCase() - const today = new Date() - today.setHours(0, 0, 0, 0) - if (headerLower === 'today') return today - if (headerLower === 'yesterday') return subDays(today, 1) + const headerLower = header.toLowerCase() + const today = new Date() + today.setHours(0, 0, 0, 0) + if (headerLower === 'today') return today + if (headerLower === 'yesterday') return subDays(today, 1) - const daysOfTheWeek = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] - const dayOfTheWeek = daysOfTheWeek.indexOf(headerLower) - if (dayOfTheWeek !== -1) { - const day = today.getDay() - if (day < dayOfTheWeek) return subDays(today, 7 - dayOfTheWeek + day) - return subDays(today, day - dayOfTheWeek) - } + const daysOfTheWeek = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] + const dayOfTheWeek = daysOfTheWeek.indexOf(headerLower) + if (dayOfTheWeek !== -1) { + const day = today.getDay() + if (day < dayOfTheWeek) return subDays(today, 7 - dayOfTheWeek + day) + return subDays(today, day - dayOfTheWeek) + } - // Jan 12 or Jan 12, 2024 - const commaIndex = indexOf(headerLower, ',') - const month = headerLower.slice(0, 3) - const dayOfMonth = Number(headerLower.slice(4, commaIndex ?? headerLower.length)) - const year = commaIndex && Number(headerLower.slice(commaIndex + 1)) + // Jan 12 or Jan 12, 2024 + const commaIndex = indexOf(headerLower, ',') + const month = headerLower.slice(0, 3) + console.log('month', month) + const dayOfMonth = Number(headerLower.slice(4, commaIndex ?? headerLower.length)) + console.log('dayOfMonth', dayOfMonth) + const year = commaIndex && Number(headerLower.slice(commaIndex + 1)) - if (Number.isNaN(month) || Number.isNaN(dayOfMonth)) { - throw Error(`Unable to parse date from history header: "${headerLower}"`) - } - if (year && !Number.isNaN(year)) { - const yearDate = new Date(year, months.indexOf(month), dayOfMonth) - if (!Number.isNaN(yearDate.getTime())) return yearDate - } - const monthDate = new Date(today.getFullYear(), months.indexOf(month), dayOfMonth) - if (!Number.isNaN(monthDate.getTime())) return monthDate + if (Number.isNaN(month) || Number.isNaN(dayOfMonth)) { + throw Error(`Unable to parse date from history header: "${headerLower}"`) + } + if (year && !Number.isNaN(year)) { + const yearDate = new Date(year, months.indexOf(month), dayOfMonth) + if (!Number.isNaN(yearDate.getTime())) return yearDate + } + const monthDate = new Date(today.getFullYear(), months.indexOf(month), dayOfMonth) + console.log('monthDate', monthDate) + if (!Number.isNaN(monthDate.getTime())) return monthDate - throw Error(`Unable to parse date from history header: "${headerLower}"`) + throw Error(`Unable to parse date from history header: "${headerLower}"`) } export const listHistory = async function* () { - const historyIterator = makeContinuationIterator( - () => - fetchHistory().then( - (response) => - response.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer - .contents, - ), - (token) => fetchHistoryContinuation(token).then(getContinuationResponseItems), - ) - for await (const section of historyIterator) { - yield section - .map((section) => section.itemSectionRenderer) - .map(({ header, contents }) => ({ - date: historyHeaderToDate(combineSomeText(header.itemSectionHeaderRenderer.title)), - videos: contents.filter(isRenderer('video')).map(processVideo), - })) - } + const historyIterator = makeContinuationIterator( + () => + fetchHistory().then( + (response) => + response.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer + .contents, + ), + (token) => fetchHistoryContinuation(token).then(getContinuationResponseItems), + ) + for await (const section of historyIterator) { + yield section + .map((section) => section.itemSectionRenderer) + .map(({ header, contents }) => ({ + date: historyHeaderToDate(combineSomeText(header.itemSectionHeaderRenderer.title)), + videos: contents.filter(isRenderer('video')).map(processVideo), + })) + } } export const listFollowedUsers = async function* () { - const guideResponse = await fetchGuide() + const guideResponse = await fetchGuide() - const subscriptionSectionItems = findRenderer('guideSubscriptionsSection')(guideResponse.items)?.items ?? [] - const nonCollapsedItems = subscriptionSectionItems.filter(isRenderer('guideEntry')) - const guideCollapsibleSection = findRenderer('guideCollapsibleEntry')(subscriptionSectionItems) - const collapsedItems = guideCollapsibleSection?.expandableItems.filter(isRenderer('guideEntry')) ?? [] + const subscriptionSectionItems = findRenderer('guideSubscriptionsSection')(guideResponse.items)?.items ?? [] + const nonCollapsedItems = subscriptionSectionItems.filter(isRenderer('guideEntry')) + const guideCollapsibleSection = findRenderer('guideCollapsibleEntry')(subscriptionSectionItems) + const collapsedItems = guideCollapsibleSection?.expandableItems.filter(isRenderer('guideEntry')) ?? [] - yield [...nonCollapsedItems, ...collapsedItems] - // fixme: update types to include the buttons at the bottom of the guideCollapsibleEntry which are just buttons - // but are still called guideEntryRenderers - .filter((entry) => !('icon' in entry.guideEntryRenderer)) - .map(processChannelGuideEntry) + yield [...nonCollapsedItems, ...collapsedItems] + // fixme: update types to include the buttons at the bottom of the guideCollapsibleEntry which are just buttons + // but are still called guideEntryRenderers + .filter((entry) => !('icon' in entry.guideEntryRenderer)) + .map(processChannelGuideEntry) } export const listLiveFollowedUsers = async function* () { - for await (const users of listFollowedUsers()) { - yield users.filter((user) => user.isLive) - } + for await (const users of listFollowedUsers()) { + yield users.filter((user) => user.isLive) + } } export const listFollowedUsersVideos = async function* () { - const subscriptionsIterator = makeContinuationIterator( - () => - fetchSubscriptions().then( - (response) => - response.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.richGridRenderer - .contents, - ), - (token) => fetchSubscriptionsContinuation(token).then(getContinuationResponseItems), - ) - for await (const videos of subscriptionsIterator) { - yield videos - .filter(isRenderer('richItem')) - .map((renderer) => renderer.richItemRenderer.content) - .filter(isRenderer('video')) - .map(processVideo) - } + const subscriptionsIterator = makeContinuationIterator( + () => + fetchSubscriptions().then( + (response) => + response.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.richGridRenderer + .contents, + ), + (token) => fetchSubscriptionsContinuation(token).then(getContinuationResponseItems), + ) + for await (const videos of subscriptionsIterator) { + yield videos + .filter(isRenderer('richItem')) + .map((renderer) => renderer.richItemRenderer.content) + .filter(isRenderer('video')) + .map(processVideo) + } } export async function setUserFollowed(userId: string, isFollowing: boolean) { - if (isFollowing) await fetchSubscribe(userId) - else await fetchUnsubscribe(userId) + if (isFollowing) await fetchSubscribe(userId) + else await fetchUnsubscribe(userId) } diff --git a/src/parser/yt/video/api.ts b/src/parser/yt/video/api.ts index 1b8dc18..85e14c6 100644 --- a/src/parser/yt/video/api.ts +++ b/src/parser/yt/video/api.ts @@ -1,150 +1,118 @@ -import * as std from "@std"; -import { - BrowseId, - Endpoint, - fetchYt, - fetchEndpointContinuation, - getContext, -} from "../core/api"; -import { CompactContinuationResponse } from "./types/responses/compact-continuation"; -import { VideoResponse } from "./types/responses/video"; -import { PlayerResponse } from "./types/responses/player"; -import { RecommendedResponse } from "./types/responses/recommended"; -import { - AppendContinuationItemsResponse, - ContinuationItem, -} from "@yt/components/continuation"; -import { RichItem } from "@yt/components/item"; -import { Video } from "./processors/regular"; -import { Renderer, isCommand } from "@yt/core/internals"; -import { fetchProxy } from "@libs/extension"; -import { getSigTimestamp } from "./processors/player/decoders/signature"; +import * as std from '@std' +import { BrowseId, Endpoint, fetchYt, fetchEndpointContinuation, getContext } from '../core/api' +import { CompactContinuationResponse } from './types/responses/compact-continuation' +import { VideoResponse } from './types/responses/video' +import { PlayerResponse } from './types/responses/player' +import { RecommendedResponse } from './types/responses/recommended' +import { AppendContinuationItemsResponse, ContinuationItem } from '@yt/components/continuation' +import { RichItem } from '@yt/components/item' +import { Video } from './processors/regular' +import { Renderer, isCommand } from '@yt/core/internals' +import { fetchProxy } from '@libs/extension' +import { getSigTimestamp } from './processors/player/decoders/signature' export const fetchRecommended = (): Promise => { - console.log("fetchRecommended"); - return fetchYt(Endpoint.Browse, { browseId: BrowseId.Recommended }); -}; + console.log('fetchRecommended') + return fetchYt(Endpoint.Browse, { browseId: BrowseId.Recommended }) +} export const fetchRecommendedContinuation = ( - continuation: string, -): Promise< - AppendContinuationItemsResponse< - RichItem