Commit 8d18b166 authored by Kourser's avatar Kourser
Browse files

fix(sync): propagate local unsubscribes via a three-way merge



Subscriptions were only ever uploaded with `remove: []`, so a local
unsubscribe never reached the gpodder.net / Nextcloud server and stale
feeds (e.g. a removed duplicate) lingered there.

- Reconcile via a three-way merge against a persisted baseline (the
  subscription set in sync at the last successful sync): apply remote-only
  changes locally and push only local-only adds *and* removes.
- A podcast added on another device is no longer mistaken for a local
  removal, so multi-device sync is robust (no accidental unsubscribes).
- First sync (no baseline) unions both sides and removes nothing; removals
  start propagating from the next sync.

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent 8dcbe9fa
Loading
Loading
Loading
Loading
Loading
+49 −14
Original line number Diff line number Diff line
@@ -575,9 +575,10 @@ final class AppModel {

    // MARK: Sync (subscriptions)

    /// Pulls remote subscription changes, applies them locally, then pushes the
    /// local subscription set. Subscription sync only — played/position sync
    /// requires local play-state persistence (not yet implemented).
    /// Reconciles subscriptions with the server via a three-way merge against
    /// the last-synced baseline: remote-only changes are applied locally and
    /// local-only changes (adds *and* removes) are pushed, so edits on one
    /// device never clobber another's. Then syncs episode play actions.
    func syncNow() async {
        guard let credentials = syncSettings.credentials() else {
            syncMessage = String(localized: "Configurez d'abord la synchronisation.")
@@ -587,19 +588,53 @@ final class AppModel {
        defer { isSyncing = false }
        let provider = makeSyncProvider(credentials)
        do {
            let changes = try await provider.subscriptionChanges(since: syncSettings.lastTimestamp)
            for url in changes.add where !isSubscribed(feedURL: url) {
            // Robust multi-device subscription sync via a three-way merge
            // against the last-synced baseline. Both backends return the full
            // list for `since: nil`, so we diff server-vs-baseline and
            // local-vs-baseline and apply/push only genuine changes — a podcast
            // added on another device is never mistaken for a local removal.
            let remote = try await provider.subscriptionChanges(since: nil)
            let serverNow = Set(remote.add)
            let localNow = Set(podcasts.map(\.feedURL))
            var newTimestamp: Int?

            if syncSettings.hasSyncedSubscriptions {
                let baseline = syncSettings.syncedSubscriptions
                let localAdds = localNow.subtracting(baseline)
                let localRemoves = baseline.subtracting(localNow)
                let remoteAdds = serverNow.subtracting(baseline)
                let remoteRemoves = baseline.subtracting(serverNow)

                // Apply remote-only changes locally.
                for url in remoteAdds where !isSubscribed(feedURL: url) {
                    await subscribe(feedURL: url)
                }
            for url in changes.remove {
                for url in remoteRemoves {
                    if let podcast = podcasts.first(where: { $0.feedURL == url }) {
                        await unsubscribe(podcast)
                    }
                }
            let newTimestamp = try await provider.uploadSubscriptionChanges(
                add: podcasts.map(\.feedURL), remove: []
            )
            syncSettings.lastTimestamp = newTimestamp ?? changes.timestamp ?? syncSettings.lastTimestamp
                // Push only what changed locally — never the whole set.
                if !localAdds.isEmpty || !localRemoves.isEmpty {
                    newTimestamp = try await provider.uploadSubscriptionChanges(
                        add: Array(localAdds), remove: Array(localRemoves))
                }
            } else {
                // First sync (no baseline): union and never remove; removals
                // begin propagating from the next sync.
                for url in serverNow where !isSubscribed(feedURL: url) {
                    await subscribe(feedURL: url)
                }
                let localOnly = Array(localNow.subtracting(serverNow))
                if !localOnly.isEmpty {
                    newTimestamp = try await provider.uploadSubscriptionChanges(
                        add: localOnly, remove: [])
                }
            }

            syncSettings.lastTimestamp = newTimestamp ?? remote.timestamp ?? syncSettings.lastTimestamp
            // The reconciled local set becomes the baseline for the next sync.
            syncSettings.saveBaseline(Set(podcasts.map(\.feedURL)))

            try await syncEpisodeActions(using: provider)

+24 −1
Original line number Diff line number Diff line
@@ -13,6 +13,12 @@ final class SyncSettings {
    var password: String
    var lastTimestamp: Int?
    var lastEpisodeTimestamp: Int?
    /// Feed URLs in sync with the server as of the last successful sync — the
    /// baseline for computing local add/remove deltas (robust multi-device sync).
    private(set) var syncedSubscriptions: Set<URL>
    /// Whether a baseline has ever been recorded; distinguishes a first sync
    /// from "synced to an empty set".
    private(set) var hasSyncedSubscriptions: Bool

    @ObservationIgnored private let defaults = UserDefaults.standard
    private static let passwordAccount = "sync.password"
@@ -26,6 +32,21 @@ final class SyncSettings {
        lastTimestamp = stored == 0 ? nil : stored
        let storedEpisode = defaults.integer(forKey: "sync.epTimestamp")
        lastEpisodeTimestamp = storedEpisode == 0 ? nil : storedEpisode
        if let stored = defaults.array(forKey: "sync.baseline") as? [String] {
            syncedSubscriptions = Set(stored.compactMap(URL.init(string:)))
            hasSyncedSubscriptions = true
        } else {
            syncedSubscriptions = []
            hasSyncedSubscriptions = false
        }
    }

    /// Records the set of subscriptions now in sync with the server, as the
    /// baseline for the next sync's delta computation.
    func saveBaseline(_ feeds: Set<URL>) {
        syncedSubscriptions = feeds
        hasSyncedSubscriptions = true
        defaults.set(feeds.map(\.absoluteString), forKey: "sync.baseline")
    }

    func save() {
@@ -45,7 +66,9 @@ final class SyncSettings {
        password = ""
        lastTimestamp = nil
        lastEpisodeTimestamp = nil
        for key in ["sync.kind", "sync.server", "sync.user", "sync.timestamp", "sync.epTimestamp"] {
        syncedSubscriptions = []
        hasSyncedSubscriptions = false
        for key in ["sync.kind", "sync.server", "sync.user", "sync.timestamp", "sync.epTimestamp", "sync.baseline"] {
            defaults.removeObject(forKey: key)
        }
        KeychainStore.delete(account: Self.passwordAccount)