Skip to content

Commit 57d81ec

Browse files
authored
Support for standalone audio files and their metadata (#414)
1 parent 473ef6c commit 57d81ec

File tree

6 files changed

+125
-10
lines changed

6 files changed

+125
-10
lines changed

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ All notable changes to this project will be documented in this file. Take a look
66

77
## [Unreleased]
88

9+
### Added
10+
11+
#### Streamer
12+
13+
* Support for standalone audio files and their metadata (contributed by [@domkm](https://github.com/readium/swift-toolkit/pull/414)).
14+
15+
916
### Changed
1017

1118
The Readium Swift toolkit now requires a minimum of iOS 13.

Sources/Streamer/Parser/Audio/AudioParser.swift

+19-10
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,41 @@ import ReadiumShared
1212
///
1313
/// It can also work for a standalone audio file.
1414
public final class AudioParser: PublicationParser {
15-
public init() {}
15+
public init(manifestAugmentor: AudioPublicationManifestAugmentor = AVAudioPublicationManifestAugmentor()) {
16+
self.manifestAugmentor = manifestAugmentor
17+
}
18+
19+
private let manifestAugmentor: AudioPublicationManifestAugmentor
1620

1721
public func parse(asset: PublicationAsset, fetcher: Fetcher, warnings: WarningLogger?) throws -> Publication.Builder? {
1822
guard accepts(asset, fetcher) else {
1923
return nil
2024
}
2125

22-
let readingOrder = fetcher.links
26+
let defaultReadingOrder = fetcher.links
2327
.filter { !ignores($0) && $0.mediaType.isAudio }
2428
.sorted { $0.href.localizedCaseInsensitiveCompare($1.href) == .orderedAscending }
2529

26-
guard !readingOrder.isEmpty else {
30+
guard !defaultReadingOrder.isEmpty else {
2731
return nil
2832
}
2933

34+
let defaultManifest = Manifest(
35+
metadata: Metadata(
36+
conformsTo: [.audiobook],
37+
title: fetcher.guessTitle(ignoring: ignores) ?? asset.name
38+
),
39+
readingOrder: defaultReadingOrder
40+
)
41+
42+
let augmented = manifestAugmentor.augment(defaultManifest, using: fetcher)
43+
3044
return Publication.Builder(
3145
mediaType: .zab,
32-
manifest: Manifest(
33-
metadata: Metadata(
34-
conformsTo: [.audiobook],
35-
title: fetcher.guessTitle(ignoring: ignores)
36-
),
37-
readingOrder: readingOrder
38-
),
46+
manifest: augmented.manifest,
3947
fetcher: fetcher,
4048
servicesBuilder: .init(
49+
cover: augmented.cover.map(GeneratedCoverService.makeFactory(cover:)),
4150
locator: AudioLocatorService.makeFactory()
4251
)
4352
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//
2+
// Copyright 2024 Readium Foundation. All rights reserved.
3+
// Use of this source code is governed by the BSD-style license
4+
// available in the top-level LICENSE file of the project.
5+
//
6+
7+
import AVFoundation
8+
import Foundation
9+
import ReadiumShared
10+
import UIKit
11+
12+
/// Implements a strategy to augment a `Manifest` of an audio publication with additional metadata and
13+
/// cover, for example by looking into the audio files metadata.
14+
public protocol AudioPublicationManifestAugmentor {
15+
func augment(_ baseManifest: Manifest, using fetcher: Fetcher) -> AudioPublicationAugmentedManifest
16+
}
17+
18+
public struct AudioPublicationAugmentedManifest {
19+
var manifest: Manifest
20+
var cover: UIImage?
21+
}
22+
23+
/// An `AudioPublicationManifestAugmentor` using AVFoundation to retrieve the audio metadata.
24+
///
25+
/// It will only work for local publications (file://).
26+
public final class AVAudioPublicationManifestAugmentor: AudioPublicationManifestAugmentor {
27+
public init() {}
28+
29+
public func augment(_ manifest: Manifest, using fetcher: Fetcher) -> AudioPublicationAugmentedManifest {
30+
let avAssets = manifest.readingOrder.map { link in
31+
fetcher.get(link).file
32+
.map { AVURLAsset(url: $0.url, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) }
33+
}
34+
var manifest = manifest
35+
manifest.readingOrder = zip(manifest.readingOrder, avAssets).map { link, avAsset in
36+
guard let avAsset = avAsset else { return link }
37+
var link = link
38+
link.title = avAsset.metadata.filter([.commonIdentifierTitle]).first(where: { $0.stringValue })
39+
link.duration = avAsset.duration.seconds
40+
return link
41+
}
42+
let avMetadata = avAssets.compactMap { $0?.metadata }.reduce([], +)
43+
var metadata = manifest.metadata
44+
metadata.localizedTitle = avMetadata.filter([.commonIdentifierTitle, .id3MetadataAlbumTitle]).first(where: { $0.stringValue })?.localizedString ?? manifest.metadata.localizedTitle
45+
metadata.localizedSubtitle = avMetadata.filter([.id3MetadataSubTitle, .iTunesMetadataTrackSubTitle]).first(where: { $0.stringValue })?.localizedString
46+
metadata.modified = avMetadata.filter([.commonIdentifierLastModifiedDate]).first(where: { $0.dateValue })
47+
metadata.published = avMetadata.filter([.commonIdentifierCreationDate, .id3MetadataDate]).first(where: { $0.dateValue })
48+
metadata.languages = avMetadata.filter([.commonIdentifierLanguage, .id3MetadataLanguage]).compactMap(\.stringValue).removingDuplicates()
49+
metadata.subjects = avMetadata.filter([.commonIdentifierSubject]).compactMap(\.stringValue).removingDuplicates().map { Subject(name: $0) }
50+
metadata.authors = avMetadata.filter([.commonIdentifierAuthor, .iTunesMetadataAuthor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }
51+
metadata.artists = avMetadata.filter([.commonIdentifierArtist, .id3MetadataOriginalArtist, .iTunesMetadataArtist, .iTunesMetadataOriginalArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }
52+
metadata.illustrators = avMetadata.filter([.iTunesMetadataAlbumArtist]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }
53+
metadata.contributors = avMetadata.filter([.commonIdentifierContributor]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }
54+
metadata.publishers = avMetadata.filter([.commonIdentifierPublisher, .id3MetadataPublisher, .iTunesMetadataPublisher]).compactMap(\.stringValue).removingDuplicates().map { Contributor(name: $0) }
55+
metadata.description = avMetadata.filter([.commonIdentifierDescription, .iTunesMetadataDescription]).first?.stringValue
56+
metadata.duration = avAssets.reduce(0) { duration, avAsset in
57+
guard let duration = duration, let avAsset = avAsset else { return nil }
58+
return duration + avAsset.duration.seconds
59+
}
60+
manifest.metadata = metadata
61+
let cover = avMetadata.filter([.commonIdentifierArtwork, .id3MetadataAttachedPicture, .iTunesMetadataCoverArt]).first(where: { $0.dataValue.flatMap(UIImage.init(data:)) })
62+
return .init(manifest: manifest, cover: cover)
63+
}
64+
}
65+
66+
private extension [AVMetadataItem] {
67+
func filter(_ identifiers: [AVMetadataIdentifier]) -> [AVMetadataItem] {
68+
identifiers.flatMap { AVMetadataItem.metadataItems(from: self, filteredByIdentifier: $0) }
69+
}
70+
}

Support/Carthage/.xcodegen

+1
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,7 @@
735735
../../Sources/Streamer/Parser
736736
../../Sources/Streamer/Parser/Audio
737737
../../Sources/Streamer/Parser/Audio/AudioParser.swift
738+
../../Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift
738739
../../Sources/Streamer/Parser/Audio/Services
739740
../../Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift
740741
../../Sources/Streamer/Parser/EPUB

Support/Carthage/Readium.xcodeproj/project.pbxproj

+4
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@
235235
A2B9CE5A5A7F999B4D849C1F /* DiffableDecoration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B61198128D628CFB3FD22A /* DiffableDecoration.swift */; };
236236
A526C9EC79DC4461D0BF8D27 /* AudioPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE529EA7B2381BB8762472 /* AudioPreferences.swift */; };
237237
A8F8C4F2C0795BACE0A8C62C /* HREFNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC8639886BD43362741AADD0 /* HREFNormalizer.swift */; };
238+
A9DFAA4F1D752E15B432FFAB /* AudioPublicationManifestAugmentor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */; };
238239
AABE86D87AEF1253765D1A88 /* HREF.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CA9A244D941CB63515EDDE /* HREF.swift */; };
239240
AAF00F4BC4765B6755AB46A3 /* Properties+Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 294E01A2E6FF25539EBC1082 /* Properties+Archive.swift */; };
240241
ACD1914D2D9BB7141148740F /* ReadiumShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97BC822B36D72EF548162129 /* ReadiumShared.framework */; };
@@ -600,6 +601,7 @@
600601
7C2787EBE9D5565DA8593711 /* Properties+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Presentation.swift"; sourceTree = "<group>"; };
601602
7C28B8CD48F8A660141F5983 /* DefaultLocatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLocatorService.swift; sourceTree = "<group>"; };
602603
7C3A9CF25E925418A1712C0B /* LazyResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyResource.swift; sourceTree = "<group>"; };
604+
7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPublicationManifestAugmentor.swift; sourceTree = "<group>"; };
603605
819D931708B3EE95CF9ADFED /* OPDSCopies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSCopies.swift; sourceTree = "<group>"; };
604606
8240F845F35439807CE8AF65 /* ContentProtectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentProtectionService.swift; sourceTree = "<group>"; };
605607
8456BF3665A9B9C0AE4CC158 /* Locator+HTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+HTML.swift"; sourceTree = "<group>"; };
@@ -1540,6 +1542,7 @@
15401542
isa = PBXGroup;
15411543
children = (
15421544
D9FFEB1FF4B5CD74EB35CD63 /* AudioParser.swift */,
1545+
7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */,
15431546
EA77F9FCF66C67516A1033F0 /* Services */,
15441547
);
15451548
path = Audio;
@@ -2201,6 +2204,7 @@
22012204
files = (
22022205
E58910A3992CC88DE5BC0AA0 /* AudioLocatorService.swift in Sources */,
22032206
57583D27AB12063C3D114A47 /* AudioParser.swift in Sources */,
2207+
A9DFAA4F1D752E15B432FFAB /* AudioPublicationManifestAugmentor.swift in Sources */,
22042208
61BBCC98965E362FA840DBB8 /* Bundle.swift in Sources */,
22052209
694AAAD5C14BC33891458A4C /* DataCompression.swift in Sources */,
22062210
C39FFB0B372929F24B2FF3DB /* DataExtension.swift in Sources */,

TestApp/Sources/Info.plist

+24
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,30 @@
216216
<string>org.readium.lcpa</string>
217217
</array>
218218
</dict>
219+
<dict>
220+
<key>CFBundleTypeName</key>
221+
<string>Zipped Audiobook</string>
222+
<key>CFBundleTypeRole</key>
223+
<string>Viewer</string>
224+
<key>LSHandlerRank</key>
225+
<string>Alternate</string>
226+
<key>LSItemContentTypes</key>
227+
<array>
228+
<string>org.readium.zab</string>
229+
</array>
230+
</dict>
231+
<dict>
232+
<key>CFBundleTypeName</key>
233+
<string>Audiobook</string>
234+
<key>CFBundleTypeRole</key>
235+
<string>Viewer</string>
236+
<key>LSHandlerRank</key>
237+
<string>Alternate</string>
238+
<key>LSItemContentTypes</key>
239+
<array>
240+
<string>public.audio</string>
241+
</array>
242+
</dict>
219243
</array>
220244
<key>CFBundleExecutable</key>
221245
<string>$(EXECUTABLE_NAME)</string>

0 commit comments

Comments
 (0)