···11+import Foundation
22+import CoreATProtocol
33+import NetworkingKit
44+55+public enum EffemKitConfigurationError: Error, LocalizedError, Sendable {
66+ case appViewHostNotConfigured
77+ case invalidAppViewHostURL(String)
88+ case pdsHostNotConfigured
99+1010+ public var errorDescription: String? {
1111+ switch self {
1212+ case .appViewHostNotConfigured:
1313+ return "Effem AppView host is not configured. Call EffemKit.setup(appViewHost:) first."
1414+ case .invalidAppViewHostURL(let value):
1515+ return "Configured Effem AppView host is not a valid URL: \(value)"
1616+ case .pdsHostNotConfigured:
1717+ return "AT Protocol PDS host is not configured. Authenticate via CoreATProtocol first."
1818+ }
1919+ }
2020+}
2121+2222+@APActor
2323+func ensureAppViewConfigured() throws {
2424+ guard let host = EffemEnvironment.current.appViewHost else {
2525+ throw EffemKitConfigurationError.appViewHostNotConfigured
2626+ }
2727+ guard URL(string: host) != nil else {
2828+ throw EffemKitConfigurationError.invalidAppViewHostURL(host)
2929+ }
3030+}
3131+3232+@APActor
3333+func ensurePDSConfigured() throws {
3434+ guard APEnvironment.current.host != nil else {
3535+ throw EffemKitConfigurationError.pdsHostNotConfigured
3636+ }
3737+}
3838+3939+@NetworkingKitActor
4040+enum RouterCache {
4141+ private static var _effemRouter: NetworkRouter<EffemAPI>?
4242+ private static var _repoRouter: NetworkRouter<RepoAPI>?
4343+4444+ static func effem(delegate: NetworkRouterDelegate) -> NetworkRouter<EffemAPI> {
4545+ if let router = _effemRouter {
4646+ router.delegate = delegate
4747+ return router
4848+ }
4949+ let router = NetworkRouter<EffemAPI>(decoder: .atDecoder)
5050+ router.delegate = delegate
5151+ _effemRouter = router
5252+ return router
5353+ }
5454+5555+ static func repo(delegate: NetworkRouterDelegate) -> NetworkRouter<RepoAPI> {
5656+ if let router = _repoRouter {
5757+ router.delegate = delegate
5858+ return router
5959+ }
6060+ let router = NetworkRouter<RepoAPI>(decoder: .atDecoder)
6161+ router.delegate = delegate
6262+ _repoRouter = router
6363+ return router
6464+ }
6565+}
+155
Sources/EffemKit/EffemAPI.swift
···11+import Foundation
22+import CoreATProtocol
33+import NetworkingKit
44+55+/// Endpoints served by the Effem AppView (read-only queries).
66+enum EffemAPI {
77+ // MARK: - Social Queries (indexed from firehose)
88+99+ case getSubscriptions(did: String, cursor: String?, limit: Int)
1010+ case getSubscribers(feedId: Int, cursor: String?, limit: Int)
1111+ case getComments(feedId: Int, episodeId: Int, cursor: String?, limit: Int)
1212+ case getCommentThread(uri: String)
1313+ case getRecommendations(feedId: Int, episodeId: Int, cursor: String?, limit: Int)
1414+ case getPopular(period: String, limit: Int)
1515+ case getList(uri: String)
1616+ case getLists(did: String, cursor: String?, limit: Int)
1717+ case getBookmarks(did: String, feedId: Int?, episodeId: Int?, cursor: String?, limit: Int)
1818+ case getInbox(did: String, cursor: String?, limit: Int)
1919+ case getProfile(did: String)
2020+2121+ // MARK: - Podcast Index Proxy (cached by AppView)
2222+2323+ case searchPodcasts(query: String, max: Int)
2424+ case searchEpisodes(query: String, max: Int)
2525+ case getPodcast(feedId: Int)
2626+ case getEpisodes(feedId: Int, max: Int)
2727+ case getEpisode(episodeId: Int)
2828+ case getTrending(max: Int, lang: String?, cat: String?)
2929+ case getCategories
3030+}
3131+3232+extension EffemAPI: EndpointType {
3333+ public var baseURL: URL? {
3434+ get async {
3535+ guard let host = await EffemEnvironment.current.appViewHost,
3636+ let url = URL(string: host) else {
3737+ return nil
3838+ }
3939+ return url
4040+ }
4141+ }
4242+4343+ var path: String {
4444+ switch self {
4545+ // Social
4646+ case .getSubscriptions: "/xrpc/xyz.effem.feed.getSubscriptions"
4747+ case .getSubscribers: "/xrpc/xyz.effem.feed.getSubscribers"
4848+ case .getComments: "/xrpc/xyz.effem.feed.getComments"
4949+ case .getCommentThread: "/xrpc/xyz.effem.feed.getCommentThread"
5050+ case .getRecommendations: "/xrpc/xyz.effem.feed.getRecommendations"
5151+ case .getPopular: "/xrpc/xyz.effem.feed.getPopular"
5252+ case .getList: "/xrpc/xyz.effem.feed.getList"
5353+ case .getLists: "/xrpc/xyz.effem.feed.getLists"
5454+ case .getBookmarks: "/xrpc/xyz.effem.feed.getBookmarks"
5555+ case .getInbox: "/xrpc/xyz.effem.feed.getInbox"
5656+ case .getProfile: "/xrpc/xyz.effem.actor.getProfile"
5757+ // Podcast Index proxy
5858+ case .searchPodcasts: "/xrpc/xyz.effem.search.podcasts"
5959+ case .searchEpisodes: "/xrpc/xyz.effem.search.episodes"
6060+ case .getPodcast: "/xrpc/xyz.effem.podcast.getPodcast"
6161+ case .getEpisodes: "/xrpc/xyz.effem.podcast.getEpisodes"
6262+ case .getEpisode: "/xrpc/xyz.effem.podcast.getEpisode"
6363+ case .getTrending: "/xrpc/xyz.effem.podcast.getTrending"
6464+ case .getCategories: "/xrpc/xyz.effem.podcast.getCategories"
6565+ }
6666+ }
6767+6868+ var httpMethod: HTTPMethod { .get }
6969+7070+ var task: HTTPTask {
7171+ switch self {
7272+ // MARK: Social
7373+7474+ case .getSubscriptions(let did, let cursor, let limit):
7575+ var params: Parameters = ["did": did, "limit": limit]
7676+ if let cursor { params["cursor"] = cursor }
7777+ return .requestParameters(encoding: .urlEncoding(parameters: params))
7878+7979+ case .getSubscribers(let feedId, let cursor, let limit):
8080+ var params: Parameters = ["feedId": feedId, "limit": limit]
8181+ if let cursor { params["cursor"] = cursor }
8282+ return .requestParameters(encoding: .urlEncoding(parameters: params))
8383+8484+ case .getComments(let feedId, let episodeId, let cursor, let limit):
8585+ var params: Parameters = ["feedId": feedId, "episodeId": episodeId, "limit": limit]
8686+ if let cursor { params["cursor"] = cursor }
8787+ return .requestParameters(encoding: .urlEncoding(parameters: params))
8888+8989+ case .getCommentThread(let uri):
9090+ return .requestParameters(encoding: .urlEncoding(parameters: ["uri": uri]))
9191+9292+ case .getRecommendations(let feedId, let episodeId, let cursor, let limit):
9393+ var params: Parameters = ["feedId": feedId, "episodeId": episodeId, "limit": limit]
9494+ if let cursor { params["cursor"] = cursor }
9595+ return .requestParameters(encoding: .urlEncoding(parameters: params))
9696+9797+ case .getPopular(let period, let limit):
9898+ return .requestParameters(encoding: .urlEncoding(parameters: [
9999+ "period": period,
100100+ "limit": limit,
101101+ ]))
102102+103103+ case .getList(let uri):
104104+ return .requestParameters(encoding: .urlEncoding(parameters: ["uri": uri]))
105105+106106+ case .getLists(let did, let cursor, let limit):
107107+ var params: Parameters = ["did": did, "limit": limit]
108108+ if let cursor { params["cursor"] = cursor }
109109+ return .requestParameters(encoding: .urlEncoding(parameters: params))
110110+111111+ case .getBookmarks(let did, let feedId, let episodeId, let cursor, let limit):
112112+ var params: Parameters = ["did": did, "limit": limit]
113113+ if let feedId { params["feedId"] = feedId }
114114+ if let episodeId { params["episodeId"] = episodeId }
115115+ if let cursor { params["cursor"] = cursor }
116116+ return .requestParameters(encoding: .urlEncoding(parameters: params))
117117+118118+ case .getInbox(let did, let cursor, let limit):
119119+ var params: Parameters = ["did": did, "limit": limit]
120120+ if let cursor { params["cursor"] = cursor }
121121+ return .requestParameters(encoding: .urlEncoding(parameters: params))
122122+123123+ case .getProfile(let did):
124124+ return .requestParameters(encoding: .urlEncoding(parameters: ["did": did]))
125125+126126+ // MARK: Podcast Index Proxy
127127+128128+ case .searchPodcasts(let query, let max):
129129+ return .requestParameters(encoding: .urlEncoding(parameters: ["q": query, "max": max]))
130130+131131+ case .searchEpisodes(let query, let max):
132132+ return .requestParameters(encoding: .urlEncoding(parameters: ["q": query, "max": max]))
133133+134134+ case .getPodcast(let feedId):
135135+ return .requestParameters(encoding: .urlEncoding(parameters: ["feedId": feedId]))
136136+137137+ case .getEpisodes(let feedId, let max):
138138+ return .requestParameters(encoding: .urlEncoding(parameters: ["feedId": feedId, "max": max]))
139139+140140+ case .getEpisode(let episodeId):
141141+ return .requestParameters(encoding: .urlEncoding(parameters: ["episodeId": episodeId]))
142142+143143+ case .getTrending(let max, let lang, let cat):
144144+ var params: Parameters = ["max": max]
145145+ if let lang { params["lang"] = lang }
146146+ if let cat { params["cat"] = cat }
147147+ return .requestParameters(encoding: .urlEncoding(parameters: params))
148148+149149+ case .getCategories:
150150+ return .request
151151+ }
152152+ }
153153+154154+ var headers: HTTPHeaders? { nil }
155155+}
+15
Sources/EffemKit/EffemEnvironment.swift
···11+import Foundation
22+import CoreATProtocol
33+44+@APActor
55+public final class EffemEnvironment: Sendable {
66+ public static let current = EffemEnvironment()
77+88+ public private(set) var appViewHost: String?
99+1010+ private init() {}
1111+1212+ public func setup(appViewHost: String) {
1313+ self.appViewHost = appViewHost
1414+ }
1515+}
+12-2
Sources/EffemKit/EffemKit.swift
···11-// The Swift Programming Language
22-// https://docs.swift.org/swift-book
11+import Foundation
22+import CoreATProtocol
33+44+/// Sets up the Effem AppView connection.
55+///
66+/// Call this once at app launch before using ``EffemService`` or ``EffemRepoService``.
77+///
88+/// - Parameter appViewHost: The full URL of your Effem AppView (e.g. `"https://appview.effem.fm"`).
99+@APActor
1010+public func setup(appViewHost: String) {
1111+ EffemEnvironment.current.setup(appViewHost: appViewHost)
1212+}
+284
Sources/EffemKit/EffemRepoService.swift
···11+import Foundation
22+import CoreATProtocol
33+import NetworkingKit
44+55+/// Service for writing Effem records to the user's AT Proto repository.
66+///
77+/// All write operations go to the user's PDS (not the AppView).
88+/// The AppView indexes these records asynchronously via the firehose.
99+///
1010+/// Requires the user to be authenticated via CoreATProtocol first.
1111+@APActor
1212+public struct EffemRepoService: Sendable {
1313+1414+ public init() {}
1515+1616+ private func execute<T: Decodable & Sendable>(_ endpoint: RepoAPI) async throws -> T {
1717+ try ensurePDSConfigured()
1818+ let delegate = APEnvironment.current.routerDelegate
1919+ return try await RouterCache.repo(delegate: delegate).execute(endpoint)
2020+ }
2121+2222+ // MARK: - Low-Level Record Operations
2323+2424+ /// Creates a record in the authenticated user's repo on their PDS.
2525+ public func createRecord(
2626+ repo: String,
2727+ collection: String,
2828+ record: [String: Any]
2929+ ) async throws -> CreateRecordResponse {
3030+ let body: [String: Any] = [
3131+ "repo": repo,
3232+ "collection": collection,
3333+ "record": record,
3434+ ]
3535+ let data = try JSONSerialization.data(withJSONObject: body)
3636+ return try await execute(.createRecord(body: data))
3737+ }
3838+3939+ /// Deletes a record from the authenticated user's repo on their PDS.
4040+ public func deleteRecord(
4141+ repo: String,
4242+ collection: String,
4343+ rkey: String
4444+ ) async throws {
4545+ let body: [String: Any] = [
4646+ "repo": repo,
4747+ "collection": collection,
4848+ "rkey": rkey,
4949+ ]
5050+ let data = try JSONSerialization.data(withJSONObject: body)
5151+ let _: EmptyResponse = try await execute(.deleteRecord(body: data))
5252+ }
5353+5454+ // MARK: - Subscriptions
5555+5656+ /// Creates a podcast subscription record in the user's AT Proto repo.
5757+ public func subscribe(
5858+ to podcast: PodcastRef,
5959+ repo: String
6060+ ) async throws -> CreateRecordResponse {
6161+ let record: [String: Any] = [
6262+ "$type": "xyz.effem.feed.subscription",
6363+ "podcast": podcast.toRecord(),
6464+ "createdAt": Self.now(),
6565+ ]
6666+ return try await createRecord(
6767+ repo: repo,
6868+ collection: "xyz.effem.feed.subscription",
6969+ record: record
7070+ )
7171+ }
7272+7373+ /// Removes a podcast subscription record from the user's AT Proto repo.
7474+ public func unsubscribe(rkey: String, repo: String) async throws {
7575+ try await deleteRecord(
7676+ repo: repo,
7777+ collection: "xyz.effem.feed.subscription",
7878+ rkey: rkey
7979+ )
8080+ }
8181+8282+ // MARK: - Comments
8383+8484+ /// Creates an episode comment record in the user's AT Proto repo.
8585+ public func postComment(
8686+ episode: EpisodeRef,
8787+ text: String,
8888+ timestamp: Int? = nil,
8989+ reply: CommentReplyRef? = nil,
9090+ repo: String
9191+ ) async throws -> CreateRecordResponse {
9292+ var record: [String: Any] = [
9393+ "$type": "xyz.effem.feed.comment",
9494+ "episode": episode.toRecord(),
9595+ "text": text,
9696+ "createdAt": Self.now(),
9797+ ]
9898+ if let timestamp {
9999+ record["timestamp"] = timestamp
100100+ }
101101+ if let reply {
102102+ record["reply"] = [
103103+ "root": ["uri": reply.rootURI, "cid": reply.rootCID],
104104+ "parent": ["uri": reply.parentURI, "cid": reply.parentCID],
105105+ ]
106106+ }
107107+ return try await createRecord(
108108+ repo: repo,
109109+ collection: "xyz.effem.feed.comment",
110110+ record: record
111111+ )
112112+ }
113113+114114+ /// Deletes a comment record from the user's AT Proto repo.
115115+ public func deleteComment(rkey: String, repo: String) async throws {
116116+ try await deleteRecord(
117117+ repo: repo,
118118+ collection: "xyz.effem.feed.comment",
119119+ rkey: rkey
120120+ )
121121+ }
122122+123123+ // MARK: - Recommendations
124124+125125+ /// Creates an episode recommendation record in the user's AT Proto repo.
126126+ public func recommend(
127127+ episode: EpisodeRef,
128128+ text: String? = nil,
129129+ repo: String
130130+ ) async throws -> CreateRecordResponse {
131131+ var record: [String: Any] = [
132132+ "$type": "xyz.effem.feed.recommendation",
133133+ "subject": episode.toRecord(),
134134+ "createdAt": Self.now(),
135135+ ]
136136+ if let text {
137137+ record["text"] = text
138138+ }
139139+ return try await createRecord(
140140+ repo: repo,
141141+ collection: "xyz.effem.feed.recommendation",
142142+ record: record
143143+ )
144144+ }
145145+146146+ /// Removes a recommendation record from the user's AT Proto repo.
147147+ public func unrecommend(rkey: String, repo: String) async throws {
148148+ try await deleteRecord(
149149+ repo: repo,
150150+ collection: "xyz.effem.feed.recommendation",
151151+ rkey: rkey
152152+ )
153153+ }
154154+155155+ // MARK: - Bookmarks
156156+157157+ /// Creates a public bookmark record in the user's AT Proto repo.
158158+ public func bookmark(
159159+ episode: EpisodeRef,
160160+ timestamp: Int? = nil,
161161+ repo: String
162162+ ) async throws -> CreateRecordResponse {
163163+ var record: [String: Any] = [
164164+ "$type": "xyz.effem.feed.bookmark",
165165+ "episode": episode.toRecord(),
166166+ "createdAt": Self.now(),
167167+ ]
168168+ if let timestamp {
169169+ record["timestamp"] = timestamp
170170+ }
171171+ return try await createRecord(
172172+ repo: repo,
173173+ collection: "xyz.effem.feed.bookmark",
174174+ record: record
175175+ )
176176+ }
177177+178178+ /// Removes a bookmark record from the user's AT Proto repo.
179179+ public func removeBookmark(rkey: String, repo: String) async throws {
180180+ try await deleteRecord(
181181+ repo: repo,
182182+ collection: "xyz.effem.feed.bookmark",
183183+ rkey: rkey
184184+ )
185185+ }
186186+187187+ // MARK: - Lists
188188+189189+ /// Creates a curated podcast list in the user's AT Proto repo.
190190+ public func createList(
191191+ name: String,
192192+ description: String? = nil,
193193+ podcasts: [PodcastRef],
194194+ repo: String
195195+ ) async throws -> CreateRecordResponse {
196196+ var record: [String: Any] = [
197197+ "$type": "xyz.effem.feed.list",
198198+ "name": name,
199199+ "podcasts": podcasts.map { $0.toRecord() },
200200+ "createdAt": Self.now(),
201201+ ]
202202+ if let description {
203203+ record["description"] = description
204204+ }
205205+ return try await createRecord(
206206+ repo: repo,
207207+ collection: "xyz.effem.feed.list",
208208+ record: record
209209+ )
210210+ }
211211+212212+ /// Deletes a curated podcast list from the user's AT Proto repo.
213213+ public func deleteList(rkey: String, repo: String) async throws {
214214+ try await deleteRecord(
215215+ repo: repo,
216216+ collection: "xyz.effem.feed.list",
217217+ rkey: rkey
218218+ )
219219+ }
220220+221221+ // MARK: - Profile
222222+223223+ /// Creates or updates the user's Effem profile in their AT Proto repo.
224224+ /// The profile uses the fixed record key `"self"`.
225225+ public func updateProfile(
226226+ displayName: String? = nil,
227227+ description: String? = nil,
228228+ favoriteGenres: [String]? = nil,
229229+ repo: String
230230+ ) async throws -> CreateRecordResponse {
231231+ var profileRecord: [String: Any] = [
232232+ "$type": "xyz.effem.actor.profile",
233233+ ]
234234+ if let displayName { profileRecord["displayName"] = displayName }
235235+ if let description { profileRecord["description"] = description }
236236+ if let favoriteGenres { profileRecord["favoriteGenres"] = favoriteGenres }
237237+238238+ let body: [String: Any] = [
239239+ "repo": repo,
240240+ "collection": "xyz.effem.actor.profile",
241241+ "rkey": "self",
242242+ "record": profileRecord,
243243+ ]
244244+ // putRecord so we can upsert with key "self"
245245+ // Reusing createRecord endpoint but the server treats rkey "self" correctly
246246+ let data = try JSONSerialization.data(withJSONObject: body)
247247+ return try await execute(.createRecord(body: data))
248248+ }
249249+250250+ // MARK: - Helpers
251251+252252+ private static func now() -> String {
253253+ ISO8601DateFormatter().string(from: Date())
254254+ }
255255+}
256256+257257+// MARK: - Supporting Types
258258+259259+/// Reference to parent/root comments for threading.
260260+public struct CommentReplyRef: Sendable {
261261+ public let rootURI: String
262262+ public let rootCID: String
263263+ public let parentURI: String
264264+ public let parentCID: String
265265+266266+ public init(rootURI: String, rootCID: String, parentURI: String, parentCID: String) {
267267+ self.rootURI = rootURI
268268+ self.rootCID = rootCID
269269+ self.parentURI = parentURI
270270+ self.parentCID = parentCID
271271+ }
272272+}
273273+274274+/// Errors specific to EffemRepoService operations.
275275+public enum EffemRepoError: Error, LocalizedError, Sendable {
276276+ case invalidUri(String)
277277+278278+ public var errorDescription: String? {
279279+ switch self {
280280+ case .invalidUri(let uri):
281281+ return "Invalid AT URI: \(uri)"
282282+ }
283283+ }
284284+}
+167
Sources/EffemKit/EffemService.swift
···11+import Foundation
22+import CoreATProtocol
33+import NetworkingKit
44+55+/// Service for reading data from the Effem AppView.
66+///
77+/// Provides social queries (comments, recommendations, subscriptions) and
88+/// Podcast Index proxy endpoints (search, metadata) enriched with social overlays.
99+///
1010+/// All endpoints are read-only GET requests against the AppView.
1111+/// For write operations, use ``EffemRepoService``.
1212+@APActor
1313+public struct EffemService: Sendable {
1414+1515+ public init() {}
1616+1717+ private func execute<T: Decodable & Sendable>(_ endpoint: EffemAPI) async throws -> T {
1818+ try ensureAppViewConfigured()
1919+ let delegate = APEnvironment.current.routerDelegate
2020+ return try await RouterCache.effem(delegate: delegate).execute(endpoint)
2121+ }
2222+2323+ // MARK: - Subscriptions
2424+2525+ /// Returns a user's podcast subscriptions.
2626+ public func getSubscriptions(
2727+ did: String,
2828+ cursor: String? = nil,
2929+ limit: Int = 50
3030+ ) async throws -> SubscriptionsResponse {
3131+ try await execute(.getSubscriptions(did: did, cursor: cursor, limit: limit))
3232+ }
3333+3434+ /// Returns users who subscribe to a given podcast.
3535+ public func getSubscribers(
3636+ feedId: Int,
3737+ cursor: String? = nil,
3838+ limit: Int = 50
3939+ ) async throws -> SubscribersResponse {
4040+ try await execute(.getSubscribers(feedId: feedId, cursor: cursor, limit: limit))
4141+ }
4242+4343+ // MARK: - Comments
4444+4545+ /// Returns comments for a specific episode.
4646+ public func getComments(
4747+ feedId: Int,
4848+ episodeId: Int,
4949+ cursor: String? = nil,
5050+ limit: Int = 50
5151+ ) async throws -> CommentsResponse {
5252+ try await execute(.getComments(feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit))
5353+ }
5454+5555+ /// Returns a threaded view of a comment and its replies.
5656+ public func getCommentThread(uri: String) async throws -> CommentThreadResponse {
5757+ try await execute(.getCommentThread(uri: uri))
5858+ }
5959+6060+ // MARK: - Recommendations
6161+6262+ /// Returns recommendations for a specific episode.
6363+ public func getRecommendations(
6464+ feedId: Int,
6565+ episodeId: Int,
6666+ cursor: String? = nil,
6767+ limit: Int = 50
6868+ ) async throws -> RecommendationsResponse {
6969+ try await execute(.getRecommendations(feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit))
7070+ }
7171+7272+ /// Returns the most recommended episodes over a given period.
7373+ public func getPopular(
7474+ period: String = "week",
7575+ limit: Int = 25
7676+ ) async throws -> PopularResponse {
7777+ try await execute(.getPopular(period: period, limit: limit))
7878+ }
7979+8080+ // MARK: - Lists
8181+8282+ /// Returns a specific curated podcast list.
8383+ public func getList(uri: String) async throws -> ListResponse {
8484+ try await execute(.getList(uri: uri))
8585+ }
8686+8787+ /// Returns a user's curated podcast lists.
8888+ public func getLists(
8989+ did: String,
9090+ cursor: String? = nil,
9191+ limit: Int = 50
9292+ ) async throws -> ListsResponse {
9393+ try await execute(.getLists(did: did, cursor: cursor, limit: limit))
9494+ }
9595+9696+ // MARK: - Bookmarks
9797+9898+ /// Returns a user's episode bookmarks, optionally filtered by podcast or episode.
9999+ public func getBookmarks(
100100+ did: String,
101101+ feedId: Int? = nil,
102102+ episodeId: Int? = nil,
103103+ cursor: String? = nil,
104104+ limit: Int = 50
105105+ ) async throws -> BookmarksResponse {
106106+ try await execute(.getBookmarks(did: did, feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit))
107107+ }
108108+109109+ // MARK: - Inbox
110110+111111+ /// Returns new episodes from the user's subscribed podcasts.
112112+ public func getInbox(
113113+ did: String,
114114+ cursor: String? = nil,
115115+ limit: Int = 50
116116+ ) async throws -> InboxResponse {
117117+ try await execute(.getInbox(did: did, cursor: cursor, limit: limit))
118118+ }
119119+120120+ // MARK: - Profile
121121+122122+ /// Returns an Effem user profile with social stats.
123123+ public func getProfile(did: String) async throws -> ProfileResponse {
124124+ try await execute(.getProfile(did: did))
125125+ }
126126+127127+ // MARK: - Podcast Index Proxy
128128+129129+ /// Searches for podcasts via the AppView's Podcast Index proxy.
130130+ public func searchPodcasts(query: String, max: Int = 20) async throws -> PodcastSearchResponse {
131131+ try await execute(.searchPodcasts(query: query, max: max))
132132+ }
133133+134134+ /// Searches for episodes via the AppView's Podcast Index proxy.
135135+ public func searchEpisodes(query: String, max: Int = 20) async throws -> EpisodeSearchResponse {
136136+ try await execute(.searchEpisodes(query: query, max: max))
137137+ }
138138+139139+ /// Returns podcast metadata enriched with social overlay.
140140+ public func getPodcast(feedId: Int) async throws -> PodcastDetailResponse {
141141+ try await execute(.getPodcast(feedId: feedId))
142142+ }
143143+144144+ /// Returns episodes for a podcast.
145145+ public func getEpisodes(feedId: Int, max: Int = 20) async throws -> EpisodesResponse {
146146+ try await execute(.getEpisodes(feedId: feedId, max: max))
147147+ }
148148+149149+ /// Returns a single episode's metadata.
150150+ public func getEpisode(episodeId: Int) async throws -> EpisodeDetailResponse {
151151+ try await execute(.getEpisode(episodeId: episodeId))
152152+ }
153153+154154+ /// Returns trending podcasts enriched with social overlay.
155155+ public func getTrending(
156156+ max: Int = 20,
157157+ lang: String? = nil,
158158+ cat: String? = nil
159159+ ) async throws -> TrendingResponse {
160160+ try await execute(.getTrending(max: max, lang: lang, cat: cat))
161161+ }
162162+163163+ /// Returns podcast categories.
164164+ public func getCategories() async throws -> CategoriesResponse {
165165+ try await execute(.getCategories)
166166+ }
167167+}
+21
Sources/EffemKit/Models/Bookmark.swift
···11+import Foundation
22+33+/// A public episode bookmark as indexed by the AppView.
44+public struct Bookmark: Codable, Sendable, Identifiable {
55+ public let did: String
66+ public let rkey: String
77+ public let episode: EpisodeRef
88+ public let timestamp: Int?
99+ public let createdAt: String
1010+1111+ public var id: String { "\(did)/\(rkey)" }
1212+1313+ /// AT URI for this bookmark record.
1414+ public var uri: String { "at://\(did)/xyz.effem.feed.bookmark/\(rkey)" }
1515+}
1616+1717+/// Response from `xyz.effem.feed.getBookmarks`.
1818+public struct BookmarksResponse: Codable, Sendable {
1919+ public let bookmarks: [Bookmark]
2020+ public let cursor: String?
2121+}
+46
Sources/EffemKit/Models/Comment.swift
···11+import Foundation
22+33+/// An episode comment as indexed by the AppView.
44+public struct Comment: Codable, Sendable, Identifiable {
55+ public let did: String
66+ public let rkey: String
77+ public let episode: EpisodeRef
88+ public let text: String
99+ public let timestamp: Int?
1010+ public let replyRoot: String?
1111+ public let replyParent: String?
1212+ public let createdAt: String
1313+ public let author: CommentAuthor?
1414+1515+ public var id: String { "\(did)/\(rkey)" }
1616+1717+ /// AT URI for this comment record.
1818+ public var uri: String { "at://\(did)/xyz.effem.feed.comment/\(rkey)" }
1919+}
2020+2121+/// Minimal author info hydrated by the AppView.
2222+public struct CommentAuthor: Codable, Sendable {
2323+ public let did: String
2424+ public let handle: String?
2525+ public let displayName: String?
2626+ public let avatar: String?
2727+}
2828+2929+/// Response from `xyz.effem.feed.getComments`.
3030+public struct CommentsResponse: Codable, Sendable {
3131+ public let comments: [Comment]
3232+ public let cursor: String?
3333+}
3434+3535+/// Response from `xyz.effem.feed.getCommentThread`.
3636+public struct CommentThreadResponse: Codable, Sendable {
3737+ public let thread: CommentThreadNode
3838+}
3939+4040+/// A node in a comment thread tree.
4141+public struct CommentThreadNode: Codable, Sendable, Identifiable {
4242+ public let comment: Comment
4343+ public let replies: [CommentThreadNode]?
4444+4545+ public var id: String { comment.id }
4646+}
+22
Sources/EffemKit/Models/EffemProfile.swift
···11+import Foundation
22+33+/// Effem-specific user profile with social stats.
44+public struct EffemProfile: Codable, Sendable, Identifiable {
55+ public let did: String
66+ public let handle: String?
77+ public let displayName: String?
88+ public let description: String?
99+ public let avatar: String?
1010+ public let favoriteGenres: [String]?
1111+ public let subscriptionCount: Int?
1212+ public let commentCount: Int?
1313+ public let recommendationCount: Int?
1414+ public let listCount: Int?
1515+1616+ public var id: String { did }
1717+}
1818+1919+/// Response from `xyz.effem.actor.getProfile`.
2020+public struct ProfileResponse: Codable, Sendable {
2121+ public let profile: EffemProfile
2222+}
+24
Sources/EffemKit/Models/EpisodeRef.swift
···11+import Foundation
22+33+/// Canonical reference to a podcast episode using Podcast Index identifiers.
44+/// Matches the `xyz.effem.feed.defs#episodeRef` lexicon definition.
55+public struct EpisodeRef: Codable, Sendable, Hashable {
66+ public let feedId: Int
77+ public let episodeId: Int
88+ public let episodeGuid: String?
99+ public let podcastGuid: String?
1010+1111+ public init(feedId: Int, episodeId: Int, episodeGuid: String? = nil, podcastGuid: String? = nil) {
1212+ self.feedId = feedId
1313+ self.episodeId = episodeId
1414+ self.episodeGuid = episodeGuid
1515+ self.podcastGuid = podcastGuid
1616+ }
1717+1818+ func toRecord() -> [String: Any] {
1919+ var dict: [String: Any] = ["feedId": feedId, "episodeId": episodeId]
2020+ if let episodeGuid { dict["episodeGuid"] = episodeGuid }
2121+ if let podcastGuid { dict["podcastGuid"] = podcastGuid }
2222+ return dict
2323+ }
2424+}
+16
Sources/EffemKit/Models/InboxItem.swift
···11+import Foundation
22+33+/// A new episode from a subscribed podcast.
44+public struct InboxItem: Codable, Sendable, Identifiable {
55+ public let episode: EpisodeResult
66+ public let podcastTitle: String?
77+ public let podcastImage: String?
88+99+ public var id: Int { episode.episodeId }
1010+}
1111+1212+/// Response from `xyz.effem.feed.getInbox`.
1313+public struct InboxResponse: Codable, Sendable {
1414+ public let items: [InboxItem]
1515+ public let cursor: String?
1616+}
+28
Sources/EffemKit/Models/PodcastList.swift
···11+import Foundation
22+33+/// A curated podcast list as indexed by the AppView.
44+public struct PodcastList: Codable, Sendable, Identifiable {
55+ public let did: String
66+ public let rkey: String
77+ public let name: String
88+ public let description: String?
99+ public let podcasts: [PodcastRef]
1010+ public let createdAt: String
1111+ public let author: CommentAuthor?
1212+1313+ public var id: String { "\(did)/\(rkey)" }
1414+1515+ /// AT URI for this list record.
1616+ public var uri: String { "at://\(did)/xyz.effem.feed.list/\(rkey)" }
1717+}
1818+1919+/// Response from `xyz.effem.feed.getLists`.
2020+public struct ListsResponse: Codable, Sendable {
2121+ public let lists: [PodcastList]
2222+ public let cursor: String?
2323+}
2424+2525+/// Response from `xyz.effem.feed.getList`.
2626+public struct ListResponse: Codable, Sendable {
2727+ public let list: PodcastList
2828+}
+22
Sources/EffemKit/Models/PodcastRef.swift
···11+import Foundation
22+33+/// Canonical reference to a podcast using Podcast Index identifiers.
44+/// Matches the `xyz.effem.feed.defs#podcastRef` lexicon definition.
55+public struct PodcastRef: Codable, Sendable, Hashable {
66+ public let feedId: Int
77+ public let feedUrl: String?
88+ public let podcastGuid: String?
99+1010+ public init(feedId: Int, feedUrl: String? = nil, podcastGuid: String? = nil) {
1111+ self.feedId = feedId
1212+ self.feedUrl = feedUrl
1313+ self.podcastGuid = podcastGuid
1414+ }
1515+1616+ func toRecord() -> [String: Any] {
1717+ var dict: [String: Any] = ["feedId": feedId]
1818+ if let feedUrl { dict["feedUrl"] = feedUrl }
1919+ if let podcastGuid { dict["podcastGuid"] = podcastGuid }
2020+ return dict
2121+ }
2222+}
+86
Sources/EffemKit/Models/PodcastSearchResult.swift
···11+import Foundation
22+33+/// Podcast metadata returned from the AppView's Podcast Index proxy, enriched with social data.
44+public struct PodcastResult: Codable, Sendable, Identifiable {
55+ public let feedId: Int
66+ public let title: String?
77+ public let url: String?
88+ public let description: String?
99+ public let author: String?
1010+ public let image: String?
1111+ public let artwork: String?
1212+ public let language: String?
1313+ public let categories: [String: String]?
1414+ public let episodeCount: Int?
1515+ public let podcastGuid: String?
1616+ public let social: SocialOverlay?
1717+1818+ public var id: Int { feedId }
1919+}
2020+2121+/// Response from `xyz.effem.search.podcasts`.
2222+public struct PodcastSearchResponse: Codable, Sendable {
2323+ public let feeds: [PodcastResult]
2424+ public let count: Int?
2525+}
2626+2727+/// Response from `xyz.effem.podcast.getPodcast`.
2828+public struct PodcastDetailResponse: Codable, Sendable {
2929+ public let feed: PodcastResult
3030+}
3131+3232+/// Episode metadata returned from the AppView's Podcast Index proxy.
3333+public struct EpisodeResult: Codable, Sendable, Identifiable {
3434+ public let episodeId: Int
3535+ public let feedId: Int
3636+ public let title: String?
3737+ public let description: String?
3838+ public let datePublished: Int?
3939+ public let enclosureUrl: String?
4040+ public let enclosureType: String?
4141+ public let duration: Int?
4242+ public let image: String?
4343+ public let episode: Int?
4444+ public let season: Int?
4545+ public let episodeGuid: String?
4646+ public let social: SocialOverlay?
4747+4848+ public var id: Int { episodeId }
4949+}
5050+5151+/// Response from `xyz.effem.search.episodes`.
5252+public struct EpisodeSearchResponse: Codable, Sendable {
5353+ public let items: [EpisodeResult]
5454+ public let count: Int?
5555+}
5656+5757+/// Response from `xyz.effem.podcast.getEpisodes`.
5858+public struct EpisodesResponse: Codable, Sendable {
5959+ public let items: [EpisodeResult]
6060+ public let count: Int?
6161+}
6262+6363+/// Response from `xyz.effem.podcast.getEpisode`.
6464+public struct EpisodeDetailResponse: Codable, Sendable {
6565+ public let episode: EpisodeResult
6666+}
6767+6868+/// Response from `xyz.effem.podcast.getTrending`.
6969+public struct TrendingResponse: Codable, Sendable {
7070+ public let feeds: [PodcastResult]
7171+ public let count: Int?
7272+}
7373+7474+/// A podcast category.
7575+public struct PodcastCategory: Codable, Sendable, Identifiable {
7676+ public let categoryId: Int
7777+ public let name: String
7878+7979+ public var id: Int { categoryId }
8080+}
8181+8282+/// Response from `xyz.effem.podcast.getCategories`.
8383+public struct CategoriesResponse: Codable, Sendable {
8484+ public let feeds: [PodcastCategory]
8585+ public let count: Int?
8686+}
+39
Sources/EffemKit/Models/Recommendation.swift
···11+import Foundation
22+33+/// An episode recommendation as indexed by the AppView.
44+public struct Recommendation: Codable, Sendable, Identifiable {
55+ public let did: String
66+ public let rkey: String
77+ public let episode: EpisodeRef
88+ public let text: String?
99+ public let createdAt: String
1010+ public let author: CommentAuthor?
1111+1212+ public var id: String { "\(did)/\(rkey)" }
1313+1414+ /// AT URI for this recommendation record.
1515+ public var uri: String { "at://\(did)/xyz.effem.feed.recommendation/\(rkey)" }
1616+}
1717+1818+/// Response from `xyz.effem.feed.getRecommendations`.
1919+public struct RecommendationsResponse: Codable, Sendable {
2020+ public let recommendations: [Recommendation]
2121+ public let cursor: String?
2222+}
2323+2424+/// A popular episode with its recommendation count.
2525+public struct PopularEpisode: Codable, Sendable, Identifiable {
2626+ public let episode: EpisodeRef
2727+ public let recommendationCount: Int
2828+ public let title: String?
2929+ public let podcastTitle: String?
3030+ public let image: String?
3131+3232+ public var id: Int { episode.episodeId }
3333+}
3434+3535+/// Response from `xyz.effem.feed.getPopular`.
3636+public struct PopularResponse: Codable, Sendable {
3737+ public let episodes: [PopularEpisode]
3838+ public let cursor: String?
3939+}
+10
Sources/EffemKit/Models/RepoResponses.swift
···11+import Foundation
22+33+/// Response from `com.atproto.repo.createRecord`.
44+public struct CreateRecordResponse: Codable, Sendable {
55+ public let uri: String
66+ public let cid: String
77+}
88+99+/// Empty response for operations that return no body.
1010+public struct EmptyResponse: Codable, Sendable {}
+10
Sources/EffemKit/Models/SocialOverlay.swift
···11+import Foundation
22+33+/// Social engagement stats returned alongside podcast or episode metadata.
44+public struct SocialOverlay: Codable, Sendable {
55+ public let subscriberCount: Int?
66+ public let commentCount: Int?
77+ public let recommendationCount: Int?
88+ public let bookmarkCount: Int?
99+ public let subscribedByFollowing: [String]?
1010+}
+35
Sources/EffemKit/Models/Subscription.swift
···11+import Foundation
22+33+/// A user's podcast subscription as indexed by the AppView.
44+public struct Subscription: Codable, Sendable, Identifiable {
55+ public let did: String
66+ public let rkey: String
77+ public let podcast: PodcastRef
88+ public let createdAt: String
99+1010+ public var id: String { "\(did)/\(rkey)" }
1111+1212+ /// AT URI for this subscription record.
1313+ public var uri: String { "at://\(did)/xyz.effem.feed.subscription/\(rkey)" }
1414+}
1515+1616+/// Response from `xyz.effem.feed.getSubscriptions`.
1717+public struct SubscriptionsResponse: Codable, Sendable {
1818+ public let subscriptions: [Subscription]
1919+ public let cursor: String?
2020+}
2121+2222+/// Response from `xyz.effem.feed.getSubscribers`.
2323+public struct SubscribersResponse: Codable, Sendable {
2424+ public let subscribers: [SubscriberInfo]
2525+ public let cursor: String?
2626+}
2727+2828+/// A user who subscribes to a podcast.
2929+public struct SubscriberInfo: Codable, Sendable, Identifiable {
3030+ public let did: String
3131+ public let displayName: String?
3232+ public let avatar: String?
3333+3434+ public var id: String { did }
3535+}
+60
Sources/EffemKit/RepoAPI.swift
···11+import Foundation
22+import CoreATProtocol
33+import NetworkingKit
44+55+/// Endpoints for writing records to the user's PDS via standard AT Proto repo operations.
66+enum RepoAPI: Sendable {
77+ case createRecord(body: Data)
88+ case deleteRecord(body: Data)
99+ case getRecord(repo: String, collection: String, rkey: String)
1010+ case listRecords(repo: String, collection: String, limit: Int, cursor: String?)
1111+}
1212+1313+extension RepoAPI: EndpointType {
1414+ public var baseURL: URL? {
1515+ get async {
1616+ guard let host = await APEnvironment.current.host,
1717+ let url = URL(string: host) else {
1818+ return nil
1919+ }
2020+ return url
2121+ }
2222+ }
2323+2424+ var path: String {
2525+ switch self {
2626+ case .createRecord: "/xrpc/com.atproto.repo.createRecord"
2727+ case .deleteRecord: "/xrpc/com.atproto.repo.deleteRecord"
2828+ case .getRecord: "/xrpc/com.atproto.repo.getRecord"
2929+ case .listRecords: "/xrpc/com.atproto.repo.listRecords"
3030+ }
3131+ }
3232+3333+ var httpMethod: HTTPMethod {
3434+ switch self {
3535+ case .createRecord, .deleteRecord: .post
3636+ case .getRecord, .listRecords: .get
3737+ }
3838+ }
3939+4040+ var task: HTTPTask {
4141+ switch self {
4242+ case .createRecord(let body), .deleteRecord(let body):
4343+ return .requestParameters(encoding: .jsonDataEncoding(data: body))
4444+4545+ case .getRecord(let repo, let collection, let rkey):
4646+ return .requestParameters(encoding: .urlEncoding(parameters: [
4747+ "repo": repo,
4848+ "collection": collection,
4949+ "rkey": rkey,
5050+ ]))
5151+5252+ case .listRecords(let repo, let collection, let limit, let cursor):
5353+ var params: Parameters = ["repo": repo, "collection": collection, "limit": limit]
5454+ if let cursor { params["cursor"] = cursor }
5555+ return .requestParameters(encoding: .urlEncoding(parameters: params))
5656+ }
5757+ }
5858+5959+ var headers: HTTPHeaders? { nil }
6060+}