From edd72f60041831b95c2f91e036184210699c4d6f Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Fri, 6 Mar 2026 14:33:50 -0500 Subject: [PATCH] support episodeState --- Sources/EffemKit/EffemAPI.swift | 10 +++++ Sources/EffemKit/EffemRepoService.swift | 43 ++++++++++++++++++++++ Sources/EffemKit/EffemService.swift | 14 +++++++ Sources/EffemKit/Models/EpisodeState.swift | 33 +++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 Sources/EffemKit/Models/EpisodeState.swift diff --git a/Sources/EffemKit/EffemAPI.swift b/Sources/EffemKit/EffemAPI.swift index 0522055..def3f07 100644 --- a/Sources/EffemKit/EffemAPI.swift +++ b/Sources/EffemKit/EffemAPI.swift @@ -15,6 +15,7 @@ enum EffemAPI { 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 getEpisodeStates(did: String, feedId: Int?, saved: Bool?, showHidden: Bool?, cursor: String?, limit: Int) case getInbox(did: String, cursor: String?, limit: Int) case getProfile(did: String) @@ -52,6 +53,7 @@ extension EffemAPI: EndpointType { case .getList: "/xrpc/xyz.effem.feed.getList" case .getLists: "/xrpc/xyz.effem.feed.getLists" case .getBookmarks: "/xrpc/xyz.effem.feed.getBookmarks" + case .getEpisodeStates: "/xrpc/xyz.effem.feed.getEpisodeStates" case .getInbox: "/xrpc/xyz.effem.feed.getInbox" case .getProfile: "/xrpc/xyz.effem.actor.getProfile" // Podcast Index proxy @@ -115,6 +117,14 @@ extension EffemAPI: EndpointType { if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) + case .getEpisodeStates(let did, let feedId, let saved, let showHidden, let cursor, let limit): + var params: Parameters = ["did": did, "limit": limit] + if let feedId { params["feedId"] = feedId } + if let saved, saved { params["saved"] = "true" } + if let showHidden, showHidden { params["hidden"] = "true" } + 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 } diff --git a/Sources/EffemKit/EffemRepoService.swift b/Sources/EffemKit/EffemRepoService.swift index e2bca7f..f468224 100644 --- a/Sources/EffemKit/EffemRepoService.swift +++ b/Sources/EffemKit/EffemRepoService.swift @@ -184,6 +184,49 @@ public struct EffemRepoService: Sendable { ) } + // MARK: - Episode States + + /// Creates or updates an episode state record in the user's AT Proto repo. + public func putEpisodeState( + episode: EpisodeRef, + positionS: Int? = nil, + durationS: Int? = nil, + played: Bool? = nil, + saved: Bool? = nil, + hidden: Bool? = nil, + repo: String + ) async throws -> CreateRecordResponse { + var record: [String: Any] = [ + "$type": "xyz.effem.feed.episodeState", + "episode": episode.toRecord(), + "createdAt": Self.now(), + ] + if let positionS { record["positionS"] = positionS } + if let durationS { record["durationS"] = durationS } + if let played { record["played"] = played } + if let saved { record["saved"] = saved } + if let hidden { record["hidden"] = hidden } + record["updatedAt"] = Self.now() + + let body: [String: Any] = [ + "repo": repo, + "collection": "xyz.effem.feed.episodeState", + "rkey": String(episode.episodeId), + "record": record, + ] + let data = try JSONSerialization.data(withJSONObject: body) + return try await execute(.createRecord(body: data)) + } + + /// Deletes an episode state record from the user's AT Proto repo. + public func deleteEpisodeState(rkey: String, repo: String) async throws { + try await deleteRecord( + repo: repo, + collection: "xyz.effem.feed.episodeState", + rkey: rkey + ) + } + // MARK: - Lists /// Creates a curated podcast list in the user's AT Proto repo. diff --git a/Sources/EffemKit/EffemService.swift b/Sources/EffemKit/EffemService.swift index 54b93c8..a636799 100644 --- a/Sources/EffemKit/EffemService.swift +++ b/Sources/EffemKit/EffemService.swift @@ -105,6 +105,20 @@ public struct EffemService: Sendable { try await execute(.getBookmarks(did: did, feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) } + // MARK: - Episode States + + /// Returns a user's episode states, optionally filtered by podcast, saved status, or hidden. + public func getEpisodeStates( + did: String, + feedId: Int? = nil, + saved: Bool? = nil, + showHidden: Bool? = nil, + cursor: String? = nil, + limit: Int = 50 + ) async throws -> EpisodeStatesResponse { + try await execute(.getEpisodeStates(did: did, feedId: feedId, saved: saved, showHidden: showHidden, cursor: cursor, limit: limit)) + } + // MARK: - Inbox /// Returns new episodes from the user's subscribed podcasts. diff --git a/Sources/EffemKit/Models/EpisodeState.swift b/Sources/EffemKit/Models/EpisodeState.swift new file mode 100644 index 0000000..8cee361 --- /dev/null +++ b/Sources/EffemKit/Models/EpisodeState.swift @@ -0,0 +1,33 @@ +import Foundation + +/// 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 { + public let did: String + public let rkey: String + public let feedId: Int + public let episodeId: Int + public let episodeGuid: String? + public let podcastGuid: String? + 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? + + public var id: String { "\(did)/\(rkey)" } + + public var uri: String { "at://\(did)/xyz.effem.feed.episodeState/\(rkey)" } + + public var episode: EpisodeRef { + EpisodeRef(feedId: feedId, episodeId: episodeId, episodeGuid: episodeGuid, podcastGuid: podcastGuid) + } +} + +/// Response from `xyz.effem.feed.getEpisodeStates`. +public struct EpisodeStatesResponse: Codable, Sendable { + public let episodeStates: [EpisodeState] + public let cursor: String? +} -- 2.51.2