diff --git a/Sources/MediaLibrary.swift b/Sources/MediaLibrary.swift index fa9ca2b..062c97b 100644 --- a/Sources/MediaLibrary.swift +++ b/Sources/MediaLibrary.swift @@ -122,9 +122,62 @@ final class MediaLibrary: ObservableObject { 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? diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 25497b4..9a2a914 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -494,8 +494,14 @@ final class PlayerBridge: ObservableObject { } func addItem(url: URL, name: String, type: String) { - // 自动入库 - let libItem = library.addItem(url: url.absoluteString, name: name, type: type) + let urlString = url.absoluteString + // 检查队列是否已有相同URL + if queue.contains(where: { $0.url.absoluteString == urlString }) { + showToast("⚠️ \(name) already in queue") + return + } + // 自动入库(库内已自动去重) + let libItem = library.addItem(url: urlString, name: name, type: type) queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type, libraryID: libItem.id)) if queue.count == 1 { playIndex(0) } @@ -607,6 +613,10 @@ final class PlayerBridge: ObservableObject { /// 添加到队列但不播放(用于批量加入) func addItemNoPlay(url: URL, name: String, type: String, libraryID: String? = nil) { + // 队列去重 + if queue.contains(where: { $0.url.absoluteString == url.absoluteString }) { + return + } queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type, libraryID: libraryID)) // 如果队列为空则自动播放第一个