From b7b7c5ac62f007b2f9c20435c262bade63f9b9a7 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 8 May 2026 18:23:16 +0200 Subject: [PATCH] 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. --- Yattee/Models/MediaSources/MediaFile.swift | 9 +- .../MediaSources/LocalFileClient.swift | 160 +++++++++++++++--- Yattee/Services/Player/PlayerService.swift | 16 +- 3 files changed, 161 insertions(+), 24 deletions(-) diff --git a/Yattee/Models/MediaSources/MediaFile.swift b/Yattee/Models/MediaSources/MediaFile.swift index 96b3c8e4..12f87efa 100644 --- a/Yattee/Models/MediaSources/MediaFile.swift +++ b/Yattee/Models/MediaSources/MediaFile.swift @@ -67,7 +67,14 @@ struct MediaFile: Identifiable, Hashable, Sendable { /// Full URL to this file. 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). diff --git a/Yattee/Services/MediaSources/LocalFileClient.swift b/Yattee/Services/MediaSources/LocalFileClient.swift index 8cf47c71..2550b463 100644 --- a/Yattee/Services/MediaSources/LocalFileClient.swift +++ b/Yattee/Services/MediaSources/LocalFileClient.swift @@ -8,6 +8,55 @@ import Foundation 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 LocalFileClient { private let fileManager = FileManager.default @@ -21,17 +70,21 @@ actor LocalFileClient { /// - Returns: Array of files and folders in the directory. func listFiles( in url: URL, - source: MediaSource + source: MediaSource, + rootURL: URL? = nil ) async throws -> [MediaFile] { guard source.type == .localFolder else { throw MediaSourceError.unknown("Invalid source type for LocalFileClient") } - // Start accessing security-scoped resource - let didStartAccessing = url.startAccessingSecurityScopedResource() + // Start accessing security-scoped resource on the resolved root if available, + // 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 { if didStartAccessing { - url.stopAccessingSecurityScopedResource() + accessURL.stopAccessingSecurityScopedResource() } } @@ -63,8 +116,13 @@ actor LocalFileClient { 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 { - if let file = try? createMediaFile(from: fileURL, source: source) { + if let file = try? createMediaFile(from: fileURL, source: source, rootURL: baseForRelative) { files.append(file) } } @@ -89,22 +147,69 @@ actor LocalFileClient { at path: String, source: MediaSource ) async throws -> [MediaFile] { - // Resolve bookmark to get valid URL (required after app restart on iOS) - let baseURL: URL - if let bookmarkData = source.bookmarkData { - baseURL = try resolveBookmark(bookmarkData) - } else { - baseURL = source.url - } + let baseURL = resolveBaseURL(for: source) + LocalFolderURLResolver.setResolvedURL(baseURL, for: source.id) + + // Defensive normalization: if `path` is absolute (legacy MediaFile entries + // produced before relative paths were computed correctly), strip everything + // 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 - if path.isEmpty || path == "/" { + if normalizedPath.isEmpty || normalizedPath == "/" { url = baseURL } else { - let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + let cleanPath = normalizedPath.hasPrefix("/") ? String(normalizedPath.dropFirst()) : normalizedPath 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//...`) + /// 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 @@ -187,7 +292,8 @@ actor LocalFileClient { private func createMediaFile( from url: URL, - source: MediaSource + source: MediaSource, + rootURL: URL ) throws -> MediaFile { let resourceValues = try url.resourceValues(forKeys: [ .isDirectoryKey, @@ -203,11 +309,23 @@ actor LocalFileClient { let createdDate = resourceValues.creationDate let contentType = resourceValues.contentType - // Calculate relative path from source root - let relativePath = url.path.replacingOccurrences( - of: source.url.path, - with: "" - ) + // Calculate relative path from the resolved source root. + // + // Using `source.url.path` here is wrong: that path was captured at folder-pick + // 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( source: source, diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 510fa631..7e926d9e 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -1394,8 +1394,20 @@ final class PlayerService { LoggingService.shared.debug("[SubtitleDebug] Found source: \(source.name), type: \(source.type)", category: .player) 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//private/var/.../Documents//...` 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)