feat: 流媒体URL并发可用性检测
- 后台并发检测所有远程流URL(最多10并发)
- 5秒超时,403标记forbidden,连接失败标记unavailable
- 默认过滤不可用频道,眼睛图标切换显示
- 进度条+状态文本实时显示检测进度
- 行内状态图标:绿✓可用/红🔒无权限/灰✗不可用
- 支持取消检测
This commit is contained in:
parent
48011dd4f0
commit
af710e4e4f
@ -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: "")
|
||||
}
|
||||
|
||||
@ -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<Void, Never>?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ struct MediaLibraryView: View {
|
||||
@State private var newPlaylistName = ""
|
||||
@State private var selectedItems: Set<String> = []
|
||||
@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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user