App working

This commit is contained in:
2025-12-14 18:45:49 +01:00
parent e6793f871a
commit 9bde07b134
14 changed files with 353 additions and 146 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
# Dependencies
node_modules/
**/node_modules/
.pnp
.pnp.js

View File

@ -1,8 +1,15 @@
import { app, BrowserWindow, ipcMain, shell, Menu, Tray, nativeImage } from 'electron';
import * as path from 'path';
import * as url from 'url';
import { fileURLToPath } from 'url';
import { fork, ChildProcess } from 'child_process';
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
let serverProcess: ChildProcess | null = null;
// ES module compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
@ -19,7 +26,7 @@ function createWindow() {
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
webSecurity: !isDev,
webSecurity: false,
},
icon: path.join(__dirname, '../public/icon.png'),
});
@ -29,15 +36,42 @@ function createWindow() {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadURL(
url.format({
pathname: path.join(__dirname, '../dist/index.html'),
protocol: 'file:',
slashes: true,
})
);
const indexPath = path.join(app.getAppPath(), 'dist', 'index.html');
console.log('Loading index.html from:', indexPath);
mainWindow.loadFile(indexPath).catch((error) => {
console.error('Failed to load index.html:', error);
// Show error in window
mainWindow?.webContents.executeJavaScript(`
document.body.innerHTML = '<div style="padding: 20px; font-family: monospace; color: red;">
<h1>Error Loading App</h1>
<p>Failed to load index.html</p>
<p>Path: ${indexPath}</p>
<p>Error: ${error.message}</p>
<p>App Path: ${app.getAppPath()}</p>
</div>';
`);
});
}
// Log all console messages from renderer
mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
console.log(`[Renderer ${level}] ${message} (${sourceId}:${line})`);
});
// Log page load events
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => {
console.error('Page failed to load:', {
errorCode,
errorDescription,
url: validatedURL,
});
});
mainWindow.webContents.on('did-finish-load', () => {
console.log('Page finished loading');
});
// Handle external links
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
@ -173,8 +207,77 @@ function createTray() {
});
}
/**
* Start the backend server using Electron's bundled Node.js
*/
function startServer() {
const serverDir = path.join(app.getAppPath(), 'server');
const serverPath = path.join(serverDir, 'src', 'index.js');
console.log('Starting server from:', serverPath);
console.log('Server directory:', serverDir);
// Use fork() which automatically uses Electron's bundled Node.js runtime
// This works in both development and packaged apps
serverProcess = fork(serverPath, [], {
cwd: serverDir,
env: {
...process.env,
PORT: '3001',
NODE_ENV: isDev ? 'development' : 'production',
},
stdio: ['ignore', 'pipe', 'pipe', 'ipc'], // 'ipc' channel for fork communication
silent: false, // Allow server to output to console
});
serverProcess.stdout?.on('data', (data) => {
console.log(`[Server] ${data.toString()}`);
});
serverProcess.stderr?.on('data', (data) => {
console.error(`[Server Error] ${data.toString()}`);
});
serverProcess.on('exit', (code, signal) => {
console.log(`Server process exited with code ${code}${signal ? ` (signal: ${signal})` : ''}`);
serverProcess = null;
});
serverProcess.on('error', (error) => {
console.error('Failed to start server:', error);
serverProcess = null;
});
// Wait a bit for server to start before creating window
return new Promise<void>((resolve) => {
// Give server time to start
setTimeout(() => {
// Check if server is still running
if (serverProcess && !serverProcess.killed) {
console.log('Server started successfully');
} else {
console.warn('Server may not have started properly');
}
resolve();
}, 2000);
});
}
/**
* Stop the backend server
*/
function stopServer() {
if (serverProcess) {
serverProcess.kill();
serverProcess = null;
}
}
// App event handlers
app.whenReady().then(() => {
app.whenReady().then(async () => {
// Start server first
await startServer();
createWindow();
createTray();
@ -186,11 +289,16 @@ app.whenReady().then(() => {
});
app.on('window-all-closed', () => {
stopServer();
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
stopServer();
});
// IPC handlers for renderer communication
ipcMain.handle('get-app-path', () => {
return app.getPath('userData');
@ -208,6 +316,27 @@ ipcMain.handle('open-external', (_event, url: string) => {
shell.openExternal(url);
});
// Window control handlers
ipcMain.handle('window-minimize', () => {
mainWindow?.minimize();
});
ipcMain.handle('window-maximize', () => {
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow?.maximize();
}
});
ipcMain.handle('window-close', () => {
mainWindow?.close();
});
ipcMain.handle('window-is-maximized', () => {
return mainWindow?.isMaximized() ?? false;
});
// Handle certificate errors for development
if (isDev) {
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {

View File

@ -11,6 +11,12 @@ contextBridge.exposeInMainWorld('electron', {
showItemInFolder: (path: string) => ipcRenderer.invoke('show-item-in-folder', path),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
// Window controls
minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
maximizeWindow: () => ipcRenderer.invoke('window-maximize'),
closeWindow: () => ipcRenderer.invoke('window-close'),
isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
// Navigation
onNavigate: (callback: (path: string) => void) => {
ipcRenderer.on('navigate', (_event, path) => callback(path));
@ -29,6 +35,10 @@ declare global {
getDownloadsPath: () => Promise<string>;
showItemInFolder: (path: string) => Promise<void>;
openExternal: (url: string) => Promise<void>;
minimizeWindow: () => Promise<void>;
maximizeWindow: () => Promise<void>;
closeWindow: () => Promise<void>;
isMaximized: () => Promise<boolean>;
onNavigate: (callback: (path: string) => void) => void;
platform: NodeJS.Platform;
isElectron: boolean;

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"module": "ES2020",
"lib": ["ES2020"],
"outDir": "../dist-electron",
"rootDir": ".",

View File

@ -2,22 +2,24 @@
"name": "bestream",
"version": "2.4.0",
"description": "Cross-platform movie streaming app with Netflix-style UI",
"author": "beStream",
"private": true,
"type": "module",
"main": "electron/main.js",
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"dev:all": "concurrently \"npm run dev\" \"npm run server\"",
"server": "cd server && npm start",
"server:install": "cd server && npm install",
"build": "tsc -b && vite build",
"build:electron": "tsc -p electron/tsconfig.json",
"preview": "vite preview",
"lint": "eslint .",
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"electron:build": "vite build && electron-builder",
"build:win": "vite build && electron-builder --win",
"build:mac": "vite build && electron-builder --mac",
"build:linux": "vite build && electron-builder --linux",
"electron:dev": "npm run build:electron && concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"electron:build": "npm run build:electron && vite build && electron-builder",
"build:win": "npm run build:electron && vite build && electron-builder --win",
"build:mac": "npm run build:electron && vite build && electron-builder --mac",
"build:linux": "npm run build:electron && vite build && electron-builder --linux",
"cap:sync": "npx cap sync",
"build:android": "vite build && npx cap sync android",
"build:ios": "vite build && npx cap sync ios"
@ -69,26 +71,25 @@
},
"files": [
"dist/**/*",
"electron/**/*"
"dist-electron/**/*",
"server/**/*"
],
"win": {
"target": [
"nsis"
],
"icon": "public/icon.ico"
"sign": null
},
"mac": {
"target": [
"dmg"
],
"icon": "public/icon.icns"
]
},
"linux": {
"target": [
"AppImage",
"deb"
],
"icon": "public/icon.png"
]
},
"nsis": {
"oneClick": false,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -10,7 +10,7 @@ const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors({
origin: ['http://localhost:5173', 'http://localhost:3000'],
origin: true, // Allow all origins for Electron app
credentials: true,
}));
app.use(express.json());

View File

@ -212,21 +212,47 @@ class TorrentManager {
// If torrent already has files (was already added), use immediately
if (torrent.files && torrent.files.length > 0) {
onReady();
} else if (typeof torrent.once === 'function') {
// Wait for metadata if torrent supports events
// Add timeout for metadata retrieval
const metadataTimeout = setTimeout(() => {
if (!torrent.files || torrent.files.length === 0) {
session.status = 'error';
session.error = 'Torrent metadata timeout - no peers or invalid torrent';
console.error(`❌ Torrent metadata timeout for: ${movieName}`);
reject(new Error('Torrent metadata timeout - no peers or invalid torrent'));
}
}, 30000); // 30 second timeout for metadata
torrent.once('ready', () => {
clearTimeout(metadataTimeout);
onReady();
});
// Also handle error events
torrent.once('error', (err) => {
clearTimeout(metadataTimeout);
session.status = 'error';
session.error = err.message || 'Torrent error';
console.error(`❌ Torrent error for ${movieName}:`, err.message);
reject(err);
});
} else {
// Wait for metadata
torrent.once('ready', onReady);
// Fallback: wait longer for files to appear
setTimeout(() => {
if (torrent.files && torrent.files.length > 0) {
onReady();
} else {
session.status = 'error';
session.error = 'Torrent failed to initialize - no metadata received';
console.error(`❌ Torrent failed to initialize: ${movieName}`);
reject(new Error('Torrent failed to initialize - no metadata received'));
}
}, 10000); // Increased to 10 seconds
}
};
try {
// Check if torrent already exists in client
const existingTorrent = this.client.get(torrentHash);
if (existingTorrent) {
console.log(`♻️ Reusing existing torrent: ${movieName}`);
setupTorrent(existingTorrent);
return;
}
this.client.add(magnetUri, {
path: this.downloadPath,
// Prioritize first and last pieces for faster start
@ -319,7 +345,19 @@ class TorrentManager {
async stopSession(sessionId) {
const session = this.sessions.get(sessionId);
if (session && session.torrent) {
// Remove torrent from client if it has a destroy method
if (typeof session.torrent.destroy === 'function') {
session.torrent.destroy();
} else {
// Fallback: remove from client using the hash
try {
this.client.remove(session.torrent, (err) => {
if (err) console.error('Error removing torrent:', err);
});
} catch (err) {
console.error('Error removing torrent:', err);
}
}
}
this.sessions.delete(sessionId);
}
@ -356,4 +394,3 @@ class TorrentManager {
}
export const torrentManager = new TorrentManager();

View File

@ -6,26 +6,16 @@ import Browse from './pages/Browse';
import Search from './pages/Search';
import MovieDetails from './pages/MovieDetails';
import Player from './pages/Player';
import TVPlayer from './pages/TVPlayer';
import Watchlist from './pages/Watchlist';
import Downloads from './pages/Downloads';
import Settings from './pages/Settings';
import History from './pages/History';
import TVShows from './pages/TVShows';
import TVShowDetails from './pages/TVShowDetails';
import Music from './pages/Music';
import ArtistDetails from './pages/ArtistDetails';
import AlbumDetails from './pages/AlbumDetails';
import Calendar from './pages/Calendar';
import Queue from './pages/Queue';
import { useSettingsStore } from './stores/settingsStore';
import { useInitializeIntegrations } from './hooks/useIntegration';
function App() {
const { settings } = useSettingsStore();
// Initialize *arr service connections on app startup
useInitializeIntegrations();
useEffect(() => {
// Apply theme
@ -41,15 +31,7 @@ function App() {
<Route path="browse/:genre" element={<Browse />} />
<Route path="search" element={<Search />} />
<Route path="movie/:id" element={<MovieDetails />} />
{/* TV Shows (Sonarr) */}
<Route path="tv" element={<TVShows />} />
<Route path="tv/:id" element={<TVShowDetails />} />
{/* Music (Lidarr) */}
<Route path="music" element={<Music />} />
<Route path="music/artist/:id" element={<ArtistDetails />} />
<Route path="music/album/:id" element={<AlbumDetails />} />
{/* Calendar & Queue */}
<Route path="calendar" element={<Calendar />} />
{/* Queue */}
<Route path="queue" element={<Queue />} />
{/* User */}
<Route path="watchlist" element={<Watchlist />} />
@ -59,8 +41,6 @@ function App() {
</Route>
{/* Full-screen player */}
<Route path="/player/:id" element={<Player />} />
{/* TV Show Player */}
<Route path="/tv/play/:showId" element={<TVPlayer />} />
</Routes>
);
}

View File

@ -8,22 +8,19 @@ import {
X,
Home,
Film,
Tv,
Music,
Calendar,
Heart,
Clock,
Download,
Settings,
Minus,
Square,
Maximize2,
} from 'lucide-react';
import { clsx } from 'clsx';
import { isElectron } from '../../utils/platform';
const navLinks = [
{ path: '/', label: 'Home', icon: Home },
{ path: '/browse', label: 'Movies', icon: Film },
{ path: '/tv', label: 'TV Shows', icon: Tv },
{ path: '/music', label: 'Music', icon: Music },
{ path: '/calendar', label: 'Calendar', icon: Calendar },
{ path: '/watchlist', label: 'My List', icon: Heart },
{ path: '/downloads', label: 'Downloads', icon: Download },
];
@ -33,8 +30,10 @@ export default function Navbar() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const isElectronApp = isElectron();
useEffect(() => {
const handleScroll = () => {
@ -49,6 +48,21 @@ export default function Navbar() {
setIsMobileMenuOpen(false);
}, [location.pathname]);
// Check if window is maximized (Electron only)
useEffect(() => {
if (isElectronApp && window.electron) {
const checkMaximized = async () => {
const maximized = await window.electron.isMaximized();
setIsMaximized(maximized);
};
checkMaximized();
// Check periodically (Electron doesn't have a direct event for this)
const interval = setInterval(checkMaximized, 500);
return () => clearInterval(interval);
}
}, [isElectronApp]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
@ -58,8 +72,53 @@ export default function Navbar() {
}
};
const handleMinimize = () => {
if (isElectronApp && window.electron) {
window.electron.minimizeWindow();
}
};
const handleMaximize = () => {
if (isElectronApp && window.electron) {
window.electron.maximizeWindow();
setIsMaximized(!isMaximized);
}
};
const handleClose = () => {
if (isElectronApp && window.electron) {
window.electron.closeWindow();
}
};
return (
<>
{/* Window Controls (Electron only, Windows/Linux) */}
{isElectronApp && (window.electron?.platform === 'win32' || window.electron?.platform === 'linux') && (
<div className="absolute top-0 right-0 flex items-center h-8 z-50">
<button
onClick={handleMinimize}
className="w-12 h-8 flex items-center justify-center hover:bg-white/10 transition-colors text-gray-300 hover:text-white"
title="Minimize"
>
<Minus size={16} />
</button>
<button
onClick={handleMaximize}
className="w-12 h-8 flex items-center justify-center hover:bg-white/10 transition-colors text-gray-300 hover:text-white"
title={isMaximized ? 'Restore' : 'Maximize'}
>
{isMaximized ? <Square size={14} /> : <Maximize2 size={14} />}
</button>
<button
onClick={handleClose}
className="w-12 h-8 flex items-center justify-center hover:bg-red-600 transition-colors text-gray-300 hover:text-white"
title="Close"
>
<X size={16} />
</button>
</div>
)}
<nav
className={clsx(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300 safe-top',

View File

@ -1,14 +1,68 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { HashRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
// Error boundary for better debugging
window.addEventListener('error', (event) => {
console.error('Global error:', event.error);
});
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason);
});
// Wait for DOM to be ready
function mountApp() {
try {
// Ensure body exists
if (!document.body) {
console.error('Document body not found!');
return;
}
// Create root element if it doesn't exist
let rootElement = document.getElementById('root');
if (!rootElement) {
console.warn('Root element not found, creating it...');
rootElement = document.createElement('div');
rootElement.id = 'root';
document.body.appendChild(rootElement);
}
console.log('Root element found, mounting React...');
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<BrowserRouter>
<HashRouter>
<App />
</BrowserRouter>
</HashRouter>
</React.StrictMode>
);
console.log('React app mounted successfully');
} catch (error) {
console.error('Failed to mount React app:', error);
const errorDiv = document.createElement('div');
errorDiv.style.cssText = 'padding: 20px; font-family: monospace; color: red; background: white; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 99999; overflow: auto;';
errorDiv.innerHTML = `
<h1>Failed to Load App</h1>
<p>Error: ${error instanceof Error ? error.message : String(error)}</p>
<pre style="background: #f0f0f0; padding: 10px; overflow: auto;">${error instanceof Error ? error.stack : ''}</pre>
`;
if (document.body) {
document.body.appendChild(errorDiv);
}
}
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountApp);
} else {
// DOM is already ready
mountApp();
}

View File

@ -14,7 +14,6 @@ import {
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { useQueue } from '../hooks/useCalendar';
import { useHasConnectedServices } from '../hooks/useIntegration';
import Button from '../components/ui/Button';
import Badge from '../components/ui/Badge';
import ProgressBar from '../components/ui/ProgressBar';

View File

@ -8,16 +8,13 @@ import {
Database,
Info,
ExternalLink,
Link2,
} from 'lucide-react';
import Select from '../components/ui/Select';
import Button from '../components/ui/Button';
import { ConnectionSetup, ServiceStatus } from '../components/integration';
import { useSettingsStore } from '../stores/settingsStore';
import { useWatchlistStore } from '../stores/watchlistStore';
import { useHistoryStore } from '../stores/historyStore';
import { useDownloadStore } from '../stores/downloadStore';
import { useIntegrationStore } from '../stores/integrationStore';
import { ytsApi } from '../services/api/yts';
export default function Settings() {
@ -25,7 +22,6 @@ export default function Settings() {
const { items: watchlistItems, clearWatchlist } = useWatchlistStore();
const { items: historyItems, clearHistory } = useHistoryStore();
const { items: downloadItems, clearCompleted } = useDownloadStore();
const { settings: integrationSettings, updateSettings: updateIntegrationSettings } = useIntegrationStore();
const handleClearCache = () => {
if (confirm('Clear all cached data? This will not affect your watchlist or history.')) {
@ -64,60 +60,6 @@ export default function Settings() {
{/* Settings Sections */}
<div className="space-y-6">
{/* Integrations */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="p-6 glass rounded-lg"
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Link2 size={20} />
Integrations
</h2>
<ServiceStatus showLabels size="md" />
</div>
<p className="text-sm text-gray-400 mb-6">
Connect to Radarr, Sonarr, and Lidarr to access your media libraries and enable
automated downloads, calendar view, and more.
</p>
<div className="space-y-4">
<ConnectionSetup type="radarr" />
<ConnectionSetup type="sonarr" />
<ConnectionSetup type="lidarr" />
</div>
</motion.section>
{/* Content Sources */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08 }}
className="p-6 glass rounded-lg"
>
<h2 className="text-xl font-semibold flex items-center gap-2 mb-4">
<Database size={20} />
Content Sources
</h2>
<div className="space-y-4">
<Select
label="Movie Source"
options={[
{ value: 'yts', label: 'YTS Only (Streaming)' },
{ value: 'radarr', label: 'Radarr Only (Library)' },
{ value: 'all', label: 'Both YTS & Radarr' },
]}
value={integrationSettings.movieSource}
onChange={(e) => updateIntegrationSettings({
movieSource: e.target.value as 'yts' | 'radarr' | 'all'
})}
/>
<p className="text-xs text-gray-500">
Choose whether to browse movies from YTS (torrent streaming) or your Radarr library.
</p>
</div>
</motion.section>
{/* Playback Settings */}
<motion.section

View File

@ -237,9 +237,4 @@ export const TRACKERS = [
'udp://tracker.leechers-paradise.org:6969',
];
// Re-export unified and service types
export * from './unified';
export * from './radarr';
export * from './sonarr';
export * from './lidarr';