# Playlist Monitor Service - UI Integration Guide ## ๐Ÿ“‹ Table of Contents 1. [UI Options Overview](#ui-options-overview) 2. [Option 1: Standalone React App](#option-1-standalone-react-app) 3. [Option 2: Extend MeTube Angular](#option-2-extend-metube-angular) 4. [Option 3: Simple HTML Dashboard](#option-3-simple-html-dashboard) 5. [API Integration Examples](#api-integration-examples) 6. [UI Components Design](#ui-components-design) 7. [Styling Guidelines](#styling-guidelines) 8. [Responsive Design](#responsive-design) 9. [Testing UI](#testing-ui) 10. [Deployment Options](#deployment-options) ## ๐ŸŽจ UI Options Overview Since the Playlist Monitor Service is a separate microservice, you have several UI integration options: ### Option Comparison | Option | Complexity | Maintenance | Features | Best For | |--------|------------|-------------|----------|----------| | **React App** | Medium | Low | Full-featured | New projects, standalone | | **Extend MeTube** | High | High | Integrated | Existing MeTube users | | **Simple HTML** | Low | Low | Basic | Quick setup, minimal needs | ## ๐Ÿš€ Option 1: Standalone React App (Recommended) ### Quick Setup (5 minutes) ```bash # Create React app with TypeScript cd /root/workspace/tubewatch npx create-react-app playlist-monitor-ui --template typescript cd playlist-monitor-ui # Install UI dependencies npm install @mui/material @emotion/react @emotion/styled axios react-query npm install @mui/icons-material @mui/x-data-grid date-fns npm install recharts # For charts npm install react-router-dom # For routing # Start development server npm start ``` ### Project Structure ``` playlist-monitor-ui/ โ”œโ”€โ”€ src/ โ”‚ โ”œโ”€โ”€ components/ # Reusable components โ”‚ โ”‚ โ”œโ”€โ”€ PlaylistCard.tsx โ”‚ โ”‚ โ”œโ”€โ”€ VideoTable.tsx โ”‚ โ”‚ โ””โ”€โ”€ StatusChart.tsx โ”‚ โ”œโ”€โ”€ pages/ # Main pages โ”‚ โ”‚ โ”œโ”€โ”€ Dashboard.tsx โ”‚ โ”‚ โ”œโ”€โ”€ Playlists.tsx โ”‚ โ”‚ โ”œโ”€โ”€ PlaylistDetail.tsx โ”‚ โ”‚ โ””โ”€โ”€ Settings.tsx โ”‚ โ”œโ”€โ”€ services/ # API services โ”‚ โ”‚ โ””โ”€โ”€ api.ts โ”‚ โ”œโ”€โ”€ hooks/ # Custom hooks โ”‚ โ”‚ โ””โ”€โ”€ usePlaylists.ts โ”‚ โ”œโ”€โ”€ utils/ # Utilities โ”‚ โ”‚ โ””โ”€โ”€ formatters.ts โ”‚ โ””โ”€โ”€ App.tsx โ”œโ”€โ”€ public/ โ””โ”€โ”€ package.json ``` ### Core API Service ```typescript // src/services/api.ts import axios from 'axios'; const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8082/api'; const api = axios.create({ baseURL: API_BASE_URL, timeout: 10000, headers: { 'Content-Type': 'application/json', }, }); // Request interceptor api.interceptors.request.use( (config) => { console.log('API Request:', config.method?.toUpperCase(), config.url); return config; }, (error) => Promise.reject(error) ); // Response interceptor api.interceptors.response.use( (response) => response, (error) => { console.error('API Error:', error.response?.data || error.message); return Promise.reject(error); } ); // Playlist API calls export const playlistAPI = { getAll: () => api.get('/playlists'), getById: (id: string) => api.get(`/playlists/${id}`), create: (data: any) => api.post('/playlists', data), update: (id: string, data: any) => api.put(`/playlists/${id}`, data), delete: (id: string) => api.delete(`/playlists/${id}`), check: (id: string) => api.post(`/playlists/${id}/check`), }; // Video API calls export const videoAPI = { getById: (id: string) => api.get(`/videos/${id}`), download: (id: string) => api.post(`/videos/${id}/download`), skip: (id: string) => api.post(`/videos/${id}/skip`), reset: (id: string) => api.post(`/videos/${id}/reset`), markAsMoved: (id: string, locationNote?: string) => api.post(`/videos/${id}/file-moved`, { location_note: locationNote }), }; // System API calls export const systemAPI = { getStatus: () => api.get('/status'), getSchedulerStatus: () => api.get('/scheduler/status'), syncWithMeTube: () => api.post('/sync-metube'), healthCheck: () => api.get('/health'), }; ``` ### Main Dashboard Component ```typescript // src/pages/Dashboard.tsx import React, { useState, useEffect } from 'react'; import { Container, Grid, Card, CardContent, Typography, Box, LinearProgress, Alert } from '@mui/material'; import { PlaylistPlay, VideoLibrary, Download, Error } from '@mui/icons-material'; import { systemAPI } from '../services/api'; import StatusChart from '../components/StatusChart'; interface SystemStatus { total_playlists: number; active_playlists: number; total_videos: number; pending_downloads: number; active_downloads: number; completed_downloads: number; failed_downloads: number; metube_status: { connected: boolean; error?: string; }; } const Dashboard: React.FC = () => { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { loadStatus(); const interval = setInterval(loadStatus, 30000); // Refresh every 30s return () => clearInterval(interval); }, []); const loadStatus = async () => { try { const response = await systemAPI.getStatus(); setStatus(response.data); setError(null); } catch (err) { setError('Failed to load system status'); console.error('Error loading status:', err); } finally { setLoading(false); } }; if (loading) return ; if (error) return {error}; if (!status) return No data available; const stats = [ { label: 'Total Playlists', value: status.total_playlists, icon: }, { label: 'Active Playlists', value: status.active_playlists, icon: }, { label: 'Total Videos', value: status.total_videos, icon: }, { label: 'Pending Downloads', value: status.pending_downloads, icon: }, { label: 'Active Downloads', value: status.active_downloads, icon: }, { label: 'Completed', value: status.completed_downloads, icon: }, { label: 'Failed', value: status.failed_downloads, icon: }, ]; return ( {/* Status Alert */} {!status.metube_status.connected && ( MeTube connection failed: {status.metube_status.error} )} {/* Stats Cards */} {stats.map((stat, index) => ( {stat.icon} {stat.label} {stat.value} ))} {/* Status Chart */} Video Status Distribution ); }; export default Dashboard; ``` ### Playlist Management Component ```typescript // src/pages/Playlists.tsx import React, { useState, useEffect } from 'react'; import { Container, Grid, Card, CardContent, Typography, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Box, Chip, IconButton, Alert, } from '@mui/material'; import { Add, PlayArrow, Delete, Edit, Refresh, CheckCircle, PauseCircle, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from 'react-query'; import { playlistAPI } from '../services/api'; interface Playlist { id: string; url: string; title?: string; check_interval: number; enabled: boolean; created_at: string; stats: { total: number; pending: number; completed: number; failed: number; }; } const Playlists: React.FC = () => { const queryClient = useQueryClient(); const [addDialog, setAddDialog] = useState(false); const [newPlaylist, setNewPlaylist] = useState({ url: '', title: '', check_interval: 60, quality: 'best', format: 'mp4', folder: '', enabled: true, }); // Fetch playlists const { data: playlists, isLoading, error } = useQuery( 'playlists', () => playlistAPI.getAll().then(res => res.data), { refetchInterval: 30000, // Refresh every 30s } ); // Create playlist mutation const createMutation = useMutation( (data: any) => playlistAPI.create(data), { onSuccess: () => { queryClient.invalidateQueries('playlists'); setAddDialog(false); setNewPlaylist({ url: '', title: '', check_interval: 60, quality: 'best', format: 'mp4', folder: '', enabled: true, }); }, } ); // Check playlist mutation const checkMutation = useMutation( (id: string) => playlistAPI.check(id), { onSuccess: () => { queryClient.invalidateQueries('playlists'); }, } ); // Delete playlist mutation const deleteMutation = useMutation( (id: string) => playlistAPI.delete(id), { onSuccess: () => { queryClient.invalidateQueries('playlists'); }, } ); const handleAddPlaylist = () => { createMutation.mutate(newPlaylist); }; const handleCheckPlaylist = (id: string) => { checkMutation.mutate(id); }; const handleDeletePlaylist = (id: string) => { if (window.confirm('Are you sure you want to delete this playlist?')) { deleteMutation.mutate(id); } }; if (isLoading) return
Loading...
; if (error) return Failed to load playlists; return ( Playlists {playlists?.map((playlist: Playlist) => ( {playlist.title || 'Untitled Playlist'} : } label={playlist.enabled ? 'Enabled' : 'Disabled'} color={playlist.enabled ? 'success' : 'default'} size="small" /> {playlist.url} Check Interval: {playlist.check_interval} minutes Total Videos: {playlist.stats.total} Pending: {playlist.stats.pending} | Completed: {playlist.stats.completed} handleCheckPlaylist(playlist.id)} disabled={checkMutation.isLoading} title="Check for new videos" > handleDeletePlaylist(playlist.id)} disabled={deleteMutation.isLoading} title="Delete playlist" > ))} {/* Add Playlist Dialog */} setAddDialog(false)} maxWidth="sm" fullWidth> Add New Playlist setNewPlaylist({ ...newPlaylist, url: e.target.value })} margin="normal" placeholder="https://www.youtube.com/playlist?list=..." /> setNewPlaylist({ ...newPlaylist, title: e.target.value })} margin="normal" /> setNewPlaylist({ ...newPlaylist, check_interval: parseInt(e.target.value) })} margin="normal" /> setNewPlaylist({ ...newPlaylist, folder: e.target.value })} margin="normal" /> ); }; export default Playlists; ``` ## ๐Ÿ”„ Option 2: Extend MeTube Angular ### Integration Approach Since MeTube uses Angular, you can extend its existing UI: ```typescript // Add to MeTube's existing structure metube/ui/src/app/ โ”œโ”€โ”€ playlist-monitor/ # New module โ”‚ โ”œโ”€โ”€ playlist-monitor.module.ts โ”‚ โ”œโ”€โ”€ components/ โ”‚ โ”‚ โ”œโ”€โ”€ playlist-list/ โ”‚ โ”‚ โ”œโ”€โ”€ playlist-detail/ โ”‚ โ”‚ โ””โ”€โ”€ video-dashboard/ โ”‚ โ”œโ”€โ”€ services/ โ”‚ โ”‚ โ””โ”€โ”€ playlist-monitor.service.ts โ”‚ โ””โ”€โ”€ models/ โ”‚ โ””โ”€โ”€ playlist.model.ts ``` ### Service Integration ```typescript // metube/ui/src/app/playlist-monitor/services/playlist-monitor.service.ts import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class PlaylistMonitorService { private apiUrl = 'http://localhost:8082/api'; constructor(private http: HttpClient) {} getPlaylists(): Observable { return this.http.get(`${this.apiUrl}/playlists`); } createPlaylist(playlist: any): Observable { return this.http.post(`${this.apiUrl}/playlists`, playlist); } checkPlaylist(id: string): Observable { return this.http.post(`${this.apiUrl}/playlists/${id}/check`, {}); } } ``` ### Component Integration ```typescript // Add to MeTube's navigation // metube/ui/src/app/app.component.html ``` ## ๐Ÿ“ Option 3: Simple HTML Dashboard ### Basic HTML Dashboard ```html Playlist Monitor Dashboard
Loading...

