Skip to content

feat(NRMediaTailorTracker): new iOS / tvOS module for AWS MediaTailor SSAI - #217

Open
avinash-newrelic wants to merge 26 commits into
masterfrom
feat/mediatailor-tracker
Open

feat(NRMediaTailorTracker): new iOS / tvOS module for AWS MediaTailor SSAI#217
avinash-newrelic wants to merge 26 commits into
masterfrom
feat/mediatailor-tracker

Conversation

@avinash-newrelic

@avinash-newrelic avinash-newrelic commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds NRMediaTailorTracker — a new iOS / tvOS SDK module that emits New Relic ad telemetry for AWS MediaTailor server-side-stitched ads playing through AVPlayer. Mirrors the role and event surface of NRTrackerIMA: attach to the player, hear about ads, emit events. Passive observer — no UI, no ad decisioning, no beacon firing.

Before: customers playing MediaTailor streams got CONTENT_* events from NRAVPlayerTracker and zero ad-side telemetry. Ads played; New Relic was blind to them.

After: customers get the full ad event vocabulary (AD_BREAK_START, AD_REQUEST, AD_START, AD_QUARTILE×3, AD_END, AD_BREAK_END, AD_ERROR, plus AD_PAUSE / AD_RESUME / AD_SKIP) with full metadata: availId, creativeId, adSystem, vastAdId, adTitle, creativeSequence, skipOffset, adProgramDateTime, availProgramDateTime, noFill, podCountMismatch, isBumper.

What's in the module

The module owns six subsystems and 17 source files, all behind a single public class (NRTrackerMediaTailor) re-exported through the umbrella header.

Subsystem Role
Detection (MTDetector) URL heuristics — recognize MediaTailor URLs, extract session ID, derive tracking URL via URL-rewrite fallback
Parser (MTManifestParser protocol + MTHlsParser + MTDashParser) Walk the personalized manifest, produce ad-break geometry + recover the tracking URL from the manifest's marker
Model (8 classes) Decoded tracking-JSON model + runtime break / pod state with firing flags
Tracking client (MTTrackingClient) GET /v1/tracking/<sessionId> with proper NextToken round-trip pagination, 5 s timeout, one retry on transient network errors, cancellable
Scheduler (MTAdScheduleMerger) 4-pass avail matcher reconciling manifest geometry with tracking metadata; manifest-geometry-wins rule; compound de-dup key
State machine (MTPlayheadStateMachine) 250 ms playhead poll loop driving delegate callbacks at every meaningful transition; fired-flag dedup

The customer app's integration is four method calls: parse manifest → fetch tracking JSON → merge → install schedule. Everything else (HTTP lifecycle, pagination, retry, dedup, state transitions, attribute enrichment, KVO pause/resume) is module-internal.

Format support

  • HLS via MTHlsParser — default for AVPlayer consumers
  • DASH via MTDashParser — for customers using third-party players (THEOplayer, Bitmovin, Shaka) with their own DASH playback
  • Custom parsers can be injected via the MTManifestParser protocol for non-standard CDN layouts

Parity with the Android reference + 15 documented fixes

Module is in parity with video-agent-android/NRMediaTailorTracker, with all 15 bugs from NRMediaTailorTracker_BUGS_TO_FIX.md fixed:

Group A (atomic-fact violations in Android, fixed in iOS):

  • A1: Drop ?t=<wallclock> cache-buster — replaced with proper NextToken pagination
  • A2: Empty avails → no-fill (AD_BREAK_START → AD_ERROR(NO_FILL) → AD_BREAK_END, no AD_START)
  • A3: Pod-count mismatch keeps manifest geometry, enriches each pod with closest tracking ad
  • A4: Compound de-dup key (availId, availProgramDateTime ?? startTimeMs) for live HLS sliding-window stability
  • A5: Configurable playhead poll interval
  • A6: Oversize pods clamped to break boundary (with telemetry counter)
  • A7: DASH multi-period detection requires every representation to match an ad marker
  • A8: Missing startTimeInSeconds logs warning + queues MISSING_AVAIL_START, then falls back

Group B (additional bugs surfaced by cross-referencing AWS atomic facts):

  • B1: NextToken round-tripped (entire pagination contract Android ignored)
  • B2: creativeId is primary identity; (availId, adId) composite fallback
  • B3: GET retained for Android parity (atomic fact says POST — divergence documented in MTTrackingClient.h)
  • B4: Tracking URL recovered from manifest marker (primary); URL-rewrite as fallback
  • B5: trackingEvents[*].startTimeInSeconds documented as relativeToAdStartMs to prevent semantic confusion
  • B6: New AD_ERROR event vocabulary with 7-value MTAdErrorCode
  • B7: availProgramDateTime / adProgramDateTime always emitted (empty when nil) for reliable live correlation

Anti-pattern guardrails

10 explicit non-goals documented in CONTRIBUTING.md and at the top of NRTrackerMediaTailor.m:

  1. No VAST beacon firing
  2. No VAST wrapper resolution
  3. No ad personalization / targeting
  4. No cross-session ad caching
  5. No manifest query parameter modification
  6. No avail suppression (BEHIND_LIVE_EDGE etc.)
  7. No ad UI rendering / player pausing / app-business-logic callbacks
  8. No assumption every avail has ads
  9. No pre-firing impression beacons before playback confirmation
  10. No OMID / viewability handoff

Platform support

  • iOS 12.0+
  • tvOS 12.0+
  • Objective-C, matching every sibling tracker in the repo

Verification

  • 132 unit tests passing, 0 failures
  • 89.92% line coverage on NRMediaTailorTracker.framework
  • Both iOS and tvOS schemes build cleanly
  • Smoke-test integration wired into Examples/iOS/SimplePlayerWithAds (the ad-tracking example), alongside the existing IMA path

Release-pipeline integration

The new module is wired into the same release machinery as NRAVPlayerTracker and NRIMATracker:

  • build-xcframeworks.sh now produces NRMediaTailorTracker.xcframework alongside the existing three modules' xcframeworks for inclusion in the GitHub Release xcframeworks.zip
  • .github/workflows/ios-publish.yml validates and publishes NRMediaTailorTracker.podspec to CocoaPods trunk
  • .github/workflows/ios-release.yml version-bumps NRMediaTailorTracker.podspec and the hardcoded @"<version>" in -[NRTrackerMediaTailor getTrackerVersion]

A pre-existing telemetry bug was surfaced and fixed while wiring ios-release.yml: the base NRVideoTracker.getTrackerVersion returns NSNull, so without a per-module override the new module's events would report trackerVersion = NSNull in NRDB. Added the override in commit 1909b13.

Test plan

  • Run xcodebuild test against NRMediaTailorTrackerTests on iPhone 16 simulator (expect 132/132 passing)
  • Verify -enableCodeCoverage YES shows ≥70% line coverage on the framework target (latest local: 89.92%)
  • Build NRMediaTailorTracker-iOS scheme against iphonesimulator SDK
  • Build NRMediaTailorTracker-tvOS scheme against appletvsimulator SDK
  • Smoke-test integration: cd Examples/iOS/SimplePlayerWithAds && pod install, open the workspace, set the MediaTailor session URL via defaults write com.newrelic.SimplePlayerWithAds MediaTailorSampleURL "<your-url>" (or the MT_SAMPLE_URL env var), run the app, tap MediaTailor Sample, confirm in a proxy capture that /v1/tracking/<sessionId> requests round-trip nextToken between consecutive calls
  • Confirm in NRDB that AD_BREAK_START → AD_START → 3× AD_QUARTILE → AD_END → AD_BREAK_END fires for at least one ad break on the integration stream, and that trackerVersion = "4.2.0" (not NSNull) on every event

