|
| 1 | +// |
| 2 | +// GameImporter+m3u.swift |
| 3 | +// PVLibrary |
| 4 | +// |
| 5 | +// Created by Joseph Mattiello on 5/14/25. |
| 6 | +// |
| 7 | + |
| 8 | +import RealmSwift |
| 9 | +import Foundation |
| 10 | + |
| 11 | +// MARK: M3U |
| 12 | +extension GameImporter { |
| 13 | + // MARK: - M3U File Organization |
| 14 | + |
| 15 | + /// Main method to organize M3U files in the import queue |
| 16 | + internal func organizeM3UFiles(in importQueue: inout [ImportQueueItem]) { |
| 17 | + ILOG("Starting M3U organization...") |
| 18 | + var i = importQueue.count - 1 |
| 19 | + |
| 20 | + while i >= 0 { |
| 21 | + let currentItem = importQueue[i] |
| 22 | + |
| 23 | + // Skip non-M3U files |
| 24 | + guard currentItem.url.pathExtension.lowercased() == Extensions.m3u.rawValue else { |
| 25 | + i -= 1 |
| 26 | + continue |
| 27 | + } |
| 28 | + |
| 29 | + // Process this M3U file |
| 30 | + processM3UFile(currentItem, atIndex: i, in: &importQueue) |
| 31 | + |
| 32 | + i -= 1 // Move to the next item |
| 33 | + } |
| 34 | + |
| 35 | + ILOG("Finished M3U organization.") |
| 36 | + } |
| 37 | + |
| 38 | + /// Process a single M3U file and its associated files |
| 39 | + private func processM3UFile(_ m3uQueueItem: ImportQueueItem, atIndex index: Int, in importQueue: inout [ImportQueueItem]) { |
| 40 | + let m3uURL = m3uQueueItem.url |
| 41 | + ILOG("Processing M3U: \(m3uURL.lastPathComponent)") |
| 42 | + |
| 43 | + // Parse the M3U file |
| 44 | + guard let fileNamesInM3U = try? cdRomFileHandler.parseM3U(from: m3uURL) else { |
| 45 | + WLOG("Could not parse M3U file: \(m3uURL.lastPathComponent)") |
| 46 | + return |
| 47 | + } |
| 48 | + |
| 49 | + if fileNamesInM3U.isEmpty { |
| 50 | + WLOG("M3U file is empty or contains no valid entries: \(m3uURL.lastPathComponent)") |
| 51 | + return |
| 52 | + } |
| 53 | + |
| 54 | + ILOG("M3U \(m3uURL.lastPathComponent) contains the following files: \(fileNamesInM3U)") |
| 55 | + |
| 56 | + // Set up the primary game item (always the M3U itself) |
| 57 | + let primaryGameItem = setupPrimaryGameItem(m3uQueueItem) |
| 58 | + |
| 59 | + // Track items to be removed from the queue |
| 60 | + var indicesToRemove: [Int] = [] |
| 61 | + |
| 62 | + // First scan the directory for all potentially related files |
| 63 | + let m3uDirectory = m3uURL.deletingLastPathComponent() |
| 64 | + scanDirectoryForRelatedFiles(m3uDirectory, primaryGameItem: primaryGameItem) |
| 65 | + |
| 66 | + // Check if any files listed in the M3U have already been imported to the database |
| 67 | + // This handles the case where the M3U arrives after its associated files |
| 68 | + Task { |
| 69 | + await checkForAlreadyImportedFiles(fileNamesInM3U, primaryGameItem: primaryGameItem, m3uURL: m3uURL) |
| 70 | + } |
| 71 | + |
| 72 | + // Process all files listed in the M3U |
| 73 | + processFilesListedInM3U(fileNamesInM3U, primaryGameItem: primaryGameItem, m3uURL: m3uURL, |
| 74 | + importQueue: &importQueue, indicesToRemove: &indicesToRemove) |
| 75 | + |
| 76 | + // Check for files on disk |
| 77 | + checkForFilesOnDisk(fileNamesInM3U, primaryGameItem: primaryGameItem, m3uURL: m3uURL) |
| 78 | + |
| 79 | + // Process CUE files to find their BIN files |
| 80 | + processCUEFilesForBINs(primaryGameItem: primaryGameItem) |
| 81 | + |
| 82 | + // Finalize the primary game item |
| 83 | + finalizePrimaryGameItem(primaryGameItem, m3uURL: m3uURL) |
| 84 | + |
| 85 | + // Remove subsumed items from queue |
| 86 | + removeSubsumedItems(atIndex: index, indicesToRemove: indicesToRemove, from: &importQueue) |
| 87 | + |
| 88 | + VLOG("Finished processing M3U: \(m3uURL.lastPathComponent)") |
| 89 | + } |
| 90 | + |
| 91 | + /// Check if any files listed in the M3U have already been imported to the database |
| 92 | + /// This handles the case where the M3U arrives after its associated files |
| 93 | + private func checkForAlreadyImportedFiles(_ fileNames: [String], primaryGameItem: ImportQueueItem, m3uURL: URL) async { |
| 94 | + ILOG("Checking if any files in M3U \(m3uURL.lastPathComponent) have already been imported to the database") |
| 95 | + |
| 96 | + let realm = RomDatabase.sharedInstance.realm |
| 97 | + var filesToConsolidate: [PVFile] = [] |
| 98 | + var gamesWithFilesToConsolidate = Set<PVGame>() |
| 99 | + |
| 100 | + // First check for exact filename matches |
| 101 | + for fileName in fileNames { |
| 102 | + // Look for files with matching names in the database |
| 103 | + let matchingFiles = realm.objects(PVFile.self).filter("fileName == %@", fileName) |
| 104 | + |
| 105 | + for file in matchingFiles { |
| 106 | + // Find games that have this file as their main file or in related files |
| 107 | + let gamesWithMainFile = realm.objects(PVGame.self).filter("file == %@", file) |
| 108 | + let gamesWithRelatedFile = realm.objects(PVGame.self).filter("ANY relatedFiles == %@", file) |
| 109 | + |
| 110 | + // Process games with this file as their main file |
| 111 | + for game in gamesWithMainFile { |
| 112 | + ILOG("Found file \(fileName) as main file for game: \(game.title ?? "Unknown")") |
| 113 | + filesToConsolidate.append(file) |
| 114 | + gamesWithFilesToConsolidate.insert(game) |
| 115 | + } |
| 116 | + |
| 117 | + // Process games with this file in their related files |
| 118 | + for game in gamesWithRelatedFile { |
| 119 | + ILOG("Found file \(fileName) as related file for game: \(game.title ?? "Unknown")") |
| 120 | + filesToConsolidate.append(file) |
| 121 | + gamesWithFilesToConsolidate.insert(game) |
| 122 | + } |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + // If we didn't find any exact matches, look for similar filenames |
| 127 | + if filesToConsolidate.isEmpty { |
| 128 | + // Extract base game name from M3U filename |
| 129 | + let m3uBaseName = m3uURL.deletingPathExtension().lastPathComponent |
| 130 | + var baseNameWithoutDisc = m3uBaseName |
| 131 | + |
| 132 | + // Remove disc/CD indicators for matching |
| 133 | + let discIndicators = ["disc", "disk", "cd"] |
| 134 | + for indicator in discIndicators { |
| 135 | + if let range = baseNameWithoutDisc.lowercased().range(of: indicator, options: .caseInsensitive) { |
| 136 | + let index = baseNameWithoutDisc.distance(from: baseNameWithoutDisc.startIndex, to: range.lowerBound) |
| 137 | + if index > 3 { // Ensure we don't cut off too much of the name |
| 138 | + baseNameWithoutDisc = String(baseNameWithoutDisc.prefix(index - 1)) |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + // Look for games with similar names |
| 144 | + let similarGames = realm.objects(PVGame.self).filter("title CONTAINS[c] %@", baseNameWithoutDisc) |
| 145 | + |
| 146 | + for game in similarGames { |
| 147 | + ILOG("Found game with similar name: \(game.title ?? "Unknown")") |
| 148 | + gamesWithFilesToConsolidate.insert(game) |
| 149 | + |
| 150 | + // Add all files from this game to our consolidation list |
| 151 | + if let mainFile = game.file { |
| 152 | + filesToConsolidate.append(mainFile) |
| 153 | + } |
| 154 | + |
| 155 | + for relatedFile in game.relatedFiles { |
| 156 | + filesToConsolidate.append(relatedFile) |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + // If we found files to consolidate, update the database |
| 162 | + if !filesToConsolidate.isEmpty { |
| 163 | + await consolidateFilesUnderM3U(primaryGameItem, files: filesToConsolidate, games: Array(gamesWithFilesToConsolidate), m3uURL: m3uURL) |
| 164 | + } else { |
| 165 | + ILOG("No already imported files found for M3U \(m3uURL.lastPathComponent)") |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + /// Consolidate already imported files under the M3U game |
| 170 | + private func consolidateFilesUnderM3U(_ primaryGameItem: ImportQueueItem, files: [PVFile], games: [PVGame], m3uURL: URL) async { |
| 171 | + ILOG("Consolidating \(files.count) files under M3U \(m3uURL.lastPathComponent)") |
| 172 | + |
| 173 | + do { |
| 174 | + // Step 1: Import the M3U file and find the corresponding game |
| 175 | + let game = try await findOrImportM3UGame(primaryGameItem: primaryGameItem, m3uURL: m3uURL) |
| 176 | + |
| 177 | + // Step 2: Consolidate all files under this game |
| 178 | + try await consolidateFilesUnderGame(game: game, files: files, games: games, m3uURL: m3uURL) |
| 179 | + |
| 180 | + ILOG("Successfully consolidated files under M3U game: \(game.title ?? "Unknown")") |
| 181 | + } catch { |
| 182 | + ELOG("Error consolidating files under M3U: \(error)") |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + /// Import the M3U file and find the corresponding game in the database |
| 187 | + private func findOrImportM3UGame(primaryGameItem: ImportQueueItem, m3uURL: URL) async throws -> PVGame { |
| 188 | + // Import the M3U file itself to create a new game entry |
| 189 | + let importResult = try await gameImporterDatabaseService.importGameIntoDatabase(queueItem: primaryGameItem) |
| 190 | + |
| 191 | + // Find the game that was just imported using multiple strategies |
| 192 | + let m3uGame = try await findImportedGame(primaryGameItem: primaryGameItem, m3uURL: m3uURL) |
| 193 | + |
| 194 | + // Store the game ID for reference |
| 195 | + let gameID = m3uGame.id |
| 196 | + primaryGameItem.gameDatabaseID = gameID |
| 197 | + |
| 198 | + return m3uGame |
| 199 | + } |
| 200 | + |
| 201 | + /// Find the imported game using multiple search strategies |
| 202 | + private func findImportedGame(primaryGameItem: ImportQueueItem, m3uURL: URL) async throws -> PVGame { |
| 203 | + let m3uFileName = m3uURL.lastPathComponent |
| 204 | + let realm = RomDatabase.sharedInstance.realm |
| 205 | + var m3uGame: PVGame? |
| 206 | + |
| 207 | + // Strategy 1: Find by filename |
| 208 | + m3uGame = try await findGameByFileName(fileName: m3uFileName, realm: realm) |
| 209 | + |
| 210 | + // Strategy 2: Find by MD5 and system identifier |
| 211 | + if m3uGame == nil { |
| 212 | + m3uGame = try await findGameByMD5AndSystem(primaryGameItem: primaryGameItem, realm: realm) |
| 213 | + } |
| 214 | + |
| 215 | + // Strategy 3: Find by title and system identifier |
| 216 | + if m3uGame == nil { |
| 217 | + m3uGame = try await findGameByTitleAndSystem(m3uURL: m3uURL, primaryGameItem: primaryGameItem, realm: realm) |
| 218 | + } |
| 219 | + |
| 220 | + guard let game = m3uGame else { |
| 221 | + throw NSError(domain: "GameImporter", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to find the imported M3U game in the database"]) |
| 222 | + } |
| 223 | + |
| 224 | + return game |
| 225 | + } |
| 226 | + |
| 227 | + /// Find a game by filename |
| 228 | + private func findGameByFileName(fileName: String, realm: Realm) async throws -> PVGame? { |
| 229 | + // Look for PVFiles with matching URL and find their associated games |
| 230 | + let files = realm.objects(PVFile.self).filter("fileName == %@", fileName) |
| 231 | + |
| 232 | + for file in files { |
| 233 | + // Check games with this file as main file |
| 234 | + let gamesWithMainFile = realm.objects(PVGame.self).filter("file == %@", file) |
| 235 | + if let game = gamesWithMainFile.first { |
| 236 | + return game |
| 237 | + } |
| 238 | + |
| 239 | + // Check games with this file in related files |
| 240 | + let gamesWithRelatedFile = realm.objects(PVGame.self).filter("ANY relatedFiles == %@", file) |
| 241 | + if let game = gamesWithRelatedFile.first { |
| 242 | + return game |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + return nil |
| 247 | + } |
| 248 | + |
| 249 | + /// Find a game by MD5 and system identifier |
| 250 | + private func findGameByMD5AndSystem(primaryGameItem: ImportQueueItem, realm: Realm) async throws -> PVGame? { |
| 251 | + guard let md5 = primaryGameItem.md5, !primaryGameItem.systems.isEmpty else { |
| 252 | + return nil |
| 253 | + } |
| 254 | + |
| 255 | + // Try each system identifier |
| 256 | + for systemID in primaryGameItem.systems { |
| 257 | + let games = realm.objects(PVGame.self).filter("md5Hash == %@ AND systemIdentifier == %@", md5, systemID.rawValue) |
| 258 | + if let game = games.first { |
| 259 | + return game |
| 260 | + } |
| 261 | + } |
| 262 | + |
| 263 | + return nil |
| 264 | + } |
| 265 | + |
| 266 | + /// Find a game by title and system identifier |
| 267 | + private func findGameByTitleAndSystem(m3uURL: URL, primaryGameItem: ImportQueueItem, realm: Realm) async throws -> PVGame? { |
| 268 | + guard !primaryGameItem.systems.isEmpty else { |
| 269 | + return nil |
| 270 | + } |
| 271 | + |
| 272 | + let title = m3uURL.deletingPathExtension().lastPathComponent |
| 273 | + |
| 274 | + // Try each system identifier |
| 275 | + for systemID in primaryGameItem.systems { |
| 276 | + let games = realm.objects(PVGame.self).filter("title == %@ AND systemIdentifier == %@", title, systemID.rawValue) |
| 277 | + if let game = games.first { |
| 278 | + return game |
| 279 | + } |
| 280 | + } |
| 281 | + |
| 282 | + return nil |
| 283 | + } |
| 284 | + |
| 285 | + /// Consolidate all files under the M3U game |
| 286 | + private func consolidateFilesUnderGame(game: PVGame, files: [PVFile], games: [PVGame], m3uURL: URL) async throws { |
| 287 | + let gameID = game.id |
| 288 | + |
| 289 | + let realm = RomDatabase.sharedInstance.realm |
| 290 | + |
| 291 | + try realm.write { |
| 292 | + // First, update the file paths to be in the same directory as the M3U |
| 293 | + let m3uDirectory = m3uURL.deletingLastPathComponent() |
| 294 | + |
| 295 | + for file in files { |
| 296 | + // Skip files already associated with this game |
| 297 | + if isFileAssociatedWithGame(file: file, game: game) { |
| 298 | + continue |
| 299 | + } |
| 300 | + |
| 301 | + // Move the file to the M3U directory if needed |
| 302 | + moveFileToM3UDirectory(file: file, m3uDirectory: m3uDirectory) |
| 303 | + |
| 304 | + // Update file associations |
| 305 | + updateFileAssociations(file: file, game: game, gameID: gameID, realm: realm) |
| 306 | + } |
| 307 | + |
| 308 | + // Update the M3U game's metadata |
| 309 | + updateGameMetadata(game: game, games: games) |
| 310 | + |
| 311 | + // Clean up empty games |
| 312 | + cleanupEmptyGames(games: games, gameID: gameID, realm: realm) |
| 313 | + } |
| 314 | + } |
| 315 | + |
| 316 | + /// Check if a file is already associated with the game |
| 317 | + private func isFileAssociatedWithGame(file: PVFile, game: PVGame) -> Bool { |
| 318 | + let isMainFile = game.file == file |
| 319 | + let isRelatedFile = game.relatedFiles.contains(file) |
| 320 | + return isMainFile || isRelatedFile |
| 321 | + } |
| 322 | + |
| 323 | + /// Move a file to the M3U directory if needed |
| 324 | + private func moveFileToM3UDirectory(file: PVFile, m3uDirectory: URL) { |
| 325 | + // Get the current file URL |
| 326 | + guard let currentURL = file.url else { return } |
| 327 | + |
| 328 | + // Create the destination URL in the M3U directory |
| 329 | + let destinationURL = m3uDirectory.appendingPathComponent(currentURL.lastPathComponent) |
| 330 | + |
| 331 | + // Move the file if it's not already in the right location |
| 332 | + if currentURL != destinationURL && FileManager.default.fileExists(atPath: currentURL.path) { |
| 333 | + do { |
| 334 | + if FileManager.default.fileExists(atPath: destinationURL.path) { |
| 335 | + // Handle filename conflict |
| 336 | + handleFileNameConflict(file: file, currentURL: currentURL, destinationURL: destinationURL, m3uDirectory: m3uDirectory) |
| 337 | + } else { |
| 338 | + // Simple move |
| 339 | + try FileManager.default.moveItem(at: currentURL, to: destinationURL) |
| 340 | + // Update the file's partial path to reflect the new location |
| 341 | + let newPartialPath = file.relativeRoot.createRelativePath(fromURL: destinationURL) |
| 342 | + file.partialPath = newPartialPath |
| 343 | + ILOG("Moved file from \(currentURL.path) to \(destinationURL.path)") |
| 344 | + } |
| 345 | + } catch { |
| 346 | + ELOG("Error moving file: \(error)") |
| 347 | + } |
| 348 | + } |
| 349 | + } |
| 350 | + |
| 351 | + /// Remove subsumed items from the queue |
| 352 | + internal func removeSubsumedItems(atIndex index: Int, indicesToRemove: [Int], from importQueue: inout [ImportQueueItem]) { |
| 353 | + // Sort indices in descending order to safely remove elements from the array |
| 354 | + let sortedIndicesToRemove = indicesToRemove.sorted(by: >) |
| 355 | + for indexToRemove in sortedIndicesToRemove { |
| 356 | + if indexToRemove < importQueue.count { // Safety check |
| 357 | + // Don't remove the M3U item itself - it's our primary game item now |
| 358 | + if indexToRemove == index { |
| 359 | + continue |
| 360 | + } |
| 361 | + let removedItem = importQueue.remove(at: indexToRemove) |
| 362 | + ILOG("Removed \(removedItem.url.lastPathComponent) from queue as it was subsumed by M3U processing") |
| 363 | + } |
| 364 | + } |
| 365 | + } |
| 366 | +} |
0 commit comments