diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..8ead7d05
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,8 @@
+*.psd filter=lfs diff=lfs merge=lfs -text
+*.zab filter=lfs diff=lfs merge=lfs -text
+*.opus filter=lfs diff=lfs merge=lfs -text
+*.audiobook filter=lfs diff=lfs merge=lfs -text
+*.webpub filter=lfs diff=lfs merge=lfs -text
+*.m4b filter=lfs diff=lfs merge=lfs -text
+*.mp3 filter=lfs diff=lfs merge=lfs -text
+*.cbz filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 654d1696..72c7d67f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -20,6 +20,11 @@ jobs:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ lfs: true
+
+ - name: Pull LFS objects
+ run: git lfs pull
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
diff --git a/.gitignore b/.gitignore
index e60b3492..c112c1aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
+.DS_Store
publications/*
-*.old
\ No newline at end of file
+*.old
+test/
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89dfc24c..1ebb5266 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
**Warning:** Features marked as *alpha* may change or be removed in a future release without notice. Use with caution.
+## [0.14.0] - 2026-06-06
+
+### Added
+
+- The audiobook parser has been dramatically improved (coded largely by Claude Opus 4.8, xhigh setting). Audiobook refers to any non-WebPub audiobook, such as a folder of audio files, a ZIP with audio files, or a single audio file (.m4b, .mp3, .opus etc.). The previous implementation was techincally non-compliant, as `bitrate` and `duration` were not included in the generated manifest. The new implementation does the folowing:
+ - Parses ISO-BMFF (MP4) containers (.m4a, .m4b, .mp4 etc.), OGG containers (.ogg, .opus) etc.
+ - Extracts basic metadata, such as title, author, subject, and adds it to the metadata of the WebPub manifest, along with duration and bitrate
+ - Exposes the audiobook's cover as a link in the WebPub manifest
+ - Builds a WebPub TOC based on individual file names or TOCs inside audio files. .m3u, .m3u8, .pls, .xspf, .cue are also supported as indpendent metadata files
+ - Tries to reduce the amount of range requests that have to be made through caching, and tries to reduce latency the initial metadata parsing causes when opening files remotely by making requests in parallel
+- Detection of other popular DRM schemes besides LCP. This includes: Adobe ADEPT, Apple Fairplay, Kobo, B&N, and any other generic encryption scheme using `encryption.xml`. There's still not any particular action taken for these DRM schemes, but it lays the groundwork for future decryption or error throwing when a particular DRM scheme is detected
+
+### Changed
+
+- The zran code now uses the [Readium-hosted fork](https://github.com/readium/zran) instead of the personal fork created by @chocolatkey
+- When calling `ConformsTo` on a manifest (and publication), the contents of the manifest's `metadata.conformsTo` is now checked for conformance *before* scanning of the reading order and other checks are performed. Whether this is the best approach is TBD
+- Various parsers (audio, image, pdf) were moved to their own folders
+- The `mediatype` package has gotten some changes based on the other toolkits:
+ - `MP3` is now `MPEGAudio`
+ - FLAC, MP4, MPEGVideo were added as new mimetypes
+ - Some more audio extensions were added to the sniffer
+
+### Fixed
+
+- The HTTP fetcher would, when making byte range requests, only accept an HTTP 206 response. But if the full range is requested (start = 0 and end = 0), HTTP 200 is also an acceptable response
+- A slash was being added as a prefix to WebPub manifest links for exploded ebook folders
+
## [0.13.4] - 2026-03-09
### Changed
diff --git a/go.mod b/go.mod
index f24b1ec9..0ca139f4 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.25.0
require (
cloud.google.com/go/storage v1.62.2
+ github.com/abema/go-mp4 v1.6.0
github.com/agext/regexp v1.3.0
github.com/andybalholm/cascadia v1.3.3
github.com/antchfx/xmlquery v1.5.1
@@ -13,21 +14,23 @@ require (
github.com/aws/smithy-go v1.25.1
github.com/azr/phash v0.2.0
github.com/bbrks/go-blurhash v1.2.0
- github.com/chocolatkey/gzran v0.0.0-20251204101541-d8891e235711
github.com/deckarep/golang-set v1.8.0
+ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/disintegration/imaging v1.6.2
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/kettek/apng v0.0.0-20220823221153-ff692776a607
github.com/pdfcpu/pdfcpu v0.12.1
github.com/pkg/errors v0.9.1
+ github.com/readium/zran v0.0.0-20260525212206-dcf56adb2c0e
github.com/relvacode/iso8601 v1.7.0
github.com/stretchr/testify v1.11.1
github.com/trimmer-io/go-xmp v1.0.0
go4.org v0.0.0-20230225012048-214862532bf5
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
- golang.org/x/image v0.39.0
- golang.org/x/net v0.52.0
- golang.org/x/text v0.36.0
+ golang.org/x/image v0.41.0
+ golang.org/x/net v0.55.0
+ golang.org/x/sync v0.20.0
+ golang.org/x/text v0.37.0
google.golang.org/api v0.274.0
)
@@ -80,10 +83,9 @@ require (
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
- golang.org/x/crypto v0.50.0 // indirect
+ golang.org/x/crypto v0.51.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
- golang.org/x/sync v0.20.0 // indirect
- golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
diff --git a/go.sum b/go.sum
index f9d0d42d..81541707 100644
--- a/go.sum
+++ b/go.sum
@@ -47,6 +47,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
+github.com/abema/go-mp4 v1.6.0 h1:aXw6240IdFHH5laJuiN992nWMHFkPAREm0yCTAFsceE=
+github.com/abema/go-mp4 v1.6.0/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
github.com/agext/regexp v1.3.0 h1:6+9tp+S41TU48gFNV47bX+pp1q7WahGofw6JccmsCDs=
github.com/agext/regexp v1.3.0/go.mod h1:6phv1gViOJXWcTfpxOi9VMS+MaSAo+SUDf7do3ur1HA=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
@@ -86,19 +88,21 @@ github.com/bbrks/go-blurhash v1.2.0/go.mod h1:r4N4/ViVMa2h6Ex6e1aoCWMTkykYWS/VXv
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chocolatkey/gzran v0.0.0-20251204101541-d8891e235711 h1:KXBH2rdtVs70qr55arSwgrXZq6QasYgox1GbYdi3kRg=
-github.com/chocolatkey/gzran v0.0.0-20251204101541-d8891e235711/go.mod h1:jk2T+gAWOv82T5A5XU+h/bA+9ngcj+DkHNrP/Ktyt88=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
+github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4=
github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo=
+github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
+github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -160,6 +164,7 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
@@ -186,9 +191,12 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
+github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
github.com/pdfcpu/pdfcpu v0.12.1 h1:HwoN72zJCj+pPbfMDChYBTZrT7SY0VwgUzqeaId3I20=
github.com/pdfcpu/pdfcpu v0.12.1/go.mod h1:7KPpVLMavcpliPrtN6o7Kuk3cFtYq8nii3SJnnsK7ps=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -199,6 +207,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/readium/zran v0.0.0-20260525212206-dcf56adb2c0e h1:DQJkuzoHhmE+FGtWRanEanh2IpiXRxwmR4OgQwiD5vA=
+github.com/readium/zran v0.0.0-20260525212206-dcf56adb2c0e/go.mod h1:3znXhDJYPPRNT6KgvPVhTmzAiqUqzEPKUPhPXrPSdB0=
github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo=
github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -212,6 +222,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
github.com/trimmer-io/go-xmp v1.0.0 h1:zY8bolSga5kOjBAaHS6hrdxLgEoYuT875xTy0QDwZWs=
github.com/trimmer-io/go-xmp v1.0.0/go.mod h1:Aaptr9sp1lLv7UnCAdQ+gSHZyY2miYaKmcNVj7HRBwA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -250,8 +261,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
-golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -265,8 +276,8 @@ golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5N
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
-golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
+golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
+golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -309,8 +320,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
-golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -354,8 +365,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
-golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -377,8 +388,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
-golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
@@ -466,7 +477,10 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
+gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/pkg/analyzer/image_test.go b/pkg/analyzer/image_test.go
index 0783d6a7..309d04f5 100644
--- a/pkg/analyzer/image_test.go
+++ b/pkg/analyzer/image_test.go
@@ -91,7 +91,7 @@ func TestMatchImage(t *testing.T) {
ok, err := MatchImage(manifest.Link{
Href: manifest.MustNewHREFFromString("audio.mp3", false),
- MediaType: &mediatype.MP3,
+ MediaType: &mediatype.MPEGAudio,
}, manifest.HashList{})
require.ErrorContains(t, err, "link is not to an image that can be matched")
require.False(t, ok)
diff --git a/pkg/archive/archive_zip.go b/pkg/archive/archive_zip.go
index 995d4073..1f5590a2 100644
--- a/pkg/archive/archive_zip.go
+++ b/pkg/archive/archive_zip.go
@@ -11,15 +11,15 @@ import (
"path"
"sync"
- "github.com/chocolatkey/gzran"
"github.com/pkg/errors"
+ "github.com/readium/zran"
)
type gozipArchiveEntry struct {
file *zip.File
minimizeReads bool
- gi gzran.Index
+ gi zran.Index
gm sync.Mutex
}
@@ -115,7 +115,7 @@ func (e *gozipArchiveEntry) Read(start int64, end int64) ([]byte, error) {
// This special reader lets us restore the decompressor state at known offsets
// which is useful when a client has already requested previous parts of the file,
// such as when a web browser requests subsequent byte ranges for media playback.
- fzr, err := gzran.NewDReader(bytes.NewReader(compressedData)) // Default interval = 1MB, same as current ZRandCutoff
+ fzr, err := zran.NewDReader(bytes.NewReader(compressedData)) // Default interval = 1MB, same as current ZRandCutoff
if err != nil {
return nil, err
}
diff --git a/pkg/fetcher/fetcher_file.go b/pkg/fetcher/fetcher_file.go
index 1b9fe07a..d7c6ec92 100644
--- a/pkg/fetcher/fetcher_file.go
+++ b/pkg/fetcher/fetcher_file.go
@@ -43,7 +43,8 @@ func (f *FileFetcher) Links(ctx context.Context) (manifest.LinkList, error) {
return err
}
- href, err := manifest.NewHREFFromString(filepath.ToSlash(filepath.Join(href, strings.TrimPrefix(apath, xpath))), false)
+ rel := strings.TrimPrefix(strings.TrimPrefix(apath, xpath), string(filepath.Separator))
+ href, err := manifest.NewHREFFromString(filepath.ToSlash(filepath.Join(href, rel)), false)
if err != nil {
return err
}
diff --git a/pkg/fetcher/fetcher_file_test.go b/pkg/fetcher/fetcher_file_test.go
index 966f85e1..32a9fd30 100644
--- a/pkg/fetcher/fetcher_file_test.go
+++ b/pkg/fetcher/fetcher_file_test.go
@@ -164,7 +164,7 @@ func TestFileFetcherLinks(t *testing.T) {
mustContain := manifest.LinkList{{
Href: manifest.MustNewHREFFromString("dir_href/subdirectory/hello.mp3", false),
- MediaType: &mediatype.MP3,
+ MediaType: &mediatype.MPEGAudio,
}, {
Href: manifest.MustNewHREFFromString("dir_href/subdirectory/text2.txt", false),
MediaType: &mediatype.Text,
diff --git a/pkg/fetcher/fetcher_http.go b/pkg/fetcher/fetcher_http.go
index 18244f0f..dc3e87f1 100644
--- a/pkg/fetcher/fetcher_http.go
+++ b/pkg/fetcher/fetcher_http.go
@@ -171,7 +171,8 @@ func (r *httpResource) Read(ctx context.Context, start int64, end int64) ([]byte
if err != nil {
return nil, Other(err)
}
- if resp.StatusCode != http.StatusPartialContent {
+
+ if (start == 0 && end == 0 && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent) || ((start != 0 || end != 0) && resp.StatusCode != http.StatusPartialContent) {
ex := httpStatusToException(resp.StatusCode)
if ex == nil {
return nil, Other(errors.New("unexpected HTTP status code: " + strconv.Itoa(resp.StatusCode)))
diff --git a/pkg/fetcher/reader.go b/pkg/fetcher/reader.go
index b020df20..eee1829d 100644
--- a/pkg/fetcher/reader.go
+++ b/pkg/fetcher/reader.go
@@ -3,6 +3,7 @@ package fetcher
import (
"context"
"errors"
+ "io"
)
// For opening a fetcher.Resource as a io.ReadSeeker
@@ -51,14 +52,21 @@ func (rs *ResourceReadSeeker) Seek(offset int64, whence int) (int64, error) {
}
}
-// Seek implements io.ReadSeeker
+// Read implements io.Reader
func (rs *ResourceReadSeeker) Read(p []byte) (n int, err error) {
- bin, errx := rs.r.Read(context.TODO(), rs.offset, rs.offset+int64(len(p)))
+ if len(p) == 0 {
+ return 0, nil
+ }
+ bin, errx := rs.r.Read(context.TODO(), rs.offset, rs.offset+int64(len(p))-1)
if errx != nil {
- err = errx
- return
+ return 0, errx
}
n = copy(p, bin)
rs.offset += int64(n)
- return
+ if n == 0 {
+ // No more bytes available: signal end-of-file so that consumers relying
+ // on the io.Reader contract (e.g. io.ReadFull/io.CopyN) terminate.
+ return 0, io.EOF
+ }
+ return n, nil
}
diff --git a/pkg/manifest/a11y.go b/pkg/manifest/a11y.go
index 69d00be3..b830db03 100644
--- a/pkg/manifest/a11y.go
+++ b/pkg/manifest/a11y.go
@@ -17,7 +17,7 @@ type A11y struct {
Certification *A11yCertification `json:"certification,omitempty"` // Certification of accessible publications.
Summary string `json:"summary,omitempty"` // A human-readable summary of specific accessibility features or deficiencies, consistent with the other accessibility metadata but expressing subtleties such as "short descriptions are present but long descriptions will be needed for non-visual users" or "short descriptions are present and no long descriptions are needed."
AccessModes []A11yAccessMode `json:"accessMode,omitempty"` // The human sensory perceptual system or cognitive faculty through which a person may process or perceive information.
- AccessModesSufficient [][]A11yPrimaryAccessMode `json:"accessModeSufficient,omitempty"` // A list of single or combined accessModes that are sufficient to understand all the intellectual content of a resource.
+ AccessModesSufficient [][]A11yPrimaryAccessMode `json:"accessModeSufficient,omitempty"` // A list of single or combined accessModes that are sufficient to understand all the intellectual content of a resource.
Features []A11yFeature `json:"feature,omitempty"` // Content features of the resource, such as accessible media, alternatives and supported enhancements for accessibility.
Hazards []A11yHazard `json:"hazard,omitempty"` // A characteristic of the described resource that is physiologically dangerous to some users.
Exemptions []A11yExemption `json:"exemption,omitempty"` // Justifications for non-conformance based on exemptions in a given jurisdiction.
diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go
index 1ac4de96..8b28acc2 100644
--- a/pkg/manifest/manifest.go
+++ b/pkg/manifest/manifest.go
@@ -29,8 +29,14 @@ func (m Manifest) ConformsTo(profile Profile) bool {
return false
}
+ // Maybe we shouldn't trust the author of a WebPub? Who knows
+ if slices.Contains(m.Metadata.ConformsTo, profile) {
+ return true
+ }
+
switch profile {
case ProfileAudiobook:
+ // Note that we aren't checking for duration or bitrate as the profile requires
return m.ReadingOrder.AllAreAudio()
case ProfileDivina:
return m.ReadingOrder.AllAreBitmap()
@@ -41,10 +47,6 @@ func (m Manifest) ConformsTo(profile Profile) bool {
}
case ProfilePDF:
return m.ReadingOrder.AllMatchMediaType(&mediatype.PDF)
- default:
- if slices.Contains(m.Metadata.ConformsTo, profile) {
- return true
- }
}
return false
}
diff --git a/pkg/mediatype/sniffer.go b/pkg/mediatype/sniffer.go
index ffabe5b8..5e3d54fa 100644
--- a/pkg/mediatype/sniffer.go
+++ b/pkg/mediatype/sniffer.go
@@ -81,9 +81,10 @@ func SniffOPDS(ctx context.Context, context SnifferContext) *MediaType {
// OPDS 1 (Heavy)
if cxml := context.ContentAsXML(); cxml != nil {
if cxml.XMLName.Space == "http://www.w3.org/2005/Atom" {
- if cxml.XMLName.Local == "feed" {
+ switch cxml.XMLName.Local {
+ case "feed":
return &OPDS1
- } else if cxml.XMLName.Local == "entry" {
+ case "entry":
return &OPDS1Entry
}
}
@@ -114,7 +115,7 @@ func SniffLCPLicense(ctx context.Context, context SnifferContext) *MediaType {
// Sniffs a bitmap image.
func SniffBitmap(ctx context.Context, context SnifferContext) *MediaType {
- if context.HasFileExtension("avif") || context.HasMediaType("image/avif") {
+ if context.HasFileExtension("avif", "avifs") || context.HasMediaType("image/avif") {
return &AVIF
}
if context.HasFileExtension("bmp", "dib") || context.HasMediaType("image/bmp", "image/x-bmp") {
@@ -149,24 +150,28 @@ func SniffAudio(ctx context.Context, context SnifferContext) *MediaType {
if context.HasFileExtension("aac") || context.HasMediaType("audio/aac") {
return &AAC
}
- if context.HasFileExtension("aiff") || context.HasMediaType("audio/aiff") {
+ if context.HasFileExtension("aiff", "aif", "aifc") || context.HasMediaType("audio/aiff") {
return &AIFF
}
- // TODO flac, m4a
+ if context.HasFileExtension("flac") || context.HasMediaType("audio/flac") {
+ return &FLAC
+ }
if context.HasFileExtension("mp3") || context.HasMediaType("audio/mpeg") {
- return &MP3
+ return &MPEGAudio
+ }
+ if context.HasFileExtension("mp4", "m4a", "m4b", "m4p", "m4r", "alac") || context.HasMediaType("audio/mp4") {
+ return &MP4
}
- if context.HasFileExtension("ogg", "oga") || context.HasMediaType("audio/ogg") {
+ if context.HasFileExtension("ogg", "oga", "mogg") || context.HasMediaType("audio/ogg") {
return &OGG
}
if context.HasFileExtension("opus") || context.HasMediaType("audio/opus") {
return &OPUS
}
- if context.HasFileExtension("wav") || context.HasMediaType("audio/wav") {
+ if context.HasFileExtension("wav", "wave") || context.HasMediaType("audio/wav", "audio/x-wav", "audio/wave") {
return &WAV
}
if context.HasFileExtension("webm") || context.HasMediaType("audio/webm") {
- // Note: .webm extension could also be a video
return &WEBMAudio
}
@@ -292,8 +297,12 @@ var cbz_extensions = map[string]struct{}{
// Authorized extensions for resources in a ZAB archive (Zipped Audio Book).
var zab_extensions = map[string]struct{}{
- "aac": {}, "aiff": {}, "alac": {}, "flac": {}, "m4a": {}, "m4b": {}, "mp3": {}, "ogg": {}, "oga": {}, "mogg": {}, "opus": {}, "wav": {}, "webm": {}, // Audio
- "asx": {}, "bio": {}, "m3u": {}, "m3u8": {}, "pla": {}, "pls": {}, "smil": {}, "vlc": {}, "wpl": {}, "xspf": {}, "zpl": {}, // Playlist
+ "aac": {}, "aiff": {}, "aif": {}, "aifc": {}, "alac": {}, "flac": {},
+ "m4a": {}, "m4b": {}, "mp3": {}, "mp4": {}, "m4r": {}, "m4p": {},
+ "ogg": {}, "oga": {}, "mogg": {}, "opus": {}, "wav": {}, "wave": {},
+ "webm": {}, // Audio
+ "asx": {}, "bio": {}, "m3u": {}, "m3u8": {}, "pla": {}, "pls": {},
+ "smil": {}, "vlc": {}, "wpl": {}, "xspf": {}, "zpl": {}, "cue": {}, "log": {}, // Playlist
}
// Sniffs a simple Archive-based format, like Comic Book Archive or Zipped Audio Book.
diff --git a/pkg/mediatype/types.go b/pkg/mediatype/types.go
index 169ed22a..a4a2dd5e 100644
--- a/pkg/mediatype/types.go
+++ b/pkg/mediatype/types.go
@@ -11,8 +11,9 @@ var CBZ, _ = New("application/vnd.comicbook+zip", "Comic Book ZIP Archive", "cbz
var CBR, _ = New("application/vnd.comicbook-rar", "Comic Book RAR Archive", "cbr")
var CSS, _ = New("text/css", "Cascading Style Sheets", "css")
var EPUB, _ = New("application/epub+zip", "EPUB", "epub")
+var FLAC, _ = New("audio/flac", "Free Lossless Audio Codec", "flac")
var GIF, _ = New("image/gif", "GIF Image", "gif")
-var GZ, _ = New("application/gzip", "GZipped content", "gz")
+var GZ, _ = New("application/gzip", "GZipped data", "gz")
var HTML, _ = New("text/html", "Hypertext Markup Language", "html")
var JavaScript, _ = New("text/javascript", "JavaScript", "js")
var JPEG, _ = New("image/jpeg", "JPEG Image", "jpeg")
@@ -23,11 +24,12 @@ var LCPProtectedAudiobook, _ = New("application/audiobook+lcp", "LCP Protected A
var LCPProtectedPDF, _ = New("application/pdf+lcp", "LCP Protected PDF", "lcpdf")
var LCPStatusDocument, _ = New("application/vnd.readium.license.status.v1.0+json", "LCP Status Document", "")
var LPF, _ = New("application/lpf+zip", "Lightweight Packaging Format", "lpf")
-var MP3, _ = New("audio/mpeg", "MP3 Audio", "mp3")
-var MPEG, _ = New("video/mpeg", "MPEG Video", "mpeg")
+var MPEGAudio, _ = New("audio/mpeg", "MP3 Audio", "mp3")
+var MP4, _ = New("audio/mp4", "MP4 Audio", "mp4")
+var MPEGVideo, _ = New("video/mpeg", "MPEG Video", "mpeg")
var NCX, _ = New("application/x-dtbncx+xml", "Navigation Control File", "ncx")
var OGG, _ = New("audio/ogg", "OGG Audio", "oga")
-var OGV, _ = New("video/ogg", "OGG Audio", "ogv")
+var OGV, _ = New("video/ogg", "OGG Video", "ogv")
var OPDS1, _ = New("application/atom+xml;profile=opds-catalog", "OPDS 1 Catalog", "")
var OPDS1Entry, _ = New("application/atom+xml;type=entry;profile=opds-catalog", "OPDS 1 Catalog Entry", "")
var OPDS2, _ = New("application/opds+json", "OPDS 2 Catalog", "")
@@ -38,6 +40,7 @@ var OPUS, _ = New("audio/opus", "OPUS Audio", "opus")
var OTF, _ = New("font/otf", "OpenType Font", "otf")
var PDF, _ = New("application/pdf", "PDF", "pdf")
var PNG, _ = New("image/png", "Portable Network Graphics", "png")
+var RAR, _ = New("application/vnd.rar", "RAR Archive", "rar")
var ReadiumAudiobook, _ = New("application/audiobook+zip", "Readium Audiobook", "audiobook")
var ReadiumAudiobookManifest, _ = New("application/audiobook+json", "Readium Audiobook", "json")
var ReadiumContentDocument, _ = New("application/vnd.readium.content+json", "Readium Content Document", "")
diff --git a/pkg/mediatype/types_matcher.go b/pkg/mediatype/types_matcher.go
index 58fc32d3..86e1cf7a 100644
--- a/pkg/mediatype/types_matcher.go
+++ b/pkg/mediatype/types_matcher.go
@@ -15,6 +15,7 @@ var knownMatches = map[string]*MediaType{
"application/vnd.comicbook-rar": &CBR,
"text/css": &CSS,
"application/epub+zip": &EPUB,
+ "audio/flac": &FLAC,
"image/gif": &GIF,
"application/gzip": &GZ,
"text/html": &HTML,
@@ -22,27 +23,28 @@ var knownMatches = map[string]*MediaType{
"image/jpeg": &JPEG,
"application/json": &JSON,
"image/jxl": &JXL,
- "application/vnd.readium.lcp.license.v1.0+json": &LCPLicenseDocument,
- "application/audiobook+lcp": &LCPProtectedAudiobook,
- "application/pdf+lcp": &LCPProtectedPDF,
- "application/vnd.readium.license.status.v1.0+json": &LCPStatusDocument,
- "application/lpf+zip": &LPF,
- "audio/mpeg": &MP3,
- "video/mpeg": &MPEG,
- "application/x-dtbncx+xml": &NCX,
- "audio/ogg": &OGG,
- "video/ogg": &OGV,
- "application/atom+xml;profile=opds-catalog": &OPDS1,
+ "application/vnd.readium.lcp.license.v1.0+json": &LCPLicenseDocument,
+ "application/audiobook+lcp": &LCPProtectedAudiobook,
+ "application/pdf+lcp": &LCPProtectedPDF,
+ "application/vnd.readium.license.status.v1.0+json": &LCPStatusDocument,
+ "application/lpf+zip": &LPF,
+ "audio/mpeg": &MPEGAudio,
+ "audio/mp4": &MP4,
+ "video/mpeg": &MPEGVideo,
+ "application/x-dtbncx+xml": &NCX,
+ "audio/ogg": &OGG,
+ "video/ogg": &OGV,
+ "application/atom+xml;profile=opds-catalog": &OPDS1,
"application/atom+xml;type=entry;profile=opds-catalog": &OPDS1Entry,
"application/opds+json": &OPDS2,
"application/opds-publication+json": &OPDS2Publication,
"application/opds-authentication+json": &OPDSAuthentication,
"application/oebps-package+xml": &OPF,
"audio/opus": &OPUS,
- "audio/ogg;codecs=opus": &OPUS,
"font/otf": &OTF,
"application/pdf": &PDF,
"image/png": &PNG,
+ "application/vnd.rar": &RAR,
"application/audiobook+zip": &ReadiumAudiobook,
"application/audiobook+json": &ReadiumAudiobookManifest,
"application/vnd.readium.content+json": &ReadiumContentDocument,
diff --git a/pkg/parser/audio/cover.go b/pkg/parser/audio/cover.go
new file mode 100644
index 00000000..49f02f6e
--- /dev/null
+++ b/pkg/parser/audio/cover.go
@@ -0,0 +1,80 @@
+package audio
+
+import (
+ "bytes"
+ "context"
+ "image"
+ // Register decoders so cover dimensions can be read.
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+
+ "github.com/dhowden/tag"
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/readium/go-toolkit/pkg/mediatype"
+ "github.com/readium/go-toolkit/pkg/pub"
+)
+
+// coverHref is the synthetic HREF under which an extracted cover image is served.
+const coverHref = "~readium/cover"
+
+// coverServiceFactory builds a [pub.ServiceFactory] serving the cover image
+// embedded in an audio file's tags. It returns nil when there is no usable
+// cover.
+func coverServiceFactory(pic *tag.Picture) pub.ServiceFactory {
+ if pic == nil || len(pic.Data) == 0 {
+ return nil
+ }
+
+ ext := pic.Ext
+ if ext == "" {
+ ext = "jpg"
+ }
+
+ var mt *mediatype.MediaType
+ if pic.MIMEType != "" {
+ if m, err := mediatype.NewOfString(pic.MIMEType); err == nil {
+ mt = &m
+ }
+ }
+ if mt == nil {
+ mt = mediatype.OfExtension(ext)
+ }
+
+ link := manifest.Link{
+ Href: manifest.MustNewHREFFromString(coverHref+"."+ext, false),
+ MediaType: mt,
+ Rels: manifest.Strings{"cover"},
+ }
+
+ if cfg, _, err := image.DecodeConfig(bytes.NewReader(pic.Data)); err == nil {
+ link.Width = uint(cfg.Width)
+ link.Height = uint(cfg.Height)
+ }
+
+ data := pic.Data
+ return func(_ pub.Context, _ bool) pub.Service {
+ return coverService{link: link, data: data}
+ }
+}
+
+// coverService exposes an in-memory cover image as a publication resource.
+type coverService struct {
+ link manifest.Link
+ data []byte
+}
+
+func (s coverService) Links() manifest.LinkList {
+ return manifest.LinkList{s.link}
+}
+
+func (s coverService) Get(_ context.Context, link manifest.Link) (fetcher.Resource, bool) {
+ if !link.URL(nil, nil).Equivalent(s.link.URL(nil, nil)) {
+ return nil, false
+ }
+ data := s.data
+ return fetcher.NewBytesResource(s.link, func() []byte { return data }), true
+}
+
+func (s coverService) Close() {}
diff --git a/pkg/parser/audio/duration_formats.go b/pkg/parser/audio/duration_formats.go
new file mode 100644
index 00000000..5aafe0a8
--- /dev/null
+++ b/pkg/parser/audio/duration_formats.go
@@ -0,0 +1,556 @@
+package audio
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "math"
+
+ "github.com/readium/go-toolkit/pkg/fetcher"
+)
+
+// skipID3v2 returns the byte offset right after a leading ID3v2 tag, or 0 if the
+// resource doesn't start with one. ID3v2 tags can be prepended to MP3, FLAC and
+// AAC streams.
+func skipID3v2(ctx context.Context, res fetcher.Resource) int64 {
+ header := readRange(ctx, res, 0, 10)
+ if len(header) < 10 || !bytes.HasPrefix(header, []byte("ID3")) {
+ return 0
+ }
+ // The size is a 28-bit synch-safe integer (7 bits per byte).
+ size := int64(header[6]&0x7F)<<21 | int64(header[7]&0x7F)<<14 | int64(header[8]&0x7F)<<7 | int64(header[9]&0x7F)
+ footer := int64(0)
+ if header[5]&0x10 != 0 { // footer present flag
+ footer = 10
+ }
+ return 10 + size + footer
+}
+
+// probeFLACDuration reads the FLAC STREAMINFO metadata block to compute the
+// duration from the total sample count and sample rate.
+func probeFLACDuration(ctx context.Context, res fetcher.Resource) float64 {
+ base := skipID3v2(ctx, res)
+ magic := readRange(ctx, res, base, 4)
+ if !bytes.Equal(magic, []byte("fLaC")) {
+ return 0
+ }
+ // STREAMINFO is always the first metadata block: 4-byte block header
+ // followed by 34 bytes of payload.
+ block := readRange(ctx, res, base+4, 4+34)
+ if len(block) < 4+34 {
+ return 0
+ }
+ if block[0]&0x7F != 0 { // block type 0 == STREAMINFO
+ return 0
+ }
+ si := block[4:]
+ // 20 bits sample rate, 3 bits channels, 5 bits bits-per-sample, 36 bits total
+ // samples, packed starting at byte 10 of STREAMINFO.
+ sampleRate := uint32(si[10])<<12 | uint32(si[11])<<4 | uint32(si[12])>>4
+ totalSamples := uint64(si[13]&0x0F)<<32 | uint64(si[14])<<24 | uint64(si[15])<<16 | uint64(si[16])<<8 | uint64(si[17])
+ if sampleRate == 0 || totalSamples == 0 {
+ return 0
+ }
+ return float64(totalSamples) / float64(sampleRate)
+}
+
+// probeWAVDuration walks the RIFF chunks of a WAV file to compute the duration
+// from the `data` chunk size and the byte rate declared in the `fmt ` chunk.
+func probeWAVDuration(ctx context.Context, res fetcher.Resource, size int64) float64 {
+ header := readRange(ctx, res, 0, 12)
+ if len(header) < 12 || !bytes.Equal(header[0:4], []byte("RIFF")) || !bytes.Equal(header[8:12], []byte("WAVE")) {
+ return 0
+ }
+
+ var byteRate uint32
+ var sampleRate uint32
+ var channels uint16
+ var bitsPerSample uint16
+ var dataSize int64 = -1
+ var factSamples int64 = -1
+
+ // Parsing is bounded by both the resource size and the RIFF-declared chunk
+ // range (bytes 4-7), so trailing garbage after the RIFF payload is ignored.
+ limit := riffLimit(int64(binary.LittleEndian.Uint32(header[4:8])), size)
+ offset := int64(12)
+ for offset+8 <= limit {
+ chunkHeader := readRange(ctx, res, offset, 8)
+ if len(chunkHeader) < 8 {
+ break
+ }
+ id := string(chunkHeader[0:4])
+ chunkSize := int64(binary.LittleEndian.Uint32(chunkHeader[4:8]))
+ body := offset + 8
+
+ switch id {
+ case "fmt ":
+ fmtBody := readRange(ctx, res, body, min64(chunkSize, 16))
+ if len(fmtBody) >= 16 {
+ channels = binary.LittleEndian.Uint16(fmtBody[2:4])
+ sampleRate = binary.LittleEndian.Uint32(fmtBody[4:8])
+ byteRate = binary.LittleEndian.Uint32(fmtBody[8:12])
+ bitsPerSample = binary.LittleEndian.Uint16(fmtBody[14:16])
+ }
+ case "fact":
+ factBody := readRange(ctx, res, body, 4)
+ if len(factBody) >= 4 {
+ factSamples = int64(binary.LittleEndian.Uint32(factBody[0:4]))
+ }
+ case "data":
+ dataSize = chunkSize
+ }
+
+ // Chunks are word-aligned: an odd size is followed by a pad byte.
+ next := body + chunkSize
+ if chunkSize%2 == 1 {
+ next++
+ }
+ if next <= offset { // guard against overflow or a non-advancing chunk
+ break
+ }
+ offset = next
+ }
+
+ // Prefer the exact sample count from `fact` (used by compressed WAV).
+ if factSamples > 0 && sampleRate > 0 {
+ return float64(factSamples) / float64(sampleRate)
+ }
+ if dataSize > 0 && byteRate > 0 {
+ return float64(dataSize) / float64(byteRate)
+ }
+ // Fall back to reconstructing the byte rate for PCM.
+ if dataSize > 0 && sampleRate > 0 && channels > 0 && bitsPerSample > 0 {
+ computed := float64(sampleRate) * float64(channels) * float64(bitsPerSample) / 8
+ if computed > 0 {
+ return float64(dataSize) / computed
+ }
+ }
+ return 0
+}
+
+// probeAIFFDuration reads the COMM chunk of an AIFF/AIFC file to compute the
+// duration from the number of sample frames and the sample rate.
+func probeAIFFDuration(ctx context.Context, res fetcher.Resource, size int64) float64 {
+ header := readRange(ctx, res, 0, 12)
+ if len(header) < 12 || !bytes.Equal(header[0:4], []byte("FORM")) {
+ return 0
+ }
+ form := string(header[8:12])
+ if form != "AIFF" && form != "AIFC" {
+ return 0
+ }
+
+ limit := riffLimit(int64(binary.BigEndian.Uint32(header[4:8])), size)
+ offset := int64(12)
+ for offset+8 <= limit {
+ chunkHeader := readRange(ctx, res, offset, 8)
+ if len(chunkHeader) < 8 {
+ break
+ }
+ id := string(chunkHeader[0:4])
+ chunkSize := int64(binary.BigEndian.Uint32(chunkHeader[4:8]))
+ body := offset + 8
+
+ if id == "COMM" {
+ commBody := readRange(ctx, res, body, 18)
+ if len(commBody) >= 18 {
+ numSampleFrames := binary.BigEndian.Uint32(commBody[2:6])
+ sampleRate := decodeExtendedFloat(commBody[8:18])
+ if numSampleFrames > 0 && sampleRate > 0 {
+ return float64(numSampleFrames) / sampleRate
+ }
+ }
+ return 0
+ }
+
+ next := body + chunkSize
+ if chunkSize%2 == 1 {
+ next++
+ }
+ if next <= offset { // guard against overflow or a non-advancing chunk
+ break
+ }
+ offset = next
+ }
+ return 0
+}
+
+// riffLimit returns the smaller of the resource size and the RIFF/FORM-declared
+// payload end (the declared content size plus the 8-byte top-level header),
+// clamping to the resource size when the declaration is absent or too large.
+func riffLimit(declaredSize, size int64) int64 {
+ end := declaredSize + 8
+ if end >= 12 && end < size {
+ return end
+ }
+ return size
+}
+
+// decodeExtendedFloat decodes an 80-bit IEEE 754 extended-precision float, as
+// used for the sample rate in AIFF COMM chunks.
+func decodeExtendedFloat(b []byte) float64 {
+ if len(b) < 10 {
+ return 0
+ }
+ sign := 1.0
+ if b[0]&0x80 != 0 {
+ sign = -1.0
+ }
+ exponent := int(uint16(b[0]&0x7F)<<8 | uint16(b[1]))
+ mantissa := binary.BigEndian.Uint64(b[2:10])
+ if exponent == 0 && mantissa == 0 {
+ return 0
+ }
+ return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
+}
+
+var mp3SampleRates = [4][3]uint32{
+ {11025, 12000, 8000}, // MPEG 2.5
+ {0, 0, 0}, // reserved
+ {22050, 24000, 16000}, // MPEG 2
+ {44100, 48000, 32000}, // MPEG 1
+}
+
+// mp3Bitrates is indexed by [versionBit][layerBit][bitrateIndex] in kbps, where
+// versionBit is 1 for MPEG1 and 0 for MPEG2/2.5, and layerBit is 1/2/3.
+var mp3Bitrates = map[[3]int][16]uint32{
+ {1, 1, 0}: {0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0}, // V1 L1
+ {1, 2, 0}: {0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0}, // V1 L2
+ {1, 3, 0}: {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}, // V1 L3
+ {0, 1, 0}: {0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0}, // V2 L1
+ {0, 2, 0}: {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}, // V2 L2/L3
+}
+
+// probeMP3Duration computes the duration of an MP3 stream. It honours a VBR
+// header (Xing/Info/VBRI) when present, and otherwise assumes constant bitrate.
+func probeMP3Duration(ctx context.Context, res fetcher.Resource, size int64) float64 {
+ start := skipID3v2(ctx, res)
+
+ // Find the first valid frame header within a small window after the tag.
+ window := readRange(ctx, res, start, 8192)
+ frameOff := -1
+ var header []byte
+ for i := 0; i+4 <= len(window); i++ {
+ if window[i] == 0xFF && window[i+1]&0xE0 == 0xE0 {
+ if h := parseMP3Header(window[i : i+4]); h != nil {
+ frameOff = i
+ header = window[i : i+4]
+ break
+ }
+ }
+ }
+ if frameOff < 0 {
+ return 0
+ }
+
+ versionBits := (header[1] >> 3) & 0x03
+ layerBits := (header[1] >> 1) & 0x03
+ sampleRate := mp3SampleRates[versionBits][(header[2]>>2)&0x03]
+ if sampleRate == 0 {
+ return 0
+ }
+ samplesPerFrame := mp3SamplesPerFrame(versionBits, layerBits)
+
+ // Side-information size determines where a VBR header sits in the frame.
+ mpeg1 := versionBits == 3
+ channels := (header[3] >> 6) & 0x03
+ mono := channels == 3
+ var sideInfo int
+ switch {
+ case mpeg1 && mono:
+ sideInfo = 17
+ case mpeg1:
+ sideInfo = 32
+ case mono:
+ sideInfo = 9
+ default:
+ sideInfo = 17
+ }
+
+ frameStart := start + int64(frameOff)
+ frame := readRange(ctx, res, frameStart, 1024)
+ if frame != nil {
+ // Xing/Info header.
+ xingOff := 4 + sideInfo
+ if xingOff+8 <= len(frame) {
+ tag := string(frame[xingOff : xingOff+4])
+ if tag == "Xing" || tag == "Info" {
+ flags := binary.BigEndian.Uint32(frame[xingOff+4 : xingOff+8])
+ if flags&0x01 != 0 && xingOff+12 <= len(frame) {
+ frames := binary.BigEndian.Uint32(frame[xingOff+8 : xingOff+12])
+ if frames > 0 {
+ return float64(frames) * float64(samplesPerFrame) / float64(sampleRate)
+ }
+ }
+ }
+ }
+ // VBRI header, always 32 bytes after the frame header.
+ vbriOff := 4 + 32
+ if vbriOff+18 <= len(frame) && string(frame[vbriOff:vbriOff+4]) == "VBRI" {
+ frames := binary.BigEndian.Uint32(frame[vbriOff+14 : vbriOff+18])
+ if frames > 0 {
+ return float64(frames) * float64(samplesPerFrame) / float64(sampleRate)
+ }
+ }
+ }
+
+ // Constant-bitrate fallback.
+ bitrate := mp3Bitrate(versionBits, layerBits, (header[2]>>4)&0x0F)
+ if bitrate == 0 {
+ return 0
+ }
+ audioBytes := size - frameStart
+ if audioBytes <= 0 {
+ return 0
+ }
+ return float64(audioBytes) * 8 / (float64(bitrate) * 1000)
+}
+
+func parseMP3Header(b []byte) []byte {
+ if len(b) < 4 || b[0] != 0xFF || b[1]&0xE0 != 0xE0 {
+ return nil
+ }
+ version := (b[1] >> 3) & 0x03
+ layer := (b[1] >> 1) & 0x03
+ bitrateIdx := (b[2] >> 4) & 0x0F
+ sampleIdx := (b[2] >> 2) & 0x03
+ if version == 1 || layer == 0 || bitrateIdx == 0 || bitrateIdx == 15 || sampleIdx == 3 {
+ return nil // reserved/invalid combinations
+ }
+ return b
+}
+
+func mp3SamplesPerFrame(versionBits, layerBits uint8) int {
+ switch layerBits {
+ case 3: // Layer I
+ return 384
+ case 2: // Layer II
+ return 1152
+ default: // Layer III
+ if versionBits == 3 { // MPEG1
+ return 1152
+ }
+ return 576
+ }
+}
+
+func mp3Bitrate(versionBits, layerBits, index uint8) uint32 {
+ verKey := 0
+ if versionBits == 3 {
+ verKey = 1
+ }
+ layer := int(4 - layerBits) // layerBits 3->1, 2->2, 1->3
+ key := [3]int{verKey, layer, 0}
+ if verKey == 0 && (layer == 2 || layer == 3) {
+ key = [3]int{0, 2, 0}
+ }
+ table, ok := mp3Bitrates[key]
+ if !ok || int(index) >= len(table) {
+ return 0
+ }
+ return table[index]
+}
+
+// EBML / Matroska element IDs used to locate the WebM duration.
+const (
+ ebmlSegment = 0x18538067
+ ebmlInfo = 0x1549A966
+ ebmlTimecodeScale = 0x2AD7B1
+ ebmlDuration = 0x4489
+)
+
+// probeWebMDuration parses the EBML structure of a WebM/Matroska file to read
+// the Duration element from the Segment Info, scaled by the TimecodeScale.
+func probeWebMDuration(ctx context.Context, res fetcher.Resource, size int64) float64 {
+ // The Segment Info element is normally near the beginning of the file.
+ readLen := int64(1 << 20)
+ if readLen > size {
+ readLen = size
+ }
+ buf := readRange(ctx, res, 0, readLen)
+ if len(buf) < 4 || !bytes.HasPrefix(buf, []byte{0x1A, 0x45, 0xDF, 0xA3}) {
+ return 0
+ }
+
+ timecodeScale := uint64(1000000) // default: 1 ms in nanoseconds
+ var duration float64
+
+ var walk func(b []byte, depth int)
+ walk = func(b []byte, depth int) {
+ pos := 0
+ for pos < len(b) {
+ id, idLen := ebmlReadID(b[pos:])
+ if idLen == 0 {
+ return
+ }
+ pos += idLen
+ length, sizeLen := ebmlReadSize(b[pos:])
+ if sizeLen == 0 {
+ return
+ }
+ pos += sizeLen
+ // Clamp to the available window. This also handles "unknown size"
+ // master elements (a live/streamed Segment), whose declared length
+ // spans the rest of the stream.
+ end := pos + int(length)
+ if length < 0 || end > len(b) {
+ end = len(b)
+ }
+ data := b[pos:end]
+ switch id {
+ case ebmlSegment, ebmlInfo:
+ if depth < 3 {
+ walk(data, depth+1)
+ }
+ case ebmlTimecodeScale:
+ timecodeScale = ebmlReadUint(data)
+ case ebmlDuration:
+ duration = ebmlReadFloat(data)
+ }
+ pos = end
+ }
+ }
+ walk(buf, 0)
+
+ if duration <= 0 {
+ return 0
+ }
+ return duration * float64(timecodeScale) / 1e9
+}
+
+// ebmlReadID reads a variable-length EBML element ID, keeping the length marker.
+func ebmlReadID(b []byte) (uint64, int) {
+ if len(b) == 0 || b[0] == 0 {
+ return 0, 0
+ }
+ length := 1
+ for mask := byte(0x80); mask != 0; mask >>= 1 {
+ if b[0]&mask != 0 {
+ break
+ }
+ length++
+ }
+ if length > 4 || length > len(b) {
+ return 0, 0
+ }
+ var id uint64
+ for i := 0; i < length; i++ {
+ id = id<<8 | uint64(b[i])
+ }
+ return id, length
+}
+
+// ebmlReadSize reads a variable-length EBML data size, stripping the marker bit.
+func ebmlReadSize(b []byte) (int64, int) {
+ if len(b) == 0 || b[0] == 0 {
+ return -1, 0
+ }
+ length := 1
+ mask := byte(0x80)
+ for mask != 0 {
+ if b[0]&mask != 0 {
+ break
+ }
+ length++
+ mask >>= 1
+ }
+ if length > 8 || length > len(b) {
+ return -1, 0
+ }
+ value := uint64(b[0] & (mask - 1))
+ for i := 1; i < length; i++ {
+ value = value<<8 | uint64(b[i])
+ }
+ return int64(value), length
+}
+
+func ebmlReadUint(b []byte) uint64 {
+ var v uint64
+ for _, x := range b {
+ v = v<<8 | uint64(x)
+ }
+ return v
+}
+
+func ebmlReadFloat(b []byte) float64 {
+ switch len(b) {
+ case 4:
+ return float64(math.Float32frombits(binary.BigEndian.Uint32(b)))
+ case 8:
+ return math.Float64frombits(binary.BigEndian.Uint64(b))
+ }
+ return 0
+}
+
+var aacSampleRates = [16]uint32{
+ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050,
+ 16000, 12000, 11025, 8000, 7350, 0, 0, 0,
+}
+
+// aacScanWindow caps how many bytes of a raw AAC stream are read into memory.
+// ADTS has no global header, so for longer streams the duration is extrapolated
+// from the average frame size measured within this window.
+const aacScanWindow = 16 << 20 // 16 MiB
+
+// probeAACDuration counts ADTS frames in a raw AAC stream to compute the
+// duration. Each frame carries 1024 samples per raw data block.
+func probeAACDuration(ctx context.Context, res fetcher.Resource, size int64) float64 {
+ start := skipID3v2(ctx, res)
+ if size <= start {
+ return 0
+ }
+ remaining := size - start
+ readLen := remaining
+ if readLen > aacScanWindow {
+ readLen = aacScanWindow
+ }
+ data := readRange(ctx, res, start, readLen)
+ if len(data) < 7 {
+ return 0
+ }
+
+ var sampleRate uint32
+ var totalSamples uint64
+ pos := 0
+ consumed := 0
+ for pos+7 <= len(data) {
+ if data[pos] != 0xFF || data[pos+1]&0xF0 != 0xF0 {
+ pos++
+ continue
+ }
+ freqIdx := (data[pos+2] >> 2) & 0x0F
+ rate := aacSampleRates[freqIdx]
+ frameLen := int(uint32(data[pos+3]&0x03)<<11 | uint32(data[pos+4])<<3 | uint32(data[pos+5])>>5)
+ if rate == 0 || frameLen < 7 {
+ pos++
+ continue
+ }
+ if pos+frameLen > len(data) {
+ break // frame extends past the read window
+ }
+ if sampleRate == 0 {
+ sampleRate = rate
+ }
+ blocks := uint64(data[pos+6]&0x03) + 1
+ totalSamples += 1024 * blocks
+ pos += frameLen
+ consumed = pos
+ }
+
+ if sampleRate == 0 || totalSamples == 0 || consumed == 0 {
+ return 0
+ }
+ samples := float64(totalSamples)
+ if readLen < remaining {
+ // Extrapolate to the full stream, assuming a roughly uniform frame rate.
+ samples *= float64(remaining) / float64(consumed)
+ }
+ return samples / float64(sampleRate)
+}
+
+func min64(a, b int64) int64 {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/pkg/parser/audio/duration_probe_test.go b/pkg/parser/audio/duration_probe_test.go
new file mode 100644
index 00000000..96b4d6bc
--- /dev/null
+++ b/pkg/parser/audio/duration_probe_test.go
@@ -0,0 +1,149 @@
+package audio
+
+import (
+ "encoding/binary"
+ "math"
+ "testing"
+
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/stretchr/testify/assert"
+)
+
+// bytesResource wraps a byte slice as a fetcher.Resource for probe tests.
+func bytesResource(b []byte) (fetcher.Resource, int64) {
+ link := manifest.Link{Href: manifest.MustNewHREFFromString("probe", false)}
+ return fetcher.NewBytesResource(link, func() []byte { return b }), int64(len(b))
+}
+
+func TestProbeWAVDuration(t *testing.T) {
+ le := binary.LittleEndian
+ var buf []byte
+ put16 := func(v uint16) { buf = le.AppendUint16(buf, v) }
+ put32 := func(v uint32) { buf = le.AppendUint32(buf, v) }
+
+ data := make([]byte, 8000) // 1s of 8000 Hz / mono / 8-bit PCM -> byteRate 8000
+ buf = append(buf, "RIFF"...)
+ put32(uint32(4 + (8 + 16) + (8 + len(data))))
+ buf = append(buf, "WAVE"...)
+ buf = append(buf, "fmt "...)
+ put32(16)
+ put16(1) // PCM
+ put16(1) // channels
+ put32(8000) // sample rate
+ put32(8000) // byte rate
+ put16(1) // block align
+ put16(8) // bits per sample
+ buf = append(buf, "data"...)
+ put32(uint32(len(data)))
+ buf = append(buf, data...)
+
+ res, size := bytesResource(buf)
+ assert.InDelta(t, 1.0, probeWAVDuration(t.Context(), res, size), 0.0001)
+}
+
+// Trailing garbage past the RIFF-declared size must not corrupt the duration.
+func TestProbeWAVIgnoresTrailingGarbage(t *testing.T) {
+ le := binary.LittleEndian
+ var buf []byte
+ put16 := func(v uint16) { buf = le.AppendUint16(buf, v) }
+ put32 := func(v uint32) { buf = le.AppendUint32(buf, v) }
+
+ data := make([]byte, 8000)
+ buf = append(buf, "RIFF"...)
+ put32(uint32(4 + (8 + 16) + (8 + len(data)))) // declared size excludes garbage
+ buf = append(buf, "WAVE"...)
+ buf = append(buf, "fmt "...)
+ put32(16)
+ put16(1)
+ put16(1)
+ put32(8000)
+ put32(8000)
+ put16(1)
+ put16(8)
+ buf = append(buf, "data"...)
+ put32(uint32(len(data)))
+ buf = append(buf, data...)
+
+ // Append a bogus "data" chunk that would corrupt the duration if parsed.
+ buf = append(buf, "data"...)
+ put32(99999)
+
+ res, size := bytesResource(buf)
+ assert.InDelta(t, 1.0, probeWAVDuration(t.Context(), res, size), 0.0001)
+}
+
+func TestProbeAIFFDuration(t *testing.T) {
+ be := binary.BigEndian
+ var buf []byte
+ put16 := func(v uint16) { buf = be.AppendUint16(buf, v) }
+ put32 := func(v uint32) { buf = be.AppendUint32(buf, v) }
+ // 80-bit IEEE extended representation of 8000.0
+ sampleRate := []byte{0x40, 0x0B, 0xFA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
+
+ buf = append(buf, "FORM"...)
+ put32(4 + (8 + 18))
+ buf = append(buf, "AIFF"...)
+ buf = append(buf, "COMM"...)
+ put32(18)
+ put16(1) // channels
+ put32(8000) // sample frames -> 1s at 8000 Hz
+ put16(8) // sample size
+ buf = append(buf, sampleRate...)
+
+ res, size := bytesResource(buf)
+ assert.InDelta(t, 1.0, probeAIFFDuration(t.Context(), res, size), 0.0001)
+}
+
+func TestProbeFLACDuration(t *testing.T) {
+ streamInfo := make([]byte, 34)
+ // Packed fields at byte 10: 8000 Hz sample rate, 8000 total samples.
+ streamInfo[10] = 0x01
+ streamInfo[11] = 0xF4
+ streamInfo[12] = 0x00
+ streamInfo[13] = 0xF0 // bps low nibble + total-samples high nibble (0)
+ streamInfo[14] = 0x00
+ streamInfo[15] = 0x00
+ streamInfo[16] = 0x1F
+ streamInfo[17] = 0x40 // total samples low 32 bits = 0x1F40 = 8000
+
+ var buf []byte
+ buf = append(buf, "fLaC"...)
+ buf = append(buf, 0x80, 0x00, 0x00, 0x22) // last block, type 0 (STREAMINFO), len 34
+ buf = append(buf, streamInfo...)
+
+ res, _ := bytesResource(buf)
+ assert.InDelta(t, 1.0, probeFLACDuration(t.Context(), res), 0.0001)
+}
+
+func TestProbeAACDuration(t *testing.T) {
+ // Two header-only ADTS frames at 44100 Hz (freq index 4), frame length 7.
+ frame := []byte{0xFF, 0xF1, 0x50, 0x80, 0x00, 0xE0, 0x00}
+ buf := append(append([]byte{}, frame...), frame...)
+
+ res, size := bytesResource(buf)
+ expected := 2 * 1024.0 / 44100.0
+ assert.InDelta(t, expected, probeAACDuration(t.Context(), res, size), 0.0001)
+}
+
+func TestProbeWebMDuration(t *testing.T) {
+ durationData := make([]byte, 4)
+ binary.BigEndian.PutUint32(durationData, math.Float32bits(5000.0)) // 5000 * 1ms = 5s
+
+ var info []byte
+ info = append(info, 0x2A, 0xD7, 0xB1, 0x84, 0x00, 0x0F, 0x42, 0x40) // TimecodeScale = 1,000,000
+ info = append(info, 0x44, 0x89, 0x84) // Duration, 4-byte payload
+ info = append(info, durationData...)
+
+ var segment []byte
+ segment = append(segment, 0x15, 0x49, 0xA9, 0x66, byte(0x80|len(info)))
+ segment = append(segment, info...)
+
+ var buf []byte
+ buf = append(buf, 0x1A, 0x45, 0xDF, 0xA3, 0x80) // EBML header, empty
+ buf = append(buf, 0x18, 0x53, 0x80, 0x67, byte(0x80|len(segment))) // Segment
+ buf = append(buf, segment...)
+
+ res, size := bytesResource(buf)
+ assert.InDelta(t, 5.0, probeWebMDuration(t.Context(), res, size), 0.0001)
+}
diff --git a/pkg/parser/audio/formats_test.go b/pkg/parser/audio/formats_test.go
new file mode 100644
index 00000000..0e1fec91
--- /dev/null
+++ b/pkg/parser/audio/formats_test.go
@@ -0,0 +1,179 @@
+package audio
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseM3U(t *testing.T) {
+ content := []byte("#EXTM3U\n" +
+ "#EXTINF:62,Chapter One\r\n" +
+ "track01.opus\n" +
+ "# a stray comment\n" +
+ "#EXTINF:120,Chapter Two\n" +
+ "sub/track02.opus\n")
+ entries := parseM3U(content)
+ require.Len(t, entries, 2)
+ assert.Equal(t, playlistEntry{Path: "track01.opus", Title: "Chapter One"}, entries[0])
+ assert.Equal(t, playlistEntry{Path: "sub/track02.opus", Title: "Chapter Two"}, entries[1])
+}
+
+func TestParsePLS(t *testing.T) {
+ content := []byte("[playlist]\n" +
+ "File1=track01.mp3\nTitle1=Intro\nLength1=30\n" +
+ "File2=track02.mp3\nTitle2=Outro\nLength2=45\n" +
+ "NumberOfEntries=2\n")
+ entries := parsePLS(content)
+ require.Len(t, entries, 2)
+ assert.Equal(t, playlistEntry{Path: "track01.mp3", Title: "Intro"}, entries[0])
+ assert.Equal(t, playlistEntry{Path: "track02.mp3", Title: "Outro"}, entries[1])
+}
+
+func TestParseXSPF(t *testing.T) {
+ content := []byte(`
+
+
+
+
+
+`)
+ entries := parseXSPF(content)
+ require.Len(t, entries, 2)
+ assert.Equal(t, playlistEntry{Path: "track01.flac", Title: "One"}, entries[0])
+ assert.Equal(t, playlistEntry{Path: "track02.flac", Title: "Two"}, entries[1])
+}
+
+// Real-world fixtures: a 13-FILE CUE sheet and the matching M3U playlist.
+func TestParseCUEFixture(t *testing.T) {
+ content, err := os.ReadFile("./testdata/luvsic.cue")
+ require.NoError(t, err)
+ entries := parseCUE(content)
+ require.Len(t, entries, 13, "one entry per FILE/TRACK pair")
+ assert.Equal(t, playlistEntry{Path: "01 - Luv(sic).wav", Title: "Luv(sic)", Start: 0}, entries[0])
+ // Quoted filename containing doubled single-quotes is preserved verbatim.
+ assert.Equal(t, "07 - Luv(sic) 12'' Remix.wav", entries[6].Path)
+ assert.Equal(t, "Luv(sic) 12' Remix", entries[6].Title)
+ assert.Equal(t, playlistEntry{Path: "13 - Perfect Circle.wav", Title: "Perfect Circle", Start: 0}, entries[12])
+}
+
+func TestParseM3UFixture(t *testing.T) {
+ content, err := os.ReadFile("./testdata/luvsic.m3u")
+ require.NoError(t, err)
+ entries := parseM3U(content)
+ require.Len(t, entries, 13)
+ assert.Equal(t, playlistEntry{Path: "01 - Luv(sic).flac", Title: "Nujabes feat. Shing02 - Luv(sic)"}, entries[0])
+ assert.Equal(t, "07 - Luv(sic) 12'' Remix.flac", entries[6].Path)
+ assert.Equal(t, `Nujabes feat. Shing02 - Luv(sic) 12" Remix`, entries[6].Title)
+ assert.Equal(t, "13 - Perfect Circle.flac", entries[12].Path)
+}
+
+func TestParseCUE(t *testing.T) {
+ content := []byte(`REM GENRE Audiobook
+PERFORMER "Robert Lynd"
+TITLE "The Art of Letters"
+FILE "art_letters.flac" WAVE
+ TRACK 01 AUDIO
+ TITLE "Dedication"
+ PERFORMER "Robert Lynd"
+ INDEX 01 00:00:00
+ TRACK 02 AUDIO
+ TITLE "Mr. Pepys"
+ INDEX 00 17:28:00
+ INDEX 01 17:30:00
+`)
+ entries := parseCUE(content)
+ require.Len(t, entries, 2)
+ // Album-level TITLE is ignored; per-track INDEX 01 (not 00) sets the start.
+ assert.Equal(t, playlistEntry{Path: "art_letters.flac", Title: "Dedication", Start: 0}, entries[0])
+ assert.Equal(t, playlistEntry{Path: "art_letters.flac", Title: "Mr. Pepys", Start: 1050}, entries[1])
+}
+
+func TestParseCUEMultipleFiles(t *testing.T) {
+ content := []byte("FILE \"a.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"A\"\n INDEX 01 00:00:00\n" +
+ "FILE \"b.wav\" WAVE\n TRACK 02 AUDIO\n TITLE \"B\"\n INDEX 01 00:00:00\n")
+ entries := parseCUE(content)
+ require.Len(t, entries, 2)
+ assert.Equal(t, "a.wav", entries[0].Path)
+ assert.Equal(t, "b.wav", entries[1].Path)
+}
+
+func TestParseCUETime(t *testing.T) {
+ v, ok := parseCUETime("01:30:37") // 1m 30s 37 frames (75/s)
+ require.True(t, ok)
+ assert.InDelta(t, 90+37.0/75, v, 0.0001)
+
+ _, ok = parseCUETime("bad")
+ assert.False(t, ok)
+}
+
+func TestParsePlaylistUnsupported(t *testing.T) {
+ assert.Nil(t, parsePlaylist("txt", []byte("not a playlist")))
+ assert.Nil(t, parsePlaylist("log", []byte("rip log, not a playlist")))
+}
+
+func TestPlaylistEntryName(t *testing.T) {
+ assert.Equal(t, "track 01.opus", playlistEntryName("audio/track%2001.opus?x=1"))
+ assert.Equal(t, "track.mp3", playlistEntryName("C:\\music\\track.mp3"))
+}
+
+func TestFormatFragmentTime(t *testing.T) {
+ assert.Equal(t, "0", formatFragmentTime(0))
+ assert.Equal(t, "1647.2", formatFragmentTime(1647.2))
+ assert.Equal(t, "62.011", formatFragmentTime(62.0109))
+}
+
+func TestParseTimecode(t *testing.T) {
+ v, ok := parseTimecode("01:02:03.500")
+ require.True(t, ok)
+ assert.InDelta(t, 3723.5, v, 0.001)
+
+ _, ok = parseTimecode("bad")
+ assert.False(t, ok)
+}
+
+func TestVorbisChapters(t *testing.T) {
+ tags := &audioTags{Raw: map[string]interface{}{
+ "CHAPTER000": "00:00:00.000",
+ "CHAPTER000NAME": "Opening",
+ "CHAPTER001": "00:01:30.000",
+ "CHAPTER001NAME": "Middle",
+ "title": "ignored",
+ }}
+ chapters := vorbisChapters(tags)
+ require.Len(t, chapters, 2)
+ assert.Equal(t, chapterEntry{Title: "Opening", Start: 0}, chapters[0])
+ assert.Equal(t, chapterEntry{Title: "Middle", Start: 90}, chapters[1])
+}
+
+func TestDecodeExtendedFloat(t *testing.T) {
+ // 80-bit IEEE extended representation of 44100.0
+ b := []byte{0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0}
+ assert.InDelta(t, 44100.0, decodeExtendedFloat(b), 0.001)
+}
+
+func TestEBMLReadSize(t *testing.T) {
+ // 0x81 -> length 1, value 1
+ v, n := ebmlReadSize([]byte{0x81})
+ assert.Equal(t, 1, n)
+ assert.Equal(t, int64(1), v)
+
+ // 0x4002 -> length 2, value 2
+ v, n = ebmlReadSize([]byte{0x40, 0x02})
+ assert.Equal(t, 2, n)
+ assert.Equal(t, int64(2), v)
+}
+
+func TestEBMLReadID(t *testing.T) {
+ id, n := ebmlReadID([]byte{0x2A, 0xD7, 0xB1})
+ assert.Equal(t, 3, n)
+ assert.Equal(t, uint64(ebmlTimecodeScale), id)
+}
+
+func TestMP3SamplesPerFrame(t *testing.T) {
+ assert.Equal(t, 1152, mp3SamplesPerFrame(3, 1)) // MPEG1 Layer III
+ assert.Equal(t, 576, mp3SamplesPerFrame(2, 1)) // MPEG2 Layer III
+ assert.Equal(t, 384, mp3SamplesPerFrame(3, 3)) // Layer I
+}
diff --git a/pkg/parser/audio/metadata.go b/pkg/parser/audio/metadata.go
new file mode 100644
index 00000000..3345b60d
--- /dev/null
+++ b/pkg/parser/audio/metadata.go
@@ -0,0 +1,111 @@
+package audio
+
+import (
+ "strings"
+
+ "github.com/dhowden/tag"
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/manifest"
+)
+
+// audioTags holds the metadata extracted from a single audio file's tags
+// (ID3, MP4 atoms, Vorbis comments, …).
+type audioTags struct {
+ Title string
+ Album string
+ Artist string
+ AlbumArtist string
+ Composer string
+ Genre string
+ Comment string
+ Year int
+ Picture *tag.Picture
+ Raw map[string]any
+}
+
+// readAudioTags reads the embedded tags of an audio resource. It returns nil
+// when no tags could be parsed (which is not an error: many audio files are
+// untagged).
+func readAudioTags(res fetcher.Resource) *audioTags {
+ m, err := tag.ReadFrom(fetcher.NewResourceReadSeeker(res))
+ if err != nil {
+ return nil
+ }
+ return &audioTags{
+ Title: strings.TrimSpace(m.Title()),
+ Album: strings.TrimSpace(m.Album()),
+ Artist: strings.TrimSpace(m.Artist()),
+ AlbumArtist: strings.TrimSpace(m.AlbumArtist()),
+ Composer: strings.TrimSpace(m.Composer()),
+ Genre: strings.TrimSpace(m.Genre()),
+ Comment: strings.TrimSpace(m.Comment()),
+ Year: m.Year(),
+ Picture: m.Picture(),
+ Raw: m.Raw(),
+ }
+}
+
+// applyTagsToMetadata enriches the publication metadata using the tags of the
+// first audio file. Only fields that are present in the tags are set, so an
+// untagged file leaves the manifest untouched.
+//
+// For audiobooks the album typically holds the book title while the per-track
+// title holds the chapter name, so the album is preferred for the publication
+// title.
+func applyTagsToMetadata(m *manifest.Metadata, t *audioTags) {
+ if t == nil {
+ return
+ }
+
+ if title := firstNonEmpty(t.Album, t.Title); title != "" {
+ m.LocalizedTitle = manifest.NewLocalizedStringFromString(title)
+ if m.Type == "" {
+ m.Type = "http://schema.org/Audiobook"
+ }
+ }
+
+ if author := firstNonEmpty(t.Artist, t.Composer); author != "" {
+ m.Authors = []manifest.Contributor{{
+ LocalizedName: manifest.NewLocalizedStringFromString(author),
+ }}
+ }
+
+ // When the album artist differs from the author, treat it as the narrator,
+ // which is the usual convention for audiobooks.
+ if t.AlbumArtist != "" && t.AlbumArtist != t.Artist {
+ m.Narrators = []manifest.Contributor{{
+ LocalizedName: manifest.NewLocalizedStringFromString(t.AlbumArtist),
+ }}
+ }
+
+ if t.Comment != "" {
+ m.Description = t.Comment
+ } else if d := rawString(t.Raw, "description"); d != "" {
+ m.Description = d
+ }
+
+ if t.Genre != "" {
+ m.Subjects = []manifest.Subject{{
+ LocalizedName: manifest.NewLocalizedStringFromString(t.Genre),
+ }}
+ }
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, v := range values {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func rawString(raw map[string]any, key string) string {
+ if raw == nil {
+ return ""
+ }
+ if v, ok := raw[key].(string); ok {
+ return strings.TrimSpace(v)
+ }
+ return ""
+}
diff --git a/pkg/parser/audio/mp4.go b/pkg/parser/audio/mp4.go
new file mode 100644
index 00000000..d7fc975c
--- /dev/null
+++ b/pkg/parser/audio/mp4.go
@@ -0,0 +1,366 @@
+package audio
+
+import (
+ "context"
+ "encoding/binary"
+ "sort"
+ "strings"
+ "unicode/utf16"
+
+ mp4 "github.com/abema/go-mp4"
+ "github.com/readium/go-toolkit/pkg/fetcher"
+)
+
+// chapterCoalesceGap is the largest gap between two chapter text samples that is
+// still read in a single request. It keeps over-reading small while collapsing a
+// chapter track stored contiguously into one read.
+const chapterCoalesceGap = 64 << 10
+
+// probeMP4 extracts the total duration (in seconds) and any embedded chapters
+// from an ISO-BMFF / MP4 container (m4a, m4b, m4p, mp4, …).
+//
+// The duration is read from the movie header (`mvhd`). Chapters are read from a
+// QuickTime/iTunes chapter track: a track whose media handler is `text`, whose
+// samples are length-prefixed UTF strings located in `mdat`, and whose
+// per-sample timing comes from the `stts` table.
+func probeMP4(ctx context.Context, res fetcher.Resource, extractChapters bool) (duration float64, chapters []chapterEntry, err error) {
+ rs := fetcher.NewResourceReadSeeker(res)
+
+ // Total duration from the movie header.
+ if boxes, e := mp4.ExtractBoxWithPayload(rs, nil, mp4.BoxPath{mp4.BoxTypeMoov(), mp4.BoxTypeMvhd()}); e == nil && len(boxes) > 0 {
+ if mvhd, ok := boxes[0].Payload.(*mp4.Mvhd); ok && mvhd.Timescale > 0 {
+ duration = float64(mvhd.GetDuration()) / float64(mvhd.Timescale)
+ }
+ }
+
+ if extractChapters {
+ chapters = extractMP4Chapters(ctx, res, rs)
+ }
+ return duration, chapters, nil
+}
+
+// extractMP4Chapters returns the chapters of an MP4 file, or nil if it has none.
+//
+// A Nero chapter list (`moov/udta/chpl`) is preferred when present: it lives in
+// the movie header, which has already been fetched, so it costs no extra reads.
+// Otherwise it falls back to a QuickTime text chapter track, whose title samples
+// live in `mdat` and may be scattered throughout the file.
+func extractMP4Chapters(ctx context.Context, res fetcher.Resource, rs *fetcher.ResourceReadSeeker) []chapterEntry {
+ if chapters := extractNeroChapters(ctx, res, rs); len(chapters) > 0 {
+ return chapters
+ }
+
+ traks, err := mp4.ExtractBox(rs, nil, mp4.BoxPath{mp4.BoxTypeMoov(), mp4.BoxTypeTrak()})
+ if err != nil {
+ return nil
+ }
+
+ for _, trak := range traks {
+ // Only consider tracks whose media handler is "text" (chapter tracks).
+ hdlrs, err := mp4.ExtractBoxWithPayload(rs, trak, mp4.BoxPath{mp4.BoxTypeMdia(), mp4.BoxTypeHdlr()})
+ if err != nil || len(hdlrs) == 0 {
+ continue
+ }
+ hdlr, ok := hdlrs[0].Payload.(*mp4.Hdlr)
+ if !ok || string(hdlr.HandlerType[:]) != "text" {
+ continue
+ }
+
+ // Media timescale, used to convert sample durations to seconds.
+ timescale := uint32(0)
+ if mdhds, err := mp4.ExtractBoxWithPayload(rs, trak, mp4.BoxPath{mp4.BoxTypeMdia(), mp4.BoxTypeMdhd()}); err == nil && len(mdhds) > 0 {
+ if mdhd, ok := mdhds[0].Payload.(*mp4.Mdhd); ok {
+ timescale = mdhd.Timescale
+ }
+ }
+ if timescale == 0 {
+ continue
+ }
+
+ stblPath := mp4.BoxPath{mp4.BoxTypeMdia(), mp4.BoxTypeMinf(), mp4.BoxTypeStbl()}
+
+ // Sample durations (stts) -> cumulative start times.
+ var sampleDeltas []uint32
+ if boxes, err := mp4.ExtractBoxWithPayload(rs, trak, append(stblPath, mp4.BoxTypeStts())); err == nil && len(boxes) > 0 {
+ if stts, ok := boxes[0].Payload.(*mp4.Stts); ok {
+ for _, e := range stts.Entries {
+ for i := uint32(0); i < e.SampleCount; i++ {
+ sampleDeltas = append(sampleDeltas, e.SampleDelta)
+ }
+ }
+ }
+ }
+
+ // Sample sizes (stsz).
+ var sampleSizes []uint32
+ if boxes, err := mp4.ExtractBoxWithPayload(rs, trak, append(stblPath, mp4.BoxTypeStsz())); err == nil && len(boxes) > 0 {
+ if stsz, ok := boxes[0].Payload.(*mp4.Stsz); ok {
+ if stsz.SampleSize != 0 {
+ sampleSizes = make([]uint32, stsz.SampleCount)
+ for i := range sampleSizes {
+ sampleSizes[i] = stsz.SampleSize
+ }
+ } else {
+ sampleSizes = stsz.EntrySize
+ }
+ }
+ }
+
+ // Chunk offsets (stco / co64).
+ var chunkOffsets []uint64
+ if boxes, err := mp4.ExtractBoxWithPayload(rs, trak, append(stblPath, mp4.BoxTypeStco())); err == nil && len(boxes) > 0 {
+ if stco, ok := boxes[0].Payload.(*mp4.Stco); ok {
+ for _, o := range stco.ChunkOffset {
+ chunkOffsets = append(chunkOffsets, uint64(o))
+ }
+ }
+ }
+ if len(chunkOffsets) == 0 {
+ if boxes, err := mp4.ExtractBoxWithPayload(rs, trak, append(stblPath, mp4.BoxTypeCo64())); err == nil && len(boxes) > 0 {
+ if co64, ok := boxes[0].Payload.(*mp4.Co64); ok {
+ chunkOffsets = co64.ChunkOffset
+ }
+ }
+ }
+
+ // Samples-to-chunk mapping (stsc).
+ var stscEntries []mp4.StscEntry
+ if boxes, err := mp4.ExtractBoxWithPayload(rs, trak, append(stblPath, mp4.BoxTypeStsc())); err == nil && len(boxes) > 0 {
+ if stsc, ok := boxes[0].Payload.(*mp4.Stsc); ok {
+ stscEntries = stsc.Entries
+ }
+ }
+
+ offsets := sampleOffsets(sampleSizes, chunkOffsets, stscEntries)
+ if len(offsets) == 0 {
+ continue
+ }
+
+ // Per-sample start time, in sample (time) order.
+ starts := make([]float64, len(offsets))
+ var cumulative uint64
+ for i := range offsets {
+ starts[i] = float64(cumulative) / float64(timescale)
+ if i < len(sampleDeltas) {
+ cumulative += uint64(sampleDeltas[i])
+ }
+ }
+
+ titles := readChapterTitles(ctx, res, offsets, sampleSizes)
+
+ chapters := make([]chapterEntry, 0, len(offsets))
+ for i := range offsets {
+ if titles[i] == "" {
+ continue
+ }
+ chapters = append(chapters, chapterEntry{Title: titles[i], Start: starts[i]})
+ }
+ if len(chapters) > 0 {
+ return chapters
+ }
+ }
+ return nil
+}
+
+// sampleOffsets resolves the absolute byte offset of every sample from the
+// sample-size, chunk-offset and sample-to-chunk tables.
+func sampleOffsets(sampleSizes []uint32, chunkOffsets []uint64, stsc []mp4.StscEntry) []uint64 {
+ if len(sampleSizes) == 0 || len(chunkOffsets) == 0 {
+ return nil
+ }
+
+ // Expand stsc into a "samples per chunk" value for each chunk.
+ samplesPerChunk := make([]uint32, len(chunkOffsets))
+ for ci := range chunkOffsets {
+ chunkNum := uint32(ci) + 1
+ spc := uint32(1)
+ for _, e := range stsc {
+ if chunkNum >= e.FirstChunk {
+ spc = e.SamplesPerChunk
+ } else {
+ break
+ }
+ }
+ samplesPerChunk[ci] = spc
+ }
+
+ offsets := make([]uint64, 0, len(sampleSizes))
+ si := 0
+ for ci, chunkOff := range chunkOffsets {
+ off := chunkOff
+ for j := uint32(0); j < samplesPerChunk[ci] && si < len(sampleSizes); j++ {
+ offsets = append(offsets, off)
+ off += uint64(sampleSizes[si])
+ si++
+ }
+ if si >= len(sampleSizes) {
+ break
+ }
+ }
+ return offsets
+}
+
+// readChapterTitles reads and decodes every chapter text sample, returning the
+// titles indexed by sample.
+//
+// Chapter title samples are tiny (a few dozen bytes) but can be scattered across
+// mdat. Reading each one through the block cache would pull a whole 256 KiB block
+// per title, so instead this reuses already-cached blocks where possible and
+// otherwise reads the exact sample bytes from the underlying resource, coalescing
+// neighbouring samples into a single read.
+func readChapterTitles(ctx context.Context, res fetcher.Resource, offsets []uint64, sizes []uint32) []string {
+ titles := make([]string, len(offsets))
+ if len(offsets) == 0 {
+ return titles
+ }
+
+ // Underlying resource for exact reads, plus the cache (if any) to reuse.
+ var cache *readCache
+ raw := res
+ if rc, ok := res.(*readCache); ok {
+ cache = rc
+ raw = rc.Resource
+ }
+
+ // Process samples in offset order so neighbours can be coalesced.
+ order := make([]int, len(offsets))
+ for i := range order {
+ order[i] = i
+ }
+ sort.Slice(order, func(a, b int) bool { return offsets[order[a]] < offsets[order[b]] })
+
+ for i := 0; i < len(order); {
+ runStart := offsets[order[i]]
+ runEnd := runStart + uint64(sizes[order[i]]) // exclusive
+ j := i + 1
+ for j < len(order) {
+ off := offsets[order[j]]
+ if off > runEnd+chapterCoalesceGap {
+ break
+ }
+ if e := off + uint64(sizes[order[j]]); e > runEnd {
+ runEnd = e
+ }
+ j++
+ }
+
+ lo, hi := int64(runStart), int64(runEnd)-1
+ var data []byte
+ if cache != nil {
+ if b, hit := cache.cachedSlice(lo, hi); hit {
+ data = b
+ }
+ }
+ if data == nil {
+ if b, err := raw.Read(ctx, lo, hi); err == nil {
+ data = b
+ }
+ }
+
+ for k := i; k < j; k++ {
+ idx := order[k]
+ start := int64(offsets[idx]) - lo
+ end := start + int64(sizes[idx])
+ if start >= 0 && end <= int64(len(data)) {
+ titles[idx] = decodeChapterTitle(data[start:end])
+ }
+ }
+ i = j
+ }
+ return titles
+}
+
+// decodeChapterTitle decodes a timed-text sample: a 16-bit big-endian length
+// followed by the text payload, which may be UTF-8 or (with a BOM) UTF-16.
+func decodeChapterTitle(sample []byte) string {
+ if len(sample) < 2 {
+ return ""
+ }
+ textLen := int(binary.BigEndian.Uint16(sample[:2]))
+ if textLen <= 0 || 2+textLen > len(sample) {
+ // Be lenient: fall back to whatever bytes follow the length prefix.
+ textLen = len(sample) - 2
+ }
+ return decodeText(sample[2 : 2+textLen])
+}
+
+// extractNeroChapters reads a Nero chapter list (`moov/udta/chpl`) when present.
+// The box lives in the movie header (already fetched), so this costs no extra
+// reads. Returns nil when there is no chpl box.
+func extractNeroChapters(ctx context.Context, res fetcher.Resource, rs *fetcher.ResourceReadSeeker) []chapterEntry {
+ boxes, err := mp4.ExtractBox(rs, nil, mp4.BoxPath{mp4.BoxTypeMoov(), mp4.BoxTypeUdta(), mp4.StrToBoxType("chpl")})
+ if err != nil || len(boxes) == 0 {
+ return nil
+ }
+ info := boxes[0]
+ data, rerr := res.Read(ctx, int64(info.Offset+info.HeaderSize), int64(info.Offset+info.Size)-1)
+ if rerr != nil {
+ return nil
+ }
+ return parseNeroChapters(data)
+}
+
+// parseNeroChapters parses a Nero `chpl` box payload: a FullBox header, an
+// 8-bit chapter count (preceded by a 4-byte reserved field in version 1), then
+// per chapter an 8-byte start time in 100 ns units and a length-prefixed UTF-8
+// title.
+func parseNeroChapters(b []byte) []chapterEntry {
+ if len(b) < 5 {
+ return nil
+ }
+ version := b[0]
+ pos := 4 // version (1) + flags (3)
+ if version != 0 {
+ if len(b) < pos+5 {
+ return nil
+ }
+ pos += 4 // reserved
+ }
+ count := int(b[pos])
+ pos++
+
+ chapters := make([]chapterEntry, 0, count)
+ for c := 0; c < count; c++ {
+ if pos+9 > len(b) {
+ break
+ }
+ start := binary.BigEndian.Uint64(b[pos : pos+8])
+ pos += 8
+ titleLen := int(b[pos])
+ pos++
+ if pos+titleLen > len(b) {
+ break
+ }
+ title := strings.TrimSpace(decodeText(b[pos : pos+titleLen]))
+ pos += titleLen
+ if title != "" {
+ chapters = append(chapters, chapterEntry{Title: title, Start: float64(start) / 1e7})
+ }
+ }
+ return chapters
+}
+
+// decodeText decodes a chapter title, honouring an optional UTF-16 byte-order
+// mark and otherwise assuming UTF-8.
+func decodeText(b []byte) string {
+ if len(b) >= 2 {
+ switch {
+ case b[0] == 0xFE && b[1] == 0xFF:
+ return decodeUTF16(b[2:], binary.BigEndian)
+ case b[0] == 0xFF && b[1] == 0xFE:
+ return decodeUTF16(b[2:], binary.LittleEndian)
+ }
+ }
+ return string(b)
+}
+
+func decodeUTF16(b []byte, order binary.ByteOrder) string {
+ if len(b)%2 != 0 {
+ b = b[:len(b)-1]
+ }
+ u16 := make([]uint16, len(b)/2)
+ for i := range u16 {
+ u16[i] = order.Uint16(b[i*2:])
+ }
+ return string(utf16.Decode(u16))
+}
diff --git a/pkg/parser/audio/ogg.go b/pkg/parser/audio/ogg.go
new file mode 100644
index 00000000..63dad86b
--- /dev/null
+++ b/pkg/parser/audio/ogg.go
@@ -0,0 +1,122 @@
+package audio
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+
+ "github.com/readium/go-toolkit/pkg/fetcher"
+)
+
+const oggCapturePattern = "OggS"
+
+// probeOggDuration computes the duration (in seconds) of an Ogg-encapsulated
+// stream (Opus or Vorbis). It reads the identification header from the start of
+// the stream to determine the sample rate, then the last page from the tail to
+// read the final granule position. This avoids scanning the whole file.
+func probeOggDuration(ctx context.Context, res fetcher.Resource, size int64) float64 {
+ if size <= 0 {
+ return 0
+ }
+
+ headLen := min(int64(8192), size)
+ head, err := res.Read(ctx, 0, headLen-1)
+ if err != nil || len(head) == 0 {
+ return 0
+ }
+
+ rate, preSkip, ok := oggIdentification(head)
+ if !ok || rate == 0 {
+ return 0
+ }
+
+ // Read a window at the end of the file large enough to contain the last page.
+ tailLen := int64(65536)
+ if tailLen > size {
+ tailLen = size
+ }
+ tail, err := res.Read(ctx, size-tailLen, size-1)
+ if err != nil || len(tail) == 0 {
+ return 0
+ }
+
+ granule, ok := lastOggGranule(tail)
+ if !ok {
+ return 0
+ }
+
+ samples := int64(granule) - int64(preSkip)
+ if samples <= 0 {
+ return 0
+ }
+ return float64(samples) / float64(rate)
+}
+
+// oggIdentification parses the first Ogg page and returns the granule sample
+// rate and pre-skip (pre-skip is zero for non-Opus streams).
+//
+// For Opus the granule positions are always expressed at 48 kHz, regardless of
+// the original input sample rate, and the pre-skip must be subtracted. For
+// Vorbis the granule rate is the audio sample rate declared in the header.
+func oggIdentification(head []byte) (rate uint32, preSkip uint16, ok bool) {
+ body := firstPageBody(head)
+ if body == nil {
+ return 0, 0, false
+ }
+
+ switch {
+ case len(body) >= 19 && bytes.HasPrefix(body, []byte("OpusHead")):
+ preSkip = binary.LittleEndian.Uint16(body[10:12])
+ return 48000, preSkip, true
+ case len(body) >= 16 && bytes.HasPrefix(body, []byte("\x01vorbis")):
+ // version(4) channels(1) then sample rate at offset 12.
+ return binary.LittleEndian.Uint32(body[12:16]), 0, true
+ }
+ return 0, 0, false
+}
+
+// firstPageBody returns the body bytes of the first Ogg page in head.
+func firstPageBody(head []byte) []byte {
+ if !bytes.HasPrefix(head, []byte(oggCapturePattern)) || len(head) < 27 {
+ return nil
+ }
+ nseg := int(head[26])
+ if len(head) < 27+nseg {
+ return nil
+ }
+ segTable := head[27 : 27+nseg]
+ bodyLen := 0
+ for _, s := range segTable {
+ bodyLen += int(s)
+ }
+ bodyStart := 27 + nseg
+ if bodyStart+bodyLen > len(head) {
+ bodyLen = len(head) - bodyStart
+ }
+ return head[bodyStart : bodyStart+bodyLen]
+}
+
+// lastOggGranule scans a tail window for the last Ogg page carrying a valid
+// granule position (i.e. not -1, which marks a page where no packet completes).
+func lastOggGranule(tail []byte) (uint64, bool) {
+ pattern := []byte(oggCapturePattern)
+ var granule uint64
+ found := false
+ for i := 0; i+27 <= len(tail); {
+ idx := bytes.Index(tail[i:], pattern)
+ if idx < 0 {
+ break
+ }
+ pos := i + idx
+ if pos+27 > len(tail) {
+ break
+ }
+ g := binary.LittleEndian.Uint64(tail[pos+6 : pos+14])
+ if g != 0xFFFFFFFFFFFFFFFF {
+ granule = g
+ found = true
+ }
+ i = pos + 4
+ }
+ return granule, found
+}
diff --git a/pkg/parser/audio/parser.go b/pkg/parser/audio/parser.go
new file mode 100644
index 00000000..2fb9ccbc
--- /dev/null
+++ b/pkg/parser/audio/parser.go
@@ -0,0 +1,198 @@
+package audio
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/readium/go-toolkit/pkg/asset"
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/internal/extensions"
+ "github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/readium/go-toolkit/pkg/mediatype"
+ "github.com/readium/go-toolkit/pkg/parser"
+ "github.com/readium/go-toolkit/pkg/pub"
+)
+
+// Handles parsing of audiobooks 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.
+type AudioParser struct {
+ rich bool // Whether to attempt extraction of metadata (duration, cover etc.) from the audio files
+
+ // skipEmbeddedChapters disables building the table of contents from chapter
+ // markers embedded in the audio files. Extracting them can be expensive on
+ // remote sources — some files scatter chapter title samples throughout the
+ // stream, costing one range request each — so callers that prioritize fast
+ // opening can turn it off. A playlist or per-file titles are still used.
+ skipEmbeddedChapters bool
+
+ // cacheBlockSize sets the granularity (in bytes) of the per-file read cache
+ // used while probing. Zero means the default. Larger blocks make fewer, bigger
+ // range requests; smaller blocks transfer less for scattered reads.
+ cacheBlockSize int
+
+ // concurrency caps how many audio files are probed in parallel. Zero means
+ // the default. Higher values hide more per-file latency on remote sources at
+ // the cost of more in-flight requests.
+ concurrency int
+}
+
+// Option configures an AudioParser.
+type Option func(*AudioParser)
+
+// WithoutEmbeddedChapters disables extracting the table of contents from chapter
+// markers embedded in the audio files (e.g. an MP4 chapter track or Vorbis
+// CHAPTER comments). This avoids the extra reads they require; the TOC then
+// comes from a playlist or per-file titles when available.
+func WithoutEmbeddedChapters() Option {
+ return func(p *AudioParser) { p.skipEmbeddedChapters = true }
+}
+
+// WithCacheBlockSize sets the block size (in bytes) of the per-file read cache
+// used while probing audio files for rich metadata. The default is 256 KiB. A
+// value <= 0 is ignored and keeps the default. Larger blocks coalesce more reads
+// into each range request (fewer requests, more bytes); smaller blocks transfer
+// less when reads are scattered.
+func WithCacheBlockSize(size int) Option {
+ return func(p *AudioParser) {
+ if size > 0 {
+ p.cacheBlockSize = size
+ }
+ }
+}
+
+// WithConcurrency sets how many audio files are probed in parallel while
+// extracting rich metadata. The default is 8. A value <= 0 is ignored and keeps
+// the default. Use 1 to probe sequentially.
+func WithConcurrency(n int) Option {
+ return func(p *AudioParser) {
+ if n > 0 {
+ p.concurrency = n
+ }
+ }
+}
+
+func NewParser() AudioParser {
+ return AudioParser{}
+}
+
+func NewRichParser(opts ...Option) AudioParser {
+ p := AudioParser{rich: true}
+ for _, opt := range opts {
+ opt(&p)
+ }
+ return p
+}
+
+// Parse implements PublicationParser
+func (p AudioParser) Parse(ctx context.Context, asset asset.PublicationAsset, fetcher fetcher.Fetcher) (*pub.Builder, error) {
+ if !p.accepts(ctx, asset, fetcher) {
+ return nil, nil
+ }
+
+ links, err := fetcher.Links(ctx)
+ if err != nil {
+ return nil, err
+ }
+ readingOrder := make(manifest.LinkList, 0, len(links))
+ for _, link := range links {
+ path := link.URL(nil, nil).Path()
+
+ // Filter out all irrelevant files
+ fext := filepath.Ext(strings.ToLower(path))
+ if len(fext) > 1 {
+ fext = fext[1:] // Remove "." from extension
+ }
+ _, contains := allowed_extensions_audio[fext]
+ if extensions.IsHiddenOrThumbs(path) || !contains {
+ continue
+ }
+ readingOrder = append(readingOrder, link)
+ }
+
+ if len(readingOrder) == 0 {
+ return nil, errors.New("no audio file found in the publication")
+ }
+
+ // Sort in alphabetical order
+ sort.Slice(readingOrder, func(i, j int) bool {
+ return readingOrder[i].Href.String() < readingOrder[j].Href.String()
+ })
+
+ // Try to figure out the publication's title
+ title := parser.GuessPublicationTitleFromFileStructure(ctx, fetcher)
+ if title == "" {
+ title = asset.Name()
+ }
+
+ man := manifest.Manifest{
+ Context: manifest.Strings{manifest.WebpubManifestContext},
+ Metadata: manifest.Metadata{
+ Type: "http://schema.org/Audiobook",
+ LocalizedTitle: manifest.NewLocalizedStringFromString(title),
+ ConformsTo: manifest.Profiles{manifest.ProfileAudiobook},
+ },
+ ReadingOrder: readingOrder,
+ }
+
+ serviceFactories := map[pub.ServiceName]pub.ServiceFactory{}
+
+ // When rich parsing is enabled, probe the audio files for durations,
+ // bitrates, embedded metadata, a cover and a table of contents.
+ if p.rich {
+ if coverFactory := p.enrich(ctx, fetcher, &man); coverFactory != nil {
+ serviceFactories[pub.CoverService_Name] = coverFactory
+ }
+ }
+
+ var builder *pub.ServicesBuilder
+ if len(serviceFactories) > 0 {
+ builder = pub.NewServicesBuilder(serviceFactories)
+ }
+ return pub.NewBuilder(man, fetcher, builder), nil
+}
+
+var allowed_extensions_audio_extra = map[string]struct{}{
+ "asx": {}, "bio": {}, "m3u": {}, "m3u8": {}, "pla": {}, "pls": {},
+ "smil": {}, "txt": {}, "vlc": {}, "wpl": {}, "xspf": {}, "zpl": {},
+ "cue": {}, "log": {},
+}
+var allowed_extensions_audio = map[string]struct{}{
+ "aac": {}, "aiff": {}, "aif": {}, "aifc": {}, "alac": {}, "flac": {},
+ "m4a": {}, "m4b": {}, "mp3": {}, "mp4": {}, "m4r": {}, "m4p": {},
+ "ogg": {}, "oga": {}, "mogg": {}, "opus": {}, "wav": {}, "wave": {},
+ "webm": {},
+}
+
+func (p AudioParser) accepts(ctx context.Context, asset asset.PublicationAsset, fetcher fetcher.Fetcher) bool {
+ if asset.MediaType(ctx).Equal(&mediatype.ZAB) {
+ return true
+ }
+ links, err := fetcher.Links(ctx)
+ if err != nil {
+ // TODO log
+ return false
+ }
+ for _, link := range links {
+ path := link.URL(nil, nil).Path()
+
+ if extensions.IsHiddenOrThumbs(path) {
+ continue
+ }
+ if link.MediaType.IsBitmap() {
+ continue
+ }
+ fext := filepath.Ext(strings.ToLower(path))
+ if len(fext) > 1 {
+ fext = fext[1:] // Remove "." from extension
+ }
+ _, contains1 := allowed_extensions_audio[fext]
+ _, contains2 := allowed_extensions_audio_extra[fext]
+ if !contains1 && !contains2 {
+ return false
+ }
+ }
+ return true
+}
diff --git a/pkg/parser/audio/parser_test.go b/pkg/parser/audio/parser_test.go
new file mode 100644
index 00000000..e8b9e2c4
--- /dev/null
+++ b/pkg/parser/audio/parser_test.go
@@ -0,0 +1,414 @@
+package audio
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/readium/go-toolkit/pkg/archive"
+ "github.com/readium/go-toolkit/pkg/asset"
+ "github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/readium/go-toolkit/pkg/pub"
+ "github.com/readium/go-toolkit/pkg/util/url"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// A single-file M4B audiobook with embedded metadata, a cover and a chapter
+// (text) track providing 26 chapters.
+const m4bPath = "./testdata/AroundTheWorldInEightyDays.m4b"
+
+const (
+ // A ZAB (Zipped Audio Book): a ZIP whose entries all live under a single
+ // "Art of Letters/" root directory containing three .opus tracks.
+ zabPath = "./testdata/art_letters.zab"
+ // The same audiobook exploded into a directory of .opus tracks, with no
+ // enclosing root folder.
+ explodedDirPath = "./testdata/art_letters"
+ // A single track, used to exercise the standalone-audio-file path.
+ standaloneFilePath = "./testdata/art_letters/artofletters_00_lynd.opus"
+)
+
+// parseAudio builds a fetcher for the asset at path and runs the AudioParser,
+// returning its raw result so that rejection and error paths can be asserted.
+func parseAudio(t *testing.T, path string) (*pub.Builder, error) {
+ t.Helper()
+ u, err := url.FromFilepath(path)
+ require.NoError(t, err)
+ a := asset.File(u)
+ fet, err := a.CreateFetcher(t.Context(), asset.Dependencies{
+ ArchiveFactory: archive.NewArchiveFactory(),
+ }, "")
+ require.NoError(t, err)
+ t.Cleanup(fet.Close)
+ return AudioParser{}.Parse(t.Context(), a, fet)
+}
+
+// withAudioParser parses the asset at path with the AudioParser and passes the
+// resulting builder to f, failing the test if the asset is rejected or errors.
+func withAudioParser(t *testing.T, path string, f func(*pub.Builder)) {
+ t.Helper()
+ b, err := parseAudio(t, path)
+ require.NoError(t, err)
+ require.NotNil(t, b, "parser unexpectedly rejected the asset")
+ f(b)
+}
+
+// readingOrderFilenames returns the decoded final path segment of every reading
+// order link. This is robust to whether the href carries an archive or
+// directory prefix.
+func readingOrderFilenames(ro manifest.LinkList) []string {
+ names := make([]string, 0, len(ro))
+ for _, link := range ro {
+ names = append(names, link.URL(nil, nil).Filename())
+ }
+ return names
+}
+
+func TestNewParser(t *testing.T) {
+ assert.Equal(t, AudioParser{}, NewParser())
+}
+
+func TestNewRichParser(t *testing.T) {
+ assert.Equal(t, AudioParser{rich: true}, NewRichParser())
+ assert.Equal(t, AudioParser{rich: true, skipEmbeddedChapters: true}, NewRichParser(WithoutEmbeddedChapters()))
+ assert.Equal(t, AudioParser{rich: true, cacheBlockSize: 512 << 10}, NewRichParser(WithCacheBlockSize(512<<10)))
+ assert.Equal(t, AudioParser{rich: true}, NewRichParser(WithCacheBlockSize(0)), "non-positive cache size is ignored")
+ assert.Equal(t, AudioParser{rich: true, concurrency: 4}, NewRichParser(WithConcurrency(4)))
+ assert.Equal(t, AudioParser{rich: true}, NewRichParser(WithConcurrency(0)), "non-positive concurrency is ignored")
+}
+
+// Custom block size and sequential concurrency still produce a correct manifest.
+func TestAudioRichWithOptions(t *testing.T) {
+ u, err := url.FromFilepath(zabPath)
+ require.NoError(t, err)
+ a := asset.File(u)
+ fet, err := a.CreateFetcher(t.Context(), asset.Dependencies{ArchiveFactory: archive.NewArchiveFactory()}, "")
+ require.NoError(t, err)
+ t.Cleanup(fet.Close)
+
+ b, err := NewRichParser(WithConcurrency(1), WithCacheBlockSize(64<<10)).Parse(t.Context(), a, fet)
+ require.NoError(t, err)
+ require.NotNil(t, b)
+ m := b.Build().Manifest
+ require.Len(t, m.ReadingOrder, 3)
+ for _, l := range m.ReadingOrder {
+ assert.Greater(t, l.Duration, 0.0)
+ assert.Greater(t, l.Bitrate, 0.0)
+ }
+ assert.Equal(t, "The Art of Letters", m.Metadata.Title())
+}
+
+// WithoutEmbeddedChapters skips building the TOC from the M4B chapter track,
+// while still extracting durations and metadata.
+func TestAudioRichWithoutEmbeddedChapters(t *testing.T) {
+ if _, err := os.Stat(m4bPath); err != nil {
+ t.Skipf("M4B fixture not available: %v", err)
+ }
+ m := parseRichAudioWith(t, m4bPath, WithoutEmbeddedChapters()).Build().Manifest
+
+ require.Len(t, m.ReadingOrder, 1)
+ assert.Greater(t, m.ReadingOrder[0].Duration, 0.0, "duration is still probed")
+ assert.Equal(t, "Around the World in Eighty Days", m.Metadata.Title(), "metadata is still extracted")
+ assert.Empty(t, m.TableOfContents, "no TOC is built from embedded chapters when disabled")
+}
+
+// parseRichAudio runs the rich AudioParser against the asset at path.
+func parseRichAudio(t *testing.T, path string) *pub.Builder {
+ return parseRichAudioWith(t, path)
+}
+
+// parseRichAudioWith runs the rich AudioParser with the given options.
+func parseRichAudioWith(t *testing.T, path string, opts ...Option) *pub.Builder {
+ t.Helper()
+ u, err := url.FromFilepath(path)
+ require.NoError(t, err)
+ a := asset.File(u)
+ fet, err := a.CreateFetcher(t.Context(), asset.Dependencies{
+ ArchiveFactory: archive.NewArchiveFactory(),
+ }, "")
+ require.NoError(t, err)
+ t.Cleanup(fet.Close)
+ b, err := NewRichParser(opts...).Parse(t.Context(), a, fet)
+ require.NoError(t, err)
+ require.NotNil(t, b, "rich parser unexpectedly rejected the asset")
+ return b
+}
+
+func tocTitles(toc manifest.LinkList) []string {
+ titles := make([]string, 0, len(toc))
+ for _, l := range toc {
+ titles = append(titles, l.Title)
+ }
+ return titles
+}
+
+// Rich parsing of an Opus ZAB sets per-track durations/bitrates, the total
+// duration, and pulls the title/author from the first file's Vorbis comments.
+func TestAudioRichOpusZAB(t *testing.T) {
+ m := parseRichAudio(t, zabPath).Build().Manifest
+ require.Len(t, m.ReadingOrder, 3)
+
+ var sum float64
+ for _, l := range m.ReadingOrder {
+ assert.Greater(t, l.Duration, 0.0, "every track must have a duration")
+ assert.Greater(t, l.Bitrate, 0.0, "every track must have a bitrate")
+ assert.Empty(t, l.URL(nil, nil).Fragment(), "reading order must not carry fragments")
+ sum += l.Duration
+ }
+
+ // First track is ~62s ("00 - Dedication").
+ assert.InDelta(t, 62.0, m.ReadingOrder[0].Duration, 1.0)
+
+ require.NotNil(t, m.Metadata.Duration)
+ assert.InDelta(t, sum, *m.Metadata.Duration, 0.01, "metadata duration is the sum of the tracks")
+
+ // Title comes from the album tag, author from the artist tag.
+ assert.Equal(t, "The Art of Letters", m.Metadata.Title())
+ require.Len(t, m.Metadata.Authors, 1)
+ assert.Equal(t, "Robert Lynd", m.Metadata.Authors[0].Name())
+ assert.Equal(t, "http://schema.org/Audiobook", m.Metadata.Type)
+
+ // No playlist or embedded chapters, so the TOC lists the per-file titles.
+ require.Len(t, m.TableOfContents, 3)
+ assert.Equal(t, "00 - Dedication", m.TableOfContents[0].Title)
+}
+
+// Rich parsing of an M4B reads the movie duration, the iTunes metadata + cover,
+// and the chapter track to build the table of contents.
+func TestAudioRichM4B(t *testing.T) {
+ if _, err := os.Stat(m4bPath); err != nil {
+ t.Skipf("M4B fixture not available: %v", err)
+ }
+
+ b := parseRichAudio(t, m4bPath)
+ p := b.Build()
+ m := p.Manifest
+
+ require.Len(t, m.ReadingOrder, 1)
+ assert.InDelta(t, 23610.91, m.ReadingOrder[0].Duration, 2.0)
+ assert.Greater(t, m.ReadingOrder[0].Bitrate, 0.0)
+ require.NotNil(t, m.Metadata.Duration)
+ assert.InDelta(t, 23610.91, *m.Metadata.Duration, 2.0)
+
+ assert.Equal(t, "Around the World in Eighty Days", m.Metadata.Title())
+ require.Len(t, m.Metadata.Authors, 1)
+ assert.Equal(t, "Jules Verne", m.Metadata.Authors[0].Name())
+
+ // Cover extracted from the `covr` atom and exposed via the cover service.
+ cover := m.Links.FirstWithRel("cover")
+ require.NotNil(t, cover, "a cover link should be present")
+ assert.Greater(t, cover.Width, uint(0))
+ assert.Greater(t, cover.Height, uint(0))
+ data, rerr := p.Get(t.Context(), *cover).Read(t.Context(), 0, 0)
+ require.Nil(t, rerr)
+ assert.NotEmpty(t, data, "the cover resource should serve bytes")
+
+ // 26 chapters from the text track; the first starts at the very beginning.
+ require.Len(t, m.TableOfContents, 26)
+ assert.Equal(t, "01 - Chapters 01 - 03", m.TableOfContents[0].Title)
+ assert.Empty(t, m.TableOfContents[0].URL(nil, nil).Fragment(), "first chapter starts at 0")
+ assert.NotEmpty(t, m.TableOfContents[1].URL(nil, nil).Fragment(), "later chapters carry a time fragment")
+ assert.True(t, strings.HasPrefix(m.TableOfContents[1].URL(nil, nil).Fragment(), "t="),
+ "chapter fragments use media-fragment time syntax")
+}
+
+// A playlist file takes precedence over both embedded chapters and the per-file
+// fallback when building the table of contents.
+func TestAudioRichPlaylistTOCPreferred(t *testing.T) {
+ dir := t.TempDir()
+ for _, name := range []string{
+ "artofletters_00_lynd.opus",
+ "artofletters_01_lynd.opus",
+ "artofletters_02_lynd.opus",
+ } {
+ src, err := os.ReadFile(filepath.Join(explodedDirPath, name))
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, name), src, 0o644))
+ }
+ playlist := strings.Join([]string{
+ "#EXTM3U",
+ "#EXTINF:62,Chapter One",
+ "artofletters_00_lynd.opus",
+ "#EXTINF:1050,Chapter Two",
+ "artofletters_01_lynd.opus",
+ "#EXTINF:789,Chapter Three",
+ "artofletters_02_lynd.opus",
+ "",
+ }, "\n")
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "playlist.m3u"), []byte(playlist), 0o644))
+
+ m := parseRichAudio(t, dir).Build().Manifest
+ require.Len(t, m.ReadingOrder, 3, "playlist file is not part of the reading order")
+ require.Len(t, m.TableOfContents, 3)
+ assert.Equal(t, []string{"Chapter One", "Chapter Two", "Chapter Three"}, tocTitles(m.TableOfContents),
+ "the TOC should come from the playlist, not the per-file titles")
+}
+
+// A CUE sheet splits a single audio file into tracks, producing a TOC with
+// media-fragment time offsets.
+func TestAudioRichCUETOC(t *testing.T) {
+ dir := t.TempDir()
+ src, err := os.ReadFile(filepath.Join(explodedDirPath, "artofletters_01_lynd.opus"))
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "book.opus"), src, 0o644))
+
+ cue := strings.Join([]string{
+ `TITLE "The Art of Letters"`,
+ `PERFORMER "Robert Lynd"`,
+ `FILE "book.opus" WAVE`,
+ ` TRACK 01 AUDIO`,
+ ` TITLE "Opening"`,
+ ` INDEX 01 00:00:00`,
+ ` TRACK 02 AUDIO`,
+ ` TITLE "Halfway"`,
+ ` INDEX 01 05:00:00`,
+ "",
+ }, "\n")
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "book.cue"), []byte(cue), 0o644))
+
+ m := parseRichAudio(t, dir).Build().Manifest
+ require.Len(t, m.ReadingOrder, 1)
+ require.Len(t, m.TableOfContents, 2)
+ assert.Equal(t, []string{"Opening", "Halfway"}, tocTitles(m.TableOfContents))
+ assert.Empty(t, m.TableOfContents[0].URL(nil, nil).Fragment(), "first track starts at 0")
+ assert.Equal(t, "t=300", m.TableOfContents[1].URL(nil, nil).Fragment(),
+ "the second track points into the file at its INDEX time")
+}
+
+func TestAudioZABAccepted(t *testing.T) {
+ withAudioParser(t, zabPath, func(b *pub.Builder) {
+ assert.NotNil(t, b)
+ })
+}
+
+func TestAudioExplodedDirectoryAccepted(t *testing.T) {
+ withAudioParser(t, explodedDirPath, func(b *pub.Builder) {
+ assert.NotNil(t, b)
+ })
+}
+
+func TestAudioStandaloneFileAccepted(t *testing.T) {
+ withAudioParser(t, standaloneFilePath, func(b *pub.Builder) {
+ assert.NotNil(t, b)
+ })
+}
+
+func TestAudioConformsToAudiobookProfile(t *testing.T) {
+ withAudioParser(t, zabPath, func(b *pub.Builder) {
+ m := b.Build().Manifest
+ assert.Equal(t, manifest.Profiles{manifest.ProfileAudiobook}, m.Metadata.ConformsTo)
+ })
+}
+
+func TestAudioManifestContext(t *testing.T) {
+ withAudioParser(t, zabPath, func(b *pub.Builder) {
+ m := b.Build().Manifest
+ assert.Equal(t, manifest.Strings{manifest.WebpubManifestContext}, m.Context)
+ })
+}
+
+func TestAudioZABReadingOrderSortedAlphabetically(t *testing.T) {
+ withAudioParser(t, zabPath, func(b *pub.Builder) {
+ ro := b.Build().Manifest.ReadingOrder
+ require.Len(t, ro, 3, "every audio track should be in the reading order")
+
+ // Relativize against the archive root to assert the exact, ordered hrefs.
+ base, _ := url.URLFromDecodedPath("Art of Letters/")
+ hrefs := make([]string, 0, len(ro))
+ for _, link := range ro {
+ hrefs = append(hrefs, base.Relativize(link.URL(nil, nil)).String())
+ }
+ assert.Exactly(t, []string{
+ "artofletters_00_lynd.opus",
+ "artofletters_01_lynd.opus",
+ "artofletters_02_lynd.opus",
+ }, hrefs, "reading order should be sorted alphabetically")
+ })
+}
+
+func TestAudioExplodedDirectoryReadingOrder(t *testing.T) {
+ withAudioParser(t, explodedDirPath, func(b *pub.Builder) {
+ ro := b.Build().Manifest.ReadingOrder
+ require.Len(t, ro, 3)
+ assert.Exactly(t, []string{
+ "artofletters_00_lynd.opus",
+ "artofletters_01_lynd.opus",
+ "artofletters_02_lynd.opus",
+ }, readingOrderFilenames(ro), "reading order should be sorted alphabetically")
+ })
+}
+
+// Hrefs for files in an exploded directory must be relative (no leading "/").
+func TestAudioExplodedDirectoryRelativeHrefs(t *testing.T) {
+ m := parseRichAudio(t, explodedDirPath).Build().Manifest
+ require.Len(t, m.ReadingOrder, 3)
+ for _, l := range m.ReadingOrder {
+ assert.False(t, strings.HasPrefix(l.Href.String(), "/"),
+ "reading order href %q should not be absolute", l.Href.String())
+ }
+ require.NotEmpty(t, m.TableOfContents)
+ for _, l := range m.TableOfContents {
+ assert.False(t, strings.HasPrefix(l.Href.String(), "/"),
+ "toc href %q should not be absolute", l.Href.String())
+ }
+ assert.Equal(t, "artofletters_00_lynd.opus", m.ReadingOrder[0].Href.String())
+}
+
+func TestAudioStandaloneFile(t *testing.T) {
+ withAudioParser(t, standaloneFilePath, func(b *pub.Builder) {
+ m := b.Build().Manifest
+ require.Len(t, m.ReadingOrder, 1)
+ assert.Equal(t, "artofletters_00_lynd.opus", m.ReadingOrder[0].URL(nil, nil).Filename())
+ assert.Equal(t, manifest.Profiles{manifest.ProfileAudiobook}, m.Metadata.ConformsTo)
+ // No archive or parent folder to infer a title from, so it falls back to
+ // the asset's filename.
+ assert.Equal(t, "artofletters_00_lynd.opus", m.Metadata.Title())
+ })
+}
+
+func TestAudioTitleFromArchiveRootDirectory(t *testing.T) {
+ withAudioParser(t, zabPath, func(b *pub.Builder) {
+ assert.Equal(t, "Art of Letters", b.Build().Manifest.Metadata.Title(),
+ "title should be inferred from the archive's common root directory")
+ })
+}
+
+func TestAudioTitleFallsBackToFilename(t *testing.T) {
+ withAudioParser(t, explodedDirPath, func(b *pub.Builder) {
+ // The tracks share no common parent folder inside the fetcher, so the
+ // title falls back to the asset's name (the directory name).
+ assert.Equal(t, "art_letters", b.Build().Manifest.Metadata.Title())
+ })
+}
+
+func TestAudioReadingOrderHasNoCover(t *testing.T) {
+ withAudioParser(t, zabPath, func(b *pub.Builder) {
+ // Unlike the image parser, the audio parser does not promote the first
+ // resource to a cover.
+ assert.Nil(t, b.Build().Manifest.ReadingOrder.FirstWithRel("cover"))
+ })
+}
+
+func TestAudioRejectsNonAudioArchive(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "book.pdf"), []byte("%PDF-1.7\n"), 0o644))
+
+ b, err := parseAudio(t, dir)
+ assert.NoError(t, err)
+ assert.Nil(t, b, "an asset containing non-audio files should be rejected")
+}
+
+func TestAudioErrorsWhenNoAudioFile(t *testing.T) {
+ dir := t.TempDir()
+ // ".txt" is an accepted "extra" extension (e.g. liner notes) but is not an
+ // actual audio track, so the parser accepts the asset yet finds nothing to
+ // add to the reading order.
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "liner-notes.txt"), []byte("Robert Lynd"), 0o644))
+
+ b, err := parseAudio(t, dir)
+ assert.Nil(t, b)
+ require.Error(t, err)
+ assert.EqualError(t, err, "no audio file found in the publication")
+}
diff --git a/pkg/parser/audio/playlist.go b/pkg/parser/audio/playlist.go
new file mode 100644
index 00000000..93534771
--- /dev/null
+++ b/pkg/parser/audio/playlist.go
@@ -0,0 +1,230 @@
+package audio
+
+import (
+ "encoding/xml"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// playlistEntry is a single reference in a playlist file: a resource path, an
+// optional human-readable title and an optional start offset within the
+// resource (in seconds), used by formats such as CUE sheets that split a single
+// audio file into tracks.
+type playlistEntry struct {
+ Path string
+ Title string
+ Start float64
+}
+
+// parsePlaylist parses the supported playlist formats (M3U/M3U8, PLS, XSPF, CUE)
+// into an ordered list of entries. It returns nil for formats it cannot parse,
+// in which case callers fall back to other table-of-contents sources.
+func parsePlaylist(ext string, content []byte) []playlistEntry {
+ switch strings.ToLower(ext) {
+ case "m3u", "m3u8":
+ return parseM3U(content)
+ case "pls":
+ return parsePLS(content)
+ case "xspf":
+ return parseXSPF(content)
+ case "cue":
+ return parseCUE(content)
+ default:
+ return nil
+ }
+}
+
+var extinfRegexp = regexp.MustCompile(`(?i)^#EXTINF:[^,]*,(.*)$`)
+
+// parseM3U parses an (extended) M3U playlist. #EXTINF lines provide the title of
+// the following resource path.
+func parseM3U(content []byte) []playlistEntry {
+ var entries []playlistEntry
+ var pendingTitle string
+ for raw := range strings.SplitSeq(string(content), "\n") {
+ line := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))
+ if line == "" {
+ continue
+ }
+ if strings.HasPrefix(line, "#") {
+ if m := extinfRegexp.FindStringSubmatch(line); m != nil {
+ pendingTitle = strings.TrimSpace(m[1])
+ }
+ continue
+ }
+ entries = append(entries, playlistEntry{Path: line, Title: pendingTitle})
+ pendingTitle = ""
+ }
+ return entries
+}
+
+// parsePLS parses a PLS playlist (an INI-like format with FileN/TitleN keys).
+func parsePLS(content []byte) []playlistEntry {
+ files := map[int]string{}
+ titles := map[int]string{}
+ maxIndex := 0
+ for raw := range strings.SplitSeq(string(content), "\n") {
+ line := strings.TrimSpace(raw)
+ before, after, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ key := strings.ToLower(strings.TrimSpace(before))
+ value := strings.TrimSpace(after)
+ switch {
+ case strings.HasPrefix(key, "file"):
+ if n, err := strconv.Atoi(key[4:]); err == nil {
+ files[n] = value
+ if n > maxIndex {
+ maxIndex = n
+ }
+ }
+ case strings.HasPrefix(key, "title"):
+ if n, err := strconv.Atoi(key[5:]); err == nil {
+ titles[n] = value
+ }
+ }
+ }
+
+ var entries []playlistEntry
+ for i := 1; i <= maxIndex; i++ {
+ path, ok := files[i]
+ if !ok {
+ continue
+ }
+ entries = append(entries, playlistEntry{Path: path, Title: titles[i]})
+ }
+ return entries
+}
+
+// parseXSPF parses an XSPF (XML Shareable Playlist Format) playlist.
+func parseXSPF(content []byte) []playlistEntry {
+ var doc struct {
+ Tracks []struct {
+ Location string `xml:"location"`
+ Title string `xml:"title"`
+ } `xml:"trackList>track"`
+ }
+ if err := xml.Unmarshal(content, &doc); err != nil {
+ return nil
+ }
+ var entries []playlistEntry
+ for _, t := range doc.Tracks {
+ location := strings.TrimSpace(t.Location)
+ if location == "" {
+ continue
+ }
+ entries = append(entries, playlistEntry{Path: location, Title: strings.TrimSpace(t.Title)})
+ }
+ return entries
+}
+
+// parseCUE parses a CUE sheet, which splits one or more audio files into tracks
+// marked by INDEX timestamps. Each TRACK becomes an entry pointing at the
+// preceding FILE, with its start offset taken from INDEX 01 (or INDEX 00 as a
+// fallback).
+func parseCUE(content []byte) []playlistEntry {
+ var entries []playlistEntry
+ var currentFile string
+
+ var (
+ inTrack bool
+ title string
+ start float64
+ hasStart bool
+ )
+ flush := func() {
+ if inTrack && currentFile != "" {
+ entries = append(entries, playlistEntry{Path: currentFile, Title: title, Start: start})
+ }
+ inTrack, title, start, hasStart = false, "", 0, false
+ }
+
+ for raw := range strings.SplitSeq(string(content), "\n") {
+ line := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))
+ if line == "" {
+ continue
+ }
+ keyword, rest, _ := strings.Cut(line, " ")
+ switch strings.ToUpper(keyword) {
+ case "FILE":
+ flush() // a pending track belongs to the previous FILE
+ currentFile = cueValue(rest, true)
+ case "TRACK":
+ flush()
+ inTrack = true
+ case "TITLE":
+ if inTrack {
+ title = cueValue(rest, false)
+ }
+ case "INDEX":
+ if !inTrack {
+ continue
+ }
+ // INDEX 01 is the canonical track start; INDEX 00 (pregap) is only a
+ // fallback. Index lines are ordered 00 before 01, so 01 wins.
+ if num, t, ok := cueIndex(rest); ok {
+ if num == 1 {
+ start, hasStart = t, true
+ } else if num == 0 && !hasStart {
+ start = t
+ }
+ }
+ }
+ }
+ flush()
+ return entries
+}
+
+// cueValue extracts a CUE field value: the text between double quotes when
+// quoted, otherwise either the first whitespace-delimited token (firstToken) or
+// the whole remainder.
+func cueValue(s string, firstToken bool) string {
+ s = strings.TrimSpace(s)
+ if len(s) >= 2 && s[0] == '"' {
+ if end := strings.IndexByte(s[1:], '"'); end >= 0 {
+ return s[1 : 1+end]
+ }
+ }
+ if firstToken {
+ if i := strings.IndexByte(s, ' '); i >= 0 {
+ return s[:i]
+ }
+ }
+ return s
+}
+
+// cueIndex parses an INDEX line body ("NN MM:SS:FF") into its index number and
+// time offset in seconds.
+func cueIndex(rest string) (int, float64, bool) {
+ fields := strings.Fields(rest)
+ if len(fields) < 2 {
+ return 0, 0, false
+ }
+ num, err := strconv.Atoi(fields[0])
+ if err != nil {
+ return 0, 0, false
+ }
+ t, ok := parseCUETime(fields[1])
+ if !ok {
+ return 0, 0, false
+ }
+ return num, t, true
+}
+
+// parseCUETime parses a CUE timecode "MM:SS:FF" (FF = frames, 75 per second)
+// into seconds.
+func parseCUETime(s string) (float64, bool) {
+ parts := strings.Split(s, ":")
+ if len(parts) != 3 {
+ return 0, false
+ }
+ mm, e1 := strconv.Atoi(parts[0])
+ ss, e2 := strconv.Atoi(parts[1])
+ ff, e3 := strconv.Atoi(parts[2])
+ if e1 != nil || e2 != nil || e3 != nil {
+ return 0, false
+ }
+ return float64(mm)*60 + float64(ss) + float64(ff)/75, true
+}
diff --git a/pkg/parser/audio/probe.go b/pkg/parser/audio/probe.go
new file mode 100644
index 00000000..dd9119f8
--- /dev/null
+++ b/pkg/parser/audio/probe.go
@@ -0,0 +1,120 @@
+package audio
+
+import (
+ "context"
+ "math"
+ "path/filepath"
+ "strings"
+
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/manifest"
+)
+
+// probeResult holds the per-resource information extracted while probing an
+// audio file.
+type probeResult struct {
+ Duration float64 // Length of the resource in seconds (0 if unknown).
+ Bitrate float64 // Average bitrate in kbps (0 if unknown).
+ Chapters []chapterEntry // Embedded chapters, if any.
+}
+
+// probeAudioFile extracts the duration, bitrate and any embedded chapters from a
+// single audio resource. The bitrate is computed as the average over the whole
+// resource, which is the most portable definition across the many supported
+// container formats.
+func probeAudioFile(ctx context.Context, res fetcher.Resource, link manifest.Link, tags *audioTags, extractChapters bool) probeResult {
+ size, _ := res.Length(ctx)
+
+ var result probeResult
+ switch audioFamily(link) {
+ case familyMP4:
+ duration, chapters, _ := probeMP4(ctx, res, extractChapters)
+ result.Duration = duration
+ result.Chapters = chapters
+ case familyOgg:
+ result.Duration = probeOggDuration(ctx, res, size)
+ if extractChapters {
+ result.Chapters = vorbisChapters(tags)
+ }
+ case familyFLAC:
+ result.Duration = probeFLACDuration(ctx, res)
+ if extractChapters {
+ result.Chapters = vorbisChapters(tags)
+ }
+ case familyWAV:
+ result.Duration = probeWAVDuration(ctx, res, size)
+ case familyAIFF:
+ result.Duration = probeAIFFDuration(ctx, res, size)
+ case familyMP3:
+ result.Duration = probeMP3Duration(ctx, res, size)
+ case familyWebM:
+ result.Duration = probeWebMDuration(ctx, res, size)
+ case familyAAC:
+ result.Duration = probeAACDuration(ctx, res, size)
+ }
+
+ if result.Duration > 0 && size > 0 {
+ // kbps = bytes * 8 bits / 1000 / seconds, rounded to one decimal place.
+ kbps := float64(size) * 8 / 1000 / result.Duration
+ result.Bitrate = math.Round(kbps*10) / 10
+ }
+ return result
+}
+
+type audioFormatFamily int
+
+const (
+ familyUnknown audioFormatFamily = iota
+ familyMP4
+ familyOgg
+ familyFLAC
+ familyWAV
+ familyAIFF
+ familyMP3
+ familyWebM
+ familyAAC
+)
+
+// audioFamily classifies an audio resource into a parsing family based on its
+// file extension.
+func audioFamily(link manifest.Link) audioFormatFamily {
+ switch linkExtension(link) {
+ case "mp4", "m4a", "m4b", "m4p", "m4r", "alac":
+ return familyMP4
+ case "ogg", "oga", "opus", "mogg":
+ return familyOgg
+ case "flac":
+ return familyFLAC
+ case "wav", "wave":
+ return familyWAV
+ case "aiff", "aif", "aifc":
+ return familyAIFF
+ case "mp3":
+ return familyMP3
+ case "webm":
+ return familyWebM
+ case "aac":
+ return familyAAC
+ }
+ return familyUnknown
+}
+
+// linkExtension returns the lower-cased file extension (without the dot) of a
+// link's HREF.
+func linkExtension(link manifest.Link) string {
+ ext := strings.ToLower(filepath.Ext(link.URL(nil, nil).Path()))
+ return strings.TrimPrefix(ext, ".")
+}
+
+// readRange reads length bytes starting at offset, clamping at the resource's
+// end. It returns nil on error.
+func readRange(ctx context.Context, res fetcher.Resource, offset, length int64) []byte {
+ if length <= 0 {
+ return nil
+ }
+ data, err := res.Read(ctx, offset, offset+length-1)
+ if err != nil {
+ return nil
+ }
+ return data
+}
diff --git a/pkg/parser/audio/readcache.go b/pkg/parser/audio/readcache.go
new file mode 100644
index 00000000..b0df2394
--- /dev/null
+++ b/pkg/parser/audio/readcache.go
@@ -0,0 +1,166 @@
+package audio
+
+import (
+ "context"
+
+ "github.com/readium/go-toolkit/pkg/fetcher"
+)
+
+// defaultCacheBlockSize is the granularity at which readCache fetches data when
+// no size is configured. Reads are rounded up to whole blocks so that the many
+// small, overlapping reads performed while probing an audio file coalesce into a
+// few range requests.
+const defaultCacheBlockSize = 256 << 10 // 256 KiB
+
+// readCache wraps a fetcher.Resource and serves reads from fixed-size blocks
+// fetched on demand.
+//
+// Probing an audio file performs many small reads — tag headers, container
+// boxes, duration markers, chapter samples — and the tag and duration passes
+// both re-read the header region. On remote sources (HTTP, S3) and ZIP archives
+// every read is a byte-range request, so without caching opening a multi-file
+// audiobook fans out into hundreds of requests. Coalescing reads into block
+// fetches (and reusing them across passes) cuts that to a handful per file.
+type readCache struct {
+ fetcher.Resource
+ size int64
+ blockSize int64
+ blocks map[int64][]byte
+}
+
+func newReadCache(res fetcher.Resource, size, blockSize int64) *readCache {
+ if blockSize <= 0 {
+ blockSize = defaultCacheBlockSize
+ }
+ return &readCache{Resource: res, size: size, blockSize: blockSize, blocks: make(map[int64][]byte)}
+}
+
+// Length returns the known size without hitting the underlying resource.
+func (c *readCache) Length(ctx context.Context) (int64, *fetcher.ResourceError) {
+ if c.size >= 0 {
+ return c.size, nil
+ }
+ return c.Resource.Length(ctx)
+}
+
+// Read serves the inclusive byte range [start, end] from cached blocks, fetching
+// any missing blocks first.
+func (c *readCache) Read(ctx context.Context, start, end int64) ([]byte, *fetcher.ResourceError) {
+ // Whole-resource reads, or an unknown size, bypass the block cache.
+ if (start == 0 && end == 0) || c.size <= 0 || end < start {
+ return c.Resource.Read(ctx, start, end)
+ }
+ if start >= c.size {
+ return []byte{}, nil
+ }
+ if end >= c.size {
+ end = c.size - 1
+ }
+
+ firstBlock := start / c.blockSize
+ lastBlock := end / c.blockSize
+ if err := c.fetch(ctx, firstBlock, lastBlock); err != nil {
+ return nil, err
+ }
+
+ out := make([]byte, 0, end-start+1)
+ for b := firstBlock; b <= lastBlock; b++ {
+ blk := c.blocks[b]
+ base := b * c.blockSize
+ lo := int64(0)
+ if start > base {
+ lo = start - base
+ }
+ hi := min(end-base+1, int64(len(blk)))
+ if lo < hi {
+ out = append(out, blk[lo:hi]...)
+ }
+ }
+ return out, nil
+}
+
+// cachedSlice returns the bytes for the inclusive range [start, end] if they are
+// already fully present in the cache, without performing any read. The second
+// return value reports whether it was a hit. The returned slice is a copy.
+func (c *readCache) cachedSlice(start, end int64) ([]byte, bool) {
+ if c.size <= 0 || start < 0 || end < start {
+ return nil, false
+ }
+ if end >= c.size {
+ end = c.size - 1
+ }
+
+ firstBlock := start / c.blockSize
+ lastBlock := end / c.blockSize
+ for b := firstBlock; b <= lastBlock; b++ {
+ blk, ok := c.blocks[b]
+ if !ok {
+ return nil, false
+ }
+ // The needed portion of this block must actually be present (the final
+ // cached block may be short if it sits at the end of the resource).
+ base := b * c.blockSize
+ needEnd := end
+ if blockEnd := base + c.blockSize - 1; needEnd > blockEnd {
+ needEnd = blockEnd
+ }
+ if int64(len(blk)) <= needEnd-base {
+ return nil, false
+ }
+ }
+
+ out := make([]byte, 0, end-start+1)
+ for b := firstBlock; b <= lastBlock; b++ {
+ blk := c.blocks[b]
+ base := b * c.blockSize
+ lo := int64(0)
+ if start > base {
+ lo = start - base
+ }
+ hi := min(end-base+1, int64(len(blk)))
+ out = append(out, blk[lo:hi]...)
+ }
+ return out, true
+}
+
+// fetch ensures every block in [first, last] is cached, retrieving each run of
+// contiguous missing blocks in a single underlying read.
+func (c *readCache) fetch(ctx context.Context, first, last int64) *fetcher.ResourceError {
+ for b := first; b <= last; {
+ if _, ok := c.blocks[b]; ok {
+ b++
+ continue
+ }
+ runStart := b
+ for b <= last {
+ if _, ok := c.blocks[b]; ok {
+ break
+ }
+ b++
+ }
+ runEnd := b - 1
+
+ off := runStart * c.blockSize
+ endOff := (runEnd+1)*c.blockSize - 1
+ if endOff >= c.size {
+ endOff = c.size - 1
+ }
+ data, err := c.Resource.Read(ctx, off, endOff)
+ if err != nil {
+ return err
+ }
+ for bi := runStart; bi <= runEnd; bi++ {
+ lo := (bi - runStart) * c.blockSize
+ if lo >= int64(len(data)) {
+ c.blocks[bi] = []byte{}
+ continue
+ }
+ hi := lo + c.blockSize
+ if hi > int64(len(data)) {
+ hi = int64(len(data))
+ }
+ c.blocks[bi] = data[lo:hi]
+ }
+ }
+ return nil
+}
diff --git a/pkg/parser/audio/readcache_test.go b/pkg/parser/audio/readcache_test.go
new file mode 100644
index 00000000..d3a9f103
--- /dev/null
+++ b/pkg/parser/audio/readcache_test.go
@@ -0,0 +1,99 @@
+package audio
+
+import (
+ "context"
+ "testing"
+
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// countingResource records how many underlying Read calls are made, to verify
+// that readCache coalesces requests.
+type countingResource struct {
+ fetcher.Resource
+ data []byte
+ reads int
+}
+
+func (r *countingResource) Length(_ context.Context) (int64, *fetcher.ResourceError) {
+ return int64(len(r.data)), nil
+}
+
+func (r *countingResource) Read(_ context.Context, start, end int64) ([]byte, *fetcher.ResourceError) {
+ r.reads++
+ if end >= int64(len(r.data)) {
+ end = int64(len(r.data)) - 1
+ }
+ if start > end {
+ return []byte{}, nil
+ }
+ return r.data[start : end+1], nil
+}
+
+func TestReadCacheCorrectnessAndCoalescing(t *testing.T) {
+ data := make([]byte, 700<<10) // 700 KiB -> blocks 0,1,2 (256 KiB each)
+ for i := range data {
+ data[i] = byte(i)
+ }
+ base, _ := bytesResource(data)
+ src := &countingResource{Resource: base, data: data}
+ c := newReadCache(src, int64(len(data)), 0) // 0 -> default 256 KiB blocks
+ require.EqualValues(t, defaultCacheBlockSize, c.blockSize)
+ ctx := t.Context()
+
+ read := func(start, end int64) []byte {
+ b, err := c.Read(ctx, start, end)
+ require.Nil(t, err)
+ return b
+ }
+
+ // First read of block 0.
+ assert.Equal(t, data[0:10], read(0, 9))
+ assert.Equal(t, 1, src.reads)
+
+ // Subsequent reads within block 0 hit the cache (no new request).
+ assert.Equal(t, data[100:201], read(100, 200))
+ assert.Equal(t, 1, src.reads)
+
+ // A read into block 1 triggers exactly one more request.
+ assert.Equal(t, data[300<<10:(300<<10)+50], read(300<<10, (300<<10)+49))
+ assert.Equal(t, 2, src.reads)
+
+ // A span across blocks 0-2: only block 2 is missing -> one more request.
+ assert.Equal(t, data[0:600<<10], read(0, (600<<10)-1))
+ assert.Equal(t, 3, src.reads)
+
+ // Reading past EOF returns the clamped tail, not an error.
+ got := read(int64(len(data))-5, int64(len(data))+100)
+ assert.Equal(t, data[len(data)-5:], got)
+}
+
+func TestReadCacheCustomBlockSize(t *testing.T) {
+ data := make([]byte, 400<<10)
+ for i := range data {
+ data[i] = byte(i)
+ }
+ base, _ := bytesResource(data)
+ src := &countingResource{Resource: base, data: data}
+ c := newReadCache(src, int64(len(data)), 128<<10) // 128 KiB blocks
+ require.EqualValues(t, 128<<10, c.blockSize)
+ ctx := t.Context()
+
+ b, err := c.Read(ctx, 0, 9)
+ require.Nil(t, err)
+ assert.Equal(t, data[0:10], b)
+ assert.Equal(t, 1, src.reads) // block 0
+
+ // Still within block 0 (< 128 KiB): cached.
+ _, err = c.Read(ctx, 100, 200)
+ require.Nil(t, err)
+ assert.Equal(t, 1, src.reads)
+
+ // At 200 KiB: block 1 with 128 KiB blocks (would be block 0 at 256 KiB).
+ b, err = c.Read(ctx, 200<<10, (200<<10)+9)
+ require.Nil(t, err)
+ assert.Equal(t, data[200<<10:(200<<10)+10], b)
+ assert.Equal(t, 2, src.reads)
+}
diff --git a/pkg/parser/audio/rich.go b/pkg/parser/audio/rich.go
new file mode 100644
index 00000000..ab094209
--- /dev/null
+++ b/pkg/parser/audio/rich.go
@@ -0,0 +1,244 @@
+package audio
+
+import (
+ "context"
+ "net/url"
+ "path"
+ "sort"
+ "strings"
+
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/readium/go-toolkit/pkg/pub"
+ "golang.org/x/sync/errgroup"
+)
+
+// defaultProbeConcurrency bounds how many reading-order files are probed in
+// parallel by default. Probing is I/O-bound (range requests to a remote source
+// or archive), so a small amount of concurrency hides per-file latency without
+// flooding the backend with requests.
+const defaultProbeConcurrency = 8
+
+// enrich performs the rich-parsing pass over an audiobook: it probes every
+// reading-order resource for its duration and bitrate, extracts publication
+// metadata (and a cover) from the first audio file, and builds a table of
+// contents. It mutates m in place and returns a cover service factory (or nil
+// when no cover was found).
+func (p AudioParser) enrich(ctx context.Context, fetch fetcher.Fetcher, m *manifest.Manifest) pub.ServiceFactory {
+ var firstTags *audioTags
+ var totalDuration float64
+ var embeddedChapters manifest.LinkList
+
+ readingOrder := m.ReadingOrder
+
+ // Acquire all resource handles serially: Get is cheap, but not guaranteed to
+ // be safe for concurrent use (e.g. FileFetcher tracks handles in a shared
+ // slice). The expensive part — reading each file — is then parallelized.
+ resources := make([]fetcher.Resource, len(readingOrder))
+ for i := range readingOrder {
+ resources[i] = fetch.Get(ctx, readingOrder[i])
+ }
+
+ // Probe files concurrently, bounded by the configured concurrency. Reading
+ // distinct resources in parallel is safe for the file and archive fetchers,
+ // and hides per-file round-trip latency on remote sources.
+ concurrency := p.concurrency
+ if concurrency <= 0 {
+ concurrency = defaultProbeConcurrency
+ }
+ probed := make([]probedItem, len(readingOrder))
+ var g errgroup.Group
+ g.SetLimit(concurrency)
+ extractChapters := !p.skipEmbeddedChapters
+ blockSize := int64(p.cacheBlockSize)
+ for i := range readingOrder {
+ i, link, res := i, readingOrder[i], resources[i]
+ g.Go(func() error {
+ probed[i] = probeReadingOrderItem(ctx, res, link, extractChapters, blockSize)
+ return nil
+ })
+ }
+ _ = g.Wait()
+
+ // Combine the results in reading order so the output is deterministic.
+ for i := range readingOrder {
+ link := readingOrder[i]
+ p := probed[i]
+
+ if p.probe.Duration > 0 {
+ link.Duration = p.probe.Duration
+ totalDuration += p.probe.Duration
+ }
+ if p.probe.Bitrate > 0 {
+ link.Bitrate = p.probe.Bitrate
+ }
+ if p.tags != nil && p.tags.Title != "" {
+ link.Title = p.tags.Title
+ }
+ if len(p.probe.Chapters) > 0 {
+ embeddedChapters = append(embeddedChapters, chaptersToLinks(p.probe.Chapters, link)...)
+ }
+
+ readingOrder[i] = link
+ if i == 0 {
+ firstTags = p.tags
+ }
+ }
+ m.ReadingOrder = readingOrder
+
+ if totalDuration > 0 {
+ d := totalDuration
+ m.Metadata.Duration = &d
+ }
+
+ applyTagsToMetadata(&m.Metadata, firstTags)
+
+ // Table of contents priority: a playlist file wins over embedded chapters,
+ // which in turn win over a flat per-file listing.
+ if toc := playlistTOC(ctx, fetch, readingOrder); len(toc) > 0 {
+ m.TableOfContents = toc
+ } else if len(embeddedChapters) > 0 {
+ m.TableOfContents = embeddedChapters
+ } else if toc := perFileTOC(readingOrder); len(toc) > 0 {
+ m.TableOfContents = toc
+ }
+
+ if firstTags != nil {
+ return coverServiceFactory(firstTags.Picture)
+ }
+ return nil
+}
+
+// probedItem is the per-file result of the parallel probing pass.
+type probedItem struct {
+ tags *audioTags
+ probe probeResult
+}
+
+// probeReadingOrderItem reads the tags and probes the duration/bitrate/chapters
+// of a single reading-order resource, always releasing the resource handle.
+//
+// All reads go through a per-file block cache so that the tag and duration
+// passes — which perform many small, overlapping reads of the header region —
+// coalesce into a handful of range requests rather than one request each. This
+// matters most for remote sources (HTTP, S3) and ZIP archives.
+func probeReadingOrderItem(ctx context.Context, res fetcher.Resource, link manifest.Link, extractChapters bool, blockSize int64) probedItem {
+ defer res.Close()
+ size, _ := res.Length(ctx)
+ cached := newReadCache(res, size, blockSize)
+ tags := readAudioTags(cached)
+ return probedItem{tags: tags, probe: probeAudioFile(ctx, cached, link, tags, extractChapters)}
+}
+
+// playlistTOC looks for the first parseable playlist file in the publication and
+// converts it into a table of contents whose entries map to the reading order.
+func playlistTOC(ctx context.Context, fetch fetcher.Fetcher, readingOrder manifest.LinkList) manifest.LinkList {
+ links, err := fetch.Links(ctx)
+ if err != nil {
+ return nil
+ }
+
+ playlists := make(manifest.LinkList, 0)
+ for _, l := range links {
+ if _, ok := allowed_extensions_audio_extra[linkExtension(l)]; ok {
+ playlists = append(playlists, l)
+ }
+ }
+ sort.Slice(playlists, func(i, j int) bool {
+ return playlists[i].Href.String() < playlists[j].Href.String()
+ })
+
+ for _, l := range playlists {
+ content := readResource(ctx, fetch, l)
+ if content == nil {
+ continue
+ }
+ entries := parsePlaylist(linkExtension(l), content)
+ if len(entries) == 0 {
+ continue
+ }
+ if toc := playlistEntriesToTOC(entries, readingOrder); len(toc) > 0 {
+ return toc
+ }
+ }
+ return nil
+}
+
+// readResource reads the full content of a link's resource, always releasing the
+// resource handle (even on a read error or panic).
+func readResource(ctx context.Context, fetch fetcher.Fetcher, link manifest.Link) []byte {
+ res := fetch.Get(ctx, link)
+ defer res.Close()
+ content, err := res.Read(ctx, 0, 0)
+ if err != nil {
+ return nil
+ }
+ return content
+}
+
+// playlistEntriesToTOC maps playlist entries onto reading-order resources by
+// matching file names, producing one TOC link per resolvable entry.
+func playlistEntriesToTOC(entries []playlistEntry, readingOrder manifest.LinkList) manifest.LinkList {
+ byName := make(map[string]manifest.Link, len(readingOrder))
+ for _, l := range readingOrder {
+ byName[strings.ToLower(l.URL(nil, nil).Filename())] = l
+ }
+
+ toc := make(manifest.LinkList, 0, len(entries))
+ for _, e := range entries {
+ name := playlistEntryName(e.Path)
+ l, ok := byName[name]
+ if !ok {
+ continue
+ }
+ title := e.Title
+ if title == "" {
+ title = l.Title
+ }
+ toc = append(toc, chapterLink(l, title, e.Start))
+ }
+ return toc
+}
+
+// playlistEntryName extracts the lower-cased file name from a playlist entry
+// path, ignoring any query string and decoding percent-escapes.
+func playlistEntryName(p string) string {
+ p = strings.ReplaceAll(p, "\\", "/")
+ if i := strings.IndexAny(p, "?#"); i >= 0 {
+ p = p[:i]
+ }
+ base := path.Base(p)
+ if decoded, err := url.PathUnescape(base); err == nil {
+ base = decoded
+ }
+ return strings.ToLower(base)
+}
+
+// perFileTOC builds a flat table of contents with one entry per reading-order
+// resource. It is used as a last resort when no playlist or embedded chapters
+// are available, and only when there is more than one titled resource.
+func perFileTOC(readingOrder manifest.LinkList) manifest.LinkList {
+ if len(readingOrder) < 2 {
+ return nil
+ }
+ titled := false
+ for _, l := range readingOrder {
+ if l.Title != "" {
+ titled = true
+ break
+ }
+ }
+ if !titled {
+ return nil
+ }
+
+ toc := make(manifest.LinkList, 0, len(readingOrder))
+ for _, l := range readingOrder {
+ title := l.Title
+ if title == "" {
+ title = l.URL(nil, nil).Filename()
+ }
+ toc = append(toc, chapterLink(l, title, 0))
+ }
+ return toc
+}
diff --git a/pkg/parser/audio/testdata/AroundTheWorldInEightyDays.m4b b/pkg/parser/audio/testdata/AroundTheWorldInEightyDays.m4b
new file mode 100644
index 00000000..6f2596f7
--- /dev/null
+++ b/pkg/parser/audio/testdata/AroundTheWorldInEightyDays.m4b
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3f2b02e4aac7d6ea72fe647db7b9bcb1131a17cd7be80f9380df13fb12de0c7c
+size 189714445
diff --git a/pkg/parser/audio/testdata/art_letters.zab b/pkg/parser/audio/testdata/art_letters.zab
new file mode 100644
index 00000000..7c39b078
--- /dev/null
+++ b/pkg/parser/audio/testdata/art_letters.zab
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a26415949a64097090655b04136b55bddc9a3196a2ecd4e2507a673f26869e0e
+size 7328837
diff --git a/pkg/parser/audio/testdata/art_letters/artofletters_00_lynd.opus b/pkg/parser/audio/testdata/art_letters/artofletters_00_lynd.opus
new file mode 100644
index 00000000..c398fe75
--- /dev/null
+++ b/pkg/parser/audio/testdata/art_letters/artofletters_00_lynd.opus
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:54e15d4fd793088e82928d88a3bd6cab33e8bfa1b60b9456948393470a5a50f4
+size 238835
diff --git a/pkg/parser/audio/testdata/art_letters/artofletters_01_lynd.opus b/pkg/parser/audio/testdata/art_letters/artofletters_01_lynd.opus
new file mode 100644
index 00000000..b7a8c0f1
--- /dev/null
+++ b/pkg/parser/audio/testdata/art_letters/artofletters_01_lynd.opus
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5b00c58e2388faee2543cf034cda6212d2b2c7da9133e004f3cdd4e91d26f39a
+size 3992223
diff --git a/pkg/parser/audio/testdata/art_letters/artofletters_02_lynd.opus b/pkg/parser/audio/testdata/art_letters/artofletters_02_lynd.opus
new file mode 100644
index 00000000..bc516047
--- /dev/null
+++ b/pkg/parser/audio/testdata/art_letters/artofletters_02_lynd.opus
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e41920fc3bea992ea044e4141f2e66ce71fb5e8bb73032391fc73d7b17ac6195
+size 3125494
diff --git a/pkg/parser/audio/testdata/luvsic.cue b/pkg/parser/audio/testdata/luvsic.cue
new file mode 100644
index 00000000..d9c15800
--- /dev/null
+++ b/pkg/parser/audio/testdata/luvsic.cue
@@ -0,0 +1,71 @@
+REM GENRE Hip-Hop
+REM DATE 2015
+REM DISCID B50F870D
+REM COMMENT "ExactAudioCopy v1.1"
+PERFORMER "Nujabes feat. Shing02"
+TITLE "Luv(sic) Hexalogy"
+FILE "01 - Luv(sic).wav" WAVE
+ TRACK 01 AUDIO
+ TITLE "Luv(sic)"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "02 - Luv(sic) Part 2.wav" WAVE
+ TRACK 02 AUDIO
+ TITLE "Luv(sic) Part 2"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "03 - Luv(sic) Part 3.wav" WAVE
+ TRACK 03 AUDIO
+ TITLE "Luv(sic) Part 3"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "04 - Luv(sic) Part 4.wav" WAVE
+ TRACK 04 AUDIO
+ TITLE "Luv(sic) Part 4"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "05 - Luv(sic) Part 5.wav" WAVE
+ TRACK 05 AUDIO
+ TITLE "Luv(sic) Part 5"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "06 - Luv(sic) Grand Finale.wav" WAVE
+ TRACK 06 AUDIO
+ TITLE "Luv(sic) Grand Finale"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "07 - Luv(sic) 12'' Remix.wav" WAVE
+ TRACK 07 AUDIO
+ TITLE "Luv(sic) 12' Remix"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "08 - Luv(sic) Part 2 Acoustica.wav" WAVE
+ TRACK 08 AUDIO
+ TITLE "Luv(sic) Part 2 Acoustica"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "09 - Luv(sic) Part 3 Ta-ku Remix.wav" WAVE
+ TRACK 09 AUDIO
+ TITLE "Luv(sic) Part 3 Ta-ku Remix"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "10 - Luv(sic) Part 4 LASTorder Remix.wav" WAVE
+ TRACK 10 AUDIO
+ TITLE "Luv(sic) Part 4 LASTorder Remix"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "11 - Luv(sic) Part 5 Jumpster Remix.wav" WAVE
+ TRACK 11 AUDIO
+ TITLE "Luv(sic) Part 5 Jumpster Remix"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "12 - Luv(sic) Part 6 Uyama Hiroto Remix.wav" WAVE
+ TRACK 12 AUDIO
+ TITLE "Luv(sic) Part 6 Uyama Hiroto Remix"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
+FILE "13 - Perfect Circle.wav" WAVE
+ TRACK 13 AUDIO
+ TITLE "Perfect Circle"
+ PERFORMER "Nujabes feat. Shing02"
+ INDEX 01 00:00:00
diff --git a/pkg/parser/audio/testdata/luvsic.m3u b/pkg/parser/audio/testdata/luvsic.m3u
new file mode 100644
index 00000000..c442914d
--- /dev/null
+++ b/pkg/parser/audio/testdata/luvsic.m3u
@@ -0,0 +1,27 @@
+#EXTM3U
+#EXTINF:286,Nujabes feat. Shing02 - Luv(sic)
+01 - Luv(sic).flac
+#EXTINF:273,Nujabes feat. Shing02 - Luv(sic) Part 2
+02 - Luv(sic) Part 2.flac
+#EXTINF:374,Nujabes feat. Shing02 - Luv(sic) Part 3
+03 - Luv(sic) Part 3.flac
+#EXTINF:311,Nujabes feat. Shing02 - Luv(sic) Part 4
+04 - Luv(sic) Part 4.flac
+#EXTINF:350,Nujabes feat. Shing02 - Luv(sic) Part 5
+05 - Luv(sic) Part 5.flac
+#EXTINF:317,Nujabes feat. Shing02 - Luv(sic) Grand Finale
+06 - Luv(sic) Grand Finale.flac
+#EXTINF:298,Nujabes feat. Shing02 - Luv(sic) 12" Remix
+07 - Luv(sic) 12'' Remix.flac
+#EXTINF:394,Nujabes feat. Shing02 - Luv(sic) Part 2 Acoustica
+08 - Luv(sic) Part 2 Acoustica.flac
+#EXTINF:296,Nujabes feat. Shing02 - Luv(sic) Part 3 Ta-ku Remix
+09 - Luv(sic) Part 3 Ta-ku Remix.flac
+#EXTINF:279,Nujabes feat. Shing02 - Luv(sic) Part 4 LASTorder Remix
+10 - Luv(sic) Part 4 LASTorder Remix.flac
+#EXTINF:279,Nujabes feat. Shing02 - Luv(sic) Part 5 Jumpster Remix
+11 - Luv(sic) Part 5 Jumpster Remix.flac
+#EXTINF:279,Nujabes feat. Shing02 - Luv(sic) Part 6 Uyama Hiroto Remix
+12 - Luv(sic) Part 6 Uyama Hiroto Remix.flac
+#EXTINF:241,Nujabes feat. Shing02 - Perfect Circle
+13 - Perfect Circle.flac
diff --git a/pkg/parser/audio/toc.go b/pkg/parser/audio/toc.go
new file mode 100644
index 00000000..a5b5c30a
--- /dev/null
+++ b/pkg/parser/audio/toc.go
@@ -0,0 +1,108 @@
+package audio
+
+import (
+ "math"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/readium/go-toolkit/pkg/manifest"
+)
+
+// chapterEntry is a single chapter extracted from an audio file: a title and a
+// start offset (in seconds) within that file.
+type chapterEntry struct {
+ Title string
+ Start float64
+}
+
+// chaptersToLinks turns the chapters found within a single audio resource into
+// table-of-contents links pointing into that resource using media-fragment time
+// offsets (e.g. `track.m4b#t=123.4`).
+func chaptersToLinks(entries []chapterEntry, base manifest.Link) manifest.LinkList {
+ links := make(manifest.LinkList, 0, len(entries))
+ for _, e := range entries {
+ links = append(links, chapterLink(base, e.Title, e.Start))
+ }
+ return links
+}
+
+// chapterLink builds a TOC link into base at the given start offset. A negative
+// or zero offset produces a link to the whole resource.
+func chapterLink(base manifest.Link, title string, start float64) manifest.Link {
+ href := base.URL(nil, nil).String()
+ if start > 0 {
+ href += "#t=" + formatFragmentTime(start)
+ }
+ return manifest.Link{
+ Href: manifest.MustNewHREFFromString(href, false),
+ MediaType: base.MediaType,
+ Title: title,
+ }
+}
+
+// formatFragmentTime formats a number of seconds for a media fragment, rounded
+// to the millisecond and without trailing zeros.
+func formatFragmentTime(seconds float64) string {
+ rounded := math.Round(seconds*1000) / 1000
+ return strconv.FormatFloat(rounded, 'f', -1, 64)
+}
+
+var vorbisChapterKey = regexp.MustCompile(`^chapter(\d+)$`)
+
+// vorbisChapters extracts chapters declared via the Vorbis comment chapter
+// extension (CHAPTERxxx / CHAPTERxxxNAME), used by Ogg and FLAC files.
+func vorbisChapters(tags *audioTags) []chapterEntry {
+ if tags == nil || tags.Raw == nil {
+ return nil
+ }
+
+ type idxChapter struct {
+ index int
+ entry chapterEntry
+ }
+ var found []idxChapter
+ for key, value := range tags.Raw {
+ m := vorbisChapterKey.FindStringSubmatch(strings.ToLower(key))
+ if m == nil {
+ continue
+ }
+ timecode, ok := value.(string)
+ if !ok {
+ continue
+ }
+ start, ok := parseTimecode(timecode)
+ if !ok {
+ continue
+ }
+ index, _ := strconv.Atoi(m[1])
+ name, _ := tags.Raw["CHAPTER"+m[1]+"NAME"].(string)
+ if name == "" {
+ name, _ = tags.Raw["chapter"+m[1]+"name"].(string)
+ }
+ found = append(found, idxChapter{index: index, entry: chapterEntry{Title: strings.TrimSpace(name), Start: start}})
+ }
+
+ sort.Slice(found, func(i, j int) bool { return found[i].index < found[j].index })
+ entries := make([]chapterEntry, 0, len(found))
+ for _, c := range found {
+ entries = append(entries, c.entry)
+ }
+ return entries
+}
+
+// parseTimecode parses a "HH:MM:SS.mmm" timecode into seconds.
+func parseTimecode(s string) (float64, bool) {
+ parts := strings.Split(strings.TrimSpace(s), ":")
+ if len(parts) != 3 {
+ return 0, false
+ }
+ h, err1 := strconv.ParseFloat(parts[0], 64)
+ m, err2 := strconv.ParseFloat(parts[1], 64)
+ sec, err3 := strconv.ParseFloat(parts[2], 64)
+ if err1 != nil || err2 != nil || err3 != nil {
+ return 0, false
+ }
+ return h*3600 + m*60 + sec, true
+}
diff --git a/pkg/parser/epub/factory.go b/pkg/parser/epub/factory.go
index dd488a53..d3632820 100644
--- a/pkg/parser/epub/factory.go
+++ b/pkg/parser/epub/factory.go
@@ -3,14 +3,13 @@ package epub
import (
"github.com/readium/go-toolkit/pkg/internal/extensions"
"github.com/readium/go-toolkit/pkg/manifest"
- "github.com/readium/go-toolkit/pkg/util/url"
)
type PublicationFactory struct {
FallbackTitle string
PackageDocument PackageDocument
NavigationData map[string]manifest.LinkList
- EncryptionData map[url.URL]manifest.Encryption
+ EncryptionData map[string]manifest.Encryption
DisplayOptions map[string]string
itemById map[string]Item
@@ -193,7 +192,7 @@ func (f PublicationFactory) computePropertiesAndRels(item Item, itemref *ItemRef
rels = extensions.AddToSet(rels, "cover")
}
- if edat, ok := f.EncryptionData[item.Href]; ok {
+ if edat, ok := f.EncryptionData[item.Href.Normalize().String()]; ok {
properties["encrypted"] = edat.ToMap() // ToMap makes it JSON-like
}
diff --git a/pkg/parser/epub/parser.go b/pkg/parser/epub/parser.go
index 93e5f668..fa66031a 100644
--- a/pkg/parser/epub/parser.go
+++ b/pkg/parser/epub/parser.go
@@ -9,8 +9,8 @@ import (
"github.com/readium/go-toolkit/pkg/fetcher"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/mediatype"
+ "github.com/readium/go-toolkit/pkg/protection"
"github.com/readium/go-toolkit/pkg/pub"
- "github.com/readium/go-toolkit/pkg/util/url"
)
type Parser struct {
@@ -51,11 +51,27 @@ func (p Parser) Parse(ctx context.Context, asset asset.PublicationAsset, f fetch
return nil, errors.Wrap(err, "invalid OPF file")
}
+ // Detect the container-level DRM scheme. This is done unconditionally,
+ // not gated on the presence of META-INF/encryption.xml, because schemes
+ // like Adobe ADEPT, Barnes & Noble, Apple FairPlay and Kobo announce
+ // themselves through other well-known files (rights.xml / sinf.xml).
+ // TODO: surface the publication-level scheme on the manifest itself so
+ // consumers can detect protection even when encryption.xml is absent.
+ scheme, err := protection.IdentifyEPUBProtection(ctx, f)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed identifying EPUB protection scheme")
+ }
+
+ encryptionData, err := parseEncryptionData(ctx, f, scheme.URI())
+ if err != nil {
+ return nil, errors.Wrap(err, "failed parsing encryption data")
+ }
+
manifest := PublicationFactory{
FallbackTitle: fallbackTitle,
PackageDocument: *packageDocument,
NavigationData: parseNavigationData(ctx, *packageDocument, f),
- EncryptionData: parseEncryptionData(ctx, f),
+ EncryptionData: encryptionData,
DisplayOptions: parseDisplayOptions(ctx, f),
}.Create()
@@ -74,12 +90,16 @@ func (p Parser) Parse(ctx context.Context, asset asset.PublicationAsset, f fetch
return pub.NewBuilder(manifest, ffetcher, builder), nil
}
-func parseEncryptionData(ctx context.Context, f fetcher.Fetcher) (ret map[url.URL]manifest.Encryption) {
- n, err := fetcher.ReadResourceAsXML(ctx, f.Get(ctx, manifest.Link{Href: manifest.MustNewHREFFromString("META-INF/encryption.xml", false)}))
- if err != nil {
- return
+// parseEncryptionData parses META-INF/encryption.xml when present and stamps
+// each entry with the supplied DRM scheme URI (typically obtained by calling
+// [protection.IdentifyEPUBProtection] at a higher level). A missing
+// encryption.xml is normal and returns (nil, nil).
+func parseEncryptionData(ctx context.Context, f fetcher.Fetcher, scheme string) (map[string]manifest.Encryption, error) {
+ n, rerr := fetcher.ReadResourceAsXML(ctx, f.Get(ctx, manifest.Link{Href: manifest.MustNewHREFFromString("META-INF/encryption.xml", false)}))
+ if rerr != nil {
+ return nil, nil
}
- return ParseEncryption(n)
+ return ParseEncryption(n, scheme), nil
}
func parseNavigationData(ctx context.Context, packageDocument PackageDocument, f fetcher.Fetcher) (ret map[string]manifest.LinkList) {
diff --git a/pkg/parser/epub/parser_encryption.go b/pkg/parser/epub/parser_encryption.go
index 9d74a148..19fdb411 100644
--- a/pkg/parser/epub/parser_encryption.go
+++ b/pkg/parser/epub/parser_encryption.go
@@ -5,7 +5,6 @@ import (
"github.com/antchfx/xmlquery"
"github.com/readium/go-toolkit/pkg/manifest"
- "github.com/readium/go-toolkit/pkg/protection"
"github.com/readium/go-toolkit/pkg/util/url"
)
@@ -13,51 +12,38 @@ var (
xpEncEncData = mustCompileNS("//enc:EncryptedData")
xpEncCipherData = mustCompileNS("enc:CipherData")
xpEncCipherRef = mustCompileNS("enc:CipherReference")
- xpEncKeyInfo = mustCompileNS("ds:KeyInfo")
- xpEncRetrieval = mustCompileNS("ds:RetrievalMethod")
xpEncMethod = mustCompileNS("enc:EncryptionMethod")
xpEncProps = mustCompileNS("enc:EncryptionProperties")
xpEncProp = mustCompileNS("enc:EncryptionProperty")
xpEncCompress = mustCompileNS("comp:Compression")
)
-func ParseEncryption(document *xmlquery.Node) (ret map[url.URL]manifest.Encryption) {
+func ParseEncryption(document *xmlquery.Node, scheme string) (ret map[string]manifest.Encryption) {
for _, node := range xmlquery.QuerySelectorAll(document, xpEncEncData) {
- u, e := parseEncryptedData(node)
+ key, e := parseEncryptedData(node, scheme)
if e != nil {
if ret == nil {
- ret = make(map[url.URL]manifest.Encryption)
+ ret = make(map[string]manifest.Encryption)
}
- ret[u] = *e
+ ret[key] = *e
}
}
return
}
-func parseEncryptedData(node *xmlquery.Node) (url.URL, *manifest.Encryption) {
+func parseEncryptedData(node *xmlquery.Node, scheme string) (string, *manifest.Encryption) {
cdat := xmlquery.QuerySelector(node, xpEncCipherData)
if cdat == nil {
- return nil, nil
+ return "", nil
}
cipherref := xmlquery.QuerySelector(cdat, xpEncCipherRef)
if cipherref == nil {
- return nil, nil
+ return "", nil
}
resourceURI := cipherref.SelectAttr("URI")
- retrievalMethod := ""
- if keyinfo := xmlquery.QuerySelector(node, xpEncKeyInfo); keyinfo != nil {
- if r := xmlquery.QuerySelector(keyinfo, xpEncRetrieval); r != nil {
- retrievalMethod = r.SelectAttr("URI")
- }
- }
-
ret := &manifest.Encryption{
- // TODO: No profile? https://github.com/readium/kotlin-toolkit/blob/develop/readium/streamer/src/main/java/org/readium/r2/streamer/parser/epub/EncryptionParser.kt#L40
- }
-
- if retrievalMethod == "license.lcpl#/encryption/content_key" {
- ret.Scheme = protection.SchemeLCP
+ Scheme: scheme,
}
if encryptionmethod := xmlquery.QuerySelector(node, xpEncMethod); encryptionmethod != nil {
@@ -74,10 +60,10 @@ func parseEncryptedData(node *xmlquery.Node) (url.URL, *manifest.Encryption) {
ru, err := url.FromEPUBHref(resourceURI)
if err != nil {
- return nil, nil
+ return "", nil
}
- return ru, ret
+ return ru.Normalize().String(), ret
}
func parseEncryptionProperties(encryptionProperties *xmlquery.Node) (int64, string) {
diff --git a/pkg/parser/epub/parser_encryption_test.go b/pkg/parser/epub/parser_encryption_test.go
index 6b32dd52..cce8557e 100644
--- a/pkg/parser/epub/parser_encryption_test.go
+++ b/pkg/parser/epub/parser_encryption_test.go
@@ -6,24 +6,19 @@ import (
"github.com/readium/go-toolkit/pkg/fetcher"
"github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/readium/go-toolkit/pkg/protection"
"github.com/readium/go-toolkit/pkg/util/url"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func loadEncryption(ctx context.Context, name string) (map[string]manifest.Encryption, error) {
+func loadEncryption(ctx context.Context, name string, scheme protection.Scheme) (map[string]manifest.Encryption, error) {
n, rerr := fetcher.ReadResourceAsXML(ctx, fetcher.NewFileResource(manifest.Link{}, "./testdata/encryption/encryption-"+name+".xml"))
if rerr != nil {
return nil, rerr.Cause
}
- enc := ParseEncryption(n)
- ret := make(map[string]manifest.Encryption)
- for k, v := range enc {
- ret[k.String()] = v
- }
-
- return ret, nil
+ return ParseEncryption(n, scheme.URI()), nil
}
var testEncMap = map[string]manifest.Encryption{
@@ -42,19 +37,19 @@ var testEncMap = map[string]manifest.Encryption{
}
func TestEncryptionParserNamespacePrefixes(t *testing.T) {
- e, err := loadEncryption(t.Context(), "lcp-prefixes")
+ e, err := loadEncryption(t.Context(), "lcp-prefixes", protection.LCP)
require.NoError(t, err)
assert.Equal(t, testEncMap, e)
}
func TestEncryptionParserDefaultNamespaces(t *testing.T) {
- e, err := loadEncryption(t.Context(), "lcp-xmlns")
+ e, err := loadEncryption(t.Context(), "lcp-xmlns", protection.LCP)
require.NoError(t, err)
assert.Equal(t, testEncMap, e)
}
func TestEncryptionParserUnknownRetrievalMethod(t *testing.T) {
- e, err := loadEncryption(t.Context(), "unknown-method")
+ e, err := loadEncryption(t.Context(), "unknown-method", protection.NoDRM)
require.NoError(t, err)
assert.Equal(t, map[string]manifest.Encryption{
url.MustURLFromString("OEBPS/images/image.jpeg").String(): {
diff --git a/pkg/parser/epub/parser_test.go b/pkg/parser/epub/parser_test.go
new file mode 100644
index 00000000..94a93373
--- /dev/null
+++ b/pkg/parser/epub/parser_test.go
@@ -0,0 +1,125 @@
+package epub
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/readium/go-toolkit/pkg/asset"
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/mediatype"
+ "github.com/readium/go-toolkit/pkg/protection"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// fakeEPUBAsset is a minimal [asset.PublicationAsset] for parser tests.
+// CreateFetcher is never invoked because the tests construct the fetcher
+// themselves and pass it to [Parser.Parse] directly.
+type fakeEPUBAsset struct{ name string }
+
+func (a fakeEPUBAsset) Name() string { return a.name }
+func (a fakeEPUBAsset) MediaType(context.Context) mediatype.MediaType { return mediatype.EPUB }
+func (a fakeEPUBAsset) CreateFetcher(context.Context, asset.Dependencies, string) (fetcher.Fetcher, error) {
+ return nil, errors.New("unused in tests")
+}
+
+func openProtectionFixture(t *testing.T, file string) *fetcher.ArchiveFetcher {
+ t.Helper()
+ f, err := fetcher.NewArchiveFetcherFromPath(t.Context(), "../../protection/testdata/"+file)
+ require.NoError(t, err)
+ t.Cleanup(f.Close)
+ return f
+}
+
+// TestParserEndToEnd exercises [Parser.Parse] against every DRM fixture in
+// pkg/protection/testdata, asserting the parse succeeds and surfaces the
+// expected publication metadata. expectedScheme is the manifest.Encryption
+// scheme URI that every encrypted resource should carry — empty means either
+// no encryption.xml is present (so no resource should be tagged) or the
+// encryption is generic (no DRM scheme attached).
+func TestParserEndToEnd(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ file string
+ title string
+ readingOrderSize int
+ hasEncryptionXML bool
+ expectedScheme string
+ }{
+ {"Adobe ADEPT", "fake-adept.epub", "Fake Adept DRM", 1, false, ""},
+ {"Barnes & Noble", "fake-bn.epub", "Fake B&N DRM", 1, false, ""},
+ {"Apple FairPlay", "fake-fairplay.epub", "Fake Fairplay DRM", 1, false, ""},
+ {"Kobo", "fake-kobo.epub", "Fake Kobo DRM", 1, false, ""},
+ {"Readium LCP", "fake-lcp.epub", "The Level 999 Villager Chapter 3", 35, true, protection.SchemeLCP},
+ {"Generic encryption (Yahoo)", "yahoo.ypub", "週刊少年マガジン 2019年8号[2019年1月23日発売]", 540, true, ""},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ f := openProtectionFixture(t, tt.file)
+ builder, err := NewParser(nil).Parse(t.Context(), fakeEPUBAsset{name: tt.file}, f)
+ require.NoError(t, err)
+ require.NotNil(t, builder)
+
+ m := builder.Manifest
+ assert.Equal(t, tt.title, m.Metadata.Title())
+ assert.Lenf(t, m.ReadingOrder, tt.readingOrderSize,
+ "expected reading order size %d, got %d", tt.readingOrderSize, len(m.ReadingOrder))
+
+ encryptedCount := 0
+ for _, link := range append(m.ReadingOrder, m.Resources...) {
+ enc, ok := link.Properties["encrypted"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+ encryptedCount++
+ if tt.expectedScheme == "" {
+ _, hasScheme := enc["scheme"]
+ assert.Falsef(t, hasScheme, "%s should not have a scheme", link.Href.String())
+ } else {
+ assert.Equalf(t, tt.expectedScheme, enc["scheme"], "scheme mismatch for %s", link.Href.String())
+ }
+ }
+ if tt.hasEncryptionXML {
+ assert.Greater(t, encryptedCount, 0, "expected at least one resource to carry encryption properties")
+ } else {
+ assert.Zero(t, encryptedCount, "no resource should carry encryption properties")
+ }
+ })
+ }
+}
+
+// TestParseEncryptionDataScheme exercises the same two-step the Parser uses:
+// [protection.IdentifyEPUBProtection] followed by parseEncryptionData, and
+// verifies the detected scheme reaches every encryption.xml entry.
+func TestParseEncryptionDataScheme(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ file string
+ expectScheme string
+ expectEntries bool
+ }{
+ {"Readium LCP", "fake-lcp.epub", protection.SchemeLCP, true},
+ {"Generic encryption (Yahoo)", "yahoo.ypub", "", true},
+ // EPUBs without META-INF/encryption.xml yield no entries at all.
+ {"Adobe ADEPT (no encryption.xml)", "fake-adept.epub", "", false},
+ {"Kobo (no encryption.xml)", "fake-kobo.epub", "", false},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ f := openProtectionFixture(t, tt.file)
+
+ scheme, err := protection.IdentifyEPUBProtection(t.Context(), f)
+ require.NoError(t, err)
+
+ enc, err := parseEncryptionData(t.Context(), f, scheme.URI())
+ require.NoError(t, err)
+ if !tt.expectEntries {
+ assert.Empty(t, enc)
+ return
+ }
+ require.NotEmpty(t, enc)
+ for u, e := range enc {
+ assert.Equalf(t, tt.expectScheme, e.Scheme, "scheme mismatch for %s", u)
+ }
+ })
+ }
+}
diff --git a/pkg/parser/parser_image.go b/pkg/parser/image/parser.go
similarity index 94%
rename from pkg/parser/parser_image.go
rename to pkg/parser/image/parser.go
index 97894a9f..3217b4a9 100644
--- a/pkg/parser/parser_image.go
+++ b/pkg/parser/image/parser.go
@@ -1,4 +1,4 @@
-package parser
+package image
import (
"context"
@@ -12,6 +12,7 @@ import (
"github.com/readium/go-toolkit/pkg/internal/extensions"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/mediatype"
+ "github.com/readium/go-toolkit/pkg/parser"
"github.com/readium/go-toolkit/pkg/pub"
)
@@ -19,6 +20,10 @@ import (
// It can also work for a standalone bitmap file.
type ImageParser struct{}
+func NewParser() ImageParser {
+ return ImageParser{}
+}
+
// Parse implements PublicationParser
func (p ImageParser) Parse(ctx context.Context, asset asset.PublicationAsset, fetcher fetcher.Fetcher) (*pub.Builder, error) {
if ok, err := p.accepts(ctx, asset, fetcher); err != nil || !ok {
@@ -50,7 +55,7 @@ func (p ImageParser) Parse(ctx context.Context, asset asset.PublicationAsset, fe
})
// Try to figure out the publication's title
- title := guessPublicationTitleFromFileStructure(ctx, fetcher)
+ title := parser.GuessPublicationTitleFromFileStructure(ctx, fetcher)
if title == "" {
title = asset.Name()
}
diff --git a/pkg/parser/parser_image_test.go b/pkg/parser/image/parser_test.go
similarity index 99%
rename from pkg/parser/parser_image_test.go
rename to pkg/parser/image/parser_test.go
index cbc9c310..419d7912 100644
--- a/pkg/parser/parser_image_test.go
+++ b/pkg/parser/image/parser_test.go
@@ -1,4 +1,4 @@
-package parser
+package image
import (
"testing"
diff --git a/pkg/parser/image/testdata/image/futuristic_tales.cbz b/pkg/parser/image/testdata/image/futuristic_tales.cbz
new file mode 100644
index 00000000..530f11e9
--- /dev/null
+++ b/pkg/parser/image/testdata/image/futuristic_tales.cbz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1719b4cd61b366358108e086dd4e13fdf5e4f5e169e177fbc281ea54aa6c19a9
+size 719861
diff --git a/pkg/parser/testdata/image/futuristic_tales.jpg b/pkg/parser/image/testdata/image/futuristic_tales.jpg
similarity index 100%
rename from pkg/parser/testdata/image/futuristic_tales.jpg
rename to pkg/parser/image/testdata/image/futuristic_tales.jpg
diff --git a/pkg/parser/parser_audio.go b/pkg/parser/parser_audio.go
deleted file mode 100644
index 0b4baf6c..00000000
--- a/pkg/parser/parser_audio.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package parser
-
-import (
- "context"
- "errors"
- "path/filepath"
- "sort"
- "strings"
-
- "github.com/readium/go-toolkit/pkg/asset"
- "github.com/readium/go-toolkit/pkg/fetcher"
- "github.com/readium/go-toolkit/pkg/internal/extensions"
- "github.com/readium/go-toolkit/pkg/manifest"
- "github.com/readium/go-toolkit/pkg/mediatype"
- "github.com/readium/go-toolkit/pkg/pub"
-)
-
-// Handles parsing of audiobooks 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.
-type AudioParser struct{}
-
-// Parse implements PublicationParser
-func (p AudioParser) Parse(ctx context.Context, asset asset.PublicationAsset, fetcher fetcher.Fetcher) (*pub.Builder, error) {
- if !p.accepts(ctx, asset, fetcher) {
- return nil, nil
- }
-
- links, err := fetcher.Links(ctx)
- if err != nil {
- return nil, err
- }
- readingOrder := make(manifest.LinkList, 0, len(links))
- for _, link := range links {
- path := link.URL(nil, nil).Path()
-
- // Filter out all irrelevant files
- fext := filepath.Ext(strings.ToLower(path))
- if len(fext) > 1 {
- fext = fext[1:] // Remove "." from extension
- }
- _, contains := allowed_extensions_audio[fext]
- if extensions.IsHiddenOrThumbs(path) || !contains {
- continue
- }
- readingOrder = append(readingOrder, link)
- }
-
- if len(readingOrder) == 0 {
- return nil, errors.New("no audio file found in the publication")
- }
-
- // Sort in alphabetical order
- sort.Slice(readingOrder, func(i, j int) bool {
- return readingOrder[i].Href.String() < readingOrder[j].Href.String()
- })
-
- // Try to figure out the publication's title
- title := guessPublicationTitleFromFileStructure(ctx, fetcher)
- if title == "" {
- title = asset.Name()
- }
-
- manifest := manifest.Manifest{
- Context: manifest.Strings{manifest.WebpubManifestContext},
- Metadata: manifest.Metadata{
- LocalizedTitle: manifest.NewLocalizedStringFromString(title),
- ConformsTo: manifest.Profiles{manifest.ProfileAudiobook},
- },
- ReadingOrder: readingOrder,
- }
-
- return pub.NewBuilder(manifest, fetcher, nil), nil // TODO services!
-}
-
-var allowed_extensions_audio_extra = map[string]struct{}{
- "asx": {}, "bio": {}, "m3u": {}, "m3u8": {}, "pla": {}, "pls": {},
- "smil": {}, "txt": {}, "vlc": {}, "wpl": {}, "xspf": {}, "zpl": {},
-}
-var allowed_extensions_audio = map[string]struct{}{
- "aac": {}, "aiff": {}, "alac": {}, "flac": {}, "m4a": {}, "m4b": {}, "mp3": {},
- "ogg": {}, "oga": {}, "mogg": {}, "opus": {}, "wav": {}, "webm": {},
-}
-
-func (p AudioParser) accepts(ctx context.Context, asset asset.PublicationAsset, fetcher fetcher.Fetcher) bool {
- if asset.MediaType(ctx).Equal(&mediatype.ZAB) {
- return true
- }
- links, err := fetcher.Links(ctx)
- if err != nil {
- // TODO log
- return false
- }
- for _, link := range links {
- path := link.URL(nil, nil).Path()
-
- if extensions.IsHiddenOrThumbs(path) {
- continue
- }
- if link.MediaType.IsBitmap() {
- continue
- }
- fext := filepath.Ext(strings.ToLower(path))
- if len(fext) > 1 {
- fext = fext[1:] // Remove "." from extension
- }
- _, contains1 := allowed_extensions_audio[fext]
- _, contains2 := allowed_extensions_audio_extra[fext]
- if !contains1 && !contains2 {
- return false
- }
- }
- return true
-}
diff --git a/pkg/parser/testdata/image/futuristic_tales.cbz b/pkg/parser/testdata/image/futuristic_tales.cbz
deleted file mode 100644
index 48da598b..00000000
Binary files a/pkg/parser/testdata/image/futuristic_tales.cbz and /dev/null differ
diff --git a/pkg/parser/utils.go b/pkg/parser/utils.go
index 6b6f1bfb..624a39e3 100644
--- a/pkg/parser/utils.go
+++ b/pkg/parser/utils.go
@@ -6,7 +6,6 @@ import (
"github.com/readium/go-toolkit/pkg/fetcher"
"github.com/readium/go-toolkit/pkg/manifest"
- "github.com/readium/go-toolkit/pkg/mediatype"
)
func hrefCommonFirstComponent(links manifest.LinkList) string {
@@ -24,7 +23,7 @@ func hrefCommonFirstComponent(links manifest.LinkList) string {
return latest
}
-func guessPublicationTitleFromFileStructure(ctx context.Context, fetcher fetcher.Fetcher) string { // TODO test for this
+func GuessPublicationTitleFromFileStructure(ctx context.Context, fetcher fetcher.Fetcher) string { // TODO test for this
links, err := fetcher.Links(ctx)
if err != nil || len(links) == 0 {
return ""
@@ -39,11 +38,3 @@ func guessPublicationTitleFromFileStructure(ctx context.Context, fetcher fetcher
return commonFirstComponent
}
-
-func isMediatypeReadiumWebPubProfile(mt mediatype.MediaType) bool {
- return mt.Matches(
- &mediatype.ReadiumWebpub, &mediatype.ReadiumWebpubManifest,
- &mediatype.ReadiumAudiobook, &mediatype.ReadiumAudiobookManifest, &mediatype.LCPProtectedAudiobook,
- &mediatype.ReadiumDivina, &mediatype.ReadiumDivinaManifest, &mediatype.LCPProtectedPDF,
- )
-}
diff --git a/pkg/parser/parser_readium_webpub.go b/pkg/parser/webpub/parser.go
similarity index 85%
rename from pkg/parser/parser_readium_webpub.go
rename to pkg/parser/webpub/parser.go
index 878b3b5f..2bef8931 100644
--- a/pkg/parser/parser_readium_webpub.go
+++ b/pkg/parser/webpub/parser.go
@@ -1,4 +1,4 @@
-package parser
+package webpub
import (
"context"
@@ -17,7 +17,7 @@ type WebPubParser struct {
// pdfFactory may never be needed
}
-func NewWebPubParser(client *http.Client) WebPubParser {
+func NewParser(client *http.Client) WebPubParser {
return WebPubParser{
client: client,
}
@@ -85,3 +85,11 @@ func (p WebPubParser) Parse(ctx context.Context, asset asset.PublicationAsset, f
return pub.NewBuilder(*manifest, lFetcher, nil), nil // TODO services!
}
+
+func isMediatypeReadiumWebPubProfile(mt mediatype.MediaType) bool {
+ return mt.Matches(
+ &mediatype.ReadiumWebpub, &mediatype.ReadiumWebpubManifest,
+ &mediatype.ReadiumAudiobook, &mediatype.ReadiumAudiobookManifest, &mediatype.LCPProtectedAudiobook,
+ &mediatype.ReadiumDivina, &mediatype.ReadiumDivinaManifest, &mediatype.LCPProtectedPDF,
+ )
+}
diff --git a/pkg/protection/drm.go b/pkg/protection/drm.go
deleted file mode 100644
index 8e7f1157..00000000
--- a/pkg/protection/drm.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package protection
-
-const (
- SchemeLCP = "http://readium.org/2014/01/lcp"
- SchemeAdept = "http://ns.adobe.com/adept"
-)
-
-// TODO replace with ContentProtection API
-/* type DRMLicense interface {
- EncryptionProfile() string
- Decipher(data []byte) []byte
- CanCopy() bool
- Copy(text string) string
-} */
diff --git a/pkg/protection/epub.go b/pkg/protection/epub.go
new file mode 100644
index 00000000..78d5d212
--- /dev/null
+++ b/pkg/protection/epub.go
@@ -0,0 +1,142 @@
+package protection
+
+import (
+ "context"
+ "strings"
+
+ "github.com/antchfx/xmlquery"
+ "github.com/antchfx/xpath"
+ "github.com/pkg/errors"
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/readium/go-toolkit/pkg/util/url"
+)
+
+// Well-known XML namespaces used by EPUB DRM containers.
+const (
+ namespaceENC = "http://www.w3.org/2001/04/xmlenc#"
+ namespaceSIG = "http://www.w3.org/2000/09/xmldsig#"
+)
+
+// Well-known file paths used by EPUB DRM containers.
+const (
+ pathLCPLicense = "META-INF/license.lcpl"
+ pathEncryption = "META-INF/encryption.xml"
+ pathAdeptRights = "META-INF/rights.xml"
+ pathFairplaySinf = "META-INF/sinf.xml"
+ pathKoboRights = "rights.xml"
+ lcpRetrievalURI = "license.lcpl#/encryption/content_key"
+ barnesAndNobleTag = "barnesandnoble"
+)
+
+var xmlNS = map[string]string{
+ "enc": namespaceENC,
+ "ds": namespaceSIG,
+ "adept": "http://ns.adobe.com/adept",
+ "fairplay": "http://itunes.apple.com/ns/epub",
+}
+
+var (
+ xpLCPRetrieval = mustCompileNS(`//enc:EncryptedData/ds:KeyInfo/ds:RetrievalMethod[@URI="` + lcpRetrievalURI + `"]`)
+ xpAdeptOperator = mustCompileNS("//adept:operatorURL")
+ xpFairplaySinf = mustCompileNS("//fairplay:sinf")
+ xpKdrm = xpath.MustCompile("//kdrm")
+)
+
+func mustCompileNS(expr string) *xpath.Expr {
+ e, err := xpath.CompileWithNS(expr, xmlNS)
+ if err != nil {
+ panic("protection: invalid xpath " + expr + ": " + err.Error())
+ }
+ return e
+}
+
+// IdentifyEPUBProtection inspects the well-known DRM metadata files inside an
+// EPUB container and returns the detected protection [Scheme]. Returns [NoDRM]
+// when no protection metadata is present.
+func IdentifyEPUBProtection(ctx context.Context, f fetcher.Fetcher) (Scheme, error) {
+ links, err := f.Links(ctx)
+ if err != nil {
+ return NoDRM, err
+ }
+
+ hasLink := func(path string) (*manifest.Link, bool) {
+ u, uerr := url.URLFromString(path)
+ if uerr != nil {
+ return nil, false
+ }
+ l := links.FirstWithHref(u)
+ return l, l != nil
+ }
+
+ readXML := func(link *manifest.Link) (*xmlquery.Node, error) {
+ doc, rerr := fetcher.ReadResourceAsXML(ctx, f.Get(ctx, *link))
+ if rerr != nil {
+ if rerr.Code == fetcher.CodeInternalServerError {
+ return nil, nil
+ }
+ return nil, errors.Wrap(rerr.Cause, "unable to read "+link.Href.String())
+ }
+ return doc, nil
+ }
+
+ // LCP: presence of the license file is the strongest signal.
+ if _, ok := hasLink(pathLCPLicense); ok {
+ return LCP, nil
+ }
+
+ // Apple FairPlay: META-INF/sinf.xml containing .
+ if link, ok := hasLink(pathFairplaySinf); ok {
+ doc, derr := readXML(link)
+ if derr != nil {
+ return NoDRM, derr
+ }
+ if doc != nil && xmlquery.QuerySelector(doc, xpFairplaySinf) != nil {
+ return Fairplay, nil
+ }
+ }
+
+ // Adobe ADEPT (and Barnes & Noble): META-INF/rights.xml with .
+ if link, ok := hasLink(pathAdeptRights); ok {
+ doc, derr := readXML(link)
+ if derr != nil {
+ return NoDRM, derr
+ }
+ if doc != nil {
+ if op := xmlquery.QuerySelector(doc, xpAdeptOperator); op != nil {
+ if strings.Contains(strings.ToLower(op.InnerText()), barnesAndNobleTag) {
+ return BarnesAndNoble, nil
+ }
+ return Adept, nil
+ }
+ }
+ }
+
+ // Kobo: rights.xml at the container root containing .
+ if link, ok := hasLink(pathKoboRights); ok {
+ doc, derr := readXML(link)
+ if derr != nil {
+ return NoDRM, derr
+ }
+ if doc != nil && xmlquery.QuerySelector(doc, xpKdrm) != nil {
+ return Kobo, nil
+ }
+ }
+
+ // Fall back to META-INF/encryption.xml: it may reveal LCP via the
+ // retrieval method, or indicate generic/unknown encryption otherwise.
+ if link, ok := hasLink(pathEncryption); ok {
+ doc, derr := readXML(link)
+ if derr != nil {
+ return NoDRM, derr
+ }
+ if doc != nil {
+ if xmlquery.QuerySelector(doc, xpLCPRetrieval) != nil {
+ return LCP, nil
+ }
+ return Generic, nil
+ }
+ }
+
+ return NoDRM, nil
+}
diff --git a/pkg/protection/epub_test.go b/pkg/protection/epub_test.go
new file mode 100644
index 00000000..30da86ea
--- /dev/null
+++ b/pkg/protection/epub_test.go
@@ -0,0 +1,40 @@
+package protection
+
+import (
+ "testing"
+
+ "github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIdentifyEPUBProtection(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ file string
+ want Scheme
+ }{
+ {"Readium LCP", "fake-lcp.epub", LCP},
+ {"Adobe ADEPT", "fake-adept.epub", Adept},
+ {"Barnes & Noble", "fake-bn.epub", BarnesAndNoble},
+ {"Apple FairPlay", "fake-fairplay.epub", Fairplay},
+ {"Kobo", "fake-kobo.epub", Kobo},
+ {"Generic encryption", "yahoo.ypub", Generic},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ f, err := fetcher.NewArchiveFetcherFromPath(t.Context(), "./testdata/"+tt.file)
+ require.NoError(t, err)
+ defer f.Close()
+
+ got, err := IdentifyEPUBProtection(t.Context(), f)
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got, "unexpected scheme for %s", tt.file)
+ })
+ }
+}
+
+func TestIdentifyEPUBProtectionNoDRM(t *testing.T) {
+ got, err := IdentifyEPUBProtection(t.Context(), fetcher.EmptyFetcher{})
+ require.NoError(t, err)
+ assert.Equal(t, NoDRM, got)
+}
diff --git a/pkg/protection/protection.go b/pkg/protection/protection.go
new file mode 100644
index 00000000..f60f13a6
--- /dev/null
+++ b/pkg/protection/protection.go
@@ -0,0 +1,9 @@
+package protection
+
+const (
+ SchemeLCP = "http://readium.org/2014/01/lcp"
+ SchemeAdept = "http://ns.adobe.com/adept"
+ SchemeFairPlay = "http://itunes.apple.com/ns/epub"
+)
+
+// TODO: ContentProtection API
diff --git a/pkg/protection/testdata/fake-adept.epub b/pkg/protection/testdata/fake-adept.epub
new file mode 100644
index 00000000..63d955b9
Binary files /dev/null and b/pkg/protection/testdata/fake-adept.epub differ
diff --git a/pkg/protection/testdata/fake-bn.epub b/pkg/protection/testdata/fake-bn.epub
new file mode 100644
index 00000000..785b1d12
Binary files /dev/null and b/pkg/protection/testdata/fake-bn.epub differ
diff --git a/pkg/protection/testdata/fake-fairplay.epub b/pkg/protection/testdata/fake-fairplay.epub
new file mode 100644
index 00000000..2e5e9343
Binary files /dev/null and b/pkg/protection/testdata/fake-fairplay.epub differ
diff --git a/pkg/protection/testdata/fake-kobo.epub b/pkg/protection/testdata/fake-kobo.epub
new file mode 100644
index 00000000..0724a6bb
Binary files /dev/null and b/pkg/protection/testdata/fake-kobo.epub differ
diff --git a/pkg/protection/testdata/fake-lcp.epub b/pkg/protection/testdata/fake-lcp.epub
new file mode 100644
index 00000000..c92f255a
Binary files /dev/null and b/pkg/protection/testdata/fake-lcp.epub differ
diff --git a/pkg/protection/testdata/yahoo.ypub b/pkg/protection/testdata/yahoo.ypub
new file mode 100644
index 00000000..d977d14b
Binary files /dev/null and b/pkg/protection/testdata/yahoo.ypub differ
diff --git a/pkg/protection/type.go b/pkg/protection/type.go
new file mode 100644
index 00000000..6a225cd6
--- /dev/null
+++ b/pkg/protection/type.go
@@ -0,0 +1,31 @@
+package protection
+
+//go:generate stringer -type Scheme
+
+type Scheme int
+
+const (
+ NoDRM Scheme = iota
+ Generic
+ LCP
+ Adept
+ BarnesAndNoble
+ Fairplay
+ Kobo
+)
+
+// URI returns the canonical namespace URI for the DRM scheme, suitable for
+// populating [manifest.Encryption]'s Scheme field. Returns the empty string for
+// [NoDRM] and [Generic] (no recognized DRM identifier).
+func (s Scheme) URI() string {
+ switch s {
+ case LCP:
+ return SchemeLCP
+ case Adept, BarnesAndNoble:
+ return SchemeAdept
+ case Fairplay:
+ return SchemeFairPlay
+ default:
+ return ""
+ }
+}
diff --git a/pkg/protection/type_string.go b/pkg/protection/type_string.go
new file mode 100644
index 00000000..1903f84e
--- /dev/null
+++ b/pkg/protection/type_string.go
@@ -0,0 +1,29 @@
+// Code generated by "stringer -type Scheme"; DO NOT EDIT.
+
+package protection
+
+import "strconv"
+
+func _() {
+ // An "invalid array index" compiler error signifies that the constant values have changed.
+ // Re-run the stringer command to generate them again.
+ var x [1]struct{}
+ _ = x[NoDRM-0]
+ _ = x[Generic-1]
+ _ = x[LCP-2]
+ _ = x[Adept-3]
+ _ = x[BarnesAndNoble-4]
+ _ = x[Fairplay-5]
+ _ = x[Kobo-6]
+}
+
+const _Scheme_name = "NoDRMGenericLCPAdeptBarnesAndNobleFairplayKobo"
+
+var _Scheme_index = [...]uint8{0, 5, 12, 15, 20, 34, 42, 46}
+
+func (i Scheme) String() string {
+ if i < 0 || i >= Scheme(len(_Scheme_index)-1) {
+ return "Scheme(" + strconv.FormatInt(int64(i), 10) + ")"
+ }
+ return _Scheme_name[_Scheme_index[i]:_Scheme_index[i+1]]
+}
diff --git a/pkg/streamer/a11y_infer_test.go b/pkg/streamer/a11y_infer_test.go
index 177c4c34..60e98ddd 100644
--- a/pkg/streamer/a11y_infer_test.go
+++ b/pkg/streamer/a11y_infer_test.go
@@ -49,15 +49,15 @@ func newLink(mt mediatype.MediaType, extension string) manifest.Link {
// If the publication contains a reference to an audio or video resource
// (inspect "resources" and "readingOrder" in RWPM).
func TestInferAuditoryAccessMode(t *testing.T) {
- assertAccessMode(t, manifest.A11yAccessModeAuditory, "mp3", mediatype.MP3)
- assertAccessMode(t, manifest.A11yAccessModeAuditory, "mpeg", mediatype.MPEG)
+ assertAccessMode(t, manifest.A11yAccessModeAuditory, "mp3", mediatype.MPEGAudio)
+ assertAccessMode(t, manifest.A11yAccessModeAuditory, "mpeg", mediatype.MPEGVideo)
}
// If the publications contains a reference to an image or a video resource
// (inspect "resources" and "readingOrder" in RWPM)
func TestInferVisualAccessMode(t *testing.T) {
assertAccessMode(t, manifest.A11yAccessModeVisual, "jpg", mediatype.JPEG)
- assertAccessMode(t, manifest.A11yAccessModeVisual, "mpeg", mediatype.MPEG)
+ assertAccessMode(t, manifest.A11yAccessModeVisual, "mpeg", mediatype.MPEGVideo)
}
func assertAccessMode(t *testing.T, accessMode manifest.A11yAccessMode, extension string, mt mediatype.MediaType) {
@@ -135,8 +135,8 @@ func TestInferTextualAccessModeAndAccessModeSufficientFromLackOfMedia(t *testing
testReadingOrder(true, mediatype.HTML, "html")
testReadingOrder(false, mediatype.JPEG, "jpg")
- testReadingOrder(false, mediatype.MP3, "mp3")
- testReadingOrder(false, mediatype.MPEG, "mpeg")
+ testReadingOrder(false, mediatype.MPEGAudio, "mp3")
+ testReadingOrder(false, mediatype.MPEGVideo, "mpeg")
testReadingOrder(false, mediatype.PDF, "pdf")
testResources := func(contains bool, mt mediatype.MediaType, extension string) {
@@ -154,8 +154,8 @@ func TestInferTextualAccessModeAndAccessModeSufficientFromLackOfMedia(t *testing
testResources(true, mediatype.HTML, "html")
testResources(false, mediatype.JPEG, "jpg")
- testResources(false, mediatype.MP3, "mp3")
- testResources(false, mediatype.MPEG, "mpeg")
+ testResources(false, mediatype.MPEGAudio, "mp3")
+ testResources(false, mediatype.MPEGVideo, "mpeg")
testResources(false, mediatype.PDF, "pdf")
}
@@ -254,7 +254,7 @@ func TestInferAuditoryAccessModeSufficient(t *testing.T) {
}
html := newLink(mediatype.HTML, "html")
- mp3 := newLink(mediatype.MP3, "mp3")
+ mp3 := newLink(mediatype.MPEGAudio, "mp3")
testReadingOrder(false, html, html)
testReadingOrder(false, html, mp3)
@@ -302,7 +302,7 @@ func TestInferVisualAccessModeSufficient(t *testing.T) {
html := newLink(mediatype.HTML, "html")
jpg := newLink(mediatype.JPEG, "jpg")
- mpeg := newLink(mediatype.MPEG, "mpeg")
+ mpeg := newLink(mediatype.MPEGVideo, "mpeg")
testReadingOrder(false, html)
testReadingOrder(false, html, jpg)
diff --git a/pkg/streamer/streamer.go b/pkg/streamer/streamer.go
index 91806687..74f7a92f 100644
--- a/pkg/streamer/streamer.go
+++ b/pkg/streamer/streamer.go
@@ -9,8 +9,11 @@ import (
"github.com/readium/go-toolkit/pkg/asset"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/parser"
+ "github.com/readium/go-toolkit/pkg/parser/audio"
"github.com/readium/go-toolkit/pkg/parser/epub"
+ "github.com/readium/go-toolkit/pkg/parser/image"
"github.com/readium/go-toolkit/pkg/parser/pdf"
+ "github.com/readium/go-toolkit/pkg/parser/webpub"
"github.com/readium/go-toolkit/pkg/pub"
)
@@ -71,9 +74,9 @@ func New(config Config) Streamer { // TODO contentProtections
defaultParsers := []parser.PublicationParser{
epub.NewParser(nil), // TODO pass strategy
pdf.NewParser(),
- parser.NewWebPubParser(config.HttpClient),
- parser.ImageParser{},
- parser.AudioParser{},
+ webpub.NewParser(config.HttpClient),
+ image.NewParser(),
+ audio.NewRichParser(),
}
if !config.IgnoreDefaultParsers {