Skip to content

Allow invalidation of resolved assets in X-ASSET-LIST interstitials #3322

Open

Description

@kelmer44

Use case description

Our use case requires live HLS streams with HLS Interstitials where EXT-X-DATERANGE interstitials use X-ASSET-LIST rather than X-ASSET-URI. The asset-list endpoint is dynamic and is provided by a third party, so a fresh request can return a different set of ads depending on a number of business rules.

We need an opt-in way for playback to resolve asset lists for interstitials that are behind the current playback position when the user later scrubs back before them. For example, if the user joins at the live edge and several interstitial ad groups are already behind that position, the app should be able to avoid playing those immediately, but still allow them to resolve normally if the user scrubs back before any of them.

Likewise, we also need an opt-in way to re-request an asset list for an ad group that was already resolved/played earlier. When the user scrubs back before that ad group and playback reaches the ad opportunity again, we want to invalidate the previous asset-list result and request the endpoint again, rather than replaying the old resolved media items.

With current Media3 behavior, joining a live HLS interstitial stream at the live edge may immediately play the latest unplayed ad group before the current playback position. To avoid that, our app marks ad groups behind the current position as skipped and groups ahead as available. We repeat this after seek/skip events.

This prevents immediate playback of past ads, but it does not allow those past X-ASSET-LIST ad groups to resolve later. Marking a group skipped removes its pending asset-list entry from the loader's internal unresolvedAssetLists. If the user then scrubs back before that ad group and the app tries to make it available, the ad has no mediaItems, and there is no pending asset-list entry for the loader to resolve.

Similarly, once an asset list has been resolved, Media3 stores the resolved ad media items in AdPlaybackState and removes the asset-list entry from the pending unresolvedAssetLists. There does not appear to be a public API to invalidate that resolved result and schedule resolution again.

I've gone through this closed issue, but I still can't find any public API that would allow us to achieve this behavior without altering media3 internals in a fork. Please do correct me if this is already possible.

Proposed solution

We suggest to add an opt-in API that lets apps invalidate and requeue HLS X-ASSET-LIST interstitial resolution without requiring Media3 to adopt any default scrub-back ad policy.

One possibility we propose is to add a method that lets implementors invalidate and requeue X-ASSET-LIST interstitial resolution. E.g.

 void invalidateAssetListAdGroup(int adGroupIndex);

Expected behavior:

  • Applies to ad groups resulting from X-ASSET-LIST
  • Clears any previously resolved media items for the given ad group.
  • Restores the ad group to an unresolved state so the loader can apply a new asset list
  • Schedules resolution of assets (if appropriate)

We could then leverage this function in onPositionDiscontinuity to do something like:

  if (group.timeUs <= playbackPositionInPeriodUs) {
        if (isSkippable(group)) {
          hlsInterstitialsAdsLoader.setWithSkippedAdGroup(i);
        }
      } else if (isReplayable(group)) {
          hlsInterstitialsAdsLoader.invalidateAssetListAdGroup(i)
          hlsInterstitialsAdsLoader.setWithAvailableAdGroup(i);
      }

Alternatively, Media3 could expose an opt-in general request strategy on HlsInterstitialsAdsLoader, so apps can decide when an asset-list backed interstitial should be invalidated/re-requested during seek or boundary crossing.

Alternatives considered

We tried using existing public apis to:

  • mark ad groups behind the current playback position as skipped
  • mark future/replayable groups as available

This is not sufficient for X-ASSET-LIST interstitials.

  • For past ad groups that have never been resolved, setting the group available cannot work because the ad has no resolved mediaItems. The asset-list entry was also removed from unresolvedAssetLists when the group was marked skipped, so there is no request for the loader to resolve.

  • For already-resolved/played ad groups, setting the group available reuses the previously resolved media items. It does not invalidate the old asset-list response or request a new one.

The currently available function setWithAvailableAdMediaItem is not enough for our needs since we work off multiple mediaItems. It also would require to duplicate the loading logic, which would be redundant.

