feat: 持久化可配置影视库并复用已有刮削信息

This commit is contained in:
2026-07-16 13:10:19 +08:00
parent ac224efcfd
commit 934763c7ae
6 changed files with 607 additions and 150 deletions
+4
View File
@@ -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
View File
@@ -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() }
}
+7 -4
View File
@@ -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
View File
@@ -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)) } }
}
}
}
+225
View File
@@ -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
}
}
+59
View File
@@ -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?