Commit 91d7fc27 authored by Kourser's avatar Kourser
Browse files

feat(core): per-podcast prefs, auto-download, transcripts, chapters, stats,...


feat(core): per-podcast prefs, auto-download, transcripts, chapters, stats, silence-skip, discovery, backup

Business logic + tests: per-podcast preferences (dedicated speed, skip intro/end, auto-download, episode cache limit, skip silence); save reporting newly added episodes; listening stats + play history queries; transcript parser (SRT/WebVTT/JSON); embedded ID3/MP4 chapter extraction; AVAudioEngine silence-skipping engine + switching engine; fyyd directory, popular feeds, content-based recommendations and language filtering; SQLite backup/restore.

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent f68904ec
Loading
Loading
Loading
Loading
+71 −0
Original line number Diff line number Diff line
import Foundation

/// A podcast directory that can be searched by term and offer suggestions.
/// `language` is an optional ISO code (e.g. "fr", "en") to bias results toward
/// that language; `nil` means no language preference.
public protocol PodcastDirectory: Sendable {
    func search(_ term: String, limit: Int, language: String?) async throws -> [PodcastSearchResult]
    /// Popular/featured podcasts, shown before the user types anything.
    func popular(limit: Int, language: String?) async throws -> [PodcastSearchResult]
}

extension PodcastSearchService: PodcastDirectory {}

/// Searches the fyyd.de podcast directory. Keyless public API.
public struct FyydSearchService: PodcastDirectory {
    private let loader: SearchDataLoading

    public init(loader: SearchDataLoading = URLSessionSearchLoader()) {
        self.loader = loader
    }

    public func search(_ term: String, limit: Int = 25, language: String? = nil) async throws -> [PodcastSearchResult] {
        let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return [] }

        var components = URLComponents(string: "https://api.fyyd.de/0.2/search/podcast")!
        components.queryItems = [
            URLQueryItem(name: "title", value: trimmed),
            URLQueryItem(name: "count", value: String(limit)),
        ]
        if let language { components.queryItems?.append(URLQueryItem(name: "language", value: language)) }
        let data = try await loader.loadData(from: components.url!)
        return Self.decode(data)
    }

    /// Currently popular ("hot") podcasts on fyyd. Same payload shape as search.
    public func popular(limit: Int = 25, language: String? = nil) async throws -> [PodcastSearchResult] {
        var components = URLComponents(string: "https://api.fyyd.de/0.2/feature/podcast/hot")!
        components.queryItems = [URLQueryItem(name: "count", value: String(limit))]
        if let language { components.queryItems?.append(URLQueryItem(name: "language", value: language)) }
        let data = try await loader.loadData(from: components.url!)
        return Self.decode(data)
    }

    /// Parses a fyyd search response, dropping entries without a feed URL.
    static func decode(_ data: Data) -> [PodcastSearchResult] {
        guard let response = try? JSONDecoder().decode(Response.self, from: data) else {
            return []
        }
        return response.data.compactMap { item in
            guard let feed = item.xmlURL, let feedURL = URL(string: feed) else { return nil }
            return PodcastSearchResult(
                title: item.title ?? "",
                author: item.author,
                feedURL: feedURL,
                artworkURL: item.imgURL.flatMap(URL.init(string:))
            )
        }
    }

    private struct Response: Decodable {
        let data: [Item]
    }

    private struct Item: Decodable {
        let title: String?
        let author: String?
        let xmlURL: String?
        let imgURL: String?
    }
}
+25 −0
Original line number Diff line number Diff line
import Foundation

