mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
Yattee v2 rewrite
This commit is contained in:
238
Yattee/Services/DeArrowBrandingProvider.swift
Normal file
238
Yattee/Services/DeArrowBrandingProvider.swift
Normal file
@@ -0,0 +1,238 @@
|
||||
//
|
||||
// DeArrowBrandingProvider.swift
|
||||
// Yattee
|
||||
//
|
||||
// Observable provider for DeArrow branding data with batch prefetching.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Provides observable access to DeArrow branding data with performance optimizations.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DeArrowBrandingProvider {
|
||||
// MARK: - Observable State
|
||||
|
||||
/// Cached titles by video ID.
|
||||
private(set) var titles: [String: String] = [:]
|
||||
|
||||
/// Cached thumbnail URLs by video ID.
|
||||
private(set) var thumbnailURLs: [String: URL] = [:]
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let api: DeArrowAPI
|
||||
private weak var settingsManager: SettingsManager?
|
||||
|
||||
// MARK: - Request Tracking
|
||||
|
||||
/// Video IDs currently being fetched.
|
||||
private var inFlightRequests: Set<String> = []
|
||||
|
||||
/// Video IDs that have been processed (success or failure).
|
||||
private var processedIDs: Set<String> = []
|
||||
|
||||
/// Prefetch task for batch operations.
|
||||
private var prefetchTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Maximum concurrent requests during batch prefetch.
|
||||
private let maxConcurrentRequests = 5
|
||||
|
||||
/// Delay between batches in milliseconds.
|
||||
private let batchDelayMs: UInt64 = 100
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(api: DeArrowAPI) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
func setSettingsManager(_ manager: SettingsManager) {
|
||||
self.settingsManager = manager
|
||||
// Sync initial API URLs from settings
|
||||
Task {
|
||||
await syncAPIURLs()
|
||||
}
|
||||
}
|
||||
|
||||
/// Syncs API URLs from settings to the DeArrow API.
|
||||
/// Call this when settings change.
|
||||
func syncAPIURLs() async {
|
||||
guard let settings = settingsManager else { return }
|
||||
|
||||
if let apiURL = URL(string: settings.deArrowAPIURL) {
|
||||
await api.setBaseURL(apiURL)
|
||||
}
|
||||
|
||||
if let thumbnailURL = URL(string: settings.deArrowThumbnailAPIURL) {
|
||||
await api.setThumbnailBaseURL(thumbnailURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Returns the DeArrow title for a video, if available and enabled.
|
||||
func title(for video: Video) -> String? {
|
||||
guard settingsManager?.deArrowEnabled == true,
|
||||
settingsManager?.deArrowReplaceTitles == true,
|
||||
case .global = video.id.source else {
|
||||
return nil
|
||||
}
|
||||
return titles[video.id.videoID]
|
||||
}
|
||||
|
||||
/// Returns the DeArrow thumbnail URL for a video, if available and enabled.
|
||||
func thumbnailURL(for video: Video) -> URL? {
|
||||
guard settingsManager?.deArrowEnabled == true,
|
||||
settingsManager?.deArrowReplaceThumbnails == true,
|
||||
case .global = video.id.source else {
|
||||
return nil
|
||||
}
|
||||
return thumbnailURLs[video.id.videoID]
|
||||
}
|
||||
|
||||
/// Fetches branding for a single video if not already processed.
|
||||
/// Only fetches for YouTube videos (global source).
|
||||
func fetchIfNeeded(for video: Video) {
|
||||
guard settingsManager?.deArrowEnabled == true,
|
||||
case .global = video.id.source else { return }
|
||||
let videoID = video.id.videoID
|
||||
guard !processedIDs.contains(videoID) && !inFlightRequests.contains(videoID) else { return }
|
||||
|
||||
inFlightRequests.insert(videoID)
|
||||
|
||||
Task(priority: .low) {
|
||||
await fetchBranding(for: videoID)
|
||||
inFlightRequests.remove(videoID)
|
||||
processedIDs.insert(videoID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefetches branding for multiple videos with throttling.
|
||||
/// - Parameter videoIDs: Array of YouTube video IDs to prefetch.
|
||||
func prefetch(videoIDs: [String]) {
|
||||
guard settingsManager?.deArrowEnabled == true else { return }
|
||||
|
||||
// Filter out already processed or in-flight IDs
|
||||
let idsToFetch = videoIDs.filter { id in
|
||||
!processedIDs.contains(id) && !inFlightRequests.contains(id)
|
||||
}
|
||||
|
||||
guard !idsToFetch.isEmpty else { return }
|
||||
|
||||
// Cancel any existing prefetch task
|
||||
prefetchTask?.cancel()
|
||||
|
||||
prefetchTask = Task(priority: .background) {
|
||||
await batchFetch(videoIDs: idsToFetch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all cached data and resets state.
|
||||
func clearCache() async {
|
||||
prefetchTask?.cancel()
|
||||
prefetchTask = nil
|
||||
|
||||
titles.removeAll()
|
||||
thumbnailURLs.removeAll()
|
||||
inFlightRequests.removeAll()
|
||||
processedIDs.removeAll()
|
||||
|
||||
await api.clearCache()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func fetchBranding(for videoID: String) async {
|
||||
// Parallel fetch: request branding and cached thumbnail simultaneously
|
||||
// This speeds up requests per DeArrow API docs recommendation
|
||||
async let brandingTask = api.branding(for: videoID)
|
||||
async let cachedThumbnailTask = api.fetchThumbnail(for: videoID, timestamp: nil)
|
||||
|
||||
do {
|
||||
let branding = try await brandingTask
|
||||
let cachedThumbnail = await cachedThumbnailTask
|
||||
|
||||
guard let branding else { return }
|
||||
|
||||
// Store title if available
|
||||
if let title = branding.bestTitle {
|
||||
titles[videoID] = title
|
||||
}
|
||||
|
||||
// Handle thumbnail with timestamp verification
|
||||
if let expectedTimestamp = branding.bestThumbnailTimestamp {
|
||||
if let serverTimestamp = cachedThumbnail.serverTimestamp,
|
||||
cachedThumbnail.imageData != nil,
|
||||
timestampsMatch(serverTimestamp, expectedTimestamp) {
|
||||
// Cached thumbnail matches expected timestamp - use it
|
||||
thumbnailURLs[videoID] = cachedThumbnail.url
|
||||
LoggingService.shared.logPlayer("DeArrow: using cached thumbnail", details: "Video: \(videoID), timestamp: \(serverTimestamp)")
|
||||
} else {
|
||||
// Need to fetch with correct timestamp
|
||||
let correctThumbnail = await api.fetchThumbnail(for: videoID, timestamp: expectedTimestamp)
|
||||
if correctThumbnail.imageData != nil {
|
||||
thumbnailURLs[videoID] = correctThumbnail.url
|
||||
LoggingService.shared.logPlayer("DeArrow: fetched thumbnail with timestamp", details: "Video: \(videoID), timestamp: \(expectedTimestamp)")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - we don't want DeArrow errors to affect the app
|
||||
LoggingService.shared.logPlayerError("DeArrow fetch failed for \(videoID)", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if two timestamps match within a small tolerance (0.5 seconds).
|
||||
private func timestampsMatch(_ t1: Double, _ t2: Double) -> Bool {
|
||||
abs(t1 - t2) < 0.5
|
||||
}
|
||||
|
||||
private func batchFetch(videoIDs: [String]) async {
|
||||
// Process in batches with concurrency limit
|
||||
let batches = videoIDs.chunked(into: maxConcurrentRequests)
|
||||
|
||||
for batch in batches {
|
||||
// Check for cancellation
|
||||
if Task.isCancelled { break }
|
||||
|
||||
// Mark as in-flight
|
||||
for id in batch {
|
||||
inFlightRequests.insert(id)
|
||||
}
|
||||
|
||||
// Fetch batch concurrently
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for videoID in batch {
|
||||
group.addTask(priority: .low) { [weak self] in
|
||||
await self?.fetchBranding(for: videoID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as processed and remove from in-flight
|
||||
for id in batch {
|
||||
inFlightRequests.remove(id)
|
||||
processedIDs.insert(id)
|
||||
}
|
||||
|
||||
// Throttle between batches
|
||||
if !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: batchDelayMs * 1_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Array Extension
|
||||
|
||||
private extension Array {
|
||||
/// Splits the array into chunks of the specified size.
|
||||
func chunked(into size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map {
|
||||
Array(self[$0..<Swift.min($0 + size, count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user