Skip to content

About

Unofficial Kotlin SDK for Tvheadend applications on JVM and Android.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

427 Commits

Folders and files

Repository files navigation

Tvheadend Kotlin SDK

CI Maven Central License: GPLv3

This unofficial, independently maintained GPLv3 Kotlin SDK provides JVM and Android libraries for applications that connect to Tvheadend.

The SDK is split into five libraries:

Artifact Platform Purpose
sdk-core Kotlin/JVM Protocol integration, session lifecycle, models, metadata, EPG, and DVR
sdk-playback Kotlin/JVM Subscription, seek, timeshift, and timestamp state machines
sdk-media3 Android Media3 sources, stream readers, and playback coordination
sdk-android Android Discovery, connectivity, atomic server-profile storage, and authenticated artwork
sdk-testing Kotlin/JVM Aggregate observation fakes, scripted events, and packet fixtures

The source targets the unreleased 0.39.0 development cycle, based on v0.38.0. For local prerelease builds, use -PsdkCandidateVersion=0.39.0-feature-name-SNAPSHOT as described in releasing. The normal build never publishes. ./gradlew clean build check stageLocalPublication verifies the repository and stages all five modules under build/local-maven; the Maven Central badge, not local source or staging, reports the latest publicly available version.

Applications can select only the modules they need. For example:

dependencies {
    implementation("at.bernhardberger.tvheadend:sdk-media3:<released-version>")
}

Replace <released-version> with the version reported by the Maven Central badge. A checkout's configured version can be newer than its public artifacts. See the consumer API guide for module selection and end-to-end usage, versioning for the provisional 0.x compatibility policy, and releasing for the publication trust boundary.

Audio passthrough

sdk-media3 provides TvheadendAudioOutputProvider, shared by the application's player and createTvheadendRenderersFactory(context, audioOutputProvider). Passthrough is enabled by default. Disabling it uses local decoding to PCM while retaining the platform's PCM output capabilities.

For asynchronously loaded preferences, call configurePassthrough before the first source is prepared. Later, call the suspending setPassthroughEnabled(player, enabled) on the player's application looper. It restarts only audio, without replacing the source or changing play intent; keep the borrowed player alive through completion or cancellation. A false result means the change could not be applied and can be retried.

The transition clears audio track overrides before re-enabling audio. Applications that remember explicit choices should restore them only after onTracksChanged reports support in the new mode. A track playable through passthrough may have no local decoder. Other track-selection parameters are retained.

The SDK bundles no software audio decoder. Applications that need software AC-3, E-AC-3, or MP3 decoding add the Media3 FFmpeg audio extension themselves; see software audio decoding.

Server profile storage

sdk-android persists an ordered collection of server profiles, one active at a time, through the application-scoped TvheadendServerProfileStore. Normal connection reads return password authentication only inside an opaque, connectable ServerProfile:

val profiles: ServerProfileStore = TvheadendServerProfileStore(context)
when (val written = profiles.addPassword(
    host = enteredHost,
    port = enteredPort,
    username = enteredUsername,
    password = enteredPassword,
    activate = true,
)) {
    ServerProfileWriteResult.NotFound -> Unit
    ServerProfileWriteResult.Unavailable -> showStorageUnavailable()
    is ServerProfileWriteResult.Applied -> {
        val stored = profiles.loadActive()
        if (stored is ServerProfileReadResult.Available) session.connect(stored.profile)
    }
}

Use profiles() to list summaries and setActive(id) to select a stored profile. Selection does not reconnect a session: call loadActive() and pass its profile to session.connect. Settings screens can call loadForEditing(id) to distinguish missing, unavailable, anonymous, and password profiles. Anonymous and password results expose the editable host and port; only the password result exposes the exact normalized username and exact password. Those credentials are immutable plaintext strings and cannot be zeroed. Keep the result only in private memory while the active secure edit surface needs it, then drop every reference; never serialize it, place it in saved state, log it, or use it as a diagnostic.

