Commit 22712996 authored by Kourser's avatar Kourser
Browse files

feat: silence notifications per podcast

New episode notifications were all-or-nothing across the library. A
subscription can now be muted from its own settings, which is what a
reader asked for after subscribing to one very prolific show.

The flag defaults to true, so nothing changes for existing libraries and
an all-default PodcastPreferences stays "unset" — setPreferences drops
the row in that case, and silencing a show is what actually writes one.
Migration v11 backfills existing rows the same way.

Muting only removes the podcast from the notification: its feed is still
refreshed and its episodes still auto-download, since those are separate
choices the user made elsewhere.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent afc94e95
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -13,6 +13,10 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
  jusqu'ici qu'un filtre à l'intérieur de la fiche d'un podcast.
- **Historique sur l'accueil** : il quitte Réglages › Bibliothèque pour rejoindre
  la barre latérale, à un geste de la reprise d'un épisode en cours.
- **Notifications par podcast** : chaque abonnement peut être mis en sourdine
  depuis ses réglages, sans couper les notifications des autres. Un podcast
  silencieux continue d'être rafraîchi et téléchargé automatiquement ; seuls ses
  nouveaux épisodes ne déclenchent plus de notification.

### Corrigé
- **iOS — affiche manquante dans le lecteur** : la plupart des flux ne publient
+7 −1
Original line number Diff line number Diff line
@@ -17,6 +17,10 @@ public struct PodcastPreferences: Hashable, Sendable, Codable {
    public var skipSilence: Bool
    /// Volume factor (0...1) multiplied into the app volume; `1` = unchanged.
    public var volume: Float
    /// Whether a background refresh may notify about this podcast's new
    /// episodes. Defaults to `true`, so an all-default value stays "unset" and
    /// silencing one show is what actually writes a row.
    public var notifyNewEpisodes: Bool

    public init(
        playbackRate: Float? = nil,
@@ -25,7 +29,8 @@ public struct PodcastPreferences: Hashable, Sendable, Codable {
        autoDownload: Bool = false,
        episodeCacheLimit: Int = 0,
        skipSilence: Bool = false,
        volume: Float = 1
        volume: Float = 1,
        notifyNewEpisodes: Bool = true
    ) {
        self.playbackRate = playbackRate
        self.skipIntro = skipIntro
@@ -34,6 +39,7 @@ public struct PodcastPreferences: Hashable, Sendable, Codable {
        self.episodeCacheLimit = episodeCacheLimit
        self.skipSilence = skipSilence
        self.volume = volume
        self.notifyNewEpisodes = notifyNewEpisodes
    }

    /// All-default preferences, i.e. "no per-podcast overrides".
+7 −0
Original line number Diff line number Diff line
@@ -136,6 +136,13 @@ public final class LibraryStore: Sendable {
                t.add(column: "isRadio", .boolean).notNull().defaults(to: false)
            }
        }
        migrator.registerMigration("v11") { db in
            // Defaults to true: existing subscriptions keep notifying, and only
            // silencing one is a deliberate change.
            try db.alter(table: "podcast_pref") { t in
                t.add(column: "notifyNewEpisodes", .boolean).notNull().defaults(to: true)
            }
        }
        return migrator
    }()

+4 −1
Original line number Diff line number Diff line
@@ -175,6 +175,7 @@ struct PodcastPrefRecord: Codable, FetchableRecord, PersistableRecord {
    var episodeCacheLimit: Int
    var skipSilence: Bool
    var volume: Double
    var notifyNewEpisodes: Bool
}

extension PodcastPrefRecord {
@@ -187,6 +188,7 @@ extension PodcastPrefRecord {
        self.episodeCacheLimit = p.episodeCacheLimit
        self.skipSilence = p.skipSilence
        self.volume = Double(p.volume)
        self.notifyNewEpisodes = p.notifyNewEpisodes
    }

    func toModel() -> PodcastPreferences {
@@ -197,7 +199,8 @@ extension PodcastPrefRecord {
            autoDownload: autoDownload,
            episodeCacheLimit: episodeCacheLimit,
            skipSilence: skipSilence,
            volume: Float(volume)
            volume: Float(volume),
            notifyNewEpisodes: notifyNewEpisodes
        )
    }
}
+22 −0
Original line number Diff line number Diff line
@@ -95,4 +95,26 @@ import PodcastModel
        let again = try await store.save(podcast: makePodcast(), episodes: [])
        #expect(try await store.preferences(forPodcastID: again.id) == .none)
    }

    @Test func notificationsAreOnByDefaultAndPersistWhenSilenced() async throws {
        let store = try LibraryStore.inMemory()
        let stored = try await store.save(
            podcast: Podcast(feedURL: URL(string: "https://e.test/f.xml")!, title: "C"),
            episodes: []
        )

        // Un abonnement neuf notifie : c'est le comportement d'avant l'option.
        #expect(try await store.preferences(forPodcastID: stored.id).notifyNewEpisodes)
        #expect(PodcastPreferences.none.notifyNewEpisodes)

        var prefs = PodcastPreferences.none
        prefs.notifyNewEpisodes = false
        try await store.setPreferences(prefs, forPodcastID: stored.id)
        #expect(try await store.preferences(forPodcastID: stored.id).notifyNewEpisodes == false)

        // Réactiver ramène à l'état par défaut, donc la ligne disparaît.
        prefs.notifyNewEpisodes = true
        try await store.setPreferences(prefs, forPodcastID: stored.id)
        #expect(try await store.preferences(forPodcastID: stored.id).notifyNewEpisodes)
    }
}
Loading