Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ docker run -d \
| `NODE_OPTIONS` | `--max-old-space-size=512` | Node.js options, used here to set memory limit (in MB) |
| `WEB_PORT` | `3000` | Port for the web UI |
| `WEB_HOST` | `127.0.0.1` | Host to bind the web UI to (`0.0.0.0` to expose externally) |
| `BASE_PATH` | `_(none)_` | Reverse-proxy subpath for the web UI (`/subsyncarr` for `https://example.com/subsyncarr/`) |
| `TZ` | _(system)_ | Timezone for logging and cron scheduling (e.g., `America/New_York`) |
| `PUID` | `1000` | User ID for file permissions (run `id -u` to find yours) |
| `PGID` | `1000` | Group ID for file permissions (run `id -g` to find yours) |
Expand Down
24 changes: 13 additions & 11 deletions public/app.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const API_BASE = window.__BASE_PATH__ || '';

class SubsyncarrPlusClient {
constructor() {
this.ws = null;
Expand All @@ -14,7 +16,7 @@ class SubsyncarrPlusClient {

initWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
this.ws = new WebSocket(`${protocol}//${location.host}/ws`);
this.ws = new WebSocket(`${protocol}//${location.host}${API_BASE}/ws`);

this.ws.onopen = () => {
console.log('WebSocket connected');
Expand Down Expand Up @@ -83,22 +85,22 @@ class SubsyncarrPlusClient {
}

async fetchInitialState() {
const response = await fetch('/api/status');
const response = await fetch(`${API_BASE}/api/status`);
const data = await response.json();
this.state = data;
this.render();
this.fetchHistory();
}

async fetchHistory() {
const response = await fetch('/api/history');
const response = await fetch(`${API_BASE}/api/history`);
const history = await response.json();

// Fetch file results for each run to calculate engine stats
const historyWithStats = await Promise.all(
history.map(async (run) => {
try {
const filesResponse = await fetch(`/api/runs/${run.id}`);
const filesResponse = await fetch(`${API_BASE}/api/runs/${run.id}`);
const data = await filesResponse.json();
const runWithFiles = { ...run, files: data.files || [] };
// Cache for file list lookups
Expand All @@ -116,7 +118,7 @@ class SubsyncarrPlusClient {

async fetchConfigStatus() {
try {
const response = await fetch('/api/config');
const response = await fetch(`${API_BASE}/api/config`);
const config = await response.json();
this.renderConfigStatus(config);
} catch (error) {
Expand Down Expand Up @@ -332,7 +334,7 @@ class SubsyncarrPlusClient {
}

async fetchBrowse(path) {
const url = path ? `/api/browse?path=${encodeURIComponent(path)}` : '/api/browse';
const url = path ? `${API_BASE}/api/browse?path=${encodeURIComponent(path)}` : `${API_BASE}/api/browse`;
const res = await fetch(url);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
Expand Down Expand Up @@ -477,7 +479,7 @@ class SubsyncarrPlusClient {

async startRun(paths = null) {
try {
const response = await fetch('/api/run/start', {
const response = await fetch(`${API_BASE}/api/run/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
Expand All @@ -498,7 +500,7 @@ class SubsyncarrPlusClient {
}

try {
const response = await fetch('/api/run/stop', {
const response = await fetch(`${API_BASE}/api/run/stop`, {
method: 'POST',
});

Expand All @@ -513,7 +515,7 @@ class SubsyncarrPlusClient {

async skipFile(filePath) {
try {
await fetch('/api/file/skip', {
await fetch(`${API_BASE}/api/file/skip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filePath }),
Expand All @@ -525,7 +527,7 @@ class SubsyncarrPlusClient {

async viewLogs(runId) {
try {
const response = await fetch(`/api/runs/${runId}/logs`);
const response = await fetch(`${API_BASE}/api/runs/${runId}/logs`);
const data = await response.json();

document.getElementById('logsContent').textContent = data.logs || 'No logs available';
Expand All @@ -542,7 +544,7 @@ class SubsyncarrPlusClient {
}

try {
const response = await fetch('/api/files/clear', {
const response = await fetch(`${API_BASE}/api/files/clear`, {
method: 'POST',
});

Expand Down
53 changes: 38 additions & 15 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ import { getScanConfig } from './config';
import cronstrue from 'cronstrue';
import parseExpression from 'cron-parser';

// Optional BASE_PATH env var for reverse-proxy subpath support.
// e.g. https://example.com/subsyncarr desires BASE_PATH=/subsyncarr to work.
// Not configuring BASE_PATH assumes https://example.com/
// or https://subsyncarr.example.com/ or just http://127.0.0.1/ etc. for path.
// .replace(/\/$/, '') strips trailing / from user misconfiguration
const basePath = (process.env.BASE_PATH || '').replace(/\/$/, '');

export class SubsyncarrPlusServer {
private app = express();
private httpServer = createServer(this.app);
private wss = new WebSocketServer({ server: this.httpServer, path: '/ws' });
private wss = new WebSocketServer({ server: this.httpServer, path: `${basePath}/ws` });
private clients: Set<WebSocket> = new Set();

constructor(
Expand All @@ -26,12 +33,28 @@ export class SubsyncarrPlusServer {

private setupMiddleware() {
this.app.use(express.json());
this.app.use(express.static(join(__dirname, '../public')));

// Serve index.html ourselves (instead of via express.static) so we can
// inject the base path for app.js to read, and fix the asset URLs.
this.app.get(`${basePath}/`, (req, res) => {
const indexPath = join(__dirname, '../public/index.html');
let html = fs.readFileSync(indexPath, 'utf-8');
html = html
.replace('href="/styles.css"', `href="${basePath}/styles.css"`)
.replace('src="/app.js"', `src="${basePath}/app.js"`)
.replace('</head>', `<script>window.__BASE_PATH__ = ${JSON.stringify(basePath)};</script></head>`);
res.type('html').send(html);
});

this.app.use(basePath, express.static(join(__dirname, '../public')));
}

private setupRoutes() {
const router = express.Router();
this.app.use(basePath, router);

// Get configuration status
this.app.get('/api/config', (req, res) => {
router.get('/api/config', (req, res) => {
console.log(`[${new Date().toISOString()}] GET /api/config`);
const config = getScanConfig();
const isDefaultPath = config.includePaths.length === 1 && config.includePaths[0] === '/scan_dir';
Expand Down Expand Up @@ -69,7 +92,7 @@ export class SubsyncarrPlusServer {
// No `path` query → returns the configured roots as virtual top-level entries.
// Otherwise enumerates immediate subdirectories of `path`, but only if `path`
// resolves inside one of the configured roots.
this.app.get('/api/browse', (req, res) => {
router.get('/api/browse', (req, res) => {
const requestedPath = typeof req.query.path === 'string' ? req.query.path : '';
console.log(`[${new Date().toISOString()}] GET /api/browse${requestedPath ? ` path=${requestedPath}` : ''}`);

Expand Down Expand Up @@ -126,7 +149,7 @@ export class SubsyncarrPlusServer {
});

// Get current status
this.app.get('/api/status', (req, res) => {
router.get('/api/status', (req, res) => {
console.log(`[${new Date().toISOString()}] GET /api/status`);
const currentRun = this.stateManager.getCurrentRun();
res.json({
Expand All @@ -137,14 +160,14 @@ export class SubsyncarrPlusServer {
});

// Get run history
this.app.get('/api/history', (req, res) => {
router.get('/api/history', (req, res) => {
const limit = parseInt(req.query.limit as string, 10) || 50;
console.log(`[${new Date().toISOString()}] GET /api/history (limit: ${limit})`);
res.json(this.stateManager.getRunHistory(limit));
});

// Get specific run details
this.app.get('/api/runs/:id', (req, res) => {
router.get('/api/runs/:id', (req, res) => {
console.log(`[${new Date().toISOString()}] GET /api/runs/${req.params.id}`);
const currentRun = this.stateManager.getCurrentRun();
const requestedId = req.params.id;
Expand Down Expand Up @@ -172,7 +195,7 @@ export class SubsyncarrPlusServer {
});

// Get logs for a specific run
this.app.get('/api/runs/:id/logs', (req, res) => {
router.get('/api/runs/:id/logs', (req, res) => {
console.log(`[${new Date().toISOString()}] GET /api/runs/${req.params.id}/logs`);
const requestedId = req.params.id;

Expand All @@ -191,7 +214,7 @@ export class SubsyncarrPlusServer {
});

// Start a new run
this.app.post('/api/run/start', async (req, res) => {
router.post('/api/run/start', async (req, res) => {
const { paths } = req.body;
console.log(
`[${new Date().toISOString()}] POST /api/run/start${paths ? ` (custom paths: ${paths.join(', ')})` : ' (default paths)'}`,
Expand All @@ -218,7 +241,7 @@ export class SubsyncarrPlusServer {
});

// Stop current run
this.app.post('/api/run/stop', (_req, res) => {
router.post('/api/run/stop', (_req, res) => {
console.log(`[${new Date().toISOString()}] POST /api/run/stop`);
try {
this.coordinator.stopRun();
Expand All @@ -234,7 +257,7 @@ export class SubsyncarrPlusServer {
});

// Skip a file
this.app.post('/api/file/skip', (req, res) => {
router.post('/api/file/skip', (req, res) => {
const { filePath } = req.body;

if (!filePath) {
Expand All @@ -248,7 +271,7 @@ export class SubsyncarrPlusServer {
});

// Clear completed files
this.app.post('/api/files/clear', (req, res) => {
router.post('/api/files/clear', (req, res) => {
console.log(`[${new Date().toISOString()}] POST /api/files/clear`);
this.stateManager.clearCompletedFiles();

Expand All @@ -268,14 +291,14 @@ export class SubsyncarrPlusServer {
});

// Get skip status statistics
this.app.get('/api/skip-status', (_req, res) => {
router.get('/api/skip-status', (_req, res) => {
console.log(`[${new Date().toISOString()}] GET /api/skip-status`);
const stats = this.stateManager.getFailureStats();
res.json(stats);
});

// Get skip status for specific file
this.app.get('/api/skip-status/:filePath(*)', (req, res) => {
router.get('/api/skip-status/:filePath(*)', (req, res) => {
const filePath = decodeURIComponent(req.params.filePath);
console.log(`[${new Date().toISOString()}] GET /api/skip-status/${filePath.split('/').pop()}`);

Expand All @@ -284,7 +307,7 @@ export class SubsyncarrPlusServer {
});

// Reset skip status for a file
this.app.post('/api/skip-status/reset', (req, res) => {
router.post('/api/skip-status/reset', (req, res) => {
const { filePath, engine } = req.body;

if (!filePath) {
Expand Down