@torrent-tv/proxy 2.89.18 → 2.89.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.89.18",
3
+ "version": "2.89.19",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -9,11 +9,12 @@
9
9
  * the evidence for exactly this reason.
10
10
  *
11
11
  * Written here instead, next to the proxy's own log, on the same durable
12
- * directory (`/data` on the addon host). One file per viewing session, named so
13
- * the two halves join without guessing: the session's start, then the torrent.
12
+ * directory (`/data` on the addon host). One file per viewing session and
13
+ * torrent, named so the two halves join without guessing: the session's start,
14
+ * then the torrent.
14
15
  */
15
16
 
16
- import { createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
17
+ import { createWriteStream, fstatSync, mkdirSync, openSync, renameSync } from "node:fs";
17
18
  import { dirname, join } from "node:path";
18
19
 
19
20
  /**
@@ -82,45 +83,106 @@ function stamp(iso) {
82
83
  *
83
84
  * @param {string} proxyLogPath - The proxy's own log file; its directory is
84
85
  * used, so the two halves are always side by side.
85
- * @returns {{ write: (session: object, lines: string[]) => void, close: () => void }}
86
+ * @returns {{ write: (session: object, lines: string[]) => void, close: () => Promise<void> }}
86
87
  */
87
88
  export function createClientLogFiles(proxyLogPath) {
88
89
  const directory = typeof proxyLogPath === "string" && proxyLogPath.length > 0
89
90
  ? dirname(proxyLogPath)
90
91
  : "";
91
- /** @type {Map<string, { stream: import("node:fs").WriteStream, path: string, bytes: number }>} */
92
+ /**
93
+ * @typedef {object} OpenFile
94
+ * @property {import("node:fs").WriteStream} stream
95
+ * @property {string} path
96
+ * @property {number} bytes
97
+ * @property {string} prefix - `client-<start>-<session>`, the part every file
98
+ * of one session shares, so they are found together.
99
+ * @property {string} film - Which torrent the name carries; empty while it
100
+ * still says `no-torrent-yet`.
101
+ */
102
+ /** @type {Map<string, OpenFile>} */
92
103
  const open = new Map();
104
+ /**
105
+ * Files taken out of `open` whose last lines are still being flushed: a
106
+ * rotated file, and the file of a film the session has moved away from.
107
+ *
108
+ * @type {Set<Promise<void>>}
109
+ */
110
+ const ending = new Set();
93
111
 
94
112
  /**
95
- * The file for this session, opened on first use.
113
+ * Finish a file that is no longer written to, and remember the wait so that
114
+ * `close()` does not return before its last lines are on disk.
96
115
  *
97
- * Keyed by the session's own id rather than by the name, because the name
98
- * gains the torrent the moment one is chosen and the earlier lines — the
99
- * page opening, the proxy being chosen, a connection failing — belong to the
100
- * same session and must not start a second file.
116
+ * @param {OpenFile} record
117
+ * @returns {void}
118
+ */
119
+ function finish(record) {
120
+ const done = new Promise((resolve) => {
121
+ try {
122
+ record.stream.end(() => resolve(undefined));
123
+ } catch {
124
+ // A stream that cannot be ended has nothing left to flush.
125
+ resolve(undefined);
126
+ }
127
+ });
128
+ ending.add(done);
129
+ void done.then(() => ending.delete(done));
130
+ }
131
+
132
+ /**
133
+ * Which torrent a batch names, or empty when none is chosen yet.
101
134
  *
102
- * @param {{ sessionId: string, startedAt: string, torrentName: string, infoHash: string }} session
103
- * @returns {{ stream: import("node:fs").WriteStream, path: string, bytes: number } | null}
135
+ * @param {{ torrentName: string, infoHash: string }} session
136
+ * @returns {string}
104
137
  */
105
- function fileFor(session) {
106
- if (!directory) return null;
107
- const key = session.sessionId;
108
- const existing = open.get(key);
109
- if (existing) return existing;
138
+ function filmOf(session) {
139
+ if (!session.torrentName) return "";
140
+ return session.infoHash || session.torrentName;
141
+ }
142
+
143
+ /**
144
+ * @param {string} prefix
145
+ * @param {{ torrentName: string, infoHash: string }} session
146
+ * @returns {string}
147
+ */
148
+ function pathFor(prefix, session) {
110
149
  const film = session.torrentName
111
150
  ? `${safeName(session.torrentName, 60)}-${safeName(session.infoHash, 8)}`
112
151
  : "no-torrent-yet";
113
- const path = join(directory, `client-${stamp(session.startedAt)}-${safeName(session.sessionId, 8)}-${film}.log`);
152
+ return join(directory, `${prefix}-${film}.log`);
153
+ }
154
+
155
+ /**
156
+ * Open a file for appending.
157
+ *
158
+ * The descriptor is opened synchronously and handed to the stream, so the
159
+ * file exists under its path before this returns. A stream that opens its
160
+ * own file does so later, and a rename made in between would leave the
161
+ * stream creating a second file under the old name.
162
+ *
163
+ * @param {string} key
164
+ * @param {string} prefix
165
+ * @param {{ torrentName: string, infoHash: string }} session
166
+ * @returns {OpenFile | null}
167
+ */
168
+ function openFile(key, prefix, session) {
169
+ const path = pathFor(prefix, session);
114
170
  try {
115
171
  mkdirSync(directory, { recursive: true });
172
+ const fd = openSync(path, "a");
173
+ /** @type {OpenFile} */
116
174
  const record = {
117
- stream: createWriteStream(path, { flags: "a" }),
175
+ stream: createWriteStream(path, { fd }),
118
176
  path,
119
- bytes: statSync(path, { throwIfNoEntry: false })?.size ?? 0
177
+ bytes: fstatSync(fd).size,
178
+ prefix,
179
+ film: filmOf(session)
120
180
  };
121
181
  // A file that stops being writable must not take the proxy down with it,
122
182
  // and must not be retried on every line.
123
- record.stream.on("error", () => open.delete(key));
183
+ record.stream.on("error", () => {
184
+ if (open.get(key) === record) open.delete(key);
185
+ });
124
186
  open.set(key, record);
125
187
  return record;
126
188
  } catch {
@@ -128,6 +190,51 @@ export function createClientLogFiles(proxyLogPath) {
128
190
  }
129
191
  }
130
192
 
193
+ /**
194
+ * The file for this session, opened on first use.
195
+ *
196
+ * Keyed by the session's own id rather than by the name. The first batch
197
+ * always arrives before a torrent is chosen, and the lines it carries — the
198
+ * page opening, the proxy being chosen, a connection failing — belong to the
199
+ * film the session goes on to play. So the file is RENAMED when the first
200
+ * torrent arrives, and those lines stay in it.
201
+ *
202
+ * A different torrent chosen later in the same page starts a file of its
203
+ * own under the same prefix: renaming again would leave the first film's
204
+ * lines under the second film's name, where nobody looking for the first
205
+ * film finds them.
206
+ *
207
+ * @param {{ sessionId: string, startedAt: string, torrentName: string, infoHash: string }} session
208
+ * @returns {OpenFile | null}
209
+ */
210
+ function fileFor(session) {
211
+ if (!directory) return null;
212
+ const key = session.sessionId;
213
+ const film = filmOf(session);
214
+ const existing = open.get(key);
215
+ if (!existing) {
216
+ const prefix = `client-${stamp(session.startedAt)}-${safeName(session.sessionId, 8)}`;
217
+ return openFile(key, prefix, session);
218
+ }
219
+ if (film === "" || film === existing.film) return existing;
220
+ if (existing.film === "") {
221
+ const path = pathFor(existing.prefix, session);
222
+ try {
223
+ renameSync(existing.path, path);
224
+ existing.path = path;
225
+ } catch {
226
+ // The lines keep going to the file under its old name; a name that
227
+ // cannot change is better than a session's log lost to an exception.
228
+ }
229
+ // Set even when the rename failed, so it is not retried on every batch.
230
+ existing.film = film;
231
+ return existing;
232
+ }
233
+ open.delete(key);
234
+ finish(existing);
235
+ return openFile(key, existing.prefix, session);
236
+ }
237
+
131
238
  return {
132
239
  write(session, lines) {
133
240
  const record = fileFor(session);
@@ -138,40 +245,38 @@ export function createClientLogFiles(proxyLogPath) {
138
245
  record.stream.write(text);
139
246
  }
140
247
  if (record.bytes >= MAX_BYTES) {
248
+ // Renamed before it is ended: the stream writes through its own
249
+ // descriptor, so the lines still in flight follow the file to its new
250
+ // name, and the next batch opens a fresh file under the old one
251
+ // instead of appending to the file that is about to be moved.
141
252
  try {
142
- record.stream.end(() => {
143
- try {
144
- renameSync(record.path, `${record.path}.1`);
145
- } catch {
146
- // Renaming a file that has gone leaves nothing to do.
147
- }
148
- });
253
+ renameSync(record.path, `${record.path}.1`);
149
254
  } catch {
150
- // A rotation that cannot happen leaves the file growing, which is
151
- // better than losing the session's log to an exception.
255
+ // A rotation that cannot happen leaves the file growing under its
256
+ // name, which is better than losing the session's log. It is tried
257
+ // again after as much again, not on every batch.
258
+ record.bytes = 0;
259
+ return;
152
260
  }
153
- open.delete(session.sessionId);
261
+ if (open.get(session.sessionId) === record) open.delete(session.sessionId);
262
+ finish(record);
154
263
  }
155
264
  },
156
265
  /**
157
266
  * Finish every open file.
158
267
  *
159
- * Awaitable, and that is not a convenience: a write stream opens and
160
- * flushes asynchronously, so a process that ends without waiting loses the
161
- * last lines of every session — which are the ones a crash is explained by.
268
+ * Awaitable, and that is not a convenience: a write stream flushes
269
+ * asynchronously, so a process that ends without waiting loses the last
270
+ * lines of every session — which are the ones a crash is explained by.
271
+ * That includes the files already set aside by a rotation or by a change
272
+ * of film.
162
273
  *
163
274
  * @returns {Promise<void>}
164
275
  */
165
276
  async close() {
166
- const ending = [...open.values()].map((record) => new Promise((resolve) => {
167
- try {
168
- record.stream.end(resolve);
169
- } catch {
170
- resolve(undefined);
171
- }
172
- }));
277
+ for (const record of open.values()) finish(record);
173
278
  open.clear();
174
- await Promise.all(ending);
279
+ await Promise.all([...ending]);
175
280
  }
176
281
  };
177
282
  }