diff --git a/Makefile b/Makefile index 6bf9a52..c5d0aac 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,3 @@ setup: # Install act for GitHub action local runner: https://nektosact.com/installation/index.html curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash - -.PHONY: docs -docs: - @echo "## Supported Anime" > docs/crunchyroll.md - @jq -r '.services.crunchyroll.series | to_entries[] | "- \(.value.title)"' assets/configs/default_config.json >> docs/crunchyroll.md diff --git a/README.md b/README.md index be31767..0373d7e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ > [!WARNING] > This is still under development and it's still experimental phase. -This is a brwwser extension to block spoilers for anime in some services. +This is a browser extension to block spoilers for anime in some services. There are a few features supported to block spoilers 1. Keep the history of the last episode of a season for each anime you watched @@ -14,6 +14,4 @@ There are a few features supported to block spoilers ## Supported services Currently, spoilers are blocked on YouTube only. -You can also see which anime is currently supported on this extension in - -- [Crunchyroll](./docs/crunchyroll.md) +Currently, [Crunchyroll](https://www.crunchyroll.com/) is the only supported service to store a watch history on your chrome browser. diff --git a/assets/configs/default_config.json b/assets/configs/default_config.json index b1cef16..1b9809c 100644 --- a/assets/configs/default_config.json +++ b/assets/configs/default_config.json @@ -1,38 +1,20 @@ { - "series": { - "demon_slayer": { + "series": [ + { "title": "Demon Slayer", - "keywords": ["Demon Slayer"] + "keywords": ["Demon Slayer", "Kimetsu no Yaiba"] }, - "jujutsu_kaisen": { + { "title": "Jujutsu Kaisen", "keywords": ["Jujutsu Kaisen"] }, - "my_hero_academia": { + { "title": "My Hero Academia", "keywords": ["My Hero Academia"] }, - "mushoku_tensei": { + { "title": "Mushoku Tensei", "keywords": ["Mushoku Tensei", "Jobless Reincarnation"] } - }, - "services": { - "crunchyroll": { - "series": { - "demon_slayer": { - "title": "Demon Slayer: Kimetsu no Yaiba" - }, - "jujutsu_kaisen": { - "title": "Jujutsu Kaisen" - }, - "my_hero_academia": { - "title": "My Hero Academia" - }, - "mushoku_tensei": { - "title": "Mushoku Tensei: Jobless Reincarnation" - } - } - } - } + ] } diff --git a/assets/configs/user_history_example.json b/assets/configs/user_history_example.json index 7b40d97..c1b588a 100644 --- a/assets/configs/user_history_example.json +++ b/assets/configs/user_history_example.json @@ -1,22 +1,25 @@ { - "series": { - "demon_slayer": { + "series": [ + { + "title": "Demon Slayer", "tv": { "season": 1, "episode": 1 } }, - "jujutsu_kaisen": { + { + "title": "Jujutsu Kaisen", "tv": { "season": 1, "episode": 1 } }, - "mushoku_tensei": { + { + "title": "Mushoku Tensei", "tv": { - "season": 2, + "season": 1, "episode": 1 } } - } + ] } diff --git a/docs/crunchyroll.md b/docs/crunchyroll.md deleted file mode 100644 index c5d173a..0000000 --- a/docs/crunchyroll.md +++ /dev/null @@ -1,5 +0,0 @@ -## Supported Anime -- Demon Slayer: Kimetsu no Yaiba -- Jujutsu Kaisen -- My Hero Academia -- Mushoku Tensei: Jobless Reincarnation diff --git a/src/background/index.ts b/src/background/index.ts index eaa309a..97a528e 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,4 +1,4 @@ -import type { Config, UserHistory } from "../blocker"; +import type { StorageAnimeConfig, StorageUserHistory } from "~blocker/storage"; import { Storage } from "@plasmohq/storage"; const storage = new Storage(); @@ -27,8 +27,8 @@ async function onInstall() { return await response.json(); }) ); - const config: Config = responses[0]; - const userHistory: UserHistory = responses[1]; + const config: StorageAnimeConfig = responses[0]; + const userHistory: StorageUserHistory = responses[1]; storage.set("config", config); storage.set("userHistory", userHistory); diff --git a/src/background/messages/getConfig.ts b/src/background/messages/getConfig.ts index 189ca16..76a9258 100644 --- a/src/background/messages/getConfig.ts +++ b/src/background/messages/getConfig.ts @@ -1,5 +1,5 @@ import type { PlasmoMessaging } from "@plasmohq/messaging"; -import type { Config, UserHistory } from "~blocker"; +import type { StorageAnimeConfig, StorageUserHistory } from "~blocker/storage"; import { Storage } from "@plasmohq/storage"; const storage = new Storage(); @@ -7,13 +7,14 @@ const storage = new Storage(); const handler: PlasmoMessaging.MessageHandler< {}, { - config: Config; - userHistory: UserHistory; + config: StorageAnimeConfig; + userHistory: StorageUserHistory; } > = async (req, res) => { Promise.all([storage.get("config"), storage.get("userHistory")]).then( (result) => { - const [config, userHistory]: [Config, UserHistory] = result as any; + const [config, userHistory]: [StorageAnimeConfig, StorageUserHistory] = + result as any; res.send({ config, userHistory, diff --git a/src/background/messages/updateWatchHistory.ts b/src/background/messages/updateWatchHistory.ts index 01030df..07d8c95 100644 --- a/src/background/messages/updateWatchHistory.ts +++ b/src/background/messages/updateWatchHistory.ts @@ -1,13 +1,16 @@ import type { PlasmoMessaging } from "@plasmohq/messaging"; -import type { Config, UserHistory } from "~blocker"; +import type { StorageAnimeConfig, StorageUserHistory } from "~blocker/storage"; import { Storage } from "@plasmohq/storage"; +import { UserHistoryManager } from "~blocker/storage"; const storage = new Storage(); export interface UpdateWatchHistoryRequest { webServiceName: string; mediaType: string; - series: string; + + // The title might be multiple choices. Enable to pass one of them to match a history + titles: string[]; season: number; episode: number; } @@ -23,53 +26,17 @@ const handler: PlasmoMessaging.MessageHandler< Promise.all([storage.get("config"), storage.get("userHistory")]).then( async (promises) => { - const [config, userHistory]: [Config, UserHistory] = promises as any; - if (config.services[message.webServiceName] == null) { - return; - } - - let result; - Object.entries( - (config as Config).services[message.webServiceName].series - ).forEach(([seriesID, thisSeries]) => { - if ( - thisSeries.title.toLocaleLowerCase() == - message.series.toLocaleLowerCase() - ) { - result = seriesID; - return; - } - }); - - if (result == null) { + const [config, userHistory]: [StorageAnimeConfig, StorageUserHistory] = + promises as any; + const userHistoryManager = new UserHistoryManager(config, userHistory); + const updatedConfigs = userHistoryManager.updateWatchHistory(message); + if (updatedConfigs == null || updatedConfigs.userHistory == null) { return; } - if (userHistory.series[result] == null) { - userHistory.series[result] = { - tv: { - season: 0, - episode: 0, - }, - }; + if (updatedConfigs.config != null) { + await storage.set("config", updatedConfigs.config); } - if (message.season < userHistory.series[result].tv.season) { - return; - } - if ( - message.season == userHistory.series[result].tv.season && - message.episode <= userHistory.series[result].tv.episode - ) { - return; - } - - userHistory.series[result].tv.season = message.season; - userHistory.series[result].tv.episode = message.episode; - await storage.set("userHistory", userHistory); - console.info("Updated a watch history", { - series: message.series, - season: userHistory.series[result].tv.season, - episode: userHistory.series[result].tv.episode, - }); + await storage.set("userHistory", updatedConfigs.userHistory); } ); }; diff --git a/src/blocker/index.test.ts b/src/blocker/index.test.ts index 8a5b0a7..527d620 100644 --- a/src/blocker/index.test.ts +++ b/src/blocker/index.test.ts @@ -75,23 +75,23 @@ describe("TextParser", () => { }; const textParser = new TextSpoilerAnalyzer( { - series: { - test: { + series: [ + { keywords, - title: "", + title: "test", }, - }, - services: {}, + ], }, { - series: { - test: { + series: [ + { + title: "test", tv: { season: 0, episode: 0, }, }, - }, + ], } ); const actual = textParser.extractEpisodeFromText(text, "Test", keywords); @@ -99,36 +99,37 @@ describe("TextParser", () => { }); }); - describe("extractSpoilerEpisode", () => { + describe("extractSpoiler", () => { const defaultTestCase = { config: { - series: { - test1: { + series: [ + { title: "Test", keywords: ["test1", "keyword1"], }, - test2: { + { title: "Test2", keywords: ["test2"], }, - }, - services: {}, + ], }, userHistory: { - series: { - test1: { + series: [ + { + title: "Test", tv: { season: 1, episode: 2, }, }, - test2: { + { + title: "Test2", tv: { season: 2, episode: 1, }, }, - }, + ], }, expected: { title: "Test", diff --git a/src/blocker/index.ts b/src/blocker/index.ts index fc2ae54..04695a4 100644 --- a/src/blocker/index.ts +++ b/src/blocker/index.ts @@ -1,37 +1,4 @@ -export interface Config { - series: { - [seriesID: string]: SeriesConfig; - }; - services: { - [serviceName: string]: { - series: { - [seriesID: string]: { - title: string; - }; - }; - }; - }; -} - -export interface SeriesConfig { - title: string; - keywords: string[]; -} - -enum MediaType { - TVShows = "tv", -} - -export interface UserHistory { - series: { - [seriesID: string]: { - [MediaType.TVShows]: { - season: number; - episode: number; - }; - }; - }; -} +import { StorageAnimeConfig, StorageUserHistory } from "./storage"; export interface Spoiler { title: string; @@ -40,16 +7,14 @@ export interface Spoiler { } export class TextSpoilerAnalyzer { - config: Config; - userHistory: UserHistory; + config: StorageAnimeConfig; + userHistory: StorageUserHistory; - constructor(config: Config, userHistory: UserHistory) { - Object.keys(config.series).forEach((contentId) => { - config.series[contentId].keywords = config.series[contentId].keywords.map( - (keyword) => { - return keyword.toLowerCase(); - } - ); + constructor(config: StorageAnimeConfig, userHistory: StorageUserHistory) { + config.series.forEach((thisSeries) => { + thisSeries.keywords = thisSeries.keywords.map((keyword) => { + return keyword.toLowerCase(); + }); }); this.config = config; @@ -111,7 +76,7 @@ export class TextSpoilerAnalyzer { season: 0, episode: 0, }; - Object.entries(this.config.series).forEach(([seriesId, config]) => { + this.config.series.forEach((config) => { const episodeFromText = this.extractEpisodeFromText( text, config.title, @@ -127,7 +92,15 @@ export class TextSpoilerAnalyzer { return; } - const allowedEpisode = this.userHistory.series[seriesId].tv; + const seriesFromHistory = this.userHistory.series.find( + (series) => series.title.toLowerCase() == config.title.toLowerCase() + ); + if (seriesFromHistory == null) { + // The title in the series is supposed to match with a user history + return; + } + + const allowedEpisode = seriesFromHistory.tv; if (episodeFromText.season < allowedEpisode.season) { return; } diff --git a/src/blocker/storage.test.ts b/src/blocker/storage.test.ts new file mode 100644 index 0000000..fd6201a --- /dev/null +++ b/src/blocker/storage.test.ts @@ -0,0 +1,116 @@ +import { UserHistoryManager } from "./storage"; + +describe("UserHistoryManager", () => { + const config = { + series: [ + { + title: "My Hero Academia", + keywords: ["My Hero Academia"], + }, + ], + }; + const userHistory = { + series: [ + { + title: "My Hero Academia", + tv: { + season: 1, + episode: 2, + }, + }, + ], + }; + + test.each([ + { + name: "Watch next episode", + message: { + mediaType: "tv", + titles: ["My Hero Academia"], + season: 1, + episode: 3, + }, + expected: { + userHistory: { + series: [ + { + title: "My Hero Academia", + tv: { + season: 1, + episode: 3, + }, + }, + ], + }, + }, + }, + { + name: "Watch next season", + message: { + mediaType: "tv", + titles: ["My Hero Academia"], + season: 2, + episode: 1, + }, + expected: { + userHistory: { + series: [ + { + title: "My Hero Academia", + tv: { + season: 2, + episode: 1, + }, + }, + ], + }, + }, + }, + { + name: "Watch new series", + message: { + mediaType: "tv", + titles: ["Demon Slayer", "Kimetsu no Yaiba"], + season: 1, + episode: 2, + }, + expected: { + config: { + series: [ + ...config.series, + { + title: "Demon Slayer", + keywords: ["Demon Slayer", "Kimetsu no Yaiba"], + }, + ], + }, + userHistory: { + series: [ + ...userHistory.series, + { + title: "Demon Slayer", + tv: { + season: 1, + episode: 2, + }, + }, + ], + }, + }, + }, + + { + name: "Watch the same episode", + message: { + mediaType: "tv", + titles: ["My Hero Academia"], + season: 1, + episode: 2, + }, + }, + ])("$name", async ({ message, expected }) => { + const manager = new UserHistoryManager(config, userHistory); + const actual = manager.updateWatchHistory(message); + expect(actual).toEqual(expected); + }); +}); diff --git a/src/blocker/storage.ts b/src/blocker/storage.ts new file mode 100644 index 0000000..77f415e --- /dev/null +++ b/src/blocker/storage.ts @@ -0,0 +1,124 @@ +export interface StorageAnimeConfig { + series: StorageSeriesConfig[]; +} + +export interface StorageSeriesConfig { + title: string; + keywords: string[]; +} + +enum MediaType { + TVShows = "tv", +} + +export interface StorageUserHistory { + series: { + title: string; + [MediaType.TVShows]: { + season: number; + episode: number; + }; + }[]; +} + +export class UserHistoryManager { + userConfig: { + series: { + [seriesTitle: string]: { + keywords: string[]; + title: string; + [MediaType.TVShows]: { + season: number; + episode: number; + }; + }; + }; + }; + storageAnimeConfig: StorageAnimeConfig; + storageUserHistory: StorageUserHistory; + + constructor( + animeConfig: StorageAnimeConfig, + userHistory: StorageUserHistory + ) { + const userConfig = { series: {} }; + animeConfig.series.forEach((series) => { + userHistory.series.forEach((userSeries) => { + if (series.title.toLowerCase() == userSeries.title.toLowerCase()) { + userConfig.series[series.title.toLowerCase()] = { + title: series.title, + keywords: series.keywords, + [MediaType.TVShows]: { + season: userSeries.tv.season, + episode: userSeries.tv.episode, + }, + }; + } + }); + }); + this.userConfig = userConfig; + this.storageAnimeConfig = animeConfig; + this.storageUserHistory = userHistory; + } + + updateWatchHistory(request: { + mediaType: string; + titles: string[]; + season: number; + episode: number; + }): { config?: StorageAnimeConfig; userHistory?: StorageUserHistory } { + const { mediaType, titles, season, episode } = request; + if (mediaType !== MediaType.TVShows) { + return; + } + + const configs = titles + .map((title) => { + return this.userConfig.series[title.toLowerCase()]; + }) + .filter((config) => config != null); + if (configs.length == 0) { + // new series to watch + this.storageUserHistory.series.push({ + title: titles[0], + tv: { + season, + episode, + }, + }); + // TODO: update config master as well + this.storageAnimeConfig.series.push({ + title: titles[0], + keywords: titles, + }); + return { + config: this.storageAnimeConfig, + userHistory: this.storageUserHistory, + }; + } + + const config = configs[0]; + if (season < config.tv.season) { + return; + } + if (season == config.tv.season && episode <= config.tv.episode) { + return; + } + + this.storageUserHistory.series.forEach((thisSeries, index) => { + if (thisSeries.title.toLowerCase() == config.title.toLowerCase()) { + this.storageUserHistory.series[index].tv.season = season; + this.storageUserHistory.series[index].tv.episode = episode; + } + }); + + console.info("Updated a watch history", { + series: titles, + season, + episode, + }); + return { + userHistory: this.storageUserHistory, + }; + } +} diff --git a/src/contents/crunchyroll/index.ts b/src/contents/crunchyroll/index.ts index 734c985..1ee05c8 100644 --- a/src/contents/crunchyroll/index.ts +++ b/src/contents/crunchyroll/index.ts @@ -47,9 +47,6 @@ async function onLoad() { } cache.set(titleContent, () => { const episode = parseTitle(titleContent); - console.log("", { - episode, - }); sendToBackground({ name: "updateWatchHistory", body: { diff --git a/src/contents/crunchyroll/parser.test.ts b/src/contents/crunchyroll/parser.test.ts index d2e85da..6dcc14f 100644 --- a/src/contents/crunchyroll/parser.test.ts +++ b/src/contents/crunchyroll/parser.test.ts @@ -8,7 +8,7 @@ describe("parsedTitle", () => { // Demon Slayer season 1 title: "Demon Slayer: Kimetsu no Yaiba | E1 - Cruelty'", expected: { - series: "Demon Slayer: Kimetsu no Yaiba", + titles: ["Demon Slayer", "Kimetsu no Yaiba"], episode: 1, season: 1, }, @@ -17,7 +17,7 @@ describe("parsedTitle", () => { // Demon Slayer season 2 title: "Demon Slayer: Kimetsu no Yaiba | E2 - Trainer Sakonji Urokodaki", expected: { - series: "Demon Slayer: Kimetsu no Yaiba", + titles: ["Demon Slayer", "Kimetsu no Yaiba"], episode: 2, // TODO: season: 2. Need to map a title to a season season: 1, @@ -27,7 +27,7 @@ describe("parsedTitle", () => { // JJK season 1 title: "JUJUTSU KAISEN | E1 - Ryomen Sukuna", expected: { - series: "JUJUTSU KAISEN", + titles: ["JUJUTSU KAISEN"], episode: 1, season: 1, }, @@ -36,7 +36,7 @@ describe("parsedTitle", () => { // JJK season 2 title: "JUJUTSU KAISEN Season 2 | E25 - Hidden Inventory", expected: { - series: "JUJUTSU KAISEN", + titles: ["JUJUTSU KAISEN"], episode: 25, season: 2, }, @@ -45,7 +45,7 @@ describe("parsedTitle", () => { // MHA season 1 (Dub) title: "My Hero Academia (English Dub) | E1 - Izuku Midoriya: Origin'", expected: { - series: "My Hero Academia", + titles: ["My Hero Academia"], episode: 1, season: 1, }, @@ -54,7 +54,7 @@ describe("parsedTitle", () => { // MHA season 2 title: "My Hero Academia Season 2 | E13.5 - Hero Notebook", expected: { - series: "My Hero Academia", + titles: ["My Hero Academia"], episode: 13.5, season: 2, }, diff --git a/src/contents/crunchyroll/parser.ts b/src/contents/crunchyroll/parser.ts index c9bb267..405fa55 100644 --- a/src/contents/crunchyroll/parser.ts +++ b/src/contents/crunchyroll/parser.ts @@ -1,5 +1,5 @@ export interface ParsedTitle { - series: string; + titles: string[]; episode: number; season: number; } @@ -30,7 +30,7 @@ export function parseTitle(title: string): ParsedTitle | null { } return { - series, + titles: series.split(": "), episode, season, }; diff --git a/src/contents/youtube.tsx b/src/contents/youtube.tsx index d9b4ca6..d9f7248 100644 --- a/src/contents/youtube.tsx +++ b/src/contents/youtube.tsx @@ -1,6 +1,7 @@ import "react"; import React, { useEffect, useState } from "react"; -import { TextSpoilerAnalyzer, Spoiler, UserHistory, Config } from "../blocker"; +import { TextSpoilerAnalyzer, Spoiler } from "../blocker"; +import { StorageUserHistory, StorageAnimeConfig } from "~blocker/storage"; import type { PlasmoCSConfig, PlasmoCSUIProps, @@ -68,8 +69,8 @@ class VideoSpoilerFilter { } } -let blockerConfig: Config; -let userHistory: UserHistory; +let blockerConfig: StorageAnimeConfig; +let userHistory: StorageUserHistory; window.addEventListener("load", async (event) => { const result = await sendToBackground({ diff --git a/src/options/index.tsx b/src/options/index.tsx index 306e5ed..079bd2a 100644 --- a/src/options/index.tsx +++ b/src/options/index.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { useStorage } from "@plasmohq/storage/hook"; -import type { UserHistory, Config } from "~blocker"; +import type { StorageUserHistory, StorageAnimeConfig } from "~blocker/storage"; interface InputJSONFieldProps { label: string; @@ -47,9 +47,10 @@ function InputJSONField({ } export default function IndexOptions() { - const [initialConfigValue, setConfig] = useStorage("config"); + const [initialConfigValue, setConfig] = + useStorage("config"); const [initialUserHistoryValue, setUserHistory] = - useStorage("userHistory"); + useStorage("userHistory"); if (initialConfigValue == null || initialUserHistoryValue == null) { return

Loading...

; @@ -63,14 +64,14 @@ export default function IndexOptions() { label={"Anime configuration"} initialValue={initialConfigValue} setValue={(value: Object) => { - setConfig(value as Config); + setConfig(value as StorageAnimeConfig); }} /> { - setUserHistory(value as UserHistory); + setUserHistory(value as StorageUserHistory); }} />