hebits-client 0.1.0 → 0.2.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/README.md CHANGED
@@ -21,6 +21,16 @@ The cookie eventually expires or gets invalidated server-side. When that happens
21
21
  call throws `LoginExpiredError` — see [Errors](#errors) below. There is no way to refresh
22
22
  it from inside this package; you have to log in again in a browser and supply a new one.
23
23
 
24
+ If your process keeps running across that rotation — a daemon, a long-lived server — pass
25
+ a function instead of a string. It's called fresh before every request, so a new cookie
26
+ takes effect on the very next call with no restart:
27
+
28
+ ```ts
29
+ new Hebits({ cookie: () => fs.readFileSync('cookie.txt', 'utf8').trim() });
30
+ ```
31
+
32
+ The function must be synchronous (read a file or an in-memory value, not an async source).
33
+
24
34
  ## Install
25
35
 
26
36
  ```sh
@@ -65,7 +75,7 @@ try {
65
75
 
66
76
  | option | type | default |
67
77
  | ------------- | ------------------------------------- | --------------------------- |
68
- | `cookie` | `string` | required |
78
+ | `cookie` | `string \| (() => string)` | required |
69
79
  | `baseUrl` | `string` | `https://hebits.net` |
70
80
  | `userAgent` | `string` | `hebits-client/<version>` |
71
81
  | `rateLimit` | `{ limit: number; interval: number }` | `{ limit: 1, interval: 2000 }` |
package/dist/index.d.ts CHANGED
@@ -1,113 +1,123 @@
1
+ import { z } from "zod";
2
+ //#region src/normalise.d.ts
1
3
  /** One torrent, with its group's context folded in. This is the package's central type
2
4
  * and the only shape consumers see. */
3
5
  interface HebitsTorrent {
4
- id: number;
5
- groupId: number;
6
- name: string;
7
- groupName: string;
8
- categoryId: number;
9
- imdb?: string;
10
- cover?: string;
11
- tags: string[];
12
- size: number;
13
- fileCount: number;
14
- seeders: number;
15
- leechers: number;
16
- snatches: number;
17
- uploadedAt: Date;
18
- resolution?: string;
19
- codec?: string;
20
- audio?: string;
21
- container?: string;
22
- downloadFactor: number;
23
- uploadFactor: number;
24
- canUseToken: boolean;
25
- hasSnatched: boolean;
6
+ id: number;
7
+ groupId: number;
8
+ name: string;
9
+ groupName: string;
10
+ categoryId: number;
11
+ imdb?: string;
12
+ cover?: string;
13
+ tags: string[];
14
+ size: number;
15
+ fileCount: number;
16
+ seeders: number;
17
+ leechers: number;
18
+ snatches: number;
19
+ uploadedAt: Date;
20
+ resolution?: string;
21
+ codec?: string;
22
+ audio?: string;
23
+ container?: string;
24
+ downloadFactor: number;
25
+ uploadFactor: number;
26
+ canUseToken: boolean;
27
+ hasSnatched: boolean;
26
28
  }
27
-
29
+ //#endregion
30
+ //#region src/transport.d.ts
28
31
  interface TransportOptions {
29
- cookie: string;
30
- baseUrl?: string;
31
- userAgent?: string;
32
- /** Default 1 request per 2s. Nothing here is latency-sensitive. */
33
- rateLimit?: {
34
- limit: number;
35
- interval: number;
36
- };
37
- /** Default 10 minutes. Set 0 to disable. */
38
- cacheTtlMs?: number;
39
- /** Caps how many distinct query keys the response cache holds at once — a modest LRU
40
- * bound, so a long-lived process issuing many distinct queries (e.g. one IMDb id per
41
- * browse call) doesn't grow the cache without limit. Default 200. */
42
- cacheMaxEntries?: number;
43
- retry?: number;
44
- timeoutMs?: number;
32
+ /** Session cookie for hebits.net. A string is sent as-is on every request — the usual
33
+ * case. Pass a function instead when the cookie can change while this client keeps
34
+ * running (an operator pastes a fresh one after the old one expired): it is called
35
+ * fresh before every request, so a new value takes effect on the very next call, with
36
+ * no restart and no code watching for rotation. Called synchronously — read a file or
37
+ * other cached value, don't do I/O inline. If it throws, the throw propagates to the
38
+ * caller of whichever call triggered it, same as any other broken input; an empty
39
+ * string is sent as-is, same as passing `cookie: ''` directly. */
40
+ cookie: string | (() => string);
41
+ baseUrl?: string;
42
+ userAgent?: string;
43
+ /** Default 1 request per 2s. Nothing here is latency-sensitive. */
44
+ rateLimit?: {
45
+ limit: number;
46
+ interval: number;
47
+ };
48
+ /** Default 10 minutes. Set 0 to disable. */
49
+ cacheTtlMs?: number;
50
+ /** Caps how many distinct query keys the response cache holds at once — a modest LRU
51
+ * bound, so a long-lived process issuing many distinct queries (e.g. one IMDb id per
52
+ * browse call) doesn't grow the cache without limit. Default 200. */
53
+ cacheMaxEntries?: number;
54
+ retry?: number;
55
+ timeoutMs?: number;
45
56
  }
46
-
57
+ //#endregion
58
+ //#region src/client.d.ts
47
59
  interface AccountStats {
48
- userId: number;
49
- uploaded: number;
50
- downloaded: number;
51
- ratio: number;
52
- requiredRatio: number;
53
- userClass: string;
60
+ userId: number;
61
+ uploaded: number;
62
+ downloaded: number;
63
+ ratio: number;
64
+ requiredRatio: number;
65
+ userClass: string;
54
66
  }
55
67
  interface BrowseOptions {
56
- /** Free-text search. An IMDb id works here — it is what Jackett sends too. Ignored if
57
- * `imdb` is also set — see `imdb` below. */
58
- query?: string;
59
- /** Convenience: sets `query` to this IMDb id. Takes precedence over `query`: passing
60
- * both silently discards `query`. */
61
- imdb?: string;
62
- /** Appended to the query; Gazelle has no season parameter. */
63
- season?: number;
64
- freeleechOnly?: boolean;
65
- /** 1 Movies, 2 TV, 8 Movie packs — see the tracker's category list. */
66
- categories?: number[];
67
- orderBy?: 'time' | 'size' | 'seeders' | 'snatches';
68
- orderWay?: 'asc' | 'desc';
69
- /** Cap applied after flattening. The API itself pages at 50 groups. */
70
- limit?: number;
68
+ /** Free-text search. An IMDb id works here — it is what Jackett sends too. Ignored if
69
+ * `imdb` is also set — see `imdb` below. */
70
+ query?: string;
71
+ /** Convenience: sets `query` to this IMDb id. Takes precedence over `query`: passing
72
+ * both silently discards `query`. */
73
+ imdb?: string;
74
+ /** Appended to the query; Gazelle has no season parameter. */
75
+ season?: number;
76
+ freeleechOnly?: boolean;
77
+ /** 1 Movies, 2 TV, 8 Movie packs — see the tracker's category list. */
78
+ categories?: number[];
79
+ orderBy?: 'time' | 'size' | 'seeders' | 'snatches';
80
+ orderWay?: 'asc' | 'desc';
81
+ /** Cap applied after flattening. The API itself pages at 50 groups. */
82
+ limit?: number;
71
83
  }
72
84
  type HebitsOptions = TransportOptions;
73
- declare class Hebits {
74
- #private;
75
- constructor(options: HebitsOptions);
76
- stats(): Promise<AccountStats>;
77
- /** The endpoint is user.php?id=N, so an id is needed. Resolves one via stats() when
78
- * not supplied, so the common call takes no arguments. */
79
- dailyDownloads(userId?: number): Promise<{
80
- used: number;
81
- limit: number;
82
- }>;
83
- checkLogin(): Promise<void>;
84
- browse(options?: BrowseOptions): Promise<HebitsTorrent[]>;
85
- /** The same endpoint as browse; separate because the call sites read differently. */
86
- search(options: BrowseOptions): Promise<HebitsTorrent[]>;
87
- /** Hebits serves an HTML page when it refuses a download, so validate before returning. */
88
- downloadTorrent(id: number): Promise<Uint8Array>;
85
+ export declare class Hebits {
86
+ #private;
87
+ constructor(options: HebitsOptions);
88
+ stats(): Promise<AccountStats>;
89
+ /** The endpoint is user.php?id=N, so an id is needed. Resolves one via stats() when
90
+ * not supplied, so the common call takes no arguments. */
91
+ dailyDownloads(userId?: number): Promise<{
92
+ used: number;
93
+ limit: number;
94
+ }>;
95
+ checkLogin(): Promise<void>;
96
+ browse(options?: BrowseOptions): Promise<HebitsTorrent[]>;
97
+ /** The same endpoint as browse; separate because the call sites read differently. */
98
+ search(options: BrowseOptions): Promise<HebitsTorrent[]>;
99
+ /** Hebits serves an HTML page when it refuses a download, so validate before returning. */
100
+ downloadTorrent(id: number): Promise<Uint8Array>;
89
101
  }
90
-
102
+ //#endregion
103
+ //#region src/errors.d.ts
91
104
  /** Base for everything this package throws. Consumers branch on the subclass. */
92
- declare class HebitsError extends Error {
93
- constructor(message: string, options?: {
94
- cause?: unknown;
95
- });
105
+ export declare class HebitsError extends Error {
106
+ constructor(message: string, options?: {
107
+ cause?: unknown;
108
+ });
96
109
  }
97
110
  /** The cookie is dead or was redirected to the login page. NEVER retried: retrying a
98
111
  * dead cookie just hammers the tracker. The operator must paste a fresh one. */
99
- declare class LoginExpiredError extends HebitsError {
100
- }
112
+ export declare class LoginExpiredError extends HebitsError {}
101
113
  /** The tracker asked us to slow down. */
102
- declare class RateLimitedError extends HebitsError {
103
- }
114
+ export declare class RateLimitedError extends HebitsError {}
104
115
  /** The API answered, but not in a shape we accept: a non-success status, or a response
105
116
  * that failed schema validation. A schema failure here means the tracker changed. */
106
- declare class ApiError extends HebitsError {
107
- }
117
+ export declare class ApiError extends HebitsError {}
108
118
  /** A .torrent download returned something that is not bencode — Hebits serves an HTML
109
119
  * page when it refuses a download. */
110
- declare class NotATorrentError extends HebitsError {
111
- }
112
-
113
- export { type AccountStats, ApiError, type BrowseOptions, Hebits, HebitsError, type HebitsOptions, type HebitsTorrent, LoginExpiredError, NotATorrentError, RateLimitedError };
120
+ export declare class NotATorrentError extends HebitsError {}
121
+ //#endregion
122
+ export type { AccountStats, BrowseOptions, HebitsOptions, HebitsTorrent };
123
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,375 +1,416 @@
1
- // src/errors.ts
1
+ import { z } from "zod";
2
+ import ky, { HTTPError } from "ky";
3
+ import pThrottle from "p-throttle";
4
+ //#region src/errors.ts
5
+ /** Base for everything this package throws. Consumers branch on the subclass. */
2
6
  var HebitsError = class extends Error {
3
- constructor(message, options) {
4
- super(message, options);
5
- this.name = new.target.name;
6
- }
7
- };
8
- var LoginExpiredError = class extends HebitsError {
9
- };
10
- var RateLimitedError = class extends HebitsError {
7
+ constructor(message, options) {
8
+ super(message, options);
9
+ this.name = new.target.name;
10
+ }
11
11
  };
12
- var ApiError = class extends HebitsError {
13
- };
14
- var NotATorrentError = class extends HebitsError {
15
- };
16
-
17
- // src/normalise.ts
12
+ /** The cookie is dead or was redirected to the login page. NEVER retried: retrying a
13
+ * dead cookie just hammers the tracker. The operator must paste a fresh one. */
14
+ var LoginExpiredError = class extends HebitsError {};
15
+ /** The tracker asked us to slow down. */
16
+ var RateLimitedError = class extends HebitsError {};
17
+ /** The API answered, but not in a shape we accept: a non-success status, or a response
18
+ * that failed schema validation. A schema failure here means the tracker changed. */
19
+ var ApiError = class extends HebitsError {};
20
+ /** A .torrent download returned something that is not bencode — Hebits serves an HTML
21
+ * page when it refuses a download. */
22
+ var NotATorrentError = class extends HebitsError {};
23
+ //#endregion
24
+ //#region src/normalise.ts
18
25
  function imdbFromCatalogue(url) {
19
- return url?.match(/\b(tt\d+)\b/)?.[1];
26
+ return url?.match(/\b(tt\d+)\b/)?.[1];
20
27
  }
28
+ /** The API returns an unzoned local timestamp. Israel observes DST, so a fixed +02:00
29
+ * offset is wrong for half the year — Jackett hardcodes it and is an hour out each
30
+ * summer.
31
+ *
32
+ * Resolve the real offset with Intl.formatToParts. Do NOT use the
33
+ * `new Date(d.toLocaleString('en-US', {timeZone}))` trick: it is correct only when the
34
+ * host machine runs in UTC, because the re-parse interprets the formatted string in the
35
+ * MACHINE's zone. Measured: on a host in Asia/Jerusalem it is 2h out in winter and 3h
36
+ * out in summer; in America/New_York, 5h out. That would silently corrupt any caller
37
+ * filtering on "uploaded in the last N hours". */
21
38
  function zoneOffsetMs(at, timeZone) {
22
- const fmt = new Intl.DateTimeFormat("en-US", {
23
- timeZone,
24
- hour12: false,
25
- year: "numeric",
26
- month: "2-digit",
27
- day: "2-digit",
28
- hour: "2-digit",
29
- minute: "2-digit",
30
- second: "2-digit"
31
- });
32
- const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
33
- const asUtc = Date.UTC(
34
- Number(p["year"]),
35
- Number(p["month"]) - 1,
36
- Number(p["day"]),
37
- Number(p["hour"]) % 24,
38
- Number(p["minute"]),
39
- Number(p["second"])
40
- );
41
- return asUtc - at.getTime();
39
+ const fmt = new Intl.DateTimeFormat("en-US", {
40
+ timeZone,
41
+ hour12: false,
42
+ year: "numeric",
43
+ month: "2-digit",
44
+ day: "2-digit",
45
+ hour: "2-digit",
46
+ minute: "2-digit",
47
+ second: "2-digit"
48
+ });
49
+ const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
50
+ return Date.UTC(Number(p["year"]), Number(p["month"]) - 1, Number(p["day"]), Number(p["hour"]) % 24, Number(p["minute"]), Number(p["second"])) - at.getTime();
42
51
  }
52
+ /** Two-pass offset resolution. A single sample at the naive-as-UTC instant is wrong near
53
+ * a DST transition, because the offset it reads is the one in force AT THAT INSTANT,
54
+ * not the one in force at the wall-clock time the string actually names. Sampling a
55
+ * second time at `naive - o1` — i.e. at our first guess of the real UTC instant —
56
+ * converges on the right offset on both sides of a transition.
57
+ *
58
+ * Two wall-clock windows are genuinely unrecoverable from an unzoned string alone, and
59
+ * two-pass resolves them the same way every other DST-aware parser does rather than
60
+ * producing garbage:
61
+ * - Spring forward (e.g. 03-27 02:00-02:59 in 2026): these wall times never occur.
62
+ * Two-pass maps them forward into 03:xx IDT — the "compatible" disambiguation
63
+ * `Temporal` also uses.
64
+ * - Fall back (e.g. 10-25 01:00-01:59 in 2026): these wall times occur twice. Two-pass
65
+ * resolves to the LATER (IST) reading, so a torrent uploaded in the first occurrence
66
+ * of that hour can read up to an hour newer than it really is. Not recoverable — the
67
+ * information needed to pick the earlier one is not in the data. */
43
68
  function parseHebitsTime(s) {
44
- const naive = Date.parse(`${s.replace(" ", "T")}Z`);
45
- if (Number.isNaN(naive)) throw new ApiError(`unparseable timestamp from Hebits: ${JSON.stringify(s)}`);
46
- const o1 = zoneOffsetMs(new Date(naive), "Asia/Jerusalem");
47
- const o2 = zoneOffsetMs(new Date(naive - o1), "Asia/Jerusalem");
48
- return new Date(naive - o2);
69
+ const naive = Date.parse(`${s.replace(" ", "T")}Z`);
70
+ if (Number.isNaN(naive)) throw new ApiError(`unparseable timestamp from Hebits: ${JSON.stringify(s)}`);
71
+ const o1 = zoneOffsetMs(new Date(naive), "Asia/Jerusalem");
72
+ const o2 = zoneOffsetMs(new Date(naive - o1), "Asia/Jerusalem");
73
+ return new Date(naive - o2);
49
74
  }
75
+ /** Collapse the tracker's seven boolean flags into the two numbers consumers reason
76
+ * about, so nobody has to remember that isQuarterLeech means 0.25.
77
+ *
78
+ * Ordering is deliberate: freeleech beats half- and quarter-leech, and between those
79
+ * two, the cheaper one wins over a torrent somehow flagged both (quarter overrides
80
+ * half). Neutral overrides everything else — it means neither side counts, regardless
81
+ * of what else is set. */
50
82
  function factorsFor(f) {
51
- let downloadFactor = 1;
52
- if (f.isHalfFreeleech) downloadFactor = 0.5;
53
- if (f.isQuarterLeech) downloadFactor = 0.25;
54
- if (f.isFreeleech || f.isPersonalFreeleech) downloadFactor = 0;
55
- let uploadFactor = 1;
56
- if (f.isUploadX2) uploadFactor = 2;
57
- if (f.isUploadX3) uploadFactor = 3;
58
- if (f.isNeutralLeech) return { downloadFactor: 0, uploadFactor: 0 };
59
- return { downloadFactor, uploadFactor };
83
+ let downloadFactor = 1;
84
+ if (f.isHalfFreeleech) downloadFactor = .5;
85
+ if (f.isQuarterLeech) downloadFactor = .25;
86
+ if (f.isFreeleech || f.isPersonalFreeleech) downloadFactor = 0;
87
+ let uploadFactor = 1;
88
+ if (f.isUploadX2) uploadFactor = 2;
89
+ if (f.isUploadX3) uploadFactor = 3;
90
+ if (f.isNeutralLeech) return {
91
+ downloadFactor: 0,
92
+ uploadFactor: 0
93
+ };
94
+ return {
95
+ downloadFactor,
96
+ uploadFactor
97
+ };
60
98
  }
61
99
  function flattenGroups(groups) {
62
- const out = [];
63
- for (const g of groups) {
64
- const imdb = imdbFromCatalogue(g.catalogue);
65
- for (const t of g.torrents) {
66
- out.push({
67
- id: t.torrentId,
68
- groupId: g.groupId,
69
- name: t.release ?? g.groupName,
70
- groupName: g.groupName,
71
- categoryId: g.categoryID,
72
- imdb,
73
- cover: g.cover,
74
- tags: g.tags ?? [],
75
- size: t.size,
76
- fileCount: t.fileCount,
77
- seeders: t.seeders,
78
- leechers: t.leechers,
79
- snatches: t.snatches,
80
- uploadedAt: parseHebitsTime(t.time),
81
- resolution: t.resolution,
82
- codec: t.codec,
83
- audio: t.audio,
84
- container: t.container,
85
- ...factorsFor(t),
86
- canUseToken: t.canUseToken,
87
- hasSnatched: t.hasSnatched
88
- });
89
- }
90
- }
91
- return out;
100
+ const out = [];
101
+ for (const g of groups) {
102
+ const imdb = imdbFromCatalogue(g.catalogue);
103
+ for (const t of g.torrents) out.push({
104
+ id: t.torrentId,
105
+ groupId: g.groupId,
106
+ name: t.release ?? g.groupName,
107
+ groupName: g.groupName,
108
+ categoryId: g.categoryID,
109
+ imdb,
110
+ cover: g.cover,
111
+ tags: g.tags ?? [],
112
+ size: t.size,
113
+ fileCount: t.fileCount,
114
+ seeders: t.seeders,
115
+ leechers: t.leechers,
116
+ snatches: t.snatches,
117
+ uploadedAt: parseHebitsTime(t.time),
118
+ resolution: t.resolution,
119
+ codec: t.codec,
120
+ audio: t.audio,
121
+ container: t.container,
122
+ ...factorsFor(t),
123
+ canUseToken: t.canUseToken,
124
+ hasSnatched: t.hasSnatched
125
+ });
126
+ }
127
+ return out;
92
128
  }
93
-
94
- // src/schemas.ts
95
- import { z } from "zod";
96
- var rawTorrentSchema = z.object({
97
- torrentId: z.number(),
98
- release: z.string().optional(),
99
- container: z.string().optional(),
100
- codec: z.string().optional(),
101
- resolution: z.string().optional(),
102
- audio: z.string().optional(),
103
- subbing: z.string().optional(),
104
- language: z.string().nullable().optional(),
105
- fileCount: z.number(),
106
- time: z.string(),
107
- size: z.number(),
108
- snatches: z.number(),
109
- seeders: z.number(),
110
- leechers: z.number(),
111
- isFreeleech: z.boolean(),
112
- isHalfFreeleech: z.boolean(),
113
- isQuarterLeech: z.boolean(),
114
- isNeutralLeech: z.boolean(),
115
- isPersonalFreeleech: z.boolean(),
116
- isUploadX2: z.boolean(),
117
- isUploadX3: z.boolean(),
118
- canUseToken: z.boolean(),
119
- hasSnatched: z.boolean()
129
+ //#endregion
130
+ //#region src/schemas.ts
131
+ /** A single torrent inside a group. Field types confirmed against the live API on
132
+ * 2026-09-18: the is* flags really are booleans, and `time` really is an unzoned string.
133
+ * `language` is confirmed against the fixtures to come back as JSON `null` (not omitted)
134
+ * on almost every torrent, so it is nullable as well as optional. */
135
+ const rawTorrentSchema = z.object({
136
+ torrentId: z.number(),
137
+ release: z.string().optional(),
138
+ container: z.string().optional(),
139
+ codec: z.string().optional(),
140
+ resolution: z.string().optional(),
141
+ audio: z.string().optional(),
142
+ subbing: z.string().optional(),
143
+ language: z.string().nullable().optional(),
144
+ fileCount: z.number(),
145
+ time: z.string(),
146
+ size: z.number(),
147
+ snatches: z.number(),
148
+ seeders: z.number(),
149
+ leechers: z.number(),
150
+ isFreeleech: z.boolean(),
151
+ isHalfFreeleech: z.boolean(),
152
+ isQuarterLeech: z.boolean(),
153
+ isNeutralLeech: z.boolean(),
154
+ isPersonalFreeleech: z.boolean(),
155
+ isUploadX2: z.boolean(),
156
+ isUploadX3: z.boolean(),
157
+ canUseToken: z.boolean(),
158
+ hasSnatched: z.boolean()
120
159
  });
121
- var rawGroupSchema = z.object({
122
- groupId: z.number(),
123
- groupName: z.string(),
124
- groupNameAlt: z.string().optional(),
125
- categoryID: z.number(),
126
- categoryName: z.string().optional(),
127
- cover: z.string().optional(),
128
- tags: z.array(z.string()).optional(),
129
- catalogue: z.string().optional(),
130
- groupYear: z.number().optional(),
131
- torrents: z.array(rawTorrentSchema)
160
+ /** A release group: one film or show, holding several encodes. */
161
+ const rawGroupSchema = z.object({
162
+ groupId: z.number(),
163
+ groupName: z.string(),
164
+ groupNameAlt: z.string().optional(),
165
+ categoryID: z.number(),
166
+ categoryName: z.string().optional(),
167
+ cover: z.string().optional(),
168
+ tags: z.array(z.string()).optional(),
169
+ catalogue: z.string().optional(),
170
+ groupYear: z.number().optional(),
171
+ torrents: z.array(rawTorrentSchema)
132
172
  });
133
- var browseResponseSchema = z.object({
134
- status: z.literal("success"),
135
- response: z.object({ results: z.array(rawGroupSchema) })
173
+ const browseResponseSchema = z.object({
174
+ status: z.literal("success"),
175
+ response: z.object({ results: z.array(rawGroupSchema) })
136
176
  });
137
- var rawUserStatsSchema = z.object({
138
- uploaded: z.number(),
139
- downloaded: z.number(),
140
- ratio: z.number(),
141
- requiredratio: z.number(),
142
- class: z.string()
177
+ const rawUserStatsSchema = z.object({
178
+ uploaded: z.number(),
179
+ downloaded: z.number(),
180
+ ratio: z.number(),
181
+ requiredratio: z.number(),
182
+ class: z.string()
143
183
  });
144
- var indexResponseSchema = z.object({
145
- status: z.literal("success"),
146
- response: z.object({
147
- id: z.number(),
148
- username: z.string().optional(),
149
- userstats: rawUserStatsSchema
150
- })
184
+ const indexResponseSchema = z.object({
185
+ status: z.literal("success"),
186
+ response: z.object({
187
+ id: z.number(),
188
+ username: z.string().optional(),
189
+ userstats: rawUserStatsSchema
190
+ })
151
191
  });
192
+ /** Parse, or throw an ApiError that names the endpoint and the offending fields.
193
+ * A failure here means the tracker changed its API — that is the signal this package
194
+ * exists to give, in place of the community maintenance Jackett used to provide. */
152
195
  function parseOrThrow(schema, data, endpoint) {
153
- const result = schema.safeParse(data);
154
- if (result.success) return result.data;
155
- const where = result.error.issues.slice(0, 5).map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
156
- throw new ApiError(`${endpoint} response did not match the expected shape \u2014 ${where}`);
196
+ const result = schema.safeParse(data);
197
+ if (result.success) return result.data;
198
+ throw new ApiError(`${endpoint} response did not match the expected shape — ${result.error.issues.slice(0, 5).map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
157
199
  }
158
-
159
- // src/scrape.ts
200
+ //#endregion
201
+ //#region src/scrape.ts
202
+ /** The profile page carries the daily download allowance as a Hebrew line:
203
+ * "הורדות יומיות: 3 / 10". Strip tags first so markup between the numbers cannot
204
+ * break the match. Returns null rather than guessing — a wrong limit here would let
205
+ * a caller spend downloads it does not have. */
160
206
  function parseDailyDownloads(html) {
161
- const text = html.replace(/<[^>]+>/g, " ");
162
- const m = text.match(/הורדות יומיות:\s*(\d+)\s*\/\s*(\d+)/);
163
- if (!m) return null;
164
- return { used: Number(m[1]), limit: Number(m[2]) };
207
+ const m = html.replace(/<[^>]+>/g, " ").match(/הורדות יומיות:\s*(\d+)\s*\/\s*(\d+)/);
208
+ if (!m) return null;
209
+ return {
210
+ used: Number(m[1]),
211
+ limit: Number(m[2])
212
+ };
165
213
  }
214
+ /** A logged-in page carries a logout link with an auth token. This is the same test
215
+ * Jackett's own indexer definition uses. */
166
216
  function isLoggedIn(html) {
167
- return /logout\.php\?auth=/.test(html);
217
+ return /logout\.php\?auth=/.test(html);
168
218
  }
169
-
170
- // src/transport.ts
171
- import ky, { HTTPError } from "ky";
172
- import pThrottle from "p-throttle";
173
- var VERSION = "0.1.0";
174
- var LOGIN_MARKERS = [/id=["']loginform["']/i, /action=["']login\.php/i];
175
- var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 500, 502, 503, 504]);
176
- var SNIFF_BYTES = 4096;
219
+ //#endregion
220
+ //#region src/transport.ts
221
+ const VERSION = "0.1.0";
222
+ const LOGIN_MARKERS = [/id=["']loginform["']/i, /action=["']login\.php/i];
223
+ const RETRYABLE_STATUS = /* @__PURE__ */ new Set([
224
+ 408,
225
+ 500,
226
+ 502,
227
+ 503,
228
+ 504
229
+ ]);
230
+ const SNIFF_BYTES = 4096;
231
+ /** A redirect to login, or a login form served with 200, both mean the cookie is dead.
232
+ * Matches the response URL's PATH only, not the full URL — a search for the literal
233
+ * string "login.php" (`browse({ query: 'login.php' })`) must not trip this. */
177
234
  function assertNotLoginPage(url, body) {
178
- const path = (() => {
179
- try {
180
- return new URL(url).pathname;
181
- } catch {
182
- return url;
183
- }
184
- })();
185
- if (path.endsWith("login.php") || LOGIN_MARKERS.some((re) => re.test(body))) {
186
- throw new LoginExpiredError("Hebits returned the login page \u2014 the cookie has expired");
187
- }
235
+ if ((() => {
236
+ try {
237
+ return new URL(url).pathname;
238
+ } catch {
239
+ return url;
240
+ }
241
+ })().endsWith("login.php") || LOGIN_MARKERS.some((re) => re.test(body))) throw new LoginExpiredError("Hebits returned the login page — the cookie has expired");
188
242
  }
243
+ /** Decode enough of a byte body to run the same login-page check text responses get.
244
+ * A .torrent file never decodes into anything matching LOGIN_MARKERS. */
189
245
  function sniff(body) {
190
- return typeof body === "string" ? body : new TextDecoder().decode(body.slice(0, SNIFF_BYTES));
246
+ return typeof body === "string" ? body : new TextDecoder().decode(body.slice(0, SNIFF_BYTES));
191
247
  }
192
248
  function createTransport(opts) {
193
- const {
194
- cookie,
195
- baseUrl = "https://hebits.net",
196
- userAgent = `hebits-client/${VERSION}`,
197
- rateLimit = { limit: 1, interval: 2e3 },
198
- cacheTtlMs = 10 * 60 * 1e3,
199
- cacheMaxEntries = 200,
200
- retry = 2,
201
- timeoutMs = 3e4
202
- } = opts;
203
- const client = ky.create({
204
- baseUrl,
205
- timeout: timeoutMs,
206
- redirect: "manual",
207
- // ky's own retry is disabled: we retry ourselves, one attempt at a time through the
208
- // throttle below, so a retry burst is still spaced like any other request. Retrying
209
- // inside a single throttle slot (ky's default) would let a 5xx burst fire several
210
- // requests back to back.
211
- retry: 0,
212
- headers: { cookie, "user-agent": userAgent }
213
- });
214
- const throttledAttempt = pThrottle(rateLimit)(
215
- async (path, sp, responseType) => {
216
- const res = await client.get(path, sp ? { searchParams: sp } : void 0);
217
- const body = responseType === "text" ? await res.text() : new Uint8Array(await res.arrayBuffer());
218
- return { res, body };
219
- }
220
- );
221
- async function request(path, sp, responseType, retriesLeft = retry) {
222
- try {
223
- const { res, body } = await throttledAttempt(path, sp, responseType);
224
- assertNotLoginPage(res.url, sniff(body));
225
- return body;
226
- } catch (e) {
227
- if (e instanceof HTTPError) {
228
- const { status, headers } = e.response;
229
- if (status === 429) throw new RateLimitedError("Hebits asked us to slow down", { cause: e });
230
- if (status >= 300 && status < 400 && /login\.php/.test(headers.get("location") ?? "")) {
231
- throw new LoginExpiredError("Hebits redirected to login \u2014 the cookie has expired", { cause: e });
232
- }
233
- if (RETRYABLE_STATUS.has(status) && retriesLeft > 0) {
234
- return request(path, sp, responseType, retriesLeft - 1);
235
- }
236
- throw new ApiError(`Hebits returned HTTP ${status} for ${path}`, { cause: e });
237
- }
238
- throw e;
239
- }
240
- }
241
- const cache = /* @__PURE__ */ new Map();
242
- const pending = /* @__PURE__ */ new Map();
243
- function pruneCache() {
244
- if (cacheTtlMs > 0) {
245
- const now = Date.now();
246
- for (const [k, v] of cache) {
247
- if (now - v.at >= cacheTtlMs) cache.delete(k);
248
- }
249
- }
250
- while (cache.size > cacheMaxEntries) {
251
- const oldest = cache.keys().next().value;
252
- if (oldest === void 0) break;
253
- cache.delete(oldest);
254
- }
255
- }
256
- function cacheGet(key) {
257
- const hit = cache.get(key);
258
- if (!hit || Date.now() - hit.at >= cacheTtlMs) return void 0;
259
- cache.delete(key);
260
- cache.set(key, hit);
261
- return hit.body;
262
- }
263
- function cacheSet(key, body) {
264
- cache.delete(key);
265
- cache.set(key, { at: Date.now(), body });
266
- pruneCache();
267
- }
268
- async function fetchBody(path, sp, opts2) {
269
- const key = `${path}?${new URLSearchParams(Object.entries(sp ?? {}).map(([k, v]) => [k, String(v)])).toString()}`;
270
- if (cacheTtlMs > 0 && !opts2?.bypassCache) {
271
- const hit = cacheGet(key);
272
- if (hit !== void 0) return hit;
273
- }
274
- const inFlight = pending.get(key);
275
- if (inFlight) return inFlight;
276
- const run = request(path, sp, "text").then(
277
- (body) => {
278
- if (cacheTtlMs > 0) cacheSet(key, body);
279
- pending.delete(key);
280
- return body;
281
- },
282
- (err) => {
283
- pending.delete(key);
284
- throw err;
285
- }
286
- );
287
- pending.set(key, run);
288
- return run;
289
- }
290
- return {
291
- async json(path, sp, opts2) {
292
- const body = await fetchBody(path, sp, opts2);
293
- try {
294
- return JSON.parse(body);
295
- } catch (e) {
296
- throw new ApiError(`${path} did not return JSON`, { cause: e });
297
- }
298
- },
299
- text: (path, sp, opts2) => fetchBody(path, sp, opts2),
300
- async bytes(path, sp) {
301
- return request(path, sp, "bytes");
302
- }
303
- };
249
+ const { cookie, baseUrl = "https://hebits.net", userAgent = `hebits-client/${VERSION}`, rateLimit = {
250
+ limit: 1,
251
+ interval: 2e3
252
+ }, cacheTtlMs = 6e5, cacheMaxEntries = 200, retry = 2, timeoutMs = 3e4 } = opts;
253
+ const client = ky.create({
254
+ baseUrl,
255
+ timeout: timeoutMs,
256
+ redirect: "manual",
257
+ retry: 0,
258
+ headers: {
259
+ "user-agent": userAgent,
260
+ ...typeof cookie === "string" ? { cookie } : {}
261
+ },
262
+ ...typeof cookie === "function" ? { hooks: { beforeRequest: [({ request }) => {
263
+ request.headers.set("cookie", cookie());
264
+ }] } } : {}
265
+ });
266
+ const throttledAttempt = pThrottle(rateLimit)(async (path, sp, responseType) => {
267
+ const res = await client.get(path, sp ? { searchParams: sp } : void 0);
268
+ return {
269
+ res,
270
+ body: responseType === "text" ? await res.text() : new Uint8Array(await res.arrayBuffer())
271
+ };
272
+ });
273
+ async function request(path, sp, responseType, retriesLeft = retry) {
274
+ try {
275
+ const { res, body } = await throttledAttempt(path, sp, responseType);
276
+ assertNotLoginPage(res.url, sniff(body));
277
+ return body;
278
+ } catch (e) {
279
+ if (e instanceof HTTPError) {
280
+ const { status, headers } = e.response;
281
+ if (status === 429) throw new RateLimitedError("Hebits asked us to slow down", { cause: e });
282
+ if (status >= 300 && status < 400 && /login\.php/.test(headers.get("location") ?? "")) throw new LoginExpiredError("Hebits redirected to login — the cookie has expired", { cause: e });
283
+ if (RETRYABLE_STATUS.has(status) && retriesLeft > 0) return request(path, sp, responseType, retriesLeft - 1);
284
+ throw new ApiError(`Hebits returned HTTP ${status} for ${path}`, { cause: e });
285
+ }
286
+ throw e;
287
+ }
288
+ }
289
+ const cache = /* @__PURE__ */ new Map();
290
+ const pending = /* @__PURE__ */ new Map();
291
+ function pruneCache() {
292
+ if (cacheTtlMs > 0) {
293
+ const now = Date.now();
294
+ for (const [k, v] of cache) if (now - v.at >= cacheTtlMs) cache.delete(k);
295
+ }
296
+ while (cache.size > cacheMaxEntries) {
297
+ const oldest = cache.keys().next().value;
298
+ if (oldest === void 0) break;
299
+ cache.delete(oldest);
300
+ }
301
+ }
302
+ function cacheGet(key) {
303
+ const hit = cache.get(key);
304
+ if (!hit || Date.now() - hit.at >= cacheTtlMs) return void 0;
305
+ cache.delete(key);
306
+ cache.set(key, hit);
307
+ return hit.body;
308
+ }
309
+ function cacheSet(key, body) {
310
+ cache.delete(key);
311
+ cache.set(key, {
312
+ at: Date.now(),
313
+ body
314
+ });
315
+ pruneCache();
316
+ }
317
+ async function fetchBody(path, sp, opts) {
318
+ const key = `${path}?${new URLSearchParams(Object.entries(sp ?? {}).map(([k, v]) => [k, String(v)])).toString()}`;
319
+ if (cacheTtlMs > 0 && !opts?.bypassCache) {
320
+ const hit = cacheGet(key);
321
+ if (hit !== void 0) return hit;
322
+ }
323
+ const inFlight = pending.get(key);
324
+ if (inFlight) return inFlight;
325
+ const run = request(path, sp, "text").then((body) => {
326
+ if (cacheTtlMs > 0) cacheSet(key, body);
327
+ pending.delete(key);
328
+ return body;
329
+ }, (err) => {
330
+ pending.delete(key);
331
+ throw err;
332
+ });
333
+ pending.set(key, run);
334
+ return run;
335
+ }
336
+ return {
337
+ async json(path, sp, opts) {
338
+ const body = await fetchBody(path, sp, opts);
339
+ try {
340
+ return JSON.parse(body);
341
+ } catch (e) {
342
+ throw new ApiError(`${path} did not return JSON`, { cause: e });
343
+ }
344
+ },
345
+ text: (path, sp, opts) => fetchBody(path, sp, opts),
346
+ async bytes(path, sp) {
347
+ return request(path, sp, "bytes");
348
+ }
349
+ };
304
350
  }
305
-
306
- // src/client.ts
351
+ //#endregion
352
+ //#region src/client.ts
307
353
  var Hebits = class {
308
- #transport;
309
- #userId;
310
- constructor(options) {
311
- this.#transport = createTransport(options);
312
- }
313
- async stats() {
314
- const raw = await this.#transport.json("ajax.php", { action: "index" });
315
- const { response } = parseOrThrow(indexResponseSchema, raw, "ajax.php?action=index");
316
- this.#userId = response.id;
317
- const u = response.userstats;
318
- return {
319
- userId: response.id,
320
- uploaded: u.uploaded,
321
- downloaded: u.downloaded,
322
- ratio: u.ratio,
323
- requiredRatio: u.requiredratio,
324
- userClass: u.class
325
- };
326
- }
327
- /** The endpoint is user.php?id=N, so an id is needed. Resolves one via stats() when
328
- * not supplied, so the common call takes no arguments. */
329
- async dailyDownloads(userId) {
330
- const id = userId ?? this.#userId ?? (await this.stats()).userId;
331
- const html = await this.#transport.text("user.php", { id }, { bypassCache: true });
332
- const parsed = parseDailyDownloads(html);
333
- if (!parsed) throw new ApiError("could not find the daily download counter on the profile page");
334
- return parsed;
335
- }
336
- async checkLogin() {
337
- const html = await this.#transport.text("", void 0, { bypassCache: true });
338
- if (!isLoggedIn(html)) throw new LoginExpiredError("no logout link on the front page \u2014 the cookie has expired");
339
- }
340
- async browse(options = {}) {
341
- const sp = { action: "browse", group_results: 0 };
342
- const terms = [options.imdb ?? options.query, options.season ? `S${String(options.season).padStart(2, "0")}` : void 0].filter(Boolean).join(" ");
343
- if (terms) sp["searchstr"] = terms;
344
- if (options.freeleechOnly) sp["freetorrent"] = 1;
345
- if (options.orderBy) sp["order_by"] = options.orderBy;
346
- if (options.orderWay) sp["order_way"] = options.orderWay;
347
- for (const c of options.categories ?? []) sp[`filter_cat[${c}]`] = 1;
348
- const raw = await this.#transport.json("ajax.php", sp);
349
- const parsed = parseOrThrow(browseResponseSchema, raw, "ajax.php?action=browse");
350
- const flat = flattenGroups(parsed.response.results);
351
- return options.limit === void 0 ? flat : flat.slice(0, options.limit);
352
- }
353
- /** The same endpoint as browse; separate because the call sites read differently. */
354
- search(options) {
355
- return this.browse(options);
356
- }
357
- /** Hebits serves an HTML page when it refuses a download, so validate before returning. */
358
- async downloadTorrent(id) {
359
- const bytes = await this.#transport.bytes("torrents.php", { action: "download", id });
360
- if (bytes[0] !== 100) {
361
- const head = new TextDecoder().decode(bytes.slice(0, 200)).replace(/\s+/g, " ");
362
- throw new NotATorrentError(`Hebits refused the download for torrent ${id}: ${head}`);
363
- }
364
- return bytes;
365
- }
366
- };
367
- export {
368
- ApiError,
369
- Hebits,
370
- HebitsError,
371
- LoginExpiredError,
372
- NotATorrentError,
373
- RateLimitedError
354
+ #transport;
355
+ #userId;
356
+ constructor(options) {
357
+ this.#transport = createTransport(options);
358
+ }
359
+ async stats() {
360
+ const raw = await this.#transport.json("ajax.php", { action: "index" });
361
+ const { response } = parseOrThrow(indexResponseSchema, raw, "ajax.php?action=index");
362
+ this.#userId = response.id;
363
+ const u = response.userstats;
364
+ return {
365
+ userId: response.id,
366
+ uploaded: u.uploaded,
367
+ downloaded: u.downloaded,
368
+ ratio: u.ratio,
369
+ requiredRatio: u.requiredratio,
370
+ userClass: u.class
371
+ };
372
+ }
373
+ /** The endpoint is user.php?id=N, so an id is needed. Resolves one via stats() when
374
+ * not supplied, so the common call takes no arguments. */
375
+ async dailyDownloads(userId) {
376
+ const id = userId ?? this.#userId ?? (await this.stats()).userId;
377
+ const parsed = parseDailyDownloads(await this.#transport.text("user.php", { id }, { bypassCache: true }));
378
+ if (!parsed) throw new ApiError("could not find the daily download counter on the profile page");
379
+ return parsed;
380
+ }
381
+ async checkLogin() {
382
+ if (!isLoggedIn(await this.#transport.text("", void 0, { bypassCache: true }))) throw new LoginExpiredError("no logout link on the front page — the cookie has expired");
383
+ }
384
+ async browse(options = {}) {
385
+ const sp = {
386
+ action: "browse",
387
+ group_results: 0
388
+ };
389
+ const terms = [options.imdb ?? options.query, options.season ? `S${String(options.season).padStart(2, "0")}` : void 0].filter(Boolean).join(" ");
390
+ if (terms) sp["searchstr"] = terms;
391
+ if (options.freeleechOnly) sp["freetorrent"] = 1;
392
+ if (options.orderBy) sp["order_by"] = options.orderBy;
393
+ if (options.orderWay) sp["order_way"] = options.orderWay;
394
+ for (const c of options.categories ?? []) sp[`filter_cat[${c}]`] = 1;
395
+ const raw = await this.#transport.json("ajax.php", sp);
396
+ const flat = flattenGroups(parseOrThrow(browseResponseSchema, raw, "ajax.php?action=browse").response.results);
397
+ return options.limit === void 0 ? flat : flat.slice(0, options.limit);
398
+ }
399
+ /** The same endpoint as browse; separate because the call sites read differently. */
400
+ search(options) {
401
+ return this.browse(options);
402
+ }
403
+ /** Hebits serves an HTML page when it refuses a download, so validate before returning. */
404
+ async downloadTorrent(id) {
405
+ const bytes = await this.#transport.bytes("torrents.php", {
406
+ action: "download",
407
+ id
408
+ });
409
+ if (bytes[0] !== 100) throw new NotATorrentError(`Hebits refused the download for torrent ${id}: ${new TextDecoder().decode(bytes.slice(0, 200)).replace(/\s+/g, " ")}`);
410
+ return bytes;
411
+ }
374
412
  };
413
+ //#endregion
414
+ export { ApiError, Hebits, HebitsError, LoginExpiredError, NotATorrentError, RateLimitedError };
415
+
375
416
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/normalise.ts","../src/schemas.ts","../src/scrape.ts","../src/transport.ts","../src/client.ts"],"sourcesContent":["/** Base for everything this package throws. Consumers branch on the subclass. */\nexport class HebitsError extends Error {\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options);\n this.name = new.target.name;\n }\n}\n\n/** The cookie is dead or was redirected to the login page. NEVER retried: retrying a\n * dead cookie just hammers the tracker. The operator must paste a fresh one. */\nexport class LoginExpiredError extends HebitsError {}\n\n/** The tracker asked us to slow down. */\nexport class RateLimitedError extends HebitsError {}\n\n/** The API answered, but not in a shape we accept: a non-success status, or a response\n * that failed schema validation. A schema failure here means the tracker changed. */\nexport class ApiError extends HebitsError {}\n\n/** A .torrent download returned something that is not bencode — Hebits serves an HTML\n * page when it refuses a download. */\nexport class NotATorrentError extends HebitsError {}\n","import { ApiError } from './errors.js';\nimport type { RawGroup, RawTorrent } from './schemas.js';\n\n/** One torrent, with its group's context folded in. This is the package's central type\n * and the only shape consumers see. */\nexport interface HebitsTorrent {\n id: number;\n groupId: number;\n name: string;\n groupName: string;\n categoryId: number;\n imdb?: string;\n cover?: string;\n tags: string[];\n size: number;\n fileCount: number;\n seeders: number;\n leechers: number;\n snatches: number;\n uploadedAt: Date;\n resolution?: string;\n codec?: string;\n audio?: string;\n container?: string;\n downloadFactor: number;\n uploadFactor: number;\n canUseToken: boolean;\n hasSnatched: boolean;\n}\n\nexport function imdbFromCatalogue(url: string | undefined): string | undefined {\n return url?.match(/\\b(tt\\d+)\\b/)?.[1];\n}\n\n/** The API returns an unzoned local timestamp. Israel observes DST, so a fixed +02:00\n * offset is wrong for half the year — Jackett hardcodes it and is an hour out each\n * summer.\n *\n * Resolve the real offset with Intl.formatToParts. Do NOT use the\n * `new Date(d.toLocaleString('en-US', {timeZone}))` trick: it is correct only when the\n * host machine runs in UTC, because the re-parse interprets the formatted string in the\n * MACHINE's zone. Measured: on a host in Asia/Jerusalem it is 2h out in winter and 3h\n * out in summer; in America/New_York, 5h out. That would silently corrupt any caller\n * filtering on \"uploaded in the last N hours\". */\nfunction zoneOffsetMs(at: Date, timeZone: string): number {\n const fmt = new Intl.DateTimeFormat('en-US', {\n timeZone, hour12: false,\n year: 'numeric', month: '2-digit', day: '2-digit',\n hour: '2-digit', minute: '2-digit', second: '2-digit',\n });\n const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value])) as Record<string, string>;\n const asUtc = Date.UTC(\n Number(p['year']), Number(p['month']) - 1, Number(p['day']),\n Number(p['hour']) % 24, Number(p['minute']), Number(p['second']),\n );\n return asUtc - at.getTime();\n}\n\n/** Two-pass offset resolution. A single sample at the naive-as-UTC instant is wrong near\n * a DST transition, because the offset it reads is the one in force AT THAT INSTANT,\n * not the one in force at the wall-clock time the string actually names. Sampling a\n * second time at `naive - o1` — i.e. at our first guess of the real UTC instant —\n * converges on the right offset on both sides of a transition.\n *\n * Two wall-clock windows are genuinely unrecoverable from an unzoned string alone, and\n * two-pass resolves them the same way every other DST-aware parser does rather than\n * producing garbage:\n * - Spring forward (e.g. 03-27 02:00-02:59 in 2026): these wall times never occur.\n * Two-pass maps them forward into 03:xx IDT — the \"compatible\" disambiguation\n * `Temporal` also uses.\n * - Fall back (e.g. 10-25 01:00-01:59 in 2026): these wall times occur twice. Two-pass\n * resolves to the LATER (IST) reading, so a torrent uploaded in the first occurrence\n * of that hour can read up to an hour newer than it really is. Not recoverable — the\n * information needed to pick the earlier one is not in the data. */\nexport function parseHebitsTime(s: string): Date {\n const naive = Date.parse(`${s.replace(' ', 'T')}Z`);\n if (Number.isNaN(naive)) throw new ApiError(`unparseable timestamp from Hebits: ${JSON.stringify(s)}`);\n const o1 = zoneOffsetMs(new Date(naive), 'Asia/Jerusalem');\n const o2 = zoneOffsetMs(new Date(naive - o1), 'Asia/Jerusalem');\n return new Date(naive - o2);\n}\n\ntype Flags = Pick<\n RawTorrent,\n 'isFreeleech' | 'isHalfFreeleech' | 'isQuarterLeech' | 'isNeutralLeech'\n | 'isPersonalFreeleech' | 'isUploadX2' | 'isUploadX3'\n>;\n\n/** Collapse the tracker's seven boolean flags into the two numbers consumers reason\n * about, so nobody has to remember that isQuarterLeech means 0.25.\n *\n * Ordering is deliberate: freeleech beats half- and quarter-leech, and between those\n * two, the cheaper one wins over a torrent somehow flagged both (quarter overrides\n * half). Neutral overrides everything else — it means neither side counts, regardless\n * of what else is set. */\nexport function factorsFor(f: Flags): { downloadFactor: number; uploadFactor: number } {\n let downloadFactor = 1;\n if (f.isHalfFreeleech) downloadFactor = 0.5;\n if (f.isQuarterLeech) downloadFactor = 0.25;\n if (f.isFreeleech || f.isPersonalFreeleech) downloadFactor = 0;\n\n let uploadFactor = 1;\n if (f.isUploadX2) uploadFactor = 2;\n if (f.isUploadX3) uploadFactor = 3;\n\n if (f.isNeutralLeech) return { downloadFactor: 0, uploadFactor: 0 };\n return { downloadFactor, uploadFactor };\n}\n\nexport function flattenGroups(groups: RawGroup[]): HebitsTorrent[] {\n const out: HebitsTorrent[] = [];\n for (const g of groups) {\n const imdb = imdbFromCatalogue(g.catalogue);\n for (const t of g.torrents) {\n out.push({\n id: t.torrentId,\n groupId: g.groupId,\n name: t.release ?? g.groupName,\n groupName: g.groupName,\n categoryId: g.categoryID,\n imdb,\n cover: g.cover,\n tags: g.tags ?? [],\n size: t.size,\n fileCount: t.fileCount,\n seeders: t.seeders,\n leechers: t.leechers,\n snatches: t.snatches,\n uploadedAt: parseHebitsTime(t.time),\n resolution: t.resolution,\n codec: t.codec,\n audio: t.audio,\n container: t.container,\n ...factorsFor(t),\n canUseToken: t.canUseToken,\n hasSnatched: t.hasSnatched,\n });\n }\n }\n return out;\n}\n","import { z } from 'zod';\nimport { ApiError } from './errors.js';\n\n/** A single torrent inside a group. Field types confirmed against the live API on\n * 2026-09-18: the is* flags really are booleans, and `time` really is an unzoned string.\n * `language` is confirmed against the fixtures to come back as JSON `null` (not omitted)\n * on almost every torrent, so it is nullable as well as optional. */\nexport const rawTorrentSchema = z.object({\n torrentId: z.number(),\n release: z.string().optional(),\n container: z.string().optional(),\n codec: z.string().optional(),\n resolution: z.string().optional(),\n audio: z.string().optional(),\n subbing: z.string().optional(),\n language: z.string().nullable().optional(),\n fileCount: z.number(),\n time: z.string(),\n size: z.number(),\n snatches: z.number(),\n seeders: z.number(),\n leechers: z.number(),\n isFreeleech: z.boolean(),\n isHalfFreeleech: z.boolean(),\n isQuarterLeech: z.boolean(),\n isNeutralLeech: z.boolean(),\n isPersonalFreeleech: z.boolean(),\n isUploadX2: z.boolean(),\n isUploadX3: z.boolean(),\n canUseToken: z.boolean(),\n hasSnatched: z.boolean(),\n});\n\n/** A release group: one film or show, holding several encodes. */\nexport const rawGroupSchema = z.object({\n groupId: z.number(),\n groupName: z.string(),\n groupNameAlt: z.string().optional(),\n categoryID: z.number(),\n categoryName: z.string().optional(),\n cover: z.string().optional(),\n tags: z.array(z.string()).optional(),\n catalogue: z.string().optional(),\n groupYear: z.number().optional(),\n torrents: z.array(rawTorrentSchema),\n});\n\nexport const browseResponseSchema = z.object({\n status: z.literal('success'),\n response: z.object({ results: z.array(rawGroupSchema) }),\n});\n\nexport const rawUserStatsSchema = z.object({\n uploaded: z.number(),\n downloaded: z.number(),\n ratio: z.number(),\n requiredratio: z.number(),\n class: z.string(),\n});\n\nexport const indexResponseSchema = z.object({\n status: z.literal('success'),\n response: z.object({\n id: z.number(),\n username: z.string().optional(),\n userstats: rawUserStatsSchema,\n }),\n});\n\nexport type RawTorrent = z.infer<typeof rawTorrentSchema>;\nexport type RawGroup = z.infer<typeof rawGroupSchema>;\nexport type RawUserStats = z.infer<typeof rawUserStatsSchema>;\n\n/** Parse, or throw an ApiError that names the endpoint and the offending fields.\n * A failure here means the tracker changed its API — that is the signal this package\n * exists to give, in place of the community maintenance Jackett used to provide. */\nexport function parseOrThrow<T extends z.ZodTypeAny>(schema: T, data: unknown, endpoint: string): z.infer<T> {\n const result = schema.safeParse(data);\n if (result.success) return result.data;\n const where = result.error.issues\n .slice(0, 5)\n .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)\n .join('; ');\n throw new ApiError(`${endpoint} response did not match the expected shape — ${where}`);\n}\n","/** The profile page carries the daily download allowance as a Hebrew line:\n * \"הורדות יומיות: 3 / 10\". Strip tags first so markup between the numbers cannot\n * break the match. Returns null rather than guessing — a wrong limit here would let\n * a caller spend downloads it does not have. */\nexport function parseDailyDownloads(html: string): { used: number; limit: number } | null {\n const text = html.replace(/<[^>]+>/g, ' ');\n const m = text.match(/הורדות יומיות:\\s*(\\d+)\\s*\\/\\s*(\\d+)/);\n if (!m) return null;\n return { used: Number(m[1]), limit: Number(m[2]) };\n}\n\n/** A logged-in page carries a logout link with an auth token. This is the same test\n * Jackett's own indexer definition uses. */\nexport function isLoggedIn(html: string): boolean {\n return /logout\\.php\\?auth=/.test(html);\n}\n","import ky, { HTTPError, type KyInstance } from 'ky';\nimport pThrottle from 'p-throttle';\nimport { ApiError, LoginExpiredError, RateLimitedError } from './errors.js';\n\nexport interface TransportOptions {\n cookie: string;\n baseUrl?: string;\n userAgent?: string;\n /** Default 1 request per 2s. Nothing here is latency-sensitive. */\n rateLimit?: { limit: number; interval: number };\n /** Default 10 minutes. Set 0 to disable. */\n cacheTtlMs?: number;\n /** Caps how many distinct query keys the response cache holds at once — a modest LRU\n * bound, so a long-lived process issuing many distinct queries (e.g. one IMDb id per\n * browse call) doesn't grow the cache without limit. Default 200. */\n cacheMaxEntries?: number;\n retry?: number;\n timeoutMs?: number;\n}\n\nexport interface RequestOptions {\n /** Skip the response cache for this call — read through to the tracker and still\n * write the fresh result back to the cache for everyone else. For calls where a\n * stale answer is actively wrong to act on (a login check, a daily quota count),\n * not just inconvenient. */\n bypassCache?: boolean;\n}\n\nexport interface Transport {\n json(path: string, searchParams?: Record<string, string | number>, opts?: RequestOptions): Promise<unknown>;\n text(path: string, searchParams?: Record<string, string | number>, opts?: RequestOptions): Promise<string>;\n bytes(path: string, searchParams?: Record<string, string | number>): Promise<Uint8Array>;\n}\n\n// Duplicates the version in package.json. Reading package.json from source would need an\n// import attribute and complicate the bundle for a string used in one header — accepted wart.\nconst VERSION = '0.1.0';\nconst LOGIN_MARKERS = [/id=[\"']loginform[\"']/i, /action=[\"']login\\.php/i];\nconst RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);\n// How much of a byte body to decode when sniffing for a login page. Login pages are\n// small; a .torrent's bencode never starts with anything that decodes into this text.\nconst SNIFF_BYTES = 4096;\n\n/** A redirect to login, or a login form served with 200, both mean the cookie is dead.\n * Matches the response URL's PATH only, not the full URL — a search for the literal\n * string \"login.php\" (`browse({ query: 'login.php' })`) must not trip this. */\nfunction assertNotLoginPage(url: string, body: string): void {\n const path = (() => {\n try {\n return new URL(url).pathname;\n } catch {\n return url;\n }\n })();\n if (path.endsWith('login.php') || LOGIN_MARKERS.some((re) => re.test(body))) {\n throw new LoginExpiredError('Hebits returned the login page — the cookie has expired');\n }\n}\n\n/** Decode enough of a byte body to run the same login-page check text responses get.\n * A .torrent file never decodes into anything matching LOGIN_MARKERS. */\nfunction sniff(body: string | Uint8Array): string {\n return typeof body === 'string' ? body : new TextDecoder().decode(body.slice(0, SNIFF_BYTES));\n}\n\nexport function createTransport(opts: TransportOptions): Transport {\n const {\n cookie,\n baseUrl = 'https://hebits.net',\n userAgent = `hebits-client/${VERSION}`,\n rateLimit = { limit: 1, interval: 2000 },\n cacheTtlMs = 10 * 60 * 1000,\n cacheMaxEntries = 200,\n retry = 2,\n timeoutMs = 30_000,\n } = opts;\n\n const client: KyInstance = ky.create({\n baseUrl,\n timeout: timeoutMs,\n redirect: 'manual',\n // ky's own retry is disabled: we retry ourselves, one attempt at a time through the\n // throttle below, so a retry burst is still spaced like any other request. Retrying\n // inside a single throttle slot (ky's default) would let a 5xx burst fire several\n // requests back to back.\n retry: 0,\n headers: { cookie, 'user-agent': userAgent },\n });\n\n // ONE throttle for every request this transport makes — text, JSON, bytes, and each\n // retry attempt of any of them. Two separate throttles (one per response type) would\n // let browsing and downloading interleave at double the configured rate.\n const throttledAttempt = pThrottle(rateLimit)(\n async (path: string, sp: Record<string, string | number> | undefined, responseType: 'text' | 'bytes') => {\n const res = await client.get(path, sp ? { searchParams: sp } : undefined);\n const body = responseType === 'text' ? await res.text() : new Uint8Array(await res.arrayBuffer());\n return { res, body };\n },\n );\n\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'text',\n retriesLeft?: number,\n ): Promise<string>;\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'bytes',\n retriesLeft?: number,\n ): Promise<Uint8Array>;\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'text' | 'bytes',\n retriesLeft: number = retry,\n ): Promise<string | Uint8Array> {\n try {\n const { res, body } = await throttledAttempt(path, sp, responseType);\n assertNotLoginPage(res.url, sniff(body));\n return body;\n } catch (e) {\n if (e instanceof HTTPError) {\n const { status, headers } = e.response;\n if (status === 429) throw new RateLimitedError('Hebits asked us to slow down', { cause: e });\n if (status >= 300 && status < 400 && /login\\.php/.test(headers.get('location') ?? '')) {\n throw new LoginExpiredError('Hebits redirected to login — the cookie has expired', { cause: e });\n }\n if (RETRYABLE_STATUS.has(status) && retriesLeft > 0) {\n return request(path, sp, responseType as 'text', retriesLeft - 1);\n }\n throw new ApiError(`Hebits returned HTTP ${status} for ${path}`, { cause: e });\n }\n throw e;\n }\n }\n\n // key -> settled value with its expiry, and key -> in-flight promise. `cache` is a\n // Map, so iteration order is insertion order; entries are deleted-and-reinserted on\n // every touch (read or write) so the first key is always the least recently used one.\n const cache = new Map<string, { at: number; body: string }>();\n const pending = new Map<string, Promise<string>>();\n\n function pruneCache(): void {\n if (cacheTtlMs > 0) {\n const now = Date.now();\n for (const [k, v] of cache) {\n if (now - v.at >= cacheTtlMs) cache.delete(k);\n }\n }\n while (cache.size > cacheMaxEntries) {\n const oldest = cache.keys().next().value;\n if (oldest === undefined) break;\n cache.delete(oldest);\n }\n }\n\n function cacheGet(key: string): string | undefined {\n const hit = cache.get(key);\n if (!hit || Date.now() - hit.at >= cacheTtlMs) return undefined;\n cache.delete(key);\n cache.set(key, hit); // bump recency\n return hit.body;\n }\n\n function cacheSet(key: string, body: string): void {\n cache.delete(key);\n cache.set(key, { at: Date.now(), body });\n pruneCache();\n }\n\n async function fetchBody(\n path: string,\n sp: Record<string, string | number> | undefined,\n opts?: RequestOptions,\n ): Promise<string> {\n const key = `${path}?${new URLSearchParams(Object.entries(sp ?? {}).map(([k, v]) => [k, String(v)])).toString()}`;\n if (cacheTtlMs > 0 && !opts?.bypassCache) {\n const hit = cacheGet(key);\n if (hit !== undefined) return hit;\n }\n const inFlight = pending.get(key);\n if (inFlight) return inFlight;\n\n // Attach the settle handler in the same statement that creates the promise, so the\n // stored promise always has a handler and a rejection is never unobserved.\n const run = request(path, sp, 'text').then(\n (body) => {\n if (cacheTtlMs > 0) cacheSet(key, body);\n pending.delete(key);\n return body;\n },\n (err) => {\n pending.delete(key); // never cache a failure\n throw err;\n },\n );\n pending.set(key, run);\n return run;\n }\n\n return {\n async json(path, sp, opts) {\n const body = await fetchBody(path, sp, opts);\n try {\n return JSON.parse(body);\n } catch (e) {\n throw new ApiError(`${path} did not return JSON`, { cause: e });\n }\n },\n text: (path, sp, opts) => fetchBody(path, sp, opts),\n async bytes(path, sp) {\n // Binary bodies skip the text CACHE — .torrent files are large and fetched once\n // each — but go through the same throttle, retry and error mapping (including the\n // login-page check) as everything else. A download is a tracker request like any\n // other, and letting it bypass rate limiting or error handling would defeat the\n // point of having them at all.\n return request(path, sp, 'bytes');\n },\n };\n}\n","import { ApiError, LoginExpiredError, NotATorrentError } from './errors.js';\nimport { flattenGroups, type HebitsTorrent } from './normalise.js';\nimport { browseResponseSchema, indexResponseSchema, parseOrThrow } from './schemas.js';\nimport { isLoggedIn, parseDailyDownloads } from './scrape.js';\nimport { createTransport, type Transport, type TransportOptions } from './transport.js';\n\nexport interface AccountStats {\n userId: number;\n uploaded: number;\n downloaded: number;\n ratio: number;\n requiredRatio: number;\n userClass: string;\n}\n\nexport interface BrowseOptions {\n /** Free-text search. An IMDb id works here — it is what Jackett sends too. Ignored if\n * `imdb` is also set — see `imdb` below. */\n query?: string;\n /** Convenience: sets `query` to this IMDb id. Takes precedence over `query`: passing\n * both silently discards `query`. */\n imdb?: string;\n /** Appended to the query; Gazelle has no season parameter. */\n season?: number;\n freeleechOnly?: boolean;\n /** 1 Movies, 2 TV, 8 Movie packs — see the tracker's category list. */\n categories?: number[];\n orderBy?: 'time' | 'size' | 'seeders' | 'snatches';\n orderWay?: 'asc' | 'desc';\n /** Cap applied after flattening. The API itself pages at 50 groups. */\n limit?: number;\n}\n\nexport type HebitsOptions = TransportOptions;\n\nexport class Hebits {\n readonly #transport: Transport;\n #userId: number | undefined;\n\n constructor(options: HebitsOptions) {\n this.#transport = createTransport(options);\n }\n\n async stats(): Promise<AccountStats> {\n const raw = await this.#transport.json('ajax.php', { action: 'index' });\n const { response } = parseOrThrow(indexResponseSchema, raw, 'ajax.php?action=index');\n this.#userId = response.id;\n const u = response.userstats;\n return {\n userId: response.id,\n uploaded: u.uploaded,\n downloaded: u.downloaded,\n ratio: u.ratio,\n requiredRatio: u.requiredratio,\n userClass: u.class,\n };\n }\n\n /** The endpoint is user.php?id=N, so an id is needed. Resolves one via stats() when\n * not supplied, so the common call takes no arguments. */\n async dailyDownloads(userId?: number): Promise<{ used: number; limit: number }> {\n const id = userId ?? this.#userId ?? (await this.stats()).userId;\n // Freshness-critical: a stale count could let a caller exceed the tracker's daily\n // download allowance, so this always reads through to the tracker.\n const html = await this.#transport.text('user.php', { id }, { bypassCache: true });\n const parsed = parseDailyDownloads(html);\n if (!parsed) throw new ApiError('could not find the daily download counter on the profile page');\n return parsed;\n }\n\n async checkLogin(): Promise<void> {\n // Freshness-critical: a cached page could report a dead cookie as valid for up to\n // `cacheTtlMs`, defeating the point of a health check.\n const html = await this.#transport.text('', undefined, { bypassCache: true });\n if (!isLoggedIn(html)) throw new LoginExpiredError('no logout link on the front page — the cookie has expired');\n }\n\n async browse(options: BrowseOptions = {}): Promise<HebitsTorrent[]> {\n const sp: Record<string, string | number> = { action: 'browse', group_results: 0 };\n const terms = [options.imdb ?? options.query, options.season ? `S${String(options.season).padStart(2, '0')}` : undefined]\n .filter(Boolean)\n .join(' ');\n if (terms) sp['searchstr'] = terms;\n if (options.freeleechOnly) sp['freetorrent'] = 1;\n if (options.orderBy) sp['order_by'] = options.orderBy;\n if (options.orderWay) sp['order_way'] = options.orderWay;\n for (const c of options.categories ?? []) sp[`filter_cat[${c}]`] = 1;\n\n const raw = await this.#transport.json('ajax.php', sp);\n const parsed = parseOrThrow(browseResponseSchema, raw, 'ajax.php?action=browse');\n const flat = flattenGroups(parsed.response.results);\n return options.limit === undefined ? flat : flat.slice(0, options.limit);\n }\n\n /** The same endpoint as browse; separate because the call sites read differently. */\n search(options: BrowseOptions): Promise<HebitsTorrent[]> {\n return this.browse(options);\n }\n\n /** Hebits serves an HTML page when it refuses a download, so validate before returning. */\n async downloadTorrent(id: number): Promise<Uint8Array> {\n const bytes = await this.#transport.bytes('torrents.php', { action: 'download', id });\n if (bytes[0] !== 0x64 /* 'd' */) {\n const head = new TextDecoder().decode(bytes.slice(0, 200)).replace(/\\s+/g, ' ');\n throw new NotATorrentError(`Hebits refused the download for torrent ${id}: ${head}`);\n }\n return bytes;\n }\n}\n"],"mappings":";AACO,IAAM,cAAN,cAA0B,MAAM;AAAA,EACrC,YAAY,SAAiB,SAA+B;AAC1D,UAAM,SAAS,OAAO;AACtB,SAAK,OAAO,WAAW;AAAA,EACzB;AACF;AAIO,IAAM,oBAAN,cAAgC,YAAY;AAAC;AAG7C,IAAM,mBAAN,cAA+B,YAAY;AAAC;AAI5C,IAAM,WAAN,cAAuB,YAAY;AAAC;AAIpC,IAAM,mBAAN,cAA+B,YAAY;AAAC;;;ACS5C,SAAS,kBAAkB,KAA6C;AAC7E,SAAO,KAAK,MAAM,aAAa,IAAI,CAAC;AACtC;AAYA,SAAS,aAAa,IAAU,UAA0B;AACxD,QAAM,MAAM,IAAI,KAAK,eAAe,SAAS;AAAA,IAC3C;AAAA,IAAU,QAAQ;AAAA,IAClB,MAAM;AAAA,IAAW,OAAO;AAAA,IAAW,KAAK;AAAA,IACxC,MAAM;AAAA,IAAW,QAAQ;AAAA,IAAW,QAAQ;AAAA,EAC9C,CAAC;AACD,QAAM,IAAI,OAAO,YAAY,IAAI,cAAc,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;AAChF,QAAM,QAAQ,KAAK;AAAA,IACjB,OAAO,EAAE,MAAM,CAAC;AAAA,IAAG,OAAO,EAAE,OAAO,CAAC,IAAI;AAAA,IAAG,OAAO,EAAE,KAAK,CAAC;AAAA,IAC1D,OAAO,EAAE,MAAM,CAAC,IAAI;AAAA,IAAI,OAAO,EAAE,QAAQ,CAAC;AAAA,IAAG,OAAO,EAAE,QAAQ,CAAC;AAAA,EACjE;AACA,SAAO,QAAQ,GAAG,QAAQ;AAC5B;AAkBO,SAAS,gBAAgB,GAAiB;AAC/C,QAAM,QAAQ,KAAK,MAAM,GAAG,EAAE,QAAQ,KAAK,GAAG,CAAC,GAAG;AAClD,MAAI,OAAO,MAAM,KAAK,EAAG,OAAM,IAAI,SAAS,sCAAsC,KAAK,UAAU,CAAC,CAAC,EAAE;AACrG,QAAM,KAAK,aAAa,IAAI,KAAK,KAAK,GAAG,gBAAgB;AACzD,QAAM,KAAK,aAAa,IAAI,KAAK,QAAQ,EAAE,GAAG,gBAAgB;AAC9D,SAAO,IAAI,KAAK,QAAQ,EAAE;AAC5B;AAeO,SAAS,WAAW,GAA4D;AACrF,MAAI,iBAAiB;AACrB,MAAI,EAAE,gBAAiB,kBAAiB;AACxC,MAAI,EAAE,eAAgB,kBAAiB;AACvC,MAAI,EAAE,eAAe,EAAE,oBAAqB,kBAAiB;AAE7D,MAAI,eAAe;AACnB,MAAI,EAAE,WAAY,gBAAe;AACjC,MAAI,EAAE,WAAY,gBAAe;AAEjC,MAAI,EAAE,eAAgB,QAAO,EAAE,gBAAgB,GAAG,cAAc,EAAE;AAClE,SAAO,EAAE,gBAAgB,aAAa;AACxC;AAEO,SAAS,cAAc,QAAqC;AACjE,QAAM,MAAuB,CAAC;AAC9B,aAAW,KAAK,QAAQ;AACtB,UAAM,OAAO,kBAAkB,EAAE,SAAS;AAC1C,eAAW,KAAK,EAAE,UAAU;AAC1B,UAAI,KAAK;AAAA,QACP,IAAI,EAAE;AAAA,QACN,SAAS,EAAE;AAAA,QACX,MAAM,EAAE,WAAW,EAAE;AAAA,QACrB,WAAW,EAAE;AAAA,QACb,YAAY,EAAE;AAAA,QACd;AAAA,QACA,OAAO,EAAE;AAAA,QACT,MAAM,EAAE,QAAQ,CAAC;AAAA,QACjB,MAAM,EAAE;AAAA,QACR,WAAW,EAAE;AAAA,QACb,SAAS,EAAE;AAAA,QACX,UAAU,EAAE;AAAA,QACZ,UAAU,EAAE;AAAA,QACZ,YAAY,gBAAgB,EAAE,IAAI;AAAA,QAClC,YAAY,EAAE;AAAA,QACd,OAAO,EAAE;AAAA,QACT,OAAO,EAAE;AAAA,QACT,WAAW,EAAE;AAAA,QACb,GAAG,WAAW,CAAC;AAAA,QACf,aAAa,EAAE;AAAA,QACf,aAAa,EAAE;AAAA,MACjB,CAAC;AAAA,IACH;AAAA,EACF;AACA,SAAO;AACT;;;AC5IA,SAAS,SAAS;AAOX,IAAM,mBAAmB,EAAE,OAAO;AAAA,EACvC,WAAW,EAAE,OAAO;AAAA,EACpB,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA,EAC7B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,EAC/B,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,EAC3B,YAAY,EAAE,OAAO,EAAE,SAAS;AAAA,EAChC,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,EAC3B,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA,EAC7B,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACzC,WAAW,EAAE,OAAO;AAAA,EACpB,MAAM,EAAE,OAAO;AAAA,EACf,MAAM,EAAE,OAAO;AAAA,EACf,UAAU,EAAE,OAAO;AAAA,EACnB,SAAS,EAAE,OAAO;AAAA,EAClB,UAAU,EAAE,OAAO;AAAA,EACnB,aAAa,EAAE,QAAQ;AAAA,EACvB,iBAAiB,EAAE,QAAQ;AAAA,EAC3B,gBAAgB,EAAE,QAAQ;AAAA,EAC1B,gBAAgB,EAAE,QAAQ;AAAA,EAC1B,qBAAqB,EAAE,QAAQ;AAAA,EAC/B,YAAY,EAAE,QAAQ;AAAA,EACtB,YAAY,EAAE,QAAQ;AAAA,EACtB,aAAa,EAAE,QAAQ;AAAA,EACvB,aAAa,EAAE,QAAQ;AACzB,CAAC;AAGM,IAAM,iBAAiB,EAAE,OAAO;AAAA,EACrC,SAAS,EAAE,OAAO;AAAA,EAClB,WAAW,EAAE,OAAO;AAAA,EACpB,cAAc,EAAE,OAAO,EAAE,SAAS;AAAA,EAClC,YAAY,EAAE,OAAO;AAAA,EACrB,cAAc,EAAE,OAAO,EAAE,SAAS;AAAA,EAClC,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,EAC3B,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA,EACnC,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,EAC/B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,EAC/B,UAAU,EAAE,MAAM,gBAAgB;AACpC,CAAC;AAEM,IAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,QAAQ,EAAE,QAAQ,SAAS;AAAA,EAC3B,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,EAAE,CAAC;AACzD,CAAC;AAEM,IAAM,qBAAqB,EAAE,OAAO;AAAA,EACzC,UAAU,EAAE,OAAO;AAAA,EACnB,YAAY,EAAE,OAAO;AAAA,EACrB,OAAO,EAAE,OAAO;AAAA,EAChB,eAAe,EAAE,OAAO;AAAA,EACxB,OAAO,EAAE,OAAO;AAClB,CAAC;AAEM,IAAM,sBAAsB,EAAE,OAAO;AAAA,EAC1C,QAAQ,EAAE,QAAQ,SAAS;AAAA,EAC3B,UAAU,EAAE,OAAO;AAAA,IACjB,IAAI,EAAE,OAAO;AAAA,IACb,UAAU,EAAE,OAAO,EAAE,SAAS;AAAA,IAC9B,WAAW;AAAA,EACb,CAAC;AACH,CAAC;AASM,SAAS,aAAqC,QAAW,MAAe,UAA8B;AAC3G,QAAM,SAAS,OAAO,UAAU,IAAI;AACpC,MAAI,OAAO,QAAS,QAAO,OAAO;AAClC,QAAM,QAAQ,OAAO,MAAM,OACxB,MAAM,GAAG,CAAC,EACV,IAAI,CAAC,MAAM,GAAG,EAAE,KAAK,KAAK,GAAG,KAAK,QAAQ,KAAK,EAAE,OAAO,EAAE,EAC1D,KAAK,IAAI;AACZ,QAAM,IAAI,SAAS,GAAG,QAAQ,qDAAgD,KAAK,EAAE;AACvF;;;AChFO,SAAS,oBAAoB,MAAsD;AACxF,QAAM,OAAO,KAAK,QAAQ,YAAY,GAAG;AACzC,QAAM,IAAI,KAAK,MAAM,qCAAqC;AAC1D,MAAI,CAAC,EAAG,QAAO;AACf,SAAO,EAAE,MAAM,OAAO,EAAE,CAAC,CAAC,GAAG,OAAO,OAAO,EAAE,CAAC,CAAC,EAAE;AACnD;AAIO,SAAS,WAAW,MAAuB;AAChD,SAAO,qBAAqB,KAAK,IAAI;AACvC;;;ACfA,OAAO,MAAM,iBAAkC;AAC/C,OAAO,eAAe;AAmCtB,IAAM,UAAU;AAChB,IAAM,gBAAgB,CAAC,yBAAyB,wBAAwB;AACxE,IAAM,mBAAmB,oBAAI,IAAI,CAAC,KAAK,KAAK,KAAK,KAAK,GAAG,CAAC;AAG1D,IAAM,cAAc;AAKpB,SAAS,mBAAmB,KAAa,MAAoB;AAC3D,QAAM,QAAQ,MAAM;AAClB,QAAI;AACF,aAAO,IAAI,IAAI,GAAG,EAAE;AAAA,IACtB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF,GAAG;AACH,MAAI,KAAK,SAAS,WAAW,KAAK,cAAc,KAAK,CAAC,OAAO,GAAG,KAAK,IAAI,CAAC,GAAG;AAC3E,UAAM,IAAI,kBAAkB,8DAAyD;AAAA,EACvF;AACF;AAIA,SAAS,MAAM,MAAmC;AAChD,SAAO,OAAO,SAAS,WAAW,OAAO,IAAI,YAAY,EAAE,OAAO,KAAK,MAAM,GAAG,WAAW,CAAC;AAC9F;AAEO,SAAS,gBAAgB,MAAmC;AACjE,QAAM;AAAA,IACJ;AAAA,IACA,UAAU;AAAA,IACV,YAAY,iBAAiB,OAAO;AAAA,IACpC,YAAY,EAAE,OAAO,GAAG,UAAU,IAAK;AAAA,IACvC,aAAa,KAAK,KAAK;AAAA,IACvB,kBAAkB;AAAA,IAClB,QAAQ;AAAA,IACR,YAAY;AAAA,EACd,IAAI;AAEJ,QAAM,SAAqB,GAAG,OAAO;AAAA,IACnC;AAAA,IACA,SAAS;AAAA,IACT,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA,IAKV,OAAO;AAAA,IACP,SAAS,EAAE,QAAQ,cAAc,UAAU;AAAA,EAC7C,CAAC;AAKD,QAAM,mBAAmB,UAAU,SAAS;AAAA,IAC1C,OAAO,MAAc,IAAiD,iBAAmC;AACvG,YAAM,MAAM,MAAM,OAAO,IAAI,MAAM,KAAK,EAAE,cAAc,GAAG,IAAI,MAAS;AACxE,YAAM,OAAO,iBAAiB,SAAS,MAAM,IAAI,KAAK,IAAI,IAAI,WAAW,MAAM,IAAI,YAAY,CAAC;AAChG,aAAO,EAAE,KAAK,KAAK;AAAA,IACrB;AAAA,EACF;AAcA,iBAAe,QACb,MACA,IACA,cACA,cAAsB,OACQ;AAC9B,QAAI;AACF,YAAM,EAAE,KAAK,KAAK,IAAI,MAAM,iBAAiB,MAAM,IAAI,YAAY;AACnE,yBAAmB,IAAI,KAAK,MAAM,IAAI,CAAC;AACvC,aAAO;AAAA,IACT,SAAS,GAAG;AACV,UAAI,aAAa,WAAW;AAC1B,cAAM,EAAE,QAAQ,QAAQ,IAAI,EAAE;AAC9B,YAAI,WAAW,IAAK,OAAM,IAAI,iBAAiB,gCAAgC,EAAE,OAAO,EAAE,CAAC;AAC3F,YAAI,UAAU,OAAO,SAAS,OAAO,aAAa,KAAK,QAAQ,IAAI,UAAU,KAAK,EAAE,GAAG;AACrF,gBAAM,IAAI,kBAAkB,4DAAuD,EAAE,OAAO,EAAE,CAAC;AAAA,QACjG;AACA,YAAI,iBAAiB,IAAI,MAAM,KAAK,cAAc,GAAG;AACnD,iBAAO,QAAQ,MAAM,IAAI,cAAwB,cAAc,CAAC;AAAA,QAClE;AACA,cAAM,IAAI,SAAS,wBAAwB,MAAM,QAAQ,IAAI,IAAI,EAAE,OAAO,EAAE,CAAC;AAAA,MAC/E;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAKA,QAAM,QAAQ,oBAAI,IAA0C;AAC5D,QAAM,UAAU,oBAAI,IAA6B;AAEjD,WAAS,aAAmB;AAC1B,QAAI,aAAa,GAAG;AAClB,YAAM,MAAM,KAAK,IAAI;AACrB,iBAAW,CAAC,GAAG,CAAC,KAAK,OAAO;AAC1B,YAAI,MAAM,EAAE,MAAM,WAAY,OAAM,OAAO,CAAC;AAAA,MAC9C;AAAA,IACF;AACA,WAAO,MAAM,OAAO,iBAAiB;AACnC,YAAM,SAAS,MAAM,KAAK,EAAE,KAAK,EAAE;AACnC,UAAI,WAAW,OAAW;AAC1B,YAAM,OAAO,MAAM;AAAA,IACrB;AAAA,EACF;AAEA,WAAS,SAAS,KAAiC;AACjD,UAAM,MAAM,MAAM,IAAI,GAAG;AACzB,QAAI,CAAC,OAAO,KAAK,IAAI,IAAI,IAAI,MAAM,WAAY,QAAO;AACtD,UAAM,OAAO,GAAG;AAChB,UAAM,IAAI,KAAK,GAAG;AAClB,WAAO,IAAI;AAAA,EACb;AAEA,WAAS,SAAS,KAAa,MAAoB;AACjD,UAAM,OAAO,GAAG;AAChB,UAAM,IAAI,KAAK,EAAE,IAAI,KAAK,IAAI,GAAG,KAAK,CAAC;AACvC,eAAW;AAAA,EACb;AAEA,iBAAe,UACb,MACA,IACAA,OACiB;AACjB,UAAM,MAAM,GAAG,IAAI,IAAI,IAAI,gBAAgB,OAAO,QAAQ,MAAM,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,SAAS,CAAC;AAC/G,QAAI,aAAa,KAAK,CAACA,OAAM,aAAa;AACxC,YAAM,MAAM,SAAS,GAAG;AACxB,UAAI,QAAQ,OAAW,QAAO;AAAA,IAChC;AACA,UAAM,WAAW,QAAQ,IAAI,GAAG;AAChC,QAAI,SAAU,QAAO;AAIrB,UAAM,MAAM,QAAQ,MAAM,IAAI,MAAM,EAAE;AAAA,MACpC,CAAC,SAAS;AACR,YAAI,aAAa,EAAG,UAAS,KAAK,IAAI;AACtC,gBAAQ,OAAO,GAAG;AAClB,eAAO;AAAA,MACT;AAAA,MACA,CAAC,QAAQ;AACP,gBAAQ,OAAO,GAAG;AAClB,cAAM;AAAA,MACR;AAAA,IACF;AACA,YAAQ,IAAI,KAAK,GAAG;AACpB,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,MAAM,KAAK,MAAM,IAAIA,OAAM;AACzB,YAAM,OAAO,MAAM,UAAU,MAAM,IAAIA,KAAI;AAC3C,UAAI;AACF,eAAO,KAAK,MAAM,IAAI;AAAA,MACxB,SAAS,GAAG;AACV,cAAM,IAAI,SAAS,GAAG,IAAI,wBAAwB,EAAE,OAAO,EAAE,CAAC;AAAA,MAChE;AAAA,IACF;AAAA,IACA,MAAM,CAAC,MAAM,IAAIA,UAAS,UAAU,MAAM,IAAIA,KAAI;AAAA,IAClD,MAAM,MAAM,MAAM,IAAI;AAMpB,aAAO,QAAQ,MAAM,IAAI,OAAO;AAAA,IAClC;AAAA,EACF;AACF;;;AC1LO,IAAM,SAAN,MAAa;AAAA,EACT;AAAA,EACT;AAAA,EAEA,YAAY,SAAwB;AAClC,SAAK,aAAa,gBAAgB,OAAO;AAAA,EAC3C;AAAA,EAEA,MAAM,QAA+B;AACnC,UAAM,MAAM,MAAM,KAAK,WAAW,KAAK,YAAY,EAAE,QAAQ,QAAQ,CAAC;AACtE,UAAM,EAAE,SAAS,IAAI,aAAa,qBAAqB,KAAK,uBAAuB;AACnF,SAAK,UAAU,SAAS;AACxB,UAAM,IAAI,SAAS;AACnB,WAAO;AAAA,MACL,QAAQ,SAAS;AAAA,MACjB,UAAU,EAAE;AAAA,MACZ,YAAY,EAAE;AAAA,MACd,OAAO,EAAE;AAAA,MACT,eAAe,EAAE;AAAA,MACjB,WAAW,EAAE;AAAA,IACf;AAAA,EACF;AAAA;AAAA;AAAA,EAIA,MAAM,eAAe,QAA2D;AAC9E,UAAM,KAAK,UAAU,KAAK,YAAY,MAAM,KAAK,MAAM,GAAG;AAG1D,UAAM,OAAO,MAAM,KAAK,WAAW,KAAK,YAAY,EAAE,GAAG,GAAG,EAAE,aAAa,KAAK,CAAC;AACjF,UAAM,SAAS,oBAAoB,IAAI;AACvC,QAAI,CAAC,OAAQ,OAAM,IAAI,SAAS,+DAA+D;AAC/F,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,aAA4B;AAGhC,UAAM,OAAO,MAAM,KAAK,WAAW,KAAK,IAAI,QAAW,EAAE,aAAa,KAAK,CAAC;AAC5E,QAAI,CAAC,WAAW,IAAI,EAAG,OAAM,IAAI,kBAAkB,gEAA2D;AAAA,EAChH;AAAA,EAEA,MAAM,OAAO,UAAyB,CAAC,GAA6B;AAClE,UAAM,KAAsC,EAAE,QAAQ,UAAU,eAAe,EAAE;AACjF,UAAM,QAAQ,CAAC,QAAQ,QAAQ,QAAQ,OAAO,QAAQ,SAAS,IAAI,OAAO,QAAQ,MAAM,EAAE,SAAS,GAAG,GAAG,CAAC,KAAK,MAAS,EACrH,OAAO,OAAO,EACd,KAAK,GAAG;AACX,QAAI,MAAO,IAAG,WAAW,IAAI;AAC7B,QAAI,QAAQ,cAAe,IAAG,aAAa,IAAI;AAC/C,QAAI,QAAQ,QAAS,IAAG,UAAU,IAAI,QAAQ;AAC9C,QAAI,QAAQ,SAAU,IAAG,WAAW,IAAI,QAAQ;AAChD,eAAW,KAAK,QAAQ,cAAc,CAAC,EAAG,IAAG,cAAc,CAAC,GAAG,IAAI;AAEnE,UAAM,MAAM,MAAM,KAAK,WAAW,KAAK,YAAY,EAAE;AACrD,UAAM,SAAS,aAAa,sBAAsB,KAAK,wBAAwB;AAC/E,UAAM,OAAO,cAAc,OAAO,SAAS,OAAO;AAClD,WAAO,QAAQ,UAAU,SAAY,OAAO,KAAK,MAAM,GAAG,QAAQ,KAAK;AAAA,EACzE;AAAA;AAAA,EAGA,OAAO,SAAkD;AACvD,WAAO,KAAK,OAAO,OAAO;AAAA,EAC5B;AAAA;AAAA,EAGA,MAAM,gBAAgB,IAAiC;AACrD,UAAM,QAAQ,MAAM,KAAK,WAAW,MAAM,gBAAgB,EAAE,QAAQ,YAAY,GAAG,CAAC;AACpF,QAAI,MAAM,CAAC,MAAM,KAAgB;AAC/B,YAAM,OAAO,IAAI,YAAY,EAAE,OAAO,MAAM,MAAM,GAAG,GAAG,CAAC,EAAE,QAAQ,QAAQ,GAAG;AAC9E,YAAM,IAAI,iBAAiB,2CAA2C,EAAE,KAAK,IAAI,EAAE;AAAA,IACrF;AACA,WAAO;AAAA,EACT;AACF;","names":["opts"]}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/errors.ts","../src/normalise.ts","../src/schemas.ts","../src/scrape.ts","../src/transport.ts","../src/client.ts"],"sourcesContent":["/** Base for everything this package throws. Consumers branch on the subclass. */\nexport class HebitsError extends Error {\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options);\n this.name = new.target.name;\n }\n}\n\n/** The cookie is dead or was redirected to the login page. NEVER retried: retrying a\n * dead cookie just hammers the tracker. The operator must paste a fresh one. */\nexport class LoginExpiredError extends HebitsError {}\n\n/** The tracker asked us to slow down. */\nexport class RateLimitedError extends HebitsError {}\n\n/** The API answered, but not in a shape we accept: a non-success status, or a response\n * that failed schema validation. A schema failure here means the tracker changed. */\nexport class ApiError extends HebitsError {}\n\n/** A .torrent download returned something that is not bencode — Hebits serves an HTML\n * page when it refuses a download. */\nexport class NotATorrentError extends HebitsError {}\n","import { ApiError } from './errors';\nimport type { RawGroup, RawTorrent } from './schemas';\n\n/** One torrent, with its group's context folded in. This is the package's central type\n * and the only shape consumers see. */\nexport interface HebitsTorrent {\n id: number;\n groupId: number;\n name: string;\n groupName: string;\n categoryId: number;\n imdb?: string;\n cover?: string;\n tags: string[];\n size: number;\n fileCount: number;\n seeders: number;\n leechers: number;\n snatches: number;\n uploadedAt: Date;\n resolution?: string;\n codec?: string;\n audio?: string;\n container?: string;\n downloadFactor: number;\n uploadFactor: number;\n canUseToken: boolean;\n hasSnatched: boolean;\n}\n\nexport function imdbFromCatalogue(url: string | undefined): string | undefined {\n return url?.match(/\\b(tt\\d+)\\b/)?.[1];\n}\n\n/** The API returns an unzoned local timestamp. Israel observes DST, so a fixed +02:00\n * offset is wrong for half the year — Jackett hardcodes it and is an hour out each\n * summer.\n *\n * Resolve the real offset with Intl.formatToParts. Do NOT use the\n * `new Date(d.toLocaleString('en-US', {timeZone}))` trick: it is correct only when the\n * host machine runs in UTC, because the re-parse interprets the formatted string in the\n * MACHINE's zone. Measured: on a host in Asia/Jerusalem it is 2h out in winter and 3h\n * out in summer; in America/New_York, 5h out. That would silently corrupt any caller\n * filtering on \"uploaded in the last N hours\". */\nfunction zoneOffsetMs(at: Date, timeZone: string): number {\n const fmt = new Intl.DateTimeFormat('en-US', {\n timeZone, hour12: false,\n year: 'numeric', month: '2-digit', day: '2-digit',\n hour: '2-digit', minute: '2-digit', second: '2-digit',\n });\n const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value])) as Record<string, string>;\n const asUtc = Date.UTC(\n Number(p['year']), Number(p['month']) - 1, Number(p['day']),\n Number(p['hour']) % 24, Number(p['minute']), Number(p['second']),\n );\n return asUtc - at.getTime();\n}\n\n/** Two-pass offset resolution. A single sample at the naive-as-UTC instant is wrong near\n * a DST transition, because the offset it reads is the one in force AT THAT INSTANT,\n * not the one in force at the wall-clock time the string actually names. Sampling a\n * second time at `naive - o1` — i.e. at our first guess of the real UTC instant —\n * converges on the right offset on both sides of a transition.\n *\n * Two wall-clock windows are genuinely unrecoverable from an unzoned string alone, and\n * two-pass resolves them the same way every other DST-aware parser does rather than\n * producing garbage:\n * - Spring forward (e.g. 03-27 02:00-02:59 in 2026): these wall times never occur.\n * Two-pass maps them forward into 03:xx IDT — the \"compatible\" disambiguation\n * `Temporal` also uses.\n * - Fall back (e.g. 10-25 01:00-01:59 in 2026): these wall times occur twice. Two-pass\n * resolves to the LATER (IST) reading, so a torrent uploaded in the first occurrence\n * of that hour can read up to an hour newer than it really is. Not recoverable — the\n * information needed to pick the earlier one is not in the data. */\nexport function parseHebitsTime(s: string): Date {\n const naive = Date.parse(`${s.replace(' ', 'T')}Z`);\n if (Number.isNaN(naive)) throw new ApiError(`unparseable timestamp from Hebits: ${JSON.stringify(s)}`);\n const o1 = zoneOffsetMs(new Date(naive), 'Asia/Jerusalem');\n const o2 = zoneOffsetMs(new Date(naive - o1), 'Asia/Jerusalem');\n return new Date(naive - o2);\n}\n\ntype Flags = Pick<\n RawTorrent,\n 'isFreeleech' | 'isHalfFreeleech' | 'isQuarterLeech' | 'isNeutralLeech'\n | 'isPersonalFreeleech' | 'isUploadX2' | 'isUploadX3'\n>;\n\n/** Collapse the tracker's seven boolean flags into the two numbers consumers reason\n * about, so nobody has to remember that isQuarterLeech means 0.25.\n *\n * Ordering is deliberate: freeleech beats half- and quarter-leech, and between those\n * two, the cheaper one wins over a torrent somehow flagged both (quarter overrides\n * half). Neutral overrides everything else — it means neither side counts, regardless\n * of what else is set. */\nexport function factorsFor(f: Flags): { downloadFactor: number; uploadFactor: number } {\n let downloadFactor = 1;\n if (f.isHalfFreeleech) downloadFactor = 0.5;\n if (f.isQuarterLeech) downloadFactor = 0.25;\n if (f.isFreeleech || f.isPersonalFreeleech) downloadFactor = 0;\n\n let uploadFactor = 1;\n if (f.isUploadX2) uploadFactor = 2;\n if (f.isUploadX3) uploadFactor = 3;\n\n if (f.isNeutralLeech) return { downloadFactor: 0, uploadFactor: 0 };\n return { downloadFactor, uploadFactor };\n}\n\nexport function flattenGroups(groups: RawGroup[]): HebitsTorrent[] {\n const out: HebitsTorrent[] = [];\n for (const g of groups) {\n const imdb = imdbFromCatalogue(g.catalogue);\n for (const t of g.torrents) {\n out.push({\n id: t.torrentId,\n groupId: g.groupId,\n name: t.release ?? g.groupName,\n groupName: g.groupName,\n categoryId: g.categoryID,\n imdb,\n cover: g.cover,\n tags: g.tags ?? [],\n size: t.size,\n fileCount: t.fileCount,\n seeders: t.seeders,\n leechers: t.leechers,\n snatches: t.snatches,\n uploadedAt: parseHebitsTime(t.time),\n resolution: t.resolution,\n codec: t.codec,\n audio: t.audio,\n container: t.container,\n ...factorsFor(t),\n canUseToken: t.canUseToken,\n hasSnatched: t.hasSnatched,\n });\n }\n }\n return out;\n}\n","import { z } from 'zod';\nimport { ApiError } from './errors';\n\n/** A single torrent inside a group. Field types confirmed against the live API on\n * 2026-09-18: the is* flags really are booleans, and `time` really is an unzoned string.\n * `language` is confirmed against the fixtures to come back as JSON `null` (not omitted)\n * on almost every torrent, so it is nullable as well as optional. */\nexport const rawTorrentSchema = z.object({\n torrentId: z.number(),\n release: z.string().optional(),\n container: z.string().optional(),\n codec: z.string().optional(),\n resolution: z.string().optional(),\n audio: z.string().optional(),\n subbing: z.string().optional(),\n language: z.string().nullable().optional(),\n fileCount: z.number(),\n time: z.string(),\n size: z.number(),\n snatches: z.number(),\n seeders: z.number(),\n leechers: z.number(),\n isFreeleech: z.boolean(),\n isHalfFreeleech: z.boolean(),\n isQuarterLeech: z.boolean(),\n isNeutralLeech: z.boolean(),\n isPersonalFreeleech: z.boolean(),\n isUploadX2: z.boolean(),\n isUploadX3: z.boolean(),\n canUseToken: z.boolean(),\n hasSnatched: z.boolean(),\n});\n\n/** A release group: one film or show, holding several encodes. */\nexport const rawGroupSchema = z.object({\n groupId: z.number(),\n groupName: z.string(),\n groupNameAlt: z.string().optional(),\n categoryID: z.number(),\n categoryName: z.string().optional(),\n cover: z.string().optional(),\n tags: z.array(z.string()).optional(),\n catalogue: z.string().optional(),\n groupYear: z.number().optional(),\n torrents: z.array(rawTorrentSchema),\n});\n\nexport const browseResponseSchema = z.object({\n status: z.literal('success'),\n response: z.object({ results: z.array(rawGroupSchema) }),\n});\n\nexport const rawUserStatsSchema = z.object({\n uploaded: z.number(),\n downloaded: z.number(),\n ratio: z.number(),\n requiredratio: z.number(),\n class: z.string(),\n});\n\nexport const indexResponseSchema = z.object({\n status: z.literal('success'),\n response: z.object({\n id: z.number(),\n username: z.string().optional(),\n userstats: rawUserStatsSchema,\n }),\n});\n\nexport type RawTorrent = z.infer<typeof rawTorrentSchema>;\nexport type RawGroup = z.infer<typeof rawGroupSchema>;\nexport type RawUserStats = z.infer<typeof rawUserStatsSchema>;\n\n/** Parse, or throw an ApiError that names the endpoint and the offending fields.\n * A failure here means the tracker changed its API — that is the signal this package\n * exists to give, in place of the community maintenance Jackett used to provide. */\nexport function parseOrThrow<T extends z.ZodTypeAny>(schema: T, data: unknown, endpoint: string): z.infer<T> {\n const result = schema.safeParse(data);\n if (result.success) return result.data;\n const where = result.error.issues\n .slice(0, 5)\n .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)\n .join('; ');\n throw new ApiError(`${endpoint} response did not match the expected shape — ${where}`);\n}\n","/** The profile page carries the daily download allowance as a Hebrew line:\n * \"הורדות יומיות: 3 / 10\". Strip tags first so markup between the numbers cannot\n * break the match. Returns null rather than guessing — a wrong limit here would let\n * a caller spend downloads it does not have. */\nexport function parseDailyDownloads(html: string): { used: number; limit: number } | null {\n const text = html.replace(/<[^>]+>/g, ' ');\n const m = text.match(/הורדות יומיות:\\s*(\\d+)\\s*\\/\\s*(\\d+)/);\n if (!m) return null;\n return { used: Number(m[1]), limit: Number(m[2]) };\n}\n\n/** A logged-in page carries a logout link with an auth token. This is the same test\n * Jackett's own indexer definition uses. */\nexport function isLoggedIn(html: string): boolean {\n return /logout\\.php\\?auth=/.test(html);\n}\n","import ky, { HTTPError, type KyInstance } from 'ky';\nimport pThrottle from 'p-throttle';\nimport { ApiError, LoginExpiredError, RateLimitedError } from './errors';\n\nexport interface TransportOptions {\n /** Session cookie for hebits.net. A string is sent as-is on every request — the usual\n * case. Pass a function instead when the cookie can change while this client keeps\n * running (an operator pastes a fresh one after the old one expired): it is called\n * fresh before every request, so a new value takes effect on the very next call, with\n * no restart and no code watching for rotation. Called synchronously — read a file or\n * other cached value, don't do I/O inline. If it throws, the throw propagates to the\n * caller of whichever call triggered it, same as any other broken input; an empty\n * string is sent as-is, same as passing `cookie: ''` directly. */\n cookie: string | (() => string);\n baseUrl?: string;\n userAgent?: string;\n /** Default 1 request per 2s. Nothing here is latency-sensitive. */\n rateLimit?: { limit: number; interval: number };\n /** Default 10 minutes. Set 0 to disable. */\n cacheTtlMs?: number;\n /** Caps how many distinct query keys the response cache holds at once — a modest LRU\n * bound, so a long-lived process issuing many distinct queries (e.g. one IMDb id per\n * browse call) doesn't grow the cache without limit. Default 200. */\n cacheMaxEntries?: number;\n retry?: number;\n timeoutMs?: number;\n}\n\nexport interface RequestOptions {\n /** Skip the response cache for this call — read through to the tracker and still\n * write the fresh result back to the cache for everyone else. For calls where a\n * stale answer is actively wrong to act on (a login check, a daily quota count),\n * not just inconvenient. */\n bypassCache?: boolean;\n}\n\nexport interface Transport {\n json(path: string, searchParams?: Record<string, string | number>, opts?: RequestOptions): Promise<unknown>;\n text(path: string, searchParams?: Record<string, string | number>, opts?: RequestOptions): Promise<string>;\n bytes(path: string, searchParams?: Record<string, string | number>): Promise<Uint8Array>;\n}\n\n// Duplicates the version in package.json. Reading package.json from source would need an\n// import attribute and complicate the bundle for a string used in one header — accepted wart.\nconst VERSION = '0.1.0';\nconst LOGIN_MARKERS = [/id=[\"']loginform[\"']/i, /action=[\"']login\\.php/i];\nconst RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);\n// How much of a byte body to decode when sniffing for a login page. Login pages are\n// small; a .torrent's bencode never starts with anything that decodes into this text.\nconst SNIFF_BYTES = 4096;\n\n/** A redirect to login, or a login form served with 200, both mean the cookie is dead.\n * Matches the response URL's PATH only, not the full URL — a search for the literal\n * string \"login.php\" (`browse({ query: 'login.php' })`) must not trip this. */\nfunction assertNotLoginPage(url: string, body: string): void {\n const path = (() => {\n try {\n return new URL(url).pathname;\n } catch {\n return url;\n }\n })();\n if (path.endsWith('login.php') || LOGIN_MARKERS.some((re) => re.test(body))) {\n throw new LoginExpiredError('Hebits returned the login page — the cookie has expired');\n }\n}\n\n/** Decode enough of a byte body to run the same login-page check text responses get.\n * A .torrent file never decodes into anything matching LOGIN_MARKERS. */\nfunction sniff(body: string | Uint8Array): string {\n return typeof body === 'string' ? body : new TextDecoder().decode(body.slice(0, SNIFF_BYTES));\n}\n\nexport function createTransport(opts: TransportOptions): Transport {\n const {\n cookie,\n baseUrl = 'https://hebits.net',\n userAgent = `hebits-client/${VERSION}`,\n rateLimit = { limit: 1, interval: 2000 },\n cacheTtlMs = 10 * 60 * 1000,\n cacheMaxEntries = 200,\n retry = 2,\n timeoutMs = 30_000,\n } = opts;\n\n const client: KyInstance = ky.create({\n baseUrl,\n timeout: timeoutMs,\n redirect: 'manual',\n // ky's own retry is disabled: we retry ourselves, one attempt at a time through the\n // throttle below, so a retry burst is still spaced like any other request. Retrying\n // inside a single throttle slot (ky's default) would let a 5xx burst fire several\n // requests back to back.\n retry: 0,\n // A string cookie is a static header, exactly as before. A function cookie is\n // resolved in beforeRequest instead, once per request, so a rotated value takes\n // effect on the very next call without recreating the client.\n headers: { 'user-agent': userAgent, ...(typeof cookie === 'string' ? { cookie } : {}) },\n ...(typeof cookie === 'function'\n ? { hooks: { beforeRequest: [({ request }) => { request.headers.set('cookie', cookie()); }] } }\n : {}),\n });\n\n // ONE throttle for every request this transport makes — text, JSON, bytes, and each\n // retry attempt of any of them. Two separate throttles (one per response type) would\n // let browsing and downloading interleave at double the configured rate.\n const throttledAttempt = pThrottle(rateLimit)(\n async (path: string, sp: Record<string, string | number> | undefined, responseType: 'text' | 'bytes') => {\n const res = await client.get(path, sp ? { searchParams: sp } : undefined);\n const body = responseType === 'text' ? await res.text() : new Uint8Array(await res.arrayBuffer());\n return { res, body };\n },\n );\n\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'text',\n retriesLeft?: number,\n ): Promise<string>;\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'bytes',\n retriesLeft?: number,\n ): Promise<Uint8Array>;\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'text' | 'bytes',\n retriesLeft: number = retry,\n ): Promise<string | Uint8Array> {\n try {\n const { res, body } = await throttledAttempt(path, sp, responseType);\n assertNotLoginPage(res.url, sniff(body));\n return body;\n } catch (e) {\n if (e instanceof HTTPError) {\n const { status, headers } = e.response;\n if (status === 429) throw new RateLimitedError('Hebits asked us to slow down', { cause: e });\n if (status >= 300 && status < 400 && /login\\.php/.test(headers.get('location') ?? '')) {\n throw new LoginExpiredError('Hebits redirected to login — the cookie has expired', { cause: e });\n }\n if (RETRYABLE_STATUS.has(status) && retriesLeft > 0) {\n return request(path, sp, responseType as 'text', retriesLeft - 1);\n }\n throw new ApiError(`Hebits returned HTTP ${status} for ${path}`, { cause: e });\n }\n throw e;\n }\n }\n\n // key -> settled value with its expiry, and key -> in-flight promise. `cache` is a\n // Map, so iteration order is insertion order; entries are deleted-and-reinserted on\n // every touch (read or write) so the first key is always the least recently used one.\n const cache = new Map<string, { at: number; body: string }>();\n const pending = new Map<string, Promise<string>>();\n\n function pruneCache(): void {\n if (cacheTtlMs > 0) {\n const now = Date.now();\n for (const [k, v] of cache) {\n if (now - v.at >= cacheTtlMs) cache.delete(k);\n }\n }\n while (cache.size > cacheMaxEntries) {\n const oldest = cache.keys().next().value;\n if (oldest === undefined) break;\n cache.delete(oldest);\n }\n }\n\n function cacheGet(key: string): string | undefined {\n const hit = cache.get(key);\n if (!hit || Date.now() - hit.at >= cacheTtlMs) return undefined;\n cache.delete(key);\n cache.set(key, hit); // bump recency\n return hit.body;\n }\n\n function cacheSet(key: string, body: string): void {\n cache.delete(key);\n cache.set(key, { at: Date.now(), body });\n pruneCache();\n }\n\n async function fetchBody(\n path: string,\n sp: Record<string, string | number> | undefined,\n opts?: RequestOptions,\n ): Promise<string> {\n const key = `${path}?${new URLSearchParams(Object.entries(sp ?? {}).map(([k, v]) => [k, String(v)])).toString()}`;\n if (cacheTtlMs > 0 && !opts?.bypassCache) {\n const hit = cacheGet(key);\n if (hit !== undefined) return hit;\n }\n const inFlight = pending.get(key);\n if (inFlight) return inFlight;\n\n // Attach the settle handler in the same statement that creates the promise, so the\n // stored promise always has a handler and a rejection is never unobserved.\n const run = request(path, sp, 'text').then(\n (body) => {\n if (cacheTtlMs > 0) cacheSet(key, body);\n pending.delete(key);\n return body;\n },\n (err) => {\n pending.delete(key); // never cache a failure\n throw err;\n },\n );\n pending.set(key, run);\n return run;\n }\n\n return {\n async json(path, sp, opts) {\n const body = await fetchBody(path, sp, opts);\n try {\n return JSON.parse(body);\n } catch (e) {\n throw new ApiError(`${path} did not return JSON`, { cause: e });\n }\n },\n text: (path, sp, opts) => fetchBody(path, sp, opts),\n async bytes(path, sp) {\n // Binary bodies skip the text CACHE — .torrent files are large and fetched once\n // each — but go through the same throttle, retry and error mapping (including the\n // login-page check) as everything else. A download is a tracker request like any\n // other, and letting it bypass rate limiting or error handling would defeat the\n // point of having them at all.\n return request(path, sp, 'bytes');\n },\n };\n}\n","import { ApiError, LoginExpiredError, NotATorrentError } from './errors';\nimport { flattenGroups, type HebitsTorrent } from './normalise';\nimport { browseResponseSchema, indexResponseSchema, parseOrThrow } from './schemas';\nimport { isLoggedIn, parseDailyDownloads } from './scrape';\nimport { createTransport, type Transport, type TransportOptions } from './transport';\n\nexport interface AccountStats {\n userId: number;\n uploaded: number;\n downloaded: number;\n ratio: number;\n requiredRatio: number;\n userClass: string;\n}\n\nexport interface BrowseOptions {\n /** Free-text search. An IMDb id works here — it is what Jackett sends too. Ignored if\n * `imdb` is also set — see `imdb` below. */\n query?: string;\n /** Convenience: sets `query` to this IMDb id. Takes precedence over `query`: passing\n * both silently discards `query`. */\n imdb?: string;\n /** Appended to the query; Gazelle has no season parameter. */\n season?: number;\n freeleechOnly?: boolean;\n /** 1 Movies, 2 TV, 8 Movie packs — see the tracker's category list. */\n categories?: number[];\n orderBy?: 'time' | 'size' | 'seeders' | 'snatches';\n orderWay?: 'asc' | 'desc';\n /** Cap applied after flattening. The API itself pages at 50 groups. */\n limit?: number;\n}\n\nexport type HebitsOptions = TransportOptions;\n\nexport class Hebits {\n readonly #transport: Transport;\n #userId: number | undefined;\n\n constructor(options: HebitsOptions) {\n this.#transport = createTransport(options);\n }\n\n async stats(): Promise<AccountStats> {\n const raw = await this.#transport.json('ajax.php', { action: 'index' });\n const { response } = parseOrThrow(indexResponseSchema, raw, 'ajax.php?action=index');\n this.#userId = response.id;\n const u = response.userstats;\n return {\n userId: response.id,\n uploaded: u.uploaded,\n downloaded: u.downloaded,\n ratio: u.ratio,\n requiredRatio: u.requiredratio,\n userClass: u.class,\n };\n }\n\n /** The endpoint is user.php?id=N, so an id is needed. Resolves one via stats() when\n * not supplied, so the common call takes no arguments. */\n async dailyDownloads(userId?: number): Promise<{ used: number; limit: number }> {\n const id = userId ?? this.#userId ?? (await this.stats()).userId;\n // Freshness-critical: a stale count could let a caller exceed the tracker's daily\n // download allowance, so this always reads through to the tracker.\n const html = await this.#transport.text('user.php', { id }, { bypassCache: true });\n const parsed = parseDailyDownloads(html);\n if (!parsed) throw new ApiError('could not find the daily download counter on the profile page');\n return parsed;\n }\n\n async checkLogin(): Promise<void> {\n // Freshness-critical: a cached page could report a dead cookie as valid for up to\n // `cacheTtlMs`, defeating the point of a health check.\n const html = await this.#transport.text('', undefined, { bypassCache: true });\n if (!isLoggedIn(html)) throw new LoginExpiredError('no logout link on the front page — the cookie has expired');\n }\n\n async browse(options: BrowseOptions = {}): Promise<HebitsTorrent[]> {\n const sp: Record<string, string | number> = { action: 'browse', group_results: 0 };\n const terms = [options.imdb ?? options.query, options.season ? `S${String(options.season).padStart(2, '0')}` : undefined]\n .filter(Boolean)\n .join(' ');\n if (terms) sp['searchstr'] = terms;\n if (options.freeleechOnly) sp['freetorrent'] = 1;\n if (options.orderBy) sp['order_by'] = options.orderBy;\n if (options.orderWay) sp['order_way'] = options.orderWay;\n for (const c of options.categories ?? []) sp[`filter_cat[${c}]`] = 1;\n\n const raw = await this.#transport.json('ajax.php', sp);\n const parsed = parseOrThrow(browseResponseSchema, raw, 'ajax.php?action=browse');\n const flat = flattenGroups(parsed.response.results);\n return options.limit === undefined ? flat : flat.slice(0, options.limit);\n }\n\n /** The same endpoint as browse; separate because the call sites read differently. */\n search(options: BrowseOptions): Promise<HebitsTorrent[]> {\n return this.browse(options);\n }\n\n /** Hebits serves an HTML page when it refuses a download, so validate before returning. */\n async downloadTorrent(id: number): Promise<Uint8Array> {\n const bytes = await this.#transport.bytes('torrents.php', { action: 'download', id });\n if (bytes[0] !== 0x64 /* 'd' */) {\n const head = new TextDecoder().decode(bytes.slice(0, 200)).replace(/\\s+/g, ' ');\n throw new NotATorrentError(`Hebits refused the download for torrent ${id}: ${head}`);\n }\n return bytes;\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,20 +1,39 @@
1
1
  {
2
2
  "name": "hebits-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A client for the Hebits private tracker's JSON API.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "repository": { "type": "git", "url": "git+https://github.com/lacherogwu/hebits-client.git" },
8
- "keywords": ["hebits", "gazelle", "torrent", "tracker", "api-client"],
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/lacherogwu/hebits-client.git"
10
+ },
11
+ "keywords": [
12
+ "hebits",
13
+ "gazelle",
14
+ "torrent",
15
+ "tracker",
16
+ "api-client"
17
+ ],
9
18
  "author": "Asaf (lacherogwu)",
10
- "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
11
- "files": ["dist"],
12
- "engines": { "node": ">=22" },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
13
31
  "scripts": {
14
- "build": "tsup",
15
- "typecheck": "tsc --noEmit",
32
+ "build": "tsdown",
33
+ "typecheck": "tsc",
16
34
  "test": "vitest run",
17
- "prepublishOnly": "npm run typecheck && npm test && npm run build"
35
+ "lint:publish": "publint && attw --pack . --profile esm-only",
36
+ "prepublishOnly": "npm run typecheck && npm test && npm run build && npm run lint:publish"
18
37
  },
19
38
  "dependencies": {
20
39
  "ky": "^2.1.0",
@@ -22,9 +41,12 @@
22
41
  "zod": "^4.6.5"
23
42
  },
24
43
  "devDependencies": {
44
+ "@arethetypeswrong/cli": "^0.18.5",
45
+ "@types/node": "^26.6.1",
25
46
  "msw": "^2.15.0",
26
- "tsup": "^8.5.1",
27
- "typescript": "^5.9.3",
47
+ "publint": "^0.3.24",
48
+ "tsdown": "^0.23.0",
49
+ "typescript": "^7.0.2",
28
50
  "vitest": "^5.0.1"
29
51
  }
30
52
  }