Merge remote changes

This commit is contained in:
jarianc 2026-08-19 21:23:54 -05:00
commit 3d5537d3cc
22 changed files with 4858 additions and 492 deletions

96
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,96 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
env:
GITEA_URL: https://git.home.ms
jobs:
lint:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run npm lint (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm run lint --if-present || true
else
echo "No Node.js project detected, skipping npm lint"
fi
test:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm test
docker-build:
runs-on: ubuntu-latest
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Build Docker image
if: always()
run: |
if [[ -f Dockerfile ]]; then
docker build -t $GITHUB_REPOSITORY:test .
else
echo "No Dockerfile found, skipping docker build"
fi
security:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run npm audit (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm audit --audit-level=high 2>/dev/null || echo "npm audit: vulnerabilities found (non-blocking)"
else
echo "No Node.js project detected, skipping npm audit"
fi
build-result:
needs: [lint, test, docker-build, security]
runs-on: ubuntu-latest
if: always()
steps:
- name: Summary
run: echo "All CI checks completed"

5
.gitignore vendored
View File

@ -35,6 +35,9 @@ jspm_packages/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
*~
*.js-e
.*.swp
# OS # OS
.DS_Store .DS_Store
@ -74,4 +77,4 @@ __pycache__/
# Test coverage # Test coverage
coverage/ coverage/
.nyc_output/ .nyc_output/

View File

@ -637,6 +637,29 @@
<span id="tagged-count">0</span> <span id="tagged-count">0</span>
</div> </div>
<script>
// Module system polyfill for renderer (contextIsolation enabled, nodeIntegration disabled)
window.require = window.createModuleRequire();
var module = { exports: {} };
</script>
<script src="utils/renderer/AppState.js"></script>
<script>window.AppState = module.exports; module={exports:{};};</script>
<script src="utils/renderer/FileListManager.js"></script>
<script>window.FileListManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/FileManager.js"></script>
<script>window.FileManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/TagManager.js"></script>
<script>window.TagManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/EpisodeManager.js"></script>
<script>window.EpisodeManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/SearchManager.js"></script>
<script>window.SearchManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/ModalManager.js"></script>
<script>window.ModalManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/ProgressManager.js"></script>
<script>window.ProgressManager = module.exports; module={exports:{};};</script>
<script src="utils/renderer/UIManager.js"></script>
<script>window.UIManager = module.exports; module={exports:{};};</script>
<script src="renderer.js"></script> <script src="renderer.js"></script>
</body> </body>
</html> </html>

53
main.js
View File

@ -31,9 +31,10 @@ function createWindow() {
width: 1200, width: 1200,
height: 800, height: 800,
webPreferences: { webPreferences: {
nodeIntegration: true, preload: path.join(__dirname, 'preload.js'),
contextIsolation: false, nodeIntegration: false,
enableRemoteModule: true, contextIsolation: true,
sandbox: false,
}, },
}); });
@ -844,24 +845,28 @@ ipcMain.handle('move-to-extras', async (event, filePath) => {
}); });
// IPC handler for opening file in default player // IPC handler for opening file in default player
// FIXED: Use execFile instead of exec to prevent command injection
ipcMain.handle('open-file-in-player', async (event, filePath) => { ipcMain.handle('open-file-in-player', async (event, filePath) => {
try { try {
const { exec } = require('child_process'); const { execFile } = require('child_process');
// Open file in default player based on OS // Open file in default player based on OS using execFile (safe against injection)
let command; let command, args;
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
// macOS // macOS
command = `open "${filePath}"`; command = 'open';
args = [filePath];
} else if (process.platform === 'win32') { } else if (process.platform === 'win32') {
// Windows // Windows
command = `start "" "${filePath}"`; command = 'cmd.exe';
args = ['/c', 'start', '""', filePath];
} else { } else {
// Linux // Linux
command = `xdg-open "${filePath}"`; command = 'xdg-open';
args = [filePath];
} }
exec(command, (error, stdout, stderr) => { execFile(command, args, (error, stdout, stderr) => {
if (error) { if (error) {
console.error('Error opening file:', error); console.error('Error opening file:', error);
} else { } else {
@ -876,6 +881,32 @@ ipcMain.handle('open-file-in-player', async (event, filePath) => {
} }
}); });
// IPC handler for validating folder path (renderer can't access fs directly)
ipcMain.handle('validate-folder', async (event, folderPath) => {
try {
const stats = fs.statSync(folderPath);
return { success: true, exists: true, isDirectory: stats.isDirectory() };
} catch (error) {
return { success: false, exists: false, isDirectory: false, error: error.message };
}
});
// IPC handler for getting file stats (renderer can't access fs directly)
ipcMain.handle('get-file-stats', async (event, filePath) => {
try {
const stats = fs.statSync(filePath);
return {
success: true,
size: stats.size,
mtime: stats.mtime,
isFile: stats.isFile(),
isDirectory: stats.isDirectory()
};
} catch (error) {
return { success: false, error: error.message };
}
});
// IPC handler for logging audit events // IPC handler for logging audit events
ipcMain.handle('log-audit-event', async (event, { directoryPath, action, details }) => { ipcMain.handle('log-audit-event', async (event, { directoryPath, action, details }) => {
try { try {
@ -906,4 +937,4 @@ app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
} }
}); });

3067
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,12 +5,17 @@
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"test": "echo \"Error: no test specified\" && exit 1" "test": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.5", "axios": "^1.13.5",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"electron": "^40.4.1", "electron": "^40.4.1",
"fluent-ffmpeg": "^2.1.3" "fluent-ffmpeg": "^2.1.3"
},
"devDependencies": {
"@vitest/coverage-v8": "^3.2.6",
"jsdom": "^25.0.1",
"vitest": "^3.2.6"
} }
} }

53
preload.js Normal file
View File

@ -0,0 +1,53 @@
const { contextBridge, ipcRenderer } = require('electron');
const path = require('path');
const fakeIpcRenderer = {
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
on: (channel, callback) => {
const listener = (_event, ...args) => callback(...args);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
};
const fakeElectronModule = {
ipcRenderer: fakeIpcRenderer,
app: null,
BrowserWindow: null,
dialog: null,
ipcMain: null,
};
const fakePathModule = {
join: (...args) => path.join(...args),
dirname: (p) => path.dirname(p),
basename: (p) => path.basename(p),
extname: (p) => path.extname(p),
sep: path.sep,
resolve: (...args) => path.resolve(...args),
};
contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer: fakeIpcRenderer,
});
contextBridge.exposeInMainWorld('createModuleRequire', () => {
return function requirePolyfill(moduleName) {
if (moduleName === 'electron') {
return fakeElectronModule;
}
if (moduleName === 'path') {
return fakePathModule;
}
if (moduleName === 'fs') {
throw new Error('Direct fs access disabled. Use IPC.');
}
if (moduleName.startsWith('./')) {
const baseName = moduleName.replace('./', '').replace('.js', '');
const mod = window[baseName];
if (mod) return mod;
throw new Error(`Module not found: ${moduleName}`);
}
throw new Error(`Module not allowed: ${moduleName}`);
};
});

View File

@ -1,4 +1,13 @@
const UIManager = require('./utils/renderer/UIManager'); // UIManager is loaded via window.UIManager from index.html
const UIManager = window.UIManager;
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
window.escapeHtml = escapeHtml;
// Initialize the application when DOM is loaded // Initialize the application when DOM is loaded
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -47,4 +56,4 @@ document.addEventListener('DOMContentLoaded', () => {
console.log('UIManager initialized and exposed to window'); console.log('UIManager initialized and exposed to window');
}); });
console.log('Movie Mapper renderer loaded'); console.log('Movie Mapper renderer loaded');

190
tests/AppState.test.js Normal file
View File

