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 // 流可用性 enum CodingKeys: String, CodingKey { case id, url, name, type, lastPosition, liked, disliked, tags case addedAt, group, logoURL, availability } 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 } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) url = try container.decode(String.self, forKey: .url) name = try container.decode(String.self, forKey: .name) type = try container.decode(String.self, forKey: .type) lastPosition = try container.decodeIfPresent(Double.self, forKey: .lastPosition) ?? 0 liked = try container.decodeIfPresent(Bool.self, forKey: .liked) ?? false disliked = try container.decodeIfPresent(Bool.self, forKey: .disliked) ?? false tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] addedAt = try container.decode(Date.self, forKey: .addedAt) group = try container.decodeIfPresent(String.self, forKey: .group) logoURL = try container.decodeIfPresent(String.self, forKey: .logoURL) availability = try container.decodeIfPresent(StreamAvailability.self, forKey: .availability) ?? .unknown } 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? 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 deduplicateItems() rebuildIndexes() } /// 合并URL重复条目:保留首次出现的条目,删除后续重复,播放列表引用合并 private func deduplicateItems() { var seen: [String: String] = [:] // url → first itemID var firstOccurrence: [String: Int] = [:] // url → first index var toRemove: Set = [] for (i, item) in items.enumerated() { if let existingID = seen[item.url] { // 重复:合并元数据到首次出现的条目 toRemove.insert(item.id) if let firstIdx = firstOccurrence[item.url] { // 合并:保留更好的名字、tags、like状态、播放位置 if items[firstIdx].lastPosition < item.lastPosition { items[firstIdx].lastPosition = item.lastPosition } if !items[firstIdx].liked && item.liked { items[firstIdx].liked = true } // 合并tags for tag in item.tags where !items[firstIdx].tags.contains(tag) { items[firstIdx].tags.append(tag) } items[firstIdx].tags.sort() // 合并播放列表引用:将重复ID替换为首条ID for p in playlists.indices { if let pos = playlists[p].itemIDs.firstIndex(of: item.id) { if !playlists[p].itemIDs.contains(existingID) { playlists[p].itemIDs[pos] = existingID } else { playlists[p].itemIDs.remove(at: pos) } } } } } else { seen[item.url] = item.id firstOccurrence[item.url] = i } } if !toRemove.isEmpty { items.removeAll { toRemove.contains($0.id) } NSLog("[MiniPlayer] Deduplicated \(toRemove.count) items") save() } } /// URL是否存在 func hasItem(url: String) -> Bool { urlIndex[url] != nil } /// 异步保存 — 不阻塞主线程,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 } } }