feat: 新增 Emby 风格云端影视库

This commit is contained in:
2026-07-16 12:46:36 +08:00
parent fc22f63db4
commit ac224efcfd
7 changed files with 365 additions and 8 deletions
+4
View File
@@ -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 = "<group>"; };
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>"; };
/* 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;
};
+61
View File
@@ -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..<min(4, requests.count) { addNext() }
var values: [(Int, MediaLibraryItem)] = []
while let value = await group.next() {
values.append(value)
addNext()
}
return values
}
return results.sorted { $0.0 < $1.0 }.map(\.1)
}
func copy(_ file: CloudFile, to parentID: String?) async {
await perform("正在复制…") { [self] in _ = try await api.fsCopy(fileIDs: [file.id], parentID: parentID); await loadFiles() }
}
+14 -4
View File
@@ -373,6 +373,7 @@ struct WorkspaceSidebar: View {
model.batchRenameTarget = nil
model.batchRenameSelection = []
}
if tool == .media { model.mediaLibraryTarget = nil }
selectedTool = tool
}
}
@@ -764,6 +765,10 @@ struct FilesBrowser: View {
Button { renameFile = file } label: { Label("重命名", systemImage: "pencil") }
if file.isDirectory {
Menu("文件夹工具") {
Button {
model.mediaLibraryTarget = file
onOpenTool(.media)
} label: { Label("打开为影视库", systemImage: "play.tv.fill") }
Button { openBatchRenameTool(for: file) } label: { Label("批量重命名…", systemImage: "character.cursor.ibeam") }
Button { openScanTool(.emptyFolders, for: file) } label: { Label("扫描空文件夹", systemImage: "folder.badge.minus") }
Button { openScanTool(.duplicates, for: file) } label: { Label("扫描重复文件", systemImage: "square.on.square") }
@@ -1133,7 +1138,10 @@ struct ToolWorkspacePage: View {
let tool: WorkspaceTool
var body: some View {
VStack(alignment: .leading, spacing: 18) {
if tool == .media {
MediaLibraryPage(model: model)
} else {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 14) {
Image(systemName: tool.icon).font(.title2.weight(.semibold)).foregroundStyle(.orange).frame(width: 52, height: 52).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 17, style: .continuous))
VStack(alignment: .leading, spacing: 4) { Text(tool.rawValue).font(.system(size: 28, weight: .bold, design: .rounded)); Text(tool.subtitle).foregroundStyle(.secondary) }
@@ -1142,15 +1150,17 @@ struct ToolWorkspacePage: View {
.padding(20).softCard(radius: 24)
switch tool {
case .media: EmptyView()
case .scan: UnifiedScanToolPage(model: model)
case .rename: BatchRenameToolPage(model: model)
case .tmdb: TMDBToolPage(model: model)
case .settings: ToolSettingsPage(model: model)
}
}
.padding(14)
.background(Color.clear)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.padding(14)
.background(Color.clear)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
+2 -3
View File
@@ -314,8 +314,8 @@ final class GuangyaAPI: @unchecked Sendable {
return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort)
}
func tmdbImage(path: String, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> 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..<end))); offset = end }; return Insecure.SHA1.hash(data: hashes).map { String(format: "%02X", $0) }.joined() }
}
+264
View File
@@ -0,0 +1,264 @@
import AppKit
import SwiftUI
private enum MediaLibraryFilter: String, CaseIterable, Identifiable {
case all = "全部"
case movies = "电影"
case series = "剧集"
case unmatched = "未识别"
var id: String { rawValue }
}
struct MediaLibraryPage: View {
@ObservedObject var model: AppModel
@State private var sourceID: String?
@State private var sourceName = "云盘根目录"
@State private var items: [MediaLibraryItem] = []
@State private var selectedID: String?
@State private var filter: MediaLibraryFilter = .all
@State private var searchText = ""
@State private var isLoading = false
@State private var errorText = ""
@State private var showFolderPicker = false
private var filteredItems: [MediaLibraryItem] {
items.filter { item in
let matchesSearch = searchText.isEmpty || item.title.localizedCaseInsensitiveContains(searchText) || item.originalTitle.localizedCaseInsensitiveContains(searchText)
let matchesFilter: Bool
switch filter {
case .all: matchesFilter = true
case .movies: matchesFilter = item.mediaKind == .movie
case .series: matchesFilter = item.mediaKind == .tv
case .unmatched: matchesFilter = !item.isMatched
}
return matchesSearch && matchesFilter
}
}
private var featuredItem: MediaLibraryItem? {
selectedID.flatMap { id in items.first { $0.id == id } }
?? items.first(where: { $0.backdropData != nil })
?? items.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
}
}
}
.preferredColorScheme(.dark)
.sheet(isPresented: $showFolderPicker) {
ScanFolderPickerSheet(model: model, title: "选择影视库目录", selectionLabel: "设为影视库") { id, name in
model.mediaLibraryTarget = nil
sourceID = id
sourceName = name
Task { await loadLibrary() }
}
}
.task {
syncTarget()
await loadLibrary()
}
.onChange(of: model.mediaLibraryTarget?.id) { _, _ in
syncTarget()
Task { await loadLibrary() }
}
}
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)
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))
}
}
}
}
}
+17 -1
View File
@@ -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?
+3
View File
@@ -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 "配置并执行媒体识别整理"