Commit 27813203 authored by Kourser's avatar Kourser
Browse files

feat(feeds): private-feed authentication and per-podcast volume

Private feeds (paid memberships): optional HTTP Basic credentials per
subscription, entered in the add-by-URL flow or in the podcast settings.
Stored in the Keychain only (never in the database or logs) and applied
to all three network paths: feed refresh (FeedFetcher gains a
credentials parameter), episode downloads (Authorization header via a
provider closure on DownloadController), and streamed playback
(AVURLAsset header options behind a new setStreamingHeaders engine
hook). Credentials are removed on unsubscribe.

Per-podcast volume: a 20-100% factor in the podcast settings (schema
migration v9), multiplied into the app volume by the playback
controller, to tame podcasts mixed louder than the rest.

Co-Authored-By: Claude (RCA)
parent c6734dbc
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -18,6 +18,13 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
- **Minuteur « Fin de l'épisode »** : en plus des durées fixes, le minuteur
  peut s'arrêter à la fin de l'épisode en cours (avec le même fondu) —
  l'épisode est marqué lu mais la file n'enchaîne pas.
- **Flux privés** (abonnements payants) : identifiant + mot de passe par
  podcast (HTTP Basic), saisis à l'ajout ou dans les réglages du podcast,
  conservés dans le trousseau et utilisés au rafraîchissement, au
  téléchargement et en lecture streaming.
- **Volume par podcast** : facteur 20–100 % dans les réglages du podcast,
  multiplié par le volume du lecteur — pour atténuer un podcast mixé plus
  fort que les autres.

### Corrigé
- L'égaliseur (et le saut de silence) activé **pendant** la lecture d'un épisode
+24 −3
Original line number Diff line number Diff line
import Foundation
import PodcastModel

/// Abstracts the network so feed fetching can be tested without real requests.
public protocol FeedDataLoading: Sendable {
    func loadData(from url: URL) async throws -> Data
    /// Authenticated variant: `authorization` is a ready-made HTTP
    /// `Authorization` header value. Loaders that don't support it fall back
    /// to the unauthenticated load (default implementation).
    func loadData(from url: URL, authorization: String?) async throws -> Data
}

public extension FeedDataLoading {
    func loadData(from url: URL, authorization: String?) async throws -> Data {
        try await loadData(from: url)
    }
}

/// Default loader backed by `URLSession`.
@@ -14,7 +25,15 @@ public struct URLSessionFeedLoader: FeedDataLoading {
    }

    public func loadData(from url: URL) async throws -> Data {
        let (data, response) = try await session.data(from: url)
        try await loadData(from: url, authorization: nil)
    }

    public func loadData(from url: URL, authorization: String?) async throws -> Data {
        var request = URLRequest(url: url)
        if let authorization {
            request.setValue(authorization, forHTTPHeaderField: "Authorization")
        }
        let (data, response) = try await session.data(for: request)
        if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
            throw FeedFetchError.httpStatus(http.statusCode)
        }
