diff --git a/Sources/EffemKit/EffemRepoService.swift b/Sources/EffemKit/EffemRepoService.swift index e45591d..bfd553c 100644 --- a/Sources/EffemKit/EffemRepoService.swift +++ b/Sources/EffemKit/EffemRepoService.swift @@ -51,6 +51,16 @@ public struct EffemRepoService: Sendable { let _: EmptyResponse = try await execute(.deleteRecord(body: data)) } + /// Lists records from a collection in the authenticated user's repo. + public func listRecords( + repo: String, + collection: String, + limit: Int = 50, + cursor: String? = nil + ) async throws -> RepoRecordListResponse { + try await execute(.listRecords(repo: repo, collection: collection, limit: limit, cursor: cursor)) + } + // MARK: - Subscriptions /// Creates a podcast subscription record in the user's AT Proto repo. @@ -70,6 +80,20 @@ public struct EffemRepoService: Sendable { ) } + /// Lists podcast subscription records directly from the user's AT Proto repo. + public func listSubscriptionRecords( + repo: String, + limit: Int = 50, + cursor: String? = nil + ) async throws -> RepoRecordListResponse { + try await listRecords( + repo: repo, + collection: "xyz.effem.feed.subscription", + limit: limit, + cursor: cursor + ) + } + /// Removes a podcast subscription record from the user's AT Proto repo. public func unsubscribe(rkey: String, repo: String) async throws { try await deleteRecord( @@ -271,6 +295,20 @@ public struct EffemRepoService: Sendable { return try await execute(.putRecord(body: data)) } + /// Lists episode-state records directly from the user's AT Proto repo. + public func listEpisodeStateRecords( + repo: String, + limit: Int = 50, + cursor: String? = nil + ) async throws -> RepoRecordListResponse { + try await listRecords( + repo: repo, + collection: "xyz.effem.feed.episodeState", + limit: limit, + cursor: cursor + ) + } + /// Deletes an episode state record from the user's AT Proto repo. public func deleteEpisodeState(rkey: String, repo: String) async throws { try await deleteRecord( diff --git a/Sources/EffemKit/Models/EpisodeState.swift b/Sources/EffemKit/Models/EpisodeState.swift index 8cee361..7320b9f 100644 --- a/Sources/EffemKit/Models/EpisodeState.swift +++ b/Sources/EffemKit/Models/EpisodeState.swift @@ -1,5 +1,17 @@ import Foundation +/// Record payload stored in the user's repo for `xyz.effem.feed.episodeState`. +public struct EpisodeStateRecord: Codable, Sendable { + public let episode: EpisodeRef + public let positionS: Int? + public let durationS: Int? + public let played: Bool? + public let saved: Bool? + public let hidden: Bool? + public let createdAt: String + public let updatedAt: String? +} + /// Per-user, per-episode playback and library state as indexed by the AppView. /// Matches the `xyz.effem.feed.episodeState` lexicon. public struct EpisodeState: Codable, Sendable, Identifiable { diff --git a/Sources/EffemKit/Models/RepoResponses.swift b/Sources/EffemKit/Models/RepoResponses.swift index 29225c9..8b9fd3e 100644 --- a/Sources/EffemKit/Models/RepoResponses.swift +++ b/Sources/EffemKit/Models/RepoResponses.swift @@ -6,5 +6,18 @@ public struct CreateRecordResponse: Codable, Sendable { public let cid: String } +/// Response from `com.atproto.repo.listRecords`. +public struct RepoRecordListResponse: Codable, Sendable { + public let records: [RepoRecord] + public let cursor: String? +} + +/// A single record returned by `com.atproto.repo.listRecords`. +public struct RepoRecord: Codable, Sendable { + public let uri: String + public let cid: String + public let value: RecordValue +} + /// Empty response for operations that return no body. public struct EmptyResponse: Codable, Sendable {} diff --git a/Sources/EffemKit/Models/Subscription.swift b/Sources/EffemKit/Models/Subscription.swift index ca97c32..762449e 100644 --- a/Sources/EffemKit/Models/Subscription.swift +++ b/Sources/EffemKit/Models/Subscription.swift @@ -1,5 +1,11 @@ import Foundation +/// Record payload stored in the user's repo for `xyz.effem.feed.subscription`. +public struct SubscriptionRecord: Codable, Sendable { + public let podcast: PodcastRef + public let createdAt: String +} + /// A user's podcast subscription as indexed by the AppView. public struct Subscription: Codable, Sendable, Identifiable { public let did: String diff --git a/Tests/EffemKitTests/EffemKitTests.swift b/Tests/EffemKitTests/EffemKitTests.swift index 800de23..b39a3f6 100644 --- a/Tests/EffemKitTests/EffemKitTests.swift +++ b/Tests/EffemKitTests/EffemKitTests.swift @@ -136,6 +136,63 @@ struct SubscriptionTests { } } +@Suite("RepoRecordList") +struct RepoRecordListTests { + @Test func decodesSubscriptionRecords() throws { + let json = """ + { + "records": [ + { + "uri": "at://did:plc:xyz/xyz.effem.feed.subscription/abc123", + "cid": "bafytest", + "value": { + "podcast": {"feedId": 42, "feedUrl": "https://example.com/feed.xml"}, + "createdAt": "2026-01-01T00:00:00Z" + } + } + ], + "cursor": "abc123" + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(RepoRecordListResponse.self, from: json) + #expect(response.records.count == 1) + #expect(response.records[0].uri == "at://did:plc:xyz/xyz.effem.feed.subscription/abc123") + #expect(response.records[0].value.podcast.feedId == 42) + #expect(response.cursor == "abc123") + } + + @Test func decodesEpisodeStateRecords() throws { + let json = """ + { + "records": [ + { + "uri": "at://did:plc:xyz/xyz.effem.feed.episodeState/12345", + "cid": "bafyepisode", + "value": { + "episode": {"feedId": 99, "episodeId": 12345}, + "positionS": 180, + "durationS": 3600, + "played": false, + "saved": true, + "hidden": false, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z" + } + } + ] + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(RepoRecordListResponse.self, from: json) + #expect(response.records.count == 1) + #expect(response.records[0].value.episode.feedId == 99) + #expect(response.records[0].value.episode.episodeId == 12345) + #expect(response.records[0].value.saved == true) + #expect(response.cursor == nil) + } +} + @Suite("SocialOverlay") struct SocialOverlayTests { @Test func decodesPartialFields() throws {