Total Playlists

Playlists
Title URL Status Videos Actions
Loading playlists...
``` ### JavaScript for Simple Dashboard ```javascript // dashboard.js const API_BASE_URL = 'http://localhost:8082/api'; // API helper functions async function apiCall(endpoint, options = {}) { try { const response = await fetch(`${API_BASE_URL}${endpoint}`, { headers: { 'Content-Type': 'application/json', }, ...options }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { console.error('API call failed:', error); throw error; } } // Load system status async function loadStatus() { try { const status = await apiCall('/status'); updateStatusCards(status); } catch (error) { showError('Failed to load system status'); } } // Update status cards function updateStatusCards(status) { const statusCards = document.getElementById('status-cards'); statusCards.innerHTML = `

${status.total_playlists}

Total Playlists

${status.active_playlists}

Active Playlists

${status.total_videos}

Total Videos

${status.pending_downloads}

Pending Downloads

`; } // Load playlists async function loadPlaylists() { try { const playlists = await apiCall('/playlists'); updatePlaylistsTable(playlists); } catch (error) { showError('Failed to load playlists'); } } // Update playlists table function updatePlaylistsTable(playlists) { const tbody = document.querySelector('#playlists-table tbody'); if (playlists.length === 0) { tbody.innerHTML = 'No playlists found'; return; } tbody.innerHTML = playlists.map(playlist => ` ${playlist.title || 'Untitled'} ${playlist.url} ${playlist.enabled ? 'Enabled' : 'Disabled'} ${playlist.stats.total} (${playlist.stats.pending} pending, ${playlist.stats.completed} completed) `).join(''); } // Add playlist async function addPlaylist() { const url = document.getElementById('playlist-url').value; const title = document.getElementById('playlist-title').value; const checkInterval = parseInt(document.getElementById('check-interval').value); if (!url) { alert('Please enter a YouTube playlist URL'); return; } try { await apiCall('/playlists', { method: 'POST', body: JSON.stringify({ url: url, title: title || undefined, check_interval: checkInterval, enabled: true }) }); // Close modal and refresh bootstrap.Modal.getInstance(document.getElementById('addPlaylistModal')).hide(); refreshData(); showSuccess('Playlist added successfully'); } catch (error) { showError('Failed to add playlist'); } } // Check playlist async function checkPlaylist(id) { try { await apiCall(`/playlists/${id}/check`, { method: 'POST' }); showSuccess('Playlist check initiated'); refreshData(); } catch (error) { showError('Failed to check playlist'); } } // UI helper functions function showAddPlaylistModal() { new bootstrap.Modal(document.getElementById('addPlaylistModal')).show(); } function showError(message) { // Simple error display - you could enhance this with toast notifications alert(`Error: ${message}`); } function showSuccess(message) { alert(`Success: ${message}`); } function refreshData() { loadStatus(); loadPlaylists(); } // Initialize dashboard document.addEventListener('DOMContentLoaded', function() { refreshData(); // Auto-refresh every 30 seconds setInterval(refreshData, 30000); }); ``` ## ๐Ÿ”— API Integration Examples ### React Hook for Real-time Updates ```typescript // src/hooks/useRealtimeUpdates.ts import { useEffect, useRef } from 'react'; import { systemAPI } from '../services/api'; export const useRealtimeUpdates = (onUpdate: (data: any) => void) => { const wsRef = useRef(null); useEffect(() => { // Connect to WebSocket for real-time updates const connectWebSocket = () => { const ws = new WebSocket('ws://localhost:8082/ws'); ws.onopen = () => { console.log('WebSocket connected'); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); onUpdate(data); }; ws.onclose = () => { console.log('WebSocket disconnected'); // Reconnect after 5 seconds setTimeout(connectWebSocket, 5000); }; ws.onerror = (error) => { console.error('WebSocket error:', error); }; wsRef.current = ws; }; connectWebSocket(); return () => { if (wsRef.current) { wsRef.current.close(); } }; }, [onUpdate]); return wsRef.current; }; ``` ## ๐ŸŽจ UI Components Design ### Key Components Needed 1. **Dashboard** - System overview with charts and statistics 2. **Playlist List** - Table/card view of all playlists 3. **Playlist Detail** - Individual playlist management 4. **Video Table** - Video status and management 5. **Add/Edit Forms** - Playlist creation and editing 6. **Status Indicators** - Real-time status updates 7. **Charts/Graphs** - Statistics visualization ### Design System Integration ```typescript // src/theme.ts import { createTheme } from '@mui/material/styles'; // Custom theme matching MeTube's style export const theme = createTheme({ palette: { primary: { main: '#1976d2', // Blue matching MeTube }, secondary: { main: '#dc004e', }, background: { default: '#f5f5f5', }, }, typography: { fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', }, components: { MuiCard: { styleOverrides: { root: { boxShadow: '0 2px 4px rgba(0,0,0,0.1)', borderRadius: 8, }, }, }, }, }); ``` ## ๐Ÿ“ฑ Responsive Design ### Mobile-First Approach ```typescript // Responsive grid breakpoints {/* Card content */} // Responsive typography Playlist Title ``` ## ๐Ÿงช Testing UI ### Component Testing with React Testing Library ```typescript // src/components/__tests__/PlaylistCard.test.tsx import { render, screen, fireEvent } from '@testing-library/react'; import PlaylistCard from '../PlaylistCard'; describe('PlaylistCard', () => { const mockPlaylist = { id: '1', title: 'Test Playlist', url: 'https://www.youtube.com/playlist?list=TEST123', enabled: true, stats: { total: 10, pending: 2, completed: 8 } }; it('renders playlist information correctly', () => { render(); expect(screen.getByText('Test Playlist')).toBeInTheDocument(); expect(screen.getByText('10 videos')).toBeInTheDocument(); }); it('calls onCheck when check button is clicked', () => { const onCheck = jest.fn(); render(); fireEvent.click(screen.getByLabelText('Check playlist')); expect(onCheck).toHaveBeenCalledWith('1'); }); }); ``` ## ๐Ÿš€ Deployment Options ### Option 1: Serve with Backend ```typescript // Modify app/main.py to serve static files from fastapi.staticfiles import StaticFiles app.mount("/ui", StaticFiles(directory="ui/build", html=True), name="ui") # Add redirect @app.get("/") async def serve_ui(): return RedirectResponse(url="/ui") ``` ### Option 2: Separate Deployment ```bash # Build React app npm run build # Deploy to static hosting # - Netlify # - Vercel # - GitHub Pages # - Nginx ``` ### Option 3: Docker Multi-Service ```dockerfile # Dockerfile for React app FROM node:18-alpine as build WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/build /usr/share/nginx/html COPY nginx.conf /etc/nginx/nginx.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] ``` ## ๐Ÿ“š Next Steps 1. **Choose UI Option** based on your needs and technical requirements 2. **Implement Core Components** following the examples above 3. **Add Real-time Updates** with WebSocket integration 4. **Test Thoroughly** with different screen sizes and browsers 5. **Deploy** using one of the suggested deployment options The UI implementation will provide a complete, user-friendly interface for managing your playlists and monitoring download progress! ๐ŸŽจโœจ