MiniPlayer/Sources/MediaLibrary.swift
yumoqing 03b8ef8d40 feat: URL deduplication for media library and queue
- On load: merge duplicate URLs (keep first, merge tags/liked/position)
- addItem: check queue for existing URL, show toast if duplicate
- addItemNoPlay: skip if URL already in queue
- Add hasItem(url:) helper
- Playlist references updated when merging duplicates
2026-06-30 23:45:02 +08:00

504 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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
deduplicateItems()
rebuildIndexes()
}
/// 合并URL重复条目:保留首次出现的条目,删除后续重复,播放列表引用合并
private func deduplicateItems() {
var seen: [String: String] = [:] // url → first itemID
var firstOccurrence: [String: Int] = [:] // url → first index
var toRemove: Set<String> = []
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
}
}
}