Skip to content

Commit 0e3c2e7

Browse files
committed
m3u handling seperate file
Signed-off-by: Joseph Mattiello <git@joemattiello.com>
1 parent ef1be07 commit 0e3c2e7

File tree

3 files changed

+377
-385
lines changed

3 files changed

+377
-385
lines changed
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
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

Comments
 (0)