Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions KMReader.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 500;
CURRENT_PROJECT_VERSION = 501;
DEVELOPMENT_TEAM = M777UHWZA4;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
Expand Down Expand Up @@ -500,7 +500,7 @@
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 500;
CURRENT_PROJECT_VERSION = 501;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=appletvos*]" = M777UHWZA4;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4;
Expand Down Expand Up @@ -560,7 +560,7 @@
CODE_SIGN_ENTITLEMENTS = KMReaderWidgets/KMReaderWidgets.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 500;
CURRENT_PROJECT_VERSION = 501;
DEVELOPMENT_TEAM = M777UHWZA4;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = KMReaderWidgets/Info.plist;
Expand Down Expand Up @@ -594,7 +594,7 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 500;
CURRENT_PROJECT_VERSION = 501;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4;
GENERATE_INFOPLIST_FILE = YES;
Expand Down
50 changes: 49 additions & 1 deletion KMReader/Core/Storage/Cache/ThumbnailCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ extension Notification.Name {
static let thumbnailDidRefresh = Notification.Name("thumbnailDidRefresh")
}

nonisolated enum ThumbnailType: String, CaseIterable, Sendable {
nonisolated enum ThumbnailType: String, CaseIterable, Hashable, Sendable {
case book
case series
case collection
Expand Down Expand Up @@ -298,6 +298,54 @@ actor ThumbnailCache {
}
}

/// Returns cached cover thumbnail ids matching the requested ids.
func cachedCoverThumbnailIds(matching requestedIdsByType: [ThumbnailType: Set<String>]) async
-> [ThumbnailType: Set<String>]
{
let requestedIdsByType = requestedIdsByType.filter { type, ids in
type != .page && !ids.isEmpty
}
guard !requestedIdsByType.isEmpty else { return [:] }

let diskCacheURL = CacheNamespace.directory(for: "KomgaThumbnailCache")

return await Task.detached(priority: .utility) {
var cachedIdsByType: [ThumbnailType: Set<String>] = [:]
let fileManager = FileManager.default

for (type, requestedIds) in requestedIdsByType {
let typeDirectory = diskCacheURL.appendingPathComponent(type.rawValue, isDirectory: true)
guard
let fileURLs = try? fileManager.contentsOfDirectory(
at: typeDirectory,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
)
else {
continue
}

var cachedIds = Set<String>()
for fileURL in fileURLs where fileURL.pathExtension.lowercased() == "jpg" {
let isRegularFile =
(try? fileURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
guard isRegularFile else { continue }

let id = fileURL.deletingPathExtension().lastPathComponent
if requestedIds.contains(id) {
cachedIds.insert(id)
}
}

if !cachedIds.isEmpty {
cachedIdsByType[type] = cachedIds
}
}

return cachedIdsByType
}.value
}

private static nonisolated func taskCacheKey(
id: String,
type: ThumbnailType,
Expand Down
53 changes: 51 additions & 2 deletions KMReader/Features/Offline/Services/OfflineCoverSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ actor OfflineCoverSyncService {
static let shared = OfflineCoverSyncService()

private let logger = AppLogger(.offline)
private let cachedProgressReportInterval: TimeInterval = 0.15
private var isRunning = false

private init() {}
Expand All @@ -33,15 +34,30 @@ actor OfflineCoverSyncService {
instanceId: instanceId,
libraryIds: libraryIds
)
var cachedThumbnailIds = await ThumbnailCache.shared.cachedCoverThumbnailIds(
matching: targetIdsByType(targets)
)
var summary = OfflineCoverSyncSummary()
summary.totalCount = targets.count
await reportProgress(summary: summary, onProgress: onProgress)
var lastCachedProgressReportAt = Date()

for target in targets {
if shouldStopSync(instanceId: instanceId) {
return await stopSync(summary: summary, onProgress: onProgress)
}

if cachedThumbnailIds[target.type]?.contains(target.thumbnailId) == true {
summary.existingCount += 1
summary.checkedCount += 1
await reportCachedProgressIfNeeded(
summary: summary,
lastReportAt: &lastCachedProgressReportAt,
onProgress: onProgress
)
continue
}

do {
let result = try await ThumbnailCache.shared.ensureMissingThumbnail(
id: target.thumbnailId,
Expand All @@ -50,16 +66,25 @@ actor OfflineCoverSyncService {

switch result {
case .cached:
cachedThumbnailIds[target.type, default: []].insert(target.thumbnailId)
summary.existingCount += 1
summary.checkedCount += 1
await reportCachedProgressIfNeeded(
summary: summary,
lastReportAt: &lastCachedProgressReportAt,
onProgress: onProgress
)
case .stored:
cachedThumbnailIds[target.type, default: []].insert(target.thumbnailId)
summary.storedCount += 1
summary.checkedCount += 1
await reportProgress(summary: summary, onProgress: onProgress)
case .cacheLimitReached:
summary.stoppedAtCacheLimit = true
logger.info("⏸️ Stopped offline cover sync because cover cache reached its maximum size")
await reportProgress(summary: summary, onProgress: onProgress)
return summary
}
summary.checkedCount += 1
} catch is CancellationError {
return await stopSync(summary: summary, onProgress: onProgress)
} catch APIError.offline {
Expand All @@ -80,10 +105,11 @@ actor OfflineCoverSyncService {
logger.warning(
"⚠️ Failed to sync offline cover for \(target.type.rawValue) \(target.thumbnailId): \(error.localizedDescription)"
)
await reportProgress(summary: summary, onProgress: onProgress)
}
await reportProgress(summary: summary, onProgress: onProgress)
}

await reportProgress(summary: summary, onProgress: onProgress)
logger.info(
"✅ Offline cover sync finished: checked=\(summary.checkedCount), existing=\(summary.existingCount), stored=\(summary.storedCount), failed=\(summary.failedCount)"
)
Expand All @@ -103,6 +129,29 @@ actor OfflineCoverSyncService {
}
}

private func targetIdsByType(_ targets: [OfflineCoverSyncTarget]) -> [ThumbnailType: Set<String>] {
var idsByType: [ThumbnailType: Set<String>] = [:]
for target in targets {
idsByType[target.type, default: []].insert(target.thumbnailId)
}
return idsByType
}

private func reportCachedProgressIfNeeded(
summary: OfflineCoverSyncSummary,
lastReportAt: inout Date,
onProgress: OfflineCoverSyncProgressHandler?
) async {
let now = Date()
guard
summary.checkedCount == summary.totalCount
|| now.timeIntervalSince(lastReportAt) >= cachedProgressReportInterval
else { return }

lastReportAt = now
await reportProgress(summary: summary, onProgress: onProgress)
}

private func stopSync(
summary: OfflineCoverSyncSummary,
onProgress: OfflineCoverSyncProgressHandler?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,47 +17,58 @@ final class OfflineCoverSyncViewModel {
private(set) var selectedLibraryIds: Set<String> = []
@ObservationIgnored private var syncTask: Task<Void, Never>?
@ObservationIgnored private var libraryScopeInstanceId: String?
@ObservationIgnored private var hasManualLibrarySelection = false

private init() {}

var syncsAllLibraries: Bool {
selectedLibraryIds.isEmpty
}

func loadLibraryScopeOptions(instanceId: String) async {
func loadLibraryScopeOptions(instanceId: String, defaultLibraryIds: [String]) async {
guard !instanceId.isEmpty else {
clearLibraryScope()
return
}

guard !Task.isCancelled else { return }
guard let database = try? await DatabaseOperator.database() else {
guard !Task.isCancelled else { return }
clearLibraryScope()
return
}

let loadedLibraries = await database.fetchLibraries(instanceId: instanceId)
.filter { $0.id != KomgaLibrary.allLibrariesId }
guard !Task.isCancelled else { return }

if libraryScopeInstanceId != instanceId {
libraryScopeInstanceId = instanceId
selectedLibraryIds = []
}
let isNewInstanceScope = libraryScopeInstanceId != instanceId
libraryScopeInstanceId = instanceId

if libraries != loadedLibraries {
libraries = loadedLibraries
}

if isNewInstanceScope {
hasManualLibrarySelection = false
}

if isNewInstanceScope || !hasManualLibrarySelection {
selectedLibraryIds = Set(defaultLibraryIds)
}
Comment thread
everpcpc marked this conversation as resolved.
normalizeSelectedLibraryIds()
}

func selectedLibraryIdsForSync(instanceId: String) -> [String] {
guard libraryScopeInstanceId == instanceId else { return [] }
func selectedLibraryIdsForSync(instanceId: String, defaultLibraryIds: [String]) -> [String] {
guard libraryScopeInstanceId == instanceId else { return defaultLibraryIds.sorted() }
return selectedLibraryIds.sorted()
}

func selectLibraries(_ libraryIds: Set<String>) {
let libraryIds = normalizedLibrarySelection(libraryIds)
guard selectedLibraryIds != libraryIds else { return }
hasManualLibrarySelection = true
Comment thread
everpcpc marked this conversation as resolved.
selectedLibraryIds = libraryIds
normalizeSelectedLibraryIds()
}

func startSyncMissingCovers(instanceId: String, libraryIds: [String]) {
Expand Down Expand Up @@ -155,18 +166,24 @@ final class OfflineCoverSyncViewModel {
libraryScopeInstanceId = nil
libraries = []
selectedLibraryIds = []
hasManualLibrarySelection = false
}

private func normalizeSelectedLibraryIds() {
selectedLibraryIds = normalizedLibrarySelection(selectedLibraryIds)
}

private func normalizedLibrarySelection(_ libraryIds: Set<String>) -> Set<String> {
guard !libraries.isEmpty else {
selectedLibraryIds = []
return
return []
}

let validLibraryIds = Set(libraries.map(\.id))
selectedLibraryIds = selectedLibraryIds.intersection(validLibraryIds)
let selectedLibraryIds = libraryIds.intersection(validLibraryIds)
if selectedLibraryIds.count == validLibraryIds.count {
selectedLibraryIds = []
return []
}
return selectedLibraryIds
}

}
17 changes: 14 additions & 3 deletions KMReader/Features/Offline/Views/OfflineCoverSyncSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct OfflineCoverSyncSection: View {
let viewModel: OfflineCoverSyncViewModel
let instanceId: String
let isOffline: Bool
let defaultLibraryIds: [String]
@State private var isPickerPresented = false

private var isStartDisabled: Bool {
Expand All @@ -19,6 +20,10 @@ struct OfflineCoverSyncSection: View {
viewModel.isSyncing || isOffline || instanceId.isEmpty
}

private var libraryScopeTaskID: [String] {
[instanceId] + defaultLibraryIds.sorted()
}

var body: some View {
Section {
VStack(alignment: .leading, spacing: 6) {
Expand Down Expand Up @@ -76,8 +81,11 @@ struct OfflineCoverSyncSection: View {
viewModel.selectLibraries(libraryIds)
}
}
.task(id: instanceId) {
await viewModel.loadLibraryScopeOptions(instanceId: instanceId)
.task(id: libraryScopeTaskID) {
await viewModel.loadLibraryScopeOptions(
instanceId: instanceId,
defaultLibraryIds: defaultLibraryIds
Comment thread
everpcpc marked this conversation as resolved.
)
Comment thread
everpcpc marked this conversation as resolved.
}
.onChange(of: instanceId) { _, newValue in
viewModel.cancelSyncIfContextChanged(instanceId: newValue, isOffline: isOffline)
Expand Down Expand Up @@ -157,7 +165,10 @@ struct OfflineCoverSyncSection: View {
return
}

let libraryIds = viewModel.selectedLibraryIdsForSync(instanceId: instanceId)
let libraryIds = viewModel.selectedLibraryIdsForSync(
instanceId: instanceId,
defaultLibraryIds: defaultLibraryIds
)
viewModel.startSyncMissingCovers(instanceId: instanceId, libraryIds: libraryIds)
}
}
4 changes: 3 additions & 1 deletion KMReader/Features/Offline/Views/OfflineTasksView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct OfflineTasksView: View {
@AppStorage("currentAccount") private var current: Current = .init()
private var instanceId: String { current.instanceId }
@AppStorage("isOffline") private var isOffline: Bool = false
@AppStorage("dashboard") private var dashboard: DashboardConfiguration = DashboardConfiguration()
@AppStorage("offlinePaused") private var isPaused: Bool = false
@AppStorage("offlineAutoDeleteRead") private var autoDeleteRead: Bool = false
@AppStorage("offlineFirstReading") private var offlineFirstReading: Bool = false
Expand Down Expand Up @@ -119,7 +120,8 @@ struct OfflineTasksView: View {
OfflineCoverSyncSection(
viewModel: coverSyncViewModel,
instanceId: instanceId,
isOffline: isOffline
isOffline: isOffline,
defaultLibraryIds: dashboard.libraryIds
Comment thread
everpcpc marked this conversation as resolved.
)

if !downloadingTasks.isEmpty {
Expand Down