Commit 0b272655 authored by Kourser's avatar Kourser
Browse files

feat(radio): searchable station catalog via Radio Browser

The add-radio sheet becomes a catalog: it opens on the most-voted
stations of the user's region and searches the community Radio Browser
directory (keyless, like fyyd) as you type, showing logo, country,
codec/bitrate and a one-tap add button; the direct-URL form stays below.
Stations added from the catalog keep their favicon as artwork
(addRadio gains an imageURL parameter).

DiscoveryKit gains RadioBrowserSearchService (search + popular by
country, hidebroken/votes ordering, stream-URL dedup) with tests.

Co-Authored-By: Claude (RCA)
parent 8aea34a6
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -30,6 +30,9 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
  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.
- **Catalogue de radios** : recherche dans l'annuaire communautaire
  Radio Browser (sans clé, comme fyyd) avec stations populaires de votre
  pays avant la saisie, logo, pays, codec/débit — ajout en un tap.

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

/// A radio station found in the Radio Browser directory, ready to add.
public struct RadioSearchResult: Identifiable, Hashable, Sendable {
    public var id: URL { streamURL }
    public var name: String
    public var streamURL: URL
    public var faviconURL: URL?
    public var country: String?
    public var tags: [String]
    public var codec: String?
    public var bitrate: Int?

    public init(
        name: String,
        streamURL: URL,
        faviconURL: URL? = nil,
        country: String? = nil,
        tags: [String] = [],
        codec: String? = nil,
        bitrate: Int? = nil
    ) {
        self.name = name
        self.streamURL = streamURL
        self.faviconURL = faviconURL
        self.country = country
        self.tags = tags
        self.codec = codec
        self.bitrate = bitrate
    }
}

/// Searches the Radio Browser directory (api.radio-browser.info) — the
/// community-run, keyless open directory of live radio streams.
public struct RadioBrowserSearchService: Sendable {
    private let loader: SearchDataLoading
    /// DNS round-robin over the community mirrors.
    private static let endpoint = "https://all.api.radio-browser.info/json/stations/search"

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

    public func search(_ term: String, limit: Int = 30) async throws -> [RadioSearchResult] {
        let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return [] }
        var components = URLComponents(string: Self.endpoint)!
        components.queryItems = Self.baseQuery(limit: limit)
            + [URLQueryItem(name: "name", value: trimmed)]
        let data = try await loader.loadData(from: components.url!)
        return Self.decode(data)
    }

    /// Most-voted working stations, optionally narrowed to an ISO country
    /// code (e.g. "FR"). Shown before the user types anything.
    public func popular(limit: Int = 30, countryCode: String? = nil) async throws -> [RadioSearchResult] {
        var components = URLComponents(string: Self.endpoint)!
        components.queryItems = Self.baseQuery(limit: limit)
        if let countryCode, !countryCode.isEmpty {
            components.queryItems?.append(URLQueryItem(name: "countrycode", value: countryCode))
        }
        let data = try await loader.loadData(from: components.url!)
        return Self.decode(data)
    }

    private static func baseQuery(limit: Int) -> [URLQueryItem] {
        [
            URLQueryItem(name: "limit", value: String(limit)),
            URLQueryItem(name: "hidebroken", value: "true"),
            URLQueryItem(name: "order", value: "votes"),
            URLQueryItem(name: "reverse", value: "true"),
        ]
    }

    /// Parses a Radio Browser response, dropping entries without a playable
    /// URL and de-duplicating stations sharing the same stream.
    static func decode(_ data: Data) -> [RadioSearchResult] {
        guard let items = try? JSONDecoder().decode([Item].self, from: data) else { return [] }
        var seen = Set<URL>()
        return items.compactMap { item in
            let name = item.name.trimmingCharacters(in: .whitespacesAndNewlines)
            let resolved = item.url_resolved?.isEmpty == false ? item.url_resolved! : item.url
            guard !name.isEmpty, let url = URL(string: resolved), seen.insert(url).inserted else {
                return nil
            }
            return RadioSearchResult(
                name: name,
                streamURL: url,
                faviconURL: item.favicon.flatMap { $0.isEmpty ? nil : URL(string: $0) },
                country: item.country?.isEmpty == false ? item.country : nil,
                tags: (item.tags ?? "").split(separator: ",").map(String.init).filter { !$0.isEmpty },
                codec: item.codec?.isEmpty == false ? item.codec : nil,
                bitrate: (item.bitrate ?? 0) > 0 ? item.bitrate : nil
            )
        }
    }

    private struct Item: Decodable {
        let name: String
        let url: String
        let url_resolved: String?
        let favicon: String?
        let country: String?
        let tags: String?
        let codec: String?
        let bitrate: Int?
    }
}
+94 −0
Original line number Diff line number Diff line
import Foundation
import Testing
@testable import DiscoveryKit

