Commit 6ff33da0 authored by Kourser's avatar Kourser
Browse files

Discovery: iTunes search + OPML import (DiscoveryKit)



- DiscoveryKit: PodcastSearchService (keyless iTunes Search, injectable
  loader) and OPMLParser (handles nested outlines).
- App: SearchView (.searchable + subscribe), OPML via .fileImporter; the add
  button becomes a menu (search / by URL / import OPML).
- macOS: add files.user-selected.read-only entitlement for OPML import.

34 package tests green; iOS + macOS build.

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent 4c351744
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -6,5 +6,7 @@
	<true/>
	<key>com.apple.security.network.client</key>
	<true/>
	<key>com.apple.security.files.user-selected.read-only</key>
	<true/>
</dict>
</plist>
+7 −0
Original line number Diff line number Diff line
@@ -11,6 +11,7 @@
		ABCDEF0123456789ABCD0015 /* FeedKit in Frameworks */ = {isa = PBXBuildFile; productRef = ABCDEF0123456789ABCD0012 /* FeedKit */; };
		ABCDEF0123456789ABCD0016 /* StorageKit in Frameworks */ = {isa = PBXBuildFile; productRef = ABCDEF0123456789ABCD0013 /* StorageKit */; };
		ABCDEF0123456789ABCD0018 /* PlaybackKit in Frameworks */ = {isa = PBXBuildFile; productRef = ABCDEF0123456789ABCD0017 /* PlaybackKit */; };
		ABCDEF0123456789ABCD001A /* DiscoveryKit in Frameworks */ = {isa = PBXBuildFile; productRef = ABCDEF0123456789ABCD0019 /* DiscoveryKit */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
@@ -34,6 +35,7 @@
				ABCDEF0123456789ABCD0015 /* FeedKit in Frameworks */,
				ABCDEF0123456789ABCD0016 /* StorageKit in Frameworks */,
				ABCDEF0123456789ABCD0018 /* PlaybackKit in Frameworks */,
				ABCDEF0123456789ABCD001A /* DiscoveryKit in Frameworks */,
			);
			runOnlyForDeploymentPostprocessing = 0;
		};
@@ -80,6 +82,7 @@
				ABCDEF0123456789ABCD0012 /* FeedKit */,
				ABCDEF0123456789ABCD0013 /* StorageKit */,
				ABCDEF0123456789ABCD0017 /* PlaybackKit */,
				ABCDEF0123456789ABCD0019 /* DiscoveryKit */,
			);
			productName = 2ndPod;
			productReference = ABCDEF0123456789ABCD0004 /* 2ndPod.app */;
@@ -340,6 +343,10 @@
			isa = XCSwiftPackageProductDependency;
			productName = PlaybackKit;
		};
		ABCDEF0123456789ABCD0019 /* DiscoveryKit */ = {
			isa = XCSwiftPackageProductDependency;
			productName = DiscoveryKit;
		};
/* End XCSwiftPackageProductDependency section */
	};
	rootObject = ABCDEF0123456789ABCD0001 /* Project object */;
+20 −0
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import PodcastModel
import FeedKit
import StorageKit
import PlaybackKit
import DiscoveryKit

