gerdur-core 2.18.0 → 2.19.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,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.19.0 - 2026-08-31
4
+
5
+ ### Changed
6
+
7
+ - **The Musixmatch lyrics fallback now latches off when it isn't working.**
8
+ For tracks Deezer has no lyrics for, the tagger scrapes Musixmatch — two page
9
+ loads each. Where Musixmatch blocks the request (it 403s from many networks and
10
+ most datacentres) every one of those still costs a round trip and still returns
11
+ nothing. Measured on a 14-track album: **2021 ms, 70% of total tagging time**,
12
+ for 3 KB of error pages.
13
+
14
+ After three consecutive *transport* failures the scraper stops being attempted
15
+ for the rest of the process. A track simply not being on Musixmatch does **not**
16
+ count toward that — the service is working fine in that case — so a run of
17
+ obscure tracks cannot disable a fallback that would otherwise succeed, and any
18
+ success resets the counter.
19
+
20
+ Measured, same album, default options: **2880 ms → 2641 ms on the first album
21
+ (the latch trips part-way through), then 653 / 729 ms — 77% faster in steady
22
+ state**, which is the case that matters for a library sync.
23
+
24
+ ### Added
25
+
26
+ - **`configureMusixmatch({maxFailures?, enabled?})`** and **`musixmatchStatus()`**
27
+ → `{available, consecutiveFailures, maxFailures}`. Re-enabling clears the
28
+ count; nonsensical thresholds are rejected.
29
+
30
+ ### Not done, deliberately
31
+
32
+ - Sending `Accept-Encoding` on the clients that don't set it. Measured the wire
33
+ first: a 14-track album transfers 176 KB, of which **111 KB is cover-art JPEG**
34
+ (incompressible) and 65 KB is `api.deezer.com` JSON that is **already gzipped**.
35
+ Musixmatch returns 3 KB total. The saving would have been ~0.
36
+
3
37
  ## 2.18.0 - 2026-08-31
4
38
 
5
39
  Write operations — the first part of this package that changes an account.
package/README.md CHANGED
@@ -589,7 +589,7 @@ model.contributors; // normalised producers / engineers / performers / …
589
589
  | `album` / `lyrics` / `publicTrack` | — | pre-fetched payloads — pass once per album to skip refetching |
590
590
  | `embedCover` / `embedArtistImage` | `true` | |
591
591
  | `writeLyrics` / `embedSyncedLyrics` | `true` | synced LRC goes to FLAC Vorbis only (no ID3v2.3 `SYLT`) |
592
- | `lyricsFallback` | `true` | scrape Musixmatch when Deezer has no lyrics — 2 requests per such track, and they fail where Musixmatch blocks you |
592
+ | `lyricsFallback` | `true` | scrape Musixmatch when Deezer has no lyrics — 2 requests per such track. Latches off automatically after 3 consecutive transport failures (see below) |
593
593
  | `richCredits` | `true` | hydrate credits + BPM for album/playlist tracks that omit them |
594
594
  | `deezerIds` / `includeRank` | `true` | write `DEEZER_*_ID` / popularity rank |
595
595
 
@@ -616,6 +616,22 @@ await pipeline(stream, createTagStream(model), createWriteStream('track.flac'));
616
616
  `resolveTagModel(track, options?)` does exactly what `addTrackTags` does minus
617
617
  the writing — same fetches, same coalescing, same `AddTrackTagsOptions`.
618
618
 
619
+ **The Musixmatch fallback looks after itself.** Where Musixmatch blocks you — it
620
+ 403s from many networks and most datacentres — those scrapes cost a round trip
621
+ each and return nothing: 2021 ms on a 14-track album, 70% of tagging time. After
622
+ three consecutive *transport* failures it stops being attempted for the rest of
623
+ the process, taking that album to 653 ms. A track simply not being on Musixmatch
624
+ doesn't count toward the latch, so it can't disable itself where it actually
625
+ works, and any success resets it.
626
+
627
+ ```ts
628
+ import {configureMusixmatch, musixmatchStatus} from 'gerdur-core';
629
+
630
+ musixmatchStatus(); // {available, consecutiveFailures, maxFailures}
631
+ configureMusixmatch({maxFailures: 5}); // more patient
632
+ configureMusixmatch({enabled: false}); // never scrape at all
633
+ ```
634
+
619
635
  Building blocks, if you want the model without writing tags:
620
636
 
621
637
  - **`getRichAlbum(albId)`** → merged gw + public album metadata (`RichAlbum`).
@@ -860,7 +876,8 @@ import type {
860
876
  `addFavoriteTracks` · `removeFavoriteTracks` · `addFavoriteAlbum` ·
861
877
  `removeFavoriteAlbum` · `addFavoriteArtist` · `removeFavoriteArtist` ·
862
878
  `followPlaylist` · `unfollowPlaylist` · `addFavoriteShow` · `createPlaylist` ·
863
- `addTracksToPlaylist` · `removeTracksFromPlaylist`
879
+ `addTracksToPlaylist` · `removeTracksFromPlaylist` · `configureMusixmatch` ·
880
+ `musixmatchStatus`
864
881
  </details>
865
882
 
866
883
  <details>
@@ -5,6 +5,8 @@ import type { lyricsType, trackType, trackTypePublicApi } from '../types';
5
5
  export { normalizeContributors } from './contributors';
6
6
  export type { NormalizedContributors } from './contributors';
7
7
  export { toLrc } from './lrc';
8
+ export { configureMusixmatch, musixmatchStatus } from './musixmatchLyrics';
9
+ export type { MusixmatchOptions } from './musixmatchLyrics';
8
10
  export { getRichAlbum } from './rich-album';
9
11
  export type { RichAlbum } from './rich-album';
10
12
  export { buildTagModel } from './model';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
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;
3
+ exports.addTrackTags = exports.resolveTagModel = exports.probeAudioOffset = exports.createTagStream = exports.MAX_COVER_SIZE = exports.downloadArtistImage = exports.downloadAlbumCover = exports.buildTagModel = exports.getRichAlbum = exports.musixmatchStatus = exports.configureMusixmatch = 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");
@@ -12,6 +12,9 @@ var contributors_1 = require("./contributors");
12
12
  Object.defineProperty(exports, "normalizeContributors", { enumerable: true, get: function () { return contributors_1.normalizeContributors; } });
13
13
  var lrc_1 = require("./lrc");
14
14
  Object.defineProperty(exports, "toLrc", { enumerable: true, get: function () { return lrc_1.toLrc; } });
15
+ var musixmatchLyrics_1 = require("./musixmatchLyrics");
16
+ Object.defineProperty(exports, "configureMusixmatch", { enumerable: true, get: function () { return musixmatchLyrics_1.configureMusixmatch; } });
17
+ Object.defineProperty(exports, "musixmatchStatus", { enumerable: true, get: function () { return musixmatchLyrics_1.musixmatchStatus; } });
15
18
  var rich_album_2 = require("./rich-album");
16
19
  Object.defineProperty(exports, "getRichAlbum", { enumerable: true, get: function () { return rich_album_2.getRichAlbum; } });
17
20
  var model_2 = require("./model");
@@ -1 +1,17 @@
1
+ declare let maxFailures: number;
2
+ export interface MusixmatchOptions {
3
+ /** consecutive transport failures before the scraper latches off. Default 3. */
4
+ maxFailures?: number;
5
+ /** force it back on (or off) — also clears the failure count when enabling */
6
+ enabled?: boolean;
7
+ }
8
+ /** Tune or reset the Musixmatch fallback. */
9
+ export declare const configureMusixmatch: (options?: MusixmatchOptions) => void;
10
+ /** Whether the fallback is still being attempted, and how close it is to latching off. */
11
+ export declare const musixmatchStatus: () => {
12
+ available: boolean;
13
+ consecutiveFailures: number;
14
+ maxFailures: number;
15
+ };
1
16
  export declare const getLyricsMusixmatch: (query: string) => Promise<string>;
17
+ export {};
@@ -1,10 +1,47 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getLyricsMusixmatch = void 0;
3
+ exports.getLyricsMusixmatch = exports.musixmatchStatus = exports.configureMusixmatch = void 0;
4
4
  const node_html_parser_1 = require("node-html-parser");
5
5
  const http_1 = require("../lib/http");
6
6
  const useragents_1 = require("./useragents");
7
7
  const baseUrl = 'https://musixmatch.com';
8
+ /**
9
+ * Musixmatch is a *fallback* for tracks Deezer has no lyrics for — two scraped
10
+ * page loads each. Where Musixmatch blocks the request (it 403s from plenty of
11
+ * networks and datacentres) every one of those still costs a round trip and
12
+ * still returns nothing: measured at **2021 ms per 14-track album, 70% of all
13
+ * tagging time**, for 3 KB of error pages.
14
+ *
15
+ * So after a few consecutive *transport* failures the scraper latches off for
16
+ * the rest of the process. A track simply not being on Musixmatch does **not**
17
+ * count — that means the service is working fine — so a run of obscure tracks
18
+ * can't disable a fallback that would otherwise work. Any success resets it.
19
+ */
20
+ let consecutiveTransportFailures = 0;
21
+ let latchedOff = false;
22
+ let maxFailures = 3;
23
+ /** Errors we raise ourselves for a legitimate miss, as opposed to the service failing. */
24
+ const MISS = /^No (song|lyrics) found!$/;
25
+ /** Tune or reset the Musixmatch fallback. */
26
+ const configureMusixmatch = (options = {}) => {
27
+ if (typeof options.maxFailures === 'number' && options.maxFailures > 0) {
28
+ maxFailures = Math.floor(options.maxFailures);
29
+ }
30
+ if (options.enabled !== undefined) {
31
+ latchedOff = !options.enabled;
32
+ if (options.enabled) {
33
+ consecutiveTransportFailures = 0;
34
+ }
35
+ }
36
+ };
37
+ exports.configureMusixmatch = configureMusixmatch;
38
+ /** Whether the fallback is still being attempted, and how close it is to latching off. */
39
+ const musixmatchStatus = () => ({
40
+ available: !latchedOff,
41
+ consecutiveFailures: consecutiveTransportFailures,
42
+ maxFailures,
43
+ });
44
+ exports.musixmatchStatus = musixmatchStatus;
8
45
  const getUrlMusixmatch = async (query) => {
9
46
  var _a;
10
47
  const data = await (0, http_1.getText)(`${baseUrl}/search/${encodeURI(query)}/tracks`, {
@@ -20,7 +57,7 @@ const getUrlMusixmatch = async (query) => {
20
57
  }
21
58
  throw new Error('No song found!');
22
59
  };
23
- const getLyricsMusixmatch = async (query) => {
60
+ const scrape = async (query) => {
24
61
  const url = await getUrlMusixmatch(query);
25
62
  const data = await (0, http_1.getText)(url, {
26
63
  headers: {
@@ -36,4 +73,25 @@ const getLyricsMusixmatch = async (query) => {
36
73
  lyrics = lyrics.replace('"body":"', '').replace('","language"', '');
37
74
  return lyrics.split('\\n').join('\n');
38
75
  };
76
+ const getLyricsMusixmatch = async (query) => {
77
+ var _a;
78
+ if (latchedOff) {
79
+ throw new Error('Musixmatch fallback is unavailable from this network (latched off)');
80
+ }
81
+ try {
82
+ const lyrics = await scrape(query);
83
+ consecutiveTransportFailures = 0; // it works — forget any earlier trouble
84
+ return lyrics;
85
+ }
86
+ catch (err) {
87
+ // a track that simply isn't there says nothing about the service
88
+ if (!MISS.test((_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : '')) {
89
+ consecutiveTransportFailures += 1;
90
+ if (consecutiveTransportFailures >= maxFailures) {
91
+ latchedOff = true;
92
+ }
93
+ }
94
+ throw err;
95
+ }
96
+ };
39
97
  exports.getLyricsMusixmatch = getLyricsMusixmatch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "2.18.0",
3
+ "version": "2.19.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",