/// Records the requested URL and replays canned bytes.
private final class SpyLoader: SearchDataLoading, @unchecked Sendable {
    let data: Data
    private(set) var requestedURL: URL?
    init(data: Data = Data("[]".utf8)) { self.data = data }
    func loadData(from url: URL) async throws -> Data {
        requestedURL = url
        return data
    }
}

@Suite struct RadioBrowserSearchTests {
    private let sample = """
    [
      {
        "name": "FIP",
        "url": "https://icecast.radiofrance.fr/fip-midfi.mp3",
        "url_resolved": "https://icecast.radiofrance.fr/fip-midfi.mp3",
        "favicon": "https://example.com/fip.png",
        "country": "France",
        "tags": "jazz,eclectic",
        "codec": "MP3",
        "bitrate": 128
      },
      {
        "name": "Doublon de flux",
        "url": "https://icecast.radiofrance.fr/fip-midfi.mp3",
        "url_resolved": "",
        "favicon": "",
        "country": "",
        "tags": "",
        "codec": "",
        "bitrate": 0
      },
      {
        "name": "",
        "url": "https://example.com/anonyme.mp3"
      },
      {
        "name": "URL invalide",
        "url": ""
      }
    ]
    """

    @Test func decodesStationsDroppingDuplicatesAndBrokenEntries() {
        let results = RadioBrowserSearchService.decode(Data(sample.utf8))
        #expect(results.count == 1) // same-stream duplicate, unnamed and URL-less entries dropped
        let fip = results[0]
        #expect(fip.name == "FIP")
        #expect(fip.streamURL == URL(string: "https://icecast.radiofrance.fr/fip-midfi.mp3"))
        #expect(fip.faviconURL == URL(string: "https://example.com/fip.png"))
        #expect(fip.country == "France")
        #expect(fip.tags == ["jazz", "eclectic"])
        #expect(fip.codec == "MP3")
        #expect(fip.bitrate == 128)
    }

    @Test func handlesGarbage() {
        #expect(RadioBrowserSearchService.decode(Data("not json".utf8)).isEmpty)
    }

    @Test func searchQueriesByNameHidingBrokenStations() async throws {
        let loader = SpyLoader()
        _ = try await RadioBrowserSearchService(loader: loader).search("fip")

        let url = try #require(loader.requestedURL)
        let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
        #expect(url.host?.hasSuffix("api.radio-browser.info") == true)
        #expect(items.contains(URLQueryItem(name: "name", value: "fip")))
        #expect(items.contains(URLQueryItem(name: "hidebroken", value: "true")))
        #expect(items.contains(URLQueryItem(name: "order", value: "votes")))
    }

    @Test func popularFiltersByCountryCode() async throws {
        let loader = SpyLoader()
        _ = try await RadioBrowserSearchService(loader: loader).popular(countryCode: "FR")

        let url = try #require(loader.requestedURL)
        let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
        #expect(items.contains(URLQueryItem(name: "countrycode", value: "FR")))
        #expect(!items.contains { $0.name == "name" })
    }