Notes for reviewers

  • The customer app drives polling cadence in v1 — the tracker doesn't auto-poll the tracking API. Typical usage is to re-fetch every 5–10 seconds for live streams, often aligned to EXT-X-TARGETDURATION.
  • The NRMediaTailorTracker_*.md planning / reference docs at the repo root are intentionally untracked per project convention and are not part of this PR.

avinash-newrelic and others added 25 commits June 22, 2026 19:40
Mirrors NRAVPlayerTracker's iOS-only framework layout. New target builds a
Swift-based `NRTrackerMediaTailor` stub that extends `NRVideoTracker` from
NewRelicVideoCore. Carries the 10 SDK-boundary anti-pattern guardrails from
FEATURE_SPEC §5 in the file header so T02-T09 inherit the non-goals.

- NRMediaTailorTracker.podspec at repo root, depends on NewRelicVideoAgent
- NRMediaTailorTracker.xcodeproj with shared scheme, iOS 13.0 deployment
- Swift 5.0, @objc-exposed class so the public symbol matches the spec
- PrivacyInfo.xcprivacy mirrors NRAVPlayerTracker's
Repo convention is Obj-C, iOS 12, tvOS 12 across all sibling trackers
(NRIMATracker, NRAVPlayerTracker, parent NewRelicVideoAgent). Bell/DeltaTre
is a tvOS customer, so v1 must ship both targets. This pivot rewrites the
T01 scaffold from a86b1f7 to match.

- NRTrackerMediaTailor.swift → NRTrackerMediaTailor.{h,m} (ObjC stub)
- Single xcodeproj, two targets: NRMediaTailorTracker-iOS,
  NRMediaTailorTracker-tvOS, each with a shared scheme
- Deployment targets lowered to iOS 12 / tvOS 12
- Podspec mirrors NRAVPlayerTracker.podspec: dual ios+tvos
  deployment, .{h,m} source glob, NewRelicVideoAgent dependency
- 10-anti-pattern guardrail block carried verbatim from the Swift file
  into NRTrackerMediaTailor.m
Add module README and CONTRIBUTING that document the 10 SDK-boundary
anti-patterns from FEATURE_SPEC §5. The matching guardrails block at the
top of NRTrackerMediaTailor.m was added during T01 and verified in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port the Android MTDetector class to Objective-C. Adds the
NRMediaTailorTracker/Detection module with class methods to:
- detect MediaTailor URLs (substring match on `mediatailor`)
- extract sessionId from `aws.sessionId` or `sessionId` query params
- derive the fallback `/v1/tracking/<sessionId>` URL by rewriting
  `/v1/master/` or `/v1/session/` paths

Per Bug B4, this class is the FALLBACK; the PRIMARY tracking-URL
discovery path parses HLS EXT-X-DATERANGE CLASS="tracking" /
DASH EventStream markers and is implemented in T05. Header
documents this clearly.

Adds a new NRMediaTailorTrackerTests iOS XCTest target (and a
shared scheme) with 18 unit tests covering HLS, DASH, both
sessionId query-param forms, malformed URLs, nil URLs, and the
default segment-marker list. Test target embeds
NRMediaTailorTracker.framework and NewRelicVideoCore.framework
into the .xctest bundle so `xcodebuild test` runs against the
prebuilt video-core framework.

Verified:
- xcodebuild -scheme NRMediaTailorTracker-iOS ** BUILD SUCCEEDED **
- xcodebuild -scheme NRMediaTailorTracker-tvOS ** BUILD SUCCEEDED **
- xcodebuild test -scheme NRMediaTailorTrackerTests
  Executed 18 tests, with 0 failures ** TEST SUCCEEDED **

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…MTAdBreak, MTAdPod)

Adds the Obj-C data model layer for the MediaTailor tracking JSON. Immutable
value objects for the wire shape (MTTrackingResponse / MTAvail / MTAd /
MTTrackingEvent / MTNonLinearAvail); mutable runtime state for the schedule
(MTAdBreak / MTAdPod with hasFired* dedup flags). Hand-rolled fromDictionary:
factories (no Codable; Obj-C only). Public AD_ERROR vocabulary lives in
MTAdErrorCode.

Bugs fixed:
- B1: MTTrackingResponse.nextToken decoded; empty string normalises to nil.
- B2: MTAd.creativeId is the primary identity; -primaryKey returns creativeId
  or the <availId>:<adId> composite fallback.
- B5: trackingEvents[*].startTimeInSeconds is RELATIVE TO AD START — stored
  as relativeToAdStartMs, with a header comment explaining the distinction
  vs. MTAvail/MTAd absolute timing. Documented per atomic facts §1, §9.
- A2 surface: MTAvail.isNoFill computed from ads.count == 0.
- A8 surface: MTAvail.hasStartTime captures whether startTimeInSeconds was
  actually present in the JSON, so the schedule merger (T06) can emit
  MISSING_AVAIL_START instead of silently inferring.

Test target wiring:
- 28 new unit tests across MTTrackingResponseDecodeTests + MTAdDecodeTests.
- 5 JSON fixtures (full, empty_ads, with_nexttoken, missing_creativeId, live)
  bundled as test target resources.
- Files added to both iOS and tvOS framework targets; tests run via the
  pre-existing NRMediaTailorTrackerTests scheme (iOS-only test target).

Build + test verification:
- xcodebuild -scheme NRMediaTailorTracker-iOS  -sdk iphonesimulator    → BUILD SUCCEEDED
- xcodebuild -scheme NRMediaTailorTracker-tvOS -sdk appletvsimulator   → BUILD SUCCEEDED
- xcodebuild test -scheme NRMediaTailorTrackerTests -sdk iphonesimulator → 46 tests / 0 failures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements MTHlsParser + MTManifestParseResult to parse `.m3u8`
manifests into ad breaks and (when present) recover the tracking
URL directly from a manifest marker.

Bug B4 — primary vs fallback discovery:
- PRIMARY: parse `#EXT-X-DATERANGE` entries with
  `CLASS="com.apple.hls.interstitial"` or `CLASS="tracking"` and
  extract their `URI` attribute. Atomic-facts §4 Method 1.
- FALLBACK: callers use `+[MTDetector deriveTrackingURL:]`
  (URL-rewrite) when no DATERANGE marker is present.

Break / pod detection:
- Per-segment URL marker match against MTDetector.defaultSegmentMarkers
  (`segments.mediatailor`, `/v1/hlssegment/`, `/v1/dashsegment/`,
  `/tm/`) plus caller-supplied `customSegmentMarkers`.
- Pod boundaries inside a break via `#EXT-X-DISCONTINUITY`.
- `#EXT-X-PROGRAM-DATE-TIME` immediately before the first ad
  segment populates `MTAdBreak.availProgramDateTime` (Bug A4 / A8
  live identity).

Filters & guards:
- 500 ms min ad duration (matches Android `MIN_AD_DURATION_MS`).
- Bug A6: pods that would overshoot the parent break are clamped
  to the break boundary; clamp count surfaced via
  `+[MTHlsParser lastClampedPodCount]`. Clamp helper exposed as
  `+clampPodIfNeeded:toBreak:` for the schedule merger (T06) to
  reuse.