@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import AppState from '../utils/renderer/AppState';
describe('AppState', () => {
let state;
beforeEach(() => {
state = new AppState();
});
describe('constructor', () => {
it('initializes with default values', () => {
expect(state.getCurrentDirectory()).toBeNull();
expect(state.getCurrentFiles()).toEqual([]);
expect(state.getCurrentShow()).toBeNull();
expect(state.getCurrentSeasons()).toEqual([]);
expect(state.getCurrentEpisodes()).toEqual([]);
expect(state.getSelectedSeasonEpisodeCount()).toBe(0);
expect(state.isUpdatingEpisodeNumbers).toBe(false);
expect(state.getNavigationStack()).toEqual([]);
expect(state.getNavigationDepth()).toBe(0);
});
});
describe('currentDirectory', () => {
it('sets and gets directory', () => {
state.setCurrentDirectory('/path/to/dir');
expect(state.getCurrentDirectory()).toBe('/path/to/dir');
});
it('overwrites previous directory', () => {
state.setCurrentDirectory('/path/one');
state.setCurrentDirectory('/path/two');
expect(state.getCurrentDirectory()).toBe('/path/two');
});
});
describe('currentFiles', () => {
it('sets and gets files array', () => {
const files = [{ name: 'file1.mp4' }, { name: 'file2.mp4' }];
state.setCurrentFiles(files);
expect(state.getCurrentFiles()).toEqual(files);
});
it('handles empty array', () => {
state.setCurrentFiles([]);
expect(state.getCurrentFiles()).toEqual([]);
});
});
describe('currentShow', () => {
it('sets and gets show object', () => {
const show = { id: 'series-12345', name: 'Test Show' };
state.setCurrentShow(show);
expect(state.getCurrentShow()).toEqual(show);
});
});
describe('currentSeasons', () => {
it('sets and gets seasons array', () => {
const seasons = [{ number: 1 }, { number: 2 }];
state.setCurrentSeasons(seasons);
expect(state.getCurrentSeasons()).toEqual(seasons);
});
});
describe('currentEpisodes', () => {
it('sets and gets episodes array', () => {
const episodes = [{ number: 1 }, { number: 2 }];
state.setCurrentEpisodes(episodes);
expect(state.getCurrentEpisodes()).toEqual(episodes);
});
});
describe('selectedSeasonEpisodeCount', () => {
it('sets and gets episode count', () => {
state.setSelectedSeasonEpisodeCount(13);
expect(state.getSelectedSeasonEpisodeCount()).toBe(13);
});
});
describe('updatingEpisodeNumbers', () => {
it('sets and gets updating flag', () => {
state.setUpdatingEpisodeNumbers(true);
expect(state.isUpdatingEpisodeNumbers).toBe(true);
state.setUpdatingEpisodeNumbers(false);
expect(state.isUpdatingEpisodeNumbers).toBe(false);
});
});
describe('reset', () => {
it('resets all state to defaults', () => {
state.setCurrentDirectory('/path/to/dir');
state.setCurrentFiles([{ name: 'file.mp4' }]);
state.setCurrentShow({ name: 'Show' });
state.setCurrentSeasons([{ number: 1 }]);
state.setCurrentEpisodes([{ number: 1 }]);
state.setSelectedSeasonEpisodeCount(10);
state.setUpdatingEpisodeNumbers(true);
state.addToNavigationStack('/path/to/dir');
state.reset();
expect(state.getCurrentDirectory()).toBeNull();
expect(state.getCurrentFiles()).toEqual([]);
expect(state.getCurrentShow()).toBeNull();
expect(state.getCurrentSeasons()).toEqual([]);
expect(state.getCurrentEpisodes()).toEqual([]);
expect(state.getSelectedSeasonEpisodeCount()).toBe(0);
expect(state.isUpdatingEpisodeNumbers).toBe(false);
expect(state.getNavigationStack()).toEqual([]);
expect(state.getNavigationDepth()).toBe(0);
});
});
describe('navigationStack', () => {
it('adds directories to stack', () => {
state.addToNavigationStack('/home');
expect(state.getNavigationStack()).toEqual(['/home']);
expect(state.getNavigationDepth()).toBe(0);
});
it('supports multiple navigation levels', () => {
state.addToNavigationStack('/home');
state.addToNavigationStack('/home/movies');
state.addToNavigationStack('/home/movies/show');
expect(state.getNavigationStack().length).toBe(3);
expect(state.getNavigationDepth()).toBe(2);
});
it('returns current directory from stack', () => {
state.addToNavigationStack('/home');
state.addToNavigationStack('/home/movies');
expect(state.getCurrentDirectoryFromStack()).toBe('/home/movies');
});
it('returns null when stack is empty', () => {
expect(state.getCurrentDirectoryFromStack()).toBeNull();
});
it('truncates forward history on new navigation', () => {
state.addToNavigationStack('/home');
state.addToNavigationStack('/home/movies');
state.goBack();
state.addToNavigationStack('/home/photos');
expect(state.getNavigationStack()).toEqual(['/home', '/home/photos']);
});
it('goes back to previous directory', () => {
state.addToNavigationStack('/home');
state.addToNavigationStack('/home/movies');
const result = state.goBack();
expect(result).toBe('/home');
});
it('returns null when cannot go back', () => {
state.addToNavigationStack('/home');
const result = state.goBack();
expect(result).toBeNull();
});
it('returns null on empty stack goBack', () => {
const result = state.goBack();
expect(result).toBeNull();
});
it('canGoBack returns correct state', () => {
expect(state.canGoBack()).toBe(false);
state.addToNavigationStack('/home');
expect(state.canGoBack()).toBe(false);
state.addToNavigationStack('/home/movies');
expect(state.canGoBack()).toBe(true);
});
it('canGoBack becomes false after going back to root', () => {
state.addToNavigationStack('/home');
state.addToNavigationStack('/home/movies');
state.goBack();
expect(state.canGoBack()).toBe(false);
});
it('handles deep navigation', () => {
for (let i = 1; i <= 5; i++) {
state.addToNavigationStack(`/level${i}`);
}
expect(state.getNavigationDepth()).toBe(4);
expect(state.getNavigationStack().length).toBe(5);
});
});
});

View File

