Build a subscription provider

July 29, 2026 · View on GitHub

简体中文 · Developer guide

A subscription provider supplies a connection form and five typed Hooks. M3UAndroid displays the form, stores the account, schedules refreshes, imports channels, and recovers playback sessions.

The complete example is ReferenceExtensionService.

The five Hooks

HookSpecSchemaBase capabilityRequest and result
SubscriptionHookSpecs.Discover4NoneLocale → one Provider descriptor
SubscriptionHookSpecs.Validate2credential.writeSubmitted values → host authentication receipt
SubscriptionHookSpecs.Refresh4subscription.readAccount and refresh reason → source and complete channel snapshot
SubscriptionHookSpecs.ResolvePlayback4playback.resolveAccount and playback reference → URL, headers, and optional session
SubscriptionHookSpecs.ClosePlayback3playback.resolveAccount, playback reference, and session → close result

Register every HookSpec with TypedExtensionService, then declare the same Hook and schema version in ExtensionManifest. Four Hooks make server requests and therefore use broker-backed handlers:

init {
    handle(SubscriptionHookSpecs.Discover) { request, _ ->
        discoverProvider(request.localeTag)
    }
    handleResultWithBroker(SubscriptionHookSpecs.Validate) { request, _, broker ->
        validateProvider(request, broker)
    }
    handleResultWithBroker(SubscriptionHookSpecs.Refresh) { request, _, broker ->
        refreshProvider(request, broker)
    }
    handleResultWithBroker(SubscriptionHookSpecs.ResolvePlayback) { request, _, broker ->
        resolvePlayback(request, broker)
    }
    handleResultWithBroker(SubscriptionHookSpecs.ClosePlayback) { request, _, broker ->
        closePlayback(request, broker)
    }
}

Discover is offline. The other four Hooks declare network. Add credential.read when a broker request uses a submitted or saved credential handle.

1. Describe the provider and its form

Discover returns one SubscriptionProviderDescriptor. Keep providerId and every ProviderKind stable between releases. List variants in display order and include every field needed for login. Set userSelectable = false only for a compatibility kind that existing accounts still need. M3UAndroid omits that kind from new-subscription choices.

private fun discoverProvider(localeTag: String?): SubscriptionProviderDiscoverResult {
    val copy = providerCopy(localeTag)
    return SubscriptionProviderDiscoverResult(
        provider = SubscriptionProviderDescriptor(
            providerId = extensionManifest.id,
            displayName = copy.providerName,
            variants = listOf(
                SubscriptionProviderVariant(
                    kind = ProviderKind("example"),
                    displayName = copy.variantName,
                )
            ),
            settingsSchema = providerSettings(localeTag),
        )
    )
}

The schema must contain the required base_url text field. Use a SECRET field for a password or token. Submitted text is available in request.settingValues; secret fields are available in request.credentialHandles.

Localize every user-facing name, label, description, and choice from request.localeTag, with a fallback to the extension's default language. Return plain text in natural reading order and do not insert bidi control characters; the host handles RTL isolation. Text should make sense when read aloud. IDs, URLs, and handles remain untranslated.

2. Authenticate the account

Validate sends the login exchange through broker.authenticate(...). Tell the broker where the returned access credential is located and which server or user IDs M3UAndroid should keep to identify the account.

val response = broker.authenticate(
    BrokerAuthenticationRequest(
        exchange = loginExchange,
        primaryCredentialSource = ResponseValueSource.JsonPointer("/accessToken"),
        opaqueContexts = listOf(
            OpaqueContextCapture(
                key = ProviderAuthenticationContextKeys.ServerId,
                source = ResponseValueSource.JsonPointer("/server_id"),
            ),
            OpaqueContextCapture(
                key = ProviderAuthenticationContextKeys.UserId,
                source = ResponseValueSource.JsonPointer("/user_id"),
            ),
        ),
    )
)

if (response.statusCode !in 200..299) {
    return HookResult.Failure(authenticationError(response.statusCode))
}

return HookResult.Success(
    SubscriptionProviderValidateResult(
        evidence = ProviderValidationEvidence.HostBrokerReceipt(
            receipt = requireNotNull(response.receipt),
        ),
    )
)

The response contains only the status code and receipt. M3UAndroid consumes that receipt to create the account and store the credential. The plugin never parses or returns the login response body.

See Use the host network broker for request values, contexts, capabilities, and errors.

3. Return a complete refresh snapshot

Return one SubscriptionSourceDescriptor and the complete channel snapshot. In schema 4 the source contains only remoteId and providerKind; it has no title.

SubscriptionSourceDescriptor(
    remoteId = request.account.serverId,
    providerKind = request.account.providerKind,
)

Every channel needs a stable remoteId and PlaybackReference. Keep only stable IDs in the reference. Resolve URLs, tokens, and cookies in ResolvePlayback.

M3UAndroid compares this snapshot with stored provider data and preserves host-owned local channel state.

4. Resolve and close playback

ResolvePlayback returns the playable URL, required headers, selected media source ID, and an optional PlaybackSessionDescriptor. Use BrokerValue references for saved credentials and captured account values; M3UAndroid resolves them when making the request or opening the media.

When a session is returned, ClosePlayback receives the same descriptor and its own account-scoped broker. Closing must be idempotent. Return success when the remote session is already closed.

Acceptance

  1. Discover returns one descriptor and every variant opens its form.
  2. Valid values complete host-managed authentication.
  3. Initial refresh imports one complete snapshot and creates the account.
  4. A channel resolves and plays with host-resolved headers.
  5. Stopping playback closes the remote session. Repeated close also succeeds.

Next: send authenticated requests through the host broker, then test the extension.