Anonymous profiles do not use the Android Keystore. The normalized username is stored as plaintext metadata; only the password is encrypted at rest, with associated data bound to the profile id, endpoint and username. Existing format-2 profiles migrate to one active format-4 profile. Undecryptable credentials stay listed but unavailable until edited or removed. Other formats require clearAll() to recover.

Successful keyed mutations return Applied(id). clearAll() returns ServerProfileListResult: Available with no profiles or selection after deletion, or Unavailable on storage failure, with no deletion guaranteed. Success proves only local persistence, not server reachability, authentication, or session readiness. JVM tests can use FakeServerProfileStore to script stored, missing, and unavailable behavior without Android Context or Android runtime internals.

The pinned at.bernhardberger.tvheadend:htsp version is recorded in gradle/libs.versions.toml. Local source substitution across adjacent checkouts is opt-in with -Ptvheadend.htsp.composite=true. To verify an unreleased HTSP candidate in the consumer-contract build, use -Ptvheadend.htsp.candidateRepository=/absolute/path/to/candidate-maven. Candidate verification does not establish dependency-verification trust for a release. The candidate repository is exclusive to HTSP and enabled only by the property; CI and release resolution do not add it automatically. See staged consumer verification.

Live stream profiles

TvheadendSession.getStreamProfiles(currentSession) discovers the ordered stream profiles for exactly the generation represented by that observation. Pass a returned ID through LivePlaybackOptions to select that server profile for one live target:

val observed = session.observation.value
val currentSession = requireNotNull(observed.currentSession)
val channel = requireNotNull(observed.channel(channelId))
val selected = when (val result = session.getStreamProfiles(currentSession)) {
    is StreamProfilesResult.Available -> result.profiles.firstOrNull()
    is StreamProfilesResult.Failed -> null
}

if (selected != null) {
    when (val target = session.bindLivePlayback(currentSession, channel.id)) {
        is PlaybackBindingResult.Bound -> handlePlaybackTargetResult(
            coordinator.setLiveTarget(
                target.binding,
                LivePlaybackOptions(
                    streamProfileId = selected.id,
                    timeshiftPeriod = 30.minutes,
                ),
            ),
        )
        PlaybackBindingResult.ObservationExpired,
        PlaybackBindingResult.TargetUnavailable,
        -> Unit
    }
}

The listed name and comment are presentation metadata. Profile IDs are opaque and redacted from string rendering. A canonical ID does not require a prior discovery-cache lookup; the exact admitted generation validates selection. Omitting the profile keeps Tvheadend's default selection.

Atomic session observation

TvheadendSession.observation is the single atomic StateFlow for lifecycle, channel, EPG, DVR, DVR configuration and disk-space freshness, server capabilities, and recording-progress capability. Read related values and call selectors from one captured observation rather than collecting independent flows:

val observed = session.observation.value
val channel = requireNotNull(observed.channel(channelId))
val current = observed.eventAt(channelId, now)
val next = observed.nextEvent(channelId, now)

currentSession is an opaque proof that the lifecycle and primary channel, EPG, and DVR snapshots are current for one session-owned generation. It becomes null in the same publication that retires that generation. Retained stale snapshots remain selectable from that retired observation without being mistaken for current data.

session.isCurrent(currentSession) inspects currency without side effects; session.awaitCurrentSession(replaced = currentSession) waits for a distinct proof and propagates cancellation. Neither leases the returned proof. Its opaque, process-local generationIdentity scopes caches across metadata-only republication under the same connection authority, but cannot reconstruct or authorize a proof. Successful EPG searches and stream-profile discovery expose their exact originatingSession for provenance, which may already be retired when the result is observed.

Retained snapshots remain selectable during SessionState.Synchronizing, but playback binding waits for a new authoritative currentSession after SessionState.Ready. An old observation can therefore never select a colliding target in a replacement connection generation. DVR mutations and recording progress are also gated on SessionState.Ready.

Ready follows the server's authoritative initial metadata fence. EPG coverage queries and DVR configuration and disk-space enrichment continue as supervised generation-owned background work and do not delay or tear down readiness. Consumers can acquire one bounded EPG horizon for the captured generation:

