From 934763c7ae32aa47cfddba303b103ccdad24a040 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Thu, 16 Jul 2026 13:10:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=81=E4=B9=85=E5=8C=96=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=BD=B1=E8=A7=86=E5=BA=93=E5=B9=B6=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E5=B7=B2=E6=9C=89=E5=88=AE=E5=89=8A=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- guangya_mac.xcodeproj/project.pbxproj | 4 + guangya_mac/AppModel.swift | 138 +++++++++-- guangya_mac/ContentView.swift | 11 +- guangya_mac/MediaLibraryPage.swift | 320 +++++++++++++++----------- guangya_mac/MediaLibraryStore.swift | 225 ++++++++++++++++++ guangya_mac/Models.swift | 59 +++++ 6 files changed, 607 insertions(+), 150 deletions(-) create mode 100644 guangya_mac/MediaLibraryStore.swift diff --git a/guangya_mac.xcodeproj/project.pbxproj b/guangya_mac.xcodeproj/project.pbxproj index 93b9471..9c40a24 100644 --- a/guangya_mac.xcodeproj/project.pbxproj +++ b/guangya_mac.xcodeproj/project.pbxproj @@ -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 = ""; }; 8C0E0B612C40000100A10001 /* BatchRenameTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRenameTool.swift; sourceTree = ""; }; 8C0E0B622C40000100A10001 /* MediaLibraryPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryPage.swift; sourceTree = ""; }; + 8C0E0B642C40000100A10001 /* MediaLibraryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryStore.swift; sourceTree = ""; }; /* 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; }; diff --git a/guangya_mac/AppModel.swift b/guangya_mac/AppModel.swift index bf1c24a..cca34ae 100644 --- a/guangya_mac/AppModel.swift +++ b/guangya_mac/AppModel.swift @@ -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] = [:] private let detailRequestGate = DetailRequestGate(limit: 4) private let metadataCache = FileMetadataCache.shared + private let mediaLibraryStore = MediaLibraryStore.shared private var isHandlingAuthorizationExpiry = false private var scanTask: Task? @@ -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, extensions: Set) -> 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() } } diff --git a/guangya_mac/ContentView.swift b/guangya_mac/ContentView.swift index d104814..bc07020 100644 --- a/guangya_mac/ContentView.swift +++ b/guangya_mac/ContentView.swift @@ -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 { diff --git a/guangya_mac/MediaLibraryPage.swift b/guangya_mac/MediaLibraryPage.swift index 587bf46..090ceb4 100644 --- a/guangya_mac/MediaLibraryPage.swift +++ b/guangya_mac/MediaLibraryPage.swift @@ -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() + 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)) } } } } } diff --git a/guangya_mac/MediaLibraryStore.swift b/guangya_mac/MediaLibraryStore.swift new file mode 100644 index 0000000..04193ae --- /dev/null +++ b/guangya_mac/MediaLibraryStore.swift @@ -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 + } +} diff --git a/guangya_mac/Models.swift b/guangya_mac/Models.swift index be72a0a..0da09fa 100644 --- a/guangya_mac/Models.swift +++ b/guangya_mac/Models.swift @@ -137,6 +137,10 @@ private func formatFileDate(_ epoch: Int64) -> String { } struct CloudFile: Identifiable, Hashable, Sendable { + static let supportedVideoExtensions: Set = [ + "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?