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 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: "")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
/// 检测流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 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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user