val currentSession = requireNotNull(observed.currentSession)
val result = session.epgRepository.acquireCoverage(currentSession, channel.id, programme.stop)

EpgCoverageAcquisitionResult settles as covered-with-data, covered-empty, ineligible, capacity-exhausted, or observation-expired. Covered results retain the exact immutable observation that established the coverage.

See the build matrix for the pinned toolchain and test runtime split, and JVM benchmarks for EPG scaling, allocation profiling, and metadata reader contention. Phase 4 real-device acceptance is recorded in the playback verification notes. Phase 5 process, credential, connectivity, and NSD acceptance is recorded in the Android lifecycle verification notes. Phase 6 end-to-end SDK, staged-consumer, and cross-process recording acceptance is recorded in the SDK acceptance notes. The Phase 7 initial no-go and bounded pass-through MPEG-TS wrapper feasibility result are in the growing-recording evaluation.

Authenticated artwork

sdk-android supplies an opaque Coil model and custom fetcher for HTSP imagecache entries. Register the component on an application-owned Coil 3 loader, then create models from channel, tag, or rating icon metadata:

val imageLoader = ImageLoader.Builder(context)
    .components {
        addTvheadendArtwork()
    }
    .build()

val observed = session.observation.value
val currentSession = requireNotNull(observed.currentSession)
val icon = requireNotNull(observed.channel(channelId)?.icon)
val artwork = TvheadendArtwork.create(session, currentSession, icon)

Channel and tag icons are typed ArtworkId values; external URLs and malformed selectors are dropped. Parse other image-cache selectors with ArtworkId.parse. With an opt-in MetadataCachePolicy, the SDK persists artwork bytes with retention and a root-wide LRU byte budget, and Coil uses an opaque restart-stable key. Without a policy, keys remain process-local and generation-scoped. The streamed result has no Coil disk identity; the SDK owns persistent storage and clearing through session.cache. See cache policy. Coil owns decoding and closes every successfully returned image source. TvheadendArtworkLoadException.failure preserves typed failures such as ACCESS_DENIED without parsing exception text.

Recording progress

DVR rule weekdays use Set<java.time.DayOfWeek>, time-rule bounds use whole-minute java.time.LocalTime, and automatic-rule bounds use AutorecTime.At(time) or AutorecTime.AnyTime. Padding uses whole-minute kotlin.time.Duration. Priority, duplicate detection and broadcast selection use open typed sets. Update fields default to null, so AutorecRuleUpdate(title = "Evening recordings") preserves every other server setting, including unknown policy values.

SessionObservation.recordingProgressCapability is SUPPORTED only when the current ready generation can close recording files without changing play count and can report position and watched state separately. Unknown and pre-v27 connections disable resume and progress reporting. Completed files remain playable from the beginning without those optional operations.

DvrProgressPolicy offers every positive saved position for completed recordings. Its pure tracker uses one 30-second elapsed cadence and reports any positive movement, plus explicit pause and terminal observations. Natural end marks a completed recording watched; an orderly exit requires at least 95% of a known positive actual media duration. Errors and growing recordings never infer completion.

Playback progress and cutpoints are owned by an observation-bound PlaybackBinding.Recording, not the mutation-only DvrRepository. The Media3 coordinator serializes reports, preserves terminal ordering, and never resolves a later connection generation for an installed target.

Media3 playback coordination

sdk-media3 provides a narrow coordinator for live channels, completed recordings, and the bounded growing pass-through MPEG-TS path. The application owns the ExoPlayer; the structured lifetime owns the coroutine running the coordinator. Source changes, resume registration, recovery, and progress observation are serialized on the player's application looper; the coordinator does not create or release the player or own a service, MediaSession, audio focus, notification, surface, autoplay, navigation, or presentation policy.

