Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74e4141236 | ||
|
|
378fecbfc0 | ||
|
|
ff77db3835 | ||
|
|
414ffe075a | ||
|
|
a391333c52 |
@@ -1,69 +0,0 @@
|
|||||||
name: Docker Build & Push
|
|
||||||
|
|
||||||
# Trigger the workflow on pushes to the main branch
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
tags:
|
|
||||||
- 'v*' # Also run on tags starting with 'v' (e.g., v1.0.0)
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-publish:
|
|
||||||
# Use a standard Linux runner
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
# Define variables for easy configuration
|
|
||||||
env:
|
|
||||||
# Replace 'gitea.local:3000' with your actual Gitea host and port (if non-standard)
|
|
||||||
REGISTRY: git.vpn.cusano.net
|
|
||||||
# Image name will be the Gitea repository path (e.g., username/repo-name)
|
|
||||||
IMAGE_NAME: ${{ gitea.repository }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# Set up QEMU for multi-architecture building (optional, but highly recommended)
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
# Set up Docker Buildx (required for multi-platform and robust builds)
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to the Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
# Use your Gitea username for the login
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
# Use a Personal Access Token (PAT) stored as a Gitea Action secret
|
|
||||||
# You MUST create a GITEA_TOKEN secret with 'write:packages' scope.
|
|
||||||
password: ${{ secrets.BUILD_TOKEN }}
|
|
||||||
|
|
||||||
# Define image tags based on branch/tag information
|
|
||||||
- name: Determine Image Tags
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=sha,prefix=,suffix=,enable=true,params=7
|
|
||||||
type=ref,event=branch
|
|
||||||
type=semver,pattern=v{{version}}
|
|
||||||
|
|
||||||
# Build and Push the Docker image
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
# Enable pushing to the registry
|
|
||||||
push: true
|
|
||||||
# Select platforms to build for (e.g., standard Linux amd64)
|
|
||||||
platforms: linux/amd64, linux/arm64
|
|
||||||
# Use the tags generated in the previous step
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
# Use the Dockerfile in the current directory (default: Dockerfile)
|
|
||||||
file: ./Dockerfile
|
|
||||||
# Set the build context
|
|
||||||
context: .
|
|
||||||
@@ -4,6 +4,11 @@ FROM python:3.13-slim
|
|||||||
# Set the working directory in the container
|
# Set the working directory in the container
|
||||||
WORKDIR /code
|
WORKDIR /code
|
||||||
|
|
||||||
|
# Install FFMPEG
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y ffmpeg --no-install-recommends && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy the requirements file into the container
|
# Copy the requirements file into the container
|
||||||
COPY ./requirements.txt /code/requirements.txt
|
COPY ./requirements.txt /code/requirements.txt
|
||||||
|
|
||||||
@@ -12,6 +17,7 @@ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
|||||||
|
|
||||||
# Copy the application code into the container
|
# Copy the application code into the container
|
||||||
COPY ./app /code/app
|
COPY ./app /code/app
|
||||||
|
COPY ./.env /code/.env
|
||||||
|
|
||||||
# Expose port 80 to the outside world
|
# Expose port 80 to the outside world
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
1
Makefile
1
Makefile
@@ -9,7 +9,6 @@ build:
|
|||||||
run: build
|
run: build
|
||||||
docker run --rm -it \
|
docker run --rm -it \
|
||||||
-v /mnt/shadowplays/TWIMG-Eligible:/videos:ro \
|
-v /mnt/shadowplays/TWIMG-Eligible:/videos:ro \
|
||||||
-v .env:/code/.env:re \
|
|
||||||
--name $(CONTAINER_NAME) \
|
--name $(CONTAINER_NAME) \
|
||||||
-p 8000:8000 \
|
-p 8000:8000 \
|
||||||
$(IMAGE_NAME)
|
$(IMAGE_NAME)
|
||||||
@@ -33,9 +33,8 @@ class Video(BaseModel):
|
|||||||
|
|
||||||
class VoteCreate(BaseModel):
|
class VoteCreate(BaseModel):
|
||||||
decision: str
|
decision: str
|
||||||
reason: List[str]
|
reason: str
|
||||||
recommended_game: Optional[str] = None
|
recommended_game: Optional[str] = None
|
||||||
timestamp: Optional[float] = None
|
|
||||||
|
|
||||||
class Vote(BaseModel):
|
class Vote(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import asyncio
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from typing import List
|
from typing import List
|
||||||
from ..models import Vote, VoteCreate, Video
|
from ..models import Vote, VoteCreate, Video
|
||||||
from ..security import is_admin, is_user
|
from ..security import is_admin, is_user
|
||||||
@@ -16,14 +17,14 @@ VIDEO_DIRECTORY = os.environ.get("VIDEO_DIRECTORY")
|
|||||||
async def scan_videos_directory(current_user: dict = Depends(is_admin)):
|
async def scan_videos_directory(current_user: dict = Depends(is_admin)):
|
||||||
"""
|
"""
|
||||||
Scans the video directory for new clips and adds them to the database.
|
Scans the video directory for new clips and adds them to the database.
|
||||||
Handles structures:
|
Handles structures:
|
||||||
- /videos/person/game/clip[.mp4|.mkv]
|
- /videos/person/game/clip[.mp4|.mkv]
|
||||||
- /videos/person/clip[.mp4|.mkv]
|
- /videos/person/clip[.mp4|.mkv]
|
||||||
"""
|
"""
|
||||||
new_videos_count = 0
|
new_videos_count = 0
|
||||||
if not VIDEO_DIRECTORY or not os.path.isdir(VIDEO_DIRECTORY):
|
if not VIDEO_DIRECTORY or not os.path.isdir(VIDEO_DIRECTORY):
|
||||||
raise HTTPException(status_code=500, detail="Video directory not found or not configured on server")
|
raise HTTPException(status_code=500, detail="Video directory not found or not configured on server")
|
||||||
|
|
||||||
db = get_db()
|
db = get_db()
|
||||||
videos_collection = db.collection('videos')
|
videos_collection = db.collection('videos')
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ async def scan_videos_directory(current_user: dict = Depends(is_admin)):
|
|||||||
for item_name in os.listdir(person_path):
|
for item_name in os.listdir(person_path):
|
||||||
item_path = os.path.join(person_path, item_name)
|
item_path = os.path.join(person_path, item_name)
|
||||||
game_name = None
|
game_name = None
|
||||||
|
|
||||||
# Structure 1: /person/game/clip.mkv
|
# Structure 1: /person/game/clip.mkv
|
||||||
if os.path.isdir(item_path):
|
if os.path.isdir(item_path):
|
||||||
game_name = item_name
|
game_name = item_name
|
||||||
@@ -47,7 +48,7 @@ async def scan_videos_directory(current_user: dict = Depends(is_admin)):
|
|||||||
if os.path.isfile(clip_path) and clip_name.lower().endswith(('.mp4', '.mkv')):
|
if os.path.isfile(clip_path) and clip_name.lower().endswith(('.mp4', '.mkv')):
|
||||||
# Construct relative path for DB: person/game/clip.mkv
|
# Construct relative path for DB: person/game/clip.mkv
|
||||||
relative_path = os.path.join(person_name, game_name, clip_name)
|
relative_path = os.path.join(person_name, game_name, clip_name)
|
||||||
|
|
||||||
# Check for duplicates
|
# Check for duplicates
|
||||||
existing = videos_collection.where('file_path', '==', relative_path).limit(1).get()
|
existing = videos_collection.where('file_path', '==', relative_path).limit(1).get()
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -61,7 +62,7 @@ async def scan_videos_directory(current_user: dict = Depends(is_admin)):
|
|||||||
}
|
}
|
||||||
new_video_ref.set(video_data)
|
new_video_ref.set(video_data)
|
||||||
new_videos_count += 1
|
new_videos_count += 1
|
||||||
|
|
||||||
# Structure 2: /person/clip.mp4
|
# Structure 2: /person/clip.mp4
|
||||||
elif os.path.isfile(item_path) and item_name.lower().endswith(('.mp4', '.mkv')):
|
elif os.path.isfile(item_path) and item_name.lower().endswith(('.mp4', '.mkv')):
|
||||||
clip_name = item_name
|
clip_name = item_name
|
||||||
@@ -88,18 +89,21 @@ async def scan_videos_directory(current_user: dict = Depends(is_admin)):
|
|||||||
@router.get("/vote-next", response_model=Video)
|
@router.get("/vote-next", response_model=Video)
|
||||||
async def get_random_unvoted_video(current_user: dict = Depends(is_user)):
|
async def get_random_unvoted_video(current_user: dict = Depends(is_user)):
|
||||||
"""
|
"""
|
||||||
Retrieves a random, unvoted video document from Firestore.
|
Retrieves a random, unvoted video document from Firestore efficiently using a small batch approach.
|
||||||
"""
|
"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
videos_stream = db.collection('videos').where('has_been_voted', '==', False).limit(5).stream()
|
|
||||||
unvoted_videos = [doc.to_dict() for doc in videos_stream]
|
limit_count = 5
|
||||||
|
unvoted_videos_query = db.collection('videos').where('has_been_voted', '==', False).limit(limit_count)
|
||||||
|
unvoted_videos = [doc.to_dict() for doc in unvoted_videos_query.stream()]
|
||||||
|
|
||||||
if not unvoted_videos:
|
if not unvoted_videos:
|
||||||
raise HTTPException(status_code=404, detail="No more videos to vote on!")
|
raise HTTPException(status_code=404, detail="No more videos to vote on!")
|
||||||
|
|
||||||
random_video = random.choice(unvoted_videos)
|
# Randomly select one video from the fetched batch
|
||||||
|
random_video_data = random.choice(unvoted_videos)
|
||||||
return Video(**random_video)
|
|
||||||
|
return Video(**random_video_data)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{video_id}/vote", status_code=status.HTTP_201_CREATED)
|
@router.post("/{video_id}/vote", status_code=status.HTTP_201_CREATED)
|
||||||
@@ -134,7 +138,7 @@ async def submit_vote(video_id: str, vote_data: VoteCreate, current_user: dict =
|
|||||||
@router.get("/{video_id}/stream")
|
@router.get("/{video_id}/stream")
|
||||||
async def stream_video(video_id: str, current_user: dict = Depends(is_user)):
|
async def stream_video(video_id: str, current_user: dict = Depends(is_user)):
|
||||||
"""
|
"""
|
||||||
Streams a video file from the server based on its Firestore document.
|
Streams a video file from the server with on-the-fly transcoding and chunking using FFmpeg.
|
||||||
"""
|
"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
video_doc = db.collection('videos').document(video_id).get()
|
video_doc = db.collection('videos').document(video_id).get()
|
||||||
@@ -142,12 +146,72 @@ async def stream_video(video_id: str, current_user: dict = Depends(is_user)):
|
|||||||
if not video_doc.exists:
|
if not video_doc.exists:
|
||||||
raise HTTPException(status_code=404, detail="Video not found")
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
|
|
||||||
full_path = os.path.join(VIDEO_DIRECTORY, video_doc.to_dict()["file_path"])
|
video_data = video_doc.to_dict()
|
||||||
print(full_path)
|
full_path = os.path.join(VIDEO_DIRECTORY, video_data["file_path"])
|
||||||
|
|
||||||
if not os.path.exists(full_path):
|
if not os.path.exists(full_path):
|
||||||
raise HTTPException(status_code=404, detail="Video file not found on disk")
|
raise HTTPException(status_code=404, detail="Video file not found on disk")
|
||||||
|
|
||||||
return FileResponse(full_path, media_type="video/mp4")
|
async def generate_video_chunks():
|
||||||
|
ffmpeg_command = [
|
||||||
|
'ffmpeg',
|
||||||
|
'-i', full_path,
|
||||||
|
'-map', '0:v:0',
|
||||||
|
'-map', '0:a?',
|
||||||
|
'-movflags', 'frag_keyframe+empty_moov+omit_tfhd_offset+frag_discont+default_base_moof',
|
||||||
|
'-f', 'mp4',
|
||||||
|
'-codec:v', 'libx264',
|
||||||
|
'-preset', 'ultrafast',
|
||||||
|
'-crf', '28',
|
||||||
|
'-codec:a', 'aac',
|
||||||
|
'-filter_complex', 'amerge',
|
||||||
|
'-ac', '2',
|
||||||
|
'-b:a', '128k',
|
||||||
|
'-loglevel', 'warning',
|
||||||
|
'-hide_banner',
|
||||||
|
'pipe:1'
|
||||||
|
]
|
||||||
|
|
||||||
|
process = None
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*ffmpeg_command,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE # Capture stderr for potential error logging
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read chunks from stdout and yield them
|
||||||
|
while True:
|
||||||
|
chunk = await process.stdout.read(8192) # Read 8KB chunks
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# This exception is raised if the client disconnects before the stream is complete
|
||||||
|
print(f"[{video_id}] Streaming cancelled by client.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{video_id}] Error during video streaming: {e}")
|
||||||
|
if process and process.returncode is None:
|
||||||
|
# If FFmpeg process is still running, try to read any remaining stderr
|
||||||
|
stderr_output = await process.stderr.read()
|
||||||
|
print(f"[{video_id}] FFmpeg stderr: {stderr_output.decode()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Error processing video stream.")
|
||||||
|
finally:
|
||||||
|
if process and process.returncode is None:
|
||||||
|
# Terminate the FFmpeg process if it's still running
|
||||||
|
print(f"[{video_id}] Terminating FFmpeg process.")
|
||||||
|
process.terminate() # Send SIGTERM
|
||||||
|
await process.wait() # Wait for process to exit
|
||||||
|
stderr_output = await process.stderr.read()
|
||||||
|
elif process and process.returncode != 0:
|
||||||
|
# If FFmpeg exited with an error code
|
||||||
|
stderr_output = await process.stderr.read()
|
||||||
|
print(f"[{video_id}] FFmpeg exited with error code {process.returncode}: {stderr_output.decode()}")
|
||||||
|
# You might want to log this error more formally or send a more specific client error
|
||||||
|
print(f"[{video_id}] Video streaming finished or terminated.")
|
||||||
|
|
||||||
|
return StreamingResponse(generate_video_chunks(), media_type="video/mp4")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/votes", response_model=List[Vote])
|
@router.get("/votes", response_model=List[Vote])
|
||||||
|
|||||||
Reference in New Issue
Block a user