gerdur-core 2.13.3 → 2.15.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.15.0 - 2026-08-31
4
+
5
+ Streaming tag write — tagging no longer holds the audio.
6
+
7
+ ### Added
8
+
9
+ - **`createTagStream(model, options?)`** — a `Transform` that rewrites a track's
10
+ tags as bytes flow through it. `addTrackTags` has to materialise the whole
11
+ file (`browser-id3-writer` allocates `audio.length + tag` and copies the audio
12
+ in, plus another copy to strip an existing ID3v2; the FLAC path concatenates
13
+ the same way), which is what caps concurrency on a server and what cancelled
14
+ `streamTrackDownload`'s constant-memory guarantee at the last step.
15
+
16
+ Both containers keep their metadata at the front, so none of that copying is
17
+ needed: MP3 emits a fresh ID3v2 and discards exactly the old tag's bytes as
18
+ they pass; FLAC buffers only the metadata block chain and passes the frames
19
+ through untouched. Peak memory is O(metadata), not O(file).
20
+
21
+ Measured, 40 MB tracks: **4 concurrent 239 MB -> 0 MB; 16 concurrent 965 MB ->
22
+ 0 MB, and 6.5x faster** (986 ms -> 150 ms) from skipping the copies. Output is
23
+ byte-identical to `addTrackTags` — the tag bytes come from the same writers,
24
+ called with an empty / truncated source so they emit only the header.
25
+ - **`resolveTagModel(track, options?)`** — the metadata resolution half of
26
+ `addTrackTags` (album, lyrics, cover, artist image, credits, BPM) without
27
+ touching audio, so a streaming caller can get a model to hand to
28
+ `createTagStream`. Same options, same coalescing. `addTrackTags` is now a thin
29
+ wrapper over it — no behaviour change.
30
+ - **`probeAudioOffset(buffer)`** — where the audio starts in a source and which
31
+ container it is; exported for callers doing their own muxing.
32
+ - `__tests__/tag-stream.ts` — byte-identical output asserted across chunk sizes
33
+ from 1 byte to whole-file, for MP3 and FLAC, with and without a pre-existing
34
+ tag.
35
+
36
+ ### Changed
37
+
38
+ - README: streaming tag write documented under **Tag MP3 / FLAC** and in
39
+ **Running this on a server**.
40
+
41
+ ## 2.14.0 - 2026-08-31
42
+
43
+ Multi-tenant caching — for backends where many accounts share one process.
44
+
45
+ ### Added
46
+
47
+ - **Shared gateway metadata cache.** Most gw payloads embed a per-account
48
+ `TRACK_TOKEN`, so each `Session` keeps its own cache. Five methods carry
49
+ nothing account-scoped — `album.getData`, `artist.getData`, `song.getLyrics`,
50
+ `album.getDiscography`, `playlist.getData` — and now go to a process-wide cache
51
+ **partitioned by country**, with a shared in-flight map so a concurrent burst
52
+ collapses into one request. Measured with 500 sessions reading the same album:
53
+ **500 gateway requests → 1**, and 1.8 MB of duplicated payload → one copy.
54
+ Track-bearing methods (`song.getData`, `playlist.getSongs`,
55
+ `song.getListByAlbum`, `song.getListData`, `episode.getData`, `mobile.*`,
56
+ `deezer.pageSearch`, `user_getInfo`, …) are never shared.
57
+ - **`configureCache({shared: {maxSize, ttl}})`** — size the shared cache to your
58
+ catalogue. Default `{maxSize: 2000, ttl: 3_600_000}`.
59
+ - **`cacheStats()`** → `{shared: {size, maxSize, hits, misses, inFlight}}` — for
60
+ a `/metrics` or health endpoint.
61
+ - **`clearSharedCaches()`** — drops the shared cache; per-session caches are
62
+ untouched.
63
+ - `__tests__/caches.ts` — offline tests pinning the isolation guarantees: no
64
+ cross-account token reuse, no cross-country bleed, in-flight entries released.
65
+ - README: a **Running this on a server** section (stream don't buffer, decrypt is
66
+ on the event loop, size the cache, evict idle sessions).
67
+
68
+ ### Not done, deliberately
69
+
70
+ - Memoising the initialised Blowfish key schedule per track was implemented,
71
+ measured and **reverted**: key setup is 39.7 µs against 33 ms to decrypt an
72
+ 8 MiB file — **0.12%**, with break-even at ~10 KiB decrypted per key. Not worth
73
+ a cache or the public knob it would need.
74
+
3
75
  ## 2.13.3 - 2026-08-31
4
76
 
5
77
  ### Docs
