mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
fix(downloads): release timed out file probes
This commit is contained in:
1 parent
9b4e3433ed
commit
e8640996e9
4 files changed
+111
-23
No files matched your search
@@ -948,10 +948,13 @@ engine` (restart required) or
|
||||
completed-missing row can be reset, so a file restored after the renderer
|
||||
snapshot is not orphaned or downloaded again. The recheck has a one-second
|
||||
deadline; timeout or probe failure leaves the row untouched and reports a
|
||||
failed submission so the season loop can continue. Episode and season
|
||||
download actions require an authoritative global list. A successful snapshot
|
||||
remains authoritative while a later background refresh is in flight; a
|
||||
latest refresh failure leaves
|
||||
failed submission so the season loop can continue. Completed-file list
|
||||
probes use the same deadline; a timeout releases their coalescing slot and is
|
||||
reported as missing for that snapshot, so a later refresh performs a new
|
||||
filesystem check instead of joining the stalled operation. Episode and
|
||||
season download actions require an authoritative global list. A successful
|
||||
snapshot remains authoritative while a later background refresh is in
|
||||
flight; a latest refresh failure leaves
|
||||
loading/empty-state resolution intact but disables starts until another
|
||||
snapshot succeeds.
|
||||
- Episode ownership uses normalized `episode.id` as the canonical `xtreamId`
|
||||
|
||||
@@ -21,10 +21,14 @@ type DownloadAsyncLstat = (
|
||||
filePath: string
|
||||
) => Promise<Pick<Stats, 'isFile' | 'isSymbolicLink'>>;
|
||||
|
||||
type DownloadFileAvailabilityProbe = (filePath: string) => Promise<boolean>;
|
||||
type DownloadFileAvailabilityProbeResult = boolean | 'unknown';
|
||||
|
||||
type DownloadFileAvailabilityProbe = (
|
||||
filePath: string
|
||||
) => Promise<DownloadFileAvailabilityProbeResult>;
|
||||
|
||||
const DEFAULT_MAX_CONCURRENT_FILE_PROBES = 4;
|
||||
const DEFAULT_RESTORED_FILE_PROBE_TIMEOUT_MS = 1_000;
|
||||
const DEFAULT_FILE_PROBE_TIMEOUT_MS = 1_000;
|
||||
|
||||
export type BoundedDownloadFileAvailability =
|
||||
ElectronDownloadFileAvailability | 'unknown';
|
||||
@@ -37,7 +41,10 @@ function createDownloadFileAvailabilityProbe(
|
||||
const pending: Array<() => void> = [];
|
||||
// Coalesce only active probes. Completed results are discarded so an
|
||||
// externally removed file is visible on the next list refresh.
|
||||
const inFlight = new Map<string, Promise<boolean>>();
|
||||
const inFlight = new Map<
|
||||
string,
|
||||
Promise<DownloadFileAvailabilityProbeResult>
|
||||
>();
|
||||
let active = 0;
|
||||
|
||||
const acquire = (): Promise<void> => {
|
||||
@@ -57,14 +64,28 @@ function createDownloadFileAvailabilityProbe(
|
||||
}
|
||||
};
|
||||
|
||||
const inspect = async (filePath: string): Promise<boolean> => {
|
||||
const inspect = async (
|
||||
filePath: string
|
||||
): Promise<DownloadFileAvailabilityProbeResult> => {
|
||||
await acquire();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const stats = await asyncLstat(filePath);
|
||||
return stats.isFile() && !stats.isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
const timedOut = new Promise<'unknown'>((resolve) => {
|
||||
timeout = setTimeout(
|
||||
() => resolve('unknown'),
|
||||
DEFAULT_FILE_PROBE_TIMEOUT_MS
|
||||
);
|
||||
});
|
||||
return await Promise.race([
|
||||
asyncLstat(filePath)
|
||||
.then((stats) => stats.isFile() && !stats.isSymbolicLink())
|
||||
.catch(() => false),
|
||||
timedOut,
|
||||
]);
|
||||
} finally {
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
release();
|
||||
}
|
||||
};
|
||||
@@ -89,6 +110,15 @@ function createDownloadFileAvailabilityProbe(
|
||||
|
||||
const probeDownloadFileAvailability = createDownloadFileAvailabilityProbe();
|
||||
|
||||
function toBoundedDownloadFileAvailability(
|
||||
available: DownloadFileAvailabilityProbeResult
|
||||
): BoundedDownloadFileAvailability {
|
||||
if (available === 'unknown') {
|
||||
return available;
|
||||
}
|
||||
return available ? 'available' : 'missing';
|
||||
}
|
||||
|
||||
export function isAvailableDownloadFile(
|
||||
filePath: string | null | undefined,
|
||||
lstat: DownloadLstat = lstatSync
|
||||
@@ -130,13 +160,14 @@ export async function getDownloadFileAvailabilityAsync(
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
return (await probe(download.filePath)) ? 'available' : 'missing';
|
||||
const available = await probe(download.filePath);
|
||||
return available === true ? 'available' : 'missing';
|
||||
}
|
||||
|
||||
export async function getDownloadFileAvailabilityWithTimeoutAsync(
|
||||
download: DownloadFileRow,
|
||||
timeoutMs = DEFAULT_RESTORED_FILE_PROBE_TIMEOUT_MS,
|
||||
probe: DownloadFileAvailabilityProbe = probeDownloadFileAvailability
|
||||
timeoutMs = DEFAULT_FILE_PROBE_TIMEOUT_MS,
|
||||
probe?: DownloadFileAvailabilityProbe
|
||||
): Promise<BoundedDownloadFileAvailability> {
|
||||
if (download.status !== 'completed') {
|
||||
return 'not-applicable';
|
||||
@@ -146,10 +177,16 @@ export async function getDownloadFileAvailabilityWithTimeoutAsync(
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
if (!probe) {
|
||||
return toBoundedDownloadFileAvailability(
|
||||
await probeDownloadFileAvailability(download.filePath)
|
||||
);
|
||||
}
|
||||
|
||||
const boundedTimeoutMs =
|
||||
Number.isFinite(timeoutMs) && timeoutMs >= 0
|
||||
? timeoutMs
|
||||
: DEFAULT_RESTORED_FILE_PROBE_TIMEOUT_MS;
|
||||
: DEFAULT_FILE_PROBE_TIMEOUT_MS;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = new Promise<'unknown'>((resolve) => {
|
||||
timeout = setTimeout(() => resolve('unknown'), boundedTimeoutMs);
|
||||
@@ -160,11 +197,7 @@ export async function getDownloadFileAvailabilityWithTimeoutAsync(
|
||||
probe(download.filePath),
|
||||
timedOut,
|
||||
]);
|
||||
return available === 'unknown'
|
||||
? available
|
||||
: available
|
||||
? 'available'
|
||||
: 'missing';
|
||||
return toBoundedDownloadFileAvailability(available);
|
||||
} catch {
|
||||
return 'unknown';
|
||||
} finally {
|
||||
|
||||
+48
@@ -77,6 +77,54 @@ describe('downloads events: file availability', () => {
|
||||
await expect(response).resolves.toHaveLength(2);
|
||||
});
|
||||
|
||||
it('times out an unresponsive probe and lets the next list refresh recheck the file', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const row = {
|
||||
filePath: '/downloads/unresponsive/movie.mp4',
|
||||
id: 1,
|
||||
status: 'completed',
|
||||
};
|
||||
const orderBy = jest.fn().mockResolvedValue([row]);
|
||||
mockGetDatabase.mockResolvedValue({
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({ orderBy })),
|
||||
})),
|
||||
});
|
||||
mockLstat
|
||||
.mockReturnValueOnce(
|
||||
new Promise(() => {
|
||||
// Simulate an unresponsive removable/network mount.
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(regularFile());
|
||||
|
||||
const timedOutRefresh = getHandler('DOWNLOADS_GET_LIST')(null);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
await expect(timedOutRefresh).resolves.toEqual([
|
||||
{
|
||||
...row,
|
||||
metadataSnapshot: undefined,
|
||||
fileAvailability: 'missing',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_GET_LIST')(null)
|
||||
).resolves.toEqual([
|
||||
{
|
||||
...row,
|
||||
metadataSnapshot: undefined,
|
||||
fileAvailability: 'available',
|
||||
},
|
||||
]);
|
||||
expect(mockLstat).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
it('starts at most four completed-file probes concurrently', async () => {
|
||||
const rows = Array.from({ length: 6 }, (_, index) => ({
|
||||
filePath: `/downloads/network/movie-${index}.mp4`,
|
||||
|
||||
@@ -98,8 +98,12 @@ variants, contextual buttons, and theme-aware styling.
|
||||
`reason: 'already-downloaded'` without mutation; active matches return
|
||||
`reason: 'already-in-progress'`. The recheck has a one-second deadline;
|
||||
timeout or probe failure leaves the row untouched and returns a failed
|
||||
submission, allowing the sequential season loop to continue. The coordinator
|
||||
counts both stable duplicate reasons as skipped. There is no batch IPC,
|
||||
submission, allowing the sequential season loop to continue. Completed-file
|
||||
probes used by list refreshes have the same deadline. A timed-out probe is
|
||||
released from the coalescing pool, the snapshot reports the file as missing,
|
||||
and a later refresh performs a fresh check instead of reusing the stalled
|
||||
filesystem operation. The coordinator counts both stable duplicate reasons
|
||||
as skipped. There is no batch IPC,
|
||||
parallel transfer, or queue reordering: destination authorization, persisted
|
||||
header handling, and the backend's one-active-transfer FIFO semantics remain
|
||||
unchanged.
|
||||
|
||||
Reference in new issue
Block a user