Download

Write a source

Video

An episode or a film is a PlaybackDescriptor: the streams to play, and the choices the site offers.

What getMedia returns #

For an ANIME title, getMedia returns EntryMedia.Playback(descriptor):

Kotlin
@Serializable
class Server(val id: String, val name: String, val dub: String, val playlistUrl: String)

override suspend fun getMedia(chapter: SEntryChapter, selection: PlaybackSelection): EntryMedia {
    val all = servers(chapter)
    val dub = all.firstOrNull { it.dub == selection.dubKey }?.dub ?: all.first().dub
    val dubbed = all.filter { it.dub == dub }
    val server = dubbed.firstOrNull { it.id == selection.serverKey } ?: dubbed.first()
    return EntryMedia.Playback(
        PlaybackDescriptor(
            selection = PlaybackSelection(dubKey = dub, serverKey = server.id),
            dubs = all.map { it.dub }.distinct().map { VideoPlaybackOption(key = it, label = it.uppercase()) },
            servers = dubbed.map { VideoPlaybackOption(key = it.id, label = it.name) },
            streams = listOf(
                VideoStream(
                    request = VideoRequest(server.playlistUrl, headers = mapOf("Referer" to "$baseUrl/")),
                    label = server.name,
                    type = VideoStreamType.HLS,
                    key = "hls",
                ),
            ),
        ),
    )
}
data class PlaybackDescriptor
What EntryMedia.Playback holds.

Fields

selection: PlaybackSelection = PlaybackSelection()
The choices the streams were found for.
dubs: List<VideoPlaybackOption> = emptyList()
The dubs the site offers.
servers: List<VideoPlaybackOption> = emptyList()
The servers the site offers for the selected dub, in the order Katari should try them.
sourceQualities: List<VideoPlaybackOption> = emptyList()
The qualities, for a site that serves each one separately.
streams: List<VideoStream> = emptyList()
The streams for the selection.
data class PlaybackSelection(dubKey: String? = null, serverKey: String? = null, streamKey: String? = null, sourceQualityKey: String? = null)
Each a key you gave, or null for none chosen.
data class VideoPlaybackOption(key: String, label: String, description: String? = null)
label is shown in the player, and description under it.

The player shows the message of an exception getMedia throws. Katari gives up on getMedia after 15 seconds.

Dubs, servers and qualities #

Dubs, servers and qualities are separate choices in the player. When the user picks one, Katari calls getMedia again with its key in selection, so return only the streams for that combination.

  • Return the selection you actually used. If the requested dub or server isn't there, fall back to another and say so in selection. Without a serverKey, Katari takes the first server as the one playing.
  • Once an episode starts playing, Katari saves its choices for the title and asks for them on the title's other episodes.
  • Katari finds the user's preferred quality by reading labels or keys such as 1080p or 4K. Name qualities that way and the first episode starts at the right one.

When a server fails #

If a server fails before the first frame, Katari tells the user and tries the next one in servers, up to three per episode. It doesn't switch away from a server the user picked. They see the error and Try another server.

Streams #

data class VideoStream
One stream to play.

Fields

request: VideoRequest?
Where the stream is, or null when the source finds the link later.
label: String = ""
Shown in the player, such as a resolution. Left blank, it is made from height and bitrate.
type: VideoStreamType = VideoStreamType.UNKNOWN
How the stream is delivered.
mimeType: String? = null
The stream's format. The player uses it when it is set, and the type otherwise.
key: String = ""
Identifies the stream in the selection's streamKey.
height: Int? = null
The vertical resolution in pixels.
bitrate: Long? = null
The average bitrate in bits per second.
subtitles: List<VideoSubtitle> = emptyList()
Separate subtitle files.
audioTracks: List<VideoAudioTrack> = emptyList()
Separate audio.
markers: List<PlaybackMarker> = emptyList()
Intros, credits and other skip markers.
mpvOptions: Map<String, String> = emptyMap()
Options for mpv.
enum class VideoStreamType
HLS, DASH, PROGRESSIVE for a single file, or UNKNOWN. PROGRESSIVE is played as MP4.
data class VideoRequest(url: String, headers: Map<String, String> = emptyMap())
url is the full address, and headers are sent with every request for the stream.
  • List one stream per resolution if the site has them as separate files. An HLS or DASH stream that carries several resolutions needs only one entry.
  • Katari plays the stream the user chose for the title, or the one your streamKey names, or the one closest to the preferred quality. If it fails to load, Katari tries the next.
  • Give each stream a key that stays the same from one episode to the next, so the user's choice carries over.
  • Streams with a blank address are dropped, and so are torrents, which Katari can't play.

Links found later #

On many sites each server's link takes requests of its own, such as opening an embedded player. Add DeferredStreamSource, list those streams with a null request, and find the link in resolveStream:

