Creating your own Service
What is a “Service”?
A “service” is the abstract concept of an interface between the core Dolos functionality and a website. Instead of rewriting everything from scratch for every site we want to support there’s only a comparatively tiny amount of code to write thanks to services. The behaviour of Dolos is standardised. It should work the same for every site, thus one only needs to provide logic required to interact with the page to adapt to a new site.
Adding a new Service is very easy in most cases.
Dolos already has two Services which may serve as good reference points. There’s the simple MyAnimeList Service and the more complex Kitsu Service.
Example for aniDB
Instead of having to read my incoherent blabbering, let’s instead take a different approach and build our own little Service together, shall we?
Don’t be afraid, it probably won’t take that long and it’s gonna be a fun adventure for you and me.
This guide is written for Dolos version
0.3.0
. If the current version of Dolos no longer matches this one I encourage you to extrapolate from this guide to the new version.
We’re going to setup the page anidb.net to work with Dolos. We’re in luck, the page seems to be “static”. What do I mean by that? Well, Kitsu for example uses the Ember framework which makes it much more complex to handle. This page, however, seems to be very generic without all that much Javascript action going on. That’s a good thing!
But enough talk for now, let’s get started!
Getting ready for development
If you haven’t already, please read the Getting ready for development guide. It guides you through downloading the code, installing its dependencies, building the extension and installing it.
From this point on it’s assumed that you have webpack running in watch mode and the extension installed.
Adding files for the new Service
Before we get to the fun part, that is, writing code, we should first
setup the basic structure for the new service. Switch to the src
directory because that’s where Dolos’ code resides. Unless otherwise
specified we assume that we’re in the src
directory.
In this directory you’ll find a folder called “services”. This is a
special folder added in v0.2.3
to group the different services in one
place. That’s not the reason it’s special though. It’s special because
all folders inside of it are automatically compiled to a javascript file
with the same name in the /dist/js
directory.
If you take a peek at the contents of the “services” folder you’ll see folders like “myanimelist” and “kitsu” which contain the services for the respective sites.
As you might expect we’re going to add a new folder to the bunch. Create
a new folder called anidb
in the “services” folder and in it an empty
file index.ts
. The index.ts
is what’s being used as an entry point
so it’s what’s being run when the user opens the aniDB website.
To check whether this works, let’s put a little something in the index
file. If you’re not already using one, now’s probably a good time to
switch to an editor. Anything will do, really, but I recommend using
something like Visual Studio Code or
Atom because working with the system’s built-in text
editor won’t bring you very far. Open the index.ts
file you just
created and add something like:
console.log("Hello World from Dolos on aniDB");
We will use this to check whether the code is actually being run.
Okay great, let’s check whether it wo-… No! Don’t even try it, of
course it won’t work yet. The extension doesn’t mysteriously know that
it should run the script when you visit the page. How could it? We need
to first tell it to be run. And if you take a look at the /dist/js
folder there’s not even a file for it… What?
The reason for the absence of the file is that webpack doesn’t notice that there’s a new service. To fix that you simply have to restart the webpack watch service.
After it’s done you should see an anidb.js
file appear in /dist/js
.
Now you might be tempted to try it out now but I need you to wait a
little longer, because even though now there’s a Javascript file the
extension still doesn’t know when to run it. We want to tell the
extension to run the anidb.js
file whenever the user navigates to a
anidb.net site. To do this, open the manifest.json
file found in the
/dist
directory. In it you’ll find the key "content_scripts"
which
holds a list of objects.
Add the following entry to the list:
{
"matches": [
"*://anidb.net/*"
],
"js": [
"js/anidb.js"
],
"run_at": "document_end"
}
To give a brief overview of what this does:
- matches tells the browser when to run the script. In our case it should run for all anidb.net pages.
-
js contains a list of javascript files to be run. We only want to
run the
js/anidb.js
file. - run_at tells the browser at what point it should run the code. If you’re interested about this, you can read about it here.
You might’ve noticed that there are multiple manifest.json
files in
there. While it’s not necessary right now, please also add the entry
to the other files!
Now we’re ready to give it a shot! If you’re using npx web-ext run
(Firefox) it should’ve already automatically reloaded the extension and
if you’re using Chrome you need to manually reload the extension by
going to chrome://extensions/
and pressing the reload button.
Open a new tab and go to https://anidb.net. Open the Web Console (Press F12 and switch to the console tab) and you should see the beautiful “Hello World from Dolos on aniDB” text from before.
If you don’t see it, please make sure that webpack has rebuilt the Javascript and you’ve reloaded the extension!
Quick Recap because I’ve heard those are useful
- We created a new directory for our service called
anidb
in theservices
folder. - Added an
index.ts
file to said folder with some debug code to check whether it works. - Restarted webpack to make it compile our new service to the
/dist
directory, - Edited the
manifest.json
files so that our file is being run when the user visits anidb.net.
Alright, it’s been a journey. We’re done… Well… at least somewhat done. From now on we actually get to write some code. Isn’t that nice?
Writing the code for our new Service
Open the index.ts
file again and start by removing the console.info
line. We know the code is being loaded, we don’t need to test it any
more.
As I’ve already slightly touched upon in the introductory paragraph,
Dolos already does all of the heavy lifting for you, you only need to
write a shallow wrapper for Dolos to be able to interact with the page.
Dolos does this by exposing a bunch of
abstract classes.
One of which is the Service
class. It doesn’t
do very much, but it provides a good framework to work with. So let’s
use it!
First, we need to import it in our file. To do that, add the following line to the top of the file:
import {Service} from "dolos/common";
dolos/common
contains a whole bunch of useful stuff and you’ll see it a lot.
Create a class called AniDB
which extends Service
:
export default class AniDB extends Service {
// to be written
}
Because we’re going to use it in other modules be sure to export the class as the default export.
And there it is. Our own little Service, thanks for readin… - oh… Have a look at the output of webpack:
TS2515: Non-abstract class 'AniDB' does not implement inherited abstract member 'route' from class 'Service'.
You think I’ve overdone this bait-and-switch by now?
What this error message is trying to tell us is that we haven’t
implemented the necessary abstract methods yet - namely
route
. It’s the only abstract method
Service
has. It takes a
URL
object for
the current location and should “route” based on it.
I think this is as good a point as any to tell you about the concept of
ServicePage
s. A service page is Dolos’
abstract representation of a view (think: page) on the service (i.e. on
the website). The service itself doesn’t really interact with the page
all that much, it passes it on to the service page. There are two
noteworthy service pages we’re going to use.
AnimePage
and
EpisodePage
but more on that later.
The route
method should delegate which
service page to show.
We need to find out how to determine which service page to show, but for now let’s just add the method to stop webpack from complaining. Place the following code in the AniDB class body.
async route(url: URL): Promise<void> {
}
Okay, now it compiles again, but how do we determine which service page to show?
Routing
After browsing on anidb.net for a bit we can see,
that the url of an Anime looks something like this:
https://anidb.net/perl-bin/animedb.pl?show=anime&aid=69
. The first
part always stays the same, but the query string looks very interesting.
it contains show=anime
and aid=69
. To detect whether we’re on an
Anime page we can simply check whether the show
parameter is “anime”.
To do that, we can use the
URL.searchParams
attribute of the url that was passed to the route method. It gives us
access to a
URLSearchParams
object which has the
URLSearchParams.get
method. So url.searchParams.get("show")
will give us the value of the
“show” parameter.
We can add a check to our route method for this:
if (url.searchParams.get("show") === "anime") {
// show anime service page
}
Actually “showing” (more like loading) the service page is very simple.
The Service
class exposes the method
showAnimePage
which does exactly
that.
Side-note: Most of Dolos’ code is async and thus returns Promises. Please use
async
/await
syntax instead of operating on the promises directly.
All we have to do is add
await this.showAnimePage();
to the if-clause and we’re done.
The second route we would like to support is for specific episodes. I mean, the entire premise of Dolos is showing episode streams so of course we need to support that.
Looking at the url of an episode we can easily tell that, again, it uses
query parameters and has a “show” parameter which now has the value
“ep”: https://anidb.net/perl-bin/animedb.pl?show=ep&eid=440
So should we just use url.searchParams.get("show")
again to check
whether the value is “ep” this time? Well, we could… But this screams
like an opportunity to use a switch statement, doesn’t it?
Let’s refactor our logic to use a switch statement:
switch (url.searchParams.get("show")) {
case "anime":
// load anime service page
await this.showAnimePage();
break;
case "ep":
// load episode service page
await this.showEpisodePage();
break;
}
Thankfully the service page exposes the method
showEpisodePage
which is
analogous to the showAnimePage
method from before.
At this point you should justifiably get suspicious of me pulling
another bait-and-switch. Fret not, this time I trust that you’ve already
sensed the problem. It builds, but something is clearly wrong because
we’re never explicitly constructing an instance of AniDB
, nor have we
coded any service page logic yet…
The problem surfaces when we try to construct an instance of our new
service. To start our service we need to call the
load
method on an instance. Since we want
to load it as soon as the code is executed, i.e. the anidb website is
loaded, we can add the following line to the end of the file:
// create a new instance and call load on it
new AniDB().load();
But - oh, here we go. There’s our problem: Constructor of class
'Service' is protected and only accessible within the class
declaration.
This means that we can’t just construct our Service like
that, because we need to provide our own constructor for the service.
Well, that’s an easy fix, right? Just add a constructor which then calls
super
.
But this is the point where our dreams come crashing down. When we look
at the signature of the constructor
for a service we can see that it takes three arguments. A service id,
the anime page, and the episode page.
The first one is easy, it’s used to uniquely identify the service. We’re
going to use anidb
.
The other arguments, however, require us to pass service page classes…
No wonder nothing complained so far. Well, we’re going to write them
soon enough, for now let’s just pass undefined
.
TypeScript won’t like that at all, so let’s add // @ts-ignore
above
the line to make TypeScript ignore it.
So the constructor should look like this:
constructor() {
// @ts-ignore
super("anidb", undefined, undefined);
}
Of course we can’t expect this to do anything or even work for that matter, but at least it builds and we can focus on writing the service pages now.
Our code so far:
// /src/services/anidb/index.ts:
import { Service } from "dolos/common";
export default class AniDB extends Service {
// Override constructor for convenience.
constructor() {
// we haven't coded the service pages yet
// @ts-ignore
super("anidb", undefined, undefined);
}
async route(url: URL): Promise<void> {
switch (url.searchParams.get("show")) {
case "anime":
// load anime service page
await this.showAnimePage();
break;
case "ep":
// load episode service page
await this.showEpisodePage();
break;
}
}
}
// load the service as soon as the code is executed.
new AniDB().load();
Anime Service Page
Because “a” comes before “e” we’re going to start with writing the anime
service page. A service page extends the abstract class
ServicePage
. As you can see it doesn’t have
a very broad spectrum of methods. But we’re not going to deal with it
directly anyway, because there’s already a specific service page for
Anime service pages. It’s called - you’ve guessed it -
AnimePage
. (If you’ve guessed
“AnimeServicePage” then you’re already a lot smarter than I was when I
named it)
All we need to do is create a class for anidb that extends
AnimePage
and implements a few cough
10 cough abstract methods. NO, PLEASE COME BACK! It’s not as
bad as it sounds. These methods are really basic.
Okay, thanks for trusting me. Have a cookie or something…
Anyway, let’s start by creating a new file in the anidb
directory
called anime.ts
and open it. To extend
AnimePage
we have to get access to it
first. Import it using import {AnimePage} from "dolos/common/pages";
.
Add a new class AniDBAnimePage
which extends AnimePage and
default-export it:
export default AniDBAnimePage extends AnimePage {
}
Upon saving you’ll find a TON of errors from webpack. Most of them are
there because we haven’t implemented the abstract methods yet, but
there’s one error that isn’t like the others: TS2314: Generic type
'AnimePage<T>' requires 1 type argument(s).
You see, the AnimePage class is a generic class. It takes the type of the Service it belongs to.
Import the AniDB service class from the index file: import AniDB from
"./index";
and replace extends AnimePage
with extends
AnimePage<AniDB>
. Now it knows that this is a service page belonging to
AniDB.
Now it’s time to implement the abstract methods. We need to implement the following method signatures, but don’t worry, we’ll go over them one by one:
async getAnimeIdentifier(): Promise<string | undefined>
async getAnimeSearchQuery(): Promise<string | undefined>
async getAnimeURL(): Promise<string | undefined>
async getEpisodeURL(episodeIndex: number): Promise<string | undefined>
async showEpisode(episodeIndex: number): Promise<boolean>
async getEpisodeCount(): Promise<number | undefined>
async canSetEpisodesWatched(): Promise<boolean>
protected async _setEpisodesWatched(progress: number): Promise<boolean>
protected async _getEpisodesWatched(): Promise<number | undefined>
async injectAnimeStatusBar(statusBar: Element): Promise<void>
But take a second to look at the names. It may look like a lot of methods, but they really only do one specific thing, so most of them are no longer than one line of code. That’s easy, right?
Okay so here goes!
getAnimeIdentifier
This method should return a string that identifies the Anime. You don’t have to return something like the name of the Anime. Often you can actually just use parts of the url (preferred). The goal here is to have something very quickly that can be used to store information for an Anime.
For aniDB this is easy. Next to the “show” parameter we’ve also seen “aid” which presumably stands for anime id. We can just use that!
getAnimeSearchQuery
Return the search query that should be passed to the search endpoint of Grobber. I don’t think there’s a situation where you wouldn’t just return the title of the Anime, but who knows?
aniDB displays the title in a table cell on the right which we can
target using the selector td.value > span[itemprop="name"]
.
getAnimeURL
You might think: “What the hell, why don’t you just use location.href?”
Well, you can do that if you want, but it might lead to some problems
which will become apparent later on. It’s a good idea to build the url
from scratch. We can do that by using the base url of an anime and
setting the show parameter to “anime” and the aid parameter to the anime
identifier from getAnimeIdentifier
.
getEpisodeURL
This method should return the url for a given episode index. This is
relatively simple for most sites because the episodes have an url like
/episode/5
. aniDB, instead, decided to express f*ck all in their urls.
It’s just a number…
Because of this questionable decision we have take a different
approach. At the bottom of the page there’s a table with all episodes in
it, we’re going to add a helper function that parses this table:
async getEpisodeURLs(): Promise<{ [key: string]: string }> {
// get all episode links
const epLinks = document.querySelectorAll(`#eplist > tbody > tr > td > a[itemprop="url"]`);
const urls: { [key: string]: string } = {};
for (const epLink of Array.from(epLinks)) {
// get the url of the episode
const url = (epLink as HTMLLinkElement).href;
if (!url) continue;
// get the episode text
const epText = epLink.textContent;
if (!epText) continue;
const episode = epText.trim();
urls[episode] = url;
}
return urls;
}
This returns an object which maps an episode number to its url.
We can use this in our actual implementation of getEpisodeURL
:
async getEpisodeURL(episodeIndex: number): Promise<string | undefined> {
const episodes = await this.getEpisodeURLs();
// get episode number from index and convert to string
const episode = (episodeIndex + 1).toString();
// return the url
return episodes[episode];
}
Dolos generally uses indices (i.e. starting from 0) to index episodes. Please stick to this convention!
showEpisode
Take the user to the given episode. In many cases this will just be
manipulating the url, but Kitsu, for example, can smoothly transition to
a different page. aniDB doesn’t do anything fancy like that, so we can
just assign the url we get from getEpisodeURL
to the location.
getEpisodeCount
Returns the amount of episodes the Anime has. aniDB makes this
relatively easy, it displays the episode count in a span tag with the
class amountOfEpisodes
. Unless it’s a movie, then it shows the text
“Movie” instead. Could be worse…
async getEpisodeCount(): Promise<number | undefined> {
const animeType = document.querySelector("div.pane.info tr.type > td.value");
if (!animeType) return;
// get the episode count. Not present for movies for example
const episodeCount = animeType.querySelector(`span[itemprop="numberOfEpisodes"]`);
if (episodeCount) return parseInt(episodeCount.innerHTML);
// if it's a movie return 1
if (animeType.innerHTML === "Movie") return 1;
return undefined;
}
canSetEpisodesWatched
Indicates whether Dolos can keep track of the amount of episodes the user has watched. This is usually true when the user is logged-in and false otherwise. In this case we’re just going to return false mainly because I can’t be bothered to actually create an account to test this. The implementation of this is left as an exercise to the reader.
_setEpisodesWatched
Is used to set the amount of episodes the user has watched. It returns
whether the operation was successful. Since we’re not doing this part we
can just return false
again.
_getEpisodesWatched
Return the amount of episodes the user has already seen, or undefined.
We’re going to return undefined
here because again, not bothering with
this part.
injectAnimeStatusBar
THE FINAL METHOD, FINALLY! This method takes an
Element
which it should add to the DOM somewhere. The element in question
happens to be the
AnimeStatusBar
which
consists of a continue watching and a subscribe button.
For aniDB there seems to be a lot of space just below the Anime poster, so let’s insert it there.
async injectAnimeStatusBar(statusBar: Element): Promise<void> {
const imgContainer = document.querySelector("div.image > div.container");
if (!imgContainer) return;
// insert the status bar after the image
imgContainer.insertAdjacentElement("afterend", statusBar);
this.injected(statusBar);
}
“But what is that last line exactly”, I’m sure you’re wondering. It’s simple, that last line is absolutely useless in this case. You see, since Dolos supports dynamic pages which don’t reload everything when they’re changing to a different page it has to make sure to remove its elements so that they don’t slowly fill-up the view because they’re never removed. For static pages like anidb.net this doesn’t matter all that much, because everything is reloaded when changing to a different page, but it’s still a good thing to do!
The entire implementation of the anime.ts
file should looks like this:
// /src/services/anidb/anime.ts:
import { AnimePage } from "dolos/common/pages";
import AniDB from "./index";
export default class AniDBAnimePage extends AnimePage<AniDB> {
async persistentStore(key: string, value: any): Promise<void> {
const prefix = await this.getAnimeIdentifier();
key = `${prefix}-${key}`;
localStorage.setItem(key, JSON.stringify(value));
}
async persistentLoad(key: string): Promise<any | null> {
const prefix = await this.getAnimeIdentifier();
key = `${prefix}-${key}`;
const serVal = localStorage.getItem(key);
if (serVal === null) return serVal;
return JSON.parse(serVal);
}
async getAnimeIdentifier(): Promise<string | undefined> {
// parse the current url into a URL object because
// it's easier to work with and return the "aid" parameter.
// searchParams.get returns null when the parameter doesn't exist,
// but we need it to return undefined, thus the "|| undefined"
return new URL(location.href).searchParams.get("aid") || undefined;
}
async getAnimeSearchQuery(): Promise<string | undefined> {
// find the "Main Title" table cell.
const mainTitle = document.querySelector(`td.value > span[itemprop="name"]`);
// return its content if it was found or undefined otherwise
return mainTitle ? mainTitle.innerHTML : undefined;
}
async getAnimeURL(): Promise<string | undefined> {
// we know that our anime identifier is the aid parameter
const animeID = await this.getAnimeIdentifier();
// if aid is undefined, return undefined!
if (!animeID) return;
const url = new URL("https://anidb.net/perl-bin/animedb.pl");
// add ?show=anime
url.searchParams.set("show", "anime");
// and &aid=<anime id>
url.searchParams.set("aid", animeID);
// return the string representation
return url.href;
}
async getEpisodeURLs(): Promise<{ [key: string]: string }> {
// if we already have it stored reuse that
const storedURLs = await this.persistentLoad("episodeURLs");
if (storedURLs) return storedURLs;
// get all episode links
const epLinks = document.querySelectorAll(`#eplist > tbody > tr > td > a[itemprop="url"]`);
const urls: { [key: string]: string } = {};
for (const epLink of Array.from(epLinks)) {
// get the url of the episode
const url = (epLink as HTMLLinkElement).href;
if (!url) continue;
const epText = epLink.textContent;
if (!epText) continue;
const episode = epText.trim();
urls[episode] = url;
}
// store urls
await this.persistentStore("episodeURLs", urls);
return urls;
}
async getEpisodeURL(episodeIndex: number): Promise<string | undefined> {
const episodes = await this.getEpisodeURLs();
// get episode number from index and convert to string
const episode = (episodeIndex + 1).toString();
// return the url
return episodes[episode];
}
async showEpisode(episodeIndex: number): Promise<boolean> {
const episodeURL = await this.getEpisodeURL(episodeIndex);
// if we don't know the url then we don't even have to bother
if (!episodeURL) return false;
location.assign(episodeURL);
return true;
}
async getEpisodeCount(): Promise<number | undefined> {
const storedEpCount = await this.persistentLoad("episodeCount");
if (storedEpCount) return storedEpCount;
// just wrap it in a function, this way we don't have to handle
// each "return" individually.
const epCount = (() => {
const animeType = document.querySelector("div.pane.info tr.type > td.value");
if (!animeType) return;
// get the episode count. Not present for movies for example
const episodeCount = animeType.querySelector(`span[itemprop="numberOfEpisodes"]`);
if (episodeCount) return parseInt(episodeCount.innerHTML);
// if it's a movie return 1
if (animeType.innerHTML === "Movie") return 1;
return undefined;
})();
await this.persistentStore("episodeCount", epCount);
return epCount;
}
async canSetEpisodesWatched(): Promise<boolean> {
return false;
}
protected async _setEpisodesWatched(progress: number): Promise<boolean> {
return false;
}
protected async _getEpisodesWatched(): Promise<number | undefined> {
return undefined;
}
async injectAnimeStatusBar(statusBar: Element): Promise<void> {
const imgContainer = document.querySelector("div.image > div.container");
if (!imgContainer) return;
// insert the status bar after the image
imgContainer.insertAdjacentElement("afterend", statusBar);
// good practise!
this.injected(statusBar);
}
}
And we’re done! That’s our Anime service page. All that’s left to do is
adding it to the AniDB service. To do that simply open the index.ts
file again, import the service page using import AniDBAnimePage from
"./anime";
and replace the first undefined
with AniDBAnimePage
:
super("anidb", AniDBAnimePage, undefined);
If you feel tempted to try it out right now then please do so. But let me warn you, it looks like absolute sh*t! You see the design of anidb.net is interfering with Dolos and making is look like a mess. What are we gonna do about it? Well… Nothing. If I had known this in advance I would’ve chosen a different website. But fact of the matter is that I didn’t.
There are ways to get around this, using Shadow DOM, iframes and what have you, but this would be totally out-of-scope for this guide.
At least now you know how to write an anime service page, but we’re still missing the most important thing: The episode page. In the next chapter we’re going to do exactly that!
Episode Service Page
Writing an episode service page is very similar to writing an anime
service page. Most of the code is already written for you and exposed
through the abstract EpisodePage
.
And, believe it or not, writing the episode page is actually easier than
the anime page. We only have to implement the following abstract
methods:
async getEpisodeIndex(): Promise<number | undefined>
async nextEpisodeButton(): Promise<SkipButton | undefined>
async showNextEpisode(): Promise<void>
async prevEpisodeButton(): Promise<SkipButton | undefined>
async showPrevEpisode(): Promise<void>
async injectEmbed(embed: Element): Promise<void>
buildAnimePage(): EpisodeAnimePageLike<AniDB>
You see? Only 7 methods! Looking at these methods you might be wondering: “what is that EpisodeAnimePageLike thing we’re supposed to build though?”
And I would be forced to answer: “Our downfall”. You’ll find out why later.
Let’s go through the methods one by one again.
getEpisodeIndex
Get the episode index of the episode. This can be done by parsing the title of the page which contains the episode number. Again, Dolos uses 0-based indices so we subtract 1 from the episode number.
async getEpisodeIndex(): Promise<number | undefined> {
// get title from page
const titleContainer = document.querySelector("h1.ep");
if (!titleContainer) return;
const epTitle = titleContainer.innerHTML;
// extract episode number
const match = /^Episode: .+ - (\d+) - .+$/.exec(epTitle);
if (!match) return;
return parseInt(match[1]) - 1;
}
nextEpisodeButton
Get the SkipButton for the
next episode. What is a “SkipButton”? Well, it contains the necessary
information for the button that is shown on the episode embed which
takes you to the next episode. It can take a href
and an onClick
handler. For our case href
is totally sufficient:
async nextEpisodeButton(): Promise<SkipButton | undefined> {
const nextEpBtn = document.querySelector(".links > .next a");
if (!nextEpBtn) return;
return {
href: (nextEpBtn as HTMLLinkElement).href,
};
}
showNextEpisode
Navigate the user to the next episode. We can just use the data from the
nextEpisodeButton
method to get the href attribute.
prevEpisodeButton
Same as nextEpisodeButton but the other way around:
async prevEpisodeButton(): Promise<SkipButton | undefined> {
const prevEpBtn = document.querySelector(".links > .prev a");
if (!prevEpBtn) return;
return {
href: (prevEpBtn as HTMLLinkElement).href,
};
}
showPrevEpisode
Same as showNextEpisode but for the previous episode using the
prevEpisodeButton
method.
injectEmbed
Add the episode embed (think: episode player) to the page. The episode page has an “airing schedule” table. Let’s insert it right above it:
async injectEmbed(embed: Element): Promise<void> {
// insert the embed just above the airing schedule table
const schedule = document.querySelector("#schedule");
if (!schedule) throw new Error("Couldn't find schedule");
schedule.insertAdjacentElement("beforebegin", embed);
// good practise!
this.injected(embed);
}
buildAnimePage
Now this is where things go south again. I mean, let me just quickly tell you what exactly the point of this function is. A lot of functionality is normally shared between anime and episode pages. I say normally because anidb doesn’t. So while normally
buildAnimePage(): EpisodeAnimePageLike<AniDB> {
return new AniDBEpisodeAnimePage(this.service);
}
Normally we could just return an instance of AniDBAnimePage
here (see
the myanimelist episode service page) but as said, anidb has a very
different site structure for the episode page than the anime page.
To fix this we use a super type which bridges these differences by overriding the relevant methods.
// override some AniDBAnimePage methods because
// they the episode page is nowhere near the same
// as the anime page.
class AniDBEpisodeAnimePage extends AniDBAnimePage {
async getAnimeIdentifier(): Promise<string | undefined> {
const animeLink = document.querySelector(".main-tabs > .anime a");
if (!animeLink) return;
// this is the same url format as seen on the real anime page.
// actually, now that I think about it, that's quite obvious...
const animeURL = new URL((animeLink as HTMLLinkElement).href);
return animeURL.searchParams.get("aid") || undefined;
}
async getAnimeSearchQuery(): Promise<string | undefined> {
// get title from page
const titleContainer = document.querySelector("h1.ep");
if (!titleContainer) return;
const epTitle = titleContainer.innerHTML;
// extract title
const match = /^Episode: (.+) - \d+ - .+$/.exec(epTitle);
if (!match) return;
return match[1];
}
async injectAnimeStatusBar(statusBar: Element): Promise<void> {
// we don't want to inject the anime status bar.
return;
}
}
If anidb wasn’t static but dynamic like Kitsu we would have to worry about transitions as well. Dolos has a transition framework between service pages and normally the transition from anime page to episode page automatically passes the anime page to be reused. This would be problematic in our case because we need a custom anime page. Luckily for us this transition doesn’t happen because the entire website is reloaded between page switches.
With that out of the way, this is what the episode.ts
file looks like:
// /src/services/anidb/episode.ts:
import AniDB from "./index";
import { EpisodePage, EpisodeAnimePageLike } from "dolos/common/pages";
import { SkipButton } from "dolos/common/components/anime";
import AniDBAnimePage from "./anime";
// override some AniDBAnimePage methods because
// they the episode page is nowhere near the same
// as the anime page.
class AniDBEpisodeAnimePage extends AniDBAnimePage {
async getAnimeIdentifier(): Promise<string | undefined> {
const animeLink = document.querySelector(".main-tabs > .anime a");
if (!animeLink) return;
// this is the same url format as seen on the real anime page.
// actually, now that I think about it, that's quite obvious...
const animeURL = new URL((animeLink as HTMLLinkElement).href);
return animeURL.searchParams.get("aid") || undefined;
}
async getAnimeSearchQuery(): Promise<string | undefined> {
// get title from page
const titleContainer = document.querySelector("h1.ep");
if (!titleContainer) return;
const epTitle = titleContainer.innerHTML;
// extract title
const match = /^Episode: (.+) - \d+ - .+$/.exec(epTitle);
if (!match) return;
return match[1];
}
async injectAnimeStatusBar(statusBar: Element): Promise<void> {
// we don't want to inject the anime status bar.
return;
}
}
export default class AniDBEpisodePage extends EpisodePage<AniDB> {
buildAnimePage(): EpisodeAnimePageLike<AniDB> {
return new AniDBEpisodeAnimePage(this.service);
}
async getEpisodeIndex(): Promise<number | undefined> {
// get title from page
const titleContainer = document.querySelector("h1.ep");
if (!titleContainer) return;
const epTitle = titleContainer.innerHTML;
// extract episode number
const match = /^Episode: .+ - (\d+) - .+$/.exec(epTitle);
if (!match) return;
return parseInt(match[1]) - 1;
}
async injectEmbed(embed: Element): Promise<void> {
// insert the embed just above the airing schedule table
const schedule = document.querySelector("#schedule");
if (!schedule) throw new Error("Couldn't find schedule");
schedule.insertAdjacentElement("beforebegin", embed);
// good practise!
this.injected(embed);
}
async nextEpisodeButton(): Promise<SkipButton | undefined> {
const nextEpBtn = document.querySelector(".links > .next a");
if (!nextEpBtn) return;
return {
href: (nextEpBtn as HTMLLinkElement).href,
};
}
async showNextEpisode(): Promise<void> {
// use the SkipButton data
const data = await this.nextEpisodeButton();
if (!data) return;
location.assign(data.href as string);
}
async prevEpisodeButton(): Promise<SkipButton | undefined> {
const prevEpBtn = document.querySelector(".links > .prev a");
if (!prevEpBtn) return;
return {
href: (prevEpBtn as HTMLLinkElement).href,
};
}
async showPrevEpisode(): Promise<void> {
// use the SkipButton data
const data = await this.prevEpisodeButton();
if (!data) return;
location.assign(data.href as string);
}
}
Now we can go back and add the AniDBEpisodePage to the AniDB service and then we’re done!
The final index.ts
file:
// /src/services/anidb/anime.ts:
import { Service } from "dolos/common";
import AniDBAnimePage from "./anime";
import AniDBEpisodePage from "./episode";
export default class AniDB extends Service {
constructor() {
// provide service pages and service id
super("anidb", AniDBAnimePage, AniDBEpisodePage);
}
async route(url: URL): Promise<void> {
switch (url.searchParams.get("show")) {
case "anime":
// load anime service page
await this.showAnimePage();
break;
case "ep":
// load episode service page
await this.showEpisodePage();
break;
}
}
}
// load the service as soon as the code is executed.
new AniDB().load();
And what does the episode page look like? This:
Yes, I know, it’s kind of disappointing how everything looks like sh*t, but this only served to illustrate how easy it is to add a new service even if it doesn’t quite work out in the end.
What have we done??
Even though it looks absolutely horrendous and we had to bend reality a little bit, it works. Of course we haven’t even touched the entire idea of setting the watch progress, but still, what we have can be considered a success. Let no one tell you otherwise.
The lack of “automated watch progress tracking” and the visual bugs are just some of the reasons why this isn’t part of Dolos yet.
If you wish, please feel free to extend this to properly support anidb.net and then open a pull request!