First big commit
This commit is contained in:
21
.eslintrc.cjs
Normal file
21
.eslintrc.cjs
Normal 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
52
.gitignore
vendored
Normal 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
41
capacitor.config.ts
Normal 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
116
docs/GETTING_API_KEYS.md
Normal 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
226
docs/OPENING_PORTS.md
Normal 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
218
electron/main.ts
Normal 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
38
electron/preload.ts
Normal 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
17
electron/tsconfig.json
Normal 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
18
index.html
Normal 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
9250
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
98
package.json
Normal file
98
package.json
Normal 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
7
postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
23
public/icon.png
Normal file
23
public/icon.png
Normal 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
12
public/logo.svg
Normal 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 |
289
scripts/install-arr-services.sh
Normal file
289
scripts/install-arr-services.sh
Normal 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
81
scripts/open-ports.sh
Normal 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
3603
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
server/package.json
Normal file
21
server/package.json
Normal 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
89
server/src/index.js
Normal 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
297
server/src/routes/stream.js
Normal 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() });
|
||||
});
|
||||
|
||||
359
server/src/services/torrentManager.js
Normal file
359
server/src/services/torrentManager.js
Normal 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();
|
||||
|
||||
219
server/src/services/transcoder.js
Normal file
219
server/src/services/transcoder.js
Normal 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
69
src/App.tsx
Normal 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;
|
||||
|
||||
108
src/components/calendar/CalendarDay.tsx
Normal file
108
src/components/calendar/CalendarDay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
214
src/components/calendar/CalendarView.tsx
Normal file
214
src/components/calendar/CalendarView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
177
src/components/calendar/UpcomingList.tsx
Normal file
177
src/components/calendar/UpcomingList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
4
src/components/calendar/index.ts
Normal file
4
src/components/calendar/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as CalendarView } from './CalendarView';
|
||||
export { default as CalendarDay } from './CalendarDay';
|
||||
export { default as UpcomingList } from './UpcomingList';
|
||||
|
||||
294
src/components/integration/ConnectionSetup.tsx
Normal file
294
src/components/integration/ConnectionSetup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
99
src/components/integration/ServiceStatus.tsx
Normal file
99
src/components/integration/ServiceStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
3
src/components/integration/index.ts
Normal file
3
src/components/integration/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as ServiceStatus } from './ServiceStatus';
|
||||
export { default as ConnectionSetup } from './ConnectionSetup';
|
||||
|
||||
114
src/components/layout/Footer.tsx
Normal file
114
src/components/layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
16
src/components/layout/Layout.tsx
Normal file
16
src/components/layout/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
224
src/components/layout/Navbar.tsx
Normal file
224
src/components/layout/Navbar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
4
src/components/layout/index.ts
Normal file
4
src/components/layout/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as Layout } from './Layout';
|
||||
export { default as Navbar } from './Navbar';
|
||||
export { default as Footer } from './Footer';
|
||||
|
||||
206
src/components/movie/Hero.tsx
Normal file
206
src/components/movie/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
171
src/components/movie/MovieCard.tsx
Normal file
171
src/components/movie/MovieCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
50
src/components/movie/MovieGrid.tsx
Normal file
50
src/components/movie/MovieGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
144
src/components/movie/MovieRow.tsx
Normal file
144
src/components/movie/MovieRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
218
src/components/movie/TorrentSelector.tsx
Normal file
218
src/components/movie/TorrentSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
6
src/components/movie/index.ts
Normal file
6
src/components/movie/index.ts
Normal 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';
|
||||
|
||||
109
src/components/music/AlbumCard.tsx
Normal file
109
src/components/music/AlbumCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
56
src/components/music/AlbumGrid.tsx
Normal file
56
src/components/music/AlbumGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
92
src/components/music/ArtistCard.tsx
Normal file
92
src/components/music/ArtistCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
54
src/components/music/ArtistGrid.tsx
Normal file
54
src/components/music/ArtistGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
199
src/components/music/MiniPlayer.tsx
Normal file
199
src/components/music/MiniPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
170
src/components/music/TrackList.tsx
Normal file
170
src/components/music/TrackList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
7
src/components/music/index.ts
Normal file
7
src/components/music/index.ts
Normal 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';
|
||||
|
||||
599
src/components/player/StreamingPlayer.tsx
Normal file
599
src/components/player/StreamingPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
418
src/components/player/VideoPlayer.tsx
Normal file
418
src/components/player/VideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
3
src/components/player/index.ts
Normal file
3
src/components/player/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as VideoPlayer } from './VideoPlayer';
|
||||
export { default as StreamingPlayer } from './StreamingPlayer';
|
||||
|
||||
133
src/components/tv/EpisodeCard.tsx
Normal file
133
src/components/tv/EpisodeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
143
src/components/tv/EpisodeList.tsx
Normal file
143
src/components/tv/EpisodeList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
127
src/components/tv/SeasonList.tsx
Normal file
127
src/components/tv/SeasonList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
124
src/components/tv/SeriesCard.tsx
Normal file
124
src/components/tv/SeriesCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
54
src/components/tv/SeriesGrid.tsx
Normal file
54
src/components/tv/SeriesGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
6
src/components/tv/index.ts
Normal file
6
src/components/tv/index.ts
Normal 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';
|
||||
|
||||
43
src/components/ui/Badge.tsx
Normal file
43
src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
92
src/components/ui/Button.tsx
Normal file
92
src/components/ui/Button.tsx
Normal 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;
|
||||
|
||||
54
src/components/ui/Input.tsx
Normal file
54
src/components/ui/Input.tsx
Normal 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
108
src/components/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
51
src/components/ui/ProgressBar.tsx
Normal file
51
src/components/ui/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
55
src/components/ui/Select.tsx
Normal file
55
src/components/ui/Select.tsx
Normal 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;
|
||||
|
||||
205
src/components/ui/Skeleton.tsx
Normal file
205
src/components/ui/Skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
53
src/components/ui/Tooltip.tsx
Normal file
53
src/components/ui/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
9
src/components/ui/index.ts
Normal file
9
src/components/ui/index.ts
Normal 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
142
src/hooks/useCalendar.ts
Normal 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);
|
||||
}
|
||||
|
||||
96
src/hooks/useIntegration.ts
Normal file
96
src/hooks/useIntegration.ts
Normal 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
195
src/hooks/useMovies.ts
Normal 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
270
src/hooks/useMusic.ts
Normal 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
193
src/hooks/useSeries.ts
Normal 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
163
src/index.css
Normal 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
14
src/main.tsx
Normal 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
234
src/pages/AlbumDetails.tsx
Normal 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
215
src/pages/ArtistDetails.tsx
Normal 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
260
src/pages/Browse.tsx
Normal 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
138
src/pages/Calendar.tsx
Normal 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
275
src/pages/Downloads.tsx
Normal 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
157
src/pages/History.tsx
Normal 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
92
src/pages/Home.tsx
Normal 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
305
src/pages/MovieDetails.tsx
Normal 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
360
src/pages/Music.tsx
Normal 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
323
src/pages/Player.tsx
Normal 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
253
src/pages/Queue.tsx
Normal 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
86
src/pages/Search.tsx
Normal 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
240
src/pages/SeriesDetails.tsx
Normal 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
354
src/pages/Settings.tsx
Normal 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
356
src/pages/TVPlayer.tsx
Normal 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
626
src/pages/TVShowDetails.tsx
Normal 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
386
src/pages/TVShows.tsx
Normal 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
69
src/pages/Watchlist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
139
src/services/api/baseArrClient.ts
Normal file
139
src/services/api/baseArrClient.ts
Normal 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
383
src/services/api/lidarr.ts
Normal 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
304
src/services/api/radarr.ts
Normal 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
360
src/services/api/sonarr.ts
Normal 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;
|
||||
}
|
||||
|
||||
438
src/services/api/tvDiscovery.ts
Normal file
438
src/services/api/tvDiscovery.ts
Normal 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
160
src/services/api/yts.ts
Normal 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
7
src/services/index.ts
Normal 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';
|
||||
|
||||
3
src/services/integration/index.ts
Normal file
3
src/services/integration/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { mediaManager, default } from './mediaManager';
|
||||
export * from './mediaManager';
|
||||
|
||||
673
src/services/integration/mediaManager.ts
Normal file
673
src/services/integration/mediaManager.ts
Normal 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;
|
||||
|
||||
206
src/services/storage/database.ts
Normal file
206
src/services/storage/database.ts
Normal 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
Reference in New Issue
Block a user