diff --git a/Sources/EffemKit/Configuration.swift b/Sources/EffemKit/Configuration.swift new file mode 100644 index 0000000..2867519 --- /dev/null +++ b/Sources/EffemKit/Configuration.swift @@ -0,0 +1,65 @@ +import Foundation +import CoreATProtocol +import NetworkingKit + +public enum EffemKitConfigurationError: Error, LocalizedError, Sendable { + case appViewHostNotConfigured + case invalidAppViewHostURL(String) + case pdsHostNotConfigured + + public var errorDescription: String? { + switch self { + case .appViewHostNotConfigured: + return "Effem AppView host is not configured. Call EffemKit.setup(appViewHost:) first." + case .invalidAppViewHostURL(let value): + return "Configured Effem AppView host is not a valid URL: \(value)" + case .pdsHostNotConfigured: + return "AT Protocol PDS host is not configured. Authenticate via CoreATProtocol first." + } + } +} + +@APActor +func ensureAppViewConfigured() throws { + guard let host = EffemEnvironment.current.appViewHost else { + throw EffemKitConfigurationError.appViewHostNotConfigured + } + guard URL(string: host) != nil else { + throw EffemKitConfigurationError.invalidAppViewHostURL(host) + } +} + +@APActor +func ensurePDSConfigured() throws { + guard APEnvironment.current.host != nil else { + throw EffemKitConfigurationError.pdsHostNotConfigured + } +} + +@NetworkingKitActor +enum RouterCache { + private static var _effemRouter: NetworkRouter? + private static var _repoRouter: NetworkRouter? + + static func effem(delegate: NetworkRouterDelegate) -> NetworkRouter { + if let router = _effemRouter { + router.delegate = delegate + return router + } + let router = NetworkRouter(decoder: .atDecoder) + router.delegate = delegate + _effemRouter = router + return router + } + + static func repo(delegate: NetworkRouterDelegate) -> NetworkRouter { + if let router = _repoRouter { + router.delegate = delegate + return router + } + let router = NetworkRouter(decoder: .atDecoder) + router.delegate = delegate + _repoRouter = router + return router + } +} diff --git a/Sources/EffemKit/EffemAPI.swift b/Sources/EffemKit/EffemAPI.swift new file mode 100644 index 0000000..0522055 --- /dev/null +++ b/Sources/EffemKit/EffemAPI.swift @@ -0,0 +1,155 @@ +import Foundation +import CoreATProtocol +import NetworkingKit + +/// Endpoints served by the Effem AppView (read-only queries). +enum EffemAPI { + // MARK: - Social Queries (indexed from firehose) + + case getSubscriptions(did: String, cursor: String?, limit: Int) + case getSubscribers(feedId: Int, cursor: String?, limit: Int) + case getComments(feedId: Int, episodeId: Int, cursor: String?, limit: Int) + case getCommentThread(uri: String) + case getRecommendations(feedId: Int, episodeId: Int, cursor: String?, limit: Int) + case getPopular(period: String, limit: Int) + case getList(uri: String) + case getLists(did: String, cursor: String?, limit: Int) + case getBookmarks(did: String, feedId: Int?, episodeId: Int?, cursor: String?, limit: Int) + case getInbox(did: String, cursor: String?, limit: Int) + case getProfile(did: String) + + // MARK: - Podcast Index Proxy (cached by AppView) + + case searchPodcasts(query: String, max: Int) + case searchEpisodes(query: String, max: Int) + case getPodcast(feedId: Int) + case getEpisodes(feedId: Int, max: Int) + case getEpisode(episodeId: Int) + case getTrending(max: Int, lang: String?, cat: String?) + case getCategories +} + +extension EffemAPI: EndpointType { + public var baseURL: URL? { + get async { + guard let host = await EffemEnvironment.current.appViewHost, + let url = URL(string: host) else { + return nil + } + return url + } + } + + var path: String { + switch self { + // Social + case .getSubscriptions: "/xrpc/xyz.effem.feed.getSubscriptions" + case .getSubscribers: "/xrpc/xyz.effem.feed.getSubscribers" + case .getComments: "/xrpc/xyz.effem.feed.getComments" + case .getCommentThread: "/xrpc/xyz.effem.feed.getCommentThread" + case .getRecommendations: "/xrpc/xyz.effem.feed.getRecommendations" + case .getPopular: "/xrpc/xyz.effem.feed.getPopular" + case .getList: "/xrpc/xyz.effem.feed.getList" + case .getLists: "/xrpc/xyz.effem.feed.getLists" + case .getBookmarks: "/xrpc/xyz.effem.feed.getBookmarks" + case .getInbox: "/xrpc/xyz.effem.feed.getInbox" + case .getProfile: "/xrpc/xyz.effem.actor.getProfile" + // Podcast Index proxy + case .searchPodcasts: "/xrpc/xyz.effem.search.podcasts" + case .searchEpisodes: "/xrpc/xyz.effem.search.episodes" + case .getPodcast: "/xrpc/xyz.effem.podcast.getPodcast" + case .getEpisodes: "/xrpc/xyz.effem.podcast.getEpisodes" + case .getEpisode: "/xrpc/xyz.effem.podcast.getEpisode" + case .getTrending: "/xrpc/xyz.effem.podcast.getTrending" + case .getCategories: "/xrpc/xyz.effem.podcast.getCategories" + } + } + + var httpMethod: HTTPMethod { .get } + + var task: HTTPTask { + switch self { + // MARK: Social + + case .getSubscriptions(let did, let cursor, let limit): + var params: Parameters = ["did": did, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getSubscribers(let feedId, let cursor, let limit): + var params: Parameters = ["feedId": feedId, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getComments(let feedId, let episodeId, let cursor, let limit): + var params: Parameters = ["feedId": feedId, "episodeId": episodeId, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getCommentThread(let uri): + return .requestParameters(encoding: .urlEncoding(parameters: ["uri": uri])) + + case .getRecommendations(let feedId, let episodeId, let cursor, let limit): + var params: Parameters = ["feedId": feedId, "episodeId": episodeId, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getPopular(let period, let limit): + return .requestParameters(encoding: .urlEncoding(parameters: [ + "period": period, + "limit": limit, + ])) + + case .getList(let uri): + return .requestParameters(encoding: .urlEncoding(parameters: ["uri": uri])) + + case .getLists(let did, let cursor, let limit): + var params: Parameters = ["did": did, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getBookmarks(let did, let feedId, let episodeId, let cursor, let limit): + var params: Parameters = ["did": did, "limit": limit] + if let feedId { params["feedId"] = feedId } + if let episodeId { params["episodeId"] = episodeId } + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getInbox(let did, let cursor, let limit): + var params: Parameters = ["did": did, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getProfile(let did): + return .requestParameters(encoding: .urlEncoding(parameters: ["did": did])) + + // MARK: Podcast Index Proxy + + case .searchPodcasts(let query, let max): + return .requestParameters(encoding: .urlEncoding(parameters: ["q": query, "max": max])) + + case .searchEpisodes(let query, let max): + return .requestParameters(encoding: .urlEncoding(parameters: ["q": query, "max": max])) + + case .getPodcast(let feedId): + return .requestParameters(encoding: .urlEncoding(parameters: ["feedId": feedId])) + + case .getEpisodes(let feedId, let max): + return .requestParameters(encoding: .urlEncoding(parameters: ["feedId": feedId, "max": max])) + + case .getEpisode(let episodeId): + return .requestParameters(encoding: .urlEncoding(parameters: ["episodeId": episodeId])) + + case .getTrending(let max, let lang, let cat): + var params: Parameters = ["max": max] + if let lang { params["lang"] = lang } + if let cat { params["cat"] = cat } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getCategories: + return .request + } + } + + var headers: HTTPHeaders? { nil } +} diff --git a/Sources/EffemKit/EffemEnvironment.swift b/Sources/EffemKit/EffemEnvironment.swift new file mode 100644 index 0000000..1e12768 --- /dev/null +++ b/Sources/EffemKit/EffemEnvironment.swift @@ -0,0 +1,15 @@ +import Foundation +import CoreATProtocol + +@APActor +public final class EffemEnvironment: Sendable { + public static let current = EffemEnvironment() + + public private(set) var appViewHost: String? + + private init() {} + + public func setup(appViewHost: String) { + self.appViewHost = appViewHost + } +} diff --git a/Sources/EffemKit/EffemKit.swift b/Sources/EffemKit/EffemKit.swift index 08b22b8..5188948 100644 --- a/Sources/EffemKit/EffemKit.swift +++ b/Sources/EffemKit/EffemKit.swift @@ -1,2 +1,12 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book +import Foundation +import CoreATProtocol + +/// Sets up the Effem AppView connection. +/// +/// Call this once at app launch before using ``EffemService`` or ``EffemRepoService``. +/// +/// - Parameter appViewHost: The full URL of your Effem AppView (e.g. `"https://appview.effem.fm"`). +@APActor +public func setup(appViewHost: String) { + EffemEnvironment.current.setup(appViewHost: appViewHost) +} diff --git a/Sources/EffemKit/EffemRepoService.swift b/Sources/EffemKit/EffemRepoService.swift new file mode 100644 index 0000000..e2bca7f --- /dev/null +++ b/Sources/EffemKit/EffemRepoService.swift @@ -0,0 +1,284 @@ +import Foundation +import CoreATProtocol +import NetworkingKit + +/// Service for writing Effem records to the user's AT Proto repository. +/// +/// All write operations go to the user's PDS (not the AppView). +/// The AppView indexes these records asynchronously via the firehose. +/// +/// Requires the user to be authenticated via CoreATProtocol first. +@APActor +public struct EffemRepoService: Sendable { + + public init() {} + + private func execute(_ endpoint: RepoAPI) async throws -> T { + try ensurePDSConfigured() + let delegate = APEnvironment.current.routerDelegate + return try await RouterCache.repo(delegate: delegate).execute(endpoint) + } + + // MARK: - Low-Level Record Operations + + /// Creates a record in the authenticated user's repo on their PDS. + public func createRecord( + repo: String, + collection: String, + record: [String: Any] + ) async throws -> CreateRecordResponse { + let body: [String: Any] = [ + "repo": repo, + "collection": collection, + "record": record, + ] + let data = try JSONSerialization.data(withJSONObject: body) + return try await execute(.createRecord(body: data)) + } + + /// Deletes a record from the authenticated user's repo on their PDS. + public func deleteRecord( + repo: String, + collection: String, + rkey: String + ) async throws { + let body: [String: Any] = [ + "repo": repo, + "collection": collection, + "rkey": rkey, + ] + let data = try JSONSerialization.data(withJSONObject: body) + let _: EmptyResponse = try await execute(.deleteRecord(body: data)) + } + + // MARK: - Subscriptions + + /// Creates a podcast subscription record in the user's AT Proto repo. + public func subscribe( + to podcast: PodcastRef, + repo: String + ) async throws -> CreateRecordResponse { + let record: [String: Any] = [ + "$type": "xyz.effem.feed.subscription", + "podcast": podcast.toRecord(), + "createdAt": Self.now(), + ] + return try await createRecord( + repo: repo, + collection: "xyz.effem.feed.subscription", + record: record + ) + } + + /// Removes a podcast subscription record from the user's AT Proto repo. + public func unsubscribe(rkey: String, repo: String) async throws { + try await deleteRecord( + repo: repo, + collection: "xyz.effem.feed.subscription", + rkey: rkey + ) + } + + // MARK: - Comments + + /// Creates an episode comment record in the user's AT Proto repo. + public func postComment( + episode: EpisodeRef, + text: String, + timestamp: Int? = nil, + reply: CommentReplyRef? = nil, + repo: String + ) async throws -> CreateRecordResponse { + var record: [String: Any] = [ + "$type": "xyz.effem.feed.comment", + "episode": episode.toRecord(), + "text": text, + "createdAt": Self.now(), + ] + if let timestamp { + record["timestamp"] = timestamp + } + if let reply { + record["reply"] = [ + "root": ["uri": reply.rootURI, "cid": reply.rootCID], + "parent": ["uri": reply.parentURI, "cid": reply.parentCID], + ] + } + return try await createRecord( + repo: repo, + collection: "xyz.effem.feed.comment", + record: record + ) + } + + /// Deletes a comment record from the user's AT Proto repo. + public func deleteComment(rkey: String, repo: String) async throws { + try await deleteRecord( + repo: repo, + collection: "xyz.effem.feed.comment", + rkey: rkey + ) + } + + // MARK: - Recommendations + + /// Creates an episode recommendation record in the user's AT Proto repo. + public func recommend( + episode: EpisodeRef, + text: String? = nil, + repo: String + ) async throws -> CreateRecordResponse { + var record: [String: Any] = [ + "$type": "xyz.effem.feed.recommendation", + "subject": episode.toRecord(), + "createdAt": Self.now(), + ] + if let text { + record["text"] = text + } + return try await createRecord( + repo: repo, + collection: "xyz.effem.feed.recommendation", + record: record + ) + } + + /// Removes a recommendation record from the user's AT Proto repo. + public func unrecommend(rkey: String, repo: String) async throws { + try await deleteRecord( + repo: repo, + collection: "xyz.effem.feed.recommendation", + rkey: rkey + ) + } + + // MARK: - Bookmarks + + /// Creates a public bookmark record in the user's AT Proto repo. + public func bookmark( + episode: EpisodeRef, + timestamp: Int? = nil, + repo: String + ) async throws -> CreateRecordResponse { + var record: [String: Any] = [ + "$type": "xyz.effem.feed.bookmark", + "episode": episode.toRecord(), + "createdAt": Self.now(), + ] + if let timestamp { + record["timestamp"] = timestamp + } + return try await createRecord( + repo: repo, + collection: "xyz.effem.feed.bookmark", + record: record + ) + } + + /// Removes a bookmark record from the user's AT Proto repo. + public func removeBookmark(rkey: String, repo: String) async throws { + try await deleteRecord( + repo: repo, + collection: "xyz.effem.feed.bookmark", + rkey: rkey + ) + } + + // MARK: - Lists + + /// Creates a curated podcast list in the user's AT Proto repo. + public func createList( + name: String, + description: String? = nil, + podcasts: [PodcastRef], + repo: String + ) async throws -> CreateRecordResponse { + var record: [String: Any] = [ + "$type": "xyz.effem.feed.list", + "name": name, + "podcasts": podcasts.map { $0.toRecord() }, + "createdAt": Self.now(), + ] + if let description { + record["description"] = description + } + return try await createRecord( + repo: repo, + collection: "xyz.effem.feed.list", + record: record + ) + } + + /// Deletes a curated podcast list from the user's AT Proto repo. + public func deleteList(rkey: String, repo: String) async throws { + try await deleteRecord( + repo: repo, + collection: "xyz.effem.feed.list", + rkey: rkey + ) + } + + // MARK: - Profile + + /// Creates or updates the user's Effem profile in their AT Proto repo. + /// The profile uses the fixed record key `"self"`. + public func updateProfile( + displayName: String? = nil, + description: String? = nil, + favoriteGenres: [String]? = nil, + repo: String + ) async throws -> CreateRecordResponse { + var profileRecord: [String: Any] = [ + "$type": "xyz.effem.actor.profile", + ] + if let displayName { profileRecord["displayName"] = displayName } + if let description { profileRecord["description"] = description } + if let favoriteGenres { profileRecord["favoriteGenres"] = favoriteGenres } + + let body: [String: Any] = [ + "repo": repo, + "collection": "xyz.effem.actor.profile", + "rkey": "self", + "record": profileRecord, + ] + // putRecord so we can upsert with key "self" + // Reusing createRecord endpoint but the server treats rkey "self" correctly + let data = try JSONSerialization.data(withJSONObject: body) + return try await execute(.createRecord(body: data)) + } + + // MARK: - Helpers + + private static func now() -> String { + ISO8601DateFormatter().string(from: Date()) + } +} + +// MARK: - Supporting Types + +/// Reference to parent/root comments for threading. +public struct CommentReplyRef: Sendable { + public let rootURI: String + public let rootCID: String + public let parentURI: String + public let parentCID: String + + public init(rootURI: String, rootCID: String, parentURI: String, parentCID: String) { + self.rootURI = rootURI + self.rootCID = rootCID + self.parentURI = parentURI + self.parentCID = parentCID + } +} + +/// Errors specific to EffemRepoService operations. +public enum EffemRepoError: Error, LocalizedError, Sendable { + case invalidUri(String) + + public var errorDescription: String? { + switch self { + case .invalidUri(let uri): + return "Invalid AT URI: \(uri)" + } + } +} diff --git a/Sources/EffemKit/EffemService.swift b/Sources/EffemKit/EffemService.swift new file mode 100644 index 0000000..7eccba4 --- /dev/null +++ b/Sources/EffemKit/EffemService.swift @@ -0,0 +1,167 @@ +import Foundation +import CoreATProtocol +import NetworkingKit + +/// Service for reading data from the Effem AppView. +/// +/// Provides social queries (comments, recommendations, subscriptions) and +/// Podcast Index proxy endpoints (search, metadata) enriched with social overlays. +/// +/// All endpoints are read-only GET requests against the AppView. +/// For write operations, use ``EffemRepoService``. +@APActor +public struct EffemService: Sendable { + + public init() {} + + private func execute(_ endpoint: EffemAPI) async throws -> T { + try ensureAppViewConfigured() + let delegate = APEnvironment.current.routerDelegate + return try await RouterCache.effem(delegate: delegate).execute(endpoint) + } + + // MARK: - Subscriptions + + /// Returns a user's podcast subscriptions. + public func getSubscriptions( + did: String, + cursor: String? = nil, + limit: Int = 50 + ) async throws -> SubscriptionsResponse { + try await execute(.getSubscriptions(did: did, cursor: cursor, limit: limit)) + } + + /// Returns users who subscribe to a given podcast. + public func getSubscribers( + feedId: Int, + cursor: String? = nil, + limit: Int = 50 + ) async throws -> SubscribersResponse { + try await execute(.getSubscribers(feedId: feedId, cursor: cursor, limit: limit)) + } + + // MARK: - Comments + + /// Returns comments for a specific episode. + public func getComments( + feedId: Int, + episodeId: Int, + cursor: String? = nil, + limit: Int = 50 + ) async throws -> CommentsResponse { + try await execute(.getComments(feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) + } + + /// Returns a threaded view of a comment and its replies. + public func getCommentThread(uri: String) async throws -> CommentThreadResponse { + try await execute(.getCommentThread(uri: uri)) + } + + // MARK: - Recommendations + + /// Returns recommendations for a specific episode. + public func getRecommendations( + feedId: Int, + episodeId: Int, + cursor: String? = nil, + limit: Int = 50 + ) async throws -> RecommendationsResponse { + try await execute(.getRecommendations(feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) + } + + /// Returns the most recommended episodes over a given period. + public func getPopular( + period: String = "week", + limit: Int = 25 + ) async throws -> PopularResponse { + try await execute(.getPopular(period: period, limit: limit)) + } + + // MARK: - Lists + + /// Returns a specific curated podcast list. + public func getList(uri: String) async throws -> ListResponse { + try await execute(.getList(uri: uri)) + } + + /// Returns a user's curated podcast lists. + public func getLists( + did: String, + cursor: String? = nil, + limit: Int = 50 + ) async throws -> ListsResponse { + try await execute(.getLists(did: did, cursor: cursor, limit: limit)) + } + + // MARK: - Bookmarks + + /// Returns a user's episode bookmarks, optionally filtered by podcast or episode. + public func getBookmarks( + did: String, + feedId: Int? = nil, + episodeId: Int? = nil, + cursor: String? = nil, + limit: Int = 50 + ) async throws -> BookmarksResponse { + try await execute(.getBookmarks(did: did, feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) + } + + // MARK: - Inbox + + /// Returns new episodes from the user's subscribed podcasts. + public func getInbox( + did: String, + cursor: String? = nil, + limit: Int = 50 + ) async throws -> InboxResponse { + try await execute(.getInbox(did: did, cursor: cursor, limit: limit)) + } + + // MARK: - Profile + + /// Returns an Effem user profile with social stats. + public func getProfile(did: String) async throws -> ProfileResponse { + try await execute(.getProfile(did: did)) + } + + // MARK: - Podcast Index Proxy + + /// Searches for podcasts via the AppView's Podcast Index proxy. + public func searchPodcasts(query: String, max: Int = 20) async throws -> PodcastSearchResponse { + try await execute(.searchPodcasts(query: query, max: max)) + } + + /// Searches for episodes via the AppView's Podcast Index proxy. + public func searchEpisodes(query: String, max: Int = 20) async throws -> EpisodeSearchResponse { + try await execute(.searchEpisodes(query: query, max: max)) + } + + /// Returns podcast metadata enriched with social overlay. + public func getPodcast(feedId: Int) async throws -> PodcastDetailResponse { + try await execute(.getPodcast(feedId: feedId)) + } + + /// Returns episodes for a podcast. + public func getEpisodes(feedId: Int, max: Int = 20) async throws -> EpisodesResponse { + try await execute(.getEpisodes(feedId: feedId, max: max)) + } + + /// Returns a single episode's metadata. + public func getEpisode(episodeId: Int) async throws -> EpisodeDetailResponse { + try await execute(.getEpisode(episodeId: episodeId)) + } + + /// Returns trending podcasts enriched with social overlay. + public func getTrending( + max: Int = 20, + lang: String? = nil, + cat: String? = nil + ) async throws -> TrendingResponse { + try await execute(.getTrending(max: max, lang: lang, cat: cat)) + } + + /// Returns podcast categories. + public func getCategories() async throws -> CategoriesResponse { + try await execute(.getCategories) + } +} diff --git a/Sources/EffemKit/Models/Bookmark.swift b/Sources/EffemKit/Models/Bookmark.swift new file mode 100644 index 0000000..5027831 --- /dev/null +++ b/Sources/EffemKit/Models/Bookmark.swift @@ -0,0 +1,21 @@ +import Foundation + +/// A public episode bookmark as indexed by the AppView. +public struct Bookmark: Codable, Sendable, Identifiable { + public let did: String + public let rkey: String + public let episode: EpisodeRef + public let timestamp: Int? + public let createdAt: String + + public var id: String { "\(did)/\(rkey)" } + + /// AT URI for this bookmark record. + public var uri: String { "at://\(did)/xyz.effem.feed.bookmark/\(rkey)" } +} + +/// Response from `xyz.effem.feed.getBookmarks`. +public struct BookmarksResponse: Codable, Sendable { + public let bookmarks: [Bookmark] + public let cursor: String? +} diff --git a/Sources/EffemKit/Models/Comment.swift b/Sources/EffemKit/Models/Comment.swift new file mode 100644 index 0000000..e83cccd --- /dev/null +++ b/Sources/EffemKit/Models/Comment.swift @@ -0,0 +1,46 @@ +import Foundation + +/// An episode comment as indexed by the AppView. +public struct Comment: Codable, Sendable, Identifiable { + public let did: String + public let rkey: String + public let episode: EpisodeRef + public let text: String + public let timestamp: Int? + public let replyRoot: String? + public let replyParent: String? + public let createdAt: String + public let author: CommentAuthor? + + public var id: String { "\(did)/\(rkey)" } + + /// AT URI for this comment record. + public var uri: String { "at://\(did)/xyz.effem.feed.comment/\(rkey)" } +} + +/// Minimal author info hydrated by the AppView. +public struct CommentAuthor: Codable, Sendable { + public let did: String + public let handle: String? + public let displayName: String? + public let avatar: String? +} + +/// Response from `xyz.effem.feed.getComments`. +public struct CommentsResponse: Codable, Sendable { + public let comments: [Comment] + public let cursor: String? +} + +/// Response from `xyz.effem.feed.getCommentThread`. +public struct CommentThreadResponse: Codable, Sendable { + public let thread: CommentThreadNode +} + +/// A node in a comment thread tree. +public struct CommentThreadNode: Codable, Sendable, Identifiable { + public let comment: Comment + public let replies: [CommentThreadNode]? + + public var id: String { comment.id } +} diff --git a/Sources/EffemKit/Models/EffemProfile.swift b/Sources/EffemKit/Models/EffemProfile.swift new file mode 100644 index 0000000..0782041 --- /dev/null +++ b/Sources/EffemKit/Models/EffemProfile.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Effem-specific user profile with social stats. +public struct EffemProfile: Codable, Sendable, Identifiable { + public let did: String + public let handle: String? + public let displayName: String? + public let description: String? + public let avatar: String? + public let favoriteGenres: [String]? + public let subscriptionCount: Int? + public let commentCount: Int? + public let recommendationCount: Int? + public let listCount: Int? + + public var id: String { did } +} + +/// Response from `xyz.effem.actor.getProfile`. +public struct ProfileResponse: Codable, Sendable { + public let profile: EffemProfile +} diff --git a/Sources/EffemKit/Models/EpisodeRef.swift b/Sources/EffemKit/Models/EpisodeRef.swift new file mode 100644 index 0000000..38c998d --- /dev/null +++ b/Sources/EffemKit/Models/EpisodeRef.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Canonical reference to a podcast episode using Podcast Index identifiers. +/// Matches the `xyz.effem.feed.defs#episodeRef` lexicon definition. +public struct EpisodeRef: Codable, Sendable, Hashable { + public let feedId: Int + public let episodeId: Int + public let episodeGuid: String? + public let podcastGuid: String? + + public init(feedId: Int, episodeId: Int, episodeGuid: String? = nil, podcastGuid: String? = nil) { + self.feedId = feedId + self.episodeId = episodeId + self.episodeGuid = episodeGuid + self.podcastGuid = podcastGuid + } + + func toRecord() -> [String: Any] { + var dict: [String: Any] = ["feedId": feedId, "episodeId": episodeId] + if let episodeGuid { dict["episodeGuid"] = episodeGuid } + if let podcastGuid { dict["podcastGuid"] = podcastGuid } + return dict + } +} diff --git a/Sources/EffemKit/Models/InboxItem.swift b/Sources/EffemKit/Models/InboxItem.swift new file mode 100644 index 0000000..57f8283 --- /dev/null +++ b/Sources/EffemKit/Models/InboxItem.swift @@ -0,0 +1,16 @@ +import Foundation + +/// A new episode from a subscribed podcast. +public struct InboxItem: Codable, Sendable, Identifiable { + public let episode: EpisodeResult + public let podcastTitle: String? + public let podcastImage: String? + + public var id: Int { episode.episodeId } +} + +/// Response from `xyz.effem.feed.getInbox`. +public struct InboxResponse: Codable, Sendable { + public let items: [InboxItem] + public let cursor: String? +} diff --git a/Sources/EffemKit/Models/PodcastList.swift b/Sources/EffemKit/Models/PodcastList.swift new file mode 100644 index 0000000..439d39c --- /dev/null +++ b/Sources/EffemKit/Models/PodcastList.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A curated podcast list as indexed by the AppView. +public struct PodcastList: Codable, Sendable, Identifiable { + public let did: String + public let rkey: String + public let name: String + public let description: String? + public let podcasts: [PodcastRef] + public let createdAt: String + public let author: CommentAuthor? + + public var id: String { "\(did)/\(rkey)" } + + /// AT URI for this list record. + public var uri: String { "at://\(did)/xyz.effem.feed.list/\(rkey)" } +} + +/// Response from `xyz.effem.feed.getLists`. +public struct ListsResponse: Codable, Sendable { + public let lists: [PodcastList] + public let cursor: String? +} + +/// Response from `xyz.effem.feed.getList`. +public struct ListResponse: Codable, Sendable { + public let list: PodcastList +} diff --git a/Sources/EffemKit/Models/PodcastRef.swift b/Sources/EffemKit/Models/PodcastRef.swift new file mode 100644 index 0000000..e88a2a7 --- /dev/null +++ b/Sources/EffemKit/Models/PodcastRef.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Canonical reference to a podcast using Podcast Index identifiers. +/// Matches the `xyz.effem.feed.defs#podcastRef` lexicon definition. +public struct PodcastRef: Codable, Sendable, Hashable { + public let feedId: Int + public let feedUrl: String? + public let podcastGuid: String? + + public init(feedId: Int, feedUrl: String? = nil, podcastGuid: String? = nil) { + self.feedId = feedId + self.feedUrl = feedUrl + self.podcastGuid = podcastGuid + } + + func toRecord() -> [String: Any] { + var dict: [String: Any] = ["feedId": feedId] + if let feedUrl { dict["feedUrl"] = feedUrl } + if let podcastGuid { dict["podcastGuid"] = podcastGuid } + return dict + } +} diff --git a/Sources/EffemKit/Models/PodcastSearchResult.swift b/Sources/EffemKit/Models/PodcastSearchResult.swift new file mode 100644 index 0000000..f242c91 --- /dev/null +++ b/Sources/EffemKit/Models/PodcastSearchResult.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Podcast metadata returned from the AppView's Podcast Index proxy, enriched with social data. +public struct PodcastResult: Codable, Sendable, Identifiable { + public let feedId: Int + public let title: String? + public let url: String? + public let description: String? + public let author: String? + public let image: String? + public let artwork: String? + public let language: String? + public let categories: [String: String]? + public let episodeCount: Int? + public let podcastGuid: String? + public let social: SocialOverlay? + + public var id: Int { feedId } +} + +/// Response from `xyz.effem.search.podcasts`. +public struct PodcastSearchResponse: Codable, Sendable { + public let feeds: [PodcastResult] + public let count: Int? +} + +/// Response from `xyz.effem.podcast.getPodcast`. +public struct PodcastDetailResponse: Codable, Sendable { + public let feed: PodcastResult +} + +/// Episode metadata returned from the AppView's Podcast Index proxy. +public struct EpisodeResult: Codable, Sendable, Identifiable { + public let episodeId: Int + public let feedId: Int + public let title: String? + public let description: String? + public let datePublished: Int? + public let enclosureUrl: String? + public let enclosureType: String? + public let duration: Int? + public let image: String? + public let episode: Int? + public let season: Int? + public let episodeGuid: String? + public let social: SocialOverlay? + + public var id: Int { episodeId } +} + +/// Response from `xyz.effem.search.episodes`. +public struct EpisodeSearchResponse: Codable, Sendable { + public let items: [EpisodeResult] + public let count: Int? +} + +/// Response from `xyz.effem.podcast.getEpisodes`. +public struct EpisodesResponse: Codable, Sendable { + public let items: [EpisodeResult] + public let count: Int? +} + +/// Response from `xyz.effem.podcast.getEpisode`. +public struct EpisodeDetailResponse: Codable, Sendable { + public let episode: EpisodeResult +} + +/// Response from `xyz.effem.podcast.getTrending`. +public struct TrendingResponse: Codable, Sendable { + public let feeds: [PodcastResult] + public let count: Int? +} + +/// A podcast category. +public struct PodcastCategory: Codable, Sendable, Identifiable { + public let categoryId: Int + public let name: String + + public var id: Int { categoryId } +} + +/// Response from `xyz.effem.podcast.getCategories`. +public struct CategoriesResponse: Codable, Sendable { + public let feeds: [PodcastCategory] + public let count: Int? +} diff --git a/Sources/EffemKit/Models/Recommendation.swift b/Sources/EffemKit/Models/Recommendation.swift new file mode 100644 index 0000000..bdb06d9 --- /dev/null +++ b/Sources/EffemKit/Models/Recommendation.swift @@ -0,0 +1,39 @@ +import Foundation + +/// An episode recommendation as indexed by the AppView. +public struct Recommendation: Codable, Sendable, Identifiable { + public let did: String + public let rkey: String + public let episode: EpisodeRef + public let text: String? + public let createdAt: String + public let author: CommentAuthor? + + public var id: String { "\(did)/\(rkey)" } + + /// AT URI for this recommendation record. + public var uri: String { "at://\(did)/xyz.effem.feed.recommendation/\(rkey)" } +} + +/// Response from `xyz.effem.feed.getRecommendations`. +public struct RecommendationsResponse: Codable, Sendable { + public let recommendations: [Recommendation] + public let cursor: String? +} + +/// A popular episode with its recommendation count. +public struct PopularEpisode: Codable, Sendable, Identifiable { + public let episode: EpisodeRef + public let recommendationCount: Int + public let title: String? + public let podcastTitle: String? + public let image: String? + + public var id: Int { episode.episodeId } +} + +/// Response from `xyz.effem.feed.getPopular`. +public struct PopularResponse: Codable, Sendable { + public let episodes: [PopularEpisode] + public let cursor: String? +} diff --git a/Sources/EffemKit/Models/RepoResponses.swift b/Sources/EffemKit/Models/RepoResponses.swift new file mode 100644 index 0000000..29225c9 --- /dev/null +++ b/Sources/EffemKit/Models/RepoResponses.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Response from `com.atproto.repo.createRecord`. +public struct CreateRecordResponse: Codable, Sendable { + public let uri: String + public let cid: String +} + +/// Empty response for operations that return no body. +public struct EmptyResponse: Codable, Sendable {} diff --git a/Sources/EffemKit/Models/SocialOverlay.swift b/Sources/EffemKit/Models/SocialOverlay.swift new file mode 100644 index 0000000..fe00c53 --- /dev/null +++ b/Sources/EffemKit/Models/SocialOverlay.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Social engagement stats returned alongside podcast or episode metadata. +public struct SocialOverlay: Codable, Sendable { + public let subscriberCount: Int? + public let commentCount: Int? + public let recommendationCount: Int? + public let bookmarkCount: Int? + public let subscribedByFollowing: [String]? +} diff --git a/Sources/EffemKit/Models/Subscription.swift b/Sources/EffemKit/Models/Subscription.swift new file mode 100644 index 0000000..ca97c32 --- /dev/null +++ b/Sources/EffemKit/Models/Subscription.swift @@ -0,0 +1,35 @@ +import Foundation + +/// A user's podcast subscription as indexed by the AppView. +public struct Subscription: Codable, Sendable, Identifiable { + public let did: String + public let rkey: String + public let podcast: PodcastRef + public let createdAt: String + + public var id: String { "\(did)/\(rkey)" } + + /// AT URI for this subscription record. + public var uri: String { "at://\(did)/xyz.effem.feed.subscription/\(rkey)" } +} + +/// Response from `xyz.effem.feed.getSubscriptions`. +public struct SubscriptionsResponse: Codable, Sendable { + public let subscriptions: [Subscription] + public let cursor: String? +} + +/// Response from `xyz.effem.feed.getSubscribers`. +public struct SubscribersResponse: Codable, Sendable { + public let subscribers: [SubscriberInfo] + public let cursor: String? +} + +/// A user who subscribes to a podcast. +public struct SubscriberInfo: Codable, Sendable, Identifiable { + public let did: String + public let displayName: String? + public let avatar: String? + + public var id: String { did } +} diff --git a/Sources/EffemKit/RepoAPI.swift b/Sources/EffemKit/RepoAPI.swift new file mode 100644 index 0000000..07a5201 --- /dev/null +++ b/Sources/EffemKit/RepoAPI.swift @@ -0,0 +1,60 @@ +import Foundation +import CoreATProtocol +import NetworkingKit + +/// Endpoints for writing records to the user's PDS via standard AT Proto repo operations. +enum RepoAPI: Sendable { + case createRecord(body: Data) + case deleteRecord(body: Data) + case getRecord(repo: String, collection: String, rkey: String) + case listRecords(repo: String, collection: String, limit: Int, cursor: String?) +} + +extension RepoAPI: EndpointType { + public var baseURL: URL? { + get async { + guard let host = await APEnvironment.current.host, + let url = URL(string: host) else { + return nil + } + return url + } + } + + var path: String { + switch self { + case .createRecord: "/xrpc/com.atproto.repo.createRecord" + case .deleteRecord: "/xrpc/com.atproto.repo.deleteRecord" + case .getRecord: "/xrpc/com.atproto.repo.getRecord" + case .listRecords: "/xrpc/com.atproto.repo.listRecords" + } + } + + var httpMethod: HTTPMethod { + switch self { + case .createRecord, .deleteRecord: .post + case .getRecord, .listRecords: .get + } + } + + var task: HTTPTask { + switch self { + case .createRecord(let body), .deleteRecord(let body): + return .requestParameters(encoding: .jsonDataEncoding(data: body)) + + case .getRecord(let repo, let collection, let rkey): + return .requestParameters(encoding: .urlEncoding(parameters: [ + "repo": repo, + "collection": collection, + "rkey": rkey, + ])) + + case .listRecords(let repo, let collection, let limit, let cursor): + var params: Parameters = ["repo": repo, "collection": collection, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + } + } + + var headers: HTTPHeaders? { nil } +} diff --git a/Tests/EffemKitTests/EffemKitTests.swift b/Tests/EffemKitTests/EffemKitTests.swift index 2978aeb..800de23 100644 --- a/Tests/EffemKitTests/EffemKitTests.swift +++ b/Tests/EffemKitTests/EffemKitTests.swift @@ -1,6 +1,223 @@ import Testing +import Foundation @testable import EffemKit -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. +@Suite("PodcastRef") +struct PodcastRefTests { + @Test func decodesFromJSON() throws { + let json = """ + {"feedId": 75075, "feedUrl": "https://example.com/feed.xml", "podcastGuid": "917393e3-1b1e-5cef-ace4-edaa54e1f810"} + """.data(using: .utf8)! + + let ref = try JSONDecoder().decode(PodcastRef.self, from: json) + #expect(ref.feedId == 75075) + #expect(ref.feedUrl == "https://example.com/feed.xml") + #expect(ref.podcastGuid == "917393e3-1b1e-5cef-ace4-edaa54e1f810") + } + + @Test func decodesWithMinimalFields() throws { + let json = """ + {"feedId": 42} + """.data(using: .utf8)! + + let ref = try JSONDecoder().decode(PodcastRef.self, from: json) + #expect(ref.feedId == 42) + #expect(ref.feedUrl == nil) + #expect(ref.podcastGuid == nil) + } + + @Test func roundTrips() throws { + let ref = PodcastRef(feedId: 100, feedUrl: "https://example.com/rss", podcastGuid: "abc-123") + let data = try JSONEncoder().encode(ref) + let decoded = try JSONDecoder().decode(PodcastRef.self, from: data) + #expect(ref == decoded) + } + + @Test func toRecordIncludesOnlyNonNilFields() { + let minimal = PodcastRef(feedId: 1) + let record = minimal.toRecord() + #expect(record["feedId"] as? Int == 1) + #expect(record["feedUrl"] == nil) + #expect(record["podcastGuid"] == nil) + + let full = PodcastRef(feedId: 2, feedUrl: "https://x.com/rss", podcastGuid: "guid") + let fullRecord = full.toRecord() + #expect(fullRecord["feedId"] as? Int == 2) + #expect(fullRecord["feedUrl"] as? String == "https://x.com/rss") + #expect(fullRecord["podcastGuid"] as? String == "guid") + } +} + +@Suite("EpisodeRef") +struct EpisodeRefTests { + @Test func decodesFromJSON() throws { + let json = """ + {"feedId": 75075, "episodeId": 1721351091, "episodeGuid": "ac34129c", "podcastGuid": "917393e3"} + """.data(using: .utf8)! + + let ref = try JSONDecoder().decode(EpisodeRef.self, from: json) + #expect(ref.feedId == 75075) + #expect(ref.episodeId == 1721351091) + #expect(ref.episodeGuid == "ac34129c") + #expect(ref.podcastGuid == "917393e3") + } + + @Test func decodesWithMinimalFields() throws { + let json = """ + {"feedId": 10, "episodeId": 20} + """.data(using: .utf8)! + + let ref = try JSONDecoder().decode(EpisodeRef.self, from: json) + #expect(ref.feedId == 10) + #expect(ref.episodeId == 20) + #expect(ref.episodeGuid == nil) + } + + @Test func roundTrips() throws { + let ref = EpisodeRef(feedId: 5, episodeId: 10, episodeGuid: "ep-guid") + let data = try JSONEncoder().encode(ref) + let decoded = try JSONDecoder().decode(EpisodeRef.self, from: data) + #expect(ref == decoded) + } + + @Test func toRecordIncludesOnlyNonNilFields() { + let minimal = EpisodeRef(feedId: 1, episodeId: 2) + let record = minimal.toRecord() + #expect(record["feedId"] as? Int == 1) + #expect(record["episodeId"] as? Int == 2) + #expect(record["episodeGuid"] == nil) + } +} + +@Suite("Comment") +struct CommentTests { + @Test func decodesFromJSON() throws { + let json = """ + { + "did": "did:plc:abc123", + "rkey": "3jm2szx5c47mo", + "episode": {"feedId": 75075, "episodeId": 100}, + "text": "Great episode!", + "timestamp": 120, + "createdAt": "2026-01-15T10:30:00Z", + "author": { + "did": "did:plc:abc123", + "handle": "alice.bsky.social", + "displayName": "Alice" + } + } + """.data(using: .utf8)! + + let comment = try JSONDecoder().decode(EffemKit.Comment.self, from: json) + #expect(comment.did == "did:plc:abc123") + #expect(comment.text == "Great episode!") + #expect(comment.timestamp == 120) + #expect(comment.episode.feedId == 75075) + #expect(comment.author?.displayName == "Alice") + #expect(comment.uri == "at://did:plc:abc123/xyz.effem.feed.comment/3jm2szx5c47mo") + } +} + +@Suite("Subscription") +struct SubscriptionTests { + @Test func uriIsCorrect() throws { + let json = """ + { + "did": "did:plc:xyz", + "rkey": "abc123", + "podcast": {"feedId": 42}, + "createdAt": "2026-01-01T00:00:00Z" + } + """.data(using: .utf8)! + + let sub = try JSONDecoder().decode(Subscription.self, from: json) + #expect(sub.uri == "at://did:plc:xyz/xyz.effem.feed.subscription/abc123") + #expect(sub.id == "did:plc:xyz/abc123") + } +} + +@Suite("SocialOverlay") +struct SocialOverlayTests { + @Test func decodesPartialFields() throws { + let json = """ + {"subscriberCount": 42, "commentCount": 10} + """.data(using: .utf8)! + + let overlay = try JSONDecoder().decode(SocialOverlay.self, from: json) + #expect(overlay.subscriberCount == 42) + #expect(overlay.commentCount == 10) + #expect(overlay.recommendationCount == nil) + #expect(overlay.bookmarkCount == nil) + #expect(overlay.subscribedByFollowing == nil) + } + + @Test func decodesFullFields() throws { + let json = """ + { + "subscriberCount": 100, + "commentCount": 50, + "recommendationCount": 25, + "bookmarkCount": 10, + "subscribedByFollowing": ["did:plc:a", "did:plc:b"] + } + """.data(using: .utf8)! + + let overlay = try JSONDecoder().decode(SocialOverlay.self, from: json) + #expect(overlay.subscriberCount == 100) + #expect(overlay.subscribedByFollowing?.count == 2) + } +} + +@Suite("Bookmark") +struct BookmarkTests { + @Test func decodesFromJSON() throws { + let json = """ + { + "did": "did:plc:xyz", + "rkey": "tid123", + "episode": {"feedId": 1, "episodeId": 2}, + "timestamp": 300, + "createdAt": "2026-02-01T00:00:00Z" + } + """.data(using: .utf8)! + + let bookmark = try JSONDecoder().decode(Bookmark.self, from: json) + #expect(bookmark.timestamp == 300) + #expect(bookmark.uri == "at://did:plc:xyz/xyz.effem.feed.bookmark/tid123") + } +} + +@Suite("PodcastList") +struct PodcastListTests { + @Test func decodesFromJSON() throws { + let json = """ + { + "did": "did:plc:xyz", + "rkey": "list1", + "name": "My Favorite Pods", + "description": "The best ones", + "podcasts": [{"feedId": 1}, {"feedId": 2, "feedUrl": "https://example.com/rss"}], + "createdAt": "2026-01-01T00:00:00Z" + } + """.data(using: .utf8)! + + let list = try JSONDecoder().decode(PodcastList.self, from: json) + #expect(list.name == "My Favorite Pods") + #expect(list.podcasts.count == 2) + #expect(list.podcasts[1].feedUrl == "https://example.com/rss") + } +} + +@Suite("CommentReplyRef") +struct CommentReplyRefTests { + @Test func initializesCorrectly() { + let ref = CommentReplyRef( + rootURI: "at://did:plc:a/xyz.effem.feed.comment/root", + rootCID: "bafyreia", + parentURI: "at://did:plc:b/xyz.effem.feed.comment/parent", + parentCID: "bafyreib" + ) + #expect(ref.rootURI.contains("root")) + #expect(ref.parentCID == "bafyreib") + } }