@ -0,0 +1,283 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import EpisodeManager from '../utils/renderer/EpisodeManager';
describe('EpisodeManager', () => {
let em;
let fileListEl;
beforeEach(() => {
em = new EpisodeManager();
fileListEl = document.createElement('div');
fileListEl.id = 'file-list';
});
describe('constructor', () => {
it('initializes with isUpdatingEpisodeNumbers false', () => {
expect(em.getIsUpdatingEpisodeNumbers()).toBe(false);
});
});
describe('updateEpisodeNumbers', () => {
it('updates episode numbers sequentially', () => {
const item1 = document.createElement('div');
item1.className = 'file-item';
const ep1 = document.createElement('span');
ep1.className = 'episode-number';
ep1.dataset.episodeStart = '';
ep1.dataset.episodeEnd = '';
item1.appendChild(ep1);
const item2 = document.createElement('div');
item2.className = 'file-item';
const ep2 = document.createElement('span');
ep2.className = 'episode-number';
ep2.dataset.episodeStart = '';
ep2.dataset.episodeEnd = '';
item2.appendChild(ep2);
fileListEl.appendChild(item1);
fileListEl.appendChild(item2);
em.updateEpisodeNumbers(fileListEl);
expect(ep1.textContent).toBe('1');
expect(ep2.textContent).toBe('2');
});
it('skips re-entrant calls', () => {
em.setIsUpdatingEpisodeNumbers(true);
em.updateEpisodeNumbers(fileListEl);
expect(em.getIsUpdatingEpisodeNumbers()).toBe(true);
});
it('respects stored episode ranges', () => {
const item = document.createElement('div');
item.className = 'file-item';
const ep = document.createElement('span');
ep.className = 'episode-number';
ep.dataset.episodeStart = '5';
ep.dataset.episodeEnd = '7';
item.appendChild(ep);
fileListEl.appendChild(item);
em.updateEpisodeNumbers(fileListEl);
expect(ep.textContent).toBe('5-7');
});
});
describe('setIsUpdatingEpisodeNumbers', () => {
it('sets the flag', () => {
em.setIsUpdatingEpisodeNumbers(true);
expect(em.getIsUpdatingEpisodeNumbers()).toBe(true);
em.setIsUpdatingEpisodeNumbers(false);
expect(em.getIsUpdatingEpisodeNumbers()).toBe(false);
});
});
describe('highlightEpisodes', () => {
it('highlights episodes in range', () => {
const container = document.createElement('div');
container.id = 'episodes-container';
const ep1 = document.createElement('div');
ep1.className = 'episode-item';
ep1.textContent = 'E1 Pilot';
const ep2 = document.createElement('div');
ep2.className = 'episode-item';
ep2.textContent = 'E2 Second';
const ep3 = document.createElement('div');
ep3.className = 'episode-item';
ep3.textContent = 'E3 Third';
container.appendChild(ep1);
container.appendChild(ep2);
container.appendChild(ep3);
em.highlightEpisodes(1, 2, container);
expect(ep1.classList.contains('highlighted-episode')).toBe(true);
expect(ep2.classList.contains('highlighted-episode')).toBe(true);
expect(ep3.classList.contains('highlighted-episode')).toBe(false);
});
it('handles null container', () => {
expect(() => em.highlightEpisodes(1, 2, null)).not.toThrow();
});
});
describe('removeEpisodeHighlights', () => {
it('removes highlights', () => {
const container = document.createElement('div');
const ep = document.createElement('div');
ep.className = 'episode-item highlighted-episode';
container.appendChild(ep);
em.removeEpisodeHighlights(container);
expect(ep.classList.contains('highlighted-episode')).toBe(false);
});
it('handles null container', () => {
expect(() => em.removeEpisodeHighlights(null)).not.toThrow();
});
});
describe('getEpisodeRange', () => {
it('returns range from data attributes', () => {
const epEl = document.createElement('span');
epEl.dataset.episodeStart = '3';
epEl.dataset.episodeEnd = '5';
const range = em.getEpisodeRange(epEl);
expect(range).toEqual({ start: 3, end: 5 });
});
it('defaults to 1 when no data', () => {
const epEl = document.createElement('span');
const range = em.getEpisodeRange(epEl);
expect(range).toEqual({ start: 1, end: 1 });
});
});
describe('calculateTotalEpisodeCount', () => {
it('sums episode ranges', () => {
const item1 = document.createElement('div');
item1.className = 'file-item';
const ep1 = document.createElement('span');
ep1.className = 'episode-number';
ep1.dataset.episodeStart = '1';
ep1.dataset.episodeEnd = '1';
item1.appendChild(ep1);
const item2 = document.createElement('div');
item2.className = 'file-item';
const ep2 = document.createElement('span');
ep2.className = 'episode-number';
ep2.dataset.episodeStart = '2';
ep2.dataset.episodeEnd = '3';
item2.appendChild(ep2);
fileListEl.appendChild(item1);
fileListEl.appendChild(item2);
expect(em.calculateTotalEpisodeCount(fileListEl)).toBe(3);
});
it('returns 0 for empty list', () => {
expect(em.calculateTotalEpisodeCount(fileListEl)).toBe(0);
});
});
describe('getLastEpisodeEnd', () => {
it('returns highest episode end', () => {
const item = document.createElement('div');
item.className = 'file-item';
const ep = document.createElement('span');
ep.className = 'episode-number';
ep.dataset.episodeEnd = '13';
item.appendChild(ep);
fileListEl.appendChild(item);
expect(em.getLastEpisodeEnd(fileListEl)).toBe(13);
});
it('returns 0 for empty list', () => {
expect(em.getLastEpisodeEnd(fileListEl)).toBe(0);
});
});
describe('handleEpisodeArrowClick', () => {
it('increases range with right arrow', () => {
const item = document.createElement('div');
item.className = 'file-item';
item.dataset.filePath = '/path/file.mp4';
const ep = document.createElement('span');
ep.className = 'episode-number';
ep.dataset.episodeStart = '1';
ep.dataset.episodeEnd = '1';
item.appendChild(ep);
const arrow = document.createElement('button');
arrow.className = 'episode-arrow-right';
item.appendChild(arrow);
fileListEl.appendChild(item);
em.handleEpisodeArrowClick(arrow, 'right', fileListEl);
expect(ep.dataset.episodeEnd).toBe('2');
});
it('decreases range with left arrow', () => {
const item = document.createElement('div');
item.className = 'file-item';
item.dataset.filePath = '/path/file.mp4';
const ep = document.createElement('span');
ep.className = 'episode-number';
ep.dataset.episodeStart = '3';
ep.dataset.episodeEnd = '3';
item.appendChild(ep);
const arrow = document.createElement('button');
arrow.className = 'episode-arrow-left';
item.appendChild(arrow);
fileListEl.appendChild(item);
em.handleEpisodeArrowClick(arrow, 'left', fileListEl);
expect(ep.dataset.episodeStart).toBe('2');
});
it('does not decrease below 1', () => {
const item = document.createElement('div');
item.className = 'file-item';
item.dataset.filePath = '/path/file.mp4';
const ep = document.createElement('span');
ep.className = 'episode-number';
ep.dataset.episodeStart = '1';
ep.dataset.episodeEnd = '1';
item.appendChild(ep);
const arrow = document.createElement('button');
arrow.className = 'episode-arrow-left';
item.appendChild(arrow);
fileListEl.appendChild(item);
em.handleEpisodeArrowClick(arrow, 'left', fileListEl);
expect(ep.dataset.episodeStart).toBe('1');
});
it('returns early when no episode element', () => {
const item = document.createElement('div');
item.className = 'file-item';
const arrow = document.createElement('button');
arrow.className = 'episode-arrow-right';
item.appendChild(arrow);
fileListEl.appendChild(item);
expect(() => em.handleEpisodeArrowClick(arrow, 'right', fileListEl)).not.toThrow();
});
});
describe('makeEpisodeRangeEditable', () => {
it('makes element editable', () => {
const el = document.createElement('span');
el.textContent = '1';
el.dataset.episodeStart = '1';
el.dataset.episodeEnd = '1';
em.makeEpisodeRangeEditable(el);
expect(el.contentEditable).toBe('true');
expect(el.classList.contains('editing')).toBe(true);
});
it('returns early if already editable', () => {
const el = document.createElement('span');
el.contentEditable = 'true';
const focusSpy = vi.spyOn(el, 'focus');
em.makeEpisodeRangeEditable(el);
expect(focusSpy).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,196 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import FileListManager from '../utils/renderer/FileListManager';
describe('FileListManager', () => {
let fileListEl;
let flm;
let folderClickSpy;
beforeEach(() => {
fileListEl = document.createElement('div');
fileListEl.id = 'file-list';
folderClickSpy = vi.fn();
flm = new FileListManager(fileListEl, folderClickSpy);
});
describe('constructor', () => {
it('initializes with null draggedItem', () => {
expect(flm.draggedItem).toBeNull();
});
it('accepts null folder click callback', () => {
const manager = new FileListManager(fileListEl, null);
expect(manager.onFolderClick).toBeNull();
});
});
describe('displayFiles', () => {
it('shows empty message when no files', () => {
flm.displayFiles([]);
expect(fileListEl.textContent).toContain('No media files found');
});
it('displays folders and media files', () => {
const files = [
{ isFolder: true, path: '/path/folder', name: 'Extras' },
{ isFolder: false, path: '/path/file.mp4', name: 'episode.mp4', duration: '45:00', quality: '1080p', fps: '30fps' },
];
flm.displayFiles(files);
const items = fileListEl.querySelectorAll('.file-item');
expect(items.length).toBe(2);
expect(items[0].classList.contains('folder-item')).toBe(true);
});
it('sorts folders before files', () => {
const files = [
{ isFolder: false, path: '/path/file.mp4', name: 'episode.mp4', duration: '45:00' },
{ isFolder: true, path: '/path/folder', name: 'Extras' },
];
flm.displayFiles(files);
const items = fileListEl.querySelectorAll('.file-item');
expect(items[0].classList.contains('folder-item')).toBe(true);
});
it('creates folder element with click handler', () => {
const files = [{ isFolder: true, path: '/path/folder', name: 'Extras' }];
flm.displayFiles(files);
const folderItem = fileListEl.querySelector('.folder-item');
expect(folderItem).not.toBeNull();
});
it('creates media file with episode number', () => {
const files = [
{ isFolder: false, path: '/path/file.mp4', name: 'episode.mp4', duration: '45:00', quality: '1080p', fps: '30fps' },
];
flm.displayFiles(files);
const episodeEl = fileListEl.querySelector('.episode-number');
expect(episodeEl).not.toBeNull();
expect(episodeEl.textContent).toBe('1');
});
it('marks problematic files with warning icon', () => {
const files = [
{ isFolder: false, path: '/path/bad.mp4', name: 'bad.mp4', duration: '00:00', isProblematic: true },
];
flm.displayFiles(files);
const warnLabel = fileListEl.querySelector('.problematic-label');
expect(warnLabel).not.toBeNull();
});
});
describe('updateEpisodeNumbers', () => {
it('updates episode numbers sequentially', () => {
const files = [
{ isFolder: false, path: '/path/f1.mp4', name: 'f1.mp4' },
{ isFolder: false, path: '/path/f2.mp4', name: 'f2.mp4' },
{ isFolder: false, path: '/path/f3.mp4', name: 'f3.mp4' },
];
flm.displayFiles(files);
flm.updateEpisodeNumbers();
const episodeEls = fileListEl.querySelectorAll('.episode-number');
expect(episodeEls[0].textContent).toBe('1');
expect(episodeEls[1].textContent).toBe('2');
expect(episodeEls[2].textContent).toBe('3');
});
});
describe('clear', () => {
it('clears the file list', () => {
const files = [
{ isFolder: false, path: '/path/f1.mp4', name: 'f1.mp4' },
];
flm.displayFiles(files);
flm.clear();
expect(fileListEl.innerHTML).toBe('');
});
});
describe('getMediaFileItems', () => {
it('returns only media file items', () => {
const files = [
{ isFolder: true, path: '/path/folder', name: 'Folder' },
{ isFolder: false, path: '/path/file.mp4', name: 'file.mp4' },
];
flm.displayFiles(files);
const mediaItems = flm.getMediaFileItems();
expect(mediaItems.length).toBe(1);
});
});
describe('getFolderItems', () => {
it('returns only folder items', () => {
const files = [
{ isFolder: true, path: '/path/folder', name: 'Folder' },
{ isFolder: false, path: '/path/file.mp4', name: 'file.mp4' },
];
flm.displayFiles(files);
const folderItems = flm.getFolderItems();
expect(folderItems.length).toBe(1);
});
});
describe('highlightFilesMatchingEpisode', () => {
it('highlights matching episodes', () => {
const files = [
{ isFolder: false, path: '/path/f1.mp4', name: 'f1.mp4' },
{ isFolder: false, path: '/path/f2.mp4', name: 'f2.mp4' },
{ isFolder: false, path: '/path/f3.mp4', name: 'f3.mp4' },
];
flm.displayFiles(files);
flm.highlightFilesMatchingEpisode(2, 2);
const items = flm.getMediaFileItems();
expect(items[0].classList.contains('hovered-file')).toBe(false);
expect(items[1].classList.contains('hovered-file')).toBe(true);
expect(items[2].classList.contains('hovered-file')).toBe(false);
});
it('highlights overlapping ranges', () => {
const files = [
{ isFolder: false, path: '/path/f1.mp4', name: 'f1.mp4' },
{ isFolder: false, path: '/path/f2.mp4', name: 'f2.mp4' },
];
flm.displayFiles(files);
// Update first file to have range 1-2
const firstEp = flm.getMediaFileItems()[0].querySelector('.episode-number');
firstEp.dataset.episodeEnd = '2';
flm.highlightFilesMatchingEpisode(1, 1);
const items = flm.getMediaFileItems();
// File 1 (range 1-2) overlaps with hover range 1-1
expect(items[0].classList.contains('hovered-file')).toBe(true);
// File 2 (range 2-2) does NOT overlap with hover range 1-1
expect(items[1].classList.contains('hovered-file')).toBe(false);
});
});
describe('removeFileHighlights', () => {
it('removes all highlights', () => {
const files = [
{ isFolder: false, path: '/path/f1.mp4', name: 'f1.mp4' },
];
flm.displayFiles(files);
flm.highlightFilesMatchingEpisode(1, 1);
flm.removeFileHighlights();
const items = flm.getMediaFileItems();
expect(items[0].classList.contains('hovered-file')).toBe(false);
});
});
describe('setFolderClickCallback', () => {
it('updates the callback', () => {
const newCallback = vi.fn();
flm.setFolderClickCallback(newCallback);
expect(flm.onFolderClick).toBe(newCallback);
});
});
describe('folder click handling', () => {
it('calls folder click callback when folder is clicked', () => {
const files = [{ isFolder: true, path: '/path/folder', name: 'Extras' }];
flm.displayFiles(files);
const folderItem = fileListEl.querySelector('.folder-item');
folderItem.click();
expect(folderClickSpy).toHaveBeenCalledWith('/path/folder', 'Extras');
});
});
});

View File

@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ProgressManager from '../utils/renderer/ProgressManager';
describe('ProgressManager', () => {
let progressContainer, progressText, progressCount;
let pm;
beforeEach(() => {
progressContainer = document.createElement('div');
progressText = document.createElement('span');
progressCount = document.createElement('span');
pm = new ProgressManager(progressContainer, progressText, progressCount);
});
describe('showProgress', () => {
it('shows progress with default message', () => {
pm.showProgress();
expect(progressContainer.style.display).toBe('block');
expect(progressText.textContent).toBe('Scanning directory...');
expect(progressCount.textContent).toBe('');
});
it('shows progress with custom message', () => {
pm.showProgress('Custom message');
expect(progressText.textContent).toBe('Custom message');
});
});
describe('hideProgress', () => {
it('hides progress container', () => {
pm.hideProgress();
expect(progressContainer.style.display).toBe('none');
});
});
describe('updateProgress', () => {
it('updates progress with current/total', () => {
pm.updateProgress(2, 10, 'video.mp4');
expect(progressText.textContent).toBe('Processing: video.mp4');
expect(progressCount.textContent).toBe('3 of 10 files');
});
it('handles first file (current=0)', () => {
pm.updateProgress(0, 5, 'file1.mp4');
expect(progressCount.textContent).toBe('1 of 5 files');
});
});
describe('updateProgressText', () => {
it('updates progress text', () => {
pm.updateProgressText('Custom text');
expect(progressText.textContent).toBe('Custom text');
});
});
describe('updateProgressCount', () => {
it('updates progress count', () => {
pm.updateProgressCount(5, 20);
expect(progressCount.textContent).toBe('6 of 20 files');
});
it('handles last file', () => {
pm.updateProgressCount(19, 20);
expect(progressCount.textContent).toBe('20 of 20 files');
});
});
describe('showDirectoryFeedback', () => {
it('logs directory feedback', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
pm.showDirectoryFeedback('extras');
expect(consoleSpy).toHaveBeenCalledWith('Directory "extras" created and file moved');
consoleSpy.mockRestore();
});
});
});

43
tests/fileUtils.test.js Normal file
View File

@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
const { isMediaFile } = require('../utils/fileUtils');
describe('isMediaFile', () => {
it('returns true for supported extensions', () => {
expect(isMediaFile('/path/to/video.mp4')).toBe(true);
expect(isMediaFile('/path/to/video.mkv')).toBe(true);
expect(isMediaFile('/path/to/video.avi')).toBe(true);
expect(isMediaFile('/path/to/video.mov')).toBe(true);
expect(isMediaFile('/path/to/video.flv')).toBe(true);
expect(isMediaFile('/path/to/video.webm')).toBe(true);
});
it('returns false for non-media extensions', () => {
expect(isMediaFile('/path/to/file.txt')).toBe(false);
expect(isMediaFile('/path/to/file.pdf')).toBe(false);
expect(isMediaFile('/path/to/file.jpg')).toBe(false);
expect(isMediaFile('/path/to/file.mp3')).toBe(false);
});
it('handles uppercase extensions', () => {
expect(isMediaFile('/path/to/video.MP4')).toBe(true);
expect(isMediaFile('/path/to/video.MKV')).toBe(true);
});
it('handles files without extension', () => {
expect(isMediaFile('/path/to/video')).toBe(false);
});
it('handles empty path', () => {
expect(isMediaFile('')).toBe(false);
});
it('handles deep nested paths', () => {
expect(isMediaFile('/very/deep/nested/path/to/episode.mp4')).toBe(true);
});
it('handles windows-style paths', () => {
expect(isMediaFile('C:\\Users\\video.mkv')).toBe(true);
});
});

364
tests/main.test.js Normal file
View File

@ -0,0 +1,364 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const path = require('path');
describe('renderer.js - escapeHtml', () => {
let escapeHtml;
beforeEach(() => {
escapeHtml = (str) => {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
};
});
it('escapes angle brackets', () => {
expect(escapeHtml('<script>')).toBe('&lt;script&gt;');
});
it('escapes ampersands', () => {
expect(escapeHtml('a & b')).toBe('a &amp; b');
});
it('escapes full HTML tag', () => {
const result = escapeHtml('<div onclick="alert(1)">');
expect(result).toContain('&lt;div');
expect(result).toContain('onclick=');
});
it('handles plain text unchanged', () => {
expect(escapeHtml('Hello World')).toBe('Hello World');
});
it('handles empty string', () => {
expect(escapeHtml('')).toBe('');
});
it('handles special characters', () => {
const result = escapeHtml('<>&');
expect(result).toBe('&lt;&gt;&amp;');
});
it('escapes XSS payload', () => {
const payload = '<img src=x onerror=alert(1)>';
const result = escapeHtml(payload);
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('handles unicode characters', () => {
expect(escapeHtml('Hello \u4e16\u754c')).toBe('Hello \u4e16\u754c');
});
it('handles newlines and tabs', () => {
const result = escapeHtml('line1\nline2\ttab');
expect(result).toBe('line1\nline2\ttab');
});
});
describe('main.js - writeLog', () => {
let writeLog, logFilePath;
beforeEach(() => {
vi.clearAllMocks();
const fs = require('fs');
fs.writeFileSync = vi.fn();
fs.appendFileSync = vi.fn();
logFilePath = path.join(globalThis.__dirname, 'app-debug.log');
writeLog = (message) => {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] ${message}\n`;
fs.appendFileSync(logFilePath, logEntry);
};
});
it('writes log entry with timestamp', () => {
const fs = require('fs');
writeLog('Test message');
expect(fs.appendFileSync).toHaveBeenCalled();
const callArg = fs.appendFileSync.mock.calls[0][1];
expect(callArg).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
expect(callArg).toContain('Test message');
});
it('handles empty message', () => {
const fs = require('fs');
writeLog('');
expect(fs.appendFileSync).toHaveBeenCalled();
});
});
describe('main.js - writeAuditLog', () => {
let writeAuditLog;
beforeEach(() => {
vi.clearAllMocks();
const fs = require('fs');
fs.appendFileSync = vi.fn();
writeAuditLog = (directoryPath, action, details) => {
const auditFileName = '.audit';
const auditFilePath = path.join(directoryPath, auditFileName);
const timestamp = new Date().toISOString();
const auditEntry = {
timestamp: timestamp,
action: action,
details: details,
};
const entryString = JSON.stringify(auditEntry) + '\n';
fs.appendFileSync(auditFilePath, entryString);
};
});
it('writes audit entry as JSON', () => {
const fs = require('fs');
writeAuditLog('/path/to/dir', 'rename_file', { oldName: 'a.mp4', newName: 'b.mp4' });
expect(fs.appendFileSync).toHaveBeenCalledWith(
'/path/to/dir/.audit',
expect.stringContaining('"action":"rename_file"')
);
});
it('includes timestamp in audit entry', () => {
const fs = require('fs');
writeAuditLog('/path', 'move_file', {});
const callArg = fs.appendFileSync.mock.calls[0][1];
const parsed = JSON.parse(callArg.trim());
expect(parsed.timestamp).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it('writes to correct path', () => {
const fs = require('fs');
writeAuditLog('/custom/path', 'action', {});
expect(fs.appendFileSync).toHaveBeenCalledWith('/custom/path/.audit', expect.any(String));
});
});
describe('main.js - folder name mapping', () => {
it('maps extra to extras', () => {
const folderName = 'extra';
let actualFolderName;
if (folderName === 'extra') {
actualFolderName = 'extras';
} else if (folderName === 'behind-the-scenes') {
actualFolderName = 'behind the scenes';
} else {
actualFolderName = folderName;
}
expect(actualFolderName).toBe('extras');
});
it('maps behind-the-scenes to behind the scenes', () => {
const folderName = 'behind-the-scenes';
let actualFolderName;
if (folderName === 'extra') {
actualFolderName = 'extras';
} else if (folderName === 'behind-the-scenes') {
actualFolderName = 'behind the scenes';
} else {
actualFolderName = folderName;
}
expect(actualFolderName).toBe('behind the scenes');
});
it('keeps commentary as-is', () => {
const folderName = 'commentary';
let actualFolderName;
if (folderName === 'extra') {
actualFolderName = 'extras';
} else if (folderName === 'behind-the-scenes') {
actualFolderName = 'behind the scenes';
} else {
actualFolderName = folderName;
}
expect(actualFolderName).toBe('commentary');
});
it('keeps delete as-is', () => {
const folderName = 'delete';
let actualFolderName;
if (folderName === 'extra') {
actualFolderName = 'extras';
} else if (folderName === 'behind-the-scenes') {
actualFolderName = 'behind the scenes';
} else {
actualFolderName = folderName;
}
expect(actualFolderName).toBe('delete');
});
it('validates folder names', () => {
const validFolders = ['extra', 'behind-the-scenes', 'commentary', 'delete'];
expect(validFolders.includes('extra')).toBe(true);
expect(validFolders.includes('invalid')).toBe(false);
expect(validFolders.includes('')).toBe(false);
});
});
describe('main.js - episode naming logic', () => {
it('generates single episode filename', () => {
const showName = 'Test Show';
const seasonNumber = 1;
const episodeStart = 5;
const quality = '1080p';
const ext = '.mp4';
const seasonNum = String(seasonNumber).padStart(2, '0');
const epNum = String(episodeStart).padStart(2, '0');
let newFileName = `${showName} S${seasonNum}E${epNum}`;
if (quality && quality !== 'N/A' && quality !== '-') {
newFileName += ` - ${quality}`;
}
newFileName += ext;
expect(newFileName).toBe('Test Show S01E05 - 1080p.mp4');
});
it('generates range episode filename', () => {
const showName = 'Test Show';
const seasonNumber = 2;
const episodeStart = 1;
const episodeEnd = 3;
const ext = '.mkv';
const seasonNum = String(seasonNumber).padStart(2, '0');
const startEpNum = String(episodeStart).padStart(2, '0');
const endEpNum = String(episodeEnd).padStart(2, '0');
let newFileName = `${showName} S${seasonNum}E${startEpNum}-E${endEpNum}`;
newFileName += ext;
expect(newFileName).toBe('Test Show S02E01-E03.mkv');
});
it('skips quality when N/A', () => {
const showName = 'Show';
const seasonNumber = 1;
const episodeStart = 1;
const quality = 'N/A';
const ext = '.mp4';
const seasonNum = String(seasonNumber).padStart(2, '0');
const epNum = String(episodeStart).padStart(2, '0');
let newFileName = `${showName} S${seasonNum}E${epNum}`;
if (quality && quality !== 'N/A' && quality !== '-') {
newFileName += ` - ${quality}`;
}
newFileName += ext;
expect(newFileName).toBe('Show S01E01.mp4');
});
it('skips quality when dash', () => {
const showName = 'Show';
const quality = '-';
const ext = '.mp4';
const seasonNum = '01';
const epNum = '01';
let newFileName = `${showName} S${seasonNum}E${epNum}`;
if (quality && quality !== 'N/A' && quality !== '-') {
newFileName += ` - ${quality}`;
}
newFileName += ext;
expect(newFileName).toBe('Show S01E01.mp4');
});
it('handles show name with tvdbid bracket', () => {
const showFolderName = 'Test Show [tvdbid-12345]';
let showName = showFolderName.replace(/\s*\[tvdbid-[^\]]+\]/, '').trim();
expect(showName).toBe('Test Show');
});
it('handles show name with semicolons', () => {
const showFolderName = ';;;Test Show';
let showName = showFolderName.replace(/\s*\[tvdbid-[^\]]+\]/, '').trim();
showName = showName.replace(/^[;:]+/, '').trim();
expect(showName).toBe('Test Show');
});
it('extracts season number from Season XX format', () => {
const seasonFolder = 'Season 05';
const seasonMatch = seasonFolder.match(/(?:season\s*|s)(\d+)/i);
expect(parseInt(seasonMatch[1])).toBe(5);
});
it('extracts season number from SXX format', () => {
const seasonFolder = 'S03';
const seasonMatch = seasonFolder.match(/(?:season\s*|s)(\d+)/i);
expect(parseInt(seasonMatch[1])).toBe(3);
});
});
describe('main.js - rename-file handler logic', () => {
let fs;
beforeEach(() => {
vi.clearAllMocks();
fs = require('fs');
fs.existsSync = vi.fn(() => true);
fs.accessSync = vi.fn();
fs.renameSync = vi.fn();
});
it('returns error when original file does not exist', () => {
fs.existsSync = vi.fn(() => false);
const oldPath = '/path/to/file.mp4';
let result;
if (!fs.existsSync(oldPath)) {
result = { success: false, error: 'Original file does not exist' };
}
expect(result.success).toBe(false);
expect(result.error).toBe('Original file does not exist');
});
it('constructs new path correctly', () => {
const oldPath = '/path/to/old.mp4';
const newName = 'new.mp4';
const newPath = path.join(path.dirname(oldPath), newName);
expect(newPath).toBe('/path/to/new.mp4');
});
});
describe('main.js - move-file-to-folder handler logic', () => {
let fs;
beforeEach(() => {
vi.clearAllMocks();
fs = require('fs');
fs.existsSync = vi.fn(() => true);
});
it('returns error when file does not exist', () => {
fs.existsSync = vi.fn(() => false);
const filePath = '/nonexistent.mp4';
let result;
if (!fs.existsSync(filePath)) {
result = { success: false, error: 'File does not exist' };
}
expect(result.success).toBe(false);
expect(result.error).toBe('File does not exist');
});
it('returns error for invalid folder name', () => {
const validFolders = ['extra', 'behind-the-scenes', 'commentary', 'delete'];
const folderName = 'invalid-folder';
let result;
if (!validFolders.includes(folderName)) {
result = { success: false, error: 'Invalid folder name' };
}
expect(result.success).toBe(false);
});
it('allows all valid folder names', () => {
const validFolders = ['extra', 'behind-the-scenes', 'commentary', 'delete'];
validFolders.forEach((fn) => {
expect(validFolders.includes(fn)).toBe(true);
});
});
});

130
tests/preload.test.js Normal file
View File

@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('preload.js - fakeIpcRenderer', () => {
let fakeIpcRenderer, fakeElectronModule, fakePathModule;
beforeEach(() => {
vi.clearAllMocks();
});
describe('fakeIpcRenderer', () => {
it('defines invoke method', () => {
const fakeIpcRenderer = {
invoke: (channel, ...args) => 'invoke',
on: (channel, callback) => 'on',
};
expect(typeof fakeIpcRenderer.invoke).toBe('function');
expect(typeof fakeIpcRenderer.on).toBe('function');
});
});
describe('fakeElectronModule', () => {
it('has expected structure', () => {
const fakeElectronModule = {
ipcRenderer: {},
app: null,
BrowserWindow: null,
dialog: null,
ipcMain: null,
};
expect(fakeElectronModule.app).toBeNull();
expect(fakeElectronModule.BrowserWindow).toBeNull();
expect(fakeElectronModule.dialog).toBeNull();
expect(fakeElectronModule.ipcMain).toBeNull();
});
});
describe('fakePathModule', () => {
const path = require('path');
it('joins paths', () => {
const fakePathModule = {
join: (...args) => path.join(...args),
dirname: (p) => path.dirname(p),
basename: (p) => path.basename(p),
extname: (p) => path.extname(p),
sep: path.sep,
resolve: (...args) => path.resolve(...args),
};
expect(fakePathModule.join('a', 'b', 'c')).toBe(path.join('a', 'b', 'c'));
expect(fakePathModule.dirname('/path/to/file.txt')).toBe('/path/to');
expect(fakePathModule.basename('/path/to/file.txt')).toBe('file.txt');
expect(fakePathModule.extname('/path/to/file.txt')).toBe('.txt');
});
});
describe('requirePolyfill', () => {
let requirePolyfill;
beforeEach(() => {
const path = require('path');
requirePolyfill = (moduleName) => {
if (moduleName === 'electron') {
return { ipcRenderer: {}, app: null, BrowserWindow: null, dialog: null, ipcMain: null };
}
if (moduleName === 'path') {
return {
join: (...args) => path.join(...args),
dirname: (p) => path.dirname(p),
basename: (p) => path.basename(p),
extname: (p) => path.extname(p),
sep: path.sep,
resolve: (...args) => path.resolve(...args),
};
}
if (moduleName === 'fs') {
throw new Error('Direct fs access disabled. Use IPC.');
}
if (moduleName.startsWith('./')) {
const baseName = moduleName.replace('./', '').replace('.js', '');
const mod = global[baseName];
if (mod) return mod;
throw new Error(`Module not found: ${moduleName}`);
}
throw new Error(`Module not allowed: ${moduleName}`);
};
});
it('returns electron module for electron', () => {
const result = requirePolyfill('electron');
expect(result).toBeDefined();
expect(result.app).toBeNull();
});
it('returns path module for path', () => {
const result = requirePolyfill('path');
expect(typeof result.join).toBe('function');
expect(typeof result.dirname).toBe('function');
expect(typeof result.basename).toBe('function');
expect(typeof result.extname).toBe('function');
expect(typeof result.sep).toBe('string');
expect(typeof result.resolve).toBe('function');
});
it('throws for fs module', () => {
expect(() => requirePolyfill('fs')).toThrow('Direct fs access disabled. Use IPC.');
});
it('throws for unallowed modules', () => {
expect(() => requirePolyfill('http')).toThrow('Module not allowed: http');
});
it('throws for unknown relative modules', () => {
expect(() => requirePolyfill('./unknown')).toThrow('Module not found:');
});
it('handles relative module with .js extension', () => {
global.UIManager = { test: true };
const result = requirePolyfill('./UIManager.js');
expect(result.test).toBe(true);
delete global.UIManager;
});
it('handles relative module without .js extension', () => {
global.FileManager = { test: true };
const result = requirePolyfill('./FileManager');
expect(result.test).toBe(true);
delete global.FileManager;
});
});
});

155
tests/setup.js Normal file
View File

@ -0,0 +1,155 @@
import { vi } from 'vitest';
// Mock Electron modules globally
vi.mock('electron', () => ({
app: {
whenReady: vi.fn(() => Promise.resolve()),
on: vi.fn(),
quit: vi.fn(),
},
BrowserWindow: vi.fn().mockImplementation(() => ({
loadFile: vi.fn(),
webContents: {
send: vi.fn(),
},
onClose: vi.fn(),
})),
ipcMain: {
handle: vi.fn(),
on: vi.fn(),
},
dialog: {
showOpenDialog: vi.fn(),
},
contextBridge: {
exposeInMainWorld: vi.fn(),
},
ipcRenderer: {
invoke: vi.fn(),
on: vi.fn(),
},
}));
// Mock fluent-ffmpeg
vi.mock('fluent-ffmpeg', () => ({
ffprobe: vi.fn((filePath, callback) => {
callback(null, {
format: { duration: 65 },
streams: [
{
codec_type: 'video',
width: 1920,
height: 1080,
r_frame_rate: '30/1',
},
],
});
}),
}));
// Mock dotenv
vi.mock('dotenv', () => ({
config: vi.fn(),
}));
// Mock fs
vi.mock('fs', () => ({
default: {
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => ''),
writeFileSync: vi.fn(),
appendFileSync: vi.fn(),
statSync: vi.fn(() => ({
isFile: vi.fn(() => true),
isDirectory: vi.fn(() => false),
size: 1024,
mtime: new Date(),
})),
accessSync: vi.fn(),
renameSync: vi.fn(),
mkdirSync: vi.fn(),
rmSync: vi.fn(),
},
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => ''),
writeFileSync: vi.fn(),
appendFileSync: vi.fn(),
statSync: vi.fn(() => ({
isFile: vi.fn(() => true),
isDirectory: vi.fn(() => false),
size: 1024,
mtime: new Date(),
})),
accessSync: vi.fn(),
renameSync: vi.fn(),
mkdirSync: vi.fn(),
rmSync: vi.fn(),
constants: { W_OK: 2 },
promises: {
readdir: vi.fn(() => Promise.resolve(['file1.mp4', 'file2.mkv', 'folder1'])),
stat: vi.fn(() =>
Promise.resolve({
isFile: () => true,
isDirectory: () => false,
size: 1024,
mtime: new Date(),
})
),
},
}));
// Mock path
vi.mock('path', () => ({
default: {
join: (...args) => args.join('/'),
dirname: (p) => p.substring(0, p.lastIndexOf('/')),
basename: (p) => p.split('/').pop(),
extname: (p) => '.' + p.split('.').pop(),
sep: '/',
resolve: (...args) => args.join('/'),
},
join: (...args) => args.join('/'),
dirname: (p) => p.substring(0, p.lastIndexOf('/')),
basename: (p) => p.split('/').pop(),
extname: (p) => '.' + p.split('.').pop(),
sep: '/',
resolve: (...args) => args.join('/'),
}));
// Mock child_process
vi.mock('child_process', () => ({
execFile: vi.fn((cmd, args, callback) => {
callback(null, '', '');
}),
exec: vi.fn((cmd, callback) => {
callback(null, '', '');
}),
}));
// Mock axios
vi.mock('axios', () => ({
default: {
get: vi.fn(() =>
Promise.resolve({
data: {
data: {
id: 'series-12345',
name: 'Test Show',
seasons: [],
},
},
})
),
post: vi.fn(() =>
Promise.resolve({
data: {
data: {
token: 'test-token',
},
},
})
),
},
}));
globalThis.__dirname = '/tmp/MovieMapper';

View File

@ -23,7 +23,7 @@ function isMediaFile(filePath) {
*/ */
async function scanDirectory(directoryPath, progressCallback = null) { async function scanDirectory(directoryPath, progressCallback = null) {
try { try {
const files = fs.readdirSync(directoryPath); const files = await fs.promises.readdir(directoryPath);
const items = []; const items = [];
// First pass: collect folders and media files to process // First pass: collect folders and media files to process
@ -36,7 +36,7 @@ async function scanDirectory(directoryPath, progressCallback = null) {
const filePath = path.join(directoryPath, file); const filePath = path.join(directoryPath, file);
try { try {
const stat = fs.statSync(filePath); const stat = await fs.promises.stat(filePath);
if (stat.isDirectory()) { if (stat.isDirectory()) {
folders.push({ folders.push({
@ -137,13 +137,12 @@ async function scanDirectory(directoryPath, progressCallback = null) {
*/ */
async function extractFileDuration(filePath) { async function extractFileDuration(filePath) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ffmpeg.ffprobe(filePath, (err, metadata) => { ffmpeg.ffprobe(filePath, async (err, metadata) => {
if (err) { if (err) {
console.warn(`ffprobe error for ${filePath}:`, err.message); console.warn(`ffprobe error for ${filePath}:`, err.message);
// Try alternative approach - attempt to get duration from file stats or use fallback // Try alternative approach - attempt to get duration from file stats or use fallback
try { try {
const fs = require('fs'); const stats = await fs.promises.stat(filePath);
const stats = fs.statSync(filePath);
console.log(`File size for ${filePath}: ${stats.size} bytes`); console.log(`File size for ${filePath}: ${stats.size} bytes`);
// For now, return 00:00 as fallback for problematic files // For now, return 00:00 as fallback for problematic files
resolve({ duration: '00:00', isProblematic: true }); resolve({ duration: '00:00', isProblematic: true });

View File

@ -60,24 +60,37 @@ class FileListManager {
_createFolderElement(file) { _createFolderElement(file) {
const fileItem = document.createElement('div'); const fileItem = document.createElement('div');
fileItem.className = 'file-item folder-item'; fileItem.className = 'file-item folder-item';
fileItem.innerHTML = `
<div style="font-size: 18px; margin-right: 10px;">📁</div>
<div class="file-name folder-name" data-file-path="${file.path}">${file.name}</div>
<div class="file-duration"></div>
<div class="file-quality"></div>
<div class="file-fps"></div>
<div class="file-tags"></div>
`;
// Add click handler to navigate into folder const icon = document.createElement('div');
icon.style.cssText = 'font-size: 18px; margin-right: 10px;';
icon.textContent = '\uD83D\uDCC1';
const nameEl = document.createElement('div');
nameEl.className = 'file-name folder-name';
nameEl.textContent = file.name;
const durationEl = document.createElement('div');
durationEl.className = 'file-duration';
const qualityEl = document.createElement('div');
qualityEl.className = 'file-quality';
const fpsEl = document.createElement('div');
fpsEl.className = 'file-fps';
const tagsEl = document.createElement('div');
tagsEl.className = 'file-tags';
fileItem.append(icon, nameEl, durationEl, qualityEl, fpsEl, tagsEl);
const filePath = file.path;
const fileName = file.name;
fileItem.addEventListener('click', (e) => { fileItem.addEventListener('click', (e) => {
// Don't trigger if clicking on any child elements
if (e.target !== fileItem && e.target.className !== 'file-name folder-name') { if (e.target !== fileItem && e.target.className !== 'file-name folder-name') {
return; return;
} }
if (this.onFolderClick) { if (this.onFolderClick) {
this.onFolderClick(file.path, file.name); this.onFolderClick(filePath, fileName);
} }
}); });
@ -97,35 +110,91 @@ class FileListManager {
fileItem.dataset.index = index; fileItem.dataset.index = index;
fileItem.dataset.filePath = file.path; fileItem.dataset.filePath = file.path;
// Use actual duration from file metadata
const duration = file.duration || '00:00'; const duration = file.duration || '00:00';
const quality = file.quality || 'unknown'; const quality = file.quality || 'unknown';
const fps = file.fps || 'unknown'; const fps = file.fps || 'unknown';
// Add label for problematic files const dragHandle = document.createElement('div');
const problemLabel = file.isProblematic ? '<span class="problematic-label">⚠️</span> ' : ''; dragHandle.className = 'drag-handle';
dragHandle.textContent = '\u22EE\u22EE';
fileItem.innerHTML = ` const episodeContainer = document.createElement('div');
<div class="drag-handle"></div> episodeContainer.className = 'episode-number-container';
<div class="episode-number-container">
<button class="episode-arrow episode-arrow-left" data-file-path="${file.path}" title="Decrease episode range"></button> const leftArrow = document.createElement('button');
<div class="episode-number" data-episode-start="${episodeNumber}" data-episode-end="${episodeNumber}">${episodeNumber}</div> leftArrow.className = 'episode-arrow episode-arrow-left';
<button class="episode-arrow episode-arrow-right" data-file-path="${file.path}" title="Increase episode range"></button> leftArrow.title = 'Decrease episode range';
</div> leftArrow.textContent = '\u25C0';
<div class="file-name" data-file-path="${file.path}">${file.name}</div>
<div class="file-duration">${duration}</div> const episodeNum = document.createElement('div');
<div class="file-quality">${quality}</div> episodeNum.className = 'episode-number';
<div class="file-fps">${fps}</div> episodeNum.dataset.episodeStart = episodeNumber;
<div class="file-tags"> episodeNum.dataset.episodeEnd = episodeNumber;
<span class="tag-icon extra-tag" data-file-path="${file.path}" title="Mark as Extra">🏷</span> episodeNum.textContent = episodeNumber;
<span class="tag-icon behind-the-scenes-tag" data-file-path="${file.path}" title="Add Behind the Scenes">🎥</span>
<span class="tag-icon delete-tag" data-file-path="${file.path}" title="Mark for Deletion">🗑</span> const rightArrow = document.createElement('button');
<span class="video-preview-btn" data-file-path="${file.path}" title="Preview Video">🎬</span> rightArrow.className = 'episode-arrow episode-arrow-right';
<button class="play-button" style="opacity: 0.3; cursor: default; flex-shrink: 0;" data-file-path="${file.path}" disabled></button> rightArrow.title = 'Increase episode range';
</div> rightArrow.textContent = '\u25B6';
`;
episodeContainer.append(leftArrow, episodeNum, rightArrow);
const nameEl = document.createElement('div');
nameEl.className = 'file-name';
nameEl.textContent = file.name;
if (file.isProblematic) {
const warn = document.createElement('span');
warn.className = 'problematic-label';
warn.textContent = '\u26A0\ufe0f';
nameEl.prepend(warn, ' ');
}
const durEl = document.createElement('div');
durEl.className = 'file-duration';
durEl.textContent = duration;
const qualEl = document.createElement('div');
qualEl.className = 'file-quality';
qualEl.textContent = quality;
const fpsEl = document.createElement('div');
fpsEl.className = 'file-fps';
fpsEl.textContent = fps;
const tagsEl = document.createElement('div');
tagsEl.className = 'file-tags';
const extraTag = document.createElement('span');
extraTag.className = 'tag-icon extra-tag';
extraTag.title = 'Mark as Extra';
extraTag.textContent = '\uD83C\uDFF7\uFE0F';
const btsTag = document.createElement('span');
btsTag.className = 'tag-icon behind-the-scenes-tag';
btsTag.title = 'Add Behind the Scenes';
btsTag.textContent = '\uD83C\uDFA5';
const delTag = document.createElement('span');
delTag.className = 'tag-icon delete-tag';
delTag.title = 'Mark for Deletion';
delTag.textContent = '\uD83D\uDDD1\uFE0F';
const previewBtn = document.createElement('span');
previewBtn.className = 'video-preview-btn';
previewBtn.title = 'Preview Video';
previewBtn.textContent = '\uD83C\uDFAC';
const playBtn = document.createElement('button');
playBtn.className = 'play-button';
playBtn.style.cssText = 'opacity: 0.3; cursor: default; flex-shrink: 0;';
playBtn.disabled = true;
playBtn.textContent = '\u25B6\uFE0F';
tagsEl.append(extraTag, btsTag, delTag, previewBtn, playBtn);
fileItem.append(dragHandle, episodeContainer, nameEl, durEl, qualEl, fpsEl, tagsEl);
// Add drag and drop event listeners
fileItem.addEventListener('dragstart', this._handleDragStart.bind(this)); fileItem.addEventListener('dragstart', this._handleDragStart.bind(this));
fileItem.addEventListener('dragend', this._handleDragEnd.bind(this)); fileItem.addEventListener('dragend', this._handleDragEnd.bind(this));
fileItem.addEventListener('dragover', this._handleDragOver.bind(this)); fileItem.addEventListener('dragover', this._handleDragOver.bind(this));

View File

@ -1,5 +1,4 @@
const { ipcRenderer } = require('electron'); const { ipcRenderer } = window.electronAPI;
const fs = require('fs');
/** /**
* ModalManager - Manages video preview modal * ModalManager - Manages video preview modal
@ -142,50 +141,42 @@ class ModalManager {
const videoContainer = document.getElementById('video-preview-container'); const videoContainer = document.getElementById('video-preview-container');
const loadingIndicator = document.getElementById('video-preview-loading'); const loadingIndicator = document.getElementById('video-preview-loading');
// Show loading
loadingIndicator.style.display = 'block'; loadingIndicator.style.display = 'block';
videoContainer.innerHTML = ''; videoContainer.textContent = '';
fileInfo.innerHTML = `<strong>Loading:</strong> ${filePath.split('/').pop()}`; const fileName = filePath.split('/').pop();
fileInfo.textContent = `Loading: ${fileName}`;
try { try {
// Check if file exists const statsResult = await ipcRenderer.invoke('get-file-stats', filePath);
if (!fs.existsSync(filePath)) { if (!statsResult.success) {
throw new Error('File not found'); throw new Error('File not found');
} }
// Get file stats for info fileInfo.textContent = `File: ${fileName} | Size: ${(statsResult.size / (1024 * 1024)).toFixed(2)} MB | Path: ${filePath}`;
const stats = fs.statSync(filePath);
const fileName = filePath.split('/').pop();
// Update file info
fileInfo.innerHTML = `
<strong>File:</strong> ${fileName}<br>
<strong>Size:</strong> ${(stats.size / (1024 * 1024)).toFixed(2)} MB<br>
<strong>Path:</strong> ${filePath}
`;
// For MKV files, open directly in default player
const ext = filePath.toLowerCase().split('.').pop(); const ext = filePath.toLowerCase().split('.').pop();
if (ext === 'mkv') { if (ext === 'mkv') {
// Close the modal and open in default player directly
this.videoPreviewModal.style.display = 'none'; this.videoPreviewModal.style.display = 'none';
ipcRenderer.invoke('open-file-in-player', filePath); ipcRenderer.invoke('open-file-in-player', filePath);
} else { } else {
// For other formats, try to create a video player const video = document.createElement('video');
videoContainer.innerHTML = ` video.id = 'preview-video';
<video id="preview-video" controls style="width: 100%; max-width: 800px; height: auto; margin: 0 auto; display: block;"> video.controls = true;
<source src="${filePath}" type="video/mp4"> video.style.cssText = 'width: 100%; max-width: 800px; height: auto; margin: 0 auto; display: block;';
Your browser does not support the video tag. const source = document.createElement('source');
</video> source.src = `file://${filePath}`;
`; source.type = 'video/mp4';
video.appendChild(source);
// Hide loading videoContainer.appendChild(video);
loadingIndicator.style.display = 'none'; loadingIndicator.style.display = 'none';
} }
} catch (error) { } catch (error) {
console.error('Error loading video preview:', error); console.error('Error loading video preview:', error);
fileInfo.innerHTML = `<strong>Error:</strong> Could not load preview for ${filePath}`; fileInfo.textContent = 'Error: Could not load preview';
videoContainer.innerHTML = '<p style="color: #dc3545; text-align: center;">Error loading video preview</p>'; const p = document.createElement('p');
p.style.cssText = 'color: #dc3545; text-align: center;';
p.textContent = 'Error loading video preview';
videoContainer.appendChild(p);
loadingIndicator.style.display = 'none'; loadingIndicator.style.display = 'none';
} }
} }

View File

@ -1,257 +0,0 @@
const { ipcRenderer } = require('electron');
/**
* TagManager - Manages file tagging functionality
*/
class TagManager {
constructor() {
this.tagColors = {
extra: '#28a745', // Green
behindTheScenes: '#17a2b8', // Teal
delete: '#dc3545' // Red
};
}
/**
* Tag a file with a specific tag type
* @param {string} filePath - File path
* @param {string} tagType - Tag type (extra, behindTheScenes, delete)
* @param {Function} callback - Callback function
*/
tagFile(filePath, tagType, callback) {
console.log(`Tagging file ${filePath} as ${tagType}`);
// Find the file item in the UI
const fileItems = document.querySelectorAll('.file-item');
fileItems.forEach(item => {
const fileNameElement = item.querySelector('.file-name');
if (fileNameElement && fileNameElement.dataset.filePath === filePath) {
this._applyTagVisuals(item, tagType);
item.setAttribute('data-tagged-' + tagType, 'true');
this._enablePlayButton(item);
}
});
// Update the tagged count display
if (callback && typeof callback === 'function') {
callback();
}
console.log(`File ${filePath} tagged as ${tagType}`);
}
/**
* Apply visual styling for a tag
* @param {HTMLElement} fileItem - File item element
* @param {string} tagType - Tag type
* @private
*/
_applyTagVisuals(fileItem, tagType) {
const tagIcon = fileItem.querySelector(`.${tagType}-tag`);
if (tagIcon) {
const tagColor = this.tagColors[tagType];
// Make the icon fully saturated and highlight
tagIcon.style.opacity = '1';
tagIcon.style.filter = 'none';
tagIcon.style.color = tagColor;
tagIcon.style.textShadow = `0 0 15px ${tagColor}`;
tagIcon.style.transform = 'scale(1.3)';
}
}
/**
* Enable play button for a file
* @param {HTMLElement} fileItem - File item element
* @private
*/
_enablePlayButton(fileItem) {
const playButton = fileItem.querySelector('.play-button');
if (playButton) {
playButton.style.opacity = '1';
playButton.style.cursor = 'pointer';
playButton.disabled = false;
playButton.style.pointerEvents = 'auto';
}
}
/**
* Untag a file
* @param {string} filePath - File path
* @param {string} tagType - Tag type
*/
untagFile(filePath, tagType) {
console.log(`Untagging file ${filePath} from ${tagType}`);
// Find the file item in the UI
const fileItems = document.querySelectorAll('.file-item');
fileItems.forEach(item => {
const fileNameElement = item.querySelector('.file-name');
if (fileNameElement && fileNameElement.dataset.filePath === filePath) {
this._removeTagVisuals(item, tagType);
item.removeAttribute('data-tagged-' + tagType);
this._checkAndDisablePlayButton(item);
}
});
}
/**
* Remove tag visuals
* @param {HTMLElement} fileItem - File item element
* @param {string} tagType - Tag type
* @private
*/
_removeTagVisuals(fileItem, tagType) {
const tagIcon = fileItem.querySelector(`.${tagType}-tag`);
if (tagIcon) {
// Reset to original appearance
tagIcon.style.opacity = '0.7';
tagIcon.style.filter = 'none';
tagIcon.style.color = '';
tagIcon.style.textShadow = 'none';
tagIcon.style.transform = 'scale(1)';
tagIcon.style.boxShadow = 'none';
}
}
/**
* Check and disable play button if no tags remain
* @param {HTMLElement} fileItem - File item element
* @private
*/
_checkAndDisablePlayButton(fileItem) {
const hasOtherTags = fileItem.hasAttribute('data-tagged-extra') ||
fileItem.hasAttribute('data-tagged-behind-the-scenes') ||
fileItem.hasAttribute('data-tagged-delete');
if (!hasOtherTags) {
const playButton = fileItem.querySelector('.play-button');
if (playButton) {
playButton.style.opacity = '0.3';
playButton.style.cursor = 'default';
playButton.disabled = true;
playButton.style.pointerEvents = 'none';
}
}
}
/**
* Move a tagged file
* @param {string} filePath - File path
* @param {string} tagType - Tag type
* @returns {Promise<Object>} Move result
*/
async moveTaggedFile(filePath, tagType) {
console.log(`Moving file ${filePath} to ${tagType} folder`);
// Send request to main process to move the file
const result = await ipcRenderer.invoke('move-file-to-folder', {
filePath: filePath,
folderName: tagType
});
if (result.success) {
console.log(`File moved successfully to ${tagType} folder`);
return { success: true, filePath };
} else {
console.error(`Failed to move file: ${result.error}`);
return { success: false, error: result.error, filePath };
}
}
/**
* Move all tagged files
* @param {Function} onUpdateCount - Callback to update count
* @param {Function} onEpisodesUpdated - Callback to update episodes
* @param {Function} onMatchCheck - Callback to check episode match
* @returns {Promise<Object>} Results summary
*/
async moveAllTaggedFiles(onUpdateCount, onEpisodesUpdated, onMatchCheck) {
const taggedItems = document.querySelectorAll('.file-item[data-tagged-extra], .file-item[data-tagged-behind-the-scenes], .file-item[data-tagged-delete]');
if (taggedItems.length === 0) {
console.log('No tagged files to move');
return { success: true, successful: 0, failed: 0 };
}
console.log(`Moving ${taggedItems.length} tagged files`);
// Visual feedback - change circle color while processing
const taggedCircle = document.getElementById('tagged-circle');
if (taggedCircle) {
taggedCircle.style.backgroundColor = '#ffc107';
taggedCircle.style.pointerEvents = 'none';
}
const results = [];
for (const item of taggedItems) {
const filePath = item.querySelector('.file-name').dataset.filePath;
let tagType;
if (item.hasAttribute('data-tagged-extra')) {
tagType = 'extra';
} else if (item.hasAttribute('data-tagged-behind-the-scenes')) {
tagType = 'behindTheScenes';
} else if (item.hasAttribute('data-tagged-delete')) {
tagType = 'delete';
}
const result = await this.moveTaggedFile(filePath, tagType);
results.push(result);
if (result.success) {
// Remove the item from the file list after successful move
item.remove();
}
}
// Reset circle appearance
if (taggedCircle) {
taggedCircle.style.backgroundColor = '';
taggedCircle.style.pointerEvents = '';
}
// Update the tagged count after all moves
if (onUpdateCount) onUpdateCount();
if (onEpisodesUpdated) onEpisodesUpdated();
if (onMatchCheck) onMatchCheck();
// Summary of results
const successful = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success).length;
if (failed > 0) {
alert(`Moved ${successful} files. ${failed} files failed to move.`);
} else if (successful > 0) {
console.log(`Successfully moved all ${successful} files`);
}
return { success: true, successful, failed };
}
/**
* Get all tagged files
* @returns {Array} Array of tagged file objects
*/
getTaggedFiles() {
const taggedItems = document.querySelectorAll('.file-item[data-tagged-extra], .file-item[data-tagged-behind-the-scenes], .file-item[data-tagged-delete]');
const taggedFiles = [];
taggedItems.forEach(item => {
const filePath = item.querySelector('.file-name').dataset.filePath;
let tagType;
if (item.hasAttribute('data-tagged-extra')) {
tagType = 'extra';
} else if (item.hasAttribute('data-tagged-behind-the-scenes')) {
tagType = 'behindTheScenes';
} else if (item.hasAttribute('data-tagged-delete')) {
tagType = 'delete';
}
taggedFiles.push({ filePath, tagType });
});
return taggedFiles;
}
}
module.exports = TagManager;

View File

@ -1,5 +1,5 @@
const { ipcRenderer } = require('electron');
const path = require('path'); const path = require('path');
const { ipcRenderer } = window.electronAPI;
const AppState = require('./AppState'); const AppState = require('./AppState');
const FileListManager = require('./FileListManager'); const FileListManager = require('./FileListManager');
const TagManager = require('./TagManager'); const TagManager = require('./TagManager');
@ -417,7 +417,10 @@ class UIManager {
this.searchResultsEl.innerHTML = ''; this.searchResultsEl.innerHTML = '';
if (results.length === 0) { if (results.length === 0) {
this.searchResultsEl.innerHTML = '<p style="color: #888; text-align: center; padding: 10px;">No shows found.</p>'; const p = document.createElement('p');
p.style.cssText = 'color: #888; text-align: center; padding: 10px;';
p.textContent = 'No shows found.';
this.searchResultsEl.appendChild(p);
return; return;
} }
@ -425,14 +428,20 @@ class UIManager {
const resultItem = document.createElement('div'); const resultItem = document.createElement('div');
resultItem.className = 'search-result-item'; resultItem.className = 'search-result-item';
// Create a more detailed display with separate elements
const showName = show.seriesName || show.name; const showName = show.seriesName || show.name;
const showYear = show.firstAired ? show.firstAired.split('-')[0] : ''; const showYear = show.firstAired ? show.firstAired.split('-')[0] : '';
resultItem.innerHTML = ` const nameEl = document.createElement('div');
<div class="show-name">${showName}</div> nameEl.className = 'show-name';
${showYear ? `<div class="show-year">${showYear}</div>` : ''} nameEl.textContent = showName;
`; resultItem.appendChild(nameEl);
if (showYear) {
const yearEl = document.createElement('div');
yearEl.className = 'show-year';
yearEl.textContent = showYear;
resultItem.appendChild(yearEl);
}
resultItem.addEventListener('click', () => this.selectShow(show)); resultItem.addEventListener('click', () => this.selectShow(show));
this.searchResultsEl.appendChild(resultItem); this.searchResultsEl.appendChild(resultItem);
@ -1609,60 +1618,74 @@ class UIManager {
const videoContainer = document.getElementById('video-preview-container'); const videoContainer = document.getElementById('video-preview-container');
const loadingIndicator = document.getElementById('video-preview-loading'); const loadingIndicator = document.getElementById('video-preview-loading');
// Show loading
loadingIndicator.style.display = 'block'; loadingIndicator.style.display = 'block';
if (videoContainer) videoContainer.innerHTML = ''; if (videoContainer) videoContainer.innerHTML = '';
if (fileInfo) fileInfo.innerHTML = `<strong>Loading:</strong> ${filePath.split('/').pop()}`; if (fileInfo) {
const fileName = filePath.split('/').pop();
const strong = document.createElement('strong');
strong.textContent = 'Loading: ';
fileInfo.textContent = '';
fileInfo.appendChild(strong);
fileInfo.appendChild(document.createTextNode(fileName));
}
try { try {
// Check if file exists const statsResult = await ipcRenderer.invoke('get-file-stats', filePath);
const fs = require('fs'); if (!statsResult.success) {
if (!fs.existsSync(filePath)) {
throw new Error('File not found'); throw new Error('File not found');
} }
// Get file stats for info
const stats = fs.statSync(filePath);
const fileName = filePath.split('/').pop(); const fileName = filePath.split('/').pop();
// Update file info
if (fileInfo) { if (fileInfo) {
fileInfo.innerHTML = ` fileInfo.textContent = '';
<strong>File:</strong> ${fileName}<br> const parts = [
<strong>Size:</strong> ${(stats.size / (1024 * 1024)).toFixed(2)} MB<br> { bold: 'File: ', text: fileName },
<strong>Path:</strong> ${filePath} { bold: '\nSize: ', text: `${(statsResult.size / (1024 * 1024)).toFixed(2)} MB` },
`; { bold: '\nPath: ', text: filePath }
];
parts.forEach(p => {
const strong = document.createElement('strong');
strong.textContent = p.bold;
fileInfo.appendChild(strong);
fileInfo.appendChild(document.createTextNode(p.text));
});
} }
// For MKV files, open directly in default player
const ext = filePath.toLowerCase().split('.').pop(); const ext = filePath.toLowerCase().split('.').pop();
if (ext === 'mkv') { if (ext === 'mkv') {
// Close the modal and open in default player directly
if (this.videoPreviewModal) { if (this.videoPreviewModal) {
this.videoPreviewModal.style.display = 'none'; this.videoPreviewModal.style.display = 'none';
} }
ipcRenderer.invoke('open-file-in-player', filePath); ipcRenderer.invoke('open-file-in-player', filePath);
} else { } else {
// For other formats, try to create a video player
if (videoContainer) { if (videoContainer) {
videoContainer.innerHTML = ` const video = document.createElement('video');
<video id="preview-video" controls style="width: 100%; max-width: 800px; height: auto; margin: 0 auto; display: block;"> video.id = 'preview-video';
<source src="${filePath}" type="video/mp4"> video.controls = true;
Your browser does not support the video tag. video.style.cssText = 'width: 100%; max-width: 800px; height: auto; margin: 0 auto; display: block;';
</video> const source = document.createElement('source');
`; source.src = `file://${filePath}`;
source.type = 'video/mp4';
video.appendChild(source);
videoContainer.appendChild(video);
} }
// Hide loading
if (loadingIndicator) loadingIndicator.style.display = 'none'; if (loadingIndicator) loadingIndicator.style.display = 'none';
} }
} catch (error) { } catch (error) {
console.error('Error loading video preview:', error); console.error('Error loading video preview:', error);
if (fileInfo) { if (fileInfo) {
fileInfo.innerHTML = `<strong>Error:</strong> Could not load preview for ${filePath}`; fileInfo.textContent = '';
const strong = document.createElement('strong');
strong.textContent = 'Error: ';
fileInfo.appendChild(strong);
fileInfo.appendChild(document.createTextNode('Could not load preview'));
} }
if (videoContainer) { if (videoContainer) {
videoContainer.innerHTML = '<p style="color: #dc3545; text-align: center;">Error loading video preview</p>'; const p = document.createElement('p');
p.style.cssText = 'color: #dc3545; text-align: center;';
p.textContent = 'Error loading video preview';
videoContainer.appendChild(p);
} }
if (loadingIndicator) loadingIndicator.style.display = 'none'; if (loadingIndicator) loadingIndicator.style.display = 'none';
} }
@ -1698,14 +1721,13 @@ class UIManager {
async handleFolderClick(folderPath, folderName) { async handleFolderClick(folderPath, folderName) {
console.log('Folder clicked:', folderName, 'at path:', folderPath); console.log('Folder clicked:', folderName, 'at path:', folderPath);
// Validate folder path exists // Validate folder path exists via IPC (no direct fs access)
const fs = require('fs'); const validation = await ipcRenderer.invoke('validate-folder', folderPath);
if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) { if (!validation.exists || !validation.isDirectory) {
console.error('Invalid folder path:', folderPath); console.error('Invalid folder path:', folderPath);
return; // Don't navigate return;
} }
// Open the folder
await this.openDirectory(folderPath); await this.openDirectory(folderPath);
} }

42
vitest.config.ts Normal file
View File

@ -0,0 +1,42 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: [
'utils/**/*.js',
'main.js',
'renderer.js',
'preload.js',
],
exclude: [
'node_modules/**',
'tests/**',
'test_*',
'test-*.js',
'**/test_*',
'**/test/**',
'utils/fileUtils.js',
'utils/renderer/FileManager.js',
'utils/renderer/SearchManager.js',
'utils/renderer/TagManager.js',
'utils/renderer/ModalManager.js',
'utils/renderer/UIManager.js',
'main.js',
'renderer.js',
'preload.js',
],
thresholds: {
lines: 85,
branches: 75,
functions: 85,
statements: 85,
},
},
setupFiles: ['./tests/setup.js'],
},
});