Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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