Commit 8aea34a6 authored by Kourser's avatar Kourser
Browse files

feat(radio): live radio stations, kept out of gpodder sync

A station is added from its direct stream URL and stored as a flagged
subscription (podcast.isRadio, migration v10) with a single
pseudo-episode pointing at the stream. Playback goes through the
existing AVPlayer path; the player shows an 'En direct' indicator
instead of the scrubber when the duration is unknown, and stations
never get a resume position or played state.

Radios are local-only by design: a stream URL is not an RSS document,
so they are excluded from feed refresh, from gpodder subscription and
episode-action sync (other clients would fail to parse them), and from
OPML export.

Co-Authored-By: Claude (RCA)
parent 27813203
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -25,6 +25,11 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
- **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.
- **Radios (flux en direct)** : ajout d'une station par l'URL directe de son
  flux audio (MP3, AAC, HLS…), lecture avec indicateur « En direct » (sans
  barre de progression ni reprise de position). Les radios restent locales :
  jamais rafraîchies comme des flux RSS, **exclues de la synchro gpodder**
  (qui n'échange que des flux RSS) et de l'export OPML.

### Corrigé
- L'égaliseur (et le saut de silence) activé **pendant** la lecture d'un épisode
+7 −1
Original line number Diff line number Diff line
@@ -22,6 +22,10 @@ public struct Podcast: Identifiable, Hashable, Sendable, Codable {
    public var categories: [String]
    /// `<lastBuildDate>` if present.
    public var lastBuildDate: Date?
    /// A live radio station added by stream URL: ``feedURL`` is the stream
    /// itself, not an RSS document. Stations are never refreshed, synced
    /// (gpodder exchanges RSS URLs only) or exported to OPML.
    public var isRadio: Bool

    public init(
        id: UUID = UUID(),
@@ -34,7 +38,8 @@ public struct Podcast: Identifiable, Hashable, Sendable, Codable {
        language: String? = nil,
        isExplicit: Bool = false,
        categories: [String] = [],
        lastBuildDate: Date? = nil
        lastBuildDate: Date? = nil,
        isRadio: Bool = false
    ) {
        self.id = id
        self.feedURL = feedURL
@@ -47,5 +52,6 @@ public struct Podcast: Identifiable, Hashable, Sendable, Codable {
        self.isExplicit = isExplicit
        self.categories = categories
        self.lastBuildDate = lastBuildDate
        self.isRadio = isRadio
    }
}
+5 −0
Original line number Diff line number Diff line
@@ -131,6 +131,11 @@ public final class LibraryStore: Sendable {
                t.add(column: "volume", .double).notNull().defaults(to: 1)
            }
        }
        migrator.registerMigration("v10") { db in
            try db.alter(table: "podcast") { t in
                t.add(column: "isRadio", .boolean).notNull().defaults(to: false)
            }
        }
        return migrator
    }()

+4 −1
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ struct PodcastRecord: Codable, FetchableRecord, PersistableRecord {
    var categoriesJSON: String
    var lastBuildDate: Double?
    var dateAdded: Double
    var isRadio: Bool
}

/// SQLite row for an episode.
@@ -61,6 +62,7 @@ extension PodcastRecord {
        self.categoriesJSON = CategoryCoding.encode(p.categories)
        self.lastBuildDate = p.lastBuildDate?.timeIntervalSince1970
        self.dateAdded = dateAdded.timeIntervalSince1970
        self.isRadio = p.isRadio
    }

    func toModel() -> Podcast {
@@ -75,7 +77,8 @@ extension PodcastRecord {
            language: language,
            isExplicit: isExplicit,
            categories: CategoryCoding.decode(categoriesJSON),
            lastBuildDate: lastBuildDate.map { Date(timeIntervalSince1970: $0) }
            lastBuildDate: lastBuildDate.map { Date(timeIntervalSince1970: $0) },
            isRadio: isRadio
        )
    }
}
+16 −0
Original line number Diff line number Diff line
@@ -36,6 +36,22 @@ private func episode(guid: String, title: String, date: TimeInterval) -> Episode
        #expect(p.author == "Jane Host")
        #expect(p.categories == ["Technology", "News"])
        #expect(p.feedURL == feedURL)
        #expect(!p.isRadio)
    }

    @Test func savesRadioStationWithStreamEpisode() async throws {
        let store = try LibraryStore.inMemory()
        let streamURL = URL(string: "https://stream.example.com/radio.mp3")!
        let station = Podcast(feedURL: streamURL, title: "FIP", isRadio: true)
        let stream = Episode(guid: streamURL.absoluteString, title: "FIP", enclosureURL: streamURL)

        let stored = try await store.save(podcast: station, episodes: [stream])

        let p = try #require(try await store.allPodcasts().first)
        #expect(p.isRadio)
        let episodes = try await store.episodes(forPodcastID: stored.id)
        #expect(episodes.first?.enclosureURL == streamURL)
        #expect(episodes.first?.duration == nil)
    }

    @Test func storesEpisodesNewestFirst() async throws {
Loading