first commit
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements and install
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
COPY start.sh .
|
||||
RUN chmod +x start.sh
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p config/thumbnails videos
|
||||
|
||||
# Expose ports (FastAPI: 8003, Streamlit: 8503)
|
||||
EXPOSE 8003 8503
|
||||
|
||||
# Set Environment Variables
|
||||
ENV CONFIG_DIR=/app/config
|
||||
ENV VIDEOS_DIR=/app/videos
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
CMD ["./start.sh"]
|
||||
@@ -0,0 +1,64 @@
|
||||
# Boys_Streaming 📺
|
||||
> A simplified, kid-friendly Chromecast controller for the bedtime ritual.
|
||||
|
||||
## 🎯 Overview
|
||||
Boys_Streaming is a Python-based Streamlit application designed to run in a Docker container. It interfaces with a NAS-mounted video library to cast a specific sequence of content to a Chromecast device: **3 Stories (< 15m) followed by 1 Calm Music track (> 2h).**
|
||||
|
||||
## 🛠 Tech Stack
|
||||
* **Language:** Python 3.11+
|
||||
* **Framework:** Streamlit (UI/UX)
|
||||
* **Casting Lib:** `pychromecast`
|
||||
* **Media Handling:** `ffmpeg-python` (for thumbnails and duration)
|
||||
* **Infrastructure:** Docker & Docker Compose
|
||||
* **Storage:** Local Bind Mounts (NAS source)
|
||||
|
||||
## 📂 Project Structure
|
||||
```text
|
||||
.
|
||||
├── app/
|
||||
│ ├── main.py # Streamlit entry point & UI
|
||||
│ ├── cast_logic.py # Chromecast discovery & playback functions
|
||||
│ ├── library.py # File scanning & metadata (thumbnail) logic
|
||||
│ ├── server.py # Local HTTP server to serve media
|
||||
│ └── utils.py # DevOps helpers (logging, env vars)
|
||||
├── config/ # Persistent storage mount (thumbnails, state)
|
||||
├── videos/ # Mounted NAS directory (Read-only)
|
||||
├── Dockerfile # Multi-stage build
|
||||
├── docker-compose.yml # Portainer-ready deployment
|
||||
└── GEMINI.md # Project soul & context
|
||||
```
|
||||
|
||||
## 📜 Development Rules
|
||||
### 🚀 DevOps Mindset
|
||||
* Clean, functional Python with strict type hints.
|
||||
* Robust logging for network discovery and Chromecast connectivity issues.
|
||||
* Environment-based configuration (use `.env` for local dev).
|
||||
|
||||
### 🧠 State Management
|
||||
* Use `st.session_state` to track the playlist selection and casting status.
|
||||
* Cache library scans (using `st.cache_data`) to mitigate NAS latency.
|
||||
* Persistence: Use the `/config` folder to track "already watched" or "random seed" state.
|
||||
|
||||
### 🎨 UI for Kids
|
||||
* **Thumbnails:** Primary selection tool. Large, high-quality previews.
|
||||
* **Large Buttons:** Touch-friendly and easy to read.
|
||||
* **Workflow:** Pick 3 Stories -> Auto-append Music -> Single "Cast" button.
|
||||
|
||||
### ⚖️ Logic Constraints
|
||||
* **"Stories":** Files strictly < 15 minutes.
|
||||
* **"Calm Music":** Files strictly > 2 hours.
|
||||
* **Sequence:** Always exactly 3 Stories + 1 Music (forced).
|
||||
|
||||
## 🚀 Commands
|
||||
| Task | Command |
|
||||
| :--- | :--- |
|
||||
| **Build** | `docker build -t boys_streaming:latest .` |
|
||||
| **Deploy** | `docker-compose up -d` |
|
||||
| **Logs** | `docker logs -f boys_streaming` |
|
||||
| **Stop** | `docker-compose down` |
|
||||
|
||||
## 🧠 Context & Edge Cases
|
||||
* **Networking:** Must use `network_mode: host` in Docker to allow mDNS/Chromecast discovery.
|
||||
* **Media Server:** Chromecast requires a URL. We must run a sidecar or internal HTTP server to serve `/videos`.
|
||||
* **Thumbnails:** Generate and store thumbnails in `/config/thumbnails` to avoid re-processing.
|
||||
* **Non-Negotiable:** The calm music video is the "exit strategy" and cannot be unselected.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
from fastapi import FastAPI, BackgroundTasks, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
import os
|
||||
from .library import scan_library
|
||||
from .cast_logic import cast_manager
|
||||
from .utils import get_logger, VIDEOS_DIR, THUMBNAILS_DIR, get_host_ip
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
app = FastAPI(title="Boys_Streaming API")
|
||||
|
||||
# Serve videos and thumbnails
|
||||
app.mount("/media", StaticFiles(directory=str(VIDEOS_DIR)), name="media")
|
||||
app.mount("/thumbs", StaticFiles(directory=str(THUMBNAILS_DIR)), name="thumbs")
|
||||
|
||||
class CastRequest(BaseModel):
|
||||
video_paths: List[str]
|
||||
|
||||
@app.get("/library")
|
||||
async def get_library():
|
||||
return scan_library()
|
||||
|
||||
@app.post("/cast")
|
||||
async def start_casting(request: CastRequest, background_tasks: BackgroundTasks):
|
||||
host_ip = get_host_ip()
|
||||
# Port is 8000 by default for uvicorn in our setup
|
||||
urls = [f"http://{host_ip}:8000/media/{path}" for path in request.video_paths]
|
||||
|
||||
logger.info(f"Received cast request for {len(urls)} videos")
|
||||
cast_manager.play_queue(urls)
|
||||
return {"status": "started", "queue_len": len(urls)}
|
||||
|
||||
@app.get("/status")
|
||||
async def get_status():
|
||||
return cast_manager.get_status()
|
||||
|
||||
@app.post("/stop")
|
||||
async def stop_casting():
|
||||
cast_manager.stop()
|
||||
return {"status": "stopped"}
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
# Pre-discover Chromecast on startup to be ready
|
||||
logger.info("Application starting up...")
|
||||
# Run discovery in background to not block startup
|
||||
import threading
|
||||
threading.Thread(target=cast_manager.discover, daemon=True).start()
|
||||
@@ -0,0 +1,97 @@
|
||||
import pychromecast
|
||||
from pychromecast.controllers.media import MediaController
|
||||
import time
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
from .utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class CastManager:
|
||||
def __init__(self):
|
||||
self.chromecast = None
|
||||
self.mc: Optional[MediaController] = None
|
||||
self.queue: List[str] = []
|
||||
self.current_index = -1
|
||||
self.is_playing = False
|
||||
self._lock = threading.Lock()
|
||||
self._discover_thread = None
|
||||
self.browser = None
|
||||
|
||||
def discover(self, timeout=5):
|
||||
logger.info("Discovering Chromecasts...")
|
||||
chromecasts, browser = pychromecast.get_chromecasts(timeout=timeout)
|
||||
self.browser = browser
|
||||
if chromecasts:
|
||||
# For simplicity, pick the first one found or allow selection later
|
||||
# In a real scenario, we might want to target a specific name
|
||||
self.chromecast = chromecasts[0]
|
||||
logger.info(f"Found Chromecast: {self.chromecast.name}")
|
||||
self.chromecast.wait()
|
||||
self.mc = self.chromecast.media_controller
|
||||
self.mc.register_status_listener(self)
|
||||
else:
|
||||
logger.error("No Chromecasts found.")
|
||||
|
||||
def play_queue(self, urls: List[str]):
|
||||
with self._lock:
|
||||
self.queue = urls
|
||||
self.current_index = 0
|
||||
self._play_current()
|
||||
|
||||
def _play_current(self):
|
||||
if 0 <= self.current_index < len(self.queue):
|
||||
url = self.queue[self.current_index]
|
||||
logger.info(f"Playing {self.current_index + 1}/{len(self.queue)}: {url}")
|
||||
if not self.chromecast:
|
||||
self.discover()
|
||||
|
||||
if self.chromecast:
|
||||
self.chromecast.wait()
|
||||
# Use generic video type, or try to guess from URL
|
||||
self.mc.play_media(url, 'video/mp4')
|
||||
self.is_playing = True
|
||||
else:
|
||||
logger.error("Cannot play: No Chromecast connected.")
|
||||
else:
|
||||
logger.info("Queue finished.")
|
||||
self.is_playing = False
|
||||
self.current_index = -1
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
if self.mc:
|
||||
self.mc.stop()
|
||||
self.queue = []
|
||||
self.current_index = -1
|
||||
self.is_playing = False
|
||||
|
||||
def new_media_status(self, status):
|
||||
"""Callback from pychromecast when media status changes."""
|
||||
logger.debug(f"Media status update: {status.player_state}")
|
||||
|
||||
# Check if the current video finished
|
||||
if status.player_state == "IDLE" and status.idle_reason == "FINISHED":
|
||||
logger.info("Current track finished. Moving to next...")
|
||||
with self._lock:
|
||||
if self.current_index != -1:
|
||||
self.current_index += 1
|
||||
# Small delay to ensure state transitions
|
||||
threading.Timer(1.0, self._play_current).start()
|
||||
|
||||
def get_status(self):
|
||||
status = {
|
||||
"is_playing": self.is_playing,
|
||||
"queue_len": len(self.queue),
|
||||
"current_index": self.current_index,
|
||||
}
|
||||
if self.mc and self.mc.status:
|
||||
status.update({
|
||||
"player_state": self.mc.status.player_state,
|
||||
"current_time": self.mc.status.current_time,
|
||||
"duration": self.mc.status.duration,
|
||||
})
|
||||
return status
|
||||
|
||||
# Singleton instance
|
||||
cast_manager = CastManager()
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import os
|
||||
import json
|
||||
import ffmpeg
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from .utils import get_logger, VIDEOS_DIR, THUMBNAILS_DIR, CONFIG_DIR
|
||||
|
||||
logger = get_logger(__name__)
|
||||
LIBRARY_CACHE = CONFIG_DIR / "library_cache.json"
|
||||
|
||||
class MediaItem:
|
||||
def __init__(self, path: Path, duration: float, thumbnail_path: str):
|
||||
self.path = path
|
||||
self.duration = duration
|
||||
self.thumbnail_path = thumbnail_path
|
||||
self.id = hashlib.md5(str(path).encode()).hexdigest()
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"filename": self.path.name,
|
||||
"path": str(self.path),
|
||||
"duration": self.duration,
|
||||
"thumbnail": self.thumbnail_path
|
||||
}
|
||||
|
||||
def get_video_duration(path: Path) -> float:
|
||||
try:
|
||||
probe = ffmpeg.probe(str(path))
|
||||
video_info = next(s for s in probe['streams'] if s['codec_type'] == 'video')
|
||||
return float(probe['format']['duration'])
|
||||
except Exception as e:
|
||||
logger.error(f"Error probing {path}: {e}")
|
||||
return 0.0
|
||||
|
||||
def generate_thumbnail(path: Path) -> Optional[str]:
|
||||
thumb_name = hashlib.md5(str(path).encode()).hexdigest() + ".jpg"
|
||||
thumb_path = THUMBNAILS_DIR / thumb_name
|
||||
|
||||
if thumb_path.exists():
|
||||
return thumb_name
|
||||
|
||||
try:
|
||||
# Get duration to pick a frame from the middle
|
||||
duration = get_video_duration(path)
|
||||
# Take a frame at 50% or 10s if duration is long
|
||||
time = min(10, duration / 2) if duration > 0 else 0
|
||||
|
||||
(
|
||||
ffmpeg
|
||||
.input(str(path), ss=time)
|
||||
.filter('scale', 640, -1)
|
||||
.output(str(thumb_path), vframes=1)
|
||||
.overwrite_output()
|
||||
.run(quiet=True)
|
||||
)
|
||||
return thumb_name
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating thumbnail for {path}: {e}")
|
||||
return None
|
||||
|
||||
def scan_library() -> Dict[str, List[Dict]]:
|
||||
logger.info(f"Scanning library in {VIDEOS_DIR}...")
|
||||
|
||||
cache = {}
|
||||
if LIBRARY_CACHE.exists():
|
||||
try:
|
||||
with open(LIBRARY_CACHE, 'r') as f:
|
||||
cache = json.load(f)
|
||||
except Exception:
|
||||
logger.warning("Could not read library cache.")
|
||||
|
||||
valid_extensions = {'.mp4', '.mkv', '.avi', '.mov', '.m4v'}
|
||||
stories = []
|
||||
music = []
|
||||
|
||||
for root, _, files in os.walk(VIDEOS_DIR):
|
||||
for file in files:
|
||||
path = Path(root) / file
|
||||
if path.suffix.lower() not in valid_extensions:
|
||||
continue
|
||||
|
||||
rel_path = str(path.relative_to(VIDEOS_DIR))
|
||||
|
||||
# Check cache
|
||||
item_data = cache.get(rel_path)
|
||||
if item_data and os.path.getmtime(path) == item_data.get('mtime'):
|
||||
# Check if thumbnail still exists
|
||||
if (THUMBNAILS_DIR / item_data['thumbnail']).exists():
|
||||
item = item_data
|
||||
else:
|
||||
item_data = None # Force re-gen
|
||||
|
||||
if not item_data or os.path.getmtime(path) != item_data.get('mtime'):
|
||||
duration = get_video_duration(path)
|
||||
thumb_name = generate_thumbnail(path)
|
||||
|
||||
if thumb_name:
|
||||
item = {
|
||||
"id": hashlib.md5(str(path).encode()).hexdigest(),
|
||||
"filename": path.name,
|
||||
"path": rel_path,
|
||||
"duration": duration,
|
||||
"thumbnail": thumb_name,
|
||||
"mtime": os.path.getmtime(path)
|
||||
}
|
||||
cache[rel_path] = item
|
||||
else:
|
||||
continue
|
||||
|
||||
# Categorize
|
||||
# Stories < 15m (900s)
|
||||
# Music > 2h (7200s)
|
||||
if item['duration'] < 900:
|
||||
stories.append(item)
|
||||
elif item['duration'] > 7200:
|
||||
music.append(item)
|
||||
|
||||
# Save cache
|
||||
with open(LIBRARY_CACHE, 'w') as f:
|
||||
json.dump(cache, f)
|
||||
|
||||
return {
|
||||
"stories": stories,
|
||||
"music": music
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import streamlit as st
|
||||
import httpx
|
||||
import time
|
||||
from pathlib import Path
|
||||
from utils import get_logger, get_host_ip
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Config - Use host IP so the browser can find the API
|
||||
HOST_IP = get_host_ip()
|
||||
API_URL = f"http://{HOST_IP}:8000"
|
||||
|
||||
st.set_page_config(
|
||||
page_title="Boys Streaming 📺",
|
||||
page_icon="📺",
|
||||
layout="wide"
|
||||
)
|
||||
|
||||
# Custom CSS for big buttons and clean look
|
||||
st.markdown("""
|
||||
<style>
|
||||
.stButton>button {
|
||||
width: 100%;
|
||||
height: 5em;
|
||||
font-size: 20px !important;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
.thumb-container {
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.thumb-selected {
|
||||
border: 5px solid #ff4b4b;
|
||||
}
|
||||
</style>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# Session State Initialization
|
||||
if 'selected_stories' not in st.session_state:
|
||||
st.session_state.selected_stories = []
|
||||
if 'library' not in st.session_state:
|
||||
st.session_state.library = None
|
||||
|
||||
def fetch_library():
|
||||
try:
|
||||
response = httpx.get(f"{API_URL}/library")
|
||||
if response.status_code == 200:
|
||||
st.session_state.library = response.json()
|
||||
else:
|
||||
st.error("Failed to fetch library from API.")
|
||||
except Exception as e:
|
||||
st.error(f"Could not connect to API: {e}")
|
||||
|
||||
def toggle_story(story_path):
|
||||
if story_path in st.session_state.selected_stories:
|
||||
st.session_state.selected_stories.remove(story_path)
|
||||
else:
|
||||
if len(st.session_state.selected_stories) < 3:
|
||||
st.session_state.selected_stories.append(story_path)
|
||||
else:
|
||||
st.warning("Only 3 stories allowed!")
|
||||
|
||||
def start_bedtime():
|
||||
if len(st.session_state.selected_stories) != 3:
|
||||
st.error("Please pick exactly 3 stories first!")
|
||||
return
|
||||
|
||||
# Auto-pick one music track
|
||||
if not st.session_state.library['music']:
|
||||
st.error("No music tracks found in library (> 2h)!")
|
||||
return
|
||||
|
||||
import random
|
||||
music_track = random.choice(st.session_state.library['music'])
|
||||
|
||||
playlist = st.session_state.selected_stories + [music_track['path']]
|
||||
|
||||
try:
|
||||
response = httpx.post(f"{API_URL}/cast", json={"video_paths": playlist})
|
||||
if response.status_code == 200:
|
||||
st.success("Bedtime ritual started! Casting to Chromecast...")
|
||||
else:
|
||||
st.error("Failed to start casting.")
|
||||
except Exception as e:
|
||||
st.error(f"Error starting bedtime: {e}")
|
||||
|
||||
# --- UI Layout ---
|
||||
st.title("📺 Boys Streaming - Bedtime!")
|
||||
|
||||
if st.button("🔄 Refresh Library"):
|
||||
fetch_library()
|
||||
|
||||
if not st.session_state.library:
|
||||
fetch_library()
|
||||
|
||||
if st.session_state.library:
|
||||
stories = st.session_state.library.get('stories', [])
|
||||
|
||||
st.subheader(f"📖 Pick 3 Stories ({len(st.session_state.selected_stories)}/3)")
|
||||
|
||||
# Display stories in a grid
|
||||
cols_per_row = 4
|
||||
for i in range(0, len(stories), cols_per_row):
|
||||
cols = st.columns(cols_per_row)
|
||||
for j, col in enumerate(cols):
|
||||
if i + j < len(stories):
|
||||
story = stories[i + j]
|
||||
is_selected = story['path'] in st.session_state.selected_stories
|
||||
|
||||
with col:
|
||||
# Use a unique key for each button
|
||||
thumb_url = f"{API_URL}/thumbs/{story['thumbnail']}"
|
||||
st.image(thumb_url, use_container_width=True)
|
||||
|
||||
label = f"✅ {story['filename']}" if is_selected else story['filename']
|
||||
if st.button(label, key=f"btn_{story['id']}"):
|
||||
toggle_story(story['path'])
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# Action area
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
if st.button("🚀 START BEDTIME", type="primary", use_container_width=True):
|
||||
start_bedtime()
|
||||
with col2:
|
||||
if st.button("🛑 STOP CASTING", use_container_width=True):
|
||||
httpx.post(f"{API_URL}/stop")
|
||||
st.warning("Casting stopped.")
|
||||
|
||||
# Status Monitor
|
||||
st.divider()
|
||||
status_placeholder = st.empty()
|
||||
|
||||
# Auto-refresh status
|
||||
try:
|
||||
status_resp = httpx.get(f"{API_URL}/status")
|
||||
if status_resp.status_code == 200:
|
||||
status = status_resp.json()
|
||||
if status.get('is_playing'):
|
||||
status_placeholder.info(f"Currently Playing item index: {status.get('current_index', 0) + 1} of {status.get('queue_len', 0)}")
|
||||
else:
|
||||
status_placeholder.text("Chromecast is idle.")
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
st.info("Waiting for library scan...")
|
||||
time.sleep(2)
|
||||
st.rerun()
|
||||
@@ -0,0 +1,45 @@
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Path configuration
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
CONFIG_DIR = Path(os.getenv("CONFIG_DIR", BASE_DIR / "config"))
|
||||
VIDEOS_DIR = Path(os.getenv("VIDEOS_DIR", BASE_DIR / "videos"))
|
||||
THUMBNAILS_DIR = CONFIG_DIR / "thumbnails"
|
||||
|
||||
# Ensure directories exist
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Logging configuration
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.FileHandler(CONFIG_DIR / "app.log")
|
||||
]
|
||||
)
|
||||
|
||||
def get_logger(name: str):
|
||||
return logging.getLogger(name)
|
||||
|
||||
def get_host_ip():
|
||||
"""Returns the host IP to be used for Chromecast.
|
||||
In host networking mode, we can use a socket to find the primary interface IP."""
|
||||
import socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
# doesn't even have to be reachable
|
||||
s.connect(('10.255.255.255', 1))
|
||||
IP = s.getsockname()[0]
|
||||
except Exception:
|
||||
IP = '127.0.0.1'
|
||||
finally:
|
||||
s.close()
|
||||
return IP
|
||||
@@ -0,0 +1,19 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
boys_streaming:
|
||||
build: .
|
||||
container_name: boys_streaming
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./videos:/app/videos
|
||||
environment:
|
||||
- VIDEOS_DIR=/app/videos
|
||||
- CONFIG_DIR=/app/config
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8501/_stcore/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -0,0 +1,9 @@
|
||||
streamlit
|
||||
fastapi
|
||||
uvicorn
|
||||
pychromecast
|
||||
ffmpeg-python
|
||||
pydantic
|
||||
httpx
|
||||
python-multipart
|
||||
python-dotenv
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start FastAPI backend in the background
|
||||
echo "Starting FastAPI backend..."
|
||||
uvicorn app.api:app --host 0.0.0.0 --port 8003 &
|
||||
|
||||
# Start Streamlit frontend
|
||||
echo "Starting Streamlit frontend..."
|
||||
streamlit run app/main.py --server.port 8503 --server.address 0.0.0.0
|
||||
|
||||
# Wait for any process to exit
|
||||
wait -n
|
||||
|
||||
# Exit with status of process that exited first
|
||||
exit $?
|
||||
Reference in New Issue
Block a user