diff --git a/Sources/Localization.swift b/Sources/Localization.swift index cf280f2..2046591 100644 --- a/Sources/Localization.swift +++ b/Sources/Localization.swift @@ -99,4 +99,13 @@ enum L { static let channelsAdded = NSLocalizedString("channels_added", value: "channels", comment: "") static let fetchFailed = NSLocalizedString("fetch_failed", value: "Fetch failed", comment: "") static let localFilesAdded = NSLocalizedString("local_files_added", value: "local files", comment: "") + + // 流检测 + static let checkStreams = NSLocalizedString("check_streams", value: "Check Streams", comment: "") + static let cancelCheck = NSLocalizedString("cancel_check", value: "Cancel", comment: "") + static let showUnavailable = NSLocalizedString("show_unavailable", value: "Show unavailable channels", comment: "") + static let hideUnavailable = NSLocalizedString("hide_unavailable", value: "Hide unavailable channels", comment: "") + static let hiddenUnavailable = NSLocalizedString("hidden_unavailable", value: "hidden (unavailable)", comment: "") + static let statusForbidden = NSLocalizedString("status_forbidden", value: "Forbidden (403)", comment: "") + static let statusUnavailable = NSLocalizedString("status_unavailable", value: "Unavailable (timeout/error)", comment: "") } diff --git a/Sources/MediaLibrary.swift b/Sources/MediaLibrary.swift index 187269f..b7ba478 100644 --- a/Sources/MediaLibrary.swift +++ b/Sources/MediaLibrary.swift @@ -1,6 +1,14 @@ import Foundation import Combine +/// 流可用性状态 +enum StreamAvailability: String, Codable { + case unknown // 未检测 + case available // 可用 + case forbidden // 403 无权限 + case unavailable // 连接失败/超时/不可播放 +} + /// 媒体库条目 struct LibraryItem: Identifiable, Codable, Equatable { let id: String @@ -14,11 +22,13 @@ struct LibraryItem: Identifiable, Codable, Equatable { var addedAt: Date var group: String? // M3U group-title var logoURL: String? // M3U tvg-logo + var availability: StreamAvailability // 流可用性 init(id: String = UUID().uuidString, url: String, name: String, type: String = "url", lastPosition: Double = 0, liked: Bool = false, disliked: Bool = false, - tags: [String] = [], group: String? = nil, logoURL: String? = nil) { + tags: [String] = [], group: String? = nil, logoURL: String? = nil, + availability: StreamAvailability = .unknown) { self.id = id self.url = url self.name = name @@ -30,6 +40,7 @@ struct LibraryItem: Identifiable, Codable, Equatable { self.addedAt = Date() self.group = group self.logoURL = logoURL + self.availability = availability } static func == (lhs: LibraryItem, rhs: LibraryItem) -> Bool { lhs.id == rhs.id } @@ -63,6 +74,11 @@ private struct LibraryStore: Codable { final class MediaLibrary: ObservableObject { @Published var items: [LibraryItem] = [] @Published var playlists: [LibraryPlaylist] = [] + @Published var isChecking = false + @Published var checkProgress: Double = 0 // 0...1 + @Published var checkStatusText: String = "" + + private var checkTask: Task? private let filePath: URL @@ -296,4 +312,118 @@ final class MediaLibrary: ObservableObject { // MARK: - 辅助 var count: Int { items.count } + + // MARK: - 流可用性检测 + + /// 并发检测所有远程流的可用性,本地文件跳过 + func checkAllStreams() { + guard !isChecking else { return } + + // 筛选需要检测的条目:远程URL,且未检测或状态未知 + let remoteIndices = items.indices.filter { idx in + let item = items[idx] + return item.type != "local" && item.type != "file" + && item.url.hasPrefix("http") + } + + guard !remoteIndices.isEmpty else { return } + + isChecking = true + checkProgress = 0 + let total = remoteIndices.count + checkStatusText = "Checking 0/\(total)..." + + checkTask = Task { [weak self] in + guard let self = self else { return } + + // 并发检测,限制同时10个连接 + await withTaskGroup(of: (Int, StreamAvailability).self) { group in + var completed = 0 + let maxConcurrent = 10 + var activeCount = 0 + + for idx in remoteIndices { + // 等待并发槽位 + while activeCount >= maxConcurrent { + if let result = await group.next() { + completed += 1 + activeCount -= 1 + self.updateItemAvailabilitySync(index: result.0, status: result.1, completed: completed, total: total) + } + } + + let url = self.items[idx].url + group.addTask { + let status = await StreamChecker.check(url: url) + return (idx, status) + } + activeCount += 1 + } + + // 收集剩余结果 + for await result in group { + completed += 1 + self.updateItemAvailabilitySync(index: result.0, status: result.1, completed: completed, total: total) + } + } + + self.save() + self.isChecking = false + self.checkStatusText = "" + } + } + + func cancelCheck() { + checkTask?.cancel() + checkTask = nil + isChecking = false + checkStatusText = "" + } + + private func updateItemAvailabilitySync(index: Int, status: StreamAvailability, completed: Int, total: Int) { + guard index < items.count else { return } + items[index].availability = status + checkProgress = Double(completed) / Double(total) + checkStatusText = "Checking \(completed)/\(total)..." + } +} + +// MARK: - 流检测工具 + +enum StreamChecker { + /// 检测流URL可用性,5秒超时 + static func check(url urlString: String) async -> StreamAvailability { + guard let url = URL(string: urlString) else { return .unavailable } + + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 5 + config.timeoutIntervalForResource = 5 + config.httpShouldSetCookies = false + config.requestCachePolicy = .reloadIgnoringLocalCacheData + let session = URLSession(configuration: config) + + do { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 5 + + let (_, response) = try await session.data(for: request) + + if let httpResp = response as? HTTPURLResponse { + switch httpResp.statusCode { + case 200...299: + return .available + case 403, 401: + return .forbidden + default: + return .unavailable + } + } + // 非HTTP响应(如file://)视为可用 + return .available + } catch { + // URLError.timedOut, cannotConnectToHost, cannotFindHost 等 + return .unavailable + } + } } diff --git a/Sources/MediaLibraryView.swift b/Sources/MediaLibraryView.swift index d1cc628..7d2fa42 100644 --- a/Sources/MediaLibraryView.swift +++ b/Sources/MediaLibraryView.swift @@ -16,6 +16,7 @@ struct MediaLibraryView: View { @State private var newPlaylistName = "" @State private var selectedItems: Set = [] @State private var displayLimit: Int = 100 // 分页:先显示100条,滚动加载更多 + @State private var showUnavailable: Bool = false // 是否显示不可用频道 enum SidebarItem: Hashable { case all @@ -37,6 +38,10 @@ struct MediaLibraryView: View { case .tag(let tag): result = library.search("", tag: tag) } + // 过滤不可用频道(默认隐藏) + if !showUnavailable { + result = result.filter { $0.availability != .forbidden && $0.availability != .unavailable } + } if !searchText.isEmpty { let q = searchText.lowercased() result = result.filter { item in @@ -219,9 +224,52 @@ struct MediaLibraryView: View { .buttonStyle(.bordered) .controlSize(.small) } + + // 检测流可用性 + if library.isChecking { + Button { + library.cancelCheck() + } label: { + HStack(spacing: 4) { + ProgressView().controlSize(.mini) + Text(L.cancelCheck) + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } else { + Button { + library.checkAllStreams() + } label: { + Label(L.checkStreams, systemImage: "network") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + // 显示/隐藏不可用 + Button { + showUnavailable.toggle() + } label: { + Image(systemName: showUnavailable ? "eye.slash" : "eye") + .foregroundColor(showUnavailable ? .orange : .secondary) + } + .buttonStyle(.plain) + .help(showUnavailable ? L.hideUnavailable : L.showUnavailable) } .padding(.horizontal, 12).padding(.vertical, 8) + // 检测进度条 + if library.isChecking { + VStack(spacing: 2) { + ProgressView(value: library.checkProgress) + .progressViewStyle(.linear) + Text(library.checkStatusText) + .font(.caption2).foregroundColor(.secondary) + } + .padding(.horizontal, 12) + } + Divider() // 条目列表 @@ -261,6 +309,13 @@ struct MediaLibraryView: View { HStack { Text("\(displayedItems.count) \(L.itemsCount)") .font(.caption).foregroundColor(.secondary) + if !showUnavailable { + let badCount = library.items.filter { $0.availability == .forbidden || $0.availability == .unavailable }.count + if badCount > 0 { + Text("(\(badCount) \(L.hiddenUnavailable))") + .font(.caption).foregroundColor(.orange) + } + } Spacer() Text("\(library.count) \(L.totalItems)") .font(.caption).foregroundColor(.secondary) @@ -321,6 +376,23 @@ struct LibraryItemRow: View { Spacer() + // 可用性状态 + switch item.availability { + case .available: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green).font(.caption) + case .forbidden: + Image(systemName: "lock.fill") + .foregroundColor(.red).font(.caption) + .help(L.statusForbidden) + case .unavailable: + Image(systemName: "xmark.circle.fill") + .foregroundColor(.gray).font(.caption) + .help(L.statusUnavailable) + case .unknown: + EmptyView() + } + // 喜欢 Button { library.toggleLike(item.id)