Kotlin
class ExampleShows : EntryHttpSource(), DeferredStreamSource {
    // In getMedia, each hoster is listed without its link:
    //     VideoStream(request = null, label = hoster.name, key = hoster.id)

    override suspend fun resolveStream(chapter: SEntryChapter, stream: VideoStream): VideoStream {
        val embed = fetch("$baseUrl/embed/${stream.key}")
        val playlist = embed.selectFirst("video source")!!.absUrl("src")
        return stream.copy(
            request = VideoRequest(playlist, headers = mapOf("Referer" to "$baseUrl/")),
            type = VideoStreamType.HLS,
        )
    }
}
  • Katari calls resolveStream only for the stream it is about to play or download, so listing an episode stays quick and short-lived links don't expire before they're used.
  • Return the stream with its request filled in. It can add subtitles, audio tracks or markers that only the resolved stream has.
  • Throw when the stream can't be played, and Katari moves on to the next.

Subtitles #

Subtitles inside the stream show up on their own. Separate subtitle files go in the stream's subtitles:

Kotlin
VideoStream(
    request = VideoRequest(episode.playlistUrl),
    type = VideoStreamType.HLS,
    subtitles = episode.tracks.map { track ->
        VideoSubtitle(
            request = VideoRequest(track.url),
            label = track.label,
            language = track.language,
            mimeType = "text/vtt",
            isDefault = track.language == "en",
        )
    },
)
data class VideoSubtitle
One subtitle file.

Fields

request: VideoRequest
The file. One with a blank address is left out.
label: String
The track's name in the player.
language: String? = null
A language tag such as en or pt-BR.
mimeType: String? = null
The format, or null to tell it from the address's ending: .vtt or .webvtt for WebVTT, .srt for SubRip, .ass or .ssa for SubStation Alpha.
key: String = ""
Identifies the track.
isDefault: Boolean = false
Whether the site marks the track as the default.
isForced: Boolean = false
Whether the site marks the track as forced subtitles.

Katari remembers the user's choice by the track's label and language, so keep those the same across episodes. Until the user chooses, the track marked isDefault shows.

Audio tracks #

Some sites serve picture and sound as separate files, or each language's audio separately. Put those in the stream's audioTracks. A stream that carries its own audio leaves it empty.

Kotlin
VideoStream(
    request = VideoRequest(rendition.videoUrl),
    type = VideoStreamType.PROGRESSIVE,
    height = 1080,
    audioTracks = listOf(
        VideoAudioTrack(VideoRequest(rendition.audioUrl), label = "Japanese", language = "ja", isDefault = true),
    ),
)
data class VideoAudioTrack
One audio file or stream.

Fields

request: VideoRequest
Where the audio is.
label: String
The track's name in the player.
language: String? = null
A language tag such as ja.
mimeType: String? = null
The audio's format.
type: VideoStreamType = VideoStreamType.UNKNOWN
How the audio is delivered, as for streams.
key: String = ""
Identifies the track.
isDefault: Boolean = false
Whether the site marks the track as the default.

Skip markers #

Markers are spans of the stream's timeline in milliseconds. The player shows them on the seek bar and offers to skip intros, recaps, credits and previews:

Kotlin
VideoStream(
    request = VideoRequest(episode.playlistUrl),
    type = VideoStreamType.HLS,
    markers = listOfNotNull(
        episode.opening?.let { PlaybackMarker(it.start * 1000, it.end * 1000, PlaybackMarkerKind.INTRO) },
        episode.ending?.let { PlaybackMarker(it.start * 1000, it.end * 1000, PlaybackMarkerKind.OUTRO) },
    ),
)
data class PlaybackMarker(startMs: Long, endMs: Long, kind: PlaybackMarkerKind, label: String? = null)
label names a CHAPTER.
Kind What the player does
INTRO, RECAP Offers to skip it, or skips it.
MIXED_INTRO An intro that is also part of the story. Offers to skip it, but never skips it automatically.
OUTRO, PREVIEW End credits and a preview of the next episode. When they run to the end, the button goes to the next episode.
CHAPTER A named section, shown on the seek bar only.

mpv #

Users who install the mpv add-on can play video in mpv. Options a stream needs from mpv go in mpvOptions, without the leading dashes:

Kotlin
VideoStream(
    request = VideoRequest(file.url),
    type = VideoStreamType.PROGRESSIVE,
    mpvOptions = mapOf("demuxer-lavf-o" to "allowed_extensions=ALL"),
)

A stream with mpvOptions plays in mpv when the add-on is installed and the user leaves the player on Auto. Your options override Katari's, and the built-in player ignores them.

Downloads #

A download takes a single file when the episode has one, and HLS or DASH otherwise, at one resolution. It keeps one audio track, the subtitles the player would show, and the markers. Live and DRM-protected streams can't be downloaded.