Activity

  1. changed the title [-]Allow invalidation of resolved asset in X-ASSET-LIST interstitials[/-] [+]Allow invalidation of resolved assets in X-ASSET-LIST interstitials[/+] on Jul 7, 2026
  2. marcbaechinger commented on Jul 7, 2026

    @marcbaechinger
    Contributor

    Thanks for your feature request.

    With the next release the ads loader will include setWithAssetListReset and variants of this method which, as I understand, pretty much does what you ask for in the expected behavior section of your post:

    • Applies to ad groups resulting from X-ASSET-LIST
    • Clears any previously resolved media items for the given ad group.
    • Restores the ad group to an unresolved state so the loader can apply a new asset list
    • Schedules resolution of assets (if appropriate)

    Can you check in [1] whether this is sufficient for your use case and if not, let us know what is missing to achieve what you try to achieve?

    EDIT: I updated the comment in #2749 (comment) to name the new API added with the commit. This wasn't clear before my change. Sorry for this.

    [1] https://github.com/androidx/media/blob/release-1.11.0-beta01/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java#L891-L1114

  3. kelmer44 commented on Jul 8, 2026

    @kelmer44
    Author

    well isnt this great timing, thanks for pointing me to the right version. Let me test this one and come back to report

  4. kelmer44 commented on Jul 8, 2026

    @kelmer44
    Author

    So far i havent been able to get 1.11.0-beta01 to even play interstitials, even without the business logic laid out above. Just doing this on the demo app:

    protected boolean initializePlayer() {
    (...)
          player = playerBuilder.build();
    (...)
          hlsInterstitialsAdsLoader.setPlayer(player);
    (...)
    }
    private MediaSource.Factory createMediaSourceFactory() {
        DefaultDrmSessionManagerProvider drmSessionManagerProvider = new DefaultDrmSessionManagerProvider();
        drmSessionManagerProvider.setDrmHttpDataSourceFactory(
            DemoUtil.getHttpDataSourceFactory(/* context= */ this));
        ImaServerSideAdInsertionMediaSource.AdsLoader.Builder serverSideAdLoaderBuilder = new ImaServerSideAdInsertionMediaSource.AdsLoader.Builder(/* context= */
            this, playerView);
        if (serverSideAdsLoaderState != null) {
          serverSideAdLoaderBuilder.setAdsLoaderState(serverSideAdsLoaderState);
        }
        serverSideAdsLoader = serverSideAdLoaderBuilder.build();
         hlsInterstitialsAdsLoader = new HlsInterstitialsAdsLoader(dataSourceFactory);
    
        DefaultMediaSourceFactory defaultMediaSourceFactory = new DefaultMediaSourceFactory(this)
            .setDataSourceFactory(dataSourceFactory);
    
        HlsInterstitialsAdsLoader.AdsMediaSourceFactory hlsAdsMediaSourceFactory =
            new HlsInterstitialsAdsLoader.AdsMediaSourceFactory(
                hlsInterstitialsAdsLoader,
                null,
                defaultMediaSourceFactory
            );
    
        return hlsAdsMediaSourceFactory;
      }

    and

    private static List<MediaItem> createMediaItems(Intent intent, DownloadTracker downloadTracker) {
        List<MediaItem> mediaItems = new ArrayList<>();
        for (MediaItem item : IntentUtil.createMediaItemsFromIntent(intent)) {
          MediaItem newItem =
              item.buildUpon()
                  .setAdsConfiguration(
                      new MediaItem.AdsConfiguration.Builder(Uri.parse("hls://interstitials"))
                          .setAdsId("ad-tag" + item.mediaId)
                          .build())
                  .build();
    
          mediaItems.add(
              maybeSetDownloadProperties(
                  newItem, downloadTracker.getDownloadRequest(newItem.localConfiguration.uri)));
        }
        return mediaItems;
      }

    gets me an interstitial ad right after joining the stream, then never again.

    same code in 1.10.0 gets me there. Has anything else changed between this versions that im missing?

  5. marcbaechinger commented on Jul 8, 2026

    @marcbaechinger
    Contributor

    Lemme check this with the test streams I have! Any specifics to the live stream like pre rolls?

  6. kelmer44 commented on Jul 8, 2026

    @kelmer44
    Author

    Nope, here's a sample manifest of my local test server.

    #EXTM3U
    #EXT-X-VERSION:7
    #EXT-X-TARGETDURATION:7
    #EXT-X-MEDIA-SEQUENCE:8
    #EXT-X-DISCONTINUITY-SEQUENCE:8
    #EXT-X-INDEPENDENT-SEGMENTS
    #EXT-X-DATERANGE:ID="ad-break-8",CLASS="com.apple.hls.interstitial",START-DATE="2026-07-08T12:06:35.371Z",PLANNED-DURATION=12.074666,X-ASSET-LIST="http://192.168.68.128:8765/interstitial-assets.json?interstitialId=ad-break-8&duration=12.074666",X-SNAP="OUT,IN",X-TIMELINE-OCCUPIES="RANGE",X-TIMELINE-STYLE="HIGHLIGHT",X-CONTENT-MAY-VARY="YES"
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:06:35.371Z
    #EXTINF:6.037333,
    /media/broadcast-ad-1.ts?v=mrc19vmz&seq=8
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:06:41.408Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/broadcast-ad-2.ts?v=mrc19vmz&seq=9
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:06:47.445Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/content-1.ts?v=mrc19vmz&seq=10
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:06:53.483Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/content-2.ts?v=mrc19vmz&seq=11
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:06:59.520Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/content-3.ts?v=mrc19vmz&seq=12
    #EXT-X-DATERANGE:ID="ad-break-13",CLASS="com.apple.hls.interstitial",START-DATE="2026-07-08T12:07:05.557Z",PLANNED-DURATION=12.074666,X-ASSET-LIST="http://192.168.68.128:8765/interstitial-assets.json?interstitialId=ad-break-13&duration=12.074666",X-SNAP="OUT,IN",X-TIMELINE-OCCUPIES="RANGE",X-TIMELINE-STYLE="HIGHLIGHT",X-CONTENT-MAY-VARY="YES"
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:07:05.557Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/broadcast-ad-1.ts?v=mrc19vmz&seq=13
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:07:11.595Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/broadcast-ad-2.ts?v=mrc19vmz&seq=14
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:07:17.632Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/content-4.ts?v=mrc19vmz&seq=15
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:07:23.669Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/content-5.ts?v=mrc19vmz&seq=16
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:07:29.707Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/content-6.ts?v=mrc19vmz&seq=17
    #EXT-X-DATERANGE:ID="ad-break-18",CLASS="com.apple.hls.interstitial",START-DATE="2026-07-08T12:07:35.744Z",PLANNED-DURATION=12.074666,X-ASSET-LIST="http://192.168.68.128:8765/interstitial-assets.json?interstitialId=ad-break-18&duration=12.074666",X-SNAP="OUT,IN",X-TIMELINE-OCCUPIES="RANGE",X-TIMELINE-STYLE="HIGHLIGHT",X-CONTENT-MAY-VARY="YES"
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:07:35.744Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/broadcast-ad-1.ts?v=mrc19vmz&seq=18
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:07:41.781Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/broadcast-ad-2.ts?v=mrc19vmz&seq=19
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:07:47.819Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/content-1.ts?v=mrc19vmz&seq=20
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:07:53.856Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/content-2.ts?v=mrc19vmz&seq=21
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:07:59.893Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/content-3.ts?v=mrc19vmz&seq=22
    #EXT-X-DATERANGE:ID="ad-break-23",CLASS="com.apple.hls.interstitial",START-DATE="2026-07-08T12:08:05.930Z",PLANNED-DURATION=12.074666,X-ASSET-LIST="http://192.168.68.128:8765/interstitial-assets.json?interstitialId=ad-break-23&duration=12.074666",X-SNAP="OUT,IN",X-TIMELINE-OCCUPIES="RANGE",X-TIMELINE-STYLE="HIGHLIGHT",X-CONTENT-MAY-VARY="YES"
    #EXT-X-PROGRAM-DATE-TIME:2026-07-08T12:08:05.930Z
    #EXT-X-DISCONTINUITY
    #EXTINF:6.037333,
    /media/broadcast-ad-1.ts?v=mrc19vmz&seq=23
  7. marcbaechinger commented on Jul 8, 2026

    @marcbaechinger
    Contributor

    Yeah, something is odd. I can repro problems with live. When going back before commit [1] live streams work again without issues. I need to work out whether this commit is the causing the problem in HlsInterstitialsAdsLoader. Thanks for your report and sorry for the inconvenience. I'll update this issue when I have news.

    EDIT: the actual culprit is #3126

    [1] 1835f0d

  8. kelmer44 commented on Jul 14, 2026

    @kelmer44
    Author

    I saw the offending commit was reverted, thanks for that @marcbaechinger.

    I tried the new setWithAssetListReset function. It does re-request the asset list as expected, but I’m seeing an issue with the ad group’s contentResumeOffsetUs after reset.

    Our scrub-back logic is roughly:

    if (group.timeUs <= playbackPositionInPeriodUs) {
      if (isSkippable(group)) {
        hlsInterstitialsAdsLoader.setWithSkippedAdGroup(i);
      }
    } else if (isReplayable(group)) {
      hlsInterstitialsAdsLoader.setWithAssetListReset(state.adsId, i);
    }

    The asset list is fetched again, but after setWithAssetListReset the ad group’s contentResumeOffsetUs appears to become 0. After the refreshed asset list resolves and plays, playback resumes at the original interstitial start time instead of after the underlying ad break, so we end up playing the broadcast ads underneath the interstitial.

    Our manifests may or may not include an explicit X-RESUME-OFFSET depending on the break. When it is absent, I understand from the HLS interstitial spec that the resume offset should be derived from X-PLAYOUT-LIMIT / PLANNED-DURATION or the asset-list durations.

    IMO resetting the asset list should not leave the ad group with a zero resume offset. Either the previous derived resume offset should be preserved until the new asset list resolves (which assumes same duration between different asset-lists), or the resume offset should be recalculated from the refreshed asset list / manifest metadata after reset.

  9. marcbaechinger commented on Jul 14, 2026

    @marcbaechinger
    Contributor

    Thanks for following up and confirming that the asset list can be reset.

    Our manifests may or may not include an explicit X-RESUME-OFFSET depending on the break. When it is absent, I understand
    from the HLS interstitial spec that the resume offset should be derived from X-PLAYOUT-LIMIT / PLANNED-DURATION or
    the asset-list durations.

    I agree with your interpretation that it should not be 0 if there is a duration for the ad/interstitial.

    Can you post the snippet of the DATERANGE tag in the playlist and the asset list JSON that produces this result? You can remove/hide the URIs, it's only about the numbers regarding durations. You can send this to android-media-github@google.com using a subject in the format "Issue #3322" if you don't want to share this here.

    With this information I can craft a unit test for repro and then provide a fix.

    Some thoughts/points of discussion:

    I understand from the HLS interstitial spec that the resume offset should be derived from X-PLAYOUT-LIMIT / PLANNED-DURATION

    • I think the duration is mandatory in an asset JSON, so we would need to take this value rather than what was in the HLS playlist before we downloaded the asset:
    Each Asset-Description JSON object MUST have a "URI" member whose
    value is a quoted-string absolute URI for a single interstitial
    asset, and a "DURATION" member whose value is a decimal-floating-
    point indicating the duration of the interstitial asset in
    seconds.
    

    In absense of an X-RESUME-OFFSET we would use the duration of the asset list accordingly. Does this make sense in your understanding as well?

    • Similaraly, I think if a duration is defined elsewhere, then X-PLAYOUT-LIMIT is not relevant regarding the offset because we have a duration from the asset list.
    If the X-RESUME-OFFSET is not present, its value is considered to be the duration of the interstitial.
    

    My interpretation of the spec is that, X-PLAYOUT-LIMIT says how long to play the ad, but it does not say something about the resume offset in case we have a explicitly defined duration of the ad. This explicit duration is then used as the resume offset as defined above as duration of the interstitial. So with an asset list that has a mandatory duration, X-PLAYOUT-LIMIT doesn't determine the resume offset AIUI.

    The playout limit can be different to the resume offset. What I mean is that an ad of 2 seconds of duration, can jump over 30 seconds of the primary stream. This would make sense to allow apps to splice in an ad with an arbitrary duration and still skip over a different duration in the primary stream.

    Your snippet would be helpfull as a first step, so we can sort out the bug in our code first, then the interpretation of the spec. Happy to hear whether you have made different exprience with yout manifest on other platforms.

  10. kelmer44 commented on Jul 14, 2026

    @kelmer44
    Author

    I dont mind sharing as this all comes from my test server. Here's the manifest, asset list and sample interstitial m3u8.

    responses.zip

    In absense of an X-RESUME-OFFSET we would use the duration of the asset list accordingly. Does this make sense in your understanding as well?
    Similaraly, I think if a duration is defined elsewhere, then X-PLAYOUT-LIMIT is not relevant regarding the offset because we have a duration from the asset list.

    I agree with you when reading the spec, but media3's code currently has this:

    private static long resolveInterstitialResumeOffsetUs(
          Interstitial interstitial, HlsMediaPlaylist mediaPlaylist) {
        if (interstitial.snapTypes.contains(SNAP_TYPE_IN)) {
          long resumeOffsetUs =
              interstitial.resumeOffsetUs != C.TIME_UNSET
                  ? interstitial.resumeOffsetUs
                  : resolveInterstitialDurationUs(interstitial, /* defaultDurationUs= */ 0L);
          long startTimeUs =
              interstitial.snapTypes.contains(SNAP_TYPE_OUT)
                  ? getClosestSegmentBoundaryUs(interstitial.startDateUnixUs, mediaPlaylist)
                  : interstitial.startDateUnixUs;
          return getClosestSegmentBoundaryUs(startTimeUs + resumeOffsetUs, mediaPlaylist) - startTimeUs;
        } else {
          return interstitial.resumeOffsetUs != C.TIME_UNSET
              ? interstitial.resumeOffsetUs
              : resolveInterstitialDurationUs(interstitial, C.TIME_UNSET);
        }
      }

    and

      private static long resolveInterstitialDurationUs(
          Interstitial interstitial, long defaultDurationUs) {
        if (interstitial.playoutLimitUs != C.TIME_UNSET) {
          return interstitial.playoutLimitUs;
        } else if (interstitial.durationUs != C.TIME_UNSET) {
          return interstitial.durationUs;
        } else if (interstitial.endDateUnixUs != C.TIME_UNSET) {
          return interstitial.endDateUnixUs - interstitial.startDateUnixUs;
        } else if (interstitial.plannedDurationUs != C.TIME_UNSET) {
          return interstitial.plannedDurationUs;
        }
        return defaultDurationUs;
      }

    if i am reading this correctly, when resumeOffsetUs is not present, then it falls back to resolveInterstitialDurationUs, which checks playout limit first. So it seems you are already using playout limit to calculate duration.

  11. marcbaechinger commented on Jul 14, 2026

    @marcbaechinger
    Contributor

    Yeah, I didn't say the code is correct. Will check and update the issue when I found something. Many thanks!

  12. marcbaechinger commented on Jul 16, 2026

    @marcbaechinger
    Contributor

    I submitted a first fix above that updates the resume offset with the duration of the ads in the asset JSON. I added a unit test that reflects your playlist/JSON samples. Thanks for that.

    I aim for another fix for resolveInterstitialDurationUs to not take the playout limit into account. However, I consider this as a potentially difficult change with side effects on the behavior. I don't want to push this as late as for an rc01 without having certainty that I don't produce side effects. I'm not sure whether I can squeeze this in for rc01. It feels like a kind of a change that should not be squeezed in in any way. Let's see. I do my best but I can not promise that will land in 1.11 I'm afraid.

  13. kelmer44 commented on Jul 16, 2026

    @kelmer44
    Author

    Thats understandable, no expectation to have this in any particular version. Thanks @marcbaechinger. Will test and report back!

  14. kelmer44 commented on Jul 17, 2026

    @kelmer44
    Author

    The new version worked great when reseting the asset lists, but i think there's a regression now on incoming interstitials; i believe the resumeOffset calculation is trying in getClosestSegmentBoundaryUs to snap to a position that doesn't exist yet in the timeline and thus it's falling back to zero (my streams have X-SNAP="OUT,IN")

    The result is that interstitials that enter the live window "naturally" seem to have a resume position of 0 now.

  15. marcbaechinger commented on Jul 20, 2026

    @marcbaechinger
    Contributor

    Thanks. That's embarrassing. :( Sorry. I have a fix for that with a test case. Will try to expand on the test cases.

  16. kelmer44 commented on Jul 30, 2026

    @kelmer44
    Author

    I just tested with the latest main and on a first glance everything seems to be working as expected. Thanks a lot! I think this can be closed now

    I assume this will not go into 1.11.0 but into 1.12.0 instead, right?

  17. marcbaechinger commented on Aug 3, 2026

    @marcbaechinger
    Contributor

    Thanks again for testing and giving feedback! Your contribution is very important to us to do the right thing for stream variants and use cases that we haven't seen yet. Your help is very much appreciated!

    Yeah, I'm afraid but given we were already on rc01 and the required change to fix that was of considerable size I decided to keep it out of 1.11. for now. I know this is suboptimal, but I think prioritizing a safe procedure here makes sense.

  18. added a commit that references this issue on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions