Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avoid reading the whole file into memory when updating ID3 tag of a file #106

Merged
merged 8 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions Source/ID3TagEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Foundation
*/
public class ID3TagEditor {
private let id3TagParser: ID3TagParser
private let id3TagCreator: ID3TagCreator
private let mp3FileReader: Mp3FileReader
private let mp3FileWriter: Mp3FileWriter
private let mp3WithID3TagBuilder: Mp3WithID3TagBuilder
Expand All @@ -21,9 +22,10 @@ public class ID3TagEditor {
*/
public init() {
self.id3TagParser = ID3TagParserFactory.make()
self.id3TagCreator = ID3TagCreatorFactory.make()
self.mp3FileReader = Mp3FileReaderFactory.make()
self.mp3FileWriter = Mp3FileWriter()
self.mp3WithID3TagBuilder = Mp3WithID3TagBuilder(id3TagCreator: ID3TagCreatorFactory.make(),
self.mp3WithID3TagBuilder = Mp3WithID3TagBuilder(id3TagCreator: id3TagCreator,
id3TagConfiguration: ID3TagConfiguration())
}

Expand All @@ -38,7 +40,10 @@ public class ID3TagEditor {
Could throw `CorruptedFile` if the file is corrupted.
*/
public func read(from path: String) throws -> ID3Tag? {
let mp3 = try mp3FileReader.readID3TagFrom(path: path)
guard let mp3 = try mp3FileReader.readID3TagFrom(path: path) else {
return nil
}

return try self.id3TagParser.parse(mp3: mp3)
}

Expand Down Expand Up @@ -68,10 +73,9 @@ public class ID3TagEditor {
ID3 tag).
*/
public func write(tag: ID3Tag, to path: String, andSaveTo newPath: String? = nil) throws {
let mp3 = try mp3FileReader.readFileFrom(path: path)
let currentTag = try self.id3TagParser.parse(mp3: mp3)
let mp3WithId3Tag = try mp3WithID3TagBuilder.build(mp3: mp3, newId3Tag: tag, currentId3Tag: currentTag)
try mp3FileWriter.write(mp3: mp3WithId3Tag, path: newPath ?? path)
let currentId3TagData = try mp3FileReader.readID3TagFrom(path: path)
let newId3TagData = try id3TagCreator.create(id3Tag: tag)
try mp3FileWriter.write(newId3TagData: newId3TagData, currentId3TagData: currentId3TagData, fromPath: path, toPath: newPath ?? path)
}

/**
Expand Down
60 changes: 40 additions & 20 deletions Source/Mp3/Mp3FileReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@ import Foundation
class Mp3FileReader {
private let tagSizeParser: TagSizeParser
private let id3TagConfiguration: ID3TagConfiguration
private let tagVersionParser: TagVersionParser
private let tagPresence: TagPresence

init(tagSizeParser: TagSizeParser,
id3TagConfiguration: ID3TagConfiguration) {
id3TagConfiguration: ID3TagConfiguration,
tagVersionParser: TagVersionParser,
tagPresence: TagPresence) {
self.tagSizeParser = tagSizeParser
self.id3TagConfiguration = id3TagConfiguration
self.tagVersionParser = tagVersionParser
self.tagPresence = tagPresence
}

/**
Expand All @@ -41,40 +47,54 @@ class Mp3FileReader {

- parameter path: the path to the mp3 file

- returns: ID3 header data of the file
- returns: ID3 header data or nil, if a tag doesn't exists in the file.

- throws: Could throw `InvalidFileFormat` if an mp3 file doesn't exists at the specified path, or if the file
does not contain the entire ID3 header
- throws: Could throw `InvalidFileFormat` if an mp3 file doesn't exists at the specified path.
Could throw `CorruptedFile` if the file is corrupted.
*/
func readID3TagFrom(path: String) throws -> Data {
func readID3TagFrom(path: String) throws -> Data? {
let validPath = URL(fileURLWithPath: path)
guard validPath.pathExtension.caseInsensitiveCompare("mp3") == ComparisonResult.orderedSame else {
throw ID3TagEditorError.invalidFileFormat
}

guard let inputStream = InputStream(fileAtPath: path) else {
throw ID3TagEditorError.corruptedFile
let readHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
defer {
if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) {
try? readHandle.close()
} else {
readHandle.closeFile()
}
}

inputStream.open()

let headerSize = id3TagConfiguration.headerSize()
let header = try read(bytesCount: headerSize, fromStream: inputStream)
let headerData = Data(header) as NSData
let header = try read(bytesCount: headerSize, from: readHandle)

let frameSize = tagSizeParser.parse(data: headerData)
let frame = try read(bytesCount: Int(frameSize), fromStream: inputStream)
// Verify that there is a valid ID3 tag to parse the size from
let version = tagVersionParser.parse(mp3: header)
guard tagPresence.isTagPresentIn(mp3: header, version: version) else {
return nil
}

let mp3 = header + frame
return Data(mp3)
let frameSize = tagSizeParser.parse(data: header as NSData)
let frame = try read(bytesCount: Int(frameSize), from: readHandle)

return header + frame
}

private func read(bytesCount: Int, fromStream stream: InputStream) throws -> [UInt8] {
var buffer = [UInt8](repeating: 0, count: bytesCount)
let result = stream.read(&buffer, maxLength: bytesCount)
if result < bytesCount {
private func read(bytesCount: Int, from fileHandle: FileHandle) throws -> Data {
let result = try {
if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
return try fileHandle.read(upToCount: bytesCount)
} else {
return fileHandle.readData(ofLength: bytesCount)
}
}()

guard let result, result.count == bytesCount else {
throw ID3TagEditorError.corruptedFile
}
return buffer

return result
}
}
6 changes: 4 additions & 2 deletions Source/Mp3/Mp3FileReaderFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ class Mp3FileReaderFactory {
let tagSizeParser = ID3TagSizeParser()
let id3TagConfiguration = ID3TagConfiguration()
let fileReader = Mp3FileReader(tagSizeParser: tagSizeParser,
id3TagConfiguration: id3TagConfiguration)

id3TagConfiguration: id3TagConfiguration,
tagVersionParser: ID3TagVersionParser(),
tagPresence: ID3TagPresence(id3TagConfiguration: id3TagConfiguration))

return fileReader
}
}
112 changes: 109 additions & 3 deletions Source/Mp3/Mp3FileWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,115 @@
import Foundation

class Mp3FileWriter {
func write(mp3: Data, path: String) throws {
try eventuallyCreateIntermediatesDirectoriesFor(path: path)
try mp3.write(to: URL(fileURLWithPath: path))
func write(newId3TagData: Data, currentId3TagData: Data?, fromPath: String, toPath: String) throws {
let validPath = URL(fileURLWithPath: toPath)
guard validPath.pathExtension.caseInsensitiveCompare("mp3") == ComparisonResult.orderedSame else {
throw ID3TagEditorError.invalidFileFormat
}

// Create a temporary file for the new mp3
let temporaryPath = {
if toPath != fromPath {
return toPath
}

return FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).mp3").path
}()

defer {
if temporaryPath != toPath {
try? FileManager.default.removeItem(atPath: temporaryPath)
}
}

try eventuallyCreateIntermediatesDirectoriesFor(path: temporaryPath)
try newId3TagData.write(to: URL(fileURLWithPath: temporaryPath))

// Create file handles
let readHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: fromPath))
defer {
if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) {
try? readHandle.close()
} else {
readHandle.closeFile()
}
}

let writeHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: temporaryPath))
defer {
if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) {
try? writeHandle.close()
} else {
writeHandle.closeFile()
}
}

