Skip to content

Commit

Permalink
Avoid reading the whole file into memory when updating ID3 tag of a f…
Browse files Browse the repository at this point in the history
…ile (#106) 🚀

* Save to temporary file without reading everything into memory.

* Increase chunk size

* Add autoreleasepool

* Refactoring.

* Add test case.

* Fix failing tests.

* Fix build on tvOS and Linux.

---------

Co-authored-by: Fabian Zwick <[email protected]>
  • Loading branch information
fabiankr and Fabian Zwick authored Oct 23, 2024
1 parent 62d10a9 commit 5c44726
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 47 deletions.
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

0 comments on commit 5c44726

Please sign in to comment.