diff --git a/guangya_mac.xcodeproj/project.pbxproj b/guangya_mac.xcodeproj/project.pbxproj index ee8b98d..93b9471 100644 --- a/guangya_mac.xcodeproj/project.pbxproj +++ b/guangya_mac.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B472C40000100A10001 /* AppTheme.swift */; }; 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 */; }; 8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; }; /* End PBXBuildFile section */ @@ -35,6 +36,7 @@ 8C0E0B472C40000100A10001 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -72,6 +74,7 @@ 8C0E0B472C40000100A10001 /* AppTheme.swift */, 8C0E0B482C40000100A10001 /* WorkspaceShell.swift */, 8C0E0B612C40000100A10001 /* BatchRenameTool.swift */, + 8C0E0B622C40000100A10001 /* MediaLibraryPage.swift */, 8C0E0B422C40000100A10001 /* Models.swift */, 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */, 8C0E0B442C40000100A10001 /* AppModel.swift */, @@ -171,6 +174,7 @@ 8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */, 8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */, 8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */, + 8C0E0B632C40000100A10001 /* MediaLibraryPage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/guangya_mac/AppModel.swift b/guangya_mac/AppModel.swift index 8b1184b..bf1c24a 100644 --- a/guangya_mac/AppModel.swift +++ b/guangya_mac/AppModel.swift @@ -34,6 +34,7 @@ final class AppModel: ObservableObject { @Published var tmdbTarget: CloudFile? @Published var batchRenameTarget: CloudFile? @Published var batchRenameSelection: [CloudFile] = [] + @Published var mediaLibraryTarget: CloudFile? @Published var isRunningTMDBJob = false @Published var isAnalyzing = false @Published var activeScanRequest: ScanRequest? @@ -376,6 +377,66 @@ 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) + } + } + + let requests = files.enumerated().map { index, file in + (index: index, file: file, parsed: parseMediaName(file.name)) + } + let api = self.api + let apiKey = tmdbAPIKey + let proxyHost = tmdbProxyHost + let proxyPort = tmdbProxyPort + var iterator = requests.makeIterator() + let results = await withTaskGroup(of: (Int, MediaLibraryItem).self, returning: [(Int, MediaLibraryItem)].self) { group in + 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) + do { + let preferredKind: TMDBMediaKind = 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) + } + let posterData: Data? + if let path = candidate.posterPath { + posterData = try? await api.tmdbImage(path: path, size: "w500", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort) + } else { + posterData = nil + } + let backdropData: Data? + if request.index < 8, let path = candidate.backdropPath { + backdropData = try? await api.tmdbImage(path: path, size: "w1280", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort) + } 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)) + } catch { + return (request.index, fallback) + } + } + } + for _ in 0.. Data { - guard let url = URL(string: "https://image.tmdb.org/t/p/original\(path)") else { throw GuangyaAPIError.invalidResponse } + func tmdbImage(path: String, size: String = "original", apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> Data { + guard let url = URL(string: "https://image.tmdb.org/t/p/\(size)\(path)") else { throw GuangyaAPIError.invalidResponse } return try await tmdbDataRequest(url, proxyHost: proxyHost, proxyPort: proxyPort) } @@ -516,4 +516,3 @@ final class GuangyaAPI: @unchecked Sendable { private static func httpDate() -> String { let formatter = DateFormatter(); formatter.locale = Locale(identifier: "en_US_POSIX"); formatter.timeZone = TimeZone(secondsFromGMT: 0); formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"; return formatter.string(from: Date()) } private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset.. 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) + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + if let kind = item.mediaKind { + Text(kind == .movie ? "电影" : "剧集") + } else { + Text(item.file.isDirectory ? "媒体文件夹" : "云盘视频") + } + if !item.year.isEmpty { Text(item.year) } + } + .font(.caption.weight(.semibold)).foregroundStyle(.white.opacity(0.72)) + Text(item.title).font(.system(size: 34, weight: .bold)).foregroundStyle(.white).lineLimit(2) + if !item.overview.isEmpty { + 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) + } + } + } + .padding(.horizontal, 34).padding(.bottom, 30) + } + .frame(height: 330) + } + + 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) + } + Spacer() + TextField("搜索影片、剧集", text: $searchText) + .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") } + } + .buttonStyle(.bordered).help("刷新影视库").disabled(isLoading) + } + HStack { + 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) + } + } + .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) + } 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("调整搜索或分类筛选。")) + .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) + } + .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 { Task { await model.share(item.file) } } label: { Label("分享", systemImage: "square.and.arrow.up") } + } + } + } + .padding(.horizontal, 28).padding(.bottom, 36) + } + } + + private func syncTarget() { + if let target = model.mediaLibraryTarget, target.isDirectory { + sourceID = target.id + sourceName = target.name + } else { + sourceID = model.currentScanRootID + sourceName = model.scanScopeLocationName + } + } + + @MainActor private func loadLibrary() async { + guard !isLoading else { return } + isLoading = true + 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 } + } + + 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 struct MediaPosterCard: View { + let item: MediaLibraryItem + let isSelected: Bool + let onSelect: () -> Void + let onOpen: () -> Void + @State private var isHovered = false + + var body: some 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) + } + } + .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()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .simultaneousGesture(TapGesture(count: 2).onEnded(onOpen)) + } +} + +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)) + } + } + } + } +} diff --git a/guangya_mac/Models.swift b/guangya_mac/Models.swift index fa3e8f4..be72a0a 100644 --- a/guangya_mac/Models.swift +++ b/guangya_mac/Models.swift @@ -462,6 +462,7 @@ struct TMDBCandidate: Identifiable, Sendable { let releaseDate: String let overview: String let posterPath: String? + let backdropPath: String? init?(json: JSONValue, forcedKind: TMDBMediaKind? = nil) { guard let id = json.firstInt(["id"]), let title = json.firstString(["title", "name"]) else { return nil } @@ -470,12 +471,27 @@ struct TMDBCandidate: Identifiable, Sendable { self.id = id; self.title = title; originalTitle = json.firstString(["original_title", "original_name"]) ?? title mediaType = rawType == "tv" ? .tv : .movie releaseDate = json.firstString(["release_date", "first_air_date"]) ?? "" - overview = json.firstString(["overview"]) ?? ""; posterPath = json.firstString(["poster_path"]) + overview = json.firstString(["overview"]) ?? ""; posterPath = json.firstString(["poster_path"]); backdropPath = json.firstString(["backdrop_path"]) } var match: TMDBMatch { TMDBMatch(id: id, title: title, mediaType: mediaType.rawValue, releaseDate: releaseDate, overview: overview) } } +struct MediaLibraryItem: Identifiable, Sendable { + let file: CloudFile + let title: String + let originalTitle: String + let mediaKind: TMDBMediaKind? + let releaseDate: String + let overview: String + let posterData: Data? + let backdropData: Data? + + var id: String { file.id } + var year: String { String(releaseDate.prefix(4)) } + var isMatched: Bool { mediaKind != nil } +} + struct ParsedMediaName: Sendable { let title: String let year: Int? diff --git a/guangya_mac/WorkspaceShell.swift b/guangya_mac/WorkspaceShell.swift index f4ca782..48d5bfd 100644 --- a/guangya_mac/WorkspaceShell.swift +++ b/guangya_mac/WorkspaceShell.swift @@ -2,6 +2,7 @@ import SwiftUI import UniformTypeIdentifiers enum WorkspaceTool: String, CaseIterable, Identifiable { + case media = "影视库" case scan = "文件扫描与清理" case rename = "批量重命名" case tmdb = "TMDB 整理" @@ -10,6 +11,7 @@ enum WorkspaceTool: String, CaseIterable, Identifiable { var id: String { rawValue } var icon: String { switch self { + case .media: return "play.tv.fill" case .scan: return "magnifyingglass.circle.fill" case .rename: return "character.cursor.ibeam" case .tmdb: return "film.stack" @@ -18,6 +20,7 @@ enum WorkspaceTool: String, CaseIterable, Identifiable { } var subtitle: String { switch self { + case .media: return "海报墙、媒体详情与播放入口" case .scan: return "空文件夹、重复文件与相似文件夹统一扫描" case .rename: return "按字符、正则和组合规则批量修改名称" case .tmdb: return "配置并执行媒体识别整理"