Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
57ca1fa
chore: pin live SABR extractor
Priveetee Jul 20, 2026
499330b
fix: target live playback at the reported head
Priveetee Jul 20, 2026
1406c6c
fix: respect near-live track heads
Priveetee Jul 20, 2026
d3d53ac
fix: start live tracks from received media
Priveetee Jul 20, 2026
0ad2e55
feat: define Android playback contract
Priveetee Jul 20, 2026
89a344d
feat: isolate Android playback sessions
Priveetee Jul 20, 2026
358308b
feat: build complete Android VOD manifests
Priveetee Jul 20, 2026
89ace11
feat: add Android VOD playback service
Priveetee Jul 20, 2026
08a629d
feat: add Android playback API handlers
Priveetee Jul 20, 2026
7f0e284
feat: expose Android playback routes
Priveetee Jul 20, 2026
7b6aa72
test: cover complete Android VOD manifests
Priveetee Jul 20, 2026
6e59964
test: cover Android playback lifecycle
Priveetee Jul 20, 2026
8f1ce45
test: cover Android playback HTTP contract
Priveetee Jul 20, 2026
24e074a
fix: align live replacement session boundaries
Priveetee Jul 21, 2026
c308275
test: preserve missing live boundary demands
Priveetee Jul 21, 2026
bdc1558
fix: advance cached live replacement tracks
Priveetee Jul 21, 2026
025f742
fix: resume live replacements at warmed boundaries
Priveetee Jul 21, 2026
cda7338
test: use real time for initialization preload
Priveetee Jul 21, 2026
9bba5aa
refactor: add subscription feed snapshot primitives
Priveetee Jul 21, 2026
956d097
feat: serve progressive subscription feed snapshots
Priveetee Jul 21, 2026
b09a86e
docs: document progressive subscription feed
Priveetee Jul 21, 2026
cb61336
test: cover progressive subscription feed snapshots
Priveetee Jul 21, 2026
0c38bae
test: update subscription feed invalidation coverage
Priveetee Jul 21, 2026
4ec3b1e
chore: merge production hotfix into dev
Priveetee Jul 21, 2026
2ee43cc
fix: rewind stale live segment demands
Priveetee Jul 21, 2026
07330ad
fix: preserve replacement playback generation
Priveetee Jul 21, 2026
620f190
fix: retain resolved live segments
Priveetee Jul 21, 2026
4be19e3
fix: resolve live segments without declared duration
Priveetee Jul 21, 2026
63d6409
chore: update PipePipeExtractor
Priveetee Jul 22, 2026
1a3ee66
fix: classify rejected YouTube sessions
Priveetee Jul 22, 2026
dc7635e
feat: add connected YouTube session tokens
Priveetee Jul 22, 2026
d6274c7
test: probe connected YouTube extraction
Priveetee Jul 22, 2026
5bf04eb
fix: preserve SABR demand backoff deadlines
Priveetee Jul 22, 2026
aa22940
chore: update PipePipeExtractor
Priveetee Jul 22, 2026
1ecfa73
fix: update PipePipeExtractor session handling
Priveetee Jul 23, 2026
5897f85
perf: reuse SABR session for same-format seeks
Priveetee Jul 23, 2026
e3eb0e9
test: cover SABR seek extraction bypass
Priveetee Jul 23, 2026
535334a
feat: add Android subtitle transport
Priveetee Jul 23, 2026
88b760a
feat: load Android subtitle inventory
Priveetee Jul 23, 2026
dc8e20e
feat: expose Android playback subtitles
Priveetee Jul 23, 2026
6ea3b19
test: cover Android subtitle transport
Priveetee Jul 23, 2026
85a809c
test: cover Android subtitle inventory
Priveetee Jul 23, 2026
a4cfe36
test: cover Android subtitle sessions
Priveetee Jul 23, 2026
c96e38c
docs: document Android subtitle playback API
Priveetee Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ dependencies {
implementation("io.ktor:ktor-server-call-logging-jvm")
implementation("io.ktor:ktor-server-rate-limit-jvm")
implementation("ch.qos.logback:logback-classic:1.5.38")
implementation("com.github.InfinityLoop1308.PipePipeExtractor:extractor:00db0e1d9b553d941f3009eb79e492d95eaf442d")
implementation("com.github.Priveetee.PipePipeExtractor:extractor:d1a723d382014c2428b273c92bc9fc8c15d59c83")
compileOnly("com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751")
implementation("org.json:json:20260522")
implementation("com.squareup.okhttp3:okhttp:5.4.0")
Expand Down
17 changes: 17 additions & 0 deletions docs/android-playback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Android playback contract

TypeType-Android discovers this contract through `androidPlayback` in
`GET /api/instance`. Contract version 1 supports completed YouTube VODs only;
active livestreams remain explicitly unsupported.

Android playback sessions use `/api/android/youtube/playback/*` and are isolated
from the web player's `/api/sabr/playback/*` sessions, generations, caches, and
window protocol. A ready VOD manifest is a complete static DASH presentation
from time zero. The server requires exact audio and video segment indexes, but
continues to fetch media bytes on demand.

Creating a session may return `202` while the exact indexes are preparing. A
seek keeps the session ID and selected itags stable, increments the generation,
and makes older media URLs return `409`. Unknown sessions return `404`; recently
expired sessions return `410`. Session manifests and media remain same-origin
and use `Cache-Control: no-store`.
16 changes: 16 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ tags:
- name: extraction
- name: downloader
- name: youtube-session
- name: android-playback
- name: user-data
paths:
/health: { $ref: ./openapi/paths/health.yaml#/Health }
Expand All @@ -27,6 +28,12 @@ paths:
/streams/bilibili: { $ref: ./openapi/paths/streams.yaml#/BiliBiliStreams }
/streams/audio-only: { $ref: ./openapi/paths/streams.yaml#/AudioOnly }
/streams/audio-only/source: { $ref: ./openapi/paths/streams.yaml#/AudioOnlySource }
/android/youtube/playback/{videoId}: { $ref: ./openapi/paths/android-playback.yaml#/Create }
/android/youtube/playback/{sessionId}/seek: { $ref: ./openapi/paths/android-playback.yaml#/Seek }
/android/youtube/playback/{sessionId}/manifest.mpd: { $ref: ./openapi/paths/android-playback.yaml#/Manifest }
/android/youtube/playback/{sessionId}/subtitles/{trackId}.vtt: { $ref: ./openapi/paths/android-playback.yaml#/Subtitle }
/android/youtube/playback/{sessionId}/{itag}/init: { $ref: ./openapi/paths/android-playback.yaml#/Initialization }
/android/youtube/playback/{sessionId}/{itag}/segment/{sequence}: { $ref: ./openapi/paths/android-playback.yaml#/Segment }
/search: { $ref: ./openapi/paths/search.yaml#/Search }
/search/filters: { $ref: ./openapi/paths/search.yaml#/SearchFilters }
/channel: { $ref: ./openapi/paths/channel.yaml#/Channel }
Expand All @@ -37,6 +44,7 @@ paths:
/playlist: { $ref: ./openapi/paths/playlists.yaml#/Playlist }
/saved-playlists: { $ref: ./openapi/paths/saved-playlists.yaml#/SavedPlaylists }
/saved-playlists/{id}: { $ref: ./openapi/paths/saved-playlists.yaml#/SavedPlaylist }
/subscriptions/feed: { $ref: ./openapi/paths/subscriptions.yaml#/SubscriptionFeed }
/settings: { $ref: ./openapi/paths/access-control.yaml#/Settings }
/allowed/channels: { $ref: ./openapi/paths/access-control.yaml#/AllowedChannels }
/allowed/channels/{channelUrl}: { $ref: ./openapi/paths/access-control.yaml#/AllowedChannel }
Expand Down Expand Up @@ -88,6 +96,12 @@ components:
$ref: ./openapi/components/instance.yaml#/InstanceMinClientVersion
InstanceResponse:
$ref: ./openapi/components/instance.yaml#/InstanceResponse
AndroidPlaybackCreateRequest:
$ref: ./openapi/components/android-playback.yaml#/AndroidPlaybackCreateRequest
AndroidPlaybackSeekRequest:
$ref: ./openapi/components/android-playback.yaml#/AndroidPlaybackSeekRequest
AndroidPlaybackResponse:
$ref: ./openapi/components/android-playback.yaml#/AndroidPlaybackResponse
StreamResponse:
$ref: ./openapi/components/streams.yaml#/StreamResponse
AudioOnlyStreamResponse:
Expand Down Expand Up @@ -118,6 +132,8 @@ components:
$ref: ./openapi/components/media.yaml#/PublicPlaylistItem
SavedPlaylistItem: { $ref: ./openapi/components/media.yaml#/SavedPlaylistItem }
SavedPlaylistRequest: { $ref: ./openapi/components/media.yaml#/SavedPlaylistRequest }
SubscriptionFeedResponse: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionFeedResponse }
SubscriptionFeedPreparingResponse: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionFeedPreparingResponse }
SettingsItem: { $ref: ./openapi/components/access-control.yaml#/SettingsItem }
AdminSettingsItem: { $ref: ./openapi/components/access-control.yaml#/AdminSettingsItem }
AllowedChannelItem: { $ref: ./openapi/components/access-control.yaml#/AllowedChannelItem }
Expand Down
50 changes: 50 additions & 0 deletions openapi/components/android-playback.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
AndroidPlaybackCreateRequest:
type: object
properties:
videoItag: { type: integer, nullable: true }
audioItag: { type: integer, nullable: true }
audioTrackId: { type: string, nullable: true }
AndroidPlaybackSeekRequest:
type: object
required: [generation, playerTimeMs]
properties:
generation: { type: integer, format: int64, minimum: 0 }
playerTimeMs: { type: integer, format: int64, minimum: 0 }
AndroidPlaybackResponse:
type: object
required:
- sessionId
- videoId
- manifestUrl
- videoItag
- audioItag
- generation
- ready
- status
- subtitles
properties:
sessionId: { type: string }
videoId: { type: string }
manifestUrl: { type: string }
videoItag: { type: integer }
audioItag: { type: integer }
audioTrackId: { type: string, nullable: true }
generation: { type: integer, format: int64, minimum: 0 }
ready: { type: boolean }
status: { type: string, enum: [ready, preparing] }
subtitles:
type: array
items: { $ref: '#/AndroidSubtitle' }
retryAfterMs: { type: integer, format: int64, nullable: true, minimum: 100, maximum: 2000 }
AndroidSubtitle:
type: object
required: [id, mimeType, languageTag, displayLanguageName, isAutoGenerated, url]
properties:
id: { type: string }
mimeType: { type: string, enum: [text/vtt] }
languageTag: { type: string }
displayLanguageName: { type: string }
isAutoGenerated: { type: boolean }
url:
type: string
description: Session-scoped same-origin resource that remains stable across playback generations.
18 changes: 18 additions & 0 deletions openapi/components/instance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ InstanceResponse:
- oidcAutoRedirect
- youtubeRemoteLoginEnabled
- youtubeRemoteLoginReady
- androidPlayback
properties:
name: { type: string, example: TypeType }
tagline: { type: string, nullable: true }
Expand Down Expand Up @@ -49,3 +50,20 @@ InstanceResponse:
type: string
nullable: true
enum: [disabled, not_configured, token_unreachable]
androidPlayback:
$ref: '#/AndroidPlaybackCapability'
AndroidPlaybackCapability:
type: object
required: [supported, contractVersion, youtube]
properties:
supported: { type: boolean, example: true }
contractVersion: { type: integer, enum: [2] }
youtube:
$ref: '#/AndroidYoutubePlaybackCapability'
AndroidYoutubePlaybackCapability:
type: object
required: [vod, live, subtitles]
properties:
vod: { type: boolean, example: true }
live: { type: boolean, example: false }
subtitles: { type: boolean, example: true }
35 changes: 35 additions & 0 deletions openapi/components/subscriptions.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
SubscriptionFeedResponse:
type: object
required: [videos, nextpage, generation, generatedAt, refreshing]
properties:
videos:
type: array
maxItems: 100
items:
$ref: ./media.yaml#/VideoItem
nextpage:
type: string
nullable: true
description: Opaque continuation bound to this snapshot generation and page size.
generation:
type: integer
format: int64
generatedAt:
type: integer
format: int64
description: Unix timestamp in milliseconds for the snapshot build.
refreshing:
type: boolean
description: True while a stale snapshot is being rebuilt in the background.
SubscriptionFeedPreparingResponse:
type: object
required: [code, retryAfterMs]
properties:
code:
type: string
enum: [subscription_feed_preparing]
retryAfterMs:
type: integer
format: int64
minimum: 100
maximum: 5000
160 changes: 160 additions & 0 deletions openapi/paths/android-playback.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
Create:
parameters:
- name: videoId
in: path
required: true
schema: { type: string }
post:
tags: [android-playback]
summary: Create an Android-only YouTube VOD playback session
description: Active livestreams are rejected. Media remains demand-driven after the complete virtual index is ready. The response includes the authoritative subtitle inventory before Media3 prepares the item.
requestBody:
required: false
content:
application/json:
schema: { $ref: ../components/android-playback.yaml#/AndroidPlaybackCreateRequest }
responses:
'200': { $ref: '#/components/responses/ReadySession' }
'202': { $ref: '#/components/responses/PreparingSession' }
'400': { $ref: ../components/common.yaml#/JsonError }
'401': { $ref: ../components/common.yaml#/JsonError }
'403': { $ref: ../components/common.yaml#/JsonError }
'422': { $ref: ../components/common.yaml#/JsonError }
'503': { $ref: ../components/common.yaml#/JsonError }
Seek:
parameters:
- $ref: '#/components/parameters/SessionId'
post:
tags: [android-playback]
summary: Reposition an Android VOD playback session
description: The session ID and selected formats stay stable while the generation increments.
requestBody:
required: true
content:
application/json:
schema: { $ref: ../components/android-playback.yaml#/AndroidPlaybackSeekRequest }
responses:
'200': { $ref: '#/components/responses/ReadySession' }
'202': { $ref: '#/components/responses/PreparingSession' }
'400': { $ref: ../components/common.yaml#/JsonError }
'404': { $ref: ../components/common.yaml#/JsonError }
'409': { $ref: ../components/common.yaml#/JsonError }
'410': { $ref: ../components/common.yaml#/JsonError }
'422': { $ref: ../components/common.yaml#/JsonError }
Manifest:
parameters:
- $ref: '#/components/parameters/SessionId'
get:
tags: [android-playback]
summary: Get the complete static DASH presentation for an Android VOD session
responses:
'200':
description: Complete standards-compatible static DASH MPD.
headers:
Cache-Control:
schema: { type: string, example: no-store }
content:
application/dash+xml:
schema: { type: string }
'202': { $ref: '#/components/responses/PreparingSession' }
'404': { $ref: ../components/common.yaml#/JsonError }
'410': { $ref: ../components/common.yaml#/JsonError }
'422': { $ref: ../components/common.yaml#/JsonError }
Subtitle:
parameters:
- $ref: '#/components/parameters/SessionId'
- $ref: '#/components/parameters/TrackId'
get:
tags: [android-playback]
summary: Get one session-scoped Android subtitle as UTF-8 WebVTT
description: The caller must use the same account context that created the playback session. The resource remains valid across seek generations.
responses:
'200':
description: Complete UTF-8 WebVTT subtitle document.
headers:
Cache-Control:
schema: { type: string, example: no-store }
content:
text/vtt:
schema: { type: string }
'401': { $ref: ../components/common.yaml#/JsonError }
'404': { $ref: ../components/common.yaml#/JsonError }
'410': { $ref: ../components/common.yaml#/JsonError }
'422': { $ref: ../components/common.yaml#/JsonError }
'503': { $ref: ../components/common.yaml#/JsonError }
Initialization:
parameters:
- $ref: '#/components/parameters/SessionId'
- $ref: '#/components/parameters/Itag'
- $ref: '#/components/parameters/SessionQuery'
- $ref: '#/components/parameters/Generation'
get:
tags: [android-playback]
summary: Get initialization bytes for a selected Android playback track
responses:
'200': { description: Initialization bytes. }
'202': { $ref: '#/components/responses/PreparingSession' }
'206': { description: Partial initialization bytes. }
'400': { $ref: ../components/common.yaml#/JsonError }
'404': { $ref: ../components/common.yaml#/JsonError }
'409': { $ref: ../components/common.yaml#/JsonError }
'410': { $ref: ../components/common.yaml#/JsonError }
Segment:
parameters:
- $ref: '#/components/parameters/SessionId'
- $ref: '#/components/parameters/Itag'
- name: sequence
in: path
required: true
schema: { type: integer, minimum: 1 }
- $ref: '#/components/parameters/SessionQuery'
- $ref: '#/components/parameters/Generation'
get:
tags: [android-playback]
summary: Get one demand-driven media segment for an Android playback track
responses:
'200': { description: Media segment bytes. }
'202': { $ref: '#/components/responses/PreparingSession' }
'206': { description: Partial media segment bytes. }
'400': { $ref: ../components/common.yaml#/JsonError }
'404': { $ref: ../components/common.yaml#/JsonError }
'409': { $ref: ../components/common.yaml#/JsonError }
'410': { $ref: ../components/common.yaml#/JsonError }
components:
parameters:
SessionId:
name: sessionId
in: path
required: true
schema: { type: string }
Itag:
name: itag
in: path
required: true
schema: { type: integer }
TrackId:
name: trackId
in: path
required: true
schema: { type: string }
SessionQuery:
name: session
in: query
required: true
schema: { type: string }
Generation:
name: generation
in: query
required: true
schema: { type: integer, format: int64, minimum: 0 }
responses:
ReadySession:
description: Playback session with a complete VOD presentation.
content:
application/json:
schema: { $ref: ../components/android-playback.yaml#/AndroidPlaybackResponse }
PreparingSession:
description: The exact virtual segment index or requested bytes are still preparing.
content:
application/json:
schema: { $ref: ../components/android-playback.yaml#/AndroidPlaybackResponse }
Loading