Skip to content

Commit

Permalink
Support for standalone audio files and their metadata (#414)
Browse files Browse the repository at this point in the history
  • Loading branch information
domkm authored May 21, 2024
1 parent 473ef6c commit 57d81ec
Show file tree
Hide file tree
Showing 6 changed files with 125 additions and 10 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 19 additions & 10 deletions Sources/Streamer/Parser/Audio/AudioParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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) }
}
}
1 change: 1 addition & 0 deletions Support/Carthage/.xcodegen
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Support/Carthage/Readium.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -600,6 +601,7 @@
7C2787EBE9D5565DA8593711 /* Properties+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Presentation.swift"; sourceTree = "<group>"; };
7C28B8CD48F8A660141F5983 /* DefaultLocatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLocatorService.swift; sourceTree = "<group>"; };
7C3A9CF25E925418A1712C0B /* LazyResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyResource.swift; sourceTree = "<group>"; };
7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPublicationManifestAugmentor.swift; sourceTree = "<group>"; };
819D931708B3EE95CF9ADFED /* OPDSCopies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSCopies.swift; sourceTree = "<group>"; };
8240F845F35439807CE8AF65 /* ContentProtectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentProtectionService.swift; sourceTree = "<group>"; };
8456BF3665A9B9C0AE4CC158 /* Locator+HTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+HTML.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1540,6 +1542,7 @@
isa = PBXGroup;
children = (
D9FFEB1FF4B5CD74EB35CD63 /* AudioParser.swift */,
7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */,
EA77F9FCF66C67516A1033F0 /* Services */,
);
path = Audio;
Expand Down Expand Up @@ -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 */,
Expand Down
24 changes: 24 additions & 0 deletions TestApp/Sources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,30 @@
<string>org.readium.lcpa</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Zipped Audiobook</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>org.readium.zab</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Audiobook</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.audio</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
Expand Down

0 comments on commit 57d81ec

Please sign in to comment.