val coordinator = createTvheadendPlaybackCoordinator(player)
val shutdownResult = coordinator.withLifetime(2.seconds) { coordinator ->
    val observed = session.observation.value
    val currentSession = requireNotNull(observed.currentSession)
    val channel = requireNotNull(observed.channel(channelId))
    val completedRecording = requireNotNull(observed.dvrEntry(completedRecordingId))
    val activeRecording = requireNotNull(observed.dvrEntry(activeRecordingId))

    val liveTarget = session.bindLivePlayback(currentSession, channel.id)
    if (liveTarget is PlaybackBindingResult.Bound) {
        handlePlaybackTargetResult(
            coordinator.setLiveTarget(
                liveTarget.binding,
                LivePlaybackOptions(timeshiftPeriod = 30.minutes),
            ),
        )
    }
    handleTimeshiftContentSeekResult(coordinator.seekTimeshiftBy((-30).seconds))
    handleTimeshiftCommandResult(coordinator.returnToLive()) // bounded near-live position
    handleTimeshiftCommandResult(coordinator.pauseTimeshift()) // pauses server delivery only
    handleTimeshiftCommandResult(coordinator.resumeTimeshift())

    val completedTarget = session.bindRecordingPlayback(currentSession, completedRecording.id)
    if (completedTarget is PlaybackBindingResult.Bound) {
        handlePlaybackTargetResult(
            coordinator.setRecordingTarget(completedTarget.binding, RecordingPlaybackStart.RESUME),
        )
    }
    val growingTarget = session.bindRecordingPlayback(currentSession, activeRecording.id)
    if (growingTarget is PlaybackBindingResult.Bound) {
        handlePlaybackTargetResult(
            coordinator.setRecordingTarget(growingTarget.binding, RecordingPlaybackStart.START_OVER),
        )
    }
}

handlePlaybackShutdownResult(shutdownResult)
session.shutdown()
player.release()

withLifetime launches the coordinator before invoking its block, cancels and joins child collectors launched on the block scope, and completes terminal cleanup before returning. Applications should handle every typed target, timeshift, and shutdown result rather than assuming the player changed. Target and timeshift outcomes expose stable dispositions, categories, and direct predicates; exact SDK-owned values are non-exhaustive so consumers must retain a fallback. A timeshift UNCONFIRMED disposition means acceptance could not be proved, while an accepted command may still carry a terminal category when its result cannot be used safely.

TvheadendPlaybackCoordinator.timeshiftState reports only the current live target's positive server grant and ordered server observations. Buffered duration, position behind live, and server pause state remain null until valid status events arrive. Timeshift pause and resume send server speeds 0 and 100; ordinary Media3 play/pause remains application-owned.

For immediate relative seeking, seekTimeshiftBy(offset) samples the client's playback position, clamps within observed history and commits a selection. It does not send a relative offset from the server reader, which may be ahead of the displayed content.

For a preview gesture, retain the first playback sample's target as the anchor and pass each key's delta to latestTimeline.resolveSelection(anchor, previous, delta). Use the latest observed timeline on every update, not the first snapshot. The returned immutable TimeshiftSeekSelection exposes its resolved target, total effective displacement and boundary (START, NONE, or LATEST). A zero delta refreshes the preview without adding a key step. Null means the supplied segment or range cannot resolve the selection.

Boundary overshoot is discarded. A latest-edge selection follows observed edge advancement; a reverse key immediately steps back from that fresh edge. Interior targets remain fixed until history eviction requires clamping. Commit with coordinator.seekTimeshift(selection): it revalidates against current history, rejects segment replacement, and routes LATEST through the live operation. Completed.selection describes the commit-time resolution; readerReached, seekCommand, buffering and pauseRestoration separately describe server acknowledgement and the subsequent buffering work. None is proof of a displayed frame. The exact-target overload remains available and returns Expired rather than silently clamping a fixed target.

During paused seeks, transport may temporarily run to obtain sufficient audio and video data, while playbackPaused retains requested presentation intent. Consumers must not mirror the temporary serverPaused transport observation into Player.playWhenReady. The SDK does not start presentation playback.

Live stream stop/start and repeated-start events replace the Media3 period and track groups on the same subscription and application-owned Player. Retained timeshift targets are segment-scoped: use TimeshiftTimeline.describesSameSegment when combining sampled content and history across restarts. describesSameSubscription checks only transport identity. Infrastructure consumers must rebuild for 0.10.0 and handle the new SegmentUnavailable seek outcome. See the stream restart contract for interruption bounds, seek safety, and the outstanding device evidence.

