feat: add travel album OTG import flow

This commit is contained in:
2026-07-08 09:24:51 +08:00
parent 00bda390e8
commit 92fcad7ac9
42 changed files with 6826 additions and 89 deletions

View File

@ -0,0 +1,166 @@
//
// TravelAlbumOTGTransferModels.swift
// suixinkan
//
import Foundation
/// OTG
struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
let id: String
let sourceId: String
let fileName: String
let thumbnailURL: URL?
let capturedAt: String
let fileSizeText: String
let fileSizeBytes: Int64
var status: TravelAlbumOTGUploadStatus
var progress: Int
var errorMessage: String?
let localPath: String
var remoteUrl: String
///
var isNotUploaded: Bool {
status != .uploaded
}
///
var canSelectForUpload: Bool {
status == .pending || status == .failed
}
}
/// OTG Tab
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
case all
case uploaded
case failed
var title: String {
switch self {
case .all: return "全部"
case .uploaded: return "已上传"
case .failed: return "失败"
}
}
}
/// Android `OtgPhotoFormatMatcher`
enum TravelAlbumOTGPhotoFormatMatcher {
///
enum PhotoKind: Equatable {
case jpeg
case raw
case other
}
private static let jpegExtensions: Set<String> = ["jpg", "jpeg", "heif", "hif"]
private static let rawExtensions: Set<String> = [
"dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2",
"orf", "rw2", "raw", "raf", "srw", "pef", "ptx", "rwl",
]
private static let jpegFormatCodes: Set<Int> = [0x3801, 0x3808]
private static let rawFormatCodes: Set<Int> = [0x380D, 0xB101, 0xB103, 0xB108]
///
static func classify(name: String, format: Int = 0, isVideo: Bool = false) -> PhotoKind {
if isVideo { return .other }
let ext = URL(fileURLWithPath: name).pathExtension.lowercased()
if rawExtensions.contains(ext) || rawFormatCodes.contains(format) {
return .raw
}
if jpegExtensions.contains(ext) || jpegFormatCodes.contains(format) {
return .jpeg
}
return .other
}
///
static func matchesUploadKind(_ object: CameraObject, option: TravelAlbumOTGPhotoFormatOption) -> Bool {
switch option {
case .jpg:
return classify(name: object.filename) == .jpeg
case .raw:
return classify(name: object.filename) == .raw
case .jpegRaw:
let kind = classify(name: object.filename)
return kind == .jpeg || kind == .raw
}
}
///
static func resolveUploadTargets(
requested: [CameraObject],
allObjects: [CameraObject],
option: TravelAlbumOTGPhotoFormatOption
) -> [CameraObject] {
guard !requested.isEmpty else { return [] }
var ordered: [CameraObject] = []
var seen = Set<String>()
for object in requested {
let peers = peers(of: object, allObjects: allObjects)
let jpegs = peers.filter { classify(name: $0.filename) == .jpeg }
let raws = peers.filter { classify(name: $0.filename) == .raw }
let candidates: [CameraObject]
switch option {
case .jpg:
candidates = Array(jpegs.prefix(1))
case .raw:
candidates = Array(raws.prefix(1))
case .jpegRaw:
candidates = jpegs + raws
}
candidates.forEach { candidate in
if seen.insert(candidate.id).inserted {
ordered.append(candidate)
}
}
}
return ordered
}
private static func peers(of object: CameraObject, allObjects: [CameraObject]) -> [CameraObject] {
let stem = captureStem(object.filename)
guard !stem.isEmpty else { return [object] }
let matches = allObjects.filter { captureStem($0.filename) == stem }
return matches.isEmpty ? [object] : matches
}
private static func captureStem(_ name: String) -> String {
(name as NSString).deletingPathExtension.uppercased()
}
}
extension TravelAlbumOTGPhotoRecord {
///
func toPhotoItem() -> TravelAlbumOTGPhotoItem {
let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in
guard !path.isEmpty else { return false }
if path.hasPrefix("http") { return true }
return FileManager.default.fileExists(atPath: path)
} ?? ""
let previewURL: URL?
if previewPath.hasPrefix("http") {
previewURL = URL(string: previewPath)
} else if !previewPath.isEmpty {
previewURL = URL(fileURLWithPath: previewPath)
} else {
previewURL = nil
}
return TravelAlbumOTGPhotoItem(
id: id,
sourceId: sourceId,
fileName: fileName,
thumbnailURL: previewURL,
capturedAt: capturedAt,
fileSizeText: TravelAlbumOTGPhotoStore.fileSizeText(fileSizeBytes),
fileSizeBytes: fileSizeBytes,
status: status,
progress: progress,
errorMessage: errorMessage,
localPath: localPath,
remoteUrl: remoteUrl
)
}
}