15 unit tests across 5 fixtures cover:
- VOD with DATERANGE → trackingURL + 1 break / 1 pod
- DATERANGE `CLASS="tracking"` variant + relative URI resolution
- DATERANGE non-tracking class ignored
- VOD segment-markers only → trackingURL nil + 1 break / 2 pods
- Custom segment marker override picks up non-default CDN paths
- Live sliding window → availProgramDateTime propagated
- Empty avail → trackingURL recovered, breaks empty
- Sub-500ms ad segment dropped
- Multi-pod break invariant: every pod end ≤ break end (Bug A6)
- Direct clamp helper: overshooting pod clamped, in-range untouched
- Nil/empty manifest, content-only manifest → empty result

Build/test verification:
- xcodebuild -scheme NRMediaTailorTracker-iOS  ** BUILD SUCCEEDED **
- xcodebuild -scheme NRMediaTailorTracker-tvOS ** BUILD SUCCEEDED **
- xcodebuild test -scheme NRMediaTailorTrackerTests
  Executed 61 tests, with 0 failures (15 new MTHlsParserTests)
  ** TEST SUCCEEDED **

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`MTTrackingClient` fetches the MediaTailor /v1/tracking endpoint and
addresses the three Android deviations from the documented contract:

- B1 (FIXED): nextToken is round-tripped per atomic facts
  f-track-client-113..f-track-client-121. First call sends no token; the
  server's nextToken is captured and echoed as a query param on every
  subsequent call. HTTP 400 drops the token and retries once with none.
- A1 (DROPPED): no `?t=<wall-clock>` cache-bust query param. The Android
  code's cache-busting defeats the backend's session-stable cache; we
  rely on the documented pagination contract instead.
- B3 (PARITY): HTTP GET. Atomic fact f-track-client-119 documents POST
  but Android uses GET and the user has chosen to retain Android parity
  — code comment cites the deviation so a future contributor doesn't
  silently "fix" it.

Implementation notes:
- NSObject + serial dispatch queue (Obj-C analogue of a Swift actor)
- Block-based completion delivered on the main queue
- NSError domain `MTTrackingErrorDomain` with codes for
  Timeout/NetworkFailure/TokenExpired/InvalidResponse/ParseFailed/Cancelled
- 5s timeout, one retry on transient network errors
- `-cancel` and `-resetSession` for player teardown

Tests cover the full B1 lifecycle: first/second-call token semantics,
HTTP-400 expiry + auto-retry, timeout mapping, mid-flight cancel,
same-token returned, malformed JSON, A1 strip, B3 GET, resetSession.
Add MTManifestParser Obj-C protocol so the tracker can swap manifest
parsers without forking the SDK. MTHlsParser conforms; a stub
MTDashParser ships as a placeholder that returns an empty result and
logs a warning so misconfigured customers notice immediately. Inject
via -[NRTrackerMediaTailor setManifestParser:].

