Skip to content

Create a Plugin

This page walks through connecting to the MediaVault API step by step. The goal is to show how to map custom JSON responses to AN Player types and provide playable stream URLs.

Overview

What the plugin does:

  1. Receives config in its factory function
  2. Implements async getFeedExtractor → calls /api/root, maps folders to Album[]
  3. Implements async getAlbumExtractor → calls /api/browse, maps folders to Album[] and files to Media[]
  4. Implements async getMediaExtractor → resolves /api/stream as a DirectStream

URI scheme the plugin creates (so AN Player can route URIs back to the correct extractor):

URI patternMeaning
https://<host>/album?path=<encoded>A browseable folder
https://<host>/media?path=<encoded>A playable media file

The host is always the configured server host, so isSupported can check uri.hostname.

Plugin Configuration

The config argument is a string. While many plugins use JSON.parse(), you are free to use any string format (e.g., a simple API key, a comma-separated list, or a custom protocol).

typescript
type Config = {
  host: string;
};

Step 1: isSupported

typescript
async isSupported(uri) {
  // Parsing as JSON is common, but not required.
  const parsedConfig = JSON.parse(config) as Config;
  return uri.hostname === parsedConfig.host;
},

AN Player calls this before doing anything else. If it returns false, the plugin is skipped and the next installed plugin gets a chance to handle the URI.

Step 2: isAlbum and isMediaTrack

typescript
async isAlbum(uri) {
  return uri.pathname === '/album';
},

async isMediaTrack(uri) {
  return uri.pathname === '/media';
},

These let AN Player know whether to open a URI as a browseable container or as a playable item without having to call the extractor first.

Step 3: Feed Extractor

The feed appears on the Discover screen. For MediaVault we return the root folders.

typescript
async getFeedExtractor() {
  const parsedConfig = JSON.parse(config);

  return {
    async getPage(_page: number) {
      const response = await fetch(`${parsedConfig.serverUrl}/api/root`);
      const data: { folders: { name: string; path: string }[] } =
        await response.json();

      const serverHost = new URL(parsedConfig.serverUrl).hostname;

      const items = data.folders.map((folder) =>
        createAlbum({
          id:    encodeURIComponent(folder.path),
          title: folder.name,
          uri:   `https://${serverHost}/album?path=${encodeURIComponent(folder.path)}`,
        }),
      );

      return { items, hasNextPage: false };
    },
  };
},

TIP

The feed only has one page (all root folders fit on one screen), so hasNextPage is always false. For APIs with pagination you would check whether a next page token exists and return true.

Step 4: Album Extractor

Called when the user taps an album. We fetch the folder contents and return both sub-folders and media files.

typescript
async getAlbumExtractor(uri) {
  return {
    async getPage(_page: number) {
      const folderPath = uri.searchParams.get('path') ?? '';
      const serverHost = uri.hostname;
      const serverUrl  = `https://${serverHost}`;

      const response = await fetch(
        `${serverUrl}/api/browse?path=${encodeURIComponent(folderPath)}`,
      );
      const data: {
        folders: { name: string; path: string }[];
        files:   { name: string; path: string; mimeType: string }[];
      } = await response.json();

      const albums = data.folders.map((f) =>
        createAlbum({
          id:    encodeURIComponent(f.path),
          title: f.name,
          uri:   `https://${serverHost}/album?path=${encodeURIComponent(f.path)}`,
        }),
      );

      const mediaItems = data.files.map((f) =>
        createMedia({
          id:    encodeURIComponent(f.path),
          title: f.name,
          uri:   `https://${serverHost}/media?path=${encodeURIComponent(f.path)}`,
        }),
      );

      return { items: [...albums, ...mediaItems], hasNextPage: false };
    },
  };
},

Step 5: Media Extractor

Called when the user taps a media item. We return a DirectStream that points directly at the server's stream endpoint.

typescript
async getMediaExtractor(uri) {
  return {
    async getStream() {
      const filePath   = uri.searchParams.get('path') ?? '';
      const serverHost = uri.hostname;

      return createDirectStream({
        uri: `https://${serverHost}/api/stream?path=${encodeURIComponent(filePath)}`,
      });
    },
  };
},

AN Player passes this URL directly to Media3/ExoPlayer, which streams the bytes from the server.

Complete Example

Below are two ways to implement the plugin. The Simple version is easier to read but lacks pagination. The Full version correctly implements the next() method for infinite scrolling, matching the pattern used in the official TMDB Plugin.

typescript
import {
  AnPlayerExtractor,
  createAlbum,
  createMedia,
  createDirectStream,
  ExtractorNotImplementedError,
} from '@xeinebiu/anplayer-plugin-core';

type Config = { host: string };

const MediaVaultPlugin: AnPlayerExtractor = ({ config, logger }) => {
  // Config is passed as a string (often JSON serialized); parse it if needed.
  const parsedConfig = JSON.parse(config) as Config;

  return {
    /**
     * Check if this module supports the provided URI.
     */
    async isSupported(uri) {
      return uri.hostname === parsedConfig.host;
    },

    async isAlbum(uri) {
      return uri.pathname === '/album';
    },

    async isMediaTrack(uri) {
      return uri.pathname === '/media';
    },

    /**
     * Discovery feed - returns one page of content.
     */
    async getFeedExtractor() {
      return {
        next: async () => {
          const response = await fetch(`https://${parsedConfig.host}/api/root`);
          const data = await response.json();

          return data.folders.map((folder) =>
            createAlbum({
              id:    encodeURIComponent(folder.path),
              title: folder.name,
              uri:   `https://${parsedConfig.host}/album?path=${encodeURIComponent(folder.path)}`,
            }),
          );
        }
      };
    },

    async getMediaExtractor(uri) {
      return {
        async getStream() {
          const filePath = uri.searchParams.get('path') ?? '';
          return createDirectStream({
            uri: `https://${uri.hostname}/api/stream?path=${encodeURIComponent(filePath)}`,
          });
        },
      };
    },
  };
};

export default MediaVaultPlugin;

For more advanced implementations, refer to the xeinebiu/android_anplayer_plugin repository for available interfaces.

Build

bash
npm run build

This produces dist/index.js, a single minified file ready to embed in the plugin manifest.

Next Steps

Go to Build & Distribute to package your plugin, host it, and install it in AN Player.