TvheadendPlaybackCoordinator.subscriptionIssue reports only the current live target's canonical Tvheadend issue. Known server codes map to safe non-exhaustive SubscriptionIssue values; unknown or localized values become UNKNOWN, and raw server text is never exposed. The exact no-input status maps to NO_INPUT unless a conflicting known canonical error is present. The state clears on period retry, target replacement, recording playback, stop, and shutdown; finalSubscriptionIssue on results with PlaybackStopOutcome.STOPPED, PlaybackStopOutcome.RETIRED_AFTER_DEPARTURE, and PlaybackStopOutcome.PLAYER_UNAVAILABLE keeps the retired live target's last issue, including when player cleanup fails after retirement.

TvheadendPlaybackCoordinator.liveDiagnostics conditionally reports immutable source display metadata, frontend state and measurements, and server queue depth, media span, and B/P/I drops for that same current live target. Relative values are percentages, absolute SNR is dB, absolute signal is dBm, BER is explicitly raw, and unavailable observations are null. Display text is normalized and bounded; controls and recognizable UUID, address, locator/userinfo, MAC, authorization, and credential-assignment forms are omitted. It does not infer secrets from otherwise ordinary human-assigned names. Raw server text and logs are not exposed. Recording replacement and every live subscription or coordinator termination clear the state; stale generations cannot republish it.

Direct live playback feeds Tvheadend's packet-level AAC stream to Media3's maintained AdtsReader. Tvheadend normalizes AAC-LATM packets to ADTS before HTSP delivery, so the SDK does not infer the source transport framing or expose a separate LATM codec type. This path has deterministic packet-fixture coverage; the current acceptance server has no AAC service, so it is not a live-server AAC claim.

Completed recordings remain playable when recording progress is unknown or unsupported; the coordinator starts them from the beginning and disables resume and reporting. Supported progress enables normal completed-recording resume and reporting. An active target, for either start mode, still requires supported progress (NOT_READY while unknown, RECORDING_PROGRESS_UNSUPPORTED otherwise) and one stable file (TARGET_UNAVAILABLE); non-.ts files remain GROWING_RECORDING_DEFERRED. On an admitted .ts target, START_OVER or a missing or zero saved position plays from the beginning; RESUME with a positive saved position starts at 0:00 and seeks once when the growing timeline becomes seekable (3 s before the recorded extent if the saved position is within 3 s of or past it). Without a seekable timeline within 20 s the resume is abandoned and playback simply continues; a viewer seek, replacement or stop cancels it. While its progress hold remains active (until the seek, a cancellation or the 20 s abandonment), no earlier position is reported. Growing seek is approximate and becomes available only after the maintained TsExtractor wrapper has validated MPEG-2, H.264, or HEVC and indexed already parsed keyframes. Other TS codecs remain forward-only.

Temporary EOF stays inside the growth reader and never marks the recording watched. RecordingPlaybackAdmission.Growing, GrowingRecordingFileReader, and GrowingRecordingFileLease require @OptIn(ExperimentalTvheadendApi::class). Completed-recording playback and DvrRepository.changes remain stable.

Growing progress is best-effort and generation-local with one RPC in flight and one latest pending observation. The dynamic indexed horizon is not a final duration, so orderly replacement never uses it to infer watched state. Natural end may mark watched only after both fresh completion and final EOF are proven. One target-scoped growth lease binds the original connection generation, DVR incarnation, and physical file across every seek or loader retry. Progress RPC admission revalidates that lease after capability and generation selection, then uses its original generation rather than whichever generation is current later. A continuity change therefore fails through the typed TvheadendRecordingException path rather than rebinding, following a clone, or carrying an offset. Target replacement never waits for a progress RPC. Explicit shutdown may drain one pending report for a caller-supplied timeout of at most ten seconds before the application shuts down the session and releases the player.

About

Unofficial Kotlin SDK for Tvheadend applications on JVM and Android.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages