diff --git a/mlbTVOS/ViewModels/GamesViewModel.swift b/mlbTVOS/ViewModels/GamesViewModel.swift index b41062a..c193cb8 100644 --- a/mlbTVOS/ViewModels/GamesViewModel.swift +++ b/mlbTVOS/ViewModels/GamesViewModel.swift @@ -54,6 +54,10 @@ final class GamesViewModel { private var refreshTask: Task? @ObservationIgnored private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:] + @ObservationIgnored + private var videoShuffleBags: [String: [URL]] = [:] + @ObservationIgnored + private var cachedStandings: (date: String, standings: [Int: TeamStanding])? // Computed properties for dashboard var liveGames: [Game] { games.filter(\.isLive) } @@ -243,11 +247,19 @@ final class GamesViewModel { } private func fetchStandings() async -> [Int: TeamStanding] { - let year = String(todayDateString.prefix(4)) + let today = todayDateString + if let cached = cachedStandings, cached.date == today { + logGamesViewModel("fetchStandings cache hit date=\(today)") + return cached.standings + } + let year = String(today.prefix(4)) do { - return try await statsAPI.fetchStandings(season: year) + let standings = try await statsAPI.fetchStandings(season: year) + cachedStandings = (date: today, standings: standings) + logGamesViewModel("fetchStandings fetched date=\(today) teams=\(standings.count)") + return standings } catch { - return [:] + return cachedStandings?.standings ?? [:] } } @@ -442,18 +454,26 @@ final class GamesViewModel { func resolveNextAuthenticatedFeedURLForActiveStream( id: String, feedURL: URL, - headers: [String: String] = [:] + headers: [String: String] = [:], + maxRetries: Int = 3 ) async -> URL? { let currentURL = activeStreams.first(where: { $0.id == id })?.overrideURL - guard let nextURL = await resolveAuthenticatedVideoFeedURL( - feedURL: feedURL, - headers: headers, - excluding: currentURL - ) else { - return nil + for attempt in 1...maxRetries { + if let nextURL = await resolveAuthenticatedVideoFeedURL( + feedURL: feedURL, + headers: headers, + excluding: currentURL + ) { + updateStreamOverrideSource(id: id, url: nextURL, headers: headers) + return nextURL + } + logGamesViewModel("resolveNextAuthenticatedFeedURL retry attempt=\(attempt)/\(maxRetries) id=\(id)") + if attempt < maxRetries { + try? await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000) + } } - updateStreamOverrideSource(id: id, url: nextURL, headers: headers) - return nextURL + logGamesViewModel("resolveNextAuthenticatedFeedURL exhausted retries id=\(id)") + return nil } func removeStream(id: String) { @@ -597,21 +617,25 @@ final class GamesViewModel { return nil } - let selectableURLs: [URL] - if let excludedURL, urls.count > 1 { - let filteredURLs = urls.filter { $0 != excludedURL } - selectableURLs = filteredURLs.isEmpty ? urls : filteredURLs - } else { - selectableURLs = urls + let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers) + var bag = videoShuffleBags[cacheKey] ?? [] + + // Refill the bag when empty (or first time), shuffled + if bag.isEmpty { + bag = urls.shuffled() + logGamesViewModel("resolveAuthenticatedVideoFeedURL reshuffled bag count=\(bag.count)") } - guard let selectedURL = selectableURLs.randomElement() else { - logGamesViewModel("resolveAuthenticatedVideoFeedURL failed reason=no-selectable-urls") - return nil + // If the next video is the one we just played, push it to the back + if let excludedURL, bag.count > 1, bag.first == excludedURL { + bag.append(bag.removeFirst()) } + let selectedURL = bag.removeFirst() + videoShuffleBags[cacheKey] = bag + logGamesViewModel( - "resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")" + "resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) remaining=\(bag.count) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")" ) return selectedURL } @@ -622,7 +646,6 @@ final class GamesViewModel { ) async -> [URL]? { let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers) if let cachedEntry = authenticatedVideoFeedCache[cacheKey], - Date().timeIntervalSince(cachedEntry.loadedAt) < 300, !cachedEntry.urls.isEmpty { logGamesViewModel( "fetchAuthenticatedVideoFeedURLs cache hit feedURL=\(gamesViewModelDebugURLDescription(feedURL)) count=\(cachedEntry.urls.count)" diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index faa8ba0..69eee6b 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -150,6 +150,7 @@ struct DashboardView: View { .onAppear { logDashboard("DashboardView appeared") viewModel.startAutoRefresh() + Task { await viewModel.loadGames() } } .onDisappear { logDashboard("DashboardView disappeared") @@ -265,11 +266,20 @@ struct DashboardView: View { guard selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil } - guard let nextURL = await viewModel.resolveAuthenticatedVideoFeedURL( - feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL, - headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders, - excluding: currentURL - ) else { + var nextURL: URL? + for attempt in 1...3 { + nextURL = await viewModel.resolveAuthenticatedVideoFeedURL( + feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL, + headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders, + excluding: currentURL + ) + if nextURL != nil { break } + logDashboard("resolveNextFullScreenSource retry attempt=\(attempt)/3") + if attempt < 3 { + try? await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000) + } + } + guard let nextURL else { return nil } return SingleStreamPlaybackSource( diff --git a/mlbTVOS/Views/MultiStreamView.swift b/mlbTVOS/Views/MultiStreamView.swift index b6d2115..a010978 100644 --- a/mlbTVOS/Views/MultiStreamView.swift +++ b/mlbTVOS/Views/MultiStreamView.swift @@ -770,9 +770,10 @@ private struct MultiStreamTile: View { guard let nextURL = await viewModel.resolveNextAuthenticatedFeedURLForActiveStream( id: stream.id, feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL, - headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders + headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders, + maxRetries: 3 ) else { - logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil") + logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil-after-retries") return }