/// App-wide state: subscriptions, the play queue, and the actions that mutate
/// them. Orchestrates playback (resolving local downloads) and queue advance.
@@ -87,6 +88,25 @@ final class AppModel {
        (try? await store.episodes(forPodcastID: podcast.id)) ?? []
    }

    func isSubscribed(feedURL: URL) -> Bool {
        podcasts.contains { $0.feedURL == feedURL }
    }

    @discardableResult
    func subscribe(feedURL: URL) async -> Bool {
        await subscribe(urlString: feedURL.absoluteString)
    }

    /// Imports every feed in an OPML document. Returns how many were added.
    func importOPML(_ data: Data) async -> (added: Int, total: Int) {
        let feeds = OPMLParser.parse(data: data)
        var added = 0
        for feed in feeds {
            if await subscribe(feedURL: feed.feedURL) { added += 1 }
        }
        return (added, feeds.count)
    }

    func unsubscribe(_ podcast: Podcast) async {
        try? await store.unsubscribe(podcastID: podcast.id)
        await load()
+49 −1
Original line number Diff line number Diff line
import SwiftUI
import UniformTypeIdentifiers
import PodcastModel

/// Adaptive root: a sidebar (queue + subscriptions) with a detail pane on
@@ -7,6 +8,11 @@ struct RootView: View {
    @Environment(AppModel.self) private var model
    @State private var selection: Destination?
    @State private var showingAdd = false
    @State private var showingSearch = false
    @State private var showingOPMLImporter = false
    @State private var importMessage: String?

    private static let opmlTypes: [UTType] = [UTType(filenameExtension: "opml") ?? .xml, .xml]

    enum Destination: Hashable {
        case queue
@@ -31,7 +37,17 @@ struct RootView: View {
            .navigationTitle("2ndPod")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Menu {
                        Button { showingSearch = true } label: {
                            Label("Rechercher un podcast", systemImage: "magnifyingglass")
                        }
                        Button { showingAdd = true } label: {
                            Label("Ajouter par URL", systemImage: "link")
                        }
                        Button { showingOPMLImporter = true } label: {
                            Label("Importer un fichier OPML", systemImage: "square.and.arrow.down")
                        }
                    } label: {
                        Label("Ajouter", systemImage: "plus")
                    }
                }
@@ -45,6 +61,38 @@ struct RootView: View {
        .sheet(isPresented: $showingAdd) {
            AddPodcastView()
        }
        .sheet(isPresented: $showingSearch) {
            SearchView()
        }
        .fileImporter(
            isPresented: $showingOPMLImporter,
            allowedContentTypes: Self.opmlTypes
        ) { result in
            handleOPMLImport(result)
        }
        .alert("Import OPML", isPresented: importAlertBinding) {
            Button("OK") { importMessage = nil }
        } message: {
            Text(importMessage ?? "")
        }
    }

    private var importAlertBinding: Binding<Bool> {
        Binding(get: { importMessage != nil }, set: { if !$0 { importMessage = nil } })
    }

    private func handleOPMLImport(_ result: Result<URL, Error>) {
        guard case .success(let url) = result else { return }
        Task {
            let scoped = url.startAccessingSecurityScopedResource()
            defer { if scoped { url.stopAccessingSecurityScopedResource() } }
            guard let data = try? Data(contentsOf: url) else {
                importMessage = "Lecture du fichier impossible."
                return
            }
            let outcome = await model.importOPML(data)
            importMessage = "\(outcome.added)/\(outcome.total) podcast(s) importé(s)."
        }
    }

    @ViewBuilder private var detail: some View {
+83 −0
Original line number Diff line number Diff line
import SwiftUI
import DiscoveryKit

struct SearchView: View {
    @Environment(\.dismiss) private var dismiss
    @State private var term = ""
    @State private var results: [PodcastSearchResult] = []
    @State private var isSearching = false
    @State private var searched = false

    private let service = PodcastSearchService()

    var body: some View {
        NavigationStack {
            List {
                if isSearching {
                    HStack { ProgressView(); Text("Recherche…").foregroundStyle(.secondary) }
                } else if results.isEmpty && searched {
                    Text("Aucun résultat.").foregroundStyle(.secondary)
                }
                ForEach(results) { result in
                    SearchResultRow(result: result)
                }
            }
            .navigationTitle("Rechercher")
            .inlineNavigationTitle()
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Fermer") { dismiss() }
                }
            }
            .searchable(text: $term, prompt: "Nom d'un podcast")
            .onSubmit(of: .search, runSearch)
        }
    }

    private func runSearch() {
        Task {
            isSearching = true
            searched = true
            results = (try? await service.search(term)) ?? []
            isSearching = false
        }
    }
}

private struct SearchResultRow: View {
    @Environment(AppModel.self) private var model
    let result: PodcastSearchResult
    @State private var adding = false

    var body: some View {
        HStack(spacing: 12) {
            ArtworkView(url: result.artworkURL, size: 48)
            VStack(alignment: .leading, spacing: 2) {
                Text(result.title).font(.headline).lineLimit(1)
                if let author = result.author, !author.isEmpty {
                    Text(author).font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
                }
            }
            Spacer()
            trailing
        }
        .padding(.vertical, 2)
    }

    @ViewBuilder private var trailing: some View {
        if model.isSubscribed(feedURL: result.feedURL) {
            Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
        } else if adding {
            ProgressView()
        } else {
            Button("Ajouter") {
                Task {
                    adding = true
                    await model.subscribe(feedURL: result.feedURL)
                    adding = false
                }
            }
            .buttonStyle(.borderless)
        }
    }
}
Loading