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.
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.
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.
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.
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.
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.
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.
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.