public extension PodcastDirectory {
    /// Content-based recommendations: searches the directory for each seed term
    /// (typically the user's most common subscription categories) and merges
    /// the results, skipping anything in `excluding` (already-subscribed feeds)
    /// and de-duplicating across seeds.
    func recommendations(
        seeds: [String], excluding: Set<URL>, limit: Int = 25, language: String? = nil
    ) async -> [PodcastSearchResult] {
        var seen = excluding
        var results: [PodcastSearchResult] = []
        for seed in seeds {
            let term = seed.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !term.isEmpty else { continue }
            let found = (try? await search(term, limit: limit, language: language)) ?? []
            for result in found where !seen.contains(result.feedURL) {
                seen.insert(result.feedURL)
                results.append(result)
                if results.count >= limit { return results }
            }
        }
        return results
    }
}
+50 −1
Original line number Diff line number Diff line
@@ -37,7 +37,7 @@ public struct PodcastSearchService: Sendable {
        self.loader = loader
    }

    public func search(_ term: String, limit: Int = 25) async throws -> [PodcastSearchResult] {
    public func search(_ term: String, limit: Int = 25, language: String? = nil) async throws -> [PodcastSearchResult] {
        let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return [] }

@@ -47,10 +47,59 @@ public struct PodcastSearchService: Sendable {
            URLQueryItem(name: "term", value: trimmed),
            URLQueryItem(name: "limit", value: String(limit)),
        ]
        if let storefront = Self.storefront(forLanguage: language) {
            components.queryItems?.append(URLQueryItem(name: "country", value: storefront))
        }
        let data = try await loader.loadData(from: components.url!)
        return Self.decode(data)
    }

    /// Top podcasts for the user's storefront. The marketing feed only yields
    /// ids, so we resolve their RSS feeds with a single batched iTunes lookup.
    public func popular(limit: Int = 25, language: String? = nil) async throws -> [PodcastSearchResult] {
        let region = Self.storefront(forLanguage: language)
            ?? Locale.current.region?.identifier.lowercased() ?? "us"
        var ids = await topIDs(region: region, limit: limit)
        if ids.isEmpty, region != "us" { ids = await topIDs(region: "us", limit: limit) }
        guard !ids.isEmpty else { return [] }

        var components = URLComponents(string: "https://itunes.apple.com/lookup")!
        components.queryItems = [
            URLQueryItem(name: "id", value: ids.joined(separator: ",")),
            URLQueryItem(name: "entity", value: "podcast"),
        ]
        let data = try await loader.loadData(from: components.url!)
        return Self.decode(data)
    }

    /// Maps an interface language to an Apple storefront (country). Returns
    /// `nil` when unknown, so callers fall back to the device region.
    static func storefront(forLanguage language: String?) -> String? {
        switch language?.lowercased().prefix(2) {
        case "fr": return "fr"
        case "en": return "us"
        default: return nil
        }
    }

    private func topIDs(region: String, limit: Int) async -> [String] {
        let url = URL(string:
            "https://rss.marketingtools.apple.com/api/v2/\(region)/podcasts/top/\(limit)/podcasts.json")!
        return Self.decodeTopIDs((try? await loader.loadData(from: url)) ?? Data())
    }

    /// Extracts podcast ids from an Apple "top podcasts" marketing feed.
    static func decodeTopIDs(_ data: Data) -> [String] {
        guard let top = try? JSONDecoder().decode(TopFeed.self, from: data) else { return [] }
        return top.feed.results.map(\.id)
    }

    private struct TopFeed: Decodable {
        let feed: Feed
        struct Feed: Decodable { let results: [Item] }
        struct Item: Decodable { let id: String }
    }

    /// Parses an iTunes Search response, dropping entries without a feed URL.
    static func decode(_ data: Data) -> [PodcastSearchResult] {
        guard let response = try? JSONDecoder().decode(Response.self, from: data) else {
+106 −0
Original line number Diff line number Diff line
import Foundation
import PodcastModel

/// Parses an episode transcript. Supports the Podcasting 2.0 JSON format,
/// SubRip (SRT) and WebVTT, falling back to plain text (tags stripped).
public enum TranscriptParser {
    public static func parse(data: Data) -> [TranscriptCue] {
        if let cues = parseJSON(data), !cues.isEmpty { return cues }

        let text = String(decoding: data, as: UTF8.self)
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return [] }

        // SRT and WebVTT both use "start --> end" timing lines.
        if text.contains("-->") { return parseCueBlocks(text) }

        let plain = stripTags(trimmed).trimmingCharacters(in: .whitespacesAndNewlines)
        return plain.isEmpty ? [] : [TranscriptCue(id: 0, start: 0, text: plain)]
    }

    // MARK: Podcasting 2.0 JSON

    private struct JSONDoc: Decodable { let segments: [Segment] }
    private struct Segment: Decodable {
        let startTime: Double?
        let endTime: Double?
        let body: String?
    }

    private static func parseJSON(_ data: Data) -> [TranscriptCue]? {
        guard let doc = try? JSONDecoder().decode(JSONDoc.self, from: data) else { return nil }
        var cues: [TranscriptCue] = []
        for segment in doc.segments {
            let body = (segment.body ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
            guard !body.isEmpty else { continue }
            cues.append(TranscriptCue(
                id: cues.count, start: segment.startTime ?? 0, end: segment.endTime, text: body
            ))
        }
        return cues
    }

    // MARK: SRT / WebVTT

    private static func parseCueBlocks(_ text: String) -> [TranscriptCue] {
        let normalized = text
            .replacingOccurrences(of: "\r\n", with: "\n")
            .replacingOccurrences(of: "\r", with: "\n")
        var cues: [TranscriptCue] = []
        for block in normalized.components(separatedBy: "\n\n") {
            let lines = block.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
            guard let timingIndex = lines.firstIndex(where: { $0.contains("-->") }),
                  let timing = parseTiming(lines[timingIndex]) else { continue }
            let body = lines[(timingIndex + 1)...]
                .map { $0.trimmingCharacters(in: .whitespaces) }
                .filter { !$0.isEmpty }
                .joined(separator: "\n")
            let clean = stripTags(body).trimmingCharacters(in: .whitespacesAndNewlines)
            guard !clean.isEmpty else { continue }
            cues.append(TranscriptCue(id: cues.count, start: timing.start, end: timing.end, text: clean))
        }
        return cues
    }

    private static func parseTiming(_ line: String) -> (start: TimeInterval, end: TimeInterval?)? {
        let parts = line.components(separatedBy: "-->")
        guard parts.count == 2, let start = seconds(parts[0]) else { return nil }
        // The end may be followed by cue settings (e.g. "align:start"); take the first token.
        let endToken = parts[1].trimmingCharacters(in: .whitespaces).split(separator: " ").first.map(String.init)
        return (start, endToken.flatMap(seconds))
    }

    /// Parses `HH:MM:SS,mmm` / `MM:SS.mmm` style timestamps into seconds.
    private static func seconds(_ raw: String) -> TimeInterval? {
        let trimmed = raw.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
        guard !trimmed.isEmpty else { return nil }
        let parts = trimmed.split(separator: ":")
        guard !parts.isEmpty else { return nil }
        var total = 0.0
        for part in parts {
            guard let value = Double(part) else { return nil }
            total = total * 60 + value
        }
        return total
    }

    private static func stripTags(_ s: String) -> String {
        guard let regex = try? NSRegularExpression(pattern: "<[^>]+>") else { return s }
        let range = NSRange(s.startIndex..., in: s)
        return regex.stringByReplacingMatches(in: s, range: range, withTemplate: "")
    }
}

/// Fetches and parses an episode's transcript file.
public struct TranscriptFetcher: Sendable {
    private let loader: FeedDataLoading

    public init(loader: FeedDataLoading = URLSessionFeedLoader()) {
        self.loader = loader
    }

    public func fetch(url: URL) async throws -> [TranscriptCue] {
        let data = try await loader.loadData(from: url)
        return TranscriptParser.parse(data: data)
    }
}
+7 −0
Original line number Diff line number Diff line
@@ -13,6 +13,13 @@ public protocol AudioPlayerEngine: AnyObject {
    func seek(to seconds: TimeInterval)
    /// Sets the playback rate (1.0 = normal). A rate > 0 implies playing.
    func setRate(_ rate: Float)
    /// Hints whether the next ``load(url:startAt:)`` should skip silence.
    /// Engines that don't support it ignore this (default no-op).
    func setSkipSilence(_ enabled: Bool)
}

public extension AudioPlayerEngine {
    func setSkipSilence(_ enabled: Bool) {}
}

@MainActor
Loading