- - +
diff --git a/media-analyzer/frontend/src/app/app.component.scss b/media-analyzer/frontend/src/app/app.component.scss index dda4489..04d047f 100644 --- a/media-analyzer/frontend/src/app/app.component.scss +++ b/media-analyzer/frontend/src/app/app.component.scss @@ -14,7 +14,7 @@ .main-content { display: grid; - grid-template-columns: 350px 1fr; + grid-template-columns: 450px 1fr; grid-template-rows: auto auto; grid-template-areas: "controls viewer" @@ -39,6 +39,14 @@ background: #f8f9fa; padding: 1.5rem; border-radius: 8px; + + > * { + margin-bottom: 1.5rem; + + &:last-child { + margin-bottom: 0; + } + } } .viewer-section { diff --git a/media-analyzer/frontend/src/app/app.component.ts b/media-analyzer/frontend/src/app/app.component.ts index 8d3061a..99236d8 100644 --- a/media-analyzer/frontend/src/app/app.component.ts +++ b/media-analyzer/frontend/src/app/app.component.ts @@ -1,17 +1,18 @@ import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { HttpClientModule } from '@angular/common/http'; -import { StreamControlComponent } from './components/stream-control/stream-control.component'; +import { UnifiedStreamControlComponent } from './components/unified-stream-control/unified-stream-control.component'; import { StreamViewerComponent } from './components/stream-viewer/stream-viewer.component'; import { AnalysisPanelComponent } from './components/analysis-panel/analysis-panel.component'; import { AnalysisService } from './services/analysis.service'; -import { StreamService } from './services/stream.service'; +import { StreamStateService } from './services/stream-state.service'; import { DetectionResult, VisualAnalysis, Analysis } from './models/analysis'; +import { Subject, takeUntil } from 'rxjs'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet, HttpClientModule, StreamControlComponent, StreamViewerComponent, AnalysisPanelComponent], + imports: [RouterOutlet, HttpClientModule, UnifiedStreamControlComponent, StreamViewerComponent, AnalysisPanelComponent], templateUrl: './app.component.html', styleUrl: './app.component.scss' }) @@ -20,95 +21,53 @@ export class AppComponent implements OnInit, OnDestroy { title = 'Media Analyzer'; selectedStreamUrl: string = ''; - currentStreamId: string = ''; currentDetections: DetectionResult[] = []; currentVisual?: VisualAnalysis; recentAnalyses: Analysis[] = []; + + private destroy$ = new Subject(); constructor( private analysisService: AnalysisService, - private streamService: StreamService + private streamStateService: StreamStateService ) {} ngOnInit() { + // Subscribe to stream URL changes from centralized state + this.streamStateService.currentStreamUrl$ + .pipe(takeUntil(this.destroy$)) + .subscribe(streamUrl => { + this.selectedStreamUrl = streamUrl; + + // Clear stream viewer when URL is empty (stream stopped) + if (!streamUrl && this.streamViewer) { + this.streamViewer.clearStream(); + } + }); + // Subscribe to analysis updates - this.analysisService.detections$.subscribe(detections => { - this.currentDetections = detections; - }); + this.analysisService.detections$ + .pipe(takeUntil(this.destroy$)) + .subscribe(detections => { + this.currentDetections = detections; + }); - this.analysisService.visual$.subscribe(visual => { - this.currentVisual = visual || undefined; - }); + this.analysisService.visual$ + .pipe(takeUntil(this.destroy$)) + .subscribe(visual => { + this.currentVisual = visual || undefined; + }); - this.analysisService.analyses$.subscribe(analyses => { - this.recentAnalyses = analyses; - }); + this.analysisService.analyses$ + .pipe(takeUntil(this.destroy$)) + .subscribe(analyses => { + this.recentAnalyses = analyses; + }); } ngOnDestroy() { - this.analysisService.disconnect(); - } - - onStreamSelected(streamUrl: string) { - console.log('App received stream URL:', streamUrl); - - // Extract filename from backend URL, then construct a browser-resolvable URL - const filename = streamUrl.split('/').pop() || ''; - this.selectedStreamUrl = `/streaming/${filename}`; - console.log('Using HLS URL:', this.selectedStreamUrl); - - // Retry function to get active stream (with small delays to allow DB update) - const getActiveStreamWithRetry = (attempt = 1, maxAttempts = 3) => { - this.streamService.getStreams().subscribe({ - next: (response) => { - const activeStream = response.streams.find(stream => stream.status === 'active'); - if (activeStream) { - this.currentStreamId = activeStream.stream_key; - console.log('Found active stream with key:', this.currentStreamId); - // Connect to WebSocket for this stream - this.analysisService.connectToStream(this.currentStreamId); - } else if (attempt < maxAttempts) { - console.log(`No active stream found (attempt ${attempt}/${maxAttempts}), retrying in 1s...`); - setTimeout(() => getActiveStreamWithRetry(attempt + 1, maxAttempts), 1000); - } else { - console.log('No active stream found after retries, falling back to filename parsing'); - this.fallbackToFilenameExtraction(filename); - } - }, - error: (error) => { - console.error('Failed to get streams from API:', error); - this.fallbackToFilenameExtraction(filename); - } - }); - }; - - // Start the retry process - getActiveStreamWithRetry(); - } - - private fallbackToFilenameExtraction(filename: string) { - const streamIdMatch = filename.match(/^([a-zA-Z0-9-]+)\.m3u8$/); - if (streamIdMatch) { - this.currentStreamId = streamIdMatch[1]; - console.log('Fallback: Extracted stream ID from filename:', this.currentStreamId); - this.analysisService.connectToStream(this.currentStreamId); - } else { - console.error('Could not extract stream ID from filename:', filename); - } - } - - onStreamStopped() { - console.log('Stream stopped - clearing player'); - // Clear the stream from player - if (this.streamViewer) { - this.streamViewer.clearStream(); - } - // Clear app state - this.selectedStreamUrl = ''; - this.currentStreamId = ''; - this.currentDetections = []; - this.currentVisual = undefined; - // Disconnect from WebSocket + this.destroy$.next(); + this.destroy$.complete(); this.analysisService.disconnect(); } } diff --git a/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.html b/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.html new file mode 100644 index 0000000..8415115 --- /dev/null +++ b/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.html @@ -0,0 +1,45 @@ +
+

🔍 Analysis Manager

+

Configure AI analysis features for video streams

+ +
+
+
+ +
+
+ +
+
+ Active Features: + {{ activeFeatures }} / {{ availableFeatures }} +
+ +
+ Processing Load: + {{ activeFeatures > 0 ? 'Light' : 'None' }} +
+ +
+ Estimated FPS: + {{ activeFeatures > 0 ? '30 FPS' : 'N/A' }} +
+
+
+
\ No newline at end of file diff --git a/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.scss b/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.scss new file mode 100644 index 0000000..fe894ad --- /dev/null +++ b/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.scss @@ -0,0 +1,121 @@ +.analysis-manager { + padding: 20px; +} + +.analysis-manager h2 { + margin-bottom: 8px; + color: #333; +} + +.description { + color: #666; + margin-bottom: 20px; + font-style: italic; +} + +.manager-content { + border: 1px solid #ddd; + border-radius: 8px; + padding: 20px; + background: #fafafa; +} + +.analysis-features { + margin-bottom: 25px; +} + +.feature-option { + margin-bottom: 15px; +} + +.feature-option.available .checkbox-container { + cursor: pointer; + border-color: #28a745; + background: white; +} + +.feature-option.unavailable .checkbox-container { + cursor: not-allowed; + border-color: #ddd; + background: #f8f8f8; + opacity: 0.6; +} + +.checkbox-container { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border: 1px solid #ddd; + border-radius: 6px; + background: white; + transition: all 0.2s; +} + +.checkbox-container:hover.available { + border-color: #007bff; + background: #f8f9ff; +} + +.checkbox-container input[type="checkbox"] { + margin-right: 12px; +} + +.checkbox-container input[type="checkbox"]:disabled { + opacity: 0.5; +} + +.checkbox-checkmark { + margin-right: 12px; +} + +.feature-details { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.feature-label { + font-weight: 600; + color: #333; + margin-bottom: 2px; +} + +.feature-description { + font-size: 0.9em; + color: #666; +} + +.feature-status { + margin-left: 12px; +} + +.coming-soon { + background: #ffc107; + color: #333; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.75em; + font-weight: 500; +} + +.status-section { + border-top: 1px solid #ddd; + padding-top: 20px; +} + +.status-item { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.status-label { + color: #666; + font-weight: 500; +} + +.status-value { + color: #333; + font-weight: 600; +} \ No newline at end of file diff --git a/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.spec.ts b/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.spec.ts new file mode 100644 index 0000000..0c95ee4 --- /dev/null +++ b/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AnalysisManagerComponent } from './analysis-manager.component'; + +describe('AnalysisManagerComponent', () => { + let component: AnalysisManagerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AnalysisManagerComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AnalysisManagerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.ts b/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.ts new file mode 100644 index 0000000..33ca4e5 --- /dev/null +++ b/media-analyzer/frontend/src/app/components/analysis-manager/analysis-manager.component.ts @@ -0,0 +1,135 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-analysis-manager', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+
+

Analysis Manager

+ {{ showContent ? '−' : '+' }} +
+ +
+
+
+ +
+
+
+
+ `, + styles: [` + .source-management { + border: 1px solid #dee2e6; + border-radius: 6px; + overflow: hidden; + } + + .source-management.disabled { + opacity: 0.6; + } + + .section-header { + background: #e9ecef; + padding: 12px 15px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + user-select: none; + } + + .disabled .section-header { + background: #e0e0e0; + } + + .section-header:hover { + background: #dee2e6; + } + + .disabled .section-header:hover { + background: #e0e0e0; + } + + .section-header h4 { + margin: 0; + color: #495057; + font-size: 14px; + } + + .disabled .section-header h4 { + color: #6c757d; + } + + .toggle-icon { + font-weight: bold; + color: #6c757d; + } + + .management-content { + padding: 15px; + background: white; + } + + .disabled .management-content { + background: #f8f8f8; + } + + .analysis-features { + margin-bottom: 15px; + } + + .feature-option { + margin-bottom: 8px; + } + + .checkbox-container { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + } + + .checkbox-container input[type="checkbox"] { + opacity: 0.4; + } + + .checkbox-container span { + opacity: 0.6; + color: #6c757d; + } + `] +}) +export class AnalysisManagerComponent { + showContent = false; + + analysisFeatures = [ + { id: 'logo_detection', label: 'Logo Detection', enabled: true, available: true }, + { id: 'visual_properties', label: 'Visual Properties', enabled: false, available: false }, + { id: 'object_detection', label: 'Object Detection', enabled: false, available: false }, + { id: 'audio_transcript', label: 'Audio Transcript', enabled: false, available: false }, + { id: 'text_recognition', label: 'Text Recognition', enabled: false, available: false } + ]; + + toggleSection() { + this.showContent = !this.showContent; + } + + get activeFeatures() { + return this.analysisFeatures.filter(f => f.enabled).length; + } + + get availableFeatures() { + return this.analysisFeatures.filter(f => f.available).length; + } +} \ No newline at end of file diff --git a/media-analyzer/frontend/src/app/components/analysis-panel/analysis-panel.component.ts b/media-analyzer/frontend/src/app/components/analysis-panel/analysis-panel.component.ts index c930c77..2b6fed8 100644 --- a/media-analyzer/frontend/src/app/components/analysis-panel/analysis-panel.component.ts +++ b/media-analyzer/frontend/src/app/components/analysis-panel/analysis-panel.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, OnChanges } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Analysis, DetectionResult, VisualAnalysis } from '../../models/analysis'; @@ -9,11 +9,14 @@ import { Analysis, DetectionResult, VisualAnalysis } from '../../models/analysis templateUrl: './analysis-panel.component.html', styleUrl: './analysis-panel.component.scss' }) -export class AnalysisPanelComponent { +export class AnalysisPanelComponent implements OnChanges { @Input() analyses: Analysis[] = []; @Input() currentDetections: DetectionResult[] = []; @Input() currentVisual?: VisualAnalysis; + ngOnChanges() { + } + getDetectionsByType(type: string): DetectionResult[] { return this.currentDetections.filter(d => d.detection_type === type); } diff --git a/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.html b/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.html new file mode 100644 index 0000000..75e371b --- /dev/null +++ b/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.html @@ -0,0 +1,41 @@ +
+

⚡ Execution Manager

+

Choose where AI processing will be executed

+ +
+
+
+ +
+
+ +
+
+ Current Mode: + {{ selectedExecution | titlecase }} +
+ +
+ Available Workers: + 1 Local +
+ +
+ Coming Soon +
+
+
+
\ No newline at end of file diff --git a/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.scss b/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.scss new file mode 100644 index 0000000..1e64414 --- /dev/null +++ b/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.scss @@ -0,0 +1,113 @@ +.execution-manager { + padding: 20px; +} + +.execution-manager h2 { + margin-bottom: 8px; + color: #333; +} + +.description { + color: #666; + margin-bottom: 20px; + font-style: italic; +} + +.manager-content { + border: 1px solid #ddd; + border-radius: 8px; + padding: 20px; + background: #fafafa; +} + +.manager-content.disabled { + opacity: 0.6; + background: #f8f8f8; + border-color: #ccc; +} + +.execution-modes { + margin-bottom: 25px; +} + +.mode-option { + margin-bottom: 15px; +} + +.radio-container { + display: flex; + align-items: center; + cursor: not-allowed; + padding: 12px; + border: 1px solid #ddd; + border-radius: 6px; + background: white; + transition: all 0.2s; +} + +.radio-container:hover { + border-color: #bbb; +} + +.radio-container input[type="radio"] { + margin-right: 12px; + cursor: not-allowed; +} + +.radio-container input[type="radio"]:disabled { + opacity: 0.5; +} + +.radio-checkmark { + margin-right: 12px; +} + +.mode-details { + display: flex; + flex-direction: column; +} + +.mode-label { + font-weight: 600; + color: #333; + margin-bottom: 2px; +} + +.mode-description { + font-size: 0.9em; + color: #666; +} + +.status-section { + border-top: 1px solid #ddd; + padding-top: 20px; + position: relative; +} + +.status-item { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.status-label { + color: #666; + font-weight: 500; +} + +.status-value { + color: #333; + font-weight: 600; +} + +.feature-badge { + position: absolute; + top: -10px; + right: 10px; + background: #17a2b8; + color: white; + padding: 4px 12px; + border-radius: 12px; + font-size: 0.8em; + font-weight: 500; +} \ No newline at end of file diff --git a/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.spec.ts b/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.spec.ts new file mode 100644 index 0000000..1f968f1 --- /dev/null +++ b/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ExecutionManagerComponent } from './execution-manager.component'; + +describe('ExecutionManagerComponent', () => { + let component: ExecutionManagerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ExecutionManagerComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ExecutionManagerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.ts b/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.ts new file mode 100644 index 0000000..579e620 --- /dev/null +++ b/media-analyzer/frontend/src/app/components/execution-manager/execution-manager.component.ts @@ -0,0 +1,128 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-execution-manager', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+
+

Execution Manager

+ {{ showContent ? '−' : '+' }} +
+ +
+
+
+ +
+
+
+
+ `, + styles: [` + .source-management { + border: 1px solid #dee2e6; + border-radius: 6px; + overflow: hidden; + } + + .source-management.disabled { + opacity: 0.6; + } + + .section-header { + background: #e9ecef; + padding: 12px 15px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + user-select: none; + } + + .disabled .section-header { + background: #e0e0e0; + } + + .section-header:hover { + background: #dee2e6; + } + + .disabled .section-header:hover { + background: #e0e0e0; + } + + .section-header h4 { + margin: 0; + color: #495057; + font-size: 14px; + } + + .disabled .section-header h4 { + color: #6c757d; + } + + .toggle-icon { + font-weight: bold; + color: #6c757d; + } + + .management-content { + padding: 15px; + background: white; + } + + .disabled .management-content { + background: #f8f8f8; + } + + .execution-modes { + margin-bottom: 15px; + } + + .mode-option { + margin-bottom: 8px; + } + + .radio-container { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + } + + .radio-container input[type="radio"] { + opacity: 0.4; + } + + .radio-container span { + opacity: 0.6; + color: #6c757d; + } + `] +}) +export class ExecutionManagerComponent { + selectedExecution: string = 'local'; + showContent = false; + + executionModes = [ + { id: 'local', label: 'Local' }, + { id: 'lan', label: 'LAN' }, + { id: 'cloud', label: 'Cloud' } + ]; + + toggleSection() { + this.showContent = !this.showContent; + } +} \ No newline at end of file diff --git a/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.html b/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.html index 6ce0fce..2ab5ad0 100644 --- a/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.html +++ b/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.html @@ -7,8 +7,11 @@
- - - + +
diff --git a/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.ts b/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.ts index 717101a..3ceaa33 100644 --- a/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.ts +++ b/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.ts @@ -16,6 +16,7 @@ export class StreamViewerComponent implements AfterViewInit, OnDestroy, OnChange @Input() detections: DetectionResult[] = []; showOverlay = true; + isPlaying = false; private hls?: Hls; private ctx?: CanvasRenderingContext2D; @@ -24,6 +25,12 @@ export class StreamViewerComponent implements AfterViewInit, OnDestroy, OnChange if (this.streamUrl) { this.loadStream(this.streamUrl); } + + // Set up video event listeners + const video = this.videoElement.nativeElement; + video.addEventListener('play', () => this.isPlaying = true); + video.addEventListener('pause', () => this.isPlaying = false); + video.addEventListener('ended', () => this.isPlaying = false); } ngOnChanges(changes: SimpleChanges) { @@ -64,6 +71,8 @@ export class StreamViewerComponent implements AfterViewInit, OnDestroy, OnChange this.hls.on(Hls.Events.MANIFEST_LOADED, () => { console.log('HLS manifest loaded'); + // Autoplay when manifest is loaded + this.autoPlay(); }); this.hls.on(Hls.Events.ERROR, (event, data) => { @@ -75,11 +84,32 @@ export class StreamViewerComponent implements AfterViewInit, OnDestroy, OnChange } else if (video.canPlayType('application/vnd.apple.mpegurl')) { // Native HLS support (Safari) video.src = url; + video.addEventListener('loadedmetadata', () => this.autoPlay()); } else { console.error('HLS not supported'); } } + private async autoPlay() { + try { + const video = this.videoElement.nativeElement; + video.muted = true; // Required for autoplay in most browsers + await video.play(); + console.log('Video autoplay started'); + } catch (error) { + console.log('Autoplay failed, user interaction required:', error); + } + } + + async togglePlayPause() { + const video = this.videoElement.nativeElement; + if (video.paused) { + await this.play(); + } else { + this.pause(); + } + } + async play() { try { const video = this.videoElement.nativeElement; diff --git a/media-analyzer/frontend/src/app/components/unified-stream-control/unified-stream-control.component.ts b/media-analyzer/frontend/src/app/components/unified-stream-control/unified-stream-control.component.ts new file mode 100644 index 0000000..a88c3e6 --- /dev/null +++ b/media-analyzer/frontend/src/app/components/unified-stream-control/unified-stream-control.component.ts @@ -0,0 +1,682 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Subject, takeUntil } from 'rxjs'; +import { StreamStateService, StreamState, StreamSession } from '../../services/stream-state.service'; +import { Stream } from '../../models/stream'; +import { ExecutionManagerComponent } from '../execution-manager/execution-manager.component'; +import { AnalysisManagerComponent } from '../analysis-manager/analysis-manager.component'; + +@Component({ + selector: 'app-unified-stream-control', + standalone: true, + imports: [CommonModule, FormsModule, ExecutionManagerComponent, AnalysisManagerComponent], + template: ` +
+
+

Control Panel

+
+ {{ getStatusText() }} +
+
+ + +
+ {{ streamState.error }} + +
+ + +
+ + +
+ + +
+ + +
+ Stream: {{ rtmpStreams[0].stream_key }} +
+
+ No RTMP streams available +
+ + +
+ + + +
+
+ + +
+

Active Stream

+
+
+ Type: + {{ streamState.currentSession.sourceType.toUpperCase() }} +
+
+ Key: + {{ streamState.currentSession.streamKey }} +
+
+ Started: + {{ formatTime(streamState.currentSession.startedAt) }} +
+
+ Session ID: + {{ streamState.currentSession.id }} +
+
+
+ + +
+
+

Stream Manager

+ {{ showSourceManagement ? '−' : '+' }} +
+ +
+ + +
+
Available Sources
+
+
+
+
+ {{ stream.source_type.toUpperCase() }} +
+
+ +
+
+
+
+ Stream Key: + {{ stream.stream_key }} +
+
+ Status: + {{ stream.status }} +
+
+ HLS URL: + {{ stream.hls_playlist_url }} +
+
+
+
+
+ +
+

No sources available.

+
+
+
+ + + + + + + + +
+
+
+
+ `, + styles: [` + .stream-control-panel { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 20px; + position: relative; + min-height: 200px; + } + + .panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + } + + .panel-header h3 { + margin: 0; + color: #343a40; + } + + .status-indicator { + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + } + + .status-indicator.streaming { + background: #d4edda; + color: #155724; + } + + .status-indicator.idle { + background: #f8d7da; + color: #721c24; + } + + .status-indicator.loading { + background: #fff3cd; + color: #856404; + } + + .error-message { + background: #f8d7da; + color: #721c24; + padding: 10px; + border-radius: 4px; + margin-bottom: 15px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .clear-error { + background: none; + border: none; + color: #721c24; + cursor: pointer; + font-size: 16px; + } + + /* Main Controls Section */ + .main-controls { + background: white; + border: 1px solid #dee2e6; + border-radius: 6px; + padding: 20px; + margin-bottom: 20px; + } + + .source-selection { + display: flex; + gap: 20px; + margin-bottom: 15px; + } + + .source-option { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: 500; + } + + .source-option input[type="radio"] { + margin: 0; + } + + .rtmp-info { + margin-bottom: 15px; + padding: 8px 12px; + border-radius: 4px; + font-size: 14px; + } + + .rtmp-info .stream-info { + color: #155724; + background: #d4edda; + padding: 4px 8px; + border-radius: 3px; + font-family: monospace; + } + + .rtmp-info .no-streams { + color: #721c24; + font-style: italic; + } + + .action-buttons { + display: flex; + justify-content: center; + } + + .start-button, .stop-button { + padding: 12px 32px; + border: none; + border-radius: 6px; + font-weight: 600; + font-size: 16px; + cursor: pointer; + transition: all 0.2s; + min-width: 140px; + } + + .start-button { + background: #28a745; + color: white; + } + + .start-button:hover:not(:disabled) { + background: #1e7e34; + transform: translateY(-1px); + } + + .stop-button { + background: #dc3545; + color: white; + } + + .stop-button:hover:not(:disabled) { + background: #c82333; + transform: translateY(-1px); + } + + button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; + } + + /* Current Session Display */ + .current-session { + background: #d4edda; + border: 1px solid #c3e6cb; + border-radius: 6px; + padding: 15px; + margin-bottom: 20px; + } + + .current-session h4 { + margin: 0 0 10px 0; + color: #155724; + } + + .session-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + } + + .session-detail { + display: flex; + flex-direction: column; + } + + .session-detail .label { + font-weight: 600; + font-size: 12px; + color: #155724; + text-transform: uppercase; + margin-bottom: 2px; + } + + .session-detail .value { + color: #155724; + font-family: monospace; + font-size: 14px; + } + + /* Source Management Section */ + .source-management { + border: 1px solid #dee2e6; + border-radius: 6px; + overflow: hidden; + } + + .section-header { + background: #e9ecef; + padding: 12px 15px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + user-select: none; + } + + .section-header:hover { + background: #dee2e6; + } + + .section-header h4 { + margin: 0; + color: #495057; + font-size: 14px; + } + + .toggle-icon { + font-weight: bold; + color: #6c757d; + } + + .management-content { + padding: 15px; + background: white; + } + + + /* Source List Styles */ + .source-list { + margin-top: 15px; + } + + .source-item { + border: 1px solid #dee2e6; + border-radius: 6px; + margin-bottom: 15px; + overflow: hidden; + } + + .source-item.active { + border-color: #28a745; + background: #f8fff9; + } + + .source-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 15px; + background: #f8f9fa; + border-bottom: 1px solid #dee2e6; + } + + .source-type-badge { + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + } + + .source-type-badge.webcam { + background: #007bff; + color: white; + } + + .source-type-badge.rtmp { + background: #6f42c1; + color: white; + } + + .delete-button { + background: #dc3545; + color: white; + border: none; + border-radius: 4px; + padding: 6px 10px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.2s; + } + + .delete-button:hover:not(:disabled) { + background: #c82333; + } + + .delete-button:disabled { + background: #6c757d; + cursor: not-allowed; + } + + .source-info { + padding: 15px; + } + + .info-row { + display: flex; + margin-bottom: 8px; + align-items: flex-start; + } + + .info-row:last-child { + margin-bottom: 0; + } + + .info-row .label { + font-weight: 600; + color: #495057; + width: 100px; + flex-shrink: 0; + font-size: 12px; + text-transform: uppercase; + } + + .info-row .value { + color: #212529; + flex: 1; + word-break: break-all; + } + + .info-row .value.mono { + font-family: 'Courier New', monospace; + background: #f8f9fa; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + } + + .info-row .value.small { + font-size: 11px; + } + + .status-badge { + padding: 3px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + } + + .status-badge.active { + background: #d4edda; + color: #155724; + } + + .status-badge.inactive { + background: #f8d7da; + color: #721c24; + } + + .no-sources { + text-align: center; + padding: 40px 20px; + color: #6c757d; + font-style: italic; + } + + .no-sources p { + margin: 0; + } + + .available-sources h5 { + margin: 0 0 15px 0; + color: #495057; + font-size: 14px; + font-weight: 600; + } + + /* Loading Overlay */ + .loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + z-index: 10; + } + + .spinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + `] +}) +export class UnifiedStreamControlComponent implements OnInit, OnDestroy { + streamState: StreamState = { + isLoading: false, + currentSession: null, + availableStreams: [], + error: null + }; + + selectedSourceType: 'webcam' | 'rtmp' = 'webcam'; + showSourceManagement = false; + + private destroy$ = new Subject(); + + constructor(private streamStateService: StreamStateService) {} + + ngOnInit() { + this.streamStateService.state$ + .pipe(takeUntil(this.destroy$)) + .subscribe(state => { + this.streamState = state; + + // Update source selection based on active stream + if (state.currentSession) { + this.selectedSourceType = state.currentSession.sourceType as 'webcam' | 'rtmp'; + } + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + async startSelectedSource() { + if (this.selectedSourceType === 'webcam') { + await this.streamStateService.startWebcamStream(); + } else if (this.selectedSourceType === 'rtmp' && this.rtmpStreams.length > 0) { + await this.streamStateService.startRtmpStream(this.rtmpStreams[0].stream_key); + } + } + + async stopStream() { + await this.streamStateService.stopCurrentStream(); + } + + + toggleSourceManagement() { + this.showSourceManagement = !this.showSourceManagement; + } + + async deleteStream(stream: Stream) { + if (stream.status === 'active') { + alert('Cannot delete an active stream. Stop it first.'); + return; + } + + if (!confirm(`Are you sure you want to delete "${stream.name}"?`)) { + return; + } + + try { + // Call the backend API to delete the stream + await this.streamStateService.deleteStream(stream.id); + } catch (error) { + console.error('Failed to delete stream:', error); + alert('Failed to delete stream. Please try again.'); + } + } + + canStart(): boolean { + if (this.selectedSourceType === 'webcam') { + return true; + } + return this.selectedSourceType === 'rtmp' && this.rtmpStreams.length > 0; + } + + clearError() { + // Update state to clear error + const currentState = this.streamState; + this.streamState = { ...currentState, error: null }; + } + + get rtmpStreams(): Stream[] { + return this.streamState.availableStreams.filter(stream => stream.source_type === 'rtmp'); + } + + get allStreams(): Stream[] { + return this.streamState.availableStreams; + } + + get isStreaming(): boolean { + return !!this.streamState.currentSession; + } + + getStatusText(): string { + if (this.streamState.isLoading) return 'Loading'; + if (this.streamState.currentSession) return 'Streaming'; + return 'Idle'; + } + + getStatusClass(): string { + if (this.streamState.isLoading) return 'loading'; + if (this.streamState.currentSession) return 'streaming'; + return 'idle'; + } + + formatTime(date: Date): string { + return new Date(date).toLocaleTimeString(); + } +} \ No newline at end of file diff --git a/media-analyzer/frontend/src/app/models/analysis.ts b/media-analyzer/frontend/src/app/models/analysis.ts index eabba02..052ad97 100644 --- a/media-analyzer/frontend/src/app/models/analysis.ts +++ b/media-analyzer/frontend/src/app/models/analysis.ts @@ -26,6 +26,7 @@ export interface VisualAnalysis { export interface Analysis { id: string; stream_id: string; + session_id?: string; timestamp: string; processing_time?: number; analysis_type: string; diff --git a/media-analyzer/frontend/src/app/services/analysis.service.ts b/media-analyzer/frontend/src/app/services/analysis.service.ts index e14689f..4da0c79 100644 --- a/media-analyzer/frontend/src/app/services/analysis.service.ts +++ b/media-analyzer/frontend/src/app/services/analysis.service.ts @@ -10,7 +10,8 @@ export class AnalysisService { private currentDetections = new BehaviorSubject([]); private currentVisual = new BehaviorSubject(null); private recentAnalyses = new BehaviorSubject([]); - private streamStartTime: Date | null = null; + private currentSessionId: string | null = null; + private connectedStreamKey: string | null = null; public detections$ = this.currentDetections.asObservable(); public visual$ = this.currentVisual.asObservable(); @@ -23,35 +24,43 @@ export class AnalysisService { }); } - connectToStream(streamId: string) { - this.streamStartTime = new Date(); - this.websocketService.subscribe(streamId); + connectToStream(streamKey: string, sessionId?: string) { + // Set current session for filtering + this.currentSessionId = sessionId || `session_${Date.now()}`; + this.connectedStreamKey = streamKey; + + // Clear existing analysis data when starting new session + this.clearAnalysis(); + + // Connect to WebSocket with session ID + this.websocketService.subscribe(streamKey, this.currentSessionId); + + console.log('Connected to stream analysis:', { + streamKey, + sessionId: this.currentSessionId + }); } disconnect() { this.websocketService.unsubscribe(); this.websocketService.disconnect(); - this.currentDetections.next([]); - this.currentVisual.next(null); - this.streamStartTime = null; + this.clearAnalysis(); + this.currentSessionId = null; + this.connectedStreamKey = null; } private handleAnalysisUpdate(analysis: Analysis) { - console.log('Received analysis update:', analysis); - - // Filter out analysis from before stream started (with 30 second buffer for recent analysis) - if (this.streamStartTime && analysis.timestamp) { - const analysisTime = new Date(analysis.timestamp); - const bufferTime = new Date(this.streamStartTime.getTime() - 30000); // 30 seconds before stream start - if (analysisTime < bufferTime) { - console.log('Ignoring old analysis from before stream start:', { - analysisTime: analysisTime.toISOString(), - streamStart: this.streamStartTime.toISOString() - }); - return; - } + // Only process analysis if we have an active session + if (!this.currentSessionId) { + return; } + // Filter by session ID - only process analysis for current session + if (analysis.session_id && analysis.session_id !== this.currentSessionId) { + return; + } + + // Update recent analyses list const current = this.recentAnalyses.value; const updated = [analysis, ...current.slice(0, 9)]; // Keep last 10 @@ -66,11 +75,6 @@ export class AnalysisService { this.currentVisual.next(analysis.visual); } - console.log('Analysis update:', { - detections: detections.length, - visual: !!analysis.visual, - timestamp: analysis.timestamp - }); } getCurrentDetections(): DetectionResult[] { @@ -90,4 +94,12 @@ export class AnalysisService { this.currentVisual.next(null); this.recentAnalyses.next([]); } + + getCurrentSessionId(): string | null { + return this.currentSessionId; + } + + isConnectedToStream(streamKey: string): boolean { + return this.connectedStreamKey === streamKey && !!this.currentSessionId; + } } diff --git a/media-analyzer/frontend/src/app/services/stream-state.service.ts b/media-analyzer/frontend/src/app/services/stream-state.service.ts new file mode 100644 index 0000000..c0ba8bf --- /dev/null +++ b/media-analyzer/frontend/src/app/services/stream-state.service.ts @@ -0,0 +1,301 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, combineLatest, distinctUntilChanged, map } from 'rxjs'; +import { StreamService } from './stream.service'; +import { AnalysisService } from './analysis.service'; +import { Stream } from '../models/stream'; + +export interface StreamSession { + id: string; + streamKey: string; + hlsUrl: string; + sourceType: 'webcam' | 'rtmp'; + startedAt: Date; +} + +export interface StreamState { + isLoading: boolean; + currentSession: StreamSession | null; + availableStreams: Stream[]; + error: string | null; +} + +@Injectable({ + providedIn: 'root' +}) +export class StreamStateService { + private readonly SESSION_KEY = 'media_analyzer_session'; + + private state = new BehaviorSubject({ + isLoading: false, + currentSession: null, + availableStreams: [], + error: null + }); + + public state$ = this.state.asObservable(); + + // Derived observables for common use cases + public isStreaming$ = this.state$.pipe( + map(state => !!state.currentSession), + distinctUntilChanged() + ); + + public currentStreamUrl$ = this.state$.pipe( + map(state => state.currentSession?.hlsUrl || ''), + distinctUntilChanged() + ); + + public isLoading$ = this.state$.pipe( + map(state => state.isLoading), + distinctUntilChanged() + ); + + constructor( + private streamService: StreamService, + private analysisService: AnalysisService + ) { + this.loadAvailableStreams(); + this.restoreSession(); + // Auto-connect to active streams after loading + this.autoConnectToActiveStream(); + } + + // Stream Operations + async startWebcamStream(): Promise { + this.updateState({ isLoading: true, error: null }); + + try { + // Stop any existing stream first + await this.stopCurrentStream(); + + const stream = await this.streamService.startWebcamStream().toPromise(); + if (!stream) throw new Error('Failed to start webcam stream'); + + const session = this.createSession(stream.stream_key, stream.hls_playlist_url || '', 'webcam'); + await this.activateSession(session); + + } catch (error: any) { + this.handleError(error); + } finally { + this.updateState({ isLoading: false }); + } + } + + async startRtmpStream(streamKey: string): Promise { + this.updateState({ isLoading: true, error: null }); + + try { + // Stop any existing stream first + await this.stopCurrentStream(); + + const response = await this.streamService.startStream(streamKey).toPromise(); + if (!response) throw new Error('Failed to start RTMP stream'); + + const session = this.createSession(streamKey, response.hls_playlist_url, 'rtmp'); + await this.activateSession(session); + + } catch (error: any) { + this.handleError(error); + } finally { + this.updateState({ isLoading: false }); + } + } + + async stopCurrentStream(): Promise { + const currentSession = this.state.value.currentSession; + if (!currentSession) return; + + this.updateState({ isLoading: true, error: null }); + + try { + // Stop backend stream + await this.streamService.stopStream(currentSession.streamKey).toPromise(); + + // Disconnect analysis service + this.analysisService.disconnect(); + + // Clear session + this.clearSession(); + + } catch (error: any) { + this.handleError(error); + } finally { + this.updateState({ isLoading: false }); + this.loadAvailableStreams(); // Refresh stream list + } + } + + async createRtmpStream(name: string): Promise { + this.updateState({ isLoading: true, error: null }); + + try { + const stream = await this.streamService.createStream({ + name, + source_type: 'rtmp', + processing_mode: 'live' + }).toPromise(); + + if (stream) { + await this.loadAvailableStreams(); + } + } catch (error: any) { + this.handleError(error); + } finally { + this.updateState({ isLoading: false }); + } + } + + async deleteStream(streamId: number): Promise { + this.updateState({ isLoading: true, error: null }); + + try { + await this.streamService.deleteStream(streamId).toPromise(); + await this.loadAvailableStreams(); + } catch (error: any) { + this.handleError(error); + } finally { + this.updateState({ isLoading: false }); + } + } + + // Session Management + private createSession(streamKey: string, hlsUrl: string, sourceType: 'webcam' | 'rtmp'): StreamSession { + const session: StreamSession = { + id: this.generateSessionId(), + streamKey, + hlsUrl: this.normalizeHlsUrl(hlsUrl), + sourceType, + startedAt: new Date() + }; + + this.persistSession(session); + return session; + } + + private async activateSession(session: StreamSession): Promise { + // Update state first + this.updateState({ currentSession: session }); + + // Connect to analysis WebSocket with session ID + this.analysisService.connectToStream(session.streamKey, session.id); + + // Wait a moment for the stream to be ready + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Refresh available streams to show current status + await this.loadAvailableStreams(); + } + + private clearSession(): void { + localStorage.removeItem(this.SESSION_KEY); + this.updateState({ currentSession: null }); + } + + private persistSession(session: StreamSession): void { + localStorage.setItem(this.SESSION_KEY, JSON.stringify(session)); + } + + private restoreSession(): void { + try { + const stored = localStorage.getItem(this.SESSION_KEY); + if (stored) { + const session: StreamSession = JSON.parse(stored); + // Only restore if session is recent (within last hour) + const sessionAge = Date.now() - new Date(session.startedAt).getTime(); + if (sessionAge < 3600000) { // 1 hour + this.updateState({ currentSession: session }); + this.analysisService.connectToStream(session.streamKey, session.id); + } else { + this.clearSession(); + } + } + } catch (error) { + console.warn('Failed to restore session:', error); + this.clearSession(); + } + } + + // Auto-connection Logic + private autoConnectToActiveStream(): void { + // Wait a moment for streams to load + setTimeout(async () => { + const currentSession = this.state.value.currentSession; + if (currentSession) { + // Already have a session, don't auto-connect + return; + } + + // Look for active streams + const streams = this.state.value.availableStreams; + const activeStream = streams.find(s => s.status === 'active'); + + if (activeStream) { + console.log('Auto-connecting to active stream:', activeStream.stream_key); + // Create a session for the active stream + const session = this.createSession( + activeStream.stream_key, + activeStream.hls_playlist_url || '', + activeStream.source_type as 'webcam' | 'rtmp' + ); + this.updateState({ currentSession: session }); + // Connect analysis with the session ID + this.analysisService.connectToStream(session.streamKey, session.id); + } + }, 1000); + } + + // Utility Methods + private async loadAvailableStreams(): Promise { + try { + const response = await this.streamService.getStreams().toPromise(); + if (response) { + this.updateState({ availableStreams: response.streams }); + } + } catch (error) { + console.error('Failed to load streams:', error); + } + } + + private normalizeHlsUrl(hlsUrl: string): string { + // Convert backend URL to direct nginx URL via proxy + const filename = hlsUrl.split('/').pop() || ''; + return `/streaming/${filename}`; + } + + private generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + private updateState(partial: Partial): void { + const current = this.state.value; + this.state.next({ ...current, ...partial }); + } + + private handleError(error: any): void { + let errorMessage = 'An unknown error occurred'; + + if (error.status === 409) { + errorMessage = error.error?.error || 'Stream conflict - another stream may be active'; + } else if (error.error?.error) { + errorMessage = error.error.error; + } else if (error.message) { + errorMessage = error.message; + } + + console.error('Stream operation error:', error); + this.updateState({ error: errorMessage }); + } + + // Getters for current state + getCurrentSession(): StreamSession | null { + return this.state.value.currentSession; + } + + getAvailableStreams(): Stream[] { + return this.state.value.availableStreams; + } + + isCurrentlyStreaming(): boolean { + return !!this.state.value.currentSession; + } +} \ No newline at end of file diff --git a/media-analyzer/frontend/src/app/services/stream.service.ts b/media-analyzer/frontend/src/app/services/stream.service.ts index 741b292..7e56c91 100644 --- a/media-analyzer/frontend/src/app/services/stream.service.ts +++ b/media-analyzer/frontend/src/app/services/stream.service.ts @@ -31,4 +31,8 @@ export class StreamService { stopStream(streamKey: string): Observable<{message: string}> { return this.http.post<{message: string}>(`${this.apiUrl}/streams/${streamKey}/stop/`, {}); } + + deleteStream(streamId: number): Observable<{message: string}> { + return this.http.delete<{message: string}>(`${this.apiUrl}/streams/${streamId}/`); + } } diff --git a/media-analyzer/frontend/src/app/services/websocket.service.ts b/media-analyzer/frontend/src/app/services/websocket.service.ts index 11d1036..20b894f 100644 --- a/media-analyzer/frontend/src/app/services/websocket.service.ts +++ b/media-analyzer/frontend/src/app/services/websocket.service.ts @@ -35,7 +35,6 @@ export class WebsocketService { this.socket.onmessage = (event) => { try { const data = JSON.parse(event.data); - console.log('WebSocket message:', data); if (data.type === 'analysis_update') { this.analysisSubject.next(data.analysis); @@ -43,6 +42,9 @@ export class WebsocketService { data.analyses.forEach((analysis: Analysis) => { this.analysisSubject.next(analysis); }); + } else if (data.type === 'pong') { + } else { + console.log('❓ Unknown message type:', data.type); } } catch (error) { console.error('Error parsing WebSocket message:', error); @@ -72,6 +74,7 @@ export class WebsocketService { send(message: any) { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); + } else { } } @@ -82,10 +85,29 @@ export class WebsocketService { }); } - subscribe(streamId: string) { + subscribe(streamId: string, sessionId?: string) { this.currentStreamId = streamId; this.connect(); - this.send({ type: 'subscribe', stream_id: streamId }); + + // Wait for connection to be open before subscribing + const message: any = { type: 'subscribe', stream_id: streamId }; + if (sessionId) { + message.session_id = sessionId; + } + + if (this.socket?.readyState === WebSocket.OPEN) { + this.send(message); + } else { + // Wait for WebSocket to open, then subscribe + const checkAndSend = () => { + if (this.socket?.readyState === WebSocket.OPEN) { + this.send(message); + } else { + setTimeout(checkAndSend, 100); + } + }; + setTimeout(checkAndSend, 100); + } } unsubscribe() { diff --git a/media-analyzer/package.json b/media-analyzer/package.json new file mode 100644 index 0000000..8e7e3ca --- /dev/null +++ b/media-analyzer/package.json @@ -0,0 +1,17 @@ +{ + "name": "media-analyzer", + "version": "1.0.0", + "description": "Real-time video analysis platform", + "scripts": { + "dev:backend": "./start-backend-only.sh", + "dev:frontend": "./start-frontend-dev.sh", + "dev:full": "docker compose up -d", + "stop": "docker compose down", + "logs": "docker compose logs -f", + "build": "docker compose build", + "clean": "docker compose down -v --remove-orphans" + }, + "keywords": ["video", "streaming", "ai", "analysis", "docker"], + "author": "Media Analyzer Team", + "license": "MIT" +} \ No newline at end of file diff --git a/media-analyzer/start-backend-only.sh b/media-analyzer/start-backend-only.sh new file mode 100755 index 0000000..da12d3b --- /dev/null +++ b/media-analyzer/start-backend-only.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +echo "🚀 Starting backend services only (excluding frontend)..." +echo "Frontend will run locally with 'ng serve' for faster development" +echo "" + +# Start all services except frontend +docker compose up -d \ + postgres \ + redis \ + backend \ + celery-logo \ + celery-default \ + file-watcher \ + nginx-rtmp + +echo "" +echo "✅ Backend services started!" +echo "" +echo "📋 Services running:" +echo " - PostgreSQL: localhost:5432" +echo " - Redis: localhost:6379" +echo " - Backend API: localhost:8000" +echo " - RTMP Server: localhost:1935 (RTMP)" +echo " - HLS Streaming: localhost:8081 (HTTP)" +echo "" +echo "🔧 To start frontend development:" +echo " cd frontend" +echo " ng serve --proxy-config proxy.conf.json" +echo "" +echo "🌐 Frontend will be available at: http://localhost:4200" +echo "" +echo "📊 To check service status:" +echo " docker compose ps" +echo "" +echo "📜 To view logs:" +echo " docker compose logs -f [service-name]" \ No newline at end of file diff --git a/media-analyzer/start-frontend-dev.sh b/media-analyzer/start-frontend-dev.sh new file mode 100755 index 0000000..d5c6f3e --- /dev/null +++ b/media-analyzer/start-frontend-dev.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +echo "🖥️ Starting Angular development server..." +echo "" + +# Check if we're in the right directory +if [ ! -f "frontend/package.json" ]; then + echo "❌ Error: Run this script from the media-analyzer root directory" + exit 1 +fi + +# Check if backend services are running +if ! docker compose ps | grep -q "backend.*Up"; then + echo "⚠️ Warning: Backend services don't appear to be running" + echo " Run './start-backend-only.sh' first to start backend services" + echo "" +fi + +cd frontend + +# Check if node_modules exists +if [ ! -d "node_modules" ]; then + echo "📦 Installing npm dependencies..." + npm install + echo "" +fi + +echo "🔥 Starting Angular dev server with hot reload..." +echo " Frontend: http://localhost:4200" +echo " Backend API: http://localhost:8000 (proxied)" +echo " HLS Streaming: http://localhost:8081 (proxied)" +echo "" +echo "💡 Changes to TypeScript files will auto-reload!" +echo "" + +# Start Angular dev server with proxy +ng serve --proxy-config proxy.conf.json --host 0.0.0.0 --port 4200 \ No newline at end of file