Skip to content

Commit 2fc5e58

Browse files
tianyifcopybara-github
authored andcommitted
Add SteeringManifestTracker
Issue: #1689 PiperOrigin-RevId: 847284384
1 parent bf94233 commit 2fc5e58

File tree

2 files changed

+702
-0
lines changed

2 files changed

+702
-0
lines changed
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package androidx.media3.exoplayer.upstream.contentsteering;
17+
18+
import static com.google.common.base.Preconditions.checkNotNull;
19+
import static com.google.common.base.Preconditions.checkState;
20+
21+
import android.net.Uri;
22+
import androidx.annotation.Nullable;
23+
import androidx.annotation.VisibleForTesting;
24+
import androidx.media3.common.C;
25+
import androidx.media3.common.util.Clock;
26+
import androidx.media3.common.util.HandlerWrapper;
27+
import androidx.media3.common.util.Log;
28+
import androidx.media3.common.util.UnstableApi;
29+
import androidx.media3.common.util.UriUtil;
30+
import androidx.media3.common.util.Util;
31+
import androidx.media3.datasource.DataSource;
32+
import androidx.media3.datasource.DataSpec;
33+
import androidx.media3.datasource.HttpDataSource;
34+
import androidx.media3.exoplayer.source.LoadEventInfo;
35+
import androidx.media3.exoplayer.source.MediaSourceEventListener;
36+
import androidx.media3.exoplayer.upstream.Loader;
37+
import androidx.media3.exoplayer.upstream.ParsingLoadable;
38+
import androidx.media3.exoplayer.util.ReleasableExecutor;
39+
import com.google.common.base.Supplier;
40+
import com.google.common.collect.ImmutableMap;
41+
import java.io.IOException;
42+
import java.util.List;
43+
import java.util.Map;
44+
45+
/** Tracks the steering manifests. */
46+
@UnstableApi
47+
public final class SteeringManifestTracker {
48+
49+
/** A callback to be notified of {@link SteeringManifestTracker} events. */
50+
public interface Callback {
51+
52+
/**
53+
* Called by the {@link SteeringManifestTracker} when it requires the steering query parameters
54+
* to build the url for loading the steering manifest.
55+
*/
56+
ImmutableMap<String, String> getSteeringQueryParameters();
57+
58+
/**
59+
* Called by the {@link SteeringManifestTracker} when the steering manifest is updated.
60+
*
61+
* @param steeringManifest The updated {@link SteeringManifest}.
62+
*/
63+
void onSteeringManifestUpdated(SteeringManifest steeringManifest);
64+
}
65+
66+
@VisibleForTesting
67+
/* package */ static final long FALLBACK_DELAY_UNTIL_NEXT_LOAD_MS = 300_000; // 5 mins.
68+
69+
private static final String TAG = "SteeringManifestTracker";
70+
private static final String RETRY_AFTER_HEADER = "Retry-After";
71+
72+
private final DataSource.Factory dataSourceFactory;
73+
@Nullable private final Supplier<ReleasableExecutor> downloadExecutorSupplier;
74+
private final Clock clock;
75+
private final SteeringManifestLoaderCallback steeringManifestLoaderCallback;
76+
77+
@Nullable private Uri steeringManifestUrl;
78+
@Nullable private Callback callback;
79+
@Nullable private MediaSourceEventListener.EventDispatcher eventDispatcher;
80+
@Nullable private SteeringManifest steeringManifest;
81+
@Nullable private HandlerWrapper steeringManifestReloadHandler;
82+
@Nullable private Loader steeringManifestLoader;
83+
private boolean hasStarted;
84+
85+
/**
86+
* Creates an instance.
87+
*
88+
* @param dataSourceFactory The {@link DataSource.Factory} to use for steering manifest loading.
89+
* @param downloadExecutorSupplier A supplier for a {@link ReleasableExecutor} that is used for
90+
* loading the steering manifest.
91+
*/
92+
public SteeringManifestTracker(
93+
DataSource.Factory dataSourceFactory,
94+
@Nullable Supplier<ReleasableExecutor> downloadExecutorSupplier) {
95+
this(dataSourceFactory, downloadExecutorSupplier, Clock.DEFAULT);
96+
}
97+
98+
/**
99+
* Creates an instance.
100+
*
101+
* @param dataSourceFactory The {@link DataSource.Factory} to use for steering manifest loading.
102+
* @param downloadExecutorSupplier A supplier for a {@link ReleasableExecutor} that is used for
103+
* loading the steering manifest.
104+
* @param clock The {@link Clock} to schedule handler messages.
105+
*/
106+
/* package */ SteeringManifestTracker(
107+
DataSource.Factory dataSourceFactory,
108+
@Nullable Supplier<ReleasableExecutor> downloadExecutorSupplier,
109+
Clock clock) {
110+
this.dataSourceFactory = dataSourceFactory;
111+
this.downloadExecutorSupplier = downloadExecutorSupplier;
112+
this.clock = clock;
113+
this.steeringManifestLoaderCallback = new SteeringManifestLoaderCallback();
114+
}
115+
116+
/**
117+
* Starts the {@link SteeringManifestTracker}.
118+
*
119+
* @param initialSteeringManifestUrl The initial steering manifest url from the content
120+
* description (an HLS multivariant playlist or a DASH MPD).
121+
* @param callback A {@link Callback}.
122+
* @param eventDispatcher A dispatcher to notify of events.
123+
*/
124+
public void start(
125+
Uri initialSteeringManifestUrl,
126+
Callback callback,
127+
MediaSourceEventListener.EventDispatcher eventDispatcher) {
128+
this.steeringManifestUrl = initialSteeringManifestUrl;
129+
this.callback = callback;
130+
this.eventDispatcher = eventDispatcher;
131+
this.steeringManifestReloadHandler =
132+
clock.createHandler(Util.getCurrentOrMainLooper(), /* callback= */ null);
133+
this.steeringManifestLoader =
134+
downloadExecutorSupplier != null
135+
? new Loader(downloadExecutorSupplier.get())
136+
: new Loader("SteeringManifestTracker");
137+
this.hasStarted = true;
138+
loadSteeringManifestImmediately();
139+
}
140+
141+
/** Stops the {@link SteeringManifestTracker}. */
142+
public void stop() {
143+
steeringManifest = null;
144+
if (steeringManifestLoader != null) {
145+
steeringManifestLoader.release();
146+
steeringManifestLoader = null;
147+
}
148+
if (steeringManifestReloadHandler != null) {
149+
steeringManifestReloadHandler.removeCallbacksAndMessages(/* token= */ null);
150+
steeringManifestReloadHandler = null;
151+
}
152+
callback = null;
153+
eventDispatcher = null;
154+
hasStarted = false;
155+
}
156+
157+
private void loadSteeringManifestImmediately() {
158+
checkState(hasStarted);
159+
Uri.Builder steeringManifestUrlBuilder = checkNotNull(steeringManifestUrl).buildUpon();
160+
ImmutableMap<String, String> steeringQueryParameters =
161+
checkNotNull(callback).getSteeringQueryParameters();
162+
for (Map.Entry<String, String> entry : steeringQueryParameters.entrySet()) {
163+
steeringManifestUrlBuilder.appendQueryParameter(entry.getKey(), entry.getValue());
164+
}
165+
DataSpec dataSpec =
166+
new DataSpec.Builder().setUri(checkNotNull(steeringManifestUrlBuilder.build())).build();
167+
ParsingLoadable<SteeringManifest> steeringManifestLoadable =
168+
new ParsingLoadable<>(
169+
dataSourceFactory.createDataSource(),
170+
dataSpec,
171+
C.DATA_TYPE_STEERING_MANIFEST,
172+
new SteeringManifestParser());
173+
checkNotNull(steeringManifestLoader)
174+
.startLoading(
175+
steeringManifestLoadable,
176+
/* callback= */ steeringManifestLoaderCallback,
177+
/* defaultMinRetryCount= */ 0);
178+
}
179+
180+
private static Uri getSteeringManifestUrl(
181+
Uri previousSteeringManifestUrl, @Nullable Uri reloadUri) {
182+
if (reloadUri == null) {
183+
// The reloadUri is null, then we continue using the previousSteeringManifestUrl.
184+
return previousSteeringManifestUrl;
185+
}
186+
if (UriUtil.isAbsolute(reloadUri.toString())) {
187+
// The reloadUri is absolute, then we use it directly.
188+
return reloadUri;
189+
}
190+
// The reloadUri is relative, then we use the relative resolution of it with respect to the
191+
// previousSteeringManifestUrl.
192+
return UriUtil.resolveToUri(previousSteeringManifestUrl.toString(), reloadUri.toString());
193+
}
194+
195+
private static LoadEventInfo buildLoadEventInfo(
196+
ParsingLoadable<SteeringManifest> loadable, long elapsedRealtimeMs, long loadDurationMs) {
197+
return new LoadEventInfo(
198+
loadable.loadTaskId,
199+
loadable.dataSpec,
200+
loadable.getUri(),
201+
loadable.getResponseHeaders(),
202+
elapsedRealtimeMs,
203+
loadDurationMs,
204+
loadable.bytesLoaded());
205+
}
206+
207+
private class SteeringManifestLoaderCallback
208+
implements Loader.Callback<ParsingLoadable<SteeringManifest>> {
209+
210+
@Override
211+
public void onLoadStarted(
212+
ParsingLoadable<SteeringManifest> loadable,
213+
long elapsedRealtimeMs,
214+
long loadDurationMs,
215+
int retryCount) {
216+
if (!hasStarted) {
217+
return;
218+
}
219+
LoadEventInfo loadEventInfo = buildLoadEventInfo(loadable, elapsedRealtimeMs, loadDurationMs);
220+
checkNotNull(eventDispatcher)
221+
.loadStarted(loadEventInfo, C.DATA_TYPE_STEERING_MANIFEST, retryCount);
222+
}
223+
224+
@Override
225+
public void onLoadCompleted(
226+
ParsingLoadable<SteeringManifest> loadable, long elapsedRealtimeMs, long loadDurationMs) {
227+
if (!hasStarted) {
228+
return;
229+
}
230+
SteeringManifest newSteeringManifest = checkNotNull(loadable.getResult());
231+
steeringManifest = newSteeringManifest;
232+
checkNotNull(callback).onSteeringManifestUpdated(newSteeringManifest);
233+
steeringManifestUrl =
234+
getSteeringManifestUrl(checkNotNull(steeringManifestUrl), newSteeringManifest.reloadUri);
235+
long delayUntilNextLoadMs =
236+
newSteeringManifest.timeToLiveMs != C.TIME_UNSET
237+
? newSteeringManifest.timeToLiveMs
238+
: FALLBACK_DELAY_UNTIL_NEXT_LOAD_MS;
239+
checkNotNull(steeringManifestReloadHandler)
240+
.postDelayed(
241+
SteeringManifestTracker.this::loadSteeringManifestImmediately, delayUntilNextLoadMs);
242+
LoadEventInfo loadEventInfo = buildLoadEventInfo(loadable, elapsedRealtimeMs, loadDurationMs);
243+
checkNotNull(eventDispatcher).loadCompleted(loadEventInfo, C.DATA_TYPE_STEERING_MANIFEST);
244+
}
245+
246+
@Override
247+
public void onLoadCanceled(
248+
ParsingLoadable<SteeringManifest> loadable,
249+
long elapsedRealtimeMs,
250+
long loadDurationMs,
251+
boolean released) {
252+
if (!hasStarted) {
253+
return;
254+
}
255+
LoadEventInfo loadEventInfo = buildLoadEventInfo(loadable, elapsedRealtimeMs, loadDurationMs);
256+
checkNotNull(eventDispatcher).loadCanceled(loadEventInfo, C.DATA_TYPE_STEERING_MANIFEST);
257+
}
258+
259+
@Override
260+
public Loader.LoadErrorAction onLoadError(
261+
ParsingLoadable<SteeringManifest> loadable,
262+
long elapsedRealtimeMs,
263+
long loadDurationMs,
264+
IOException error,
265+
int errorCount) {
266+
if (!hasStarted) {
267+
return Loader.DONT_RETRY;
268+
}
269+
int responseCode = Integer.MAX_VALUE;
270+
if (error instanceof HttpDataSource.InvalidResponseCodeException) {
271+
responseCode = ((HttpDataSource.InvalidResponseCodeException) error).responseCode;
272+
}
273+
// See https://datatracker.ietf.org/doc/html/draft-pantos-content-steering-01#section-7
274+
// (sub-term 7).
275+
long delayUntilNextLoadMs =
276+
FALLBACK_DELAY_UNTIL_NEXT_LOAD_MS; // Use fallback TTL unless the below cases.
277+
if (responseCode == 410) {
278+
// If HTTP 410 Gone is in response, we will not reload for the remainder of
279+
// the session.
280+
delayUntilNextLoadMs = C.TIME_UNSET;
281+
checkNotNull(steeringManifestLoader).release();
282+
checkNotNull(steeringManifestReloadHandler).removeCallbacksAndMessages(/* token= */ null);
283+
} else if (responseCode == 429) {
284+
// If HTTP 429 Too Many Requests with a Retry-After header is in response, we will wait
285+
// for the specified time until reload.
286+
@Nullable List<String> retryAfter = loadable.getResponseHeaders().get(RETRY_AFTER_HEADER);
287+
if (retryAfter != null) {
288+
try {
289+
delayUntilNextLoadMs = Long.parseLong(retryAfter.get(0)) * 1000;
290+
} catch (NumberFormatException e) {
291+
Log.w(TAG, "Retry-After header string doesn't contain a parsable long");
292+
}
293+
}
294+
} else if (steeringManifest != null && steeringManifest.timeToLiveMs != C.TIME_UNSET) {
295+
// If there has been a steeringManifest with a TTL, we will wait for that previously
296+
// specified TTL until reload.
297+
delayUntilNextLoadMs = steeringManifest.timeToLiveMs;
298+
}
299+
if (delayUntilNextLoadMs != C.TIME_UNSET) {
300+
checkNotNull(steeringManifestReloadHandler)
301+
.postDelayed(
302+
SteeringManifestTracker.this::loadSteeringManifestImmediately,
303+
delayUntilNextLoadMs);
304+
}
305+
LoadEventInfo loadEventInfo = buildLoadEventInfo(loadable, elapsedRealtimeMs, loadDurationMs);
306+
checkNotNull(eventDispatcher)
307+
.loadError(
308+
loadEventInfo,
309+
C.DATA_TYPE_STEERING_MANIFEST,
310+
error,
311+
/* wasCanceled= */ (delayUntilNextLoadMs == C.TIME_UNSET));
312+
// We do not retry here but will still reload with the latest parameters after the delay.
313+
return Loader.DONT_RETRY;
314+
}
315+
}
316+
}

0 commit comments

Comments
 (0)