feat: 持久化可配置影视库并复用已有刮削信息
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B482C40000100A10001 /* WorkspaceShell.swift */; };
|
||||
8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B612C40000100A10001 /* BatchRenameTool.swift */; };
|
||||
8C0E0B632C40000100A10001 /* MediaLibraryPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B622C40000100A10001 /* MediaLibraryPage.swift */; };
|
||||
8C0E0B652C40000100A10001 /* MediaLibraryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B642C40000100A10001 /* MediaLibraryStore.swift */; };
|
||||
8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -37,6 +38,7 @@
|
||||
8C0E0B482C40000100A10001 /* WorkspaceShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceShell.swift; sourceTree = "<group>"; };
|
||||
8C0E0B612C40000100A10001 /* BatchRenameTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRenameTool.swift; sourceTree = "<group>"; };
|
||||
8C0E0B622C40000100A10001 /* MediaLibraryPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryPage.swift; sourceTree = "<group>"; };
|
||||
8C0E0B642C40000100A10001 /* MediaLibraryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryStore.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -75,6 +77,7 @@
|
||||
8C0E0B482C40000100A10001 /* WorkspaceShell.swift */,
|
||||
8C0E0B612C40000100A10001 /* BatchRenameTool.swift */,
|
||||
8C0E0B622C40000100A10001 /* MediaLibraryPage.swift */,
|
||||
8C0E0B642C40000100A10001 /* MediaLibraryStore.swift */,
|
||||
8C0E0B422C40000100A10001 /* Models.swift */,
|
||||
8C0E0B432C40000100A10001 /* GuangyaAPI.swift */,
|
||||
8C0E0B442C40000100A10001 /* AppModel.swift */,
|
||||
@@ -175,6 +178,7 @@
|
||||
8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */,
|
||||
8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */,
|
||||
8C0E0B632C40000100A10001 /* MediaLibraryPage.swift in Sources */,
|
||||
8C0E0B652C40000100A10001 /* MediaLibraryStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
+123
-15
@@ -35,6 +35,9 @@ final class AppModel: ObservableObject {
|
||||
@Published var batchRenameTarget: CloudFile?
|
||||
@Published var batchRenameSelection: [CloudFile] = []
|
||||
@Published var mediaLibraryTarget: CloudFile?
|
||||
@Published var mediaLibraries: [MediaLibraryDefinition] = []
|
||||
@Published var isScanningMediaLibrary = false
|
||||
@Published var mediaLibraryScanProgress = MediaLibraryScanProgress()
|
||||
@Published var isRunningTMDBJob = false
|
||||
@Published var isAnalyzing = false
|
||||
@Published var activeScanRequest: ScanRequest?
|
||||
@@ -72,6 +75,7 @@ final class AppModel: ObservableObject {
|
||||
private var detailRequestTasks: [String: Task<JSONValue, Error>] = [:]
|
||||
private let detailRequestGate = DetailRequestGate(limit: 4)
|
||||
private let metadataCache = FileMetadataCache.shared
|
||||
private let mediaLibraryStore = MediaLibraryStore.shared
|
||||
private var isHandlingAuthorizationExpiry = false
|
||||
private var scanTask: Task<Void, Never>?
|
||||
|
||||
@@ -280,7 +284,7 @@ final class AppModel: ObservableObject {
|
||||
// The web player does not use the generic download endpoint for videos.
|
||||
// It selects a videoResource GCID then asks get_vod_download_url for the
|
||||
// signed media address, which AVPlayer can consume directly.
|
||||
if file.fileType == 2,
|
||||
if file.isVideo,
|
||||
let resources = detail.firstArrayDeep(["videoResource"]),
|
||||
let defaultResource = resources.first(where: { $0["info"]?["defaultResolution"]?.boolValue == true }) ?? resources.first,
|
||||
let gcid = defaultResource.firstStringDeep(["gcid"]),
|
||||
@@ -377,18 +381,81 @@ final class AppModel: ObservableObject {
|
||||
return BatchRenameExecutionResult(succeeded: succeeded, failed: failed)
|
||||
}
|
||||
|
||||
func loadMediaLibrary(parentID: String?) async throws -> [MediaLibraryItem] {
|
||||
let source = try await allFiles(parentID: parentID)
|
||||
.filter { $0.isDirectory || $0.fileType == 2 }
|
||||
.prefix(60)
|
||||
let files = Array(source)
|
||||
guard !tmdbAPIKey.isEmpty else {
|
||||
return files.map { file in
|
||||
let parsed = parseMediaName(file.name)
|
||||
return MediaLibraryItem(file: file, title: parsed.title, originalTitle: file.name, mediaKind: nil, releaseDate: parsed.year.map(String.init) ?? "", overview: "", posterData: nil, backdropData: nil)
|
||||
}
|
||||
}
|
||||
func loadMediaLibraries() async {
|
||||
mediaLibraries = await mediaLibraryStore.libraries()
|
||||
}
|
||||
|
||||
func createMediaLibrary(name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool) async -> MediaLibraryDefinition {
|
||||
let library = MediaLibraryDefinition(id: UUID().uuidString, name: name.trimmingCharacters(in: .whitespacesAndNewlines), rootID: rootID, rootPath: rootPath, kind: kind, recursive: recursive, updatedAt: nil)
|
||||
await mediaLibraryStore.save(library: library)
|
||||
await loadMediaLibraries()
|
||||
return library
|
||||
}
|
||||
|
||||
func deleteMediaLibrary(_ library: MediaLibraryDefinition) async {
|
||||
await mediaLibraryStore.delete(libraryID: library.id)
|
||||
await loadMediaLibraries()
|
||||
}
|
||||
|
||||
func cachedMediaLibraryItems(libraryID: String) async -> [MediaLibraryItem] {
|
||||
await mediaLibraryStore.items(libraryID: libraryID)
|
||||
}
|
||||
|
||||
func scanMediaLibrary(_ library: MediaLibraryDefinition) async throws -> [MediaLibraryItem] {
|
||||
guard !isScanningMediaLibrary else { return await cachedMediaLibraryItems(libraryID: library.id) }
|
||||
isScanningMediaLibrary = true
|
||||
mediaLibraryScanProgress = MediaLibraryScanProgress(phase: "正在读取云端目录…")
|
||||
defer { isScanningMediaLibrary = false }
|
||||
let snapshot = try await discoverMediaFiles(parentID: library.rootID, rootPath: library.rootPath, recursive: library.recursive)
|
||||
mediaLibraryScanProgress = MediaLibraryScanProgress(phase: "正在读取已有刮削信息…", completed: 0, total: snapshot.files.count)
|
||||
let items = await scrapeMediaFiles(snapshot.files, kind: library.kind, sidecars: snapshot.sidecars)
|
||||
var updatedLibrary = library
|
||||
updatedLibrary.updatedAt = Date()
|
||||
await mediaLibraryStore.save(library: updatedLibrary)
|
||||
await mediaLibraryStore.replaceItems(items, library: updatedLibrary)
|
||||
await loadMediaLibraries()
|
||||
mediaLibraryScanProgress.phase = "扫描完成"
|
||||
return items
|
||||
}
|
||||
|
||||
private func discoverMediaFiles(parentID: String?, rootPath: String, recursive: Bool) async throws -> MediaScanSnapshot {
|
||||
let initialPath = parentID == nil ? [] : rootPath.split(separator: "/").map(String.init)
|
||||
var queue: [(id: String?, path: [String])] = [(parentID, initialPath)]
|
||||
var media: [CloudFile] = []
|
||||
var sidecars: [String: MediaSidecarFiles] = [:]
|
||||
while !queue.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
let current = queue.removeFirst()
|
||||
var children = try await allFiles(parentID: current.id)
|
||||
for index in children.indices {
|
||||
children[index].cloudPath = "/" + (current.path + [children[index].name]).joined(separator: "/")
|
||||
}
|
||||
let directMedia = children.filter(\.isVideo)
|
||||
media += directMedia
|
||||
let ordinaryFiles = children.filter { !$0.isDirectory && !$0.isVideo }
|
||||
for video in directMedia {
|
||||
let stem = (video.name as NSString).deletingPathExtension.lowercased()
|
||||
func first(named names: Set<String>, extensions: Set<String>) -> CloudFile? {
|
||||
ordinaryFiles.first { file in
|
||||
let lower = file.name.lowercased()
|
||||
let ext = (lower as NSString).pathExtension
|
||||
return names.contains(lower) || (extensions.contains(ext) && (lower.hasPrefix(stem + ".") || lower.hasPrefix(stem + "-") || lower.hasPrefix(stem + "_")))
|
||||
}
|
||||
}
|
||||
let nfoFiles = ordinaryFiles.filter { ($0.name as NSString).pathExtension.lowercased() == "nfo" }
|
||||
let nfo = first(named: ["movie.nfo", "tvshow.nfo"], extensions: ["nfo"]) ?? (nfoFiles.count == 1 ? nfoFiles.first : nil)
|
||||
let poster = first(named: ["poster.jpg", "poster.jpeg", "poster.png", "folder.jpg", "folder.png"], extensions: ["jpg", "jpeg", "png", "webp"])
|
||||
let backdrop = first(named: ["fanart.jpg", "fanart.png", "backdrop.jpg", "background.jpg"], extensions: ["jpg", "jpeg", "png", "webp"])
|
||||
if nfo != nil || poster != nil || backdrop != nil { sidecars[video.id] = MediaSidecarFiles(nfo: nfo, poster: poster, backdrop: backdrop) }
|
||||
}
|
||||
if recursive { queue += children.filter(\.isDirectory).map { ($0.id, current.path + [$0.name]) } }
|
||||
}
|
||||
let unique = Array(Dictionary(grouping: media, by: \.id).compactMap { $0.value.first })
|
||||
.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending }
|
||||
return MediaScanSnapshot(files: unique, sidecars: sidecars)
|
||||
}
|
||||
|
||||
private func scrapeMediaFiles(_ files: [CloudFile], kind: MediaLibraryKind, sidecars: [String: MediaSidecarFiles]) async -> [MediaLibraryItem] {
|
||||
let requests = files.enumerated().map { index, file in
|
||||
(index: index, file: file, parsed: parseMediaName(file.name))
|
||||
}
|
||||
@@ -401,9 +468,18 @@ final class AppModel: ObservableObject {
|
||||
func addNext() {
|
||||
guard let request = iterator.next() else { return }
|
||||
group.addTask {
|
||||
let fallback = MediaLibraryItem(file: request.file, title: request.parsed.title, originalTitle: request.file.name, mediaKind: nil, releaseDate: request.parsed.year.map(String.init) ?? "", overview: "", posterData: nil, backdropData: nil)
|
||||
let fallback = MediaLibraryItem(file: request.file, tmdbID: nil, title: request.parsed.title, originalTitle: request.file.name, mediaKind: nil, releaseDate: request.parsed.year.map(String.init) ?? "", overview: "", posterData: nil, backdropData: nil)
|
||||
do {
|
||||
let preferredKind: TMDBMediaKind = request.parsed.isEpisode ? .tv : .automatic
|
||||
if let existing = await Self.existingScrape(for: request.file, fallback: fallback, sidecars: sidecars[request.file.id], fallbackKind: kind, api: api) {
|
||||
return (request.index, existing)
|
||||
}
|
||||
guard !apiKey.isEmpty else { return (request.index, fallback) }
|
||||
let preferredKind: TMDBMediaKind
|
||||
switch kind {
|
||||
case .movies: preferredKind = .movie
|
||||
case .series: preferredKind = .tv
|
||||
case .mixed: preferredKind = request.parsed.isEpisode ? .tv : .automatic
|
||||
}
|
||||
let response = try await api.tmdbSearch(query: request.parsed.title, apiKey: apiKey, mediaKind: preferredKind, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
guard let candidate = (response["results"]?.arrayValue ?? []).compactMap({ TMDBCandidate(json: $0, forcedKind: preferredKind == .automatic ? nil : preferredKind) }).first else {
|
||||
return (request.index, fallback)
|
||||
@@ -420,7 +496,7 @@ final class AppModel: ObservableObject {
|
||||
} else {
|
||||
backdropData = nil
|
||||
}
|
||||
return (request.index, MediaLibraryItem(file: request.file, title: candidate.title, originalTitle: request.file.name, mediaKind: candidate.mediaType, releaseDate: candidate.releaseDate, overview: candidate.overview, posterData: posterData, backdropData: backdropData))
|
||||
return (request.index, MediaLibraryItem(file: request.file, tmdbID: candidate.id, title: candidate.title, originalTitle: request.file.name, mediaKind: candidate.mediaType, releaseDate: candidate.releaseDate, overview: candidate.overview, posterData: posterData, backdropData: backdropData))
|
||||
} catch {
|
||||
return (request.index, fallback)
|
||||
}
|
||||
@@ -430,6 +506,7 @@ final class AppModel: ObservableObject {
|
||||
var values: [(Int, MediaLibraryItem)] = []
|
||||
while let value = await group.next() {
|
||||
values.append(value)
|
||||
mediaLibraryScanProgress.completed = values.count
|
||||
addNext()
|
||||
}
|
||||
return values
|
||||
@@ -437,6 +514,37 @@ final class AppModel: ObservableObject {
|
||||
return results.sorted { $0.0 < $1.0 }.map(\.1)
|
||||
}
|
||||
|
||||
private nonisolated static func existingScrape(for file: CloudFile, fallback: MediaLibraryItem, sidecars: MediaSidecarFiles?, fallbackKind: MediaLibraryKind, api: GuangyaAPI) async -> MediaLibraryItem? {
|
||||
guard let sidecars else { return nil }
|
||||
async let nfoData = sidecarData(for: sidecars.nfo, api: api)
|
||||
async let posterData = sidecarData(for: sidecars.poster, api: api)
|
||||
async let backdropData = sidecarData(for: sidecars.backdrop, api: api)
|
||||
let (loadedNFO, loadedPoster, loadedBackdrop) = await (nfoData, posterData, backdropData)
|
||||
guard let parsed = loadedNFO.flatMap(MediaNFOParser.parse) else {
|
||||
guard loadedPoster != nil || loadedBackdrop != nil else { return nil }
|
||||
return MediaLibraryItem(file: file, tmdbID: fallback.tmdbID, title: fallback.title, originalTitle: fallback.originalTitle, mediaKind: fallback.mediaKind, releaseDate: fallback.releaseDate, overview: fallback.overview, posterData: loadedPoster, backdropData: loadedBackdrop)
|
||||
}
|
||||
let inferredKind: TMDBMediaKind?
|
||||
switch fallbackKind {
|
||||
case .movies: inferredKind = .movie
|
||||
case .series: inferredKind = .tv
|
||||
case .mixed: inferredKind = parsed.mediaKind
|
||||
}
|
||||
return MediaLibraryItem(file: file, tmdbID: parsed.tmdbID, title: parsed.title, originalTitle: parsed.originalTitle, mediaKind: parsed.mediaKind ?? inferredKind, releaseDate: parsed.releaseDate, overview: parsed.overview, posterData: loadedPoster, backdropData: loadedBackdrop)
|
||||
}
|
||||
|
||||
private nonisolated static func sidecarData(for file: CloudFile, api: GuangyaAPI) async -> Data? {
|
||||
guard let result = try? await api.downloadURL(fileID: file.id),
|
||||
let value = result.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]),
|
||||
let url = URL(string: value) else { return nil }
|
||||
return try? await URLSession.shared.data(from: url).0
|
||||
}
|
||||
|
||||
private nonisolated static func sidecarData(for file: CloudFile?, api: GuangyaAPI) async -> Data? {
|
||||
guard let file else { return nil }
|
||||
return await sidecarData(for: file, api: api)
|
||||
}
|
||||
|
||||
func copy(_ file: CloudFile, to parentID: String?) async {
|
||||
await perform("正在复制…") { [self] in _ = try await api.fsCopy(fileIDs: [file.id], parentID: parentID); await loadFiles() }
|
||||
}
|
||||
|
||||
@@ -333,6 +333,10 @@ struct WorkspaceSidebar: View {
|
||||
|
||||
ScrollView(.vertical, showsIndicators: true) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
ToolSidebarItem(tool: .media, selected: selectedTool == .media) {
|
||||
model.mediaLibraryTarget = nil
|
||||
selectedTool = .media
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("文件空间").font(.caption.weight(.bold)).foregroundStyle(.secondary).padding(.horizontal, 8)
|
||||
ForEach([WorkspaceSection.files, .recentViewed, .recentRestored]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } }
|
||||
@@ -367,13 +371,12 @@ struct WorkspaceSidebar: View {
|
||||
|
||||
if toolsExpanded {
|
||||
VStack(spacing: 4) {
|
||||
ForEach(WorkspaceTool.allCases) { tool in
|
||||
ForEach(WorkspaceTool.allCases.filter { $0 != .media }) { tool in
|
||||
ToolSidebarItem(tool: tool, selected: selectedTool == tool) {
|
||||
if tool == .rename {
|
||||
model.batchRenameTarget = nil
|
||||
model.batchRenameSelection = []
|
||||
}
|
||||
if tool == .media { model.mediaLibraryTarget = nil }
|
||||
selectedTool = tool
|
||||
}
|
||||
}
|
||||
@@ -747,7 +750,7 @@ struct FilesBrowser: View {
|
||||
@ViewBuilder private func fileMenu(_ file: CloudFile) -> some View {
|
||||
Button { if file.isDirectory { Task { await model.openFolder(file) } } else { onPreview(file) } } label: { Label(file.isDirectory ? "打开文件夹" : "预览", systemImage: file.isDirectory ? "folder" : "eye") }
|
||||
if !file.isDirectory { Button { Task { await model.download(file) } } label: { Label("下载", systemImage: "arrow.down.circle") } }
|
||||
if file.fileType == 2 { Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("使用 IINA / VLC 播放", systemImage: "play.rectangle.on.rectangle") } }
|
||||
if file.isVideo { Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("使用 IINA / VLC 播放", systemImage: "play.rectangle.on.rectangle") } }
|
||||
Button {
|
||||
selectedFileIDs = [file.id]
|
||||
onShowInspector()
|
||||
@@ -973,7 +976,7 @@ struct FilePreviewSheet: View {
|
||||
private var isImage: Bool { file.fileType == 1 || ["jpg", "jpeg", "png", "gif", "webp", "heic", "bmp", "tiff"].contains(ext) }
|
||||
// AVPlayer handles the native family; containers/codecs beyond it can be handed
|
||||
// to IINA/VLC, which are purpose-built for MKV, HEVC, AV1, DTS, subtitles, etc.
|
||||
private var isVideo: Bool { file.fileType == 2 || ["mp4", "mov", "m4v", "mkv", "webm", "avi", "flv", "wmv", "ts", "mts", "m2ts", "mpeg", "mpg", "3gp", "ogv", "vob", "rmvb", "asf", "f4v"].contains(ext) }
|
||||
private var isVideo: Bool { file.isVideo }
|
||||
private var nativeVideo: Bool { ["mp4", "mov", "m4v"].contains(ext) }
|
||||
|
||||
var body: some View {
|
||||
|
||||
+189
-131
@@ -11,18 +11,22 @@ private enum MediaLibraryFilter: String, CaseIterable, Identifiable {
|
||||
|
||||
struct MediaLibraryPage: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var sourceID: String?
|
||||
@State private var sourceName = "云盘根目录"
|
||||
@State private var selectedLibraryID: String?
|
||||
@State private var items: [MediaLibraryItem] = []
|
||||
@State private var selectedID: String?
|
||||
@State private var selectedItemID: String?
|
||||
@State private var filter: MediaLibraryFilter = .all
|
||||
@State private var searchText = ""
|
||||
@State private var isLoading = false
|
||||
@State private var isLoadingCache = false
|
||||
@State private var errorText = ""
|
||||
@State private var showFolderPicker = false
|
||||
@State private var showCreateLibrary = false
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
private var filteredItems: [MediaLibraryItem] {
|
||||
items.filter { item in
|
||||
private var selectedLibrary: MediaLibraryDefinition? {
|
||||
model.mediaLibraries.first { $0.id == selectedLibraryID }
|
||||
}
|
||||
|
||||
private var visibleItems: [MediaLibraryItem] {
|
||||
let filtered = items.filter { item in
|
||||
let matchesSearch = searchText.isEmpty || item.title.localizedCaseInsensitiveContains(searchText) || item.originalTitle.localizedCaseInsensitiveContains(searchText)
|
||||
let matchesFilter: Bool
|
||||
switch filter {
|
||||
@@ -33,60 +37,86 @@ struct MediaLibraryPage: View {
|
||||
}
|
||||
return matchesSearch && matchesFilter
|
||||
}
|
||||
var seen = Set<String>()
|
||||
return filtered.filter { item in
|
||||
let key = item.mediaKind == .tv && item.tmdbID != nil ? "tv-\(item.tmdbID!)" : item.id
|
||||
return seen.insert(key).inserted
|
||||
}
|
||||
}
|
||||
|
||||
private var featuredItem: MediaLibraryItem? {
|
||||
selectedID.flatMap { id in items.first { $0.id == id } }
|
||||
?? items.first(where: { $0.backdropData != nil })
|
||||
?? items.first
|
||||
selectedItemID.flatMap { id in items.first { $0.id == id } }
|
||||
?? visibleItems.first(where: { $0.backdropData != nil })
|
||||
?? visibleItems.first
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.055, green: 0.06, blue: 0.06).ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if let featuredItem { featuredHero(featuredItem) }
|
||||
libraryControls
|
||||
libraryContent
|
||||
if model.mediaLibraries.isEmpty && !isLoadingCache {
|
||||
emptyLibraryState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if let featuredItem { featuredHero(featuredItem) }
|
||||
libraryControls
|
||||
libraryContent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.sheet(isPresented: $showFolderPicker) {
|
||||
ScanFolderPickerSheet(model: model, title: "选择影视库目录", selectionLabel: "设为影视库") { id, name in
|
||||
model.mediaLibraryTarget = nil
|
||||
sourceID = id
|
||||
sourceName = name
|
||||
Task { await loadLibrary() }
|
||||
.sheet(isPresented: $showCreateLibrary) {
|
||||
CreateMediaLibrarySheet(model: model) { name, rootID, rootPath, kind, recursive in
|
||||
Task {
|
||||
let library = await model.createMediaLibrary(name: name, rootID: rootID, rootPath: rootPath, kind: kind, recursive: recursive)
|
||||
selectedLibraryID = library.id
|
||||
await scan(library)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
syncTarget()
|
||||
await loadLibrary()
|
||||
.confirmationDialog("删除影视库?", isPresented: $showDeleteConfirmation) {
|
||||
Button("删除影视库", role: .destructive) {
|
||||
guard let library = selectedLibrary else { return }
|
||||
Task {
|
||||
await model.deleteMediaLibrary(library)
|
||||
selectedLibraryID = model.mediaLibraries.first?.id
|
||||
await loadCachedItems()
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) { }
|
||||
} message: {
|
||||
Text("只删除本机影视库配置和刮削缓存,不会删除云盘文件。")
|
||||
}
|
||||
.onChange(of: model.mediaLibraryTarget?.id) { _, _ in
|
||||
syncTarget()
|
||||
Task { await loadLibrary() }
|
||||
.task { await initializeLibrary() }
|
||||
.onChange(of: selectedLibraryID) { _, _ in Task { await loadCachedItems() } }
|
||||
.onChange(of: model.mediaLibraryTarget?.id) { _, _ in Task { await initializeLibrary() } }
|
||||
}
|
||||
|
||||
private var emptyLibraryState: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image(systemName: "play.tv.fill").font(.system(size: 52, weight: .light)).foregroundStyle(.orange)
|
||||
Text("创建第一个影视库").font(.title2.weight(.bold))
|
||||
Text("选择云盘目录并指定内容类型,扫描后将在本机保存海报和刮削信息。")
|
||||
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth: 440)
|
||||
Button { showCreateLibrary = true } label: { Label("新建影视库", systemImage: "plus") }
|
||||
.buttonStyle(.borderedProminent).tint(.orange)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func featuredHero(_ item: MediaLibraryItem) -> some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
MediaArtwork(data: item.backdropData ?? item.posterData, icon: item.file.isDirectory ? "folder.fill" : "film.fill", contentMode: .fill)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 330)
|
||||
.clipped()
|
||||
LinearGradient(colors: [.clear, Color.black.opacity(0.28), Color.black.opacity(0.92)], startPoint: .top, endPoint: .bottom)
|
||||
LinearGradient(colors: [Color.black.opacity(0.76), .clear], startPoint: .leading, endPoint: .trailing)
|
||||
MediaArtwork(data: item.backdropData ?? item.posterData, icon: "film.fill", contentMode: .fill)
|
||||
.frame(maxWidth: .infinity).frame(height: 330).clipped()
|
||||
LinearGradient(colors: [.clear, Color.black.opacity(0.28), Color.black.opacity(0.94)], startPoint: .top, endPoint: .bottom)
|
||||
LinearGradient(colors: [Color.black.opacity(0.78), .clear], startPoint: .leading, endPoint: .trailing)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
if let kind = item.mediaKind {
|
||||
Text(kind == .movie ? "电影" : "剧集")
|
||||
} else {
|
||||
Text(item.file.isDirectory ? "媒体文件夹" : "云盘视频")
|
||||
}
|
||||
Text(item.mediaKind == .tv ? "剧集" : (item.mediaKind == .movie ? "电影" : "云盘视频"))
|
||||
if !item.year.isEmpty { Text(item.year) }
|
||||
let count = resourceCount(for: item)
|
||||
if count > 1 { Text("\(count) 集") }
|
||||
}
|
||||
.font(.caption.weight(.semibold)).foregroundStyle(.white.opacity(0.72))
|
||||
Text(item.title).font(.system(size: 34, weight: .bold)).foregroundStyle(.white).lineLimit(2)
|
||||
@@ -94,14 +124,9 @@ struct MediaLibraryPage: View {
|
||||
Text(item.overview).font(.callout).foregroundStyle(.white.opacity(0.78)).lineLimit(3).frame(maxWidth: 620, alignment: .leading)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
Button { open(item) } label: {
|
||||
Label(item.file.isDirectory ? "浏览内容" : "播放", systemImage: item.file.isDirectory ? "rectangle.grid.2x2" : "play.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent).tint(.white).foregroundStyle(.black)
|
||||
if !item.file.isDirectory {
|
||||
Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Button { play(item) } label: { Label(resourceCount(for: item) > 1 ? "播放第 1 集" : "播放", systemImage: "play.fill") }
|
||||
.buttonStyle(.borderedProminent).tint(.white).foregroundStyle(.black)
|
||||
Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 34).padding(.bottom, 30)
|
||||
@@ -111,59 +136,63 @@ struct MediaLibraryPage: View {
|
||||
|
||||
private var libraryControls: some View {
|
||||
VStack(spacing: 14) {
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("影视库").font(.title2.weight(.bold))
|
||||
Text(sourceName).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
HStack(spacing: 12) {
|
||||
Picker("影视库", selection: $selectedLibraryID) {
|
||||
ForEach(model.mediaLibraries) { library in Text(library.name).tag(Optional(library.id)) }
|
||||
}
|
||||
.labelsHidden().frame(width: 210)
|
||||
if let library = selectedLibrary {
|
||||
Text(library.kind.title).font(.caption.weight(.semibold)).foregroundStyle(.orange)
|
||||
Text(library.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
TextField("搜索影片、剧集", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(.horizontal, 12).frame(width: 230, height: 34)
|
||||
.textFieldStyle(.plain).padding(.horizontal, 12).frame(width: 230, height: 34)
|
||||
.background(Color.white.opacity(0.09), in: RoundedRectangle(cornerRadius: 6))
|
||||
Button { showFolderPicker = true } label: { Image(systemName: "folder") }.buttonStyle(.bordered).help("选择影视库目录")
|
||||
Button { Task { await loadLibrary() } } label: {
|
||||
if isLoading { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") }
|
||||
Button { showCreateLibrary = true } label: { Image(systemName: "plus") }.buttonStyle(.bordered).help("新建影视库")
|
||||
Button { if let library = selectedLibrary { Task { await scan(library) } } } label: {
|
||||
if model.isScanningMediaLibrary { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.triangle.2.circlepath") }
|
||||
}
|
||||
.buttonStyle(.bordered).help("扫描并刮削").disabled(model.isScanningMediaLibrary || selectedLibrary == nil)
|
||||
Menu { Button("删除影视库", role: .destructive) { showDeleteConfirmation = true } } label: { Image(systemName: "ellipsis.circle") }
|
||||
.menuStyle(.borderlessButton).help("影视库操作")
|
||||
}
|
||||
if model.isScanningMediaLibrary {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView(value: Double(model.mediaLibraryScanProgress.completed), total: Double(max(1, model.mediaLibraryScanProgress.total))).frame(maxWidth: 260)
|
||||
Text(model.mediaLibraryScanProgress.phase).font(.caption.weight(.medium))
|
||||
Text("\(model.mediaLibraryScanProgress.completed)/\(model.mediaLibraryScanProgress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.buttonStyle(.bordered).help("刷新影视库").disabled(isLoading)
|
||||
}
|
||||
HStack {
|
||||
Picker("分类", selection: $filter) {
|
||||
ForEach(MediaLibraryFilter.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented).labelsHidden().frame(width: 360)
|
||||
Picker("分类", selection: $filter) { ForEach(MediaLibraryFilter.allCases) { Text($0.rawValue).tag($0) } }
|
||||
.pickerStyle(.segmented).labelsHidden().frame(width: 360)
|
||||
Spacer()
|
||||
Text("\(filteredItems.count) 项").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
Text("\(visibleItems.count) 个条目 · \(items.count) 个资源").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 28).padding(.vertical, 20)
|
||||
}
|
||||
|
||||
@ViewBuilder private var libraryContent: some View {
|
||||
if isLoading && items.isEmpty {
|
||||
VStack(spacing: 14) {
|
||||
ProgressView().controlSize(.large)
|
||||
Text("正在构建影视库…").font(.callout.weight(.medium))
|
||||
Text("正在匹配媒体信息与海报").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 90)
|
||||
if isLoadingCache && items.isEmpty {
|
||||
VStack(spacing: 14) { ProgressView().controlSize(.large); Text("正在读取本地影视库…").font(.callout.weight(.medium)) }
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 90)
|
||||
} else if !errorText.isEmpty && items.isEmpty {
|
||||
ContentUnavailableView("影视库加载失败", systemImage: "exclamationmark.triangle", description: Text(errorText))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 70)
|
||||
} else if filteredItems.isEmpty {
|
||||
ContentUnavailableView("没有匹配的媒体", systemImage: "film.stack", description: Text("调整搜索或分类筛选。"))
|
||||
ContentUnavailableView("影视库加载失败", systemImage: "exclamationmark.triangle", description: Text(errorText)).frame(maxWidth: .infinity).padding(.vertical, 70)
|
||||
} else if visibleItems.isEmpty {
|
||||
ContentUnavailableView("影视库暂无内容", systemImage: "film.stack", description: Text("点击扫描按钮读取云盘视频并更新刮削信息。"))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 70)
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 145, maximum: 190), spacing: 18)], alignment: .leading, spacing: 24) {
|
||||
ForEach(filteredItems) { item in
|
||||
MediaPosterCard(item: item, isSelected: selectedID == item.id) {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { selectedID = item.id }
|
||||
} onOpen: {
|
||||
open(item)
|
||||
}
|
||||
ForEach(visibleItems) { item in
|
||||
MediaPosterCard(item: item, resourceCount: resourceCount(for: item), isSelected: selectedItemID == item.id) {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { selectedItemID = item.id }
|
||||
} onOpen: { play(item) }
|
||||
.contextMenu {
|
||||
Button { open(item) } label: { Label(item.file.isDirectory ? "浏览内容" : "播放", systemImage: item.file.isDirectory ? "folder" : "play.fill") }
|
||||
if !item.file.isDirectory { Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") } }
|
||||
Button { play(item) } label: { Label("播放", systemImage: "play.fill") }
|
||||
Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") }
|
||||
Button { Task { await model.share(item.file) } } label: { Label("分享", systemImage: "square.and.arrow.up") }
|
||||
}
|
||||
}
|
||||
@@ -172,43 +201,91 @@ struct MediaLibraryPage: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func syncTarget() {
|
||||
@MainActor private func initializeLibrary() async {
|
||||
isLoadingCache = true
|
||||
defer { isLoadingCache = false }
|
||||
await model.loadMediaLibraries()
|
||||
if let target = model.mediaLibraryTarget, target.isDirectory {
|
||||
sourceID = target.id
|
||||
sourceName = target.name
|
||||
} else {
|
||||
sourceID = model.currentScanRootID
|
||||
sourceName = model.scanScopeLocationName
|
||||
if let existing = model.mediaLibraries.first(where: { $0.rootID == target.id }) {
|
||||
selectedLibraryID = existing.id
|
||||
items = await model.cachedMediaLibraryItems(libraryID: existing.id)
|
||||
} else {
|
||||
let library = await model.createMediaLibrary(name: target.name, rootID: target.id, rootPath: target.cloudPath, kind: .mixed, recursive: true)
|
||||
selectedLibraryID = library.id
|
||||
await scan(library)
|
||||
}
|
||||
return
|
||||
}
|
||||
if selectedLibraryID == nil { selectedLibraryID = model.mediaLibraries.first?.id }
|
||||
await loadCachedItems()
|
||||
}
|
||||
|
||||
@MainActor private func loadLibrary() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
@MainActor private func loadCachedItems() async {
|
||||
guard let library = selectedLibrary else { items = []; return }
|
||||
isLoadingCache = true; errorText = ""
|
||||
defer { isLoadingCache = false }
|
||||
items = await model.cachedMediaLibraryItems(libraryID: library.id)
|
||||
if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id }
|
||||
}
|
||||
|
||||
@MainActor private func scan(_ library: MediaLibraryDefinition) async {
|
||||
errorText = ""
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let loaded = try await model.loadMediaLibrary(parentID: sourceID)
|
||||
items = loaded
|
||||
if !loaded.contains(where: { $0.id == selectedID }) { selectedID = loaded.first?.id }
|
||||
} catch is CancellationError { }
|
||||
catch { errorText = error.localizedDescription }
|
||||
items = try await model.scanMediaLibrary(library)
|
||||
selectedItemID = items.first?.id
|
||||
} catch { errorText = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func open(_ item: MediaLibraryItem) {
|
||||
if item.file.isDirectory {
|
||||
sourceID = item.file.id
|
||||
sourceName = item.title
|
||||
model.mediaLibraryTarget = item.file
|
||||
Task { await loadLibrary() }
|
||||
} else {
|
||||
Task { await model.playWithExternalPlayer(item.file) }
|
||||
private func resourceCount(for item: MediaLibraryItem) -> Int {
|
||||
guard item.mediaKind == .tv, let tmdbID = item.tmdbID else { return 1 }
|
||||
return items.filter { $0.mediaKind == .tv && $0.tmdbID == tmdbID }.count
|
||||
}
|
||||
|
||||
private func play(_ item: MediaLibraryItem) { Task { await model.playWithExternalPlayer(item.file) } }
|
||||
}
|
||||
|
||||
private struct CreateMediaLibrarySheet: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let onCreate: (String, String?, String, MediaLibraryKind, Bool) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var rootID: String?
|
||||
@State private var rootPath = "云盘根目录"
|
||||
@State private var kind: MediaLibraryKind = .movies
|
||||
@State private var recursive = true
|
||||
@State private var showFolderPicker = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "play.tv.fill").font(.title2).foregroundStyle(.orange).frame(width: 44, height: 44).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||
VStack(alignment: .leading, spacing: 3) { Text("新建影视库").font(.title2.weight(.bold)); Text("配置内容类型与云盘媒体目录").font(.caption).foregroundStyle(.secondary) }
|
||||
Spacer()
|
||||
}
|
||||
TextField("影视库名称", text: $name).textFieldStyle(.roundedBorder)
|
||||
Picker("内容类型", selection: $kind) { ForEach(MediaLibraryKind.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented)
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) { Text("媒体目录").font(.caption).foregroundStyle(.secondary); Text(rootPath).font(.callout.weight(.semibold)).lineLimit(2) }
|
||||
Spacer()
|
||||
Button("选择目录") { showFolderPicker = true }.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(12).background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8))
|
||||
Toggle("扫描子文件夹", isOn: $recursive)
|
||||
HStack { Spacer(); Button("取消") { dismiss() }.keyboardShortcut(.cancelAction); Button("创建并扫描") { onCreate(name, rootID, rootPath, kind, recursive); dismiss() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
|
||||
}
|
||||
.padding(24).frame(width: 520)
|
||||
.sheet(isPresented: $showFolderPicker) {
|
||||
ScanFolderPickerSheet(model: model, title: "选择媒体目录", selectionLabel: "选择此目录") { id, path in
|
||||
rootID = id; rootPath = path
|
||||
if name.isEmpty, path != "整个云盘" { name = path.split(separator: "/").last.map(String.init) ?? "影视库" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediaPosterCard: View {
|
||||
let item: MediaLibraryItem
|
||||
let resourceCount: Int
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
let onOpen: () -> Void
|
||||
@@ -218,29 +295,17 @@ private struct MediaPosterCard: View {
|
||||
Button(action: onSelect) {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
MediaArtwork(data: item.posterData, icon: item.file.isDirectory ? "folder.fill" : "film.fill", contentMode: .fill)
|
||||
.aspectRatio(2 / 3, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
if item.file.isDirectory {
|
||||
Image(systemName: "rectangle.grid.2x2.fill").font(.caption.weight(.bold)).padding(7).background(.black.opacity(0.68), in: Circle()).padding(8)
|
||||
} else if isHovered {
|
||||
Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10)
|
||||
}
|
||||
MediaArtwork(data: item.posterData, icon: "film.fill", contentMode: .fill)
|
||||
.aspectRatio(2 / 3, contentMode: .fit).frame(maxWidth: .infinity).clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
if isHovered { Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10) }
|
||||
else if resourceCount > 1 { Text("\(resourceCount) 集").font(.caption.weight(.bold)).padding(.horizontal, 8).padding(.vertical, 5).background(.black.opacity(0.74), in: Capsule()).padding(8) }
|
||||
}
|
||||
.overlay { RoundedRectangle(cornerRadius: 6).stroke(isSelected ? Color.orange : Color.white.opacity(isHovered ? 0.38 : 0.10), lineWidth: isSelected ? 2 : 1) }
|
||||
Text(item.title).font(.callout.weight(.semibold)).lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Text(item.year.isEmpty ? (item.file.isDirectory ? "文件夹" : "视频") : item.year)
|
||||
if let kind = item.mediaKind { Text("·"); Text(kind == .movie ? "电影" : "剧集") }
|
||||
}
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
HStack(spacing: 6) { Text(item.year.isEmpty ? "视频" : item.year); if let kind = item.mediaKind { Text("·"); Text(kind == .movie ? "电影" : "剧集") } }.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { isHovered = $0 }
|
||||
.simultaneousGesture(TapGesture(count: 2).onEnded(onOpen))
|
||||
.buttonStyle(.plain).onHover { isHovered = $0 }.simultaneousGesture(TapGesture(count: 2).onEnded(onOpen))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,17 +313,10 @@ private struct MediaArtwork: View {
|
||||
let data: Data?
|
||||
let icon: String
|
||||
let contentMode: ContentMode
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let data, let image = NSImage(data: data) {
|
||||
Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode)
|
||||
} else {
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(red: 0.19, green: 0.22, blue: 0.20), Color(red: 0.08, green: 0.09, blue: 0.09)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
Image(systemName: icon).font(.system(size: 42, weight: .light)).foregroundStyle(.white.opacity(0.28))
|
||||
}
|
||||
}
|
||||
if let data, let image = NSImage(data: data) { Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) }
|
||||
else { ZStack { LinearGradient(colors: [Color(red: 0.19, green: 0.22, blue: 0.20), Color(red: 0.08, green: 0.09, blue: 0.09)], startPoint: .topLeading, endPoint: .bottomTrailing); Image(systemName: icon).font(.system(size: 42, weight: .light)).foregroundStyle(.white.opacity(0.28)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
actor MediaLibraryStore {
|
||||
static let shared = MediaLibraryStore()
|
||||
|
||||
private var database: OpaquePointer?
|
||||
private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
|
||||
private init() {
|
||||
guard let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
|
||||
let directory = baseURL.appendingPathComponent("com.example.guangya-mac", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let url = directory.appendingPathComponent("media-library.sqlite3")
|
||||
guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK,
|
||||
let database else { self.database = nil; return }
|
||||
Self.execute(database, "PRAGMA journal_mode=WAL;")
|
||||
Self.execute(database, "PRAGMA synchronous=NORMAL;")
|
||||
Self.execute(database, "PRAGMA foreign_keys=ON;")
|
||||
Self.execute(database, """
|
||||
CREATE TABLE IF NOT EXISTS media_libraries (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
root_id TEXT,
|
||||
root_path TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
recursive INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at REAL
|
||||
);
|
||||
""")
|
||||
Self.execute(database, """
|
||||
CREATE TABLE IF NOT EXISTS media_items (
|
||||
library_id TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
resource_path TEXT NOT NULL,
|
||||
cloud_name TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
gcid TEXT,
|
||||
file_type INTEGER NOT NULL,
|
||||
tmdb_id INTEGER,
|
||||
media_kind TEXT,
|
||||
title TEXT NOT NULL,
|
||||
original_title TEXT NOT NULL,
|
||||
release_date TEXT NOT NULL,
|
||||
overview TEXT NOT NULL,
|
||||
poster BLOB,
|
||||
backdrop BLOB,
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (library_id, file_id),
|
||||
FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_library_kind ON media_items(library_id, media_kind);")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_gcid ON media_items(gcid);")
|
||||
}
|
||||
|
||||
deinit { if let database { sqlite3_close(database) } }
|
||||
|
||||
func libraries() -> [MediaLibraryDefinition] {
|
||||
guard let database else { return [] }
|
||||
var statement: OpaquePointer?
|
||||
let sql = "SELECT id, name, root_id, root_path, kind, recursive, updated_at FROM media_libraries ORDER BY name COLLATE NOCASE;"
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [] }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
var values: [MediaLibraryDefinition] = []
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let id = text(statement, 0), let name = text(statement, 1), let rootPath = text(statement, 3),
|
||||
let rawKind = text(statement, 4), let kind = MediaLibraryKind(rawValue: rawKind) else { continue }
|
||||
let epoch = integer(statement, 6)
|
||||
values.append(MediaLibraryDefinition(id: id, name: name, rootID: text(statement, 2), rootPath: rootPath, kind: kind, recursive: sqlite3_column_int(statement, 5) != 0, updatedAt: epoch.map { Date(timeIntervalSince1970: TimeInterval($0)) }))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func save(library: MediaLibraryDefinition) {
|
||||
guard let database else { return }
|
||||
var statement: OpaquePointer?
|
||||
let sql = """
|
||||
INSERT INTO media_libraries (id, name, root_id, root_path, kind, recursive, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, root_id=excluded.root_id,
|
||||
root_path=excluded.root_path, kind=excluded.kind, recursive=excluded.recursive,
|
||||
updated_at=excluded.updated_at;
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
bind(library.id, statement, 1); bind(library.name, statement, 2); bind(library.rootID, statement, 3)
|
||||
bind(library.rootPath, statement, 4); bind(library.kind.rawValue, statement, 5)
|
||||
sqlite3_bind_int(statement, 6, library.recursive ? 1 : 0)
|
||||
if let updatedAt = library.updatedAt { sqlite3_bind_double(statement, 7, updatedAt.timeIntervalSince1970) } else { sqlite3_bind_null(statement, 7) }
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
func delete(libraryID: String) {
|
||||
guard let database else { return }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(database, "DELETE FROM media_libraries WHERE id = ?1;", -1, &statement, nil) == SQLITE_OK, let statement else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
bind(libraryID, statement, 1)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
func items(libraryID: String) -> [MediaLibraryItem] {
|
||||
guard let database else { return [] }
|
||||
var statement: OpaquePointer?
|
||||
let sql = """
|
||||
SELECT file_id, resource_path, cloud_name, file_size, gcid, file_type, tmdb_id,
|
||||
media_kind, title, original_title, release_date, overview, poster, backdrop
|
||||
FROM media_items WHERE library_id = ?1 ORDER BY title COLLATE NOCASE;
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [] }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
bind(libraryID, statement, 1)
|
||||
var values: [MediaLibraryItem] = []
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let fileID = text(statement, 0), let path = text(statement, 1), let name = text(statement, 2),
|
||||
let title = text(statement, 8), let originalTitle = text(statement, 9),
|
||||
let releaseDate = text(statement, 10), let overview = text(statement, 11) else { continue }
|
||||
var json: [String: JSONValue] = [
|
||||
"fileId": .string(fileID), "name": .string(name), "resType": .number(1),
|
||||
"fileType": .number(Double(integer(statement, 5) ?? 2)), "path": .string(path)
|
||||
]
|
||||
if let size = integer(statement, 3) { json["size"] = .number(Double(size)) }
|
||||
if let gcid = text(statement, 4) { json["gcid"] = .string(gcid) }
|
||||
guard let file = CloudFile(json: .object(json)) else { continue }
|
||||
let kind = text(statement, 7).flatMap(TMDBMediaKind.init(rawValue:))
|
||||
values.append(MediaLibraryItem(file: file, tmdbID: integer(statement, 6).map(Int.init), title: title, originalTitle: originalTitle, mediaKind: kind, releaseDate: releaseDate, overview: overview, posterData: blob(statement, 12), backdropData: blob(statement, 13)))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func replaceItems(_ items: [MediaLibraryItem], library: MediaLibraryDefinition) {
|
||||
guard let database else { return }
|
||||
Self.execute(database, "BEGIN IMMEDIATE TRANSACTION;")
|
||||
var deleteStatement: OpaquePointer?
|
||||
if sqlite3_prepare_v2(database, "DELETE FROM media_items WHERE library_id = ?1;", -1, &deleteStatement, nil) == SQLITE_OK, let deleteStatement {
|
||||
bind(library.id, deleteStatement, 1); sqlite3_step(deleteStatement); sqlite3_finalize(deleteStatement)
|
||||
}
|
||||
var statement: OpaquePointer?
|
||||
let sql = """
|
||||
INSERT INTO media_items (library_id, file_id, resource_path, cloud_name, file_size, gcid,
|
||||
file_type, tmdb_id, media_kind, title, original_title, release_date, overview, poster, backdrop, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16);
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { Self.execute(database, "ROLLBACK;"); return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
for item in items {
|
||||
sqlite3_reset(statement); sqlite3_clear_bindings(statement)
|
||||
bind(library.id, statement, 1); bind(item.file.id, statement, 2); bind(item.file.cloudPath, statement, 3)
|
||||
bind(item.file.name, statement, 4); bind(item.file.size, statement, 5); bind(item.file.gcid, statement, 6)
|
||||
sqlite3_bind_int64(statement, 7, Int64(item.file.fileType)); bind(item.tmdbID.map(Int64.init), statement, 8)
|
||||
bind(item.mediaKind?.rawValue, statement, 9); bind(item.title, statement, 10); bind(item.originalTitle, statement, 11)
|
||||
bind(item.releaseDate, statement, 12); bind(item.overview, statement, 13); bind(item.posterData, statement, 14); bind(item.backdropData, statement, 15)
|
||||
sqlite3_bind_double(statement, 16, Date().timeIntervalSince1970)
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else { Self.execute(database, "ROLLBACK;"); return }
|
||||
}
|
||||
Self.execute(database, "COMMIT;")
|
||||
}
|
||||
|
||||
private func bind(_ value: String?, _ statement: OpaquePointer, _ index: Int32) {
|
||||
if let value { sqlite3_bind_text(statement, index, value, -1, transient) } else { sqlite3_bind_null(statement, index) }
|
||||
}
|
||||
private func bind(_ value: Int64?, _ statement: OpaquePointer, _ index: Int32) {
|
||||
if let value { sqlite3_bind_int64(statement, index, value) } else { sqlite3_bind_null(statement, index) }
|
||||
}
|
||||
private func bind(_ value: Data?, _ statement: OpaquePointer, _ index: Int32) {
|
||||
if let value { _ = value.withUnsafeBytes { sqlite3_bind_blob(statement, index, $0.baseAddress, Int32(value.count), transient) } } else { sqlite3_bind_null(statement, index) }
|
||||
}
|
||||
private func text(_ statement: OpaquePointer, _ column: Int32) -> String? {
|
||||
guard sqlite3_column_type(statement, column) != SQLITE_NULL, let value = sqlite3_column_text(statement, column) else { return nil }
|
||||
return String(cString: value)
|
||||
}
|
||||
private func integer(_ statement: OpaquePointer, _ column: Int32) -> Int64? { sqlite3_column_type(statement, column) == SQLITE_NULL ? nil : sqlite3_column_int64(statement, column) }
|
||||
private func blob(_ statement: OpaquePointer, _ column: Int32) -> Data? {
|
||||
guard sqlite3_column_type(statement, column) != SQLITE_NULL, let bytes = sqlite3_column_blob(statement, column) else { return nil }
|
||||
return Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, column)))
|
||||
}
|
||||
@discardableResult private nonisolated static func execute(_ database: OpaquePointer, _ sql: String) -> Bool { sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK }
|
||||
}
|
||||
|
||||
final class MediaNFOParser: NSObject, XMLParserDelegate {
|
||||
private var currentElement = ""
|
||||
private var currentText = ""
|
||||
private var rootElement = ""
|
||||
private var tmdbID: Int?
|
||||
private var title = ""
|
||||
private var originalTitle = ""
|
||||
private var overview = ""
|
||||
private var releaseDate = ""
|
||||
private var uniqueIDIsTMDB = false
|
||||
|
||||
static func parse(_ data: Data) -> ParsedMediaNFO? {
|
||||
let delegate = MediaNFOParser()
|
||||
let parser = XMLParser(data: data)
|
||||
parser.delegate = delegate
|
||||
guard parser.parse(), !delegate.title.isEmpty else { return nil }
|
||||
let kind: TMDBMediaKind? = delegate.rootElement == "tvshow" || delegate.rootElement == "episodedetails" ? .tv : (delegate.rootElement == "movie" ? .movie : nil)
|
||||
return ParsedMediaNFO(tmdbID: delegate.tmdbID, title: delegate.title, originalTitle: delegate.originalTitle.isEmpty ? delegate.title : delegate.originalTitle, overview: delegate.overview, releaseDate: delegate.releaseDate, mediaKind: kind)
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
|
||||
if rootElement.isEmpty { rootElement = elementName.lowercased() }
|
||||
currentElement = elementName.lowercased()
|
||||
currentText = ""
|
||||
uniqueIDIsTMDB = currentElement == "uniqueid" && attributeDict["type"]?.lowercased() == "tmdb"
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) { currentText += string }
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
|
||||
let value = currentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch elementName.lowercased() {
|
||||
case "title": if title.isEmpty { title = value }
|
||||
case "originaltitle": originalTitle = value
|
||||
case "plot", "outline": if overview.isEmpty { overview = value }
|
||||
case "premiered", "releasedate": if releaseDate.isEmpty { releaseDate = value }
|
||||
case "year": if releaseDate.isEmpty { releaseDate = value }
|
||||
case "uniqueid": if uniqueIDIsTMDB { tmdbID = Int(value) }
|
||||
case "tmdbid": tmdbID = Int(value)
|
||||
default: break
|
||||
}
|
||||
currentText = ""
|
||||
uniqueIDIsTMDB = false
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,10 @@ private func formatFileDate(_ epoch: Int64) -> String {
|
||||
}
|
||||
|
||||
struct CloudFile: Identifiable, Hashable, Sendable {
|
||||
static let supportedVideoExtensions: Set<String> = [
|
||||
"mp4", "m4v", "mkv", "mov", "avi", "ts", "m2ts", "mts", "webm",
|
||||
"flv", "f4v", "wmv", "asf", "mpg", "mpeg", "vob", "iso", "rm", "rmvb", "3gp", "ogv"
|
||||
]
|
||||
let id: String
|
||||
let name: String
|
||||
let isDirectory: Bool
|
||||
@@ -149,9 +153,11 @@ struct CloudFile: Identifiable, Hashable, Sendable {
|
||||
let fileType: Int
|
||||
|
||||
var pathDepth: Int { cloudPath.split(separator: "/").count }
|
||||
var isVideo: Bool { !isDirectory && (fileType == 2 || Self.supportedVideoExtensions.contains((name as NSString).pathExtension.lowercased())) }
|
||||
|
||||
var icon: String {
|
||||
if isDirectory { return "folder.fill" }
|
||||
if isVideo { return "play.rectangle.fill" }
|
||||
switch fileType {
|
||||
case 1: return "photo.fill"
|
||||
case 2: return "play.rectangle.fill"
|
||||
@@ -164,6 +170,7 @@ struct CloudFile: Identifiable, Hashable, Sendable {
|
||||
|
||||
var typeName: String {
|
||||
if isDirectory { return "文件夹" }
|
||||
if isVideo { return "视频" }
|
||||
return [1: "图片", 2: "视频", 3: "音频", 4: "文档", 5: "压缩包", 9: "BT种子"][fileType] ?? "文件"
|
||||
}
|
||||
|
||||
@@ -479,6 +486,7 @@ struct TMDBCandidate: Identifiable, Sendable {
|
||||
|
||||
struct MediaLibraryItem: Identifiable, Sendable {
|
||||
let file: CloudFile
|
||||
let tmdbID: Int?
|
||||
let title: String
|
||||
let originalTitle: String
|
||||
let mediaKind: TMDBMediaKind?
|
||||
@@ -492,6 +500,57 @@ struct MediaLibraryItem: Identifiable, Sendable {
|
||||
var isMatched: Bool { mediaKind != nil }
|
||||
}
|
||||
|
||||
enum MediaLibraryKind: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case movies
|
||||
case series
|
||||
case mixed
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .movies: return "电影"
|
||||
case .series: return "电视剧"
|
||||
case .mixed: return "混合内容"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaLibraryDefinition: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
var name: String
|
||||
var rootID: String?
|
||||
var rootPath: String
|
||||
var kind: MediaLibraryKind
|
||||
var recursive: Bool
|
||||
var updatedAt: Date?
|
||||
}
|
||||
|
||||
struct MediaLibraryScanProgress: Sendable {
|
||||
var phase = ""
|
||||
var completed = 0
|
||||
var total = 0
|
||||
}
|
||||
|
||||
struct MediaSidecarFiles: Sendable {
|
||||
let nfo: CloudFile?
|
||||
let poster: CloudFile?
|
||||
let backdrop: CloudFile?
|
||||
}
|
||||
|
||||
struct MediaScanSnapshot: Sendable {
|
||||
let files: [CloudFile]
|
||||
let sidecars: [String: MediaSidecarFiles]
|
||||
}
|
||||
|
||||
struct ParsedMediaNFO: Sendable {
|
||||
let tmdbID: Int?
|
||||
let title: String
|
||||
let originalTitle: String
|
||||
let overview: String
|
||||
let releaseDate: String
|
||||
let mediaKind: TMDBMediaKind?
|
||||
}
|
||||
|
||||
struct ParsedMediaName: Sendable {
|
||||
let title: String
|
||||
let year: Int?
|
||||
|
||||
Reference in New Issue
Block a user