Skip to content

Commit 76400ce

Browse files
committed
Track data usage during streaming playback
Adds StreamingCellularTracker to monitor bytes received during streaming. Integrates with DefaultPlayer and MediaExporter for tracking streamed audio data.
1 parent 9a01f31 commit 76400ce

File tree

3 files changed

+223
-1
lines changed

3 files changed

+223
-1
lines changed

podcasts/DefaultPlayer.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ class DefaultPlayer: PlaybackProtocol, Hashable {
3636
private var episodeUuid: String?
3737
private var podcastUuid: String?
3838

39+
40+
#if !os(watchOS) && !APPCLIP
41+
private var cellularTracker: StreamingCellularTracker?
42+
#endif
43+
3944
#if !os(watchOS)
4045
private lazy var episodeArtwork: EpisodeArtwork = {
4146
EpisodeArtwork()
@@ -74,6 +79,20 @@ class DefaultPlayer: PlaybackProtocol, Hashable {
7479
episodeUuid = episode.uuid
7580
podcastUuid = episode.parentIdentifier()
7681

82+
// Start cellular tracking for remote streaming
83+
// MediaExporterResourceLoaderDelegate handles its own tracking for cache+stream,
84+
// but for direct AVPlayer streaming we use StreamingCellularTracker
85+
#if !os(watchOS) && !APPCLIP
86+
if let urlAsset = playerItem.asset as? AVURLAsset, !urlAsset.url.isFileURL {
87+
cellularTracker = StreamingCellularTracker()
88+
cellularTracker?.startTracking(
89+
playerItem: playerItem,
90+
episodeUuid: episode.uuid,
91+
podcastUuid: episode.parentIdentifier()
92+
)
93+
}
94+
#endif
95+
7796
configurePlayer(videoPodcast: episode.videoPodcast())
7897
}
7998

@@ -182,6 +201,11 @@ class DefaultPlayer: PlaybackProtocol, Hashable {
182201
}
183202
cleanupPlayer()
184203

204+
#if !os(watchOS) && !APPCLIP
205+
cellularTracker?.stopTracking()
206+
cellularTracker = nil
207+
#endif
208+
185209
audioMix = nil
186210
assetTrack = nil
187211
player = nil

podcasts/MediaExporterResourceLoaderDelegate.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22
import AVFoundation
33
import UIKit
4+
import PocketCastsDataModel
45
import PocketCastsServer
56
import PocketCastsUtils
67

@@ -51,6 +52,11 @@ class MediaExporterResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelega
5152

5253
private let saveFilePath: String
5354
private let callback: FileExporterProgressReport?
55+
56+
// Episode context for cellular tracking
57+
private let episodeUuid: String?
58+
private let podcastUuid: String?
59+
5460
private lazy var callbackQueue: DispatchQueue = {
5561
let queue: DispatchQueue
5662
if FeatureFlag.useBackgroundQueueForStreamingCallback.enabled {
@@ -70,8 +76,10 @@ class MediaExporterResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelega
7076
typealias FileExporterProgressReport = (_ status: FileExportStatus, _ contentType: String?, _ downloaded: Int64, _ total: Int64) -> ()
7177

7278
// MARK: Init
73-
init(saveFilePath: String, callback: FileExporterProgressReport?) {
79+
init(saveFilePath: String, episodeUuid: String? = nil, podcastUuid: String? = nil, callback: FileExporterProgressReport?) {
7480
self.saveFilePath = saveFilePath
81+
self.episodeUuid = episodeUuid
82+
self.podcastUuid = podcastUuid
7583
self.callback = callback
7684
self.fileHandle = MediaFileHandle(filePath: saveFilePath)
7785
super.init()
@@ -172,6 +180,24 @@ class MediaExporterResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelega
172180
downloadComplete()
173181
}
174182

183+
func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
184+
let bytesReceived = task.countOfBytesReceived
185+
let isCellular = metrics.transactionMetrics.last?.isCellular ?? false
186+
187+
guard bytesReceived > 0 else { return }
188+
189+
let connectionType: CellularDataUsageManager.ConnectionType = isCellular ? .cellular : .wifi
190+
191+
DataManager.sharedManager.cellularDataUsageManager.add(
192+
episodeUuid: episodeUuid,
193+
podcastUuid: podcastUuid,
194+
bytesStreamed: bytesReceived,
195+
operationType: .stream,
196+
connectionType: connectionType,
197+
sessionType: .foreground
198+
)
199+
}
200+
175201
// MARK: Internal methods
176202

177203
func startDataRequest(with url: URL) {
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import AVFoundation
2+
import Foundation
3+
import Network
4+
import PocketCastsDataModel
5+
import PocketCastsUtils
6+
7+
/// Tracks network data usage for AVPlayer streaming by monitoring network path changes
8+
/// and querying AVPlayerItem access logs for bytes transferred.
9+
///
10+
/// This is used for direct AVPlayer streaming (when not using MediaExporterResourceLoaderDelegate).
11+
/// For cached streaming, network tracking is handled by MediaExporterResourceLoaderDelegate's
12+
/// URLSessionTaskMetrics.
13+
#if !os(watchOS)
14+
class StreamingCellularTracker {
15+
private let monitor = NWPathMonitor()
16+
private let monitorQueue = DispatchQueue(label: "com.pocketcasts.StreamingCellularTracker")
17+
18+
private weak var playerItem: AVPlayerItem?
19+
private var episodeUuid: String?
20+
private var podcastUuid: String?
21+
22+
private var currentConnectionType: CellularDataUsageManager.ConnectionType?
23+
private var bytesWhenConnectionStarted: Int64 = 0
24+
private var lastReportedBytes: Int64 = 0
25+
26+
private var accessLogObserver: NSObjectProtocol?
27+
28+
init() {}
29+
30+
deinit {
31+
stopTracking()
32+
}
33+
34+
/// Start tracking network usage for a player item
35+
func startTracking(playerItem: AVPlayerItem, episodeUuid: String?, podcastUuid: String?) {
36+
stopTracking()
37+
38+
self.playerItem = playerItem
39+
self.episodeUuid = episodeUuid
40+
self.podcastUuid = podcastUuid
41+
self.lastReportedBytes = 0
42+
self.bytesWhenConnectionStarted = 0
43+
self.currentConnectionType = nil
44+
45+
monitor.pathUpdateHandler = { [weak self] path in
46+
self?.handlePathUpdate(path)
47+
}
48+
monitor.start(queue: monitorQueue)
49+
50+
// Observe access log changes to track bytes as they're downloaded
51+
accessLogObserver = NotificationCenter.default.addObserver(
52+
forName: .AVPlayerItemNewAccessLogEntry,
53+
object: playerItem,
54+
queue: nil
55+
) { [weak self] _ in
56+
self?.checkAndReportConnectionUsage()
57+
}
58+
}
59+
60+
/// Stop tracking and report final network usage
61+
func stopTracking() {
62+
reportCurrentConnectionUsageIfNeeded()
63+
64+
monitor.cancel()
65+
66+
if let observer = accessLogObserver {
67+
NotificationCenter.default.removeObserver(observer)
68+
accessLogObserver = nil
69+
}
70+
71+
playerItem = nil
72+
episodeUuid = nil
73+
podcastUuid = nil
74+
currentConnectionType = nil
75+
bytesWhenConnectionStarted = 0
76+
lastReportedBytes = 0
77+
}
78+
79+
// MARK: - Private
80+
81+
private func handlePathUpdate(_ path: NWPath) {
82+
let previousConnectionType = currentConnectionType
83+
let nextConnectionType: CellularDataUsageManager.ConnectionType? = {
84+
if path.usesInterfaceType(.cellular) {
85+
return .cellular
86+
}
87+
88+
if path.usesInterfaceType(.wifi) {
89+
return .wifi
90+
}
91+
92+
return nil
93+
}()
94+
95+
guard previousConnectionType != nextConnectionType else {
96+
return
97+
}
98+
99+
reportCurrentConnectionUsageIfNeeded()
100+
101+
currentConnectionType = nextConnectionType
102+
bytesWhenConnectionStarted = currentBytesTransferred()
103+
lastReportedBytes = 0
104+
105+
let connectionLabel = nextConnectionType?.displayName ?? "none"
106+
FileLog.shared.addMessage("StreamingCellularTracker: Switched connection type to \(connectionLabel), starting bytes: \(bytesWhenConnectionStarted)")
107+
}
108+
109+
private func checkAndReportConnectionUsage() {
110+
guard currentConnectionType != nil else { return }
111+
112+
let currentBytes = currentBytesTransferred()
113+
let bytesOnConnection = currentBytes - bytesWhenConnectionStarted
114+
115+
let bytesThreshold: Int64 = 100 * 1024
116+
if bytesOnConnection - lastReportedBytes >= bytesThreshold {
117+
let bytesToReport = bytesOnConnection - lastReportedBytes
118+
reportConnectionBytes(bytesToReport)
119+
lastReportedBytes = bytesOnConnection
120+
}
121+
}
122+
123+
private func reportCurrentConnectionUsageIfNeeded() {
124+
guard currentConnectionType != nil else { return }
125+
126+
let currentBytes = currentBytesTransferred()
127+
let bytesOnConnection = currentBytes - bytesWhenConnectionStarted
128+
let unreportedBytes = bytesOnConnection - lastReportedBytes
129+
130+
if unreportedBytes > 0 {
131+
reportConnectionBytes(unreportedBytes)
132+
lastReportedBytes = bytesOnConnection
133+
}
134+
}
135+
136+
private func reportConnectionBytes(_ bytes: Int64) {
137+
guard bytes > 0, let connectionType = currentConnectionType else { return }
138+
139+
FileLog.shared.addMessage("StreamingCellularTracker: Reporting \(bytes) bytes of \(connectionType.displayName) streaming for episode: \(episodeUuid ?? "unknown")")
140+
141+
DataManager.sharedManager.cellularDataUsageManager.add(
142+
episodeUuid: episodeUuid,
143+
podcastUuid: podcastUuid,
144+
bytesStreamed: bytes,
145+
operationType: .stream,
146+
connectionType: connectionType,
147+
sessionType: .foreground
148+
)
149+
}
150+
151+
private func currentBytesTransferred() -> Int64 {
152+
guard let accessLog = playerItem?.accessLog(),
153+
let lastEvent = accessLog.events.last else {
154+
return 0
155+
}
156+
return lastEvent.numberOfBytesTransferred
157+
}
158+
}
159+
160+
private extension CellularDataUsageManager.ConnectionType {
161+
var displayName: String {
162+
switch self {
163+
case .unknown:
164+
return "unknown"
165+
case .wifi:
166+
return "wifi"
167+
case .cellular:
168+
return "cellular"
169+
}
170+
}
171+
}
172+
#endif

0 commit comments

Comments
 (0)