mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
Fix local folder playback after app container UUID changes
After iOS reinstall/restore the app container UUID rotates, which left both the persisted source.url and the security-scoped bookmark pointing at a no-longer-current path. Files derived a stale absolute path that got appended onto the resolved bookmark, producing doubled URLs that MPV could not load. - Resolve the base URL by picking whichever of the bookmark or source.url actually exists on disk. - Compute MediaFile relative paths against the resolved root so they survive later container changes. - Hold the security-scoped resource access for the source's lifetime via a shared resolver, so MPV can open files long after the directory enumeration that resolved the bookmark has returned. - Normalize legacy absolute paths embedded in old recents/history video IDs so they re-resolve under the current container.
This commit is contained in:
@@ -67,7 +67,14 @@ struct MediaFile: Identifiable, Hashable, Sendable {
|
|||||||
|
|
||||||
/// Full URL to this file.
|
/// Full URL to this file.
|
||||||
var url: URL {
|
var url: URL {
|
||||||
source.url.appendingPathComponent(path)
|
// For local folders, the persisted `source.url` may point at a stale app
|
||||||
|
// container path after iOS reinstall/restore. Prefer the freshly resolved
|
||||||
|
// bookmark URL stored in `LocalFolderURLResolver` when available.
|
||||||
|
if source.type == .localFolder,
|
||||||
|
let resolved = LocalFolderURLResolver.resolvedURL(for: source.id) {
|
||||||
|
return resolved.appendingPathComponent(path)
|
||||||
|
}
|
||||||
|
return source.url.appendingPathComponent(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// File extension (lowercase).
|
/// File extension (lowercase).
|
||||||
|
|||||||
@@ -8,6 +8,55 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Sync, thread-safe cache of resolved local-folder root URLs keyed by source ID.
|
||||||
|
///
|
||||||
|
/// The persisted `MediaSource.url` captures the path of the app container at folder-pick time.
|
||||||
|
/// On iOS the container UUID changes across reinstall/restore, so that path becomes stale.
|
||||||
|
/// The security-scoped bookmark resolves to the *current* container; this resolver lets
|
||||||
|
/// synchronous call sites (`MediaFile.url`) read the freshly resolved path without
|
||||||
|
/// re-resolving the bookmark on every access.
|
||||||
|
enum LocalFolderURLResolver {
|
||||||
|
private static var cache: [UUID: URL] = [:]
|
||||||
|
private static var persistentAccess: [UUID: URL] = [:]
|
||||||
|
private static let lock = NSLock()
|
||||||
|
|
||||||
|
static func setResolvedURL(_ url: URL, for sourceID: UUID) {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
cache[sourceID] = url
|
||||||
|
|
||||||
|
// Acquire (and hold) a security-scoped access token for this URL so that
|
||||||
|
// background readers — particularly MPV/libavformat — can open files inside
|
||||||
|
// it long after the directory enumeration that resolved the bookmark has
|
||||||
|
// returned. Without this, iOS revokes the scope as soon as the matching
|
||||||
|
// `defer { stopAccessing... }` fires, and subsequent file opens fail with
|
||||||
|
// a generic "loading failed" error.
|
||||||
|
if persistentAccess[sourceID]?.path != url.path {
|
||||||
|
if let previous = persistentAccess[sourceID] {
|
||||||
|
previous.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
let didStart = url.startAccessingSecurityScopedResource()
|
||||||
|
if didStart {
|
||||||
|
persistentAccess[sourceID] = url
|
||||||
|
} else {
|
||||||
|
persistentAccess.removeValue(forKey: sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolvedURL(for sourceID: UUID) -> URL? {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return cache[sourceID]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func clear(_ sourceID: UUID) {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
cache.removeValue(forKey: sourceID)
|
||||||
|
if let previous = persistentAccess.removeValue(forKey: sourceID) {
|
||||||
|
previous.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Actor-based client for local file system operations.
|
/// Actor-based client for local file system operations.
|
||||||
actor LocalFileClient {
|
actor LocalFileClient {
|
||||||
private let fileManager = FileManager.default
|
private let fileManager = FileManager.default
|
||||||
@@ -21,17 +70,21 @@ actor LocalFileClient {
|
|||||||
/// - Returns: Array of files and folders in the directory.
|
/// - Returns: Array of files and folders in the directory.
|
||||||
func listFiles(
|
func listFiles(
|
||||||
in url: URL,
|
in url: URL,
|
||||||
source: MediaSource
|
source: MediaSource,
|
||||||
|
rootURL: URL? = nil
|
||||||
) async throws -> [MediaFile] {
|
) async throws -> [MediaFile] {
|
||||||
guard source.type == .localFolder else {
|
guard source.type == .localFolder else {
|
||||||
throw MediaSourceError.unknown("Invalid source type for LocalFileClient")
|
throw MediaSourceError.unknown("Invalid source type for LocalFileClient")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start accessing security-scoped resource
|
// Start accessing security-scoped resource on the resolved root if available,
|
||||||
let didStartAccessing = url.startAccessingSecurityScopedResource()
|
// otherwise on the directory itself. Bookmark access is granted for the root,
|
||||||
|
// and child URLs inherit access while it is held.
|
||||||
|
let accessURL = rootURL ?? url
|
||||||
|
let didStartAccessing = accessURL.startAccessingSecurityScopedResource()
|
||||||
defer {
|
defer {
|
||||||
if didStartAccessing {
|
if didStartAccessing {
|
||||||
url.stopAccessingSecurityScopedResource()
|
accessURL.stopAccessingSecurityScopedResource()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +116,13 @@ actor LocalFileClient {
|
|||||||
|
|
||||||
var files: [MediaFile] = []
|
var files: [MediaFile] = []
|
||||||
|
|
||||||
|
// Use the resolved root URL (from the bookmark) for relative-path computation.
|
||||||
|
// Falling back to the directory URL itself yields paths relative to that directory,
|
||||||
|
// which still avoids the stale-container-UUID prefix problem.
|
||||||
|
let baseForRelative = rootURL ?? url
|
||||||
|
|
||||||
for fileURL in contents {
|
for fileURL in contents {
|
||||||
if let file = try? createMediaFile(from: fileURL, source: source) {
|
if let file = try? createMediaFile(from: fileURL, source: source, rootURL: baseForRelative) {
|
||||||
files.append(file)
|
files.append(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,22 +147,69 @@ actor LocalFileClient {
|
|||||||
at path: String,
|
at path: String,
|
||||||
source: MediaSource
|
source: MediaSource
|
||||||
) async throws -> [MediaFile] {
|
) async throws -> [MediaFile] {
|
||||||
// Resolve bookmark to get valid URL (required after app restart on iOS)
|
let baseURL = resolveBaseURL(for: source)
|
||||||
let baseURL: URL
|
LocalFolderURLResolver.setResolvedURL(baseURL, for: source.id)
|
||||||
if let bookmarkData = source.bookmarkData {
|
|
||||||
baseURL = try resolveBookmark(bookmarkData)
|
// Defensive normalization: if `path` is absolute (legacy MediaFile entries
|
||||||
} else {
|
// produced before relative paths were computed correctly), strip everything
|
||||||
baseURL = source.url
|
// up to and including the source folder name so the appendPathComponent below
|
||||||
}
|
// doesn't double-up the path under a stale container UUID.
|
||||||
|
let normalizedPath = Self.normalizeAbsolutePath(path, sourceFolderName: baseURL.lastPathComponent)
|
||||||
|
|
||||||
let url: URL
|
let url: URL
|
||||||
if path.isEmpty || path == "/" {
|
if normalizedPath.isEmpty || normalizedPath == "/" {
|
||||||
url = baseURL
|
url = baseURL
|
||||||
} else {
|
} else {
|
||||||
let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
|
let cleanPath = normalizedPath.hasPrefix("/") ? String(normalizedPath.dropFirst()) : normalizedPath
|
||||||
url = baseURL.appendingPathComponent(cleanPath)
|
url = baseURL.appendingPathComponent(cleanPath)
|
||||||
}
|
}
|
||||||
return try await listFiles(in: url, source: source)
|
return try await listFiles(in: url, source: source, rootURL: baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks the most likely valid root URL for a local-folder source.
|
||||||
|
///
|
||||||
|
/// On iOS, the app container UUID changes across reinstall/restore. Two storage
|
||||||
|
/// locations capture the path independently and either can become stale:
|
||||||
|
/// - `source.url` — captured at folder-pick time, may be updated when the source
|
||||||
|
/// is re-saved.
|
||||||
|
/// - `source.bookmarkData` — security-scoped bookmark; for paths inside the app's
|
||||||
|
/// own Documents directory, iOS does not migrate the captured path across
|
||||||
|
/// container changes, so the resolved URL can point at the previous container.
|
||||||
|
///
|
||||||
|
/// We try the bookmark first, fall back to `source.url`, and prefer whichever
|
||||||
|
/// actually exists on disk. If both exist, the bookmark wins (its security scope
|
||||||
|
/// matters for folders picked outside the app sandbox). If neither exists we still
|
||||||
|
/// return something so the caller's error path triggers normally.
|
||||||
|
private func resolveBaseURL(for source: MediaSource) -> URL {
|
||||||
|
var bookmarkURL: URL?
|
||||||
|
if let bookmarkData = source.bookmarkData {
|
||||||
|
bookmarkURL = try? resolveBookmark(bookmarkData)
|
||||||
|
}
|
||||||
|
|
||||||
|
let bookmarkExists = bookmarkURL.map { fileManager.fileExists(atPath: $0.path) } ?? false
|
||||||
|
if let bookmarkURL, bookmarkExists {
|
||||||
|
return bookmarkURL
|
||||||
|
}
|
||||||
|
if fileManager.fileExists(atPath: source.url.path) {
|
||||||
|
return source.url
|
||||||
|
}
|
||||||
|
return bookmarkURL ?? source.url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips a leading absolute container path (`/private/var/.../Documents/<sourceFolder>/...`)
|
||||||
|
/// down to the portion after the source folder, returning a path relative to the source root.
|
||||||
|
/// No-op for already-relative paths.
|
||||||
|
static func normalizeAbsolutePath(_ path: String, sourceFolderName: String) -> String {
|
||||||
|
guard path.hasPrefix("/") else { return path }
|
||||||
|
let marker = "/\(sourceFolderName)/"
|
||||||
|
if let range = path.range(of: marker, options: .backwards) {
|
||||||
|
return String(path[range.upperBound...])
|
||||||
|
}
|
||||||
|
let suffix = "/\(sourceFolderName)"
|
||||||
|
if path.hasSuffix(suffix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Security-Scoped Bookmarks
|
// MARK: - Security-Scoped Bookmarks
|
||||||
@@ -187,7 +292,8 @@ actor LocalFileClient {
|
|||||||
|
|
||||||
private func createMediaFile(
|
private func createMediaFile(
|
||||||
from url: URL,
|
from url: URL,
|
||||||
source: MediaSource
|
source: MediaSource,
|
||||||
|
rootURL: URL
|
||||||
) throws -> MediaFile {
|
) throws -> MediaFile {
|
||||||
let resourceValues = try url.resourceValues(forKeys: [
|
let resourceValues = try url.resourceValues(forKeys: [
|
||||||
.isDirectoryKey,
|
.isDirectoryKey,
|
||||||
@@ -203,11 +309,23 @@ actor LocalFileClient {
|
|||||||
let createdDate = resourceValues.creationDate
|
let createdDate = resourceValues.creationDate
|
||||||
let contentType = resourceValues.contentType
|
let contentType = resourceValues.contentType
|
||||||
|
|
||||||
// Calculate relative path from source root
|
// Calculate relative path from the resolved source root.
|
||||||
let relativePath = url.path.replacingOccurrences(
|
//
|
||||||
of: source.url.path,
|
// Using `source.url.path` here is wrong: that path was captured at folder-pick
|
||||||
with: ""
|
// time and on iOS contains the *original* app container UUID. After reinstall
|
||||||
)
|
// or restore the container UUID changes; `url.path` (from the directory enum)
|
||||||
|
// contains the new UUID, so the prefix won't match and the "relative" path
|
||||||
|
// would degrade to an absolute path. Always strip against the live root URL
|
||||||
|
// we just enumerated from instead.
|
||||||
|
let standardizedURLPath = url.standardizedFileURL.path
|
||||||
|
let standardizedRootPath = rootURL.standardizedFileURL.path
|
||||||
|
var relativePath = standardizedURLPath
|
||||||
|
if standardizedURLPath.hasPrefix(standardizedRootPath) {
|
||||||
|
relativePath = String(standardizedURLPath.dropFirst(standardizedRootPath.count))
|
||||||
|
}
|
||||||
|
if relativePath.hasPrefix("/") {
|
||||||
|
relativePath = String(relativePath.dropFirst())
|
||||||
|
}
|
||||||
|
|
||||||
return MediaFile(
|
return MediaFile(
|
||||||
source: source,
|
source: source,
|
||||||
|
|||||||
@@ -1394,8 +1394,20 @@ final class PlayerService {
|
|||||||
LoggingService.shared.debug("[SubtitleDebug] Found source: \(source.name), type: \(source.type)", category: .player)
|
LoggingService.shared.debug("[SubtitleDebug] Found source: \(source.name), type: \(source.type)", category: .player)
|
||||||
|
|
||||||
let password = mediaSourcesManager?.password(for: source)
|
let password = mediaSourcesManager?.password(for: source)
|
||||||
let parentPath = (filePath as NSString).deletingLastPathComponent
|
|
||||||
let fileName = (filePath as NSString).lastPathComponent
|
// Legacy video IDs for `.localFolder` sources may carry an absolute container
|
||||||
|
// path (recorded before relative paths were computed correctly). Normalize
|
||||||
|
// against the source folder name so the path becomes relative and doesn't
|
||||||
|
// get appended onto the freshly resolved bookmark base, which would produce
|
||||||
|
// a doubled `/Documents/<folder>/private/var/.../Documents/<folder>/...` path.
|
||||||
|
let normalizedFilePath: String
|
||||||
|
if source.type == .localFolder {
|
||||||
|
normalizedFilePath = LocalFileClient.normalizeAbsolutePath(filePath, sourceFolderName: source.url.lastPathComponent)
|
||||||
|
} else {
|
||||||
|
normalizedFilePath = filePath
|
||||||
|
}
|
||||||
|
let parentPath = (normalizedFilePath as NSString).deletingLastPathComponent
|
||||||
|
let fileName = (normalizedFilePath as NSString).lastPathComponent
|
||||||
|
|
||||||
LoggingService.shared.debug("[SubtitleDebug] parentPath: \(parentPath), fileName: \(fileName)", category: .player)
|
LoggingService.shared.debug("[SubtitleDebug] parentPath: \(parentPath), fileName: \(fileName)", category: .player)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user