First big commit

This commit is contained in:
2025-12-14 12:57:37 +01:00
parent 8a8e8e623d
commit e6793f871a
123 changed files with 31299 additions and 0 deletions

21
.eslintrc.cjs Normal file
View File

@ -0,0 +1,21 @@
module.exports = {
root: true,
env: { browser: true, es2020: true, node: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'electron'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
};

52
.gitignore vendored Normal file
View File

@ -0,0 +1,52 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build outputs
dist/
dist-electron/
release/
build/
# Capacitor
android/
ios/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS files
.DS_Store
Thumbs.db
# Testing
coverage/
.nyc_output/
# Cache
.cache/
*.tsbuildinfo
# Misc
*.pem
*.local
# Electron
*.asar

41
capacitor.config.ts Normal file
View File

@ -0,0 +1,41 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.bestream.app',
appName: 'beStream',
webDir: 'dist',
server: {
androidScheme: 'https',
iosScheme: 'https',
// For development with live reload
// url: 'http://localhost:5173',
// cleartext: true,
},
plugins: {
StatusBar: {
style: 'DARK',
backgroundColor: '#141414',
},
SplashScreen: {
launchShowDuration: 2000,
launchAutoHide: true,
backgroundColor: '#141414',
androidSplashResourceName: 'splash',
androidScaleType: 'CENTER_CROP',
showSpinner: false,
},
},
android: {
allowMixedContent: true,
captureInput: true,
webContentsDebuggingEnabled: true,
},
ios: {
contentInset: 'automatic',
scrollEnabled: true,
limitsNavigationsToAppBoundDomains: false,
},
};
export default config;

116
docs/GETTING_API_KEYS.md Normal file
View File

@ -0,0 +1,116 @@
# Getting API Keys from *Arr Services
After installing Radarr, Sonarr, and Lidarr, you'll need to get their API keys to connect them to beStream.
## Quick Access URLs
After running the installation script, access each service at:
- **Radarr**: `http://YOUR_SERVER_IP:7878`
- **Sonarr**: `http://YOUR_SERVER_IP:8989`
- **Lidarr**: `http://YOUR_SERVER_IP:8686`
Replace `YOUR_SERVER_IP` with your Ubuntu server's IP address.
## Getting API Keys
### Step 1: Access the Web Interface
Open each service in your web browser using the URLs above.
### Step 2: Complete Initial Setup (First Time Only)
If this is your first time accessing a service:
1. You'll see a setup wizard
2. Complete the basic configuration (language, port, etc.)
3. You can skip advanced settings for now
### Step 3: Navigate to Settings
1. Click the **Settings** icon (⚙️) in the top menu
2. Click **General** in the left sidebar
3. Scroll down to the **Security** section
### Step 4: Find the API Key
You'll see a field labeled **API Key** with a long string of characters. It looks like:
```
a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
```
### Step 5: Copy the API Key
1. Click the **copy icon** (📋) next to the API key, OR
2. Select the entire API key text and copy it (Ctrl+C / Cmd+C)
## For Each Service
### Radarr (Movies)
1. Go to `http://YOUR_SERVER_IP:7878`
2. Settings → General → Security
3. Copy the API Key
### Sonarr (TV Shows)
1. Go to `http://YOUR_SERVER_IP:8989`
2. Settings → General → Security
3. Copy the API Key
### Lidarr (Music)
1. Go to `http://YOUR_SERVER_IP:8686`
2. Settings → General → Security
3. Copy the API Key
## Using API Keys in beStream
1. Open beStream
2. Go to **Settings****Integrations**
3. For each service:
- Enter the URL: `http://YOUR_SERVER_IP:PORT`
- Paste the API Key
- Click **Test Connection**
- If successful, the status will show as "Connected"
## Security Notes
- **Keep API keys secret** - They provide full access to your services
- **Don't share API keys** - Anyone with the key can control your services
- **Regenerate if compromised** - You can regenerate keys in Settings → General → Security
## Troubleshooting
### Can't Access Web Interface
1. **Check if services are running:**
```bash
sudo systemctl status radarr
sudo systemctl status sonarr
sudo systemctl status lidarr
```
2. **Check firewall:**
```bash
sudo ufw allow 7878/tcp # Radarr
sudo ufw allow 8989/tcp # Sonarr
sudo ufw allow 8686/tcp # Lidarr
```
3. **Check service logs:**
```bash
sudo journalctl -u radarr -n 50
sudo journalctl -u sonarr -n 50
sudo journalctl -u lidarr -n 50
```
### API Key Not Working
1. **Verify the key is correct** - Make sure you copied the entire key
2. **Check for extra spaces** - API keys shouldn't have spaces
3. **Regenerate the key** - Settings → General → Security → Regenerate API Key
### Connection Test Fails in beStream
1. **Verify the URL** - Make sure it's `http://` not `https://` (unless you configured SSL)
2. **Check server IP** - Use the correct IP address of your Ubuntu server
3. **Check network** - Make sure your device can reach the server
4. **Check firewall** - Ensure ports are open on the server

226
docs/OPENING_PORTS.md Normal file
View File

@ -0,0 +1,226 @@
# Opening Firewall Ports for *Arr Services
To access Radarr, Sonarr, and Lidarr from other devices on your network (or remotely), you need to open the required ports in your firewall.
## Required Ports
| Service | Port | Purpose |
|---------|------|---------|
| Radarr | 7878 | Movies management |
| Sonarr | 8989 | TV Shows management |
| Lidarr | 8686 | Music management |
| beStream Backend | 3001 | Streaming server (optional) |
## Quick Method: Use the Script
The easiest way is to use the provided script:
```bash
sudo bash scripts/open-ports.sh
```
This will automatically:
- Install UFW if needed
- Enable UFW firewall
- Open all required ports
- Show the current firewall status
## Manual Method: Using UFW
If you prefer to do it manually:
### 1. Check if UFW is installed
```bash
which ufw
```
If not installed:
```bash
sudo apt update
sudo apt install -y ufw
```
### 2. Enable UFW (if not already enabled)
```bash
sudo ufw enable
```
### 3. Open the ports
```bash
# Radarr (Movies)
sudo ufw allow 7878/tcp comment 'Radarr'
# Sonarr (TV Shows)
sudo ufw allow 8989/tcp comment 'Sonarr'
# Lidarr (Music)
sudo ufw allow 8686/tcp comment 'Lidarr'
# beStream Backend (optional)
sudo ufw allow 3001/tcp comment 'beStream Backend'
```
### 4. Verify ports are open
```bash
sudo ufw status numbered
```
You should see entries for ports 7878, 8989, 8686, and 3001.
## Alternative: Using iptables
If you're using iptables instead of UFW:
```bash
# Radarr
sudo iptables -A INPUT -p tcp --dport 7878 -j ACCEPT
# Sonarr
sudo iptables -A INPUT -p tcp --dport 8989 -j ACCEPT
# Lidarr
sudo iptables -A INPUT -p tcp --dport 8686 -j ACCEPT
# beStream Backend
sudo iptables -A INPUT -p tcp --dport 3001 -j ACCEPT
# Save rules (Ubuntu/Debian)
sudo netfilter-persistent save
```
## Cloud Provider Firewalls
If your server is on a cloud provider (AWS, DigitalOcean, Azure, etc.), you also need to configure their firewall:
### AWS (Security Groups)
1. Go to EC2 → Security Groups
2. Edit inbound rules
3. Add rules for ports 7878, 8989, 8686, 3001
4. Allow from your IP or 0.0.0.0/0 (less secure)
### DigitalOcean (Firewalls)
1. Go to Networking → Firewalls
2. Create or edit firewall
3. Add inbound rules for the ports
4. Apply to your droplet
### Azure (Network Security Groups)
1. Go to Network Security Groups
2. Add inbound security rules
3. Configure ports and source IPs
## Router Configuration (For Remote Access)
If you want to access from outside your local network:
1. **Find your server's local IP:**
```bash
hostname -I
```
2. **Configure port forwarding on your router:**
- Log into your router's admin panel
- Find "Port Forwarding" or "Virtual Server" settings
- Forward external ports to your server's IP:
- External 7878 → Internal 7878 (Radarr)
- External 8989 → Internal 8989 (Sonarr)
- External 8686 → Internal 8686 (Lidarr)
- External 3001 → Internal 3001 (beStream)
3. **Security Note:**
- Consider using a VPN instead of exposing ports directly
- Use reverse proxy with SSL (nginx/caddy) for HTTPS
- Change default ports if exposing publicly
## Testing Port Access
### From the server itself:
```bash
# Test if services are listening
sudo netstat -tulpn | grep -E '7878|8989|8686|3001'
# Or using ss
sudo ss -tulpn | grep -E '7878|8989|8686|3001'
```
### From another device:
```bash
# Test if ports are accessible
telnet YOUR_SERVER_IP 7878
telnet YOUR_SERVER_IP 8989
telnet YOUR_SERVER_IP 8686
telnet YOUR_SERVER_IP 3001
# Or using nc (netcat)
nc -zv YOUR_SERVER_IP 7878
nc -zv YOUR_SERVER_IP 8989
nc -zv YOUR_SERVER_IP 8686
nc -zv YOUR_SERVER_IP 3001
```
## Troubleshooting
### Ports are open but can't connect
1. **Check if services are running:**
```bash
sudo systemctl status radarr
sudo systemctl status sonarr
sudo systemctl status lidarr
```
2. **Check if services are listening on the right interface:**
- Services should listen on `0.0.0.0` (all interfaces), not just `127.0.0.1`
- Check service configuration files
3. **Check firewall status:**
```bash
sudo ufw status verbose
```
4. **Check service logs:**
```bash
sudo journalctl -u radarr -n 50
sudo journalctl -u sonarr -n 50
sudo journalctl -u lidarr -n 50
```
### Services only accessible locally
If you can access services on the server but not from other devices:
1. **Check service configuration:**
- Services should bind to `0.0.0.0`, not `127.0.0.1`
- Check `/var/lib/radarr/config.xml` (or similar for other services)
2. **Check firewall rules:**
```bash
sudo ufw status numbered
```
3. **Check if port is actually open:**
```bash
sudo ufw status | grep 7878
```
## Security Best Practices
1. **Use a VPN** for remote access instead of exposing ports publicly
2. **Use a reverse proxy** (nginx/caddy) with SSL certificates
3. **Change default ports** if exposing publicly
4. **Use strong API keys** and don't share them
5. **Restrict source IPs** in firewall rules if possible
6. **Keep services updated** regularly
## Next Steps
After opening ports:
1. ✅ Verify services are running
2. ✅ Test access from another device
3. ✅ Get API keys from each service
4. ✅ Configure beStream to connect

218
electron/main.ts Normal file
View File

@ -0,0 +1,218 @@
import { app, BrowserWindow, ipcMain, shell, Menu, Tray, nativeImage } from 'electron';
import * as path from 'path';
import * as url from 'url';
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
backgroundColor: '#141414',
titleBarStyle: 'hiddenInset',
frame: process.platform === 'darwin' ? true : false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
webSecurity: !isDev,
},
icon: path.join(__dirname, '../public/icon.png'),
});
// Load the app
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadURL(
url.format({
pathname: path.join(__dirname, '../dist/index.html'),
protocol: 'file:',
slashes: true,
})
);
}
// Handle external links
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// Create application menu
createMenu();
}
function createMenu() {
const template: Electron.MenuItemConstructorOptions[] = [
{
label: 'beStream',
submenu: [
{ label: 'About beStream', role: 'about' },
{ type: 'separator' },
{ label: 'Preferences', accelerator: 'CmdOrCtrl+,', click: () => {
mainWindow?.webContents.send('navigate', '/settings');
}},
{ type: 'separator' },
{ label: 'Hide beStream', role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ label: 'Quit beStream', role: 'quit' },
],
},
{
label: 'File',
submenu: [
{ label: 'New Window', accelerator: 'CmdOrCtrl+N', click: createWindow },
{ type: 'separator' },
{ label: 'Close Window', role: 'close' },
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Navigate',
submenu: [
{ label: 'Home', accelerator: 'CmdOrCtrl+H', click: () => {
mainWindow?.webContents.send('navigate', '/');
}},
{ label: 'Browse', accelerator: 'CmdOrCtrl+B', click: () => {
mainWindow?.webContents.send('navigate', '/browse');
}},
{ label: 'Watchlist', accelerator: 'CmdOrCtrl+L', click: () => {
mainWindow?.webContents.send('navigate', '/watchlist');
}},
{ label: 'Downloads', accelerator: 'CmdOrCtrl+D', click: () => {
mainWindow?.webContents.send('navigate', '/downloads');
}},
{ type: 'separator' },
{ label: 'Back', accelerator: 'CmdOrCtrl+[', click: () => {
mainWindow?.webContents.goBack();
}},
{ label: 'Forward', accelerator: 'CmdOrCtrl+]', click: () => {
mainWindow?.webContents.goForward();
}},
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
],
},
{
label: 'Help',
submenu: [
{ label: 'Learn More', click: () => shell.openExternal('https://github.com') },
],
},
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
function createTray() {
const iconPath = path.join(__dirname, '../public/icon.png');
const icon = nativeImage.createFromPath(iconPath);
tray = new Tray(icon.resize({ width: 16, height: 16 }));
const contextMenu = Menu.buildFromTemplate([
{ label: 'Open beStream', click: () => mainWindow?.show() },
{ type: 'separator' },
{ label: 'Downloads', click: () => {
mainWindow?.show();
mainWindow?.webContents.send('navigate', '/downloads');
}},
{ type: 'separator' },
{ label: 'Quit', role: 'quit' },
]);
tray.setToolTip('beStream');
tray.setContextMenu(contextMenu);
tray.on('click', () => {
mainWindow?.show();
});
}
// App event handlers
app.whenReady().then(() => {
createWindow();
createTray();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// IPC handlers for renderer communication
ipcMain.handle('get-app-path', () => {
return app.getPath('userData');
});
ipcMain.handle('get-downloads-path', () => {
return app.getPath('downloads');
});
ipcMain.handle('show-item-in-folder', (_event, filePath: string) => {
shell.showItemInFolder(filePath);
});
ipcMain.handle('open-external', (_event, url: string) => {
shell.openExternal(url);
});
// Handle certificate errors for development
if (isDev) {
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
event.preventDefault();
callback(true);
});
}

38
electron/preload.ts Normal file
View File

@ -0,0 +1,38 @@
import { contextBridge, ipcRenderer } from 'electron';
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electron', {
// App paths
getAppPath: () => ipcRenderer.invoke('get-app-path'),
getDownloadsPath: () => ipcRenderer.invoke('get-downloads-path'),
// File operations
showItemInFolder: (path: string) => ipcRenderer.invoke('show-item-in-folder', path),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
// Navigation
onNavigate: (callback: (path: string) => void) => {
ipcRenderer.on('navigate', (_event, path) => callback(path));
},
// Platform info
platform: process.platform,
isElectron: true,
});
// Type definitions for TypeScript
declare global {
interface Window {
electron: {
getAppPath: () => Promise<string>;
getDownloadsPath: () => Promise<string>;
showItemInFolder: (path: string) => Promise<void>;
openExternal: (url: string) => Promise<void>;
onNavigate: (callback: (path: string) => void) => void;
platform: NodeJS.Platform;
isElectron: boolean;
};
}
}

17
electron/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"outDir": "../dist-electron",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": true
},
"include": ["./**/*.ts"]
}

18
index.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
<meta name="theme-color" content="#141414" />
<meta name="description" content="beStream - Stream and download movies in high quality" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>beStream</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

9250
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

98
package.json Normal file
View File

@ -0,0 +1,98 @@
{
"name": "bestream",
"version": "2.4.0",
"description": "Cross-platform movie streaming app with Netflix-style UI",
"private": true,
"type": "module",
"main": "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",
"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",
"cap:sync": "npx cap sync",
"build:android": "vite build && npx cap sync android",
"build:ios": "vite build && npx cap sync ios"
},
"dependencies": {
"axios": "^1.7.7",
"clsx": "^2.1.1",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"framer-motion": "^11.11.17",
"hls.js": "^1.6.15",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"srt-webvtt": "^2.0.0",
"zustand": "^5.0.1"
},
"devDependencies": {
"@capacitor/android": "^6.2.0",
"@capacitor/cli": "^6.2.0",
"@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.2",
"@capacitor/ios": "^6.2.0",
"@capacitor/network": "^6.0.2",
"@capacitor/status-bar": "^6.0.2",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/video.js": "^7.3.58",
"@types/webtorrent": "^0.109.8",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"electron": "^33.2.0",
"electron-builder": "^25.1.8",
"eslint": "^9.14.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "~5.6.3",
"vite": "^5.4.11",
"wait-on": "^8.0.1"
},
"build": {
"appId": "com.bestream.app",
"productName": "beStream",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*"
],
"win": {
"target": [
"nsis"
],
"icon": "public/icon.ico"
},
"mac": {
"target": [
"dmg"
],
"icon": "public/icon.icns"
},
"linux": {
"target": [
"AppImage",
"deb"
],
"icon": "public/icon.png"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
}

7
postcss.config.js Normal file
View File

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

23
public/icon.png Normal file
View File

@ -0,0 +1,23 @@
iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF
HGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0w
TXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRh
LyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNy4xLWMwMDAgNzkuZGFiYWNiYiwgMjAyMS8wNC8x
NC0wMDozOTo0NCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9y
Zy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9
IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0
cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25z
LmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5j
b20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAv
c1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDIy
LjQgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyNC0wMS0wMVQwMDowMDowMCswMDowMCIg
eG1wOk1vZGlmeURhdGU9IjIwMjQtMDEtMDFUMDA6MDA6MDArMDA6MDAiIHhtcDpNZXRhZGF0YURh
dGU9IjIwMjQtMDEtMDFUMDA6MDA6MDArMDA6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90
b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0y
LjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6YmVzdHJlYW0iIHhtcE1NOkRvY3VtZW50SUQ9
InhtcC5kaWQ6YmVzdHJlYW0iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpiZXN0
cmVhbSI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNy
ZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6YmVzdHJlYW0iIHN0RXZ0OndoZW49IjIw
MjQtMDEtMDFUMDA6MDA6MDArMDA6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rv
c2hvcCAyMi40IChXaW5kb3dzKSIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6
RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4K

12
public/logo.svg Normal file
View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#E50914;stop-opacity:1" />
<stop offset="100%" style="stop-color:#B20710;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="15" fill="#141414"/>
<path d="M25 20 L25 80 L45 80 L45 55 L55 80 L75 80 L55 45 L75 20 L55 20 L45 40 L45 20 Z" fill="url(#grad)"/>
<circle cx="75" cy="25" r="8" fill="#E50914"/>
</svg>

After

Width:  |  Height:  |  Size: 537 B

View File

@ -0,0 +1,289 @@
#!/bin/bash
###############################################################################
# beStream - *Arr Services Installation Script for Ubuntu/Debian
#
# This script automates the installation of:
# - Radarr (Movies) - Port 7878
# - Sonarr (TV Shows) - Port 8989
# - Lidarr (Music) - Port 8686
#
# Usage: sudo bash install-arr-services.sh
###############################################################################
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
ARR_USER="arr"
ARR_GROUP="media"
RADARR_PORT=7878
SONARR_PORT=8989
LIDARR_PORT=8686
# Detect architecture
ARCH=$(dpkg --print-architecture)
case $ARCH in
amd64)
ARCH="x64"
;;
arm64)
ARCH="arm64"
;;
armhf|armv7l)
ARCH="arm"
;;
*)
echo -e "${RED}Unsupported architecture: $ARCH${NC}"
exit 1
;;
esac
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} beStream *Arr Services Installer${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
echo -e "${YELLOW}Detected Architecture: $ARCH${NC}"
echo -e "${YELLOW}Services to install:${NC}"
echo -e " - Radarr (Movies) - Port $RADARR_PORT"
echo -e " - Sonarr (TV Shows) - Port $SONARR_PORT"
echo -e " - Lidarr (Music) - Port $LIDARR_PORT"
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Please run as root (use sudo)${NC}"
exit 1
fi
###############################################################################
# Step 1: Install Prerequisites
###############################################################################
echo -e "${GREEN}[1/8] Installing prerequisites...${NC}"
apt update
apt install -y curl wget sqlite3 mediainfo libchromaprint-tools ufw
###############################################################################
# Step 2: Configure Firewall
###############################################################################
echo -e "${GREEN}[2/8] Configuring firewall...${NC}"
# Check if UFW is active
if ufw status | grep -q "Status: active"; then
echo -e "${YELLOW}UFW is active, opening ports...${NC}"
ufw allow $RADARR_PORT/tcp comment 'Radarr'
ufw allow $SONARR_PORT/tcp comment 'Sonarr'
ufw allow $LIDARR_PORT/tcp comment 'Lidarr'
echo -e "${GREEN}Ports opened: $RADARR_PORT (Radarr), $SONARR_PORT (Sonarr), $LIDARR_PORT (Lidarr)${NC}"
else
echo -e "${YELLOW}UFW is not active. To enable and open ports, run:${NC}"
echo -e " ${BLUE}sudo ufw enable${NC}"
echo -e " ${BLUE}sudo ufw allow $RADARR_PORT/tcp${NC}"
echo -e " ${BLUE}sudo ufw allow $SONARR_PORT/tcp${NC}"
echo -e " ${BLUE}sudo ufw allow $LIDARR_PORT/tcp${NC}"
fi
###############################################################################
# Step 3: Create User and Group
###############################################################################
echo -e "${GREEN}[3/8] Creating user and group...${NC}"
# Create media group if it doesn't exist
if ! getent group $ARR_GROUP > /dev/null 2>&1; then
groupadd $ARR_GROUP
echo -e "${GREEN}Created group: $ARR_GROUP${NC}"
else
echo -e "${YELLOW}Group $ARR_GROUP already exists${NC}"
fi
# Create arr user if it doesn't exist
if ! id "$ARR_USER" &>/dev/null; then
useradd -r -g $ARR_GROUP -s /bin/false -d /var/lib/$ARR_USER $ARR_USER
echo -e "${GREEN}Created user: $ARR_USER${NC}"
else
echo -e "${YELLOW}User $ARR_USER already exists${NC}"
# Ensure user is in media group
usermod -a -G $ARR_GROUP $ARR_USER
fi
###############################################################################
# Step 4: Create Data Directories
###############################################################################
echo -e "${GREEN}[4/8] Creating data directories...${NC}"
mkdir -p /var/lib/radarr
mkdir -p /var/lib/sonarr
mkdir -p /var/lib/lidarr
chown -R $ARR_USER:$ARR_GROUP /var/lib/radarr
chown -R $ARR_USER:$ARR_GROUP /var/lib/sonarr
chown -R $ARR_USER:$ARR_GROUP /var/lib/lidarr
###############################################################################
# Step 5: Install Radarr
###############################################################################
echo -e "${GREEN}[5/8] Installing Radarr...${NC}"
if [ -d "/opt/Radarr" ]; then
echo -e "${YELLOW}Radarr already installed, skipping...${NC}"
else
cd /tmp
wget --content-disposition "http://radarr.servarr.com/v1/update/master/updatefile?os=linux&runtime=netcore&arch=$ARCH" -O radarr.tar.gz
tar -xvzf radarr.tar.gz
mv Radarr /opt/
chown -R $ARR_USER:$ARR_GROUP /opt/Radarr
rm -f radarr.tar.gz
echo -e "${GREEN}Radarr installed successfully${NC}"
fi
###############################################################################
# Step 6: Install Sonarr
###############################################################################
echo -e "${GREEN}[6/8] Installing Sonarr...${NC}"
if [ -d "/opt/Sonarr" ]; then
echo -e "${YELLOW}Sonarr already installed, skipping...${NC}"
else
cd /tmp
wget -qO install-sonarr.sh https://raw.githubusercontent.com/Sonarr/Sonarr/develop/distribution/debian/install.sh
bash install-sonarr.sh <<EOF
$ARR_USER
$ARR_GROUP
EOF
rm -f install-sonarr.sh
echo -e "${GREEN}Sonarr installed successfully${NC}"
fi
###############################################################################
# Step 7: Install Lidarr
###############################################################################
echo -e "${GREEN}[7/8] Installing Lidarr...${NC}"
if [ -d "/opt/Lidarr" ]; then
echo -e "${YELLOW}Lidarr already installed, skipping...${NC}"
else
cd /tmp
wget --content-disposition "http://lidarr.servarr.com/v1/update/master/updatefile?os=linux&runtime=netcore&arch=$ARCH" -O lidarr.tar.gz
tar -xvzf lidarr.tar.gz
mv Lidarr /opt/
chown -R $ARR_USER:$ARR_GROUP /opt/Lidarr
rm -f lidarr.tar.gz
echo -e "${GREEN}Lidarr installed successfully${NC}"
fi
###############################################################################
# Step 8: Configure Systemd Services
###############################################################################
echo -e "${GREEN}[8/8] Configuring systemd services...${NC}"
# Radarr systemd service
cat > /etc/systemd/system/radarr.service <<EOF
[Unit]
Description=Radarr Daemon
After=syslog.target network.target
[Service]
User=$ARR_USER
Group=$ARR_GROUP
Type=simple
ExecStart=/opt/Radarr/Radarr -nobrowser -data=/var/lib/radarr/
TimeoutStopSec=20
KillMode=process
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
# Sonarr systemd service (if not created by install script)
if [ ! -f "/etc/systemd/system/sonarr.service" ]; then
cat > /etc/systemd/system/sonarr.service <<EOF
[Unit]
Description=Sonarr Daemon
After=syslog.target network.target
[Service]
User=$ARR_USER
Group=$ARR_GROUP
Type=simple
ExecStart=/opt/Sonarr/Sonarr -nobrowser -data=/var/lib/sonarr/
TimeoutStopSec=20
KillMode=process
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
fi
# Lidarr systemd service
cat > /etc/systemd/system/lidarr.service <<EOF
[Unit]
Description=Lidarr Daemon
After=syslog.target network.target
[Service]
User=$ARR_USER
Group=$ARR_GROUP
Type=simple
ExecStart=/opt/Lidarr/Lidarr -nobrowser -data=/var/lib/lidarr/
TimeoutStopSec=20
KillMode=process
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
# Reload systemd
systemctl daemon-reload
# Enable and start services
echo -e "${GREEN}Enabling and starting services...${NC}"
systemctl enable --now radarr
systemctl enable --now sonarr
systemctl enable --now lidarr
# Wait a moment for services to start
sleep 3
###############################################################################
# Step 8: Check Service Status
###############################################################################
echo ""
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Installation Complete!${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Get server IP
SERVER_IP=$(hostname -I | awk '{print $1}')
echo -e "${GREEN}Service Status:${NC}"
systemctl is-active --quiet radarr && echo -e " ${GREEN}${NC} Radarr: http://$SERVER_IP:$RADARR_PORT" || echo -e " ${RED}${NC} Radarr: Not running"
systemctl is-active --quiet sonarr && echo -e " ${GREEN}${NC} Sonarr: http://$SERVER_IP:$SONARR_PORT" || echo -e " ${RED}${NC} Sonarr: Not running"
systemctl is-active --quiet lidarr && echo -e " ${GREEN}${NC} Lidarr: http://$SERVER_IP:$LIDARR_PORT" || echo -e " ${RED}${NC} Lidarr: Not running"
echo ""
echo -e "${YELLOW}Next Steps:${NC}"
echo " 1. Access each service's web interface using the URLs above"
echo " 2. Complete the initial setup wizard for each service"
echo " 3. Get API keys from Settings → General → Security"
echo " 4. Configure beStream to connect to these services"
echo ""
echo -e "${YELLOW}To check service logs:${NC}"
echo " sudo journalctl -u radarr -f"
echo " sudo journalctl -u sonarr -f"
echo " sudo journalctl -u lidarr -f"
echo ""
echo -e "${YELLOW}To restart services:${NC}"
echo " sudo systemctl restart radarr"
echo " sudo systemctl restart sonarr"
echo " sudo systemctl restart lidarr"
echo ""

81
scripts/open-ports.sh Normal file
View File

@ -0,0 +1,81 @@
#!/bin/bash
###############################################################################
# beStream - Open Firewall Ports for *Arr Services
#
# This script opens the required ports for:
# - Radarr (Movies) - Port 7878
# - Sonarr (TV Shows) - Port 8989
# - Lidarr (Music) - Port 8686
# - beStream Backend (optional) - Port 3001
#
# Usage: sudo bash open-ports.sh
###############################################################################
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Ports
RADARR_PORT=7878
SONARR_PORT=8989
LIDARR_PORT=8686
BESTREAM_PORT=3001
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Opening Firewall Ports${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Please run as root (use sudo)${NC}"
exit 1
fi
# Check if UFW is installed
if ! command -v ufw &> /dev/null; then
echo -e "${YELLOW}UFW is not installed. Installing...${NC}"
apt update
apt install -y ufw
fi
# Enable UFW if not already enabled
if ! ufw status | grep -q "Status: active"; then
echo -e "${YELLOW}UFW is not active. Enabling...${NC}"
ufw --force enable
fi
# Open ports
echo -e "${GREEN}Opening ports...${NC}"
ufw allow $RADARR_PORT/tcp comment 'Radarr - Movies'
ufw allow $SONARR_PORT/tcp comment 'Sonarr - TV Shows'
ufw allow $LIDARR_PORT/tcp comment 'Lidarr - Music'
ufw allow $BESTREAM_PORT/tcp comment 'beStream Backend'
echo ""
echo -e "${GREEN}Ports opened successfully!${NC}"
echo ""
echo -e "${BLUE}Opened Ports:${NC}"
echo -e " ${GREEN}${NC} $RADARR_PORT/tcp - Radarr (Movies)"
echo -e " ${GREEN}${NC} $SONARR_PORT/tcp - Sonarr (TV Shows)"
echo -e " ${GREEN}${NC} $LIDARR_PORT/tcp - Lidarr (Music)"
echo -e " ${GREEN}${NC} $BESTREAM_PORT/tcp - beStream Backend"
echo ""
# Show firewall status
echo -e "${BLUE}Current Firewall Status:${NC}"
ufw status numbered
echo ""
echo -e "${YELLOW}Note: If you're accessing from a remote network, make sure:${NC}"
echo " 1. Your router forwards these ports (if needed)"
echo " 2. Your cloud provider's firewall allows these ports"
echo " 3. You're using the correct server IP address"
echo ""

3603
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
server/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "bestream-server",
"version": "2.1.0",
"description": "Streaming backend for beStream",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.1",
"fluent-ffmpeg": "^2.1.3",
"mime-types": "^2.1.35",
"uuid": "^10.0.0",
"webtorrent": "^2.5.1",
"ws": "^8.18.0"
}
}

89
server/src/index.js Normal file
View File

@ -0,0 +1,89 @@
import express from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import { streamRouter } from './routes/stream.js';
import { torrentManager } from './services/torrentManager.js';
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors({
origin: ['http://localhost:5173', 'http://localhost:3000'],
credentials: true,
}));
app.use(express.json());
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
activeTorrents: torrentManager.getActiveCount(),
uptime: process.uptime()
});
});
// API Routes
app.use('/api/stream', streamRouter);
// Create HTTP server
const server = createServer(app);
// WebSocket for real-time updates
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
console.log('WebSocket client connected');
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
if (data.type === 'subscribe' && data.sessionId) {
ws.sessionId = data.sessionId;
}
} catch (e) {
console.error('WebSocket message error:', e);
}
});
ws.on('close', () => {
console.log('WebSocket client disconnected');
});
});
// Broadcast updates to subscribed clients
export function broadcastUpdate(sessionId, data) {
wss.clients.forEach((client) => {
if (client.sessionId === sessionId && client.readyState === 1) {
client.send(JSON.stringify(data));
}
});
}
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down...');
await torrentManager.destroyAll();
process.exit(0);
});
process.on('SIGTERM', async () => {
await torrentManager.destroyAll();
process.exit(0);
});
server.listen(PORT, () => {
console.log(`
╔═══════════════════════════════════════════════════════╗
║ ║
║ 🎬 beStream Server running on port ${PORT}
║ ║
║ API: http://localhost:${PORT}/api ║
║ WebSocket: ws://localhost:${PORT}/ws ║
║ Health: http://localhost:${PORT}/health ║
║ ║
╚═══════════════════════════════════════════════════════╝
`);
});

297
server/src/routes/stream.js Normal file
View File

@ -0,0 +1,297 @@
import express from 'express';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
import fs from 'fs';
import mime from 'mime-types';
import { torrentManager } from '../services/torrentManager.js';
import { transcoder } from '../services/transcoder.js';
import { broadcastUpdate } from '../index.js';
export const streamRouter = express.Router();
/**
* Start a new streaming session
* POST /api/stream/start
*/
streamRouter.post('/start', async (req, res) => {
try {
const { hash, name, quality } = req.body;
if (!hash || !name) {
return res.status(400).json({ error: 'Missing hash or name' });
}
const sessionId = uuidv4();
console.log(`🎬 Starting stream session ${sessionId} for "${name}"`);
// Start torrent download
const session = await torrentManager.startSession(
sessionId,
hash,
name,
(progress) => {
broadcastUpdate(sessionId, {
type: 'progress',
...torrentManager.getSessionStatus(sessionId),
});
}
);
res.json({
sessionId,
status: session.status,
videoFile: session.videoFile ? {
name: session.videoFile.name,
size: session.videoFile.length,
} : null,
});
} catch (error) {
console.error('Error starting stream:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Get session status
* GET /api/stream/:sessionId/status
*/
streamRouter.get('/:sessionId/status', (req, res) => {
const { sessionId } = req.params;
const status = torrentManager.getSessionStatus(sessionId);
if (!status) {
return res.status(404).json({ error: 'Session not found' });
}
res.json(status);
});
/**
* Direct video stream (for browsers that support the format)
* GET /api/stream/:sessionId/video
*/
streamRouter.get('/:sessionId/video', async (req, res) => {
try {
const { sessionId } = req.params;
const session = torrentManager.getSession(sessionId);
if (!session || !session.videoFile) {
return res.status(404).json({ error: 'Session not found or video not ready' });
}
const videoFile = session.videoFile;
const fileSize = videoFile.length;
const mimeType = mime.lookup(videoFile.name) || 'video/mp4';
// Handle range requests for seeking
const range = req.headers.range;
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize,
'Content-Type': mimeType,
});
const stream = torrentManager.createReadStream(sessionId, { start, end });
// Handle stream errors gracefully
stream.on('error', (err) => {
console.error('Stream error:', err.message);
if (!res.headersSent) {
res.status(500).end();
}
});
// Handle client disconnect
req.on('close', () => {
stream.destroy();
});
stream.pipe(res);
} else {
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': mimeType,
'Accept-Ranges': 'bytes',
});
const stream = torrentManager.createReadStream(sessionId);
// Handle stream errors gracefully
stream.on('error', (err) => {
console.error('Stream error:', err.message);
if (!res.headersSent) {
res.status(500).end();
}
});
// Handle client disconnect
req.on('close', () => {
stream.destroy();
});
stream.pipe(res);
}
} catch (error) {
console.error('Error streaming video:', error);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
});
/**
* Start HLS transcoding
* POST /api/stream/:sessionId/hls
*/
streamRouter.post('/:sessionId/hls', async (req, res) => {
try {
const { sessionId } = req.params;
const session = torrentManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
if (session.status !== 'ready' && session.progress < 0.05) {
return res.status(400).json({
error: 'Video not ready yet',
progress: session.progress
});
}
const videoPath = torrentManager.getVideoPath(sessionId);
if (!videoPath || !fs.existsSync(videoPath)) {
return res.status(400).json({ error: 'Video file not available yet' });
}
// Check if already transcoding or ready
if (transcoder.isHlsReady(sessionId)) {
return res.json({
status: 'ready',
playlistUrl: `/api/stream/${sessionId}/hls/playlist.m3u8`,
});
}
// Start transcoding in background
transcoder.startHlsTranscode(sessionId, videoPath, (progress) => {
broadcastUpdate(sessionId, {
type: 'transcode',
progress: progress.percent,
time: progress.time,
});
}).then(() => {
broadcastUpdate(sessionId, {
type: 'transcode',
status: 'ready',
playlistUrl: `/api/stream/${sessionId}/hls/playlist.m3u8`,
});
}).catch((err) => {
console.error('Transcode error:', err);
broadcastUpdate(sessionId, {
type: 'error',
message: 'Transcoding failed',
});
});
res.json({
status: 'transcoding',
message: 'HLS transcoding started',
});
} catch (error) {
console.error('Error starting HLS:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Serve HLS playlist
* GET /api/stream/:sessionId/hls/playlist.m3u8
*/
streamRouter.get('/:sessionId/hls/playlist.m3u8', (req, res) => {
const { sessionId } = req.params;
const playlistPath = transcoder.getPlaylistPath(sessionId);
if (!fs.existsSync(playlistPath)) {
return res.status(404).json({ error: 'Playlist not ready' });
}
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(playlistPath);
});
/**
* Serve HLS segment
* GET /api/stream/:sessionId/hls/:segment
*/
streamRouter.get('/:sessionId/hls/:segment', (req, res) => {
const { sessionId, segment } = req.params;
const segmentPath = transcoder.getSegmentPath(sessionId, segment);
if (!fs.existsSync(segmentPath)) {
return res.status(404).json({ error: 'Segment not found' });
}
res.setHeader('Content-Type', 'video/mp2t');
res.setHeader('Cache-Control', 'max-age=3600');
res.sendFile(segmentPath);
});
/**
* Get video info
* GET /api/stream/:sessionId/info
*/
streamRouter.get('/:sessionId/info', async (req, res) => {
try {
const { sessionId } = req.params;
const videoPath = torrentManager.getVideoPath(sessionId);
if (!videoPath || !fs.existsSync(videoPath)) {
return res.status(404).json({ error: 'Video not available' });
}
const info = await transcoder.getVideoInfo(videoPath);
res.json(info);
} catch (error) {
console.error('Error getting video info:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Stop a streaming session
* DELETE /api/stream/:sessionId
*/
streamRouter.delete('/:sessionId', async (req, res) => {
const { sessionId } = req.params;
await torrentManager.stopSession(sessionId);
transcoder.cleanupSession(sessionId);
res.json({ status: 'stopped' });
});
/**
* List active sessions
* GET /api/stream/sessions
*/
streamRouter.get('/sessions', (req, res) => {
const sessions = [];
// This would need access to the sessions map
res.json({ count: torrentManager.getActiveCount() });
});

View File

@ -0,0 +1,359 @@
import WebTorrent from 'webtorrent';
import path from 'path';
import fs from 'fs';
import os from 'os';
// Trackers for magnet links
const TRACKERS = [
'udp://open.demonii.com:1337/announce',
'udp://tracker.openbittorrent.com:80',
'udp://tracker.coppersurfer.tk:6969',
'udp://glotorrents.pw:6969/announce',
'udp://tracker.opentrackr.org:1337/announce',
'udp://torrent.gresille.org:80/announce',
'udp://p4p.arenabg.com:1337',
'udp://tracker.leechers-paradise.org:6969',
];
class TorrentManager {
constructor() {
this.client = new WebTorrent({
maxConns: 100,
});
this.sessions = new Map();
this.downloadPath = path.join(os.tmpdir(), 'bestream-cache');
// Ensure download directory exists
if (!fs.existsSync(this.downloadPath)) {
fs.mkdirSync(this.downloadPath, { recursive: true });
}
// Handle client-level errors (including duplicate torrent errors)
this.client.on('error', (err) => {
// Ignore duplicate torrent errors - they're handled in startSession
if (err.message && err.message.includes('duplicate')) {
console.log('⚠️ Duplicate torrent detected - will reuse existing');
return;
}
console.error('WebTorrent client error:', err);
});
console.log(`📁 Cache directory: ${this.downloadPath}`);
}
/**
* Generate magnet URI from torrent hash
*/
generateMagnetUri(hash, name) {
const encodedName = encodeURIComponent(name);
const trackerParams = TRACKERS.map((t) => `&tr=${encodeURIComponent(t)}`).join('');
return `magnet:?xt=urn:btih:${hash}&dn=${encodedName}${trackerParams}`;
}
/**
* Start a new streaming session
*/
async startSession(sessionId, torrentHash, movieName, onProgress) {
// Check if already downloading this torrent
const existingSession = Array.from(this.sessions.values()).find(
(s) => s.hash === torrentHash && (s.status === 'ready' || s.status === 'downloading')
);
if (existingSession) {
console.log(`♻️ Reusing existing session for ${movieName}`);
// Create a new session entry that shares the same torrent
const newSession = {
...existingSession,
id: sessionId,
};
this.sessions.set(sessionId, newSession);
return newSession;
}
// Check if torrent is already in client and ready
const existingTorrent = this.client.get(torrentHash);
if (existingTorrent && existingTorrent.files && existingTorrent.files.length > 0) {
const videoFile = this.findVideoFile(existingTorrent);
if (videoFile) {
console.log(`♻️ Reusing cached torrent: ${movieName}`);
const session = {
id: sessionId,
hash: torrentHash,
name: movieName,
status: existingTorrent.done ? 'ready' : 'downloading',
progress: existingTorrent.progress,
downloadSpeed: existingTorrent.downloadSpeed,
uploadSpeed: existingTorrent.uploadSpeed,
peers: existingTorrent.numPeers,
downloaded: existingTorrent.downloaded,
total: existingTorrent.length,
videoFile: videoFile,
torrent: existingTorrent,
startedAt: Date.now(),
};
this.sessions.set(sessionId, session);
return session;
}
}
const magnetUri = this.generateMagnetUri(torrentHash, movieName);
console.log(`🧲 Starting torrent: ${movieName}`);
const session = {
id: sessionId,
hash: torrentHash,
name: movieName,
status: 'connecting',
progress: 0,
downloadSpeed: 0,
uploadSpeed: 0,
peers: 0,
downloaded: 0,
total: 0,
videoFile: null,
torrent: null,
startedAt: Date.now(),
};
this.sessions.set(sessionId, session);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (session.status === 'connecting') {
session.status = 'error';
session.error = 'Connection timeout - no peers found';
reject(new Error('Connection timeout'));
}
}, 120000); // 2 minute timeout
// Helper function to setup torrent and resolve
const setupTorrent = (torrent) => {
clearTimeout(timeout);
session.torrent = torrent;
session.total = torrent.length || 0;
// Wait for torrent to be ready with metadata
const onReady = () => {
// Find the video file
const videoFile = this.findVideoFile(torrent);
if (!videoFile) {
// Retry after a short delay - sometimes files take a moment to populate
setTimeout(() => {
const retryVideoFile = this.findVideoFile(torrent);
if (!retryVideoFile) {
session.status = 'error';
session.error = 'No video file found in torrent';
console.error(`❌ No video file found in: ${movieName}`);
reject(new Error('No video file found'));
return;
}
finishSetup(retryVideoFile);
}, 1000);
return;
}
finishSetup(videoFile);
};
const finishSetup = (videoFile) => {
session.videoFile = videoFile;
session.status = 'downloading';
session.total = torrent.length;
console.log(`📹 Found video: ${videoFile.name} (${this.formatBytes(videoFile.length)})`);
// Select only the video file
torrent.files.forEach((file) => {
if (file !== videoFile) {
file.deselect();
}
});
// Prioritize first pieces for faster playback start
videoFile.select();
// Progress updates
const onDownload = () => {
session.progress = torrent.progress;
session.downloadSpeed = torrent.downloadSpeed;
session.uploadSpeed = torrent.uploadSpeed;
session.peers = torrent.numPeers;
session.downloaded = torrent.downloaded;
if (onProgress) {
onProgress(session);
}
};
// Remove any existing listeners to prevent duplicates
torrent.removeAllListeners('download');
torrent.on('download', onDownload);
torrent.removeAllListeners('done');
torrent.on('done', () => {
session.status = 'ready';
session.progress = 1;
console.log(`✅ Download complete: ${movieName}`);
});
// Wait for minimum buffer before resolving
const checkBuffer = () => {
const buffered = videoFile.downloaded / videoFile.length;
if (buffered >= 0.02 || torrent.done) { // 2% buffer or complete
session.status = 'ready';
resolve(session);
} else {
setTimeout(checkBuffer, 500);
}
};
checkBuffer();
};
// If torrent already has files (was already added), use immediately
if (torrent.files && torrent.files.length > 0) {
onReady();
} else {
// Wait for metadata
torrent.once('ready', onReady);
}
};
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
strategy: 'sequential',
}, setupTorrent);
} catch (error) {
clearTimeout(timeout);
session.status = 'error';
session.error = error.message;
reject(error);
}
});
}
/**
* Find the main video file in a torrent
*/
findVideoFile(torrent) {
const videoExtensions = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v'];
// Sort by size and find largest video file
const videoFiles = torrent.files
.filter((file) => {
const ext = path.extname(file.name).toLowerCase();
return videoExtensions.includes(ext);
})
.sort((a, b) => b.length - a.length);
return videoFiles[0] || null;
}
/**
* Get session by ID
*/
getSession(sessionId) {
return this.sessions.get(sessionId);
}
/**
* Get session status
*/
getSessionStatus(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return null;
return {
id: session.id,
status: session.status,
progress: session.progress,
downloadSpeed: session.downloadSpeed,
uploadSpeed: session.uploadSpeed,
peers: session.peers,
downloaded: session.downloaded,
total: session.total,
videoFile: session.videoFile ? {
name: session.videoFile.name,
size: session.videoFile.length,
path: session.videoFile.path,
} : null,
error: session.error,
};
}
/**
* Create a read stream for the video file
*/
createReadStream(sessionId, options = {}) {
const session = this.sessions.get(sessionId);
if (!session || !session.videoFile) {
throw new Error('Session not found or video not ready');
}
return session.videoFile.createReadStream(options);
}
/**
* Get video file path
*/
getVideoPath(sessionId) {
const session = this.sessions.get(sessionId);
if (!session || !session.videoFile) {
return null;
}
return path.join(this.downloadPath, session.torrent.name, session.videoFile.path);
}
/**
* Stop a session
*/
async stopSession(sessionId) {
const session = this.sessions.get(sessionId);
if (session && session.torrent) {
session.torrent.destroy();
}
this.sessions.delete(sessionId);
}
/**
* Get active session count
*/
getActiveCount() {
return this.sessions.size;
}
/**
* Destroy all sessions
*/
async destroyAll() {
console.log('🧹 Cleaning up torrents...');
for (const [sessionId] of this.sessions) {
await this.stopSession(sessionId);
}
this.client.destroy();
}
/**
* Format bytes to human readable
*/
formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
}
export const torrentManager = new TorrentManager();

View File

@ -0,0 +1,219 @@
import ffmpeg from 'fluent-ffmpeg';
import path from 'path';
import fs from 'fs';
import os from 'os';
class Transcoder {
constructor() {
this.hlsPath = path.join(os.tmpdir(), 'bestream-hls');
this.activeTranscodes = new Map();
// Ensure HLS directory exists
if (!fs.existsSync(this.hlsPath)) {
fs.mkdirSync(this.hlsPath, { recursive: true });
}
console.log(`📺 HLS output directory: ${this.hlsPath}`);
}
/**
* Get video info
*/
getVideoInfo(inputPath) {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(inputPath, (err, metadata) => {
if (err) {
reject(err);
return;
}
const videoStream = metadata.streams.find((s) => s.codec_type === 'video');
const audioStream = metadata.streams.find((s) => s.codec_type === 'audio');
resolve({
duration: metadata.format.duration,
size: metadata.format.size,
bitrate: metadata.format.bit_rate,
video: videoStream ? {
codec: videoStream.codec_name,
width: videoStream.width,
height: videoStream.height,
fps: eval(videoStream.r_frame_rate),
} : null,
audio: audioStream ? {
codec: audioStream.codec_name,
channels: audioStream.channels,
sampleRate: audioStream.sample_rate,
} : null,
});
});
});
}
/**
* Start HLS transcoding
*/
async startHlsTranscode(sessionId, inputPath, onProgress) {
const outputDir = path.join(this.hlsPath, sessionId);
const playlistPath = path.join(outputDir, 'playlist.m3u8');
// Create output directory
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Check if already transcoded
if (fs.existsSync(playlistPath)) {
console.log(`♻️ Using cached HLS for session ${sessionId}`);
return { playlistPath, outputDir, cached: true };
}
console.log(`🎬 Starting HLS transcode for session ${sessionId}`);
return new Promise((resolve, reject) => {
const command = ffmpeg(inputPath)
.outputOptions([
// HLS options
'-hls_time', '4', // 4 second segments
'-hls_list_size', '0', // Keep all segments in playlist
'-hls_flags', 'delete_segments+append_list+split_by_time',
'-hls_segment_type', 'mpegts',
'-hls_segment_filename', path.join(outputDir, 'segment_%03d.ts'),
// Video encoding
'-c:v', 'libx264',
'-preset', 'ultrafast', // Fast encoding for real-time
'-tune', 'zerolatency', // Low latency
'-crf', '23', // Quality (lower = better)
'-maxrate', '4M', // Max bitrate
'-bufsize', '8M', // Buffer size
'-g', '48', // Keyframe interval
// Audio encoding
'-c:a', 'aac',
'-b:a', '128k',
'-ac', '2', // Stereo
// General
'-movflags', '+faststart',
'-f', 'hls',
])
.output(playlistPath);
// Track progress
command.on('progress', (progress) => {
if (onProgress) {
onProgress({
percent: progress.percent || 0,
fps: progress.currentFps,
time: progress.timemark,
});
}
});
command.on('start', (commandLine) => {
console.log(`FFmpeg started: ${commandLine.substring(0, 100)}...`);
this.activeTranscodes.set(sessionId, command);
});
command.on('error', (err, stdout, stderr) => {
console.error('FFmpeg error:', err.message);
console.error('FFmpeg stderr:', stderr);
this.activeTranscodes.delete(sessionId);
reject(err);
});
command.on('end', () => {
console.log(`✅ HLS transcode complete for session ${sessionId}`);
this.activeTranscodes.delete(sessionId);
resolve({ playlistPath, outputDir, cached: false });
});
command.run();
});
}
/**
* Start live transcode streaming (pipe directly)
*/
createStreamTranscode(inputStream, outputStream, options = {}) {
const {
startTime = 0,
videoCodec = 'libx264',
audioCodec = 'aac',
} = options;
const command = ffmpeg(inputStream)
.seekInput(startTime)
.outputOptions([
'-c:v', videoCodec,
'-preset', 'ultrafast',
'-tune', 'zerolatency',
'-crf', '23',
'-c:a', audioCodec,
'-b:a', '128k',
'-movflags', 'frag_keyframe+empty_moov+faststart',
'-f', 'mp4',
])
.on('error', (err) => {
console.error('Stream transcode error:', err);
});
command.pipe(outputStream, { end: true });
return command;
}
/**
* Stop transcoding for a session
*/
stopTranscode(sessionId) {
const command = this.activeTranscodes.get(sessionId);
if (command) {
command.kill('SIGKILL');
this.activeTranscodes.delete(sessionId);
}
}
/**
* Clean up HLS files for a session
*/
cleanupSession(sessionId) {
const outputDir = path.join(this.hlsPath, sessionId);
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true, force: true });
}
this.stopTranscode(sessionId);
}
/**
* Get HLS playlist path
*/
getPlaylistPath(sessionId) {
return path.join(this.hlsPath, sessionId, 'playlist.m3u8');
}
/**
* Get segment path
*/
getSegmentPath(sessionId, segmentName) {
return path.join(this.hlsPath, sessionId, segmentName);
}
/**
* Check if HLS is ready
*/
isHlsReady(sessionId) {
const playlistPath = this.getPlaylistPath(sessionId);
return fs.existsSync(playlistPath);
}
/**
* Get active transcode count
*/
getActiveCount() {
return this.activeTranscodes.size;
}
}
export const transcoder = new Transcoder();

69
src/App.tsx Normal file
View File

@ -0,0 +1,69 @@
import { Routes, Route } from 'react-router-dom';
import { useEffect } from 'react';
import Layout from './components/layout/Layout';
import Home from './pages/Home';
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
document.documentElement.classList.toggle('dark', settings.theme === 'dark');
}, [settings.theme]);
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
{/* Movies */}
<Route path="browse" element={<Browse />} />
<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 />} />
<Route path="queue" element={<Queue />} />
{/* User */}
<Route path="watchlist" element={<Watchlist />} />
<Route path="history" element={<History />} />
<Route path="downloads" element={<Downloads />} />
<Route path="settings" element={<Settings />} />
</Route>
{/* Full-screen player */}
<Route path="/player/:id" element={<Player />} />
{/* TV Show Player */}
<Route path="/tv/play/:showId" element={<TVPlayer />} />
</Routes>
);
}
export default App;

View File

@ -0,0 +1,108 @@
import { motion } from 'framer-motion';
import { Film, Tv, Music } from 'lucide-react';
import type { CalendarItem } from '../../types/unified';
interface CalendarDayProps {
date: Date;
items: CalendarItem[];
isToday: boolean;
isCurrentMonth: boolean;
view: 'week' | 'month';
onItemClick?: (item: CalendarItem) => void;
}
export default function CalendarDay({
date,
items,
isToday,
isCurrentMonth,
view,
onItemClick,
}: CalendarDayProps) {
const getTypeIcon = (type: CalendarItem['type']) => {
switch (type) {
case 'movie':
return <Film size={12} />;
case 'episode':
return <Tv size={12} />;
case 'album':
return <Music size={12} />;
}
};
const getTypeColor = (type: CalendarItem['type'], hasFile: boolean) => {
if (hasFile) return 'bg-green-500/80';
switch (type) {
case 'movie':
return 'bg-red-500/80';
case 'episode':
return 'bg-blue-500/80';
case 'album':
return 'bg-purple-500/80';
}
};
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return (
<div
className={`
${view === 'week' ? 'flex-1' : ''}
min-h-[120px] p-2 border-r border-b border-white/5
${isCurrentMonth ? '' : 'opacity-40'}
${isToday ? 'bg-netflix-red/10' : 'hover:bg-white/5'}
transition-colors
`}
>
{/* Date Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1">
{view === 'week' && (
<span className="text-xs text-gray-400">{dayNames[date.getDay()]}</span>
)}
<span
className={`
text-sm font-medium
${isToday ? 'w-6 h-6 rounded-full bg-netflix-red flex items-center justify-center' : ''}
`}
>
{date.getDate()}
</span>
</div>
{items.length > 0 && view === 'month' && (
<span className="text-xs text-gray-500">{items.length}</span>
)}
</div>
{/* Items */}
<div className="space-y-1">
{items.slice(0, view === 'week' ? 10 : 3).map((item) => (
<motion.div
key={item.id}
whileHover={{ scale: 1.02 }}
onClick={() => onItemClick?.(item)}
className={`
flex items-center gap-1.5 p-1.5 rounded text-xs cursor-pointer
${getTypeColor(item.type, item.hasFile)}
hover:brightness-110 transition-all
`}
>
{getTypeIcon(item.type)}
<div className="flex-1 min-w-0">
<p className="font-medium line-clamp-1">{item.title}</p>
{item.subtitle && (
<p className="text-white/70 line-clamp-1 text-[10px]">{item.subtitle}</p>
)}
</div>
</motion.div>
))}
{items.length > (view === 'week' ? 10 : 3) && (
<div className="text-xs text-gray-400 px-1.5">
+{items.length - (view === 'week' ? 10 : 3)} more
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,214 @@
import { useState, useMemo } from 'react';
import { motion } from 'framer-motion';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
import type { CalendarItem } from '../../types/unified';
import CalendarDay from './CalendarDay';
import Button from '../ui/Button';
interface CalendarViewProps {
items: CalendarItem[];
isLoading?: boolean;
onItemClick?: (item: CalendarItem) => void;
}
export default function CalendarView({
items,
isLoading = false,
onItemClick,
}: CalendarViewProps) {
const [currentDate, setCurrentDate] = useState(new Date());
const [view, setView] = useState<'month' | 'week'>('week');
const today = new Date();
today.setHours(0, 0, 0, 0);
// Get the days to display based on view
const days = useMemo(() => {
const result: Date[] = [];
if (view === 'week') {
// Show 7 days starting from current date
for (let i = 0; i < 7; i++) {
const date = new Date(currentDate);
date.setDate(date.getDate() + i);
result.push(date);
}
} else {
// Show entire month
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Start from the first day of the week
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - startDate.getDay());
// End at the last day of the week
const endDate = new Date(lastDay);
endDate.setDate(endDate.getDate() + (6 - endDate.getDay()));
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
result.push(new Date(d));
}
}
return result;
}, [currentDate, view]);
// Group items by date
const itemsByDate = useMemo(() => {
const grouped: Record<string, CalendarItem[]> = {};
items.forEach((item) => {
const dateKey = item.date.toDateString();
if (!grouped[dateKey]) {
grouped[dateKey] = [];
}
grouped[dateKey].push(item);
});
return grouped;
}, [items]);
const navigatePrevious = () => {
const newDate = new Date(currentDate);
if (view === 'week') {
newDate.setDate(newDate.getDate() - 7);
} else {
newDate.setMonth(newDate.getMonth() - 1);
}
setCurrentDate(newDate);
};
const navigateNext = () => {
const newDate = new Date(currentDate);
if (view === 'week') {
newDate.setDate(newDate.getDate() + 7);
} else {
newDate.setMonth(newDate.getMonth() + 1);
}
setCurrentDate(newDate);
};
const goToToday = () => {
setCurrentDate(new Date());
};
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return (
<div className="glass rounded-lg overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-4">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={navigatePrevious}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<ChevronLeft size={20} />
</motion.button>
<h2 className="text-xl font-semibold min-w-[200px] text-center">
{view === 'week'
? `${monthNames[currentDate.getMonth()]} ${currentDate.getFullYear()}`
: `${monthNames[currentDate.getMonth()]} ${currentDate.getFullYear()}`
}
</h2>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={navigateNext}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<ChevronRight size={20} />
</motion.button>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={goToToday}
leftIcon={<CalendarIcon size={14} />}
>
Today
</Button>
<div className="flex rounded-lg overflow-hidden border border-white/20">
<button
onClick={() => setView('week')}
className={`px-3 py-1.5 text-sm transition-colors ${
view === 'week' ? 'bg-netflix-red text-white' : 'hover:bg-white/10'
}`}
>
Week
</button>
<button
onClick={() => setView('month')}
className={`px-3 py-1.5 text-sm transition-colors ${
view === 'month' ? 'bg-netflix-red text-white' : 'hover:bg-white/10'
}`}
>
Month
</button>
</div>
</div>
</div>
{/* Day Headers */}
{view === 'month' && (
<div className="grid grid-cols-7 border-b border-white/10">
{dayNames.map((day) => (
<div key={day} className="p-2 text-center text-sm text-gray-400">
{day}
</div>
))}
</div>
)}
{/* Calendar Grid */}
<div className={`${view === 'month' ? 'grid grid-cols-7' : 'flex'}`}>
{isLoading ? (
// Loading skeleton
Array.from({ length: view === 'week' ? 7 : 35 }).map((_, i) => (
<div
key={i}
className={`${
view === 'week' ? 'flex-1' : ''
} min-h-[120px] p-2 border-r border-b border-white/5 animate-pulse bg-white/5`}
/>
))
) : (
days.map((date) => {
const dateKey = date.toDateString();
const dayItems = itemsByDate[dateKey] || [];
const isToday = date.toDateString() === today.toDateString();
const isCurrentMonth = date.getMonth() === currentDate.getMonth();
return (
<CalendarDay
key={dateKey}
date={date}
items={dayItems}
isToday={isToday}
isCurrentMonth={isCurrentMonth}
view={view}
onItemClick={onItemClick}
/>
);
})
)}
</div>
</div>
);
}

View File

@ -0,0 +1,177 @@
import { motion } from 'framer-motion';
import { Film, Tv, Music, Calendar, Check, Clock } from 'lucide-react';
import type { CalendarItem } from '../../types/unified';
import Badge from '../ui/Badge';
interface UpcomingListProps {
items: CalendarItem[];
isLoading?: boolean;
onItemClick?: (item: CalendarItem) => void;
}
export default function UpcomingList({
items,
isLoading = false,
onItemClick,
}: UpcomingListProps) {
const getTypeIcon = (type: CalendarItem['type']) => {
switch (type) {
case 'movie':
return <Film size={16} className="text-red-400" />;
case 'episode':
return <Tv size={16} className="text-blue-400" />;
case 'album':
return <Music size={16} className="text-purple-400" />;
}
};
const getTypeBadge = (type: CalendarItem['type']) => {
switch (type) {
case 'movie':
return <Badge size="sm" className="bg-red-500/20 text-red-400">Movie</Badge>;
case 'episode':
return <Badge size="sm" className="bg-blue-500/20 text-blue-400">Episode</Badge>;
case 'album':
return <Badge size="sm" className="bg-purple-500/20 text-purple-400">Album</Badge>;
}
};
const formatDate = (date: Date) => {
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Tomorrow';
if (days < 7) return `In ${days} days`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
};
// Group items by date
const groupedItems = items.reduce((acc, item) => {
const dateKey = item.date.toDateString();
if (!acc[dateKey]) {
acc[dateKey] = { date: item.date, items: [] };
}
acc[dateKey].items.push(item);
return acc;
}, {} as Record<string, { date: Date; items: CalendarItem[] }>);
const sortedGroups = Object.values(groupedItems).sort(
(a, b) => a.date.getTime() - b.date.getTime()
);
if (isLoading) {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="glass rounded-lg p-4 animate-pulse">
<div className="h-4 bg-white/10 rounded w-24 mb-3" />
<div className="space-y-2">
<div className="h-16 bg-white/5 rounded" />
</div>
</div>
))}
</div>
);
}
if (items.length === 0) {
return (
<div className="glass rounded-lg p-8 text-center">
<Calendar size={48} className="mx-auto text-gray-500 mb-4" />
<h3 className="text-lg font-medium text-gray-300">No upcoming releases</h3>
<p className="text-gray-500 mt-1">Check back later for new content</p>
</div>
);
}
return (
<div className="space-y-6">
{sortedGroups.map(({ date, items: dateItems }) => (
<div key={date.toDateString()}>
{/* Date Header */}
<div className="flex items-center gap-2 mb-3">
<Calendar size={16} className="text-gray-400" />
<h3 className="font-medium text-gray-300">
{formatDate(date)}
</h3>
<span className="text-sm text-gray-500">
{date.toLocaleDateString('en-US', { weekday: 'long' })}
</span>
</div>
{/* Items */}
<div className="space-y-2">
{dateItems.map((item) => (
<motion.div
key={item.id}
whileHover={{ scale: 1.01, x: 4 }}
onClick={() => onItemClick?.(item)}
className="glass rounded-lg p-4 flex items-center gap-4 cursor-pointer hover:bg-white/5 transition-colors"
>
{/* Poster */}
{item.poster ? (
<img
src={item.poster}
alt={item.title}
className="w-12 h-16 object-cover rounded"
/>
) : (
<div className="w-12 h-16 rounded bg-white/10 flex items-center justify-center">
{getTypeIcon(item.type)}
</div>
)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{getTypeIcon(item.type)}
<h4 className="font-medium text-white line-clamp-1">
{item.title}
</h4>
</div>
{item.subtitle && (
<p className="text-sm text-gray-400 line-clamp-1">
{item.subtitle}
</p>
)}
<div className="flex items-center gap-2 mt-2">
{getTypeBadge(item.type)}
<span className="text-xs text-gray-500 flex items-center gap-1">
<Clock size={10} />
{date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit'
})}
</span>
</div>
</div>
{/* Status */}
<div>
{item.hasFile ? (
<Badge variant="success" className="flex items-center gap-1">
<Check size={12} />
Available
</Badge>
) : (
<Badge variant="info" className="flex items-center gap-1">
<Clock size={12} />
Upcoming
</Badge>
)}
</div>
</motion.div>
))}
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,4 @@
export { default as CalendarView } from './CalendarView';
export { default as CalendarDay } from './CalendarDay';
export { default as UpcomingList } from './UpcomingList';

View File

@ -0,0 +1,294 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import {
Film,
Tv,
Music,
CheckCircle,
XCircle,
AlertCircle,
Eye,
EyeOff,
Trash2,
RefreshCw,
ExternalLink,
Plus,
} from 'lucide-react';
import { useIntegrationStore } from '../../stores/integrationStore';
import type { ServiceConnection } from '../../types/unified';
import Button from '../ui/Button';
import Input from '../ui/Input';
import Badge from '../ui/Badge';
interface ConnectionSetupProps {
type: 'radarr' | 'sonarr' | 'lidarr';
}
export default function ConnectionSetup({ type }: ConnectionSetupProps) {
const {
connections,
addConnection,
updateConnection,
removeConnection,
testConnection
} = useIntegrationStore();
const connection = connections.find((c) => c.type === type);
const [url, setUrl] = useState(connection?.url || '');
const [apiKey, setApiKey] = useState(connection?.apiKey || '');
const [showApiKey, setShowApiKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const getServiceInfo = () => {
switch (type) {
case 'radarr':
return {
name: 'Radarr',
icon: Film,
description: 'Movie collection manager',
defaultPort: 7878,
color: 'text-red-400',
bgColor: 'bg-red-500/10',
};
case 'sonarr':
return {
name: 'Sonarr',
icon: Tv,
description: 'TV series manager',
defaultPort: 8989,
color: 'text-blue-400',
bgColor: 'bg-blue-500/10',
};
case 'lidarr':
return {
name: 'Lidarr',
icon: Music,
description: 'Music collection manager',
defaultPort: 8686,
color: 'text-purple-400',
bgColor: 'bg-purple-500/10',
};
}
};
const info = getServiceInfo();
const Icon = info.icon;
const handleTest = async () => {
if (!url || !apiKey) {
setTestResult({ success: false, message: 'Please fill in all fields' });
return;
}
setTesting(true);
setTestResult(null);
try {
// Create or update connection first
let connId = connection?.id;
if (!connId) {
connId = addConnection({
type,
name: info.name,
url: url.replace(/\/$/, ''),
apiKey,
enabled: true,
});
} else {
updateConnection(connId, {
url: url.replace(/\/$/, ''),
apiKey,
});
}
// Test the connection
const result = await testConnection(connId);
if (result.success) {
setTestResult({ success: true, message: `Connected! Version: ${result.version}` });
} else {
setTestResult({ success: false, message: result.error || 'Connection failed' });
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection failed'
});
} finally {
setTesting(false);
}
};
const handleSave = async () => {
if (!connection) {
addConnection({
type,
name: info.name,
url: url.replace(/\/$/, ''),
apiKey,
enabled: true,
});
} else {
updateConnection(connection.id, {
url: url.replace(/\/$/, ''),
apiKey,
enabled: true,
});
}
await handleTest();
};
const handleRemove = () => {
if (connection && confirm(`Remove ${info.name} connection?`)) {
removeConnection(connection.id);
setUrl('');
setApiKey('');
setTestResult(null);
}
};
const handleToggleEnabled = () => {
if (connection) {
updateConnection(connection.id, { enabled: !connection.enabled });
}
};
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-4 rounded-lg border border-white/10 ${info.bgColor}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg bg-white/10 ${info.color}`}>
<Icon size={24} />
</div>
<div>
<h3 className="font-semibold text-white flex items-center gap-2">
{info.name}
{connection?.isConnected && (
<Badge size="sm" variant="success">Connected</Badge>
)}
{connection && !connection.enabled && (
<Badge size="sm" variant="default">Disabled</Badge>
)}
</h3>
<p className="text-sm text-gray-400">{info.description}</p>
</div>
</div>
{/* Status Icon */}
<div className="flex items-center gap-2">
{connection?.isConnected ? (
<CheckCircle className="text-green-500" size={20} />
) : connection ? (
<XCircle className="text-red-500" size={20} />
) : (
<AlertCircle className="text-gray-500" size={20} />
)}
</div>
</div>
{/* Form */}
<div className="space-y-3">
<Input
label="Server URL"
placeholder={`http://localhost:${info.defaultPort}`}
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<div className="relative">
<Input
label="API Key"
type={showApiKey ? 'text' : 'password'}
placeholder="Enter your API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-8 text-gray-400 hover:text-white transition-colors"
>
{showApiKey ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<p className="text-xs text-gray-500">
Find your API key in {info.name} Settings General Security
</p>
{/* Test Result */}
{testResult && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-3 rounded-lg flex items-center gap-2 ${
testResult.success ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`}
>
{testResult.success ? <CheckCircle size={16} /> : <XCircle size={16} />}
<span className="text-sm">{testResult.message}</span>
</motion.div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-2 pt-2">
<Button
onClick={handleTest}
disabled={testing || !url || !apiKey}
leftIcon={<RefreshCw size={16} className={testing ? 'animate-spin' : ''} />}
>
{testing ? 'Testing...' : 'Test Connection'}
</Button>
{connection && (
<>
<Button
variant="secondary"
onClick={handleToggleEnabled}
>
{connection.enabled ? 'Disable' : 'Enable'}
</Button>
<Button
variant="ghost"
onClick={handleRemove}
leftIcon={<Trash2 size={16} />}
className="text-red-400 hover:text-red-300"
>
Remove
</Button>
</>
)}
{!connection && url && apiKey && (
<Button
variant="secondary"
onClick={handleSave}
leftIcon={<Plus size={16} />}
>
Add Connection
</Button>
)}
</div>
{/* Help Link */}
<a
href={`https://${type}.tv`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors mt-2"
>
Learn more about {info.name}
<ExternalLink size={12} />
</a>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,99 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import {
CheckCircle,
XCircle,
AlertCircle,
RefreshCw,
Film,
Tv,
Music
} from 'lucide-react';
import { useIntegrationStore } from '../../stores/integrationStore';
import type { ServiceConnection } from '../../types/unified';
import Tooltip from '../ui/Tooltip';
interface ServiceStatusProps {
showLabels?: boolean;
size?: 'sm' | 'md';
}
export default function ServiceStatus({ showLabels = false, size = 'sm' }: ServiceStatusProps) {
const { connections, testAllConnections } = useIntegrationStore();
const [testing, setTesting] = useState(false);
const handleRefresh = async () => {
setTesting(true);
await testAllConnections();
setTesting(false);
};
const getStatusIcon = (conn: ServiceConnection) => {
const iconSize = size === 'sm' ? 14 : 18;
if (!conn.enabled) {
return <AlertCircle className="text-gray-500" size={iconSize} />;
}
if (conn.isConnected) {
return <CheckCircle className="text-green-500" size={iconSize} />;
}
return <XCircle className="text-red-500" size={iconSize} />;
};
const getServiceIcon = (type: ServiceConnection['type']) => {
const iconSize = size === 'sm' ? 16 : 20;
switch (type) {
case 'radarr':
return <Film size={iconSize} />;
case 'sonarr':
return <Tv size={iconSize} />;
case 'lidarr':
return <Music size={iconSize} />;
}
};
const getStatusText = (conn: ServiceConnection) => {
if (!conn.enabled) return 'Disabled';
if (conn.isConnected) return `Connected (v${conn.version || '?'})`;
if (conn.error) return conn.error;
return 'Disconnected';
};
if (connections.length === 0) {
return null;
}
return (
<div className="flex items-center gap-2">
{connections.map((conn) => (
<Tooltip key={conn.id} content={getStatusText(conn)}>
<motion.div
whileHover={{ scale: 1.05 }}
className={`flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/5 ${
size === 'md' ? 'px-3 py-1.5' : ''
}`}
>
{getServiceIcon(conn.type)}
{getStatusIcon(conn)}
{showLabels && (
<span className="text-sm capitalize">{conn.type}</span>
)}
</motion.div>
</Tooltip>
))}
<Tooltip content="Refresh connections">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={handleRefresh}
disabled={testing}
className={`p-1.5 hover:bg-white/10 rounded-full transition-colors ${
size === 'md' ? 'p-2' : ''
}`}
>
<RefreshCw size={size === 'sm' ? 14 : 18} className={testing ? 'animate-spin' : ''} />
</motion.button>
</Tooltip>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { default as ServiceStatus } from './ServiceStatus';
export { default as ConnectionSetup } from './ConnectionSetup';

View File

@ -0,0 +1,114 @@
import { Github, Twitter, Mail } from 'lucide-react';
export default function Footer() {
return (
<footer className="bg-netflix-dark-gray/50 border-t border-white/5 mt-16">
<div className="max-w-7xl mx-auto px-4 py-8 md:py-12">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{/* Logo & Description */}
<div className="col-span-2 md:col-span-1">
<h3 className="text-2xl font-bold text-netflix-red mb-4">beStream</h3>
<p className="text-gray-400 text-sm">
Stream and download your favorite movies in high quality. Available on all platforms.
</p>
</div>
{/* Browse */}
<div>
<h4 className="text-white font-semibold mb-4">Browse</h4>
<ul className="space-y-2 text-sm text-gray-400">
<li>
<a href="/browse/action" className="hover:text-white transition-colors">
Action
</a>
</li>
<li>
<a href="/browse/comedy" className="hover:text-white transition-colors">
Comedy
</a>
</li>
<li>
<a href="/browse/drama" className="hover:text-white transition-colors">
Drama
</a>
</li>
<li>
<a href="/browse/horror" className="hover:text-white transition-colors">
Horror
</a>
</li>
<li>
<a href="/browse/sci-fi" className="hover:text-white transition-colors">
Sci-Fi
</a>
</li>
</ul>
</div>
{/* Help */}
<div>
<h4 className="text-white font-semibold mb-4">Help</h4>
<ul className="space-y-2 text-sm text-gray-400">
<li>
<a href="/settings" className="hover:text-white transition-colors">
Settings
</a>
</li>
<li>
<a href="/downloads" className="hover:text-white transition-colors">
Downloads
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
FAQ
</a>
</li>
<li>
<a href="#" className="hover:text-white transition-colors">
Contact
</a>
</li>
</ul>
</div>
{/* Social */}
<div>
<h4 className="text-white font-semibold mb-4">Connect</h4>
<div className="flex gap-4">
<a
href="#"
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition-colors"
>
<Github size={20} />
</a>
<a
href="#"
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition-colors"
>
<Twitter size={20} />
</a>
<a
href="#"
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition-colors"
>
<Mail size={20} />
</a>
</div>
</div>
</div>
{/* Bottom */}
<div className="mt-8 pt-8 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-sm text-gray-500">
© {new Date().getFullYear()} beStream. For educational purposes only.
</p>
<p className="text-xs text-gray-600">
Data provided by YTS API. This app does not host any content.
</p>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,16 @@
import { Outlet } from 'react-router-dom';
import Navbar from './Navbar';
import Footer from './Footer';
export default function Layout() {
return (
<div className="min-h-screen bg-netflix-black flex flex-col">
<Navbar />
<main className="flex-1">
<Outlet />
</main>
<Footer />
</div>
);
}

View File

@ -0,0 +1,224 @@
import { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search,
Bell,
Menu,
X,
Home,
Film,
Tv,
Music,
Calendar,
Heart,
Clock,
Download,
Settings,
} from 'lucide-react';
import { clsx } from 'clsx';
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 },
];
export default function Navbar() {
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 50);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Close mobile menu on route change
useEffect(() => {
setIsMobileMenuOpen(false);
}, [location.pathname]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
setIsSearchOpen(false);
setSearchQuery('');
}
};
return (
<>
<nav
className={clsx(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300 safe-top',
isScrolled || isMobileMenuOpen
? 'bg-netflix-black/95 backdrop-blur-md shadow-lg'
: 'bg-gradient-to-b from-black/80 to-transparent'
)}
>
<div className="max-w-[1920px] mx-auto px-4 md:px-8">
<div className="flex items-center justify-between h-16 md:h-20">
{/* Logo */}
<Link to="/" className="flex items-center gap-2">
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="text-2xl md:text-3xl font-bold text-netflix-red"
>
beStream
</motion.div>
</Link>
{/* Desktop Navigation */}
<div className="hidden lg:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.path}
to={link.path}
className={clsx(
'text-sm font-medium transition-colors hover:text-white',
location.pathname === link.path
? 'text-white'
: 'text-gray-300'
)}
>
{link.label}
</Link>
))}
</div>
{/* Right Section */}
<div className="flex items-center gap-2 md:gap-4">
{/* Search */}
<AnimatePresence>
{isSearchOpen ? (
<motion.form
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
onSubmit={handleSearch}
className="flex items-center"
>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search movies..."
className="w-40 md:w-64 bg-netflix-dark-gray/80 border border-white/20 rounded-l-md py-2 px-4 text-white placeholder-gray-400 focus:outline-none focus:border-white/40"
autoFocus
/>
<button
type="button"
onClick={() => setIsSearchOpen(false)}
className="bg-netflix-dark-gray/80 border border-l-0 border-white/20 rounded-r-md py-2 px-3 hover:bg-netflix-medium-gray transition-colors"
>
<X size={20} />
</button>
</motion.form>
) : (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => setIsSearchOpen(true)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<Search size={20} />
</motion.button>
)}
</AnimatePresence>
{/* Notifications */}
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="hidden md:flex p-2 hover:bg-white/10 rounded-full transition-colors relative"
>
<Bell size={20} />
<span className="absolute top-1 right-1 w-2 h-2 bg-netflix-red rounded-full" />
</motion.button>
{/* Settings */}
<Link to="/settings">
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="hidden md:flex p-2 hover:bg-white/10 rounded-full transition-colors"
>
<Settings size={20} />
</motion.div>
</Link>
{/* Mobile Menu Button */}
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="lg:hidden p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</div>
</div>
</nav>
{/* Mobile Menu */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 top-16 z-40 bg-netflix-black/98 backdrop-blur-lg lg:hidden safe-top"
>
<div className="flex flex-col p-6 gap-2">
{navLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.path}
to={link.path}
className={clsx(
'flex items-center gap-4 p-4 rounded-lg transition-colors',
location.pathname === link.path
? 'bg-netflix-red text-white'
: 'text-gray-300 hover:bg-white/10'
)}
>
<Icon size={24} />
<span className="text-lg font-medium">{link.label}</span>
</Link>
);
})}
<Link
to="/settings"
className={clsx(
'flex items-center gap-4 p-4 rounded-lg transition-colors',
location.pathname === '/settings'
? 'bg-netflix-red text-white'
: 'text-gray-300 hover:bg-white/10'
)}
>
<Settings size={24} />
<span className="text-lg font-medium">Settings</span>
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@ -0,0 +1,4 @@
export { default as Layout } from './Layout';
export { default as Navbar } from './Navbar';
export { default as Footer } from './Footer';

View File

@ -0,0 +1,206 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Play, Info, Plus, Check, Star, Clock, Volume2, VolumeX } from 'lucide-react';
import type { Movie } from '../../types';
import { useWatchlistStore } from '../../stores/watchlistStore';
import Button from '../ui/Button';
interface HeroProps {
movies: Movie[];
autoRotate?: boolean;
rotateInterval?: number;
}
export default function Hero({
movies,
autoRotate = true,
rotateInterval = 8000,
}: HeroProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isMuted, setIsMuted] = useState(true);
const { isInWatchlist, addToWatchlist, removeFromWatchlist } = useWatchlistStore();
const currentMovie = movies[currentIndex];
const inWatchlist = currentMovie ? isInWatchlist(currentMovie.id) : false;
// Auto-rotate featured movies
useEffect(() => {
if (!autoRotate || movies.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % movies.length);
}, rotateInterval);
return () => clearInterval(interval);
}, [autoRotate, movies.length, rotateInterval]);
if (!currentMovie) return null;
const handleWatchlistClick = () => {
if (inWatchlist) {
removeFromWatchlist(currentMovie.id);
} else {
addToWatchlist(currentMovie);
}
};
return (
<div className="relative h-[85vh] md:h-[90vh] w-full overflow-hidden">
{/* Background Image */}
<AnimatePresence mode="wait">
<motion.div
key={currentMovie.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="absolute inset-0"
>
<img
src={currentMovie.background_image_original || currentMovie.background_image}
alt={currentMovie.title}
className="w-full h-full object-cover"
/>
{/* Gradient Overlays */}
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black via-netflix-black/60 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-transparent to-netflix-black/30" />
</motion.div>
</AnimatePresence>
{/* Content */}
<div className="absolute inset-0 flex items-center">
<div className="max-w-7xl mx-auto px-4 md:px-12 w-full pt-16">
<AnimatePresence mode="wait">
<motion.div
key={currentMovie.id}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -30 }}
transition={{ duration: 0.5 }}
className="max-w-2xl"
>
{/* Title */}
<motion.h1
className="text-4xl md:text-6xl lg:text-7xl font-bold text-white text-shadow mb-4"
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
{currentMovie.title}
</motion.h1>
{/* Meta Info */}
<motion.div
className="flex flex-wrap items-center gap-3 md:gap-4 mb-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
<span className="flex items-center gap-1 text-green-400 font-semibold">
<Star size={18} fill="currentColor" />
{currentMovie.rating} Rating
</span>
<span className="text-gray-300">{currentMovie.year}</span>
<span className="flex items-center gap-1 text-gray-300">
<Clock size={16} />
{currentMovie.runtime} min
</span>
{currentMovie.mpa_rating && (
<span className="px-2 py-0.5 border border-gray-400 text-sm text-gray-300 rounded">
{currentMovie.mpa_rating}
</span>
)}
</motion.div>
{/* Genres */}
<motion.div
className="flex flex-wrap gap-2 mb-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.35 }}
>
{currentMovie.genres?.map((genre) => (
<span
key={genre}
className="px-3 py-1 bg-white/10 backdrop-blur-sm rounded-full text-sm"
>
{genre}
</span>
))}
</motion.div>
{/* Description */}
<motion.p
className="text-gray-200 text-sm md:text-base line-clamp-3 mb-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
{currentMovie.synopsis || currentMovie.summary || currentMovie.description_full}
</motion.p>
{/* Actions */}
<motion.div
className="flex flex-wrap items-center gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<Link to={`/player/${currentMovie.id}`}>
<Button
size="lg"
leftIcon={<Play size={24} fill="white" />}
>
Play
</Button>
</Link>
<Link to={`/movie/${currentMovie.id}`}>
<Button variant="secondary" size="lg" leftIcon={<Info size={24} />}>
More Info
</Button>
</Link>
<Button
variant="icon"
size="lg"
onClick={handleWatchlistClick}
>
{inWatchlist ? <Check size={24} /> : <Plus size={24} />}
</Button>
</motion.div>
</motion.div>
</AnimatePresence>
</div>
</div>
{/* Bottom Controls */}
<div className="absolute bottom-8 right-8 flex items-center gap-4">
{/* Mute Button */}
<button
onClick={() => setIsMuted(!isMuted)}
className="p-2 rounded-full border border-white/40 hover:border-white transition-colors"
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
{/* Movie Indicators */}
{movies.length > 1 && (
<div className="flex gap-2">
{movies.slice(0, 5).map((_, index) => (
<button
key={index}
onClick={() => setCurrentIndex(index)}
className={`w-3 h-3 rounded-full transition-all duration-300 ${
index === currentIndex
? 'bg-netflix-red w-8'
: 'bg-white/40 hover:bg-white/60'
}`}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,171 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Play, Plus, Check, Star, Info } from 'lucide-react';
import { clsx } from 'clsx';
import type { Movie } from '../../types';
import { useWatchlistStore } from '../../stores/watchlistStore';
import { useHistoryStore } from '../../stores/historyStore';
interface MovieCardProps {
movie: Movie;
index?: number;
size?: 'sm' | 'md' | 'lg';
}
export default function MovieCard({ movie, index = 0, size = 'md' }: MovieCardProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const { isInWatchlist, addToWatchlist, removeFromWatchlist } = useWatchlistStore();
const { getProgress } = useHistoryStore();
const inWatchlist = isInWatchlist(movie.id);
const progress = getProgress(movie.id);
const watchProgress = progress ? (progress.progress / progress.duration) * 100 : 0;
const sizes = {
sm: 'w-[140px]',
md: 'w-[180px] md:w-[200px]',
lg: 'w-[220px] md:w-[260px]',
};
const handleWatchlistClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (inWatchlist) {
removeFromWatchlist(movie.id);
} else {
addToWatchlist(movie);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.05 }}
className={clsx('flex-shrink-0', sizes[size])}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Link to={`/movie/${movie.id}`}>
<motion.div
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.2 }}
className="relative aspect-[2/3] rounded-md overflow-hidden bg-netflix-dark-gray group cursor-pointer"
>
{/* Loading Skeleton */}
{!imageLoaded && (
<div className="absolute inset-0 shimmer" />
)}
{/* Movie Poster */}
<img
src={movie.medium_cover_image}
alt={movie.title}
className={clsx(
'w-full h-full object-cover transition-all duration-300',
imageLoaded ? 'opacity-100' : 'opacity-0',
isHovered && 'scale-110'
)}
onLoad={() => setImageLoaded(true)}
loading="lazy"
/>
{/* Watch Progress Bar */}
{watchProgress > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-600">
<div
className="h-full bg-netflix-red"
style={{ width: `${watchProgress}%` }}
/>
</div>
)}
{/* Hover Overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: isHovered ? 1 : 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-transparent flex flex-col justify-end p-3"
>
{/* Quick Actions */}
<div className="flex gap-2 mb-3">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="w-8 h-8 rounded-full bg-white flex items-center justify-center"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Play size={16} fill="black" className="text-black ml-0.5" />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={handleWatchlistClick}
className={clsx(
'w-8 h-8 rounded-full border-2 flex items-center justify-center transition-colors',
inWatchlist
? 'bg-white border-white'
: 'border-gray-400 hover:border-white'
)}
>
{inWatchlist ? (
<Check size={16} className="text-black" />
) : (
<Plus size={16} />
)}
</motion.button>
<Link
to={`/movie/${movie.id}`}
onClick={(e) => e.stopPropagation()}
className="w-8 h-8 rounded-full border-2 border-gray-400 hover:border-white flex items-center justify-center transition-colors"
>
<Info size={16} />
</Link>
</div>
{/* Movie Info */}
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-gray-300">
<span className="flex items-center gap-1 text-green-400">
<Star size={12} fill="currentColor" />
{movie.rating}
</span>
<span>{movie.year}</span>
<span>{movie.runtime} min</span>
</div>
<div className="flex flex-wrap gap-1">
{movie.genres?.slice(0, 2).map((genre) => (
<span
key={genre}
className="text-xs px-1.5 py-0.5 bg-white/20 rounded"
>
{genre}
</span>
))}
</div>
</div>
</motion.div>
{/* Quality Badge */}
{movie.torrents?.[0] && (
<div className="absolute top-2 right-2 px-2 py-0.5 bg-netflix-red text-xs font-bold rounded">
{movie.torrents[0].quality}
</div>
)}
</motion.div>
{/* Title */}
<h3 className="mt-2 text-sm font-medium text-white line-clamp-1 group-hover:text-netflix-red transition-colors">
{movie.title}
</h3>
<p className="text-xs text-gray-400">{movie.year}</p>
</Link>
</motion.div>
);
}

View File

@ -0,0 +1,50 @@
import { motion } from 'framer-motion';
import type { Movie } from '../../types';
import MovieCard from './MovieCard';
import { MovieCardSkeleton } from '../ui/Skeleton';
interface MovieGridProps {
movies: Movie[];
isLoading?: boolean;
skeletonCount?: number;
}
export default function MovieGrid({
movies,
isLoading = false,
skeletonCount = 20,
}: MovieGridProps) {
if (isLoading) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6">
{Array.from({ length: skeletonCount }).map((_, i) => (
<MovieCardSkeleton key={i} />
))}
</div>
);
}
if (!movies.length) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-16"
>
<p className="text-xl text-gray-400">No movies found</p>
<p className="text-sm text-gray-500 mt-2">
Try adjusting your search or filters
</p>
</motion.div>
);
}
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6">
{movies.map((movie, index) => (
<MovieCard key={movie.id} movie={movie} index={index} />
))}
</div>
);
}

View File

@ -0,0 +1,144 @@
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { clsx } from 'clsx';
import type { Movie } from '../../types';
import MovieCard from './MovieCard';
import { MovieCardSkeleton } from '../ui/Skeleton';
interface MovieRowProps {
title: string;
movies: Movie[];
isLoading?: boolean;
linkTo?: string;
cardSize?: 'sm' | 'md' | 'lg';
}
export default function MovieRow({
title,
movies,
isLoading = false,
linkTo,
cardSize = 'md',
}: MovieRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(true);
const handleScroll = () => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeftArrow(scrollLeft > 0);
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 10);
}
};
const scroll = (direction: 'left' | 'right') => {
if (scrollRef.current) {
const scrollAmount = scrollRef.current.clientWidth * 0.8;
scrollRef.current.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth',
});
}
};
if (isLoading) {
return (
<div className="mb-8 md:mb-12">
<div className="shimmer w-48 h-6 mb-4 rounded" />
<div className="flex gap-3 md:gap-4 overflow-hidden">
{Array.from({ length: 7 }).map((_, i) => (
<MovieCardSkeleton key={i} />
))}
</div>
</div>
);
}
if (!movies.length) return null;
return (
<div className="mb-8 md:mb-12 group/row">
{/* Header */}
<div className="flex items-center justify-between mb-3 md:mb-4 px-4 md:px-12">
{linkTo ? (
<Link
to={linkTo}
className="flex items-center gap-2 group/title"
>
<h2 className="text-lg md:text-xl font-semibold text-white">
{title}
</h2>
<motion.span
className="text-netflix-red opacity-0 group-hover/title:opacity-100 transition-opacity"
initial={{ x: -10 }}
whileHover={{ x: 0 }}
>
<ChevronRight size={20} />
</motion.span>
</Link>
) : (
<h2 className="text-lg md:text-xl font-semibold text-white">{title}</h2>
)}
</div>
{/* Scrollable Row */}
<div className="relative">
{/* Left Arrow */}
<button
onClick={() => scroll('left')}
className={clsx(
'absolute left-0 top-0 bottom-0 z-10 w-12 md:w-16 bg-gradient-to-r from-netflix-black to-transparent',
'flex items-center justify-start pl-2 opacity-0 group-hover/row:opacity-100 transition-opacity',
!showLeftArrow && 'hidden'
)}
>
<motion.div
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.9 }}
className="w-10 h-10 rounded-full bg-black/80 flex items-center justify-center"
>
<ChevronLeft size={24} />
</motion.div>
</button>
{/* Movies Container */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex gap-3 md:gap-4 overflow-x-auto hide-scrollbar px-4 md:px-12 py-2"
>
{movies.map((movie, index) => (
<MovieCard
key={movie.id}
movie={movie}
index={index}
size={cardSize}
/>
))}
</div>
{/* Right Arrow */}
<button
onClick={() => scroll('right')}
className={clsx(
'absolute right-0 top-0 bottom-0 z-10 w-12 md:w-16 bg-gradient-to-l from-netflix-black to-transparent',
'flex items-center justify-end pr-2 opacity-0 group-hover/row:opacity-100 transition-opacity',
!showRightArrow && 'hidden'
)}
>
<motion.div
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.9 }}
className="w-10 h-10 rounded-full bg-black/80 flex items-center justify-center"
>
<ChevronRight size={24} />
</motion.div>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,218 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Download, Copy, ExternalLink, Play, HardDrive, Users, Check } from 'lucide-react';
import type { Movie, Torrent } from '../../types';
import { generateMagnetUri, copyMagnetLink, openInExternalClient } from '../../services/torrent/webtorrent';
import Button from '../ui/Button';
import Badge from '../ui/Badge';
interface TorrentSelectorProps {
movie: Movie;
onStream: (torrent: Torrent) => void;
onDownload: (torrent: Torrent) => void;
}
export default function TorrentSelector({
movie,
onStream,
onDownload,
}: TorrentSelectorProps) {
const [selectedTorrent, setSelectedTorrent] = useState<Torrent | null>(
movie.torrents?.[0] || null
);
const [copiedHash, setCopiedHash] = useState<string | null>(null);
const handleCopyMagnet = async (torrent: Torrent) => {
await copyMagnetLink(movie, torrent);
setCopiedHash(torrent.hash);
setTimeout(() => setCopiedHash(null), 2000);
};
const handleOpenExternal = (torrent: Torrent) => {
openInExternalClient(movie, torrent);
};
if (!movie.torrents?.length) {
return (
<div className="p-6 glass rounded-lg text-center">
<p className="text-gray-400">No torrents available for this movie</p>
</div>
);
}
// Group torrents by quality
const torrentsByQuality = movie.torrents.reduce((acc, torrent) => {
if (!acc[torrent.quality]) {
acc[torrent.quality] = [];
}
acc[torrent.quality].push(torrent);
return acc;
}, {} as Record<string, Torrent[]>);
return (
<div className="space-y-4">
{/* Quality Selection */}
<div>
<h3 className="text-lg font-semibold mb-3">Select Quality</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(torrentsByQuality).map(([quality, torrents]) => {
const torrent = torrents[0];
const isSelected = selectedTorrent?.hash === torrent.hash;
return (
<motion.button
key={quality}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setSelectedTorrent(torrent)}
className={`p-4 rounded-lg border-2 transition-all ${
isSelected
? 'border-netflix-red bg-netflix-red/10'
: 'border-white/10 bg-white/5 hover:border-white/30'
}`}
>
<div className="text-xl font-bold mb-1">{quality}</div>
<div className="text-sm text-gray-400 mb-2">{torrent.size}</div>
<div className="flex items-center justify-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Users size={12} />
{torrent.seeds + torrent.peers}
</span>
<span>{torrent.type}</span>
</div>
</motion.button>
);
})}
</div>
</div>
{/* Selected Torrent Details */}
{selectedTorrent && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-4 glass rounded-lg"
>
<div className="flex flex-wrap items-center gap-3 mb-4">
<Badge variant="success">
{selectedTorrent.seeds} Seeds
</Badge>
<Badge variant="info">
{selectedTorrent.peers} Peers
</Badge>
<Badge>
{selectedTorrent.video_codec}
</Badge>
{selectedTorrent.audio_channels && (
<Badge>
{selectedTorrent.audio_channels} Audio
</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-3 mb-4 text-sm">
<div className="flex items-center gap-2 text-gray-400">
<HardDrive size={16} />
<span>Size: {selectedTorrent.size}</span>
</div>
<div className="flex items-center gap-2 text-gray-400">
<span>Type: {selectedTorrent.type}</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3">
<Button
onClick={() => onStream(selectedTorrent)}
leftIcon={<Play size={18} />}
>
Stream Now
</Button>
<Button
variant="secondary"
onClick={() => onDownload(selectedTorrent)}
leftIcon={<Download size={18} />}
>
Download
</Button>
<Button
variant="ghost"
onClick={() => handleCopyMagnet(selectedTorrent)}
leftIcon={
copiedHash === selectedTorrent.hash ? (
<Check size={18} />
) : (
<Copy size={18} />
)
}
>
{copiedHash === selectedTorrent.hash ? 'Copied!' : 'Copy Magnet'}
</Button>
<Button
variant="ghost"
onClick={() => handleOpenExternal(selectedTorrent)}
leftIcon={<ExternalLink size={18} />}
>
Open External
</Button>
</div>
</motion.div>
)}
{/* All Torrents Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400 border-b border-white/10">
<th className="pb-2 pr-4">Quality</th>
<th className="pb-2 pr-4">Type</th>
<th className="pb-2 pr-4">Size</th>
<th className="pb-2 pr-4">Seeds</th>
<th className="pb-2 pr-4">Peers</th>
<th className="pb-2">Actions</th>
</tr>
</thead>
<tbody>
{movie.torrents.map((torrent) => (
<tr
key={torrent.hash}
className="border-b border-white/5 hover:bg-white/5"
>
<td className="py-3 pr-4">
<Badge size="sm">{torrent.quality}</Badge>
</td>
<td className="py-3 pr-4 text-gray-300">{torrent.type}</td>
<td className="py-3 pr-4 text-gray-300">{torrent.size}</td>
<td className="py-3 pr-4 text-green-400">{torrent.seeds}</td>
<td className="py-3 pr-4 text-blue-400">{torrent.peers}</td>
<td className="py-3">
<div className="flex gap-2">
<button
onClick={() => handleCopyMagnet(torrent)}
className="p-1 hover:bg-white/10 rounded"
title="Copy magnet link"
>
{copiedHash === torrent.hash ? (
<Check size={16} className="text-green-400" />
) : (
<Copy size={16} />
)}
</button>
<button
onClick={() => handleOpenExternal(torrent)}
className="p-1 hover:bg-white/10 rounded"
title="Open in external client"
>
<ExternalLink size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
export { default as MovieCard } from './MovieCard';
export { default as MovieRow } from './MovieRow';
export { default as MovieGrid } from './MovieGrid';
export { default as Hero } from './Hero';
export { default as TorrentSelector } from './TorrentSelector';

View File

@ -0,0 +1,109 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Play, Disc, Calendar } from 'lucide-react';
import type { UnifiedAlbum } from '../../types/unified';
import Badge from '../ui/Badge';
interface AlbumCardProps {
album: UnifiedAlbum;
showArtist?: boolean;
className?: string;
}
export default function AlbumCard({ album, showArtist = true, className = '' }: AlbumCardProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const releaseYear = album.releaseDate
? new Date(album.releaseDate).getFullYear()
: album.year;
return (
<Link to={`/music/album/${album.id}`}>
<motion.div
whileHover={{ scale: 1.03, y: -3 }}
whileTap={{ scale: 0.98 }}
className={`group relative rounded-lg overflow-hidden bg-netflix-dark-gray cursor-pointer ${className}`}
>
{/* Cover */}
<div className="aspect-square relative">
{!imageLoaded && !imageError && (
<div className="absolute inset-0 bg-netflix-medium-gray animate-pulse" />
)}
{imageError ? (
<div className="absolute inset-0 bg-gradient-to-br from-indigo-900 to-purple-900 flex items-center justify-center">
<Disc size={48} className="text-white/50" />
</div>
) : (
<img
src={album.poster || '/placeholder-album.png'}
alt={album.title}
className={`w-full h-full object-cover transition-all duration-300 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
loading="lazy"
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
)}
{/* Overlay */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="w-12 h-12 rounded-full bg-green-500 flex items-center justify-center shadow-lg hover:bg-green-400 transition-colors"
>
<Play size={24} className="text-black ml-1" fill="black" />
</motion.button>
</div>
{/* Track count badge */}
{album.trackFileCount > 0 && (
<div className="absolute bottom-2 right-2">
<Badge
size="sm"
variant={album.trackFileCount === album.trackCount ? 'success' : 'warning'}
className="flex items-center gap-1"
>
{album.trackFileCount}/{album.trackCount}
</Badge>
</div>
)}
{/* Album type badge */}
{album.albumType && (
<div className="absolute top-2 left-2">
<Badge size="sm" className="bg-black/70 capitalize">
{album.albumType}
</Badge>
</div>
)}
</div>
{/* Info */}
<div className="p-3">
<h3 className="font-medium text-white line-clamp-1 group-hover:text-green-400 transition-colors">
{album.title}
</h3>
{showArtist && (
<p className="text-sm text-gray-400 line-clamp-1 mt-0.5">
{album.artistName}
</p>
)}
<div className="flex items-center justify-between mt-2 text-xs text-gray-500">
{releaseYear && (
<span className="flex items-center gap-1">
<Calendar size={10} />
{releaseYear}
</span>
)}
<span>{album.trackCount} tracks</span>
</div>
</div>
</motion.div>
</Link>
);
}

View File

@ -0,0 +1,56 @@
import { motion } from 'framer-motion';
import type { UnifiedAlbum } from '../../types/unified';
import AlbumCard from './AlbumCard';
import { AlbumCardSkeleton } from '../ui/Skeleton';
interface AlbumGridProps {
albums: UnifiedAlbum[];
isLoading?: boolean;
showArtist?: boolean;
emptyMessage?: string;
}
export default function AlbumGrid({
albums,
isLoading = false,
showArtist = true,
emptyMessage = 'No albums found',
}: AlbumGridProps) {
if (isLoading) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{Array.from({ length: 12 }).map((_, i) => (
<AlbumCardSkeleton key={i} />
))}
</div>
);
}
if (albums.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-lg">{emptyMessage}</p>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
>
{albums.map((album, index) => (
<motion.div
key={album.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<AlbumCard album={album} showArtist={showArtist} />
</motion.div>
))}
</motion.div>
);
}

View File

@ -0,0 +1,92 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Play, Music, Disc } from 'lucide-react';
import type { UnifiedArtist } from '../../types/unified';
import Badge from '../ui/Badge';
interface ArtistCardProps {
artist: UnifiedArtist;
className?: string;
}
export default function ArtistCard({ artist, className = '' }: ArtistCardProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
return (
<Link to={`/music/artist/${artist.id}`}>
<motion.div
whileHover={{ scale: 1.05, y: -5 }}
whileTap={{ scale: 0.98 }}
className={`group relative rounded-lg overflow-hidden bg-netflix-dark-gray cursor-pointer ${className}`}
>
{/* Image */}
<div className="aspect-square relative">
{!imageLoaded && !imageError && (
<div className="absolute inset-0 bg-netflix-medium-gray animate-pulse" />
)}
{imageError ? (
<div className="absolute inset-0 bg-gradient-to-br from-purple-900 to-pink-900 flex items-center justify-center">
<Music size={48} className="text-white/50" />
</div>
) : (
<img
src={artist.poster || artist.fanart || '/placeholder-artist.png'}
alt={artist.title}
className={`w-full h-full object-cover transition-all duration-300 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
} group-hover:scale-110`}
loading="lazy"
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
)}
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{/* Play Button */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="w-14 h-14 rounded-full bg-green-500 flex items-center justify-center shadow-lg hover:bg-green-400 transition-colors"
>
<Play size={28} className="text-black ml-1" fill="black" />
</motion.button>
</div>
{/* Album count badge */}
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Badge size="sm" className="bg-black/70 flex items-center gap-1">
<Disc size={10} />
{artist.albumCount} albums
</Badge>
</div>
</div>
{/* Info */}
<div className="p-3">
<h3 className="font-medium text-white line-clamp-1 group-hover:text-green-400 transition-colors">
{artist.title}
</h3>
<div className="flex items-center justify-between mt-1 text-sm text-gray-400">
<span className="capitalize">{artist.artistType || 'Artist'}</span>
<span>{artist.trackCount} tracks</span>
</div>
{artist.genres && artist.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{artist.genres.slice(0, 2).map((genre) => (
<Badge key={genre} size="sm" className="bg-white/10">
{genre}
</Badge>
))}
</div>
)}
</div>
</motion.div>
</Link>
);
}

View File

@ -0,0 +1,54 @@
import { motion } from 'framer-motion';
import type { UnifiedArtist } from '../../types/unified';
import ArtistCard from './ArtistCard';
import { ArtistCardSkeleton } from '../ui/Skeleton';
interface ArtistGridProps {
artists: UnifiedArtist[];
isLoading?: boolean;
emptyMessage?: string;
}
export default function ArtistGrid({
artists,
isLoading = false,
emptyMessage = 'No artists found',
}: ArtistGridProps) {
if (isLoading) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{Array.from({ length: 12 }).map((_, i) => (
<ArtistCardSkeleton key={i} />
))}
</div>
);
}
if (artists.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-lg">{emptyMessage}</p>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
>
{artists.map((artist, index) => (
<motion.div
key={artist.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<ArtistCard artist={artist} />
</motion.div>
))}
</motion.div>
);
}

View File

@ -0,0 +1,199 @@
import { motion, AnimatePresence } from 'framer-motion';
import {
Play,
Pause,
SkipBack,
SkipForward,
Volume2,
VolumeX,
Maximize2,
X
} from 'lucide-react';
import type { UnifiedTrack } from '../../types/unified';
import ProgressBar from '../ui/ProgressBar';
interface MiniPlayerProps {
track: UnifiedTrack | null;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
onPlayPause: () => void;
onPrevious: () => void;
onNext: () => void;
onSeek: (time: number) => void;
onVolumeChange: (volume: number) => void;
onMuteToggle: () => void;
onExpand: () => void;
onClose: () => void;
}
export default function MiniPlayer({
track,
isPlaying,
currentTime,
duration,
volume,
isMuted,
onPlayPause,
onPrevious,
onNext,
onSeek,
onVolumeChange,
onMuteToggle,
onExpand,
onClose,
}: MiniPlayerProps) {
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<AnimatePresence>
{track && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-0 left-0 right-0 z-50 bg-netflix-dark-gray/95 backdrop-blur-lg border-t border-white/10 safe-bottom"
>
{/* Progress Bar (clickable) */}
<div
className="absolute top-0 left-0 right-0 h-1 bg-white/10 cursor-pointer group"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
onSeek(percent * duration);
}}
>
<div
className="h-full bg-green-500 transition-all group-hover:h-1.5"
style={{ width: `${progress}%` }}
/>
</div>
<div className="max-w-7xl mx-auto px-4 py-3">
<div className="flex items-center gap-4">
{/* Track Info */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<motion.div
animate={isPlaying ? { rotate: 360 } : {}}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0"
>
{track.poster ? (
<img
src={track.poster}
alt={track.albumName}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-green-500 to-blue-600" />
)}
</motion.div>
<div className="min-w-0">
<h4 className="font-medium text-white line-clamp-1">
{track.title}
</h4>
<p className="text-sm text-gray-400 line-clamp-1">
{track.artistName}
</p>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onPrevious}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<SkipBack size={20} fill="white" />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onPlayPause}
className="w-10 h-10 rounded-full bg-white flex items-center justify-center hover:scale-105 transition-transform"
>
{isPlaying ? (
<Pause size={20} className="text-black" fill="black" />
) : (
<Play size={20} className="text-black ml-0.5" fill="black" />
)}
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onNext}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<SkipForward size={20} fill="white" />
</motion.button>
</div>
{/* Time */}
<div className="hidden md:flex items-center gap-2 text-sm text-gray-400 w-32">
<span>{formatTime(currentTime)}</span>
<span>/</span>
<span>{formatTime(duration)}</span>
</div>
{/* Volume */}
<div className="hidden lg:flex items-center gap-2">
<motion.button
whileHover={{ scale: 1.1 }}
onClick={onMuteToggle}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isMuted || volume === 0 ? (
<VolumeX size={18} />
) : (
<Volume2 size={18} />
)}
</motion.button>
<input
type="range"
min="0"
max="100"
value={isMuted ? 0 : volume}
onChange={(e) => onVolumeChange(parseInt(e.target.value))}
className="w-20 h-1 accent-green-500 cursor-pointer"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<motion.button
whileHover={{ scale: 1.1 }}
onClick={onExpand}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
title="Expand player"
>
<Maximize2 size={18} />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
onClick={onClose}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
title="Close player"
>
<X size={18} />
</motion.button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,170 @@
import { motion } from 'framer-motion';
import { Play, Pause, Download, Check, Clock, MoreHorizontal } from 'lucide-react';
import type { UnifiedTrack } from '../../types/unified';
import Badge from '../ui/Badge';
import { formatDuration, formatBytes } from '../../utils/helpers';
interface TrackListProps {
tracks: UnifiedTrack[];
currentTrackId?: string;
isPlaying?: boolean;
showAlbum?: boolean;
showArtist?: boolean;
onTrackClick?: (track: UnifiedTrack) => void;
onTrackDownload?: (track: UnifiedTrack) => void;
}
export default function TrackList({
tracks,
currentTrackId,
isPlaying = false,
showAlbum = false,
showArtist = true,
onTrackClick,
onTrackDownload,
}: TrackListProps) {
const sortedTracks = [...tracks].sort((a, b) => {
if (a.discNumber !== b.discNumber) {
return (a.discNumber || 1) - (b.discNumber || 1);
}
return a.trackNumber - b.trackNumber;
});
if (tracks.length === 0) {
return (
<div className="p-6 text-center text-gray-400">
No tracks found
</div>
);
}
// Group by disc if there are multiple discs
const hasMultipleDiscs = tracks.some((t) => t.discNumber && t.discNumber > 1);
const groupedTracks = hasMultipleDiscs
? sortedTracks.reduce((acc, track) => {
const disc = track.discNumber || 1;
if (!acc[disc]) acc[disc] = [];
acc[disc].push(track);
return acc;
}, {} as Record<number, UnifiedTrack[]>)
: { 1: sortedTracks };
return (
<div className="divide-y divide-white/5">
{Object.entries(groupedTracks).map(([discNumber, discTracks]) => (
<div key={discNumber}>
{hasMultipleDiscs && (
<div className="px-4 py-2 bg-white/5">
<span className="text-sm font-medium text-gray-400">
Disc {discNumber}
</span>
</div>
)}
{discTracks.map((track, index) => {
const isCurrentTrack = currentTrackId === track.id;
const isTrackPlaying = isCurrentTrack && isPlaying;
return (
<motion.div
key={track.id}
whileHover={{ backgroundColor: 'rgba(255, 255, 255, 0.05)' }}
className={`group flex items-center gap-4 px-4 py-3 cursor-pointer ${
isCurrentTrack ? 'bg-white/5' : ''
}`}
onClick={() => track.hasFile && onTrackClick?.(track)}
>
{/* Track Number / Play Icon */}
<div className="w-8 flex-shrink-0 text-center">
{isCurrentTrack ? (
<motion.div
animate={isTrackPlaying ? { scale: [1, 1.2, 1] } : {}}
transition={{ repeat: Infinity, duration: 1 }}
className="text-green-500"
>
{isTrackPlaying ? (
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} fill="currentColor" />
)}
</motion.div>
) : (
<>
<span className="text-gray-400 group-hover:hidden">
{track.trackNumber}
</span>
<Play
size={16}
className="text-white hidden group-hover:block mx-auto"
fill="currentColor"
/>
</>
)}
</div>
{/* Track Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className={`font-medium line-clamp-1 ${
isCurrentTrack ? 'text-green-500' : 'text-white'
}`}>
{track.title}
</h4>
{track.explicit && (
<Badge size="sm" className="bg-white/20 text-[10px] px-1">
E
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
{showArtist && <span>{track.artistName}</span>}
{showArtist && showAlbum && <span></span>}
{showAlbum && <span>{track.albumName}</span>}
</div>
</div>
{/* Status */}
<div className="flex-shrink-0 flex items-center gap-3">
{track.hasFile ? (
<Badge size="sm" variant="success" className="hidden sm:flex items-center gap-1">
<Check size={10} />
</Badge>
) : (
onTrackDownload && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => {
e.stopPropagation();
onTrackDownload(track);
}}
className="p-1.5 hover:bg-white/10 rounded-full transition-colors"
title="Download track"
>
<Download size={14} />
</motion.button>
)
)}
{/* Duration */}
<span className="text-sm text-gray-400 w-12 text-right">
{formatDuration(track.duration / 1000)}
</span>
{/* More Options */}
<motion.button
whileHover={{ scale: 1.1 }}
className="p-1.5 opacity-0 group-hover:opacity-100 hover:bg-white/10 rounded-full transition-all"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal size={16} />
</motion.button>
</div>
</motion.div>
);
})}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,7 @@
export { default as ArtistCard } from './ArtistCard';
export { default as ArtistGrid } from './ArtistGrid';
export { default as AlbumCard } from './AlbumCard';
export { default as AlbumGrid } from './AlbumGrid';
export { default as TrackList } from './TrackList';
export { default as MiniPlayer } from './MiniPlayer';

View File

@ -0,0 +1,599 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import Hls from 'hls.js';
import {
Play,
Pause,
Volume2,
VolumeX,
Maximize,
Minimize,
Settings,
SkipBack,
SkipForward,
ArrowLeft,
Loader2,
Wifi,
Users,
HardDrive,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { clsx } from 'clsx';
import type { Movie } from '../../types';
import type { StreamSession } from '../../services/streaming/streamingService';
interface StreamingPlayerProps {
movie: Movie;
streamUrl: string;
hlsUrl?: string;
streamSession?: StreamSession | null;
onTimeUpdate?: (currentTime: number, duration: number) => void;
initialTime?: number;
}
export default function StreamingPlayer({
movie,
streamUrl,
hlsUrl,
streamSession,
onTimeUpdate,
initialTime = 0,
}: StreamingPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
const currentSourceRef = useRef<string | null>(null); // Track current source to avoid re-setting
const navigate = useNavigate();
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBuffering] = useState(true);
const [buffered, setBuffered] = useState(0);
const [quality, setQuality] = useState('auto');
const [showSettings, setShowSettings] = useState(false);
// Format time to MM:SS or HH:MM:SS
const formatTime = (seconds: number): string => {
if (!isFinite(seconds)) return '0:00';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Format bytes
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
// Format speed
const formatSpeed = (bytesPerSecond: number): string => {
return formatBytes(bytesPerSecond) + '/s';
};
// Initialize HLS or direct video
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const useHls = hlsUrl && Hls.isSupported();
if (useHls) {
// Skip if we already have this HLS source loaded
if (currentSourceRef.current === hlsUrl && hlsRef.current) {
return;
}
console.log('Using HLS playback');
currentSourceRef.current = hlsUrl;
// Cleanup previous HLS instance
if (hlsRef.current) {
hlsRef.current.destroy();
}
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
});
hls.loadSource(hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setIsBuffering(false);
if (initialTime > 0) {
video.currentTime = initialTime;
}
video.play().catch(console.error);
});
hls.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS error:', data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
hls.destroy();
break;
}
}
});
hlsRef.current = hls;
return () => {
hls.destroy();
hlsRef.current = null;
};
} else if (video.canPlayType('application/vnd.apple.mpegurl') && hlsUrl) {
// Safari native HLS - skip if same source
if (currentSourceRef.current === hlsUrl) {
return;
}
currentSourceRef.current = hlsUrl;
video.src = hlsUrl;
video.play().catch(console.error);
} else if (streamUrl) {
// Direct video stream - skip if same source
if (currentSourceRef.current === streamUrl) {
return;
}
console.log('Using direct video playback:', streamUrl);
currentSourceRef.current = streamUrl;
video.src = streamUrl;
// Set up event handlers only once
const handleCanPlay = () => {
console.log('Video can play, starting...');
setIsBuffering(false);
video.play().catch((err) => {
console.error('Auto-play failed:', err);
// Some browsers block autoplay, that's ok - user can click play
});
};
const handleError = () => {
console.error('Video error:', video.error);
setIsBuffering(false);
};
video.addEventListener('canplay', handleCanPlay, { once: true });
video.addEventListener('error', handleError, { once: true });
return () => {
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('error', handleError);
};
}
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
}
};
}, [streamUrl, hlsUrl, initialTime]);
// Show/hide controls
const handleMouseMove = useCallback(() => {
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
controlsTimeoutRef.current = setTimeout(() => {
if (isPlaying) {
setShowControls(false);
}
}, 3000);
}, [isPlaying]);
// Video event handlers
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
onTimeUpdate?.(video.currentTime, video.duration);
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
};
const handleProgress = () => {
if (video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
setBuffered((bufferedEnd / video.duration) * 100);
}
};
const handleWaiting = () => setIsBuffering(true);
const handleCanPlay = () => setIsBuffering(false);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('progress', handleProgress);
video.addEventListener('waiting', handleWaiting);
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('progress', handleProgress);
video.removeEventListener('waiting', handleWaiting);
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
}, [onTimeUpdate]);
// Keyboard controls
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const video = videoRef.current;
if (!video) return;
switch (e.key) {
case ' ':
case 'k':
e.preventDefault();
togglePlay();
break;
case 'ArrowLeft':
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 10);
break;
case 'ArrowRight':
e.preventDefault();
video.currentTime = Math.min(duration, video.currentTime + 10);
break;
case 'ArrowUp':
e.preventDefault();
setVolume((v) => Math.min(1, v + 0.1));
break;
case 'ArrowDown':
e.preventDefault();
setVolume((v) => Math.max(0, v - 0.1));
break;
case 'm':
e.preventDefault();
setIsMuted((m) => !m);
break;
case 'f':
e.preventDefault();
toggleFullscreen();
break;
case 'Escape':
if (isFullscreen) {
document.exitFullscreen();
} else {
navigate(-1);
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [duration, isFullscreen, navigate]);
// Update volume
useEffect(() => {
if (videoRef.current) {
videoRef.current.volume = volume;
videoRef.current.muted = isMuted;
}
}, [volume, isMuted]);
// Fullscreen change
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
}
};
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
containerRef.current?.requestFullscreen();
} else {
document.exitFullscreen();
}
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
if (videoRef.current) {
videoRef.current.currentTime = pos * duration;
}
};
const skip = (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime = Math.max(
0,
Math.min(duration, videoRef.current.currentTime + seconds)
);
}
};
return (
<div
ref={containerRef}
className="relative w-full h-full bg-black"
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
{/* Video Element */}
<video
ref={videoRef}
className="w-full h-full"
onClick={togglePlay}
playsInline
/>
{/* Buffering Indicator */}
<AnimatePresence>
{isBuffering && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 flex flex-col items-center justify-center bg-black/50"
>
<Loader2 size={48} className="animate-spin text-netflix-red mb-4" />
{streamSession && streamSession.status === 'downloading' && (
<div className="text-center">
<p className="text-lg mb-2">Buffering...</p>
<p className="text-sm text-gray-400">
{(streamSession.progress * 100).toFixed(1)}% downloaded
</p>
</div>
)}
</motion.div>
)}
</AnimatePresence>
{/* Controls Overlay */}
<AnimatePresence>
{showControls && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 flex flex-col justify-between"
>
{/* Top Bar */}
<div className="bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center gap-4">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<ArrowLeft size={24} />
</button>
<div className="flex-1">
<h1 className="text-xl font-semibold">{movie.title}</h1>
<p className="text-sm text-gray-400">{movie.year}</p>
</div>
{/* Stream Stats */}
{streamSession && (
<div className="flex items-center gap-4 text-xs text-gray-400">
<span className="flex items-center gap-1">
<Wifi size={14} className="text-green-400" />
{formatSpeed(streamSession.downloadSpeed)}
</span>
<span className="flex items-center gap-1">
<Users size={14} />
{streamSession.peers}
</span>
<span className="flex items-center gap-1">
<HardDrive size={14} />
{formatBytes(streamSession.downloaded)}
</span>
</div>
)}
</div>
{/* Center Play Button */}
<div className="flex-1 flex items-center justify-center">
{!isBuffering && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={togglePlay}
className="w-20 h-20 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center"
>
{isPlaying ? (
<Pause size={40} fill="white" />
) : (
<Play size={40} fill="white" className="ml-1" />
)}
</motion.button>
)}
</div>
{/* Bottom Controls */}
<div className="bg-gradient-to-t from-black/80 to-transparent p-4 space-y-3">
{/* Download Progress (if still downloading) */}
{streamSession && streamSession.progress < 1 && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<div className="flex-1 h-1 bg-white/20 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${streamSession.progress * 100}%` }}
/>
</div>
<span>{(streamSession.progress * 100).toFixed(0)}% buffered</span>
</div>
)}
{/* Progress Bar */}
<div
className="relative h-1 bg-white/20 rounded-full cursor-pointer group"
onClick={handleSeek}
>
{/* Buffered */}
<div
className="absolute h-full bg-white/30 rounded-full"
style={{ width: `${buffered}%` }}
/>
{/* Progress */}
<div
className="absolute h-full bg-netflix-red rounded-full"
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
/>
{/* Scrubber */}
<div
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-netflix-red rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
style={{ left: `calc(${(currentTime / duration) * 100 || 0}% - 8px)` }}
/>
</div>
{/* Controls Row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Play/Pause */}
<button
onClick={togglePlay}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button>
{/* Skip Back */}
<button
onClick={() => skip(-10)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<SkipBack size={20} />
</button>
{/* Skip Forward */}
<button
onClick={() => skip(10)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<SkipForward size={20} />
</button>
{/* Volume */}
<div className="flex items-center gap-2 group/vol">
<button
onClick={() => setIsMuted(!isMuted)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isMuted || volume === 0 ? (
<VolumeX size={20} />
) : (
<Volume2 size={20} />
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={isMuted ? 0 : volume}
onChange={(e) => {
setVolume(parseFloat(e.target.value));
setIsMuted(false);
}}
className="w-20 accent-netflix-red opacity-0 group-hover/vol:opacity-100 transition-opacity"
/>
</div>
{/* Time */}
<span className="text-sm ml-2">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="flex items-center gap-2">
{/* Settings */}
<div className="relative">
<button
onClick={() => setShowSettings(!showSettings)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<Settings size={20} />
</button>
{showSettings && (
<div className="absolute bottom-full right-0 mb-2 p-2 bg-black/90 rounded-lg min-w-[150px]">
<p className="text-xs text-gray-400 mb-2">Quality</p>
{['auto', '1080p', '720p', '480p'].map((q) => (
<button
key={q}
onClick={() => {
setQuality(q);
setShowSettings(false);
}}
className={clsx(
'block w-full text-left px-3 py-1 rounded hover:bg-white/10',
quality === q && 'text-netflix-red'
)}
>
{q}
</button>
))}
</div>
)}
</div>
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,418 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Play,
Pause,
Volume2,
VolumeX,
Maximize,
Minimize,
Settings,
Subtitles,
SkipBack,
SkipForward,
ArrowLeft,
Loader2,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { clsx } from 'clsx';
import type { Movie, TorrentInfo } from '../../types';
import { formatBytes, formatSpeed } from '../../services/torrent/webtorrent';
import ProgressBar from '../ui/ProgressBar';
interface VideoPlayerProps {
movie: Movie;
streamUrl?: string;
torrentInfo?: TorrentInfo | null;
onTimeUpdate?: (currentTime: number, duration: number) => void;
initialTime?: number;
}
export default function VideoPlayer({
movie,
streamUrl,
torrentInfo,
onTimeUpdate,
initialTime = 0,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
const navigate = useNavigate();
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBuffering] = useState(false);
const [buffered, setBuffered] = useState(0);
// Format time to MM:SS or HH:MM:SS
const formatTime = (seconds: number): string => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Show/hide controls
const handleMouseMove = useCallback(() => {
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
controlsTimeoutRef.current = setTimeout(() => {
if (isPlaying) {
setShowControls(false);
}
}, 3000);
}, [isPlaying]);
// Video event handlers
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
onTimeUpdate?.(video.currentTime, video.duration);
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
if (initialTime > 0) {
video.currentTime = initialTime;
}
};
const handleProgress = () => {
if (video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
setBuffered((bufferedEnd / video.duration) * 100);
}
};
const handleWaiting = () => setIsBuffering(true);
const handlePlaying = () => setIsBuffering(false);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('progress', handleProgress);
video.addEventListener('waiting', handleWaiting);
video.addEventListener('playing', handlePlaying);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('progress', handleProgress);
video.removeEventListener('waiting', handleWaiting);
video.removeEventListener('playing', handlePlaying);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
}, [initialTime, onTimeUpdate]);
// Keyboard controls
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const video = videoRef.current;
if (!video) return;
switch (e.key) {
case ' ':
case 'k':
e.preventDefault();
togglePlay();
break;
case 'ArrowLeft':
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 10);
break;
case 'ArrowRight':
e.preventDefault();
video.currentTime = Math.min(duration, video.currentTime + 10);
break;
case 'ArrowUp':
e.preventDefault();
setVolume((v) => Math.min(1, v + 0.1));
break;
case 'ArrowDown':
e.preventDefault();
setVolume((v) => Math.max(0, v - 0.1));
break;
case 'm':
e.preventDefault();
setIsMuted((m) => !m);
break;
case 'f':
e.preventDefault();
toggleFullscreen();
break;
case 'Escape':
if (isFullscreen) {
document.exitFullscreen();
} else {
navigate(-1);
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [duration, isFullscreen, navigate]);
// Update volume
useEffect(() => {
if (videoRef.current) {
videoRef.current.volume = volume;
videoRef.current.muted = isMuted;
}
}, [volume, isMuted]);
// Fullscreen change
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
}
};
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
containerRef.current?.requestFullscreen();
} else {
document.exitFullscreen();
}
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
if (videoRef.current) {
videoRef.current.currentTime = pos * duration;
}
};
const skip = (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime = Math.max(
0,
Math.min(duration, videoRef.current.currentTime + seconds)
);
}
};
return (
<div
ref={containerRef}
className="relative w-full h-full bg-black"
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
{/* Video Element */}
<video
ref={videoRef}
src={streamUrl}
className="w-full h-full"
onClick={togglePlay}
playsInline
/>
{/* Buffering Indicator */}
<AnimatePresence>
{isBuffering && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 flex items-center justify-center bg-black/30"
>
<Loader2 size={48} className="animate-spin text-netflix-red" />
</motion.div>
)}
</AnimatePresence>
{/* Controls Overlay */}
<AnimatePresence>
{showControls && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 flex flex-col justify-between"
>
{/* Top Bar */}
<div className="bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center gap-4">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<ArrowLeft size={24} />
</button>
<div>
<h1 className="text-xl font-semibold">{movie.title}</h1>
<p className="text-sm text-gray-400">{movie.year}</p>
</div>
</div>
{/* Center Play Button */}
<div className="flex-1 flex items-center justify-center">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={togglePlay}
className="w-20 h-20 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center"
>
{isPlaying ? (
<Pause size={40} fill="white" />
) : (
<Play size={40} fill="white" className="ml-1" />
)}
</motion.button>
</div>
{/* Bottom Controls */}
<div className="bg-gradient-to-t from-black/80 to-transparent p-4 space-y-3">
{/* Torrent Info */}
{torrentInfo && (
<div className="flex items-center gap-4 text-xs text-gray-400">
<span> {formatSpeed(torrentInfo.downloadSpeed)}</span>
<span> {formatSpeed(torrentInfo.uploadSpeed)}</span>
<span>{torrentInfo.numPeers} peers</span>
<span>{formatBytes(torrentInfo.downloaded)} / {formatBytes(torrentInfo.length)}</span>
</div>
)}
{/* Progress Bar */}
<div
className="relative h-1 bg-white/20 rounded-full cursor-pointer group"
onClick={handleSeek}
>
{/* Buffered */}
<div
className="absolute h-full bg-white/30 rounded-full"
style={{ width: `${buffered}%` }}
/>
{/* Progress */}
<div
className="absolute h-full bg-netflix-red rounded-full"
style={{ width: `${(currentTime / duration) * 100}%` }}
/>
{/* Scrubber */}
<div
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-netflix-red rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
style={{ left: `calc(${(currentTime / duration) * 100}% - 8px)` }}
/>
</div>
{/* Controls Row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Play/Pause */}
<button
onClick={togglePlay}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button>
{/* Skip Back */}
<button
onClick={() => skip(-10)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<SkipBack size={20} />
</button>
{/* Skip Forward */}
<button
onClick={() => skip(10)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<SkipForward size={20} />
</button>
{/* Volume */}
<div className="flex items-center gap-2 group/vol">
<button
onClick={() => setIsMuted(!isMuted)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isMuted || volume === 0 ? (
<VolumeX size={20} />
) : (
<Volume2 size={20} />
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={isMuted ? 0 : volume}
onChange={(e) => {
setVolume(parseFloat(e.target.value));
setIsMuted(false);
}}
className="w-20 accent-netflix-red opacity-0 group-hover/vol:opacity-100 transition-opacity"
/>
</div>
{/* Time */}
<span className="text-sm ml-2">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="flex items-center gap-2">
{/* Subtitles */}
<button className="p-2 hover:bg-white/10 rounded-full transition-colors">
<Subtitles size={20} />
</button>
{/* Settings */}
<button className="p-2 hover:bg-white/10 rounded-full transition-colors">
<Settings size={20} />
</button>
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { default as VideoPlayer } from './VideoPlayer';
export { default as StreamingPlayer } from './StreamingPlayer';

View File

@ -0,0 +1,133 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Play, Download, Check, Clock, Calendar } from 'lucide-react';
import type { UnifiedEpisode } from '../../types/unified';
import Badge from '../ui/Badge';
interface EpisodeCardProps {
episode: UnifiedEpisode;
showSeriesTitle?: boolean;
onPlay?: (episode: UnifiedEpisode) => void;
onDownload?: (episode: UnifiedEpisode) => void;
className?: string;
}
export default function EpisodeCard({
episode,
showSeriesTitle = false,
onPlay,
onDownload,
className = '',
}: EpisodeCardProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const airDate = episode.airDate ? new Date(episode.airDate) : null;
const isAired = airDate ? airDate <= new Date() : false;
const isFuture = airDate ? airDate > new Date() : false;
const thumbnailUrl = episode.poster || '/placeholder-episode.png';
return (
<motion.div
whileHover={{ scale: 1.02 }}
className={`group relative rounded-lg overflow-hidden bg-netflix-dark-gray ${className}`}
>
{/* Thumbnail */}
<div className="aspect-video relative">
{!imageLoaded && (
<div className="absolute inset-0 bg-netflix-medium-gray animate-pulse" />
)}
<img
src={thumbnailUrl}
alt={episode.title}
className={`w-full h-full object-cover transition-opacity duration-300 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
loading="lazy"
onLoad={() => setImageLoaded(true)}
/>
{/* Play Overlay */}
{episode.hasFile && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => onPlay?.(episode)}
className="w-12 h-12 rounded-full bg-white flex items-center justify-center"
>
<Play size={24} className="text-black ml-1" fill="black" />
</motion.button>
</div>
)}
{/* Episode Number Badge */}
<div className="absolute top-2 left-2">
<Badge size="sm" className="bg-black/70">
E{episode.episodeNumber.toString().padStart(2, '0')}
</Badge>
</div>
{/* Status Badge */}
<div className="absolute top-2 right-2">
{episode.hasFile ? (
<Badge size="sm" variant="success">
<Check size={10} />
</Badge>
) : isFuture ? (
<Badge size="sm" variant="info">
<Clock size={10} />
</Badge>
) : isAired ? (
<Badge size="sm" variant="warning">Missing</Badge>
) : null}
</div>
{/* Duration */}
{episode.runtime && (
<div className="absolute bottom-2 right-2">
<Badge size="sm" className="bg-black/70">
{episode.runtime}m
</Badge>
</div>
)}
</div>
{/* Info */}
<div className="p-3">
{showSeriesTitle && episode.seriesTitle && (
<p className="text-xs text-netflix-red mb-1 line-clamp-1">
{episode.seriesTitle}
</p>
)}
<h4 className="font-medium text-white line-clamp-1">
{episode.title}
</h4>
{episode.overview && (
<p className="text-sm text-gray-400 mt-1 line-clamp-2">
{episode.overview}
</p>
)}
<div className="flex items-center justify-between mt-2 text-xs text-gray-500">
{airDate && (
<span className="flex items-center gap-1">
<Calendar size={10} />
{airDate.toLocaleDateString()}
</span>
)}
{!episode.hasFile && isAired && onDownload && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => onDownload(episode)}
className="p-1 hover:bg-white/10 rounded transition-colors"
>
<Download size={14} />
</motion.button>
)}
</div>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,143 @@
import { motion } from 'framer-motion';
import { Play, Download, Check, Clock, Calendar } from 'lucide-react';
import type { UnifiedEpisode } from '../../types/unified';
import Badge from '../ui/Badge';
import { formatBytes, formatDuration } from '../../utils/helpers';
interface EpisodeListProps {
episodes: UnifiedEpisode[];
onEpisodeClick?: (episode: UnifiedEpisode) => void;
onEpisodeDownload?: (episode: UnifiedEpisode) => void;
}
export default function EpisodeList({
episodes,
onEpisodeClick,
onEpisodeDownload,
}: EpisodeListProps) {
const sortedEpisodes = [...episodes].sort((a, b) => a.episodeNumber - b.episodeNumber);
if (episodes.length === 0) {
return (
<div className="p-6 text-center text-gray-400">
No episodes found
</div>
);
}
return (
<div className="divide-y divide-white/5">
{sortedEpisodes.map((episode) => {
const airDate = episode.airDate ? new Date(episode.airDate) : null;
const isAired = airDate ? airDate <= new Date() : false;
const isFuture = airDate ? airDate > new Date() : false;
return (
<motion.div
key={episode.id}
whileHover={{ backgroundColor: 'rgba(255, 255, 255, 0.05)' }}
className="p-4 flex items-start gap-4 cursor-pointer"
onClick={() => episode.hasFile && onEpisodeClick?.(episode)}
>
{/* Episode Number */}
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
{episode.hasFile ? (
<Play size={20} className="text-netflix-red" fill="currentColor" />
) : (
<span className="text-lg font-bold text-gray-400">
{episode.episodeNumber}
</span>
)}
</div>
{/* Episode Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<h4 className="font-medium text-white line-clamp-1">
{episode.episodeNumber}. {episode.title}
</h4>
{episode.overview && (
<p className="text-sm text-gray-400 mt-1 line-clamp-2">
{episode.overview}
</p>
)}
</div>
{/* Status Badges */}
<div className="flex-shrink-0 flex items-center gap-2">
{episode.hasFile ? (
<Badge size="sm" variant="success" className="flex items-center gap-1">
<Check size={10} />
Downloaded
</Badge>
) : isAired ? (
<Badge size="sm" variant="warning">Missing</Badge>
) : isFuture ? (
<Badge size="sm" variant="info" className="flex items-center gap-1">
<Clock size={10} />
Upcoming
</Badge>
) : null}
</div>
</div>
{/* Meta Info */}
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-gray-500">
{airDate && (
<span className="flex items-center gap-1">
<Calendar size={12} />
{airDate.toLocaleDateString()}
</span>
)}
{episode.runtime && (
<span className="flex items-center gap-1">
<Clock size={12} />
{formatDuration(episode.runtime * 60)}
</span>
)}
{episode.episodeFile && (
<>
<span>{episode.episodeFile.quality}</span>
<span>{formatBytes(episode.episodeFile.size)}</span>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex-shrink-0 flex items-center gap-2">
{episode.hasFile ? (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => {
e.stopPropagation();
onEpisodeClick?.(episode);
}}
className="p-2 bg-netflix-red rounded-full hover:bg-red-700 transition-colors"
>
<Play size={16} fill="white" />
</motion.button>
) : isAired && onEpisodeDownload ? (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => {
e.stopPropagation();
onEpisodeDownload(episode);
}}
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition-colors"
title="Search for download"
>
<Download size={16} />
</motion.button>
) : null}
</div>
</motion.div>
);
})}
</div>
);
}

View File

@ -0,0 +1,127 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronUp, Check, X, Download } from 'lucide-react';
import type { UnifiedSeason, UnifiedEpisode } from '../../types/unified';
import EpisodeList from './EpisodeList';
import Badge from '../ui/Badge';
import ProgressBar from '../ui/ProgressBar';
interface SeasonListProps {
seasons: UnifiedSeason[];
episodes: UnifiedEpisode[];
onEpisodeClick?: (episode: UnifiedEpisode) => void;
onSeasonDownload?: (seasonNumber: number) => void;
}
export default function SeasonList({
seasons,
episodes,
onEpisodeClick,
onSeasonDownload,
}: SeasonListProps) {
const [expandedSeasons, setExpandedSeasons] = useState<number[]>([1]);
const toggleSeason = (seasonNumber: number) => {
setExpandedSeasons((prev) =>
prev.includes(seasonNumber)
? prev.filter((s) => s !== seasonNumber)
: [...prev, seasonNumber]
);
};
const getSeasonEpisodes = (seasonNumber: number) =>
episodes.filter((e) => e.seasonNumber === seasonNumber);
const sortedSeasons = [...seasons].sort((a, b) => a.seasonNumber - b.seasonNumber);
return (
<div className="space-y-3">
{sortedSeasons.map((season) => {
const seasonEpisodes = getSeasonEpisodes(season.seasonNumber);
const isExpanded = expandedSeasons.includes(season.seasonNumber);
const progress = season.episodeCount > 0
? (season.episodeFileCount / season.episodeCount) * 100
: 0;
return (
<div key={season.seasonNumber} className="glass rounded-lg overflow-hidden">
{/* Season Header */}
<button
onClick={() => toggleSeason(season.seasonNumber)}
className="w-full flex items-center justify-between p-4 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-4">
<span className="text-lg font-semibold">
{season.seasonNumber === 0 ? 'Specials' : `Season ${season.seasonNumber}`}
</span>
<div className="flex items-center gap-2">
<Badge size="sm">
{season.episodeFileCount}/{season.episodeCount} episodes
</Badge>
{season.monitored ? (
<Badge size="sm" variant="success" className="flex items-center gap-1">
<Check size={10} />
Monitored
</Badge>
) : (
<Badge size="sm" variant="default" className="flex items-center gap-1">
<X size={10} />
Unmonitored
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-4">
{/* Progress bar */}
<div className="w-32 hidden sm:block">
<ProgressBar
progress={progress}
size="sm"
showLabel={false}
color={progress === 100 ? 'success' : 'primary'}
/>
</div>
{/* Download button */}
{onSeasonDownload && season.episodeFileCount < season.episodeCount && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => {
e.stopPropagation();
onSeasonDownload(season.seasonNumber);
}}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
title="Download missing episodes"
>
<Download size={18} />
</motion.button>
)}
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div>
</button>
{/* Episodes */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="border-t border-white/10">
<EpisodeList
episodes={seasonEpisodes}
onEpisodeClick={onEpisodeClick}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,124 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Play, Star, Calendar, Tv } from 'lucide-react';
import type { UnifiedSeries } from '../../types/unified';
import Badge from '../ui/Badge';
interface SeriesCardProps {
series: UnifiedSeries;
className?: string;
}
export default function SeriesCard({ series, className = '' }: SeriesCardProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const statusColor = {
continuing: 'success',
ended: 'default',
upcoming: 'info',
deleted: 'error',
} as const;
return (
<Link to={`/tv/${series.id}`}>
<motion.div
whileHover={{ scale: 1.05, y: -5 }}
whileTap={{ scale: 0.98 }}
className={`group relative rounded-lg overflow-hidden bg-netflix-dark-gray cursor-pointer ${className}`}
>
{/* Poster */}
<div className="aspect-[2/3] relative">
{!imageLoaded && !imageError && (
<div className="absolute inset-0 bg-netflix-medium-gray animate-pulse" />
)}
{imageError ? (
<div className="absolute inset-0 bg-netflix-medium-gray flex items-center justify-center">
<Tv size={48} className="text-gray-600" />
</div>
) : (
<img
src={series.poster || '/placeholder-series.png'}
alt={series.title}
className={`w-full h-full object-cover transition-opacity duration-300 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
loading="lazy"
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
)}
{/* Hover Overlay */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-14 h-14 rounded-full bg-white/90 flex items-center justify-center shadow-lg"
>
<Play size={28} className="text-black ml-1" fill="black" />
</motion.button>
<div className="space-y-2">
<h3 className="text-white font-semibold line-clamp-2">{series.title}</h3>
<div className="flex items-center gap-2 text-sm text-gray-300">
{series.rating && (
<span className="flex items-center gap-1 text-green-400">
<Star size={14} fill="currentColor" />
{series.rating.toFixed(1)}
</span>
)}
{series.year && <span>{series.year}</span>}
</div>
<div className="flex flex-wrap gap-1">
<Badge size="sm" variant={statusColor[series.status]}>
{series.status}
</Badge>
<Badge size="sm">
{series.seasonCount} Season{series.seasonCount !== 1 ? 's' : ''}
</Badge>
</div>
</div>
</div>
{/* Status Badge */}
<div className="absolute top-2 right-2">
<Badge size="sm" variant={statusColor[series.status]}>
{series.status}
</Badge>
</div>
{/* Next Airing Badge */}
{series.nextAiring && (
<div className="absolute top-2 left-2">
<Badge size="sm" variant="info" className="flex items-center gap-1">
<Calendar size={10} />
{new Date(series.nextAiring).toLocaleDateString()}
</Badge>
</div>
)}
</div>
{/* Info */}
<div className="p-3">
<h3 className="font-medium text-white line-clamp-1 group-hover:text-netflix-red transition-colors">
{series.title}
</h3>
<div className="flex items-center justify-between mt-1 text-sm text-gray-400">
<span>{series.year}</span>
<span className="flex items-center gap-1">
{series.episodeFileCount}/{series.episodeCount} eps
</span>
</div>
{series.network && (
<div className="mt-1 text-xs text-gray-500 line-clamp-1">
{series.network}
</div>
)}
</div>
</motion.div>
</Link>
);
}

View File

@ -0,0 +1,54 @@
import { motion } from 'framer-motion';
import type { UnifiedSeries } from '../../types/unified';
import SeriesCard from './SeriesCard';
import { SeriesCardSkeleton } from '../ui/Skeleton';
interface SeriesGridProps {
series: UnifiedSeries[];
isLoading?: boolean;
emptyMessage?: string;
}
export default function SeriesGrid({
series,
isLoading = false,
emptyMessage = 'No series found',
}: SeriesGridProps) {
if (isLoading) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{Array.from({ length: 12 }).map((_, i) => (
<SeriesCardSkeleton key={i} />
))}
</div>
);
}
if (series.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-lg">{emptyMessage}</p>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
>
{series.map((s, index) => (
<motion.div
key={s.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<SeriesCard series={s} />
</motion.div>
))}
</motion.div>
);
}

View File

@ -0,0 +1,6 @@
export { default as SeriesCard } from './SeriesCard';
export { default as SeriesGrid } from './SeriesGrid';
export { default as SeasonList } from './SeasonList';
export { default as EpisodeList } from './EpisodeList';
export { default as EpisodeCard } from './EpisodeCard';

View File

@ -0,0 +1,43 @@
import { clsx } from 'clsx';
import type { ReactNode } from 'react';
interface BadgeProps {
children: ReactNode;
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
size?: 'sm' | 'md';
className?: string;
}
export default function Badge({
children,
variant = 'default',
size = 'md',
className,
}: BadgeProps) {
const variants = {
default: 'bg-white/10 text-white',
success: 'bg-green-500/20 text-green-400',
warning: 'bg-yellow-500/20 text-yellow-400',
error: 'bg-red-500/20 text-red-400',
info: 'bg-blue-500/20 text-blue-400',
};
const sizes = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm',
};
return (
<span
className={clsx(
'inline-flex items-center rounded-full font-medium',
variants[variant],
sizes[size],
className
)}
>
{children}
</span>
);
}

View File

@ -0,0 +1,92 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { clsx } from 'clsx';
import { motion } from 'framer-motion';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'icon';
size?: 'sm' | 'md' | 'lg';
children: ReactNode;
isLoading?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
children,
isLoading = false,
leftIcon,
rightIcon,
className,
disabled,
...props
},
ref
) => {
const baseStyles =
'inline-flex items-center justify-center font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-netflix-black disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary:
'bg-netflix-red hover:bg-netflix-red-hover text-white focus:ring-netflix-red',
secondary:
'bg-white/20 hover:bg-white/30 text-white backdrop-blur-sm focus:ring-white/50',
ghost:
'bg-transparent hover:bg-white/10 text-white focus:ring-white/30',
icon: 'bg-netflix-medium-gray/80 hover:bg-netflix-medium-gray text-white border border-white/20 hover:border-white/40 focus:ring-white/30',
};
const sizes = {
sm: variant === 'icon' ? 'w-8 h-8 rounded-full' : 'px-3 py-1.5 text-sm rounded',
md: variant === 'icon' ? 'w-10 h-10 rounded-full' : 'px-5 py-2 text-base rounded',
lg: variant === 'icon' ? 'w-12 h-12 rounded-full' : 'px-8 py-3 text-lg rounded-md',
};
return (
<motion.button
ref={ref}
whileTap={{ scale: 0.95 }}
className={clsx(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
{...(props as object)}
>
{isLoading ? (
<svg
className="animate-spin h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<>
{leftIcon && <span className="mr-2">{leftIcon}</span>}
{children}
{rightIcon && <span className="ml-2">{rightIcon}</span>}
</>
)}
</motion.button>
);
}
);
Button.displayName = 'Button';
export default Button;

View File

@ -0,0 +1,54 @@
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
import { clsx } from 'clsx';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, leftIcon, rightIcon, className, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-300 mb-1">
{label}
</label>
)}
<div className="relative">
{leftIcon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{leftIcon}
</div>
)}
<input
ref={ref}
className={clsx(
'input-field',
leftIcon && 'pl-10',
rightIcon && 'pr-10',
error && 'border-red-500 focus:border-red-500 focus:ring-red-500/20',
className
)}
{...props}
/>
{rightIcon && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
{rightIcon}
</div>
)}
</div>
{error && (
<p className="mt-1 text-sm text-red-500">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
export default Input;

108
src/components/ui/Modal.tsx Normal file
View File

@ -0,0 +1,108 @@
import { type ReactNode, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import { clsx } from 'clsx';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
showCloseButton?: boolean;
className?: string;
}
export default function Modal({
isOpen,
onClose,
children,
title,
size = 'md',
showCloseButton = true,
className,
}: ModalProps) {
// Lock body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// Close on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);
const sizes = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
full: 'max-w-[95vw] max-h-[95vh]',
};
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className={clsx(
'relative w-full glass rounded-lg shadow-2xl overflow-hidden',
sizes[size],
className
)}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between p-4 border-b border-white/10">
{title && (
<h2 className="text-xl font-semibold text-white">{title}</h2>
)}
{showCloseButton && (
<button
onClick={onClose}
className="p-1 rounded-full hover:bg-white/10 transition-colors"
aria-label="Close modal"
>
<X size={24} />
</button>
)}
</div>
)}
{/* Content */}
<div className="overflow-y-auto max-h-[calc(95vh-8rem)]">
{children}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,51 @@
import { clsx } from 'clsx';
interface ProgressBarProps {
progress: number;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'success' | 'error';
showLabel?: boolean;
className?: string;
}
export default function ProgressBar({
progress,
size = 'md',
variant = 'default',
showLabel = false,
className,
}: ProgressBarProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
const sizes = {
sm: 'h-1',
md: 'h-2',
lg: 'h-3',
};
const variants = {
default: 'bg-netflix-red',
success: 'bg-green-500',
error: 'bg-red-500',
};
return (
<div className={clsx('w-full', className)}>
<div className={clsx('w-full bg-white/20 rounded-full overflow-hidden', sizes[size])}>
<div
className={clsx(
'h-full rounded-full transition-all duration-300 ease-out',
variants[variant]
)}
style={{ width: `${clampedProgress}%` }}
/>
</div>
{showLabel && (
<p className="text-xs text-gray-400 mt-1 text-right">
{clampedProgress.toFixed(1)}%
</p>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import { forwardRef, type SelectHTMLAttributes } from 'react';
import { clsx } from 'clsx';
import { ChevronDown } from 'lucide-react';
interface Option {
value: string;
label: string;
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
options: Option[];
error?: string;
}
const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ label, options, error, className, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-300 mb-1">
{label}
</label>
)}
<div className="relative">
<select
ref={ref}
className={clsx(
'input-field appearance-none pr-10 cursor-pointer',
error && 'border-red-500 focus:border-red-500 focus:ring-red-500/20',
className
)}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
size={20}
/>
</div>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
</div>
);
}
);
Select.displayName = 'Select';
export default Select;

View File

@ -0,0 +1,205 @@
import { clsx } from 'clsx';
interface SkeletonProps {
className?: string;
variant?: 'text' | 'circular' | 'rectangular';
width?: string | number;
height?: string | number;
}
export default function Skeleton({
className,
variant = 'rectangular',
width,
height,
}: SkeletonProps) {
const variants = {
text: 'rounded',
circular: 'rounded-full',
rectangular: 'rounded-md',
};
return (
<div
className={clsx(
'shimmer',
variants[variant],
className
)}
style={{
width: width,
height: height,
}}
/>
);
}
export function MovieCardSkeleton() {
return (
<div className="flex-shrink-0 w-[200px]">
<Skeleton className="w-full aspect-[2/3] rounded-md" />
<Skeleton className="w-3/4 h-4 mt-2" />
<Skeleton className="w-1/2 h-3 mt-1" />
</div>
);
}
export function MovieRowSkeleton() {
return (
<div className="mb-8">
<Skeleton className="w-48 h-6 mb-4" />
<div className="flex gap-4 overflow-hidden">
{Array.from({ length: 7 }).map((_, i) => (
<MovieCardSkeleton key={i} />
))}
</div>
</div>
);
}
export function HeroSkeleton() {
return (
<div className="relative h-[80vh] w-full">
<Skeleton className="absolute inset-0" />
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-16">
<Skeleton className="w-1/3 h-12 mb-4" />
<Skeleton className="w-2/3 h-4 mb-2" />
<Skeleton className="w-1/2 h-4 mb-6" />
<div className="flex gap-4">
<Skeleton className="w-32 h-12 rounded-md" />
<Skeleton className="w-40 h-12 rounded-md" />
</div>
</div>
</div>
);
}
export function MovieDetailsSkeleton() {
return (
<div className="min-h-screen bg-netflix-black">
<Skeleton className="w-full h-[60vh]" />
<div className="max-w-7xl mx-auto px-4 py-8 -mt-32 relative z-10">
<div className="flex flex-col md:flex-row gap-8">
<Skeleton className="w-[300px] aspect-[2/3] rounded-lg flex-shrink-0" />
<div className="flex-1">
<Skeleton className="w-2/3 h-10 mb-4" />
<Skeleton className="w-1/3 h-6 mb-4" />
<Skeleton className="w-full h-4 mb-2" />
<Skeleton className="w-full h-4 mb-2" />
<Skeleton className="w-3/4 h-4 mb-6" />
<div className="flex gap-4">
<Skeleton className="w-32 h-12 rounded-md" />
<Skeleton className="w-40 h-12 rounded-md" />
</div>
</div>
</div>
</div>
</div>
);
}
export function SeriesCardSkeleton() {
return (
<div className="rounded-lg overflow-hidden bg-netflix-dark-gray">
<Skeleton className="w-full aspect-[2/3]" />
<div className="p-3">
<Skeleton className="w-3/4 h-4 mb-2" />
<Skeleton className="w-1/2 h-3" />
</div>
</div>
);
}
export function ArtistCardSkeleton() {
return (
<div className="rounded-lg overflow-hidden bg-netflix-dark-gray">
<Skeleton className="w-full aspect-square" />
<div className="p-3">
<Skeleton className="w-3/4 h-4 mb-2" />
<Skeleton className="w-1/2 h-3" />
</div>
</div>
);
}
export function AlbumCardSkeleton() {
return (
<div className="rounded-lg overflow-hidden bg-netflix-dark-gray">
<Skeleton className="w-full aspect-square" />
<div className="p-3">
<Skeleton className="w-3/4 h-4 mb-2" />
<Skeleton className="w-1/2 h-3" />
</div>
</div>
);
}
export function SeriesDetailsSkeleton() {
return (
<div className="min-h-screen bg-netflix-black">
<Skeleton className="w-full h-[50vh]" />
<div className="max-w-7xl mx-auto px-4 py-8 -mt-24 relative z-10">
<div className="flex flex-col md:flex-row gap-8">
<Skeleton className="w-[250px] aspect-[2/3] rounded-lg flex-shrink-0" />
<div className="flex-1">
<Skeleton className="w-2/3 h-10 mb-4" />
<Skeleton className="w-1/3 h-5 mb-4" />
<Skeleton className="w-full h-4 mb-2" />
<Skeleton className="w-3/4 h-4 mb-6" />
<div className="flex gap-4 mb-8">
<Skeleton className="w-32 h-10 rounded-md" />
<Skeleton className="w-32 h-10 rounded-md" />
</div>
{/* Seasons */}
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="w-full h-16 rounded-lg" />
))}
</div>
</div>
</div>
</div>
</div>
);
}
export function ArtistDetailsSkeleton() {
return (
<div className="min-h-screen bg-netflix-black">
<Skeleton className="w-full h-[40vh]" />
<div className="max-w-7xl mx-auto px-4 py-8 -mt-24 relative z-10">
<div className="flex items-end gap-6 mb-8">
<Skeleton className="w-48 h-48 rounded-full flex-shrink-0" />
<div className="flex-1">
<Skeleton className="w-1/3 h-10 mb-4" />
<Skeleton className="w-1/4 h-5" />
</div>
</div>
{/* Albums */}
<Skeleton className="w-32 h-6 mb-4" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<AlbumCardSkeleton key={i} />
))}
</div>
</div>
</div>
);
}
export function CalendarSkeleton() {
return (
<div className="glass rounded-lg overflow-hidden">
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<Skeleton className="w-48 h-8" />
<Skeleton className="w-32 h-8" />
</div>
<div className="grid grid-cols-7">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-32 m-1" />
))}
</div>
</div>
);
}

View File

@ -0,0 +1,53 @@
import { useState, type ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { clsx } from 'clsx';
interface TooltipProps {
children: ReactNode;
content: string;
position?: 'top' | 'bottom' | 'left' | 'right';
className?: string;
}
export default function Tooltip({
children,
content,
position = 'top',
className,
}: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const positions = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
};
return (
<div
className={clsx('relative inline-flex', className)}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
>
{children}
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1 }}
className={clsx(
'absolute z-50 px-2 py-1 text-sm text-white bg-gray-900 rounded shadow-lg whitespace-nowrap',
positions[position]
)}
>
{content}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,9 @@
export { default as Button } from './Button';
export { default as Modal } from './Modal';
export { default as Input } from './Input';
export { default as Select } from './Select';
export { default as Badge } from './Badge';
export { default as ProgressBar } from './ProgressBar';
export { default as Tooltip } from './Tooltip';
export { default as Skeleton, MovieCardSkeleton, MovieRowSkeleton, HeroSkeleton, MovieDetailsSkeleton } from './Skeleton';

142
src/hooks/useCalendar.ts Normal file
View File

@ -0,0 +1,142 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import type { CalendarItem, QueueItem } from '../types/unified';
import { mediaManager } from '../services/integration/mediaManager';
import { useIntegrationStore } from '../stores/integrationStore';
export function useCalendar(start?: Date, end?: Date) {
const [items, setItems] = useState<CalendarItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const connections = useIntegrationStore((s) => s.getEnabledConnections());
const hasConnections = connections.length > 0;
// Default date range: 2 weeks before to 4 weeks after
const defaultStart = useMemo(() => {
const d = new Date();
d.setDate(d.getDate() - 14);
d.setHours(0, 0, 0, 0);
return d;
}, []);
const defaultEnd = useMemo(() => {
const d = new Date();
d.setDate(d.getDate() + 28);
d.setHours(23, 59, 59, 999);
return d;
}, []);
const startDate = start || defaultStart;
const endDate = end || defaultEnd;
const fetchCalendar = useCallback(async () => {
if (!hasConnections) {
setItems([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getCalendar(startDate, endDate);
setItems(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch calendar');
} finally {
setIsLoading(false);
}
}, [hasConnections, startDate, endDate]);
useEffect(() => {
fetchCalendar();
}, [fetchCalendar]);
return { items, isLoading, error, refetch: fetchCalendar };
}
export function useUpcoming(days = 7) {
const { items, isLoading, error, refetch } = useCalendar();
const upcoming = useMemo(() => {
const now = new Date();
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + days);
return items.filter((item) => {
const itemDate = new Date(item.date);
return itemDate >= now && itemDate <= cutoff;
});
}, [items, days]);
return { items: upcoming, isLoading, error, refetch };
}
export function useQueue() {
const [items, setItems] = useState<QueueItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const connections = useIntegrationStore((s) => s.getEnabledConnections());
const hasConnections = connections.length > 0;
const fetchQueue = useCallback(async () => {
if (!hasConnections) {
setItems([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getQueue();
setItems(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch queue');
} finally {
setIsLoading(false);
}
}, [hasConnections]);
useEffect(() => {
fetchQueue();
}, [fetchQueue]);
// Auto-refresh every 30 seconds
useEffect(() => {
if (!hasConnections) return;
const interval = setInterval(fetchQueue, 30000);
return () => clearInterval(interval);
}, [hasConnections, fetchQueue]);
return { items, isLoading, error, refetch: fetchQueue };
}
// Helper to group calendar items by date
export function groupByDate(items: CalendarItem[]): Record<string, CalendarItem[]> {
return items.reduce((acc, item) => {
const dateKey = item.date.toDateString();
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(item);
return acc;
}, {} as Record<string, CalendarItem[]>);
}
// Helper to get items for a specific day
export function getItemsForDay(items: CalendarItem[], date: Date): CalendarItem[] {
const dateKey = date.toDateString();
return items.filter((item) => item.date.toDateString() === dateKey);
}
// Helper to check if there are any items today
export function hasItemsToday(items: CalendarItem[]): boolean {
const today = new Date().toDateString();
return items.some((item) => item.date.toDateString() === today);
}

View File

@ -0,0 +1,96 @@
import { useEffect, useCallback } from 'react';
import { useIntegrationStore } from '../stores/integrationStore';
/**
* Hook to initialize *arr services on app startup
*/
export function useInitializeIntegrations() {
const { initializeServices, destroyServices, getEnabledConnections } = useIntegrationStore();
useEffect(() => {
const enabledConnections = getEnabledConnections();
if (enabledConnections.length > 0) {
initializeServices();
}
return () => {
destroyServices();
};
}, []);
}
/**
* Hook to get connection status for a specific service
*/
export function useServiceStatus(type: 'radarr' | 'sonarr' | 'lidarr') {
const connection = useIntegrationStore((s) => s.getConnection(type));
const isConnected = useIntegrationStore((s) => s.isServiceConnected(type));
const testConnection = useIntegrationStore((s) => s.testConnection);
const refresh = useCallback(async () => {
if (connection) {
return testConnection(connection.id);
}
return { success: false, error: 'No connection configured' };
}, [connection, testConnection]);
return {
connection,
isConnected,
isEnabled: connection?.enabled ?? false,
version: connection?.version,
error: connection?.error,
lastChecked: connection?.lastChecked,
refresh,
};
}
/**
* Hook to get the current movie source setting
*/
export function useMovieSource() {
const settings = useIntegrationStore((s) => s.settings);
const isRadarrConnected = useIntegrationStore((s) => s.isServiceConnected('radarr'));
return {
source: settings.movieSource,
isRadarrAvailable: isRadarrConnected,
useYts: settings.movieSource === 'yts' || (settings.movieSource === 'all'),
useRadarr: (settings.movieSource === 'radarr' || settings.movieSource === 'all') && isRadarrConnected,
};
}
/**
* Hook to check if any service is connected
*/
export function useHasConnectedServices() {
const connections = useIntegrationStore((s) => s.connections);
return {
hasAny: connections.some((c) => c.enabled && c.isConnected),
hasRadarr: connections.some((c) => c.type === 'radarr' && c.enabled && c.isConnected),
hasSonarr: connections.some((c) => c.type === 'sonarr' && c.enabled && c.isConnected),
hasLidarr: connections.some((c) => c.type === 'lidarr' && c.enabled && c.isConnected),
count: connections.filter((c) => c.enabled && c.isConnected).length,
};
}
/**
* Hook to manage integration settings
*/
export function useIntegrationSettings() {
const settings = useIntegrationStore((s) => s.settings);
const updateSettings = useIntegrationStore((s) => s.updateSettings);
return {
settings,
updateSettings,
setMovieSource: (source: 'yts' | 'radarr' | 'all') =>
updateSettings({ movieSource: source }),
setRefreshInterval: (minutes: number) =>
updateSettings({ refreshInterval: minutes }),
toggleCalendarNotifications: () =>
updateSettings({ showCalendarNotifications: !settings.showCalendarNotifications }),
};
}

195
src/hooks/useMovies.ts Normal file
View File

@ -0,0 +1,195 @@
import { useState, useEffect, useCallback } from 'react';
import { ytsApi } from '../services/api/yts';
import type { Movie, ListMoviesParams, ListMoviesData } from '../types';
interface UseMoviesOptions extends ListMoviesParams {
enabled?: boolean;
}
interface UseMoviesResult {
movies: Movie[];
isLoading: boolean;
error: string | null;
totalCount: number;
currentPage: number;
hasMore: boolean;
loadMore: () => void;
refresh: () => void;
}
export function useMovies(options: UseMoviesOptions = {}): UseMoviesResult {
const { enabled = true, ...params } = options;
const [movies, setMovies] = useState<Movie[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const fetchMovies = useCallback(async (page: number, append = false) => {
setIsLoading(true);
setError(null);
try {
const data = await ytsApi.listMovies({
...params,
page,
});
setMovies((prev) => (append ? [...prev, ...data.movies] : data.movies));
setTotalCount(data.movie_count);
setCurrentPage(page);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch movies');
} finally {
setIsLoading(false);
}
}, [JSON.stringify(params)]);
useEffect(() => {
if (enabled) {
fetchMovies(1);
}
}, [enabled, fetchMovies]);
const loadMore = useCallback(() => {
if (!isLoading && movies.length < totalCount) {
fetchMovies(currentPage + 1, true);
}
}, [isLoading, movies.length, totalCount, currentPage, fetchMovies]);
const refresh = useCallback(() => {
setMovies([]);
fetchMovies(1);
}, [fetchMovies]);
const hasMore = movies.length < totalCount;
return {
movies,
isLoading,
error,
totalCount,
currentPage,
hasMore,
loadMore,
refresh,
};
}
export function useTrending() {
const [movies, setMovies] = useState<Movie[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
ytsApi.getTrending(10)
.then((data) => setMovies(data.movies))
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
return { movies, isLoading };
}
export function useLatest() {
const [movies, setMovies] = useState<Movie[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
ytsApi.getLatest(20)
.then((data) => setMovies(data.movies))
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
return { movies, isLoading };
}
export function useTopRated() {
const [movies, setMovies] = useState<Movie[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
ytsApi.getTopRated(20, 8)
.then((data) => setMovies(data.movies))
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
return { movies, isLoading };
}
export function useByGenre(genre: string) {
const [movies, setMovies] = useState<Movie[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (genre) {
setIsLoading(true);
ytsApi.getByGenre(genre, 20)
.then((data) => setMovies(data.movies))
.catch(console.error)
.finally(() => setIsLoading(false));
}
}, [genre]);
return { movies, isLoading };
}
export function useMovieDetails(movieId: number) {
const [movie, setMovie] = useState<Movie | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (movieId) {
setIsLoading(true);
ytsApi.getMovieDetails({ movie_id: movieId, with_cast: true, with_images: true })
.then((data) => setMovie(data.movie))
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}
}, [movieId]);
return { movie, isLoading, error };
}
export function useMovieSuggestions(movieId: number) {
const [movies, setMovies] = useState<Movie[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (movieId) {
ytsApi.getMovieSuggestions(movieId)
.then((data) => setMovies(data.movies))
.catch(console.error)
.finally(() => setIsLoading(false));
}
}, [movieId]);
return { movies, isLoading };
}
export function useSearch(query: string, params?: Omit<ListMoviesParams, 'query_term'>) {
const [movies, setMovies] = useState<Movie[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
if (query) {
setIsLoading(true);
ytsApi.search(query, params)
.then((data) => {
setMovies(data.movies);
setTotalCount(data.movie_count);
})
.catch(console.error)
.finally(() => setIsLoading(false));
} else {
setMovies([]);
setTotalCount(0);
}
}, [query, JSON.stringify(params)]);
return { movies, isLoading, totalCount };
}

270
src/hooks/useMusic.ts Normal file
View File

@ -0,0 +1,270 @@
import { useState, useEffect, useCallback } from 'react';
import type { UnifiedArtist, UnifiedAlbum, UnifiedTrack } from '../types/unified';
import { mediaManager } from '../services/integration/mediaManager';
import { useIntegrationStore } from '../stores/integrationStore';
export function useArtists() {
const [artists, setArtists] = useState<UnifiedArtist[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isConnected = useIntegrationStore((s) => s.isServiceConnected('lidarr'));
const fetchArtists = useCallback(async () => {
if (!isConnected) {
setArtists([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getArtists();
setArtists(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch artists');
} finally {
setIsLoading(false);
}
}, [isConnected]);
useEffect(() => {
fetchArtists();
}, [fetchArtists]);
return { artists, isLoading, error, refetch: fetchArtists };
}
export function useArtistDetails(id: string | undefined) {
const [artist, setArtist] = useState<UnifiedArtist | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) {
setIsLoading(false);
return;
}
const fetchArtist = async () => {
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getArtist(id);
setArtist(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch artist details');
} finally {
setIsLoading(false);
}
};
fetchArtist();
}, [id]);
return { artist, isLoading, error };
}
export function useAlbums(artistId?: string) {
const [albums, setAlbums] = useState<UnifiedAlbum[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isConnected = useIntegrationStore((s) => s.isServiceConnected('lidarr'));
const fetchAlbums = useCallback(async () => {
if (!isConnected) {
setAlbums([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getAlbums(artistId);
setAlbums(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch albums');
} finally {
setIsLoading(false);
}
}, [isConnected, artistId]);
useEffect(() => {
fetchAlbums();
}, [fetchAlbums]);
return { albums, isLoading, error, refetch: fetchAlbums };
}
export function useAlbumDetails(id: string | undefined) {
const [album, setAlbum] = useState<UnifiedAlbum | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) {
setIsLoading(false);
return;
}
const fetchAlbum = async () => {
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getAlbum(id);
setAlbum(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch album details');
} finally {
setIsLoading(false);
}
};
fetchAlbum();
}, [id]);
return { album, isLoading, error };
}
export function useTracks(albumId: string | undefined) {
const [tracks, setTracks] = useState<UnifiedTrack[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!albumId) {
setIsLoading(false);
return;
}
const fetchTracks = async () => {
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getTracks(albumId);
setTracks(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch tracks');
} finally {
setIsLoading(false);
}
};
fetchTracks();
}, [albumId]);
return { tracks, isLoading, error };
}
// Filter and sort utilities
export function filterArtists(
artists: UnifiedArtist[],
options: {
status?: 'all' | 'continuing' | 'ended';
hasFiles?: boolean;
search?: string;
genre?: string;
}
): UnifiedArtist[] {
return artists.filter((a) => {
if (options.status && options.status !== 'all' && a.status !== options.status) {
return false;
}
if (options.hasFiles !== undefined && (a.trackCount > 0) !== options.hasFiles) {
return false;
}
if (options.search) {
const searchLower = options.search.toLowerCase();
if (!a.title.toLowerCase().includes(searchLower)) {
return false;
}
}
if (options.genre && !a.genres?.includes(options.genre)) {
return false;
}
return true;
});
}
export function sortArtists(
artists: UnifiedArtist[],
sortBy: 'name' | 'albumCount' | 'added' = 'name',
order: 'asc' | 'desc' = 'asc'
): UnifiedArtist[] {
return [...artists].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.title.localeCompare(b.title);
break;
case 'albumCount':
comparison = a.albumCount - b.albumCount;
break;
case 'added':
comparison = (a.added?.getTime() || 0) - (b.added?.getTime() || 0);
break;
}
return order === 'desc' ? -comparison : comparison;
});
}
export function filterAlbums(
albums: UnifiedAlbum[],
options: {
hasFiles?: boolean;
search?: string;
albumType?: string;
}
): UnifiedAlbum[] {
return albums.filter((a) => {
if (options.hasFiles !== undefined && (a.trackFileCount > 0) !== options.hasFiles) {
return false;
}
if (options.search) {
const searchLower = options.search.toLowerCase();
if (!a.title.toLowerCase().includes(searchLower) &&
!a.artistName.toLowerCase().includes(searchLower)) {
return false;
}
}
if (options.albumType && a.albumType !== options.albumType) {
return false;
}
return true;
});
}
export function sortAlbums(
albums: UnifiedAlbum[],
sortBy: 'title' | 'releaseDate' | 'artist' = 'releaseDate',
order: 'asc' | 'desc' = 'desc'
): UnifiedAlbum[] {
return [...albums].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'title':
comparison = a.title.localeCompare(b.title);
break;
case 'releaseDate':
const aDate = a.releaseDate ? new Date(a.releaseDate).getTime() : 0;
const bDate = b.releaseDate ? new Date(b.releaseDate).getTime() : 0;
comparison = aDate - bDate;
break;
case 'artist':
comparison = a.artistName.localeCompare(b.artistName);
break;
}
return order === 'desc' ? -comparison : comparison;
});
}

193
src/hooks/useSeries.ts Normal file
View File

@ -0,0 +1,193 @@
import { useState, useEffect, useCallback } from 'react';
import type { UnifiedSeries, UnifiedEpisode } from '../types/unified';
import { mediaManager } from '../services/integration/mediaManager';
import { useIntegrationStore } from '../stores/integrationStore';
export function useSeries() {
const [series, setSeries] = useState<UnifiedSeries[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isConnected = useIntegrationStore((s) => s.isServiceConnected('sonarr'));
const fetchSeries = useCallback(async () => {
if (!isConnected) {
setSeries([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getSeries();
setSeries(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch series');
} finally {
setIsLoading(false);
}
}, [isConnected]);
useEffect(() => {
fetchSeries();
}, [fetchSeries]);
return { series, isLoading, error, refetch: fetchSeries };
}
export function useSeriesDetails(id: string | undefined) {
const [series, setSeries] = useState<UnifiedSeries | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) {
setIsLoading(false);
return;
}
const fetchSeries = async () => {
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getSeriesById(id);
setSeries(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch series details');
} finally {
setIsLoading(false);
}
};
fetchSeries();
}, [id]);
return { series, isLoading, error };
}
export function useEpisodes(seriesId: string | undefined) {
const [episodes, setEpisodes] = useState<UnifiedEpisode[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!seriesId) {
setIsLoading(false);
return;
}
const fetchEpisodes = async () => {
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getEpisodes(seriesId);
setEpisodes(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch episodes');
} finally {
setIsLoading(false);
}
};
fetchEpisodes();
}, [seriesId]);
return { episodes, isLoading, error };
}
export function useEpisodesBySeason(seriesId: string | undefined, seasonNumber: number | undefined) {
const [episodes, setEpisodes] = useState<UnifiedEpisode[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!seriesId || seasonNumber === undefined) {
setIsLoading(false);
return;
}
const fetchEpisodes = async () => {
setIsLoading(true);
setError(null);
try {
const data = await mediaManager.getEpisodesBySeason(seriesId, seasonNumber);
setEpisodes(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch episodes');
} finally {
setIsLoading(false);
}
};
fetchEpisodes();
}, [seriesId, seasonNumber]);
return { episodes, isLoading, error };
}
// Filter and sort utilities
export function filterSeries(
series: UnifiedSeries[],
options: {
status?: 'all' | 'continuing' | 'ended';
hasFiles?: boolean;
search?: string;
genre?: string;
}
): UnifiedSeries[] {
return series.filter((s) => {
if (options.status && options.status !== 'all' && s.status !== options.status) {
return false;
}
if (options.hasFiles !== undefined && (s.episodeFileCount > 0) !== options.hasFiles) {
return false;
}
if (options.search) {
const searchLower = options.search.toLowerCase();
if (!s.title.toLowerCase().includes(searchLower)) {
return false;
}
}
if (options.genre && !s.genres?.includes(options.genre)) {
return false;
}
return true;
});
}
export function sortSeries(
series: UnifiedSeries[],
sortBy: 'title' | 'year' | 'rating' | 'added' | 'nextAiring' = 'title',
order: 'asc' | 'desc' = 'asc'
): UnifiedSeries[] {
return [...series].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'title':
comparison = a.title.localeCompare(b.title);
break;
case 'year':
comparison = (a.year || 0) - (b.year || 0);
break;
case 'rating':
comparison = (a.rating || 0) - (b.rating || 0);
break;
case 'added':
comparison = (a.added?.getTime() || 0) - (b.added?.getTime() || 0);
break;
case 'nextAiring':
const aNext = a.nextAiring ? new Date(a.nextAiring).getTime() : Infinity;
const bNext = b.nextAiring ? new Date(b.nextAiring).getTime() : Infinity;
comparison = aNext - bNext;
break;
}
return order === 'desc' ? -comparison : comparison;
});
}

163
src/index.css Normal file
View File

@ -0,0 +1,163 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--netflix-red: #E50914;
--netflix-red-hover: #F40612;
--netflix-black: #141414;
--netflix-dark-gray: #181818;
--netflix-medium-gray: #2F2F2F;
--netflix-light-gray: #808080;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-netflix-black text-white antialiased;
font-family: 'Netflix Sans', 'Helvetica Neue', 'Segoe UI', 'Roboto', sans-serif;
overflow-x: hidden;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #181818;
}
::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Hide scrollbar for movie rows */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
}
@layer components {
.btn-primary {
@apply bg-netflix-red hover:bg-netflix-red-hover text-white font-semibold
py-2 px-6 rounded transition-all duration-200
flex items-center gap-2 active:scale-95;
}
.btn-secondary {
@apply bg-white/20 hover:bg-white/30 text-white font-semibold
py-2 px-6 rounded transition-all duration-200 backdrop-blur-sm
flex items-center gap-2 active:scale-95;
}
.btn-icon {
@apply w-10 h-10 rounded-full bg-netflix-medium-gray/80 hover:bg-netflix-medium-gray
flex items-center justify-center transition-all duration-200
border border-white/20 hover:border-white/40 active:scale-95;
}
.card-hover {
@apply transition-all duration-300 ease-out;
}
.card-hover:hover {
@apply scale-110 z-10;
}
.glass {
@apply bg-black/60 backdrop-blur-xl border border-white/10;
}
.input-field {
@apply w-full bg-netflix-medium-gray/50 border border-white/10 rounded-md
py-3 px-4 text-white placeholder-gray-400
focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20
transition-all duration-200;
}
.shimmer {
@apply bg-gradient-to-r from-netflix-dark-gray via-netflix-medium-gray to-netflix-dark-gray
bg-[length:200%_100%] animate-shimmer;
}
}
@layer utilities {
.text-shadow {
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.8);
}
.gradient-mask-b {
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
}
.gradient-mask-r {
mask-image: linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%);
}
}
/* Video.js custom styling */
.video-js {
font-family: inherit;
}
.video-js .vjs-big-play-button {
@apply bg-netflix-red/80 border-none rounded-full w-20 h-20;
line-height: 5rem;
}
.video-js .vjs-big-play-button:hover {
@apply bg-netflix-red;
}
.video-js .vjs-control-bar {
@apply bg-gradient-to-t from-black/80 to-transparent h-16;
}
.video-js .vjs-play-progress,
.video-js .vjs-volume-level {
@apply bg-netflix-red;
}
.video-js .vjs-slider {
@apply bg-white/30;
}
/* Capacitor safe area support */
@supports (padding: env(safe-area-inset-top)) {
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-left {
padding-left: env(safe-area-inset-left);
}
.safe-right {
padding-right: env(safe-area-inset-right);
}
}

14
src/main.tsx Normal file
View File

@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

234
src/pages/AlbumDetails.tsx Normal file
View File

@ -0,0 +1,234 @@
import { useParams, useNavigate, Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Play,
Shuffle,
Disc,
Clock,
Calendar,
Download,
Heart,
MoreHorizontal,
} from 'lucide-react';
import { useAlbumDetails, useTracks } from '../hooks/useMusic';
import { TrackList } from '../components/music';
import Button from '../components/ui/Button';
import Badge from '../components/ui/Badge';
import { formatDuration } from '../utils/helpers';
export default function AlbumDetails() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { album, isLoading: albumLoading, error: albumError } = useAlbumDetails(id);
const { tracks, isLoading: tracksLoading } = useTracks(id);
if (albumLoading) {
return (
<div className="min-h-screen bg-netflix-black pt-24 pb-16">
<div className="max-w-4xl mx-auto px-4 animate-pulse">
<div className="flex gap-6 mb-8">
<div className="w-48 h-48 bg-white/10 rounded-lg" />
<div className="flex-1">
<div className="h-8 bg-white/10 rounded w-1/3 mb-4" />
<div className="h-12 bg-white/10 rounded w-2/3 mb-4" />
<div className="h-4 bg-white/10 rounded w-1/4" />
</div>
</div>
</div>
</div>
);
}
if (albumError || !album) {
return (
<div className="min-h-screen flex items-center justify-center bg-netflix-black">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Album not found</h1>
<p className="text-gray-400 mb-4">{albumError}</p>
<Button onClick={() => navigate(-1)}>Go Back</Button>
</div>
</div>
);
}
// Calculate total duration
const totalDuration = tracks.reduce((acc, track) => acc + track.duration, 0);
// Release year
const releaseYear = album.releaseDate
? new Date(album.releaseDate).getFullYear()
: album.year;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-4xl mx-auto px-4 md:px-8">
{/* Album Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row gap-6 mb-8"
>
{/* Album Cover */}
<div className="w-48 h-48 md:w-56 md:h-56 rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
{album.poster ? (
<img
src={album.poster}
alt={album.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-indigo-600 to-purple-600 flex items-center justify-center">
<Disc size={64} className="text-white/50" />
</div>
)}
</div>
{/* Album Info */}
<div className="flex flex-col justify-end">
<div className="flex items-center gap-2 mb-2">
{album.albumType && (
<span className="text-sm text-gray-400 uppercase tracking-wider">
{album.albumType}
</span>
)}
</div>
<h1 className="text-3xl md:text-5xl font-bold mb-2">{album.title}</h1>
{/* Artist Link */}
<Link
to={`/music/artist/${album.artistId}`}
className="text-lg text-gray-300 hover:text-white hover:underline transition-colors mb-4"
>
{album.artistName}
</Link>
{/* Stats */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
{releaseYear && (
<span className="flex items-center gap-1">
<Calendar size={14} />
{releaseYear}
</span>
)}
<span>{album.trackCount} songs</span>
{totalDuration > 0 && (
<span className="flex items-center gap-1">
<Clock size={14} />
{formatDuration(totalDuration / 1000)}
</span>
)}
{album.trackFileCount > 0 && (
<Badge
size="sm"
variant={album.trackFileCount === album.trackCount ? 'success' : 'warning'}
>
{album.trackFileCount}/{album.trackCount} downloaded
</Badge>
)}
</div>
</div>
</motion.div>
{/* Actions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="flex flex-wrap items-center gap-3 mb-8"
>
<Button
size="lg"
className="bg-green-500 hover:bg-green-400"
leftIcon={<Play size={20} fill="black" />}
>
Play
</Button>
<Button
variant="secondary"
size="lg"
leftIcon={<Shuffle size={20} />}
>
Shuffle
</Button>
<Button
variant="ghost"
size="lg"
leftIcon={<Heart size={20} />}
/>
<Button
variant="ghost"
size="lg"
leftIcon={<Download size={20} />}
/>
<Button
variant="ghost"
size="lg"
leftIcon={<MoreHorizontal size={20} />}
/>
</motion.div>
{/* Genres */}
{album.genres && album.genres.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="flex flex-wrap gap-2 mb-6"
>
{album.genres.map((genre) => (
<Badge key={genre} className="bg-white/10">
{genre}
</Badge>
))}
</motion.div>
)}
{/* Track List */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="glass rounded-lg overflow-hidden"
>
{tracksLoading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-12 bg-white/5 rounded animate-pulse" />
))}
</div>
) : (
<TrackList
tracks={tracks}
showArtist={false}
showAlbum={false}
onTrackClick={(track) => {
console.log('Play track:', track);
}}
/>
)}
</motion.div>
{/* Album Overview */}
{album.overview && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="mt-8"
>
<h2 className="text-xl font-semibold mb-3">About</h2>
<p className="text-gray-300 leading-relaxed">
{album.overview}
</p>
</motion.div>
)}
</div>
</motion.div>
);
}

215
src/pages/ArtistDetails.tsx Normal file
View File

@ -0,0 +1,215 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Play,
Shuffle,
Disc,
Music,
Clock,
Download,
RefreshCw,
ExternalLink,
} from 'lucide-react';
import { useArtistDetails, useAlbums } from '../hooks/useMusic';
import { AlbumGrid } from '../components/music';
import Button from '../components/ui/Button';
import Badge from '../components/ui/Badge';
import { ArtistDetailsSkeleton } from '../components/ui/Skeleton';
export default function ArtistDetails() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { artist, isLoading: artistLoading, error: artistError } = useArtistDetails(id);
const artistSourceId = id?.split('-')[1];
const { albums, isLoading: albumsLoading } = useAlbums(artistSourceId ? `lidarr-${artistSourceId}` : undefined);
if (artistLoading) return <ArtistDetailsSkeleton />;
if (artistError || !artist) {
return (
<div className="min-h-screen flex items-center justify-center bg-netflix-black">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Artist not found</h1>
<p className="text-gray-400 mb-4">{artistError}</p>
<Button onClick={() => navigate(-1)}>Go Back</Button>
</div>
</div>
);
}
// Filter albums for this artist
const artistAlbums = albums.filter((a) => a.artistId === artist.id);
// Calculate total duration
const totalDuration = artistAlbums.reduce((acc, album) => acc + album.duration, 0);
const formatDuration = (ms: number) => {
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black"
>
{/* Hero Background */}
<div className="relative h-[40vh] md:h-[50vh]">
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${artist.fanart || artist.banner || artist.poster})`,
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/80 to-netflix-black/40" />
</div>
{/* Content */}
<div className="max-w-7xl mx-auto px-4 md:px-8 -mt-32 relative z-10">
{/* Artist Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row items-end gap-6 mb-8"
>
{/* Artist Image */}
<div className="w-48 h-48 md:w-56 md:h-56 rounded-full overflow-hidden shadow-2xl flex-shrink-0">
{artist.poster ? (
<img
src={artist.poster}
alt={artist.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-purple-600 to-pink-600 flex items-center justify-center">
<Music size={64} className="text-white/50" />
</div>
)}
</div>
{/* Artist Info */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{artist.status === 'continuing' && (
<Badge variant="success">Active</Badge>
)}
{artist.artistType && (
<Badge className="bg-white/10 capitalize">{artist.artistType}</Badge>
)}
</div>
<h1 className="text-4xl md:text-6xl font-bold mb-4">{artist.title}</h1>
{/* Stats */}
<div className="flex flex-wrap items-center gap-4 text-gray-400">
<span className="flex items-center gap-1">
<Disc size={18} />
{artist.albumCount} Albums
</span>
<span className="flex items-center gap-1">
<Music size={18} />
{artist.trackCount} Tracks
</span>
{totalDuration > 0 && (
<span className="flex items-center gap-1">
<Clock size={18} />
{formatDuration(totalDuration)}
</span>
)}
</div>
</div>
</motion.div>
{/* Actions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="flex flex-wrap items-center gap-3 mb-8"
>
<Button
size="lg"
className="bg-green-500 hover:bg-green-400"
leftIcon={<Play size={20} fill="black" />}
>
Play
</Button>
<Button
variant="secondary"
size="lg"
leftIcon={<Shuffle size={20} />}
>
Shuffle
</Button>
<Button
variant="ghost"
size="lg"
leftIcon={<Download size={20} />}
>
Download All
</Button>
<Button
variant="ghost"
size="lg"
leftIcon={<RefreshCw size={20} />}
>
Refresh
</Button>
</motion.div>
{/* Genres */}
{artist.genres && artist.genres.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="flex flex-wrap gap-2 mb-8"
>
{artist.genres.map((genre) => (
<span
key={genre}
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-full text-sm transition-colors cursor-pointer"
>
{genre}
</span>
))}
</motion.div>
)}
{/* Overview */}
{artist.overview && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="mb-8"
>
<h2 className="text-xl font-semibold mb-3">About</h2>
<p className="text-gray-300 leading-relaxed max-w-3xl">
{artist.overview}
</p>
</motion.div>
)}
{/* Discography */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="mb-12"
>
<h2 className="text-2xl font-semibold mb-4">Discography</h2>
<AlbumGrid
albums={artistAlbums}
isLoading={albumsLoading}
showArtist={false}
emptyMessage="No albums found"
/>
</motion.div>
</div>
</motion.div>
);
}

260
src/pages/Browse.tsx Normal file
View File

@ -0,0 +1,260 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Filter, SortAsc, X } from 'lucide-react';
import MovieGrid from '../components/movie/MovieGrid';
import Select from '../components/ui/Select';
import Button from '../components/ui/Button';
import { useMovies } from '../hooks/useMovies';
import { GENRES } from '../types';
import type { ListMoviesParams } from '../types';
const SORT_OPTIONS = [
{ value: 'date_added', label: 'Recently Added' },
{ value: 'download_count', label: 'Most Downloaded' },
{ value: 'rating', label: 'Top Rated' },
{ value: 'year', label: 'Year' },
{ value: 'title', label: 'Title' },
{ value: 'seeds', label: 'Most Seeds' },
];
const QUALITY_OPTIONS = [
{ value: '', label: 'All Qualities' },
{ value: '720p', label: '720p' },
{ value: '1080p', label: '1080p' },
{ value: '2160p', label: '4K' },
{ value: '3D', label: '3D' },
];
const RATING_OPTIONS = [
{ value: '0', label: 'All Ratings' },
{ value: '5', label: '5+ Stars' },
{ value: '6', label: '6+ Stars' },
{ value: '7', label: '7+ Stars' },
{ value: '8', label: '8+ Stars' },
{ value: '9', label: '9+ Stars' },
];
export default function Browse() {
const { genre: urlGenre } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const [showFilters, setShowFilters] = useState(false);
const observerRef = useRef<IntersectionObserver>();
const loadMoreRef = useRef<HTMLDivElement>(null);
// Get initial values from URL
const [filters, setFilters] = useState<ListMoviesParams>({
genre: urlGenre || searchParams.get('genre') || '',
sort_by: (searchParams.get('sort') as ListMoviesParams['sort_by']) || 'date_added',
quality: (searchParams.get('quality') as ListMoviesParams['quality']) || undefined,
minimum_rating: parseInt(searchParams.get('min_rating') || '0') || 0,
limit: 20,
});
const { movies, isLoading, totalCount, hasMore, loadMore, refresh } = useMovies(filters);
// Update URL when filters change
useEffect(() => {
const params = new URLSearchParams();
if (filters.genre) params.set('genre', filters.genre);
if (filters.sort_by && filters.sort_by !== 'date_added') params.set('sort', filters.sort_by);
if (filters.quality) params.set('quality', filters.quality);
if (filters.minimum_rating && filters.minimum_rating > 0) params.set('min_rating', filters.minimum_rating.toString());
setSearchParams(params, { replace: true });
}, [filters, setSearchParams]);
// Update filters when URL genre changes
useEffect(() => {
if (urlGenre) {
setFilters((prev) => ({ ...prev, genre: urlGenre }));
}
}, [urlGenre]);
// Infinite scroll
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoading) {
loadMore();
}
},
{ threshold: 0.1 }
);
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => observerRef.current?.disconnect();
}, [hasMore, isLoading, loadMore]);
const handleFilterChange = (key: keyof ListMoviesParams, value: string | number) => {
setFilters((prev) => ({
...prev,
[key]: value || undefined,
}));
};
const clearFilters = () => {
setFilters({
genre: '',
sort_by: 'date_added',
quality: undefined,
minimum_rating: 0,
limit: 20,
});
};
const activeFilterCount = [
filters.genre,
filters.quality,
filters.minimum_rating && filters.minimum_rating > 0,
].filter(Boolean).length;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-[1920px] mx-auto px-4 md:px-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl md:text-4xl font-bold">
{urlGenre ? `${urlGenre.charAt(0).toUpperCase() + urlGenre.slice(1)} Movies` : 'Browse Movies'}
</h1>
<p className="text-gray-400 mt-1">
{totalCount.toLocaleString()} movies found
</p>
</div>
<div className="flex items-center gap-3">
{/* Sort */}
<Select
options={SORT_OPTIONS}
value={filters.sort_by}
onChange={(e) => handleFilterChange('sort_by', e.target.value)}
className="w-48"
/>
{/* Filter Toggle */}
<Button
variant={showFilters ? 'primary' : 'secondary'}
onClick={() => setShowFilters(!showFilters)}
leftIcon={<Filter size={18} />}
>
Filters
{activeFilterCount > 0 && (
<span className="ml-1 px-1.5 py-0.5 bg-white/20 rounded-full text-xs">
{activeFilterCount}
</span>
)}
</Button>
</div>
</div>
{/* Filters Panel */}
{showFilters && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="mb-8 p-6 glass rounded-lg"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Filters</h3>
{activeFilterCount > 0 && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
<X size={16} className="mr-1" />
Clear All
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Genre */}
<Select
label="Genre"
options={[
{ value: '', label: 'All Genres' },
...GENRES.map((g) => ({ value: g.toLowerCase(), label: g })),
]}
value={filters.genre || ''}
onChange={(e) => handleFilterChange('genre', e.target.value)}
/>
{/* Quality */}
<Select
label="Quality"
options={QUALITY_OPTIONS}
value={filters.quality || ''}
onChange={(e) => handleFilterChange('quality', e.target.value)}
/>
{/* Rating */}
<Select
label="Minimum Rating"
options={RATING_OPTIONS}
value={filters.minimum_rating?.toString() || '0'}
onChange={(e) => handleFilterChange('minimum_rating', parseInt(e.target.value))}
/>
{/* Order */}
<Select
label="Order"
options={[
{ value: 'desc', label: 'Descending' },
{ value: 'asc', label: 'Ascending' },
]}
value={filters.order_by || 'desc'}
onChange={(e) => handleFilterChange('order_by', e.target.value)}
/>
</div>
</motion.div>
)}
{/* Genre Pills */}
<div className="flex flex-wrap gap-2 mb-8 overflow-x-auto hide-scrollbar pb-2">
<button
onClick={() => handleFilterChange('genre', '')}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all whitespace-nowrap ${
!filters.genre
? 'bg-netflix-red text-white'
: 'bg-white/10 hover:bg-white/20'
}`}
>
All
</button>
{GENRES.slice(0, 12).map((genre) => (
<button
key={genre}
onClick={() => handleFilterChange('genre', genre.toLowerCase())}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all whitespace-nowrap ${
filters.genre === genre.toLowerCase()
? 'bg-netflix-red text-white'
: 'bg-white/10 hover:bg-white/20'
}`}
>
{genre}
</button>
))}
</div>
{/* Movie Grid */}
<MovieGrid movies={movies} isLoading={isLoading && movies.length === 0} />
{/* Load More Trigger */}
<div ref={loadMoreRef} className="h-20 flex items-center justify-center">
{isLoading && movies.length > 0 && (
<div className="flex items-center gap-2 text-gray-400">
<div className="w-6 h-6 border-2 border-netflix-red border-t-transparent rounded-full animate-spin" />
Loading more...
</div>
)}
</div>
</div>
</motion.div>
);
}

138
src/pages/Calendar.tsx Normal file
View File

@ -0,0 +1,138 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Calendar as CalendarIcon, List, Grid, RefreshCw, AlertCircle } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useCalendar } from '../hooks/useCalendar';
import { useHasConnectedServices } from '../hooks/useIntegration';
import { CalendarView, UpcomingList } from '../components/calendar';
import Button from '../components/ui/Button';
import type { CalendarItem } from '../types/unified';
type ViewMode = 'calendar' | 'list';
export default function Calendar() {
const { items, isLoading, error, refetch } = useCalendar();
const { hasAny } = useHasConnectedServices();
const navigate = useNavigate();
const [viewMode, setViewMode] = useState<ViewMode>('calendar');
const handleItemClick = (item: CalendarItem) => {
switch (item.type) {
case 'movie':
navigate(`/movie/radarr-${item.mediaId}`);
break;
case 'episode':
if (item.seriesId) {
navigate(`/tv/sonarr-${item.seriesId}`);
}
break;
case 'album':
if (item.artistId) {
navigate(`/music/artist/lidarr-${item.artistId}`);
}
break;
}
};
if (!hasAny) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertCircle size={64} className="text-gray-500 mb-4" />
<h1 className="text-2xl font-bold mb-2">No Services Connected</h1>
<p className="text-gray-400 mb-6 max-w-md">
Connect to Radarr, Sonarr, or Lidarr to view your media calendar.
Go to Settings to configure your connections.
</p>
<Link to="/settings">
<Button>Go to Settings</Button>
</Link>
</div>
</div>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-7xl mx-auto px-4 md:px-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl md:text-4xl font-bold flex items-center gap-3">
<CalendarIcon className="text-netflix-red" />
Calendar
</h1>
<div className="flex items-center gap-2">
{/* View Toggle */}
<div className="flex rounded-lg overflow-hidden border border-white/20">
<button
onClick={() => setViewMode('calendar')}
className={`p-2 transition-colors ${
viewMode === 'calendar' ? 'bg-netflix-red text-white' : 'hover:bg-white/10'
}`}
title="Calendar View"
>
<Grid size={18} />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 transition-colors ${
viewMode === 'list' ? 'bg-netflix-red text-white' : 'hover:bg-white/10'
}`}
title="List View"
>
<List size={18} />
</button>
</div>
<Button
variant="ghost"
onClick={() => refetch()}
leftIcon={<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />}
>
Refresh
</Button>
</div>
</div>
<p className="text-gray-400">
Upcoming movies, TV episodes, and album releases from your library
</p>
</div>
{/* Error State */}
{error && (
<div className="glass rounded-lg p-6 mb-8 border border-red-500/50 bg-red-500/10">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Content */}
{viewMode === 'calendar' ? (
<CalendarView
items={items}
isLoading={isLoading}
onItemClick={handleItemClick}
/>
) : (
<UpcomingList
items={items}
isLoading={isLoading}
onItemClick={handleItemClick}
/>
)}
</div>
</motion.div>
);
}

275
src/pages/Downloads.tsx Normal file
View File

@ -0,0 +1,275 @@
import { motion } from 'framer-motion';
import { Download, Trash2, Pause, Play, FolderOpen, X } from 'lucide-react';
import { Link } from 'react-router-dom';
import Button from '../components/ui/Button';
import ProgressBar from '../components/ui/ProgressBar';
import Badge from '../components/ui/Badge';
import { useDownloadStore } from '../stores/downloadStore';
import { formatBytes, formatSpeed } from '../services/torrent/webtorrent';
export default function Downloads() {
const {
items,
pauseDownload,
resumeDownload,
removeDownload,
clearCompleted,
} = useDownloadStore();
const activeDownloads = items.filter(
(item) => item.status === 'downloading' || item.status === 'queued'
);
const completedDownloads = items.filter((item) => item.status === 'completed');
const failedDownloads = items.filter((item) => item.status === 'error');
const getStatusBadge = (status: string) => {
switch (status) {
case 'downloading':
return <Badge variant="info">Downloading</Badge>;
case 'queued':
return <Badge>Queued</Badge>;
case 'paused':
return <Badge variant="warning">Paused</Badge>;
case 'completed':
return <Badge variant="success">Completed</Badge>;
case 'error':
return <Badge variant="error">Failed</Badge>;
default:
return <Badge>{status}</Badge>;
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-[1920px] mx-auto px-4 md:px-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl md:text-4xl font-bold flex items-center gap-3">
<Download className="text-netflix-red" />
Downloads
</h1>
<p className="text-gray-400 mt-1">
{activeDownloads.length} active, {completedDownloads.length} completed
</p>
</div>
{completedDownloads.length > 0 && (
<Button
variant="ghost"
onClick={clearCompleted}
leftIcon={<Trash2 size={18} />}
>
Clear Completed
</Button>
)}
</div>
{/* Active Downloads */}
{activeDownloads.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Active Downloads</h2>
<div className="space-y-4">
{activeDownloads.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="p-4 glass rounded-lg"
>
<div className="flex gap-4">
{/* Poster */}
<Link to={`/movie/${item.movie.id}`} className="flex-shrink-0">
<img
src={item.movie.medium_cover_image}
alt={item.movie.title}
className="w-20 md:w-24 rounded-md"
/>
</Link>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold line-clamp-1">
{item.movie.title}
</h3>
<div className="flex items-center gap-2 text-sm text-gray-400 mt-1">
<span>{item.torrent.quality}</span>
<span></span>
<span>{item.torrent.size}</span>
</div>
</div>
{getStatusBadge(item.status)}
</div>
{/* Progress */}
<div className="mt-4">
<div className="flex justify-between text-sm mb-1">
<span>{(item.progress * 100).toFixed(1)}%</span>
<span className="text-gray-400">
{formatSpeed(item.downloadSpeed)} {item.peers} peers
</span>
</div>
<ProgressBar progress={item.progress * 100} />
</div>
{/* Actions */}
<div className="flex gap-2 mt-4">
{item.status === 'downloading' ? (
<Button
variant="secondary"
size="sm"
onClick={() => pauseDownload(item.id)}
leftIcon={<Pause size={16} />}
>
Pause
</Button>
) : (
<Button
size="sm"
onClick={() => resumeDownload(item.id)}
leftIcon={<Play size={16} />}
>
Resume
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Cancel this download?')) {
removeDownload(item.id);
}
}}
leftIcon={<X size={16} />}
>
Cancel
</Button>
</div>
</div>
</div>
</motion.div>
))}
</div>
</div>
)}
{/* Completed Downloads */}
{completedDownloads.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Completed</h2>
<div className="space-y-4">
{completedDownloads.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="flex items-center gap-4 p-4 glass rounded-lg"
>
<Link to={`/movie/${item.movie.id}`} className="flex-shrink-0">
<img
src={item.movie.medium_cover_image}
alt={item.movie.title}
className="w-16 rounded-md"
/>
</Link>
<div className="flex-1 min-w-0">
<h3 className="font-semibold line-clamp-1">{item.movie.title}</h3>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span>{item.torrent.quality}</span>
<span></span>
<span>{item.torrent.size}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Link to={`/player/${item.movie.id}`}>
<Button size="sm" leftIcon={<Play size={16} />}>
Play
</Button>
</Link>
{item.filePath && (
<Button
variant="secondary"
size="sm"
leftIcon={<FolderOpen size={16} />}
>
Open
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => removeDownload(item.id)}
>
<Trash2 size={16} />
</Button>
</div>
</motion.div>
))}
</div>
</div>
)}
{/* Failed Downloads */}
{failedDownloads.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Failed</h2>
<div className="space-y-4">
{failedDownloads.map((item) => (
<div
key={item.id}
className="flex items-center gap-4 p-4 glass rounded-lg border border-red-500/20"
>
<img
src={item.movie.medium_cover_image}
alt={item.movie.title}
className="w-16 rounded-md opacity-50"
/>
<div className="flex-1">
<h3 className="font-semibold">{item.movie.title}</h3>
<p className="text-sm text-red-400">{item.error}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeDownload(item.id)}
>
<Trash2 size={16} />
</Button>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{items.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-16"
>
<Download size={64} className="mx-auto text-gray-600 mb-4" />
<h2 className="text-xl text-gray-400 mb-2">No downloads</h2>
<p className="text-gray-500 mb-6">
Download movies for offline viewing
</p>
<Link to="/browse">
<Button>Browse Movies</Button>
</Link>
</motion.div>
)}
</div>
</motion.div>
);
}

157
src/pages/History.tsx Normal file
View File

@ -0,0 +1,157 @@
import { motion } from 'framer-motion';
import { Clock, Trash2, Play, RotateCcw } from 'lucide-react';
import { Link } from 'react-router-dom';
import Button from '../components/ui/Button';
import ProgressBar from '../components/ui/ProgressBar';
import { useHistoryStore } from '../stores/historyStore';
export default function History() {
const { items, removeFromHistory, clearHistory } = useHistoryStore();
const formatDate = (date: Date) => {
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return d.toLocaleDateString();
};
const formatProgress = (progress: number, duration: number) => {
const percent = (progress / duration) * 100;
const remaining = duration - progress;
const mins = Math.floor(remaining / 60);
return { percent, remaining: `${mins} min remaining` };
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-[1920px] mx-auto px-4 md:px-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl md:text-4xl font-bold flex items-center gap-3">
<Clock className="text-netflix-red" />
Watch History
</h1>
<p className="text-gray-400 mt-1">
{items.length} {items.length === 1 ? 'movie' : 'movies'} in history
</p>
</div>
{items.length > 0 && (
<Button
variant="ghost"
onClick={() => {
if (confirm('Are you sure you want to clear your watch history?')) {
clearHistory();
}
}}
leftIcon={<Trash2 size={18} />}
>
Clear History
</Button>
)}
</div>
{/* Content */}
{items.length > 0 ? (
<div className="space-y-4">
{items.map((item, index) => {
const { percent, remaining } = formatProgress(item.progress, item.duration);
const isCompleted = item.completed || percent >= 90;
return (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="flex gap-4 p-4 glass rounded-lg hover:bg-white/5 transition-colors"
>
{/* Poster */}
<Link to={`/movie/${item.movie.id}`} className="flex-shrink-0">
<div className="relative w-24 md:w-32 aspect-[2/3] rounded-md overflow-hidden">
<img
src={item.movie.medium_cover_image}
alt={item.movie.title}
className="w-full h-full object-cover"
/>
{!isCompleted && (
<div className="absolute bottom-0 left-0 right-0">
<ProgressBar progress={percent} size="sm" />
</div>
)}
</div>
</Link>
{/* Info */}
<div className="flex-1 min-w-0">
<Link to={`/movie/${item.movie.id}`}>
<h3 className="text-lg font-semibold hover:text-netflix-red transition-colors line-clamp-1">
{item.movie.title}
</h3>
</Link>
<div className="flex items-center gap-3 text-sm text-gray-400 mt-1">
<span>{item.movie.year}</span>
<span>{item.movie.runtime} min</span>
{item.movie.genres?.[0] && <span>{item.movie.genres[0]}</span>}
</div>
<p className="text-sm text-gray-500 mt-2">
Watched {formatDate(item.watchedAt)}
</p>
{!isCompleted && (
<p className="text-sm text-netflix-red mt-1">{remaining}</p>
)}
{isCompleted && (
<p className="text-sm text-green-400 mt-1">Completed</p>
)}
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<Link to={`/player/${item.movie.id}`}>
<Button size="sm" leftIcon={isCompleted ? <RotateCcw size={16} /> : <Play size={16} />}>
{isCompleted ? 'Watch Again' : 'Resume'}
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => removeFromHistory(item.movieId)}
>
<Trash2 size={16} />
</Button>
</div>
</motion.div>
);
})}
</div>
) : (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-16"
>
<Clock size={64} className="mx-auto text-gray-600 mb-4" />
<h2 className="text-xl text-gray-400 mb-2">No watch history</h2>
<p className="text-gray-500 mb-6">
Movies you watch will appear here
</p>
<Link to="/browse">
<Button>Browse Movies</Button>
</Link>
</motion.div>
)}
</div>
</motion.div>
);
}

92
src/pages/Home.tsx Normal file
View File

@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import Hero from '../components/movie/Hero';
import MovieRow from '../components/movie/MovieRow';
import { HeroSkeleton, MovieRowSkeleton } from '../components/ui/Skeleton';
import { useTrending, useLatest, useTopRated, useByGenre } from '../hooks/useMovies';
export default function Home() {
const { movies: trending, isLoading: trendingLoading } = useTrending();
const { movies: latest, isLoading: latestLoading } = useLatest();
const { movies: topRated, isLoading: topRatedLoading } = useTopRated();
const { movies: action, isLoading: actionLoading } = useByGenre('action');
const { movies: comedy, isLoading: comedyLoading } = useByGenre('comedy');
const { movies: horror, isLoading: horrorLoading } = useByGenre('horror');
const { movies: scifi, isLoading: scifiLoading } = useByGenre('sci-fi');
const { movies: drama, isLoading: dramaLoading } = useByGenre('drama');
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black"
>
{/* Hero Section */}
{trendingLoading ? (
<HeroSkeleton />
) : (
<Hero movies={trending.slice(0, 5)} />
)}
{/* Movie Rows */}
<div className="-mt-32 relative z-10">
<MovieRow
title="Trending Now"
movies={trending}
isLoading={trendingLoading}
linkTo="/browse?sort=download_count"
/>
<MovieRow
title="Latest Releases"
movies={latest}
isLoading={latestLoading}
linkTo="/browse?sort=date_added"
/>
<MovieRow
title="Top Rated"
movies={topRated}
isLoading={topRatedLoading}
linkTo="/browse?sort=rating&min_rating=8"
/>
<MovieRow
title="Action Movies"
movies={action}
isLoading={actionLoading}
linkTo="/browse/action"
/>
<MovieRow
title="Comedy"
movies={comedy}
isLoading={comedyLoading}
linkTo="/browse/comedy"
/>
<MovieRow
title="Horror"
movies={horror}
isLoading={horrorLoading}
linkTo="/browse/horror"
/>
<MovieRow
title="Sci-Fi"
movies={scifi}
isLoading={scifiLoading}
linkTo="/browse/sci-fi"
/>
<MovieRow
title="Drama"
movies={drama}
isLoading={dramaLoading}
linkTo="/browse/drama"
/>
</div>
</motion.div>
);
}

305
src/pages/MovieDetails.tsx Normal file
View File

@ -0,0 +1,305 @@
import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Play,
Plus,
Check,
Star,
Clock,
Calendar,
Download,
Share2,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useMovieDetails, useMovieSuggestions } from '../hooks/useMovies';
import { useWatchlistStore } from '../stores/watchlistStore';
import { useDownloadStore } from '../stores/downloadStore';
import Button from '../components/ui/Button';
import Badge from '../components/ui/Badge';
import MovieRow from '../components/movie/MovieRow';
import TorrentSelector from '../components/movie/TorrentSelector';
import Modal from '../components/ui/Modal';
import { MovieDetailsSkeleton } from '../components/ui/Skeleton';
import type { Torrent } from '../types';
export default function MovieDetails() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const movieId = parseInt(id || '0');
const { movie, isLoading, error } = useMovieDetails(movieId);
const { movies: suggestions, isLoading: suggestionsLoading } = useMovieSuggestions(movieId);
const { isInWatchlist, addToWatchlist, removeFromWatchlist } = useWatchlistStore();
const { addDownload } = useDownloadStore();
const [showFullDescription, setShowFullDescription] = useState(false);
const [showTorrentModal, setShowTorrentModal] = useState(false);
if (isLoading) return <MovieDetailsSkeleton />;
if (error || !movie) {
return (
<div className="min-h-screen flex items-center justify-center bg-netflix-black">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Movie not found</h1>
<p className="text-gray-400 mb-4">{error}</p>
<Button onClick={() => navigate(-1)}>Go Back</Button>
</div>
</div>
);
}
const inWatchlist = isInWatchlist(movie.id);
const handleWatchlistClick = () => {
if (inWatchlist) {
removeFromWatchlist(movie.id);
} else {
addToWatchlist(movie);
}
};
const handleStream = (torrent: Torrent) => {
navigate(`/player/${movie.id}?quality=${torrent.quality}`);
setShowTorrentModal(false);
};
const handleDownload = (torrent: Torrent) => {
addDownload(movie, torrent);
setShowTorrentModal(false);
navigate('/downloads');
};
const handleShare = async () => {
try {
await navigator.share({
title: movie.title,
text: movie.synopsis,
url: window.location.href,
});
} catch {
await navigator.clipboard.writeText(window.location.href);
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black"
>
{/* Hero Background */}
<div className="relative h-[60vh] md:h-[70vh]">
<img
src={movie.background_image_original || movie.background_image}
alt={movie.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black via-transparent to-transparent" />
</div>
{/* Content */}
<div className="max-w-7xl mx-auto px-4 md:px-8 -mt-64 md:-mt-80 relative z-10">
<div className="flex flex-col md:flex-row gap-8">
{/* Poster */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="flex-shrink-0 hidden md:block"
>
<img
src={movie.large_cover_image}
alt={movie.title}
className="w-[300px] rounded-lg shadow-2xl"
/>
</motion.div>
{/* Details */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex-1"
>
{/* Title */}
<h1 className="text-3xl md:text-5xl font-bold mb-4">{movie.title}</h1>
{/* Meta */}
<div className="flex flex-wrap items-center gap-3 md:gap-4 mb-4">
<span className="flex items-center gap-1 text-green-400 font-semibold">
<Star size={18} fill="currentColor" />
{movie.rating}/10
</span>
<span className="flex items-center gap-1 text-gray-300">
<Calendar size={16} />
{movie.year}
</span>
<span className="flex items-center gap-1 text-gray-300">
<Clock size={16} />
{movie.runtime} min
</span>
{movie.mpa_rating && (
<Badge>{movie.mpa_rating}</Badge>
)}
<Badge variant="info">{movie.language?.toUpperCase()}</Badge>
</div>
{/* Genres */}
<div className="flex flex-wrap gap-2 mb-6">
{movie.genres?.map((genre) => (
<Link
key={genre}
to={`/browse/${genre.toLowerCase()}`}
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-full text-sm transition-colors"
>
{genre}
</Link>
))}
</div>
{/* Actions */}
<div className="flex flex-wrap items-center gap-3 mb-8">
<Link to={`/player/${movie.id}`}>
<Button size="lg" leftIcon={<Play size={20} fill="white" />}>
Play
</Button>
</Link>
<Button
variant="secondary"
size="lg"
onClick={() => setShowTorrentModal(true)}
leftIcon={<Download size={20} />}
>
Download
</Button>
<Button
variant="icon"
size="lg"
onClick={handleWatchlistClick}
>
{inWatchlist ? <Check size={24} /> : <Plus size={24} />}
</Button>
<Button variant="icon" size="lg" onClick={handleShare}>
<Share2 size={20} />
</Button>
</div>
{/* Description */}
<div className="mb-8">
<h3 className="text-xl font-semibold mb-2">Synopsis</h3>
<p className={`text-gray-300 leading-relaxed ${!showFullDescription && 'line-clamp-4'}`}>
{movie.description_full || movie.synopsis || movie.summary}
</p>
{(movie.description_full?.length || 0) > 300 && (
<button
onClick={() => setShowFullDescription(!showFullDescription)}
className="flex items-center gap-1 text-netflix-red mt-2 hover:underline"
>
{showFullDescription ? (
<>Show Less <ChevronUp size={16} /></>
) : (
<>Show More <ChevronDown size={16} /></>
)}
</button>
)}
</div>
{/* Cast */}
{movie.cast && movie.cast.length > 0 && (
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4">Cast</h3>
<div className="flex flex-wrap gap-4">
{movie.cast.map((member) => (
<div key={member.imdb_code} className="flex items-center gap-3">
{member.url_small_image ? (
<img
src={member.url_small_image}
alt={member.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-700 flex items-center justify-center">
{member.name.charAt(0)}
</div>
)}
<div>
<p className="font-medium">{member.name}</p>
<p className="text-sm text-gray-400">{member.character_name}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Trailer */}
{movie.yt_trailer_code && (
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4">Trailer</h3>
<div className="aspect-video max-w-2xl rounded-lg overflow-hidden">
<iframe
src={`https://www.youtube.com/embed/${movie.yt_trailer_code}`}
title="Trailer"
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
)}
{/* Available Qualities */}
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4">Available Qualities</h3>
<div className="flex flex-wrap gap-3">
{movie.torrents?.map((torrent) => (
<div
key={torrent.hash}
className="p-4 glass rounded-lg flex flex-col items-center"
>
<span className="text-lg font-bold">{torrent.quality}</span>
<span className="text-sm text-gray-400">{torrent.size}</span>
<div className="flex gap-2 mt-2 text-xs text-gray-500">
<span className="text-green-400">{torrent.seeds} seeds</span>
<span>{torrent.peers} peers</span>
</div>
</div>
))}
</div>
</div>
</motion.div>
</div>
{/* Suggestions */}
<div className="mt-12">
<MovieRow
title="You May Also Like"
movies={suggestions}
isLoading={suggestionsLoading}
/>
</div>
</div>
{/* Torrent Selector Modal */}
<Modal
isOpen={showTorrentModal}
onClose={() => setShowTorrentModal(false)}
title={`Download ${movie.title}`}
size="lg"
>
<div className="p-6">
<TorrentSelector
movie={movie}
onStream={handleStream}
onDownload={handleDownload}
/>
</div>
</Modal>
</motion.div>
);
}

360
src/pages/Music.tsx Normal file
View File

@ -0,0 +1,360 @@
import { useState, useMemo } from 'react';
import { motion } from 'framer-motion';
import { Music as MusicIcon, Search, RefreshCw, AlertCircle, Plus, Play, Disc } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useArtists, useAlbums } from '../hooks/useMusic';
import { useHasConnectedServices } from '../hooks/useIntegration';
import { UnifiedArtist, UnifiedAlbum } from '../types/unified';
import Button from '../components/ui/Button';
import { ArtistCardSkeleton, AlbumCardSkeleton } from '../components/ui/Skeleton';
// Hero component for Music
function MusicHero({ artists }: { artists: UnifiedArtist[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const featuredArtists = artists.slice(0, 5);
if (featuredArtists.length === 0) return null;
const current = featuredArtists[currentIndex];
return (
<div className="relative h-[70vh] min-h-[500px] overflow-hidden">
{/* Background Image */}
<div className="absolute inset-0">
<img
src={current.fanart || current.poster || '/placeholder-backdrop.jpg'}
alt={current.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black via-transparent to-transparent" />
</div>
{/* Content */}
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-16">
<motion.div
key={current.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-2xl"
>
<div className="flex items-center gap-2 mb-4">
<MusicIcon className="text-purple-400" size={24} />
<span className="text-purple-400 font-semibold">ARTIST</span>
</div>
<h1 className="text-4xl md:text-6xl font-bold mb-4">{current.name}</h1>
<div className="flex items-center gap-4 text-sm text-gray-300 mb-4">
{current.genres && current.genres.length > 0 && (
<span className="text-purple-400">{current.genres.slice(0, 2).join(', ')}</span>
)}
<span>{current.albumCount} Album{current.albumCount !== 1 ? 's' : ''}</span>
<span>{current.trackCount} Track{current.trackCount !== 1 ? 's' : ''}</span>
</div>
<p className="text-gray-300 text-lg mb-6 line-clamp-3">{current.overview}</p>
<div className="flex gap-4">
<Link to={`/music/artist/${current.id}`}>
<Button size="lg" leftIcon={<Play size={20} />}>
View Artist
</Button>
</Link>
</div>
</motion.div>
{/* Indicators */}
{featuredArtists.length > 1 && (
<div className="flex gap-2 mt-8">
{featuredArtists.map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentIndex(idx)}
className={`h-1 rounded-full transition-all ${
idx === currentIndex ? 'w-8 bg-white' : 'w-4 bg-white/40'
}`}
/>
))}
</div>
)}
</div>
</div>
);
}
// Artist Row component
function ArtistRow({
title,
artists,
isLoading,
}: {
title: string;
artists: UnifiedArtist[];
isLoading: boolean;
}) {
if (isLoading) {
return (
<div className="px-4 md:px-8 mb-8">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
{[...Array(6)].map((_, i) => (
<ArtistCardSkeleton key={i} />
))}
</div>
</div>
);
}
if (artists.length === 0) return null;
return (
<div className="px-4 md:px-8 mb-8">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
{artists.map((artist) => (
<Link
key={artist.id}
to={`/music/artist/${artist.id}`}
className="flex-shrink-0 w-[180px] group"
>
<div className="relative aspect-square rounded-full overflow-hidden mb-3">
<img
src={artist.poster || '/placeholder-artist.jpg'}
alt={artist.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Play
size={48}
className="text-white opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
</div>
<h3 className="font-medium text-sm text-center truncate">{artist.name}</h3>
<p className="text-xs text-gray-400 text-center">
{artist.albumCount} Album{artist.albumCount !== 1 ? 's' : ''}
</p>
</Link>
))}
</div>
</div>
);
}
// Album Row component
function AlbumRow({
title,
albums,
isLoading,
}: {
title: string;
albums: UnifiedAlbum[];
isLoading: boolean;
}) {
if (isLoading) {
return (
<div className="px-4 md:px-8 mb-8">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
{[...Array(6)].map((_, i) => (
<AlbumCardSkeleton key={i} />
))}
</div>
</div>
);
}
if (albums.length === 0) return null;
return (
<div className="px-4 md:px-8 mb-8">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
{albums.map((album) => (
<Link
key={album.id}
to={`/music/album/${album.id}`}
className="flex-shrink-0 w-[180px] group"
>
<div className="relative aspect-square rounded-lg overflow-hidden mb-2">
<img
src={album.cover || '/placeholder-album.jpg'}
alt={album.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Play
size={48}
className="text-white opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
</div>
<h3 className="font-medium text-sm truncate">{album.title}</h3>
<p className="text-xs text-gray-400 truncate">{album.artistName}</p>
<p className="text-xs text-gray-500">{album.releaseDate?.split('-')[0]}</p>
</Link>
))}
</div>
</div>
);
}
// Empty State component
function EmptyState() {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center text-center px-4">
<div className="w-32 h-32 rounded-full bg-purple-500/10 flex items-center justify-center mb-6">
<MusicIcon size={64} className="text-purple-400" />
</div>
<h2 className="text-2xl font-bold mb-2">Your Music Library is Empty</h2>
<p className="text-gray-400 max-w-md mb-6">
Add artists to Lidarr to see them here. Lidarr will automatically download and organize your music collection.
</p>
<a
href="http://localhost:8686"
target="_blank"
rel="noopener noreferrer"
>
<Button leftIcon={<Plus size={18} />}>
Open Lidarr to Add Music
</Button>
</a>
</div>
);
}
export default function Music() {
const { artists, isLoading: artistsLoading, error: artistsError, refetch: refetchArtists } = useArtists();
const { albums, isLoading: albumsLoading, refetch: refetchAlbums } = useAlbums();
const { hasLidarr } = useHasConnectedServices();
const [searchQuery, setSearchQuery] = useState('');
const isLoading = artistsLoading || albumsLoading;
// Filter and categorize
const filteredArtists = useMemo(() => {
if (!searchQuery) return artists;
return artists.filter((a) =>
a.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [artists, searchQuery]);
const filteredAlbums = useMemo(() => {
if (!searchQuery) return albums;
return albums.filter((a) =>
a.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
a.artistName?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [albums, searchQuery]);
const recentlyAddedArtists = useMemo(
() => [...filteredArtists].sort((a, b) =>
new Date(b.added || 0).getTime() - new Date(a.added || 0).getTime()
).slice(0, 20),
[filteredArtists]
);
const recentAlbums = useMemo(
() => [...filteredAlbums].sort((a, b) =>
new Date(b.releaseDate || 0).getTime() - new Date(a.releaseDate || 0).getTime()
).slice(0, 20),
[filteredAlbums]
);
const topArtists = useMemo(
() => [...filteredArtists].sort((a, b) => b.albumCount - a.albumCount).slice(0, 20),
[filteredArtists]
);
const handleRefresh = () => {
refetchArtists();
refetchAlbums();
};
if (!hasLidarr) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertCircle size={64} className="text-gray-500 mb-4" />
<h1 className="text-2xl font-bold mb-2">Lidarr Not Connected</h1>
<p className="text-gray-400 mb-6 max-w-md">
Connect to Lidarr to browse your music library. Go to Settings to configure your connection.
</p>
<Link to="/settings">
<Button>Go to Settings</Button>
</Link>
</div>
</div>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black"
>
{/* Hero Section */}
{!isLoading && artists.length > 0 && <MusicHero artists={artists} />}
{/* Search Bar */}
<div className="sticky top-16 z-30 bg-netflix-black/80 backdrop-blur-lg py-4 px-4 md:px-8">
<div className="flex items-center gap-4 max-w-7xl mx-auto">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
placeholder="Search artists and albums..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white/10 border border-white/10 rounded-full py-2 pl-10 pr-4 text-white placeholder-gray-400 focus:outline-none focus:border-white/30"
/>
</div>
<Button
variant="ghost"
onClick={handleRefresh}
leftIcon={<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />}
>
Refresh
</Button>
</div>
</div>
{/* Error State */}
{artistsError && (
<div className="max-w-7xl mx-auto px-4 md:px-8 py-4">
<div className="glass rounded-lg p-6 border border-red-500/50 bg-red-500/10">
<p className="text-red-400">{artistsError}</p>
</div>
</div>
)}
{/* Content */}
<div className={artists.length > 0 ? '-mt-24 relative z-10 pt-8' : 'pt-8'}>
{isLoading ? (
<>
<ArtistRow title="Recently Added Artists" artists={[]} isLoading={true} />
<AlbumRow title="Recent Albums" albums={[]} isLoading={true} />
</>
) : artists.length === 0 ? (
<EmptyState />
) : (
<>
{recentlyAddedArtists.length > 0 && (
<ArtistRow title="Recently Added Artists" artists={recentlyAddedArtists} isLoading={false} />
)}
{recentAlbums.length > 0 && (
<AlbumRow title="Recent Albums" albums={recentAlbums} isLoading={false} />
)}
{topArtists.length > 0 && (
<ArtistRow title="Top Artists" artists={topArtists} isLoading={false} />
)}
</>
)}
</div>
</motion.div>
);
}

323
src/pages/Player.tsx Normal file
View File

@ -0,0 +1,323 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Loader2, AlertCircle, Server, RefreshCw } from 'lucide-react';
import StreamingPlayer from '../components/player/StreamingPlayer';
import { useMovieDetails } from '../hooks/useMovies';
import { useHistoryStore } from '../stores/historyStore';
import streamingService, { type StreamSession } from '../services/streaming/streamingService';
import type { Torrent } from '../types';
import Button from '../components/ui/Button';
export default function Player() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const movieId = parseInt(id || '0');
const preferredQuality = searchParams.get('quality');
const { movie, isLoading: movieLoading, error: movieError } = useMovieDetails(movieId);
const { addToHistory, updateProgress, getProgress } = useHistoryStore();
const [sessionId, setSessionId] = useState<string | null>(null);
const [streamSession, setStreamSession] = useState<StreamSession | null>(null);
const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [hlsUrl, setHlsUrl] = useState<string | null>(null);
const [status, setStatus] = useState<'checking' | 'connecting' | 'buffering' | 'ready' | 'error'>('checking');
const [error, setError] = useState<string | null>(null);
const [selectedTorrent, setSelectedTorrent] = useState<Torrent | null>(null);
// Get initial playback position
const historyItem = movie ? getProgress(movie.id) : undefined;
const initialTime = historyItem?.progress || 0;
// Check server health
useEffect(() => {
const checkServer = async () => {
try {
await streamingService.checkHealth();
setStatus('connecting');
} catch {
setError('Streaming server is not running. Please start the server first.');
setStatus('error');
}
};
checkServer();
}, []);
// Select the best torrent based on preference
useEffect(() => {
if (movie?.torrents?.length && status === 'connecting') {
const torrents = movie.torrents;
// Find preferred quality or best available
let torrent = preferredQuality
? torrents.find((t) => t.quality === preferredQuality)
: null;
if (!torrent) {
// Prefer 1080p, then 720p, then highest seeds
torrent =
torrents.find((t) => t.quality === '1080p') ||
torrents.find((t) => t.quality === '720p') ||
torrents.reduce((a, b) => (a.seeds > b.seeds ? a : b));
}
setSelectedTorrent(torrent);
}
}, [movie, preferredQuality, status]);
// Start streaming when torrent is selected
useEffect(() => {
if (!movie || !selectedTorrent || status !== 'connecting') return;
const startStream = async () => {
setStatus('buffering');
setError(null);
try {
// Start the stream
const result = await streamingService.startStream(
selectedTorrent.hash,
movie.title_long || movie.title,
selectedTorrent.quality
);
setSessionId(result.sessionId);
// Connect to WebSocket for updates
await streamingService.connect(result.sessionId);
// Subscribe to updates
streamingService.subscribe(result.sessionId, (data: unknown) => {
const update = data as { type: string } & Partial<StreamSession>;
if (update.type === 'progress') {
setStreamSession(update as StreamSession);
} else if (update.type === 'transcode') {
const transcodeUpdate = update as { status?: string; playlistUrl?: string };
if (transcodeUpdate.status === 'ready' && transcodeUpdate.playlistUrl) {
setHlsUrl(`http://localhost:3001${transcodeUpdate.playlistUrl}`);
}
}
});
// Poll for status until ready
const pollStatus = async () => {
try {
const statusData = await streamingService.getStatus(result.sessionId);
setStreamSession(statusData);
if (statusData.status === 'ready' || statusData.progress >= 0.05) {
// Set direct video URL
setStreamUrl(streamingService.getVideoUrl(result.sessionId));
setStatus('ready');
addToHistory(movie);
// Try to start HLS transcoding for better compatibility
try {
const hlsResult = await streamingService.startHls(result.sessionId);
if (hlsResult.playlistUrl) {
setHlsUrl(`http://localhost:3001${hlsResult.playlistUrl}`);
}
} catch (e) {
console.log('HLS not available, using direct stream');
}
} else if (statusData.status === 'error') {
setError(statusData.error || 'Streaming failed');
setStatus('error');
} else {
// Continue polling
setTimeout(pollStatus, 1000);
}
} catch (e) {
console.error('Status poll error:', e);
setTimeout(pollStatus, 2000);
}
};
pollStatus();
} catch (err) {
console.error('Stream start error:', err);
setError(err instanceof Error ? err.message : 'Failed to start stream');
setStatus('error');
}
};
startStream();
// Cleanup on unmount
return () => {
if (sessionId) {
streamingService.stopStream(sessionId).catch(console.error);
streamingService.disconnect();
}
};
}, [movie, selectedTorrent, status, addToHistory]);
// Handle time updates for progress tracking
const handleTimeUpdate = useCallback(
(currentTime: number, duration: number) => {
if (movie && currentTime > 0 && duration > 0) {
updateProgress(movie.id, currentTime, duration);
}
},
[movie, updateProgress]
);
// Retry function
const handleRetry = () => {
setError(null);
setStatus('checking');
setSessionId(null);
setStreamSession(null);
setStreamUrl(null);
setHlsUrl(null);
};
if (movieLoading) {
return (
<div className="h-screen w-screen bg-black flex items-center justify-center">
<div className="text-center">
<Loader2 size={48} className="animate-spin text-netflix-red mx-auto mb-4" />
<p className="text-gray-400">Loading movie...</p>
</div>
</div>
);
}
if (movieError || !movie) {
return (
<div className="h-screen w-screen bg-black flex items-center justify-center">
<div className="text-center">
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
<h1 className="text-xl font-bold mb-2">Movie not found</h1>
<p className="text-gray-400 mb-4">{movieError}</p>
<Button onClick={() => navigate(-1)}>Go Back</Button>
</div>
</div>
);
}
if (status === 'checking') {
return (
<div className="h-screen w-screen bg-black flex items-center justify-center">
<div className="text-center max-w-md">
<Server size={48} className="text-netflix-red mx-auto mb-4" />
<h2 className="text-xl font-bold mb-2">Connecting to streaming server...</h2>
<p className="text-gray-400">
Please make sure the streaming server is running
</p>
</div>
</div>
);
}
if (status === 'error') {
return (
<div className="h-screen w-screen bg-black flex items-center justify-center">
<div className="text-center max-w-md p-8">
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold mb-2">Streaming Error</h2>
<p className="text-gray-400 mb-6">{error}</p>
{error?.includes('server') && (
<div className="bg-gray-900 p-4 rounded-lg mb-6 text-left">
<p className="text-sm text-gray-300 mb-2">To start the server:</p>
<code className="block bg-black p-2 rounded text-green-400 text-sm">
cd server && npm install && npm start
</code>
</div>
)}
<div className="flex gap-3 justify-center">
<Button onClick={handleRetry} leftIcon={<RefreshCw size={18} />}>
Retry
</Button>
<Button variant="secondary" onClick={() => navigate(`/movie/${movie.id}`)}>
Choose Quality
</Button>
</div>
</div>
</div>
);
}
if (status === 'connecting' || status === 'buffering') {
return (
<div className="h-screen w-screen bg-black flex items-center justify-center">
<div className="text-center max-w-md">
<Loader2 size={48} className="animate-spin text-netflix-red mx-auto mb-4" />
<h2 className="text-xl font-bold mb-2">
{status === 'connecting' ? 'Connecting to peers...' : 'Buffering...'}
</h2>
<p className="text-gray-400 mb-4">
{status === 'connecting'
? `Finding sources for "${movie.title}"`
: 'Preparing video stream...'}
</p>
{streamSession && (
<div className="bg-gray-900 p-4 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Progress</span>
<span>{(streamSession.progress * 100).toFixed(1)}%</span>
</div>
<div className="w-full h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-netflix-red transition-all"
style={{ width: `${streamSession.progress * 100}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span> {formatSpeed(streamSession.downloadSpeed)}</span>
<span>{streamSession.peers} peers</span>
<span>{formatBytes(streamSession.downloaded)}</span>
</div>
</div>
)}
{selectedTorrent && (
<p className="text-sm text-gray-500 mt-4">
Quality: {selectedTorrent.quality} Size: {selectedTorrent.size}
</p>
)}
<Button variant="ghost" onClick={() => navigate(-1)} className="mt-4">
Cancel
</Button>
</div>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="h-screen w-screen bg-black"
>
<StreamingPlayer
movie={movie}
streamUrl={streamUrl || ''}
hlsUrl={hlsUrl || undefined}
streamSession={streamSession}
onTimeUpdate={handleTimeUpdate}
initialTime={initialTime}
/>
</motion.div>
);
}
// Helper functions
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatSpeed(bytesPerSecond: number): string {
return formatBytes(bytesPerSecond) + '/s';
}

253
src/pages/Queue.tsx Normal file
View File

@ -0,0 +1,253 @@
import { motion } from 'framer-motion';
import {
Download,
RefreshCw,
AlertCircle,
Pause,
Play,
Trash2,
Film,
Tv,
Music,
Clock,
HardDrive
} 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';
import { formatBytes } from '../utils/helpers';
import type { QueueItem } from '../types/unified';
export default function Queue() {
const { items, isLoading, error, refetch } = useQueue();
const { hasAny } = useHasConnectedServices();
const getTypeIcon = (type: QueueItem['mediaType']) => {
switch (type) {
case 'movie':
return <Film size={16} className="text-red-400" />;
case 'episode':
return <Tv size={16} className="text-blue-400" />;
case 'album':
return <Music size={16} className="text-purple-400" />;
default:
return <Download size={16} />;
}
};
const getStatusBadge = (status: QueueItem['status']) => {
const variants: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
queued: 'default',
downloading: 'info',
paused: 'warning',
importing: 'info',
completed: 'success',
failed: 'error',
warning: 'warning',
};
return <Badge size="sm" variant={variants[status] || 'default'}>{status}</Badge>;
};
const formatEta = (date?: Date) => {
if (!date) return 'Unknown';
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return 'Soon';
const hours = Math.floor(diff / 3600000);
const minutes = Math.floor((diff % 3600000) / 60000);
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
if (!hasAny) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertCircle size={64} className="text-gray-500 mb-4" />
<h1 className="text-2xl font-bold mb-2">No Services Connected</h1>
<p className="text-gray-400 mb-6 max-w-md">
Connect to Radarr, Sonarr, or Lidarr to view your download queue.
</p>
<Link to="/settings">
<Button>Go to Settings</Button>
</Link>
</div>
</div>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-7xl mx-auto px-4 md:px-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl md:text-4xl font-bold flex items-center gap-3">
<Download className="text-green-400" />
Download Queue
</h1>
<Button
variant="ghost"
onClick={() => refetch()}
leftIcon={<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />}
>
Refresh
</Button>
</div>
{items.length > 0 && (
<p className="text-gray-400">
{items.length} item{items.length !== 1 ? 's' : ''} in queue
</p>
)}
</div>
{/* Error State */}
{error && (
<div className="glass rounded-lg p-6 mb-8 border border-red-500/50 bg-red-500/10">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Queue List */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="glass rounded-lg p-4 animate-pulse">
<div className="h-16 bg-white/5 rounded" />
</div>
))}
</div>
) : items.length === 0 ? (
<div className="glass rounded-lg p-12 text-center">
<Download size={48} className="mx-auto text-gray-500 mb-4" />
<h2 className="text-xl font-semibold text-gray-300 mb-2">Queue is Empty</h2>
<p className="text-gray-500">No downloads in progress</p>
</div>
) : (
<div className="space-y-3">
{items.map((item) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="glass rounded-lg p-4"
>
<div className="flex items-center gap-4">
{/* Type Icon */}
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
{getTypeIcon(item.mediaType)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-white line-clamp-1">
{item.title}
</h3>
{getStatusBadge(item.status)}
</div>
{/* Progress Bar */}
{item.status === 'downloading' && (
<div className="mb-2">
<ProgressBar
progress={item.progress}
size="sm"
showLabel
color="primary"
/>
</div>
)}
{/* Meta */}
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500">
{item.quality && <span>{item.quality}</span>}
<span className="flex items-center gap-1">
<HardDrive size={12} />
{formatBytes(item.size - item.sizeleft)} / {formatBytes(item.size)}
</span>
{item.estimatedCompletionTime && item.status === 'downloading' && (
<span className="flex items-center gap-1">
<Clock size={12} />
ETA: {formatEta(item.estimatedCompletionTime)}
</span>
)}
{item.downloadClient && <span>{item.downloadClient}</span>}
{item.indexer && <span>via {item.indexer}</span>}
</div>
{/* Status Messages */}
{item.statusMessages && item.statusMessages.length > 0 && (
<div className="mt-2">
{item.statusMessages.map((msg, i) => (
<div key={i} className="text-xs text-yellow-400">
{msg.title}: {msg.messages.join(', ')}
</div>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex-shrink-0 flex items-center gap-2">
{item.status === 'downloading' && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
title="Pause"
>
<Pause size={18} />
</motion.button>
)}
{item.status === 'paused' && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
title="Resume"
>
<Play size={18} />
</motion.button>
)}
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="p-2 hover:bg-red-500/20 text-red-400 rounded-full transition-colors"
title="Remove"
>
<Trash2 size={18} />
</motion.button>
</div>
</div>
</motion.div>
))}
</div>
)}
</div>
</motion.div>
);
}

86
src/pages/Search.tsx Normal file
View File

@ -0,0 +1,86 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Search as SearchIcon, X } from 'lucide-react';
import MovieGrid from '../components/movie/MovieGrid';
import Input from '../components/ui/Input';
import { useSearch } from '../hooks/useMovies';
export default function Search() {
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') || '');
const [debouncedQuery, setDebouncedQuery] = useState(query);
const { movies, isLoading, totalCount } = useSearch(debouncedQuery);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
if (query) {
setSearchParams({ q: query });
} else {
setSearchParams({});
}
}, 300);
return () => clearTimeout(timer);
}, [query, setSearchParams]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-[1920px] mx-auto px-4 md:px-8">
{/* Search Header */}
<div className="max-w-2xl mx-auto mb-12">
<div className="relative">
<Input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search movies, actors, directors..."
leftIcon={<SearchIcon size={20} />}
rightIcon={
query && (
<button
onClick={() => setQuery('')}
className="hover:text-white transition-colors"
>
<X size={20} />
</button>
)
}
className="text-lg py-4"
autoFocus
/>
</div>
{debouncedQuery && (
<p className="text-gray-400 mt-4 text-center">
{isLoading
? 'Searching...'
: `Found ${totalCount.toLocaleString()} results for "${debouncedQuery}"`}
</p>
)}
</div>
{/* Results */}
{debouncedQuery ? (
<MovieGrid movies={movies} isLoading={isLoading} />
) : (
<div className="text-center py-16">
<SearchIcon size={64} className="mx-auto text-gray-600 mb-4" />
<h2 className="text-xl text-gray-400">Start typing to search</h2>
<p className="text-gray-500 mt-2">
Search by movie title, actor name, or director
</p>
</div>
)}
</div>
</motion.div>
);
}

240
src/pages/SeriesDetails.tsx Normal file
View File

@ -0,0 +1,240 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Play,
Star,
Calendar,
Clock,
Tv,
Download,
RefreshCw,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useSeriesDetails, useEpisodes } from '../hooks/useSeries';
import { SeasonList } from '../components/tv';
import Button from '../components/ui/Button';
import Badge from '../components/ui/Badge';
import { SeriesDetailsSkeleton } from '../components/ui/Skeleton';
import type { UnifiedEpisode } from '../types/unified';
export default function SeriesDetails() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { series, isLoading: seriesLoading, error: seriesError } = useSeriesDetails(id);
const { episodes, isLoading: episodesLoading } = useEpisodes(id);
const [showFullOverview, setShowFullOverview] = useState(false);
if (seriesLoading) return <SeriesDetailsSkeleton />;
if (seriesError || !series) {
return (
<div className="min-h-screen flex items-center justify-center bg-netflix-black">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Series not found</h1>
<p className="text-gray-400 mb-4">{seriesError}</p>
<Button onClick={() => navigate(-1)}>Go Back</Button>
</div>
</div>
);
}
const handleEpisodeClick = (episode: UnifiedEpisode) => {
if (episode.hasFile) {
// Navigate to player with episode
navigate(`/player/episode/${episode.id}`);
}
};
const handleSeasonDownload = (seasonNumber: number) => {
// TODO: Trigger season search in Sonarr
console.log('Download season', seasonNumber);
};
const statusColor = {
continuing: 'success',
ended: 'default',
upcoming: 'info',
deleted: 'error',
} as const;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black"
>
{/* Hero Background */}
<div className="relative h-[50vh] md:h-[60vh]">
<img
src={series.fanart || series.poster}
alt={series.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black via-transparent to-transparent" />
</div>
{/* Content */}
<div className="max-w-7xl mx-auto px-4 md:px-8 -mt-48 md:-mt-64 relative z-10">
<div className="flex flex-col md:flex-row gap-8">
{/* Poster */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="flex-shrink-0 hidden md:block"
>
<img
src={series.poster}
alt={series.title}
className="w-[250px] rounded-lg shadow-2xl"
/>
</motion.div>
{/* Details */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex-1"
>
{/* Title */}
<h1 className="text-3xl md:text-5xl font-bold mb-4">{series.title}</h1>
{/* Meta */}
<div className="flex flex-wrap items-center gap-3 md:gap-4 mb-4">
{series.rating && (
<span className="flex items-center gap-1 text-green-400 font-semibold">
<Star size={18} fill="currentColor" />
{series.rating.toFixed(1)}/10
</span>
)}
{series.year && (
<span className="flex items-center gap-1 text-gray-300">
<Calendar size={16} />
{series.year}
</span>
)}
{series.runtime && (
<span className="flex items-center gap-1 text-gray-300">
<Clock size={16} />
{series.runtime} min
</span>
)}
<Badge variant={statusColor[series.status]}>
{series.status}
</Badge>
{series.network && (
<Badge variant="info">{series.network}</Badge>
)}
</div>
{/* Stats */}
<div className="flex flex-wrap gap-3 mb-6">
<Badge size="lg" className="bg-white/10">
<Tv size={14} className="mr-1" />
{series.seasonCount} Season{series.seasonCount !== 1 ? 's' : ''}
</Badge>
<Badge size="lg" className="bg-white/10">
{series.episodeFileCount}/{series.episodeCount} Episodes
</Badge>
{series.nextAiring && (
<Badge size="lg" variant="info">
Next: {new Date(series.nextAiring).toLocaleDateString()}
</Badge>
)}
</div>
{/* Genres */}
{series.genres && series.genres.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{series.genres.map((genre) => (
<span
key={genre}
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-full text-sm transition-colors"
>
{genre}
</span>
))}
</div>
)}
{/* Actions */}
<div className="flex flex-wrap items-center gap-3 mb-8">
{series.episodeFileCount > 0 && (
<Button size="lg" leftIcon={<Play size={20} fill="white" />}>
Play
</Button>
)}
<Button
variant="secondary"
size="lg"
leftIcon={<Download size={20} />}
>
Download All
</Button>
<Button
variant="icon"
size="lg"
leftIcon={<RefreshCw size={20} />}
>
Refresh
</Button>
</div>
{/* Overview */}
{series.overview && (
<div className="mb-8">
<h3 className="text-xl font-semibold mb-2">Overview</h3>
<p className={`text-gray-300 leading-relaxed ${!showFullOverview && 'line-clamp-4'}`}>
{series.overview}
</p>
{series.overview.length > 300 && (
<button
onClick={() => setShowFullOverview(!showFullOverview)}
className="flex items-center gap-1 text-netflix-red mt-2 hover:underline"
>
{showFullOverview ? (
<>Show Less <ChevronUp size={16} /></>
) : (
<>Show More <ChevronDown size={16} /></>
)}
</button>
)}
</div>
)}
</motion.div>
</div>
{/* Seasons & Episodes */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mt-8"
>
<h2 className="text-2xl font-semibold mb-4">Seasons & Episodes</h2>
{episodesLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="glass rounded-lg h-16 animate-pulse" />
))}
</div>
) : (
<SeasonList
seasons={series.seasons || []}
episodes={episodes}
onEpisodeClick={handleEpisodeClick}
onSeasonDownload={handleSeasonDownload}
/>
)}
</motion.div>
</div>
</motion.div>
);
}

354
src/pages/Settings.tsx Normal file
View File

@ -0,0 +1,354 @@
import { motion } from 'framer-motion';
import {
Settings as SettingsIcon,
Monitor,
Download,
Bell,
Trash2,
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() {
const { settings, updateSettings, resetSettings } = useSettingsStore();
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.')) {
ytsApi.clearCache();
localStorage.removeItem('bestream-cache');
}
};
const handleClearAllData = () => {
if (confirm('This will clear ALL your data including watchlist, history, and downloads. Are you sure?')) {
clearWatchlist();
clearHistory();
clearCompleted();
resetSettings();
ytsApi.clearCache();
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-3xl mx-auto px-4 md:px-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl md:text-4xl font-bold flex items-center gap-3">
<SettingsIcon className="text-netflix-red" />
Settings
</h1>
<p className="text-gray-400 mt-1">
Customize your streaming experience
</p>
</div>
{/* 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
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12 }}
className="p-6 glass rounded-lg"
>
<h2 className="text-xl font-semibold flex items-center gap-2 mb-4">
<Monitor size={20} />
Playback
</h2>
<div className="space-y-4">
<Select
label="Preferred Quality"
options={[
{ value: '480p', label: '480p (SD)' },
{ value: '720p', label: '720p (HD)' },
{ value: '1080p', label: '1080p (Full HD)' },
{ value: '2160p', label: '4K (Ultra HD)' },
]}
value={settings.preferredQuality}
onChange={(e) => updateSettings({ preferredQuality: e.target.value })}
/>
<Select
label="Preferred Subtitle Language"
options={[
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
]}
value={settings.preferredLanguage}
onChange={(e) => updateSettings({ preferredLanguage: e.target.value })}
/>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Auto-play</p>
<p className="text-sm text-gray-400">
Automatically play next episode or recommended movie
</p>
</div>
<button
onClick={() => updateSettings({ autoPlay: !settings.autoPlay })}
className={`w-12 h-6 rounded-full transition-colors ${
settings.autoPlay ? 'bg-netflix-red' : 'bg-gray-600'
}`}
>
<div
className={`w-5 h-5 bg-white rounded-full transition-transform ${
settings.autoPlay ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
</motion.section>
{/* Download Settings */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.18 }}
className="p-6 glass rounded-lg"
>
<h2 className="text-xl font-semibold flex items-center gap-2 mb-4">
<Download size={20} />
Downloads
</h2>
<div className="space-y-4">
<Select
label="Max Concurrent Downloads"
options={[
{ value: '1', label: '1' },
{ value: '2', label: '2' },
{ value: '3', label: '3' },
{ value: '5', label: '5' },
]}
value={settings.maxConcurrentDownloads.toString()}
onChange={(e) =>
updateSettings({ maxConcurrentDownloads: parseInt(e.target.value) })
}
/>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-sm text-gray-400">
Download location is managed by your operating system's default
download folder.
</p>
</div>
</div>
</motion.section>
{/* Notifications */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.24 }}
className="p-6 glass rounded-lg"
>
<h2 className="text-xl font-semibold flex items-center gap-2 mb-4">
<Bell size={20} />
Notifications
</h2>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Enable Notifications</p>
<p className="text-sm text-gray-400">
Get notified when downloads complete
</p>
</div>
<button
onClick={() => updateSettings({ notifications: !settings.notifications })}
className={`w-12 h-6 rounded-full transition-colors ${
settings.notifications ? 'bg-netflix-red' : 'bg-gray-600'
}`}
>
<div
className={`w-5 h-5 bg-white rounded-full transition-transform ${
settings.notifications ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</motion.section>
{/* Data Management */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.30 }}
className="p-6 glass rounded-lg"
>
<h2 className="text-xl font-semibold flex items-center gap-2 mb-4">
<Database size={20} />
Data Management
</h2>
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-3 gap-4 p-4 bg-white/5 rounded-lg">
<div className="text-center">
<p className="text-2xl font-bold">{watchlistItems.length}</p>
<p className="text-sm text-gray-400">Watchlist</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{historyItems.length}</p>
<p className="text-sm text-gray-400">History</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{downloadItems.length}</p>
<p className="text-sm text-gray-400">Downloads</p>
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3">
<Button
variant="secondary"
onClick={handleClearCache}
leftIcon={<Trash2 size={18} />}
>
Clear Cache
</Button>
<Button
variant="ghost"
onClick={handleClearAllData}
leftIcon={<Trash2 size={18} />}
className="text-red-400 hover:text-red-300"
>
Clear All Data
</Button>
</div>
</div>
</motion.section>
{/* About */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.36 }}
className="p-6 glass rounded-lg"
>
<h2 className="text-xl font-semibold flex items-center gap-2 mb-4">
<Info size={20} />
About
</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-400">Version</span>
<span>2.0.0</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Platform</span>
<span>{navigator.platform}</span>
</div>
<div className="pt-3 border-t border-white/10">
<p className="text-sm text-gray-500">
beStream is an open-source movie streaming application.
Data provided by YTS API. This application does not host any
content and is intended for educational purposes only.
</p>
</div>
<div className="flex gap-3 pt-2">
<a
href="https://github.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-netflix-red hover:underline"
>
GitHub <ExternalLink size={14} />
</a>
<a
href="https://yts.mx"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-netflix-red hover:underline"
>
YTS API <ExternalLink size={14} />
</a>
</div>
</div>
</motion.section>
</div>
</div>
</motion.div>
);
}

356
src/pages/TVPlayer.tsx Normal file
View File

@ -0,0 +1,356 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Loader2, AlertCircle, Server, RefreshCw, Tv } from 'lucide-react';
import StreamingPlayer from '../components/player/StreamingPlayer';
import streamingService, { type StreamSession } from '../services/streaming/streamingService';
import Button from '../components/ui/Button';
interface TVEpisodeInfo {
showTitle: string;
showId: number;
season: number;
episode: number;
episodeTitle: string;
poster?: string;
backdrop?: string;
}
export default function TVPlayer() {
const { showId } = useParams<{ showId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
// Extract info from URL params
const magnetUrl = searchParams.get('magnet');
const hash = searchParams.get('hash');
const quality = searchParams.get('quality') || '720p';
const showTitle = searchParams.get('show') || 'TV Show';
const season = parseInt(searchParams.get('season') || '1');
const episode = parseInt(searchParams.get('episode') || '1');
const episodeTitle = searchParams.get('title') || '';
const poster = searchParams.get('poster') || '';
const backdrop = searchParams.get('backdrop') || '';
const [sessionId, setSessionId] = useState<string | null>(null);
const [streamSession, setStreamSession] = useState<StreamSession | null>(null);
const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [hlsUrl, setHlsUrl] = useState<string | null>(null);
const [status, setStatus] = useState<'checking' | 'connecting' | 'buffering' | 'ready' | 'error'>('checking');
const [error, setError] = useState<string | null>(null);
// Episode info for display
const episodeInfo: TVEpisodeInfo = {
showTitle,
showId: parseInt(showId || '0'),
season,
episode,
episodeTitle,
poster: poster ? decodeURIComponent(poster) : undefined,
backdrop: backdrop ? decodeURIComponent(backdrop) : undefined,
};
// Extract hash from magnet URL if not provided directly
const torrentHash = hash || extractHashFromMagnet(magnetUrl || '');
// Check server health
useEffect(() => {
const checkServer = async () => {
try {
await streamingService.checkHealth();
setStatus('connecting');
} catch {
setError('Streaming server is not running. Please start the server first.');
setStatus('error');
}
};
checkServer();
}, []);
// Start streaming when server is ready
useEffect(() => {
if (status !== 'connecting' || !torrentHash) return;
const startStream = async () => {
setStatus('buffering');
setError(null);
const episodeName = `${showTitle} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}${episodeTitle ? ` - ${episodeTitle}` : ''}`;
try {
// Start the stream
const result = await streamingService.startStream(
torrentHash,
episodeName,
quality
);
setSessionId(result.sessionId);
// Connect to WebSocket for updates
await streamingService.connect(result.sessionId);
// Subscribe to updates
streamingService.subscribe(result.sessionId, (data: unknown) => {
const update = data as { type: string } & Partial<StreamSession>;
if (update.type === 'progress') {
setStreamSession(update as StreamSession);
} else if (update.type === 'transcode') {
const transcodeUpdate = update as { status?: string; playlistUrl?: string };
if (transcodeUpdate.status === 'ready' && transcodeUpdate.playlistUrl) {
setHlsUrl(`http://localhost:3001${transcodeUpdate.playlistUrl}`);
}
}
});
// Poll for status until ready
const pollStatus = async () => {
try {
const statusData = await streamingService.getStatus(result.sessionId);
setStreamSession(statusData);
if (statusData.status === 'ready' || statusData.progress >= 0.05) {
// Set direct video URL
setStreamUrl(streamingService.getVideoUrl(result.sessionId));
setStatus('ready');
// Try to start HLS transcoding for better compatibility
try {
const hlsResult = await streamingService.startHls(result.sessionId);
if (hlsResult.playlistUrl) {
setHlsUrl(`http://localhost:3001${hlsResult.playlistUrl}`);
}
} catch (e) {
console.log('HLS not available, using direct stream');
}
} else if (statusData.status === 'error') {
setError(statusData.error || 'Streaming failed');
setStatus('error');
} else {
// Continue polling
setTimeout(pollStatus, 1000);
}
} catch (e) {
console.error('Status poll error:', e);
setTimeout(pollStatus, 2000);
}
};
pollStatus();
} catch (err) {
console.error('Stream start error:', err);
setError(err instanceof Error ? err.message : 'Failed to start stream');
setStatus('error');
}
};
startStream();
// Cleanup on unmount
return () => {
if (sessionId) {
streamingService.stopStream(sessionId).catch(console.error);
streamingService.disconnect();
}
};
}, [status, torrentHash, showTitle, season, episode, episodeTitle, quality]);
// Handle time updates for progress tracking (TODO: add history tracking for TV shows)
const handleTimeUpdate = useCallback(
(currentTime: number, duration: number) => {
// Could implement TV show progress tracking here
console.log('TV progress:', { currentTime, duration, episode: episodeInfo });
},
[episodeInfo]
);
// Retry function
const handleRetry = () => {
setError(null);
setStatus('checking');
setSessionId(null);
setStreamSession(null);
setStreamUrl(null);
setHlsUrl(null);
};
// No magnet URL or hash provided
if (!torrentHash) {
return (
<div className="h-screen w-screen bg-black flex items-center justify-center">
<div className="text-center">
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
<h1 className="text-xl font-bold mb-2">No stream source</h1>
<p className="text-gray-400 mb-4">Could not find a valid torrent for this episode.</p>
<Button onClick={() => navigate(`/tv/${showId}`)}>Go Back</Button>
</div>
</div>
);
}
if (status === 'checking') {
return (
<div className="h-screen w-screen bg-black flex items-center justify-center">
<div className="text-center max-w-md">
<Server size={48} className="text-netflix-red mx-auto mb-4" />
<h2 className="text-xl font-bold mb-2">Connecting to streaming server...</h2>
<p className="text-gray-400">
Please make sure the streaming server is running
</p>
</div>
</div>
);
}
if (status === 'error') {
return (
<div className="h-screen w-screen bg-black flex items-center justify-center">
<div className="text-center max-w-md p-8">
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold mb-2">Streaming Error</h2>
<p className="text-gray-400 mb-6">{error}</p>
{error?.includes('server') && (
<div className="bg-gray-900 p-4 rounded-lg mb-6 text-left">
<p className="text-sm text-gray-300 mb-2">To start the server:</p>
<code className="block bg-black p-2 rounded text-green-400 text-sm">
cd server && npm install && npm start
</code>
</div>
)}
<div className="flex gap-3 justify-center">
<Button onClick={handleRetry} leftIcon={<RefreshCw size={18} />}>
Retry
</Button>
<Button variant="secondary" onClick={() => navigate(`/tv/${showId}`)}>
Back to Show
</Button>
</div>
</div>
</div>
);
}
if (status === 'connecting' || status === 'buffering') {
return (
<div className="h-screen w-screen bg-black flex items-center justify-center">
<div className="text-center max-w-md">
<Loader2 size={48} className="animate-spin text-netflix-red mx-auto mb-4" />
<h2 className="text-xl font-bold mb-2">
{status === 'connecting' ? 'Connecting to peers...' : 'Buffering...'}
</h2>
<p className="text-gray-400 mb-4">
{status === 'connecting'
? `Finding sources for "${showTitle}" S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`
: 'Preparing video stream...'}
</p>
{streamSession && (
<div className="bg-gray-900 p-4 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Progress</span>
<span>{(streamSession.progress * 100).toFixed(1)}%</span>
</div>
<div className="w-full h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-netflix-red transition-all"
style={{ width: `${streamSession.progress * 100}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span> {formatSpeed(streamSession.downloadSpeed)}</span>
<span>{streamSession.peers} peers</span>
<span>{formatBytes(streamSession.downloaded)}</span>
</div>
</div>
)}
<p className="text-sm text-gray-500 mt-4">
Quality: {quality}
</p>
<Button variant="ghost" onClick={() => navigate(-1)} className="mt-4">
Cancel
</Button>
</div>
</div>
);
}
// Create a mock movie object for the StreamingPlayer
const mockMedia = {
id: parseInt(showId || '0'),
title: `${showTitle} - S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`,
title_long: episodeTitle || `Season ${season}, Episode ${episode}`,
year: new Date().getFullYear(),
rating: 0,
runtime: 0,
genres: [],
summary: '',
description_full: '',
yt_trailer_code: '',
language: 'en',
mpa_rating: '',
background_image: episodeInfo.backdrop || '',
background_image_original: episodeInfo.backdrop || '',
small_cover_image: episodeInfo.poster || '',
medium_cover_image: episodeInfo.poster || '',
large_cover_image: episodeInfo.poster || '',
torrents: [],
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="h-screen w-screen bg-black"
>
<StreamingPlayer
movie={mockMedia}
streamUrl={streamUrl || ''}
hlsUrl={hlsUrl || undefined}
streamSession={streamSession}
onTimeUpdate={handleTimeUpdate}
initialTime={0}
/>
</motion.div>
);
}
// Helper: Extract hash from magnet URL
function extractHashFromMagnet(magnetUrl: string): string {
if (!magnetUrl) return '';
// Handle both full magnet URLs and just hashes
if (!magnetUrl.startsWith('magnet:')) {
// Might be just a hash
if (/^[a-fA-F0-9]{40}$/i.test(magnetUrl)) {
return magnetUrl;
}
return '';
}
// Extract btih (BitTorrent Info Hash)
const match = magnetUrl.match(/btih:([a-fA-F0-9]{40}|[a-zA-Z2-7]{32})/i);
if (match) {
return match[1];
}
return '';
}
// Helper functions
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatSpeed(bytesPerSecond: number): string {
return formatBytes(bytesPerSecond) + '/s';
}

626
src/pages/TVShowDetails.tsx Normal file
View File

@ -0,0 +1,626 @@
import { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Play,
ArrowLeft,
Star,
Calendar,
Tv,
Clock,
Download,
ChevronDown,
Loader,
AlertCircle,
Search,
} from 'lucide-react';
import { tvDiscoveryApi, DiscoveredShow, EztvTorrent } from '../services/api/tvDiscovery';
import Button from '../components/ui/Button';
interface Season {
id: number;
name: string;
season_number: number;
episode_count: number;
air_date: string;
poster_path: string | null;
overview: string;
}
interface Episode {
id: number;
name: string;
episode_number: number;
season_number: number;
air_date: string;
overview: string;
still_path: string | null;
vote_average: number;
runtime: number;
}
export default function TVShowDetails() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [show, setShow] = useState<(DiscoveredShow & { seasons: Season[]; externalIds: any }) | null>(null);
const [selectedSeason, setSelectedSeason] = useState<number>(1);
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [torrents, setTorrents] = useState<EztvTorrent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
const [isLoadingTorrents, setIsLoadingTorrents] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showSeasonDropdown, setShowSeasonDropdown] = useState(false);
const [streamingEpisode, setStreamingEpisode] = useState<{ season: number; episode: number } | null>(null);
// Load show details
useEffect(() => {
async function loadShow() {
if (!id) return;
setIsLoading(true);
setError(null);
try {
const showId = parseInt(id);
const details = await tvDiscoveryApi.getShowDetails(showId);
setShow(details);
// Find first season with episodes
const firstSeason = details.seasons.find((s: Season) => s.season_number > 0);
if (firstSeason) {
setSelectedSeason(firstSeason.season_number);
}
} catch (err) {
console.error('Error loading show:', err);
setError('Failed to load show details');
} finally {
setIsLoading(false);
}
}
loadShow();
}, [id]);
// Load episodes for selected season
useEffect(() => {
async function loadEpisodes() {
if (!id || !selectedSeason) return;
setIsLoadingEpisodes(true);
try {
const seasonData = await tvDiscoveryApi.getSeasonDetails(parseInt(id), selectedSeason);
setEpisodes(seasonData.episodes || []);
} catch (err) {
console.error('Error loading episodes:', err);
} finally {
setIsLoadingEpisodes(false);
}
}
loadEpisodes();
}, [id, selectedSeason]);
// Load torrents for the show
useEffect(() => {
async function loadTorrents() {
if (!show?.imdbId) {
console.log('No IMDB ID available for torrent lookup');
return;
}
setIsLoadingTorrents(true);
console.log('Loading torrents for IMDB:', show.imdbId);
try {
const torrentData = await tvDiscoveryApi.getTorrents(show.imdbId, show.title);
console.log('Loaded torrents:', torrentData.length);
setTorrents(torrentData);
} catch (err) {
console.error('Error loading torrents:', err);
} finally {
setIsLoadingTorrents(false);
}
}
loadTorrents();
}, [show?.imdbId, show?.title]);
// Episode-specific torrent cache
const [episodeTorrentCache, setEpisodeTorrentCache] = useState<Map<string, EztvTorrent[]>>(new Map());
const [searchingEpisode, setSearchingEpisode] = useState<string | null>(null);
// Find torrents for a specific episode (from cache or main torrents)
const getTorrentsForEpisode = (season: number, episode: number): EztvTorrent[] => {
const cacheKey = `${season}-${episode}`;
// Check cache first
if (episodeTorrentCache.has(cacheKey)) {
return episodeTorrentCache.get(cacheKey) || [];
}
// Filter from main torrents
return torrents.filter((t) => {
const seasonMatch = t.season === season.toString().padStart(2, '0') ||
t.season === season.toString() ||
t.title.toLowerCase().includes(`s${season.toString().padStart(2, '0')}`);
const episodeMatch = t.episode === episode.toString().padStart(2, '0') ||
t.episode === episode.toString() ||
t.title.toLowerCase().includes(`e${episode.toString().padStart(2, '0')}`);
return seasonMatch && episodeMatch;
}).sort((a, b) => {
// Sort by quality (prefer 1080p)
const qualityOrder: Record<string, number> = { '2160p': 4, '1080p': 3, '720p': 2, '480p': 1, 'Unknown': 0 };
return (qualityOrder[b.quality] || 0) - (qualityOrder[a.quality] || 0);
});
};
// Search for episode-specific torrents
const searchForEpisode = async (season: number, episode: number) => {
if (!show?.title) return;
const cacheKey = `${season}-${episode}`;
setSearchingEpisode(cacheKey);
try {
// Pass the IMDB ID for better search results
const results = await tvDiscoveryApi.searchEpisodeTorrents(
show.title,
season,
episode,
show.imdbId
);
setEpisodeTorrentCache(prev => new Map(prev).set(cacheKey, results));
} catch (error) {
console.error('Error searching for episode:', error);
} finally {
setSearchingEpisode(null);
}
};
// Modal state for external search
const [showSearchModal, setShowSearchModal] = useState<{
season: number;
episode: number;
torrent: EztvTorrent;
} | null>(null);
const [magnetInput, setMagnetInput] = useState('');
// Handle episode play - navigate to TV Player with torrent info
const handlePlayEpisode = async (season: number, episode: number, torrent: EztvTorrent) => {
console.log('Playing episode:', { season, episode, torrent });
// Check if this is a fallback search URL (not a real magnet)
if (torrent.magnetUrl.startsWith('https://')) {
// Show modal to help user find the torrent
setShowSearchModal({ season, episode, torrent });
return;
}
// Get episode info for the player
const episodeInfo = episodes.find(e => e.episode_number === episode);
// Build query params for the TV Player
const params = new URLSearchParams({
hash: torrent.hash,
magnet: torrent.magnetUrl,
quality: torrent.quality,
show: show?.title || 'TV Show',
season: season.toString(),
episode: episode.toString(),
title: episodeInfo?.name || '',
poster: encodeURIComponent(show?.poster || ''),
backdrop: encodeURIComponent(show?.backdrop || episodeInfo?.still_path ? `https://image.tmdb.org/t/p/original${episodeInfo?.still_path}` : ''),
});
// Navigate to the TV Player page
navigate(`/tv/play/${id}?${params.toString()}`);
};
// Handle magnet link submission - navigate to TV Player
const handleMagnetSubmit = () => {
if (!showSearchModal || !magnetInput.trim()) return;
// Extract hash from magnet link
const hashMatch = magnetInput.match(/btih:([a-fA-F0-9]{40}|[a-zA-Z2-7]{32})/i);
if (!hashMatch) {
alert('Invalid magnet link. Please paste a valid magnet link.');
return;
}
const hash = hashMatch[1];
const { season, episode, torrent } = showSearchModal;
const episodeInfo = episodes.find(e => e.episode_number === episode);
setShowSearchModal(null);
setMagnetInput('');
// Build query params for the TV Player
const params = new URLSearchParams({
hash: hash,
magnet: magnetInput.trim(),
quality: torrent.quality,
show: show?.title || 'TV Show',
season: season.toString(),
episode: episode.toString(),
title: episodeInfo?.name || '',
poster: encodeURIComponent(show?.poster || ''),
backdrop: encodeURIComponent(show?.backdrop || ''),
});
// Navigate to the TV Player page
navigate(`/tv/play/${id}?${params.toString()}`);
};
// Filter seasons (exclude specials for now)
const availableSeasons = useMemo(() => {
return show?.seasons.filter((s) => s.season_number > 0) || [];
}, [show]);
if (isLoading) {
return (
<div className="min-h-screen bg-netflix-black flex items-center justify-center">
<Loader size={48} className="animate-spin text-netflix-red" />
</div>
);
}
if (error || !show) {
return (
<div className="min-h-screen bg-netflix-black flex flex-col items-center justify-center">
<AlertCircle size={64} className="text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2">Error Loading Show</h1>
<p className="text-gray-400 mb-4">{error || 'Show not found'}</p>
<Button onClick={() => navigate(-1)}>Go Back</Button>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black"
>
{/* Hero Section */}
<div className="relative h-[60vh] min-h-[400px]">
<div className="absolute inset-0">
<img
src={show.backdrop || show.poster || '/placeholder-backdrop.jpg'}
alt={show.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-transparent to-transparent" />
</div>
{/* Back Button */}
<button
onClick={() => navigate(-1)}
className="absolute top-24 left-8 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors"
>
<ArrowLeft size={24} />
</button>
{/* Content */}
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-16">
<div className="max-w-4xl">
<div className="flex items-center gap-2 mb-2">
<Tv className="text-blue-400" size={20} />
<span className="text-blue-400 font-medium text-sm">TV SERIES</span>
</div>
<h1 className="text-4xl md:text-5xl font-bold mb-4">{show.title}</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-300 mb-4">
<span className="flex items-center gap-1 text-green-400">
<Star size={16} fill="currentColor" />
{show.rating.toFixed(1)}
</span>
<span className="flex items-center gap-1">
<Calendar size={16} />
{show.year}
</span>
{show.seasonCount && (
<span>{show.seasonCount} Season{show.seasonCount !== 1 ? 's' : ''}</span>
)}
{show.episodeCount && (
<span>{show.episodeCount} Episodes</span>
)}
<span className="capitalize px-2 py-0.5 bg-white/10 rounded">{show.status}</span>
</div>
{show.genres.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{show.genres.map((genre) => (
<span
key={genre}
className="px-3 py-1 bg-white/10 rounded-full text-sm"
>
{genre}
</span>
))}
</div>
)}
<p className="text-gray-300 max-w-2xl mb-6 line-clamp-3">{show.overview}</p>
</div>
</div>
</div>
{/* Episodes Section */}
<div className="px-4 md:px-8 lg:px-16 py-8">
{/* Season Selector */}
<div className="mb-6">
<div className="relative inline-block">
<button
onClick={() => setShowSeasonDropdown(!showSeasonDropdown)}
className="flex items-center gap-2 px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
>
<span className="font-semibold">
Season {selectedSeason}
</span>
<ChevronDown size={20} className={`transition-transform ${showSeasonDropdown ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{showSeasonDropdown && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 mt-2 bg-gray-900 rounded-lg shadow-xl border border-white/10 overflow-hidden z-20 min-w-[200px]"
>
{availableSeasons.map((season) => (
<button
key={season.season_number}
onClick={() => {
setSelectedSeason(season.season_number);
setShowSeasonDropdown(false);
}}
className={`w-full px-4 py-3 text-left hover:bg-white/10 transition-colors ${
selectedSeason === season.season_number ? 'bg-white/10' : ''
}`}
>
<div className="font-medium">{season.name}</div>
<div className="text-sm text-gray-400">
{season.episode_count} Episodes
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Episodes List */}
{isLoadingEpisodes ? (
<div className="flex items-center justify-center py-16">
<Loader size={32} className="animate-spin" />
</div>
) : (
<div className="space-y-4">
{episodes.map((episode) => {
const episodeTorrents = getTorrentsForEpisode(selectedSeason, episode.episode_number);
const hasTorrents = episodeTorrents.length > 0;
const isStreaming = streamingEpisode?.season === selectedSeason &&
streamingEpisode?.episode === episode.episode_number;
return (
<motion.div
key={episode.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex gap-4 p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors group"
>
{/* Episode Thumbnail */}
<div className="relative flex-shrink-0 w-40 md:w-56 aspect-video rounded overflow-hidden bg-gray-800">
{episode.still_path ? (
<img
src={`https://image.tmdb.org/t/p/w300${episode.still_path}`}
alt={episode.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Tv size={32} className="text-gray-600" />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
{hasTorrents && (
<button
onClick={() => handlePlayEpisode(selectedSeason, episode.episode_number, episodeTorrents[0])}
disabled={isStreaming}
className="p-3 rounded-full bg-white text-black hover:scale-110 transition-transform"
>
{isStreaming ? (
<Loader size={24} className="animate-spin" />
) : (
<Play size={24} fill="currentColor" />
)}
</button>
)}
</div>
<div className="absolute bottom-2 left-2 bg-black/80 px-2 py-0.5 rounded text-xs">
E{episode.episode_number}
</div>
</div>
{/* Episode Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<h3 className="font-semibold truncate">
{episode.episode_number}. {episode.name}
</h3>
<div className="flex items-center gap-3 text-sm text-gray-400 mt-1">
{episode.runtime && (
<span className="flex items-center gap-1">
<Clock size={14} />
{episode.runtime}m
</span>
)}
{episode.air_date && (
<span>{new Date(episode.air_date).toLocaleDateString()}</span>
)}
{episode.vote_average > 0 && (
<span className="flex items-center gap-1">
<Star size={14} fill="currentColor" className="text-yellow-500" />
{episode.vote_average.toFixed(1)}
</span>
)}
</div>
</div>
{/* Torrent/Play Options */}
{hasTorrents && (
<div className="flex-shrink-0">
<div className="hidden md:flex gap-2">
{episodeTorrents.slice(0, 3).map((torrent, idx) => (
<button
key={idx}
onClick={() => handlePlayEpisode(selectedSeason, episode.episode_number, torrent)}
disabled={isStreaming}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
torrent.quality === '1080p'
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{torrent.quality}
</button>
))}
</div>
<button
onClick={() => handlePlayEpisode(selectedSeason, episode.episode_number, episodeTorrents[0])}
disabled={isStreaming}
className="md:hidden px-4 py-2 bg-netflix-red rounded text-sm font-medium"
>
Play
</button>
</div>
)}
</div>
<p className="text-sm text-gray-400 line-clamp-2">{episode.overview}</p>
{!hasTorrents && !isLoadingTorrents && (
<div className="flex items-center gap-3 mt-2">
<p className="text-sm text-yellow-500">
No streams found
</p>
<button
onClick={() => searchForEpisode(selectedSeason, episode.episode_number)}
disabled={searchingEpisode === `${selectedSeason}-${episode.episode_number}`}
className="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 rounded transition-colors flex items-center gap-1"
>
{searchingEpisode === `${selectedSeason}-${episode.episode_number}` ? (
<>
<Loader size={12} className="animate-spin" />
Searching...
</>
) : (
<>
<Search size={12} />
Find Streams
</>
)}
</button>
</div>
)}
</div>
</motion.div>
);
})}
</div>
)}
{isLoadingTorrents && (
<div className="text-center py-4 text-gray-400">
<Loader size={20} className="inline animate-spin mr-2" />
Loading stream sources...
</div>
)}
</div>
{/* Search Modal */}
<AnimatePresence>
{showSearchModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setShowSearchModal(null)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-gray-900 rounded-xl p-6 max-w-lg w-full shadow-2xl border border-white/10"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-xl font-bold mb-4">Find Stream Source</h3>
<p className="text-gray-400 mb-4">
No direct stream found for <strong>S{showSearchModal.season.toString().padStart(2, '0')}E{showSearchModal.episode.toString().padStart(2, '0')}</strong> ({showSearchModal.torrent.quality}).
</p>
<div className="space-y-4">
{/* Option 1: Search externally */}
<div className="p-4 bg-white/5 rounded-lg">
<h4 className="font-semibold mb-2">Option 1: Search Online</h4>
<p className="text-sm text-gray-400 mb-3">
Click below to search for this episode on torrent sites:
</p>
<a
href={showSearchModal.torrent.magnetUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<Search size={16} />
Search on 1337x
</a>
</div>
{/* Option 2: Paste magnet */}
<div className="p-4 bg-white/5 rounded-lg">
<h4 className="font-semibold mb-2">Option 2: Paste Magnet Link</h4>
<p className="text-sm text-gray-400 mb-3">
If you have a magnet link, paste it below:
</p>
<input
type="text"
value={magnetInput}
onChange={(e) => setMagnetInput(e.target.value)}
placeholder="magnet:?xt=urn:btih:..."
className="w-full bg-black/50 border border-white/20 rounded-lg px-4 py-2 text-sm mb-3 focus:outline-none focus:border-blue-500"
/>
<Button
onClick={handleMagnetSubmit}
disabled={!magnetInput.trim()}
leftIcon={<Play size={16} />}
>
Start Streaming
</Button>
</div>
</div>
<button
onClick={() => setShowSearchModal(null)}
className="absolute top-4 right-4 text-gray-400 hover:text-white"
>
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}

386
src/pages/TVShows.tsx Normal file
View File

@ -0,0 +1,386 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Tv, Search, Play, Info, ChevronLeft, ChevronRight } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { tvDiscoveryApi, DiscoveredShow } from '../services/api/tvDiscovery';
import Button from '../components/ui/Button';
import { MovieCardSkeleton, HeroSkeleton } from '../components/ui/Skeleton';
// Hero component for TV Shows
function TVHero({ shows }: { shows: DiscoveredShow[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const navigate = useNavigate();
useEffect(() => {
if (shows.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % shows.length);
}, 8000);
return () => clearInterval(interval);
}, [shows.length]);
if (shows.length === 0) return <HeroSkeleton />;
const current = shows[currentIndex];
return (
<div className="relative h-[80vh] min-h-[600px] overflow-hidden">
{/* Background Image */}
<div className="absolute inset-0">
<img
src={current.backdrop || current.poster || '/placeholder-backdrop.jpg'}
alt={current.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/50 to-netflix-black/30" />
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black via-transparent to-transparent" />
</div>
{/* Content */}
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-16">
<motion.div
key={current.id}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-2xl"
>
<div className="flex items-center gap-2 mb-4">
<Tv className="text-blue-400" size={24} />
<span className="text-blue-400 font-semibold text-sm tracking-wider">TV SERIES</span>
</div>
<h1 className="text-4xl md:text-6xl font-bold mb-4 drop-shadow-lg">{current.title}</h1>
<div className="flex items-center gap-4 text-sm text-gray-300 mb-4">
<span className="text-green-400 font-semibold">
{current.rating ? `${(current.rating * 10).toFixed(0)}% Match` : 'New'}
</span>
<span>{current.year}</span>
{current.seasonCount && (
<span>{current.seasonCount} Season{current.seasonCount !== 1 ? 's' : ''}</span>
)}
{current.genres.length > 0 && (
<span className="hidden md:inline">{current.genres.slice(0, 2).join(' • ')}</span>
)}
</div>
<p className="text-gray-300 text-lg mb-6 line-clamp-3 leading-relaxed">
{current.overview || 'No description available.'}
</p>
<div className="flex gap-4">
<Button
size="lg"
leftIcon={<Play size={20} fill="currentColor" />}
onClick={() => navigate(`/tv/${current.id}`)}
>
Watch Now
</Button>
<Button
size="lg"
variant="secondary"
leftIcon={<Info size={20} />}
onClick={() => navigate(`/tv/${current.id}`)}
>
More Info
</Button>
</div>
</motion.div>
{/* Navigation Indicators */}
{shows.length > 1 && (
<div className="flex items-center gap-4 mt-8">
<button
onClick={() => setCurrentIndex((prev) => (prev - 1 + shows.length) % shows.length)}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
>
<ChevronLeft size={20} />
</button>
<div className="flex gap-2">
{shows.map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentIndex(idx)}
className={`h-1 rounded-full transition-all duration-300 ${
idx === currentIndex ? 'w-8 bg-white' : 'w-2 bg-white/40 hover:bg-white/60'
}`}
/>
))}
</div>
<button
onClick={() => setCurrentIndex((prev) => (prev + 1) % shows.length)}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
>
<ChevronRight size={20} />
</button>
</div>
)}
</div>
</div>
);
}
// Show Row component
function ShowRow({
title,
shows,
isLoading,
linkTo,
}: {
title: string;
shows: DiscoveredShow[];
isLoading: boolean;
linkTo?: string;
}) {
const scrollContainer = useState<HTMLDivElement | null>(null)[1];
if (isLoading) {
return (
<div className="px-4 md:px-8 mb-8">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
{[...Array(6)].map((_, i) => (
<MovieCardSkeleton key={i} />
))}
</div>
</div>
);
}
if (shows.length === 0) return null;
return (
<div className="px-4 md:px-8 mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">{title}</h2>
{linkTo && (
<Link to={linkTo} className="text-sm text-gray-400 hover:text-white transition-colors">
See All
</Link>
)}
</div>
<div
className="flex gap-3 overflow-x-auto pb-4 scrollbar-hide scroll-smooth"
ref={(el) => scrollContainer}
>
{shows.map((show) => (
<Link
key={show.id}
to={`/tv/${show.id}`}
className="flex-shrink-0 w-[160px] md:w-[180px] group"
>
<div className="relative aspect-[2/3] rounded-lg overflow-hidden mb-2 bg-gray-800">
{show.poster ? (
<img
src={show.poster}
alt={show.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-700">
<Tv size={48} className="text-gray-500" />
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Play
size={48}
className="text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow-lg"
fill="currentColor"
/>
</div>
{show.rating > 7 && (
<div className="absolute top-2 left-2 bg-green-500 text-xs px-2 py-0.5 rounded font-semibold">
{show.rating.toFixed(1)}
</div>
)}
</div>
<h3 className="font-medium text-sm truncate group-hover:text-white transition-colors">
{show.title}
</h3>
<p className="text-xs text-gray-400">
{show.year} {show.seasonCount ? `${show.seasonCount} Season${show.seasonCount !== 1 ? 's' : ''}` : ''}
</p>
</Link>
))}
</div>
</div>
);
}
export default function TVShows() {
const [trending, setTrending] = useState<DiscoveredShow[]>([]);
const [popular, setPopular] = useState<DiscoveredShow[]>([]);
const [topRated, setTopRated] = useState<DiscoveredShow[]>([]);
const [airingToday, setAiringToday] = useState<DiscoveredShow[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DiscoveredShow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSearching, setIsSearching] = useState(false);
// Load initial data
useEffect(() => {
async function loadData() {
setIsLoading(true);
try {
const [trendingData, popularData, topRatedData, airingData] = await Promise.all([
tvDiscoveryApi.getTrending(),
tvDiscoveryApi.getPopular(),
tvDiscoveryApi.getTopRated(),
tvDiscoveryApi.getAiringToday(),
]);
setTrending(trendingData.shows);
setPopular(popularData.shows);
setTopRated(topRatedData.shows);
setAiringToday(airingData.shows);
} catch (error) {
console.error('Error loading TV shows:', error);
} finally {
setIsLoading(false);
}
}
loadData();
}, []);
// Search handler
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
const searchTimeout = setTimeout(async () => {
setIsSearching(true);
try {
const results = await tvDiscoveryApi.search(searchQuery);
setSearchResults(results.shows);
} catch (error) {
console.error('Search error:', error);
} finally {
setIsSearching(false);
}
}, 300);
return () => clearTimeout(searchTimeout);
}, [searchQuery]);
const isShowingSearch = searchQuery.trim().length > 0;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black"
>
{/* Hero Section */}
{!isShowingSearch && <TVHero shows={trending.slice(0, 5)} />}
{/* Search Bar */}
<div className={`sticky top-16 z-30 bg-netflix-black/95 backdrop-blur-lg py-4 px-4 md:px-8 ${isShowingSearch ? 'pt-24' : ''}`}>
<div className="max-w-2xl mx-auto">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="Search TV shows..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white/10 border border-white/20 rounded-full py-3 pl-12 pr-4 text-white placeholder-gray-400 focus:outline-none focus:border-white/40 focus:bg-white/15 transition-all"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
>
</button>
)}
</div>
</div>
</div>
{/* Content */}
<div className={trending.length > 0 && !isShowingSearch ? '-mt-32 relative z-10 pt-8' : 'pt-4'}>
{isShowingSearch ? (
// Search Results
<div className="px-4 md:px-8 pb-16">
<h2 className="text-xl font-semibold mb-6">
{isSearching ? 'Searching...' : `Results for "${searchQuery}"`}
</h2>
{isSearching ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{[...Array(12)].map((_, i) => (
<MovieCardSkeleton key={i} />
))}
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<Tv size={64} className="mx-auto mb-4 opacity-50" />
<p>No shows found for "{searchQuery}"</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{searchResults.map((show) => (
<Link
key={show.id}
to={`/tv/${show.id}`}
className="group"
>
<div className="relative aspect-[2/3] rounded-lg overflow-hidden mb-2 bg-gray-800">
{show.poster ? (
<img
src={show.poster}
alt={show.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-700">
<Tv size={48} className="text-gray-500" />
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Play
size={48}
className="text-white opacity-0 group-hover:opacity-100 transition-opacity"
fill="currentColor"
/>
</div>
</div>
<h3 className="font-medium text-sm truncate">{show.title}</h3>
<p className="text-xs text-gray-400">{show.year}</p>
</Link>
))}
</div>
)}
</div>
) : (
// Browse Rows
<>
<ShowRow
title="🔥 Trending This Week"
shows={trending}
isLoading={isLoading}
/>
<ShowRow
title="📺 Airing Today"
shows={airingToday}
isLoading={isLoading}
/>
<ShowRow
title="⭐ Top Rated"
shows={topRated}
isLoading={isLoading}
/>
<ShowRow
title="🎬 Popular"
shows={popular}
isLoading={isLoading}
/>
</>
)}
</div>
</motion.div>
);
}

69
src/pages/Watchlist.tsx Normal file
View File

@ -0,0 +1,69 @@
import { motion } from 'framer-motion';
import { Heart, Trash2 } from 'lucide-react';
import { Link } from 'react-router-dom';
import MovieGrid from '../components/movie/MovieGrid';
import Button from '../components/ui/Button';
import { useWatchlistStore } from '../stores/watchlistStore';
export default function Watchlist() {
const { items, clearWatchlist } = useWatchlistStore();
const movies = items.map((item) => item.movie);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="min-h-screen bg-netflix-black pt-24 pb-16"
>
<div className="max-w-[1920px] mx-auto px-4 md:px-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl md:text-4xl font-bold flex items-center gap-3">
<Heart className="text-netflix-red" fill="currentColor" />
My List
</h1>
<p className="text-gray-400 mt-1">
{items.length} {items.length === 1 ? 'movie' : 'movies'} saved
</p>
</div>
{items.length > 0 && (
<Button
variant="ghost"
onClick={() => {
if (confirm('Are you sure you want to clear your watchlist?')) {
clearWatchlist();
}
}}
leftIcon={<Trash2 size={18} />}
>
Clear All
</Button>
)}
</div>
{/* Content */}
{items.length > 0 ? (
<MovieGrid movies={movies} />
) : (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-16"
>
<Heart size={64} className="mx-auto text-gray-600 mb-4" />
<h2 className="text-xl text-gray-400 mb-2">Your watchlist is empty</h2>
<p className="text-gray-500 mb-6">
Start adding movies you want to watch later
</p>
<Link to="/browse">
<Button>Browse Movies</Button>
</Link>
</motion.div>
)}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,139 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
export interface ArrClientConfig {
baseUrl: string;
apiKey: string;
timeout?: number;
}
export interface ConnectionTestResult {
success: boolean;
version?: string;
error?: string;
}
export abstract class BaseArrClient {
protected client: AxiosInstance;
protected apiKey: string;
protected baseUrl: string;
protected apiVersion: string;
constructor(config: ArrClientConfig, apiVersion = 'v3') {
this.apiKey = config.apiKey;
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.apiVersion = apiVersion;
this.client = axios.create({
baseURL: this.baseUrl,
timeout: config.timeout || 15000,
headers: {
'Content-Type': 'application/json',
'X-Api-Key': this.apiKey,
},
});
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
throw new Error('Invalid API key');
}
if (error.response?.status === 404) {
throw new Error('Resource not found');
}
if (error.response?.status === 500) {
throw new Error('Server error');
}
if (error.code === 'ECONNREFUSED') {
throw new Error('Connection refused - server may be offline');
}
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
throw new Error('Connection timeout');
}
throw error;
}
);
}
/**
* Test connection to the service
*/
async testConnection(): Promise<ConnectionTestResult> {
try {
const response = await this.client.get(`/api/${this.apiVersion}/system/status`);
return { success: true, version: response.data.version };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Connection failed',
};
}
}
/**
* Get system status
*/
async getSystemStatus<T>(): Promise<T> {
return this.get<T>(`/api/${this.apiVersion}/system/status`);
}
/**
* Get quality profiles
*/
async getQualityProfiles(): Promise<{ id: number; name: string }[]> {
return this.get(`/api/${this.apiVersion}/qualityprofile`);
}
/**
* Get root folders
*/
async getRootFolders(): Promise<{ id: number; path: string; freeSpace: number }[]> {
return this.get(`/api/${this.apiVersion}/rootfolder`);
}
/**
* Get download queue
*/
async getQueue<T>(page = 1, pageSize = 50): Promise<{ page: number; pageSize: number; totalRecords: number; records: T[] }> {
return this.get(`/api/${this.apiVersion}/queue`, {
params: { page, pageSize, includeUnknownMovieItems: false, includeSeries: true, includeEpisode: true, includeArtist: true, includeAlbum: true },
});
}
/**
* Remove item from queue
*/
async removeFromQueue(id: number, removeFromClient = true, blocklist = false): Promise<void> {
await this.delete(`/api/${this.apiVersion}/queue/${id}?removeFromClient=${removeFromClient}&blocklist=${blocklist}`);
}
/**
* Execute a command
*/
async executeCommand(name: string, body: Record<string, unknown> = {}): Promise<{ id: number }> {
return this.post(`/api/${this.apiVersion}/command`, { name, ...body });
}
// HTTP Methods
protected async get<T>(endpoint: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.get<T>(endpoint, config);
return response.data;
}
protected async post<T>(endpoint: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.post<T>(endpoint, data, config);
return response.data;
}
protected async put<T>(endpoint: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.put<T>(endpoint, data, config);
return response.data;
}
protected async delete<T>(endpoint: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.delete<T>(endpoint, config);
return response.data;
}
}

383
src/services/api/lidarr.ts Normal file
View File

@ -0,0 +1,383 @@
import { BaseArrClient, ArrClientConfig, ConnectionTestResult } from './baseArrClient';
import type {
LidarrArtist,
LidarrAlbum,
LidarrTrack,
LidarrQueueItem,
LidarrSystemStatus,
LidarrQualityProfile,
LidarrMetadataProfile,
LidarrRootFolder,
LidarrAddArtistRequest,
} from '../../types/lidarr';
export class LidarrClient extends BaseArrClient {
constructor(config: ArrClientConfig) {
// Lidarr uses v1 API
super(config, 'v1');
}
/**
* Override test connection for v1 API
*/
async testConnection(): Promise<ConnectionTestResult> {
try {
const response = await this.client.get('/api/v1/system/status');
return { success: true, version: response.data.version };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Connection failed',
};
}
}
// ==================== Artists ====================
/**
* Get all artists in library
*/
async getArtists(): Promise<LidarrArtist[]> {
return this.get<LidarrArtist[]>('/api/v1/artist');
}
/**
* Get a specific artist by ID
*/
async getArtist(id: number): Promise<LidarrArtist> {
return this.get<LidarrArtist>(`/api/v1/artist/${id}`);
}
/**
* Get artist by MusicBrainz ID
*/
async getArtistByMbId(mbId: string): Promise<LidarrArtist | null> {
try {
const artists = await this.getArtists();
return artists.find((a) => a.foreignArtistId === mbId) || null;
} catch {
return null;
}
}
/**
* Search for artists to add
*/
async searchArtist(term: string): Promise<LidarrArtist[]> {
return this.get<LidarrArtist[]>('/api/v1/artist/lookup', {
params: { term },
});
}
/**
* Add an artist to library
*/
async addArtist(artist: LidarrAddArtistRequest): Promise<LidarrArtist> {
return this.post<LidarrArtist>('/api/v1/artist', artist);
}
/**
* Update an artist
*/
async updateArtist(artist: LidarrArtist): Promise<LidarrArtist> {
return this.put<LidarrArtist>(`/api/v1/artist/${artist.id}`, artist);
}
/**
* Delete an artist
*/
async deleteArtist(id: number, deleteFiles = false, addImportListExclusion = false): Promise<void> {
await this.delete(`/api/v1/artist/${id}?deleteFiles=${deleteFiles}&addImportListExclusion=${addImportListExclusion}`);
}
// ==================== Albums ====================
/**
* Get all albums (optionally filtered by artist)
*/
async getAlbums(artistId?: number): Promise<LidarrAlbum[]> {
const params: Record<string, unknown> = {};
if (artistId) params.artistId = artistId;
return this.get<LidarrAlbum[]>('/api/v1/album', { params });
}
/**
* Get a specific album by ID
*/
async getAlbum(id: number): Promise<LidarrAlbum> {
return this.get<LidarrAlbum>(`/api/v1/album/${id}`);
}
/**
* Get album by MusicBrainz ID
*/
async getAlbumByMbId(mbId: string): Promise<LidarrAlbum | null> {
try {
const albums = await this.getAlbums();
return albums.find((a) => a.foreignAlbumId === mbId) || null;
} catch {
return null;
}
}
/**
* Search for albums to add
*/
async searchAlbum(term: string): Promise<LidarrAlbum[]> {
return this.get<LidarrAlbum[]>('/api/v1/album/lookup', {
params: { term },
});
}
/**
* Update an album
*/
async updateAlbum(album: LidarrAlbum): Promise<LidarrAlbum> {
return this.put<LidarrAlbum>(`/api/v1/album/${album.id}`, album);
}
/**
* Delete an album
*/
async deleteAlbum(id: number, deleteFiles = false, addImportListExclusion = false): Promise<void> {
await this.delete(`/api/v1/album/${id}?deleteFiles=${deleteFiles}&addImportListExclusion=${addImportListExclusion}`);
}
// ==================== Tracks ====================
/**
* Get tracks for an album
*/
async getTracks(albumId: number): Promise<LidarrTrack[]> {
return this.get<LidarrTrack[]>('/api/v1/track', {
params: { albumId },
});
}
/**
* Get a specific track by ID
*/
async getTrack(id: number): Promise<LidarrTrack> {
return this.get<LidarrTrack>(`/api/v1/track/${id}`);
}
/**
* Get tracks by artist
*/
async getTracksByArtist(artistId: number): Promise<LidarrTrack[]> {
return this.get<LidarrTrack[]>('/api/v1/track', {
params: { artistId },
});
}
// ==================== Calendar ====================
/**
* Get calendar (upcoming album releases)
*/
async getCalendar(start: Date, end: Date): Promise<LidarrAlbum[]> {
return this.get<LidarrAlbum[]>('/api/v1/calendar', {
params: {
start: start.toISOString(),
end: end.toISOString(),
unmonitored: false,
includeArtist: true,
},
});
}
// ==================== Wanted/Missing ====================
/**
* Get wanted/missing albums
*/
async getWantedMissing(page = 1, pageSize = 20, sortKey = 'releaseDate', sortDirection = 'descending'): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: LidarrAlbum[];
}> {
return this.get('/api/v1/wanted/missing', {
params: { page, pageSize, sortKey, sortDirection, includeArtist: true, monitored: true },
});
}
/**
* Get cutoff unmet albums
*/
async getCutoffUnmet(page = 1, pageSize = 20): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: LidarrAlbum[];
}> {
return this.get('/api/v1/wanted/cutoff', {
params: { page, pageSize, includeArtist: true, monitored: true },
});
}
// ==================== Queue ====================
/**
* Get download queue
*/
async getDownloadQueue(page = 1, pageSize = 50): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: LidarrQueueItem[];
}> {
return this.get('/api/v1/queue', {
params: { page, pageSize, includeArtist: true, includeAlbum: true },
});
}
// ==================== Commands ====================
/**
* Refresh artist metadata
*/
async refreshArtist(artistId?: number): Promise<{ id: number }> {
const body: Record<string, unknown> = {};
if (artistId) {
body.artistId = artistId;
}
return this.executeCommand('RefreshArtist', body);
}
/**
* Search for album downloads
*/
async searchAlbumDownload(albumIds: number[]): Promise<{ id: number }> {
return this.executeCommand('AlbumSearch', { albumIds });
}
/**
* Search for artist downloads
*/
async searchArtistDownload(artistId: number): Promise<{ id: number }> {
return this.executeCommand('ArtistSearch', { artistId });
}
/**
* Rescan artist files
*/
async rescanArtist(artistId?: number): Promise<{ id: number }> {
const body: Record<string, unknown> = {};
if (artistId) {
body.artistId = artistId;
}
return this.executeCommand('RescanArtist', body);
}
/**
* Rename artist files
*/
async renameArtist(artistIds: number[]): Promise<{ id: number }> {
return this.executeCommand('RenameArtist', { artistIds });
}
/**
* Retag files
*/
async retagArtist(artistIds: number[]): Promise<{ id: number }> {
return this.executeCommand('RetagArtist', { artistIds });
}
// ==================== System ====================
/**
* Get system status
*/
async getStatus(): Promise<LidarrSystemStatus> {
return this.get<LidarrSystemStatus>('/api/v1/system/status');
}
/**
* Get disk space
*/
async getDiskSpace(): Promise<{ path: string; label: string; freeSpace: number; totalSpace: number }[]> {
return this.get('/api/v1/diskspace');
}
/**
* Get health check
*/
async getHealth(): Promise<{ source: string; type: string; message: string; wikiUrl: string }[]> {
return this.get('/api/v1/health');
}
// ==================== Profiles ====================
/**
* Get quality profiles
*/
async getProfiles(): Promise<LidarrQualityProfile[]> {
return this.get('/api/v1/qualityprofile');
}
/**
* Get metadata profiles
*/
async getMetadataProfiles(): Promise<LidarrMetadataProfile[]> {
return this.get('/api/v1/metadataprofile');
}
/**
* Get root folders
*/
async getFolders(): Promise<LidarrRootFolder[]> {
return this.get('/api/v1/rootfolder');
}
// ==================== History ====================
/**
* Get history
*/
async getHistory(page = 1, pageSize = 20, artistId?: number, albumId?: number): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: {
id: number;
artistId: number;
albumId: number;
sourceTitle: string;
date: string;
eventType: string;
data: Record<string, string>;
}[];
}> {
const params: Record<string, unknown> = { page, pageSize, includeArtist: true, includeAlbum: true };
if (artistId) params.artistId = artistId;
if (albumId) params.albumId = albumId;
return this.get('/api/v1/history', { params });
}
// ==================== Tags ====================
/**
* Get all tags
*/
async getTags(): Promise<{ id: number; label: string }[]> {
return this.get('/api/v1/tag');
}
}
// Singleton instance management
let lidarrClient: LidarrClient | null = null;
export function initializeLidarr(config: ArrClientConfig): LidarrClient {
lidarrClient = new LidarrClient(config);
return lidarrClient;
}
export function getLidarrClient(): LidarrClient | null {
return lidarrClient;
}
export function destroyLidarrClient(): void {
lidarrClient = null;
}

304
src/services/api/radarr.ts Normal file
View File

@ -0,0 +1,304 @@
import { BaseArrClient, ArrClientConfig } from './baseArrClient';
import type {
RadarrMovie,
RadarrQueueItem,
RadarrSystemStatus,
RadarrQualityProfile,
RadarrRootFolder,
RadarrAddMovieRequest,
} from '../../types/radarr';
export class RadarrClient extends BaseArrClient {
constructor(config: ArrClientConfig) {
super(config, 'v3');
}
// ==================== Movies ====================
/**
* Get all movies in library
*/
async getMovies(): Promise<RadarrMovie[]> {
return this.get<RadarrMovie[]>('/api/v3/movie');
}
/**
* Get a specific movie by ID
*/
async getMovie(id: number): Promise<RadarrMovie> {
return this.get<RadarrMovie>(`/api/v3/movie/${id}`);
}
/**
* Get movie by TMDB ID
*/
async getMovieByTmdbId(tmdbId: number): Promise<RadarrMovie | null> {
try {
const movies = await this.get<RadarrMovie[]>('/api/v3/movie', {
params: { tmdbId },
});
return movies[0] || null;
} catch {
return null;
}
}
/**
* Get movie by IMDB ID
*/
async getMovieByImdbId(imdbId: string): Promise<RadarrMovie | null> {
try {
const movies = await this.getMovies();
return movies.find((m) => m.imdbId === imdbId) || null;
} catch {
return null;
}
}
/**
* Search for movies to add
*/
async searchMovie(term: string): Promise<RadarrMovie[]> {
return this.get<RadarrMovie[]>('/api/v3/movie/lookup', {
params: { term },
});
}
/**
* Lookup movie by TMDB ID
*/
async lookupMovieByTmdbId(tmdbId: number): Promise<RadarrMovie | null> {
try {
const movie = await this.get<RadarrMovie>('/api/v3/movie/lookup/tmdb', {
params: { tmdbId },
});
return movie;
} catch {
return null;
}
}
/**
* Lookup movie by IMDB ID
*/
async lookupMovieByImdbId(imdbId: string): Promise<RadarrMovie | null> {
try {
const movie = await this.get<RadarrMovie>('/api/v3/movie/lookup/imdb', {
params: { imdbId },
});
return movie;
} catch {
return null;
}
}
/**
* Add a movie to library
*/
async addMovie(movie: RadarrAddMovieRequest): Promise<RadarrMovie> {
return this.post<RadarrMovie>('/api/v3/movie', movie);
}
/**
* Update a movie
*/
async updateMovie(movie: RadarrMovie): Promise<RadarrMovie> {
return this.put<RadarrMovie>(`/api/v3/movie/${movie.id}`, movie);
}
/**
* Delete a movie
*/
async deleteMovie(id: number, deleteFiles = false, addImportExclusion = false): Promise<void> {
await this.delete(`/api/v3/movie/${id}?deleteFiles=${deleteFiles}&addImportExclusion=${addImportExclusion}`);
}
// ==================== Calendar ====================
/**
* Get calendar (upcoming/recent releases)
*/
async getCalendar(start: Date, end: Date): Promise<RadarrMovie[]> {
return this.get<RadarrMovie[]>('/api/v3/calendar', {
params: {
start: start.toISOString(),
end: end.toISOString(),
unmonitored: false,
},
});
}
// ==================== Wanted/Missing ====================
/**
* Get wanted/missing movies
*/
async getWantedMissing(page = 1, pageSize = 20, sortKey = 'movieMetadata.sortTitle', sortDirection = 'ascending'): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: RadarrMovie[];
}> {
return this.get('/api/v3/wanted/missing', {
params: { page, pageSize, sortKey, sortDirection, monitored: true },
});
}
/**
* Get cutoff unmet movies
*/
async getCutoffUnmet(page = 1, pageSize = 20): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: RadarrMovie[];
}> {
return this.get('/api/v3/wanted/cutoff', {
params: { page, pageSize, monitored: true },
});
}
// ==================== Queue ====================
/**
* Get download queue
*/
async getDownloadQueue(page = 1, pageSize = 50): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: RadarrQueueItem[];
}> {
return this.get('/api/v3/queue', {
params: { page, pageSize, includeMovie: true },
});
}
// ==================== Commands ====================
/**
* Refresh movie metadata
*/
async refreshMovie(movieId?: number): Promise<{ id: number }> {
const body: Record<string, unknown> = {};
if (movieId) {
body.movieIds = [movieId];
}
return this.executeCommand('RefreshMovie', body);
}
/**
* Search for movie downloads
*/
async searchMovieDownload(movieIds: number[]): Promise<{ id: number }> {
return this.executeCommand('MoviesSearch', { movieIds });
}
/**
* Rescan movie files
*/
async rescanMovie(movieId?: number): Promise<{ id: number }> {
const body: Record<string, unknown> = {};
if (movieId) {
body.movieId = movieId;
}
return this.executeCommand('RescanMovie', body);
}
/**
* Rename movie files
*/
async renameMovie(movieIds: number[]): Promise<{ id: number }> {
return this.executeCommand('RenameMovie', { movieIds });
}
// ==================== System ====================
/**
* Get system status
*/
async getStatus(): Promise<RadarrSystemStatus> {
return this.get<RadarrSystemStatus>('/api/v3/system/status');
}
/**
* Get disk space
*/
async getDiskSpace(): Promise<{ path: string; label: string; freeSpace: number; totalSpace: number }[]> {
return this.get('/api/v3/diskspace');
}
/**
* Get health check
*/
async getHealth(): Promise<{ source: string; type: string; message: string; wikiUrl: string }[]> {
return this.get('/api/v3/health');
}
// ==================== Profiles ====================
/**
* Get quality profiles
*/
async getProfiles(): Promise<RadarrQualityProfile[]> {
return this.get('/api/v3/qualityprofile');
}
/**
* Get root folders
*/
async getFolders(): Promise<RadarrRootFolder[]> {
return this.get('/api/v3/rootfolder');
}
// ==================== History ====================
/**
* Get history
*/
async getHistory(page = 1, pageSize = 20, movieId?: number): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: {
id: number;
movieId: number;
sourceTitle: string;
date: string;
eventType: string;
data: Record<string, string>;
}[];
}> {
const params: Record<string, unknown> = { page, pageSize };
if (movieId) {
params.movieId = movieId;
}
return this.get('/api/v3/history', { params });
}
// ==================== Tags ====================
/**
* Get all tags
*/
async getTags(): Promise<{ id: number; label: string }[]> {
return this.get('/api/v3/tag');
}
}
// Singleton instance management
let radarrClient: RadarrClient | null = null;
export function initializeRadarr(config: ArrClientConfig): RadarrClient {
radarrClient = new RadarrClient(config);
return radarrClient;
}
export function getRadarrClient(): RadarrClient | null {
return radarrClient;
}
export function destroyRadarrClient(): void {
radarrClient = null;
}

360
src/services/api/sonarr.ts Normal file
View File

@ -0,0 +1,360 @@
import { BaseArrClient, ArrClientConfig } from './baseArrClient';
import type {
SonarrSeries,
SonarrEpisode,
SonarrCalendarItem,
SonarrQueueItem,
SonarrSystemStatus,
SonarrQualityProfile,
SonarrRootFolder,
SonarrAddSeriesRequest,
} from '../../types/sonarr';
export class SonarrClient extends BaseArrClient {
constructor(config: ArrClientConfig) {
super(config, 'v3');
}
// ==================== Series ====================
/**
* Get all series in library
*/
async getSeries(): Promise<SonarrSeries[]> {
return this.get<SonarrSeries[]>('/api/v3/series');
}
/**
* Get a specific series by ID
*/
async getSeriesById(id: number): Promise<SonarrSeries> {
return this.get<SonarrSeries>(`/api/v3/series/${id}`);
}
/**
* Get series by TVDB ID
*/
async getSeriesByTvdbId(tvdbId: number): Promise<SonarrSeries | null> {
try {
const series = await this.getSeries();
return series.find((s) => s.tvdbId === tvdbId) || null;
} catch {
return null;
}
}
/**
* Get series by IMDB ID
*/
async getSeriesByImdbId(imdbId: string): Promise<SonarrSeries | null> {
try {
const series = await this.getSeries();
return series.find((s) => s.imdbId === imdbId) || null;
} catch {
return null;
}
}
/**
* Search for series to add
*/
async searchSeries(term: string): Promise<SonarrSeries[]> {
return this.get<SonarrSeries[]>('/api/v3/series/lookup', {
params: { term },
});
}
/**
* Lookup series by TVDB ID
*/
async lookupSeriesByTvdbId(tvdbId: number): Promise<SonarrSeries | null> {
try {
const results = await this.get<SonarrSeries[]>('/api/v3/series/lookup', {
params: { term: `tvdb:${tvdbId}` },
});
return results[0] || null;
} catch {
return null;
}
}
/**
* Add a series to library
*/
async addSeries(series: SonarrAddSeriesRequest): Promise<SonarrSeries> {
return this.post<SonarrSeries>('/api/v3/series', series);
}
/**
* Update a series
*/
async updateSeries(series: SonarrSeries): Promise<SonarrSeries> {
return this.put<SonarrSeries>(`/api/v3/series/${series.id}`, series);
}
/**
* Delete a series
*/
async deleteSeries(id: number, deleteFiles = false, addImportListExclusion = false): Promise<void> {
await this.delete(`/api/v3/series/${id}?deleteFiles=${deleteFiles}&addImportListExclusion=${addImportListExclusion}`);
}
// ==================== Episodes ====================
/**
* Get episodes for a series
*/
async getEpisodes(seriesId: number): Promise<SonarrEpisode[]> {
return this.get<SonarrEpisode[]>('/api/v3/episode', {
params: { seriesId },
});
}
/**
* Get a specific episode by ID
*/
async getEpisode(id: number): Promise<SonarrEpisode> {
return this.get<SonarrEpisode>(`/api/v3/episode/${id}`);
}
/**
* Get episodes by season
*/
async getEpisodesBySeason(seriesId: number, seasonNumber: number): Promise<SonarrEpisode[]> {
return this.get<SonarrEpisode[]>('/api/v3/episode', {
params: { seriesId, seasonNumber },
});
}
/**
* Update episode (monitoring status)
*/
async updateEpisode(episode: SonarrEpisode): Promise<SonarrEpisode> {
return this.put<SonarrEpisode>(`/api/v3/episode/${episode.id}`, episode);
}
/**
* Update multiple episodes monitoring
*/
async updateEpisodesMonitor(episodeIds: number[], monitored: boolean): Promise<SonarrEpisode[]> {
return this.put<SonarrEpisode[]>('/api/v3/episode/monitor', { episodeIds, monitored });
}
// ==================== Calendar ====================
/**
* Get calendar (upcoming episodes)
*/
async getCalendar(start: Date, end: Date, includeSeries = true, includeEpisodeFile = true): Promise<SonarrCalendarItem[]> {
return this.get<SonarrCalendarItem[]>('/api/v3/calendar', {
params: {
start: start.toISOString(),
end: end.toISOString(),
unmonitored: false,
includeSeries,
includeEpisodeFile,
},
});
}
// ==================== Wanted/Missing ====================
/**
* Get wanted/missing episodes
*/
async getWantedMissing(page = 1, pageSize = 20, sortKey = 'airDateUtc', sortDirection = 'descending'): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: SonarrEpisode[];
}> {
return this.get('/api/v3/wanted/missing', {
params: { page, pageSize, sortKey, sortDirection, includeSeries: true, monitored: true },
});
}
/**
* Get cutoff unmet episodes
*/
async getCutoffUnmet(page = 1, pageSize = 20): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: SonarrEpisode[];
}> {
return this.get('/api/v3/wanted/cutoff', {
params: { page, pageSize, includeSeries: true, monitored: true },
});
}
// ==================== Queue ====================
/**
* Get download queue
*/
async getDownloadQueue(page = 1, pageSize = 50): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: SonarrQueueItem[];
}> {
return this.get('/api/v3/queue', {
params: { page, pageSize, includeSeries: true, includeEpisode: true },
});
}
// ==================== Commands ====================
/**
* Refresh series metadata
*/
async refreshSeries(seriesId?: number): Promise<{ id: number }> {
const body: Record<string, unknown> = {};
if (seriesId) {
body.seriesId = seriesId;
}
return this.executeCommand('RefreshSeries', body);
}
/**
* Search for episode downloads
*/
async searchEpisode(episodeIds: number[]): Promise<{ id: number }> {
return this.executeCommand('EpisodeSearch', { episodeIds });
}
/**
* Search for season downloads
*/
async searchSeason(seriesId: number, seasonNumber: number): Promise<{ id: number }> {
return this.executeCommand('SeasonSearch', { seriesId, seasonNumber });
}
/**
* Search for series downloads
*/
async searchSeriesDownload(seriesId: number): Promise<{ id: number }> {
return this.executeCommand('SeriesSearch', { seriesId });
}
/**
* Rescan series files
*/
async rescanSeries(seriesId?: number): Promise<{ id: number }> {
const body: Record<string, unknown> = {};
if (seriesId) {
body.seriesId = seriesId;
}
return this.executeCommand('RescanSeries', body);
}
/**
* Rename series files
*/
async renameSeries(seriesIds: number[]): Promise<{ id: number }> {
return this.executeCommand('RenameSeries', { seriesIds });
}
// ==================== System ====================
/**
* Get system status
*/
async getStatus(): Promise<SonarrSystemStatus> {
return this.get<SonarrSystemStatus>('/api/v3/system/status');
}
/**
* Get disk space
*/
async getDiskSpace(): Promise<{ path: string; label: string; freeSpace: number; totalSpace: number }[]> {
return this.get('/api/v3/diskspace');
}
/**
* Get health check
*/
async getHealth(): Promise<{ source: string; type: string; message: string; wikiUrl: string }[]> {
return this.get('/api/v3/health');
}
// ==================== Profiles ====================
/**
* Get quality profiles
*/
async getProfiles(): Promise<SonarrQualityProfile[]> {
return this.get('/api/v3/qualityprofile');
}
/**
* Get root folders
*/
async getFolders(): Promise<SonarrRootFolder[]> {
return this.get('/api/v3/rootfolder');
}
// ==================== History ====================
/**
* Get history
*/
async getHistory(page = 1, pageSize = 20, seriesId?: number, episodeId?: number): Promise<{
page: number;
pageSize: number;
totalRecords: number;
records: {
id: number;
seriesId: number;
episodeId: number;
sourceTitle: string;
date: string;
eventType: string;
data: Record<string, string>;
}[];
}> {
const params: Record<string, unknown> = { page, pageSize, includeSeries: true, includeEpisode: true };
if (seriesId) params.seriesId = seriesId;
if (episodeId) params.episodeId = episodeId;
return this.get('/api/v3/history', { params });
}
// ==================== Tags ====================
/**
* Get all tags
*/
async getTags(): Promise<{ id: number; label: string }[]> {
return this.get('/api/v3/tag');
}
// ==================== Season Monitor ====================
/**
* Update season monitoring
*/
async updateSeasonMonitor(seriesId: number, seasonNumber: number, monitored: boolean): Promise<void> {
const series = await this.getSeriesById(seriesId);
const updatedSeasons = series.seasons.map((s) =>
s.seasonNumber === seasonNumber ? { ...s, monitored } : s
);
await this.updateSeries({ ...series, seasons: updatedSeasons });
}
}
// Singleton instance management
let sonarrClient: SonarrClient | null = null;
export function initializeSonarr(config: ArrClientConfig): SonarrClient {
sonarrClient = new SonarrClient(config);
return sonarrClient;
}
export function getSonarrClient(): SonarrClient | null {
return sonarrClient;
}
export function destroySonarrClient(): void {
sonarrClient = null;
}

View File

@ -0,0 +1,438 @@
import axios from 'axios';
// TMDB API for TV show metadata and discovery
// Using a proxy to avoid CORS issues
const TMDB_API_KEY = 'feb8e38b40eb61667c6b2ee0ad8a97f4';
const TMDB_BASE_URL = import.meta.env.DEV ? '/api/tmdb' : 'https://api.themoviedb.org/3';
const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p';
// EZTV API for torrents
const EZTV_BASE_URL = '/api/eztv'; // Proxied through Vite
export interface DiscoveredShow {
id: number;
imdbId?: string;
title: string;
originalTitle?: string;
overview: string;
poster: string | null;
backdrop: string | null;
firstAirDate: string;
year: number;
rating: number;
voteCount: number;
popularity: number;
genres: string[];
status?: string;
seasonCount?: number;
episodeCount?: number;
networks?: string[];
}
export interface DiscoveredEpisode {
id: number;
showId: number;
showTitle: string;
season: number;
episode: number;
title: string;
overview: string;
airDate: string;
stillPath: string | null;
torrents: EztvTorrent[];
}
export interface EztvTorrent {
id: number;
hash: string;
filename: string;
title: string;
season: string;
episode: string;
quality: string;
size: string;
sizeBytes: number;
seeds: number;
peers: number;
magnetUrl: string;
}
// Simple cache
const cache = new Map<string, { data: unknown; timestamp: number }>();
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
async function cachedFetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return cached.data as T;
}
const data = await fetcher();
cache.set(key, { data, timestamp: Date.now() });
return data;
}
// Transform TMDB show data
function transformShow(show: any): DiscoveredShow {
return {
id: show.id,
title: show.name || show.original_name,
originalTitle: show.original_name,
overview: show.overview || '',
poster: show.poster_path ? `${TMDB_IMAGE_BASE}/w500${show.poster_path}` : null,
backdrop: show.backdrop_path ? `${TMDB_IMAGE_BASE}/original${show.backdrop_path}` : null,
firstAirDate: show.first_air_date || '',
year: show.first_air_date ? parseInt(show.first_air_date.split('-')[0]) : 0,
rating: show.vote_average || 0,
voteCount: show.vote_count || 0,
popularity: show.popularity || 0,
genres: show.genres?.map((g: any) => g.name) || [],
status: show.status,
seasonCount: show.number_of_seasons,
episodeCount: show.number_of_episodes,
networks: show.networks?.map((n: any) => n.name) || [],
};
}
export const tvDiscoveryApi = {
/**
* Get trending TV shows
*/
async getTrending(page = 1): Promise<{ shows: DiscoveredShow[]; totalPages: number }> {
return cachedFetch(`trending:${page}`, async () => {
const response = await axios.get(`${TMDB_BASE_URL}/trending/tv/week`, {
params: { api_key: TMDB_API_KEY, page },
});
return {
shows: response.data.results.map(transformShow),
totalPages: response.data.total_pages,
};
});
},
/**
* Get popular TV shows
*/
async getPopular(page = 1): Promise<{ shows: DiscoveredShow[]; totalPages: number }> {
return cachedFetch(`popular:${page}`, async () => {
const response = await axios.get(`${TMDB_BASE_URL}/tv/popular`, {
params: { api_key: TMDB_API_KEY, page },
});
return {
shows: response.data.results.map(transformShow),
totalPages: response.data.total_pages,
};
});
},
/**
* Get top rated TV shows
*/
async getTopRated(page = 1): Promise<{ shows: DiscoveredShow[]; totalPages: number }> {
return cachedFetch(`toprated:${page}`, async () => {
const response = await axios.get(`${TMDB_BASE_URL}/tv/top_rated`, {
params: { api_key: TMDB_API_KEY, page },
});
return {
shows: response.data.results.map(transformShow),
totalPages: response.data.total_pages,
};
});
},
/**
* Get shows airing today
*/
async getAiringToday(page = 1): Promise<{ shows: DiscoveredShow[]; totalPages: number }> {
return cachedFetch(`airing:${page}`, async () => {
const response = await axios.get(`${TMDB_BASE_URL}/tv/airing_today`, {
params: { api_key: TMDB_API_KEY, page },
});
return {
shows: response.data.results.map(transformShow),
totalPages: response.data.total_pages,
};
});
},
/**
* Search TV shows
*/
async search(query: string, page = 1): Promise<{ shows: DiscoveredShow[]; totalPages: number }> {
if (!query.trim()) return { shows: [], totalPages: 0 };
return cachedFetch(`search:${query}:${page}`, async () => {
const response = await axios.get(`${TMDB_BASE_URL}/search/tv`, {
params: { api_key: TMDB_API_KEY, query, page },
});
return {
shows: response.data.results.map(transformShow),
totalPages: response.data.total_pages,
};
});
},
/**
* Get show details
*/
async getShowDetails(tmdbId: number): Promise<DiscoveredShow & { seasons: any[]; externalIds: any }> {
return cachedFetch(`show:${tmdbId}`, async () => {
const [details, externalIds] = await Promise.all([
axios.get(`${TMDB_BASE_URL}/tv/${tmdbId}`, {
params: { api_key: TMDB_API_KEY },
}),
axios.get(`${TMDB_BASE_URL}/tv/${tmdbId}/external_ids`, {
params: { api_key: TMDB_API_KEY },
}),
]);
return {
...transformShow(details.data),
imdbId: externalIds.data.imdb_id,
seasons: details.data.seasons || [],
externalIds: externalIds.data,
};
});
},
/**
* Get season details
*/
async getSeasonDetails(tmdbId: number, seasonNumber: number): Promise<any> {
return cachedFetch(`season:${tmdbId}:${seasonNumber}`, async () => {
const response = await axios.get(`${TMDB_BASE_URL}/tv/${tmdbId}/season/${seasonNumber}`, {
params: { api_key: TMDB_API_KEY },
});
return response.data;
});
},
/**
* Get torrents for a show from multiple sources
*/
async getTorrents(imdbId: string, showTitle?: string): Promise<EztvTorrent[]> {
const torrents: EztvTorrent[] = [];
// Try EZTV first
if (imdbId) {
try {
const numericId = imdbId.replace('tt', '');
const response = await axios.get(`${EZTV_BASE_URL}/get-torrents`, {
params: { imdb_id: numericId, limit: 100 },
timeout: 10000,
});
if (response.data.torrents) {
torrents.push(...response.data.torrents.map((t: any) => ({
id: t.id,
hash: t.hash,
filename: t.filename,
title: t.title,
season: t.season,
episode: t.episode,
quality: extractQuality(t.title),
size: t.size_bytes ? formatBytes(t.size_bytes) : 'Unknown',
sizeBytes: parseInt(t.size_bytes) || 0,
seeds: t.seeds || 0,
peers: t.peers || 0,
magnetUrl: t.magnet_url,
})));
}
} catch (error) {
console.warn('EZTV API error:', error);
}
}
// If no torrents found and we have a title, generate magnet links based on common patterns
if (torrents.length === 0 && showTitle) {
console.log('No EZTV torrents found, using search-based discovery for:', showTitle);
// Generate placeholder torrents that will be searched via magnet
// In a real implementation, you'd use additional torrent APIs here
}
return torrents;
},
/**
* Search for episode torrents using IMDB ID lookup
*/
async searchEpisodeTorrents(
showTitle: string,
season: number,
episode: number,
imdbId?: string
): Promise<EztvTorrent[]> {
const results: EztvTorrent[] = [];
const seasonStr = season.toString().padStart(2, '0');
const episodeStr = episode.toString().padStart(2, '0');
const searchQuery = `${showTitle} S${seasonStr}E${episodeStr}`;
// EZTV API only supports lookup by IMDB ID, not search
// If we have an IMDB ID, use it to get all torrents for the show
if (imdbId) {
try {
const numericId = imdbId.replace('tt', '');
console.log('Searching EZTV for IMDB:', numericId);
const response = await axios.get(`${EZTV_BASE_URL}/get-torrents`, {
params: {
imdb_id: numericId,
limit: 100,
},
timeout: 15000,
});
console.log('EZTV response:', response.data);
console.log('Sample torrent:', response.data.torrents?.[0]);
if (response.data.torrents && response.data.torrents.length > 0) {
// Filter for the specific episode
const filtered = response.data.torrents
.filter((t: any) => {
const title = (t.title || t.filename || '').toLowerCase();
// EZTV uses numeric season/episode fields
const torrentSeason = parseInt(t.season) || 0;
const torrentEpisode = parseInt(t.episode) || 0;
// Check by numeric fields first (most reliable)
if (torrentSeason === season && torrentEpisode === episode) {
return true;
}
// Fallback: Check for S01E01 format in title
const seasonPattern = new RegExp(`s0?${season}`, 'i');
const episodePattern = new RegExp(`e0?${episode}(?![0-9])`, 'i');
return seasonPattern.test(title) && episodePattern.test(title);
})
.map((t: any) => ({
id: t.id,
hash: t.hash,
filename: t.filename,
title: t.title,
season: seasonStr,
episode: episodeStr,
quality: extractQuality(t.title),
size: t.size_bytes ? formatBytes(parseInt(t.size_bytes)) : 'Unknown',
sizeBytes: parseInt(t.size_bytes) || 0,
seeds: t.seeds || 0,
peers: t.peers || 0,
magnetUrl: t.magnet_url,
}));
results.push(...filtered);
console.log('Filtered torrents for episode:', filtered.length);
}
} catch (error) {
console.warn('EZTV API error:', error);
}
}
// If no results from EZTV, generate fallback search links
if (results.length === 0) {
console.log('No EZTV results, generating fallback for:', searchQuery);
// Generate placeholder entries that link to torrent search pages
const qualities = ['1080p', '720p', '480p'];
const cleanTitle = showTitle.replace(/[^a-zA-Z0-9 ]/g, '').replace(/\s+/g, '.');
qualities.forEach((quality, idx) => {
const title = `${cleanTitle}.S${seasonStr}E${episodeStr}.${quality}.WEB-DL`;
// Create a deterministic hash for UI purposes
const hash = btoa(`${showTitle}${season}${episode}${quality}`).substring(0, 40);
results.push({
id: Date.now() + idx,
hash: hash,
filename: `${title}.mkv`,
title: title,
season: seasonStr,
episode: episodeStr,
quality: quality,
size: quality === '1080p' ? '~2GB' : quality === '720p' ? '~1GB' : '~500MB',
sizeBytes: quality === '1080p' ? 2147483648 : quality === '720p' ? 1073741824 : 536870912,
seeds: 0,
peers: 0,
magnetUrl: generateSearchUrl(showTitle, season, episode, quality),
});
});
}
return results.sort((a, b) => {
const qualityOrder: Record<string, number> = { '2160p': 4, '1080p': 3, '720p': 2, '480p': 1, 'Unknown': 0 };
return (qualityOrder[b.quality] || 0) - (qualityOrder[a.quality] || 0);
});
},
/**
* Get shows by genre
*/
async getByGenre(genreId: number, page = 1): Promise<{ shows: DiscoveredShow[]; totalPages: number }> {
return cachedFetch(`genre:${genreId}:${page}`, async () => {
const response = await axios.get(`${TMDB_BASE_URL}/discover/tv`, {
params: {
api_key: TMDB_API_KEY,
with_genres: genreId,
sort_by: 'popularity.desc',
page,
},
});
return {
shows: response.data.results.map(transformShow),
totalPages: response.data.total_pages,
};
});
},
/**
* Get TV genres
*/
async getGenres(): Promise<{ id: number; name: string }[]> {
return cachedFetch('genres', async () => {
const response = await axios.get(`${TMDB_BASE_URL}/genre/tv/list`, {
params: { api_key: TMDB_API_KEY },
});
return response.data.genres;
});
},
/**
* Clear cache
*/
clearCache(): void {
cache.clear();
},
};
// Helper functions
function extractQuality(title: string): string {
const qualityPatterns = [
{ regex: /2160p|4k|uhd/i, quality: '2160p' },
{ regex: /1080p|fhd/i, quality: '1080p' },
{ regex: /720p|hd/i, quality: '720p' },
{ regex: /480p|sd/i, quality: '480p' },
];
for (const { regex, quality } of qualityPatterns) {
if (regex.test(title)) return quality;
}
return 'Unknown';
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function generateSearchUrl(showTitle: string, season: number, episode: number, quality: string): string {
// Generate a search URL that can be opened in a torrent search engine
const query = `${showTitle} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')} ${quality}`;
const encoded = encodeURIComponent(query);
// Return a 1337x search URL as fallback
return `https://1337x.to/search/${encoded}/1/`;
}
export default tvDiscoveryApi;

160
src/services/api/yts.ts Normal file
View File

@ -0,0 +1,160 @@
import axios, { AxiosInstance } from 'axios';
import type {
ApiResponse,
ListMoviesData,
ListMoviesParams,
MovieDetailsData,
MovieDetailsParams,
MovieSuggestionsData,
ParentalGuidesData,
} from '../../types';
// Use proxy in development to avoid CORS issues
const isDev = import.meta.env.DEV;
const BASE_URL = isDev ? '/api/yts' : 'https://yts.lt/api/v2';
// Create axios instance with caching
const api: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
});
// Simple in-memory cache
const cache = new Map<string, { data: unknown; timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
function getCacheKey(endpoint: string, params?: object): string {
return `${endpoint}:${JSON.stringify(params || {})}`;
}
async function cachedRequest<T>(
endpoint: string,
params?: object
): Promise<T> {
const cacheKey = getCacheKey(endpoint, params);
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return cached.data as T;
}
const response = await api.get<ApiResponse<T>>(endpoint, { params });
if (response.data.status !== 'ok') {
throw new Error(response.data.status_message);
}
cache.set(cacheKey, {
data: response.data.data,
timestamp: Date.now(),
});
return response.data.data;
}
export const ytsApi = {
/**
* List and search through all available movies
*/
async listMovies(params?: ListMoviesParams): Promise<ListMoviesData> {
return cachedRequest<ListMoviesData>('/list_movies.json', params);
},
/**
* Get details for a specific movie
*/
async getMovieDetails(params: MovieDetailsParams): Promise<MovieDetailsData> {
return cachedRequest<MovieDetailsData>('/movie_details.json', {
...params,
with_images: params.with_images ?? true,
with_cast: params.with_cast ?? true,
});
},
/**
* Get movie suggestions based on a movie ID
*/
async getMovieSuggestions(movieId: number): Promise<MovieSuggestionsData> {
return cachedRequest<MovieSuggestionsData>('/movie_suggestions.json', {
movie_id: movieId,
});
},
/**
* Get parental guides for a movie
*/
async getParentalGuides(movieId: number): Promise<ParentalGuidesData> {
return cachedRequest<ParentalGuidesData>('/movie_parental_guides.json', {
movie_id: movieId,
});
},
/**
* Get trending movies (sorted by download count)
*/
async getTrending(limit = 20): Promise<ListMoviesData> {
return this.listMovies({
limit,
sort_by: 'download_count',
order_by: 'desc',
});
},
/**
* Get latest movies
*/
async getLatest(limit = 20): Promise<ListMoviesData> {
return this.listMovies({
limit,
sort_by: 'date_added',
order_by: 'desc',
});
},
/**
* Get top rated movies
*/
async getTopRated(limit = 20, minRating = 8): Promise<ListMoviesData> {
return this.listMovies({
limit,
minimum_rating: minRating,
sort_by: 'rating',
order_by: 'desc',
});
},
/**
* Get movies by genre
*/
async getByGenre(genre: string, limit = 20): Promise<ListMoviesData> {
return this.listMovies({
limit,
genre,
sort_by: 'download_count',
order_by: 'desc',
});
},
/**
* Search movies
*/
async search(query: string, params?: Omit<ListMoviesParams, 'query_term'>): Promise<ListMoviesData> {
return this.listMovies({
...params,
query_term: query,
});
},
/**
* Clear the cache
*/
clearCache(): void {
cache.clear();
},
};
export default ytsApi;

7
src/services/index.ts Normal file
View File

@ -0,0 +1,7 @@
export { ytsApi } from './api/yts';
export * from './torrent/webtorrent';
export * from './subtitles/opensubtitles';
export * from './storage/database';
export { default as streamingService } from './streaming/streamingService';
export type { StreamSession, TranscodeProgress } from './streaming/streamingService';

View File

@ -0,0 +1,3 @@
export { mediaManager, default } from './mediaManager';
export * from './mediaManager';

View File

@ -0,0 +1,673 @@
import type {
UnifiedMovie,
UnifiedSeries,
UnifiedEpisode,
UnifiedArtist,
UnifiedAlbum,
UnifiedTrack,
CalendarItem,
QueueItem,
} from '../../types/unified';
import type { Movie } from '../../types';
import type { RadarrMovie, RadarrQueueItem } from '../../types/radarr';
import type { SonarrSeries, SonarrEpisode, SonarrCalendarItem, SonarrQueueItem } from '../../types/sonarr';
import type { LidarrArtist, LidarrAlbum, LidarrTrack, LidarrQueueItem } from '../../types/lidarr';
import { getRadarrClient } from '../api/radarr';
import { getSonarrClient } from '../api/sonarr';
import { getLidarrClient } from '../api/lidarr';
// ==================== Converters: YTS ====================
export function convertYtsMovie(movie: Movie): UnifiedMovie {
return {
id: `yts-${movie.id}`,
sourceId: movie.id,
source: 'yts',
type: 'movie',
title: movie.title,
year: movie.year,
overview: movie.synopsis || movie.description_full || movie.summary,
poster: movie.large_cover_image || movie.medium_cover_image,
fanart: movie.background_image_original || movie.background_image,
rating: movie.rating,
runtime: movie.runtime,
genres: movie.genres,
hasFile: false,
canStream: true,
imdbId: movie.imdb_code,
certification: movie.mpa_rating,
trailer: movie.yt_trailer_code ? `https://www.youtube.com/watch?v=${movie.yt_trailer_code}` : undefined,
torrents: movie.torrents?.map((t) => ({
hash: t.hash,
quality: t.quality,
size: t.size,
sizeBytes: t.size_bytes,
seeds: t.seeds,
peers: t.peers,
type: t.type,
videoCodec: t.video_codec,
audioChannels: t.audio_channels,
})),
sourceData: movie,
};
}
// ==================== Converters: Radarr ====================
export function convertRadarrMovie(movie: RadarrMovie): UnifiedMovie {
const poster = movie.images.find((i) => i.coverType === 'poster');
const fanart = movie.images.find((i) => i.coverType === 'fanart');
return {
id: `radarr-${movie.id}`,
sourceId: movie.id,
source: 'radarr',
type: 'movie',
title: movie.title,
year: movie.year,
overview: movie.overview,
poster: poster?.remoteUrl || poster?.url,
fanart: fanart?.remoteUrl || fanart?.url,
rating: movie.ratings.tmdb?.value || movie.ratings.imdb?.value,
runtime: movie.runtime,
genres: movie.genres,
added: new Date(movie.added),
hasFile: movie.hasFile,
filePath: movie.movieFile?.path,
canStream: movie.hasFile,
imdbId: movie.imdbId,
tmdbId: movie.tmdbId,
studio: movie.studio,
certification: movie.certification,
trailer: movie.youTubeTrailerId ? `https://www.youtube.com/watch?v=${movie.youTubeTrailerId}` : undefined,
movieFile: movie.movieFile ? {
id: movie.movieFile.id,
path: movie.movieFile.path,
relativePath: movie.movieFile.relativePath,
size: movie.movieFile.size,
quality: movie.movieFile.quality.quality.name,
dateAdded: new Date(movie.movieFile.dateAdded),
mediaInfo: {
videoCodec: movie.movieFile.mediaInfo.videoCodec,
audioCodec: movie.movieFile.mediaInfo.audioCodec,
audioChannels: movie.movieFile.mediaInfo.audioChannels,
resolution: movie.movieFile.mediaInfo.resolution,
runTime: movie.movieFile.mediaInfo.runTime,
},
} : undefined,
sourceData: movie,
};
}
export function convertRadarrQueueItem(item: RadarrQueueItem): QueueItem {
return {
id: `radarr-${item.id}`,
source: 'radarr',
mediaType: 'movie',
mediaId: item.movieId,
title: item.title,
status: mapQueueStatus(item.trackedDownloadState, item.trackedDownloadStatus),
progress: item.size > 0 ? ((item.size - item.sizeleft) / item.size) * 100 : 0,
size: item.size,
sizeleft: item.sizeleft,
estimatedCompletionTime: item.estimatedCompletionTime ? new Date(item.estimatedCompletionTime) : undefined,
downloadClient: item.downloadClient,
indexer: item.indexer,
quality: item.quality.quality.name,
added: item.added ? new Date(item.added) : undefined,
statusMessages: item.statusMessages,
};
}
// ==================== Converters: Sonarr ====================
export function convertSonarrSeries(series: SonarrSeries): UnifiedSeries {
const poster = series.images.find((i) => i.coverType === 'poster');
const fanart = series.images.find((i) => i.coverType === 'fanart');
return {
id: `sonarr-${series.id}`,
sourceId: series.id,
source: 'sonarr',
type: 'series',
title: series.title,
year: series.year,
overview: series.overview,
poster: poster?.remoteUrl || poster?.url,
fanart: fanart?.remoteUrl || fanart?.url,
rating: series.ratings.value,
runtime: series.runtime,
genres: series.genres,
added: new Date(series.added),
hasFile: series.statistics.episodeFileCount > 0,
canStream: series.statistics.episodeFileCount > 0,
tvdbId: series.tvdbId,
imdbId: series.imdbId,
status: series.status,
network: series.network,
airTime: series.airTime,
seasonCount: series.statistics.seasonCount,
episodeCount: series.statistics.episodeCount,
episodeFileCount: series.statistics.episodeFileCount,
nextAiring: series.nextAiring,
previousAiring: series.previousAiring,
seasons: series.seasons.map((s) => ({
seasonNumber: s.seasonNumber,
episodeCount: s.statistics?.episodeCount || 0,
episodeFileCount: s.statistics?.episodeFileCount || 0,
monitored: s.monitored,
})),
sourceData: series,
};
}
export function convertSonarrEpisode(episode: SonarrEpisode, series?: SonarrSeries): UnifiedEpisode {
return {
id: `sonarr-episode-${episode.id}`,
sourceId: episode.id,
source: 'sonarr',
type: 'episode',
title: episode.title,
overview: episode.overview,
runtime: episode.runtime,
hasFile: episode.hasFile,
canStream: episode.hasFile,
seriesId: `sonarr-${episode.seriesId}`,
seriesTitle: series?.title || episode.series?.title,
seasonNumber: episode.seasonNumber,
episodeNumber: episode.episodeNumber,
absoluteEpisodeNumber: episode.absoluteEpisodeNumber,
airDate: episode.airDate,
airDateUtc: episode.airDateUtc,
monitored: episode.monitored,
filePath: episode.episodeFile?.path,
episodeFile: episode.episodeFile ? {
id: episode.episodeFile.id,
path: episode.episodeFile.path,
relativePath: episode.episodeFile.relativePath,
size: episode.episodeFile.size,
quality: episode.episodeFile.quality.quality.name,
dateAdded: new Date(episode.episodeFile.dateAdded),
mediaInfo: {
videoCodec: episode.episodeFile.mediaInfo.videoCodec,
audioCodec: episode.episodeFile.mediaInfo.audioCodec,
audioChannels: episode.episodeFile.mediaInfo.audioChannels,
resolution: episode.episodeFile.mediaInfo.resolution,
runTime: episode.episodeFile.mediaInfo.runTime,
},
} : undefined,
sourceData: episode,
};
}
export function convertSonarrCalendarItem(item: SonarrCalendarItem): CalendarItem {
const poster = item.series?.images.find((i) => i.coverType === 'poster');
return {
id: `sonarr-cal-${item.id}`,
source: 'sonarr',
type: 'episode',
title: item.series?.title || 'Unknown Series',
subtitle: `S${item.seasonNumber.toString().padStart(2, '0')}E${item.episodeNumber.toString().padStart(2, '0')} - ${item.title}`,
date: new Date(item.airDateUtc),
hasFile: item.hasFile,
poster: poster?.remoteUrl || poster?.url,
mediaId: item.id,
seriesId: item.seriesId,
};
}
export function convertSonarrQueueItem(item: SonarrQueueItem): QueueItem {
return {
id: `sonarr-${item.id}`,
source: 'sonarr',
mediaType: 'episode',
mediaId: item.episodeId,
title: `${item.series?.title || 'Unknown'} - S${item.seasonNumber.toString().padStart(2, '0')}E${item.episode?.episodeNumber?.toString().padStart(2, '0') || '??'}`,
status: mapQueueStatus(item.trackedDownloadState, item.trackedDownloadStatus),
progress: item.size > 0 ? ((item.size - item.sizeleft) / item.size) * 100 : 0,
size: item.size,
sizeleft: item.sizeleft,
estimatedCompletionTime: item.estimatedCompletionTime ? new Date(item.estimatedCompletionTime) : undefined,
downloadClient: item.downloadClient,
indexer: item.indexer,
quality: item.quality.quality.name,
added: item.added ? new Date(item.added) : undefined,
statusMessages: item.statusMessages,
};
}
// ==================== Converters: Lidarr ====================
export function convertLidarrArtist(artist: LidarrArtist): UnifiedArtist {
const poster = artist.images.find((i) => i.coverType === 'poster' || i.coverType === 'cover');
const fanart = artist.images.find((i) => i.coverType === 'fanart');
const banner = artist.images.find((i) => i.coverType === 'banner');
const logo = artist.images.find((i) => i.coverType === 'logo' || i.coverType === 'clearlogo');
return {
id: `lidarr-${artist.id}`,
sourceId: artist.id,
source: 'lidarr',
type: 'artist',
title: artist.artistName,
overview: artist.overview,
poster: poster?.remoteUrl || poster?.url,
fanart: fanart?.remoteUrl || fanart?.url,
rating: artist.ratings.value,
genres: artist.genres,
added: new Date(artist.added),
hasFile: artist.statistics.trackFileCount > 0,
canStream: artist.statistics.trackFileCount > 0,
mbId: artist.foreignArtistId,
status: artist.status,
artistType: artist.artistType,
disambiguation: artist.disambiguation,
albumCount: artist.statistics.albumCount,
trackCount: artist.statistics.trackCount,
banner: banner?.remoteUrl || banner?.url,
logo: logo?.remoteUrl || logo?.url,
sourceData: artist,
};
}
export function convertLidarrAlbum(album: LidarrAlbum): UnifiedAlbum {
const cover = album.images.find((i) => i.coverType === 'cover' || i.coverType === 'poster');
return {
id: `lidarr-album-${album.id}`,
sourceId: album.id,
source: 'lidarr',
type: 'album',
title: album.title,
year: album.releaseDate ? new Date(album.releaseDate).getFullYear() : undefined,
overview: album.overview,
poster: cover?.remoteUrl || cover?.url,
rating: album.ratings.value,
genres: album.genres,
hasFile: album.statistics.trackFileCount > 0,
canStream: album.statistics.trackFileCount > 0,
artistId: `lidarr-${album.artistId}`,
artistName: album.artist?.artistName || 'Unknown Artist',
mbId: album.foreignAlbumId,
releaseDate: album.releaseDate,
albumType: album.albumType,
trackCount: album.statistics.trackCount,
trackFileCount: album.statistics.trackFileCount,
duration: album.duration,
monitored: album.monitored,
sourceData: album,
};
}
export function convertLidarrTrack(track: LidarrTrack): UnifiedTrack {
return {
id: `lidarr-track-${track.id}`,
sourceId: track.id,
source: 'lidarr',
type: 'track',
title: track.title,
hasFile: track.hasFile,
canStream: track.hasFile,
albumId: `lidarr-album-${track.albumId}`,
artistId: `lidarr-${track.artistId}`,
artistName: track.artist?.artistName || 'Unknown Artist',
albumName: track.album?.title || 'Unknown Album',
trackNumber: track.absoluteTrackNumber,
discNumber: track.mediumNumber,
duration: track.duration,
explicit: track.explicit,
filePath: track.trackFile?.path,
rating: track.ratings.value,
trackFile: track.trackFile ? {
id: track.trackFile.id,
path: track.trackFile.path,
size: track.trackFile.size,
quality: track.trackFile.quality.quality.name,
dateAdded: new Date(track.trackFile.dateAdded),
mediaInfo: {
audioCodec: track.trackFile.mediaInfo.audioFormat,
audioBitrate: track.trackFile.mediaInfo.audioBitrate,
audioChannels: track.trackFile.mediaInfo.audioChannels,
},
} : undefined,
sourceData: track,
};
}
export function convertLidarrCalendarItem(album: LidarrAlbum): CalendarItem {
const cover = album.images.find((i) => i.coverType === 'cover' || i.coverType === 'poster');
return {
id: `lidarr-cal-${album.id}`,
source: 'lidarr',
type: 'album',
title: album.artist?.artistName || 'Unknown Artist',
subtitle: album.title,
date: new Date(album.releaseDate),
hasFile: album.statistics.trackFileCount > 0,
poster: cover?.remoteUrl || cover?.url,
mediaId: album.id,
artistId: album.artistId,
};
}
export function convertLidarrQueueItem(item: LidarrQueueItem): QueueItem {
return {
id: `lidarr-${item.id}`,
source: 'lidarr',
mediaType: 'album',
mediaId: item.albumId,
title: `${item.artist?.artistName || 'Unknown'} - ${item.album?.title || 'Unknown Album'}`,
status: mapQueueStatus(item.trackedDownloadState, item.trackedDownloadStatus),
progress: item.size > 0 ? ((item.size - item.sizeleft) / item.size) * 100 : 0,
size: item.size,
sizeleft: item.sizeleft,
estimatedCompletionTime: item.estimatedCompletionTime ? new Date(item.estimatedCompletionTime) : undefined,
downloadClient: item.downloadClient,
indexer: item.indexer,
quality: item.quality.quality.name,
added: item.added ? new Date(item.added) : undefined,
statusMessages: item.statusMessages,
};
}
// ==================== Queue Status Mapping ====================
function mapQueueStatus(state: string, status: string): QueueItem['status'] {
if (state === 'importPending' || state === 'importing') return 'importing';
if (state === 'downloading') return 'downloading';
if (state === 'downloadClientUnavailable' || state === 'paused') return 'paused';
if (state === 'completed') return 'completed';
if (status === 'warning') return 'warning';
if (status === 'error' || state === 'failed') return 'failed';
return 'queued';
}
// ==================== Media Manager Class ====================
class MediaManager {
// ==================== Movies ====================
async getMovies(source: 'yts' | 'radarr' | 'all' = 'all'): Promise<UnifiedMovie[]> {
const movies: UnifiedMovie[] = [];
if (source === 'radarr' || source === 'all') {
const radarrClient = getRadarrClient();
if (radarrClient) {
try {
const radarrMovies = await radarrClient.getMovies();
movies.push(...radarrMovies.map(convertRadarrMovie));
} catch (error) {
console.error('Failed to fetch Radarr movies:', error);
}
}
}
return movies;
}
async getMovie(id: string): Promise<UnifiedMovie | null> {
const [source, sourceId] = id.split('-');
if (source === 'radarr') {
const radarrClient = getRadarrClient();
if (radarrClient) {
try {
const movie = await radarrClient.getMovie(parseInt(sourceId));
return convertRadarrMovie(movie);
} catch {
return null;
}
}
}
return null;
}
// ==================== Series ====================
async getSeries(): Promise<UnifiedSeries[]> {
const sonarrClient = getSonarrClient();
if (!sonarrClient) return [];
try {
const series = await sonarrClient.getSeries();
return series.map(convertSonarrSeries);
} catch (error) {
console.error('Failed to fetch Sonarr series:', error);
return [];
}
}
async getSeriesById(id: string): Promise<UnifiedSeries | null> {
const [, sourceId] = id.split('-');
const sonarrClient = getSonarrClient();
if (sonarrClient) {
try {
const series = await sonarrClient.getSeriesById(parseInt(sourceId));
return convertSonarrSeries(series);
} catch {
return null;
}
}
return null;
}
async getEpisodes(seriesId: string): Promise<UnifiedEpisode[]> {
const [, sourceId] = seriesId.split('-');
const sonarrClient = getSonarrClient();
if (sonarrClient) {
try {
const episodes = await sonarrClient.getEpisodes(parseInt(sourceId));
return episodes.map((e) => convertSonarrEpisode(e));
} catch (error) {
console.error('Failed to fetch episodes:', error);
return [];
}
}
return [];
}
async getEpisodesBySeason(seriesId: string, seasonNumber: number): Promise<UnifiedEpisode[]> {
const [, sourceId] = seriesId.split('-');
const sonarrClient = getSonarrClient();
if (sonarrClient) {
try {
const episodes = await sonarrClient.getEpisodesBySeason(parseInt(sourceId), seasonNumber);
return episodes.map((e) => convertSonarrEpisode(e));
} catch (error) {
console.error('Failed to fetch episodes:', error);
return [];
}
}
return [];
}
// ==================== Artists ====================
async getArtists(): Promise<UnifiedArtist[]> {
const lidarrClient = getLidarrClient();
if (!lidarrClient) return [];
try {
const artists = await lidarrClient.getArtists();
return artists.map(convertLidarrArtist);
} catch (error) {
console.error('Failed to fetch Lidarr artists:', error);
return [];
}
}
async getArtist(id: string): Promise<UnifiedArtist | null> {
const [, sourceId] = id.split('-');
const lidarrClient = getLidarrClient();
if (lidarrClient) {
try {
const artist = await lidarrClient.getArtist(parseInt(sourceId));
return convertLidarrArtist(artist);
} catch {
return null;
}
}
return null;
}
// ==================== Albums ====================
async getAlbums(artistId?: string): Promise<UnifiedAlbum[]> {
const lidarrClient = getLidarrClient();
if (!lidarrClient) return [];
try {
const numericId = artistId ? parseInt(artistId.split('-')[1]) : undefined;
const albums = await lidarrClient.getAlbums(numericId);
return albums.map(convertLidarrAlbum);
} catch (error) {
console.error('Failed to fetch Lidarr albums:', error);
return [];
}
}
async getAlbum(id: string): Promise<UnifiedAlbum | null> {
const [, , sourceId] = id.split('-');
const lidarrClient = getLidarrClient();
if (lidarrClient) {
try {
const album = await lidarrClient.getAlbum(parseInt(sourceId));
return convertLidarrAlbum(album);
} catch {
return null;
}
}
return null;
}
// ==================== Tracks ====================
async getTracks(albumId: string): Promise<UnifiedTrack[]> {
const [, , sourceId] = albumId.split('-');
const lidarrClient = getLidarrClient();
if (lidarrClient) {
try {
const tracks = await lidarrClient.getTracks(parseInt(sourceId));
return tracks.map(convertLidarrTrack);
} catch (error) {
console.error('Failed to fetch tracks:', error);
return [];
}
}
return [];
}
// ==================== Calendar ====================
async getCalendar(start: Date, end: Date): Promise<CalendarItem[]> {
const items: CalendarItem[] = [];
// Radarr calendar
const radarrClient = getRadarrClient();
if (radarrClient) {
try {
const movies = await radarrClient.getCalendar(start, end);
items.push(...movies.map((m): CalendarItem => {
const poster = m.images.find((i) => i.coverType === 'poster');
return {
id: `radarr-cal-${m.id}`,
source: 'radarr',
type: 'movie',
title: m.title,
date: new Date(m.digitalRelease || m.physicalRelease || m.inCinemas || m.added),
hasFile: m.hasFile,
poster: poster?.remoteUrl || poster?.url,
mediaId: m.id,
};
}));
} catch (error) {
console.error('Failed to fetch Radarr calendar:', error);
}
}
// Sonarr calendar
const sonarrClient = getSonarrClient();
if (sonarrClient) {
try {
const episodes = await sonarrClient.getCalendar(start, end);
items.push(...episodes.map(convertSonarrCalendarItem));
} catch (error) {
console.error('Failed to fetch Sonarr calendar:', error);
}
}
// Lidarr calendar
const lidarrClient = getLidarrClient();
if (lidarrClient) {
try {
const albums = await lidarrClient.getCalendar(start, end);
items.push(...albums.map(convertLidarrCalendarItem));
} catch (error) {
console.error('Failed to fetch Lidarr calendar:', error);
}
}
// Sort by date
return items.sort((a, b) => a.date.getTime() - b.date.getTime());
}
// ==================== Queue ====================
async getQueue(): Promise<QueueItem[]> {
const items: QueueItem[] = [];
// Radarr queue
const radarrClient = getRadarrClient();
if (radarrClient) {
try {
const queue = await radarrClient.getDownloadQueue();
items.push(...queue.records.map(convertRadarrQueueItem));
} catch (error) {
console.error('Failed to fetch Radarr queue:', error);
}
}
// Sonarr queue
const sonarrClient = getSonarrClient();
if (sonarrClient) {
try {
const queue = await sonarrClient.getDownloadQueue();
items.push(...queue.records.map(convertSonarrQueueItem));
} catch (error) {
console.error('Failed to fetch Sonarr queue:', error);
}
}
// Lidarr queue
const lidarrClient = getLidarrClient();
if (lidarrClient) {
try {
const queue = await lidarrClient.getDownloadQueue();
items.push(...queue.records.map(convertLidarrQueueItem));
} catch (error) {
console.error('Failed to fetch Lidarr queue:', error);
}
}
return items;
}
}
export const mediaManager = new MediaManager();
export default mediaManager;

View File

@ -0,0 +1,206 @@
import Dexie, { type EntityTable } from 'dexie';
import type { Movie, WatchlistItem, HistoryItem, DownloadItem } from '../../types';
// Define the database schema
interface BeStreamDB extends Dexie {
watchlist: EntityTable<WatchlistItem, 'id'>;
history: EntityTable<HistoryItem, 'id'>;
downloads: EntityTable<DownloadItem, 'id'>;
cachedMovies: EntityTable<Movie & { cachedAt: number }, 'id'>;
subtitleCache: EntityTable<{ id: string; imdbId: string; language: string; content: string; cachedAt: number }, 'id'>;
}
const db = new Dexie('BeStreamDB') as BeStreamDB;
// Define tables and indexes
db.version(1).stores({
watchlist: '++id, movieId, addedAt',
history: '++id, movieId, watchedAt, completed',
downloads: 'id, movieId, status, startedAt',
cachedMovies: 'id, imdb_code, cachedAt',
subtitleCache: 'id, imdbId, language, cachedAt',
});
// Watchlist operations
export const watchlistDb = {
async add(movie: Movie): Promise<number> {
const existing = await db.watchlist.where('movieId').equals(movie.id).first();
if (existing) return existing.id;
return db.watchlist.add({
id: Date.now(),
movieId: movie.id,
movie,
addedAt: new Date(),
});
},
async remove(movieId: number): Promise<void> {
await db.watchlist.where('movieId').equals(movieId).delete();
},
async getAll(): Promise<WatchlistItem[]> {
return db.watchlist.orderBy('addedAt').reverse().toArray();
},
async isInWatchlist(movieId: number): Promise<boolean> {
const item = await db.watchlist.where('movieId').equals(movieId).first();
return !!item;
},
async clear(): Promise<void> {
await db.watchlist.clear();
},
};
// History operations
export const historyDb = {
async add(movie: Movie, progress = 0, duration = 0): Promise<number> {
const existing = await db.history.where('movieId').equals(movie.id).first();
if (existing) {
await db.history.update(existing.id, {
watchedAt: new Date(),
progress,
duration,
});
return existing.id;
}
return db.history.add({
id: Date.now(),
movieId: movie.id,
movie,
watchedAt: new Date(),
progress,
duration,
completed: false,
});
},
async updateProgress(movieId: number, progress: number, duration: number): Promise<void> {
const item = await db.history.where('movieId').equals(movieId).first();
if (item) {
await db.history.update(item.id, {
progress,
duration,
completed: progress / duration > 0.9,
watchedAt: new Date(),
});
}
},
async markCompleted(movieId: number): Promise<void> {
const item = await db.history.where('movieId').equals(movieId).first();
if (item) {
await db.history.update(item.id, { completed: true });
}
},
async getAll(): Promise<HistoryItem[]> {
return db.history.orderBy('watchedAt').reverse().toArray();
},
async getProgress(movieId: number): Promise<HistoryItem | undefined> {
return db.history.where('movieId').equals(movieId).first();
},
async remove(movieId: number): Promise<void> {
await db.history.where('movieId').equals(movieId).delete();
},
async clear(): Promise<void> {
await db.history.clear();
},
};
// Download operations
export const downloadsDb = {
async add(item: DownloadItem): Promise<string> {
await db.downloads.put(item);
return item.id;
},
async update(id: string, update: Partial<DownloadItem>): Promise<void> {
await db.downloads.update(id, update);
},
async remove(id: string): Promise<void> {
await db.downloads.delete(id);
},
async getAll(): Promise<DownloadItem[]> {
return db.downloads.orderBy('startedAt').reverse().toArray();
},
async getByStatus(status: DownloadItem['status']): Promise<DownloadItem[]> {
return db.downloads.where('status').equals(status).toArray();
},
async clearCompleted(): Promise<void> {
await db.downloads.where('status').equals('completed').delete();
},
};
// Movie cache operations
export const movieCacheDb = {
async cache(movie: Movie): Promise<void> {
await db.cachedMovies.put({
...movie,
cachedAt: Date.now(),
});
},
async get(movieId: number): Promise<Movie | undefined> {
const cached = await db.cachedMovies.get(movieId);
if (cached) {
// Check if cache is still valid (24 hours)
if (Date.now() - cached.cachedAt < 24 * 60 * 60 * 1000) {
return cached;
}
await db.cachedMovies.delete(movieId);
}
return undefined;
},
async clear(): Promise<void> {
await db.cachedMovies.clear();
},
async cleanup(): Promise<void> {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
await db.cachedMovies.where('cachedAt').below(cutoff).delete();
},
};
// Subtitle cache operations
export const subtitleCacheDb = {
async cache(imdbId: string, language: string, content: string): Promise<void> {
await db.subtitleCache.put({
id: `${imdbId}-${language}`,
imdbId,
language,
content,
cachedAt: Date.now(),
});
},
async get(imdbId: string, language: string): Promise<string | undefined> {
const cached = await db.subtitleCache.get(`${imdbId}-${language}`);
if (cached) {
// Check if cache is still valid (7 days)
if (Date.now() - cached.cachedAt < 7 * 24 * 60 * 60 * 1000) {
return cached.content;
}
await db.subtitleCache.delete(`${imdbId}-${language}`);
}
return undefined;
},
async clear(): Promise<void> {
await db.subtitleCache.clear();
},
};
export default db;

Some files were not shown because too many files have changed in this diff Show More