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] {
// tagslike
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()
// IDID
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
}
/// 线M3UUI
private var saveTask: DispatchWorkItem?
private func save() {
// debounce: save500ms
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
}
/// M3UURLsave
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 {
/// URL5
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
}
}
// HTTPfile://
return .available
} catch {
// URLError.timedOut, cannotConnectToHost, cannotFindHost
return .unavailable
}
}
}