diff --git a/DailymotionConfig.json b/DailymotionConfig.json index d801d80839ee9392758c4880016061f1215e3521..9c7f3f653bbb1591bddc6e6e2200c383d3f1ebd5 100644 --- a/DailymotionConfig.json +++ b/DailymotionConfig.json @@ -7,7 +7,7 @@ "sourceUrl": "https://stefancruz.github.io/GrayjayDailymotion/DailymotionConfig.json", "scriptUrl": "./DailymotionScript.js", "repositoryUrl": "https://github.com/stefancruz/GrayjayDailymotion", - "version": 15, + "version": 16, "iconUrl": "./DailymotionIcon.png", "id": "9c87e8db-e75d-48f4-afe5-2d203d4b95c5", "scriptSignature": "", diff --git a/Readme.md b/Readme.md index 640b29f97a4e10fc96153b5c333b8e1c7eed7ff8..73b6526c07537430ce0d67f6ad5190c0d7d9efd7 100644 --- a/Readme.md +++ b/Readme.md @@ -21,23 +21,21 @@ Click [here](https://stefancruz.github.io/GrayjayDailymotion/index.html) to inst - [x] - Sign in (import subscriptions and playlists (and Likes, Favorites, Recently Watched)) - [x] - Policentric Comments - [x] - Subtitles +- [x] - State management ## Todo -- [ ] - State management -- [ ] - Platform comments (not generally available) +- [ ] - Unit tests - (wip) +- [ ] - Platform comments (not generally available) - (wip) ## Known bugs - [ ] - Live filter in Subscriptions tab - ## Install npm install ## Update graphql types (optional) -npm run get-token - npm run codegen ## Build diff --git a/build/DailymotionConfig.json b/build/DailymotionConfig.json index d801d80839ee9392758c4880016061f1215e3521..9c7f3f653bbb1591bddc6e6e2200c383d3f1ebd5 100644 --- a/build/DailymotionConfig.json +++ b/build/DailymotionConfig.json @@ -7,7 +7,7 @@ "sourceUrl": "https://stefancruz.github.io/GrayjayDailymotion/DailymotionConfig.json", "scriptUrl": "./DailymotionScript.js", "repositoryUrl": "https://github.com/stefancruz/GrayjayDailymotion", - "version": 15, + "version": 16, "iconUrl": "./DailymotionIcon.png", "id": "9c87e8db-e75d-48f4-afe5-2d203d4b95c5", "scriptSignature": "", diff --git a/build/DailymotionScript.js b/build/DailymotionScript.js index 593025f18ce5d76755d5a95d3b1a08d1702e5a37..766f6bf116574e2cb9a15f89c1517f28ed6817f0 100644 --- a/build/DailymotionScript.js +++ b/build/DailymotionScript.js @@ -1413,183 +1413,6 @@ const USER_WATCHED_VIDEOS_QUERY = ` } }`; -const SourceChannelToGrayjayChannel = (pluginId, url, sourceChannel) => { - const externalLinks = sourceChannel?.externalLinks ?? {}; - const links = Object.keys(externalLinks).reduce((acc, key) => { - if (externalLinks[key]) { - acc[key.replace('URL', '')] = externalLinks[key]; - } - return acc; - }, {}); - return new PlatformChannel({ - id: new PlatformID(PLATFORM, sourceChannel?.id ?? "", pluginId, PLATFORM_CLAIMTYPE), - name: sourceChannel?.displayName ?? "", - thumbnail: sourceChannel?.avatar?.url ?? "", - banner: sourceChannel.banner?.url ?? "", - subscribers: sourceChannel?.metrics?.engagement?.followers?.edges[0]?.node?.total ?? 0, - description: sourceChannel?.description ?? "", - url, - links - }); -}; -const SourceAuthorToGrayjayPlatformAuthorLink = (pluginId, creator) => { - return new PlatformAuthorLink(new PlatformID(PLATFORM, creator?.id ?? "", pluginId, PLATFORM_CLAIMTYPE), creator?.displayName ?? "", creator?.name ? `${BASE_URL}/${creator?.name}` : "", creator?.avatar?.url ?? "", creator?.followers?.totalCount ?? creator?.stats?.followers?.total ?? creator?.metrics?.engagement?.followers?.edges[0]?.node?.total ?? 0); -}; -const SourceVideoToGrayjayVideo = (pluginId, sourceVideo) => { - const isLive = getIsLive(sourceVideo); - const viewCount = getViewCount(sourceVideo); - const video = { - id: new PlatformID(PLATFORM, sourceVideo?.id ?? "", pluginId, PLATFORM_CLAIMTYPE), - description: sourceVideo?.description ?? '', - name: sourceVideo?.title ?? "", - thumbnails: new Thumbnails([ - new Thumbnail(sourceVideo?.thumbnail?.url ?? "", 0) - ]), - author: SourceAuthorToGrayjayPlatformAuthorLink(pluginId, sourceVideo?.creator), - uploadDate: Math.floor(new Date(sourceVideo?.createdAt).getTime() / 1000), - datetime: Math.floor(new Date(sourceVideo?.createdAt).getTime() / 1000), - url: `${BASE_URL_VIDEO}/${sourceVideo?.xid}`, - duration: sourceVideo?.duration ?? 0, - viewCount, - isLive - }; - return new PlatformVideo(video); -}; -const SourceCollectionToGrayjayPlaylistDetails = (pluginId, sourceCollection, videos = []) => { - return new PlatformPlaylistDetails({ - url: sourceCollection?.xid ? `${BASE_URL_PLAYLIST}/${sourceCollection?.xid}` : "", - id: new PlatformID(PLATFORM, sourceCollection?.xid ?? "", pluginId, PLATFORM_CLAIMTYPE), - author: sourceCollection?.creator ? SourceAuthorToGrayjayPlatformAuthorLink(pluginId, sourceCollection?.creator) : {}, - name: sourceCollection.name, - thumbnail: sourceCollection?.thumbnail?.url, - videoCount: videos.length ?? 0, - contents: new VideoPager(videos) - }); -}; -const SourceCollectionToGrayjayPlaylist = (pluginId, sourceCollection) => { - return new PlatformPlaylist({ - url: `${BASE_URL_PLAYLIST}/${sourceCollection?.xid}`, - id: new PlatformID(PLATFORM, sourceCollection?.xid ?? "", pluginId, PLATFORM_CLAIMTYPE), - author: SourceAuthorToGrayjayPlatformAuthorLink(pluginId, sourceCollection?.creator), - name: sourceCollection?.name, - thumbnail: sourceCollection?.thumbnail?.url, - videoCount: sourceCollection?.metrics?.engagement?.videos?.edges[0]?.node?.total, - }); -}; -const getIsLive = (sourceVideo) => { - return sourceVideo?.isOnAir === true || sourceVideo?.duration == undefined; -}; -const getViewCount = (sourceVideo) => { - let viewCount = 0; - if (getIsLive(sourceVideo)) { - viewCount = sourceVideo?.audienceCount ?? sourceVideo?.viewCount ?? sourceVideo?.stats?.views?.total ?? 0; - } - else { - viewCount = sourceVideo?.viewCount ?? sourceVideo?.stats?.views?.total ?? 0; - } - return viewCount; -}; -const SourceVideoToPlatformVideoDetailsDef = (pluginId, sourceVideo, sources, sourceSubtitle) => { - let positiveRatingCount = 0; - let negativeRatingCount = 0; - const ratings = sourceVideo?.metrics?.engagement?.likes?.edges ?? []; - for (const edge of ratings) { - const ratingName = edge?.node?.rating; - const ratingTotal = edge?.node?.total; - if (POSITIVE_RATINGS_LABELS.includes(ratingName)) { - positiveRatingCount += ratingTotal; - } - else if (NEGATIVE_RATINGS_LABELS.includes(ratingName)) { - negativeRatingCount += ratingTotal; - } - } - const isLive = getIsLive(sourceVideo); - const viewCount = getViewCount(sourceVideo); - const duration = isLive ? 0 : sourceVideo?.duration ?? 0; - const platformVideoDetails = { - id: new PlatformID(PLATFORM, sourceVideo?.id ?? "", pluginId, PLATFORM_CLAIMTYPE), - name: sourceVideo?.title ?? "", - thumbnails: new Thumbnails([new Thumbnail(sourceVideo?.thumbnail?.url ?? "", 0)]), - author: SourceAuthorToGrayjayPlatformAuthorLink(pluginId, sourceVideo?.creator), - uploadDate: Math.floor(new Date(sourceVideo?.createdAt).getTime() / 1000), - datetime: Math.floor(new Date(sourceVideo?.createdAt).getTime() / 1000), - duration, - // viewCount, - viewCount, - url: sourceVideo?.xid ? `${BASE_URL_VIDEO}/${sourceVideo.xid}` : "", - isLive, - description: sourceVideo?.description ?? "", - video: new VideoSourceDescriptor(sources), - rating: new RatingLikesDislikes(positiveRatingCount, negativeRatingCount), - dash: null, - live: null, - hls: null, - subtitles: [] - }; - if (sourceSubtitle?.enable && sourceSubtitle?.data) { - Object.keys(sourceSubtitle.data).forEach(key => { - const subtitleData = sourceSubtitle.data[key]; - if (subtitleData) { - const subtitleUrl = subtitleData.urls[0]; - platformVideoDetails.subtitles.push({ - name: subtitleData.label, - url: subtitleUrl, - format: "text/vtt", - getSubtitles() { - try { - const subResp = http.GET(subtitleUrl, {}); - if (!subResp.isOk) { - if (IS_TESTING) { - bridge.log(`Failed to fetch subtitles from ${subtitleUrl}`); - } - return ""; - } - return convertSRTtoVTT(subResp.body); - } - catch (error) { - if (IS_TESTING) { - bridge.log(`Error fetching subtitles: ${error?.message}`); - } - return ""; - } - } - }); - } - }); - } - return platformVideoDetails; -}; -/** - * Converts SRT subtitle format to VTT format. - * - * @param {string} srt - The SRT subtitle string. - * @returns {string} - The converted VTT subtitle string. - */ -const convertSRTtoVTT = (srt) => { - // Initialize the VTT output with the required header - const vtt = ['WEBVTT\n\n']; - // Split the SRT input into blocks based on double newlines - const srtBlocks = srt.split('\n\n'); - // Process each block individually - srtBlocks.forEach((block) => { - // Split each block into lines - const lines = block.split('\n'); - if (lines.length >= 3) { - // Extract and convert the timestamp line - const timestamp = lines[1].replace(/,/g, '.'); - // Extract the subtitle text lines - const subtitleText = lines.slice(2).join('\n'); - // Add the converted block to the VTT output - vtt.push(`${timestamp}\n${subtitleText}\n\n`); - } - }); - // Join the VTT array into a single string and return it - return vtt.join(''); -}; - -let AUTHORIZATION_TOKEN_ANONYMOUS_USER = ""; -let AUTHORIZATION_TOKEN_ANONYMOUS_USER_EXPIRATION_DATE; -let httpClientRequestToken = http.newClient(false); function getPreferredCountry(preferredCountryIndex) { const countryName = COUNTRY_NAMES[preferredCountryIndex]; const code = COUNTRY_NAMES_TO_CODE[countryName]; @@ -1614,95 +1437,6 @@ function isUsernameUrl(url) { const regex = new RegExp('^' + BASE_URL.replace(/\./g, '\\.') + '/[^/]+$'); return regex.test(url); } -// TODO: save to state -function getAnonymousUserTokenSingleton() { - // Check if the anonymous user token is available and not expired - if (AUTHORIZATION_TOKEN_ANONYMOUS_USER) { - const isTokenValid = AUTHORIZATION_TOKEN_ANONYMOUS_USER_EXPIRATION_DATE && new Date().getTime() < AUTHORIZATION_TOKEN_ANONYMOUS_USER_EXPIRATION_DATE; - if (isTokenValid) { - return AUTHORIZATION_TOKEN_ANONYMOUS_USER; - } - } - // Prepare the request body for obtaining a new token - const body = objectToUrlEncodedString({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - grant_type: 'client_credentials' - }); - // Make the HTTP POST request to the authorization API - const res = httpClientRequestToken.POST(`${BASE_URL_API_AUTH}`, body, { - 'User-Agent': USER_AGENT, - 'Content-Type': 'application/x-www-form-urlencoded', - 'Origin': BASE_URL, - 'DNT': '1', - 'Sec-GPC': '1', - 'Connection': 'keep-alive', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-site', - 'Priority': 'u=4', - 'Pragma': 'no-cache', - 'Cache-Control': 'no-cache' - }, false); - // Check if the response code indicates success - if (res.code !== 200) { - console.error('Failed to get token', res); - throw new ScriptException("", "Failed to get token: " + res.code + " - " + res.body); - } - // Parse the response JSON to extract the token information - const json = JSON.parse(res.body); - // Ensure the response contains the necessary token information - if (!json.token_type || !json.access_token) { - console.error('Invalid token response', res); - throw new ScriptException("", 'Invalid token response: ' + res.body); - } - // Store the token and its expiration date - AUTHORIZATION_TOKEN_ANONYMOUS_USER = `${json.token_type} ${json.access_token}`; - AUTHORIZATION_TOKEN_ANONYMOUS_USER_EXPIRATION_DATE = new Date().getTime() + (json.expires_in * 1000); - return AUTHORIZATION_TOKEN_ANONYMOUS_USER; -} -function executeGqlQuery(httpClient, requestOptions) { - const headersToAdd = requestOptions.headers || { - "User-Agent": USER_AGENT, - "Accept": "*/*", - // "Accept-Language": Accept_Language, - "Referer": BASE_URL, - "Origin": BASE_URL, - "DNT": "1", - "Connection": "keep-alive", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-site", - "Pragma": "no-cache", - "Cache-Control": "no-cache" - }; - const gql = JSON.stringify({ - operationName: requestOptions.operationName, - variables: requestOptions.variables, - query: requestOptions.query, - }); - const usePlatformAuth = requestOptions.usePlatformAuth == undefined ? false : requestOptions.usePlatformAuth; - const throwOnError = requestOptions.throwOnError == undefined ? true : requestOptions.throwOnError; - if (!usePlatformAuth) { - headersToAdd.Authorization = getAnonymousUserTokenSingleton(); - } - const res = httpClient.POST(BASE_URL_API, gql, headersToAdd, usePlatformAuth); - if (!res.isOk) { - console.error('Failed to get token', res); - if (throwOnError) { - throw new ScriptException("Failed to get token", res); - } - } - const body = JSON.parse(res.body); - // some errors may be returned in the body with a status code 200 - if (body.errors) { - const message = body.errors.map(e => e.message).join(', '); - if (throwOnError) { - throw new UnavailableException(message); - } - } - return body; -} const parseUploadDateFilter = (filter) => { let createdAfterVideos; const now = new Date(); @@ -1776,82 +1510,6 @@ function generateUUIDv4() { return v.toString(16); }); } -function getPages(httpClient, query, operationName, variables, usePlatformAuth, setRoot, hasNextCallback, getNextPage, map) { - let all = []; - if (!hasNextCallback) { - hasNextCallback = () => false; - } - let hasNext = true; - let nextPage = 1; - do { - variables = { ...variables, page: nextPage }; - const jsonResponse = executeGqlQuery(httpClient, { - operationName, - variables, - query, - usePlatformAuth - }); - const root = setRoot(jsonResponse); - nextPage = getNextPage(root, nextPage); - const items = map(root); - hasNext = hasNextCallback(root); - all = all.concat(items); - } while (hasNext); - return all; -} -function getLikePlaylist(pluginId, httpClient, usePlatformAuth = false, thumbnailResolutionIndex = 0) { - return getPlatformSystemPlaylist({ - pluginId, - httpClient, - query: USER_LIKED_VIDEOS_QUERY, - operationName: 'USER_LIKED_VIDEOS_QUERY', - rootObject: 'likedMedias', - playlistName: 'Liked Videos', - usePlatformAuth, - thumbnailResolutionIndex - }); -} -function getFavoritesPlaylist(pluginId, httpClient, usePlatformAuth = false, thumbnailResolutionIndex = 0) { - return getPlatformSystemPlaylist({ - pluginId, - httpClient, - query: USER_WATCH_LATER_VIDEOS_QUERY, - operationName: 'USER_WATCH_LATER_VIDEOS_QUERY', - rootObject: 'watchLaterMedias', - playlistName: 'Favorites', - usePlatformAuth, - thumbnailResolutionIndex - }); -} -function getRecentlyWatchedPlaylist(pluginId, httpClient, usePlatformAuth = false, thumbnailResolutionIndex = 0) { - return getPlatformSystemPlaylist({ - pluginId, - httpClient, - query: USER_WATCHED_VIDEOS_QUERY, - operationName: 'USER_WATCHED_VIDEOS_QUERY', - rootObject: 'watchedVideos', - playlistName: 'Recently Watched', - usePlatformAuth, - thumbnailResolutionIndex - }); -} -function getPlatformSystemPlaylist(opts) { - const videos = getPages(opts.httpClient, opts.query, opts.operationName, { - page: 1, - thumbnail_resolution: THUMBNAIL_HEIGHT[opts.thumbnailResolutionIndex] - }, opts.usePlatformAuth, (jsonResponse) => jsonResponse?.data?.me, //set root - (me) => (me?.[opts.rootObject]?.edges.length ?? 0) > 0 ?? false, //hasNextCallback - (me, currentPage) => ++currentPage, //getNextPage - (me) => me?.[opts.rootObject]?.edges.map(edge => { - return SourceVideoToGrayjayVideo(opts.pluginId, edge.node); - })); - const collection = { - "id": generateUUIDv4(), - "name": opts.playlistName, - "creator": {} - }; - return SourceCollectionToGrayjayPlaylistDetails(opts.pluginId, collection, videos); -} class SearchPagerAll extends VideoPager { cb; @@ -1898,35 +1556,214 @@ class ChannelPlaylistPager extends PlaylistPager { super(results, hasMore, { params, page }); this.cb = cb; } - nextPage() { - this.context.page += 1; - return this.cb(this.context.params.url, this.context.page); + nextPage() { + this.context.page += 1; + return this.cb(this.context.params.url, this.context.page); + } +} +class SearchPlaylistPager extends PlaylistPager { + cb; + constructor(results, hasMore, params, page, cb) { + super(results, hasMore, { params, page }); + this.cb = cb; + } + nextPage() { + this.context.page = this.context.page + 1; + const opts = { + q: this.context.params.query, + sort: this.context.params.sort, + page: this.context.page, + filters: this.context.params.filters + }; + return this.cb(opts); + } +} + +const SourceChannelToGrayjayChannel = (pluginId, url, sourceChannel) => { + const externalLinks = sourceChannel?.externalLinks ?? {}; + const links = Object.keys(externalLinks).reduce((acc, key) => { + if (externalLinks[key]) { + acc[key.replace('URL', '')] = externalLinks[key]; + } + return acc; + }, {}); + return new PlatformChannel({ + id: new PlatformID(PLATFORM, sourceChannel?.id ?? "", pluginId, PLATFORM_CLAIMTYPE), + name: sourceChannel?.displayName ?? "", + thumbnail: sourceChannel?.avatar?.url ?? "", + banner: sourceChannel.banner?.url ?? "", + subscribers: sourceChannel?.metrics?.engagement?.followers?.edges[0]?.node?.total ?? 0, + description: sourceChannel?.description ?? "", + url, + links + }); +}; +const SourceAuthorToGrayjayPlatformAuthorLink = (pluginId, creator) => { + return new PlatformAuthorLink(new PlatformID(PLATFORM, creator?.id ?? "", pluginId, PLATFORM_CLAIMTYPE), creator?.displayName ?? "", creator?.name ? `${BASE_URL}/${creator?.name}` : "", creator?.avatar?.url ?? "", creator?.followers?.totalCount ?? creator?.stats?.followers?.total ?? creator?.metrics?.engagement?.followers?.edges[0]?.node?.total ?? 0); +}; +const SourceVideoToGrayjayVideo = (pluginId, sourceVideo) => { + const isLive = getIsLive(sourceVideo); + const viewCount = getViewCount(sourceVideo); + const video = { + id: new PlatformID(PLATFORM, sourceVideo?.id ?? "", pluginId, PLATFORM_CLAIMTYPE), + description: sourceVideo?.description ?? '', + name: sourceVideo?.title ?? "", + thumbnails: new Thumbnails([ + new Thumbnail(sourceVideo?.thumbnail?.url ?? "", 0) + ]), + author: SourceAuthorToGrayjayPlatformAuthorLink(pluginId, sourceVideo?.creator), + uploadDate: Math.floor(new Date(sourceVideo?.createdAt).getTime() / 1000), + datetime: Math.floor(new Date(sourceVideo?.createdAt).getTime() / 1000), + url: `${BASE_URL_VIDEO}/${sourceVideo?.xid}`, + duration: sourceVideo?.duration ?? 0, + viewCount, + isLive + }; + return new PlatformVideo(video); +}; +const SourceCollectionToGrayjayPlaylistDetails = (pluginId, sourceCollection, videos = []) => { + return new PlatformPlaylistDetails({ + url: sourceCollection?.xid ? `${BASE_URL_PLAYLIST}/${sourceCollection?.xid}` : "", + id: new PlatformID(PLATFORM, sourceCollection?.xid ?? "", pluginId, PLATFORM_CLAIMTYPE), + author: sourceCollection?.creator ? SourceAuthorToGrayjayPlatformAuthorLink(pluginId, sourceCollection?.creator) : {}, + name: sourceCollection.name, + thumbnail: sourceCollection?.thumbnail?.url, + videoCount: videos.length ?? 0, + contents: new VideoPager(videos) + }); +}; +const SourceCollectionToGrayjayPlaylist = (pluginId, sourceCollection) => { + return new PlatformPlaylist({ + url: `${BASE_URL_PLAYLIST}/${sourceCollection?.xid}`, + id: new PlatformID(PLATFORM, sourceCollection?.xid ?? "", pluginId, PLATFORM_CLAIMTYPE), + author: SourceAuthorToGrayjayPlatformAuthorLink(pluginId, sourceCollection?.creator), + name: sourceCollection?.name, + thumbnail: sourceCollection?.thumbnail?.url, + videoCount: sourceCollection?.metrics?.engagement?.videos?.edges[0]?.node?.total, + }); +}; +const getIsLive = (sourceVideo) => { + return sourceVideo?.isOnAir === true || sourceVideo?.duration == undefined; +}; +const getViewCount = (sourceVideo) => { + let viewCount = 0; + if (getIsLive(sourceVideo)) { + viewCount = sourceVideo?.audienceCount ?? sourceVideo?.viewCount ?? sourceVideo?.stats?.views?.total ?? 0; + } + else { + viewCount = sourceVideo?.viewCount ?? sourceVideo?.stats?.views?.total ?? 0; } -} -class SearchPlaylistPager extends PlaylistPager { - cb; - constructor(results, hasMore, params, page, cb) { - super(results, hasMore, { params, page }); - this.cb = cb; + return viewCount; +}; +const SourceVideoToPlatformVideoDetailsDef = (pluginId, sourceVideo, sources, sourceSubtitle) => { + let positiveRatingCount = 0; + let negativeRatingCount = 0; + const ratings = sourceVideo?.metrics?.engagement?.likes?.edges ?? []; + for (const edge of ratings) { + const ratingName = edge?.node?.rating; + const ratingTotal = edge?.node?.total; + if (POSITIVE_RATINGS_LABELS.includes(ratingName)) { + positiveRatingCount += ratingTotal; + } + else if (NEGATIVE_RATINGS_LABELS.includes(ratingName)) { + negativeRatingCount += ratingTotal; + } } - nextPage() { - this.context.page = this.context.page + 1; - const opts = { - q: this.context.params.query, - sort: this.context.params.sort, - page: this.context.page, - filters: this.context.params.filters - }; - return this.cb(opts); + const isLive = getIsLive(sourceVideo); + const viewCount = getViewCount(sourceVideo); + const duration = isLive ? 0 : sourceVideo?.duration ?? 0; + const platformVideoDetails = { + id: new PlatformID(PLATFORM, sourceVideo?.id ?? "", pluginId, PLATFORM_CLAIMTYPE), + name: sourceVideo?.title ?? "", + thumbnails: new Thumbnails([new Thumbnail(sourceVideo?.thumbnail?.url ?? "", 0)]), + author: SourceAuthorToGrayjayPlatformAuthorLink(pluginId, sourceVideo?.creator), + uploadDate: Math.floor(new Date(sourceVideo?.createdAt).getTime() / 1000), + datetime: Math.floor(new Date(sourceVideo?.createdAt).getTime() / 1000), + duration, + // viewCount, + viewCount, + url: sourceVideo?.xid ? `${BASE_URL_VIDEO}/${sourceVideo.xid}` : "", + isLive, + description: sourceVideo?.description ?? "", + video: new VideoSourceDescriptor(sources), + rating: new RatingLikesDislikes(positiveRatingCount, negativeRatingCount), + dash: null, + live: null, + hls: null, + subtitles: [] + }; + if (sourceSubtitle?.enable && sourceSubtitle?.data) { + Object.keys(sourceSubtitle.data).forEach(key => { + const subtitleData = sourceSubtitle.data[key]; + if (subtitleData) { + const subtitleUrl = subtitleData.urls[0]; + platformVideoDetails.subtitles.push({ + name: subtitleData.label, + url: subtitleUrl, + format: "text/vtt", + getSubtitles() { + try { + const subResp = http.GET(subtitleUrl, {}); + if (!subResp.isOk) { + if (IS_TESTING) { + bridge.log(`Failed to fetch subtitles from ${subtitleUrl}`); + } + return ""; + } + return convertSRTtoVTT(subResp.body); + } + catch (error) { + if (IS_TESTING) { + bridge.log(`Error fetching subtitles: ${error?.message}`); + } + return ""; + } + } + }); + } + }); } -} + return platformVideoDetails; +}; +/** + * Converts SRT subtitle format to VTT format. + * + * @param {string} srt - The SRT subtitle string. + * @returns {string} - The converted VTT subtitle string. + */ +const convertSRTtoVTT = (srt) => { + // Initialize the VTT output with the required header + const vtt = ['WEBVTT\n\n']; + // Split the SRT input into blocks based on double newlines + const srtBlocks = srt.split('\n\n'); + // Process each block individually + srtBlocks.forEach((block) => { + // Split each block into lines + const lines = block.split('\n'); + if (lines.length >= 3) { + // Extract and convert the timestamp line + const timestamp = lines[1].replace(/,/g, '.'); + // Extract the subtitle text lines + const subtitleText = lines.slice(2).join('\n'); + // Add the converted block to the VTT output + vtt.push(`${timestamp}\n${subtitleText}\n\n`); + } + }); + // Join the VTT array into a single string and return it + return vtt.join(''); +}; let config; let _settings; -const LIKE_PLAYLIST_ID = "LIKE_PLAYLIST_ID"; -const FAVORITES_PLAYLIST_ID = "FAVORITES_PLAYLIST_ID"; -const RECENTLY_WATCHED_PLAYLIST_ID = "RECENTLY_WATCHED_PLAYLIST_ID"; +const state = { + anonymousUserAuthorizationToken: "", + anonymousUserAuthorizationTokenExpirationDate: 0 +}; +const LIKE_PLAYLIST_ID = "LIKE_PLAYLIST"; +const FAVORITES_PLAYLIST_ID = "FAVORITES_PLAYLIST"; +const RECENTLY_WATCHED_PLAYLIST_ID = "RECENTLY_WATCHED_PLAYLIST"; let httpClientAnonymous = http.newClient(false); +let httpClientRequestToken = http.newClient(false); // Will be used to store private playlists that require authentication const authenticatedPlaylistCollection = []; source.setSettings = function (settings) { @@ -1946,9 +1783,64 @@ source.enable = function (conf, settings, saveStateStr) { _settings.playlistsPerPageOptionIndex = 0; config.id = "9c87e8db-e75d-48f4-afe5-2d203d4b95c5"; } + let didSaveState = false; + try { + if (saveStateStr) { + const saveState = JSON.parse(saveStateStr); + if (saveState) { + state.anonymousUserAuthorizationToken = saveState.anonymousUserAuthorizationToken; + state.anonymousUserAuthorizationTokenExpirationDate = saveState.anonymousUserAuthorizationTokenExpirationDate; + if (!isTokenValid()) { + log("Token expired. Fetching a new one."); + } + else { + didSaveState = true; + log("Using save state"); + } + } + } + } + catch (ex) { + log("Failed to parse saveState:" + ex); + didSaveState = false; + } + if (!didSaveState) { + log("Getting a new token"); + const body = objectToUrlEncodedString({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + grant_type: 'client_credentials' + }); + const res = httpClientRequestToken.POST(BASE_URL_API_AUTH, body, { + 'User-Agent': USER_AGENT, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Origin': BASE_URL, + 'DNT': '1', + 'Sec-GPC': '1', + 'Connection': 'keep-alive', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-site', + 'Priority': 'u=4', + 'Pragma': 'no-cache', + 'Cache-Control': 'no-cache' + }, false); + if (res.code !== 200) { + console.error('Failed to get token', res); + throw new ScriptException("", "Failed to get token: " + res.code + " - " + res.body); + } + const json = JSON.parse(res.body); + if (!json.token_type || !json.access_token) { + console.error('Invalid token response', res); + throw new ScriptException("", 'Invalid token response: ' + res.body); + } + state.anonymousUserAuthorizationToken = `${json.token_type} ${json.access_token}`; + state.anonymousUserAuthorizationTokenExpirationDate = Date.now() + (json.expires_in * 1000); + log(`json.expires_in: ${json.expires_in}`); + log(`state.anonymousUserAuthorizationTokenExpirationDate: ${state.anonymousUserAuthorizationTokenExpirationDate}`); + } }; source.getHome = function () { - getAnonymousUserTokenSingleton(); return getVideoPager({}, 0); }; source.searchSuggestions = function (query) { @@ -2017,6 +1909,12 @@ source.isContentDetailsUrl = function (url) { source.getContentDetails = function (url) { return getSavedVideo(url, false); }; +source.saveState = () => { + return JSON.stringify({ + anonymousUserAuthorizationToken: state.anonymousUserAuthorizationToken, + anonymousUserAuthorizationTokenExpirationDate: state.anonymousUserAuthorizationTokenExpirationDate + }); +}; //Playlist source.isPlaylistUrl = (url) => { return url.startsWith(BASE_URL_PLAYLIST) || @@ -2059,7 +1957,7 @@ source.getPlaylist = (url) => { }; source.getUserSubscriptions = () => { if (!bridge.isLoggedIn()) { - bridge.log("Failed to retrieve subscriptions page because not logged in."); + log("Failed to retrieve subscriptions page because not logged in."); throw new ScriptException("Not logged in"); } const headers = { @@ -2115,7 +2013,7 @@ source.getUserSubscriptions = () => { }; source.getUserPlaylists = () => { if (!bridge.isLoggedIn()) { - bridge.log("Failed to retrieve subscriptions page because not logged in."); + log("Failed to retrieve subscriptions page because not logged in."); throw new ScriptException("Not logged in"); } const headers = { @@ -2275,7 +2173,7 @@ function getChannelContentsPager(url, page, type, order, filters) { const shouldLoadVideos = type === Type.Feed.Mixed || type === Type.Feed.Videos; const shouldLoadLives = type === Type.Feed.Mixed || type === Type.Feed.Streams || type === Type.Feed.Live; if (IS_TESTING) { - bridge.log(`Getting channel contents for ${url}, page: ${page}, type: ${type}, order: ${order}, shouldLoadVideos: ${shouldLoadVideos}, shouldLoadLives: ${shouldLoadLives}, filters: ${JSON.stringify(filters)}`); + log(`Getting channel contents for ${url}, page: ${page}, type: ${type}, order: ${order}, shouldLoadVideos: ${shouldLoadVideos}, shouldLoadLives: ${shouldLoadLives}, filters: ${JSON.stringify(filters)}`); } /** Recent = Sort liked medias by most recent. @@ -2418,7 +2316,7 @@ function getSavedVideo(url, usePlatformAuth = false) { "Cache-Control": "no-cache" }; if (!usePlatformAuth) { - videoDetailsRequestHeaders.Authorization = getAnonymousUserTokenSingleton(); + videoDetailsRequestHeaders.Authorization = state.anonymousUserAuthorizationToken; } const variables = { "xid": id, @@ -2517,6 +2415,128 @@ function getChannelPlaylists(url, page = 1) { const hasMore = channel?.collections?.pageInfo?.hasNextPage ?? false; return new ChannelPlaylistPager(content, hasMore, params, page, getChannelPlaylists); } +function isTokenValid() { + const currentTime = Date.now(); + return state.anonymousUserAuthorizationTokenExpirationDate > currentTime; +} +function executeGqlQuery(httpClient, requestOptions) { + const headersToAdd = requestOptions.headers || { + "User-Agent": USER_AGENT, + "Accept": "*/*", + // "Accept-Language": Accept_Language, + "Referer": BASE_URL, + "Origin": BASE_URL, + "DNT": "1", + "Connection": "keep-alive", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + "Pragma": "no-cache", + "Cache-Control": "no-cache" + }; + const gql = JSON.stringify({ + operationName: requestOptions.operationName, + variables: requestOptions.variables, + query: requestOptions.query, + }); + const usePlatformAuth = requestOptions.usePlatformAuth == undefined ? false : requestOptions.usePlatformAuth; + const throwOnError = requestOptions.throwOnError == undefined ? true : requestOptions.throwOnError; + if (!usePlatformAuth) { + headersToAdd.Authorization = state.anonymousUserAuthorizationToken; + } + const res = httpClient.POST(BASE_URL_API, gql, headersToAdd, usePlatformAuth); + if (!res.isOk) { + console.error('Failed to get token', res); + if (throwOnError) { + throw new ScriptException("Failed to get token", res); + } + } + const body = JSON.parse(res.body); + // some errors may be returned in the body with a status code 200 + if (body.errors) { + const message = body.errors.map(e => e.message).join(', '); + if (throwOnError) { + throw new UnavailableException(message); + } + } + return body; +} +function getPages(httpClient, query, operationName, variables, usePlatformAuth, setRoot, hasNextCallback, getNextPage, map) { + let all = []; + if (!hasNextCallback) { + hasNextCallback = () => false; + } + let hasNext = true; + let nextPage = 1; + do { + variables = { ...variables, page: nextPage }; + const jsonResponse = executeGqlQuery(httpClient, { + operationName, + variables, + query, + usePlatformAuth + }); + const root = setRoot(jsonResponse); + nextPage = getNextPage(root, nextPage); + const items = map(root); + hasNext = hasNextCallback(root); + all = all.concat(items); + } while (hasNext); + return all; +} +function getLikePlaylist(pluginId, httpClient, usePlatformAuth = false, thumbnailResolutionIndex = 0) { + return getPlatformSystemPlaylist({ + pluginId, + httpClient, + query: USER_LIKED_VIDEOS_QUERY, + operationName: 'USER_LIKED_VIDEOS_QUERY', + rootObject: 'likedMedias', + playlistName: 'Liked Videos', + usePlatformAuth, + thumbnailResolutionIndex + }); +} +function getFavoritesPlaylist(pluginId, httpClient, usePlatformAuth = false, thumbnailResolutionIndex = 0) { + return getPlatformSystemPlaylist({ + pluginId, + httpClient, + query: USER_WATCH_LATER_VIDEOS_QUERY, + operationName: 'USER_WATCH_LATER_VIDEOS_QUERY', + rootObject: 'watchLaterMedias', + playlistName: 'Favorites', + usePlatformAuth, + thumbnailResolutionIndex + }); +} +function getRecentlyWatchedPlaylist(pluginId, httpClient, usePlatformAuth = false, thumbnailResolutionIndex = 0) { + return getPlatformSystemPlaylist({ + pluginId, + httpClient, + query: USER_WATCHED_VIDEOS_QUERY, + operationName: 'USER_WATCHED_VIDEOS_QUERY', + rootObject: 'watchedVideos', + playlistName: 'Recently Watched', + usePlatformAuth, + thumbnailResolutionIndex + }); +} +function getPlatformSystemPlaylist(opts) { + const videos = getPages(opts.httpClient, opts.query, opts.operationName, { + page: 1, + thumbnail_resolution: THUMBNAIL_HEIGHT[opts.thumbnailResolutionIndex] + }, opts.usePlatformAuth, (jsonResponse) => jsonResponse?.data?.me, //set root + (me) => (me?.[opts.rootObject]?.edges.length ?? 0) > 0 ?? false, //hasNextCallback + (me, currentPage) => ++currentPage, //getNextPage + (me) => me?.[opts.rootObject]?.edges.map(edge => { + return SourceVideoToGrayjayVideo(opts.pluginId, edge.node); + })); + const collection = { + "id": generateUUIDv4(), + "name": opts.playlistName, + "creator": {} + }; + return SourceCollectionToGrayjayPlaylistDetails(opts.pluginId, collection, videos); +} function getHttpContext(opts = { usePlatformAuth: false }) { return opts.usePlatformAuth ? http : httpClientAnonymous; } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index d7988cf25e6763d77748c217e4429c4bece7c334..d9a10feeb33db8c3f2ff63faaab0fc05b2c8c3a5 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -9,8 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "axios": "1.7.2", - "dotenv": "16.4.5" + "axios": "1.7.2" }, "devDependencies": { "@graphql-codegen/cli": "5.0.2", @@ -25,7 +24,7 @@ "graphql": "16.8.1", "rollup": "4.18.0", "rollup-plugin-copy": "3.5.0", - "rollup-plugin-delete": "^2.0.0", + "rollup-plugin-delete": "2.0.0", "typescript": "5.4.5" }, "engines": { @@ -3390,6 +3389,7 @@ "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index a856208ea1d196a06bc18d1a6d6b297562dcab48..c2909ea3b8e0ca082d99968cfe9b99bfdc324486 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "scripts": { "build": "rollup -c", "start": "rollup -c -w", - "get-token": "node ./scripts/get-token.js", "codegen": "graphql-codegen --config ./scripts/codegen.ts" }, "engines": { @@ -28,12 +27,11 @@ "@rollup/plugin-typescript": "11.1.6", "graphql": "16.8.1", "rollup": "4.18.0", - "rollup-plugin-delete": "^2.0.0", - "typescript": "5.4.5", - "rollup-plugin-copy": "3.5.0" + "rollup-plugin-copy": "3.5.0", + "rollup-plugin-delete": "2.0.0", + "typescript": "5.4.5" }, "dependencies": { - "axios": "1.7.2", - "dotenv": "16.4.5" + "axios": "1.7.2" } -} \ No newline at end of file +} diff --git a/scripts/codegen.ts b/scripts/codegen.ts index a13e0a8f2a64cf62c0b79c91684bcedbb0ca6544..8113db3a6e6444b0223cd6a61a4487336bd6f491 100644 --- a/scripts/codegen.ts +++ b/scripts/codegen.ts @@ -1,48 +1,104 @@ -import type { CodegenConfig } from '@graphql-codegen/cli'; -const path = require('path'); - -const currentDirectory = process.cwd(); - -require('dotenv').config({ path: path.join(currentDirectory, 'scripts', '.env')}); - -const config: CodegenConfig = { - overwrite: true, - schema: { - // URL of the GraphQL endpoint - "https://graphql.api.dailymotion.com": { - // Headers to be sent with the request - headers: { - // Authorization header with the token from environment variables - 'Authorization': `Bearer ${process.env.GRAPHQL_ACCESS_TOKEN}`, - 'Content-Type': 'application/json', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', - 'Accept': '*/*', - 'Accept-Language': 'en-GB,en;q=0.5', - 'Accept-Encoding': 'gzip, deflate, br, zstd', - 'Origin': 'https://www.dailymotion.com', - 'DNT': '1', - 'Sec-GPC': '1', - 'Connection': 'keep-alive', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-site', - 'Priority': 'u=4', - 'Cache-Control': 'no-cache' - }, +const axios = require("axios").default; + +const client_id = 'f1a362d288c1b98099c7'; +const client_secret = 'eea605b96e01c796ff369935357eca920c5da4c5'; +const grant_type = 'client_credentials'; + +// Function to fetch and save the token +async function fetchAndSaveToken() { + const options = { + method: 'POST', + url: 'https://graphql.api.dailymotion.com/oauth/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', + Accept: '*/*', + 'Accept-Language': 'en-GB,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br, zstd', + Origin: 'https://www.dailymotion.com', + DNT: '1', + 'Sec-GPC': '1', + Connection: 'keep-alive', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-site', + Priority: 'u=4', + Pragma: 'no-cache', + 'Cache-Control': 'no-cache' }, - }, - generates: { - "./types/CodeGenDailymotion.d.ts": { - plugins: [ - "typescript", - "typescript-operations", - "typescript-resolvers", - ], + data: { + client_id, + client_secret, + grant_type + } + }; + + try { + const response = await axios.request(options); + const token = response.data.access_token; + console.log('Token fetched '); + return token; + } catch (error) { + console.error('Error fetching the token:', error); + throw error; + } +} + +// Main function to setup GraphQL Codegen config +async function setupCodegenConfig() { + + const token = await fetchAndSaveToken(); + + const config = { + overwrite: true, + schema: { + // URL of the GraphQL endpoint + "https://graphql.api.dailymotion.com": { + // Headers to be sent with the request + headers: { + // Authorization header with the token + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', + 'Accept': '*/*', + 'Accept-Language': 'en-GB,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br, zstd', + 'Origin': 'https://www.dailymotion.com', + 'DNT': '1', + 'Sec-GPC': '1', + 'Connection': 'keep-alive', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-site', + 'Priority': 'u=4', + 'Cache-Control': 'no-cache' + }, + }, }, - "./types/CodeGenDailymotion.schema.json": { - plugins: ["introspection"], + generates: { + "./types/CodeGenDailymotion.d.ts": { + plugins: [ + "typescript", + "typescript-operations", + "typescript-resolvers", + ], + }, + "./types/CodeGenDailymotion.schema.json": { + plugins: ["introspection"], + }, }, - }, -}; + }; + + return config; +} + +export default new Promise((resolve, reject) => { + setupCodegenConfig() + .then(config => { + resolve(config); + }).catch(error => { + console.error('Failed to setup GraphQL Codegen config:', error); + reject(error); + }); -export default config; +}) diff --git a/scripts/get-token.js b/scripts/get-token.js deleted file mode 100644 index 97c0cf44a99cb5f046cc84af5acc8b3fb52437be..0000000000000000000000000000000000000000 --- a/scripts/get-token.js +++ /dev/null @@ -1,39 +0,0 @@ -const axios = require("axios").default; -const fs = require('fs'); -const path = require("path"); - -const options = { - method: 'POST', - url: 'https://graphql.api.dailymotion.com/oauth/token', - headers: { - cookie: 'dmvk=664c425a1edc4; ts=351870; v1st=15af1984-8adb-4541-9020-b5712535e57b', - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', - Accept: '*/*', - 'Accept-Language': 'en-GB,en;q=0.5', - 'Accept-Encoding': 'gzip, deflate, br, zstd', - Origin: 'https://www.dailymotion.com', - DNT: '1', - 'Sec-GPC': '1', - Connection: 'keep-alive', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-site', - Priority: 'u=4', - Pragma: 'no-cache', - 'Cache-Control': 'no-cache' - }, - data: { - client_id: 'f1a362d288c1b98099c7', - client_secret: 'eea605b96e01c796ff369935357eca920c5da4c5', - grant_type: 'client_credentials' - } -}; - -axios.request(options).then(function (response) { - const token = response.data.access_token; - const filepath = path.join('./scripts', '.env'); - fs.writeFileSync(filepath, `GRAPHQL_ACCESS_TOKEN=${token}\n`); -}).catch(function (error) { - console.error(error); -}); \ No newline at end of file diff --git a/src/DailymotionScript.ts b/src/DailymotionScript.ts index 9ddc3d6571c6624d176fefb4b5e003e890b28fe9..369cd9885499a540f251812cc6714cf85a2076c8 100644 --- a/src/DailymotionScript.ts +++ b/src/DailymotionScript.ts @@ -1,9 +1,14 @@ let config: Config; let _settings: IDailymotionPluginSettings; -const LIKE_PLAYLIST_ID = "LIKE_PLAYLIST_ID"; -const FAVORITES_PLAYLIST_ID = "FAVORITES_PLAYLIST_ID"; -const RECENTLY_WATCHED_PLAYLIST_ID = "RECENTLY_WATCHED_PLAYLIST_ID"; +const state = { + anonymousUserAuthorizationToken: "", + anonymousUserAuthorizationTokenExpirationDate: 0 +}; + +const LIKE_PLAYLIST_ID = "LIKE_PLAYLIST"; +const FAVORITES_PLAYLIST_ID = "FAVORITES_PLAYLIST"; +const RECENTLY_WATCHED_PLAYLIST_ID = "RECENTLY_WATCHED_PLAYLIST"; import { @@ -23,7 +28,10 @@ import { ERROR_TYPES, LikedMediaSort, VIDEOS_PER_PAGE_OPTIONS, - PLAYLISTS_PER_PAGE_OPTIONS + PLAYLISTS_PER_PAGE_OPTIONS, + CLIENT_ID, + CLIENT_SECRET, + BASE_URL_API_AUTH } from './constants'; import { @@ -38,19 +46,19 @@ import { SEARCH_CHANNEL, CHANNEL_PLAYLISTS_QUERY, SUBSCRIPTIONS_QUERY, - GET_CHANNEL_PLAYLISTS_XID + GET_CHANNEL_PLAYLISTS_XID, + USER_LIKED_VIDEOS_QUERY, + USER_WATCHED_VIDEOS_QUERY, + USER_WATCH_LATER_VIDEOS_QUERY } from './gqlQueries'; import { getChannelNameFromUrl, isUsernameUrl, - executeGqlQuery, getPreferredCountry, - getAnonymousUserTokenSingleton, getQuery, - getLikePlaylist, - getFavoritesPlaylist, - getRecentlyWatchedPlaylist + objectToUrlEncodedString, + generateUUIDv4 } from './util'; import { @@ -60,7 +68,9 @@ import { Live, LiveConnection, LiveEdge, + Maybe, SuggestionConnection, + User, Video, VideoConnection, VideoEdge @@ -86,6 +96,7 @@ import { let httpClientAnonymous: IHttp = http.newClient(false); +let httpClientRequestToken: IHttp = http.newClient(false); // Will be used to store private playlists that require authentication @@ -114,13 +125,76 @@ source.enable = function (conf, settings, saveStateStr) { config.id = "9c87e8db-e75d-48f4-afe5-2d203d4b95c5"; } -} + let didSaveState = false; + try { + if (saveStateStr) { + const saveState = JSON.parse(saveStateStr); + if (saveState) { + state.anonymousUserAuthorizationToken = saveState.anonymousUserAuthorizationToken; + state.anonymousUserAuthorizationTokenExpirationDate = saveState.anonymousUserAuthorizationTokenExpirationDate; + + if (!isTokenValid()) { + log("Token expired. Fetching a new one."); + } else { + didSaveState = true; + log("Using save state"); + } + } + } + } catch (ex) { + log("Failed to parse saveState:" + ex); + didSaveState = false; + } -source.getHome = function () { + if (!didSaveState) { + + log("Getting a new token"); + + const body = objectToUrlEncodedString({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + grant_type: 'client_credentials' + }); + + const res = httpClientRequestToken.POST(BASE_URL_API_AUTH, body, { + 'User-Agent': USER_AGENT, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Origin': BASE_URL, + 'DNT': '1', + 'Sec-GPC': '1', + 'Connection': 'keep-alive', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-site', + 'Priority': 'u=4', + 'Pragma': 'no-cache', + 'Cache-Control': 'no-cache' + }, false); + + if (res.code !== 200) { + console.error('Failed to get token', res); + throw new ScriptException("", "Failed to get token: " + res.code + " - " + res.body); + } - getAnonymousUserTokenSingleton(); + const json = JSON.parse(res.body); + if (!json.token_type || !json.access_token) { + console.error('Invalid token response', res); + throw new ScriptException("", 'Invalid token response: ' + res.body); + } + + state.anonymousUserAuthorizationToken = `${json.token_type} ${json.access_token}`; + state.anonymousUserAuthorizationTokenExpirationDate = Date.now() + (json.expires_in * 1000); + + log(`json.expires_in: ${json.expires_in}`); + log(`state.anonymousUserAuthorizationTokenExpirationDate: ${state.anonymousUserAuthorizationTokenExpirationDate}`); + } + +} + + +source.getHome = function () { return getVideoPager({}, 0); }; @@ -219,6 +293,13 @@ source.getContentDetails = function (url) { return getSavedVideo(url, false); }; +source.saveState = () => { + return JSON.stringify({ + anonymousUserAuthorizationToken: state.anonymousUserAuthorizationToken, + anonymousUserAuthorizationTokenExpirationDate: state.anonymousUserAuthorizationTokenExpirationDate + }); +}; + //Playlist source.isPlaylistUrl = (url): boolean => { return url.startsWith(BASE_URL_PLAYLIST) || @@ -279,7 +360,7 @@ source.getPlaylist = (url: string): PlatformPlaylistDetails => { source.getUserSubscriptions = (): string[] => { if (!bridge.isLoggedIn()) { - bridge.log("Failed to retrieve subscriptions page because not logged in."); + log("Failed to retrieve subscriptions page because not logged in."); throw new ScriptException("Not logged in"); } @@ -351,7 +432,7 @@ source.getUserSubscriptions = (): string[] => { source.getUserPlaylists = (): string[] => { if (!bridge.isLoggedIn()) { - bridge.log("Failed to retrieve subscriptions page because not logged in."); + log("Failed to retrieve subscriptions page because not logged in."); throw new ScriptException("Not logged in"); } @@ -567,7 +648,7 @@ function getChannelContentsPager(url, page, type, order, filters) { const shouldLoadLives = type === Type.Feed.Mixed || type === Type.Feed.Streams || type === Type.Feed.Live; if (IS_TESTING) { - bridge.log(`Getting channel contents for ${url}, page: ${page}, type: ${type}, order: ${order}, shouldLoadVideos: ${shouldLoadVideos}, shouldLoadLives: ${shouldLoadLives}, filters: ${JSON.stringify(filters)}`); + log(`Getting channel contents for ${url}, page: ${page}, type: ${type}, order: ${order}, shouldLoadVideos: ${shouldLoadVideos}, shouldLoadLives: ${shouldLoadLives}, filters: ${JSON.stringify(filters)}`); } /** @@ -745,7 +826,7 @@ function getSavedVideo(url, usePlatformAuth = false) { }; if (!usePlatformAuth) { - videoDetailsRequestHeaders.Authorization = getAnonymousUserTokenSingleton(); + videoDetailsRequestHeaders.Authorization = state.anonymousUserAuthorizationToken; } const variables = { @@ -878,6 +959,184 @@ function getChannelPlaylists(url: string, page: number = 1): SearchPlaylistPager return new ChannelPlaylistPager(content, hasMore, params, page, getChannelPlaylists); } +function isTokenValid() { + const currentTime = Date.now(); + return state.anonymousUserAuthorizationTokenExpirationDate > currentTime; +} + +function executeGqlQuery(httpClient, requestOptions) { + + const headersToAdd = requestOptions.headers || { + "User-Agent": USER_AGENT, + "Accept": "*/*", + // "Accept-Language": Accept_Language, + "Referer": BASE_URL, + "Origin": BASE_URL, + "DNT": "1", + "Connection": "keep-alive", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + "Pragma": "no-cache", + "Cache-Control": "no-cache" + } + + const gql = JSON.stringify({ + operationName: requestOptions.operationName, + variables: requestOptions.variables, + query: requestOptions.query, + }); + + const usePlatformAuth = requestOptions.usePlatformAuth == undefined ? false : requestOptions.usePlatformAuth; + const throwOnError = requestOptions.throwOnError == undefined ? true : requestOptions.throwOnError; + + if (!usePlatformAuth) { + headersToAdd.Authorization = state.anonymousUserAuthorizationToken; + } + + const res = httpClient.POST(BASE_URL_API, gql, headersToAdd, usePlatformAuth); + + if (!res.isOk) { + console.error('Failed to get token', res); + if (throwOnError) { + throw new ScriptException("Failed to get token", res); + } + } + + const body = JSON.parse(res.body); + + // some errors may be returned in the body with a status code 200 + if (body.errors) { + const message = body.errors.map(e => e.message).join(', '); + + if (throwOnError) { + throw new UnavailableException(message); + } + } + + return body; +} + + +function getPages<TI, TO>( + httpClient: IHttp, + query: string, + operationName: string, + variables: any, + usePlatformAuth: boolean, + setRoot: (jsonResponse: any) => TI, + hasNextCallback: (page: TI) => boolean, + getNextPage: (page: TI, currentPage) => number, + map: (page: any) => TO[] + +): TO[] { + + let all: TO[] = []; + + if (!hasNextCallback) { + hasNextCallback = () => false; + } + + let hasNext = true; + let nextPage = 1; + + do { + + variables = { ...variables, page: nextPage }; + + const jsonResponse = executeGqlQuery( + httpClient, + { + operationName, + variables, + query, + usePlatformAuth + }); + + const root = setRoot(jsonResponse); + + nextPage = getNextPage(root, nextPage); + + const items = map(root); + + hasNext = hasNextCallback(root); + + all = all.concat(items); + + } while (hasNext); + + return all; +} + +function getLikePlaylist(pluginId: string, httpClient: IHttp, usePlatformAuth: boolean = false, thumbnailResolutionIndex: number = 0): PlatformPlaylistDetails { + return getPlatformSystemPlaylist({ + pluginId, + httpClient, + query: USER_LIKED_VIDEOS_QUERY, + operationName: 'USER_LIKED_VIDEOS_QUERY', + rootObject: 'likedMedias', + playlistName: 'Liked Videos', + usePlatformAuth, + thumbnailResolutionIndex + }); + +} + +function getFavoritesPlaylist(pluginId: string, httpClient: IHttp, usePlatformAuth: boolean = false, thumbnailResolutionIndex: number = 0): PlatformPlaylistDetails { + return getPlatformSystemPlaylist({ + pluginId, + httpClient, + query: USER_WATCH_LATER_VIDEOS_QUERY, + operationName: 'USER_WATCH_LATER_VIDEOS_QUERY', + rootObject: 'watchLaterMedias', + playlistName: 'Favorites', + usePlatformAuth, + thumbnailResolutionIndex + }) +} + +function getRecentlyWatchedPlaylist(pluginId: string, httpClient: IHttp, usePlatformAuth: boolean = false, thumbnailResolutionIndex: number = 0): PlatformPlaylistDetails { + return getPlatformSystemPlaylist({ + pluginId, + httpClient, + query: USER_WATCHED_VIDEOS_QUERY, + operationName: 'USER_WATCHED_VIDEOS_QUERY', + rootObject: 'watchedVideos', + playlistName: 'Recently Watched', + usePlatformAuth, + thumbnailResolutionIndex + + }); +} + +function getPlatformSystemPlaylist(opts: IPlatformSystemPlaylist): PlatformPlaylistDetails { + + const videos: PlatformVideo[] = getPages<Maybe<User>, PlatformVideo>( + opts.httpClient, + opts.query, + opts.operationName, + { + page: 1, + thumbnail_resolution: THUMBNAIL_HEIGHT[opts.thumbnailResolutionIndex] + }, + opts.usePlatformAuth, + (jsonResponse) => jsonResponse?.data?.me,//set root + (me) => (me?.[opts.rootObject]?.edges.length ?? 0) > 0 ?? false,//hasNextCallback + (me, currentPage) => ++currentPage, //getNextPage + (me) => me?.[opts.rootObject]?.edges.map(edge => { + return SourceVideoToGrayjayVideo(opts.pluginId, edge.node as Video); + })); + + const collection = { + "id": generateUUIDv4(), + "name": opts.playlistName, + "creator": {} + } + + return SourceCollectionToGrayjayPlaylistDetails(opts.pluginId, collection as Collection, videos); +} + + function getHttpContext(opts: { usePlatformAuth: boolean } = { usePlatformAuth: false }): IHttp { return opts.usePlatformAuth ? http : httpClientAnonymous; } diff --git a/src/util.ts b/src/util.ts index 9a53b1504552fc94f22a0b35e91c606ce492c159..890ec20b83b3fe0fb8cc7dd662d420ac0740bff0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,22 +1,9 @@ -let AUTHORIZATION_TOKEN_ANONYMOUS_USER: string = ""; -let AUTHORIZATION_TOKEN_ANONYMOUS_USER_EXPIRATION_DATE: number; -let httpClientRequestToken: IHttp = http.newClient(false); - -import { Collection, Maybe, User, Video } from '../types/CodeGenDailymotion'; -import { SourceCollectionToGrayjayPlaylistDetails, SourceVideoToGrayjayVideo } from './Mappers'; import { BASE_URL, - USER_AGENT, - BASE_URL_API, COUNTRY_NAMES, COUNTRY_NAMES_TO_CODE, - CLIENT_ID, - CLIENT_SECRET, - BASE_URL_API_AUTH, - DURATION_THRESHOLDS, - THUMBNAIL_HEIGHT, + DURATION_THRESHOLDS } from './constants' -import { USER_WATCH_LATER_VIDEOS_QUERY, USER_LIKED_VIDEOS_QUERY, USER_WATCHED_VIDEOS_QUERY } from './gqlQueries'; export function getPreferredCountry(preferredCountryIndex) { const countryName = COUNTRY_NAMES[preferredCountryIndex]; @@ -53,120 +40,6 @@ export function isUsernameUrl(url) { return regex.test(url); } - -// TODO: save to state -export function getAnonymousUserTokenSingleton() { - // Check if the anonymous user token is available and not expired - if (AUTHORIZATION_TOKEN_ANONYMOUS_USER) { - - const isTokenValid = AUTHORIZATION_TOKEN_ANONYMOUS_USER_EXPIRATION_DATE && new Date().getTime() < AUTHORIZATION_TOKEN_ANONYMOUS_USER_EXPIRATION_DATE; - - if (isTokenValid) { - return AUTHORIZATION_TOKEN_ANONYMOUS_USER; - } - } - - // Prepare the request body for obtaining a new token - const body = objectToUrlEncodedString({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - grant_type: 'client_credentials' - }); - - // Make the HTTP POST request to the authorization API - const res = httpClientRequestToken.POST(`${BASE_URL_API_AUTH}`, body, { - 'User-Agent': USER_AGENT, - 'Content-Type': 'application/x-www-form-urlencoded', - 'Origin': BASE_URL, - 'DNT': '1', - 'Sec-GPC': '1', - 'Connection': 'keep-alive', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-site', - 'Priority': 'u=4', - 'Pragma': 'no-cache', - 'Cache-Control': 'no-cache' - }, false); - - // Check if the response code indicates success - if (res.code !== 200) { - console.error('Failed to get token', res); - throw new ScriptException("", "Failed to get token: " + res.code + " - " + res.body); - } - - // Parse the response JSON to extract the token information - const json = JSON.parse(res.body); - - // Ensure the response contains the necessary token information - if (!json.token_type || !json.access_token) { - console.error('Invalid token response', res); - throw new ScriptException("", 'Invalid token response: ' + res.body); - } - - // Store the token and its expiration date - AUTHORIZATION_TOKEN_ANONYMOUS_USER = `${json.token_type} ${json.access_token}`; - AUTHORIZATION_TOKEN_ANONYMOUS_USER_EXPIRATION_DATE = new Date().getTime() + (json.expires_in * 1000); - - return AUTHORIZATION_TOKEN_ANONYMOUS_USER; -} - - -export function executeGqlQuery(httpClient, requestOptions) { - - const headersToAdd = requestOptions.headers || { - "User-Agent": USER_AGENT, - "Accept": "*/*", - // "Accept-Language": Accept_Language, - "Referer": BASE_URL, - "Origin": BASE_URL, - "DNT": "1", - "Connection": "keep-alive", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-site", - "Pragma": "no-cache", - "Cache-Control": "no-cache" - } - - - const gql = JSON.stringify({ - operationName: requestOptions.operationName, - variables: requestOptions.variables, - query: requestOptions.query, - }); - - const usePlatformAuth = requestOptions.usePlatformAuth == undefined ? false : requestOptions.usePlatformAuth; - const throwOnError = requestOptions.throwOnError == undefined ? true : requestOptions.throwOnError; - - if (!usePlatformAuth) { - headersToAdd.Authorization = getAnonymousUserTokenSingleton(); - } - - const res = httpClient.POST(BASE_URL_API, gql, headersToAdd, usePlatformAuth); - - if (!res.isOk) { - console.error('Failed to get token', res); - if (throwOnError) { - throw new ScriptException("Failed to get token", res); - } - } - - const body = JSON.parse(res.body); - - // some errors may be returned in the body with a status code 200 - if (body.errors) { - const message = body.errors.map(e => e.message).join(', '); - - if (throwOnError) { - throw new UnavailableException(message); - } - } - - return body; -} - - export const parseUploadDateFilter = (filter) => { let createdAfterVideos; @@ -250,124 +123,4 @@ export function generateUUIDv4() { const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); -} - - -export function getPages<TI, TO>( - httpClient: IHttp, - query: string, - operationName: string, - variables: any, - usePlatformAuth: boolean, - setRoot: (jsonResponse: any) => TI, - hasNextCallback: (page: TI) => boolean, - getNextPage: (page: TI, currentPage) => number, - map: (page: any) => TO[] - -): TO[] { - - let all: TO[] = []; - - if (!hasNextCallback) { - hasNextCallback = () => false; - } - - let hasNext = true; - let nextPage = 1; - - do { - - variables = { ...variables, page: nextPage }; - - const jsonResponse = executeGqlQuery( - httpClient, - { - operationName, - variables, - query, - usePlatformAuth - }); - - const root = setRoot(jsonResponse); - - nextPage = getNextPage(root, nextPage); - - const items = map(root); - - hasNext = hasNextCallback(root); - - all = all.concat(items); - - } while (hasNext); - - return all; -} - - -export function getLikePlaylist(pluginId: string, httpClient: IHttp, usePlatformAuth: boolean = false, thumbnailResolutionIndex: number = 0): PlatformPlaylistDetails { - return getPlatformSystemPlaylist({ - pluginId, - httpClient, - query: USER_LIKED_VIDEOS_QUERY, - operationName: 'USER_LIKED_VIDEOS_QUERY', - rootObject: 'likedMedias', - playlistName: 'Liked Videos', - usePlatformAuth, - thumbnailResolutionIndex - }); - -} - -export function getFavoritesPlaylist(pluginId: string, httpClient: IHttp, usePlatformAuth: boolean = false, thumbnailResolutionIndex: number = 0): PlatformPlaylistDetails { - return getPlatformSystemPlaylist({ - pluginId, - httpClient, - query: USER_WATCH_LATER_VIDEOS_QUERY, - operationName: 'USER_WATCH_LATER_VIDEOS_QUERY', - rootObject: 'watchLaterMedias', - playlistName: 'Favorites', - usePlatformAuth, - thumbnailResolutionIndex - }) -} - -export function getRecentlyWatchedPlaylist(pluginId: string, httpClient: IHttp, usePlatformAuth: boolean = false, thumbnailResolutionIndex: number = 0): PlatformPlaylistDetails { - return getPlatformSystemPlaylist({ - pluginId, - httpClient, - query: USER_WATCHED_VIDEOS_QUERY, - operationName: 'USER_WATCHED_VIDEOS_QUERY', - rootObject: 'watchedVideos', - playlistName: 'Recently Watched', - usePlatformAuth, - thumbnailResolutionIndex - - }); -} - -export function getPlatformSystemPlaylist(opts: IPlatformSystemPlaylist): PlatformPlaylistDetails { - - const videos: PlatformVideo[] = getPages<Maybe<User>, PlatformVideo>( - opts.httpClient, - opts.query, - opts.operationName, - { - page: 1, - thumbnail_resolution: THUMBNAIL_HEIGHT[opts.thumbnailResolutionIndex] - }, - opts.usePlatformAuth, - (jsonResponse) => jsonResponse?.data?.me,//set root - (me) => (me?.[opts.rootObject]?.edges.length ?? 0) > 0 ?? false,//hasNextCallback - (me, currentPage) => ++currentPage, //getNextPage - (me) => me?.[opts.rootObject]?.edges.map(edge => { - return SourceVideoToGrayjayVideo(opts.pluginId, edge.node as Video); - })); - - const collection = { - "id": generateUUIDv4(), - "name": opts.playlistName, - "creator": {} - } - - return SourceCollectionToGrayjayPlaylistDetails(opts.pluginId, collection as Collection, videos); -} +} \ No newline at end of file diff --git a/types/CodeGenDailymotion.d.ts b/types/CodeGenDailymotion.d.ts index 83782e5f1ea2d0a1455fa7c35f0743c69e8dfbc5..2c4835c17b1d82147560e53ab4d3feaf830b4fa1 100644 --- a/types/CodeGenDailymotion.d.ts +++ b/types/CodeGenDailymotion.d.ts @@ -1772,7 +1772,9 @@ export type CreateCommentInput = { /** The ID generated for the client performing the mutation. */ clientMutationId?: InputMaybe<Scalars['String']['input']>; /** The ID of the post that the comment is created for. */ - postId: Scalars['ID']['input']; + postId?: InputMaybe<Scalars['ID']['input']>; + /** The ID of the story that the comment is created for. */ + storyId?: InputMaybe<Scalars['ID']['input']>; /** The text on the comment. */ text: Scalars['String']['input']; }; @@ -2737,11 +2739,11 @@ export type IdOperator = { /** Short for equal, must match the given data exactly. */ eq?: InputMaybe<Scalars['ID']['input']>; /** Short for in array, must NOT be an element of the array. */ - in?: InputMaybe<Array<InputMaybe<Scalars['ID']['input']>>>; + in?: InputMaybe<Array<Scalars['ID']['input']>>; /** Short for not equal, must be different from the given data. */ ne?: InputMaybe<Scalars['ID']['input']>; /** Short for not in array, must be an element of the array. */ - nin?: InputMaybe<Array<InputMaybe<Scalars['ID']['input']>>>; + nin?: InputMaybe<Array<Scalars['ID']['input']>>; }; /** Information of an Image. */ @@ -5077,7 +5079,7 @@ export type PostOperator = { /** Short for equal, must match the given data exactly. */ eq?: InputMaybe<PostTypename>; /** Short for in array, must be an element of the array. */ - in?: InputMaybe<Array<InputMaybe<PostTypename>>>; + in?: InputMaybe<Array<PostTypename>>; }; /** The possible values for a PostStatus. */ diff --git a/types/CodeGenDailymotion.schema.json b/types/CodeGenDailymotion.schema.json index 18ba2e7f5589fc1ea09f6f6541681b7a27310ce1..e22caed6225fe8f9f6001c4fe563a743c0c9fca5 100644 --- a/types/CodeGenDailymotion.schema.json +++ b/types/CodeGenDailymotion.schema.json @@ -8626,13 +8626,21 @@ "name": "postId", "description": "The ID of the post that the comment is created for.", "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "storyId", + "description": "The ID of the story that the comment is created for.", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null }, "defaultValue": null, "isDeprecated": false, @@ -13279,9 +13287,13 @@ "kind": "LIST", "name": null, "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } } }, "defaultValue": null, @@ -13307,9 +13319,13 @@ "kind": "LIST", "name": null, "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } } }, "defaultValue": null, @@ -23611,9 +23627,13 @@ "kind": "LIST", "name": null, "ofType": { - "kind": "ENUM", - "name": "PostTypename", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PostTypename", + "ofType": null + } } }, "defaultValue": null, diff --git a/types/plugin.d.ts b/types/plugin.d.ts index 2b99504dea32dbeb6baba4ea29985ac637274f7d..f6c210772fd9b4be8d7d55faa6ba8f7d6c532973 100644 --- a/types/plugin.d.ts +++ b/types/plugin.d.ts @@ -1148,13 +1148,13 @@ function parseSettings(settings) { return newSettings; } -function log(str: string) { - if (str) { - console.log(str); - if (typeof str == "string") - bridge.log(str); +function log(obj: string | object) { + if (obj) { + console.log(obj); + if (typeof obj == "string") + bridge.log(obj); else - bridge.log(JSON.stringify(str, null, 4)); + bridge.log(JSON.stringify(obj, null, 4)); } }