// Seek over the tag of the existing file, then copy the rest in chunks
if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
try writeHandle.seekToEnd()
} else {
writeHandle.seekToEndOfFile()
}

if let currentId3TagData = currentId3TagData {
if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) {
try readHandle.seek(toOffset: UInt64(currentId3TagData.count))
} else {
readHandle.seek(toFileOffset: UInt64(currentId3TagData.count))
}
}

var isFinished = false
while !isFinished {
let work = {
let chunk = try {
if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
return try readHandle.read(upToCount: 131072) // 128 KB
} else {
return readHandle.readData(ofLength: 131072) // 128 KB
}
}()

if let chunk, !chunk.isEmpty {
if #available(macOS 10.15.4, iOS 13.4, watchOS 6.2, tvOS 13.4, *) {
try writeHandle.write(contentsOf: chunk)
} else {
writeHandle.write(chunk)
}
} else {
isFinished = true
}
}

#if canImport(ObjectiveC)
// autoreleasepool is only needed in Objective-C environment (not on Linux)
try autoreleasepool(invoking: work)
#else
try work()
#endif
}

// Replace the file
if temporaryPath != toPath {
#if os(Linux)
// For some reason the FileManager.replaceItemAt(_:withItemAt:) doesn't work on Linux and fails with `NSFileWriteUnknownError`
let backupPath = URL(fileURLWithPath: toPath).appendingPathExtension("tmp").path
try FileManager.default.copyItem(atPath: toPath, toPath: backupPath)
defer {
try? FileManager.default.removeItem(atPath: backupPath)
}

do {
try FileManager.default.removeItem(atPath: toPath)
try FileManager.default.copyItem(atPath: temporaryPath, toPath: toPath)
} catch {
try? FileManager.default.copyItem(atPath: backupPath, toPath: toPath)
throw error
}
#else
_ = try FileManager.default.replaceItemAt(validPath, withItemAt: URL(fileURLWithPath: temporaryPath))
#endif
}
}

