mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
feat(downloads): derive completed file availability
This commit is contained in:
1 parent
263f0f3d52
commit
bd61426d8e
7 files changed
+326
-23
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
decorateDownloadItem,
|
||||
getDownloadFileAvailability,
|
||||
isAvailableDownloadFile,
|
||||
type DownloadLstat,
|
||||
} from './download-file-availability';
|
||||
|
||||
function lstatResult(options: {
|
||||
isFile: boolean;
|
||||
isSymbolicLink?: boolean;
|
||||
}): DownloadLstat {
|
||||
return () => ({
|
||||
isFile: () => options.isFile,
|
||||
isSymbolicLink: () => options.isSymbolicLink ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
describe('download file availability', () => {
|
||||
it('marks only a completed regular non-symbolic-link file available', () => {
|
||||
const lstat = lstatResult({ isFile: true });
|
||||
|
||||
expect(
|
||||
getDownloadFileAvailability(
|
||||
{
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'completed',
|
||||
},
|
||||
lstat
|
||||
)
|
||||
).toBe('available');
|
||||
expect(
|
||||
isAvailableDownloadFile('/downloads/movie.mp4', lstat)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing path', undefined, lstatResult({ isFile: true })],
|
||||
['empty path', '', lstatResult({ isFile: true })],
|
||||
[
|
||||
'filesystem error',
|
||||
'/downloads/missing.mp4',
|
||||
(() => {
|
||||
throw new Error('ENOENT');
|
||||
}) as DownloadLstat,
|
||||
],
|
||||
[
|
||||
'directory',
|
||||
'/downloads/folder',
|
||||
lstatResult({ isFile: false }),
|
||||
],
|
||||
[
|
||||
'symbolic link',
|
||||
'/downloads/link.mp4',
|
||||
lstatResult({ isFile: true, isSymbolicLink: true }),
|
||||
],
|
||||
])('marks a completed %s missing', (_label, filePath, lstat) => {
|
||||
expect(
|
||||
getDownloadFileAvailability(
|
||||
{ filePath, status: 'completed' },
|
||||
lstat
|
||||
)
|
||||
).toBe('missing');
|
||||
expect(isAvailableDownloadFile(filePath, lstat)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not inspect unfinished downloads', () => {
|
||||
const lstat = jest.fn<ReturnType<DownloadLstat>, [string]>();
|
||||
|
||||
expect(
|
||||
getDownloadFileAvailability(
|
||||
{
|
||||
filePath: '/downloads/queued.mp4',
|
||||
status: 'queued',
|
||||
},
|
||||
lstat
|
||||
)
|
||||
).toBe('not-applicable');
|
||||
expect(lstat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('decorates a row without mutating it', () => {
|
||||
const row = Object.freeze({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
status: 'completed' as const,
|
||||
title: 'Movie',
|
||||
});
|
||||
|
||||
expect(decorateDownloadItem(row, lstatResult({ isFile: true }))).toEqual(
|
||||
{
|
||||
...row,
|
||||
fileAvailability: 'available',
|
||||
}
|
||||
);
|
||||
expect(row).not.toHaveProperty('fileAvailability');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
type ElectronBridgeDownloadStatus,
|
||||
type ElectronDownloadFileAvailability,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { lstatSync, type Stats } from 'node:fs';
|
||||
|
||||
interface DownloadFileRow {
|
||||
filePath?: string | null;
|
||||
status: ElectronBridgeDownloadStatus;
|
||||
}
|
||||
|
||||
export type DownloadLstat = (filePath: string) => Pick<
|
||||
Stats,
|
||||
'isFile' | 'isSymbolicLink'
|
||||
>;
|
||||
|
||||
export function isAvailableDownloadFile(
|
||||
filePath: string | null | undefined,
|
||||
lstat: DownloadLstat = lstatSync
|
||||
): boolean {
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = lstat(filePath);
|
||||
return stats.isFile() && !stats.isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDownloadFileAvailability(
|
||||
download: DownloadFileRow,
|
||||
lstat: DownloadLstat = lstatSync
|
||||
): ElectronDownloadFileAvailability {
|
||||
if (download.status !== 'completed') {
|
||||
return 'not-applicable';
|
||||
}
|
||||
|
||||
return isAvailableDownloadFile(download.filePath, lstat)
|
||||
? 'available'
|
||||
: 'missing';
|
||||
}
|
||||
|
||||
export function decorateDownloadItem<T extends DownloadFileRow>(
|
||||
download: T,
|
||||
lstat: DownloadLstat = lstatSync
|
||||
): T & { fileAvailability: ElectronDownloadFileAvailability } {
|
||||
return {
|
||||
...download,
|
||||
fileAvailability: getDownloadFileAvailability(download, lstat),
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
expectManagedPathLookup,
|
||||
getHandler,
|
||||
MANAGED_PATH_STATE,
|
||||
mockExistsSync,
|
||||
mockLstatSync,
|
||||
mockManagedPath,
|
||||
mockOpenPath,
|
||||
mockPauseDownload,
|
||||
@@ -85,7 +85,6 @@ describe('downloads events: pause, resume, and reveal', () => {
|
||||
])('$operation managed-path boundary', ({ channel, filePath }) => {
|
||||
it('rejects an unmanaged database path before accessing the filesystem', async () => {
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.UNMANAGED);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
|
||||
error: 'File not found',
|
||||
@@ -93,14 +92,16 @@ describe('downloads events: pause, resume, and reveal', () => {
|
||||
});
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).not.toHaveBeenCalled();
|
||||
expect(mockLstatSync).not.toHaveBeenCalled();
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a managed database path that is missing from disk', async () => {
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockLstatSync.mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
|
||||
error: 'File not found',
|
||||
@@ -108,15 +109,44 @@ describe('downloads events: pause, resume, and reveal', () => {
|
||||
});
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockLstatSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockLstatSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'directory',
|
||||
{
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'symbolic link',
|
||||
{
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => true,
|
||||
},
|
||||
],
|
||||
])('rejects a managed %s', async (_label, stats) => {
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
|
||||
mockLstatSync.mockReturnValue(stats);
|
||||
|
||||
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
|
||||
error: 'File not found',
|
||||
success: false,
|
||||
});
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockLstatSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when the managed-path database query rejects', async () => {
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.ERROR);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
@@ -140,7 +170,7 @@ describe('downloads events: pause, resume, and reveal', () => {
|
||||
}
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).not.toHaveBeenCalled();
|
||||
expect(mockLstatSync).not.toHaveBeenCalled();
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -149,15 +179,18 @@ describe('downloads events: pause, resume, and reveal', () => {
|
||||
it('reveals a managed file that exists on disk', async () => {
|
||||
const filePath = '/downloads/reveal-success.mp4';
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockLstatSync.mockReturnValue({
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_REVEAL_FILE')(null, filePath)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockLstatSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockLstatSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockShowItemInFolder).toHaveBeenCalledTimes(1);
|
||||
expect(mockShowItemInFolder).toHaveBeenCalledWith(filePath);
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
@@ -166,7 +199,10 @@ describe('downloads events: pause, resume, and reveal', () => {
|
||||
it('waits for the native shell before reporting a managed file as played', async () => {
|
||||
const filePath = '/downloads/play-success.mp4';
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockLstatSync.mockReturnValue({
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
});
|
||||
let resolveOpenPath!: (value: string) => void;
|
||||
const openPathResult = new Promise<string>((resolve) => {
|
||||
resolveOpenPath = resolve;
|
||||
@@ -183,8 +219,8 @@ describe('downloads events: pause, resume, and reveal', () => {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockLstatSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockLstatSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockOpenPath).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenPath).toHaveBeenCalledWith(filePath);
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
getHandler,
|
||||
mockGetDatabase,
|
||||
mockLstatSync,
|
||||
setupDownloadsEventsHarness,
|
||||
} from './downloads.test-helpers';
|
||||
|
||||
function regularFile() {
|
||||
return {
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('downloads events: file availability', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDownloadsEventsHarness();
|
||||
});
|
||||
|
||||
it('decorates every download in the list from the current filesystem state', async () => {
|
||||
const rows = [
|
||||
{
|
||||
filePath: '/downloads/available.mp4',
|
||||
id: 1,
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
filePath: '/downloads/missing.mp4',
|
||||
id: 2,
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
filePath: '/downloads/queued.mp4',
|
||||
id: 3,
|
||||
status: 'queued',
|
||||
},
|
||||
];
|
||||
const orderBy = jest.fn().mockResolvedValue(rows);
|
||||
mockGetDatabase.mockResolvedValue({
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({ orderBy })),
|
||||
})),
|
||||
});
|
||||
mockLstatSync.mockImplementation((filePath) => {
|
||||
if (filePath === '/downloads/missing.mp4') {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
return regularFile();
|
||||
});
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_GET_LIST')(null)
|
||||
).resolves.toEqual([
|
||||
{ ...rows[0], fileAvailability: 'available' },
|
||||
{ ...rows[1], fileAvailability: 'missing' },
|
||||
{ ...rows[2], fileAvailability: 'not-applicable' },
|
||||
]);
|
||||
expect(mockLstatSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('decorates an individual download from the current filesystem state', async () => {
|
||||
const row = {
|
||||
filePath: '/downloads/missing.mp4',
|
||||
id: 2,
|
||||
status: 'completed',
|
||||
};
|
||||
mockGetDatabase.mockResolvedValue({
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([row]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
mockLstatSync.mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
await expect(getHandler('DOWNLOADS_GET')(null, 2)).resolves.toEqual({
|
||||
...row,
|
||||
fileAvailability: 'missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a missing individual download response nullable', async () => {
|
||||
mockGetDatabase.mockResolvedValue({
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(getHandler('DOWNLOADS_GET')(null, 404)).resolves.toBeNull();
|
||||
expect(mockLstatSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { app, dialog, ipcMain, shell } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
|
||||
import {
|
||||
decorateDownloadItem,
|
||||
isAvailableDownloadFile,
|
||||
} from './download-file-availability';
|
||||
import { removePartialDownloadFile } from './download-file-path';
|
||||
import {
|
||||
resumeDownloadRequest,
|
||||
@@ -207,11 +210,12 @@ ipcMain.handle('DOWNLOADS_GET_LIST', async (_event, playlistId?: string) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const query = db.select().from(schema.downloads);
|
||||
return playlistId
|
||||
const rows = await (playlistId
|
||||
? query
|
||||
.where(eq(schema.downloads.playlistId, playlistId))
|
||||
.orderBy(schema.downloads.createdAt)
|
||||
: query.orderBy(schema.downloads.createdAt);
|
||||
: query.orderBy(schema.downloads.createdAt));
|
||||
return rows.map((row) => decorateDownloadItem(row));
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error getting download list:', error);
|
||||
throw error;
|
||||
@@ -226,7 +230,7 @@ ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => {
|
||||
.from(schema.downloads)
|
||||
.where(eq(schema.downloads.id, downloadId))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
return result[0] ? decorateDownloadItem(result[0]) : null;
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error getting download:', error);
|
||||
throw error;
|
||||
@@ -252,7 +256,10 @@ ipcMain.handle('DOWNLOADS_SELECT_FOLDER', async () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => {
|
||||
if (!(await isManagedDownloadFile(filePath)) || !existsSync(filePath)) {
|
||||
if (
|
||||
!(await isManagedDownloadFile(filePath)) ||
|
||||
!isAvailableDownloadFile(filePath)
|
||||
) {
|
||||
return { error: 'File not found', success: false };
|
||||
}
|
||||
shell.showItemInFolder(filePath);
|
||||
@@ -260,7 +267,10 @@ ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => {
|
||||
if (!(await isManagedDownloadFile(filePath)) || !existsSync(filePath)) {
|
||||
if (
|
||||
!(await isManagedDownloadFile(filePath)) ||
|
||||
!isAvailableDownloadFile(filePath)
|
||||
) {
|
||||
return { error: 'File not found', success: false };
|
||||
}
|
||||
await shell.openPath(filePath);
|
||||
|
||||
@@ -15,7 +15,7 @@ export const mockBroadcastDownloadUpdate = jest.fn();
|
||||
export const mockRemovePartialDownloadFile = jest.fn();
|
||||
export const mockPauseDownload = jest.fn();
|
||||
export const mockResumeDownloadRequest = jest.fn();
|
||||
export const mockExistsSync = jest.fn();
|
||||
export const mockLstatSync = jest.fn();
|
||||
export const mockOpenPath = jest.fn();
|
||||
export const mockShowItemInFolder = jest.fn();
|
||||
export const mockEq = jest.fn();
|
||||
@@ -55,14 +55,14 @@ export async function setupDownloadsEventsHarness(): Promise<void> {
|
||||
mockRemovePartialDownloadFile.mockReset();
|
||||
mockPauseDownload.mockReset();
|
||||
mockResumeDownloadRequest.mockReset();
|
||||
mockExistsSync.mockReset();
|
||||
mockLstatSync.mockReset();
|
||||
mockOpenPath.mockReset().mockResolvedValue('');
|
||||
mockShowItemInFolder.mockReset();
|
||||
mockEq.mockReset();
|
||||
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual<typeof import('node:fs')>('node:fs'),
|
||||
existsSync: mockExistsSync,
|
||||
lstatSync: mockLstatSync,
|
||||
}));
|
||||
jest.doMock('drizzle-orm', () => {
|
||||
const actual =
|
||||
|
||||
@@ -137,6 +137,11 @@ export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = {
|
||||
export type ElectronBridgeDownloadStatus =
|
||||
(typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES)[keyof typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES];
|
||||
|
||||
export type ElectronDownloadFileAvailability =
|
||||
| 'available'
|
||||
| 'missing'
|
||||
| 'not-applicable';
|
||||
|
||||
export const ELECTRON_BRIDGE_APP_UPDATE_STATUSES = {
|
||||
Unsupported: 'unsupported',
|
||||
Idle: 'idle',
|
||||
@@ -544,6 +549,7 @@ export interface ElectronDownloadItem {
|
||||
filePath?: string;
|
||||
posterUrl?: string;
|
||||
status: ElectronBridgeDownloadStatus;
|
||||
fileAvailability: ElectronDownloadFileAvailability;
|
||||
bytesDownloaded?: number;
|
||||
totalBytes?: number;
|
||||
errorMessage?: string;
|
||||
|
||||
Reference in new issue
Block a user