From 364d2cf1797c6dd147388e5e95981548a579b82e Mon Sep 17 00:00:00 2001 From: Dom Kiva Meyer Date: Wed, 17 Apr 2024 13:30:57 -0700 Subject: [PATCH 1/7] Add resource metadata extraction to AudioParser --- .../Streamer/Parser/Audio/AudioParser.swift | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index cba9f4b6e..bc5f46312 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import AVFoundation import Foundation import R2Shared @@ -14,12 +15,16 @@ import R2Shared public final class AudioParser: PublicationParser { public init() {} + private func metadataItems(_ metadataItems: [AVMetadataItem], _ key: AVMetadataKey) -> [AVMetadataItem] { + AVMetadataItem.metadataItems(from: metadataItems, withKey: key, keySpace: .common) + } + public func parse(asset: PublicationAsset, fetcher: Fetcher, warnings: WarningLogger?) throws -> Publication.Builder? { guard accepts(asset, fetcher) else { return nil } - let readingOrder = fetcher.links + var readingOrder = fetcher.links .filter { !ignores($0) && $0.mediaType.isAudio } .sorted { $0.href.localizedCaseInsensitiveCompare($1.href) == .orderedAscending } @@ -27,14 +32,44 @@ public final class AudioParser: PublicationParser { return nil } + let avAssets = readingOrder.map { link in fetcher.get(link).file.map { AVURLAsset(url: $0, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) } } + + readingOrder = zip(readingOrder, avAssets).map { link, avAsset in + guard let avAsset else { return link } + return link.copy( + title: metadataItems(avAsset.metadata, .commonKeyTitle).first?.stringValue, + bitrate: avAsset.tracks(withMediaType: .audio).first.map { Double($0.estimatedDataRate) }, + duration: avAsset.duration.seconds + ) + } + + let durations = readingOrder.compactMap(\.duration) + + var metadata = Metadata( + conformsTo: [.audiobook], + title: fetcher.guessTitle(ignoring: ignores) ?? asset.name, + duration: readingOrder.count == durations.count ? durations.reduce(0, +) : nil + ) + + if let avMetadata = avAssets.compactMap({ $0 }).first?.metadata { + metadata = metadata.copy( + title: metadataItems(avMetadata, .commonKeyTitle).first?.stringValue ?? metadata.title, + modified: metadataItems(avMetadata, .commonKeyLastModifiedDate).first?.stringValue?.dateFromISO8601, + published: metadataItems(avMetadata, .commonKeyCreationDate).first?.stringValue?.dateFromISO8601, + languages: metadataItems(avMetadata, .commonKeyLanguage).compactMap(\.stringValue), + subjects: metadataItems(avMetadata, .commonKeySubject).compactMap(\.stringValue).map { Subject(name: $0) }, + authors: metadataItems(avMetadata, .commonKeyAuthor).compactMap(\.stringValue).map { Contributor(name: $0) }, + contributors: metadataItems(avMetadata, .commonKeyContributor).compactMap(\.stringValue).map { Contributor(name: $0) }, + publishers: metadataItems(avMetadata, .commonKeyPublisher).compactMap(\.stringValue).map { Contributor(name: $0) }, + description: metadataItems(avMetadata, .commonKeyDescription).first?.stringValue + ) + } + return Publication.Builder( mediaType: .zab, format: .cbz, manifest: Manifest( - metadata: Metadata( - conformsTo: [.audiobook], - title: fetcher.guessTitle(ignoring: ignores) ?? asset.name - ), + metadata: metadata, readingOrder: readingOrder ), fetcher: fetcher, From 21e93adccf7bc32e580e23052cbb4960ae9d29ac Mon Sep 17 00:00:00 2001 From: Dom Kiva Meyer Date: Fri, 19 Apr 2024 13:50:01 -0700 Subject: [PATCH 2/7] Attempt to fix CI failure --- Sources/Streamer/Parser/Audio/AudioParser.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index bc5f46312..88ee9289d 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -35,7 +35,7 @@ public final class AudioParser: PublicationParser { let avAssets = readingOrder.map { link in fetcher.get(link).file.map { AVURLAsset(url: $0, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) } } readingOrder = zip(readingOrder, avAssets).map { link, avAsset in - guard let avAsset else { return link } + guard let avAsset = avAsset else { return link } return link.copy( title: metadataItems(avAsset.metadata, .commonKeyTitle).first?.stringValue, bitrate: avAsset.tracks(withMediaType: .audio).first.map { Double($0.estimatedDataRate) }, From 370dc818b1e0dd8a4587607263f70128cf236856 Mon Sep 17 00:00:00 2001 From: Dom Kiva Meyer Date: Wed, 15 May 2024 17:53:33 -0700 Subject: [PATCH 3/7] Make AudioParser metadata logic configurable --- .../Streamer/Parser/Audio/AudioParser.swift | 112 +++++++++++------- 1 file changed, 72 insertions(+), 40 deletions(-) diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index 88ee9289d..e9fad968a 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -7,73 +7,105 @@ import AVFoundation import Foundation import R2Shared +import UIKit + +private extension [AVMetadataItem] { + func filter(_ identifiers: [AVMetadataIdentifier]) -> [AVMetadataItem] { + identifiers.flatMap { AVMetadataItem.metadataItems(from: self, filteredByIdentifier: $0) } + } +} + +public struct AudioPublicationAugmentedManifest { + var manifest: Manifest + var cover: UIImage? +} + +public protocol AudioPublicationManifestAugmentor { + func augment(_ baseManifest: Manifest, using fetcher: Fetcher) -> AudioPublicationAugmentedManifest +} + +public final class AVAudioPublicationManifestAugmentor: AudioPublicationManifestAugmentor { + public init() {} + public func augment(_ baseManifest: Manifest, using fetcher: Fetcher) -> AudioPublicationAugmentedManifest + { + let avAssets = baseManifest.readingOrder.map { link in + fetcher.get(link).file.map { AVURLAsset(url: $0, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) } + } + let readingOrder = zip(baseManifest.readingOrder, avAssets).map { link, avAsset in + guard let avAsset = avAsset else { return link } + return link.copy( + title: avAsset.metadata.filter([.commonIdentifierTitle]).first(where: { $0.stringValue }), + bitrate: avAsset.tracks(withMediaType: .audio).first.map { Double($0.estimatedDataRate) }, + duration: avAsset.duration.seconds + ) + } + let avMetadata = avAssets.compactMap { $0?.metadata }.reduce([], +) + + let metadata = baseManifest.metadata.copy( + title: avMetadata.filter([.commonIdentifierTitle, .id3MetadataAlbumTitle]).first(where: { $0.stringValue }) ?? baseManifest.metadata.title, + subtitle: avMetadata.filter([.id3MetadataSubTitle, .iTunesMetadataTrackSubTitle]).first(where: { $0.stringValue }), + modified: avMetadata.filter([.commonIdentifierLastModifiedDate]).first(where: { $0.dateValue }), + published: avMetadata.filter([.commonIdentifierCreationDate, .id3MetadataDate]).first(where: { $0.dateValue }), + languages: avMetadata.filter([.commonIdentifierLanguage, .id3MetadataLanguage]).compactMap(\.stringValue).removingDuplicates(), + subjects: avMetadata.filter([.commonIdentifierSubject]).compactMap(\.stringValue).removingDuplicates().map { Subject(name: $0) }, + authors: avMetadata.filter([.commonIdentifierAuthor, .iTunesMetadataAuthor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }, + artists: avMetadata.filter([.commonIdentifierArtist, .id3MetadataOriginalArtist, .iTunesMetadataArtist, .iTunesMetadataOriginalArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }, + illustrators: avMetadata.filter([.iTunesMetadataAlbumArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }, + contributors: avMetadata.filter([.commonIdentifierContributor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }, + publishers: avMetadata.filter([.commonIdentifierPublisher, .id3MetadataPublisher, .iTunesMetadataPublisher]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }, + description: avMetadata.filter([.commonIdentifierDescription, .iTunesMetadataDescription]).first?.stringValue, + duration: avAssets.reduce(0) { duration, avAsset in + guard let duration = duration, let avAsset = avAsset else { return nil } + return duration + avAsset.duration.seconds + } + ) + let manifest = baseManifest.copy(metadata: metadata, readingOrder: readingOrder) + let cover = avMetadata.filter([.commonIdentifierArtwork, .id3MetadataAttachedPicture, .iTunesMetadataCoverArt]).first(where: { $0.dataValue.flatMap(UIImage.init(data:)) }) + return .init(manifest: manifest, cover: cover) + } +} /// Parses an audiobook Publication from an unstructured archive format containing audio files, /// such as ZAB (Zipped Audio Book) or a simple ZIP. /// /// It can also work for a standalone audio file. public final class AudioParser: PublicationParser { - public init() {} - - private func metadataItems(_ metadataItems: [AVMetadataItem], _ key: AVMetadataKey) -> [AVMetadataItem] { - AVMetadataItem.metadataItems(from: metadataItems, withKey: key, keySpace: .common) + public init(manifestAugmentor: AudioPublicationManifestAugmentor = AVAudioPublicationManifestAugmentor()) { + self.manifestAugmentor = manifestAugmentor } + private let manifestAugmentor: AudioPublicationManifestAugmentor + public func parse(asset: PublicationAsset, fetcher: Fetcher, warnings: WarningLogger?) throws -> Publication.Builder? { guard accepts(asset, fetcher) else { return nil } - var readingOrder = fetcher.links + let defaultReadingOrder = fetcher.links .filter { !ignores($0) && $0.mediaType.isAudio } .sorted { $0.href.localizedCaseInsensitiveCompare($1.href) == .orderedAscending } - guard !readingOrder.isEmpty else { + guard !defaultReadingOrder.isEmpty else { return nil } - let avAssets = readingOrder.map { link in fetcher.get(link).file.map { AVURLAsset(url: $0, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) } } - - readingOrder = zip(readingOrder, avAssets).map { link, avAsset in - guard let avAsset = avAsset else { return link } - return link.copy( - title: metadataItems(avAsset.metadata, .commonKeyTitle).first?.stringValue, - bitrate: avAsset.tracks(withMediaType: .audio).first.map { Double($0.estimatedDataRate) }, - duration: avAsset.duration.seconds - ) - } - - let durations = readingOrder.compactMap(\.duration) - - var metadata = Metadata( - conformsTo: [.audiobook], - title: fetcher.guessTitle(ignoring: ignores) ?? asset.name, - duration: readingOrder.count == durations.count ? durations.reduce(0, +) : nil + let defaultManifest = Manifest( + metadata: Metadata( + conformsTo: [.audiobook], + title: fetcher.guessTitle(ignoring: ignores) ?? asset.name + ), + readingOrder: defaultReadingOrder ) - if let avMetadata = avAssets.compactMap({ $0 }).first?.metadata { - metadata = metadata.copy( - title: metadataItems(avMetadata, .commonKeyTitle).first?.stringValue ?? metadata.title, - modified: metadataItems(avMetadata, .commonKeyLastModifiedDate).first?.stringValue?.dateFromISO8601, - published: metadataItems(avMetadata, .commonKeyCreationDate).first?.stringValue?.dateFromISO8601, - languages: metadataItems(avMetadata, .commonKeyLanguage).compactMap(\.stringValue), - subjects: metadataItems(avMetadata, .commonKeySubject).compactMap(\.stringValue).map { Subject(name: $0) }, - authors: metadataItems(avMetadata, .commonKeyAuthor).compactMap(\.stringValue).map { Contributor(name: $0) }, - contributors: metadataItems(avMetadata, .commonKeyContributor).compactMap(\.stringValue).map { Contributor(name: $0) }, - publishers: metadataItems(avMetadata, .commonKeyPublisher).compactMap(\.stringValue).map { Contributor(name: $0) }, - description: metadataItems(avMetadata, .commonKeyDescription).first?.stringValue - ) - } + let augmented = manifestAugmentor.augment(defaultManifest, using: fetcher) return Publication.Builder( mediaType: .zab, format: .cbz, - manifest: Manifest( - metadata: metadata, - readingOrder: readingOrder - ), + manifest: augmented.manifest, fetcher: fetcher, servicesBuilder: .init( + cover: augmented.cover.map(GeneratedCoverService.makeFactory(cover:)), locator: AudioLocatorService.makeFactory() ) ) From 4f39967c8c9510c9e0f8e566dacc1527199cc1b8 Mon Sep 17 00:00:00 2001 From: Dom Kiva Meyer Date: Sat, 18 May 2024 11:52:51 -0700 Subject: [PATCH 4/7] Update AudioParser for 3.0.0 --- .../Streamer/Parser/Audio/AudioParser.swift | 57 +++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index fe3c609f7..cba2a483d 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -26,40 +26,39 @@ public protocol AudioPublicationManifestAugmentor { public final class AVAudioPublicationManifestAugmentor: AudioPublicationManifestAugmentor { public init() {} - public func augment(_ baseManifest: Manifest, using fetcher: Fetcher) -> AudioPublicationAugmentedManifest + public func augment(_ manifest: Manifest, using fetcher: Fetcher) -> AudioPublicationAugmentedManifest { - let avAssets = baseManifest.readingOrder.map { link in - fetcher.get(link).file.map { AVURLAsset(url: $0, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) } + let avAssets = manifest.readingOrder.map { link in + fetcher.get(link).file.map { AVURLAsset(url: $0.url, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) } } - let readingOrder = zip(baseManifest.readingOrder, avAssets).map { link, avAsset in + var manifest = manifest + manifest.readingOrder = zip(manifest.readingOrder, avAssets).map { link, avAsset in guard let avAsset = avAsset else { return link } - return link.copy( - title: avAsset.metadata.filter([.commonIdentifierTitle]).first(where: { $0.stringValue }), - bitrate: avAsset.tracks(withMediaType: .audio).first.map { Double($0.estimatedDataRate) }, - duration: avAsset.duration.seconds - ) + var link = link + link.title = avAsset.metadata.filter([.commonIdentifierTitle]).first(where: { $0.stringValue }) + link.bitrate = avAsset.tracks(withMediaType: .audio).first.map { Double($0.estimatedDataRate) } + link.duration = avAsset.duration.seconds + return link } let avMetadata = avAssets.compactMap { $0?.metadata }.reduce([], +) - - let metadata = baseManifest.metadata.copy( - title: avMetadata.filter([.commonIdentifierTitle, .id3MetadataAlbumTitle]).first(where: { $0.stringValue }) ?? baseManifest.metadata.title, - subtitle: avMetadata.filter([.id3MetadataSubTitle, .iTunesMetadataTrackSubTitle]).first(where: { $0.stringValue }), - modified: avMetadata.filter([.commonIdentifierLastModifiedDate]).first(where: { $0.dateValue }), - published: avMetadata.filter([.commonIdentifierCreationDate, .id3MetadataDate]).first(where: { $0.dateValue }), - languages: avMetadata.filter([.commonIdentifierLanguage, .id3MetadataLanguage]).compactMap(\.stringValue).removingDuplicates(), - subjects: avMetadata.filter([.commonIdentifierSubject]).compactMap(\.stringValue).removingDuplicates().map { Subject(name: $0) }, - authors: avMetadata.filter([.commonIdentifierAuthor, .iTunesMetadataAuthor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }, - artists: avMetadata.filter([.commonIdentifierArtist, .id3MetadataOriginalArtist, .iTunesMetadataArtist, .iTunesMetadataOriginalArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }, - illustrators: avMetadata.filter([.iTunesMetadataAlbumArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }, - contributors: avMetadata.filter([.commonIdentifierContributor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }, - publishers: avMetadata.filter([.commonIdentifierPublisher, .id3MetadataPublisher, .iTunesMetadataPublisher]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }, - description: avMetadata.filter([.commonIdentifierDescription, .iTunesMetadataDescription]).first?.stringValue, - duration: avAssets.reduce(0) { duration, avAsset in - guard let duration = duration, let avAsset = avAsset else { return nil } - return duration + avAsset.duration.seconds - } - ) - let manifest = baseManifest.copy(metadata: metadata, readingOrder: readingOrder) + var metadata = manifest.metadata + metadata.localizedTitle = avMetadata.filter([.commonIdentifierTitle, .id3MetadataAlbumTitle]).first(where: { $0.stringValue })?.localizedString ?? manifest.metadata.localizedTitle + metadata.localizedSubtitle = avMetadata.filter([.id3MetadataSubTitle, .iTunesMetadataTrackSubTitle]).first(where: { $0.stringValue })?.localizedString + metadata.modified = avMetadata.filter([.commonIdentifierLastModifiedDate]).first(where: { $0.dateValue }) + metadata.published = avMetadata.filter([.commonIdentifierCreationDate, .id3MetadataDate]).first(where: { $0.dateValue }) + metadata.languages = avMetadata.filter([.commonIdentifierLanguage, .id3MetadataLanguage]).compactMap(\.stringValue).removingDuplicates() + metadata.subjects = avMetadata.filter([.commonIdentifierSubject]).compactMap(\.stringValue).removingDuplicates().map { Subject(name: $0) } + metadata.authors = avMetadata.filter([.commonIdentifierAuthor, .iTunesMetadataAuthor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + metadata.artists = avMetadata.filter([.commonIdentifierArtist, .id3MetadataOriginalArtist, .iTunesMetadataArtist, .iTunesMetadataOriginalArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + metadata.illustrators = avMetadata.filter([.iTunesMetadataAlbumArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + metadata.contributors = avMetadata.filter([.commonIdentifierContributor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + metadata.publishers = avMetadata.filter([.commonIdentifierPublisher, .id3MetadataPublisher, .iTunesMetadataPublisher]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + metadata.description = avMetadata.filter([.commonIdentifierDescription, .iTunesMetadataDescription]).first?.stringValue + metadata.duration = avAssets.reduce(0) { duration, avAsset in + guard let duration = duration, let avAsset = avAsset else { return nil } + return duration + avAsset.duration.seconds + } + manifest.metadata = metadata let cover = avMetadata.filter([.commonIdentifierArtwork, .id3MetadataAttachedPicture, .iTunesMetadataCoverArt]).first(where: { $0.dataValue.flatMap(UIImage.init(data:)) }) return .init(manifest: manifest, cover: cover) } From 3a78647c8426a6294ef383c8f59c1d533718a09c Mon Sep 17 00:00:00 2001 From: Dom Kiva Meyer Date: Sun, 19 May 2024 17:59:00 -0700 Subject: [PATCH 5/7] Remove malfunctioning bitrate extraction --- Sources/Streamer/Parser/Audio/AudioParser.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index cba2a483d..211149a77 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -36,7 +36,6 @@ public final class AVAudioPublicationManifestAugmentor: AudioPublicationManifest guard let avAsset = avAsset else { return link } var link = link link.title = avAsset.metadata.filter([.commonIdentifierTitle]).first(where: { $0.stringValue }) - link.bitrate = avAsset.tracks(withMediaType: .audio).first.map { Double($0.estimatedDataRate) } link.duration = avAsset.duration.seconds return link } From 39096c4535e6c9baf19b95865d71c9d7851eddcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 21 May 2024 11:27:47 +0200 Subject: [PATCH 6/7] Minor refactoring and update changelog --- CHANGELOG.md | 7 ++ .../Streamer/Parser/Audio/AudioParser.swift | 56 --------------- .../AudioPublicationManifestAugmentor.swift | 70 +++++++++++++++++++ TestApp/Sources/Info.plist | 24 +++++++ 4 files changed, 101 insertions(+), 56 deletions(-) create mode 100644 Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 796fe171a..366e41970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to this project will be documented in this file. Take a look ## [Unreleased] +### Added + +#### Streamer + +* Support for standalone audio files and their metadata (contributed by [@domkm](https://github.com/readium/swift-toolkit/pull/414)). + + ### Changed The Readium Swift toolkit now requires a minimum of iOS 13. diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index 211149a77..c0d7dd89c 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -4,64 +4,8 @@ // available in the top-level LICENSE file of the project. // -import AVFoundation import Foundation import ReadiumShared -import UIKit - -private extension [AVMetadataItem] { - func filter(_ identifiers: [AVMetadataIdentifier]) -> [AVMetadataItem] { - identifiers.flatMap { AVMetadataItem.metadataItems(from: self, filteredByIdentifier: $0) } - } -} - -public struct AudioPublicationAugmentedManifest { - var manifest: Manifest - var cover: UIImage? -} - -public protocol AudioPublicationManifestAugmentor { - func augment(_ baseManifest: Manifest, using fetcher: Fetcher) -> AudioPublicationAugmentedManifest -} - -public final class AVAudioPublicationManifestAugmentor: AudioPublicationManifestAugmentor { - public init() {} - public func augment(_ manifest: Manifest, using fetcher: Fetcher) -> AudioPublicationAugmentedManifest - { - let avAssets = manifest.readingOrder.map { link in - fetcher.get(link).file.map { AVURLAsset(url: $0.url, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) } - } - var manifest = manifest - manifest.readingOrder = zip(manifest.readingOrder, avAssets).map { link, avAsset in - guard let avAsset = avAsset else { return link } - var link = link - link.title = avAsset.metadata.filter([.commonIdentifierTitle]).first(where: { $0.stringValue }) - link.duration = avAsset.duration.seconds - return link - } - let avMetadata = avAssets.compactMap { $0?.metadata }.reduce([], +) - var metadata = manifest.metadata - metadata.localizedTitle = avMetadata.filter([.commonIdentifierTitle, .id3MetadataAlbumTitle]).first(where: { $0.stringValue })?.localizedString ?? manifest.metadata.localizedTitle - metadata.localizedSubtitle = avMetadata.filter([.id3MetadataSubTitle, .iTunesMetadataTrackSubTitle]).first(where: { $0.stringValue })?.localizedString - metadata.modified = avMetadata.filter([.commonIdentifierLastModifiedDate]).first(where: { $0.dateValue }) - metadata.published = avMetadata.filter([.commonIdentifierCreationDate, .id3MetadataDate]).first(where: { $0.dateValue }) - metadata.languages = avMetadata.filter([.commonIdentifierLanguage, .id3MetadataLanguage]).compactMap(\.stringValue).removingDuplicates() - metadata.subjects = avMetadata.filter([.commonIdentifierSubject]).compactMap(\.stringValue).removingDuplicates().map { Subject(name: $0) } - metadata.authors = avMetadata.filter([.commonIdentifierAuthor, .iTunesMetadataAuthor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } - metadata.artists = avMetadata.filter([.commonIdentifierArtist, .id3MetadataOriginalArtist, .iTunesMetadataArtist, .iTunesMetadataOriginalArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } - metadata.illustrators = avMetadata.filter([.iTunesMetadataAlbumArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } - metadata.contributors = avMetadata.filter([.commonIdentifierContributor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } - metadata.publishers = avMetadata.filter([.commonIdentifierPublisher, .id3MetadataPublisher, .iTunesMetadataPublisher]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } - metadata.description = avMetadata.filter([.commonIdentifierDescription, .iTunesMetadataDescription]).first?.stringValue - metadata.duration = avAssets.reduce(0) { duration, avAsset in - guard let duration = duration, let avAsset = avAsset else { return nil } - return duration + avAsset.duration.seconds - } - manifest.metadata = metadata - let cover = avMetadata.filter([.commonIdentifierArtwork, .id3MetadataAttachedPicture, .iTunesMetadataCoverArt]).first(where: { $0.dataValue.flatMap(UIImage.init(data:)) }) - return .init(manifest: manifest, cover: cover) - } -} /// Parses an audiobook Publication from an unstructured archive format containing audio files, /// such as ZAB (Zipped Audio Book) or a simple ZIP. diff --git a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift new file mode 100644 index 000000000..fecbba31e --- /dev/null +++ b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift @@ -0,0 +1,70 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import AVFoundation +import Foundation +import ReadiumShared +import UIKit + +/// Implements a strategy to augment a `Manifest` of an audio publication with additional metadata and +/// cover, for example by looking into the audio files metadata. +public protocol AudioPublicationManifestAugmentor { + func augment(_ baseManifest: Manifest, using fetcher: Fetcher) -> AudioPublicationAugmentedManifest +} + +public struct AudioPublicationAugmentedManifest { + var manifest: Manifest + var cover: UIImage? +} + +/// An `AudioPublicationManifestAugmentor` using AVFoundation to retrieve the audio metadata. +/// +/// It will only work for local publications (file://). +public final class AVAudioPublicationManifestAugmentor: AudioPublicationManifestAugmentor { + public init() {} + + public func augment(_ manifest: Manifest, using fetcher: Fetcher) -> AudioPublicationAugmentedManifest { + let avAssets = manifest.readingOrder.map { link in + fetcher.get(link).file + .map { AVURLAsset(url: $0.url, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) } + } + var manifest = manifest + manifest.readingOrder = zip(manifest.readingOrder, avAssets).map { link, avAsset in + guard let avAsset = avAsset else { return link } + var link = link + link.title = avAsset.metadata.filter([.commonIdentifierTitle]).first(where: { $0.stringValue }) + link.duration = avAsset.duration.seconds + return link + } + let avMetadata = avAssets.compactMap { $0?.metadata }.reduce([], +) + var metadata = manifest.metadata + metadata.localizedTitle = avMetadata.filter([.commonIdentifierTitle, .id3MetadataAlbumTitle]).first(where: { $0.stringValue })?.localizedString ?? manifest.metadata.localizedTitle + metadata.localizedSubtitle = avMetadata.filter([.id3MetadataSubTitle, .iTunesMetadataTrackSubTitle]).first(where: { $0.stringValue })?.localizedString + metadata.modified = avMetadata.filter([.commonIdentifierLastModifiedDate]).first(where: { $0.dateValue }) + metadata.published = avMetadata.filter([.commonIdentifierCreationDate, .id3MetadataDate]).first(where: { $0.dateValue }) + metadata.languages = avMetadata.filter([.commonIdentifierLanguage, .id3MetadataLanguage]).compactMap(\.stringValue).removingDuplicates() + metadata.subjects = avMetadata.filter([.commonIdentifierSubject]).compactMap(\.stringValue).removingDuplicates().map { Subject(name: $0) } + metadata.authors = avMetadata.filter([.commonIdentifierAuthor, .iTunesMetadataAuthor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + metadata.artists = avMetadata.filter([.commonIdentifierArtist, .id3MetadataOriginalArtist, .iTunesMetadataArtist, .iTunesMetadataOriginalArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + metadata.illustrators = avMetadata.filter([.iTunesMetadataAlbumArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + metadata.contributors = avMetadata.filter([.commonIdentifierContributor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + metadata.publishers = avMetadata.filter([.commonIdentifierPublisher, .id3MetadataPublisher, .iTunesMetadataPublisher]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) } + metadata.description = avMetadata.filter([.commonIdentifierDescription, .iTunesMetadataDescription]).first?.stringValue + metadata.duration = avAssets.reduce(0) { duration, avAsset in + guard let duration = duration, let avAsset = avAsset else { return nil } + return duration + avAsset.duration.seconds + } + manifest.metadata = metadata + let cover = avMetadata.filter([.commonIdentifierArtwork, .id3MetadataAttachedPicture, .iTunesMetadataCoverArt]).first(where: { $0.dataValue.flatMap(UIImage.init(data:)) }) + return .init(manifest: manifest, cover: cover) + } +} + +private extension [AVMetadataItem] { + func filter(_ identifiers: [AVMetadataIdentifier]) -> [AVMetadataItem] { + identifiers.flatMap { AVMetadataItem.metadataItems(from: self, filteredByIdentifier: $0) } + } +} diff --git a/TestApp/Sources/Info.plist b/TestApp/Sources/Info.plist index eca5aa9c7..b6df21c8f 100644 --- a/TestApp/Sources/Info.plist +++ b/TestApp/Sources/Info.plist @@ -216,6 +216,30 @@ org.readium.lcpa + + CFBundleTypeName + Zipped Audiobook + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + org.readium.zab + + + + CFBundleTypeName + Audiobook + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.audio + + CFBundleExecutable $(EXECUTABLE_NAME) From 0cc66653b6c413b3a70d2dcdbce10c48e4a0a589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 21 May 2024 11:34:53 +0200 Subject: [PATCH 7/7] Update Carthage project --- Support/Carthage/.xcodegen | 1 + Support/Carthage/Readium.xcodeproj/project.pbxproj | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 89a06a0b2..121d50cd7 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -735,6 +735,7 @@ ../../Sources/Streamer/Parser ../../Sources/Streamer/Parser/Audio ../../Sources/Streamer/Parser/Audio/AudioParser.swift +../../Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift ../../Sources/Streamer/Parser/Audio/Services ../../Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift ../../Sources/Streamer/Parser/EPUB diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index e47ba1409..12402cbca 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -235,6 +235,7 @@ A2B9CE5A5A7F999B4D849C1F /* DiffableDecoration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B61198128D628CFB3FD22A /* DiffableDecoration.swift */; }; A526C9EC79DC4461D0BF8D27 /* AudioPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE529EA7B2381BB8762472 /* AudioPreferences.swift */; }; A8F8C4F2C0795BACE0A8C62C /* HREFNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC8639886BD43362741AADD0 /* HREFNormalizer.swift */; }; + A9DFAA4F1D752E15B432FFAB /* AudioPublicationManifestAugmentor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */; }; AABE86D87AEF1253765D1A88 /* HREF.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CA9A244D941CB63515EDDE /* HREF.swift */; }; AAF00F4BC4765B6755AB46A3 /* Properties+Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 294E01A2E6FF25539EBC1082 /* Properties+Archive.swift */; }; ACD1914D2D9BB7141148740F /* ReadiumShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97BC822B36D72EF548162129 /* ReadiumShared.framework */; }; @@ -600,6 +601,7 @@ 7C2787EBE9D5565DA8593711 /* Properties+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Presentation.swift"; sourceTree = ""; }; 7C28B8CD48F8A660141F5983 /* DefaultLocatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLocatorService.swift; sourceTree = ""; }; 7C3A9CF25E925418A1712C0B /* LazyResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyResource.swift; sourceTree = ""; }; + 7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPublicationManifestAugmentor.swift; sourceTree = ""; }; 819D931708B3EE95CF9ADFED /* OPDSCopies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSCopies.swift; sourceTree = ""; }; 8240F845F35439807CE8AF65 /* ContentProtectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentProtectionService.swift; sourceTree = ""; }; 8456BF3665A9B9C0AE4CC158 /* Locator+HTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+HTML.swift"; sourceTree = ""; }; @@ -1540,6 +1542,7 @@ isa = PBXGroup; children = ( D9FFEB1FF4B5CD74EB35CD63 /* AudioParser.swift */, + 7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */, EA77F9FCF66C67516A1033F0 /* Services */, ); path = Audio; @@ -2201,6 +2204,7 @@ files = ( E58910A3992CC88DE5BC0AA0 /* AudioLocatorService.swift in Sources */, 57583D27AB12063C3D114A47 /* AudioParser.swift in Sources */, + A9DFAA4F1D752E15B432FFAB /* AudioPublicationManifestAugmentor.swift in Sources */, 61BBCC98965E362FA840DBB8 /* Bundle.swift in Sources */, 694AAAD5C14BC33891458A4C /* DataCompression.swift in Sources */, C39FFB0B372929F24B2FF3DB /* DataExtension.swift in Sources */,