Skip to content

Dev: Adding video services

Carson McManus edited this page Nov 22, 2020 · 1 revision

Adding support for new video services

To support multiple video services, OpenTogetherTube uses a plugin-like system called "Service Adapters". Every service adapter is a class that handles the communication with a specific video service, like YouTube or Vimeo.

To create your own service adapter, create a new file under server/services/ which contains a class that extends ServiceAdapter and overrides all necessary methods. If you want to see an example, take a look at server/services/vimeo.js. It is a very simple, yet complete implementation.

Here is a template that you can use to start working on your own adapter. It includes the most common, required methods.

const ServiceAdapter = require("../serviceadapter");

class MyServiceAdapter extends ServiceAdapter {
  get serviceId() {
    return "";
  }

  canHandleURL(url) {
    return false;
  }

  isCollectionURL(url) {
    return false;
  }

  getVideoId(url) {
    return "";
  }

  async fetchVideoInfo(videoId) {
    return [];
  }
}

module.exports = MyServiceAdapter;

Methods

get serviceId() [required]

The service ID is required to identify the service in client-server communication and the cache. It's a simple string.

Example

  get serviceId() {
    return "myservice";
  }

canHandleURL(link) [required]

In order to work out which adapter has to handle a specific request, every adapter has to be able to determine whether it can resolve the URL. The method receives the URL as a string and must return a boolean that is true if the URL points to a valid video source that this adapter can handle.

Example

const URL = require("url");
// ...
  canHandleURL(url) {
    // Returns true for all URLs pointing to example.com.
    const host = URL.parse(url).host;
    return host.endsWith("example.com");
  }

isCollectionURL(url) [required]

Determines whether a URL points to a collection resource like a playlist or a channel. This method receives a URL as a string. This URL was already run through canHandleURL, so it is guaranteed to be valid. If your video service doesn't have any collections, this method should just return false.

Example

const URL = require("url");
// ...
  isCollectionURL(url) {
    const pathname = URL.parse(url).pathname;
    return pathname.startsWith("/playlist/");
  }

getVideoId(url) [required]

This method extracts the ID of a video from a URL and returns it. It receives a URL as a string that is guaranteed to be valid and not a collection URL as defined by canHandleURL and isCollectionURL.

Example

const URL = require("url");
// ...
  getVideoId(url) {
    // This splits the path into parts separated by slashes and returns the
    // last part. Given the URL is "https://example.com/video/abc123" it would
    // return "abc123".
    const pathname = URL.parse(url).pathname;
    return pathname.split("/").slice(-1)[0].trim();
  }

async fetchVideoInfo(videoId, missingInfo) [required]

This is the heart of every service adapter. It receives a video ID, queries the API for information and returns it in the form of a Video object.

In addition to a video ID, it receives an array with properties that are still missing. This is useful for APIs that let you query only specific bits of information in order to save quota. If a video is partially cached, this array will include only the properties that are not yet cached. Otherwise it will include all properties. You can ignore this parameter if your API doesn't support this.

Example

const axios = require("axios");
const ServiceAdapter = require("../serviceadapter");
const Video = require("../../common/video");

class ExampleAdapter extends ServiceAdapter {
  api = axios.create({
    baseURL: "https://api.example.com",
  });

  // ...

  async fetchVideoInfo(videoId) {
    const result = await this.api.get(`/video/${videoId}`);
    const video = new Video({
      service: this.serviceId,
      id: videoId,
      title: result.data.title,
      description: result.data.description,
      thumbnail: result.data.thumbnail_url,
      length: result.data.duration,
    });
    return video;
  }
}

get isCacheSafe() [optional]

Implement this getter and return false to denote that video information should not be cached and instead always be fetched from the API. If you don't implement this, it will default to true, which will usually work. One example of a non-cache-safe service would be direct links, because the videos behind URLs could arbitrarily change, while the video behind a YouTube ID can not.

Example

  get isCacheSafe() {
    return false;
  }

async resolveURL(url) [optional]

This method is only required if your implementation of isCollectionURL can return true.

If a URL does not simply point to only one video as denoted by isCollectionURL, this method will be called instead of fetchVideoInfo. It receives the URL as a string and must return an array of Video objects.

Example

const URL = require("url");
// ...
  async resolveURL(url) {
    // fetchChannelVideos, fetchPlaylistVideos, getChannelId and getPlaylistId
    // are not part of the ServiceAdapter interface. They are simply part of
    // this example code. You would have to implement these yourself and you
    // could call them whatever you want.
    const urlInfo = URL.parse(url);
    if (urlInfo.pathname.startsWith("/channel/")) {
      return this.fetchChannelVideos(this.getChannelId(url));
    }
    else if (urlInfo.pathname.startsWith("/playlist/")) {
      return this.fetchPlaylistVideos(this.getPlaylistId(url));
    }
    else {
      // This is technically not needed if you set your `canHandleURL` and
      // `isCollectionURL` methods up correctly. It's still included here in
      // case something goes wrong and we still receive an invalid URL.
      return [];
    }
  }

fetchManyVideoInfo(requests) [optional]

This method is only required if your implementation of isCollectionURL can return true.

This method receives an array of request objects each containing a video and a missingInfo array. Here is an example of what it could look like:

[
  {
    "id": "abc123",
    "missingInfo": ["description", "length"]
  }
]

For most APIs you will probably only need the ID. The purpose of the missingInfo property is described in fetchVideoInfo.

fetchManyVideoInfo must return an array of Video objects.

Example

  async fetchManyVideoInfo(requests) {
    // If your API only allows single video queries, you can just delegate to
    // fetchVideoInfo.
    const videos = Promise.all(
      requests.map((request) => this.fetchVideoInfo(request.id))
    );
    return videos;
  }