From 225e2450d1f4096be1f1a756c873c9f9827f03f7 Mon Sep 17 00:00:00 2001 From: chaeyn Date: Thu, 15 Oct 2026 15:36:48 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20iOS=20YouTube=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=B1=84=EB=84=90=20=EC=84=A0=ED=83=9D=EA=B3=BC=20?= =?UTF-8?q?=EB=B0=A9=EC=86=A1=20=EC=A4=80=EB=B9=84=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveStatus/BroadcastLiveStatusPanel.swift | 2 +- .../BroadcastPlatformSelectionView.swift | 142 +++--------------- .../Settings/BroadcastSettingsView.swift | 29 ++++ .../YouTube/BroadcastSettingsEditor.swift | 8 +- .../YouTube/StreamingAccountModels.swift | 2 + .../Features/YouTube/YouTubeAPI.swift | 21 ++- .../YouTube/YouTubeAuthorization.swift | 1 + .../Features/YouTube/YouTubeIntegration.swift | 58 +++++-- .../Features/YouTube/YouTubeModels.swift | 9 +- .../Resources/YouTubeChannels.xcstrings | 72 +++++++++ .../BroadcastSettingsTests.swift | 14 ++ .../YouTubeAccountAPITests.swift | 26 ++++ .../YouTubeIntegrationAccountTests.swift | 29 +++- contracts/api/youtube-multi-channel-v1.md | 15 ++ .../fixtures/youtube-multi-channel.v1.json | 28 ++++ docs/ios-youtube-multi-channel.md | 7 + 16 files changed, 314 insertions(+), 149 deletions(-) create mode 100644 apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings create mode 100644 contracts/api/youtube-multi-channel-v1.md create mode 100644 contracts/fixtures/youtube-multi-channel.v1.json create mode 100644 docs/ios-youtube-multi-channel.md diff --git a/apps/ios/InnoLive/InnoLive/Features/Home/LiveStatus/BroadcastLiveStatusPanel.swift b/apps/ios/InnoLive/InnoLive/Features/Home/LiveStatus/BroadcastLiveStatusPanel.swift index 496f5055..f69de87d 100644 --- a/apps/ios/InnoLive/InnoLive/Features/Home/LiveStatus/BroadcastLiveStatusPanel.swift +++ b/apps/ios/InnoLive/InnoLive/Features/Home/LiveStatus/BroadcastLiveStatusPanel.swift @@ -45,7 +45,7 @@ struct BroadcastLiveStatusPanel: View { Circle() .fill(Self.dotColor(tone)) .frame(width: dotSize, height: dotSize) - Text(BroadcastSettingsProvider(rawValue: target.provider)?.title ?? target.title) + Text(target.provider == "youtube" ? target.title : (BroadcastSettingsProvider(rawValue: target.provider)?.title ?? target.title)) .fontWeight(.semibold) Spacer(minLength: 8) Text(policy.targetLabel) diff --git a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift index f42c524c..a72eea8f 100644 --- a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift +++ b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift @@ -10,6 +10,7 @@ struct BroadcastPlatformSelectionView: View { @ObservedObject var authentication: AuthSession @ObservedObject var youtube: YouTubeIntegration @State private var isShowingDisconnectConfirmation = false + @State private var disconnectAccount: StreamingAccountSummary? @State private var isShowingCHZZKDisconnectConfirmation = false private var visiblePlatforms: [BroadcastPlatform] { @@ -139,133 +140,36 @@ struct BroadcastPlatformSelectionView: View { } private var youTubeRow: some View { - Group { - if let connection = youtube.connection { - connectedYouTubeAccountRow(connection) - } else { - SettingsGlassRow { - HStack(spacing: 12) { - platformIdentity(.youTube) - Spacer() - - if youtube.isRefreshingConnection { - ProgressView() - .controlSize(.small) - } else { - Button(action: connectYouTube) { - if youtube.isConnecting { - ProgressView() - } else { - Text(String(localized: "계정 연결")) - } - } - .innoLiveGlassButtonStyle(prominent: true) - .tint(.blue) - .disabled(youtube.isYouTubeAccountChangeBlocked) - } + VStack(alignment: .leading, spacing: 12) { + platformIdentity(.youTube) + ForEach(youtube.youtubeAccounts, id: \.channelID) { account in + HStack { + VStack(alignment: .leading) { + Text(account.displayTitle).font(.callout.weight(.semibold)) + if account.reconnectRequired { Text(String(localized: "재연결 필요")).font(.caption) } + } + Spacer() + Button(String(localized: "연결 해제"), role: .destructive) { + disconnectAccount = account + isShowingDisconnectConfirmation = true } + .disabled(!youtube.canDisconnectYouTubeChannel(account)) } } + Button(String(localized: "채널 추가 연결", table: "YouTubeChannels"), action: connectYouTube) + .disabled(youtube.isYouTubeAccountChangeBlocked || youtube.youtubeAccounts.count >= 5) + if youtube.isYouTubeConnectionOperationInProgress { ProgressView() } } - .confirmationDialog( - String(localized: "YouTube 연결 해제"), - isPresented: $isShowingDisconnectConfirmation, - titleVisibility: .visible - ) { + .padding(16) + .confirmationDialog(String(localized: "YouTube 연결 해제"), + isPresented: $isShowingDisconnectConfirmation, titleVisibility: .visible) { Button(String(localized: "연결 해제"), role: .destructive) { - Task { - await youtube.disconnectYouTubeAccount( - accessToken: authentication.currentAccessToken() - ) - } + guard let account = disconnectAccount else { return } + Task { await youtube.disconnectYouTubeAccount(accessToken: authentication.currentAccessToken(), accountID: account.accountID) } } - .disabled(!youtube.canDisconnectYouTubeAccount) Button(String(localized: "취소"), role: .cancel) {} } message: { - Text(String(localized: "연결을 해제하면 다시 방송하기 전에 YouTube 계정을 연결해야 합니다.")) - } - } - - private func connectedYouTubeAccountRow(_ connection: YouTubeConnection) -> some View { - VStack(alignment: .leading, spacing: 8) { - platformIdentity(.youTube, allowsWrapping: true) - - VStack(alignment: .leading, spacing: 2) { - Text(connection.channel.title) - .font(.callout.weight(.semibold)) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - Text( - connection.requiresReconnection - ? String(localized: "재연결 필요") - : String(localized: "연결됨") - ) - .font(.caption) - .foregroundStyle(connection.requiresReconnection ? .orange : .secondary) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - - youTubeConnectedAccountActions(requiresReconnection: connection.requiresReconnection) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 16) - .padding(.vertical, 16) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .innoLiveGlassBackground(cornerRadius: 16) - } - - private func youTubeConnectedAccountActions(requiresReconnection: Bool) -> some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 8) { - youTubeConnectedAccountActionControls( - requiresReconnection: requiresReconnection, - allowsWrapping: false - ) - } - VStack(alignment: .leading, spacing: 8) { - youTubeConnectedAccountActionControls( - requiresReconnection: requiresReconnection, - allowsWrapping: true - ) - } - } - } - - @ViewBuilder - private func youTubeConnectedAccountActionControls( - requiresReconnection: Bool, - allowsWrapping: Bool - ) -> some View { - if youtube.isConnecting || youtube.isRefreshingConnection || youtube.isDisconnecting { - ProgressView() - .controlSize(.small) - } - if requiresReconnection { - Button(action: connectYouTube) { - youTubeAccountActionLabel(String(localized: "다시 연결"), allowsWrapping: allowsWrapping) - } - .buttonStyle(.borderless) - .disabled(youtube.isYouTubeAccountChangeBlocked) - } - Button(role: .destructive) { - isShowingDisconnectConfirmation = true - } label: { - youTubeAccountActionLabel(String(localized: "연결 해제"), allowsWrapping: allowsWrapping) - } - .buttonStyle(.borderless) - .disabled(!youtube.canDisconnectYouTubeAccount) - } - - @ViewBuilder - private func youTubeAccountActionLabel(_ title: String, allowsWrapping: Bool) -> some View { - if allowsWrapping { - Text(title) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } else { - Text(title) + Text(disconnectAccount?.displayTitle ?? "YouTube") } } diff --git a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastSettingsView.swift b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastSettingsView.swift index d50d6c3e..f3625609 100644 --- a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastSettingsView.swift +++ b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastSettingsView.swift @@ -7,6 +7,7 @@ struct BroadcastSettingsView: View { @ObservedObject private var editor: BroadcastSettingsEditor @State private var query = "" @State private var isSaved = false + @State private var isConfirmingChannel = false @State private var transmissionNoticeProvider: BroadcastSettingsProvider? @State private var nextTransmissionNoticeProvider: BroadcastSettingsProvider? @State private var isShowingMediaConsent = false @@ -99,6 +100,20 @@ struct BroadcastSettingsView: View { .disabled(!youtube.canEditLiveBroadcast(provider) || youtube.isSavingLiveSettings) } } + if editor.provider == .youtube, !isLiveEditing, !youtube.youtubeAccounts.isEmpty { + Section(String(localized: "YouTube 채널", table: "YouTubeChannels")) { + Picker(String(localized: "방송할 채널", table: "YouTubeChannels"), selection: Binding( + get: { youtube.selectedYouTubeAccountID ?? "" }, + set: { youtube.selectYouTubeChannel($0.isEmpty ? nil : $0, accessToken: token) } + )) { + ForEach(youtube.youtubeAccounts, id: \.channelID) { account in + Text(account.displayTitle).tag(account.accountID ?? "") + } + } + .disabled(!youtube.canSelectYouTubeChannel) + .accessibilityIdentifier("youtube-channel-picker") + } + } if editor.provider == .youtube { youtubeForm } else { chzzkForm } if isLiveEditing { @@ -144,6 +159,10 @@ struct BroadcastSettingsView: View { } .navigationTitle(isLiveEditing ? String(localized: "방송 정보 수정") : String(localized: "방송 설정")) .navigationBarTitleDisplayMode(.inline) + .confirmationDialog(String(localized: "방송할 채널", table: "YouTubeChannels"), isPresented: $isConfirmingChannel, titleVisibility: .visible) { + Button(youtube.selectedYouTubeAccount?.displayTitle ?? "YouTube") { performPrimaryAction() } + Button(String(localized: "취소"), role: .cancel) {} + } .onChange(of: youtube.selectedBroadcastProviders) { _, selected in if !isLiveEditing, modeProvider == nil, !selected.contains(editor.provider), let provider = BroadcastSettingsProvider.allCases.first(where: selected.contains) { @@ -391,6 +410,16 @@ struct BroadcastSettingsView: View { } private func searchCHZZK() { Task { await editor.searchCategories(query: query, accessToken: token) } } private func primaryAction() { + if !isLiveEditing, youtube.youtubeAccounts.count > 1, + (modeProvider == .youtube || (onPrepare != nil && youtube.selectedBroadcastProviders.contains(.youtube))), + youtube.canSelectYouTubeChannel { + isConfirmingChannel = true + } else { + performPrimaryAction() + } + } + + private func performPrimaryAction() { if isLiveEditing { Task { isSaved = await youtube.saveLiveSettings(accessToken: token) } return diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/BroadcastSettingsEditor.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/BroadcastSettingsEditor.swift index 903b353e..7188b835 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/BroadcastSettingsEditor.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/BroadcastSettingsEditor.swift @@ -18,6 +18,7 @@ final class BroadcastSettingsEditor: ObservableObject { private let api: YouTubeAPI private let preferences: YouTubePreferencesStore private(set) var contextGeneration = UUID() + private var youtubeAccountID: String? private var context: String? private var keys: [BroadcastSettingsProvider: String] = [:] private var revisions: [BroadcastSettingsProvider: Int] = [:] @@ -31,11 +32,12 @@ final class BroadcastSettingsEditor: ObservableObject { self.preferences = preferences } - func configure(scope: BroadcastSessionScope?, channels: [String: String]) { - let next = scope.map { $0.storageKey + ":" + (channels["youtube"] ?? "") + ":" + (channels["chzzk"] ?? "") } + func configure(scope: BroadcastSessionScope?, channels: [String: String], youtubeAccountID: String? = nil) { + let next = scope.map { $0.storageKey + ":" + (youtubeAccountID ?? channels["youtube"] ?? "") + ":" + (channels["chzzk"] ?? "") } guard next != context else { return } reset() context = next + self.youtubeAccountID = youtubeAccountID guard let scope else { return } for provider in BroadcastSettingsProvider.allCases { let channel = channels[provider.rawValue] ?? "unconnected" @@ -113,7 +115,7 @@ final class BroadcastSettingsEditor: ObservableObject { loadingDefaults.insert(provider) defer { if requests[provider] == request { loadingDefaults.remove(provider) } } do { - let values = try await api.broadcastDefaults(session: session, accessToken: accessToken, provider: provider) + let values = try await api.broadcastDefaults(session: session, accessToken: accessToken, provider: provider, accountID: provider == .youtube ? youtubeAccountID : nil) guard context == originalContext, requests[provider] == request, usesDefaults[provider] == true, revisions[provider, default: 0] == revision, revision == 0, !Task.isCancelled else { return } if provider == .youtube { diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/StreamingAccountModels.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/StreamingAccountModels.swift index a0831107..99768947 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/StreamingAccountModels.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/StreamingAccountModels.swift @@ -1,6 +1,7 @@ import Foundation struct StreamingAccountSummary: Decodable, Equatable { + var accountID: String? = nil let provider: String let channelID: String let channelTitle: String @@ -8,6 +9,7 @@ struct StreamingAccountSummary: Decodable, Equatable { let reconnectRequired: Bool enum CodingKeys: String, CodingKey { + case accountID = "id" case provider case channelID = "channel_id" case channelTitle = "channel_title" diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAPI.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAPI.swift index 96d0eeaa..149ff4a9 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAPI.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAPI.swift @@ -64,6 +64,8 @@ private struct YouTubeBroadcastSettingsRequest: Encodable { private struct YouTubePrepareStreamRequest: Encodable { var provider = "youtube" + var accountID: String? = nil + enum CodingKeys: String, CodingKey { case provider; case accountID = "account_id" } } private struct YouTubeEmptyRequest: Encodable {} @@ -90,6 +92,8 @@ struct YouTubeSessionCreateRequest: Encodable, Equatable { private struct BroadcastModeRequest: Encodable { let resolution: String let targets: [String] + var accountID: String? = nil + enum CodingKeys: String, CodingKey { case resolution, targets; case accountID = "account_id" } } private struct UpgradeOfferSelectionRequest: Encodable { @@ -220,8 +224,8 @@ final class YouTubeAPI: PlanAPIClient { ) } - func disconnectStreamingAccount(accessToken: String, provider: String = "youtube") async throws { - guard let url = serverURLProvider("/auth/streaming/accounts/\(provider)") else { + func disconnectStreamingAccount(accessToken: String, provider: String = "youtube", accountID: String? = nil) async throws { + guard let url = serverURLProvider("/auth/streaming/accounts/\(provider)" + (accountID.map { "/" + $0 } ?? "")) else { throw YouTubeAPIError.configuration } var request = URLRequest(url: url) @@ -320,10 +324,10 @@ final class YouTubeAPI: PlanAPIClient { } func changeBroadcastMode(session: YouTubeBroadcastSession, accessToken: String, - resolution: String, targets: Set) async throws -> YouTubeSessionResponse { + resolution: String, targets: Set, accountID: String? = nil) async throws -> YouTubeSessionResponse { try await request(path: "/sessions/\(session.sessionID)/broadcast-mode", method: "PUT", accessToken: accessToken, ownerToken: session.ownerToken, - body: BroadcastModeRequest(resolution: resolution, targets: targets.map(\.rawValue).sorted())) + body: BroadcastModeRequest(resolution: resolution, targets: targets.map(\.rawValue).sorted(), accountID: accountID)) } func selectUpgradeOffer(session: YouTubeBroadcastSession, accessToken: String, @@ -340,11 +344,11 @@ final class YouTubeAPI: PlanAPIClient { } func broadcastDefaults(session: YouTubeBroadcastSession, accessToken: String, - provider: BroadcastSettingsProvider) async throws -> BroadcastSettingsDefaults { + provider: BroadcastSettingsProvider, accountID: String? = nil) async throws -> BroadcastSettingsDefaults { try await request(path: "/sessions/\(session.sessionID)/broadcast/defaults", method: "GET", accessToken: accessToken, ownerToken: session.ownerToken, body: Optional.none, - queryItems: [URLQueryItem(name: "provider", value: provider.rawValue)]) + queryItems: [URLQueryItem(name: "provider", value: provider.rawValue)] + (accountID.map { [URLQueryItem(name: "account_id", value: $0)] } ?? [])) } func youtubeCategories(accessToken: String) async throws -> [YouTubeBroadcastCategory] { @@ -365,14 +369,15 @@ final class YouTubeAPI: PlanAPIClient { func prepareStream( session: YouTubeBroadcastSession, accessToken: String, - provider: BroadcastSettingsProvider = .youtube + provider: BroadcastSettingsProvider = .youtube, + accountID: String? = nil ) async throws -> YouTubeSessionResponse { try await request( path: "/sessions/\(session.sessionID)/stream/prepare", method: "POST", accessToken: accessToken, ownerToken: session.ownerToken, - body: YouTubePrepareStreamRequest(provider: provider.rawValue) + body: YouTubePrepareStreamRequest(provider: provider.rawValue, accountID: accountID) ) } diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAuthorization.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAuthorization.swift index 2d71d3a6..ea43d634 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAuthorization.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAuthorization.swift @@ -16,6 +16,7 @@ final class YouTubeAuthorization { clientID: clientID, serverClientID: configuration.webClientID ) + GIDSignIn.sharedInstance.signOut() let result = try await GIDSignIn.sharedInstance.signIn( withPresenting: viewController, hint: nil, diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift index dc0c5e8a..69767dc3 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift @@ -397,7 +397,8 @@ final class YouTubeIntegration: ObservableObject { upgradeOffer?.options?.contains(upgradeOption) == true else { return false } } let snapshot = try await api.changeBroadcastMode(session: current, accessToken: accessToken, - resolution: resolution, targets: targets) + resolution: resolution, targets: targets, + accountID: added.contains(.youtube) ? selectedYouTubeAccountID : nil) guard isCurrent() else { if broadcastModeRequest == request, generation == sessionOperationGeneration, session?.sessionID == current.sessionID { broadcastModeAwaitingStatus = true } @@ -494,17 +495,43 @@ final class YouTubeIntegration: ObservableObject { let settingsEditor: BroadcastSettingsEditor @Published private(set) var streamingAccounts: [YouTubeStreamingAccountSummary] = [] + @Published private(set) var selectedYouTubeAccountID: String? + var youtubeAccounts: [StreamingAccountSummary] { streamingAccounts.filter { $0.provider == "youtube" } } + var selectedYouTubeAccount: StreamingAccountSummary? { + youtubeAccounts.first { $0.accountID == selectedYouTubeAccountID } + } + var canSelectYouTubeChannel: Bool { + !isChangingStreamState && !isChangingBroadcastMode && !isPreparingSession + && !isYouTubeConnectionOperationInProgress + && !StreamingAccountPolicy.isInUse("youtube", snapshot: responseState) + } + func selectYouTubeChannel(_ id: String?, accessToken: String?) { + guard canSelectYouTubeChannel, let account = youtubeAccounts.first(where: { $0.accountID == id }) else { return } + selectedYouTubeAccountID = id + connection = account.youtubeConnection + configureSettingsEditor(accessToken: accessToken) + } + func canDisconnectYouTubeChannel(_ account: StreamingAccountSummary) -> Bool { + guard !isYouTubeConnectionOperationInProgress, !isChangingStreamState, + !isChangingBroadcastMode, !isPreparingSession else { return false } + if let target = responseState.details.targets?.first(where: { $0.provider == "youtube" }), + target.stream.broadcastPhaseValue != .idle { + return target.accountID != nil && target.accountID != account.accountID + } + return !StreamingAccountPolicy.isInUse("youtube", snapshot: responseState) + } + func configureSettingsEditor(accessToken: String?) { let scope = accessToken.flatMap { try? api.broadcastSessionScope(accessToken: $0) } var channels = Dictionary(streamingAccounts.map { ($0.provider, $0.channelID) }, uniquingKeysWith: { _, last in last }) if let connection { channels["youtube"] = connection.channel.id } - settingsEditor.configure(scope: scope, channels: channels) + settingsEditor.configure(scope: scope, channels: channels, youtubeAccountID: selectedYouTubeAccountID) } func settingsScopeKey(accessToken: String?) -> String { let scope = accessToken.flatMap { try? api.broadcastSessionScope(accessToken: $0) } return (scope?.storageKey ?? "") + ":" + streamingAccounts.map { $0.provider + ":" + $0.channelID }.sorted().joined(separator: ":") - + ":" + (connection?.channel.id ?? "") + + ":" + (connection?.channel.id ?? "") + ":" + (selectedYouTubeAccountID ?? "") } func isSettingsAccountConnected(_ provider: BroadcastSettingsProvider) -> Bool { @@ -666,6 +693,7 @@ final class YouTubeIntegration: ObservableObject { resetLiveEditing() resetBroadcastMode() streamingAccounts = [] + selectedYouTubeAccountID = nil chzzkAccountMessage = nil sessionOperationGeneration &+= 1 invalidateConnectionOperation() @@ -926,7 +954,10 @@ final class YouTubeIntegration: ObservableObject { private func applyStreamingAccounts(_ accounts: [StreamingAccountSummary]) { streamingAccounts = accounts - if let connection = accounts.first(where: { $0.provider == "youtube" })?.youtubeConnection { + let selected = accounts.first { $0.provider == "youtube" && $0.accountID == selectedYouTubeAccountID } + ?? accounts.first { $0.provider == "youtube" } + selectedYouTubeAccountID = selected?.accountID + if let connection = selected?.youtubeConnection { self.connection = connection persistConnection() } else { @@ -1022,14 +1053,19 @@ final class YouTubeIntegration: ObservableObject { guard isCurrentConnectionOperation(generation) else { return } connection = YouTubeConnection(provider: response.provider, channel: response.channel) persistConnection() + let accounts = try await api.streamingAccounts(accessToken: accessToken) + guard isCurrentConnectionOperation(generation) else { return } + applyStreamingAccounts(accounts) } catch { guard isCurrentConnectionOperation(generation) else { return } handle(error) } } - func disconnectYouTubeAccount(accessToken: String?) async { - guard canDisconnectYouTubeAccount else { return } + func disconnectYouTubeAccount(accessToken: String?, accountID: String? = nil) async { + let account = youtubeAccounts.first { $0.accountID == (accountID ?? selectedYouTubeAccountID) } + if accountID != nil && account == nil { return } + guard account.map(canDisconnectYouTubeChannel) ?? canDisconnectYouTubeAccount else { return } clearError() guard let accessToken, !accessToken.isEmpty else { showError(.unauthorized) @@ -1045,11 +1081,9 @@ final class YouTubeIntegration: ObservableObject { } do { - try await api.disconnectStreamingAccount(accessToken: accessToken) + try await api.disconnectStreamingAccount(accessToken: accessToken, accountID: account?.accountID) guard isCurrentConnectionOperation(generation) else { return } - connection = nil - streamingAccounts.removeAll { $0.provider == "youtube" } - clearPersistedYouTubeConnection() + applyStreamingAccounts(streamingAccounts.filter { $0.provider != "youtube" || $0.accountID != account?.accountID }) } catch { guard isCurrentConnectionOperation(generation) else { return } // A failed DELETE does not prove that the server removed the link. @@ -1508,7 +1542,8 @@ final class YouTubeIntegration: ObservableObject { let preparedSnapshot = try await api.prepareStream( session: session, accessToken: accessToken, - provider: provider + provider: provider, + accountID: provider == .youtube ? selectedYouTubeAccountID : nil ) guard !useEditor || settingsEditor.contextGeneration == editorGeneration else { return false } guard applySessionResponse(preparedSnapshot, sessionID: session.sessionID, generation: generation, provider: provider.rawValue, clearsWarnings: true) else { return false } @@ -2068,6 +2103,7 @@ final class YouTubeIntegration: ObservableObject { resetBroadcastMode() resetUpgradeOffer() streamingAccounts = [] + selectedYouTubeAccountID = nil preparationGeneration &+= 1 preparationStatus = nil selectedBroadcastProviders = [.youtube] diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeModels.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeModels.swift index 933e49e1..872f5aec 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeModels.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeModels.swift @@ -412,10 +412,17 @@ enum BroadcastRemainingTime: Equatable { struct BroadcastTargetState: Decodable, Equatable, Identifiable { let provider: String var stream: YouTubeStreamState + var accountID: String? = nil + var channelTitle: String? = nil + enum CodingKeys: String, CodingKey { + case provider, stream + case accountID = "account_id" + case channelTitle = "channel_title" + } var id: String { provider } var title: String { switch provider { - case "youtube": return "YouTube" + case "youtube": return channelTitle.map { "YouTube · " + $0 } ?? "YouTube" case "chzzk": return "CHZZK" default: return provider } diff --git a/apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings b/apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings new file mode 100644 index 00000000..0aeb391e --- /dev/null +++ b/apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings @@ -0,0 +1,72 @@ +{ + "sourceLanguage": "ko", + "strings": { + "채널 추가 연결": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "채널 추가 연결" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add channel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャンネルを追加" + } + } + } + }, + "YouTube 채널": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "YouTube 채널" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "YouTube channel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "YouTube チャンネル" + } + } + } + }, + "방송할 채널": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "방송할 채널" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Broadcast channel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "配信するチャンネル" + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/ios/InnoLive/InnoLiveTests/BroadcastSettingsTests.swift b/apps/ios/InnoLive/InnoLiveTests/BroadcastSettingsTests.swift index 633889f1..127ce805 100644 --- a/apps/ios/InnoLive/InnoLiveTests/BroadcastSettingsTests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/BroadcastSettingsTests.swift @@ -104,6 +104,20 @@ final class BroadcastSettingsTests: XCTestCase { editor.setUsesDefaults(false, provider: .youtube) XCTAssertEqual(editor.youtube.title, "typed") } + func testChannelChangeIgnoresLateDefaultsAndSendsSelectedAccountID() async throws { + editor.configure(scope: scope("A"), channels: ["youtube": "channel-a"], youtubeAccountID: "account-a") + enqueue("{\"title\":\"channel A stale\"}", delay: 0.1) + let session = try session() + let task = Task { await editor.loadDefaults(session: session, accessToken: "access", provider: .youtube) } + await waitForRequest() + let query = URLComponents(url: try XCTUnwrap(SettingsURLProtocol.requests.first?.url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "account_id" }?.value, "account-a") + editor.configure(scope: scope("A"), channels: ["youtube": "channel-b"], youtubeAccountID: "account-b") + editor.editYouTube { $0.title = "channel B draft" } + await task.value + XCTAssertEqual(editor.youtube.title, "channel B draft") + } + func testLateDefaultsNeverOverwriteManualInput() async throws { enqueue("{\"title\":\"last\"}", delay: 0.1) let session = try session() diff --git a/apps/ios/InnoLive/InnoLiveTests/YouTubeAccountAPITests.swift b/apps/ios/InnoLive/InnoLiveTests/YouTubeAccountAPITests.swift index 8a877b75..b5ea5aa4 100644 --- a/apps/ios/InnoLive/InnoLiveTests/YouTubeAccountAPITests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/YouTubeAccountAPITests.swift @@ -176,6 +176,32 @@ final class YouTubeAccountAPITests: XCTestCase { XCTAssertEqual(AccountManagementURLProtocol.requests.count, 1) } + func testDisconnectSpecificChannelUsesAccountIDPath() async throws { + AccountManagementURLProtocol.responses = [.init(statusCode: 204, data: Data())] + try await makeAPI().disconnectStreamingAccount(accessToken: "access", accountID: "account-b") + XCTAssertEqual(AccountManagementURLProtocol.requests.last?.url?.path, "/auth/streaming/accounts/youtube/account-b") + } + + func testPrepareAndModeCarryAccountIDButExistingTargetModeOmitsIt() async throws { + let session = try JSONDecoder().decode(YouTubeBroadcastSession.self, from: Data("{\"session_id\":\"session\",\"owner_token\":\"owner\",\"stream\":{\"status\":\"idle\",\"publisher_active\":false,\"reconnect_attempts\":0}}".utf8)) + let api = makeAPI() + for operation in 0..<3 { + AccountManagementURLProtocol.responses = [.init(statusCode: 200, data: Data("{}".utf8))] + if operation == 0 { _ = try? await api.prepareStream(session: session, accessToken: "access", accountID: "account-b") } + else { _ = try? await api.changeBroadcastMode(session: session, accessToken: "access", resolution: "fhd", targets: [.youtube], accountID: operation == 1 ? "account-b" : nil) } + let request = try XCTUnwrap(AccountManagementURLProtocol.requests.last) + var data = request.httpBody + if data == nil, let stream = request.httpBodyStream { + stream.open(); defer { stream.close() } + var bytes = [UInt8](repeating: 0, count: 4096) + let count = stream.read(&bytes, maxLength: bytes.count) + data = Data(bytes.prefix(max(0, count))) + } + let body = try XCTUnwrap(try JSONSerialization.jsonObject(with: XCTUnwrap(data)) as? [String: Any]) + XCTAssertEqual(body["account_id"] as? String, operation == 2 ? nil : "account-b") + } + } + private func makeAPI() -> YouTubeAPI { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [AccountManagementURLProtocol.self] diff --git a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift index 1bec12b1..5c013662 100644 --- a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift @@ -34,7 +34,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { provider: "youtube", channel: YouTubeChannel(id: "known-channel", title: "Known Channel") ) - let store = YouTubePreferencesStore(userDefaults: userDefaults) + let store = YouTubePreferencesStore(defaults: userDefaults) store.saveConnection(knownConnection) let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ @@ -53,7 +53,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { provider: "youtube", channel: YouTubeChannel(id: "known-channel", title: "Known Channel") ) - let store = YouTubePreferencesStore(userDefaults: userDefaults) + let store = YouTubePreferencesStore(defaults: userDefaults) store.saveConnection(knownConnection) let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ @@ -71,7 +71,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { provider: "youtube", channel: YouTubeChannel(id: "known-channel", title: "Known Channel") ) - let store = YouTubePreferencesStore(userDefaults: userDefaults) + let store = YouTubePreferencesStore(defaults: userDefaults) store.saveConnection(knownConnection) let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ @@ -90,7 +90,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { provider: "youtube", channel: YouTubeChannel(id: "known-channel", title: "Known Channel") ) - let store = YouTubePreferencesStore(userDefaults: userDefaults) + let store = YouTubePreferencesStore(defaults: userDefaults) store.saveConnection(knownConnection) let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ @@ -104,7 +104,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { } func testPrepareDoesNotStartWhileDisconnectIsInProgress() async throws { - let store = YouTubePreferencesStore(userDefaults: userDefaults) + let store = YouTubePreferencesStore(defaults: userDefaults) store.saveConnection( YouTubeConnection( provider: "youtube", @@ -135,7 +135,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { } func testResetIgnoresDelayedRefreshResponse() async throws { - let store = YouTubePreferencesStore(userDefaults: userDefaults) + let store = YouTubePreferencesStore(defaults: userDefaults) let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ .init( @@ -162,6 +162,23 @@ final class YouTubeIntegrationAccountTests: XCTestCase { XCTAssertNil(store.loadConnection()) } + func testChannelSelectionSurvivesRefreshAndDisconnectOnlyRemovesChosenChannel() async throws { + let body = Data("[{\"id\":\"a\",\"provider\":\"youtube\",\"channel_id\":\"uca\",\"channel_title\":\"A\",\"reconnect_required\":false},{\"id\":\"b\",\"provider\":\"youtube\",\"channel_id\":\"ucb\",\"channel_title\":\"B\",\"reconnect_required\":false}]".utf8) + YouTubeIntegrationAccountURLProtocol.responses = [.init(statusCode: 200, data: body), .init(statusCode: 200, data: body), .init(statusCode: 204, data: Data())] + let integration = makeIntegration(store: YouTubePreferencesStore(defaults: userDefaults)) + await integration.refreshConnection(accessToken: "access") + XCTAssertEqual(integration.youtubeAccounts.count, 2) + integration.selectYouTubeChannel("b", accessToken: "access") + await integration.refreshConnection(accessToken: "access") + XCTAssertEqual(integration.selectedYouTubeAccountID, "b") + XCTAssertEqual(integration.connection?.channel.title, "B") + await integration.disconnectYouTubeAccount(accessToken: "access", accountID: "a") + XCTAssertEqual(integration.youtubeAccounts.map(\.accountID), ["b"]) + XCTAssertEqual(integration.selectedYouTubeAccountID, "b") + integration.reset() + XCTAssertNil(integration.selectedYouTubeAccountID) + } + private func makeIntegration(store: YouTubePreferencesStore) -> YouTubeIntegration { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [YouTubeIntegrationAccountURLProtocol.self] diff --git a/contracts/api/youtube-multi-channel-v1.md b/contracts/api/youtube-multi-channel-v1.md new file mode 100644 index 00000000..91e9d693 --- /dev/null +++ b/contracts/api/youtube-multi-channel-v1.md @@ -0,0 +1,15 @@ +# YouTube multi-channel v1 + +Source: [server request, 2026-10-12](https://app.notion.com/p/3f7ba097c659808a978cc00a3ce15c95). + +- GET `/auth/streaming/accounts`: optional `id` is the streaming account UUID; preserve all YouTube entries, channel_id, channel_title and reconnect_required. +- POST `/auth/youtube/connect`: unchanged native authorization code payload. Reconnecting the same channel updates it; another channel adds it, up to five. +- DELETE `/auth/streaming/accounts/youtube/{id}`: remove only that connection. A live target's account cannot be removed; server `streaming_account_in_use` remains authoritative. +- POST `/sessions/{id}/stream/prepare`: optional `account_id` selects YouTube connection. Omit for CHZZK. Multiple YouTube channels require selection. +- GET `/sessions/{id}/broadcast/defaults`: `provider=youtube&account_id={id}`. Settings draft and response generation are scoped to the selected account/channel. Ignore previous channel responses after selection changes. +- PUT `/sessions/{id}/broadcast-mode`: optional `account_id` only when adding YouTube. Existing YouTube target keeps its original channel. +- Session `targets[]`: optional `account_id`, `channel_title`; missing fields remain compatible with earlier servers. + +## Compatibility + +New fields are additive. Legacy one-channel responses without `id` retain provider-only prepare/defaults/delete. Multi-channel responses must supply distinct account UUIDs. Authentication v2 is outside this change. No server API fields are renamed. diff --git a/contracts/fixtures/youtube-multi-channel.v1.json b/contracts/fixtures/youtube-multi-channel.v1.json new file mode 100644 index 00000000..0f170100 --- /dev/null +++ b/contracts/fixtures/youtube-multi-channel.v1.json @@ -0,0 +1,28 @@ +{ + "accounts": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "provider": "youtube", + "channel_id": "UCmain", + "channel_title": "Main", + "reconnect_required": false + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "provider": "youtube", + "channel_id": "UCbrand", + "channel_title": "Brand", + "reconnect_required": false + } + ], + "prepare": { + "provider": "youtube", + "account_id": "22222222-2222-4222-8222-222222222222" + }, + "target": { + "provider": "youtube", + "account_id": "22222222-2222-4222-8222-222222222222", + "channel_title": "Brand", + "stream": {} + } +} diff --git a/docs/ios-youtube-multi-channel.md b/docs/ios-youtube-multi-channel.md new file mode 100644 index 00000000..5ec4a6ed --- /dev/null +++ b/docs/ios-youtube-multi-channel.md @@ -0,0 +1,7 @@ +# iOS YouTube channel selection + +The platform settings screen lists each connected YouTube channel with a separate disconnect confirmation. Channel addition invokes Google authorization with the previous SDK session signed out so the user can choose another account. The app access token is unchanged. + +Broadcast settings shows the channel picker before preparing YouTube or adding YouTube during CHZZK broadcasting. When multiple channels are connected, each preparation/addition asks the user to confirm the selected channel name. Selection updates the settings scope and reloads defaults. Saving succeeds before prepare receives the selected account ID. A prepared/live YouTube target locks channel selection; the target title displays the server channel_title. Refresh preserves the selected connection while it exists. Removing another channel preserves it. + +Verification: focused URLProtocol tests cover account-specific DELETE, prepare/mode payloads, refresh/selection, and late defaults from a previous channel. Actual Google/brand-channel consent and production two-channel streaming require account/device verification. From ac4eb8b56e9dcb20d3212089f2821b1b2d3b4b8c Mon Sep 17 00:00:00 2001 From: chaeyn Date: Thu, 15 Oct 2026 15:39:53 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20=EC=B1=84=EB=84=90=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=ED=9B=84=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/YouTube/YouTubeIntegration.swift | 18 ++++++---- .../YouTubeIntegrationAccountTests.swift | 33 +++++++++++++++---- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift index 69767dc3..06ed7c59 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift @@ -1049,19 +1049,23 @@ final class YouTubeIntegration: ObservableObject { presenting: viewController ) guard isCurrentConnectionOperation(generation) else { return } - let response = try await api.connect(serverAuthCode: serverAuthCode, accessToken: accessToken) - guard isCurrentConnectionOperation(generation) else { return } - connection = YouTubeConnection(provider: response.provider, channel: response.channel) - persistConnection() - let accounts = try await api.streamingAccounts(accessToken: accessToken) - guard isCurrentConnectionOperation(generation) else { return } - applyStreamingAccounts(accounts) + try await connectAuthorizedYouTubeAccount(serverAuthCode: serverAuthCode, accessToken: accessToken) } catch { guard isCurrentConnectionOperation(generation) else { return } handle(error) } } + // Keep the previous selection until the full account list confirms its connection ID. + func connectAuthorizedYouTubeAccount(serverAuthCode: String, accessToken: String) async throws { + let generation = connectionOperationGeneration + _ = try await api.connect(serverAuthCode: serverAuthCode, accessToken: accessToken) + guard isCurrentConnectionOperation(generation) else { return } + let accounts = try await api.streamingAccounts(accessToken: accessToken) + guard isCurrentConnectionOperation(generation) else { return } + applyStreamingAccounts(accounts) + } + func disconnectYouTubeAccount(accessToken: String?, accountID: String? = nil) async { let account = youtubeAccounts.first { $0.accountID == (accountID ?? selectedYouTubeAccountID) } if accountID != nil && account == nil { return } diff --git a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift index 5c013662..8d0c99dc 100644 --- a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift @@ -34,7 +34,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { provider: "youtube", channel: YouTubeChannel(id: "known-channel", title: "Known Channel") ) - let store = YouTubePreferencesStore(defaults: userDefaults) + let store = YouTubePreferencesStore(userDefaults: userDefaults) store.saveConnection(knownConnection) let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ @@ -53,7 +53,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { provider: "youtube", channel: YouTubeChannel(id: "known-channel", title: "Known Channel") ) - let store = YouTubePreferencesStore(defaults: userDefaults) + let store = YouTubePreferencesStore(userDefaults: userDefaults) store.saveConnection(knownConnection) let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ @@ -71,7 +71,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { provider: "youtube", channel: YouTubeChannel(id: "known-channel", title: "Known Channel") ) - let store = YouTubePreferencesStore(defaults: userDefaults) + let store = YouTubePreferencesStore(userDefaults: userDefaults) store.saveConnection(knownConnection) let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ @@ -90,7 +90,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { provider: "youtube", channel: YouTubeChannel(id: "known-channel", title: "Known Channel") ) - let store = YouTubePreferencesStore(defaults: userDefaults) + let store = YouTubePreferencesStore(userDefaults: userDefaults) store.saveConnection(knownConnection) let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ @@ -104,7 +104,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { } func testPrepareDoesNotStartWhileDisconnectIsInProgress() async throws { - let store = YouTubePreferencesStore(defaults: userDefaults) + let store = YouTubePreferencesStore(userDefaults: userDefaults) store.saveConnection( YouTubeConnection( provider: "youtube", @@ -135,7 +135,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { } func testResetIgnoresDelayedRefreshResponse() async throws { - let store = YouTubePreferencesStore(defaults: userDefaults) + let store = YouTubePreferencesStore(userDefaults: userDefaults) let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ .init( @@ -165,7 +165,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { func testChannelSelectionSurvivesRefreshAndDisconnectOnlyRemovesChosenChannel() async throws { let body = Data("[{\"id\":\"a\",\"provider\":\"youtube\",\"channel_id\":\"uca\",\"channel_title\":\"A\",\"reconnect_required\":false},{\"id\":\"b\",\"provider\":\"youtube\",\"channel_id\":\"ucb\",\"channel_title\":\"B\",\"reconnect_required\":false}]".utf8) YouTubeIntegrationAccountURLProtocol.responses = [.init(statusCode: 200, data: body), .init(statusCode: 200, data: body), .init(statusCode: 204, data: Data())] - let integration = makeIntegration(store: YouTubePreferencesStore(defaults: userDefaults)) + let integration = makeIntegration(store: YouTubePreferencesStore(userDefaults: userDefaults)) await integration.refreshConnection(accessToken: "access") XCTAssertEqual(integration.youtubeAccounts.count, 2) integration.selectYouTubeChannel("b", accessToken: "access") @@ -179,6 +179,25 @@ final class YouTubeIntegrationAccountTests: XCTestCase { XCTAssertNil(integration.selectedYouTubeAccountID) } + func testConnectSuccessThenListFailurePreservesSelectedChannel() async throws { + let store = YouTubePreferencesStore(userDefaults: userDefaults) + let integration = makeIntegration(store: store) + YouTubeIntegrationAccountURLProtocol.responses = [ + .init(statusCode: 200, data: Data("[{\"id\":\"a\",\"provider\":\"youtube\",\"channel_id\":\"uca\",\"channel_title\":\"A\",\"reconnect_required\":false}]".utf8)), + .init(statusCode: 200, data: Data("{\"provider\":\"youtube\",\"channel\":{\"id\":\"ucb\",\"title\":\"B\"}}".utf8)), + .init(statusCode: 503, data: Data("{}".utf8)) + ] + await integration.refreshConnection(accessToken: "access") + do { + try await integration.connectAuthorizedYouTubeAccount(serverAuthCode: "code", accessToken: "access") + XCTFail("Account list failure must be reported") + } catch {} + XCTAssertEqual(YouTubeIntegrationAccountURLProtocol.requests.count, 3) + XCTAssertEqual(integration.selectedYouTubeAccountID, "a") + XCTAssertEqual(integration.connection?.channel.id, "uca") + XCTAssertEqual(store.loadConnection()?.channel.id, "uca") + } + private func makeIntegration(store: YouTubePreferencesStore) -> YouTubeIntegration { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [YouTubeIntegrationAccountURLProtocol.self] From 4d75efc3d272536b7c3cc9f1bec77a105d5b9578 Mon Sep 17 00:00:00 2001 From: chaeyn Date: Thu, 15 Oct 2026 15:40:20 +0900 Subject: [PATCH 03/10] =?UTF-8?q?test:=20=EC=B1=84=EB=84=90=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=84=B1=EA=B3=B5=20fixture=20=ED=95=84=EC=88=98?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift index 8d0c99dc..706a0192 100644 --- a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift @@ -184,7 +184,7 @@ final class YouTubeIntegrationAccountTests: XCTestCase { let integration = makeIntegration(store: store) YouTubeIntegrationAccountURLProtocol.responses = [ .init(statusCode: 200, data: Data("[{\"id\":\"a\",\"provider\":\"youtube\",\"channel_id\":\"uca\",\"channel_title\":\"A\",\"reconnect_required\":false}]".utf8)), - .init(statusCode: 200, data: Data("{\"provider\":\"youtube\",\"channel\":{\"id\":\"ucb\",\"title\":\"B\"}}".utf8)), + .init(statusCode: 200, data: Data("{\"connected\":true,\"provider\":\"youtube\",\"channel\":{\"id\":\"ucb\",\"title\":\"B\"}}".utf8)), .init(statusCode: 503, data: Data("{}".utf8)) ] await integration.refreshConnection(accessToken: "access") From 8a246ec6b6aea9c13b056f4eb5bc22cd78423c25 Mon Sep 17 00:00:00 2001 From: chaeyn Date: Thu, 15 Oct 2026 15:41:07 +0900 Subject: [PATCH 04/10] =?UTF-8?q?test:=20=EC=A0=9C=EA=B1=B0=EB=90=9C=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=20ID=EC=9D=98=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=95=B4=EC=A0=9C=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InnoLiveTests/YouTubeIntegrationAccountTests.swift | 4 ++++ docs/ios-youtube-multi-channel.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift index 706a0192..f93f5706 100644 --- a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift @@ -175,6 +175,10 @@ final class YouTubeIntegrationAccountTests: XCTestCase { await integration.disconnectYouTubeAccount(accessToken: "access", accountID: "a") XCTAssertEqual(integration.youtubeAccounts.map(\.accountID), ["b"]) XCTAssertEqual(integration.selectedYouTubeAccountID, "b") + let requestCount = YouTubeIntegrationAccountURLProtocol.requests.count + await integration.disconnectYouTubeAccount(accessToken: "access", accountID: "a") + XCTAssertEqual(YouTubeIntegrationAccountURLProtocol.requests.count, requestCount) + XCTAssertEqual(integration.selectedYouTubeAccountID, "b") integration.reset() XCTAssertNil(integration.selectedYouTubeAccountID) } diff --git a/docs/ios-youtube-multi-channel.md b/docs/ios-youtube-multi-channel.md index 5ec4a6ed..287f1e55 100644 --- a/docs/ios-youtube-multi-channel.md +++ b/docs/ios-youtube-multi-channel.md @@ -2,6 +2,6 @@ The platform settings screen lists each connected YouTube channel with a separate disconnect confirmation. Channel addition invokes Google authorization with the previous SDK session signed out so the user can choose another account. The app access token is unchanged. -Broadcast settings shows the channel picker before preparing YouTube or adding YouTube during CHZZK broadcasting. When multiple channels are connected, each preparation/addition asks the user to confirm the selected channel name. Selection updates the settings scope and reloads defaults. Saving succeeds before prepare receives the selected account ID. A prepared/live YouTube target locks channel selection; the target title displays the server channel_title. Refresh preserves the selected connection while it exists. Removing another channel preserves it. +Broadcast settings shows the channel picker before preparing YouTube or adding YouTube during CHZZK broadcasting. When multiple channels are connected, each preparation/addition asks the user to confirm the selected channel name. Selection updates the settings scope and reloads defaults. Saving succeeds before prepare receives the selected account ID. A prepared/live YouTube target locks channel selection; the target title displays the server channel_title. Connecting an additional channel preserves the selected connection until the account list confirms both its channel and connection ID. A failed list refresh keeps the previous selection and persisted connection. Refresh preserves the selected connection while it exists. Removing another channel preserves it. Verification: focused URLProtocol tests cover account-specific DELETE, prepare/mode payloads, refresh/selection, and late defaults from a previous channel. Actual Google/brand-channel consent and production two-channel streaming require account/device verification. From e22eb2974547c5da90a4f726eedfdc9d84a9925c Mon Sep 17 00:00:00 2001 From: chaeyn Date: Thu, 15 Oct 2026 15:45:41 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=EC=B1=84=EB=84=90=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=20=EB=8F=84=EB=8B=AC=20=EC=8B=9C=20=EC=9E=AC=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BroadcastPlatformSelectionView.swift | 60 +++++++++++++++---- .../Features/YouTube/YouTubeIntegration.swift | 3 + .../YouTubeIntegrationAccountTests.swift | 13 ++++ 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift index a72eea8f..3d651983 100644 --- a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift +++ b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift @@ -141,23 +141,31 @@ struct BroadcastPlatformSelectionView: View { private var youTubeRow: some View { VStack(alignment: .leading, spacing: 12) { - platformIdentity(.youTube) + platformIdentity(.youTube, allowsWrapping: true) ForEach(youtube.youtubeAccounts, id: \.channelID) { account in - HStack { - VStack(alignment: .leading) { - Text(account.displayTitle).font(.callout.weight(.semibold)) - if account.reconnectRequired { Text(String(localized: "재연결 필요")).font(.caption) } - } - Spacer() - Button(String(localized: "연결 해제"), role: .destructive) { - disconnectAccount = account - isShowingDisconnectConfirmation = true + VStack(alignment: .leading, spacing: 8) { + Text(account.displayTitle) + .font(.callout.weight(.semibold)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + Text(account.reconnectRequired ? String(localized: "재연결 필요") : String(localized: "연결됨")) + .font(.caption) + .foregroundStyle(account.reconnectRequired ? .orange : .secondary) + .fixedSize(horizontal: false, vertical: true) + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { youTubeAccountActions(account, allowsWrapping: false) } + VStack(alignment: .leading, spacing: 8) { youTubeAccountActions(account, allowsWrapping: true) } } - .disabled(!youtube.canDisconnectYouTubeChannel(account)) } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .innoLiveGlassBackground(cornerRadius: 16) } Button(String(localized: "채널 추가 연결", table: "YouTubeChannels"), action: connectYouTube) - .disabled(youtube.isYouTubeAccountChangeBlocked || youtube.youtubeAccounts.count >= 5) + .innoLiveGlassButtonStyle(prominent: true) + .tint(.blue) + .disabled(!youtube.canConnectYouTubeChannel()) if youtube.isYouTubeConnectionOperationInProgress { ProgressView() } } .padding(16) @@ -173,6 +181,34 @@ struct BroadcastPlatformSelectionView: View { } } + @ViewBuilder + private func youTubeAccountActions(_ account: StreamingAccountSummary, allowsWrapping: Bool) -> some View { + if account.reconnectRequired { + Button(action: connectYouTube) { + youTubeAccountActionLabel(String(localized: "다시 연결"), allowsWrapping: allowsWrapping) + } + .buttonStyle(.borderless) + .disabled(!youtube.canConnectYouTubeChannel(reconnecting: true)) + } + Button(role: .destructive) { + disconnectAccount = account + isShowingDisconnectConfirmation = true + } label: { + youTubeAccountActionLabel(String(localized: "연결 해제"), allowsWrapping: allowsWrapping) + } + .buttonStyle(.borderless) + .disabled(!youtube.canDisconnectYouTubeChannel(account)) + } + + @ViewBuilder + private func youTubeAccountActionLabel(_ title: String, allowsWrapping: Bool) -> some View { + if allowsWrapping { + Text(title).multilineTextAlignment(.leading).fixedSize(horizontal: false, vertical: true) + } else { + Text(title) + } + } + private func connectYouTube() { guard let presentingViewController else { return } Task { diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift index 06ed7c59..29f59fb3 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift @@ -500,6 +500,9 @@ final class YouTubeIntegration: ObservableObject { var selectedYouTubeAccount: StreamingAccountSummary? { youtubeAccounts.first { $0.accountID == selectedYouTubeAccountID } } + func canConnectYouTubeChannel(reconnecting: Bool = false) -> Bool { + !isYouTubeAccountChangeBlocked && (reconnecting || youtubeAccounts.count < 5) + } var canSelectYouTubeChannel: Bool { !isChangingStreamState && !isChangingBroadcastMode && !isPreparingSession && !isYouTubeConnectionOperationInProgress diff --git a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift index f93f5706..3daa9a2c 100644 --- a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift @@ -202,6 +202,19 @@ final class YouTubeIntegrationAccountTests: XCTestCase { XCTAssertEqual(store.loadConnection()?.channel.id, "uca") } + func testFiveChannelsStillAllowReconnectButNotAddition() async throws { + let accounts = (1...5).map { index in + ["id": "account-\(index)", "provider": "youtube", "channel_id": "uc-\(index)", + "channel_title": "Channel \(index)", "reconnect_required": true] as [String: Any] + } + YouTubeIntegrationAccountURLProtocol.responses = [.init(statusCode: 200, data: try JSONSerialization.data(withJSONObject: accounts))] + let integration = makeIntegration(store: YouTubePreferencesStore(userDefaults: userDefaults)) + await integration.refreshConnection(accessToken: "access") + XCTAssertEqual(integration.youtubeAccounts.count, 5) + XCTAssertFalse(integration.canConnectYouTubeChannel()) + XCTAssertTrue(integration.canConnectYouTubeChannel(reconnecting: true)) + } + private func makeIntegration(store: YouTubePreferencesStore) -> YouTubeIntegration { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [YouTubeIntegrationAccountURLProtocol.self] From fa47330f148fa50682b2513610761516df3b5eda Mon Sep 17 00:00:00 2001 From: chaeyn Date: Thu, 15 Oct 2026 15:46:30 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=EC=B1=84=EB=84=90=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C?= =?UTF-8?q?=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=97=B0=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BroadcastPlatformSelectionView.swift | 16 ++++++++++++++ .../Resources/YouTubeChannels.xcstrings | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift index 3d651983..e627cf25 100644 --- a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift +++ b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift @@ -142,6 +142,22 @@ struct BroadcastPlatformSelectionView: View { private var youTubeRow: some View { VStack(alignment: .leading, spacing: 12) { platformIdentity(.youTube, allowsWrapping: true) + if youtube.youtubeAccounts.isEmpty, let connection = youtube.connection { + VStack(alignment: .leading, spacing: 8) { + Text(connection.channel.title) + .font(.callout.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + Text(String(localized: "마지막으로 확인한 채널", table: "YouTubeChannels")) + .font(.caption).foregroundStyle(.secondary) + Button(String(localized: "다시 시도")) { + Task { await youtube.refreshConnection(accessToken: authentication.currentAccessToken()) } + } + .disabled(youtube.isYouTubeConnectionOperationInProgress) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .innoLiveGlassBackground(cornerRadius: 16) + } ForEach(youtube.youtubeAccounts, id: \.channelID) { account in VStack(alignment: .leading, spacing: 8) { Text(account.displayTitle) diff --git a/apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings b/apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings index 0aeb391e..6ae602d4 100644 --- a/apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings +++ b/apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings @@ -66,6 +66,28 @@ } } } + }, + "마지막으로 확인한 채널": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "마지막으로 확인한 채널" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Last confirmed channel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最後に確認したチャンネル" + } + } + } } }, "version": "1.0" From 13d0a0c9f48ea02fd4eec790a3a12371893d3e11 Mon Sep 17 00:00:00 2001 From: chaeyn Date: Thu, 15 Oct 2026 15:50:40 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=EB=AF=B8=EB=94=94=EC=96=B4=20?= =?UTF-8?q?=EC=A4=80=EB=B9=84=EC=99=80=20=EC=A2=85=EB=A3=8C=20=EC=A4=91=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=20=EC=84=A0=ED=83=9D=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift index 29f59fb3..84c02eaa 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift @@ -505,6 +505,8 @@ final class YouTubeIntegration: ObservableObject { } var canSelectYouTubeChannel: Bool { !isChangingStreamState && !isChangingBroadcastMode && !isPreparingSession + && !isEndingSession && !isSavingLiveSettings + && preparationStatus?.isRunning != true && preparationStatus?.phase != .cancelling && !isYouTubeConnectionOperationInProgress && !StreamingAccountPolicy.isInUse("youtube", snapshot: responseState) } From bcb11d0d344b65d63654ce19673a60d8e398ebf7 Mon Sep 17 00:00:00 2001 From: chaeyn Date: Thu, 15 Oct 2026 15:50:53 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=EB=B0=A9=EC=86=A1=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84=20=EC=A4=91=20=EC=B1=84=EB=84=90=20=ED=95=B4=EC=A0=9C?= =?UTF-8?q?=20=EA=B2=BD=ED=95=A9=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InnoLive/Features/YouTube/YouTubeIntegration.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift index 84c02eaa..ad6d5cf7 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift @@ -518,7 +518,8 @@ final class YouTubeIntegration: ObservableObject { } func canDisconnectYouTubeChannel(_ account: StreamingAccountSummary) -> Bool { guard !isYouTubeConnectionOperationInProgress, !isChangingStreamState, - !isChangingBroadcastMode, !isPreparingSession else { return false } + !isChangingBroadcastMode, !isPreparingSession, !isEndingSession, + preparationStatus?.isRunning != true, preparationStatus?.phase != .cancelling else { return false } if let target = responseState.details.targets?.first(where: { $0.provider == "youtube" }), target.stream.broadcastPhaseValue != .idle { return target.accountID != nil && target.accountID != account.accountID From 6d7f1f297cf32c8c76875768d6f4989c2e8806aa Mon Sep 17 00:00:00 2001 From: chaeyn Date: Thu, 15 Oct 2026 16:08:23 +0900 Subject: [PATCH 09/10] =?UTF-8?q?docs:=20YouTube=20=EB=8B=A4=EC=A4=91=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=20=EA=B3=84=EC=95=BD=20=EC=83=89=EC=9D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/README.md b/contracts/README.md index f3c248ea..ba6cacf8 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -2,6 +2,7 @@ This directory is the source of truth for behavior shared by all clients. +- `api/youtube-multi-channel-v1.md`: connected YouTube account selection, defaults, and channel-specific disconnect - `api/youtube-viewer-chat-v1.md`: optional YouTube video IDs and iOS viewer-chat lifecycle - `api/streaming-accounts-v1.md`: CHZZK OAuth and shared account listing/disconnection semantics - `api/broadcast-settings-v1.md`: provider-specific settings, categories, defaults and draft isolation From f08c6994b883134f15eb43ddc18f347239f2593a Mon Sep 17 00:00:00 2001 From: chaeyn Date: Thu, 15 Oct 2026 16:19:34 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EC=98=A8=EB=94=94=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=A0=84=ED=99=98=EA=B3=BC=20=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=EC=97=B0=EA=B2=B0=20=EB=B3=80=EA=B2=BD=20=EA=B2=BD?= =?UTF-8?q?=ED=95=A9=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/YouTube/YouTubeIntegration.swift | 10 ++-- .../BroadcastSessionLifecycleTests.swift | 48 ++++++++++++++++++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift index 3f5290d2..6f0bfcfb 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift @@ -63,7 +63,7 @@ final class YouTubeIntegration: ObservableObject { /// End the old session before preparing a new one; the caller reuses the normal consent/camera flow. func switchPreparationToOnDevice(accessToken: String?) async -> Bool { - guard offersOnDevicePreparation, !isSwitchingToOnDevice, !isChangingStreamState, + guard offersOnDevicePreparation, !isSwitchingToOnDevice, !isYouTubeConnectionOperationInProgress, !isChangingStreamState, !isChangingAIProcessing, !isPreparingSession, !isConnectingVideo, !isEndingSession, !isChangingBroadcastMode, !isTogglingAnonymization, !isTogglingScreenBlur, broadcastPreparationTask == nil, let accessToken, !accessToken.isEmpty else { return false } @@ -559,7 +559,7 @@ final class YouTubeIntegration: ObservableObject { !isYouTubeAccountChangeBlocked && (reconnecting || youtubeAccounts.count < 5) } var canSelectYouTubeChannel: Bool { - !isChangingStreamState && !isChangingBroadcastMode && !isPreparingSession + !isChangingStreamState && !isChangingBroadcastMode && !isPreparingSession && !isSwitchingToOnDevice && !isEndingSession && !isSavingLiveSettings && preparationStatus?.isRunning != true && preparationStatus?.phase != .cancelling && !isYouTubeConnectionOperationInProgress @@ -572,7 +572,7 @@ final class YouTubeIntegration: ObservableObject { configureSettingsEditor(accessToken: accessToken) } func canDisconnectYouTubeChannel(_ account: StreamingAccountSummary) -> Bool { - guard !isYouTubeConnectionOperationInProgress, !isChangingStreamState, + guard !isYouTubeConnectionOperationInProgress, !isChangingStreamState, !isSwitchingToOnDevice, !isChangingBroadcastMode, !isPreparingSession, !isEndingSession, preparationStatus?.isRunning != true, preparationStatus?.phase != .cancelling else { return false } if let target = responseState.details.targets?.first(where: { $0.provider == "youtube" }), @@ -929,7 +929,7 @@ final class YouTubeIntegration: ObservableObject { var isYouTubeAccountChangeBlocked: Bool { isBroadcastSettingsLocked || isChangingBroadcastMode || isHandlingUpgradeOffer - || isYouTubeConnectionOperationInProgress + || isSwitchingToOnDevice || isYouTubeConnectionOperationInProgress } var canDisconnectYouTubeAccount: Bool { @@ -941,7 +941,7 @@ final class YouTubeIntegration: ObservableObject { } var isCHZZKAccountChangeBlocked: Bool { - isChangingBroadcastMode || isHandlingUpgradeOffer || isYouTubeConnectionOperationInProgress || isPreparingSession || isChangingStreamState + isChangingBroadcastMode || isHandlingUpgradeOffer || isSwitchingToOnDevice || isYouTubeConnectionOperationInProgress || isPreparingSession || isChangingStreamState || isEndingSession || preparationStatus?.isRunning == true || preparationStatus?.phase == .cancelling || StreamingAccountPolicy.isInUse("chzzk", snapshot: responseState) diff --git a/apps/ios/InnoLive/InnoLiveTests/BroadcastSessionLifecycleTests.swift b/apps/ios/InnoLive/InnoLiveTests/BroadcastSessionLifecycleTests.swift index 02152f8f..478e6847 100644 --- a/apps/ios/InnoLive/InnoLiveTests/BroadcastSessionLifecycleTests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/BroadcastSessionLifecycleTests.swift @@ -86,6 +86,50 @@ final class BroadcastSessionLifecycleTests: XCTestCase { XCTAssertEqual(methods, ["POST", "DELETE", "DELETE", "POST"]) } + func testRetainedSessionCleanupBlocksChannelChanges() async throws { + let integration = makeIntegration() + SessionLifecycleURLProtocol.responses = [ + .init(status: 200, body: #"[{"id":"a","provider":"youtube","channel_id":"uca","channel_title":"A","reconnect_required":false},{"id":"b","provider":"youtube","channel_id":"ucb","channel_title":"B","reconnect_required":false}]"#), + .empty(204) + ] + await integration.refreshConnection(accessToken: token) + try store.save(old, scope: scope()) + try await seedExhaustedPlan(integration) + var checked = false + SessionLifecycleURLProtocol.beforeResponse = { request in + guard request.httpMethod == "DELETE" else { return } + checked = true + XCTAssertTrue(integration.isSwitchingToOnDevice) + XCTAssertTrue(integration.isYouTubeAccountChangeBlocked) + XCTAssertTrue(integration.isCHZZKAccountChangeBlocked) + XCTAssertFalse(integration.canSelectYouTubeChannel) + integration.selectYouTubeChannel("b", accessToken: self.token) + XCTAssertEqual(integration.selectedYouTubeAccountID, "a") + guard let account = integration.youtubeAccounts.last else { XCTFail("Expected connected channel"); return } + XCTAssertFalse(integration.canDisconnectYouTubeChannel(account)) + await integration.disconnectYouTubeAccount(accessToken: self.token, accountID: "b") + } + let switched = await integration.switchPreparationToOnDevice(accessToken: token) + XCTAssertTrue(switched) + XCTAssertTrue(checked) + XCTAssertEqual(methods, ["GET", "DELETE"]) + XCTAssertEqual(integration.youtubeAccounts.count, 2) + } + + func testAccountRefreshBlocksOnDeviceSwitch() async throws { + let integration = makeIntegration() + try await seedExhaustedPlan(integration) + SessionLifecycleURLProtocol.responses = [.init(status: 200, body: "[]")] + SessionLifecycleURLProtocol.beforeResponse = { _ in + XCTAssertTrue(integration.isYouTubeConnectionOperationInProgress) + let switched = await integration.switchPreparationToOnDevice(accessToken: self.token) + XCTAssertFalse(switched) + } + await integration.refreshConnection(accessToken: token) + XCTAssertEqual(integration.planProcessingMode, .server) + XCTAssertEqual(methods, ["GET"]) + } + func testAccountResetDuringFallbackRejectsLateSwitch() async throws { let integration = makeIntegration() SessionLifecycleURLProtocol.responses = [.created("old"), .empty(204)] @@ -606,7 +650,7 @@ private final class SessionLifecycleURLProtocol: URLProtocol { } @MainActor static var responses: [Response] = [] @MainActor static var requests: [URLRequest] = [] - @MainActor static var beforeResponse: ((URLRequest) -> Void)? + @MainActor static var beforeResponse: ((URLRequest) async -> Void)? override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } @@ -618,7 +662,7 @@ private final class SessionLifecycleURLProtocol: URLProtocol { return } let response = Self.responses.removeFirst() - Self.beforeResponse?(request) + await Self.beforeResponse?(request) let http = HTTPURLResponse(url: request.url!, statusCode: response.status, httpVersion: nil, headerFields: nil)! client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: Data(response.body.utf8))