@@ -34,8 +53,10 @@ public struct FeedFetcher: Sendable {
        self.loader = loader
    }

    public func fetch(url: URL) async throws -> ParsedFeed {
        let data = try await loader.loadData(from: url)
    /// Fetches a feed, authenticating with `credentials` when provided
    /// (private/premium feeds, HTTP Basic).
    public func fetch(url: URL, credentials: FeedCredentials? = nil) async throws -> ParsedFeed {
        let data = try await loader.loadData(from: url, authorization: credentials?.basicAuthorization)
        return try FeedParser.parse(data: data, feedURL: url)
    }
}
+14 −1
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@ public final class AVAudioPlayerEngine: AudioPlayerEngine {
    private var statusObservation: NSKeyValueObservation?
    private var desiredRate: Float = 1.0
    private var reportedDuration: TimeInterval = 0
    private var streamingHeaders: [String: String]?

    public init() {
        player.automaticallyWaitsToMinimizeStalling = true
@@ -36,7 +37,15 @@ public final class AVAudioPlayerEngine: AudioPlayerEngine {
            NotificationCenter.default.removeObserver(failureObserver)
        }
        statusObservation?.invalidate()
        let item = AVPlayerItem(url: url)
        let item: AVPlayerItem
        if let headers = streamingHeaders, !url.isFileURL {
            // Header injection for authenticated feeds. The options key has no
            // public constant but is the long-standing supported spelling.
            let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
            item = AVPlayerItem(asset: asset)
        } else {
            item = AVPlayerItem(url: url)
        }
        reportedDuration = 0
        player.replaceCurrentItem(with: item)
        endObserver = NotificationCenter.default.addObserver(
@@ -100,6 +109,10 @@ public final class AVAudioPlayerEngine: AudioPlayerEngine {
        player.volume = min(1, max(0, volume))
    }

    public func setStreamingHeaders(_ headers: [String: String]?) {
        streamingHeaders = headers
    }

    private func handleTick(_ time: CMTime) {
        let seconds = time.seconds
        if seconds.isFinite {
+5 −0
Original line number Diff line number Diff line
@@ -23,12 +23,17 @@ public protocol AudioPlayerEngine: AnyObject {
    /// top of the system volume. Engines that don't support it ignore this
    /// (default no-op).
    func setVolume(_ volume: Float)
    /// HTTP headers for the next remote ``load(url:startAt:)`` (e.g. an
    /// `Authorization` header for private feeds). Ignored for local files;
    /// engines that don't stream ignore this (default no-op).
    func setStreamingHeaders(_ headers: [String: String]?)
}

public extension AudioPlayerEngine {
    func setSkipSilence(_ enabled: Bool) {}
    func setEqualizer(_ gains: [Float]?) {}
    func setVolume(_ volume: Float) {}
    func setStreamingHeaders(_ headers: [String: String]?) {}
}

@MainActor
+23 −8
Original line number Diff line number Diff line
@@ -38,6 +38,8 @@ public final class PlaybackController {
    @ObservationIgnored private var sleepTask: Task<Void, Never>?
    /// App-level volume (0...1) the fade multiplies into; restored afterwards.
    @ObservationIgnored private var baseVolume: Float = 1
    /// Per-item volume factor (0...1), e.g. a per-podcast adjustment.
    @ObservationIgnored private var itemVolume: Float = 1
    @ObservationIgnored public weak var observer: PlaybackObserver?
    /// Called when the current item plays to its end (used for queue advance).
    @ObservationIgnored public var onFinish: (@MainActor () -> Void)?
@@ -64,6 +66,9 @@ public final class PlaybackController {
    ///   - rate: a dedicated rate to switch to (e.g. a per-podcast speed), or
    ///     `nil` to keep the current rate.
    ///   - skipEnd: seconds to cut from the end before auto-advancing; `0` off.
    ///   - volume: per-item volume factor (e.g. a per-podcast adjustment),
    ///     multiplied into the app volume.
    ///   - streamingHeaders: HTTP headers for remote playback (private feeds).
    public func play(
        episode: Episode,
        url: URL? = nil,
@@ -71,7 +76,9 @@ public final class PlaybackController {
        rate: Float? = nil,
        skipEnd: TimeInterval = 0,
        skipSilence: Bool = false,
        equalizer: [Float]? = nil
        equalizer: [Float]? = nil,
        volume: Float = 1,
        streamingHeaders: [String: String]? = nil
    ) {
        guard let source = url ?? episode.enclosureURL else { return }
        if let rate { playbackRate = rate }
@@ -80,8 +87,11 @@ public final class PlaybackController {
        currentEpisode = episode
        currentTime = startAt
        duration = episode.duration ?? 0
        itemVolume = volume
        engine.setSkipSilence(skipSilence)
        engine.setEqualizer(equalizer)
        engine.setStreamingHeaders(streamingHeaders)
        applyVolume()
        engine.load(url: source, startAt: startAt)
        engine.setRate(playbackRate)
        engine.play()
@@ -148,7 +158,7 @@ public final class PlaybackController {
        let wasFading = sleepFadeFactor < 1
        sleepRemaining = nil
        sleepAtEpisodeEnd = false
        if wasFading { engine.setVolume(baseVolume) }
        if wasFading { applyVolume() }
    }

    /// Internal for tests (production ticking is driven by ``startSleepTimer``).
@@ -158,11 +168,10 @@ public final class PlaybackController {
            sleepRemaining = nil
            sleepTask = nil
            pause()
            engine.setVolume(baseVolume) // undo the fade for the next playback
            applyVolume() // undo the fade for the next playback
        } else {
            sleepRemaining = remaining - 1
            let fade = sleepFadeFactor
            if fade < 1 { engine.setVolume(baseVolume * fade) }
            if sleepFadeFactor < 1 { applyVolume() }
        }
    }

@@ -218,7 +227,13 @@ public final class PlaybackController {
    /// system volume.
    public func setVolume(_ volume: Float) {
        baseVolume = volume
        engine.setVolume(volume * sleepFadeFactor)
        applyVolume()
    }

    /// Pushes the effective volume (app volume × per-item factor × sleep fade)
    /// to the engine.
    private func applyVolume() {
        engine.setVolume(baseVolume * itemVolume * sleepFadeFactor)
    }

    private func clamp(_ time: TimeInterval) -> TimeInterval {
@@ -238,7 +253,7 @@ extension PlaybackController: AudioPlayerEngineDelegate {
        if time.isFinite { currentTime = time }
        // End-of-episode sleep: re-derive the fade from the position on every
        // tick (also restores full volume after a seek back out of the window).
        if sleepAtEpisodeEnd { engine.setVolume(baseVolume * sleepFadeFactor) }
        if sleepAtEpisodeEnd { applyVolume() }
        // Per-podcast "skip end": once close enough to the end, treat the item
        // as finished so the queue advances early. Fires at most once per item.
        if skipEndSeconds > 0, !didCutEnd, duration > skipEndSeconds,
@@ -261,7 +276,7 @@ extension PlaybackController: AudioPlayerEngineDelegate {
        let sleptAtEnd = sleepAtEpisodeEnd
        if sleptAtEnd {
            sleepAtEpisodeEnd = false
            engine.setVolume(baseVolume) // undo the fade for the next playback
            applyVolume() // undo the fade for the next playback
        }
        notify()
        if sleptAtEnd { onSleepAtEpisodeEnd?() } else { onFinish?() }
Loading