package/README.md CHANGED
@@ -55,6 +55,7 @@ the CLI and the file-writing layer on top.
55
55
  - [Tag MP3 / FLAC](#tag-mp3--flac)
56
56
  - [Enrichment (MusicBrainz + Cover Art Archive)](#enrichment)
57
57
  - [Use multiple accounts](#use-multiple-accounts)
58
+ - [Running this on a server](#running-this-on-a-server)
58
59
  - [HTTP helpers](#http-helpers)
59
60
  - [Errors](#errors)
60
61
  - [Types](#types)
@@ -525,6 +526,29 @@ model.contributors; // normalised producers / engineers / performers / …
525
526
  | `richCredits` | `true` | hydrate credits + BPM for album/playlist tracks that omit them |
526
527
  | `deezerIds` / `includeRank` | `true` | write `DEEZER_*_ID` / popularity rank |
527
528
 
529
+ **On a server, tag as a stream instead.** `addTrackTags` must materialise the
530
+ whole file, which is what caps concurrency. `createTagStream` produces
531
+ byte-identical output without ever holding the audio — both containers keep
532
+ their metadata at the front, so only the header is buffered (a few KB for MP3;
533
+ the source's own metadata region for FLAC):
534
+
535
+ ```ts
536
+ import {pipeline} from 'stream/promises';
537
+ import {streamTrackDownload, resolveTagModel, createTagStream} from 'gerdur-core';
538
+
539
+ const model = await resolveTagModel(track); // the fetches, no audio
540
+ const {stream} = await streamTrackDownload(track, 9);
541
+ await pipeline(stream, createTagStream(model), createWriteStream('track.flac'));
542
+ ```
543
+
544
+ | 40 MB tracks, concurrent | `addTrackTags` | `createTagStream` |
545
+ | :--- | ---: | ---: |
546
+ | 4 | +239 MB | **+0 MB** |
547
+ | 16 | +965 MB, 986 ms | **+0 MB, 150 ms** |
548
+
549
+ `resolveTagModel(track, options?)` does exactly what `addTrackTags` does minus
550
+ the writing — same fetches, same coalescing, same `AddTrackTagsOptions`.
551
+
528
552
  Building blocks, if you want the model without writing tags:
529
553
 
530
554
  - **`getRichAlbum(albId)`** → merged gw + public album metadata (`RichAlbum`).
@@ -604,6 +628,40 @@ and the raw channels (`gw`, `gwLight`, `gwGet`).
604
628
  in tests). The free `getTrackDownloadUrl` / `resolveDownloadUrls` /
605
629
  `streamTrackDownload` / `refreshTrackTokens` all take an optional `session`.
606
630
 
631
+ **Caching across sessions.** Most gw payloads embed a per-account `TRACK_TOKEN`,
632
+ so each `Session` has its own response cache. Five methods carry nothing
633
+ account-scoped — `album.getData`, `artist.getData`, `song.getLyrics`,
634
+ `album.getDiscography`, `playlist.getData` — and those go to a **process-wide
635
+ cache partitioned by country**, so one copy serves every session and a
636
+ concurrent burst collapses into one request. Measured with 500 sessions reading
637
+ the same album: **500 gateway requests → 1**, and 1.8 MB of duplicated payload →
638
+ one copy. Track lists (`song.getData`, `playlist.getSongs`, `song.getListByAlbum`,
639
+ `episode.getData`, …) are never shared.
640
+
641
+ ```ts
642
+ import {configureCache, cacheStats, clearSharedCaches} from 'gerdur-core';
643
+
644
+ configureCache({shared: {maxSize: 20_000, ttl: 30 * 60_000}}); // once, at startup
645
+ cacheStats(); // {shared: {size, maxSize, hits, misses, inFlight}} — for /metrics
646
+ ```
647
+
648
+ ### Running this on a server
649
+
650
+ - **Stream, don't buffer — including the tags.** `downloadTrackBuffer` /
651
+ `getTrackBuffer` hold the whole file, and `addTrackTags` holds it again (the
652
+ tag writers allocate a second copy). Use `streamTrackDownload` +
653
+ `resolveTagModel` + `createTagStream` for an end-to-end constant-memory path:
654
+ measured at 16 concurrent 40 MB tracks, **965 MB → ~0 MB**, and 6.5x faster.
655
+ - **Decryption runs on the event loop.** Blowfish costs ~33 ms per 8 MiB
656
+ (~243 MiB/s), so heavy concurrent traffic will compete with everything else in
657
+ the process. Put the decrypt in a worker if you saturate a core.
658
+ - **Size the shared cache** to your catalogue with `configureCache`, and export
659
+ `cacheStats()` so you can see the hit rate.
660
+ - **Evict idle sessions yourself.** `createSession` has no lifecycle — a session
661
+ per user, kept forever, keeps its cache forever.
662
+ - `httpAgent` / `httpsAgent` are process-global (`maxSockets: 64`) and shared
663
+ between API calls and CDN downloads.
664
+
607
665
  ### HTTP helpers
608
666
 
609
667
  The zero-dependency HTTP client (`get`/`post`/`head`, redirects, gzip/br/deflate,
@@ -744,7 +802,8 @@ import type {
744
802
  <details>
745
803
  <summary><b>Tagging</b></summary>
746
804
 
747
- `addTrackTags` · `buildTagModel` · `getRichAlbum` · `normalizeContributors` ·
805
+ `addTrackTags` · `resolveTagModel` · `createTagStream` · `probeAudioOffset` ·
806
+ `buildTagModel` · `getRichAlbum` · `normalizeContributors` ·
748
807
  `toLrc` · `downloadAlbumCover` · `downloadArtistImage` · `MAX_COVER_SIZE`
749
808
  </details>
750
809
 
@@ -759,7 +818,8 @@ import type {
759
818
  <details>
760
819
  <summary><b>HTTP &amp; errors</b></summary>
761
820
 
762
- `getJson` · `getText` · `getBuffer` · `getStream` · `HttpClient` · `httpAgent` ·
821
+ `configureCache` · `cacheStats` · `clearSharedCaches` · `getJson` · `getText` ·
822
+ `getBuffer` · `getStream` · `HttpClient` · `httpAgent` ·
763
823
  `httpsAgent` · `HttpStatusError` · `DeezerError` · `GeoBlocked` · `WrongLicense`
764
824
  · `ExpiredTrackToken`
765
825
  </details>
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export { initDeezerApi, createSession, defaultSession, Session, RETRY_POLICY, DE
3
3
  export type { SessionUserData } from './lib/session';
4
4
  export { DeezerError } from './lib/errors';
5
5
  export type { DeezerErrorPayload } from './lib/errors';
6
+ export { configureCache, cacheStats, clearSharedCaches } from './lib/caches';
7
+ export type { CacheOptions, CacheStats } from './lib/caches';
6
8
  export * from './api';
7
9
  export * from './converter';
8
10
  export * from './lib/decrypt';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.getStream = exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.DeezerError = exports.DEFAULT_ARL = exports.RETRY_POLICY = exports.Session = exports.defaultSession = exports.createSession = exports.initDeezerApi = void 0;
17
+ exports.getStream = exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.clearSharedCaches = exports.cacheStats = exports.configureCache = exports.DeezerError = exports.DEFAULT_ARL = exports.RETRY_POLICY = exports.Session = exports.defaultSession = exports.createSession = exports.initDeezerApi = void 0;
18
18
  require("./lib/session-augment"); // wires the download methods onto Session.prototype
19
19
  var session_1 = require("./lib/session");
20
20
  Object.defineProperty(exports, "initDeezerApi", { enumerable: true, get: function () { return session_1.initDeezerApi; } });
@@ -25,6 +25,10 @@ Object.defineProperty(exports, "RETRY_POLICY", { enumerable: true, get: function
25
25
  Object.defineProperty(exports, "DEFAULT_ARL", { enumerable: true, get: function () { return session_1.DEFAULT_ARL; } });
26
26
  var errors_1 = require("./lib/errors");
27
27
  Object.defineProperty(exports, "DeezerError", { enumerable: true, get: function () { return errors_1.DeezerError; } });
28
+ var caches_1 = require("./lib/caches");
29
+ Object.defineProperty(exports, "configureCache", { enumerable: true, get: function () { return caches_1.configureCache; } });
30
+ Object.defineProperty(exports, "cacheStats", { enumerable: true, get: function () { return caches_1.cacheStats; } });
31
+ Object.defineProperty(exports, "clearSharedCaches", { enumerable: true, get: function () { return caches_1.clearSharedCaches; } });
28
32
  __exportStar(require("./api"), exports);
29
33
  __exportStar(require("./converter"), exports);
30
34
  __exportStar(require("./lib/decrypt"), exports);
@@ -0,0 +1,59 @@
1
+ /** An LRU that counts hits and misses and can be resized at runtime. */
2
+ declare class CountingCache {
3
+ private lru;
4
+ private opts;
5
+ hits: number;
6
+ misses: number;
7
+ constructor(maxSize: number, ttl?: number);
8
+ get(key: string): any;
9
+ set(key: string, value: any): void;
10
+ clear(): void;
11
+ /** Resize / re-TTL. Drops the current contents (a new LRU is built). */
12
+ reconfigure(opts: {
13
+ maxSize?: number;
14
+ ttl?: number;
15
+ }): void;
16
+ get size(): number;
17
+ get maxSize(): number;
18
+ }
19
+ /**
20
+ * Account-independent gateway payloads, shared across every {@link Session}.
21
+ * Keyed by country + method + params — see `Session.gw`.
22
+ */
23
+ export declare const sharedGatewayCache: CountingCache;
24
+ /** In-flight requests for {@link sharedGatewayCache}, so concurrent sessions coalesce. */
25
+ export declare const sharedInFlight: Map<string, Promise<any>>;
26
+ export interface CacheOptions {
27
+ /**
28
+ * The shared gateway metadata cache. Default `{maxSize: 2000, ttl: 3_600_000}`.
29
+ * Raise `maxSize` for a large catalogue; entries are JSON payloads of a few KB.
30
+ */
31
+ shared?: {
32
+ maxSize?: number;
33
+ ttl?: number;
34
+ };
35
+ }
36
+ /**
37
+ * Resize the shared cache. Call once at startup — reconfiguring drops its
38
+ * contents.
39
+ *
40
+ * ```ts
41
+ * configureCache({shared: {maxSize: 20_000, ttl: 30 * 60_000}});
42
+ * ```
43
+ */
44
+ export declare const configureCache: (options: CacheOptions) => void;
45
+ /** Drop the shared cache. Per-session caches are untouched (`session.cache.clear()` for those). */
46
+ export declare const clearSharedCaches: () => void;
47
+ export interface CacheStats {
48
+ /** the shared account-independent gateway metadata cache */
49
+ shared: {
50
+ size: number;
51
+ maxSize: number;
52
+ hits: number;
53
+ misses: number;
54
+ inFlight: number;
55
+ };
56
+ }
57
+ /** A snapshot of shared-cache occupancy and hit rate — for metrics / health endpoints. */
58
+ export declare const cacheStats: () => CacheStats;
59
+ export {};
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.cacheStats = exports.clearSharedCaches = exports.configureCache = exports.sharedInFlight = exports.sharedGatewayCache = void 0;
7
+ /**
8
+ * The process-wide cache for account-independent gateway metadata.
9
+ *
10
+ * Each {@link Session} keeps its own response cache because most gw payloads
11
+ * embed a per-account `TRACK_TOKEN`. A handful of methods carry nothing
12
+ * account-scoped, though — album, artist, lyrics, discography and playlist
13
+ * metadata are the same bytes for every account in a country. Caching those per
14
+ * session multiplies one payload by the number of logged-in accounts, which is
15
+ * fine for a script and wrong for a server.
16
+ *
17
+ * Holding them here means they are stored once, and — via {@link sharedInFlight}
18
+ * — a burst of sessions asking for the same album shares a single request
19
+ * instead of issuing one each. Measured at 500 sessions reading one album:
20
+ * **500 gateway requests → 1**, and 1.8 MB of duplicated payload → one copy.
21
+ *
22
+ * Tune with {@link configureCache}, inspect with {@link cacheStats}.
23
+ */
24
+ const fast_lru_1 = __importDefault(require("./fast-lru"));
25
+ /** An LRU that counts hits and misses and can be resized at runtime. */
26
+ class CountingCache {
27
+ constructor(maxSize, ttl = 0) {
28
+ this.hits = 0;
29
+ this.misses = 0;
30
+ this.opts = { maxSize, ttl };
31
+ this.lru = new fast_lru_1.default(this.opts);
32
+ }
33
+ get(key) {
34
+ const value = this.lru.get(key);
35
+ if (value === undefined) {
36
+ this.misses++;
37
+ }
38
+ else {
39
+ this.hits++;
40
+ }
41
+ return value;
42
+ }
43
+ set(key, value) {
44
+ this.lru.set(key, value);
45
+ }
46
+ clear() {
47
+ this.lru.clear();
48
+ }
49
+ /** Resize / re-TTL. Drops the current contents (a new LRU is built). */
50
+ reconfigure(opts) {
51
+ var _a, _b;
52
+ this.opts = { maxSize: (_a = opts.maxSize) !== null && _a !== void 0 ? _a : this.opts.maxSize, ttl: (_b = opts.ttl) !== null && _b !== void 0 ? _b : this.opts.ttl };
53
+ this.lru = new fast_lru_1.default(this.opts);
54
+ }
55
+ get size() {
56
+ return this.lru.size;
57
+ }
58
+ get maxSize() {
59
+ return this.opts.maxSize;
60
+ }
61
+ }
62
+ /**
63
+ * Account-independent gateway payloads, shared across every {@link Session}.
64
+ * Keyed by country + method + params — see `Session.gw`.
65
+ */
66
+ exports.sharedGatewayCache = new CountingCache(2000, 60 * 60000);
67
+ /** In-flight requests for {@link sharedGatewayCache}, so concurrent sessions coalesce. */
68
+ exports.sharedInFlight = new Map();
69
+ /**
70
+ * Resize the shared cache. Call once at startup — reconfiguring drops its
71
+ * contents.
72
+ *
73
+ * ```ts
74
+ * configureCache({shared: {maxSize: 20_000, ttl: 30 * 60_000}});
75
+ * ```
76
+ */
77
+ const configureCache = (options) => {
78
+ if (options.shared) {
79
+ exports.sharedGatewayCache.reconfigure(options.shared);
80
+ exports.sharedInFlight.clear();
81
+ }
82
+ };
83
+ exports.configureCache = configureCache;
84
+ /** Drop the shared cache. Per-session caches are untouched (`session.cache.clear()` for those). */
85
+ const clearSharedCaches = () => {
86
+ exports.sharedGatewayCache.clear();
87
+ exports.sharedInFlight.clear();
88
+ };
89
+ exports.clearSharedCaches = clearSharedCaches;
90
+ /** A snapshot of shared-cache occupancy and hit rate — for metrics / health endpoints. */
91
+ const cacheStats = () => ({
92
+ shared: {
93
+ size: exports.sharedGatewayCache.size,
94
+ maxSize: exports.sharedGatewayCache.maxSize,
95
+ hits: exports.sharedGatewayCache.hits,
96
+ misses: exports.sharedGatewayCache.misses,
97
+ inFlight: exports.sharedInFlight.size,
98
+ },
99
+ });
100
+ exports.cacheStats = cacheStats;
@@ -34,6 +34,9 @@ const getBlowfishKey = (trackId) => {
34
34
  return key;
35
35
  };
36
36
  const CHUNK = 2048;
37
+ // Note: memoising the initialised Blowfish schedule per track was measured and
38
+ // rejected — key setup is 39.7µs against 33ms to decrypt an 8 MiB file (0.12%),
39
+ // break-even at ~10 KiB decrypted per key. Not worth the cache.
37
40
  /**
38
41
  * Decrypt a downloaded track. Deezer applies Blowfish-CBC "stripe" obfuscation:
39
42
  * the file is split into 2048-byte chunks and only every third chunk (0, 3, 6…)
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Breaker tuning. A plain object — mutate it at startup to change the policy
3
+ * process-wide, the same way {@link RETRY_POLICY} works.
4
+ */
5
+ export declare const BREAKER_POLICY: {
6
+ /** consecutive rate-limited rounds before the circuit opens */
7
+ trip: number;
8
+ /** how long the circuit stays open (requests fail fast) once tripped */
9
+ openMs: number;
10
+ /** shared cooldown after the first rate-limited round; doubles per round */
11
+ cooldownMs: number;
12
+ /** ceiling on the shared cooldown */
13
+ maxCooldownMs: number;
14
+ };
15
+ /** A read-only view of a session's rate-limit state, for metrics / health checks. */
16
+ export interface RateLimitState {
17
+ /** consecutive rate-limited rounds; `0` when healthy */
18
+ consecutive: number;
19
+ /** the circuit is open — requests are failing fast */
20
+ open: boolean;
21
+ /** ms until the circuit closes again (`0` when not open) */
22
+ openForMs: number;
23
+ /** a shared cooldown is in progress and new requests will wait on it */
24
+ cooling: boolean;
25
+ }
26
+ export declare class RateLimitGate {
27
+ private gate;
28
+ private consecutive;
29
+ private openUntil;
30
+ /**
31
+ * Called before every attempt. Throws when the circuit is open; otherwise
32
+ * waits out any cooldown already in progress.
33
+ */
34
+ pass(): Promise<void>;
35
+ /**
36
+ * Report a rate-limited response. The first caller in a round opens the shared
37
+ * cooldown; the rest join it. Returns the cooldown to await.
38
+ */
39
+ trip(): Promise<void>;
40
+ /** Report a clean response — the endpoint is healthy again. */
41
+ succeed(): void;
42
+ /** Current state, for `cacheStats`-style reporting. */
43
+ get state(): RateLimitState;
44
+ /** Forget everything (used by tests and by `Session.init` on an account change). */
45
+ reset(): void;
46
+ }
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.RateLimitGate = exports.BREAKER_POLICY = void 0;
7
+ /**
8
+ * Coordinated rate-limit handling for a {@link Session}.
9
+ *
10
+ * The retry loop already backs off when Deezer answers `code: 4` ("quota limit
11
+ * exceeded"). The problem is that it backs off *per request*: a session with 60
12
+ * concurrent gateway calls — tagging a long playlist, say — has all 60
13
+ * independently discover the limit, independently sleep, and independently
14
+ * retry, so the limiter keeps getting hit by the full fan-out and every caller
15
+ * burns its whole 30 s deadline before failing.
16
+ *
17
+ * This coordinates them:
18
+ *
19
+ * - **One shared cooldown.** The first request to see a rate limit opens a gate;
20
+ * every other request in that round waits on the *same* timer instead of
21
+ * starting its own. During a cooldown the wire sees nothing, rather than N
22
+ * retries. The cooldown escalates per consecutive round and is jittered.
23
+ * - **A breaker.** After {@link BREAKER_POLICY}`.trip` consecutive rounds the
24
+ * circuit opens and requests fail fast with a `DeezerError` for
25
+ * `openMs`, instead of each holding a socket and a promise chain for the full
26
+ * deadline. The next request after that window is the half-open probe: one
27
+ * success resets everything.
28
+ *
29
+ * Scope is **per session**, which is the granularity Deezer's per-account quota
30
+ * uses and keeps one noisy tenant from failing everyone else's requests. If you
31
+ * also need to respect an IP-level limit shared by many sessions, coordinate
32
+ * that above this library.
33
+ */
34
+ const delay_1 = __importDefault(require("delay"));
35
+ const errors_1 = require("./errors");
36
+ /**
37
+ * Breaker tuning. A plain object — mutate it at startup to change the policy
38
+ * process-wide, the same way {@link RETRY_POLICY} works.
39
+ */
40
+ exports.BREAKER_POLICY = {
41
+ /** consecutive rate-limited rounds before the circuit opens */
42
+ trip: 5,
43
+ /** how long the circuit stays open (requests fail fast) once tripped */
44
+ openMs: 10000,
45
+ /** shared cooldown after the first rate-limited round; doubles per round */
46
+ cooldownMs: 1000,
47
+ /** ceiling on the shared cooldown */
48
+ maxCooldownMs: 15000,
49
+ };
50
+ class RateLimitGate {
51
+ constructor() {
52
+ this.gate = null;
53
+ this.consecutive = 0;
54
+ this.openUntil = 0;
55
+ }
56
+ /**
57
+ * Called before every attempt. Throws when the circuit is open; otherwise
58
+ * waits out any cooldown already in progress.
59
+ */
60
+ async pass() {
61
+ const openForMs = this.openUntil - Date.now();
62
+ if (openForMs > 0) {
63
+ throw new errors_1.DeezerError({
64
+ code: 4,
65
+ RATE_LIMIT_CIRCUIT_OPEN: `Deezer rate-limited this session; retrying in ${Math.ceil(openForMs / 1000)}s`,
66
+ });
67
+ }
68
+ if (this.gate) {
69
+ await this.gate;
70
+ }
71
+ }
72
+ /**
73
+ * Report a rate-limited response. The first caller in a round opens the shared
74
+ * cooldown; the rest join it. Returns the cooldown to await.
75
+ */
76
+ trip() {
77
+ if (!this.gate) {
78
+ // one increment per round, not per concurrent failure
79
+ this.consecutive++;
80
+ if (this.consecutive >= exports.BREAKER_POLICY.trip) {
81
+ this.openUntil = Date.now() + exports.BREAKER_POLICY.openMs;
82
+ }
83
+ const window = Math.min(exports.BREAKER_POLICY.cooldownMs * 2 ** (this.consecutive - 1), exports.BREAKER_POLICY.maxCooldownMs);
84
+ const jittered = window / 2 + Math.random() * (window / 2);
85
+ this.gate = (0, delay_1.default)(jittered).then(() => {
86
+ this.gate = null;
87
+ });
88
+ }
89
+ return this.gate;
90
+ }
91
+ /** Report a clean response — the endpoint is healthy again. */
92
+ succeed() {
93
+ this.consecutive = 0;
94
+ this.openUntil = 0;
95
+ }
96
+ /** Current state, for `cacheStats`-style reporting. */
97
+ get state() {
98
+ const openForMs = Math.max(0, this.openUntil - Date.now());
99
+ return {
100
+ consecutive: this.consecutive,
101
+ open: openForMs > 0,
102
+ openForMs,
103
+ cooling: this.gate !== null,
104
+ };
105
+ }
106
+ /** Forget everything (used by tests and by `Session.init` on an account change). */
107
+ reset() {
108
+ this.consecutive = 0;
109
+ this.openUntil = 0;
110
+ this.gate = null;
111
+ }
112
+ }
113
+ exports.RateLimitGate = RateLimitGate;
@@ -102,7 +102,14 @@ export declare class Session {
102
102
  * backoff, and a wall-clock deadline. Throws `DeezerError` on exhaustion.
103
103
  */
104
104
  request<T>(method: 'GET' | 'POST', url: string, body?: unknown, config?: SessionRequestConfig): Promise<HttpResponse<T>>;
105
- /** Single-flight + LRU around a fetcher, keyed per session. */
105
+ /**
106
+ * Single-flight + LRU around a fetcher.
107
+ *
108
+ * `shared` routes to the process-wide cache and in-flight map instead of this
109
+ * session's — used for gw methods whose payloads carry nothing account-scoped
110
+ * (see {@link ACCOUNT_INDEPENDENT_GW}). Two sessions asking for the same album
111
+ * concurrently then share one request rather than issuing one each.
112
+ */
106
113
  private coalesce;
107
114
  /** POST `gateway.php` — the main gw method channel. Coalesced + cached. */
108
115
  gw<T = any>(body: Record<string, unknown>, method: string): Promise<T>;
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.setDefaultSession = exports.createSession = exports.initDeezerApi = exports.defaultSession = exports.Session = exports.RETRY_POLICY = exports.DEFAULT_ARL = void 0;
7
7
  const delay_1 = __importDefault(require("delay"));
8
+ const caches_1 = require("./caches");
8
9
  const errors_1 = require("./errors");
9
10
  const fast_lru_1 = __importDefault(require("./fast-lru"));
10
11
  const http_1 = require("./http");
@@ -33,6 +34,22 @@ exports.RETRY_POLICY = {
33
34
  /** overall wall-clock budget from the first attempt */
34
35
  deadlineMs: 30000,
35
36
  };
37
+ // A shared cooldown + circuit breaker across a session's concurrent requests was
38
+ // built and measured against this loop, then dropped. Numbers, 60 concurrent
39
+ // requests against a permanently rate-limited endpoint (baseline 420 wire calls
40
+ // / 8.5s):
41
+ // - as first written (trip after 5 rounds, escalating shared cooldown):
42
+ // 420 calls / 36s — strictly worse. The retry cap is per request, so
43
+ // synchronising the waits changes nothing about total calls and only
44
+ // lengthens them.
45
+ // - tuned hard (open on the first rate-limited round, 300ms cooldown):
46
+ // 60 calls / 0.2s, but a *single* request meeting one transient `code: 4`
47
+ // then fails outright, where this loop retries and succeeds.
48
+ // The flaw is structural: the request that trips the breaker is immediately
49
+ // blocked by its own trip. A correct version has to tell new work apart from an
50
+ // in-flight retry — shed the former, never the latter — which means threading
51
+ // per-request attempt state through the gate. Worth doing only with a real
52
+ // workload to tune against; guessing made it worse twice.
36
53
  /** Exponential backoff (ms) with full jitter on the top half of the window. */
37
54
  const backoffDelay = (attempt) => {
38
55
  const windowMs = Math.min(exports.RETRY_POLICY.baseMs * 2 ** attempt, exports.RETRY_POLICY.maxDelayMs);
@@ -40,6 +57,27 @@ const backoffDelay = (attempt) => {
40
57
  };
41
58
  /** How long a loaded `deezer.getUserData` payload is trusted before a refresh. */
42
59
  const USER_DATA_TTL_MS = 25 * 60 * 1000;
60
+ /**
61
+ * gw methods whose payloads carry **no account-scoped fields**, so one copy can
62
+ * serve every session instead of one copy per session.
63
+ *
64
+ * The account-scoped field that matters is `TRACK_TOKEN`, and it appears on
65
+ * exactly two response shapes — `trackType` and `showEpisodeType`. None of the
66
+ * methods below embed either, so their results are identical for every account
67
+ * in a given country. Everything else (`song.getData`, `playlist.getSongs`,
68
+ * `song.getListByAlbum`, `song.getListData`, `episode.getData`, `mobile.*`,
69
+ * `deezer.pageSearch`, `user_getInfo`, …) stays in the per-session cache.
70
+ *
71
+ * The shared key is prefixed with the session's country, so a multi-region
72
+ * deployment never serves one country's availability view to another.
73
+ */
74
+ const ACCOUNT_INDEPENDENT_GW = new Set([
75
+ 'album.getData',
76
+ 'artist.getData',
77
+ 'song.getLyrics',
78
+ 'album.getDiscography',
79
+ 'playlist.getData', // playlistInfo — metadata only (playlist.getSongs is NOT shared)
80
+ ]);
43
81
  /**
44
82
  * One Deezer session — owns the `arl`, the HTTP client (and its `sid` /
45
83
  * `api_token`), and the account's `license_token` / `country` / streaming
@@ -217,38 +255,52 @@ class Session {
217
255
  }
218
256
  }
219
257
  // ─── Cached request primitives ──────────────────────────────────────────────
220
- /** Single-flight + LRU around a fetcher, keyed per session. */
221
- coalesce(key, fetcher) {
222
- const cached = this.cache.get(key);
258
+ /**
259
+ * Single-flight + LRU around a fetcher.
260
+ *
261
+ * `shared` routes to the process-wide cache and in-flight map instead of this
262
+ * session's — used for gw methods whose payloads carry nothing account-scoped
263
+ * (see {@link ACCOUNT_INDEPENDENT_GW}). Two sessions asking for the same album
264
+ * concurrently then share one request rather than issuing one each.
265
+ */
266
+ coalesce(key, fetcher, shared = false) {
267
+ const cache = shared ? caches_1.sharedGatewayCache : this.cache;
268
+ const inFlight = shared ? caches_1.sharedInFlight : this.inFlight;
269
+ const cached = cache.get(key);
223
270
  if (cached) {
224
271
  return Promise.resolve(cached);
225
272
  }
226
- const pending = this.inFlight.get(key);
273
+ const pending = inFlight.get(key);
227
274
  if (pending) {
228
275
  return pending;
229
276
  }
230
277
  const promise = (async () => {
231
278
  try {
232
- return await fetcher();
279
+ const value = await fetcher();
280
+ cache.set(key, value);
281
+ return value;
233
282
  }
234
283
  finally {
235
- this.inFlight.delete(key);
284
+ inFlight.delete(key);
236
285
  }
237
286
  })();
238
- this.inFlight.set(key, promise);
287
+ inFlight.set(key, promise);
239
288
  return promise;
240
289
  }
241
290
  /** POST `gateway.php` — the main gw method channel. Coalesced + cached. */
242
291
  gw(body, method) {
243
- const key = `gw:${method}:${Object.entries(body).join(':')}`;
292
+ var _a;
293
+ const shared = ACCOUNT_INDEPENDENT_GW.has(method);
294
+ const key = shared
295
+ ? `${(_a = this.country) !== null && _a !== void 0 ? _a : 'XX'}:gw:${method}:${Object.entries(body).join(':')}`
296
+ : `gw:${method}:${Object.entries(body).join(':')}`;
244
297
  return this.coalesce(key, async () => {
245
298
  const { data: { error, results }, } = await this.request('POST', '/gateway.php', body, { params: { method } });
246
299
  if (results && Object.keys(results).length > 0) {
247
- this.cache.set(key, results);
248
300
  return results;
249
301
  }
250
302
  throw new errors_1.DeezerError(error);
251
- });
303
+ }, shared);
252
304
  }
253
305
  /** POST `gw-light.php` — the lighter method channel (search, suggest, …). */
254
306
  gwLight(body, method) {
@@ -258,7 +310,6 @@ class Session {
258
310
  params: { method, api_version: '1.0' },
259
311
  });
260
312
  if (results && Object.keys(results).length > 0) {
261
- this.cache.set(key, results);
262
313
  return results;
263
314
  }
264
315
  throw new errors_1.DeezerError(error);
@@ -272,7 +323,6 @@ class Session {
272
323
  params: { method, ...params },
273
324
  });
274
325
  if (results && Object.keys(results).length > 0) {
275
- this.cache.set(cacheKey, results);
276
326
  return results;
277
327
  }
278
328
  throw new errors_1.DeezerError(error);
@@ -10,6 +10,7 @@ export type { RichAlbum } from './rich-album';
10
10
  export { buildTagModel } from './model';
11
11
  export type { TrackTagModel, Person } from './model';
12
12
  export { downloadAlbumCover, downloadArtistImage, MAX_COVER_SIZE } from './abumCover';
13
+ export { createTagStream, probeAudioOffset } from './tag-stream';
13
14
  export interface AddTrackTagsOptions {
14
15
  /** embedded cover size in px, 56–1800 (default 1000) */
15
16
  coverSize?: number;
@@ -48,6 +49,20 @@ export interface TaggedTrack {
48
49
  /** everything gerdur pulled together for this track — use `model.lyricsSynced` for a `.lrc` sidecar */
49
50
  model: TrackTagModel;
50
51
  }
52
+ /**
53
+ * Resolve everything Deezer has for a track into the canonical tag model —
54
+ * without touching any audio.
55
+ *
56
+ * Fetches album info, lyrics, cover, artist image and (for album/playlist
57
+ * tracks, which ship without them) full credits + BPM, all in parallel; every
58
+ * call is memoised and in-flight-coalesced, so resolving all tracks of one album
59
+ * hits each metadata endpoint once. Pass any of
60
+ * `options.{album,lyrics,cover,publicTrack}` to skip the corresponding fetch.
61
+ *
62
+ * Feed the result to `createTagStream(model)` to tag a stream, or use
63
+ * {@link addTrackTags} to tag a `Buffer` in one call.
64
+ */
65
+ export declare const resolveTagModel: (trackInput: trackType, options?: AddTrackTagsOptions) => Promise<TrackTagModel>;
51
66
  /**
52
67
  * Pull together everything Deezer has for a track and write it into the audio.
53
68
  *
@@ -57,6 +72,10 @@ export interface TaggedTrack {
57
72
  * tracks of one album hits each metadata endpoint once. Pass any of
58
73
  * `options.{album,lyrics,cover,publicTrack}` to skip the corresponding fetch.
59
74
  *
75
+ * This holds the whole file in memory (and the tag writers allocate another
76
+ * copy). On a server, prefer {@link resolveTagModel} + `createTagStream`, which
77
+ * produces byte-identical output without buffering the audio.
78
+ *
60
79
  * @returns `{buffer, model}` — `model` carries the structured metadata and,
61
80
  * when available, `model.lyricsSynced` (an LRC document for a sidecar file).
62
81
  */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.addTrackTags = exports.MAX_COVER_SIZE = exports.downloadArtistImage = exports.downloadAlbumCover = exports.buildTagModel = exports.getRichAlbum = exports.toLrc = exports.normalizeContributors = void 0;
3
+ exports.addTrackTags = exports.resolveTagModel = exports.probeAudioOffset = exports.createTagStream = exports.MAX_COVER_SIZE = exports.downloadArtistImage = exports.downloadAlbumCover = exports.buildTagModel = exports.getRichAlbum = exports.toLrc = exports.normalizeContributors = void 0;
4
4
  const abumCover_1 = require("./abumCover");
5
5
  const getTrackLyrics_1 = require("./getTrackLyrics");
6
6
  const id3_1 = require("./id3");
@@ -20,6 +20,9 @@ var abumCover_2 = require("./abumCover");
20
20
  Object.defineProperty(exports, "downloadAlbumCover", { enumerable: true, get: function () { return abumCover_2.downloadAlbumCover; } });
21
21
  Object.defineProperty(exports, "downloadArtistImage", { enumerable: true, get: function () { return abumCover_2.downloadArtistImage; } });
22
22
  Object.defineProperty(exports, "MAX_COVER_SIZE", { enumerable: true, get: function () { return abumCover_2.MAX_COVER_SIZE; } });
23
+ var tag_stream_1 = require("./tag-stream");
24
+ Object.defineProperty(exports, "createTagStream", { enumerable: true, get: function () { return tag_stream_1.createTagStream; } });
25
+ Object.defineProperty(exports, "probeAudioOffset", { enumerable: true, get: function () { return tag_stream_1.probeAudioOffset; } });
23
26
  const DEFAULTS = {
24
27
  coverSize: 1000,
25
28
  embedCover: true,
@@ -55,18 +58,19 @@ const hydrate = async (track) => {
55
58
  }
56
59
  };
57
60
  /**
58
- * Pull together everything Deezer has for a track and write it into the audio.
61
+ * Resolve everything Deezer has for a track into the canonical tag model
62
+ * without touching any audio.
59
63
  *
60
- * Sniffs `fLaC` vs MP3 and dispatches to the FLAC / ID3 writer. Fetches album
61
- * info, lyrics, cover and (for album/playlist tracks) full credits + BPM in
62
- * parallel — every call is memoised and in-flight-coalesced, so tagging all
63
- * tracks of one album hits each metadata endpoint once. Pass any of
64
+ * Fetches album info, lyrics, cover, artist image and (for album/playlist
65
+ * tracks, which ship without them) full credits + BPM, all in parallel; every
66
+ * call is memoised and in-flight-coalesced, so resolving all tracks of one album
67
+ * hits each metadata endpoint once. Pass any of
64
68
  * `options.{album,lyrics,cover,publicTrack}` to skip the corresponding fetch.
65
69
  *
66
- * @returns `{buffer, model}` `model` carries the structured metadata and,
67
- * when available, `model.lyricsSynced` (an LRC document for a sidecar file).
70
+ * Feed the result to `createTagStream(model)` to tag a stream, or use
71
+ * {@link addTrackTags} to tag a `Buffer` in one call.
68
72
  */
69
- const addTrackTags = async (trackBuffer, trackInput, options = {}) => {
73
+ const resolveTagModel = async (trackInput, options = {}) => {
70
74
  const opt = { ...DEFAULTS, ...options };
71
75
  const track = opt.richCredits ? await hydrate(trackInput) : trackInput;
72
76
  const [album, lyrics, publicTrack, cover, artistImage] = await Promise.all([
@@ -106,6 +110,28 @@ const addTrackTags = async (trackBuffer, trackInput, options = {}) => {
106
110
  deezerIds: opt.deezerIds,
107
111
  includeRank: opt.includeRank,
108
112
  });
113
+ return model;
114
+ };
115
+ exports.resolveTagModel = resolveTagModel;
116
+ /**
117
+ * Pull together everything Deezer has for a track and write it into the audio.
118
+ *
119
+ * Sniffs `fLaC` vs MP3 and dispatches to the FLAC / ID3 writer. Fetches album
120
+ * info, lyrics, cover and (for album/playlist tracks) full credits + BPM in
121
+ * parallel — every call is memoised and in-flight-coalesced, so tagging all
122
+ * tracks of one album hits each metadata endpoint once. Pass any of
123
+ * `options.{album,lyrics,cover,publicTrack}` to skip the corresponding fetch.
124
+ *
125
+ * This holds the whole file in memory (and the tag writers allocate another
126
+ * copy). On a server, prefer {@link resolveTagModel} + `createTagStream`, which
127
+ * produces byte-identical output without buffering the audio.
128
+ *
129
+ * @returns `{buffer, model}` — `model` carries the structured metadata and,
130
+ * when available, `model.lyricsSynced` (an LRC document for a sidecar file).
131
+ */
132
+ const addTrackTags = async (trackBuffer, trackInput, options = {}) => {
133
+ const model = await (0, exports.resolveTagModel)(trackInput, options);
134
+ const opt = { ...DEFAULTS, ...options };
109
135
  const isFlac = trackBuffer.slice(0, 4).toString('ascii') === 'fLaC';
110
136
  const buffer = isFlac
111
137
  ? (0, flacmetata_1.writeMetadataFlac)(trackBuffer, model, { embedSyncedLyrics: opt.embedSyncedLyrics })
@@ -0,0 +1,64 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * Streaming tag writer — rewrite a track's tags as bytes flow through, without
4
+ * ever holding the audio.
5
+ *
6
+ * `addTrackTags` has to materialise the whole file: `browser-id3-writer`
7
+ * allocates `audio.length + tag` and copies the audio into it (and copies again
8
+ * to strip an existing ID3v2), and the FLAC path concatenates the same way. That
9
+ * is ~2–3× the file size per concurrent call — measured at **+121 MB retained
10
+ * for a 40 MB MP3** — which is what caps concurrency on a server and what
11
+ * quietly cancels `streamTrackDownload`'s constant-memory guarantee at the last
12
+ * step.
13
+ *
14
+ * Both container formats put their metadata at the *front*, so none of that
15
+ * copying is necessary:
16
+ *
17
+ * - **MP3** — an ID3v2 tag is a 10-byte header plus a syncsafe length. Emit a
18
+ * freshly built tag, discard exactly that many bytes of the source as they go
19
+ * past, pass the rest through. Nothing is buffered at all.
20
+ * - **FLAC** — `fLaC` then a chain of metadata blocks, the last flagged. Buffer
21
+ * only that chain (to parse STREAMINFO and friends), emit the rebuilt blocks,
22
+ * pass the frames through untouched.
23
+ *
24
+ * Peak memory becomes O(metadata) instead of O(file): a few KB for MP3, and for
25
+ * FLAC whatever the source's own metadata region is.
26
+ *
27
+ * The tag bytes come from the exact same writers `addTrackTags` uses — called
28
+ * with an empty / truncated source so they emit only the header — so output is
29
+ * byte-identical to the buffered path.
30
+ *
31
+ * ```ts
32
+ * import {pipeline} from 'stream/promises';
33
+ * const {stream} = await streamTrackDownload(track, 9);
34
+ * await pipeline(stream, createTagStream(model), createWriteStream('out.flac'));
35
+ * ```
36
+ */
37
+ import { Transform } from 'stream';
38
+ import type { FlacWriteOptions } from './flacmetata';
39
+ import type { TrackTagModel } from './model';
40
+ type Probe = {
41
+ ready: false;
42
+ } | {
43
+ ready: true;
44
+ audioOffset: number;
45
+ flac: boolean;
46
+ };
47
+ /**
48
+ * Where the audio starts in the source, and which container it is.
49
+ * `{ready: false}` means "need more bytes".
50
+ */
51
+ export declare const probeAudioOffset: (buf: Buffer) => Probe;
52
+ /**
53
+ * A `Transform` that replaces the tags on a track as it streams through.
54
+ *
55
+ * Feed it the decrypted audio (e.g. from {@link streamTrackDownload}); it emits
56
+ * the same bytes {@link addTrackTags} would produce, without buffering the file.
57
+ * Detects MP3 vs FLAC from the source itself.
58
+ *
59
+ * @param model the canonical tag model — from `buildTagModel`, or the `model`
60
+ * an earlier `addTrackTags` call returned
61
+ * @param options forwarded to the FLAC writer (e.g. `embedSyncedLyrics`)
62
+ */
63
+ export declare const createTagStream: (model: TrackTagModel, options?: FlacWriteOptions) => Transform;
64
+ export {};
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createTagStream = exports.probeAudioOffset = void 0;
4
+ /**
5
+ * Streaming tag writer — rewrite a track's tags as bytes flow through, without
6
+ * ever holding the audio.
7
+ *
8
+ * `addTrackTags` has to materialise the whole file: `browser-id3-writer`
9
+ * allocates `audio.length + tag` and copies the audio into it (and copies again
10
+ * to strip an existing ID3v2), and the FLAC path concatenates the same way. That
11
+ * is ~2–3× the file size per concurrent call — measured at **+121 MB retained
12
+ * for a 40 MB MP3** — which is what caps concurrency on a server and what
13
+ * quietly cancels `streamTrackDownload`'s constant-memory guarantee at the last
14
+ * step.
15
+ *
16
+ * Both container formats put their metadata at the *front*, so none of that
17
+ * copying is necessary:
18
+ *
19
+ * - **MP3** — an ID3v2 tag is a 10-byte header plus a syncsafe length. Emit a
20
+ * freshly built tag, discard exactly that many bytes of the source as they go
21
+ * past, pass the rest through. Nothing is buffered at all.
22
+ * - **FLAC** — `fLaC` then a chain of metadata blocks, the last flagged. Buffer
23
+ * only that chain (to parse STREAMINFO and friends), emit the rebuilt blocks,
24
+ * pass the frames through untouched.
25
+ *
26
+ * Peak memory becomes O(metadata) instead of O(file): a few KB for MP3, and for
27
+ * FLAC whatever the source's own metadata region is.
28
+ *
29
+ * The tag bytes come from the exact same writers `addTrackTags` uses — called
30
+ * with an empty / truncated source so they emit only the header — so output is
31
+ * byte-identical to the buffered path.
32
+ *
33
+ * ```ts
34
+ * import {pipeline} from 'stream/promises';
35
+ * const {stream} = await streamTrackDownload(track, 9);
36
+ * await pipeline(stream, createTagStream(model), createWriteStream('out.flac'));
37
+ * ```
38
+ */
39
+ const stream_1 = require("stream");
40
+ const flacmetata_1 = require("./flacmetata");
41
+ const id3_1 = require("./id3");
42
+ /**
43
+ * Cap on how much of the source is buffered while looking for the end of its
44
+ * metadata region. Only FLAC ever gets here, and only for its own metadata
45
+ * (STREAMINFO + SEEKTABLE + any embedded art). Past this the stream errors
46
+ * rather than growing without bound.
47
+ */
48
+ const MAX_HEADER_BYTES = 16 * 1024 * 1024;
49
+ /**
50
+ * Where the audio starts in the source, and which container it is.
51
+ * `{ready: false}` means "need more bytes".
52
+ */
53
+ const probeAudioOffset = (buf) => {
54
+ if (buf.length >= 4 && buf.toString('ascii', 0, 4) === 'fLaC') {
55
+ // fLaC | (1 byte: last-block flag + type)(3 bytes: length)[body] ...
56
+ let offset = 4;
57
+ for (;;) {
58
+ if (offset + 4 > buf.length) {
59
+ return { ready: false };
60
+ }
61
+ const isLast = buf.readUInt8(offset) >= 128;
62
+ offset += 4 + buf.readUIntBE(offset + 1, 3);
63
+ if (isLast) {
64
+ // the whole chain must be present for the FLAC parser to walk it
65
+ return offset <= buf.length ? { ready: true, audioOffset: offset, flac: true } : { ready: false };
66
+ }
67
+ }
68
+ }
69
+ if (buf.length < 10) {
70
+ return { ready: false };
71
+ }
72
+ if (buf.toString('ascii', 0, 3) !== 'ID3') {
73
+ return { ready: true, audioOffset: 0, flac: false }; // no existing tag — audio from byte 0
74
+ }
75
+ // ID3v2 size is 4 syncsafe bytes (7 bits each) at offset 6
76
+ const size = (buf[6] << 21) | (buf[7] << 14) | (buf[8] << 7) | buf[9];
77
+ return { ready: true, audioOffset: 10 + size, flac: false };
78
+ };
79
+ exports.probeAudioOffset = probeAudioOffset;
80
+ /**
81
+ * A `Transform` that replaces the tags on a track as it streams through.
82
+ *
83
+ * Feed it the decrypted audio (e.g. from {@link streamTrackDownload}); it emits
84
+ * the same bytes {@link addTrackTags} would produce, without buffering the file.
85
+ * Detects MP3 vs FLAC from the source itself.
86
+ *
87
+ * @param model the canonical tag model — from `buildTagModel`, or the `model`
88
+ * an earlier `addTrackTags` call returned
89
+ * @param options forwarded to the FLAC writer (e.g. `embedSyncedLyrics`)
90
+ */
91
+ const createTagStream = (model, options = {}) => {
92
+ let head = [];
93
+ let headLength = 0;
94
+ /** 'probing' → reading the source header; 'skipping' → dropping its old tag; 'passthrough' → audio */
95
+ let mode = 'probing';
96
+ let toSkip = 0;
97
+ return new stream_1.Transform({
98
+ transform(chunk, _encoding, callback) {
99
+ const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
100
+ if (mode === 'passthrough') {
101
+ this.push(part);
102
+ return callback();
103
+ }
104
+ if (mode === 'skipping') {
105
+ if (part.length <= toSkip) {
106
+ toSkip -= part.length;
107
+ }
108
+ else {
109
+ this.push(part.subarray(toSkip));
110
+ toSkip = 0;
111
+ mode = 'passthrough';
112
+ }
113
+ return callback();
114
+ }
115
+ head.push(part);
116
+ headLength += part.length;
117
+ if (headLength > MAX_HEADER_BYTES) {
118
+ return callback(new Error(`No audio found in the first ${MAX_HEADER_BYTES} bytes — is this a track?`));
119
+ }
120
+ const buffered = head.length === 1 ? head[0] : Buffer.concat(head, headLength);
121
+ const probe = (0, exports.probeAudioOffset)(buffered);
122
+ if (!probe.ready) {
123
+ head = [buffered];
124
+ return callback();
125
+ }
126
+ try {
127
+ if (probe.flac) {
128
+ // truncating at audioOffset makes the writer's own `slice(framesOffset)`
129
+ // empty, so it returns exactly `fLaC` + the rebuilt metadata blocks
130
+ this.push((0, flacmetata_1.writeMetadataFlac)(buffered.subarray(0, probe.audioOffset), model, options));
131
+ this.push(buffered.subarray(probe.audioOffset));
132
+ mode = 'passthrough';
133
+ }
134
+ else {
135
+ // an empty source makes the ID3 writer emit only the tag
136
+ this.push((0, id3_1.writeMetadataMp3)(Buffer.alloc(0), model));
137
+ if (buffered.length > probe.audioOffset) {
138
+ this.push(buffered.subarray(probe.audioOffset));
139
+ mode = 'passthrough';
140
+ }
141
+ else {
142
+ toSkip = probe.audioOffset - buffered.length;
143
+ mode = 'skipping';
144
+ }
145
+ }
146
+ }
147
+ catch (err) {
148
+ return callback(err);
149
+ }
150
+ head = [];
151
+ headLength = 0;
152
+ return callback();
153
+ },
154
+ flush(callback) {
155
+ // a source too short to probe (or one that never closed its metadata
156
+ // chain) — tag what we have rather than dropping it
157
+ if (mode === 'probing' && headLength > 0) {
158
+ const buffered = head.length === 1 ? head[0] : Buffer.concat(head, headLength);
159
+ try {
160
+ this.push((0, id3_1.writeMetadataMp3)(Buffer.alloc(0), model));
161
+ this.push(buffered);
162
+ }
163
+ catch (err) {
164
+ return callback(err);
165
+ }
166
+ }
167
+ return callback();
168
+ },
169
+ });
170
+ };
171
+ exports.createTagStream = createTagStream;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "2.13.3",
3
+ "version": "2.15.0",
4
4
  "description": "Deezer API client, cross-service URL resolution, Blowfish track decryption and MP3/FLAC metadata tagging — the engine behind the gerdur CLI.",
5
5
  "keywords": [
6
6
  "deezer",