    @Test func searchReturnsEmptyForBlankTerm() async throws {
        let loader = SpyLoader()
        #expect(try await RadioBrowserSearchService(loader: loader).search("   ").isEmpty)
        #expect(loader.requestedURL == nil) // no request at all
    }
}
+33 −3
Original line number Diff line number Diff line
@@ -100,7 +100,7 @@
			attributes = {
				BuildIndependentTargetsInParallel = 1;
				LastSwiftUpdateCheck = 2650;
				LastUpgradeCheck = 2650;
				LastUpgradeCheck = 2660;
				TargetAttributes = {
					ABCDEF0123456789ABCD0006 = {
						CreatedOnToolsVersion = 26.5;
@@ -191,12 +191,15 @@
			buildSettings = {
				ALWAYS_SEARCH_USER_PATHS = NO;
				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
				CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
				CLANG_ANALYZER_NONNULL = YES;
				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
				CLANG_ENABLE_MODULES = YES;
				CLANG_ENABLE_OBJC_ARC = YES;
				CLANG_ENABLE_OBJC_WEAK = YES;
				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
				CLANG_WARN_BOOL_CONVERSION = YES;
				CLANG_WARN_COMMA = YES;
				CLANG_WARN_CONSTANT_CONVERSION = YES;
				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
@@ -204,11 +207,20 @@
				CLANG_WARN_ENUM_CONVERSION = YES;
				CLANG_WARN_INFINITE_RECURSION = YES;
				CLANG_WARN_INT_CONVERSION = YES;
				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
				CLANG_WARN_STRICT_PROTOTYPES = YES;
				CLANG_WARN_SUSPICIOUS_MOVE = YES;
				CLANG_WARN_UNREACHABLE_CODE = YES;
				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
				COPY_PHASE_STRIP = NO;
				DEAD_CODE_STRIPPING = YES;
				DEBUG_INFORMATION_FORMAT = dwarf;
				DEVELOPMENT_TEAM = 88NB46UUP7;
				ENABLE_STRICT_OBJC_MSGSEND = YES;
				ENABLE_TESTABILITY = YES;
				ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -220,7 +232,9 @@
					"DEBUG=1",
					"$(inherited)",
				);
				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
				GCC_WARN_UNDECLARED_SELECTOR = YES;
				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
				GCC_WARN_UNUSED_FUNCTION = YES;
				GCC_WARN_UNUSED_VARIABLE = YES;
@@ -228,6 +242,7 @@
				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
				MTL_FAST_MATH = YES;
				ONLY_ACTIVE_ARCH = YES;
				STRING_CATALOG_GENERATE_SYMBOLS = YES;
				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
			};
@@ -238,12 +253,15 @@
			buildSettings = {
				ALWAYS_SEARCH_USER_PATHS = NO;
				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
				CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
				CLANG_ANALYZER_NONNULL = YES;
				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
				CLANG_ENABLE_MODULES = YES;
				CLANG_ENABLE_OBJC_ARC = YES;
				CLANG_ENABLE_OBJC_WEAK = YES;
				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
				CLANG_WARN_BOOL_CONVERSION = YES;
				CLANG_WARN_COMMA = YES;
				CLANG_WARN_CONSTANT_CONVERSION = YES;
				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
@@ -251,23 +269,35 @@
				CLANG_WARN_ENUM_CONVERSION = YES;
				CLANG_WARN_INFINITE_RECURSION = YES;
				CLANG_WARN_INT_CONVERSION = YES;
				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
				CLANG_WARN_STRICT_PROTOTYPES = YES;
				CLANG_WARN_SUSPICIOUS_MOVE = YES;
				CLANG_WARN_UNREACHABLE_CODE = YES;
				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
				COPY_PHASE_STRIP = NO;
				DEAD_CODE_STRIPPING = YES;
				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
				DEVELOPMENT_TEAM = 88NB46UUP7;
				ENABLE_NS_ASSERTIONS = NO;
				ENABLE_STRICT_OBJC_MSGSEND = YES;
				ENABLE_USER_SCRIPT_SANDBOXING = YES;
				GCC_C_LANGUAGE_STANDARD = gnu17;
				GCC_NO_COMMON_BLOCKS = YES;
				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
				GCC_WARN_UNDECLARED_SELECTOR = YES;
				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
				GCC_WARN_UNUSED_FUNCTION = YES;
				GCC_WARN_UNUSED_VARIABLE = YES;
				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
				MTL_ENABLE_DEBUG_INFO = NO;
				MTL_FAST_MATH = YES;
				STRING_CATALOG_GENERATE_SYMBOLS = YES;
				SWIFT_COMPILATION_MODE = wholemodule;
			};
			name = Release;
@@ -280,7 +310,7 @@
				"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Skingomz.macOS.entitlements;
				CODE_SIGN_STYLE = Automatic;
				CURRENT_PROJECT_VERSION = 3;
				DEVELOPMENT_TEAM = 88NB46UUP7;
				DEAD_CODE_STRIPPING = YES;
				ENABLE_HARDENED_RUNTIME = YES;
				ENABLE_PREVIEWS = YES;
				ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -316,7 +346,7 @@
				"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Skingomz.macOS.entitlements;
				CODE_SIGN_STYLE = Automatic;
				CURRENT_PROJECT_VERSION = 3;
				DEVELOPMENT_TEAM = 88NB46UUP7;
				DEAD_CODE_STRIPPING = YES;
				ENABLE_HARDENED_RUNTIME = YES;
				ENABLE_PREVIEWS = YES;
				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+4 −2
Original line number Diff line number Diff line
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
   LastUpgradeVersion = "2650"
   version = "1.7">
   LastUpgradeVersion = "2660"
   version = "1.3">
   <BuildAction
      parallelizeBuildables = "YES"
      buildImplicitDependencies = "YES">
@@ -27,6 +27,8 @@
      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
      shouldUseLaunchSchemeArgsEnv = "YES">
      <Testables>
      </Testables>
   </TestAction>
   <LaunchAction
      buildConfiguration = "Debug"
Loading