private func eventuallyCreateIntermediatesDirectoriesFor(path: String) throws {
Expand Down
4 changes: 3 additions & 1 deletion Source/Mp3/Mp3WithID3TagBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ class Mp3WithID3TagBuilder {
tagSizeWithHeader = Int(validCurrentId3Tag.properties.size) + ID3TagConfiguration().headerSize()
}
var mp3WithTag = try id3TagCreator.create(id3Tag: newId3Tag)
mp3WithTag.append(mp3.subdata(in: tagSizeWithHeader..<mp3.count))
if !mp3.isEmpty {
mp3WithTag.append(mp3.subdata(in: tagSizeWithHeader..<mp3.count))
}
return mp3WithTag
}
}
36 changes: 21 additions & 15 deletions Tests/Mp3/Mp3FileReaderTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,61 +11,67 @@ import Testing
struct Mp3FileReaderTest {
@Test func testNotAnMP3FileWhenReadingEntireFile() {
let path = PathLoader().pathFor(name: "example-cover", fileType: "jpg")
let mp3FileReader = Mp3FileReader(tagSizeParser: ID3TagSizeParser(),
id3TagConfiguration: ID3TagConfiguration())
let mp3FileReader = Mp3FileReaderFactory.make()

#expect(throws: ID3TagEditorError.invalidFileFormat.self) { try mp3FileReader.readFileFrom(path: path) }
}

@Test func testMP3FileWhenReadingEntireFile() {
let path = PathLoader().pathFor(name: "example", fileType: "mp3")
let mp3FileReader = Mp3FileReader(tagSizeParser: ID3TagSizeParser(),
id3TagConfiguration: ID3TagConfiguration())
let mp3FileReader = Mp3FileReaderFactory.make()

#expect(throws: Never.self) { try mp3FileReader.readFileFrom(path: path) }
}

@Test func testNotAnMP3fileWhenReadingID3Tag() {
let path = PathLoader().pathFor(name: "example-cover", fileType: "jpg")
let mp3FileReader = Mp3FileReader(tagSizeParser: ID3TagSizeParser(),
id3TagConfiguration: ID3TagConfiguration())
let mp3FileReader = Mp3FileReaderFactory.make()

#expect(throws: ID3TagEditorError.invalidFileFormat.self) { try mp3FileReader.readID3TagFrom(path: path) }
}

@Test func testMP3fileWhenReadingID3Tag() {
let path = PathLoader().pathFor(name: "example", fileType: "mp3")
let mp3FileReader = Mp3FileReader(tagSizeParser: ID3TagSizeParser(),
id3TagConfiguration: ID3TagConfiguration())
let mp3FileReader = Mp3FileReaderFactory.make()

#expect(throws: Never.self) { try mp3FileReader.readID3TagFrom(path: path) }
}

@Test func testNonExistentMP3fileWhenReadingID3Tag() {
let path = "/non-existent.mp3"
let mp3FileReader = Mp3FileReader(tagSizeParser: ID3TagSizeParser(),
id3TagConfiguration: ID3TagConfiguration())
let mp3FileReader = Mp3FileReaderFactory.make()

#expect(throws: ID3TagEditorError.corruptedFile.self) { try mp3FileReader.readID3TagFrom(path: path) }
#expect(throws: Error.self) { try mp3FileReader.readFileFrom(path: path) }
#expect(throws: Error.self) { try mp3FileReader.readID3TagFrom(path: path) }
}

@Test func testOnlyReadsID3Tag() throws {
let path = PathLoader().pathFor(name: "example", fileType: "mp3")
let mp3FileReader = Mp3FileReader(tagSizeParser: ID3TagSizeParser(),
id3TagConfiguration: ID3TagConfiguration())
let mp3FileReader = Mp3FileReaderFactory.make()

let id3TagData = try mp3FileReader.readID3TagFrom(path: path)
let id3TagData = try #require(try mp3FileReader.readID3TagFrom(path: path))

// 10 bytes Tag + 34213 bytes according to the Tag Size in the file's ID3 Tag
#expect(id3TagData.count == 10 + 34213)
}

@Test func testIgnoresWhenMissingID3Tag() throws {
let path = PathLoader().pathFor(name: "example-to-be-modified", fileType: "mp3")
let mp3FileReader = Mp3FileReaderFactory.make()

let id3TagData = try mp3FileReader.readID3TagFrom(path: path)

// The file has no ID3 tag
#expect(id3TagData == nil)
}

static let allTests = [
("testNotAnMP3FileWhenReadingEntireFile", testNotAnMP3FileWhenReadingEntireFile),
("testMP3FileWhenReadingEntireFile", testMP3FileWhenReadingEntireFile),
("testNotAnMP3fileWhenReadingID3Tag", testNotAnMP3fileWhenReadingID3Tag),
("testMP3fileWhenReadingID3Tag", testMP3fileWhenReadingID3Tag),
("testNonExistentMP3fileWhenReadingID3Tag", testNonExistentMP3fileWhenReadingID3Tag),
("testOnlyReadsID3Tag", testOnlyReadsID3Tag)
("testOnlyReadsID3Tag", testOnlyReadsID3Tag),
("testIgnoresWhenMissingID3Tag", testIgnoresWhenMissingID3Tag)
]
}
Loading
Loading