From 42af9d4ebfdae972aa774bab732c7355c9550125 Mon Sep 17 00:00:00 2001 From: Stephen Tsimidis Date: Sun, 18 Feb 2024 10:19:20 -0500 Subject: [PATCH] Implement support for Crave (#348) --- README.md | 9 +- .../history/components/HistoryList.tsx | 2 +- src/services/crave/CraveApi.ts | 493 ++++++++++++++++++ src/services/crave/CraveParser.ts | 94 ++++ src/services/crave/CraveService.ts | 11 + src/services/crave/crave.ts | 4 + 6 files changed, 611 insertions(+), 2 deletions(-) create mode 100644 src/services/crave/CraveApi.ts create mode 100644 src/services/crave/CraveParser.ts create mode 100644 src/services/crave/CraveService.ts create mode 100644 src/services/crave/crave.ts diff --git a/README.md b/README.md index cbca8c70..5bd86ee1 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,19 @@ Extension will be enabled until you restart Firefox. ### Table of Contents +- [Loading the extension manually in Chrome](#loading-the-extension-manually-in-chrome) +- [Loading the extension manually in Firefox](#loading-the-extension-manually-in-firefox) +- [Table of Contents](#table-of-contents) - [What is Universal Trakt Scrobbler?](#what-is-universal-trakt-scrobbler) - [Why do I need this extension?](#why-do-i-need-this-extension) - [Which streaming services are supported?](#which-streaming-services-are-supported) - [How does the extension work?](#how-does-the-extension-work) -- [Problems](#problems) +- [Known Issues](#known-issues) +- [Other Problems](#other-problems) - [Help Translate](#help-translate) - [Development](#development) + - [How to add more streaming services](#how-to-add-more-streaming-services) + - [How to add scrobbler/sync to streaming services](#how-to-add-scrobblersync-to-streaming-services) - [Credits](#credits) ### What is Universal Trakt Scrobbler? @@ -65,6 +71,7 @@ If you want to scrobble / sync from Netflix, this is the only Trakt.tv [plugin]( | :---------------: | :------: | :--: | :------------------------------ | | Amazon Prime | ✔️ | ✔️ | - | | AMC+ | ✔️ | ❌ | - | +| Crave | ✔️ | ✔️ | - | | Crunchyroll | ❌ | ✔️ | Can't identify movies as movies | | Disney+ | ✔️ | ❌ | - | | Go3 | ✔️ | ❌ | - | diff --git a/src/modules/history/components/HistoryList.tsx b/src/modules/history/components/HistoryList.tsx index 910ae044..408b9b3c 100644 --- a/src/modules/history/components/HistoryList.tsx +++ b/src/modules/history/components/HistoryList.tsx @@ -385,7 +385,7 @@ export const HistoryList = (): JSX.Element => { width: '100%', }} > - {({ height }) => ( + {({ height }: { height: number }) => ( = { data: T } & { errors: Array<{ message: string }> }; + +export interface CraveAxisContent { + axisId: number; + title: string; + seasonNumber: number; + episodeNumber: number; + axisMedia: CraveAxisMedia; + contentType: 'EPISODE' | 'FEATURE' | 'PROMO'; +} + +export interface CraveAxisMedia { + axisId: number; + title: string; + firstAirYear: number; +} + +export interface CraveProfile { + id: string; + nickname: string; +} + +interface CraveSessionNoAuth extends ServiceApiSession { + profileName: null; + isAuthenticated: false; + expirationDate: null; +} + +interface CraveSession extends ServiceApiSession { + isAuthenticated: true; + accessToken: string; + refreshToken: string; + expirationDate: number; +} + +interface CraveWatchHistoryPageResponse { + content: Array; + last: boolean; +} + +interface CraveAxisContentsResponse { + contentData: { + items: Array; + }; +} + +interface CraveResolvedPathResponse { + resolvedPath: { + lastSegment: { + content: CraveAxisContent; + }; + }; +} + +interface CraveAuthorizeResponse { + access_token: string; + refresh_token: string; + expires_in: number; + creation_date: number; +} + +interface RetryPolicy { + numberOfTries: number; + action: () => Promise; + onBeforeRetry: () => Promise; +} + +class _CraveApi extends ServiceApi { + isActivated = false; + session?: CraveSession | CraveSessionNoAuth; + pageNumber = 0; + pageSize = 500; + allowedContentTypes = ['episode', 'feature']; + + constructor() { + super(CraveService.id); + } + + async activate() { + try { + this.session = await this.getInitialSession(); + this.isActivated = true; + } catch (err) { + if (Shared.errors.validate(err)) { + Shared.errors.log(`Failed to activate ${this.id} API`, err); + } + throw new Error('Failed to activate API'); + } + } + + async checkLogin(): Promise { + if (!this.isActivated) { + await this.activate(); + } + return await super.checkLogin(); + } + + async loadHistoryItems(cancelKey?: string): Promise { + const auth = await this.authorize(); + + const watchHistoryPage = await this.queryWatchHistoryPage(auth, cancelKey); + if (watchHistoryPage.last) { + this.hasReachedHistoryEnd = true; + } else { + this.pageNumber++; + } + + // Exclude non-episode or feature items, such as trailers. + return watchHistoryPage.content.filter((p) => this.allowedContentTypes.includes(p.contentType)); + } + + isNewHistoryItem(historyItem: CraveHistoryItem, lastSync: number) { + return historyItem.timestamp / 1000 > lastSync; + } + + getHistoryItemId(historyItem: CraveHistoryItem): string { + return historyItem.contentId; + } + + async getItem(path: string): Promise { + let resolvedPath; + try { + resolvedPath = await this.queryResolvedPath(path); + } catch (err) { + if (Shared.errors.validate(err)) { + Shared.errors.error('Failed to get item.', err); + } + return null; + } + + const baseScrobbleItem: BaseItemValues = { + serviceId: this.id, + id: resolvedPath.axisId.toString(), + title: resolvedPath.title, + year: resolvedPath.axisMedia.firstAirYear, + }; + if (resolvedPath.contentType === 'FEATURE') { + return new MovieItem(baseScrobbleItem); + } else if (resolvedPath.contentType === 'EPISODE') { + return new EpisodeItem({ + ...baseScrobbleItem, + season: resolvedPath.seasonNumber, + number: resolvedPath.episodeNumber, + show: { + serviceId: this.id, + id: resolvedPath.axisMedia.axisId.toString(), + title: resolvedPath.axisMedia.title, + }, + }); + } + // Not a trackable content item, so return null. + Shared.errors.log( + 'Failed to get item.', + new Error(`Content is not trackable: ${resolvedPath.contentType}`) + ); + return null; + } + + async convertHistoryItems(historyItems: CraveHistoryItem[]): Promise { + const itemsWithMetadata = await this.loadHistoryItemsMetadata(historyItems); + const items = itemsWithMetadata.map(({ watchedItem, axisItem }) => { + const baseScrobbleItem: BaseItemValues = { + serviceId: this.id, + id: watchedItem.contentId, + // Use Crave's completion flag in place of the progress threshold checked by the add-on. + // This can avoid causing the add-on to sync the same viewing twice. + progress: watchedItem.completed ? watchedItem.progression : 0, + title: axisItem?.title ?? watchedItem.title, + watchedAt: watchedItem.timestamp / 1000, + year: axisItem?.axisMedia.firstAirYear, + }; + if (watchedItem.contentType === 'episode') { + return new EpisodeItem({ + ...baseScrobbleItem, + season: Number(watchedItem.season), + number: Number(watchedItem.episode), + show: { + serviceId: this.id, + id: watchedItem.mediaId, + title: axisItem?.axisMedia.title ?? '', + }, + }); + } else { + return new MovieItem(baseScrobbleItem); + } + }); + + return Promise.resolve(items); + } + + updateItemFromHistory(item: ScrobbleItemValues, historyItem: CraveHistoryItem): void { + item.progress = historyItem.progression; + item.watchedAt = historyItem.timestamp / 1000; + } + + private async loadHistoryItemsMetadata(historyItems: CraveHistoryItem[]) { + type WatchedItemMetadata = { watchedItem: CraveHistoryItem; axisItem?: CraveAxisContent }; + + const axisIds = historyItems.map((p) => Number(p.contentId)); + const axisItems = await this.queryAxisContentsByAxisIds(axisIds); + + return historyItems.reduce>((metadata, watchedItem) => { + const axisItem = axisItems.find((p) => p.axisId == +watchedItem.contentId); + metadata.push({ watchedItem, axisItem }); + + return metadata; + }, []); + } + + private async authorize(): Promise { + if (!this.isActivated) { + await this.activate(); + } + + if (!this.session || !this.session?.isAuthenticated) { + throw new Error('User is not authorized.'); + } + + // The token may have expired since the API was activated, so verify + // that it is still active and refresh if necessary. + if (!this.verifyAccessToken(this.session)) { + await this.refresh(this.session); + } + + return this.session; + } + + private verifyAccessToken(session: CraveSession): boolean { + return session.expirationDate > Date.now(); + } + + private async refresh(session: CraveSession): Promise { + // Get a new access token using the refresh token. + const responseText = await Requests.send({ + url: LOGIN_URL, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${btoa('crave-web:default')}`, + }, + body: `refresh_token=${session.refreshToken}`, + }); + const craveAuthorizeResponse = JSON.parse(responseText) as CraveAuthorizeResponse; + session.accessToken = craveAuthorizeResponse.access_token; + session.refreshToken = craveAuthorizeResponse.refresh_token; + session.expirationDate = + craveAuthorizeResponse.creation_date + craveAuthorizeResponse.expires_in; + } + + private async queryAxisContentsByAxisIds(axisIds: number[]) { + const requestDetails = { + url: GRAPHQL_URL, + method: 'POST', + body: { + operationName: 'AxisContents', + variables: { + axisIds, + subscriptions: ['CRAVE', 'STARZ', 'SUPER_ECRAN'], + maturity: 'ADULT', + language: 'ENGLISH', + authenticationState: 'AUTH', + playbackLanguage: 'ENGLISH', + }, + query: `query AxisContents($axisIds: [Int], $subscriptions: [Subscription]!, $maturity: Maturity!, $language: Language!, $authenticationState: AuthenticationState!, $playbackLanguage: PlaybackLanguage!) @uaContext(subscriptions: $subscriptions, maturity: $maturity, language: $language, authenticationState: $authenticationState, playbackLanguage: $playbackLanguage) { + contentData: axisContents(axisIds: $axisIds) { + items: contents { + id + axisId + title + __typename + ... on AxisContent { + seasonNumber + episodeNumber + path + axisMedia { + id + axisId + title + firstAirYear + __typename + } + contentType + __typename + } + } + __typename + } + }`, + }, + }; + const responseText = await Requests.send(requestDetails); + const response = JSON.parse(responseText) as GraphQLResponse; + if (response.errors) { + Shared.errors.warning( + 'AxisContents GraphQL query responded with errors', + new Error(response.errors.join('\n')) + ); + return []; + } + return response.data.contentData.items; + } + + private async queryResolvedPath(path: string) { + const requestDetails = { + url: GRAPHQL_URL, + method: 'POST', + body: { + operationName: 'resolvePath', + variables: { + path, + subscriptions: ['CRAVE', 'STARZ', 'SUPER_ECRAN'], + maturity: 'ADULT', + language: 'ENGLISH', + authenticationState: 'AUTH', + playbackLanguage: 'ENGLISH', + }, + query: `query resolvePath($path: String!, $subscriptions: [Subscription]!, $maturity: Maturity!, $language: Language!, $authenticationState: AuthenticationState!, $playbackLanguage: PlaybackLanguage!) @uaContext(subscriptions: $subscriptions, maturity: $maturity, language: $language, authenticationState: $authenticationState, playbackLanguage: $playbackLanguage) { + resolvedPath(path: $path) { + redirected + path + lastSegment { + position + content { + id + title + path + __typename + ... on AxisContent { + axisId + __typename + seasonNumber + episodeNumber + path + axisMedia { + id + axisId + title + firstAirYear + __typename + } + contentType + __typename + } + __typename + } + __typename + } + } + }`, + }, + }; + const responseText = await Requests.send(requestDetails); + const response = JSON.parse(responseText) as GraphQLResponse; + if (response.errors) { + throw new Error(`resolvePath GraphQL query responded with errors. + ${response.errors.join('\n')}`); + } + return response.data.resolvedPath.lastSegment.content; + } + + private async queryWatchHistoryPage(auth: CraveSession, cancelKey: string | undefined) { + const params = `?pageNumber=${this.pageNumber}&pageSize=${this.pageSize}`; + const responseText = await Requests.send({ + url: `${WATCH_HISTORY_URL}${params}`, + method: 'GET', + headers: { + Authorization: `Bearer ${auth.accessToken}`, + }, + cancelKey, + }); + const response = JSON.parse(responseText) as CraveWatchHistoryPageResponse; + return response; + } + + private async queryProfiles(auth: CraveSession) { + const responseText = await Requests.send({ + url: PROFILES_URL, + method: 'GET', + headers: { + Authorization: `Bearer ${auth.accessToken}`, + }, + }); + return JSON.parse(responseText) as Array; + } + + private async getInitialSession(): Promise { + const auth = await ScriptInjector.inject(this.id, 'session', HOST_URL); + if (auth) { + // The initial access token might have expired, so refresh and retry if this first query fails. + const profiles = await this.retry({ + action: () => this.queryProfiles(auth), + onBeforeRetry: () => this.refresh(auth), + numberOfTries: 2, + }); + const profileInfo = profiles.find((p) => p.id === auth.profileName); + if (profileInfo) { + auth.profileName = profileInfo.nickname; + } + return auth; + } + return { isAuthenticated: false, profileName: null, expirationDate: null }; + } + + private async retry({ numberOfTries, action, onBeforeRetry }: RetryPolicy) { + let i = 0; + let lastError: Error | null = null; + do { + try { + return await action(); + } catch (err) { + if (Shared.errors.validate(err)) { + Shared.errors.log(`An error occurred on attempt #${i + 1}/${numberOfTries}`, err); + lastError = err; + } + } + } while (++i < numberOfTries && (await onBeforeRetry(), true)); + + const error = new Error('Exceeded number of retries'); + if (Shared.errors.validate(lastError)) { + Shared.errors.log(error.message, lastError); + } + throw error; + } +} + +Shared.functionsToInject[`${CraveService.id}-session`] = (): CraveSession | null => { + const cookies = document.cookie.split(';').reduce((cookiesMap, cookiePair) => { + const keyValuePair = cookiePair.split('=') as [string, string]; + cookiesMap.set(keyValuePair[0].trimStart(), keyValuePair[1]); + return cookiesMap; + }, new Map()); + + const accessToken = cookies.get('access') ?? ''; + if (!accessToken) { + return null; + } + + const jwtPayload = JSON.parse(atob(accessToken.split('.')[1])) as { + exp: number; + profile_id: string; + }; + return { + profileName: jwtPayload.profile_id, + isAuthenticated: true, + accessToken: accessToken, + refreshToken: cookies.get('refresh') ?? '', + expirationDate: jwtPayload.exp * 1000, + }; +}; + +export const CraveApi = new _CraveApi(); diff --git a/src/services/crave/CraveParser.ts b/src/services/crave/CraveParser.ts new file mode 100644 index 00000000..05243500 --- /dev/null +++ b/src/services/crave/CraveParser.ts @@ -0,0 +1,94 @@ +import { ScrobbleParser } from '@common/ScrobbleParser'; +import { CraveApi } from '@/crave/CraveApi'; +import { EpisodeItem, ScrobbleItem, ShowItemValues } from '@models/Item'; +import { Shared } from '@common/Shared'; + +class _CraveParser extends ScrobbleParser { + isStaleUrl = false; + episodeContext: Partial | null = null; + + constructor() { + super(CraveApi, { + /** + * When actively watching, the path will match tv-shows or movies followed by 2 path segments. + * Examples: + * https://www.crave.ca/en/tv-shows/30-rock/cutbacks-s3e17 => /tv-shows/30-rock/cutbacks-s3e17 + * https://www.crave.ca/en/movies/ghostbusters-51932/ghostbusters => /movies/ghostbusters-51932/ghostbusters + */ + watchingUrlRegex: /(?\/(?:tv-shows|movies)\/.+\/.+)/, + }); + } + + protected async parseItemFromApi(): Promise { + if (this.isStaleUrl) { + // Stop using the API to get full details if the URL has gone stale. + return null; + } + + const apiItem = await super.parseItemFromApi(); + + if (apiItem?.type === 'episode' && !this.episodeContext) { + // Save context about the originally loaded show so that it can be used + // for subsequent requests when querying video information from the DOM. + // This is mainly so that the year disambiguation is still available when + // watching multiple episodes from a show. + this.episodeContext = { + year: apiItem.year, + show: apiItem.show, + }; + } + + return this.overrideItemIfStale(apiItem); + } + + protected parseItemFromDom(): Promise { + const subtitle = + document.querySelector('[class^=jasper-player-title__subtitle]')?.innerHTML ?? ''; + const extractedData = /S(?\d+) E(?\d+): (?.+)/.exec(subtitle); + if (!extractedData?.groups) { + return Promise.resolve(null); + } + return Promise.resolve( + new EpisodeItem({ + serviceId: this.api.id, + id: subtitle, + title: extractedData.groups.episodeTitle ?? '', + season: Number(extractedData.groups.season ?? 0), + number: Number(extractedData.groups.number ?? 0), + year: this.episodeContext?.year, + show: this.episodeContext?.show as ShowItemValues, + }) + ); + } + + /** + * Get an item that should override the original item if it has gone stale, and emit + * events to update the state of the current item. + * @param item The potentially stale item. + * @returns The item that should override the original item when stale, or the original item. + */ + private async overrideItemIfStale(item: ScrobbleItem | null): Promise { + if (item?.type === 'episode') { + // The video URL doesn't update when clicking the "Play Next" button, so the + // current item may be wrong. Check if that's the case, and if it is, correct the item. + const domItem = await this.parseItemFromDom(); + if ( + domItem?.type === 'episode' && + (domItem.season !== item.season || domItem.number !== item.number) + ) { + // Mark the URL as stale so that future calls know to skip calling the API. + this.isStaleUrl = true; + // The scrobbler only explicitly rechecks trakt mapping data after stopping playback. Manually + // dispatch the event so that the item is fully updated. + await Shared.events.dispatch('SCROBBLING_ITEM_CORRECTED', null, { + oldItem: item, + newItem: domItem, + }); + return domItem; + } + } + return item; + } +} + +export const CraveParser = new _CraveParser(); diff --git a/src/services/crave/CraveService.ts b/src/services/crave/CraveService.ts new file mode 100644 index 00000000..6cb58fdf --- /dev/null +++ b/src/services/crave/CraveService.ts @@ -0,0 +1,11 @@ +import { Service } from '@models/Service'; + +export const CraveService = new Service({ + id: 'crave', + name: 'Crave', + homePage: 'https://www.crave.ca', + hostPatterns: ['*://*.www.crave.ca/*', '*://*.bellmedia.ca/*'], + hasScrobbler: true, + hasSync: true, + hasAutoSync: true, +}); diff --git a/src/services/crave/crave.ts b/src/services/crave/crave.ts new file mode 100644 index 00000000..6d7a239e --- /dev/null +++ b/src/services/crave/crave.ts @@ -0,0 +1,4 @@ +import { init } from '@service'; +import '@/crave/CraveParser'; + +void init('crave');