feat: 增加自动合集与多目录管理界面

This commit is contained in:
2026-07-16 14:03:09 +08:00
parent 6526e6cdc8
commit 8f78b25313
+162 -39
View File
@@ -5,6 +5,7 @@ private enum MediaLibraryFilter: String, CaseIterable, Identifiable {
case all = "全部"
case movies = "电影"
case series = "剧集"
case collections = "合集"
case unmatched = "未识别"
var id: String { rawValue }
}
@@ -20,11 +21,19 @@ struct MediaLibraryPage: View {
@State private var errorText = ""
@State private var showCreateLibrary = false
@State private var showDeleteConfirmation = false
@State private var editingLibrary: MediaLibraryDefinition?
@State private var activeCollectionID: String?
private var selectedLibrary: MediaLibraryDefinition? {
model.mediaLibraries.first { $0.id == selectedLibraryID }
}
private var activeProgress: MediaLibraryScanProgress? {
if model.isScanningMediaLibrary { return model.mediaLibraryScanProgress }
if model.isWritingMediaMetadata { return model.mediaMetadataWriteProgress }
return nil
}
private var visibleItems: [MediaLibraryItem] {
let filtered = items.filter { item in
let matchesSearch = searchText.isEmpty || item.title.localizedCaseInsensitiveContains(searchText) || item.originalTitle.localizedCaseInsensitiveContains(searchText)
@@ -33,6 +42,7 @@ struct MediaLibraryPage: View {
case .all: matchesFilter = true
case .movies: matchesFilter = item.mediaKind == .movie
case .series: matchesFilter = item.mediaKind == .tv
case .collections: matchesFilter = activeCollection?.items.contains(where: { $0.id == item.id }) ?? false
case .unmatched: matchesFilter = !item.isMatched
}
return matchesSearch && matchesFilter
@@ -47,6 +57,29 @@ struct MediaLibraryPage: View {
return order.compactMap { preferred[$0] }
}
private var collectionGroups: [MediaCollectionGroup] {
var grouped: [String: [MediaLibraryItem]] = [:]
var names: [String: String] = [:]
for item in items {
guard let name = item.collectionName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { continue }
let key = item.collectionID.map { "tmdb-\($0)" } ?? "nfo-\(name.lowercased())"
grouped[key, default: []].append(item)
names[key] = name
}
return grouped.compactMap { key, resources in
var preferred: [String: MediaLibraryItem] = [:]
for item in resources {
let itemKey = item.tmdbID.map(String.init) ?? item.id
if preferred[itemKey].map({ languageScore(item) > languageScore($0) }) ?? true { preferred[itemKey] = item }
}
let members = preferred.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
guard members.count >= 2, let name = names[key] else { return nil }
return MediaCollectionGroup(id: key, name: name, items: members)
}.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
private var activeCollection: MediaCollectionGroup? { collectionGroups.first { $0.id == activeCollectionID } }
private var featuredItem: MediaLibraryItem? {
selectedItemID.flatMap { id in items.first { $0.id == id } }
?? visibleItems.first(where: { $0.backdropData != nil })
@@ -61,7 +94,7 @@ struct MediaLibraryPage: View {
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
if let featuredItem { featuredHero(featuredItem) }
if let featuredItem, filter != .collections || activeCollection != nil { featuredHero(featuredItem) }
libraryControls
libraryContent
}
@@ -70,14 +103,23 @@ struct MediaLibraryPage: View {
}
.preferredColorScheme(.dark)
.sheet(isPresented: $showCreateLibrary) {
CreateMediaLibrarySheet(model: model) { name, rootID, rootPath, kind, recursive in
CreateMediaLibrarySheet(model: model) { name, sources, kind, recursive in
Task {
let library = await model.createMediaLibrary(name: name, rootID: rootID, rootPath: rootPath, kind: kind, recursive: recursive)
let library = await model.createMediaLibrary(name: name, sources: sources, kind: kind, recursive: recursive)
selectedLibraryID = library.id
await scan(library)
}
}
}
.sheet(item: $editingLibrary) { library in
CreateMediaLibrarySheet(model: model, initialLibrary: library) { name, sources, kind, recursive in
Task {
let updated = MediaLibraryDefinition(id: library.id, name: name, sources: sources, kind: kind, recursive: recursive, updatedAt: library.updatedAt)
await model.updateMediaLibrary(updated)
await scan(updated)
}
}
}
.confirmationDialog("删除影视库?", isPresented: $showDeleteConfirmation) {
Button("删除影视库", role: .destructive) {
guard let library = selectedLibrary else { return }
@@ -92,15 +134,21 @@ struct MediaLibraryPage: View {
Text("只删除本机影视库配置和刮削缓存,不会删除云盘文件。")
}
.task { await initializeLibrary() }
.onChange(of: selectedLibraryID) { _, _ in Task { await loadCachedItems() } }
.onChange(of: selectedLibraryID) { _, _ in activeCollectionID = nil; Task { await loadCachedItems() } }
.onChange(of: filter) { _, value in if value != .collections { activeCollectionID = nil } }
.onChange(of: model.mediaLibraryTarget?.id) { _, _ in Task { await initializeLibrary() } }
.onReceive(model.$mediaLibraryLiveItems) { liveItems in
guard model.mediaLibraryLiveLibraryID == selectedLibraryID else { return }
items = liveItems
if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id }
}
}
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("选择云盘目录并指定内容类型,扫描后将在本机保存海报和刮削信息。")
Text("添加一个或多个云盘目录,扫描后将在本机保存海报、合集和刮削信息。")
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth: 440)
Button { showCreateLibrary = true } label: { Label("新建影视库", systemImage: "plus") }
.buttonStyle(.borderedProminent).tint(.orange)
@@ -155,7 +203,7 @@ struct MediaLibraryPage: View {
}
.labelsHidden().frame(width: 220, alignment: .leading)
if let library = selectedLibrary {
Text("\(library.kind.title) · \(library.rootPath)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
Text("\(library.kind.title) · \(library.sources.count) 个目录").font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer()
@@ -167,25 +215,33 @@ struct MediaLibraryPage: View {
.padding(.horizontal, 10).frame(width: 240, height: 34).background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 7))
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") }
if model.isScanningMediaLibrary || model.isWritingMediaMetadata { 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") }
.buttonStyle(.bordered).help("扫描并刮削").disabled(model.isScanningMediaLibrary || model.isWritingMediaMetadata || selectedLibrary == nil)
Menu {
Button { editingLibrary = selectedLibrary } label: { Label("管理影视库", systemImage: "slider.horizontal.3") }
Divider()
Button("删除影视库", role: .destructive) { showDeleteConfirmation = true }
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton).help("影视库操作")
}
if model.isScanningMediaLibrary {
if let progress = activeProgress {
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)
ProgressView(value: Double(progress.completed), total: Double(max(1, progress.total))).frame(maxWidth: 260)
Text(progress.phase).font(.caption.weight(.medium))
Text("\(progress.completed)/\(progress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Spacer()
}
}
HStack {
if let collection = activeCollection {
Button { activeCollectionID = nil; selectedItemID = nil } label: { Image(systemName: "chevron.left") }.buttonStyle(.bordered).help("返回合集")
Text(collection.name).font(.callout.weight(.semibold)).lineLimit(1)
}
Picker("分类", selection: $filter) { ForEach(MediaLibraryFilter.allCases) { Text($0.rawValue).tag($0) } }
.pickerStyle(.segmented).labelsHidden().frame(width: 360)
.pickerStyle(.segmented).labelsHidden().frame(width: 440)
Spacer()
Label("\(visibleItems.count) 个条目", systemImage: "rectangle.grid.2x2").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Label(filter == .collections && activeCollection == nil ? "\(collectionGroups.count) 个合集" : "\(visibleItems.count) 个条目", systemImage: filter == .collections ? "rectangle.stack" : "rectangle.grid.2x2").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Text("·").foregroundStyle(.tertiary)
Label("\(items.count) 个资源", systemImage: "film.stack").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
@@ -210,6 +266,19 @@ struct MediaLibraryPage: View {
.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 filter == .collections && activeCollection == nil && collectionGroups.isEmpty {
ContentUnavailableView("暂无自动合集", systemImage: "rectangle.stack", description: Text("至少有两部本地影片属于同一 TMDB 或 NFO 合集时会自动显示。"))
.frame(maxWidth: .infinity).padding(.vertical, 70)
} else if filter == .collections && activeCollection == nil {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180, maximum: 230), spacing: 18)], alignment: .leading, spacing: 24) {
ForEach(collectionGroups) { collection in
MediaCollectionCard(collection: collection) {
activeCollectionID = collection.id
selectedItemID = collection.items.first?.id
}
}
}
.padding(.horizontal, 28).padding(.bottom, 36)
} else if visibleItems.isEmpty {
ContentUnavailableView("影视库暂无内容", systemImage: "film.stack", description: Text("点击扫描按钮读取云盘视频并更新刮削信息。"))
.frame(maxWidth: .infinity).padding(.vertical, 70)
@@ -235,7 +304,7 @@ struct MediaLibraryPage: View {
defer { isLoadingCache = false }
await model.loadMediaLibraries()
if let target = model.mediaLibraryTarget, target.isDirectory {
if let existing = model.mediaLibraries.first(where: { $0.rootID == target.id }) {
if let existing = model.mediaLibraries.first(where: { library in library.sources.contains { $0.rootID == target.id } }) {
selectedLibraryID = existing.id
items = await model.cachedMediaLibraryItems(libraryID: existing.id)
} else {
@@ -283,25 +352,34 @@ struct MediaLibraryPage: View {
private struct CreateMediaLibrarySheet: View {
@ObservedObject var model: AppModel
let onCreate: (String, String?, String, MediaLibraryKind, Bool) -> Void
let initialLibrary: MediaLibraryDefinition?
let onSave: (String, [MediaLibrarySource], 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 name: String
@State private var sources: [MediaLibrarySource]
@State private var kind: MediaLibraryKind
@State private var recursive: Bool
@State private var showFolderPicker = false
@State private var hasChosenRoot = false
private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) }
init(model: AppModel, initialLibrary: MediaLibraryDefinition? = nil, onSave: @escaping (String, [MediaLibrarySource], MediaLibraryKind, Bool) -> Void) {
self.model = model
self.initialLibrary = initialLibrary
self.onSave = onSave
_name = State(initialValue: initialLibrary?.name ?? "")
_sources = State(initialValue: initialLibrary?.sources ?? [])
_kind = State(initialValue: initialLibrary?.kind ?? .movies)
_recursive = State(initialValue: initialLibrary?.recursive ?? true)
}
var body: some View {
ZStack {
Color(nsColor: .windowBackgroundColor).ignoresSafeArea()
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 14) {
Image(systemName: "play.tv.fill").font(.title2).foregroundStyle(.orange).frame(width: 46, height: 46).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) }
VStack(alignment: .leading, spacing: 3) { Text(initialLibrary == nil ? "新建影视库" : "管理影视库").font(.title2.weight(.bold)); Text("\(sources.count)云端媒体目录").font(.caption).foregroundStyle(.secondary) }
Spacer()
Button { dismiss() } label: { Image(systemName: "xmark") }.buttonStyle(.bordered).help("关闭")
}
@@ -322,30 +400,37 @@ private struct CreateMediaLibrarySheet: View {
}
VStack(alignment: .leading, spacing: 10) {
Text("媒体目录").font(.callout.weight(.semibold))
Button { showFolderPicker = true } label: {
HStack(spacing: 12) {
Image(systemName: hasChosenRoot ? "folder.fill" : "folder.badge.plus").font(.title3).foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 3) {
Text(hasChosenRoot ? rootPath : "选择云盘文件夹").font(.callout.weight(.semibold)).foregroundStyle(.primary).lineLimit(1)
if hasChosenRoot { Text(rootID == nil ? "整个云盘" : "已选择媒体目录").font(.caption).foregroundStyle(.secondary) }
if !sources.isEmpty {
ScrollView {
VStack(spacing: 0) {
ForEach(sources) { source in
HStack(spacing: 10) {
Image(systemName: "folder.fill").foregroundStyle(.orange).frame(width: 20)
Text(source.path).font(.callout).lineLimit(1)
Spacer()
Button { sources.removeAll { $0.id == source.id } } label: { Image(systemName: "minus.circle") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("移除目录")
}
.padding(.horizontal, 12).frame(height: 38)
if source.id != sources.last?.id { Divider().padding(.leading, 42) }
}
}
Spacer()
Image(systemName: "chevron.right").font(.caption.weight(.bold)).foregroundStyle(.tertiary)
}
.padding(12).contentShape(Rectangle())
.frame(maxHeight: 152)
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain).background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8))
Button { showFolderPicker = true } label: { Label("添加媒体目录", systemImage: "folder.badge.plus") }.buttonStyle(.bordered)
}
Toggle(isOn: $recursive) {
VStack(alignment: .leading, spacing: 2) { Text("包含子文件夹").font(.callout.weight(.semibold)); Text("递归扫描目录中的电影、剧集和刮削文件").font(.caption).foregroundStyle(.secondary) }
}
Divider()
HStack {
if !hasChosenRoot { Label("选择媒体目录", systemImage: "exclamationmark.circle").font(.caption).foregroundStyle(.secondary) }
if sources.isEmpty { Label("至少添加一个媒体目录", systemImage: "exclamationmark.circle").font(.caption).foregroundStyle(.secondary) }
Spacer()
Button("取消") { dismiss() }.keyboardShortcut(.cancelAction)
Button { onCreate(trimmedName, rootID, rootPath, kind, recursive); dismiss() } label: { Label("创建并扫描", systemImage: "plus") }
.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(trimmedName.isEmpty || !hasChosenRoot)
Button { onSave(trimmedName, sources, kind, recursive); dismiss() } label: { Label(initialLibrary == nil ? "创建并扫描" : "保存并扫描", systemImage: initialLibrary == nil ? "plus" : "checkmark") }
.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(trimmedName.isEmpty || sources.isEmpty)
}
}
.padding(24)
@@ -353,8 +438,12 @@ private struct CreateMediaLibrarySheet: View {
}
.frame(width: 590)
.sheet(isPresented: $showFolderPicker) {
ScanFolderPickerSheet(model: model, title: "选择媒体目录", selectionLabel: "选择此目录") { id, path in
rootID = id; rootPath = path; hasChosenRoot = true
ScanFolderPickerSheet(model: model, title: "选择媒体目录", selectionLabel: "添加此目录") { id, path in
if id == nil { sources = [MediaLibrarySource(rootID: nil, path: path)] }
else if !sources.contains(where: { $0.rootID == id }) {
sources.removeAll { $0.rootID == nil }
sources.append(MediaLibrarySource(rootID: id, path: path))
}
if trimmedName.isEmpty, path != "整个云盘" { name = path.split(separator: "/").last.map(String.init) ?? kind.title }
else if trimmedName.isEmpty { name = kind.title }
}
@@ -381,6 +470,40 @@ private struct LibraryKindButton: View {
}
}
private struct MediaCollectionGroup: Identifiable {
let id: String
let name: String
let items: [MediaLibraryItem]
var artwork: Data? { items.first(where: { $0.posterData != nil })?.posterData }
}
private struct MediaCollectionCard: View {
let collection: MediaCollectionGroup
let onOpen: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: onOpen) {
VStack(alignment: .leading, spacing: 10) {
ZStack(alignment: .bottomTrailing) {
MediaArtwork(data: collection.artwork, icon: "rectangle.stack.fill", contentMode: .fill)
.aspectRatio(2 / 3, contentMode: .fit).frame(maxWidth: .infinity).clipShape(RoundedRectangle(cornerRadius: 6))
Label("\(collection.items.count)", systemImage: "film.stack")
.font(.caption.weight(.bold)).padding(.horizontal, 8).padding(.vertical, 5)
.foregroundStyle(.white).background(.black.opacity(0.76), in: Capsule()).padding(8)
}
.overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(isHovered ? 0.34 : 0.10), lineWidth: 1) }
Text(collection.name).font(.callout.weight(.semibold)).foregroundStyle(.primary).lineLimit(1)
Text(collection.items.map(\.year).filter { !$0.isEmpty }.sorted().first ?? "自动合集")
.font(.caption).foregroundStyle(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain).onHover { isHovered = $0 }
}
}
private struct MediaPosterCard: View {
let item: MediaLibraryItem
let resourceCount: Int