diff --git a/.eslintignore b/.eslintignore
index 69dac23e..a51e9756 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,2 +1,7 @@
build/*
eslint-plugin-prefer-arrow/*
+src/streaming-services/scrobbler-template/*
+src/streaming-services/sync-template/*
+!.eslintrc.js
+!.eslintrc.typed.js
+!.lintstagedrc.js
diff --git a/.gitignore b/.gitignore
index c6fb5b6c..8add02c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,5 +5,5 @@ dist/
config.json
*.log
/.idea/
-/universal-trakt-sync.iml
+/universal-trakt-scrobbler.iml
package-lock.json
diff --git a/.lintstagedrc.js b/.lintstagedrc.js
new file mode 100644
index 00000000..f6f6ebfc
--- /dev/null
+++ b/.lintstagedrc.js
@@ -0,0 +1,7 @@
+module.exports = {
+ '*.{json,css,html,md,yml,yaml}': 'prettier --write',
+ '*.{js,jsx,ts,tsx}': (filenames) => [
+ 'tsc --noEmit -p ./tsconfig.json',
+ `eslint --fix --quiet -c ./.eslintrc.typed.js --no-eslintrc ${filenames.join(' ')}`,
+ ],
+};
diff --git a/README.md b/README.md
index 77b85fb8..562bcb9d 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,29 @@
-
+
- Universal Trakt Sync
+ Universal Trakt Scrobbler
-A universal sync for Trakt.tv.
+A universal scrobbler for Trakt.tv.
-
-
+
+
-
+
-
+
### Table of Contents
-- [What is Universal Trakt Sync?](#what-is-universal-trakt-sync)
+- [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)
@@ -31,33 +31,34 @@
- [Development](#development)
- [Credits](#credits)
-### What is Universal Trakt Sync?
+### What is Universal Trakt Scrobbler?
-An extension that allows you to sync your TV shows and movies watching history from your favorite streaming services to Trakt.tv.
+An extension that allows you to automatically scrobble TV shows and movies that you are watching, and sync your history, from your favorite streaming services to Trakt.tv.
### Why do I need this extension?
-If you want to sync from Netflix, this is the only Trakt.tv [plugin](https://trakt.tv/apps) that does it. In the future, we'll be adding support for more streaming services, so it will also serve as a single extension that works for multiple services.
+If you want to scrobble / sync from Netflix, this is the only Trakt.tv [plugin](https://trakt.tv/apps) that does it. In the future, we'll be adding support for more streaming services, so it will also serve as a single extension that works for multiple services.
### Which streaming services are supported?
+- Amazon Prime (Scrobble only)
+- HBO Go (Scrobble only - tested only for Latin America)
- Netflix
-- NRK
-- Viaplay
+- NRK (Sync only)
+- Viaplay (Sync only)
### How does the extension work?
-It extracts information about the TV shows / movies that you have watched watching by scraping the page and sends the data to Trakt using the [Trakt API](https://trakt.docs.apiary.io/).
+It extracts information about the TV shows / movies that you are watching / have watched by scraping the page or using the stremaing service API and sends the data to Trakt using the [Trakt API](https://trakt.docs.apiary.io/).
### Known Issues
-- The extension does not work well with Firefox containers, so if you use them, make sure that you are logged in to your Trakt / Netflix accounts in the no-container.
-- When you stop watching something on Netflix, try not to immediately close the tab / window. Using the "Back to Browse" button or pausing first is preferable, because then the extension is able to detect that you have stopped watching. If you simply close the tab / window, the extension will keep scrobbling the item.
-- Make sure you are logged into Streaming Services before trying to sync history content.
+- You might have to disable the "automatic mode" in the Temporary Containers extension while logging in, if you use it.
+- Make sure you are logged into streaming services before trying to sync history content.
### Other Problems
-If you find any other problems or have suggestions or questions, feel free to [open an issue](https://github.com/trakt-tools/universal-trakt-sync/issues/new).
+If you find any other problems or have suggestions or questions, feel free to [open an issue](https://github.com/trakt-tools/universal-trakt-scrobbler/issues/new).
### Development
@@ -101,22 +102,21 @@ npm run build
npm run zip
```
-####How to add more streaming services
+#### How to add more streaming services
-- Make sure that the serivce you want to add actually has a rest API.
-- Add hostname/domain to permissions in manifest in `webpack.config.js`.
-- Copy one the existing services in `src/modules/history/streaming-services/`. Netflix is the most complex one.
-- The Page- and Store-files should be fairly easy to update. Probably just the name as all logic is in the inherited classes.
-- As a TypeScript project, interfaces of rest APIs should be declared in `src/typedefs.d.ts`.
-- All APIs have different aspects and limitations, updates may be needed elsewhere in the sourcecode to handle these cases.
+- First of all, edit the file `src/streaming-services/streaming-services.ts` and add an entry for the new service with a unique ID e.g. 'Netflix' => 'netflix', 'Amazon Prime' => 'amazon-prime'. Don't forget to set the `hasScrobbler` and `hasSync` flags correctly.
+- Some services can have different aspects and limitations, and updates may be needed elsewhere in the source code to handle these cases, so the steps below are more of a guideline.
+- For a scrobbler: copy the `src/streaming-services/scrobbler-template/` folder and adjust accordingly. Remember to use **the same ID** specified in `src/streaming-services/streaming-services.ts` for the folder name and for the content script file name. That's it!
+- For a sync: copy the `src/streaming-services/sync-template/` folder and adjust accordingly. Remember to use **the same ID** specified in `src/streaming-services/streaming-services.ts` for the folder name, and don't forget to import the `*Api.ts` file in `src/streaming-services/pages.ts`, otherwise the service won't load at all. That's it!
+- You can see the folders of the other services for some reference. The templates are just the basic to get you started.
### Credits
This extension is based on [traktflix](https://github.com/tegon/traktflix), the original Netflix sync developed by [tegon](https://github.com/user/tegon), which was discontinued in favor of Universal Trakt Sync.
-
-
+
+
This product uses the TMDb API, but is not endorsed or certified by TMDb.
diff --git a/package.json b/package.json
index 794cce30..67e48b3e 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,12 @@
{
- "name": "universal-trakt-sync",
+ "name": "universal-trakt-scrobbler",
"version": "1.0.0",
"description": "A universal sync for Trakt.tv.",
"author": "trakt-tools",
"private": true,
"repository": {
"type": "git",
- "url": "git://github.com/trakt-tools/universal-trakt-sync.git"
+ "url": "git://github.com/trakt-tools/universal-trakt-scrobbler.git"
},
"scripts": {
"build": "webpack --env.production",
@@ -100,12 +100,5 @@
"hooks": {
"pre-commit": "lint-staged"
}
- },
- "lint-staged": {
- "*.{json,css,html,md,yml,yaml}": "prettier --write",
- "*.{js,jsx,ts,tsx}": [
- "tsc --allowJs --checkJs --jsx 'react' --module 'CommonJS' --target 'ES2020' --strict --typeRoots ./node_modules/@types,./node_modules/web-ext-types --resolveJsonModule --skipLibCheck --noEmit ./src/global.d.ts",
- "eslint --fix --quiet -c ./.eslintrc.typed.js --no-eslintrc"
- ]
}
}
diff --git a/scripts/generateRelease.js b/scripts/generateRelease.js
index a16951b9..b0613462 100644
--- a/scripts/generateRelease.js
+++ b/scripts/generateRelease.js
@@ -7,7 +7,7 @@ const packageJson = require(path.resolve(__dirname, '../package.json'));
const args = getArguments(process);
const octokit = new Octokit({
auth: args.token,
- userAgent: 'universal-trakt-sync',
+ userAgent: 'universal-trakt-scrobbler',
});
const defaultParams = {
owner: packageJson.author,
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 429d0773..f0811024 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -1,6 +1,6 @@
{
"appName": {
- "message": "Universal Trakt Sync"
+ "message": "Universal Trakt Scrobbler"
},
"appShortName": {
"message": "UTS"
@@ -15,7 +15,7 @@
"message": "Add with release date."
},
"aboutMessage": {
- "message": "Sync the history from your favorite streaming services to Trakt.tv!"
+ "message": "Send your activity from your favorite streaming services to Trakt.tv!"
},
"allowRollbarDescription": {
"message": "With this option enabled, whenever a bug occurs, information about it will be collected and sent to us, so that we can fix it faster and improve the extension. This information includes: when the bug happened, the traceback of the bug, the name and version of your browser, and the name of your OS. This data is not shared with any other service."
@@ -66,6 +66,15 @@
"correctWrongItemFailed": {
"message": "Failed to correct wrong item."
},
+ "couldNotScrobble": {
+ "message": "Could not scrobble."
+ },
+ "disableScrobblingDescription": {
+ "message": "With this option enabled, the extension will not automatically scrobble what you are watching. This is useful if you only want to use the history sync."
+ },
+ "disableScrobblingName": {
+ "message": "Disable scrobbling."
+ },
"errorNotification": {
"message": "An error has occurred. :("
},
@@ -90,6 +99,9 @@
"hideSyncedName": {
"message": "Hide synced."
},
+ "history": {
+ "message": "History"
+ },
"historySyncSuccess": {
"message": "$EPISODES$ episodes and $MOVIES$ movies synced.",
"placeholders": {
@@ -146,6 +158,12 @@
"notWatched": {
"message": "Not Watched"
},
+ "notWatching": {
+ "message": "Watch something!"
+ },
+ "nowScrobbling": {
+ "message": "Now Scrobbling"
+ },
"on": {
"message": "On"
},
@@ -172,6 +190,15 @@
"saveOptionSuccess": {
"message": "Option saved."
},
+ "scrobblePaused": {
+ "message": "Scrobble paused."
+ },
+ "scrobbleStarted": {
+ "message": "Scrobble started."
+ },
+ "scrobbleStopped": {
+ "message": "Scrobble stopped."
+ },
"select": {
"message": "Select:"
},
@@ -190,6 +217,12 @@
"sendReceiveSuggestionsName": {
"message": "Send / receive correction suggestions."
},
+ "showNotificationsDescription": {
+ "message": "With this option enabled, you will receive browser notifications whenever you start / stop scrobbling something."
+ },
+ "showNotificationsName": {
+ "message": "Show browser notifications."
+ },
"streamingServicesDescription": {
"message": "Choose which streaming services to enable."
},
diff --git a/src/_locales/nb/messages.json b/src/_locales/nb/messages.json
index e7a0ff4b..e3453512 100644
--- a/src/_locales/nb/messages.json
+++ b/src/_locales/nb/messages.json
@@ -1,6 +1,6 @@
{
"appName": {
- "message": "Universal Trakt Sync"
+ "message": "Universal Trakt Scrobbler"
},
"appShortName": {
"message": "UTS"
@@ -15,7 +15,7 @@
"message": "Legg til med utgivelsesdato."
},
"aboutMessage": {
- "message": "Synkroniser historikken fra dine favorittstrømmetjenester til Trakt.tv!"
+ "message": "Send aktiviteten fra dine favorittstrømmetjenester til Trakt.tv!"
},
"allowRollbarDescription": {
"message": "Dersom det oppstår en feil, kan utviklerne få informasjon om det, slik at det er enklere å feilsøke, fikse og forbedre utvidelsen. Informasjonen som sendes inkluderer: Når feilen inntraff, omstendinghetene rundt feilen, navn og versjonsnummeret på nettleseren, og navnet på operativsystemet. Dataene blir ikke delt videre med andre tjenester."
@@ -66,6 +66,15 @@
"confirmClearTraktCacheTitle": {
"message": "Fjerne Trakt cache?"
},
+ "couldNotScrobble": {
+ "message": "Scrobbling feilet."
+ },
+ "disableScrobblingDescription": {
+ "message": "Dersom dette valget er aktivert vil utvidelsen ikke automatisk scrobble det du ser på. Dette er nyttig om du bare ønsker å bruke sync av historikk."
+ },
+ "disableScrobblingName": {
+ "message": "Deaktiver scrobbling."
+ },
"errorNotification": {
"message": "Noe har gått galt. :("
},
@@ -87,6 +96,9 @@
"grantCookiesName": {
"message": "Tillat \"cookies\" slik at utvidelsen kan virke sammen med Firefox Containers."
},
+ "history": {
+ "message": "Historikk"
+ },
"hideSyncedName": {
"message": "Skjul allerede synkroniserte elementer."
},
@@ -146,6 +158,12 @@
"notWatched": {
"message": "Ikke registrert som sett"
},
+ "notWatching": {
+ "message": "Se på noe!"
+ },
+ "nowScrobbling": {
+ "message": "Scrobbler nå"
+ },
"on": {
"message": "På"
},
@@ -172,6 +190,15 @@
"saveOptionSuccess": {
"message": "Alternativ lagret."
},
+ "scrobblePaused": {
+ "message": "Scrobbling pauset."
+ },
+ "scrobbleStarted": {
+ "message": "Scrobbling startet."
+ },
+ "scrobbleStopped": {
+ "message": "Scrobbling stoppet."
+ },
"select": {
"message": "Velg:"
},
@@ -190,6 +217,12 @@
"sendReceiveSuggestionsName": {
"message": "Send / motta forslag til rettelser."
},
+ "showNotificationsDescription": {
+ "message": "Viser nettleservarsler hver gang du starter / stopper å scrobble noe."
+ },
+ "showNotificationsName": {
+ "message": "Vis nettleservarsler"
+ },
"streamingServicesDescription": {
"message": "Velg strømmetjenester som du vil ha aktive."
},
diff --git a/src/_locales/pt_BR/messages.json b/src/_locales/pt_BR/messages.json
index 76d6cc74..f618c936 100644
--- a/src/_locales/pt_BR/messages.json
+++ b/src/_locales/pt_BR/messages.json
@@ -1,6 +1,6 @@
{
"appName": {
- "message": "Universal Trakt Sync"
+ "message": "Universal Trakt Scrobbler"
},
"appShortName": {
"message": "UTS"
@@ -15,7 +15,7 @@
"message": "Adicionar com data de lançamento."
},
"aboutMessage": {
- "message": "Sincronize o histórico dos seus serviços de streaming favoritos para o Trakt.tv!"
+ "message": "Envie sua atividade nos seus serviços de streaming favoritos para o Trakt.tv!"
},
"allowRollbarDescription": {
"message": "Com essa opção ativada, sempre que um erro acontecer, informações sobre ele serão coletadas e enviadas para nós, para que possamos consertá-lo mais rápido e melhorar a extensão. Essas informações incluem: quando o erro aconteceu, o traço do erro, o nome e a versão do neu navegador, e o nome do seu sistema operacional. Esses dados não são compartilhados com nenhum outro serviço."
@@ -66,6 +66,15 @@
"correctWrongItemFailed": {
"message": "Ocorreu um erro ao corrigir o item errado."
},
+ "couldNotScrobble": {
+ "message": "Não foi possível realizar o scrobble."
+ },
+ "disableScrobblingDescription": {
+ "message": "With this option enabled, the extension will not automatically scrobble what you are watching. This is useful if you only want to use the history sync."
+ },
+ "disableScrobblingName": {
+ "message": "Disable scrobbling."
+ },
"errorNotification": {
"message": "Ocorreu um erro. :("
},
@@ -90,6 +99,9 @@
"hideSyncedName": {
"message": "Esconder itens sincronizados."
},
+ "history": {
+ "message": "Histórico"
+ },
"historySyncSuccess": {
"message": "$EPISODES$ episódios e $MOVIES$ filmes sincronizados.",
"placeholders": {
@@ -146,6 +158,12 @@
"notWatched": {
"message": "Não Assistido"
},
+ "notWatching": {
+ "message": "Assista algo!"
+ },
+ "nowScrobbling": {
+ "message": "Fazendo Scrobbling"
+ },
"on": {
"message": "No"
},
@@ -172,6 +190,15 @@
"saveOptionSuccess": {
"message": "Opção salva."
},
+ "scrobblePaused": {
+ "message": "O scrobble foi pausado."
+ },
+ "scrobbleStarted": {
+ "message": "O scrobble começou."
+ },
+ "scrobbleStopped": {
+ "message": "O scrobble parou."
+ },
"select": {
"message": "Selecionar:"
},
@@ -190,6 +217,12 @@
"sendReceiveSuggestionsName": {
"message": "Enviar / receber sugestões de correção."
},
+ "showNotificationsDescription": {
+ "message": "Com essa opção ativada, você receberá notificações no seu navegador sempre que um scrobble começar/parar ."
+ },
+ "showNotificationsName": {
+ "message": "Mostrar notificações no navegador."
+ },
"streamingServicesDescription": {
"message": "Escolha quais serviços de streaming deseja ativar."
},
diff --git a/src/api/TraktApi.ts b/src/api/TraktApi.ts
index f525b8bd..3c4175b4 100644
--- a/src/api/TraktApi.ts
+++ b/src/api/TraktApi.ts
@@ -8,6 +8,7 @@ export class TraktApi {
REVOKE_TOKEN_URL: string;
SEARCH_URL: string;
SHOWS_URL: string;
+ SCROBBLE_URL: string;
SYNC_URL: string;
SETTINGS_URL: string;
@@ -21,6 +22,7 @@ export class TraktApi {
this.REVOKE_TOKEN_URL = `${this.API_URL}/oauth/revoke`;
this.SEARCH_URL = `${this.API_URL}/search`;
this.SHOWS_URL = `${this.API_URL}/shows`;
+ this.SCROBBLE_URL = `${this.API_URL}/scrobble`;
this.SYNC_URL = `${this.API_URL}/sync/history`;
this.SETTINGS_URL = `${this.API_URL}/users/settings`;
}
diff --git a/src/api/TraktAuth.ts b/src/api/TraktAuth.ts
index b6a200bc..193e0029 100644
--- a/src/api/TraktAuth.ts
+++ b/src/api/TraktAuth.ts
@@ -1,8 +1,8 @@
import { secrets } from '../secrets';
-import { BrowserStorage } from '../services/BrowserStorage';
-import { Requests } from '../services/Requests';
-import { Shared } from '../services/Shared';
-import { Tabs } from '../services/Tabs';
+import { BrowserStorage } from '../common/BrowserStorage';
+import { Requests } from '../common/Requests';
+import { Shared } from '../common/Shared';
+import { Tabs } from '../common/Tabs';
import { TraktApi } from './TraktApi';
export type TraktManualAuth = {
diff --git a/src/api/TraktScrobble.ts b/src/api/TraktScrobble.ts
new file mode 100644
index 00000000..9b633266
--- /dev/null
+++ b/src/api/TraktScrobble.ts
@@ -0,0 +1,95 @@
+import { TraktItem } from '../models/TraktItem';
+import { EventDispatcher } from '../common/Events';
+import { Messaging } from '../common/Messaging';
+import { RequestException, Requests } from '../common/Requests';
+import { Shared } from '../common/Shared';
+import { TraktApi } from './TraktApi';
+
+export interface TraktScrobbleData {
+ movie?: {
+ ids: {
+ trakt: number;
+ };
+ };
+ episode?: {
+ ids: {
+ trakt: number;
+ };
+ };
+ progress: number;
+}
+
+class _TraktScrobble extends TraktApi {
+ START: number;
+ PAUSE: number;
+ STOP: number;
+
+ paths: Record;
+
+ constructor() {
+ super();
+
+ this.START = 1;
+ this.PAUSE = 2;
+ this.STOP = 3;
+
+ this.paths = {
+ [this.START]: '/start',
+ [this.PAUSE]: '/pause',
+ [this.STOP]: '/stop',
+ };
+ }
+
+ start = async (item: TraktItem): Promise => {
+ if (!Shared.isBackgroundPage) {
+ await Messaging.toBackground({ action: 'start-scrobble' });
+ }
+ await this.send(item, this.START);
+ };
+
+ pause = async (item: TraktItem): Promise => {
+ await this.send(item, this.PAUSE);
+ };
+
+ stop = async (item: TraktItem): Promise => {
+ await this.send(item, this.STOP);
+ if (!Shared.isBackgroundPage) {
+ await Messaging.toBackground({ action: 'stop-scrobble' });
+ }
+ };
+
+ send = async (item: TraktItem, scrobbleType: number): Promise => {
+ const path = this.paths[scrobbleType];
+ try {
+ const data = {} as TraktScrobbleData;
+ if (item.type === 'show') {
+ data.episode = {
+ ids: {
+ trakt: item.id,
+ },
+ };
+ } else {
+ data.movie = {
+ ids: {
+ trakt: item.id,
+ },
+ };
+ }
+ data.progress = item.progress;
+ await Requests.send({
+ url: `${this.SCROBBLE_URL}${path}`,
+ method: 'POST',
+ body: data,
+ });
+ await EventDispatcher.dispatch('SCROBBLE_SUCCESS', null, { item, scrobbleType });
+ } catch (err) {
+ await EventDispatcher.dispatch('SCROBBLE_ERROR', null, {
+ item,
+ scrobbleType,
+ error: err as RequestException,
+ });
+ }
+ };
+}
+
+export const TraktScrobble = new _TraktScrobble();
diff --git a/src/api/TraktSearch.ts b/src/api/TraktSearch.ts
index 4ce47f33..458437d1 100644
--- a/src/api/TraktSearch.ts
+++ b/src/api/TraktSearch.ts
@@ -1,7 +1,7 @@
import { Item } from '../models/Item';
-import { SyncItem } from '../models/SyncItem';
-import { EventDispatcher, Events } from '../services/Events';
-import { Requests } from '../services/Requests';
+import { TraktItem } from '../models/TraktItem';
+import { EventDispatcher } from '../common/Events';
+import { Requests } from '../common/Requests';
import { TraktApi } from './TraktApi';
export type TraktSearchItem = TraktSearchShowItem | TraktSearchMovieItem;
@@ -18,6 +18,7 @@ export interface TraktEpisodeItemEpisode {
title: string;
ids: {
trakt: number;
+ tmdb: number;
};
}
@@ -30,6 +31,7 @@ export interface TraktSearchShowItemShow {
year: number;
ids: {
trakt: number;
+ tmdb: number;
};
}
@@ -42,6 +44,7 @@ export interface TraktSearchMovieItemMovie {
year: number;
ids: {
trakt: number;
+ tmdb: number;
};
}
@@ -50,8 +53,8 @@ class _TraktSearch extends TraktApi {
super();
}
- find = async (item: Item, url?: string): Promise => {
- let syncItem: SyncItem | undefined;
+ find = async (item: Item, url?: string): Promise => {
+ let traktItem: TraktItem | undefined;
try {
let searchItem: TraktSearchEpisodeItem | TraktSearchMovieItem;
if (url) {
@@ -63,13 +66,15 @@ class _TraktSearch extends TraktApi {
}
if ('episode' in searchItem) {
const id = searchItem.episode.ids.trakt;
+ const tmdbId = searchItem.show.ids.tmdb;
const title = searchItem.show.title;
const year = searchItem.show.year;
const season = searchItem.episode.season;
const episode = searchItem.episode.number;
const episodeTitle = searchItem.episode.title;
- syncItem = new SyncItem({
+ traktItem = new TraktItem({
id,
+ tmdbId,
type: 'show',
title,
year,
@@ -79,15 +84,22 @@ class _TraktSearch extends TraktApi {
});
} else {
const id = searchItem.movie.ids.trakt;
+ const tmdbId = searchItem.movie.ids.tmdb;
const title = searchItem.movie.title;
const year = searchItem.movie.year;
- syncItem = new SyncItem({ id, type: 'movie', title, year });
+ traktItem = new TraktItem({
+ id,
+ tmdbId,
+ type: 'movie',
+ title,
+ year,
+ });
}
- await EventDispatcher.dispatch(Events.SEARCH_SUCCESS, null, { searchItem });
+ await EventDispatcher.dispatch('SEARCH_SUCCESS', null, { searchItem });
} catch (err) {
- await EventDispatcher.dispatch(Events.SEARCH_ERROR, null, { error: err as Error });
+ await EventDispatcher.dispatch('SEARCH_ERROR', null, { error: err as Error });
}
- return syncItem;
+ return traktItem;
};
findItemFromUrl = async (url: string): Promise => {
diff --git a/src/api/TraktSettings.ts b/src/api/TraktSettings.ts
index a9cd76ff..3dbf7b25 100644
--- a/src/api/TraktSettings.ts
+++ b/src/api/TraktSettings.ts
@@ -1,4 +1,4 @@
-import { Requests } from '../services/Requests';
+import { Requests } from '../common/Requests';
import { TraktApi } from './TraktApi';
export interface TraktSettingsResponse {
diff --git a/src/api/TraktSync.ts b/src/api/TraktSync.ts
index 32711dbd..1555f83c 100644
--- a/src/api/TraktSync.ts
+++ b/src/api/TraktSync.ts
@@ -1,9 +1,8 @@
import * as moment from 'moment';
import { Item } from '../models/Item';
-import { ISyncItem } from '../models/SyncItem';
-import { Errors } from '../services/Errors';
-import { EventDispatcher, Events } from '../services/Events';
-import { Requests } from '../services/Requests';
+import { Errors } from '../common/Errors';
+import { EventDispatcher } from '../common/Events';
+import { Requests } from '../common/Requests';
import { TraktApi } from './TraktApi';
export interface TraktHistoryItem {
@@ -33,6 +32,9 @@ class _TraktSync extends TraktApi {
}
loadHistory = async (item: Item): Promise => {
+ if (!item.trakt) {
+ return;
+ }
const responseText = await Requests.send({
url: this.getUrl(item),
method: 'GET',
@@ -41,11 +43,11 @@ class _TraktSync extends TraktApi {
const historyItem = historyItems.find(
(x) => moment(x.watched_at).diff(item.watchedAt, 'days') === 0
);
- (item.trakt as ISyncItem).watchedAt = historyItem && moment(historyItem.watched_at);
+ item.trakt.watchedAt = historyItem && moment(historyItem.watched_at);
};
getUrl = (item: Item): string => {
- if (!item.trakt || !('id' in item.trakt)) {
+ if (!item.trakt) {
return '';
}
let url = '';
@@ -63,13 +65,13 @@ class _TraktSync extends TraktApi {
episodes: items
.filter((item) => item.isSelected && item.type === 'show')
.map((item) => ({
- ids: { trakt: (item.trakt as ISyncItem).id },
+ ids: { trakt: item.trakt?.id },
watched_at: addWithReleaseDate ? 'released' : item.watchedAt,
})),
movies: items
.filter((item) => item.isSelected && item.type === 'movie')
.map((item) => ({
- ids: { trakt: (item.trakt as ISyncItem).id },
+ ids: { trakt: item.trakt?.id },
watched_at: addWithReleaseDate ? 'released' : item.watchedAt,
})),
};
@@ -84,26 +86,20 @@ class _TraktSync extends TraktApi {
movies: responseJson.not_found.movies.map((item) => item.ids.trakt),
};
for (const item of items) {
- if (item.isSelected) {
- if (
- item.type === 'show' &&
- !notFoundItems.episodes.includes((item.trakt as ISyncItem).id)
- ) {
- (item.trakt as ISyncItem).watchedAt = item.watchedAt;
- } else if (
- item.type === 'movie' &&
- !notFoundItems.movies.includes((item.trakt as ISyncItem).id)
- ) {
- (item.trakt as ISyncItem).watchedAt = item.watchedAt;
+ if (item.isSelected && item.trakt) {
+ if (item.type === 'show' && !notFoundItems.episodes.includes(item.trakt.id)) {
+ item.trakt.watchedAt = item.watchedAt;
+ } else if (item.type === 'movie' && !notFoundItems.movies.includes(item.trakt.id)) {
+ item.trakt.watchedAt = item.watchedAt;
}
}
}
- await EventDispatcher.dispatch(Events.HISTORY_SYNC_SUCCESS, null, {
+ await EventDispatcher.dispatch('HISTORY_SYNC_SUCCESS', null, {
added: responseJson.added,
});
} catch (err) {
Errors.error('Failed to sync history.', err);
- await EventDispatcher.dispatch(Events.HISTORY_SYNC_ERROR, null, { error: err as Error });
+ await EventDispatcher.dispatch('HISTORY_SYNC_ERROR', null, { error: err as Error });
}
};
}
diff --git a/src/assets/assets.ts b/src/assets/assets.ts
index a5ed8a61..c8b693c7 100644
--- a/src/assets/assets.ts
+++ b/src/assets/assets.ts
@@ -9,3 +9,4 @@ import './images/uts-icon-selected-38.png';
import './styles/history.css';
import './styles/layout.css';
import './styles/options.css';
+import './styles/popup.css';
diff --git a/src/assets/styles/history.css b/src/assets/styles/history.css
index 89d8d328..3c0d4930 100644
--- a/src/assets/styles/history.css
+++ b/src/assets/styles/history.css
@@ -30,6 +30,14 @@
color: #fff;
}
+.history-info {
+ text-align: center;
+}
+
+.history-info > * {
+ margin: 8px 0;
+}
+
.history-list-item {
display: flex;
justify-content: end;
diff --git a/src/assets/styles/options.css b/src/assets/styles/options.css
index 4ce60096..3f824ab2 100644
--- a/src/assets/styles/options.css
+++ b/src/assets/styles/options.css
@@ -21,10 +21,35 @@
}
.options--streaming-service {
+ align-items: center;
display: grid;
grid-gap: 0 50px;
- grid-template-columns: repeat(5, 150px);
- justify-content: center;
+ grid-template-columns: 150px;
+ justify-content: space-around;
+}
+
+@media only screen and (min-width: 400px) {
+ .options--streaming-service {
+ grid-template-columns: repeat(2, 150px);
+ }
+}
+
+@media only screen and (min-width: 600px) {
+ .options--streaming-service {
+ grid-template-columns: repeat(3, 150px);
+ }
+}
+
+@media only screen and (min-width: 800px) {
+ .options--streaming-service {
+ grid-template-columns: repeat(4, 150px);
+ }
+}
+
+@media only screen and (min-width: 1000px) {
+ .options--streaming-service {
+ grid-template-columns: repeat(5, 150px);
+ }
}
.options-list-item {
diff --git a/src/assets/styles/popup.css b/src/assets/styles/popup.css
new file mode 100644
index 00000000..fa67d078
--- /dev/null
+++ b/src/assets/styles/popup.css
@@ -0,0 +1,74 @@
+.popup-container {
+ height: 200px;
+ padding: 8px;
+ width: 400px;
+}
+
+.popup-container--content {
+ height: 100%;
+ position: relative;
+}
+
+.popup-container--content > * {
+ height: 100%;
+}
+
+.popup-container--overlay-color {
+ background-color: rgba(0, 0, 0, 0.75);
+ bottom: 0;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+}
+
+.popup-container--overlay-image {
+ background-image: url('../images/background.jpg');
+ background-size: cover !important;
+ bottom: 0;
+ filter: blur(2px) grayscale(0.5);
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+}
+
+.popup-header {
+ color: #fff;
+}
+
+.popup-info {
+ color: #fff;
+ text-align: center;
+}
+
+.popup-info > * {
+ margin: 8px 0;
+}
+
+.popup-watching--content {
+ height: 100%;
+ position: relative;
+}
+
+.popup-watching--content > * {
+ height: 100%;
+}
+
+.popup-watching--overlay-color {
+ background-color: rgba(0, 0, 0, 0.5);
+ bottom: 0;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+}
+
+.popup-watching--overlay-image {
+ background-size: cover !important;
+ bottom: 0;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+}
diff --git a/src/common/BrowserAction.ts b/src/common/BrowserAction.ts
new file mode 100644
index 00000000..3b8979eb
--- /dev/null
+++ b/src/common/BrowserAction.ts
@@ -0,0 +1,32 @@
+import { EventDispatcher } from './Events';
+import { Messaging } from './Messaging';
+import { Shared } from './Shared';
+
+class _BrowserAction {
+ startListeners = () => {
+ EventDispatcher.subscribe('SCROBBLE_ACTIVE', null, this.setActiveIcon);
+ EventDispatcher.subscribe('SCROBBLE_INACTIVE', null, this.setInactiveIcon);
+ };
+
+ setActiveIcon = async (): Promise => {
+ if (Shared.isBackgroundPage) {
+ await browser.browserAction.setIcon({
+ path: browser.runtime.getURL('images/uts-icon-selected-38.png'),
+ });
+ } else {
+ await Messaging.toBackground({ action: 'set-active-icon' });
+ }
+ };
+
+ setInactiveIcon = async (): Promise => {
+ if (Shared.isBackgroundPage) {
+ await browser.browserAction.setIcon({
+ path: browser.runtime.getURL('images/uts-icon-38.png'),
+ });
+ } else {
+ await Messaging.toBackground({ action: 'set-inactive-icon' });
+ }
+ };
+}
+
+export const BrowserAction = new _BrowserAction();
diff --git a/src/services/BrowserStorage.ts b/src/common/BrowserStorage.ts
similarity index 77%
rename from src/services/BrowserStorage.ts
rename to src/common/BrowserStorage.ts
index 19590e64..353fc10f 100644
--- a/src/services/BrowserStorage.ts
+++ b/src/common/BrowserStorage.ts
@@ -1,18 +1,22 @@
import { TraktAuthDetails } from '../api/TraktAuth';
-import { ISyncItem } from '../models/SyncItem';
-import { StreamingServiceId, streamingServices } from '../streaming-services';
+import { TraktItemBase } from '../models/TraktItem';
+import { StreamingServiceId, streamingServices } from '../streaming-services/streaming-services';
import { Shared } from './Shared';
export type StorageValues = {
auth?: TraktAuthDetails;
options?: StorageValuesOptions;
syncOptions?: StorageValuesSyncOptions;
- traktCache?: Record>;
+ traktCache?: Record>;
correctUrls?: Record>;
+ scrobblingItem?: Omit;
+ scrobblingTabId?: number;
};
export type StorageValuesOptions = {
streamingServices: Record;
+ disableScrobbling: boolean;
+ showNotifications: boolean;
allowRollbar: boolean;
sendReceiveSuggestions: boolean;
grantCookies: boolean;
@@ -71,11 +75,14 @@ class _BrowserStorage {
await browser.storage.local.set(values);
};
- get = (keys?: string | string[]): Promise => {
+ get = (keys?: keyof StorageValues | (keyof StorageValues)[] | null): Promise => {
return browser.storage.local.get(keys);
};
- remove = async (keys: string | string[], doSync = false): Promise => {
+ remove = async (
+ keys: keyof StorageValues | (keyof StorageValues)[],
+ doSync = false
+ ): Promise => {
if (doSync && this.isSyncAvailable) {
await browser.storage.sync.remove(keys);
}
@@ -89,7 +96,9 @@ class _BrowserStorage {
await browser.storage.local.clear();
};
- getSize = async (keys?: string | string[]): Promise => {
+ getSize = async (
+ keys?: keyof StorageValues | (keyof StorageValues)[] | null
+ ): Promise => {
let size = '';
const values = await this.get(keys);
let bytes = (JSON.stringify(values) || '').length;
@@ -120,6 +129,24 @@ class _BrowserStorage {
permissions: [],
doShow: true,
},
+ disableScrobbling: {
+ id: 'disableScrobbling',
+ name: '',
+ description: '',
+ value: false,
+ origins: [],
+ permissions: [],
+ doShow: true,
+ },
+ showNotifications: {
+ id: 'showNotifications',
+ name: '',
+ description: '',
+ value: false,
+ origins: [],
+ permissions: ['notifications'],
+ doShow: true,
+ },
sendReceiveSuggestions: {
id: 'sendReceiveSuggestions',
name: '',
@@ -153,6 +180,14 @@ class _BrowserStorage {
option.name = browser.i18n.getMessage(`${option.id}Name`);
option.description = browser.i18n.getMessage(`${option.id}Description`);
option.value = (values.options && values.options[option.id]) || option.value;
+ if (option.id === 'streamingServices') {
+ const missingServices = Object.fromEntries(
+ Object.keys(streamingServices)
+ .filter((serviceId) => !(serviceId in option.value))
+ .map((serviceId) => [serviceId, false])
+ ) as Record;
+ option.value = { ...option.value, ...missingServices };
+ }
}
return options;
};
diff --git a/src/services/Errors.ts b/src/common/Errors.ts
similarity index 62%
rename from src/services/Errors.ts
rename to src/common/Errors.ts
index 281d4536..283c76ea 100644
--- a/src/services/Errors.ts
+++ b/src/common/Errors.ts
@@ -2,17 +2,9 @@ import * as React from 'react';
import * as Rollbar from 'rollbar';
import { secrets } from '../secrets';
import { BrowserStorage } from './BrowserStorage';
-import { EventDispatcher, Events } from './Events';
+import { ErrorData, EventDispatcher, ScrobbleErrorData } from './Events';
import { RequestException } from './Requests';
-export type ErrorEventData = {
- error: ErrorDetails | RequestException;
-};
-
-export type ErrorDetails = {
- message?: string;
-};
-
class _Errors {
rollbar?: Rollbar;
@@ -33,35 +25,40 @@ class _Errors {
};
startListeners = (): void => {
- EventDispatcher.subscribe(Events.SEARCH_ERROR, null, this.onSearchError);
+ EventDispatcher.subscribe('SCROBBLE_ERROR', null, (data: ScrobbleErrorData) =>
+ this.onItemError(data, 'scrobble')
+ );
+ EventDispatcher.subscribe('SEARCH_ERROR', null, (data: ErrorData) =>
+ this.onItemError(data, 'find')
+ );
};
- onSearchError = async (data: ErrorEventData): Promise => {
+ onItemError = async (
+ data: ScrobbleErrorData | ErrorData,
+ type: 'scrobble' | 'find'
+ ): Promise => {
if (data.error) {
const values = await BrowserStorage.get('auth');
if (values.auth && values.auth.access_token) {
- this.error('Failed to find item.', data.error);
+ this.error(`Failed to ${type} item.`, data.error);
} else {
- this.warning('Failed to find item.', data.error);
+ this.warning(`Failed to ${type} item.`, data.error);
}
}
};
- log = (
- message: Error | string,
- details: ErrorDetails | RequestException | React.ErrorInfo
- ): void => {
+ log = (message: Error | string, details: Error | RequestException | React.ErrorInfo): void => {
console.log(`[UTS] ${message.toString()}`, details);
};
- warning = (message: string, details: ErrorDetails | RequestException): void => {
+ warning = (message: string, details: Error | RequestException): void => {
console.warn(`[UTS] ${message}`, details);
if (this.rollbar) {
this.rollbar.warning(message, 'message' in details ? { message: details.message } : details);
}
};
- error = (message: string, details: ErrorDetails | RequestException): void => {
+ error = (message: string, details: Error | RequestException): void => {
console.error(`[UTS] ${message}`, details);
if (this.rollbar) {
this.rollbar.error(message, 'message' in details ? { message: details.message } : details);
diff --git a/src/common/Events.ts b/src/common/Events.ts
new file mode 100644
index 00000000..8fee282e
--- /dev/null
+++ b/src/common/Events.ts
@@ -0,0 +1,206 @@
+import { Item } from '../models/Item';
+import { TraktItem } from '../models/TraktItem';
+import { StoreData } from '../streaming-services/common/SyncStore';
+import { StreamingServiceId } from '../streaming-services/streaming-services';
+import { StorageValuesOptions, StorageValuesSyncOptions } from './BrowserStorage';
+import { Errors } from './Errors';
+import { RequestException } from './Requests';
+import { TraktSearchItem } from '../api/TraktSearch';
+import { Color } from '@material-ui/lab';
+
+export interface EventData {
+ LOGIN_SUCCESS: LoginSuccessData;
+ LOGIN_ERROR: ErrorData;
+ LOGOUT_SUCCESS: SuccessData;
+ LOGOUT_ERROR: ErrorData;
+ SCROBBLE_SUCCESS: ScrobbleSuccessData;
+ SCROBBLE_ERROR: ScrobbleErrorData;
+ SCROBBLE_ACTIVE: SuccessData;
+ SCROBBLE_INACTIVE: SuccessData;
+ SCROBBLE_START: SuccessData;
+ SCROBBLE_PAUSE: SuccessData;
+ SCROBBLE_STOP: SuccessData;
+ SCROBBLE_PROGRESS: ScrobbleProgressData;
+ SEARCH_SUCCESS: SearchSuccessData;
+ SEARCH_ERROR: ErrorData;
+ OPTIONS_CHANGE: OptionsChangeData;
+ STREAMING_SERVICE_OPTIONS_CHANGE: StreamingServiceOptionsChangeData;
+ OPTIONS_CLEAR: SuccessData;
+ DIALOG_SHOW: DialogShowData;
+ SNACKBAR_SHOW: SnackbarShowData;
+ WRONG_ITEM_DIALOG_SHOW: WrongItemDialogShowData;
+ WRONG_ITEM_CORRECTED: WrongItemCorrectedData;
+ HISTORY_OPTIONS_CHANGE: HistoryOptionsChangeData;
+ STREAMING_SERVICE_STORE_UPDATE: StreamingServiceStoreUpdateData;
+ STREAMING_SERVICE_HISTORY_LOAD_ERROR: ErrorData;
+ STREAMING_SERVICE_HISTORY_CHANGE: StreamingServiceHistoryChangeData;
+ TRAKT_HISTORY_LOAD_ERROR: ErrorData;
+ HISTORY_SYNC_SUCCESS: HistorySyncSuccessData;
+ HISTORY_SYNC_ERROR: ErrorData;
+}
+
+export type Event = keyof EventData;
+
+export type SuccessData = Record;
+
+export interface ErrorData {
+ error: Error;
+}
+
+export interface LoginSuccessData {
+ auth: Record;
+}
+
+export interface ScrobbleSuccessData {
+ item?: TraktItem;
+ scrobbleType: number;
+}
+
+export type ScrobbleErrorData = ScrobbleSuccessData & {
+ error: RequestException;
+};
+
+export interface ScrobbleProgressData {
+ progress: number;
+}
+
+export interface SearchSuccessData {
+ searchItem: TraktSearchItem;
+}
+
+export interface OptionsChangeData {
+ id: K;
+ value: StorageValuesOptions[K];
+}
+
+export type StreamingServiceOptionsChangeData = {
+ id: K;
+ value: boolean;
+}[];
+
+export interface DialogShowData {
+ title: string;
+ message: string;
+ onConfirm?: () => void;
+ onDeny?: () => void;
+}
+
+export interface SnackbarShowData {
+ messageName: string;
+ messageArgs?: string[];
+ severity: Color;
+}
+
+export interface WrongItemDialogShowData {
+ serviceId?: StreamingServiceId;
+ item?: Item;
+}
+
+export interface WrongItemCorrectedData {
+ item: Item;
+ url: string;
+}
+
+export interface HistoryOptionsChangeData {
+ id: keyof StorageValuesSyncOptions;
+ value: boolean | number;
+}
+
+export interface StreamingServiceStoreUpdateData {
+ data: StoreData;
+}
+
+export interface StreamingServiceHistoryChangeData {
+ index?: number;
+ checked: boolean;
+}
+
+export interface HistorySyncSuccessData {
+ added: {
+ episodes: number;
+ movies: number;
+ };
+}
+
+export type EventDispatcherListeners = Record<
+ string,
+ Record[]>
+>;
+
+export type EventDispatcherListener = (data: EventData[K]) => void | Promise;
+
+class _EventDispatcher {
+ globalSpecifier = 'all';
+ listeners: EventDispatcherListeners;
+
+ constructor() {
+ this.listeners = {};
+ }
+
+ subscribe = (
+ eventType: K,
+ eventSpecifier: string | null,
+ listener: EventDispatcherListener
+ ): void => {
+ if (!this.listeners[eventType]) {
+ this.listeners[eventType] = {};
+ }
+ if (!this.listeners[eventType][this.globalSpecifier]) {
+ this.listeners[eventType][this.globalSpecifier] = [];
+ }
+ this.listeners[eventType][this.globalSpecifier].push(listener);
+ if (!eventSpecifier || eventSpecifier === this.globalSpecifier) {
+ return;
+ }
+ if (!this.listeners[eventType][eventSpecifier]) {
+ this.listeners[eventType][eventSpecifier] = [];
+ }
+ this.listeners[eventType][eventSpecifier].push(listener);
+ };
+
+ unsubscribe = (
+ eventType: K,
+ eventSpecifier: string | null,
+ listener: EventDispatcherListener
+ ): void => {
+ if (!this.listeners[eventType]) {
+ return;
+ }
+ if (this.listeners[eventType][this.globalSpecifier]) {
+ this.listeners[eventType][this.globalSpecifier] = this.listeners[eventType][
+ this.globalSpecifier
+ ].filter((fn) => fn !== listener);
+ }
+ if (
+ eventSpecifier &&
+ eventSpecifier !== this.globalSpecifier &&
+ this.listeners[eventType][eventSpecifier]
+ ) {
+ this.listeners[eventType][eventSpecifier] = this.listeners[eventType][eventSpecifier].filter(
+ (fn) => fn !== listener
+ );
+ }
+ };
+
+ dispatch = async (
+ eventType: K,
+ eventSpecifier: string | null,
+ data: EventData[K]
+ ): Promise => {
+ const listeners =
+ this.listeners[eventType] &&
+ this.listeners[eventType][eventSpecifier || this.globalSpecifier];
+ if (!listeners) {
+ return;
+ }
+ for (const listener of listeners) {
+ try {
+ await listener(data);
+ } catch (err) {
+ Errors.log('Failed to dispatch.', err);
+ }
+ }
+ };
+}
+
+export const EventDispatcher = new _EventDispatcher();
diff --git a/src/services/Messaging.ts b/src/common/Messaging.ts
similarity index 57%
rename from src/services/Messaging.ts
rename to src/common/Messaging.ts
index ab056269..a9c0b527 100644
--- a/src/services/Messaging.ts
+++ b/src/common/Messaging.ts
@@ -1,5 +1,7 @@
+import { MessageRequest } from '../modules/background/background';
+
class _Messaging {
- toBackground = async (message: Record): Promise> => {
+ toBackground = async (message: MessageRequest): Promise> => {
const response: string = await browser.runtime.sendMessage(JSON.stringify(message));
return JSON.parse(response) as Record;
};
diff --git a/src/common/Notifications.ts b/src/common/Notifications.ts
new file mode 100644
index 00000000..665d766c
--- /dev/null
+++ b/src/common/Notifications.ts
@@ -0,0 +1,67 @@
+import { TraktScrobble } from '../api/TraktScrobble';
+import { BrowserStorage } from './BrowserStorage';
+import { EventDispatcher, ScrobbleErrorData, ScrobbleSuccessData } from './Events';
+import { Messaging } from './Messaging';
+import { RequestException } from './Requests';
+
+class _Notifications {
+ messageNames: Record;
+
+ constructor() {
+ this.messageNames = {
+ [TraktScrobble.START]: 'scrobbleStarted',
+ [TraktScrobble.PAUSE]: 'scrobblePaused',
+ [TraktScrobble.STOP]: 'scrobbleStoped',
+ };
+ }
+
+ startListeners = () => {
+ EventDispatcher.subscribe('SCROBBLE_SUCCESS', null, this.onScrobble);
+ EventDispatcher.subscribe('SCROBBLE_ERROR', null, this.onScrobble);
+ };
+
+ onScrobble = async (data: ScrobbleSuccessData | ScrobbleErrorData): Promise => {
+ if (!data.item?.title) {
+ return;
+ }
+ let title = '';
+ let message = '';
+ if ('error' in data) {
+ title = await this.getTitleFromException(data.error);
+ message = `${browser.i18n.getMessage('couldNotScrobble')} ${data.item.title}`;
+ } else {
+ title = data.item.title;
+ message = browser.i18n.getMessage(this.messageNames[data.scrobbleType]);
+ }
+ await this.show(title, message);
+ };
+
+ getTitleFromException = async (err: RequestException): Promise => {
+ let title = '';
+ if (err) {
+ if (err.status === 404) {
+ title = browser.i18n.getMessage('errorNotificationNotFound');
+ } else if (err.status === 0) {
+ const { auth } = await BrowserStorage.get('auth');
+ if (auth?.access_token) {
+ title = browser.i18n.getMessage('errorNotificationServers');
+ } else {
+ title = browser.i18n.getMessage('errorNotificationLogin');
+ }
+ } else {
+ title = browser.i18n.getMessage('errorNotificationServers');
+ }
+ } else {
+ title = browser.i18n.getMessage('errorNotification');
+ }
+ return title;
+ };
+
+ show = async (title: string, message: string): Promise => {
+ await Messaging.toBackground({ action: 'show-notification', title, message });
+ };
+}
+
+const Notifications = new _Notifications();
+
+export { Notifications };
diff --git a/src/services/Requests.ts b/src/common/Requests.ts
similarity index 96%
rename from src/services/Requests.ts
rename to src/common/Requests.ts
index 22d7570f..21440d96 100644
--- a/src/services/Requests.ts
+++ b/src/common/Requests.ts
@@ -13,7 +13,7 @@ export type RequestDetails = {
url: string;
method: string;
headers?: Record;
- body?: string | Record;
+ body?: unknown;
};
export type Fetch = (input: RequestInfo, init?: RequestInit) => Promise;
@@ -58,9 +58,9 @@ class _Requests {
let options = await this.getOptions(request, tabId);
if (window.wrappedJSObject) {
// Firefox wraps page objects, so if we want to send the request from a container, we have to unwrap them.
- fetch = XPCNativeWrapper(window.wrappedJSObject.fetch as Fetch);
+ fetch = XPCNativeWrapper(window.wrappedJSObject.fetch);
window.wrappedJSObject.fetchOptions = cloneInto(options, window);
- options = XPCNativeWrapper(window.wrappedJSObject.fetchOptions as Record);
+ options = XPCNativeWrapper(window.wrappedJSObject.fetchOptions);
}
return fetch(request.url, options);
};
diff --git a/src/services/Session.ts b/src/common/Session.ts
similarity index 70%
rename from src/services/Session.ts
rename to src/common/Session.ts
index a76f48bc..c2bfa0f7 100644
--- a/src/services/Session.ts
+++ b/src/common/Session.ts
@@ -1,5 +1,5 @@
import { Errors } from './Errors';
-import { EventDispatcher, Events } from './Events';
+import { EventDispatcher } from './Events';
import { Messaging } from './Messaging';
class _Session {
@@ -14,13 +14,13 @@ class _Session {
const auth = await Messaging.toBackground({ action: 'check-login' });
if (auth && auth.access_token) {
this.isLoggedIn = true;
- await EventDispatcher.dispatch(Events.LOGIN_SUCCESS, null, { auth });
+ await EventDispatcher.dispatch('LOGIN_SUCCESS', null, { auth });
} else {
throw auth;
}
} catch (err) {
this.isLoggedIn = false;
- await EventDispatcher.dispatch(Events.LOGIN_ERROR, null, {});
+ await EventDispatcher.dispatch('LOGIN_ERROR', null, { error: err as Error });
}
};
@@ -29,14 +29,14 @@ class _Session {
const auth = await Messaging.toBackground({ action: 'login' });
if (auth && auth.access_token) {
this.isLoggedIn = true;
- await EventDispatcher.dispatch(Events.LOGIN_SUCCESS, null, { auth });
+ await EventDispatcher.dispatch('LOGIN_SUCCESS', null, { auth });
} else {
throw auth;
}
} catch (err) {
Errors.error('Failed to log in.', err);
this.isLoggedIn = false;
- await EventDispatcher.dispatch(Events.LOGIN_ERROR, null, { error: err as Error });
+ await EventDispatcher.dispatch('LOGIN_ERROR', null, { error: err as Error });
}
};
@@ -44,11 +44,11 @@ class _Session {
try {
await Messaging.toBackground({ action: 'logout' });
this.isLoggedIn = false;
- await EventDispatcher.dispatch(Events.LOGOUT_SUCCESS, null, {});
+ await EventDispatcher.dispatch('LOGOUT_SUCCESS', null, {});
} catch (err) {
Errors.error('Failed to log out.', err);
this.isLoggedIn = true;
- await EventDispatcher.dispatch(Events.LOGOUT_ERROR, null, { error: err as Error });
+ await EventDispatcher.dispatch('LOGOUT_ERROR', null, { error: err as Error });
}
};
diff --git a/src/services/Shared.ts b/src/common/Shared.ts
similarity index 100%
rename from src/services/Shared.ts
rename to src/common/Shared.ts
diff --git a/src/services/Tabs.ts b/src/common/Tabs.ts
similarity index 100%
rename from src/services/Tabs.ts
rename to src/common/Tabs.ts
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
index 71c8f0cb..34b8c190 100644
--- a/src/components/ErrorBoundary.tsx
+++ b/src/components/ErrorBoundary.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { Errors } from '../services/Errors';
+import { Errors } from '../common/Errors';
interface ErrorBoundaryProps {
children: React.ReactNode;
diff --git a/src/components/UtsDialog.tsx b/src/components/UtsDialog.tsx
index 5af10bd1..5605c92b 100644
--- a/src/components/UtsDialog.tsx
+++ b/src/components/UtsDialog.tsx
@@ -8,16 +8,9 @@ import {
} from '@material-ui/core';
import * as React from 'react';
import { useEffect, useState } from 'react';
-import { EventDispatcher, Events } from '../services/Events';
+import { EventDispatcher, DialogShowData } from '../common/Events';
-interface DialogData {
- title: string;
- message: string;
- onConfirm?: () => void;
- onDeny?: () => void;
-}
-
-interface DialogState extends DialogData {
+interface DialogState extends DialogShowData {
isOpen: boolean;
}
@@ -41,14 +34,14 @@ export const UtsDialog: React.FC = () => {
useEffect(() => {
const startListeners = () => {
- EventDispatcher.subscribe(Events.DIALOG_SHOW, null, showDialog);
+ EventDispatcher.subscribe('DIALOG_SHOW', null, showDialog);
};
const stopListeners = () => {
- EventDispatcher.unsubscribe(Events.DIALOG_SHOW, null, showDialog);
+ EventDispatcher.unsubscribe('DIALOG_SHOW', null, showDialog);
};
- const showDialog = (data: DialogData) => {
+ const showDialog = (data: DialogShowData) => {
setDialog({
isOpen: true,
title: data.title,
diff --git a/src/components/UtsSnackbar.tsx b/src/components/UtsSnackbar.tsx
index f3f3fe51..998c444d 100644
--- a/src/components/UtsSnackbar.tsx
+++ b/src/components/UtsSnackbar.tsx
@@ -2,13 +2,7 @@ import { Snackbar } from '@material-ui/core';
import { Alert, Color } from '@material-ui/lab';
import * as React from 'react';
import { useEffect, useState } from 'react';
-import { EventDispatcher, Events } from '../services/Events';
-
-interface SnackbarData {
- messageName: string;
- messageArgs: string[];
- severity: Color;
-}
+import { EventDispatcher, SnackbarShowData } from '../common/Events';
interface SnackBarState {
isOpen: boolean;
@@ -32,14 +26,14 @@ export const UtsSnackbar: React.FC = () => {
useEffect(() => {
const startListeners = () => {
- EventDispatcher.subscribe(Events.SNACKBAR_SHOW, null, showSnackbar);
+ EventDispatcher.subscribe('SNACKBAR_SHOW', null, showSnackbar);
};
const stopListeners = () => {
- EventDispatcher.unsubscribe(Events.SNACKBAR_SHOW, null, showSnackbar);
+ EventDispatcher.unsubscribe('SNACKBAR_SHOW', null, showSnackbar);
};
- const showSnackbar = (data: SnackbarData) => {
+ const showSnackbar = (data: SnackbarShowData) => {
setSnackbar({
isOpen: true,
message: browser.i18n.getMessage(data.messageName, data.messageArgs || []),
diff --git a/src/components/WrongItemDialog.tsx b/src/components/WrongItemDialog.tsx
index 9228a4e5..1a2edced 100644
--- a/src/components/WrongItemDialog.tsx
+++ b/src/components/WrongItemDialog.tsx
@@ -10,10 +10,10 @@ import {
} from '@material-ui/core';
import * as React from 'react';
import { Item } from '../models/Item';
-import { BrowserStorage } from '../services/BrowserStorage';
-import { Errors } from '../services/Errors';
-import { EventDispatcher, Events, WrongItemDialogData } from '../services/Events';
-import { StreamingServiceId, streamingServices } from '../streaming-services';
+import { BrowserStorage } from '../common/BrowserStorage';
+import { Errors } from '../common/Errors';
+import { EventDispatcher, WrongItemDialogShowData } from '../common/Events';
+import { StreamingServiceId, streamingServices } from '../streaming-services/streaming-services';
import { UtsCenter } from './UtsCenter';
interface WrongItemDialogState {
@@ -68,13 +68,13 @@ export const WrongItemDialog: React.FC = () => {
}
correctUrls[dialog.serviceId][dialog.item.id] = url;
await BrowserStorage.set({ correctUrls }, true);
- await EventDispatcher.dispatch(Events.WRONG_ITEM_CORRECTED, dialog.serviceId, {
+ await EventDispatcher.dispatch('WRONG_ITEM_CORRECTED', dialog.serviceId, {
item: dialog.item,
url,
});
} catch (err) {
Errors.error('Failed to correct item.', err);
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'correctWrongItemFailed',
severity: 'error',
});
@@ -104,14 +104,14 @@ export const WrongItemDialog: React.FC = () => {
React.useEffect(() => {
const startListeners = () => {
- EventDispatcher.subscribe(Events.WRONG_ITEM_DIALOG_SHOW, null, openDialog);
+ EventDispatcher.subscribe('WRONG_ITEM_DIALOG_SHOW', null, openDialog);
};
const stopListeners = () => {
- EventDispatcher.unsubscribe(Events.WRONG_ITEM_DIALOG_SHOW, null, openDialog);
+ EventDispatcher.unsubscribe('WRONG_ITEM_DIALOG_SHOW', null, openDialog);
};
- const openDialog = (data: WrongItemDialogData) => {
+ const openDialog = (data: WrongItemDialogShowData) => {
setDialog({
isOpen: true,
isLoading: false,
diff --git a/src/global.d.ts b/src/global.d.ts
index a3a9f08c..52a136ba 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -1,5 +1,10 @@
declare interface Window {
- wrappedJSObject?: Record;
+ wrappedJSObject?: {
+ fetch: import('./common/Requests').Fetch;
+ fetchOptions: Record;
+ netflix?: import('./streaming-services/netflix/NetflixApi').NetflixGlobalObject;
+ sdk?: import('./streaming-services/hbo-go/HboGoApi').HboGoGlobalObject;
+ };
Rollbar?: import('rollbar');
}
diff --git a/src/html/history.html b/src/html/history.html
index ed353b0d..72e820da 100644
--- a/src/html/history.html
+++ b/src/html/history.html
@@ -3,7 +3,7 @@
- Universal Trakt Sync
+ Universal Trakt Scrobbler - History
diff --git a/src/html/options.html b/src/html/options.html
new file mode 100644
index 00000000..5c184ea5
--- /dev/null
+++ b/src/html/options.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Universal Trakt Scrobbler - Options
+
+
+
+
+
+
+
diff --git a/src/html/popup.html b/src/html/popup.html
new file mode 100644
index 00000000..475f1209
--- /dev/null
+++ b/src/html/popup.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Universal Trakt Scrobbler - Popup
+
+
+
+
+
+
+
diff --git a/src/models/Item.ts b/src/models/Item.ts
index ae848518..f677f320 100644
--- a/src/models/Item.ts
+++ b/src/models/Item.ts
@@ -1,4 +1,4 @@
-import { ISyncItem } from './SyncItem';
+import { TraktItem } from './TraktItem';
// We use this to correct known wrong titles.
const correctTitles: Record = {
@@ -15,7 +15,7 @@ const correctTitles: Record = {
};
export interface IItem {
- id: number;
+ id: string;
type: 'show' | 'movie';
title: string;
year: number;
@@ -23,18 +23,16 @@ export interface IItem {
episode?: number;
episodeTitle?: string;
isCollection?: boolean;
- watchedAt: import('moment').Moment;
+ watchedAt?: import('moment').Moment;
percentageWatched?: number;
- trakt?: ISyncItem | TraktNotFound;
+ trakt?: TraktItem | null;
+ isSelected?: boolean;
+ index?: number;
}
-export type TraktNotFound = {
- notFound: true;
-};
-
//TODO this should be refactored or split into show and movie. Inheritance could be used to get the similarities.
export class Item implements IItem {
- id: number;
+ id: string;
type: 'show' | 'movie';
title: string;
year: number;
@@ -42,9 +40,9 @@ export class Item implements IItem {
episode?: number;
episodeTitle?: string;
isCollection?: boolean;
- watchedAt: import('moment').Moment;
+ watchedAt?: import('moment').Moment;
percentageWatched: number;
- trakt?: ISyncItem | TraktNotFound;
+ trakt?: TraktItem | null;
isSelected?: boolean;
index?: number;
diff --git a/src/models/SyncItem.ts b/src/models/SyncItem.ts
deleted file mode 100644
index 3cd11c25..00000000
--- a/src/models/SyncItem.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-export interface ISyncItem {
- id: number;
- type: 'show' | 'movie';
- title: string;
- year: number;
- season?: number;
- episode?: number;
- episodeTitle?: string;
- watchedAt?: import('moment').Moment;
-}
-
-export class SyncItem implements ISyncItem {
- id: number;
- type: 'show' | 'movie';
- title: string;
- year: number;
- season?: number;
- episode?: number;
- episodeTitle?: string;
- watchedAt?: import('moment').Moment;
-
- constructor(options: ISyncItem) {
- this.id = options.id;
- this.type = options.type;
- this.title = options.title;
- this.year = options.year;
- if (this.type === 'show') {
- this.season = options.season;
- this.episode = options.episode;
- this.episodeTitle = options.episodeTitle;
- }
- this.watchedAt = options.watchedAt;
- }
-}
diff --git a/src/models/TraktItem.ts b/src/models/TraktItem.ts
new file mode 100644
index 00000000..02db02b9
--- /dev/null
+++ b/src/models/TraktItem.ts
@@ -0,0 +1,60 @@
+import { Moment } from 'moment';
+
+export type ITraktItem = TraktItemBase & TraktItemExtra;
+
+export interface TraktItemBase {
+ id: number;
+ tmdbId: number;
+ type: 'show' | 'movie';
+ title: string;
+ year: number;
+ season?: number;
+ episode?: number;
+ episodeTitle?: string;
+}
+
+export interface TraktItemExtra {
+ watchedAt?: Moment;
+ progress?: number;
+}
+
+export class TraktItem implements ITraktItem {
+ id: number;
+ tmdbId: number;
+ type: 'show' | 'movie';
+ title: string;
+ year: number;
+ season?: number;
+ episode?: number;
+ episodeTitle?: string;
+ watchedAt?: Moment;
+ progress: number;
+
+ constructor(options: ITraktItem) {
+ this.id = options.id;
+ this.tmdbId = options.tmdbId;
+ this.type = options.type;
+ this.title = options.title;
+ this.year = options.year;
+ if (this.type === 'show') {
+ this.season = options.season;
+ this.episode = options.episode;
+ this.episodeTitle = options.episodeTitle;
+ }
+ this.watchedAt = options.watchedAt;
+ this.progress = options.progress ?? 0;
+ }
+
+ static getBase = (item: TraktItem): TraktItemBase => {
+ return {
+ id: item.id,
+ tmdbId: item.tmdbId,
+ type: item.type,
+ title: item.title,
+ year: item.year,
+ season: item.season,
+ episode: item.episode,
+ episodeTitle: item.episodeTitle,
+ };
+ };
+}
diff --git a/src/modules/background/background.ts b/src/modules/background/background.ts
index d30d94be..25e0cc05 100644
--- a/src/modules/background/background.ts
+++ b/src/modules/background/background.ts
@@ -1,18 +1,69 @@
import { TraktAuth } from '../../api/TraktAuth';
-import { BrowserStorage, StorageValuesOptions } from '../../services/BrowserStorage';
-import { Errors } from '../../services/Errors';
-import { RequestDetails, Requests } from '../../services/Requests';
-import { Shared } from '../../services/Shared';
-import { Tabs } from '../../services/Tabs';
-import { streamingServices } from '../../streaming-services';
-
-interface MessageRequest {
- action: 'check-login' | 'finish-login' | 'login' | 'logout' | 'send-request';
- url: string;
+import { TraktScrobble } from '../../api/TraktScrobble';
+import { TraktItem } from '../../models/TraktItem';
+import { BrowserAction } from '../../common/BrowserAction';
+import { BrowserStorage, StorageValuesOptions } from '../../common/BrowserStorage';
+import { Errors } from '../../common/Errors';
+import { RequestDetails, Requests } from '../../common/Requests';
+import { Shared } from '../../common/Shared';
+import { streamingServices } from '../../streaming-services/streaming-services';
+
+export type MessageRequest =
+ | CheckLoginMessage
+ | FinishLoginMessage
+ | LoginMessage
+ | LogoutMessage
+ | SetActiveIconMessage
+ | SetInactiveIconMessage
+ | StartScrobbleMessage
+ | StopScrobbleMessage
+ | SendRequestMessage
+ | ShowNotificationMessage;
+
+export interface CheckLoginMessage {
+ action: 'check-login';
+}
+
+export interface FinishLoginMessage {
+ action: 'finish-login';
redirectUrl: string;
+}
+
+export interface LoginMessage {
+ action: 'login';
+}
+
+export interface LogoutMessage {
+ action: 'logout';
+}
+
+export interface SendRequestMessage {
+ action: 'send-request';
request: RequestDetails;
}
+export interface SetActiveIconMessage {
+ action: 'set-active-icon';
+}
+
+export interface SetInactiveIconMessage {
+ action: 'set-inactive-icon';
+}
+
+export interface ShowNotificationMessage {
+ action: 'show-notification';
+ title: string;
+ message: string;
+}
+
+export interface StartScrobbleMessage {
+ action: 'start-scrobble';
+}
+
+export interface StopScrobbleMessage {
+ action: 'stop-scrobble';
+}
+
const init = async () => {
Shared.isBackgroundPage = true;
await BrowserStorage.sync();
@@ -20,14 +71,31 @@ const init = async () => {
if (storage.options?.allowRollbar) {
Errors.startRollbar();
}
+ browser.tabs.onRemoved.addListener((tabId) => void onTabRemoved(tabId));
browser.storage.onChanged.addListener(onStorageChanged);
- browser.browserAction.onClicked.addListener(() => void onBrowserActionClicked());
if (storage.options?.grantCookies) {
addWebRequestListener();
}
browser.runtime.onMessage.addListener((onMessage as unknown) as browser.runtime.onMessageEvent);
};
+/**
+ * Checks if the tab that was closed was the tab that was scrobbling and, if that's the case, stops the scrobble.
+ */
+const onTabRemoved = async (tabId: number) => {
+ const { scrobblingTabId } = await BrowserStorage.get('scrobblingTabId');
+ if (tabId !== scrobblingTabId) {
+ return;
+ }
+ const { scrobblingItem } = await BrowserStorage.get('scrobblingItem');
+ if (scrobblingItem) {
+ await TraktScrobble.stop(new TraktItem(scrobblingItem));
+ await BrowserStorage.remove('scrobblingItem');
+ }
+ await BrowserStorage.remove('scrobblingTabId');
+ await BrowserAction.setInactiveIcon();
+};
+
const onStorageChanged = (
changes: browser.storage.ChangeDict,
areaName: browser.storage.StorageName
@@ -45,17 +113,6 @@ const onStorageChanged = (
}
};
-const onBrowserActionClicked = async (): Promise => {
- const tabs = await browser.tabs.query({
- url: `${browser.runtime.getURL('/')}*`,
- });
- if (tabs.length > 0) {
- await browser.tabs.update(tabs[0].id, { active: true });
- } else {
- await Tabs.open(browser.runtime.getURL('html/history.html'));
- }
-};
-
const addWebRequestListener = () => {
if (
!browser.webRequest ||
@@ -130,6 +187,37 @@ const onMessage = (request: string, sender: browser.runtime.MessageSender): Prom
executingAction = Requests.send(parsedRequest.request, sender.tab?.id);
break;
}
+ case 'set-active-icon': {
+ executingAction = BrowserAction.setActiveIcon();
+ break;
+ }
+ case 'set-inactive-icon': {
+ executingAction = BrowserAction.setInactiveIcon();
+ break;
+ }
+ case 'start-scrobble': {
+ executingAction = setScrobblingTabId(sender.tab?.id);
+ break;
+ }
+ case 'stop-scrobble': {
+ executingAction = removeScrobblingTabId();
+ break;
+ }
+ case 'show-notification': {
+ executingAction = browser.permissions
+ .contains({ permissions: ['notifications'] })
+ .then((hasPermissions) => {
+ if (hasPermissions) {
+ return browser.notifications.create({
+ type: 'basic',
+ iconUrl: 'images/uts-icon-128.png',
+ title: parsedRequest.title,
+ message: parsedRequest.message,
+ });
+ }
+ });
+ break;
+ }
}
return new Promise((resolve) => {
executingAction
@@ -147,4 +235,21 @@ const onMessage = (request: string, sender: browser.runtime.MessageSender): Prom
});
};
+const setScrobblingTabId = async (tabId?: number): Promise => {
+ const { scrobblingItem, scrobblingTabId } = await BrowserStorage.get([
+ 'scrobblingItem',
+ 'scrobblingTabId',
+ ]);
+ if (scrobblingItem && tabId !== scrobblingTabId) {
+ // Stop the previous scrobble if it exists.
+ await TraktScrobble.stop(new TraktItem(scrobblingItem));
+ await BrowserStorage.remove('scrobblingItem');
+ }
+ await BrowserStorage.set({ scrobblingTabId: tabId }, false);
+};
+
+const removeScrobblingTabId = (): Promise => {
+ return BrowserStorage.remove('scrobblingTabId');
+};
+
void init();
diff --git a/src/modules/content/trakt/trakt.ts b/src/modules/content/trakt/trakt.ts
index ee25b45c..535bc4ff 100644
--- a/src/modules/content/trakt/trakt.ts
+++ b/src/modules/content/trakt/trakt.ts
@@ -1,4 +1,4 @@
-import { Session } from '../../../services/Session';
+import { Session } from '../../../common/Session';
const init = async () => {
await Session.finishLogin();
diff --git a/src/modules/history/HistoryApp.tsx b/src/modules/history/HistoryApp.tsx
index 073804cf..4456c847 100644
--- a/src/modules/history/HistoryApp.tsx
+++ b/src/modules/history/HistoryApp.tsx
@@ -6,14 +6,13 @@ import { Redirect, Route, Router, Switch } from 'react-router-dom';
import { ErrorBoundary } from '../../components/ErrorBoundary';
import { UtsDialog } from '../../components/UtsDialog';
import { UtsSnackbar } from '../../components/UtsSnackbar';
-import { EventDispatcher, Events } from '../../services/Events';
-import { Session } from '../../services/Session';
+import { EventDispatcher } from '../../common/Events';
+import { Session } from '../../common/Session';
import { HistoryHeader } from './components/HistoryHeader';
import { AboutPage } from './pages/AboutPage';
import { HomePage } from './pages/HomePage';
import { LoginPage } from './pages/LoginPage';
-import { OptionsPage } from './pages/OptionsPage.';
-import { streamingServicePages } from './streaming-services/pages';
+import { streamingServicePages } from '../../streaming-services/pages';
const history = createBrowserHistory();
@@ -22,13 +21,13 @@ export const HistoryApp: React.FC = () => {
useEffect(() => {
const startListeners = () => {
- EventDispatcher.subscribe(Events.LOGIN_SUCCESS, null, onLogin);
- EventDispatcher.subscribe(Events.LOGOUT_SUCCESS, null, onLogout);
+ EventDispatcher.subscribe('LOGIN_SUCCESS', null, onLogin);
+ EventDispatcher.subscribe('LOGOUT_SUCCESS', null, onLogout);
};
const stopListeners = () => {
- EventDispatcher.unsubscribe(Events.LOGIN_SUCCESS, null, onLogin);
- EventDispatcher.unsubscribe(Events.LOGOUT_SUCCESS, null, onLogout);
+ EventDispatcher.unsubscribe('LOGIN_SUCCESS', null, onLogin);
+ EventDispatcher.unsubscribe('LOGOUT_SUCCESS', null, onLogout);
};
const onLogin = () => {
@@ -53,7 +52,6 @@ export const HistoryApp: React.FC = () => {
-
{streamingServicePages.map((service) => (
))}
diff --git a/src/modules/history/components/HistoryHeader.tsx b/src/modules/history/components/HistoryHeader.tsx
index a6ee9a67..93eba47b 100644
--- a/src/modules/history/components/HistoryHeader.tsx
+++ b/src/modules/history/components/HistoryHeader.tsx
@@ -2,7 +2,8 @@ import { AppBar, Button, Toolbar } from '@material-ui/core';
import { History } from 'history';
import * as React from 'react';
import { UtsLeftRight } from '../../../components/UtsLeftRight';
-import { Session } from '../../../services/Session';
+import { Session } from '../../../common/Session';
+import { Tabs } from '../../../common/Tabs';
interface HistoryHeaderProps {
history: History;
@@ -16,6 +17,10 @@ export const HistoryHeader: React.FC = (props: HistoryHeader
history.push(path);
};
+ const onLinkClick = async (url: string): Promise => {
+ await Tabs.open(url);
+ };
+
const onLogoutClick = async () => {
await Session.logout();
};
@@ -33,7 +38,10 @@ export const HistoryHeader: React.FC = (props: HistoryHeader
onRouteClick('/about')}>
{browser.i18n.getMessage('about')}
- onRouteClick('/options')}>
+ onLinkClick(browser.runtime.getURL('/html/options.html'))}
+ >
{browser.i18n.getMessage('options')}
>
diff --git a/src/modules/history/components/history/HistoryList.tsx b/src/modules/history/components/history/HistoryList.tsx
index 24923482..5820f4f2 100644
--- a/src/modules/history/components/history/HistoryList.tsx
+++ b/src/modules/history/components/history/HistoryList.tsx
@@ -2,7 +2,7 @@ import { List } from '@material-ui/core';
import * as React from 'react';
import { WrongItemDialog } from '../../../../components/WrongItemDialog';
import { Item } from '../../../../models/Item';
-import { StreamingServiceId } from '../../../../streaming-services';
+import { StreamingServiceId } from '../../../../streaming-services/streaming-services';
import { HistoryListItem } from './HistoryListItem';
interface HistoryListProps {
diff --git a/src/modules/history/components/history/HistoryListItem.tsx b/src/modules/history/components/history/HistoryListItem.tsx
index 85b066d1..9c5826d3 100644
--- a/src/modules/history/components/history/HistoryListItem.tsx
+++ b/src/modules/history/components/history/HistoryListItem.tsx
@@ -3,8 +3,8 @@ import { green, red } from '@material-ui/core/colors';
import SyncIcon from '@material-ui/icons/Sync';
import * as React from 'react';
import { Item } from '../../../../models/Item';
-import { EventDispatcher, Events } from '../../../../services/Events';
-import { StreamingServiceId } from '../../../../streaming-services';
+import { EventDispatcher } from '../../../../common/Events';
+import { StreamingServiceId } from '../../../../streaming-services/streaming-services';
import { HistoryListItemCard } from './HistoryListItemCard';
interface HistoryListItemProps {
@@ -18,27 +18,26 @@ export const HistoryListItem: React.FC = (props: HistoryLi
const { dateFormat, item, serviceId, serviceName } = props;
const onCheckboxChange = async () => {
- await EventDispatcher.dispatch(Events.STREAMING_SERVICE_HISTORY_CHANGE, null, {
+ await EventDispatcher.dispatch('STREAMING_SERVICE_HISTORY_CHANGE', null, {
index: item.index,
checked: !item.isSelected,
});
};
const openWrongItemDialog = async () => {
- await EventDispatcher.dispatch(Events.WRONG_ITEM_DIALOG_SHOW, null, {
+ await EventDispatcher.dispatch('WRONG_ITEM_DIALOG_SHOW', null, {
serviceId,
item,
});
};
- const [statusColor, statusMessageName] =
- item.trakt && 'watchedAt' in item.trakt && item.trakt.watchedAt
- ? [green[500], 'itemSynced']
- : [red[500], 'itemNotSynced'];
+ const [statusColor, statusMessageName] = item.trakt?.watchedAt
+ ? [green[500], 'itemSynced']
+ : [red[500], 'itemNotSynced'];
return (
- {item.trakt && !('notFound' in item.trakt) && !item.trakt.watchedAt && (
+ {item.trakt && !item.trakt.watchedAt && (
Promise;
}
@@ -28,9 +29,9 @@ export const HistoryListItemCard: React.FC = (
{`${browser.i18n.getMessage('on')} ${name}`}
- {item ? (
+ {typeof item !== 'undefined' ? (
<>
- {'notFound' in item ? (
+ {item === null ? (
{browser.i18n.getMessage('notFound')}
) : item.type === 'show' ? (
<>
diff --git a/src/modules/history/components/history/HistoryOptionsList.tsx b/src/modules/history/components/history/HistoryOptionsList.tsx
index 80022071..dd79e4e1 100644
--- a/src/modules/history/components/history/HistoryOptionsList.tsx
+++ b/src/modules/history/components/history/HistoryOptionsList.tsx
@@ -1,12 +1,12 @@
import { Button, ButtonGroup, FormGroup, Typography } from '@material-ui/core';
import * as React from 'react';
-import { SyncOption } from '../../../../services/BrowserStorage';
-import { Store } from '../../streaming-services/common/Store';
+import { SyncOption } from '../../../../common/BrowserStorage';
+import { SyncStore } from '../../../../streaming-services/common/SyncStore';
import { HistoryOptionsListItem } from './HistoryOptionsListItem';
interface HistoryOptionsListProps {
options: SyncOption[];
- store: Store;
+ store: SyncStore;
}
export const HistoryOptionsList: React.FC = (
diff --git a/src/modules/history/components/history/HistoryOptionsListItem.tsx b/src/modules/history/components/history/HistoryOptionsListItem.tsx
index bd32c117..9095b5c6 100644
--- a/src/modules/history/components/history/HistoryOptionsListItem.tsx
+++ b/src/modules/history/components/history/HistoryOptionsListItem.tsx
@@ -1,7 +1,7 @@
import { FormControlLabel, Switch, TextField } from '@material-ui/core';
import * as React from 'react';
-import { SyncOption } from '../../../../services/BrowserStorage';
-import { EventDispatcher, Events } from '../../../../services/Events';
+import { SyncOption } from '../../../../common/BrowserStorage';
+import { EventDispatcher } from '../../../../common/Events';
interface HistoryOptionsListItemProps {
option: SyncOption;
@@ -9,14 +9,14 @@ interface HistoryOptionsListItemProps {
export const HistoryOptionsListItem: React.FC = ({ option }) => {
const onSwitchChange = async (): Promise => {
- await EventDispatcher.dispatch(Events.HISTORY_OPTIONS_CHANGE, null, {
+ await EventDispatcher.dispatch('HISTORY_OPTIONS_CHANGE', null, {
id: option.id,
value: !option.value,
});
};
const onNumberInputChange = async (event: React.ChangeEvent): Promise => {
- await EventDispatcher.dispatch(Events.HISTORY_OPTIONS_CHANGE, null, {
+ await EventDispatcher.dispatch('HISTORY_OPTIONS_CHANGE', null, {
id: option.id,
value: parseInt(event.currentTarget.value),
});
diff --git a/src/modules/history/history.tsx b/src/modules/history/history.tsx
index f76ced81..79e1fcd1 100644
--- a/src/modules/history/history.tsx
+++ b/src/modules/history/history.tsx
@@ -1,13 +1,13 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import '../../assets/assets';
-import { BrowserStorage } from '../../services/BrowserStorage';
-import { Errors } from '../../services/Errors';
-import { Shared } from '../../services/Shared';
+import { BrowserStorage } from '../../common/BrowserStorage';
+import { Errors } from '../../common/Errors';
+import { Shared } from '../../common/Shared';
import { HistoryApp } from './HistoryApp';
const init = async () => {
- Shared.isBackgroundPage = false;
+ Shared.isBackgroundPage = true;
await BrowserStorage.sync();
const values = await BrowserStorage.get('options');
if (values.options && values.options.allowRollbar) {
diff --git a/src/modules/history/pages/AboutPage.tsx b/src/modules/history/pages/AboutPage.tsx
index 589bb972..70bdd36e 100644
--- a/src/modules/history/pages/AboutPage.tsx
+++ b/src/modules/history/pages/AboutPage.tsx
@@ -1,6 +1,6 @@
import { Button, Typography } from '@material-ui/core';
import * as React from 'react';
-import { Tabs } from '../../../services/Tabs';
+import { Tabs } from '../../../common/Tabs';
import { HistoryInfo } from '../components/HistoryInfo';
export const AboutPage: React.FC = () => {
@@ -13,7 +13,7 @@ export const AboutPage: React.FC = () => {
{browser.i18n.getMessage('aboutMessage')}
onLinkClick('https://github.com/trakt-tools/universal-trakt-sync')}
+ onClick={() => onLinkClick('https://github.com/trakt-tools/universal-trakt-scrobbler')}
variant="contained"
>
{browser.i18n.getMessage('readMore')}
diff --git a/src/modules/history/pages/HomePage.tsx b/src/modules/history/pages/HomePage.tsx
index 50363cb8..434e00dc 100644
--- a/src/modules/history/pages/HomePage.tsx
+++ b/src/modules/history/pages/HomePage.tsx
@@ -3,10 +3,10 @@ import * as React from 'react';
import { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { UtsCenter } from '../../../components/UtsCenter';
-import { Session } from '../../../services/Session';
+import { Session } from '../../../common/Session';
import { HistoryInfo } from '../components/HistoryInfo';
-import { StreamingServicePage, streamingServicePages } from '../streaming-services/pages';
-import { BrowserStorage } from '../../../services/BrowserStorage';
+import { StreamingServicePage, streamingServicePages } from '../../../streaming-services/pages';
+import { BrowserStorage } from '../../../common/BrowserStorage';
export const HomePage: React.FC = () => {
const history = useHistory();
diff --git a/src/modules/history/pages/LoginPage.tsx b/src/modules/history/pages/LoginPage.tsx
index 90843fec..80dcaf99 100644
--- a/src/modules/history/pages/LoginPage.tsx
+++ b/src/modules/history/pages/LoginPage.tsx
@@ -3,8 +3,8 @@ import * as React from 'react';
import { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { UtsCenter } from '../../../components/UtsCenter';
-import { EventDispatcher, Events } from '../../../services/Events';
-import { Session } from '../../../services/Session';
+import { EventDispatcher } from '../../../common/Events';
+import { Session } from '../../../common/Session';
export const LoginPage: React.FC = () => {
const history = useHistory();
@@ -17,13 +17,13 @@ export const LoginPage: React.FC = () => {
useEffect(() => {
const startListeners = () => {
- EventDispatcher.subscribe(Events.LOGIN_SUCCESS, null, onLoginSuccess);
- EventDispatcher.subscribe(Events.LOGIN_ERROR, null, onLoginError);
+ EventDispatcher.subscribe('LOGIN_SUCCESS', null, onLoginSuccess);
+ EventDispatcher.subscribe('LOGIN_ERROR', null, onLoginError);
};
const stopListeners = () => {
- EventDispatcher.unsubscribe(Events.LOGIN_SUCCESS, null, onLoginSuccess);
- EventDispatcher.unsubscribe(Events.LOGIN_ERROR, null, onLoginError);
+ EventDispatcher.unsubscribe('LOGIN_SUCCESS', null, onLoginSuccess);
+ EventDispatcher.unsubscribe('LOGIN_ERROR', null, onLoginError);
};
const onLoginSuccess = () => {
diff --git a/src/modules/history/streaming-services/common/common.ts b/src/modules/history/streaming-services/common/common.ts
deleted file mode 100644
index 2cd6e3f8..00000000
--- a/src/modules/history/streaming-services/common/common.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Page } from './Page';
-import { Store } from './Store';
-import { StreamingServiceId } from '../../../../streaming-services';
-import { Api } from './Api';
-
-const stores = {} as Record;
-
-export const getStore = (serviceId: StreamingServiceId) => {
- if (!stores[serviceId]) {
- stores[serviceId] = new Store();
- }
- return stores[serviceId];
-};
-
-const apis = {} as Record;
-
-export const registerApi = (serviceId: StreamingServiceId, serviceApi: Api) => {
- apis[serviceId] = serviceApi;
-};
-
-export const getApi = (serviceId: StreamingServiceId) => {
- return apis[serviceId];
-};
-
-const pageBuilders = {} as Record React.ReactElement | null>;
-
-export const getPageBuilder = (
- serviceId: StreamingServiceId,
- serviceName: string
-): (() => React.ReactElement | null) => {
- if (!pageBuilders[serviceId]) {
- pageBuilders[serviceId] = () =>
- Page({ serviceId, serviceName, store: getStore(serviceId), api: getApi(serviceId) });
- }
- return pageBuilders[serviceId];
-};
diff --git a/src/modules/history/streaming-services/netflix/NetflixApi.ts b/src/modules/history/streaming-services/netflix/NetflixApi.ts
deleted file mode 100644
index 1317dd70..00000000
--- a/src/modules/history/streaming-services/netflix/NetflixApi.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import * as moment from 'moment';
-import { Item } from '../../../../models/Item';
-import { Errors } from '../../../../services/Errors';
-import { EventDispatcher, Events } from '../../../../services/Events';
-import { Requests } from '../../../../services/Requests';
-import { Api } from '../common/Api';
-import { getStore, registerApi } from '../common/common';
-
-export interface NetflixHistoryResponse {
- viewedItems: NetflixHistoryItem[];
-}
-
-export type NetflixHistoryItem = NetflixHistoryShowItem | NetflixHistoryMovieItem;
-
-export interface NetflixHistoryShowItem {
- date: number;
- duration: number;
- episodeTitle: string;
- movieID: number;
- seasonDescriptor: string;
- series: number;
- seriesTitle: string;
- title: string;
-}
-
-export interface NetflixHistoryMovieItem {
- date: number;
- duration: number;
- movieID: number;
- title: string;
-}
-
-export interface NetflixMetadataResponse {
- value: {
- videos: { [key: number]: NetflixMetadataItem };
- };
-}
-
-export type NetflixMetadataItem = NetflixMetadataShowItem | NetflixMetadataMovieItem;
-
-export interface NetflixMetadataShowItem {
- releaseYear: number;
- summary: {
- episode: number;
- id: number;
- season: number;
- };
-}
-
-export interface NetflixMetadataMovieItem {
- releaseYear: number;
- summary: {
- id: number;
- };
-}
-
-export type NetflixHistoryItemWithMetadata =
- | NetflixHistoryShowItemWithMetadata
- | NetflixHistoryMovieItemWithMetadata;
-
-export type NetflixHistoryShowItemWithMetadata = NetflixHistoryShowItem & NetflixMetadataShowItem;
-
-export type NetflixHistoryMovieItemWithMetadata = NetflixHistoryMovieItem &
- NetflixMetadataMovieItem;
-
-interface ApiParams {
- authUrl: string;
- buildIdentifier: string;
-}
-
-class _NetflixApi extends Api {
- HOST_URL: string;
- API_URL: string;
- ACTIVATE_URL: string;
- AUTH_REGEX: RegExp;
- BUILD_IDENTIFIER_REGEX: RegExp;
- isActivated: boolean;
- apiParams: Partial;
- constructor() {
- super('netflix');
-
- this.HOST_URL = 'https://www.netflix.com';
- this.API_URL = `${this.HOST_URL}/api/shakti`;
- this.ACTIVATE_URL = `${this.HOST_URL}/Activate`;
- this.AUTH_REGEX = /"authURL":"(.*?)"/;
- this.BUILD_IDENTIFIER_REGEX = /"BUILD_IDENTIFIER":"(.*?)"/;
-
- this.isActivated = false;
- this.apiParams = {};
- }
-
- extractAuthUrl = (text: string): string | undefined => {
- return this.AUTH_REGEX.exec(text)?.[1];
- };
-
- extractBuildIdentifier = (text: string): string | undefined => {
- return this.BUILD_IDENTIFIER_REGEX.exec(text)?.[1];
- };
-
- activate = async () => {
- const responseText = await Requests.send({
- url: this.ACTIVATE_URL,
- method: 'GET',
- });
- this.apiParams.authUrl = this.extractAuthUrl(responseText);
- this.apiParams.buildIdentifier = this.extractBuildIdentifier(responseText);
- this.isActivated = true;
- };
-
- checkParams = (apiParams: Partial): apiParams is ApiParams => {
- return (
- typeof apiParams.authUrl !== 'undefined' && typeof apiParams.buildIdentifier !== 'undefined'
- );
- };
-
- loadHistory = async (nextPage: number, nextVisualPage: number, itemsToLoad: number) => {
- try {
- if (!this.isActivated) {
- await this.activate();
- }
- if (!this.checkParams(this.apiParams)) {
- throw new Error('Invalid API params');
- }
- let isLastPage = false;
- let items: Item[] = [];
- const historyItems: NetflixHistoryItem[] = [];
- do {
- const responseText = await Requests.send({
- url: `${this.API_URL}/${this.apiParams.buildIdentifier}/viewingactivity?languages=en-US&authURL=${this.apiParams.authUrl}&pg=${nextPage}`,
- method: 'GET',
- });
- const responseJson = JSON.parse(responseText) as NetflixHistoryResponse;
- if (responseJson && responseJson.viewedItems.length > 0) {
- itemsToLoad -= responseJson.viewedItems.length;
- historyItems.push(...responseJson.viewedItems);
- } else {
- isLastPage = true;
- }
- nextPage += 1;
- } while (!isLastPage && itemsToLoad > 0);
- if (historyItems.length > 0) {
- const historyItemsWithMetadata = await this.getHistoryMetadata(historyItems);
- items = historyItemsWithMetadata.map(this.parseHistoryItem);
- }
- nextVisualPage += 1;
- getStore('netflix')
- .update({ isLastPage, nextPage, nextVisualPage, items })
- .then(this.loadTraktHistory)
- .catch(() => {
- /** Do nothing */
- });
- } catch (err) {
- Errors.error('Failed to load Netflix history.', err);
- await EventDispatcher.dispatch(Events.STREAMING_SERVICE_HISTORY_LOAD_ERROR, null, {
- error: err as Error,
- });
- }
- };
-
- getHistoryMetadata = async (historyItems: NetflixHistoryItem[]) => {
- if (!this.checkParams(this.apiParams)) {
- throw new Error('Invalid API params');
- }
- let historyItemsWithMetadata: NetflixHistoryItemWithMetadata[] = [];
- const responseText = await Requests.send({
- url: `${this.API_URL}/${this.apiParams.buildIdentifier}/pathEvaluator?languages=en-US`,
- method: 'POST',
- body: `authURL=${this.apiParams.authUrl}&${historyItems
- .map((historyItem) => `path=["videos",${historyItem.movieID},["releaseYear","summary"]]`)
- .join('&')}`,
- });
- const responseJson = JSON.parse(responseText) as NetflixMetadataResponse;
- if (responseJson && responseJson.value.videos) {
- historyItemsWithMetadata = historyItems.map((historyItem) => {
- const metadata = responseJson.value.videos[historyItem.movieID];
- let combinedItem: NetflixHistoryItemWithMetadata;
- if (metadata) {
- combinedItem = Object.assign({}, historyItem, metadata);
- } else {
- combinedItem = historyItem as NetflixHistoryItemWithMetadata;
- }
- return combinedItem;
- });
- } else {
- throw responseText;
- }
- return historyItemsWithMetadata;
- };
-
- isShow = (
- historyItem: NetflixHistoryItemWithMetadata
- ): historyItem is NetflixHistoryShowItemWithMetadata => {
- return 'series' in historyItem;
- };
-
- parseHistoryItem = (historyItem: NetflixHistoryItemWithMetadata) => {
- let item: Item;
- const id = historyItem.movieID;
- const type = 'series' in historyItem ? 'show' : 'movie';
- const year = historyItem.releaseYear;
- const watchedAt = moment(historyItem.date);
- if (this.isShow(historyItem)) {
- const title = historyItem.seriesTitle.trim();
- let season;
- let episode;
- const isCollection = !historyItem.seasonDescriptor.includes('Season');
- if (!isCollection) {
- season = historyItem.summary.season;
- episode = historyItem.summary.episode;
- }
- const episodeTitle = historyItem.episodeTitle.trim();
- item = new Item({
- id,
- type,
- title,
- year,
- season,
- episode,
- episodeTitle,
- isCollection,
- watchedAt,
- });
- } else {
- const title = historyItem.title.trim();
- item = new Item({ id, type, title, year, watchedAt });
- }
- return item;
- };
-}
-
-export const NetflixApi = new _NetflixApi();
-
-registerApi('netflix', NetflixApi);
diff --git a/src/modules/history/pages/OptionsPage..tsx b/src/modules/options/OptionsApp.tsx
similarity index 68%
rename from src/modules/history/pages/OptionsPage..tsx
rename to src/modules/options/OptionsApp.tsx
index 5c3f0101..b80104a1 100644
--- a/src/modules/history/pages/OptionsPage..tsx
+++ b/src/modules/options/OptionsApp.tsx
@@ -1,30 +1,28 @@
-import { CircularProgress } from '@material-ui/core';
+import { CircularProgress, Container } from '@material-ui/core';
import * as React from 'react';
import { useEffect, useState } from 'react';
-import { UtsCenter } from '../../../components/UtsCenter';
-import {
- BrowserStorage,
- Option,
- Options,
- StorageValuesOptions,
-} from '../../../services/BrowserStorage';
-import { Errors } from '../../../services/Errors';
+import { ErrorBoundary } from '../../components/ErrorBoundary';
+import { UtsCenter } from '../../components/UtsCenter';
+import { UtsDialog } from '../../components/UtsDialog';
+import { UtsSnackbar } from '../../components/UtsSnackbar';
+import { BrowserStorage, Option, Options, StorageValuesOptions } from '../../common/BrowserStorage';
+import { Errors } from '../../common/Errors';
import {
EventDispatcher,
- Events,
- OptionEventData,
- StreamingServiceOptionEventData,
-} from '../../../services/Events';
-import { StreamingServiceId, streamingServices } from '../../../streaming-services';
-import { OptionsActions } from '../components/options/OptionsActions';
-import { OptionsList } from '../components/options/OptionsList';
+ OptionsChangeData,
+ StreamingServiceOptionsChangeData,
+} from '../../common/Events';
+import { StreamingServiceId, streamingServices } from '../../streaming-services/streaming-services';
+import { OptionsActions } from './components/OptionsActions';
+import { OptionsHeader } from './components/OptionsHeader';
+import { OptionsList } from './components/OptionsList';
interface ContentProps {
isLoading: boolean;
options: Options;
}
-export const OptionsPage: React.FC = () => {
+export const OptionsApp: React.FC = () => {
const [content, setContent] = useState({
isLoading: true,
options: {} as Options,
@@ -39,26 +37,26 @@ export const OptionsPage: React.FC = () => {
useEffect(() => {
const startListeners = () => {
- EventDispatcher.subscribe(Events.OPTIONS_CLEAR, null, resetOptions);
- EventDispatcher.subscribe(Events.OPTIONS_CHANGE, null, onOptionChange);
+ EventDispatcher.subscribe('OPTIONS_CLEAR', null, resetOptions);
+ EventDispatcher.subscribe('OPTIONS_CHANGE', null, onOptionChange);
EventDispatcher.subscribe(
- Events.STREAMING_SERVICE_OPTIONS_CHANGE,
+ 'STREAMING_SERVICE_OPTIONS_CHANGE',
null,
onStreamingServiceOptionChange
);
};
const stopListeners = () => {
- EventDispatcher.unsubscribe(Events.OPTIONS_CLEAR, null, resetOptions);
- EventDispatcher.unsubscribe(Events.OPTIONS_CHANGE, null, onOptionChange);
+ EventDispatcher.unsubscribe('OPTIONS_CLEAR', null, resetOptions);
+ EventDispatcher.unsubscribe('OPTIONS_CHANGE', null, onOptionChange);
EventDispatcher.unsubscribe(
- Events.STREAMING_SERVICE_OPTIONS_CHANGE,
+ 'STREAMING_SERVICE_OPTIONS_CHANGE',
null,
onStreamingServiceOptionChange
);
};
- const onOptionChange = (data: OptionEventData) => {
+ const onOptionChange = (data: OptionsChangeData) => {
const optionsToSave = {} as StorageValuesOptions;
const options = {
...content.options,
@@ -94,7 +92,7 @@ export const OptionsPage: React.FC = () => {
})
.catch(async (err) => {
Errors.error('Failed to save option.', err);
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'saveOptionFailed',
severity: 'error',
});
@@ -105,7 +103,7 @@ export const OptionsPage: React.FC = () => {
};
const onStreamingServiceOptionChange = (
- data: StreamingServiceOptionEventData
+ data: StreamingServiceOptionsChangeData
) => {
const optionsToSave = {} as StorageValuesOptions;
const options = {
@@ -157,7 +155,7 @@ export const OptionsPage: React.FC = () => {
})
.catch(async (err) => {
Errors.error('Failed to save option.', err);
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'saveOptionFailed',
severity: 'error',
});
@@ -181,13 +179,13 @@ export const OptionsPage: React.FC = () => {
isLoading: false,
options,
});
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'saveOptionSuccess',
severity: 'success',
});
} catch (err) {
Errors.error('Failed to save option.', err);
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'saveOptionFailed',
severity: 'error',
});
@@ -202,14 +200,23 @@ export const OptionsPage: React.FC = () => {
void resetOptions();
}, []);
- return content.isLoading ? (
-
-
-
- ) : (
- <>
-
-
- >
+ return (
+
+
+
+ {content.isLoading ? (
+
+
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+
);
};
diff --git a/src/modules/history/components/options/OptionsActions.tsx b/src/modules/options/components/OptionsActions.tsx
similarity index 72%
rename from src/modules/history/components/options/OptionsActions.tsx
rename to src/modules/options/components/OptionsActions.tsx
index aab5e321..80540d3c 100644
--- a/src/modules/history/components/options/OptionsActions.tsx
+++ b/src/modules/options/components/OptionsActions.tsx
@@ -1,9 +1,9 @@
import { Box, Button, Divider } from '@material-ui/core';
import * as React from 'react';
import { useEffect, useState } from 'react';
-import { BrowserStorage } from '../../../../services/BrowserStorage';
-import { Errors } from '../../../../services/Errors';
-import { EventDispatcher, Events } from '../../../../services/Events';
+import { BrowserStorage } from '../../../common/BrowserStorage';
+import { Errors } from '../../../common/Errors';
+import { EventDispatcher } from '../../../common/Events';
export const OptionsActions: React.FC = () => {
const [cacheSize, setCacheSize] = useState('0 B');
@@ -13,22 +13,22 @@ export const OptionsActions: React.FC = () => {
};
const onClearStorageClick = async () => {
- await EventDispatcher.dispatch(Events.DIALOG_SHOW, null, {
+ await EventDispatcher.dispatch('DIALOG_SHOW', null, {
title: browser.i18n.getMessage('confirmClearStorageTitle'),
message: browser.i18n.getMessage('confirmClearStorageMessage'),
onConfirm: async () => {
try {
await BrowserStorage.clear(true);
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'clearStorageSuccess',
severity: 'success',
});
- await EventDispatcher.dispatch(Events.OPTIONS_CLEAR, null, {});
- await EventDispatcher.dispatch(Events.LOGOUT_SUCCESS, null, {});
+ await EventDispatcher.dispatch('OPTIONS_CLEAR', null, {});
+ await EventDispatcher.dispatch('LOGOUT_SUCCESS', null, {});
void updateTraktCacheSize();
} catch (err) {
Errors.error('Failed to clear storage.', err);
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'clearStorageFailed',
severity: 'error',
});
@@ -38,20 +38,20 @@ export const OptionsActions: React.FC = () => {
};
const onClearTraktCacheClick = async () => {
- await EventDispatcher.dispatch(Events.DIALOG_SHOW, null, {
+ await EventDispatcher.dispatch('DIALOG_SHOW', null, {
title: browser.i18n.getMessage('confirmClearTraktCacheTitle'),
message: browser.i18n.getMessage('confirmClearTraktCacheMessage'),
onConfirm: async () => {
try {
await BrowserStorage.remove('traktCache');
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'clearTraktCacheSuccess',
severity: 'success',
});
void updateTraktCacheSize();
} catch (err) {
Errors.error('Failed to clear Trakt cache.', err);
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'clearTraktCacheFailed',
severity: 'error',
});
diff --git a/src/modules/options/components/OptionsHeader.tsx b/src/modules/options/components/OptionsHeader.tsx
new file mode 100644
index 00000000..4641b9ac
--- /dev/null
+++ b/src/modules/options/components/OptionsHeader.tsx
@@ -0,0 +1,12 @@
+import { AppBar, Toolbar, Typography } from '@material-ui/core';
+import * as React from 'react';
+
+export const OptionsHeader: React.FC = () => {
+ return (
+
+
+ {browser.i18n.getMessage('options')}
+
+
+ );
+};
diff --git a/src/modules/history/components/options/OptionsList.tsx b/src/modules/options/components/OptionsList.tsx
similarity index 85%
rename from src/modules/history/components/options/OptionsList.tsx
rename to src/modules/options/components/OptionsList.tsx
index f32294ba..2f78dfe8 100644
--- a/src/modules/history/components/options/OptionsList.tsx
+++ b/src/modules/options/components/OptionsList.tsx
@@ -1,6 +1,6 @@
import { List } from '@material-ui/core';
import * as React from 'react';
-import { Option, StorageValuesOptions } from '../../../../services/BrowserStorage';
+import { Option, StorageValuesOptions } from '../../../common/BrowserStorage';
import { OptionsListItem } from './OptionsListItem';
interface OptionsListProps {
diff --git a/src/modules/history/components/options/OptionsListItem.tsx b/src/modules/options/components/OptionsListItem.tsx
similarity index 74%
rename from src/modules/history/components/options/OptionsListItem.tsx
rename to src/modules/options/components/OptionsListItem.tsx
index 24c268fc..80918d3e 100644
--- a/src/modules/history/components/options/OptionsListItem.tsx
+++ b/src/modules/options/components/OptionsListItem.tsx
@@ -7,9 +7,10 @@ import {
Switch,
} from '@material-ui/core';
import * as React from 'react';
-import { Option, StorageValuesOptions } from '../../../../services/BrowserStorage';
-import { EventDispatcher, Events } from '../../../../services/Events';
+import { Option, StorageValuesOptions } from '../../../common/BrowserStorage';
+import { EventDispatcher } from '../../../common/Events';
import { StreamingServiceOptions } from './StreamingServiceOptions';
+import { StreamingServiceId } from '../../../streaming-services/streaming-services';
interface OptionsListItemProps {
option: Option;
@@ -19,7 +20,7 @@ export const OptionsListItem: React.FC = (props: OptionsLi
const { option } = props;
const onChange = async () => {
- await EventDispatcher.dispatch(Events.OPTIONS_CHANGE, null, {
+ await EventDispatcher.dispatch('OPTIONS_CHANGE', null, {
id: option.id,
value: !option.value,
});
@@ -30,9 +31,9 @@ export const OptionsListItem: React.FC = (props: OptionsLi
return;
}
await EventDispatcher.dispatch(
- Events.STREAMING_SERVICE_OPTIONS_CHANGE,
+ 'STREAMING_SERVICE_OPTIONS_CHANGE',
null,
- Object.keys(option.value).map((id) => ({ id, value: true }))
+ (Object.keys(option.value) as StreamingServiceId[]).map((id) => ({ id, value: true }))
);
};
@@ -41,9 +42,9 @@ export const OptionsListItem: React.FC = (props: OptionsLi
return;
}
await EventDispatcher.dispatch(
- Events.STREAMING_SERVICE_OPTIONS_CHANGE,
+ 'STREAMING_SERVICE_OPTIONS_CHANGE',
null,
- Object.keys(option.value).map((id) => ({ id, value: false }))
+ (Object.keys(option.value) as StreamingServiceId[]).map((id) => ({ id, value: false }))
);
};
@@ -52,9 +53,12 @@ export const OptionsListItem: React.FC = (props: OptionsLi
return;
}
await EventDispatcher.dispatch(
- Events.STREAMING_SERVICE_OPTIONS_CHANGE,
+ 'STREAMING_SERVICE_OPTIONS_CHANGE',
null,
- Object.entries(option.value).map(([id, value]) => ({ id, value: !value }))
+ (Object.entries(option.value) as [StreamingServiceId, boolean][]).map(([id, value]) => ({
+ id,
+ value: !value,
+ }))
);
};
diff --git a/src/modules/history/components/options/StreamingServiceOption.tsx b/src/modules/options/components/StreamingServiceOption.tsx
similarity index 75%
rename from src/modules/history/components/options/StreamingServiceOption.tsx
rename to src/modules/options/components/StreamingServiceOption.tsx
index f26505da..94f4e0ab 100644
--- a/src/modules/history/components/options/StreamingServiceOption.tsx
+++ b/src/modules/options/components/StreamingServiceOption.tsx
@@ -1,7 +1,10 @@
import { ListItem, ListItemSecondaryAction, ListItemText, Switch } from '@material-ui/core';
import * as React from 'react';
-import { EventDispatcher, Events } from '../../../../services/Events';
-import { StreamingServiceId, streamingServices } from '../../../../streaming-services';
+import { EventDispatcher } from '../../../common/Events';
+import {
+ StreamingServiceId,
+ streamingServices,
+} from '../../../streaming-services/streaming-services';
interface StreamingServiceOptionProps {
id: StreamingServiceId;
@@ -16,7 +19,7 @@ export const StreamingServiceOption: React.FC = (
const service = streamingServices[id];
const onChange = async () => {
- await EventDispatcher.dispatch(Events.STREAMING_SERVICE_OPTIONS_CHANGE, null, [
+ await EventDispatcher.dispatch('STREAMING_SERVICE_OPTIONS_CHANGE', null, [
{ id, value: !value },
]);
};
diff --git a/src/modules/history/components/options/StreamingServiceOptions.tsx b/src/modules/options/components/StreamingServiceOptions.tsx
similarity index 69%
rename from src/modules/history/components/options/StreamingServiceOptions.tsx
rename to src/modules/options/components/StreamingServiceOptions.tsx
index 12c1ed47..91db26f5 100644
--- a/src/modules/history/components/options/StreamingServiceOptions.tsx
+++ b/src/modules/options/components/StreamingServiceOptions.tsx
@@ -1,7 +1,7 @@
import { List } from '@material-ui/core';
import * as React from 'react';
import { StreamingServiceOption } from './StreamingServiceOption';
-import { StreamingServiceId } from '../../../../streaming-services';
+import { StreamingServiceId } from '../../../streaming-services/streaming-services';
interface StreamingServiceOptionsProps {
options: Record;
@@ -13,9 +13,11 @@ export const StreamingServiceOptions: React.FC = (
const { options } = props;
return (
- {(Object.entries(options) as [StreamingServiceId, boolean][]).map(([id, value]) => (
-
- ))}
+ {(Object.entries(options) as [StreamingServiceId, boolean][])
+ .sort(([idA], [idB]) => idA.localeCompare(idB))
+ .map(([id, value]) => (
+
+ ))}
);
};
diff --git a/src/modules/options/options.tsx b/src/modules/options/options.tsx
new file mode 100644
index 00000000..70ae7502
--- /dev/null
+++ b/src/modules/options/options.tsx
@@ -0,0 +1,20 @@
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+import '../../assets/assets';
+import { BrowserStorage } from '../../common/BrowserStorage';
+import { Errors } from '../../common/Errors';
+import { Shared } from '../../common/Shared';
+import { OptionsApp } from './OptionsApp';
+
+const init = async () => {
+ Shared.isBackgroundPage = true;
+ await BrowserStorage.sync();
+ const values = await BrowserStorage.get('options');
+ if (values.options && values.options.allowRollbar) {
+ Errors.startRollbar();
+ }
+ const root = document.querySelector('#root');
+ ReactDOM.render( , root);
+};
+
+void init();
diff --git a/src/modules/popup/PopupApp.tsx b/src/modules/popup/PopupApp.tsx
new file mode 100644
index 00000000..a1fae5a3
--- /dev/null
+++ b/src/modules/popup/PopupApp.tsx
@@ -0,0 +1,62 @@
+import { Box } from '@material-ui/core';
+import { createBrowserHistory } from 'history';
+import * as React from 'react';
+import { useEffect, useState } from 'react';
+import { Redirect, Route, Router, Switch } from 'react-router-dom';
+import { ErrorBoundary } from '../../components/ErrorBoundary';
+import { EventDispatcher } from '../../common/Events';
+import { Session } from '../../common/Session';
+import { PopupHeader } from './components/PopupHeader';
+import { AboutPage } from './pages/AboutPage';
+import { HomePage } from './pages/HomePage';
+import { LoginPage } from './pages/LoginPage';
+
+const history = createBrowserHistory();
+
+export const PopupApp: React.FC = () => {
+ const [isLoggedIn, setLoggedIn] = useState(Session.isLoggedIn);
+
+ useEffect(() => {
+ const startListeners = () => {
+ EventDispatcher.subscribe('LOGIN_SUCCESS', null, onLogin);
+ EventDispatcher.subscribe('LOGOUT_SUCCESS', null, onLogout);
+ };
+
+ const stopListeners = () => {
+ EventDispatcher.unsubscribe('LOGIN_SUCCESS', null, onLogin);
+ EventDispatcher.unsubscribe('LOGOUT_SUCCESS', null, onLogout);
+ };
+
+ const onLogin = () => {
+ setLoggedIn(true);
+ };
+
+ const onLogout = () => {
+ setLoggedIn(false);
+ history.push('/login');
+ };
+
+ startListeners();
+ return stopListeners;
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/modules/popup/components/PopupHeader.tsx b/src/modules/popup/components/PopupHeader.tsx
new file mode 100644
index 00000000..ec0a021b
--- /dev/null
+++ b/src/modules/popup/components/PopupHeader.tsx
@@ -0,0 +1,70 @@
+import { AppBar, Button, Toolbar } from '@material-ui/core';
+import { History } from 'history';
+import * as PropTypes from 'prop-types';
+import * as React from 'react';
+import { UtsLeftRight } from '../../../components/UtsLeftRight';
+import { Session } from '../../../common/Session';
+import { Tabs } from '../../../common/Tabs';
+
+interface IPopupHeader {
+ history: History;
+ isLoggedIn: boolean;
+}
+
+export const PopupHeader: React.FC = ({ history, isLoggedIn }) => {
+ const onRouteClick = (path: string) => {
+ history.push(path);
+ };
+
+ const onLinkClick = async (url: string): Promise => {
+ await Tabs.open(url);
+ };
+
+ const onLogoutClick = async (): Promise => {
+ await Session.logout();
+ };
+
+ return (
+
+
+
+ onRouteClick('/home')}>
+ {browser.i18n.getMessage('home')}
+
+ onRouteClick('/about')}>
+ {browser.i18n.getMessage('about')}
+
+ onLinkClick(browser.runtime.getURL('html/history.html'))}
+ >
+ {browser.i18n.getMessage('history')}
+
+ onLinkClick(browser.runtime.getURL('html/options.html'))}
+ >
+ {browser.i18n.getMessage('options')}
+
+ >
+ }
+ right={
+ isLoggedIn ? (
+
+ {browser.i18n.getMessage('logout')}
+
+ ) : undefined
+ }
+ />
+
+
+ );
+};
+
+PopupHeader.propTypes = {
+ history: PropTypes.any.isRequired,
+ isLoggedIn: PropTypes.bool.isRequired,
+};
diff --git a/src/modules/popup/components/PopupInfo.tsx b/src/modules/popup/components/PopupInfo.tsx
new file mode 100644
index 00000000..7a6adeb2
--- /dev/null
+++ b/src/modules/popup/components/PopupInfo.tsx
@@ -0,0 +1,16 @@
+import { Box } from '@material-ui/core';
+import * as PropTypes from 'prop-types';
+import * as React from 'react';
+import { UtsCenter } from '../../../components/UtsCenter';
+
+export const PopupInfo: React.FC = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+PopupInfo.propTypes = {
+ children: PropTypes.node.isRequired,
+};
diff --git a/src/modules/popup/components/PopupNotWatching.tsx b/src/modules/popup/components/PopupNotWatching.tsx
new file mode 100644
index 00000000..8930b01c
--- /dev/null
+++ b/src/modules/popup/components/PopupNotWatching.tsx
@@ -0,0 +1,11 @@
+import { Typography } from '@material-ui/core';
+import * as React from 'react';
+import { PopupInfo } from './PopupInfo';
+
+export const PopupNotWatching: React.FC = () => {
+ return (
+
+ {browser.i18n.getMessage('notWatching')}
+
+ );
+};
diff --git a/src/modules/popup/components/PopupTmdbImage.tsx b/src/modules/popup/components/PopupTmdbImage.tsx
new file mode 100644
index 00000000..de1eccb0
--- /dev/null
+++ b/src/modules/popup/components/PopupTmdbImage.tsx
@@ -0,0 +1,126 @@
+import { Box } from '@material-ui/core';
+import * as PropTypes from 'prop-types';
+import * as React from 'react';
+import { useEffect, useState } from 'react';
+import { TraktItem } from '../../../models/TraktItem';
+import { secrets } from '../../../secrets';
+import { Errors } from '../../../common/Errors';
+import { Requests } from '../../../common/Requests';
+
+interface IPopupTmdbImage {
+ item: TraktItem;
+}
+
+export interface TmdbConfigResponse {
+ images?: {
+ secure_base_url?: string;
+ poster_sizes?: string[];
+ still_sizes?: string[];
+ };
+}
+
+export type TmdbImageResponse = TmdbShowImageResponse | TmdbMovieImageResponse;
+
+export interface TmdbShowImageResponse {
+ stills: {
+ file_path: string;
+ }[];
+}
+
+export interface TmdbMovieImageResponse {
+ posters: {
+ file_path: string;
+ }[];
+}
+
+export interface TmdbErrorResponse {
+ status_nessage: string;
+ status_code: number;
+}
+
+export const PopupTmdbImage: React.FC = ({ item }) => {
+ const [imageConfig, setImageConfig] = useState({
+ host: '',
+ width: {
+ movie: '',
+ show: '',
+ },
+ });
+ const [imageUrl, setImageUrl] = useState(
+ 'https://trakt.tv/assets/placeholders/thumb/poster-2d5709c1b640929ca1ab60137044b152.png'
+ );
+
+ useEffect(() => {
+ const getConfig = async (): Promise => {
+ try {
+ const responseText = await Requests.send({
+ url: `https://api.themoviedb.org/3/configuration?api_key=${secrets.tmdbApiKey}`,
+ method: 'GET',
+ });
+ const responseJson = JSON.parse(responseText) as TmdbConfigResponse;
+ setImageConfig({
+ host: responseJson.images?.secure_base_url ?? '',
+ width: {
+ movie: responseJson.images?.poster_sizes?.[2] ?? '',
+ show: responseJson.images?.still_sizes?.[2] ?? '',
+ },
+ });
+ } catch (err) {
+ Errors.warning('Failed to get TMDB config.', err);
+ }
+ };
+
+ void getConfig();
+ }, []);
+
+ useEffect(() => {
+ const getImageUrl = async (): Promise => {
+ if (!item?.tmdbId || !imageConfig.host) {
+ return;
+ }
+ try {
+ const responseText = await Requests.send({
+ url: getApiUrl(),
+ method: 'GET',
+ });
+ const responseJson = JSON.parse(responseText) as TmdbImageResponse | TmdbErrorResponse;
+ if (!('status_code' in responseJson)) {
+ const image = 'stills' in responseJson ? responseJson.stills[0] : responseJson.posters[0];
+ if (image) {
+ setImageUrl(`${imageConfig.host}${imageConfig.width[item.type]}${image.file_path}`);
+ }
+ }
+ } catch (err) {
+ Errors.warning('Failed to find item on TMDB.', err);
+ }
+ };
+
+ const getApiUrl = (): string => {
+ let type = '';
+ let path = '';
+ if (item.type === 'show') {
+ type = 'tv';
+ path = `${item.tmdbId.toString()}/season/${item.season?.toString() ?? ''}/episode/${
+ item.episode?.toString() ?? ''
+ }`;
+ } else {
+ type = 'movie';
+ path = item.tmdbId.toString();
+ }
+ return `https://api.themoviedb.org/3/${type}/${path}/images?api_key=${secrets.tmdbApiKey}`;
+ };
+
+ void getImageUrl();
+ }, [imageConfig, item]);
+
+ return (
+
+ );
+};
+
+PopupTmdbImage.propTypes = {
+ item: PropTypes.instanceOf(TraktItem).isRequired,
+};
diff --git a/src/modules/popup/components/PopupWatching.tsx b/src/modules/popup/components/PopupWatching.tsx
new file mode 100644
index 00000000..a56ba1ff
--- /dev/null
+++ b/src/modules/popup/components/PopupWatching.tsx
@@ -0,0 +1,37 @@
+import { Box, Typography } from '@material-ui/core';
+import * as PropTypes from 'prop-types';
+import * as React from 'react';
+import { TraktItem } from '../../../models/TraktItem';
+import { PopupInfo } from './PopupInfo';
+import { PopupTmdbImage } from './PopupTmdbImage';
+
+interface IPopupWatching {
+ item: TraktItem;
+}
+
+export const PopupWatching: React.FC = ({ item }) => {
+ return (
+
+
+
+
+
+ {browser.i18n.getMessage('nowScrobbling')}
+ {item.type === 'show' ? (
+ <>
+ {item.episodeTitle}
+ {browser.i18n.getMessage('from')}
+ {item.title}
+ >
+ ) : (
+ {item.title}
+ )}
+
+
+
+ );
+};
+
+PopupWatching.propTypes = {
+ item: PropTypes.instanceOf(TraktItem).isRequired,
+};
diff --git a/src/modules/popup/pages/AboutPage.tsx b/src/modules/popup/pages/AboutPage.tsx
new file mode 100644
index 00000000..888cf4b5
--- /dev/null
+++ b/src/modules/popup/pages/AboutPage.tsx
@@ -0,0 +1,23 @@
+import { Button, Typography } from '@material-ui/core';
+import * as React from 'react';
+import { Tabs } from '../../../common/Tabs';
+import { PopupInfo } from '../components/PopupInfo';
+
+export const AboutPage: React.FC = () => {
+ const onLinkClick = async (url: string): Promise => {
+ await Tabs.open(url);
+ };
+
+ return (
+
+ {browser.i18n.getMessage('aboutMessage')}
+ onLinkClick('https://github.com/trakt-tools/universal-trakt-scrobbler')}
+ variant="contained"
+ >
+ {browser.i18n.getMessage('readMore')}
+
+
+ );
+};
diff --git a/src/modules/popup/pages/HomePage.tsx b/src/modules/popup/pages/HomePage.tsx
new file mode 100644
index 00000000..30370693
--- /dev/null
+++ b/src/modules/popup/pages/HomePage.tsx
@@ -0,0 +1,56 @@
+import { CircularProgress } from '@material-ui/core';
+import * as React from 'react';
+import { useEffect, useState } from 'react';
+import { useHistory } from 'react-router-dom';
+import { UtsCenter } from '../../../components/UtsCenter';
+import { BrowserStorage } from '../../../common/BrowserStorage';
+import { Session } from '../../../common/Session';
+import { PopupNotWatching } from '../components/PopupNotWatching';
+import { PopupWatching } from '../components/PopupWatching';
+import { TraktItem } from '../../../models/TraktItem';
+
+interface IPopupHomeContent {
+ isLoading: boolean;
+ scrobblingItem: TraktItem | null;
+}
+
+const initialContentState: IPopupHomeContent = {
+ isLoading: true,
+ scrobblingItem: null,
+};
+
+export const HomePage: React.FC = () => {
+ const history = useHistory();
+ const [content, setContent] = useState(initialContentState);
+
+ useEffect(() => {
+ const getScrobblingItem = async (): Promise => {
+ if (Session.isLoggedIn) {
+ const { scrobblingItem } = await BrowserStorage.get('scrobblingItem');
+ setContent({
+ isLoading: false,
+ scrobblingItem: scrobblingItem ? new TraktItem(scrobblingItem) : null,
+ });
+ } else {
+ setContent({ ...initialContentState });
+ history.push('/login');
+ }
+ };
+
+ void getScrobblingItem();
+ }, []);
+
+ let component = null;
+ if (content.isLoading) {
+ component = (
+
+
+
+ );
+ } else if (content.scrobblingItem) {
+ component = ;
+ } else {
+ component = ;
+ }
+ return component;
+};
diff --git a/src/modules/popup/pages/LoginPage.tsx b/src/modules/popup/pages/LoginPage.tsx
new file mode 100644
index 00000000..a50fb695
--- /dev/null
+++ b/src/modules/popup/pages/LoginPage.tsx
@@ -0,0 +1,57 @@
+import { Button, CircularProgress } from '@material-ui/core';
+import * as React from 'react';
+import { useEffect, useState } from 'react';
+import { useHistory } from 'react-router-dom';
+import { UtsCenter } from '../../../components/UtsCenter';
+import { EventDispatcher } from '../../../common/Events';
+import { Session } from '../../../common/Session';
+
+export const LoginPage: React.FC = () => {
+ const history = useHistory();
+ const [isLoading, setLoading] = useState(true);
+
+ const onLoginClick = async (): Promise => {
+ setLoading(true);
+ await Session.login();
+ };
+
+ useEffect(() => {
+ const startListeners = () => {
+ EventDispatcher.subscribe('LOGIN_SUCCESS', null, onLoginSuccess);
+ EventDispatcher.subscribe('LOGIN_ERROR', null, onLoginError);
+ };
+
+ const stopListeners = () => {
+ EventDispatcher.unsubscribe('LOGIN_SUCCESS', null, onLoginSuccess);
+ EventDispatcher.unsubscribe('LOGIN_ERROR', null, onLoginError);
+ };
+
+ const onLoginSuccess = () => {
+ setLoading(false);
+ history.push('/home');
+ };
+
+ const onLoginError = () => {
+ setLoading(false);
+ };
+
+ startListeners();
+ return stopListeners;
+ }, []);
+
+ useEffect(() => {
+ void Session.checkLogin();
+ }, []);
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+ {browser.i18n.getMessage('login')}
+
+ )}
+
+ );
+};
diff --git a/src/modules/popup/popup.tsx b/src/modules/popup/popup.tsx
new file mode 100644
index 00000000..ccb90be0
--- /dev/null
+++ b/src/modules/popup/popup.tsx
@@ -0,0 +1,20 @@
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+import '../../assets/assets';
+import { BrowserStorage } from '../../common/BrowserStorage';
+import { Errors } from '../../common/Errors';
+import { Shared } from '../../common/Shared';
+import { PopupApp } from './PopupApp';
+
+const init = async () => {
+ Shared.isBackgroundPage = true;
+ await BrowserStorage.sync();
+ const values = await BrowserStorage.get('options');
+ if (values.options && values.options.allowRollbar) {
+ Errors.startRollbar();
+ }
+ const root = document.querySelector('#root');
+ ReactDOM.render( , root);
+};
+
+void init();
diff --git a/src/services/Events.ts b/src/services/Events.ts
deleted file mode 100644
index 72c88e84..00000000
--- a/src/services/Events.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { Item } from '../models/Item';
-import { StoreData } from '../modules/history/streaming-services/common/Store';
-import { StreamingServiceId } from '../streaming-services';
-import { StorageValuesOptions, StorageValuesSyncOptions } from './BrowserStorage';
-import { Errors } from './Errors';
-
-export enum Events {
- LOGIN_SUCCESS,
- LOGIN_ERROR,
- LOGOUT_SUCCESS,
- LOGOUT_ERROR,
- SEARCH_SUCCESS,
- SEARCH_ERROR,
- OPTIONS_CHANGE,
- STREAMING_SERVICE_OPTIONS_CHANGE,
- OPTIONS_CLEAR,
- DIALOG_SHOW,
- SNACKBAR_SHOW,
- WRONG_ITEM_DIALOG_SHOW,
- WRONG_ITEM_CORRECTED,
- HISTORY_OPTIONS_CHANGE,
- STREAMING_SERVICE_STORE_UPDATE,
- STREAMING_SERVICE_HISTORY_LOAD_ERROR,
- STREAMING_SERVICE_HISTORY_CHANGE,
- TRAKT_HISTORY_LOAD_ERROR,
- HISTORY_SYNC_SUCCESS,
- HISTORY_SYNC_ERROR,
-}
-
-export type EventDispatcherListeners = Record<
- string,
- Record[]>
->;
-
-export type EventDispatcherListener = (data: T) => void | Promise;
-
-export interface HistoryOptionsChangeData {
- id: keyof StorageValuesSyncOptions;
- value: boolean | number;
-}
-
-export interface StreamingServiceStoreUpdateData {
- data: StoreData;
-}
-
-export interface StreamingServiceHistoryChangeData {
- index: number;
- checked: boolean;
-}
-
-export interface WrongItemDialogData {
- serviceId?: StreamingServiceId;
- item?: Item;
-}
-
-export interface WrongItemCorrectedData {
- item: Item;
- url: string;
-}
-
-export interface HistorySyncSuccessData {
- added: {
- episodes: number;
- movies: number;
- };
-}
-
-export interface OptionEventData {
- id: K;
- value: StorageValuesOptions[K];
-}
-
-export type StreamingServiceOptionEventData = {
- id: K;
- value: boolean;
-}[];
-
-class _EventDispatcher {
- globalSpecifier = 'all';
- listeners: EventDispatcherListeners;
-
- constructor() {
- this.listeners = {};
- }
-
- subscribe = (
- eventType: Events,
- eventSpecifier: string | null,
- listener: EventDispatcherListener
- ): void => {
- if (!this.listeners[eventType]) {
- this.listeners[eventType] = {};
- }
- if (!this.listeners[eventType][this.globalSpecifier]) {
- this.listeners[eventType][this.globalSpecifier] = [];
- }
- this.listeners[eventType][this.globalSpecifier].push(listener);
- if (!eventSpecifier || eventSpecifier === this.globalSpecifier) {
- return;
- }
- if (!this.listeners[eventType][eventSpecifier]) {
- this.listeners[eventType][eventSpecifier] = [];
- }
- this.listeners[eventType][eventSpecifier].push(listener);
- };
-
- unsubscribe = (
- eventType: Events,
- eventSpecifier: string | null,
- listener: EventDispatcherListener
- ): void => {
- if (!this.listeners[eventType]) {
- return;
- }
- if (this.listeners[eventType][this.globalSpecifier]) {
- this.listeners[eventType][this.globalSpecifier] = this.listeners[eventType][
- this.globalSpecifier
- ].filter((fn) => fn !== listener);
- }
- if (
- eventSpecifier &&
- eventSpecifier !== this.globalSpecifier &&
- this.listeners[eventType][eventSpecifier]
- ) {
- this.listeners[eventType][eventSpecifier] = this.listeners[eventType][eventSpecifier].filter(
- (fn) => fn !== listener
- );
- }
- };
-
- dispatch = async (
- eventType: Events,
- eventSpecifier: string | null,
- data: unknown
- ): Promise => {
- const listeners =
- this.listeners[eventType] &&
- this.listeners[eventType][eventSpecifier || this.globalSpecifier];
- if (!listeners) {
- return;
- }
- for (const listener of listeners) {
- try {
- await listener(data);
- } catch (err) {
- Errors.log('Failed to dispatch.', err);
- }
- }
- };
-}
-
-export const EventDispatcher = new _EventDispatcher();
diff --git a/src/streaming-services.ts b/src/streaming-services.ts
deleted file mode 100644
index c027e89f..00000000
--- a/src/streaming-services.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export type StreamingServiceId = 'netflix' | 'nrk' | 'viaplay';
-
-export interface StreamingService {
- id: StreamingServiceId;
- name: string;
- homePage: string;
- hostPatterns: string[];
-}
-
-export const streamingServices: Record = {
- netflix: {
- id: 'netflix',
- name: 'Netflix',
- homePage: 'https://www.netflix.com/',
- hostPatterns: ['*://*.netflix.com/*'],
- },
- nrk: {
- id: 'nrk',
- name: 'NRK',
- homePage: 'https://tv.nrk.no/',
- hostPatterns: ['*://*.tv.nrk.no/*'],
- },
- viaplay: {
- id: 'viaplay',
- name: 'Viaplay',
- homePage: 'https://viaplay.no/',
- hostPatterns: ['*://*.viaplay.no/*'],
- },
-};
diff --git a/src/streaming-services/amazon-prime/AmazonPrimeApi.ts b/src/streaming-services/amazon-prime/AmazonPrimeApi.ts
new file mode 100644
index 00000000..853c61c5
--- /dev/null
+++ b/src/streaming-services/amazon-prime/AmazonPrimeApi.ts
@@ -0,0 +1,86 @@
+import { Item } from '../../models/Item';
+import { Errors } from '../../common/Errors';
+import { Requests } from '../../common/Requests';
+import { Api } from '../common/Api';
+import { registerApi } from '../common/common';
+
+export interface AmazonPrimeMetadataItem {
+ catalogMetadata: {
+ catalog: {
+ entityType: 'TV Show' | 'Movie';
+ episodeNumber?: number;
+ id: string;
+ title: string;
+ };
+ family?: {
+ tvAncestors: [
+ {
+ catalog: {
+ seasonNumber: number;
+ };
+ },
+ {
+ catalog: {
+ title: string;
+ };
+ }
+ ];
+ };
+ };
+}
+
+class _AmazonPrimeApi extends Api {
+ API_URL: string;
+
+ constructor() {
+ super('amazon-prime');
+
+ this.API_URL = 'https://atv-ps.primevideo.com';
+ }
+
+ loadHistory = (nextPage: number, nextVisualPage: number, itemsToLoad: number): Promise => {
+ return Promise.resolve();
+ };
+
+ getItem = async (id: string): Promise- => {
+ let item: Item | undefined;
+ try {
+ const responseText = await Requests.send({
+ url: `${this.API_URL}/cdp/catalog/GetPlaybackResources?asin=${id}&consumptionType=Streaming&desiredResources=CatalogMetadata&deviceID=21de9f61b9ea631b704325f9bb991dd53891cdebfddeb6c73ce1efad&deviceTypeID=AOAGZA014O5RE&firmware=1&gascEnabled=true&resourceUsage=CacheResources&videoMaterialType=Feature&titleDecorationScheme=primary-content&uxLocale=en_US`,
+ method: 'GET',
+ });
+ item = this.parseMetadata(JSON.parse(responseText) as AmazonPrimeMetadataItem);
+ } catch (err) {
+ Errors.error('Failed to get item.', err);
+ }
+ return item;
+ };
+
+ parseMetadata = (metadata: AmazonPrimeMetadataItem): Item => {
+ let item: Item;
+ const { catalog, family } = metadata.catalogMetadata;
+ const { id, entityType } = catalog;
+ const type = entityType === 'TV Show' ? 'show' : 'movie';
+ const year = 0;
+ if (type === 'show') {
+ let title = '';
+ let season;
+ if (family) {
+ const [seasonInfo, showInfo] = family.tvAncestors;
+ title = showInfo.catalog.title;
+ season = seasonInfo.catalog.seasonNumber;
+ }
+ const { episodeNumber: episode, title: episodeTitle } = catalog;
+ const isCollection = false;
+ item = new Item({ id, type, title, year, isCollection, season, episode, episodeTitle });
+ } else {
+ const { title } = catalog;
+ item = new Item({ id, type, title, year });
+ }
+ return item;
+ };
+}
+
+export const AmazonPrimeApi = new _AmazonPrimeApi();
+
+registerApi('amazon-prime', AmazonPrimeApi);
diff --git a/src/streaming-services/amazon-prime/AmazonPrimeEvents.ts b/src/streaming-services/amazon-prime/AmazonPrimeEvents.ts
new file mode 100644
index 00000000..d07dcd20
--- /dev/null
+++ b/src/streaming-services/amazon-prime/AmazonPrimeEvents.ts
@@ -0,0 +1,78 @@
+import { registerScrobbleEvents } from '../common/common';
+import { ScrobbleEvents } from '../common/ScrobbleEvents';
+import { AmazonPrimeParser } from './AmazonPrimeParser';
+
+class _AmazonPrimeEvents extends ScrobbleEvents {
+ progress: number;
+ url: string;
+ videoId: string;
+
+ constructor() {
+ super();
+
+ this.progress = 0.0;
+ this.url = '';
+ this.videoId = '';
+ }
+
+ startListeners = () => {
+ super.startListeners();
+ document.body.addEventListener('click', (event) => void this.onClick(event), true);
+ };
+
+ onClick = async (event: Event) => {
+ const target: HTMLElement = event.target as HTMLElement;
+ const playButton: HTMLElement | null =
+ target.dataset.asin || target.dataset.titleId
+ ? target
+ : target.closest('[data-asin], [data-title-id]');
+ if (playButton) {
+ const videoId = playButton.dataset.asin || playButton.dataset.titleId;
+ if (videoId !== this.videoId) {
+ if (this.isPlaying) {
+ await this.stop();
+ }
+ this.videoId = videoId ?? '';
+ AmazonPrimeParser.id = this.videoId;
+ await this.start();
+ this.isPaused = false;
+ this.isPlaying = true;
+ }
+ }
+ };
+
+ checkForChanges = async (): Promise
=> {
+ if (this.videoId) {
+ const session = AmazonPrimeParser.parseSession();
+ if (this.isPaused !== session.paused || this.isPlaying !== session.playing) {
+ if (session.paused) {
+ if (!this.isPaused) {
+ await this.pause();
+ }
+ } else if (session.playing) {
+ if (!this.isPlaying) {
+ await this.start();
+ }
+ } else if (this.isPlaying) {
+ await this.stop();
+ this.videoId = '';
+ AmazonPrimeParser.id = '';
+ }
+ this.isPaused = session.paused;
+ this.isPlaying = session.playing;
+ }
+ if (this.isPlaying) {
+ const newProgress = session.progress;
+ if (this.progress !== newProgress) {
+ await this.updateProgress(newProgress);
+ this.progress = newProgress;
+ }
+ }
+ }
+ this.changeListenerId = window.setTimeout(() => void this.checkForChanges(), 500);
+ };
+}
+
+export const AmazonPrimeEvents = new _AmazonPrimeEvents();
+
+registerScrobbleEvents('amazon-prime', AmazonPrimeEvents);
diff --git a/src/streaming-services/amazon-prime/AmazonPrimeParser.ts b/src/streaming-services/amazon-prime/AmazonPrimeParser.ts
new file mode 100644
index 00000000..12a7dcec
--- /dev/null
+++ b/src/streaming-services/amazon-prime/AmazonPrimeParser.ts
@@ -0,0 +1,44 @@
+import { Item } from '../../models/Item';
+import { AmazonPrimeApi } from './AmazonPrimeApi';
+import { ScrobbleParser } from '../common/ScrobbleController';
+import { registerScrobbleParser } from '../common/common';
+
+export interface AmazonPrimeSession {
+ playing: boolean;
+ paused: boolean;
+ progress: number;
+}
+
+class _AmazonPrimeParser implements ScrobbleParser {
+ id: string;
+
+ constructor() {
+ this.id = '';
+ }
+
+ parseItem = async (): Promise- => {
+ const item = this.id ? await AmazonPrimeApi.getItem(this.id) : undefined;
+ return item;
+ };
+
+ parseSession = (): AmazonPrimeSession => {
+ const loadingSpinner = document.querySelector('.loadingSpinner:not([style="display: none;"])');
+ const playing = !!loadingSpinner || !!document.querySelector('.pausedIcon');
+ const paused = !!document.querySelector('.playIcon');
+ const progress = this.parseProgress();
+ return { playing, paused, progress };
+ };
+
+ parseProgress = (): number => {
+ let progress = 0.0;
+ const scrubber: HTMLElement | null = document.querySelector('.positionBar:not(.vertical)');
+ if (scrubber) {
+ progress = parseFloat(scrubber.style.width);
+ }
+ return progress;
+ };
+}
+
+export const AmazonPrimeParser = new _AmazonPrimeParser();
+
+registerScrobbleParser('amazon-prime', AmazonPrimeParser);
diff --git a/src/streaming-services/amazon-prime/amazon-prime.ts b/src/streaming-services/amazon-prime/amazon-prime.ts
new file mode 100644
index 00000000..a130fb0c
--- /dev/null
+++ b/src/streaming-services/amazon-prime/amazon-prime.ts
@@ -0,0 +1,4 @@
+import { init } from '../common/content';
+import './AmazonPrimeEvents';
+
+void init('amazon-prime');
diff --git a/src/modules/history/streaming-services/common/Api.ts b/src/streaming-services/common/Api.ts
similarity index 64%
rename from src/modules/history/streaming-services/common/Api.ts
rename to src/streaming-services/common/Api.ts
index edb72c14..42227d15 100644
--- a/src/modules/history/streaming-services/common/Api.ts
+++ b/src/streaming-services/common/Api.ts
@@ -1,12 +1,12 @@
-import { TraktSearch } from '../../../../api/TraktSearch';
-import { TraktSync } from '../../../../api/TraktSync';
-import { Item } from '../../../../models/Item';
-import { ISyncItem } from '../../../../models/SyncItem';
-import { BrowserStorage } from '../../../../services/BrowserStorage';
-import { Errors } from '../../../../services/Errors';
-import { EventDispatcher, Events } from '../../../../services/Events';
-import { StreamingServiceId } from '../../../../streaming-services';
-import { getStore } from './common';
+import { TraktSearch } from '../../api/TraktSearch';
+import { TraktSync } from '../../api/TraktSync';
+import { Item } from '../../models/Item';
+import { TraktItem, TraktItemBase } from '../../models/TraktItem';
+import { BrowserStorage } from '../../common/BrowserStorage';
+import { Errors } from '../../common/Errors';
+import { EventDispatcher } from '../../common/Events';
+import { StreamingServiceId } from '../streaming-services';
+import { getSyncStore } from './common';
export abstract class Api {
id: StreamingServiceId;
@@ -30,16 +30,16 @@ export abstract class Api {
traktCache = {};
}
const promises = [];
- const items = getStore(this.id).data.items;
+ const items = getSyncStore(this.id).data.items;
for (const item of items) {
promises.push(this.loadTraktItemHistory(item, traktCache, correctUrls?.[this.id][item.id]));
}
await Promise.all(promises);
await BrowserStorage.set({ traktCache }, false);
- await getStore(this.id).update();
+ await getSyncStore(this.id).update();
} catch (err) {
Errors.error('Failed to load Trakt history.', err);
- await EventDispatcher.dispatch(Events.TRAKT_HISTORY_LOAD_ERROR, null, {
+ await EventDispatcher.dispatch('TRAKT_HISTORY_LOAD_ERROR', null, {
error: err as Error,
});
}
@@ -47,7 +47,7 @@ export abstract class Api {
loadTraktItemHistory = async (
item: Item,
- traktCache: Record
>,
+ traktCache: Record,
url?: string
) => {
if (item.trakt && !url) {
@@ -56,16 +56,13 @@ export abstract class Api {
try {
const cacheId = this.getTraktCacheId(item);
const cacheItem = traktCache[cacheId];
- item.trakt = url || !cacheItem ? await TraktSearch.find(item, url) : cacheItem;
+ item.trakt = url || !cacheItem ? await TraktSearch.find(item, url) : new TraktItem(cacheItem);
await TraktSync.loadHistory(item);
if (item.trakt) {
- const { watchedAt, ...cacheItem } = item.trakt;
- traktCache[cacheId] = cacheItem;
+ traktCache[cacheId] = TraktItem.getBase(item.trakt);
}
} catch (err) {
- item.trakt = {
- notFound: true,
- };
+ item.trakt = null;
}
};
diff --git a/src/streaming-services/common/ScrobbleController.ts b/src/streaming-services/common/ScrobbleController.ts
new file mode 100644
index 00000000..4ae45f8a
--- /dev/null
+++ b/src/streaming-services/common/ScrobbleController.ts
@@ -0,0 +1,78 @@
+import { TraktScrobble } from '../../api/TraktScrobble';
+import { TraktSearch } from '../../api/TraktSearch';
+import { Item } from '../../models/Item';
+import { TraktItem } from '../../models/TraktItem';
+import { BrowserStorage } from '../../common/BrowserStorage';
+import { Errors } from '../../common/Errors';
+import { EventDispatcher, ScrobbleProgressData } from '../../common/Events';
+
+export interface ScrobbleParser {
+ parseItem(): Promise- ;
+}
+
+export class ScrobbleController {
+ parser: ScrobbleParser;
+ item: Item | undefined;
+ reachedScrobbleThreshold: boolean;
+ scrobbleThreshold: number;
+
+ constructor(parser: ScrobbleParser) {
+ this.parser = parser;
+ this.reachedScrobbleThreshold = false;
+ this.scrobbleThreshold = 80.0;
+ }
+
+ startListeners = () => {
+ EventDispatcher.subscribe('SCROBBLE_START', null, this.onStart);
+ EventDispatcher.subscribe('SCROBBLE_PAUSE', null, this.onPause);
+ EventDispatcher.subscribe('SCROBBLE_STOP', null, this.onStop);
+ EventDispatcher.subscribe('SCROBBLE_PROGRESS', null, this.onProgress);
+ };
+
+ onStart = async (): Promise
=> {
+ try {
+ this.reachedScrobbleThreshold = false;
+ if (!this.item) {
+ this.item = await this.parser.parseItem();
+ }
+ if (this.item) {
+ if (!this.item.trakt) {
+ this.item.trakt = await TraktSearch.find(this.item);
+ }
+ if (this.item.trakt) {
+ await TraktScrobble.start(this.item.trakt);
+ await BrowserStorage.set({ scrobblingItem: TraktItem.getBase(this.item.trakt) }, false);
+ }
+ }
+ } catch (err) {
+ Errors.log('Failed to parse item.', err);
+ }
+ };
+
+ onPause = async (): Promise => {
+ if (this.item?.trakt) {
+ await TraktScrobble.pause(this.item.trakt);
+ }
+ };
+
+ onStop = async (): Promise => {
+ if (this.item?.trakt) {
+ await TraktScrobble.stop(this.item.trakt);
+ await BrowserStorage.remove('scrobblingItem');
+ }
+ this.item = undefined;
+ this.reachedScrobbleThreshold = false;
+ };
+
+ onProgress = async (data: ScrobbleProgressData): Promise => {
+ if (!this.item?.trakt) {
+ return;
+ }
+ this.item.trakt.progress = data.progress;
+ if (!this.reachedScrobbleThreshold && this.item.trakt.progress > this.scrobbleThreshold) {
+ // Update the stored progress after reaching the scrobble threshold to make sure that the item is scrobbled on tab close.
+ await BrowserStorage.set({ scrobblingItem: TraktItem.getBase(this.item.trakt) }, false);
+ this.reachedScrobbleThreshold = true;
+ }
+ };
+}
diff --git a/src/streaming-services/common/ScrobbleEvents.ts b/src/streaming-services/common/ScrobbleEvents.ts
new file mode 100644
index 00000000..a22a56ee
--- /dev/null
+++ b/src/streaming-services/common/ScrobbleEvents.ts
@@ -0,0 +1,59 @@
+import { EventDispatcher } from '../../common/Events';
+
+export abstract class ScrobbleEvents {
+ changeListenerId: number | null;
+ isPaused: boolean;
+ isPlaying: boolean;
+
+ constructor() {
+ this.changeListenerId = null;
+ this.isPaused = false;
+ this.isPlaying = false;
+ }
+
+ startListeners = () => {
+ this.addChangeListener();
+ };
+
+ stopListeners = () => {
+ this.stopChangeListener();
+ };
+
+ getLocation = (): string => {
+ return window.location.href;
+ };
+
+ addChangeListener = () => {
+ void this.checkForChanges();
+ };
+
+ stopChangeListener = () => {
+ if (this.changeListenerId !== null) {
+ window.clearTimeout(this.changeListenerId);
+ }
+ this.changeListenerId = null;
+ };
+
+ abstract async checkForChanges(): Promise;
+
+ start = async (): Promise => {
+ await EventDispatcher.dispatch('SCROBBLE_START', null, {});
+ await EventDispatcher.dispatch('SCROBBLE_ACTIVE', null, {});
+ };
+
+ pause = async (): Promise => {
+ await EventDispatcher.dispatch('SCROBBLE_PAUSE', null, {});
+ await EventDispatcher.dispatch('SCROBBLE_INACTIVE', null, {});
+ };
+
+ stop = async (): Promise => {
+ await EventDispatcher.dispatch('SCROBBLE_STOP', null, {});
+ if (!this.isPaused) {
+ await EventDispatcher.dispatch('SCROBBLE_INACTIVE', null, {});
+ }
+ };
+
+ updateProgress = async (newProgress: number): Promise => {
+ await EventDispatcher.dispatch('SCROBBLE_PROGRESS', null, { progress: newProgress });
+ };
+}
diff --git a/src/modules/history/streaming-services/common/Page.tsx b/src/streaming-services/common/SyncPage.tsx
similarity index 65%
rename from src/modules/history/streaming-services/common/Page.tsx
rename to src/streaming-services/common/SyncPage.tsx
index 5c7afe2e..a5f781ee 100644
--- a/src/modules/history/streaming-services/common/Page.tsx
+++ b/src/streaming-services/common/SyncPage.tsx
@@ -2,37 +2,31 @@ import { Box, CircularProgress } from '@material-ui/core';
import * as PropTypes from 'prop-types';
import * as React from 'react';
import { useEffect, useState } from 'react';
-import { TraktSettings } from '../../../../api/TraktSettings';
-import { TraktSync } from '../../../../api/TraktSync';
-import { UtsCenter } from '../../../../components/UtsCenter';
-import { Item } from '../../../../models/Item';
-import { ISyncItem } from '../../../../models/SyncItem';
-import {
- BrowserStorage,
- StorageValuesSyncOptions,
- SyncOptions,
-} from '../../../../services/BrowserStorage';
-import { Errors } from '../../../../services/Errors';
+import { TraktSettings } from '../../api/TraktSettings';
+import { TraktSync } from '../../api/TraktSync';
+import { UtsCenter } from '../../components/UtsCenter';
+import { Item } from '../../models/Item';
+import { BrowserStorage, StorageValuesSyncOptions, SyncOptions } from '../../common/BrowserStorage';
+import { Errors } from '../../common/Errors';
import {
EventDispatcher,
- Events,
HistoryOptionsChangeData,
HistorySyncSuccessData,
StreamingServiceStoreUpdateData,
WrongItemCorrectedData,
-} from '../../../../services/Events';
-import { StreamingServiceId } from '../../../../streaming-services';
-import { HistoryActions } from '../../components/history/HistoryActions';
-import { HistoryList } from '../../components/history/HistoryList';
-import { HistoryOptionsList } from '../../components/history/HistoryOptionsList';
+} from '../../common/Events';
+import { StreamingServiceId } from '../streaming-services';
+import { HistoryActions } from '../../modules/history/components/history/HistoryActions';
+import { HistoryList } from '../../modules/history/components/history/HistoryList';
+import { HistoryOptionsList } from '../../modules/history/components/history/HistoryOptionsList';
import { Api } from './Api';
-import { getApi, getStore } from './common';
-import { Store } from './Store';
+import { getApi, getSyncStore } from './common';
+import { SyncStore } from './SyncStore';
interface PageProps {
serviceId: StreamingServiceId;
serviceName: string;
- store: Store;
+ store: SyncStore;
api: Api;
}
@@ -49,7 +43,7 @@ interface Content {
items: Item[];
}
-export const Page: React.FC = (props: PageProps) => {
+export const SyncPage: React.FC = (props: PageProps) => {
const { serviceId, serviceName, store, api } = props;
const [optionsContent, setOptionsContent] = useState({
@@ -100,30 +94,22 @@ export const Page: React.FC = (props: PageProps) => {
useEffect(() => {
const startListeners = () => {
- EventDispatcher.subscribe(Events.STREAMING_SERVICE_STORE_UPDATE, null, onStoreUpdate);
- EventDispatcher.subscribe(
- Events.STREAMING_SERVICE_HISTORY_LOAD_ERROR,
- null,
- onHistoryLoadError
- );
- EventDispatcher.subscribe(Events.TRAKT_HISTORY_LOAD_ERROR, null, onTraktHistoryLoadError);
- EventDispatcher.subscribe(Events.WRONG_ITEM_CORRECTED, serviceId, onWrongItemCorrected);
- EventDispatcher.subscribe(Events.HISTORY_SYNC_SUCCESS, null, onHistorySyncSuccess);
- EventDispatcher.subscribe(Events.HISTORY_SYNC_ERROR, null, onHistorySyncError);
+ EventDispatcher.subscribe('STREAMING_SERVICE_STORE_UPDATE', null, onStoreUpdate);
+ EventDispatcher.subscribe('STREAMING_SERVICE_HISTORY_LOAD_ERROR', null, onHistoryLoadError);
+ EventDispatcher.subscribe('TRAKT_HISTORY_LOAD_ERROR', null, onTraktHistoryLoadError);
+ EventDispatcher.subscribe('WRONG_ITEM_CORRECTED', serviceId, onWrongItemCorrected);
+ EventDispatcher.subscribe('HISTORY_SYNC_SUCCESS', null, onHistorySyncSuccess);
+ EventDispatcher.subscribe('HISTORY_SYNC_ERROR', null, onHistorySyncError);
store.startListeners();
};
const stopListeners = () => {
- EventDispatcher.unsubscribe(Events.STREAMING_SERVICE_STORE_UPDATE, null, onStoreUpdate);
- EventDispatcher.unsubscribe(
- Events.STREAMING_SERVICE_HISTORY_LOAD_ERROR,
- null,
- onHistoryLoadError
- );
- EventDispatcher.unsubscribe(Events.TRAKT_HISTORY_LOAD_ERROR, null, onTraktHistoryLoadError);
- EventDispatcher.unsubscribe(Events.WRONG_ITEM_CORRECTED, serviceId, onWrongItemCorrected);
- EventDispatcher.unsubscribe(Events.HISTORY_SYNC_SUCCESS, null, onHistorySyncSuccess);
- EventDispatcher.unsubscribe(Events.HISTORY_SYNC_ERROR, null, onHistorySyncError);
+ EventDispatcher.unsubscribe('STREAMING_SERVICE_STORE_UPDATE', null, onStoreUpdate);
+ EventDispatcher.unsubscribe('STREAMING_SERVICE_HISTORY_LOAD_ERROR', null, onHistoryLoadError);
+ EventDispatcher.unsubscribe('TRAKT_HISTORY_LOAD_ERROR', null, onTraktHistoryLoadError);
+ EventDispatcher.unsubscribe('WRONG_ITEM_CORRECTED', serviceId, onWrongItemCorrected);
+ EventDispatcher.unsubscribe('HISTORY_SYNC_SUCCESS', null, onHistorySyncSuccess);
+ EventDispatcher.unsubscribe('HISTORY_SYNC_ERROR', null, onHistorySyncError);
store.stopListeners();
};
@@ -135,14 +121,14 @@ export const Page: React.FC = (props: PageProps) => {
};
const onHistoryLoadError = async () => {
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'loadHistoryError',
severity: 'error',
});
};
const onTraktHistoryLoadError = async () => {
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'loadTraktHistoryError',
severity: 'error',
});
@@ -156,11 +142,11 @@ export const Page: React.FC = (props: PageProps) => {
}
await getApi(serviceId).loadTraktItemHistory(data.item, traktCache, data.url);
await BrowserStorage.set({ traktCache }, false);
- await getStore(serviceId).update();
+ await getSyncStore(serviceId).update();
};
const onHistorySyncSuccess = async (data: HistorySyncSuccessData) => {
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageArgs: [data.added.episodes.toString(), data.added.movies.toString()],
messageName: 'historySyncSuccess',
severity: 'success',
@@ -168,7 +154,7 @@ export const Page: React.FC = (props: PageProps) => {
};
const onHistorySyncError = async () => {
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'historySyncError',
severity: 'error',
});
@@ -180,11 +166,11 @@ export const Page: React.FC = (props: PageProps) => {
useEffect(() => {
const startListeners = () => {
- EventDispatcher.subscribe(Events.HISTORY_OPTIONS_CHANGE, null, onOptionsChange);
+ EventDispatcher.subscribe('HISTORY_OPTIONS_CHANGE', null, onOptionsChange);
};
const stopListeners = () => {
- EventDispatcher.unsubscribe(Events.HISTORY_OPTIONS_CHANGE, null, onOptionsChange);
+ EventDispatcher.unsubscribe('HISTORY_OPTIONS_CHANGE', null, onOptionsChange);
};
const onOptionsChange = (data: HistoryOptionsChangeData) => {
@@ -205,14 +191,14 @@ export const Page: React.FC = (props: PageProps) => {
hasLoaded: true,
options,
});
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'saveOptionSuccess',
severity: 'success',
});
})
.catch(async (err) => {
Errors.error('Failed to save option.', err);
- await EventDispatcher.dispatch(Events.SNACKBAR_SHOW, null, {
+ await EventDispatcher.dispatch('SNACKBAR_SHOW', null, {
messageName: 'saveOptionFailed',
severity: 'error',
});
@@ -259,7 +245,7 @@ export const Page: React.FC = (props: PageProps) => {
content.nextVisualPage * optionsContent.options.itemsPerLoad.value
);
if (optionsContent.options.hideSynced.value) {
- itemsToShow = itemsToShow.filter((x) => !x.trakt || !(x.trakt as ISyncItem).watchedAt);
+ itemsToShow = itemsToShow.filter((x) => !x.trakt?.watchedAt);
}
}
@@ -283,9 +269,9 @@ export const Page: React.FC = (props: PageProps) => {
);
};
-Page.propTypes = {
+SyncPage.propTypes = {
serviceId: PropTypes.any.isRequired,
serviceName: PropTypes.string.isRequired,
- store: PropTypes.instanceOf(Store).isRequired,
+ store: PropTypes.instanceOf(SyncStore).isRequired,
api: PropTypes.any.isRequired,
};
diff --git a/src/modules/history/streaming-services/common/Store.ts b/src/streaming-services/common/SyncStore.ts
similarity index 70%
rename from src/modules/history/streaming-services/common/Store.ts
rename to src/streaming-services/common/SyncStore.ts
index ecd0ab89..35bbd527 100644
--- a/src/modules/history/streaming-services/common/Store.ts
+++ b/src/streaming-services/common/SyncStore.ts
@@ -1,9 +1,5 @@
-import { Item } from '../../../../models/Item';
-import {
- EventDispatcher,
- Events,
- StreamingServiceHistoryChangeData,
-} from '../../../../services/Events';
+import { Item } from '../../models/Item';
+import { EventDispatcher, StreamingServiceHistoryChangeData } from '../../common/Events';
export interface StoreData {
isLastPage: boolean;
@@ -12,7 +8,7 @@ export interface StoreData {
items: Item[];
}
-export class Store {
+export class SyncStore {
data: StoreData;
constructor() {
this.data = {
@@ -24,20 +20,19 @@ export class Store {
}
startListeners = (): void => {
- EventDispatcher.subscribe(Events.STREAMING_SERVICE_HISTORY_CHANGE, null, this.onHistoryChange);
- EventDispatcher.subscribe(Events.HISTORY_SYNC_SUCCESS, null, this.onHistorySyncSuccess);
+ EventDispatcher.subscribe('STREAMING_SERVICE_HISTORY_CHANGE', null, this.onHistoryChange);
+ EventDispatcher.subscribe('HISTORY_SYNC_SUCCESS', null, this.onHistorySyncSuccess);
};
stopListeners = (): void => {
- EventDispatcher.unsubscribe(
- Events.STREAMING_SERVICE_HISTORY_CHANGE,
- null,
- this.onHistoryChange
- );
- EventDispatcher.unsubscribe(Events.HISTORY_SYNC_SUCCESS, null, this.onHistorySyncSuccess);
+ EventDispatcher.unsubscribe('STREAMING_SERVICE_HISTORY_CHANGE', null, this.onHistoryChange);
+ EventDispatcher.unsubscribe('HISTORY_SYNC_SUCCESS', null, this.onHistorySyncSuccess);
};
onHistoryChange = (data: StreamingServiceHistoryChangeData): void => {
+ if (typeof data.index === 'undefined') {
+ return;
+ }
const item = this.data.items[data.index];
if (item) {
item.isSelected = data.checked;
@@ -90,7 +85,7 @@ export class Store {
items: [...this.data.items, ...(data.items || [])],
};
}
- await EventDispatcher.dispatch(Events.STREAMING_SERVICE_STORE_UPDATE, null, {
+ await EventDispatcher.dispatch('STREAMING_SERVICE_STORE_UPDATE', null, {
data: this.data,
});
};
diff --git a/src/streaming-services/common/common.ts b/src/streaming-services/common/common.ts
new file mode 100644
index 00000000..6ca93e89
--- /dev/null
+++ b/src/streaming-services/common/common.ts
@@ -0,0 +1,63 @@
+import { StreamingServiceId } from '../streaming-services';
+import { Api } from './Api';
+import { ScrobbleController, ScrobbleParser } from './ScrobbleController';
+import { ScrobbleEvents } from './ScrobbleEvents';
+import { SyncPage } from './SyncPage';
+import { SyncStore } from './SyncStore';
+
+const apis = {} as Record;
+
+export const registerApi = (serviceId: StreamingServiceId, serviceApi: Api) => {
+ apis[serviceId] = serviceApi;
+};
+
+export const getApi = (serviceId: StreamingServiceId) => {
+ return apis[serviceId];
+};
+
+const scrobbleParsers = {} as Record;
+
+export const registerScrobbleParser = (serviceId: StreamingServiceId, parser: ScrobbleParser) => {
+ scrobbleParsers[serviceId] = parser;
+};
+
+const scrobbleControllers = {} as Record;
+
+export const getScrobbleController = (serviceId: StreamingServiceId) => {
+ if (!scrobbleControllers[serviceId]) {
+ scrobbleControllers[serviceId] = new ScrobbleController(scrobbleParsers[serviceId]);
+ }
+ return scrobbleControllers[serviceId];
+};
+
+const scrobbleEvents = {} as Record;
+
+export const registerScrobbleEvents = (serviceId: StreamingServiceId, events: ScrobbleEvents) => {
+ scrobbleEvents[serviceId] = events;
+};
+
+export const getScrobbleEvents = (serviceId: StreamingServiceId) => {
+ return scrobbleEvents[serviceId];
+};
+
+const syncStores = {} as Record;
+
+export const getSyncStore = (serviceId: StreamingServiceId) => {
+ if (!syncStores[serviceId]) {
+ syncStores[serviceId] = new SyncStore();
+ }
+ return syncStores[serviceId];
+};
+
+const syncPageBuilders = {} as Record React.ReactElement | null>;
+
+export const getSyncPageBuilder = (
+ serviceId: StreamingServiceId,
+ serviceName: string
+): (() => React.ReactElement | null) => {
+ if (!syncPageBuilders[serviceId]) {
+ syncPageBuilders[serviceId] = () =>
+ SyncPage({ serviceId, serviceName, store: getSyncStore(serviceId), api: getApi(serviceId) });
+ }
+ return syncPageBuilders[serviceId];
+};
diff --git a/src/streaming-services/common/content.ts b/src/streaming-services/common/content.ts
new file mode 100644
index 00000000..5cbc275a
--- /dev/null
+++ b/src/streaming-services/common/content.ts
@@ -0,0 +1,26 @@
+import { BrowserAction } from '../../common/BrowserAction';
+import { BrowserStorage } from '../../common/BrowserStorage';
+import { Errors } from '../../common/Errors';
+import { Notifications } from '../../common/Notifications';
+import { Shared } from '../../common/Shared';
+import { StreamingServiceId } from '../streaming-services';
+import { getScrobbleController, getScrobbleEvents } from './common';
+
+export const init = async (serviceId: StreamingServiceId) => {
+ Shared.isBackgroundPage = false;
+ await BrowserStorage.sync();
+ const { options } = await BrowserStorage.get('options');
+ if (options) {
+ const { allowRollbar, showNotifications } = options;
+ if (allowRollbar) {
+ Errors.startRollbar();
+ Errors.startListeners();
+ }
+ if (showNotifications) {
+ Notifications.startListeners();
+ }
+ }
+ BrowserAction.startListeners();
+ getScrobbleController(serviceId).startListeners();
+ getScrobbleEvents(serviceId).startListeners();
+};
diff --git a/src/streaming-services/hbo-go/HboGoApi.ts b/src/streaming-services/hbo-go/HboGoApi.ts
new file mode 100644
index 00000000..454e6d4c
--- /dev/null
+++ b/src/streaming-services/hbo-go/HboGoApi.ts
@@ -0,0 +1,139 @@
+import { Item } from '../../models/Item';
+import { Api } from '../common/Api';
+import { registerApi } from '../common/common';
+
+export interface HboGoGlobalObject {
+ analytics: {
+ content: HboGoMetadataItem;
+ paused: boolean;
+ };
+ player: {
+ currentPlaybackProgress: {
+ source: {
+ _value: {
+ progressPercent: number;
+ };
+ };
+ };
+ };
+}
+
+export interface HboGoSession {
+ content: HboGoMetadataItem;
+ playing: boolean;
+ paused: boolean;
+ progress: number;
+}
+
+export type HboGoMetadataItem = HboGoMetadataShowItem | HboGoMetadataMovieItem;
+
+export interface HboGoMetadataShowItem {
+ Category: 'Series';
+ Id: string;
+ Index: number;
+ Name: string;
+ ProductionYear: number;
+ SeasonIndex: number;
+ SeriesName: string;
+}
+
+export interface HboGoMetadataMovieItem {
+ Category: 'Movies';
+ Id: string;
+ Name: string;
+ ProductionYear: number;
+}
+
+class _HboGoApi extends Api {
+ hasInjectedSessionScript: boolean;
+ sessionListener: ((event: Event) => void) | null;
+
+ constructor() {
+ super('hbo-go');
+
+ this.hasInjectedSessionScript = false;
+ this.sessionListener = null;
+ }
+
+ loadHistory = (nextPage: number, nextVisualPage: number, itemsToLoad: number): Promise => {
+ return Promise.resolve();
+ };
+
+ parseMetadata = (metadata: HboGoMetadataItem): Item => {
+ let item: Item;
+ const { Id: id, ProductionYear: year = 0 } = metadata;
+ const type = metadata.Category === 'Series' ? 'show' : 'movie';
+ if (metadata.Category === 'Series') {
+ const title = metadata.SeriesName.trim();
+ const { SeasonIndex: season, Index: episode } = metadata;
+ const episodeTitle = metadata.Name.trim();
+ const isCollection = false;
+ item = new Item({ id, type, title, year, season, episode, episodeTitle, isCollection });
+ } else {
+ const title = metadata.Name.trim();
+ item = new Item({ id, type, title, year });
+ }
+ return item;
+ };
+
+ getSession = (): Promise => {
+ return new Promise((resolve) => {
+ if ('wrappedJSObject' in window && window.wrappedJSObject) {
+ // Firefox wraps page objects, so we can access the global netflix object by unwrapping it.
+ let session: HboGoSession | undefined | null;
+ const { sdk } = window.wrappedJSObject;
+ if (sdk) {
+ const { content, paused } = sdk.analytics;
+ const progress = sdk.player.currentPlaybackProgress.source._value.progressPercent;
+ const playing = typeof progress !== 'undefined' && !paused;
+ session =
+ typeof progress !== 'undefined' && content
+ ? { content, playing, paused, progress }
+ : null;
+ }
+ resolve(session);
+ } else {
+ // Chrome does not allow accessing page objects from extensions, so we need to inject a script into the page and exchange messages in order to access the global netflix object.
+ if (!this.hasInjectedSessionScript) {
+ const script = document.createElement('script');
+ script.textContent = `
+ window.addEventListener('uts-getSession', () => {
+ let session;
+ if (sdk) {
+ const { content, paused } = sdk.analytics;
+ const progress = sdk.player.currentPlaybackProgress.source._value.progressPercent;
+ const playing = typeof progress !== 'undefined' && !paused;
+ session = typeof progress !== 'undefined' && content ? { content, playing, paused, progress } : null;
+ }
+ const event = new CustomEvent('uts-onSessionReceived', {
+ detail: { session: JSON.stringify(session) },
+ });
+ window.dispatchEvent(event);
+ });
+ `;
+ document.body.appendChild(script);
+ this.hasInjectedSessionScript = true;
+ }
+ if (this.sessionListener) {
+ window.removeEventListener('uts-onSessionReceived', this.sessionListener);
+ }
+ this.sessionListener = (event: Event) => {
+ const session = (event as CustomEvent>).detail
+ .session;
+ if (typeof session === 'undefined') {
+ resolve(session);
+ } else {
+ resolve(JSON.parse(session) as HboGoSession | null);
+ }
+ };
+ window.addEventListener('uts-onSessionReceived', this.sessionListener, false);
+ const event = new CustomEvent('uts-getSession');
+ window.dispatchEvent(event);
+ }
+ });
+ };
+}
+
+export const HboGoApi = new _HboGoApi();
+
+registerApi('hbo-go', HboGoApi);
diff --git a/src/streaming-services/hbo-go/HboGoEvents.ts b/src/streaming-services/hbo-go/HboGoEvents.ts
new file mode 100644
index 00000000..0134df8d
--- /dev/null
+++ b/src/streaming-services/hbo-go/HboGoEvents.ts
@@ -0,0 +1,65 @@
+import { registerScrobbleEvents } from '../common/common';
+import { ScrobbleEvents } from '../common/ScrobbleEvents';
+import { HboGoApi } from './HboGoApi';
+import { HboGoParser } from './HboGoParser';
+
+class _HboGoEvents extends ScrobbleEvents {
+ progress: number;
+ url: string;
+ videoId: string;
+
+ constructor() {
+ super();
+
+ this.progress = 0.0;
+ this.url = '';
+ this.videoId = '';
+ }
+
+ checkForChanges = async (): Promise => {
+ // If we can access the global sdk object from the page, there is no need to parse the page in order to retrieve information about the item being watched.
+ let session = await HboGoApi.getSession();
+ if (typeof session === 'undefined') {
+ session = HboGoParser.parseSession();
+ }
+ if (session) {
+ if (this.videoId !== session.content.Id) {
+ if (this.isPlaying) {
+ await this.stop();
+ }
+ await this.start();
+ this.videoId = session.content.Id;
+ this.isPaused = false;
+ this.isPlaying = true;
+ } else if (this.isPaused !== session.paused || this.isPlaying !== session.playing) {
+ if (session.paused) {
+ if (!this.isPaused) {
+ await this.pause();
+ }
+ } else if (session.playing) {
+ if (!this.isPlaying) {
+ await this.start();
+ }
+ } else if (this.isPlaying) {
+ await this.stop();
+ }
+ this.isPaused = session.paused;
+ this.isPlaying = session.playing;
+ }
+ if (this.isPlaying) {
+ const newProgress = session.progress;
+ if (this.progress !== newProgress) {
+ await this.updateProgress(newProgress);
+ this.progress = newProgress;
+ }
+ }
+ } else if (this.isPlaying || this.isPaused) {
+ await this.stop();
+ }
+ this.changeListenerId = window.setTimeout(() => void this.checkForChanges(), 500);
+ };
+}
+
+export const HboGoEvents = new _HboGoEvents();
+
+registerScrobbleEvents('hbo-go', HboGoEvents);
diff --git a/src/streaming-services/hbo-go/HboGoParser.ts b/src/streaming-services/hbo-go/HboGoParser.ts
new file mode 100644
index 00000000..31702008
--- /dev/null
+++ b/src/streaming-services/hbo-go/HboGoParser.ts
@@ -0,0 +1,53 @@
+import { Item } from '../../models/Item';
+import { registerScrobbleParser } from '../common/common';
+import { ScrobbleParser } from '../common/ScrobbleController';
+import { HboGoApi, HboGoMetadataItem, HboGoSession } from './HboGoApi';
+
+class _HboGoParser implements ScrobbleParser {
+ parseItem = async (): Promise- => {
+ // If we can access the global sdk object from the page, there is no need to parse the page in order to retrieve the item being watched.
+ let item: Item | undefined;
+ const session = (await HboGoApi.getSession()) || this.parseSession();
+ if (session && session.content.Id) {
+ item = HboGoApi.parseMetadata(session.content);
+ }
+ return item;
+ };
+
+ parseSession = (): HboGoSession => {
+ const content = {} as HboGoMetadataItem;
+ const contentTitleElement = document.querySelector('.contentTitle');
+ if (contentTitleElement) {
+ const contentTitle = contentTitleElement.textContent?.trim() ?? '';
+ const showMatches = /(.+?)\s\|\sS(\d+?)\sE(\d+?)\s(.+)/.exec(contentTitle);
+ content.Id = contentTitle.replace(' ', '');
+ content.ProductionYear = 0;
+ content.Category = showMatches ? 'Series' : 'Movies';
+ if (content.Category === 'Series') {
+ content.SeriesName = showMatches?.[0] ?? '';
+ content.SeasonIndex = parseInt(showMatches?.[1] ?? '0');
+ content.Index = parseInt(showMatches?.[2] ?? '0');
+ content.Name = showMatches?.[3] ?? '';
+ } else {
+ content.Name = contentTitle;
+ }
+ }
+ const playing = !!document.querySelector('.playbackPauseButton');
+ const paused = !!document.querySelector('.playbackPlayButton');
+ const progress = this.parseProgress();
+ return { content, playing, paused, progress };
+ };
+
+ parseProgress = (): number => {
+ let progress = 0.0;
+ const scrubber: HTMLElement | null = document.querySelector('.timelineProgress');
+ if (scrubber) {
+ progress = parseFloat(scrubber.style.width);
+ }
+ return progress;
+ };
+}
+
+export const HboGoParser = new _HboGoParser();
+
+registerScrobbleParser('hbo-go', HboGoParser);
diff --git a/src/streaming-services/hbo-go/hbo-go.ts b/src/streaming-services/hbo-go/hbo-go.ts
new file mode 100644
index 00000000..cf308698
--- /dev/null
+++ b/src/streaming-services/hbo-go/hbo-go.ts
@@ -0,0 +1,4 @@
+import { init } from '../common/content';
+import './HboGoEvents';
+
+void init('hbo-go');
diff --git a/src/streaming-services/netflix/NetflixApi.ts b/src/streaming-services/netflix/NetflixApi.ts
new file mode 100644
index 00000000..39447df0
--- /dev/null
+++ b/src/streaming-services/netflix/NetflixApi.ts
@@ -0,0 +1,484 @@
+import * as moment from 'moment';
+import { Item } from '../../models/Item';
+import { Errors } from '../../common/Errors';
+import { EventDispatcher } from '../../common/Events';
+import { Requests } from '../../common/Requests';
+import { Shared } from '../../common/Shared';
+import { Api } from '../common/Api';
+import { getSyncStore, registerApi } from '../common/common';
+
+export interface NetflixGlobalObject {
+ appContext: {
+ state: {
+ playerApp: {
+ getState: () => NetflixPlayerState;
+ };
+ };
+ };
+ reactContext: {
+ models: {
+ userInfo: {
+ data: {
+ authURL: string;
+ };
+ };
+ serverDefs: {
+ data: {
+ BUILD_IDENTIFIER: string;
+ };
+ };
+ };
+ };
+}
+
+export interface NetflixPlayerState {
+ videoPlayer: {
+ playbackStateBySessionId: Record
;
+ };
+}
+
+export interface NetflixApiParams {
+ authUrl: string;
+ buildIdentifier: string;
+}
+
+export interface NetflixScrobbleSession {
+ currentTime: number;
+ duration: number;
+ paused: boolean;
+ playing: boolean;
+ videoId: number;
+}
+
+export interface NetflixHistoryResponse {
+ viewedItems: NetflixHistoryItem[];
+}
+
+export type NetflixHistoryItem = NetflixHistoryShowItem | NetflixHistoryMovieItem;
+
+export interface NetflixHistoryShowItem {
+ date: number;
+ duration: number;
+ episodeTitle: string;
+ movieID: number;
+ seasonDescriptor: string;
+ series: number;
+ seriesTitle: string;
+ title: string;
+}
+
+export interface NetflixHistoryMovieItem {
+ date: number;
+ duration: number;
+ movieID: number;
+ title: string;
+}
+
+export interface NetflixMetadataResponse {
+ value: {
+ videos: { [key: number]: NetflixMetadataItem };
+ };
+}
+
+export type NetflixMetadataItem = NetflixMetadataShowItem | NetflixMetadataMovieItem;
+
+export interface NetflixMetadataShowItem {
+ releaseYear: number;
+ summary: {
+ episode: number;
+ id: number;
+ season: number;
+ };
+}
+
+export interface NetflixMetadataMovieItem {
+ releaseYear: number;
+ summary: {
+ id: number;
+ };
+}
+
+export interface NetflixSingleMetadataItem {
+ video: NetflixMetadataShow | NetflixMetadataMovie;
+}
+
+export interface NetflixMetadataGeneric {
+ id: number;
+ title: string;
+ year: number;
+}
+
+export type NetflixMetadataShow = NetflixMetadataGeneric & {
+ type: 'show';
+ currentEpisode: number;
+ seasons: NetflixMetadataShowSeason[];
+};
+
+export interface NetflixMetadataShowSeason {
+ episodes: NetflixMetadataShowEpisode[];
+ seq: number;
+ shortName: string;
+}
+
+export interface NetflixMetadataShowEpisode {
+ id: number;
+ seq: number;
+ title: string;
+}
+
+export type NetflixMetadataMovie = NetflixMetadataGeneric & {
+ type: 'movie';
+};
+
+export type NetflixHistoryItemWithMetadata =
+ | NetflixHistoryShowItemWithMetadata
+ | NetflixHistoryMovieItemWithMetadata;
+
+export type NetflixHistoryShowItemWithMetadata = NetflixHistoryShowItem & NetflixMetadataShowItem;
+
+export type NetflixHistoryMovieItemWithMetadata = NetflixHistoryMovieItem &
+ NetflixMetadataMovieItem;
+
+class _NetflixApi extends Api {
+ HOST_URL: string;
+ API_URL: string;
+ ACTIVATE_URL: string;
+ AUTH_REGEX: RegExp;
+ BUILD_IDENTIFIER_REGEX: RegExp;
+ isActivated: boolean;
+ apiParams: Partial;
+ hasInjectedApiParamsScript: boolean;
+ hasInjectedSessionScript: boolean;
+ apiParamsListener: ((event: Event) => void) | undefined;
+ sessionListener: ((event: Event) => void) | undefined;
+
+ constructor() {
+ super('netflix');
+
+ this.HOST_URL = 'https://www.netflix.com';
+ this.API_URL = `${this.HOST_URL}/api/shakti`;
+ this.ACTIVATE_URL = `${this.HOST_URL}/Activate`;
+ this.AUTH_REGEX = /"authURL":"(.*?)"/;
+ this.BUILD_IDENTIFIER_REGEX = /"BUILD_IDENTIFIER":"(.*?)"/;
+
+ this.isActivated = false;
+ this.apiParams = {};
+ this.hasInjectedApiParamsScript = false;
+ this.hasInjectedSessionScript = false;
+ }
+
+ extractAuthUrl = (text: string): string | undefined => {
+ return this.AUTH_REGEX.exec(text)?.[1];
+ };
+
+ extractBuildIdentifier = (text: string): string | undefined => {
+ return this.BUILD_IDENTIFIER_REGEX.exec(text)?.[1];
+ };
+
+ activate = async () => {
+ // If we can access the global netflix object from the page, there is no need to send a request to Netflix in order to retrieve the API params.
+ let apiParams;
+ if (!Shared.isBackgroundPage) {
+ apiParams = await this.getApiParams();
+ }
+ if (apiParams && this.checkParams(apiParams)) {
+ this.apiParams.authUrl = apiParams.authUrl;
+ this.apiParams.buildIdentifier = apiParams.buildIdentifier;
+ } else {
+ const responseText = await Requests.send({
+ url: this.ACTIVATE_URL,
+ method: 'GET',
+ });
+ this.apiParams.authUrl = this.extractAuthUrl(responseText);
+ this.apiParams.buildIdentifier = this.extractBuildIdentifier(responseText);
+ }
+ this.isActivated = true;
+ };
+
+ checkParams = (apiParams: Partial): apiParams is NetflixApiParams => {
+ return (
+ typeof apiParams.authUrl !== 'undefined' && typeof apiParams.buildIdentifier !== 'undefined'
+ );
+ };
+
+ loadHistory = async (nextPage: number, nextVisualPage: number, itemsToLoad: number) => {
+ try {
+ if (!this.isActivated) {
+ await this.activate();
+ }
+ if (!this.checkParams(this.apiParams)) {
+ throw new Error('Invalid API params');
+ }
+ let isLastPage = false;
+ let items: Item[] = [];
+ const historyItems: NetflixHistoryItem[] = [];
+ do {
+ const responseText = await Requests.send({
+ url: `${this.API_URL}/${this.apiParams.buildIdentifier}/viewingactivity?languages=en-US&authURL=${this.apiParams.authUrl}&pg=${nextPage}`,
+ method: 'GET',
+ });
+ const responseJson = JSON.parse(responseText) as NetflixHistoryResponse;
+ if (responseJson && responseJson.viewedItems.length > 0) {
+ itemsToLoad -= responseJson.viewedItems.length;
+ historyItems.push(...responseJson.viewedItems);
+ } else {
+ isLastPage = true;
+ }
+ nextPage += 1;
+ } while (!isLastPage && itemsToLoad > 0);
+ if (historyItems.length > 0) {
+ const historyItemsWithMetadata = await this.getHistoryMetadata(historyItems);
+ items = historyItemsWithMetadata.map(this.parseHistoryItem);
+ }
+ nextVisualPage += 1;
+ getSyncStore('netflix')
+ .update({ isLastPage, nextPage, nextVisualPage, items })
+ .then(this.loadTraktHistory)
+ .catch(() => {
+ /** Do nothing */
+ });
+ } catch (err) {
+ Errors.error('Failed to load Netflix history.', err);
+ await EventDispatcher.dispatch('STREAMING_SERVICE_HISTORY_LOAD_ERROR', null, {
+ error: err as Error,
+ });
+ }
+ };
+
+ getHistoryMetadata = async (historyItems: NetflixHistoryItem[]) => {
+ if (!this.checkParams(this.apiParams)) {
+ throw new Error('Invalid API params');
+ }
+ let historyItemsWithMetadata: NetflixHistoryItemWithMetadata[] = [];
+ const responseText = await Requests.send({
+ url: `${this.API_URL}/${this.apiParams.buildIdentifier}/pathEvaluator?languages=en-US`,
+ method: 'POST',
+ body: `authURL=${this.apiParams.authUrl}&${historyItems
+ .map((historyItem) => `path=["videos",${historyItem.movieID},["releaseYear","summary"]]`)
+ .join('&')}`,
+ });
+ const responseJson = JSON.parse(responseText) as NetflixMetadataResponse;
+ if (responseJson && responseJson.value.videos) {
+ historyItemsWithMetadata = historyItems.map((historyItem) => {
+ const metadata = responseJson.value.videos[historyItem.movieID];
+ let combinedItem: NetflixHistoryItemWithMetadata;
+ if (metadata) {
+ combinedItem = Object.assign({}, historyItem, metadata);
+ } else {
+ combinedItem = historyItem as NetflixHistoryItemWithMetadata;
+ }
+ return combinedItem;
+ });
+ } else {
+ throw responseText;
+ }
+ return historyItemsWithMetadata;
+ };
+
+ isShow = (
+ historyItem: NetflixHistoryItemWithMetadata
+ ): historyItem is NetflixHistoryShowItemWithMetadata => {
+ return 'series' in historyItem;
+ };
+
+ parseHistoryItem = (historyItem: NetflixHistoryItemWithMetadata) => {
+ let item: Item;
+ const id = historyItem.movieID.toString();
+ const type = 'series' in historyItem ? 'show' : 'movie';
+ const year = historyItem.releaseYear;
+ const watchedAt = moment(historyItem.date);
+ if (this.isShow(historyItem)) {
+ const title = historyItem.seriesTitle.trim();
+ let season;
+ let episode;
+ const isCollection = !historyItem.seasonDescriptor.includes('Season');
+ if (!isCollection) {
+ season = historyItem.summary.season;
+ episode = historyItem.summary.episode;
+ }
+ const episodeTitle = historyItem.episodeTitle.trim();
+ item = new Item({
+ id,
+ type,
+ title,
+ year,
+ season,
+ episode,
+ episodeTitle,
+ isCollection,
+ watchedAt,
+ });
+ } else {
+ const title = historyItem.title.trim();
+ item = new Item({ id, type, title, year, watchedAt });
+ }
+ return item;
+ };
+
+ getItem = async (id: string): Promise- => {
+ let item: Item | undefined;
+ if (!this.isActivated) {
+ await this.activate();
+ }
+ if (!this.checkParams(this.apiParams)) {
+ throw new Error('Invalid API params');
+ }
+ try {
+ const responseText = await Requests.send({
+ url: `${this.API_URL}/${this.apiParams.buildIdentifier}/metadata?languages=en-US&movieid=${id}`,
+ method: 'GET',
+ });
+ item = this.parseMetadata(JSON.parse(responseText));
+ } catch (err) {
+ Errors.error('Failed to get item.', err);
+ }
+ return item;
+ };
+
+ parseMetadata = (metadata: NetflixSingleMetadataItem): Item => {
+ let item: Item;
+ const { video } = metadata;
+ const id = video.id.toString();
+ const { type, title, year } = video;
+ if (video.type === 'show') {
+ let episodeInfo: NetflixMetadataShowEpisode | undefined;
+ const seasonInfo = video.seasons.find((season) =>
+ season.episodes.find((episode) => {
+ const isMatch = episode.id === video.currentEpisode;
+ if (isMatch) {
+ episodeInfo = episode;
+ }
+ return isMatch;
+ })
+ );
+ if (!seasonInfo || !episodeInfo) {
+ throw new Error('Could not find item');
+ }
+ const isCollection = seasonInfo.shortName.includes('C');
+ const season = seasonInfo.seq;
+ const episode = episodeInfo.seq;
+ const episodeTitle = episodeInfo.title;
+ item = new Item({ id, type, title, year, isCollection, season, episode, episodeTitle });
+ } else {
+ item = new Item({ id, type, title, year });
+ }
+ return item;
+ };
+
+ getApiParams = (): Promise
> => {
+ return new Promise((resolve) => {
+ if ('wrappedJSObject' in window && window.wrappedJSObject) {
+ // Firefox wraps page objects, so we can access the global netflix object by unwrapping it.
+ const apiParams: Partial = {};
+ const { netflix } = window.wrappedJSObject;
+ if (netflix) {
+ const authUrl = netflix.reactContext.models.userInfo.data.authURL;
+ if (authUrl) {
+ apiParams.authUrl = authUrl;
+ }
+ const buildIdentifier = netflix.reactContext.models.serverDefs.data.BUILD_IDENTIFIER;
+ if (buildIdentifier) {
+ apiParams.buildIdentifier = buildIdentifier;
+ }
+ }
+ resolve(apiParams);
+ } else {
+ // Chrome does not allow accessing page objects from extensions, so we need to inject a script into the page and exchange messages in order to access the global netflix object.
+ if (!this.hasInjectedApiParamsScript) {
+ const script = document.createElement('script');
+ script.textContent = `
+ window.addEventListener('uts-getApiParams', () => {
+ let apiParams = {};
+ if (netflix) {
+ const authUrl = netflix.reactContext.models.userInfo.data.authURL;
+ if (authUrl) {
+ apiParams.authUrl = authUrl;
+ }
+ const buildIdentifier = netflix.reactContext.models.serverDefs.data.BUILD_IDENTIFIER;
+ if (buildIdentifier) {
+ apiParams.buildIdentifier = buildIdentifier;
+ }
+ }
+ const event = new CustomEvent('uts-onApiParamsReceived', {
+ detail: { apiParams: JSON.stringify(apiParams) },
+ });
+ window.dispatchEvent(event);
+ });
+ `;
+ document.body.appendChild(script);
+ this.hasInjectedApiParamsScript = true;
+ }
+ if (this.apiParamsListener) {
+ window.removeEventListener('uts-onApiParamsReceived', this.apiParamsListener);
+ }
+ this.apiParamsListener = (event: Event) =>
+ resolve(
+ JSON.parse(
+ (event as CustomEvent>).detail.apiParams
+ ) as Partial
+ );
+ window.addEventListener('uts-onApiParamsReceived', this.apiParamsListener, false);
+ const event = new CustomEvent('uts-getApiParams');
+ window.dispatchEvent(event);
+ }
+ });
+ };
+
+ getSession = (): Promise => {
+ return new Promise((resolve) => {
+ if ('wrappedJSObject' in window && window.wrappedJSObject) {
+ // Firefox wraps page objects, so we can access the global netflix object by unwrapping it.
+ let session: NetflixScrobbleSession | undefined | null;
+ const { netflix } = window.wrappedJSObject;
+ if (netflix) {
+ const sessions = netflix.appContext.state.playerApp.getState().videoPlayer
+ .playbackStateBySessionId;
+ const currentId = Object.keys(sessions).find((id) => id.startsWith('watch'));
+ session = currentId ? sessions[currentId] : undefined;
+ }
+ resolve(session);
+ } else {
+ // Chrome does not allow accessing page objects from extensions, so we need to inject a script into the page and exchange messages in order to access the global netflix object.
+ if (!this.hasInjectedSessionScript) {
+ const script = document.createElement('script');
+ script.textContent = `
+ window.addEventListener('uts-getSession', () => {
+ let session;
+ if (netflix) {
+ const sessions = netflix.appContext.state.playerApp.getState().videoPlayer.playbackStateBySessionId;
+ const currentId = Object.keys(sessions)
+ .find(id => id.startsWith('watch'));
+ session = currentId ? sessions[currentId] : undefined;
+ }
+ const event = new CustomEvent('uts-onSessionReceived', {
+ detail: { session: JSON.stringify(session) },
+ });
+ window.dispatchEvent(event);
+ });
+ `;
+ document.body.appendChild(script);
+ this.hasInjectedSessionScript = true;
+ }
+ if (this.sessionListener) {
+ window.removeEventListener('uts-onSessionReceived', this.sessionListener);
+ }
+ this.sessionListener = (event: Event) => {
+ const session = (event as CustomEvent>).detail
+ .session;
+ if (typeof session === 'undefined') {
+ resolve(session);
+ } else {
+ resolve(JSON.parse(session) as NetflixScrobbleSession | null);
+ }
+ };
+ window.addEventListener('uts-onSessionReceived', this.sessionListener, false);
+ const event = new CustomEvent('uts-getSession');
+ window.dispatchEvent(event);
+ }
+ });
+ };
+}
+
+export const NetflixApi = new _NetflixApi();
+
+registerApi('netflix', NetflixApi);
diff --git a/src/streaming-services/netflix/NetflixEvents.ts b/src/streaming-services/netflix/NetflixEvents.ts
new file mode 100644
index 00000000..d1444819
--- /dev/null
+++ b/src/streaming-services/netflix/NetflixEvents.ts
@@ -0,0 +1,100 @@
+import { registerScrobbleEvents } from '../common/common';
+import { ScrobbleEvents } from '../common/ScrobbleEvents';
+import { NetflixApi } from './NetflixApi';
+import { NetflixParser } from './NetflixParser';
+
+class _NetflixEvents extends ScrobbleEvents {
+ progress: number;
+ url: string;
+ videoId: number;
+
+ constructor() {
+ super();
+
+ this.progress = 0.0;
+ this.url = '';
+ this.videoId = 0;
+ }
+
+ checkForChanges = async (): Promise => {
+ // If we can access the global netflix object from the page, there is no need to parse the page in order to retrieve information about the item being watched.
+ const session = await NetflixApi.getSession();
+ if (typeof session !== 'undefined') {
+ if (session) {
+ if (this.videoId !== session.videoId) {
+ if (this.isPlaying) {
+ await this.stop();
+ }
+ await this.start();
+ this.videoId = session.videoId;
+ this.isPaused = false;
+ this.isPlaying = true;
+ } else if (this.isPaused !== session.paused || this.isPlaying !== session.playing) {
+ if (session.paused) {
+ if (!this.isPaused) {
+ await this.pause();
+ }
+ } else if (session.playing) {
+ if (!this.isPlaying) {
+ await this.start();
+ }
+ } else if (this.isPlaying) {
+ await this.stop();
+ }
+ this.isPaused = session.paused;
+ this.isPlaying = session.playing;
+ }
+ if (this.isPlaying) {
+ const newProgress = Math.round((session.currentTime / session.duration) * 10000) / 100;
+ if (this.progress !== newProgress) {
+ await this.updateProgress(newProgress);
+ this.progress = newProgress;
+ }
+ }
+ } else if (this.isPlaying || this.isPaused) {
+ await this.stop();
+ }
+ } else {
+ const newUrl = this.getLocation();
+ if (this.url !== newUrl) {
+ await this.onUrlChange(this.url, newUrl);
+ this.url = newUrl;
+ }
+ if (this.isPlaying) {
+ const newProgress = NetflixParser.parseProgress();
+ if (this.progress === newProgress) {
+ if (!this.isPaused) {
+ await this.pause();
+ this.isPaused = true;
+ }
+ } else {
+ if (this.isPaused) {
+ await this.start();
+ this.isPaused = false;
+ }
+ await this.updateProgress(newProgress);
+ this.progress = newProgress;
+ }
+ }
+ }
+ this.changeListenerId = window.setTimeout(() => void this.checkForChanges(), 500);
+ };
+
+ onUrlChange = async (oldUrl: string, newUrl: string): Promise => {
+ if (oldUrl.includes('watch') && newUrl.includes('watch')) {
+ await this.stop();
+ await this.start();
+ this.isPlaying = true;
+ } else if (oldUrl.includes('watch') && !newUrl.includes('watch')) {
+ await this.stop();
+ this.isPlaying = false;
+ } else if (!oldUrl.includes('watch') && newUrl.includes('watch')) {
+ await this.start();
+ this.isPlaying = true;
+ }
+ };
+}
+
+export const NetflixEvents = new _NetflixEvents();
+
+registerScrobbleEvents('netflix', NetflixEvents);
diff --git a/src/streaming-services/netflix/NetflixParser.ts b/src/streaming-services/netflix/NetflixParser.ts
new file mode 100644
index 00000000..c2b36013
--- /dev/null
+++ b/src/streaming-services/netflix/NetflixParser.ts
@@ -0,0 +1,52 @@
+import { Item } from '../../models/Item';
+import { registerScrobbleParser } from '../common/common';
+import { ScrobbleParser } from '../common/ScrobbleController';
+import { NetflixApi } from './NetflixApi';
+
+class _NetflixParser implements ScrobbleParser {
+ getLocation = (): string => {
+ return window.location.href;
+ };
+
+ parseItem = (): Promise- => {
+ return new Promise((resolve) => void this.checkId(resolve));
+ };
+
+ parseProgress = (): number => {
+ let progress = 0.0;
+ const scrubber: HTMLElement | null = document.querySelector('.scrubber-bar .current-progress');
+ if (scrubber) {
+ progress = parseFloat(scrubber.style.width);
+ }
+ return progress;
+ };
+
+ checkId = async (callback: (item: Item | undefined) => void): Promise
=> {
+ const id = await this.getId();
+ if (id) {
+ const item = await NetflixApi.getItem(id);
+ callback(item);
+ } else {
+ setTimeout(() => void this.checkId(callback), 500);
+ }
+ };
+
+ getId = async (): Promise => {
+ // If we can access the global netflix object from the page, there is no need to parse the page in order to retrieve the ID of the item being watched.
+ let id: string | null = null;
+ const session = await NetflixApi.getSession();
+ if (session) {
+ id = session.videoId.toString();
+ } else {
+ const matches = /watch\/(\d+)/.exec(this.getLocation());
+ if (matches) {
+ [, id] = matches;
+ }
+ }
+ return id;
+ };
+}
+
+export const NetflixParser = new _NetflixParser();
+
+registerScrobbleParser('netflix', NetflixParser);
diff --git a/src/streaming-services/netflix/netflix.ts b/src/streaming-services/netflix/netflix.ts
new file mode 100644
index 00000000..0f009d57
--- /dev/null
+++ b/src/streaming-services/netflix/netflix.ts
@@ -0,0 +1,4 @@
+import { init } from '../common/content';
+import './NetflixEvents';
+
+void init('netflix');
diff --git a/src/modules/history/streaming-services/nrk/NrkApi.ts b/src/streaming-services/nrk/NrkApi.ts
similarity index 89%
rename from src/modules/history/streaming-services/nrk/NrkApi.ts
rename to src/streaming-services/nrk/NrkApi.ts
index 9db9464e..56478860 100644
--- a/src/modules/history/streaming-services/nrk/NrkApi.ts
+++ b/src/streaming-services/nrk/NrkApi.ts
@@ -1,10 +1,10 @@
import * as moment from 'moment';
-import { Item } from '../../../../models/Item';
-import { Errors } from '../../../../services/Errors';
-import { EventDispatcher, Events } from '../../../../services/Events';
-import { Requests } from '../../../../services/Requests';
+import { Item } from '../../models/Item';
+import { Errors } from '../../common/Errors';
+import { EventDispatcher } from '../../common/Events';
+import { Requests } from '../../common/Requests';
import { Api } from '../common/Api';
-import { getStore, registerApi } from '../common/common';
+import { getSyncStore, registerApi } from '../common/common';
export interface NrkHistoryItem {
lastSeen: NrkLastSeen;
@@ -82,7 +82,7 @@ class _NrkApi extends Api {
items = historyItems.map(this.parseHistoryItem);
}
nextVisualPage += 1;
- getStore('nrk')
+ getSyncStore('nrk')
.update({ isLastPage, nextPage, nextVisualPage, items })
.then(this.loadTraktHistory)
.catch(() => {
@@ -90,7 +90,7 @@ class _NrkApi extends Api {
});
} catch (err) {
Errors.error('Failed to load NRK history.', err);
- await EventDispatcher.dispatch(Events.STREAMING_SERVICE_HISTORY_LOAD_ERROR, null, {
+ await EventDispatcher.dispatch('STREAMING_SERVICE_HISTORY_LOAD_ERROR', null, {
error: err as Error,
});
}
@@ -99,7 +99,7 @@ class _NrkApi extends Api {
parseHistoryItem = (historyItem: NrkHistoryItem): Item => {
const program: NrkProgramInfo = historyItem.program;
let item: Item;
- const id = parseInt(program.id, 10);
+ const id = program.id;
const type = program.programType === 'Episode' ? 'show' : 'movie';
const year = program.productionYear;
const percentageWatched = parseInt(historyItem.lastSeen.percentageWatched, 10);
diff --git a/src/modules/history/streaming-services/pages.ts b/src/streaming-services/pages.ts
similarity index 52%
rename from src/modules/history/streaming-services/pages.ts
rename to src/streaming-services/pages.ts
index bf62cffd..014d12ff 100644
--- a/src/modules/history/streaming-services/pages.ts
+++ b/src/streaming-services/pages.ts
@@ -1,5 +1,5 @@
-import { StreamingService, streamingServices } from '../../../streaming-services';
-import { getPageBuilder } from './common/common';
+import { getSyncPageBuilder } from './common/common';
+import { StreamingService, streamingServices } from './streaming-services';
import './netflix/NetflixApi';
import './nrk/NrkApi';
import './viaplay/ViaplayApi';
@@ -9,10 +9,10 @@ export interface StreamingServicePage extends StreamingService {
pageBuilder: () => React.ReactElement | null;
}
-export const streamingServicePages: StreamingServicePage[] = Object.values(streamingServices).map(
- (service) => ({
+export const streamingServicePages: StreamingServicePage[] = Object.values(streamingServices)
+ .filter((service) => service.hasSync)
+ .map((service) => ({
...service,
path: `/${service.id}`,
- pageBuilder: getPageBuilder(service.id, service.name),
- })
-);
+ pageBuilder: getSyncPageBuilder(service.id, service.name),
+ }));
diff --git a/src/streaming-services/scrobbler-template/ScrobblerTemplateApi.ts b/src/streaming-services/scrobbler-template/ScrobblerTemplateApi.ts
new file mode 100644
index 00000000..03dac361
--- /dev/null
+++ b/src/streaming-services/scrobbler-template/ScrobblerTemplateApi.ts
@@ -0,0 +1,24 @@
+import { Api } from '../common/Api';
+import { registerApi } from '../common/common';
+
+// Define any types you need here.
+
+// This class should communicate with the service API, in order to retrieve the necessary information for scrobbling. If the service does not have an API, that information should be retrieved in the *Parser class instead, so that this class only deals with requests, and not direct DOM manipulation. Keep in mind that some services might have hidden APIs that you can use (you can usually find them by watching your network requests when using the service).
+class _ScrobblerTemplateApi extends Api {
+ // Define any properties you need here.
+
+ constructor() {
+ super('scrobbler-template');
+ }
+
+ // This method is only required for syncing, but since it is an abstract method, we have to implement at least a basic block for it.
+ loadHistory = (nextPage: number, nextVisualPage: number, itemsToLoad: number): Promise => {
+ return Promise.resolve();
+ };
+
+ // Define any methods you need here.
+}
+
+export const ScrobblerTemplateApi = new _ScrobblerTemplateApi();
+
+registerApi('scrobbler-template', ScrobblerTemplateApi);
diff --git a/src/streaming-services/scrobbler-template/ScrobblerTemplateEvents.ts b/src/streaming-services/scrobbler-template/ScrobblerTemplateEvents.ts
new file mode 100644
index 00000000..102de4f2
--- /dev/null
+++ b/src/streaming-services/scrobbler-template/ScrobblerTemplateEvents.ts
@@ -0,0 +1,33 @@
+import { registerScrobbleEvents } from '../common/common';
+import { ScrobbleEvents } from '../common/ScrobbleEvents';
+
+// Define any types you need here.
+
+// This class should watch for changes in the item that the user is watching. It is responsible for starting / pausing and stopping the scrobble, as well as updating the progress of the scrobble.
+class _ScrobblerTemplateEvents extends ScrobbleEvents {
+ // Define any properties you need here.
+
+ // This method checks for changes every half second (by default) and triggers the appropriate methods.
+ checkForChanges = async (): Promise => {
+ // To start the scrobble.
+ await this.start();
+
+ // To pause the scrobble.
+ await this.pause();
+
+ // To stop the scrobble.
+ await this.stop();
+
+ // To update the progress of the scrobble.
+ await this.updateProgress(newProgress);
+
+ // Do not change this line of code, unless you want to change the check frequency from a half second to something else. Assigning the timeout ID to the 'changeListenerId' property here is very important.
+ this.changeListenerId = window.setTimeout(() => void this.checkForChanges(), 500);
+ };
+
+ // Define any methods you need here.
+}
+
+export const ScrobblerTemplateEvents = new _ScrobblerTemplateEvents();
+
+registerScrobbleEvents('scrobbler-template', ScrobblerTemplateEvents);
diff --git a/src/streaming-services/scrobbler-template/ScrobblerTemplateParser.ts b/src/streaming-services/scrobbler-template/ScrobblerTemplateParser.ts
new file mode 100644
index 00000000..848fef25
--- /dev/null
+++ b/src/streaming-services/scrobbler-template/ScrobblerTemplateParser.ts
@@ -0,0 +1,36 @@
+import { Item } from '../../models/Item';
+import { registerScrobbleParser } from '../common/common';
+import { ScrobbleParser } from '../common/ScrobbleController';
+import { ScrobblerTemplateApi } from './ScrobblerTemplateApi';
+
+// Define any types you need here.
+
+// This class should parse the item that the user is watching. If the service has an API that provides that information, this class should mostly act as a proxy to the *Api class. If the service does not have an API, this class should retrieve that information directly from the DOM.
+class _ScrobblerTemplateParser implements ScrobbleParser {
+ // Define any properties you need here.
+
+ // This method should return the item that the user is watching, if the information was successfully retrieved.
+ parseItem = async (): Promise- => {
+ let item: Item | undefined;
+
+ // If the service has an API, this method will most likely look like this.
+ item = await ScrobblerTemplateApi.getItem();
+
+ // If the service does not have an API, this method will most likely looks like this.
+ const titleElement = document.querySelector('titleSelector');
+ const yearElement = document.querySelector('yearSelector');
+ const id = 'someUniqueId';
+ const type = 'movie';
+ const title = titleElement?.textContent ?? '';
+ const year = parseInt(yearElement?.textContent ?? '0');
+ item = new Item({ id, type, title, year });
+
+ return item;
+ };
+
+ // Define any methods you need here.
+}
+
+export const ScrobblerTemplateParser = new _ScrobblerTemplateParser();
+
+registerScrobbleParser('scrobbler-template', ScrobblerTemplateParser);
diff --git a/src/streaming-services/scrobbler-template/scrobbler-template.ts b/src/streaming-services/scrobbler-template/scrobbler-template.ts
new file mode 100644
index 00000000..89b6ee8e
--- /dev/null
+++ b/src/streaming-services/scrobbler-template/scrobbler-template.ts
@@ -0,0 +1,10 @@
+// This is the file that is loaded in the service page as a content script.
+
+import { init } from '../common/content';
+
+// Import all files that are required for the scrobbler to work here. Since './ScrobblerTemplateParser' already imports './ScrobblerTemplateApi', we do not need to import it twice.
+import './ScrobblerTemplateEvents';
+import './ScrobblerTemplateParser';
+
+// This function prevents us from writing duplicate code, as it already initializes everything that should be enough to make most scrobblers work out of the box. You can always not use it and implement your own init function.
+void init('scrobbler-template');
diff --git a/src/streaming-services/streaming-services.ts b/src/streaming-services/streaming-services.ts
new file mode 100644
index 00000000..f414f653
--- /dev/null
+++ b/src/streaming-services/streaming-services.ts
@@ -0,0 +1,53 @@
+export type StreamingServiceId = 'amazon-prime' | 'hbo-go' | 'netflix' | 'nrk' | 'viaplay';
+
+export interface StreamingService {
+ id: StreamingServiceId;
+ name: string;
+ homePage: string;
+ hostPatterns: string[];
+ hasScrobbler: boolean;
+ hasSync: boolean;
+}
+
+export const streamingServices: Record
= {
+ 'amazon-prime': {
+ id: 'amazon-prime',
+ name: 'Amazon Prime',
+ homePage: 'https://www.primevideo.com/',
+ hostPatterns: ['*://*.primevideo.com/*'],
+ hasScrobbler: true,
+ hasSync: false,
+ },
+ 'hbo-go': {
+ id: 'hbo-go',
+ name: 'HBO Go',
+ homePage: 'https://www.hbogola.com/',
+ hostPatterns: ['*://*.hbogola.com/*', '*://*.hbogo.com.br/*'],
+ hasScrobbler: true,
+ hasSync: false,
+ },
+ netflix: {
+ id: 'netflix',
+ name: 'Netflix',
+ homePage: 'https://www.netflix.com/',
+ hostPatterns: ['*://*.netflix.com/*'],
+ hasScrobbler: true,
+ hasSync: true,
+ },
+ nrk: {
+ id: 'nrk',
+ name: 'NRK',
+ homePage: 'https://tv.nrk.no/',
+ hostPatterns: ['*://*.tv.nrk.no/*'],
+ hasScrobbler: false,
+ hasSync: true,
+ },
+ viaplay: {
+ id: 'viaplay',
+ name: 'Viaplay',
+ homePage: 'https://viaplay.no/',
+ hostPatterns: ['*://*.viaplay.no/*'],
+ hasScrobbler: false,
+ hasSync: true,
+ },
+};
diff --git a/src/streaming-services/sync-template/SyncTemplateApi.ts b/src/streaming-services/sync-template/SyncTemplateApi.ts
new file mode 100644
index 00000000..0e78be12
--- /dev/null
+++ b/src/streaming-services/sync-template/SyncTemplateApi.ts
@@ -0,0 +1,41 @@
+import { Item } from '../../models/Item';
+import { Api } from '../common/Api';
+import { getSyncStore, registerApi } from '../common/common';
+
+// Define any types you need here.
+
+// This class should communicate with the service API, in order to retrieve the necessary information for syncing. Keep in mind that some services might have hidden APIs that you can use (you can usually find them by watching your network requests when using the service).
+class _SyncTemplateApi extends Api {
+ // Define any properties you need here.
+
+ constructor() {
+ super('sync-template');
+ }
+
+ // This method should load the next page of history items and update the sync store for the service.
+ loadHistory = async (
+ nextPage: number,
+ nextVisualPage: number,
+ itemsToLoad: number
+ ): Promise => {
+ // The code could look like this.
+ let items: Item[] = [];
+ let itemsLoaded = 0;
+ let isLastPage = false;
+ while (itemsLoaded < itemsToLoad && !isLastPage) {
+ const nextItems = await this.loadPage(nextPage);
+ items.push(...nextItems);
+ itemsLoaded += nextItems.length;
+ nextPage += 1;
+ isLastPage = this.checkLastPage();
+ }
+ nextVisualPage += 1;
+ await getSyncStore('sync-template').update({ isLastPage, nextPage, nextVisualPage, items });
+ };
+
+ // Define any methods you need here.
+}
+
+export const SyncTemplateApi = new _SyncTemplateApi();
+
+registerApi('sync-template', SyncTemplateApi);
diff --git a/src/modules/history/streaming-services/viaplay/ViaplayApi.ts b/src/streaming-services/viaplay/ViaplayApi.ts
similarity index 91%
rename from src/modules/history/streaming-services/viaplay/ViaplayApi.ts
rename to src/streaming-services/viaplay/ViaplayApi.ts
index b58ee367..9c0bb21c 100644
--- a/src/modules/history/streaming-services/viaplay/ViaplayApi.ts
+++ b/src/streaming-services/viaplay/ViaplayApi.ts
@@ -1,10 +1,10 @@
import * as moment from 'moment';
-import { Item } from '../../../../models/Item';
-import { Errors } from '../../../../services/Errors';
-import { EventDispatcher, Events } from '../../../../services/Events';
-import { Requests } from '../../../../services/Requests';
+import { Item } from '../../models/Item';
+import { Errors } from '../../common/Errors';
+import { EventDispatcher } from '../../common/Events';
+import { Requests } from '../../common/Requests';
import { Api } from '../common/Api';
-import { getStore, registerApi } from '../common/common';
+import { getSyncStore, registerApi } from '../common/common';
export interface ViaplayWatchedTopResponse {
_embedded: {
@@ -145,7 +145,7 @@ class _ViaplayApi extends Api {
items = historyItems.map(this.parseHistoryItem);
}
nextVisualPage += 1;
- getStore('viaplay')
+ getSyncStore('viaplay')
.update({ isLastPage, nextPage, nextVisualPage, items })
.then(this.loadTraktHistory)
.catch(() => {
@@ -153,7 +153,7 @@ class _ViaplayApi extends Api {
});
} catch (err) {
Errors.error('Failed to load Viaplay history.', err);
- await EventDispatcher.dispatch(Events.STREAMING_SERVICE_HISTORY_LOAD_ERROR, null, {
+ await EventDispatcher.dispatch('STREAMING_SERVICE_HISTORY_LOAD_ERROR', null, {
error: err as Error,
});
}
@@ -164,7 +164,7 @@ class _ViaplayApi extends Api {
const year = historyItem.content.production.year;
const percentageWatched = historyItem.user.progress?.elapsedPercent || 0;
const watchedAt = moment(historyItem.user.progress?.updated);
- const id = parseInt(historyItem.system.guid, 10);
+ const id = historyItem.system.guid;
if (historyItem.type === 'episode') {
const content = historyItem.content;
const title = content.originalTitle ?? content.series.title;
diff --git a/tsconfig.json b/tsconfig.json
index 271a72f4..cf610f1f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,10 @@
{
- "exclude": ["./node_modules", "./build"],
+ "exclude": [
+ "./node_modules",
+ "./build",
+ "./src/streaming-services/scrobbler-template",
+ "./src/streaming-services/sync-template"
+ ],
"compilerOptions": {
"allowJs": true,
"checkJs": true,
diff --git a/webpack.config.ts b/webpack.config.ts
index 5ab4f637..fc3dc984 100644
--- a/webpack.config.ts
+++ b/webpack.config.ts
@@ -34,7 +34,7 @@ import * as ProgressBarWebpackPlugin from 'progress-bar-webpack-plugin';
import * as webpack from 'webpack';
import * as configJson from './config.json';
import * as packageJson from './package.json';
-import { streamingServices } from './src/streaming-services';
+import { streamingServices } from './src/streaming-services/streaming-services';
const BASE_PATH = process.cwd();
const loaders = {
@@ -76,15 +76,29 @@ const getWebpackConfig = (env: Environment) => {
mode = 'development';
}
const config = configJson[mode];
+ const streamingServiceEntries = Object.fromEntries(
+ Object.values(streamingServices)
+ .filter((service) => service.hasScrobbler)
+ .map((service) => [
+ [`./chrome/js/${service.id}`, [`./src/streaming-services/${service.id}/${service.id}.ts`]],
+ [`./firefox/js/${service.id}`, [`./src/streaming-services/${service.id}/${service.id}.ts`]],
+ ])
+ .flat()
+ ) as Record;
return {
devtool: env.production ? false : 'source-map',
entry: {
'./chrome/js/background': ['./src/modules/background/background.ts'],
'./chrome/js/trakt': ['./src/modules/content/trakt/trakt.ts'],
+ './chrome/js/popup': ['./src/modules/popup/popup.tsx'],
'./chrome/js/history': ['./src/modules/history/history.tsx'],
+ './chrome/js/options': ['./src/modules/options/options.tsx'],
'./firefox/js/background': ['./src/modules/background/background.ts'],
'./firefox/js/trakt': ['./src/modules/content/trakt/trakt.ts'],
+ './firefox/js/popup': ['./src/modules/popup/popup.tsx'],
'./firefox/js/history': ['./src/modules/history/history.tsx'],
+ './firefox/js/options': ['./src/modules/options/options.tsx'],
+ ...streamingServiceEntries,
},
mode,
module: {
@@ -156,6 +170,13 @@ const getWebpackConfig = (env: Environment) => {
};
const getManifest = (config: Config, browserName: string): string => {
+ const streamingServiceScripts: Manifest['content_scripts'] = Object.values(streamingServices)
+ .filter((service) => service.hasScrobbler)
+ .map((service) => ({
+ js: ['js/lib/browser-polyfill.js', `js/${service.id}.js`],
+ matches: service.hostPatterns,
+ run_at: 'document_idle',
+ }));
const manifest: Manifest = {
manifest_version: 2,
name: '__MSG_appName__',
@@ -175,10 +196,12 @@ const getManifest = (config: Config, browserName: string): string => {
matches: ['*://*.trakt.tv/apps*'],
run_at: 'document_start',
},
+ ...streamingServiceScripts,
],
default_locale: 'en',
optional_permissions: [
'cookies',
+ 'notifications',
'webRequest',
'webRequestBlocking',
'*://api.rollbar.com/*',
@@ -193,6 +216,8 @@ const getManifest = (config: Config, browserName: string): string => {
19: 'images/uts-icon-19.png',
38: 'images/uts-icon-38.png',
},
+ default_popup: 'html/popup.html',
+ default_title: 'Universal Trakt Scrobbler',
},
permissions: ['identity', 'storage', 'tabs', 'unlimitedStorage', '*://*.trakt.tv/*'],
web_accessible_resources: [