- 后台并发检测所有远程流URL(最多10并发)
- 5秒超时,403标记forbidden,连接失败标记unavailable
- 默认过滤不可用频道,眼睛图标切换显示
- 进度条+状态文本实时显示检测进度
- 行内状态图标:绿✓可用/红🔒无权限/灰✗不可用
- 支持取消检测
430 lines
14 KiB
Swift
430 lines
14 KiB
Swift
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
|
||
var url: String
|
||
var name: String
|
||
var type: String // "file", "url", "stream", "local"
|
||
var lastPosition: Double // 秒,上次播放位置
|
||
var liked: Bool
|
||
var disliked: Bool
|
||
var tags: [String]
|
||
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,
|
||
availability: StreamAvailability = .unknown) {
|
||
self.id = id
|
||
self.url = url
|
||
self.name = name
|
||
self.type = type
|
||
self.lastPosition = lastPosition
|
||
self.liked = liked
|
||
self.disliked = disliked
|
||
self.tags = tags
|
||
self.addedAt = Date()
|
||
self.group = group
|
||
self.logoURL = logoURL
|
||
self.availability = availability
|
||
}
|
||
|
||
static func == (lhs: LibraryItem, rhs: LibraryItem) -> Bool { lhs.id == rhs.id }
|
||
}
|
||
|
||
/// 播放列表
|
||
struct LibraryPlaylist: Identifiable, Codable, Equatable {
|
||
let id: String
|
||
var name: String
|
||
var itemIDs: [String]
|
||
var createdAt: Date
|
||
|
||
init(id: String = UUID().uuidString, name: String, itemIDs: [String] = []) {
|
||
self.id = id
|
||
self.name = name
|
||
self.itemIDs = itemIDs
|
||
self.createdAt = Date()
|
||
}
|
||
|
||
static func == (lhs: LibraryPlaylist, rhs: LibraryPlaylist) -> Bool { lhs.id == rhs.id }
|
||
}
|
||
|
||
/// 持久化结构
|
||
private struct LibraryStore: Codable {
|
||
var items: [LibraryItem]
|
||
var playlists: [LibraryPlaylist]
|
||
}
|
||
|
||
/// 媒体库管理器 — JSON文件持久化
|
||
@MainActor
|
||
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
|
||
|
||
// MARK: - 索引(O(1)查找)
|
||
private var urlIndex: [String: String] = [:] // url → itemID
|
||
private var idIndex: [String: Int] = [:] // itemID → array index
|
||
|
||
init() {
|
||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||
let dir = appSupport.appendingPathComponent("MiniPlayer", isDirectory: true)
|
||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||
self.filePath = dir.appendingPathComponent("library.json")
|
||
load()
|
||
}
|
||
|
||
// MARK: - 持久化
|
||
|
||
private func load() {
|
||
guard let data = try? Data(contentsOf: filePath),
|
||
let store = try? JSONDecoder().decode(LibraryStore.self, from: data) else { return }
|
||
items = store.items
|
||
playlists = store.playlists
|
||
rebuildIndexes()
|
||
}
|
||
|
||
/// 异步保存 — 不阻塞主线程,M3U几千条数据不卡UI
|
||
private var saveTask: DispatchWorkItem?
|
||
|
||
private func save() {
|
||
// debounce: 合并连续save调用,只在500ms后真正写入
|
||
saveTask?.cancel()
|
||
let store = LibraryStore(items: items, playlists: playlists)
|
||
let path = filePath
|
||
let task = DispatchWorkItem {
|
||
guard let data = try? JSONEncoder().encode(store) else { return }
|
||
try? data.write(to: path, options: .atomic)
|
||
}
|
||
saveTask = task
|
||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5, execute: task)
|
||
}
|
||
|
||
/// 强制同步保存(退出时调用)
|
||
func saveSync() {
|
||
saveTask?.cancel()
|
||
let store = LibraryStore(items: items, playlists: playlists)
|
||
guard let data = try? JSONEncoder().encode(store) else { return }
|
||
try? data.write(to: filePath, options: .atomic)
|
||
}
|
||
|
||
private func rebuildIndexes() {
|
||
urlIndex.removeAll(keepingCapacity: true)
|
||
idIndex.removeAll(keepingCapacity: true)
|
||
for (i, item) in items.enumerated() {
|
||
urlIndex[item.url] = item.id
|
||
idIndex[item.id] = i
|
||
}
|
||
}
|
||
|
||
// MARK: - 条目操作
|
||
|
||
/// 添加单条,URL去重
|
||
@discardableResult
|
||
func addItem(url: String, name: String, type: String = "url",
|
||
group: String? = nil, logoURL: String? = nil) -> LibraryItem {
|
||
if let existingID = urlIndex[url], let idx = idIndex[existingID] {
|
||
return items[idx]
|
||
}
|
||
let item = LibraryItem(url: url, name: name, type: type, group: group, logoURL: logoURL)
|
||
let newIndex = items.count
|
||
items.append(item)
|
||
urlIndex[item.url] = item.id
|
||
idIndex[item.id] = newIndex
|
||
save()
|
||
return item
|
||
}
|
||
|
||
/// 批量添加(M3U导入),跳过已存在的URL,仅触发一次save
|
||
func addItems(_ newItems: [LibraryItem]) -> Int {
|
||
var count = 0
|
||
for item in newItems {
|
||
if urlIndex[item.url] == nil {
|
||
let newIndex = items.count
|
||
items.append(item)
|
||
urlIndex[item.url] = item.id
|
||
idIndex[item.id] = newIndex
|
||
count += 1
|
||
}
|
||
}
|
||
if count > 0 { save() }
|
||
return count
|
||
}
|
||
|
||
func removeItem(_ id: String) {
|
||
items.removeAll { $0.id == id }
|
||
for i in playlists.indices {
|
||
playlists[i].itemIDs.removeAll { $0 == id }
|
||
}
|
||
rebuildIndexes()
|
||
save()
|
||
}
|
||
|
||
// MARK: - 喜欢 / 不喜欢
|
||
|
||
func toggleLike(_ id: String) {
|
||
guard let idx = idIndex[id] else { return }
|
||
items[idx].liked.toggle()
|
||
if items[idx].liked { items[idx].disliked = false }
|
||
save()
|
||
}
|
||
|
||
func toggleDislike(_ id: String) {
|
||
guard let idx = idIndex[id] else { return }
|
||
items[idx].disliked.toggle()
|
||
if items[idx].disliked { items[idx].liked = false }
|
||
save()
|
||
}
|
||
|
||
// MARK: - 标签
|
||
|
||
func addTag(_ tag: String, to id: String) {
|
||
guard let idx = idIndex[id] else { return }
|
||
let t = tag.trimmingCharacters(in: .whitespaces)
|
||
guard !t.isEmpty, !items[idx].tags.contains(t) else { return }
|
||
items[idx].tags.append(t)
|
||
items[idx].tags.sort()
|
||
save()
|
||
}
|
||
|
||
func removeTag(_ tag: String, from id: String) {
|
||
guard let idx = idIndex[id] else { return }
|
||
items[idx].tags.removeAll { $0 == tag }
|
||
save()
|
||
}
|
||
|
||
/// 所有已使用的标签(去重+排序)
|
||
var allTags: [String] {
|
||
Array(Set(items.flatMap { $0.tags })).sorted()
|
||
}
|
||
|
||
// MARK: - 播放位置(节流:30秒内只保存一次)
|
||
|
||
private var lastPositionSaveTime: Date = .distantPast
|
||
|
||
func savePosition(_ itemID: String, position: Double) {
|
||
guard let idx = idIndex[itemID] else { return }
|
||
// 节流:距上次保存 < 30秒且位置差 < 5秒则跳过
|
||
let now = Date()
|
||
if now.timeIntervalSince(lastPositionSaveTime) < 30,
|
||
abs(items[idx].lastPosition - position) < 5 {
|
||
return
|
||
}
|
||
items[idx].lastPosition = position
|
||
lastPositionSaveTime = now
|
||
save()
|
||
}
|
||
|
||
/// 强制保存位置(退出时)
|
||
func savePositionImmediate(_ itemID: String, position: Double) {
|
||
guard let idx = idIndex[itemID] else { return }
|
||
items[idx].lastPosition = position
|
||
saveSync()
|
||
}
|
||
|
||
func getPosition(for itemID: String) -> Double {
|
||
guard let idx = idIndex[itemID] else { return 0 }
|
||
return items[idx].lastPosition
|
||
}
|
||
|
||
/// 按URL查找库条目 — O(1)
|
||
func findItem(byURL url: String) -> LibraryItem? {
|
||
guard let id = urlIndex[url], let idx = idIndex[id] else { return nil }
|
||
return items[idx]
|
||
}
|
||
|
||
// MARK: - 播放列表
|
||
|
||
func createPlaylist(name: String) -> LibraryPlaylist {
|
||
let pl = LibraryPlaylist(name: name)
|
||
playlists.append(pl)
|
||
save()
|
||
return pl
|
||
}
|
||
|
||
func deletePlaylist(_ id: String) {
|
||
playlists.removeAll { $0.id == id }
|
||
save()
|
||
}
|
||
|
||
func renamePlaylist(_ id: String, name: String) {
|
||
guard let idx = playlists.firstIndex(where: { $0.id == id }) else { return }
|
||
playlists[idx].name = name
|
||
save()
|
||
}
|
||
|
||
func addToPlaylist(_ playlistID: String, itemID: String) {
|
||
guard let idx = playlists.firstIndex(where: { $0.id == playlistID }) else { return }
|
||
guard !playlists[idx].itemIDs.contains(itemID) else { return }
|
||
playlists[idx].itemIDs.append(itemID)
|
||
save()
|
||
}
|
||
|
||
func removeFromPlaylist(_ playlistID: String, itemID: String) {
|
||
guard let idx = playlists.firstIndex(where: { $0.id == playlistID }) else { return }
|
||
playlists[idx].itemIDs.removeAll { $0 == itemID }
|
||
save()
|
||
}
|
||
|
||
func items(in playlistID: String) -> [LibraryItem] {
|
||
guard let pl = playlists.first(where: { $0.id == playlistID }) else { return [] }
|
||
return pl.itemIDs.compactMap { id in
|
||
guard let idx = idIndex[id] else { return nil }
|
||
return items[idx]
|
||
}
|
||
}
|
||
|
||
// MARK: - 搜索(模糊查询)
|
||
|
||
func search(_ text: String, tag: String? = nil, likedOnly: Bool = false) -> [LibraryItem] {
|
||
items.filter { item in
|
||
if likedOnly && !item.liked { return false }
|
||
if let tag = tag, !item.tags.contains(tag) { return false }
|
||
guard !text.isEmpty else { return true }
|
||
let q = text.lowercased()
|
||
return item.name.lowercased().contains(q)
|
||
|| item.url.lowercased().contains(q)
|
||
|| item.tags.contains(where: { $0.lowercased().contains(q) })
|
||
|| (item.group?.lowercased().contains(q) ?? false)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
}
|