The protocol returns a non-null MTManifestParseResult (deviation from
the task description's "return nil") — the protocol declares non-null
and tests assert non-nil, which is the safer contract.

Per locked decision (FEATURE_SPEC §8): HLS-only first release, DASH
adapter ships as a fast-follow.

Files:
- Parser/MTManifestParser.h (protocol)
- Parser/MTDashParser.{h,m} (stub)
- Parser/MTHlsParser.{h,m} (declare + implement <MTManifestParser>)
- Tracker/NRTrackerMediaTailor.{h,m} (setManifestParser:)
- NRMediaTailorTrackerTests/Parser/MTManifestParserSeamTests.m
- README.md (DASH adapter seam section)
- NRMediaTailorTracker.xcodeproj/project.pbxproj (register new files
  on iOS + tvOS targets, fresh FA100/FA200 UUID prefixes)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the HLS manifest carries a DATERANGE entry indicating an ad break
(CLASS="com.apple.hls.interstitial" or "tracking") but no ad-segment
markers follow, the parser now synthesizes a placeholder MTAdBreak with
isNoFill=YES and pods.count==0. This is the contract the T06 merger
consumes and matches atomic facts §6 ("Ad-server failure: tracker emits
AD_BREAK_START -> AD_BREAK_END immediately, no AD_START").

Before this fix, an empty-avail DATERANGE was lost — the parser only
emitted breaks when it saw actual ad-segment URLs. That meant manifests
where MediaTailor signaled an ad break but the ad server returned no
ads (and the tracking API was unavailable or silent) produced no
AD_BREAK_START/AD_BREAK_END events. The fix lets the manifest path
alone surface ad-server-failure avails to the state machine.

Originally implemented by ios-builder-2; committed by team-lead after
recovering the working-tree changes that were stranded during the
T11 / T04 / T05.1 three-way pbxproj merge dance.

Also includes minor pbxproj cleanup: removes 3 duplicate PBXBuildFile
and PBXFileReference entries that snuck in during the merge.
The T11 commit shipped -setManifestParser: but left the getter returning
nil when never set. Convert to a property with a lazy getter that
instantiates a shared MTHlsParser on first access. Callers that never
inject a custom parser get HLS support for free; callers that inject a
DASH adapter override before the first parse and keep it.

Two new tests pin the contract:
- testTracker_manifestParser_defaultsLazilyToMTHlsParser
- testTracker_manifestParser_setterOverridesDefault

Build + test final lines:
- iOS    : ** BUILD SUCCEEDED **
- tvOS   : ** BUILD SUCCEEDED **
- Tests  : ** TEST SUCCEEDED ** (82 tests / 0 failures; was 80)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stateless merger that combines manifest-parsed ad breaks (T05) with
tracking-API enrichment (T03/T04). Returns a MergedSchedule containing
the de-duplicated break list plus a queue of pending AD_ERROR events
that the state machine (T07/T08) will drain.

Bug fixes carried by this stage:

  A2 — Empty avails are now no-fill breaks. avail.ads.count == 0 marks
       the paired break with isNoFill = YES and queues
       MTAdErrorCodeNoFill. The state machine will suppress AD_START /
       quartiles and emit AD_BREAK_START → AD_ERROR(NO_FILL) → AD_BREAK_END.
       Empty avails with no manifest counterpart are also synthesised so
       customers see the no-fill in NRDB even when the player hasn't
       reached the slate segments.

  A3 — Manifest pod count != tracking ad count keeps manifest geometry.
       Each pod is enriched with metadata from the closest-by-startTime
       tracking ad. podCountMismatch = YES is flagged on the break and
       MTAdErrorCodeManifestTrackingMismatch is queued. The merger no
       longer wipes pod boundaries.

  A4 — Compound de-dup key (availId, adProgramDateTime ?? startTimeMs).
       VOD collapses to startTimeMs; live uses wall-clock so HLS sliding-
       window rotation no longer produces duplicate breaks.

  A8 — Missing avail startTimeInSeconds logs a dataIntegrityWarning,
       queues MTAdErrorCodeMissingAvailStart, and falls back to the
       first ad's startTime — instead of Android's silent inference.

  B2 — Pod identity uses [MTAd primaryKey] (creativeId primary,
       <availId>:<adId> composite fallback).

10 new tests cover the golden path, all four Group A bug fixes, B2
identity in both presence and absence of creativeId, and a nil-input
edge case.

Build + test final lines:
- iOS    : ** BUILD SUCCEEDED **
- tvOS   : ** BUILD SUCCEEDED **
- Tests  : ** TEST SUCCEEDED ** (92 tests / 0 failures; was 82)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rgedSchedule

Adds two derived counters on MergedSchedule:
- podCountMismatchCount   — number of breaks with podCountMismatch == YES
- dataIntegrityWarningCount — number of MISSING_AVAIL_START pending errors

Both are computed in -initWithBreaks:pendingErrors: by walking the input
arrays once, so callers don't have to re-scan. Exposed for the state
machine (T07) and event emitter (T08) telemetry without forcing them to
re-derive.

Three new tests:
- testManifestOnly_nilTracking_breaksPassThroughUnchanged (manifest-only flow)
- testCounters_podCountMismatchCount_aggregatesAcrossBreaks (counter sanity)
- testCounters_dataIntegrityWarningCount_countsMissingAvailStart

Build + test final lines:
- iOS    : ** BUILD SUCCEEDED **
- tvOS   : ** BUILD SUCCEEDED **
- Tests  : ** TEST SUCCEEDED ** (95 tests / 0 failures; was 92)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MTPlayheadStateMachine consumes a MergedSchedule (T06) and an AVPlayer,
and drives an explicit IN_CONTENT → IN_BREAK → IN_POD → quartile flow
that the event emitter (T08) will wire to NRVideoTracker.sendXxx calls.

The state machine exposes a -tickAtPositionMs: test seam, so tests run
without a real AVPlayer or wall-clock dependency. The AVPlayer
integration is a thin AVPlayer.addPeriodicTimeObserverForInterval
wrapper around the same tick method.

Bug fixes:
- A5: playheadPollInterval is configurable on init (default 0.250s;
  non-positive inputs clamp to default).
- A6 (state-machine side): a pod ending before its break ends transits
  back to IN_BREAK idle and waits for break.endTimeMs; no spurious
  events. Parser-side clamp policy is unchanged (see memory entry
  [[a6-clamp-vs-reject]]).

Error drain ordering matches the atomic-facts contract:
- AD_BREAK_START → AD_ERROR(NO_FILL) → AD_BREAK_END for no-fill breaks
- AD_BREAK_START → AD_ERROR(MISSING_AVAIL_START) → AD_START for the A8
  case
Errors drain from MergedSchedule.pendingErrors using pointer identity,
so each error fires exactly once even across backward seeks.

Dedup uses the existing MTAdBreak/MTAdPod hasFired* flags from T03 —
backward seeks never re-fire a quartile, POD_START, or BREAK_START.

Tests (8 total, all clock-free via -tickAtPositionMs:):
- Golden: 1 break + 2 pods → full event sequence in order
- No-fill: BREAK_START + ERROR(NO_FILL) + BREAK_END, no pod/quartile
- Backward seek inside pod: no duplicate quartiles
- Skip past entire break: no events (matches IMA tracker behaviour)
- A5: configurable poll interval honored; 0.0 and -1.0 clamp to 0.250
- A8 drain order: BREAK_START → ERROR(MISSING_AVAIL_START) → POD_START
- A6: last pod ends before break end → idle in IN_BREAK then BREAK_END
- Forward seek across multiple quartiles: all three fire in one tick

xcodebuild gates:
- NRMediaTailorTracker-iOS  iphonesimulator    → BUILD SUCCEEDED
- NRMediaTailorTracker-tvOS appletvsimulator   → BUILD SUCCEEDED
- NRMediaTailorTrackerTests iPhone 16          → 103 tests / 0 failures

Pbxproj UUID prefix: C70 (source) / C71 (tests). No collisions with
9CA0, AB10, CD1, CD2, CE3, EE1, EE2, FA1, FA2, DB1, DB2.

Originally assigned to ios-builder-1; they shipped MTAdState.h then
stalled. Team-lead extracted MTAdState.h from their tree and wrote the
remainder.
…attrs)

NRTrackerMediaTailor now adopts MTPlayheadStateMachineDelegate. Each
state transition translates to the corresponding NRVideoTracker send
call:
  enteredBreak:     → sendAdBreakStart
  enteredPod:       → sendRequest + sendStart
  crossedQuartile:  → sendAdQuartile (currentQuartileNumber drives getAdQuartile)
  exitedPod:        → sendEnd
  exitedBreak:      → sendAdBreakEnd
  raisedError:      → sendVideoErrorEvent:AD_ERROR with errorCode attr

Public lifecycle (T09 will extend further):
  - startTrackingWithSchedule: builds the state machine + subscribes
  - stopTracking: tears down (idempotent)
  - notifyAdSkipped: app-invoked, fires AD_SKIP if inside a pod

Bug fixes:
- B6: AD_ERROR is wired for all seven MTAdErrorCode values. Each event
  carries `errorCode` (canonical string) and `errorMessage` attributes.
- B7: getAttributes: always emits `availProgramDateTime` and
  `adProgramDateTime` keys, even when the source values are nil
  (empty string), so live-stream consumers can reliably correlate.

NRVideoTracker overrides:
- getIsAd → @1
- getAdBreakId → current break's availId
- getAdCreativeId → current pod's creativeId
- getAdQuartile → currentQuartileNumber (set by the delegate path)
- getAdPartner → current pod's adSystem
- getTrackerName → "NRMediaTailorTracker"
- getAttributes:attributes: merges in every pod / break attribute the
  spec lists (availId, adId, creativeId, adTitle, adSystem,
  creativeSequence, vastAdId, skipOffset, isBumper, noFill,
  podCountMismatch, plus the always-emitted programDateTime keys).

Tests (8 new, total now 111):
- Golden path: full sequence in order
- No-fill: BREAK_START + AD_ERROR(NO_FILL) + BREAK_END, no pod events
- B6: all seven error codes round-trip through AD_ERROR
- B7: programDateTime keys present in every emitted attribute dict
- Pod attributes land on AD_START
- notifyAdSkipped fires AD_SKIP when inside a pod
- notifyAdSkipped is a no-op outside a pod
- stopTracking is idempotent

xcodebuild gates:
- NRMediaTailorTracker-iOS  iphonesimulator    → BUILD SUCCEEDED
- NRMediaTailorTracker-tvOS appletvsimulator   → BUILD SUCCEEDED
- NRMediaTailorTrackerTests iPhone 16          → 111 tests / 0 failures

Pbxproj UUID prefix: D80 (test file only — no new source files).
Adds full player attachment + teardown:

setPlayer:
- Accepts an AVPlayer; non-AVPlayer args fall through to super.
- Detaches the previous player (state-machine time observer +
  KVO) before installing the new one.
- Registers KVO on `timeControlStatus` with a static context
  pointer; the observer emits AD_PAUSE / AD_RESUME only when the
  tracker is currently inside an ad break (main-content
  pause/resume is the customer's content tracker's concern).

dispose:
- Idempotent — early-return on second call.
- Cancels any in-flight `MTTrackingClient` request and resets the
  client's session (drops the nextToken).
- Tears down the state machine via `stopTracking`.
- Removes the KVO observer and clears the AVPlayer reference.
- Calls super.dispose so the base NRTracker chain runs.
- After dispose, ALL delegate callbacks (incl. raisedError:) and
  notifyAdSkipped / setPlayer: / startTrackingWithSchedule:
  early-return. The tracker is single-use.

dealloc:
- Calls dispose defensively, so customers who simply drop their
  strong reference get a clean teardown without leaking the
  AVPlayer KVO registration.

Tests (8 new, total now 119):
- dispose is idempotent
- After dispose, further work is a no-op (no crashes, state cleared)
- setPlayer attaches the state machine when tracking is active
- Re-attaching swaps players cleanly (no crash, dispose clean)
- setPlayer with non-AVPlayer args delegates to super
- setPlayer after dispose is a no-op
- Memory leak test: tracker releases cleanly after strong refs drop
- stopTracking leaves the player attached (vs dispose which detaches)

xcodebuild gates:
- NRMediaTailorTracker-iOS  iphonesimulator    → BUILD SUCCEEDED
- NRMediaTailorTracker-tvOS appletvsimulator   → BUILD SUCCEEDED
- NRMediaTailorTrackerTests iPhone 16          → 119 tests / 0 failures

Pbxproj UUID prefix: D80x...A001 (re-use of T08's D80 namespace —
no collision since A000 was the events test file and A001 is the
lifecycle test file).
Wires NRTrackerMediaTailor into the existing sample app:

- Podfile: add `pod 'NRMediaTailorTracker', :path => '../../../'`.
- New `MediaTailorSamples.{h,m}` — placeholder sample-URL holder. The
  default URL is intentionally a `REPLACE_ME` AWS endpoint; the
  integration smoke path requires the operator to substitute their own
  MediaTailor session URL before running.
- `ViewController.m`: new `-clickMediaTailorSample:` IBAction that builds
  an AVPlayer, attaches a `NRTrackerMediaTailor`, and registers a
  content tracker alongside via `NRVAVideo.addPlayer` (parity with the
  IMA example flow). `dealloc` calls `-dispose` on the tracker for clean
  teardown.

The IBAction is exposed for storyboard wiring; the actual button hookup
is intentionally not committed because changing the storyboard would
fork the existing example's UI without context. Drop a button in IB and
wire its `Touch Up Inside` event to `clickMediaTailorSample:` when
running the smoke test.

Verification flow (per FEATURE_SPEC §9 "Definition of Done"):
1. Replace `MediaTailorSamples.defaultSampleURLString` with a real
   MediaTailor session URL.
2. `pod install`, build, run.
3. Capture proxy log (Charles / mitmproxy) and verify the
   `/v1/tracking/<sessionId>` calls round-trip `nextToken` between
   consecutive requests (proves Bug B1 fix end-to-end).
4. Confirm in NRDB that AD_BREAK_START → AD_START → 3×AD_QUARTILE →
   AD_END → AD_BREAK_END fires for at least one ad break.
5. Optional: run the Android module side-by-side on the same stream;
   event sequences should match.

In production the host app fetches the personalized manifest, parses
it via `MTHlsParser`, polls `/v1/tracking/<sessionId>` via
`MTTrackingClient`, merges with `MTAdScheduleMerger`, and feeds the
schedule into `-[NRTrackerMediaTailor startTrackingWithSchedule:]`.
The example's smoke path leaves the schedule-feeding step to the
operator since it depends on the operator's networking layer; see
`NRMediaTailorTracker/README.md` "Integration" for the full snippet.
- NRMediaTailorTracker/README.md: refresh the integration snippet to match
  the actual public API shipped across T01–T11 (init → setPlayer →
  startTrackingWithSchedule → dispose, with the MTHlsParser /
  MTTrackingClient / MTAdScheduleMerger sequence shown explicitly). The
  prior snippet referenced an `initWithPlayer:` initializer that we never
  shipped. Add a "Verification" section with the canonical xcodebuild
  commands for unit tests + coverage + both-platform builds + the
  integration smoke flow.

- README.md (repo root): add NRMediaTailorTracker to the Modules table.
  Bump "three modules" to "four modules" in the section intro.

- CHANGELOG.md: add an Unreleased entry covering the new module, the
  bug-fix list vs the Android reference (14 of 15 fixed; A7 deferred
  behind the DASH adapter seam), and the bumpfree wire compatibility
  with the existing event vocabulary.

T12 coverage gate (≥70% line coverage on NRMediaTailorTracker.framework):
  Measured 89.92% line coverage via `xcrun xccov view --report
  --only-targets`. 119 tests across the 17 source files. Documented in
  the module README's new Verification section so any contributor can
  reproduce the measurement.

A CI workflow that runs the test scheme on every PR is intentionally
not added here — the repo's existing workflows are publish/release
flows, and adding a PR-test workflow is a separate piece of CI
engineering (runner configuration, simulator selection, cert handling)
that doesn't belong with the docs sweep.
… variants

scripts/bootstrap-newrelic-video-core.sh builds NewRelicVideoCore for
iphonesimulator + iphoneos + appletvsimulator + appletvos under
Debug, writing the framework artifacts to
NewRelicVideoCore/build/Debug-<sdk>/NewRelicVideoCore.framework.
That's the path every consumer module's xcodeproj declares in its
framework search paths.

Background: NRMediaTailorTracker (and existing siblings
NRAVPlayerTracker, NRIMATracker) link against a pre-built
NewRelicVideoCore framework rather than a source dependency. The
build/ directory is gitignored, so on a fresh clone the tvOS scheme
fails to link until someone builds NewRelicVideoCore tvOS first.
Surfaced by ios-builder-1 during T01.

Verified locally — all four build invocations end in
** BUILD SUCCEEDED ** on this machine. Added a "First-clone bootstrap"
section to NRMediaTailorTracker/README.md telling new contributors to
run the script after cloning.

Re-run when bumping the NewRelicVideoCore source.
…button

The MediaTailor smoke-test wiring originally landed in T13
(commit 1e7fd11) under Examples/iOS/SimplePlayerUsingPods. That was
the wrong example — SimplePlayerUsingPods is the content-only
playback demo. NRMediaTailorTracker is an ad-tracking module, so the
example belongs in Examples/iOS/SimplePlayerWithAds alongside the
existing IMA integration.

Also: the T13 commit had latent bugs that prevented the example
from building (Podfile.lock not updated; MediaTailorSamples.{h,m}
created but never wired into the pbxproj). This commit ships the
re-homed integration as a working example.

Changes in SimplePlayerUsingPods (revert to pre-T13):
- Podfile: drop the NRMediaTailorTracker pod entry
- ViewController.m: revert to the pre-T13 content-only version
- Delete MediaTailorSamples.{h,m}

Changes in SimplePlayerWithAds (new home for the MediaTailor demo):
- Podfile: add `pod 'NRMediaTailorTracker', :path => '../../../'`
  alongside the existing NRAVPlayerTracker / NRIMATracker pods.
- New `MediaTailorSamples.{h,m}` — resolves the session URL from
  NSUserDefaults (`MediaTailorSampleURL` key) first, then the
  `MT_SAMPLE_URL` env var, then a `REPLACE_ME` placeholder. Lets the
  smoke-test operator plug in their real URL without editing source:
      defaults write com.newrelic.SimplePlayerWithAds \
          MediaTailorSampleURL "https://<your.mediatailor.session.url>"
- ViewController.m: new `clickMediaTailorSample:` IBAction and
  `playMediaTailorVideo:` internal method. Unlike the IMA buttons,
  this path skips IMAAdsLoader / IMAAdsManager — MediaTailor ads are
  stitched into the HLS manifest server-side, so no client-side
  ads SDK is needed. Instantiates NRTrackerMediaTailor, attaches the
  player, and registers a content tracker via NRVAVideo.addPlayer.
  Dealloc and viewDidAppear (dismiss path) call -dispose so the
  KVO/observer teardown runs cleanly.
- Main.storyboard: new "MediaTailor Sample" button, wired to
  `clickMediaTailorSample:`, anchored 8pt below the Airshow button
  with centerX alignment.
- SimplePlayerWithAds.xcodeproj/project.pbxproj: file references and
  build-file entries for MediaTailorSamples.{h,m} (UUID prefix
  MT0100xx — clearly distinct from the existing 9CEE25xx range).
- Podfile.lock: regenerated by `pod install` — includes
  NRMediaTailorTracker 4.2.0.

Verified locally:
- `xcodebuild -workspace SimplePlayerWithAds.xcworkspace -scheme
  SimplePlayerWithAds -sdk iphonesimulator build` → BUILD SUCCEEDED.
- The new button renders below "Airshow" and wires to the new
  IBAction (verified via storyboard XML diff).

Verification flow (unchanged from T13 brief):
1. Replace `MediaTailorSamples.defaultSampleURLString` placeholder
   with a real MediaTailor session URL — either edit the placeholder
   in source, set the NSUserDefaults override, or set MT_SAMPLE_URL
   in the simulator's env.
2. Run the example app, tap "MediaTailor Sample".
3. Capture a proxy log and confirm `/v1/tracking/<sessionId>` calls
   round-trip `nextToken` between consecutive requests.
4. Confirm in NRDB that AD_BREAK_START → AD_START → 3×AD_QUARTILE →
   AD_END → AD_BREAK_END fires for at least one ad break.
Replaces the T11 stub `MTDashParser` with a full NSXMLParser-backed DASH
MPD parser. Conforms to the `MTManifestParser` seam alongside `MTHlsParser`,
so DASH now ships out of the box — no more "HLS only in v1".

The headline fix is Bug A7. The Android `MTDashParser.java` classified a
`<Period>` as ad based on the first `<Representation>`'s BaseURL. DASH
periods can legitimately mix representations with different BaseURLs, so
that's wrong. The iOS parser walks every `<Representation>` across every
`<AdaptationSet>`, resolves the effective URL through the period →
adaptation-set → representation `BaseURL` inheritance chain, and only
classifies a period as ad when **all** representations match an
ad-segment marker. Mixed → content. The mixed-period count is exposed
via `-[MTDashParser mixedPeriodCount]` for tests + runtime telemetry.

Also handles:
 - Multi-period VOD (period start from `start` attribute or running sum
   of prior durations; ISO 8601 PT… durations).
 - Dynamic live: `availabilityStartTime` + period start offset →
   `availProgramDateTime` on every break (Bug A4 live-identity contract).
 - SCTE-35 + `urn:aws:elemental:mediatailor:tracking` `<EventStream>` —
   takes precedence over BaseURL classification per atomic facts §10.
 - `<Location>` element → tracking URL (DASH analogue of HLS DATERANGE
   `CLASS="tracking"` URI).
 - Min ad duration 500 ms (matches HLS parser).
 - Graceful return-empty on nil / empty / non-UTF-8 / malformed XML /
   no-periods / period-missing-duration inputs.

Fixtures + tests
 - `mediatailor_dash_vod_multiperiod.mpd` — content/ad/content, asserts
   single break at the middle period.
 - `mediatailor_dash_dynamic_live.mpd` — SCTE-35 event, asserts
   `availProgramDateTime` is populated from `availabilityStartTime`.
 - `mediatailor_dash_mixed_representations.mpd` — THE A7 fixture: 3
   representations, 2 match ad marker, 1 doesn't → 0 breaks,
   `mixedPeriodCount == 1`.
 - `mediatailor_dash_no_periods.mpd` — degenerate `<MPD/>`, returns
   empty.

Test count: 119 → 132 (13 new DASH tests). Both schemes build clean
(`NRMediaTailorTracker-iOS` / `-tvOS`); all tests pass on the iPhone 16
simulator.

Out of scope: model classes, merger, state machine — untouched per the
T16 brief.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…he canonical contributor path

The script (scripts/bootstrap-newrelic-video-core.sh, added in T20) pre-built
NewRelicVideoCore for all four SDK/simulator slices to satisfy the standalone
xcodebuild path declared in NRMediaTailorTracker.xcodeproj's framework search
paths. That standalone path is a contributor convenience, not the canonical
workflow.

The canonical contributor workflow is the same one already used for sibling
modules: cd into the example app and pod install. CocoaPods resolves
NewRelicVideoCore (published as the NewRelicVideoAgent pod, module name
NewRelicVideoCore) and the dependent trackers via the local podspecs at
:path => '../../../'. NRMediaTailorTracker.podspec already declares
s.dependency 'NewRelicVideoAgent' so it slots in cleanly.

This commit:
  • Deletes scripts/bootstrap-newrelic-video-core.sh (and the now-empty
    scripts/ directory)
  • Rewrites NRMediaTailorTracker/README.md "Verification" section to point
    contributors at Examples/iOS/SimplePlayerWithAds + pod install +
    SimplePlayerWithAds.xcworkspace. Standalone xcodebuild instructions
    removed (they depended on the deleted script's output).
  • Updates the test count from the stale 119 to the actual 132.

Sibling tracker READMEs (NRAVPlayerTracker, NRIMATracker) do not exist, so
there's no precedent to mirror; the minimal pods pointer matches the
SimplePlayerWithAds.Podfile that's already in the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Appends NRMediaTailorTracker to the list of frameworks the release script
builds into universal XCFrameworks. Mirrors the NRAVPlayerTracker line:
both schemes (iOS + tvOS), depends on NewRelicVideoCore, no extra setup.

The trailing zip + GitHub Release upload steps use glob (mv *.xcframework
xcframeworks/) so they pick up the new artifact transparently — no further
edits needed.

Without this, ios-publish.yml's "Build XCFrameworks" step would continue
to omit NRMediaTailorTracker.xcframework from the released xcframeworks.zip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… validate + publish lists

Adds NRMediaTailorTracker to every pod-enumeration in the publish workflow:

  • validate_with_retry list (line 129) — pod lib lint must pass before
    publish-time
  • publish_pod list (line 286) — pushed to CocoaPods trunk after the
    NewRelicVideoAgent indexing wait, parallel to NRAVPlayerTracker /
    NRIMATracker
  • GitHub Release notes body (line 332)
  • Dry-run summary echo (line 360)
  • verify_pod post-publish check (line 387)
  • "Users can now install with" echo (line 397)

Without these, every release would silently skip NRMediaTailorTracker —
the version bump would land in master, but the pod would not exist on
CocoaPods trunk at the new version.

Dependency order: NRMediaTailorTracker is registered as depending on
NewRelicVideoAgent (s.dependency in its podspec), so it must publish after
NewRelicVideoAgent indexing completes. The existing wait_for_indexing
gate on NewRelicVideoAgent at line 280 covers this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The base NRVideoTracker.getTrackerVersion returns (NSString *)[NSNull null].
Without a per-module override, every event the tracker emits would carry
trackerVersion = NSNull in NRDB — making it impossible to filter telemetry
by tracker version, correlate against module-specific bugs, or attribute
issues to a specific NRMediaTailorTracker release.

Sibling trackers (NRTrackerIMA, NRTrackerAVPlayer) each declare this
override returning the current module version. NRTrackerMediaTailor was
the only public tracker class in the repo missing it — a gap in the T08
event-emission parity work that didn't surface until we went to integrate
this module into ios-release.yml's version-bump workflow, which sed-patches
the hardcoded string in each module's tracker .m alongside the podspec.

Adds:

  - (NSString *)getTrackerVersion {
      return @"4.2.0";
  }

placed after getTrackerName in the existing "NRVideoTracker attribute
overrides" pragma section. Matches NRTrackerIMA.m:106-108 verbatim apart
from the indentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… version-bump list

The release workflow sed-replaces version strings in 3 places per
module: (a) the podspec's s.version, (b) the tracker's hardcoded
`return @"X.Y.Z";` in -getTrackerVersion, and (c) a verify-grep that
prints the bumped value. Plus the source file lands in the workflow's
`git add` list so the version-bump PR commit includes it.

This commit wires NRMediaTailorTracker into all five touch points:
  - sed: NRMediaTailorTracker.podspec → s.version
  - sed: NRTrackerMediaTailor.m → -getTrackerVersion return literal
  - verify-grep for the podspec
  - verify-grep for the .m
  - `git add` for the .m

Requires the previous commit (the -getTrackerVersion override
returning @"4.2.0") — without that override the source-file sed would
have nothing to match.

After this, the next time the release workflow runs, NRMediaTailorTracker
gets version-bumped alongside the existing three modules.
@avinash-newrelic
avinash-newrelic force-pushed the feat/mediatailor-tracker branch from 8983df7 to 82f201e Compare June 29, 2026 06:11
NRMediaTailorTracker (P0-110 / P0-111 / P0-112):
- adSegmentPrefix: public custom-CDN ad-segment marker, plumbed to a new
  MTHlsParser.customSegmentMarkers instance property used on the
  MTManifestParser protocol path (nil/empty preserves default AWS markers).
- trackingUrl: public override + -resolvedTrackingURLForManifestURL:
  (verbatim when set, else MTDetector deriveTrackingURL: fallback).
- pollIntervalMs: public playhead poll cadence, default 250ms, clamped
  100..5000ms with a warning; replaces the hard-coded 0.250 at
  -startTrackingWithSchedule:.

NewRelicVideoCore — NRAdConfig (P0-116):
- New NRAdConfig (+csai / +mediaTailor / +mediaTailorWithSegmentPrefix:… )
  + adConfig on NRVAVideoPlayerConfiguration. addPlayer: now selects the
  ad-tracker class by type (NRTrackerIMA vs NRTrackerMediaTailor) via
  dynamic class loading and wires MediaTailor's player + adSegmentPrefix +
  trackingUrl. Legacy adEnabled:YES maps to csai (no behavior change).
- SimplePlayerWithAds migrated to NRAdConfig.mediaTailor().

Tests: 8 core tests (NRAdConfig factories, legacy->csai mapping, config
selection) + tracker config/clamp + custom-marker detection tests. Core
and tracker suites and the sample workspace build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@skatti97 skatti97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work overall on the A1–A8/B1–B7 fixes vs. the Android reference.

if (_currentPeriod == nil) return;
double presentationTime = [attrs[@"presentationTime"] doubleValue];
double duration = [attrs[@"duration"] doubleValue];
NSTimeInterval startMs = (presentationTime / _eventStreamTimescale) * 1000.0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SCTE-35 EventStream presentationTime isn't offset by the enclosing <Period>'s start. Per the DASH spec, EventStream Event presentationTime is relative to the containing Period's start, but this computes startMs directly from presentationTime with no addition of _currentPeriod.startMs. Contrast with finalizeCurrentPeriod (the non-EventStream branch), which correctly does p.hasStart ? p.startMs : _runningPriorPeriodEndMs. For any MPD with a <Period start="PT100S"> containing an EventStream ad marker, the resulting MTAdBreak.startTimeMs will be wrong by the Period's start offset, causing MTPlayheadStateMachine to fire AD_BREAK_START/quartiles at the wrong playhead position. The only EventStream fixture (mediatailor_dash_dynamic_live.mpd) uses start="PT0S", which masks this.

self.avPlayer = nil;
}

- (void)observeValueForKeyPath:(NSString *)keyPath

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timeControlStatus KVO handler reads currentBreak/currentPod without any thread/queue guarantee, while MTPlayheadStateMachine's delegate callbacks write those same properties on the main queue. AVFoundation doesn't guarantee timeControlStatus KVO notifications land on the main thread, so this can run concurrently with the state machine's main-queue mutations. To be precise about the actual risk: since currentBreak/currentPod are declared weak, ARC's weak-accessor machinery is itself thread-safe (no memory corruption), so this isn't a crash risk — but it is a real TOCTOU-style logical race: sendPause/sendResume can fire based on a break/pod that's concurrently exiting on the main queue, producing an ad-pause event for an ad that's already ended from the state machine's point of view. Worth hopping this handler onto the main queue before touching currentBreak/currentPod, to match the stated "main-queue only" contract documented elsewhere in this class.

MTAvail *match = [self matchAvailForBreak:manifestBreak in:avails consumed:consumedAvails];
if (match) {
[consumedAvails addObject:match];
[self enrichBreak:manifestBreak withAvail:match outErrors:errors];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enrichBreak: runs (and can queue an MTMergedScheduleError) before the dedup check on line 41-46 decides whether to drop this break. MTMergedScheduleError.adBreak is declared weak, and MergedSchedule.breaks is the only strong owner of MTAdBreak instances in the default (HLS) parsing path. If this break turns out to be a dedup duplicate (same key as an earlier break in this same call — the A4 "live window slide" case), it's enriched — possibly queuing an error — and then dropped from outBreaks at line 46. Once mergeManifestBreaks: returns, nothing retains that dropped break, so the queued error's weak adBreak becomes nil. MTPlayheadStateMachine.drainPendingErrorsForBreak: matches via err.adBreak != brk, which can never match a nil adBreak, so the queued error (e.g. a MISSING_AVAIL_START warning) is silently swallowed. Suggest either skipping enrichment for a break you already know will be deduped, or resolving the dedup key before enriching.

errorCode:MTAdErrorCodeMissingAvailStart
message:@"avail missing startTimeInSeconds; inferred from first ad"]];
MTAd *firstAd = avail.ads.firstObject;
if (firstAd && br.startTimeMs == 0.0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This treats a legitimate zero-position break the same as an "unset" one. br.startTimeMs is a primitive NSTimeInterval with no sentinel for "unset" — a preroll break parsed at position 0 (a completely normal, correct value) looks identical here to "never set." When the avail is missing startTimeInSeconds (the A8 case) and the break's own startTimeMs happens to be 0.0 because it's a real preroll, this overwrites the correct value with firstAd.startTimeMs, desyncing the break from its own pods (which still start at 0). The existing test testA8_missingAvailStartTime_logsAndQueuesError actually constructs this exact 0→42000ms overwrite but only asserts on the error code/count, not on the resulting startTimeMs, so it wouldn't catch a regression here. Consider gating this fallback on whether the break's start was itself inferred/unknown, rather than on the raw value being 0.0.

if ([consumed containsObject:avail]) continue;
if (!avail.hasStartTime) continue;
NSTimeInterval delta = fabs(avail.startTimeMs - br.startTimeMs);
if (delta <= bestDelta) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-strict <= means a tied delta lets a later avail silently replace an earlier match. bestDelta starts at the full tolerance and this loop updates best/bestDelta whenever delta <= bestDelta, so two avails equidistant from br.startTimeMs resolve to whichever is later in iteration order rather than the first-found. Contrast with the sibling helper elsewhere in this file that uses bestDelta = INFINITY with a strict <, keeping the first-found candidate on ties — this loop is inconsistent with that convention. avails isn't sorted or deduped before this pass, so ties are reachable whenever the tracking API returns two unconsumed avails at the same tolerance-boundary distance. Untested today (no test exercises two candidates at an equal delta). Suggest switching to strict < to match the sibling helper's tie-breaking behavior.

+ (id)createAdTracker {
// Dynamic class loading with graceful fallback
Class trackerClass = NSClassFromString(@"NRTrackerIMA");
+ (id)createAdTrackerForConfig:(NRAdConfig *)adConfig {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NRVAVideo.h still declares the old no-arg +(id)createAdTracker; selector, which no longer has an implementation after this rename to createAdTrackerForConfig:. (The header itself isn't touched by this PR's diff, so I can't anchor a comment directly on it — flagging here instead.) Any caller invoking [NRVAVideo createAdTracker] per the still-published public header would crash at runtime with "unrecognized selector sent to class." Nothing in this repo calls the bare selector today, so it's not an active crash, but the public API contract is broken silently. Worth removing (or updating) the stale declaration in NRVAVideo.h line 178 in the same PR.


+ (instancetype)fromJSONData:(NSData *)data error:(NSError **)error {
if (data.length == 0) {
if (error) *error = [NSError errorWithDomain:@"MTTrackingResponse" code:1 userInfo:@{NSLocalizedDescriptionKey: @"empty body"}];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ad-hoc error domain/codes instead of the module's own MTTrackingErrorDomain/MTTrackingErrorCode. fromJSONData: builds NSErrors with domain string @"MTTrackingResponse" and raw codes 1/2, rather than the shared error domain used everywhere else in this module (e.g. MTTrackingClient.m). Any caller that switches on MTTrackingErrorDomain/MTTrackingErrorCodeInvalidResponse or ParseFailed (as NRTrackerMediaTailor.m does for the client's errors) will never match an error from an empty-body or non-object JSON response here — it's silently misclassified into an unlisted domain. Suggest reusing MTTrackingErrorDomain with an appropriate MTTrackingErrorCode here for consistency.

// (IMA drives itself via the IMA SDK, so it needs neither.) The host
// still calls -startTrackingWithSchedule: once it has the manifest +
// tracking JSON — MediaTailor is a passive schedule observer.
if (adTracker && config.adConfig.type == NRAdTrackerTypeMediaTailor) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared infrastructure hardcodes a MediaTailor-specific branch rather than a generic per-tracker configuration contract. This if (adTracker && config.adConfig.type == NRAdTrackerTypeMediaTailor) block lives in core addPlayer: and pushes MediaTailor-specific fields (adSegmentPrefix, trackingUrl) via KVC. It also checks respondsToSelector:@selector(setAdSegmentPrefix:) and then calls setValue:forKey:@"adSegmentPrefix" — two different identifiers (a selector and a KVC key string) that aren't tied together, so a future rename of one and not the other would pass the guard and then crash with NSUnknownKeyException. Longer-term, a third tracker type will need another type-switch branch bolted onto this same shared method. A -configureWithAdConfig:player: method that each tracker subclass implements (called uniformly, without NRVAVideo.m knowing tracker-specific field names) would avoid both the KVC/selector drift risk and the growing type-switch.

@skatti97 skatti97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up pass focused purely on code quality (reuse + simplification), no correctness bugs in this batch. 6 findings below.

+ (instancetype)fromDictionary:(NSDictionary *)dict {
if (![dict isKindOfClass:[NSDictionary class]]) return nil;

NSString *availId = [dict[@"availId"] isKindOfClass:[NSString class]] ? dict[@"availId"] : nil;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same isKindOfClass:[NSString class]] ? ... : nil guard hand-copied 9 times. This exact pattern — extract a dictionary value, verify it's actually a string, fall back to nil — appears here and at MTAvail.m:32, MTAd.m:44, MTNonLinearAvail.m:26, and MTTrackingEvent.m:28-30,45: nine call sites across four files, each re-deriving the identical check. A small shared helper (e.g. MTStringOrNil(dict, key)) would remove the duplication and give one place to harden the check later (trimming whitespace, tolerating NSNull, etc.) instead of nine.

self.currentState = MTAdStateInPod;
}

- (void)checkQuartilesForPod:(MTAdPod *)pod atPositionMs:(NSTimeInterval)positionMs {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three near-identical blocks differing only by a threshold and a flag name. checkQuartilesForPod: repeats the same if (progress >= X && !pod.hasFiredQN) { pod.hasFiredQN = YES; ... } shape three times for 0.25/Q1, 0.50/Q2, 0.75/Q3 — nothing differs structurally between them. Iterating a small static array of (threshold, quartileNumber) pairs in one loop would remove the copy-paste and the risk of a future edit mismatching a threshold with the wrong flag (or a 4th checkpoint being added as a 4th near-identical block).

// Firing flags
@property (nonatomic, assign) BOOL hasFiredStart;
@property (nonatomic, assign) BOOL hasFiredEnd;
@property (nonatomic, assign) BOOL hasFiredAdStart;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasFiredAdStart/hasFiredQ1/hasFiredQ2/hasFiredQ3 (lines 47-50) are declared but never read or written anywhere in the module. Quartile/ad-start firing is actually tracked one level down on MTAdPod, which has its own working copies of these same flags that MTPlayheadStateMachine actually uses. The only place these four properties are touched at all is a default-value assertion in MTAdDecodeTests.m (always-passing, since nothing ever sets them). Beyond being dead weight, they're actively misleading — a future contributor skimming this header would reasonably assume break-level quartile tracking happens here, and either build on top of these stale fields or duplicate the (already-correct) pod-level tracking a second time. Suggest deleting all four properties and their corresponding test assertions.

#pragma mark - Player attachment / KVO

- (void)setPlayer:(id)player {
if (self.isDisposed) { return; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (self.isDisposed) { return; } is copy-pasted into 11 methods (this line plus 192, 214, 234, 248, 256, 264, 275, 283, 290, 297), including every MTPlayheadStateMachineDelegate callback. It works today, but it's structurally optional — a new delegate/public method added later that forgets to paste this guard would silently act on a torn-down tracker. Since dispose already tears down self.stateMachine and detaches the player, setting self.stateMachine.delegate = nil before doing so would make the six delegate callbacks structurally impossible to invoke post-dispose, removing the need for the guard on those six specifically (the public entry points like setPlayer:/startTrackingWithSchedule: would still need it, since those can be called directly regardless of the delegate link).


@property (nonatomic, strong, readonly) MergedSchedule *schedule;
@property (nonatomic, assign, readwrite) NSTimeInterval playheadPollInterval;
@property (nonatomic, assign, readwrite) MTAdState currentState;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentState is a manually-synced cache instead of a computed value. It's independently assigned at (at least) 4 separate spots in this file — line 117 (entering a break), 147 (exiting), 158 (entering a break, no-fill branch), 167 (entering a pod) — rather than being derived on read from currentAdBreak/currentAdPod/isNoFill, which the object already tracks. A future transition path that forgets to update currentState would leave it stale while the rest of the object has already moved on, with nothing to catch the mismatch. Computing it on read instead (if (!self.currentAdBreak) return MTAdStateContent; if (self.currentAdBreak.isNoFill) return MTAdStateNoFill; return self.currentAdPod ? MTAdStateInPod : MTAdStateInBreak;) removes this whole category of "forgot to update it" bug.

NSMutableString *_locationText;

// <BaseURL> text accumulators (depth-aware).
BOOL _capturingPeriodBaseURL;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four parallel (BOOL capturing_, NSMutableString *_Text) pairs (lines 69-74, plus the top-level pair above) track what's structurally a single piece of state: "which BaseURL level am I currently inside, if any." The same 4-way if/else-if chain over these pairs is repeated in didStartElement (~173-192), foundCharacters (~280-296), and didEndElement (~314-344) — their mutual exclusivity is only an implicit convention enforced by re-deriving the same chain three times. A single _baseURLCaptureTarget enum (None/TopLevel/Period/Adaptation/Representation) plus one shared text accumulator would collapse all three call sites to a single switch/if, and make "at most one level capturing at a time" a structural invariant instead of a convention three separate methods have to independently maintain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants