audioproxy-rails 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2c006e53f916a943195a3887a184f3fa141e086227ecc03919199a335f32df7
4
- data.tar.gz: 110cc6e5eb81d7fb34d3688b2c56953d822792157a27e9669d1f25c17b86d1e1
3
+ metadata.gz: 4de1340b2094f8154a8c5f02b45c5409fc7b0468967cbe9bf0bda9d836264f4d
4
+ data.tar.gz: 6a69838fa47da950bdd6a80880b31e2f2a75dad76991cfa52599dc01ea92925a
5
5
  SHA512:
6
- metadata.gz: 8c506590e9344488d0cc4222aec06ae92315628e685526fd8a7b2c1ab7692997dadba3b84294d7bcf1f1c4d653c5b6b8320e695c88ce86d571a83d0e5561ab4b
7
- data.tar.gz: a1a54ccb83749af4b65339ba42d29c11ae76d1cc914376461388dcf70707febf0771159df327b54b26ad00692371b5980829e8b7031f1077235ab31546168dff
6
+ metadata.gz: 0c86091fd6f663dab0785223d5cc9a19d4396d04243363aa8d2ec9ddb94d631eb6c626319c8cd7ca40f0927c14abfdf436e498a9eeecaf5fb00e63e79b424a07
7
+ data.tar.gz: 966e160dfe9a51c1e3e47c0c6bcad259a7a4a829242917fbe2428d9f7f1c03f00132c5f753a1b1bceac06e1933689d3a9934f4e7cebdf4e4a7689fe9ebffc777
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ * Probe metadata URLs. `Audioproxy.info_url(source)` and the `audioproxy_info_url` view helper
6
+ build the proxy's `/info` URL, which returns duration, sample rate, channels and tags as JSON. The
7
+ proxy's `/info` has no options segment, so `info_url` raises for any option or `raw:`, and ignores
8
+ `config.default_options` and `config.expires_in`. Info URLs therefore never expire, even when
9
+ every other URL your app builds does.
10
+
11
+ * Waveform peaks URLs. `Audioproxy.peaks_url(source, **options)` and the `audioproxy_peaks_url`
12
+ view helper build a variant URL with `f:peaks` fixed. They accept only the options that change a
13
+ waveform (`pts`, `pk_fmt`, `pk_bits`, `ch`, `t`, `fade`, `gain`, `norm`, `dl`, `cb`) and raise on
14
+ the rest, since the proxy refuses encoding options on peaks. Of the configured defaults, only
15
+ `t`, `fade`, `gain`, `norm` and `cb` carry over, so the waveform follows normalized audio while an
16
+ audio default such as `br: 96` or `ch: 2` never reaches a peaks request.
17
+
18
+ * `pk_bits` option, aliased as `peak_bits`: the width of each peaks value, 8 or 16. peaks.js reads
19
+ only 8-bit data, so a peaks.js client asks for `pk_bits: 8`. Requires audioproxy 0.8.0 or newer;
20
+ older proxies answer `pk_bits:` with a `422`.
21
+
22
+ * `url_for` output is unchanged, byte for byte.
23
+
3
24
  ## 0.2.0
4
25
 
5
26
  * Expiring URLs. `url_for` and every view helper accept `expires_in:` (a duration or Integer
data/README.md CHANGED
@@ -10,9 +10,9 @@ Full Rails is a development dependency only. `require "audioproxy"` works in a p
10
10
 
11
11
  ## Status
12
12
 
13
- Core signing and typed options work, in both the proxy's short spellings and their aliases, as do the Railtie's credentials/ENV wiring, the view helpers — URL, `<audio>` tag and preload hint — and ActiveStorage resolution for the S3 and Disk services. Blobs on any other service raise; see [ActiveStorage](#activestorage) for what to do about that.
13
+ Core signing and typed options work, in both the proxy's short spellings and their aliases, as do [`info` and peaks URLs](#metadata-and-waveforms), the Railtie's credentials/ENV wiring, the view helpers — URL, `<audio>` tag and preload hint — and ActiveStorage resolution for the S3 and Disk services. Blobs on any other service raise; see [ActiveStorage](#activestorage) for what to do about that.
14
14
 
15
- [Expiring URLs](#expiring-urls) work too, against [audioproxy 0.6.0 or newer](#minimum-proxy-version). One caveat worth knowing either way: everything in this gem is verified against the proxy's published signature vectors and its source, but no test here has yet asked a running proxy whether a generated URL is accepted. That round-trip is the next change.
15
+ [Expiring URLs](#expiring-urls) work too, against [audioproxy 0.6.0 or newer](#minimum-proxy-version), and `pk_bits` needs 0.8.0. Everything here is verified against the proxy's published signature vectors and its source, and CI also asks a running proxy to accept signed, unsigned, typed-option and expiring URLs. `info` and peaks URLs are not in that round-trip yet.
16
16
 
17
17
  ## Installation
18
18
 
@@ -92,6 +92,24 @@ Audioproxy.url_for("s3://masters/2026/piece-final.wav", raw: "f:opus/br:96")
92
92
 
93
93
  The result is `{endpoint}/{signature}/{options}/{source}`. The source is always emitted in `enc/` form (unpadded base64url), so spaces, nested URLs, and already-escaped bytes need no special handling.
94
94
 
95
+ ### Metadata and waveforms
96
+
97
+ The proxy answers two more things a player needs around playback, and each has its own entry point:
98
+
99
+ ```ruby
100
+ Audioproxy.info_url("s3://masters/piece.wav")
101
+ # => "https://audio.example.com/…/info/enc/czM6Ly9tYXN0ZXJz…"
102
+
103
+ Audioproxy.peaks_url("s3://masters/piece.wav", pts: 800, pk_bits: 8)
104
+ # => "https://audio.example.com/…/f:peaks/pts:800/pk_bits:8/enc/czM6Ly9tYXN0ZXJz…"
105
+ ```
106
+
107
+ `info_url` builds the proxy's probe-metadata URL: duration, sample rate, channels and tags as JSON, which is how a player learns the duration it needs before it can write a sensible `t:`. It takes **no options**, because the proxy answers any options segment alongside `info` with a `422`. So it raises for a typed key or `raw:`, and it ignores `config.default_options`, the one entry point that does. It also ignores `config.expires_in` and raises for a per-call `expires_in:` or `expires_at:`, since `/info` has nowhere to carry `exp`: **info URLs never expire, even when every other URL your app builds does.** The proxy treats them as operator-facing rather than something handed to listeners, so keep them that way. Info responses are cached for an hour and are deliberately not `immutable`, because a re-upload changes the answer; do not memoize a response forever.
108
+
109
+ `peaks_url` is a variant URL with the format fixed to `f:peaks`. It accepts only the options that change a waveform: `pts`, `pk_fmt`, `pk_bits`, `ch`, `t`, `fade`, `gain`, `norm`, `dl` and `cb`, in either spelling. Anything else raises, naming that list. The proxy refuses `br`, `q`, `sr` and `bd` on a peaks request, and an option it merely ignored would still enter its cache key and buy a second render of identical peaks. `raw:` is refused too, because a pre-rendered string cannot be checked; `url_for(source, raw: "f:peaks/…")` stays the escape hatch.
110
+
111
+ Only some configured defaults carry over to peaks: `t`, `fade`, `gain` and `norm`, which change the samples, so the waveform matches the audio drawn above it, and the cache buster `cb`. The rest are skipped. `f:opus` or `br:96` would render into a `422`, and `ch` and `dl` mean something else as audio defaults: `ch: 2` is stereo output for audio but per-channel pairs for peaks, and an audio filename is the wrong name for peaks JSON. Pass either per call when you want it on the peaks. A `raw:` default is skipped entirely. Expiry works exactly as it does for `url_for`. The gem does not add `ch:1` for you even though peaks default to mono; the proxy fills in its own defaults.
112
+
95
113
  ## Options
96
114
 
97
115
  Describe the variant with the proxy's option keys as Ruby keyword arguments, either in the proxy's own short spelling or in the spelled-out alias next to it:
@@ -115,9 +133,12 @@ Audioproxy.url_for("s3://masters/piece.wav", f: :opus, br: 96, t: [12.5, 30])
115
133
  | `norm` | `normalize` | `norm: [:ebu, -16, -1.5, 11]` | loudness normalization: mode, then I, TP, LRA |
116
134
  | `pts` | `peak_count` | `pts: 800` | peak points, for waveform output |
117
135
  | `pk_fmt` | `peak_format` | `pk_fmt: :json` | peaks format |
136
+ | `pk_bits` | `peak_bits` | `pk_bits: 8` | peaks value width, 8 or 16 |
118
137
  | `dl` | `download` | `dl: "piece.mp3"` | download filename |
119
138
  | `cb` | `cache_buster` | `cb: "v2"` | cache buster |
120
139
 
140
+ If the peaks are for [peaks.js](https://github.com/bbc/peaks.js), ask for `pk_bits: 8`: peaks.js only loads 8-bit waveform data, and the proxy's default width is 16. The alias is `peak_bits` rather than `bit_depth`, because `bit_depth` already means `bd`, the sample format of encoded audio.
141
+
121
142
  Segments render in the order you write the keywords. The gem does not sort them and does not materialize defaults; that is the proxy's normalization, and a half-normalization here would only invent a third spelling. If you want URLs to stay stable across a codebase, keep the argument order stable.
122
143
 
123
144
  `t`, `fade` and `norm` take colon-separated parts, so they take arrays: `t: [12.5, 30]` renders `t:12.5:30`. A single part can be written as a scalar: `t: 12.5` renders `t:12.5`. Symbols and strings render alike, so `f: :opus` and `f: "opus"` are the same URL.
@@ -274,7 +295,11 @@ An expiry composes with `raw:` rather than replacing it, since `exp` is not a va
274
295
 
275
296
  ### Minimum proxy version
276
297
 
277
- Expiring URLs need **audioproxy 0.6.0 or newer**, the release that added the `exp` option. `0.5.0` and earlier answer `exp:` with a `422 invalid option`. That failure is loud and server-side, so pointing this gem at an older proxy breaks visibly rather than silently ignoring the expiry, and every other feature here works against `0.5.0` unchanged. This gem does not version-sniff.
298
+ Expiring URLs need **audioproxy 0.6.0 or newer**, the release that added the `exp` option. `0.5.0` and earlier answer `exp:` with a `422 invalid option`. That failure is loud and server-side, so pointing this gem at an older proxy breaks visibly rather than silently ignoring the expiry.
299
+
300
+ `pk_bits` needs **audioproxy 0.8.0 or newer**, the release that added it. Older proxies answer `pk_bits:` with a `422` for an unknown key.
301
+
302
+ Every other feature here works against `0.5.0` unchanged. This gem does not version-sniff.
278
303
 
279
304
  ## Rails
280
305
 
@@ -351,6 +376,8 @@ Nothing is validated at boot. An app with no credentials and no ENV boots fine
351
376
 
352
377
  The `html:` bucket is not ceremony. Without it, proxy option names and HTML attribute names would share one namespace, and the gem would have to guess which one you meant for any key it did not recognize — so a mistyped `bitrat: 96` would land silently on the `<audio>` element as an attribute and quietly ship the default format instead. With the bucket, the two never mix in either direction: an unknown proxy option raises, and an `html:` entry never reaches the proxy. Proxy options never appear as tag attributes.
353
378
 
379
+ `audioproxy_info_url` and `audioproxy_peaks_url` are [`info_url` and `peaks_url`](#metadata-and-waveforms) under view names, as `audioproxy_url` is `url_for`. There are no tag helpers for either: both return JSON (or peaks' binary `.dat`) for a script to fetch, and there is no HTML element to hand them to.
380
+
354
381
  #### Preloading a variant
355
382
 
356
383
  `audioproxy_preload_link_tag` emits a resource hint for a variant you are about to play. Because the first request for a variant is a render, the hint overlaps that render with page load rather than leaving someone waiting for it after a click:
@@ -447,6 +474,63 @@ It boots the dummy Rails app in `test/dummy` for the integration tests. Style ch
447
474
  bin/rubocop
448
475
  ```
449
476
 
477
+ ### Round-trip tests against a real proxy
478
+
479
+ `bin/test` checks the bytes this gem produces. It cannot check that a real proxy *accepts* them, and
480
+ some things are not expressible as a byte comparison at all: whether an expired URL is refused with
481
+ `410`, and whether the second `exp` names is still served, are questions only a running proxy
482
+ answers.
483
+
484
+ Those tests live in `test/server/`, and they are skipped unless you tell them where a proxy is. The
485
+ gem does not start one for you — start it yourself:
486
+
487
+ ```bash
488
+ mkdir -p /tmp/audioproxy-fixtures
489
+
490
+ docker run --rm -d --name audioproxy-test \
491
+ --platform linux/amd64 \
492
+ -p 4000:4000 \
493
+ -e AP_KEY=00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF \
494
+ -e AP_SALT=FFEEDDCCBBAA99887766554433221100 \
495
+ -e AP_ALLOW_INSECURE=true \
496
+ -e AP_LOCAL_ROOT=/srv/audio \
497
+ -v /tmp/audioproxy-fixtures:/srv/audio \
498
+ ghcr.io/audioproxy/audioproxy:0.6.0
499
+ ```
500
+
501
+ Then point the suite at it:
502
+
503
+ ```bash
504
+ AUDIOPROXY_PROXY_URL=http://127.0.0.1:4000 bin/test test/server
505
+ ```
506
+
507
+ Drop the path to run everything, unit tests included. Stop it with
508
+ `docker rm -f audioproxy-test` when you are done.
509
+
510
+ The key and salt above are the published known-answer test vectors — not secrets, and the same ones
511
+ in `test/fixtures/signature_vectors.rb`, so the round-trips exercise the bytes those vectors pin.
512
+ The suite writes a generated WAV into `/tmp/audioproxy-fixtures` (override with
513
+ `AUDIOPROXY_FIXTURE_ROOT`), so there is nothing to fetch and you need no ffmpeg on your side.
514
+ `--platform linux/amd64` is there because the published image is amd64-only; on Apple Silicon it
515
+ runs under emulation, and on an amd64 host the flag does nothing.
516
+
517
+ A few things worth knowing:
518
+
519
+ - **`AUDIOPROXY_PROXY_URL` unset means skip; set means fail if nothing answers.** Setting it is a
520
+ claim that a proxy is there, so an unreachable one is an error rather than a quiet pass. That
521
+ keeps `bin/test` a fast, Docker-free unit run for anyone who cannot pull an image, without
522
+ letting a broken round-trip environment look like a green one.
523
+ - **The proxy version is checked, not assumed.** The suite asks `/health` what it is talking to and
524
+ refuses to run against anything but the pinned release, so a mismatch is a named failure rather
525
+ than a confusing one.
526
+ - **The pin is advanced by hand, as part of the proxy's release checklist.** A pinned tag nobody
527
+ advances stops testing anything interesting, and it fails silently — by passing. When the proxy
528
+ releases, bump `PROXY_VERSION` in `test/support/server_roundtrip.rb` and the image in
529
+ `.github/workflows/ci.yml`. Forgetting one of the two is caught by the version check above.
530
+ - **In CI the proxy is a service container**, and the round-trip job is **visible but not required**:
531
+ it goes red on failure, but putting it in branch protection would make every merge depend on ghcr
532
+ being up, and the unit suite is the real safety net.
533
+
450
534
  ## License
451
535
 
452
536
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -11,8 +11,8 @@ module Audioproxy
11
11
  # carrying a separator — because a mangled segment is a valid-looking URL for
12
12
  # the wrong variant, and it fails at request time, far from here.
13
13
  module Options
14
- # The proxy's fifteen option keys, canonical short spellings.
15
- KEYS = %i[bd br cb ch dl exp f fade gain norm pk_fmt pts q sr t].freeze
14
+ # The proxy's sixteen option keys, canonical short spellings.
15
+ KEYS = %i[bd br cb ch dl exp f fade gain norm pk_bits pk_fmt pts q sr t].freeze
16
16
 
17
17
  # The one *request* option in the grammar: signed as path bytes, but
18
18
  # excluded from the proxy's canonical options string, its cache key and its
@@ -26,8 +26,9 @@ module Audioproxy
26
26
  # rather read than decode. Total over KEYS, so "does this key have an alias"
27
27
  # never has two answers: +fade+ and +gain+ are already words and alias to
28
28
  # themselves. The names are the proxy's own where it has one — its Options
29
- # struct calls pts +peak_count+ and pk_fmt +peak_format+ — so this is one
30
- # vocabulary spelled twice, not a second vocabulary (D2).
29
+ # struct calls pts +peak_count+, pk_fmt +peak_format+ and pk_bits
30
+ # +peak_bits+ — so this is one vocabulary spelled twice, not a second
31
+ # vocabulary (D2). peak_bits is deliberately not bit_depth, which bd owns.
31
32
  ALIASES = {
32
33
  f: :format,
33
34
  br: :bitrate,
@@ -41,6 +42,7 @@ module Audioproxy
41
42
  norm: :normalize,
42
43
  pts: :peak_count,
43
44
  pk_fmt: :peak_format,
45
+ pk_bits: :peak_bits,
44
46
  dl: :download,
45
47
  cb: :cache_buster,
46
48
  exp: :expires_at
@@ -53,6 +55,22 @@ module Audioproxy
53
55
  .merge(KEYS.to_h { |key| [ key, key ] })
54
56
  .freeze
55
57
 
58
+ # The keys that reach the proxy's peaks renderer (API v1 §3.3): the peaks
59
+ # keys themselves, and the ones peaks follow so a waveform matches the
60
+ # audio drawn under it. A positive list on purpose (D4): an ignored option
61
+ # still enters the proxy's cache key, so one that slips through buys a
62
+ # second render of byte-identical peaks, and a key added to the proxy later
63
+ # is refused here until someone checks it belongs.
64
+ PEAKS_KEYS = %i[pts pk_fmt pk_bits ch t fade gain norm dl cb].freeze
65
+
66
+ # The configured defaults that carry over to peaks (D10): the ones that
67
+ # change the samples, so a waveform matches the audio drawn above it, and
68
+ # the cache buster. Narrower than PEAKS_KEYS because a default was written
69
+ # for audio: ch:2 there means stereo output, but on peaks it means
70
+ # per-channel pairs instead of the mono downmix, and a dl: filename for the
71
+ # audio is never the right name for peaks JSON.
72
+ PEAKS_DEFAULT_KEYS = %i[t fade gain norm cb].freeze
73
+
56
74
  # Keys whose grammar takes colon-separated parts: +t:START[:DURATION]+,
57
75
  # +fade:IN[:OUT]+, +norm:ebu[:I[:TP[:LRA]]]+.
58
76
  MULTI_PART_KEYS = %i[t fade norm].freeze
@@ -11,6 +11,18 @@ module Audioproxy
11
11
  Audioproxy.url_for(source, **options)
12
12
  end
13
13
 
14
+ # URL helpers only, with no tag counterparts on purpose (D7): info is
15
+ # JSON and peaks are JSON or a binary .dat, both fetched by a script, so
16
+ # there is no element to hand them to. A <div data-peaks-url> convention
17
+ # would make this gem the keeper of some JavaScript library's markup.
18
+ def audioproxy_info_url(source, **options)
19
+ Audioproxy.info_url(source, **options)
20
+ end
21
+
22
+ def audioproxy_peaks_url(source, **options)
23
+ Audioproxy.peaks_url(source, **options)
24
+ end
25
+
14
26
  # The html: bucket is the seam between proxy options and tag attributes
15
27
  # (D4). Without it, proxy option keys and HTML attribute names share one
16
28
  # namespace, and a typoed option lands silently on the <audio> element
@@ -19,6 +19,11 @@ module Audioproxy
19
19
  # always-valid options string is its default format spelled out.
20
20
  FALLBACK_OPTIONS = "f:mp3".freeze
21
21
 
22
+ INFO_SEGMENT = "info".freeze
23
+
24
+ # Builder keywords that carry an expiry, which /info cannot hold (D9).
25
+ EXPIRY_KEYWORDS = %i[expires_in expires_at].freeze
26
+
22
27
  attr_reader :config
23
28
 
24
29
  def initialize(config = Audioproxy.config)
@@ -36,20 +41,32 @@ module Audioproxy
36
41
  # overriding +config.expires_in+; either given as nil opts out of it (D6).
37
42
  def url_for(source, raw: nil, endpoint: nil, unsigned: nil,
38
43
  expires_in: Expiry::UNSET, expires_at: Expiry::UNSET, **typed)
39
- base = endpoint.nil? ? config.endpoint : Config.new.tap { |c| c.endpoint = endpoint }.endpoint
40
- raise ConfigurationError, "Audioproxy has no endpoint configured" if base.nil?
44
+ assemble(source, endpoint: endpoint, unsigned: unsigned) do
45
+ expiry = expiry_for(expires_in, expires_at)
46
+ with_expiry(variant_segment(raw, typed), expiry)
47
+ end
48
+ end
41
49
 
42
- # Once per URL, so the window arithmetic and the past-check cannot
43
- # straddle a second boundary (D4).
44
- expiry = Expiry.timestamp(
45
- expires_in: expires_in, expires_at: expires_at,
46
- default_expires_in: config.expires_in, now: Time.now.to_i
47
- )
50
+ # The proxy's probe-metadata endpoint, +/{sig}/info/{source}+. API v1 §4:
51
+ # any options segment alongside +info+ is a 422, and the grammar therefore
52
+ # cannot carry +exp+ either. So neither +config.default_options+ (D2) nor
53
+ # +config.expires_in+ (D9) is consulted here — the one entry point that
54
+ # ignores them, because honouring them would break every info request.
55
+ def info_url(source, endpoint: nil, unsigned: nil, **options)
56
+ reject_info_options!(options)
48
57
 
49
- rest_of_path = "/#{options_segment(raw, typed, expiry)}/#{source_segment(source)}"
50
- insecure = unsigned.nil? ? config.unsigned : unsigned
58
+ assemble(source, endpoint: endpoint, unsigned: unsigned) { INFO_SEGMENT }
59
+ end
51
60
 
52
- "#{base}/#{insecure ? INSECURE_SEGMENT : sign(rest_of_path)}#{rest_of_path}"
61
+ # A variant URL with the format fixed to +f:peaks+. Peaks are a format, not
62
+ # an endpoint, so this is url_for's grammar with a screened vocabulary: only
63
+ # Options::PEAKS_KEYS pass, per call and from the defaults (D4, D10).
64
+ def peaks_url(source, raw: nil, endpoint: nil, unsigned: nil,
65
+ expires_in: Expiry::UNSET, expires_at: Expiry::UNSET, **typed)
66
+ assemble(source, endpoint: endpoint, unsigned: unsigned) do
67
+ expiry = expiry_for(expires_in, expires_at)
68
+ with_expiry(peaks_segment(raw, typed), expiry)
69
+ end
53
70
  end
54
71
 
55
72
  # Signs via Audioproxy::Signer. Whether the config *can* sign is this
@@ -64,14 +81,31 @@ module Audioproxy
64
81
  end
65
82
 
66
83
  private
84
+ # The block yields the segment between signature and source, and runs
85
+ # after the endpoint check, so a missing endpoint is reported first.
86
+ def assemble(source, endpoint:, unsigned:)
87
+ base = endpoint.nil? ? config.endpoint : Config.new.tap { |c| c.endpoint = endpoint }.endpoint
88
+ raise ConfigurationError, "Audioproxy has no endpoint configured" if base.nil?
89
+
90
+ rest_of_path = "/#{yield}/#{source_segment(source)}"
91
+ insecure = unsigned.nil? ? config.unsigned : unsigned
92
+
93
+ "#{base}/#{insecure ? INSECURE_SEGMENT : sign(rest_of_path)}#{rest_of_path}"
94
+ end
95
+
96
+ # Once per URL, so the window arithmetic and the past-check cannot
97
+ # straddle a second boundary (D4).
98
+ def expiry_for(expires_in, expires_at)
99
+ Expiry.timestamp(
100
+ expires_in: expires_in, expires_at: expires_at,
101
+ default_expires_in: config.expires_in, now: Time.now.to_i
102
+ )
103
+ end
104
+
67
105
  # Precedence, per D4: an explicit per-call source of options replaces the
68
106
  # configured defaults entirely; typed per-call keys merge over typed
69
107
  # defaults key-by-key, keeping the defaults' position and appending the
70
108
  # rest in caller order.
71
- def options_segment(raw, typed, expiry)
72
- with_expiry(variant_segment(raw, typed), expiry)
73
- end
74
-
75
109
  def variant_segment(raw, typed)
76
110
  unless raw.nil? || typed.empty?
77
111
  raise ArgumentError,
@@ -98,6 +132,62 @@ module Audioproxy
98
132
  FALLBACK_OPTIONS
99
133
  end
100
134
 
135
+ # Screened on the keys as written, so an error echoes the spelling the
136
+ # caller typed rather than a canonical key they never saw.
137
+ def peaks_segment(raw, typed)
138
+ unless raw.nil?
139
+ raise ArgumentError,
140
+ "Audioproxy peaks_url does not take raw:, because a pre-rendered string cannot be checked " \
141
+ "against the options peaks accept; use url_for(source, raw: \"f:peaks/…\") to write it whole"
142
+ end
143
+
144
+ written = typed.keys
145
+ typed = Options.resolve(typed)
146
+ reject_expiry_option!(typed, "peaks_url")
147
+
148
+ if typed.key?(:f)
149
+ format = typed.delete(:f)
150
+ unless format == :peaks || format == "peaks"
151
+ key = written.find { |spelling| Options::CANONICAL[spelling.to_sym] == :f }
152
+ raise ArgumentError,
153
+ "Audioproxy peaks_url fixes the format to peaks, but was also given #{key}: #{format.inspect}; " \
154
+ "use url_for for any other format"
155
+ end
156
+ end
157
+
158
+ refused = written.reject { |key| [ :f, *Options::PEAKS_KEYS ].include?(Options::CANONICAL[key.to_sym]) }
159
+ unless refused.empty?
160
+ raise ArgumentError,
161
+ "Audioproxy peaks_url does not take #{refused.join(", ")}; peaks accept only " \
162
+ "#{Options::PEAKS_KEYS.join(", ")}, each also accepted spelled out. Any other option still " \
163
+ "enters the proxy's cache key, so it would buy a second render of identical peaks, or a 422"
164
+ end
165
+
166
+ # Defaults were written for audio variants, so only the ones that mean
167
+ # the same thing for peaks carry over; f:opus or br:96 beside f:peaks is
168
+ # a 422 (D10). f:peaks leads regardless, so a redundant format: cannot
169
+ # move it (D3). select, not slice: slice reorders by its arguments, and
170
+ # defaults keep the order they were written in, as they do for url_for.
171
+ defaults = config.default_options.select { |key, _| Options::PEAKS_DEFAULT_KEYS.include?(key) }
172
+ Options.render({ f: :peaks }.merge(defaults).merge(typed))
173
+ end
174
+
175
+ def reject_info_options!(options)
176
+ expiry = options.slice(*EXPIRY_KEYWORDS).compact
177
+ unless expiry.empty?
178
+ raise ArgumentError,
179
+ "Audioproxy info_url cannot carry an expiry (got #{expiry.keys.join(" and ")}:): the proxy's " \
180
+ "/info has no options segment, so there is nowhere to put exp. Info URLs do not expire"
181
+ end
182
+
183
+ rest = options.except(*EXPIRY_KEYWORDS)
184
+ return if rest.empty?
185
+
186
+ raise ArgumentError,
187
+ "Audioproxy info_url takes no proxy options (got #{rest.keys.join(", ")}): the proxy answers " \
188
+ "any options segment alongside /info with a 422. Use url_for or peaks_url to describe a variant"
189
+ end
190
+
101
191
  # exp is a *request* option: signed as path bytes, but excluded from the
102
192
  # proxy's cache key, so it is orthogonal to the variant options that
103
193
  # raw: and the typed keys describe and composes with either (D3). It goes
@@ -123,11 +213,11 @@ module Audioproxy
123
213
  # exp: written as an ordinary option skips every check in Expiry, and the
124
214
  # two mistakes it invites — a timestamp already past, a millisecond
125
215
  # timestamp — both render a URL that looks right and never works (D1).
126
- def reject_expiry_option!(typed)
216
+ def reject_expiry_option!(typed, entry = "url_for")
127
217
  return unless typed.key?(:exp)
128
218
 
129
219
  raise ArgumentError,
130
- "Audioproxy url_for does not take exp: as an option key; " \
220
+ "Audioproxy #{entry} does not take exp: as an option key; " \
131
221
  "pass expires_in: (a duration from now) or expires_at: (the instant), " \
132
222
  "which validate the value and do the arithmetic"
133
223
  end
@@ -1,3 +1,3 @@
1
1
  module Audioproxy
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/audioproxy.rb CHANGED
@@ -19,12 +19,23 @@ module Audioproxy
19
19
  config
20
20
  end
21
21
 
22
- # Single public entry point: usable from jobs, mailers and serializers of
23
- # any Ruby program, Rails or not.
22
+ # Usable from jobs, mailers and serializers of any Ruby program, Rails or
23
+ # not, as are the two below.
24
24
  def url_for(source, **options)
25
25
  UrlBuilder.new(config).url_for(source, **options)
26
26
  end
27
27
 
28
+ # Probe metadata as JSON. Takes no proxy options and ignores the configured
29
+ # defaults and expiry, because /info can carry neither.
30
+ def info_url(source, **options)
31
+ UrlBuilder.new(config).info_url(source, **options)
32
+ end
33
+
34
+ # Waveform peaks: f:peaks fixed, and only the options peaks read.
35
+ def peaks_url(source, **options)
36
+ UrlBuilder.new(config).peaks_url(source, **options)
37
+ end
38
+
28
39
  # Anything that is not already a source String is handed to this resolver.
29
40
  # The Rails layer registers one for ActiveStorage objects; the core stays
30
41
  # ignorant of what a blob is, which is what keeps +url_for+ usable with no
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: audioproxy-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Rubisch
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-13 00:00:00.000000000 Z
10
+ date: 2026-09-28 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64