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 424ded840..c0d7dd89c 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -12,32 +12,41 @@ import ReadiumShared /// /// It can also work for a standalone audio file. public final class AudioParser: PublicationParser { - public init() {} + 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 } - let 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 defaultManifest = Manifest( + metadata: Metadata( + conformsTo: [.audiobook], + title: fetcher.guessTitle(ignoring: ignores) ?? asset.name + ), + readingOrder: defaultReadingOrder + ) + + let augmented = manifestAugmentor.augment(defaultManifest, using: fetcher) + return Publication.Builder( mediaType: .zab, - manifest: Manifest( - metadata: Metadata( - conformsTo: [.audiobook], - title: fetcher.guessTitle(ignoring: ignores) - ), - readingOrder: readingOrder - ), + manifest: augmented.manifest, fetcher: fetcher, servicesBuilder: .init( + cover: augmented.cover.map(GeneratedCoverService.makeFactory(cover:)), locator: AudioLocatorService.makeFactory() ) ) 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/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 */, 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)