feat: 流媒体URL并发可用性检测

- 后台并发检测所有远程流URL(最多10并发)
- 5秒超时,403标记forbidden,连接失败标记unavailable
- 默认过滤不可用频道,眼睛图标切换显示
- 进度条+状态文本实时显示检测进度
- 行内状态图标:绿✓可用/红🔒无权限/灰✗不可用
- 支持取消检测
This commit is contained in:
yumoqing 2026-06-30 22:41:04 +08:00
parent 48011dd4f0
commit af710e4e4f
3 changed files with 212 additions and 1 deletions

View File

@ -99,4 +99,13 @@ enum L {
static let channelsAdded = NSLocalizedString("channels_added", value: "channels", comment: "") static let channelsAdded = NSLocalizedString("channels_added", value: "channels", comment: "")
static let fetchFailed = NSLocalizedString("fetch_failed", value: "Fetch failed", comment: "") static let fetchFailed = NSLocalizedString("fetch_failed", value: "Fetch failed", comment: "")
static let localFilesAdded = NSLocalizedString("local_files_added", value: "local files", 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: "")
} }

View File

@ -1,6 +1,14 @@
import Foundation import Foundation
import Combine import Combine
///
enum StreamAvailability: String, Codable {
case unknown //
case available //
case forbidden // 403
case unavailable // //
}
/// ///
struct LibraryItem: Identifiable, Codable, Equatable { struct LibraryItem: Identifiable, Codable, Equatable {
let id: String let id: String
@ -14,11 +22,13 @@ struct LibraryItem: Identifiable, Codable, Equatable {
var addedAt: Date var addedAt: Date
var group: String? // M3U group-title var group: String? // M3U group-title
var logoURL: String? // M3U tvg-logo var logoURL: String? // M3U tvg-logo
var availability: StreamAvailability //
init(id: String = UUID().uuidString, url: String, name: String, init(id: String = UUID().uuidString, url: String, name: String,
type: String = "url", lastPosition: Double = 0, type: String = "url", lastPosition: Double = 0,
liked: Bool = false, disliked: Bool = false, 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.id = id
self.url = url self.url = url
self.name = name self.name = name
@ -30,6 +40,7 @@ struct LibraryItem: Identifiable, Codable, Equatable {
self.addedAt = Date() self.addedAt = Date()
self.group = group self.group = group
self.logoURL = logoURL self.logoURL = logoURL
self.availability = availability
} }
static func == (lhs: LibraryItem, rhs: LibraryItem) -> Bool { lhs.id == rhs.id } static func == (lhs: LibraryItem, rhs: LibraryItem) -> Bool { lhs.id == rhs.id }
@ -63,6 +74,11 @@ private struct LibraryStore: Codable {
final class MediaLibrary: ObservableObject { final class MediaLibrary: ObservableObject {
@Published var items: [LibraryItem] = [] @Published var items: [LibraryItem] = []
@Published var playlists: [LibraryPlaylist] = [] @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 private let filePath: URL
@ -296,4 +312,118 @@ final class MediaLibrary: ObservableObject {
// MARK: - // MARK: -
var count: Int { items.count } 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 {
/// URL5
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
}
}
// HTTPfile://
return .available
} catch {
// URLError.timedOut, cannotConnectToHost, cannotFindHost
return .unavailable
}
}
} }

View File

@ -16,6 +16,7 @@ struct MediaLibraryView: View {
@State private var newPlaylistName = "" @State private var newPlaylistName = ""
@State private var selectedItems: Set<String> = [] @State private var selectedItems: Set<String> = []
@State private var displayLimit: Int = 100 // 100 @State private var displayLimit: Int = 100 // 100
@State private var showUnavailable: Bool = false //
enum SidebarItem: Hashable { enum SidebarItem: Hashable {
case all case all
@ -37,6 +38,10 @@ struct MediaLibraryView: View {
case .tag(let tag): case .tag(let tag):
result = library.search("", tag: tag) result = library.search("", tag: tag)
} }
//
if !showUnavailable {
result = result.filter { $0.availability != .forbidden && $0.availability != .unavailable }
}
if !searchText.isEmpty { if !searchText.isEmpty {
let q = searchText.lowercased() let q = searchText.lowercased()
result = result.filter { item in result = result.filter { item in
@ -219,9 +224,52 @@ struct MediaLibraryView: View {
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .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) .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() Divider()
// //
@ -261,6 +309,13 @@ struct MediaLibraryView: View {
HStack { HStack {
Text("\(displayedItems.count) \(L.itemsCount)") Text("\(displayedItems.count) \(L.itemsCount)")
.font(.caption).foregroundColor(.secondary) .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() Spacer()
Text("\(library.count) \(L.totalItems)") Text("\(library.count) \(L.totalItems)")
.font(.caption).foregroundColor(.secondary) .font(.caption).foregroundColor(.secondary)
@ -321,6 +376,23 @@ struct LibraryItemRow: View {
Spacer() 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 { Button {
library.toggleLike(item.id) library.toggleLike(item.id)