Compare commits

9 Commits
dev ... master

Author SHA1 Message Date
Logan Cusano
6a239f3820 Add arm to builds
All checks were successful
Docker Build & Push / build-and-publish (push) Successful in 3m50s
2025-11-19 03:35:43 -05:00
Logan Cusano
5e885dab54 Remove env from the dockerfile and add it to the makefile
All checks were successful
Docker Build & Push / build-and-publish (push) Successful in 42s
2025-11-19 03:25:39 -05:00
Logan Cusano
edf59cb9df Fix registry
Some checks failed
Docker Build & Push / build-and-publish (push) Failing after 21s
2025-11-19 03:23:18 -05:00
Logan Cusano
02b5e7f558 Update secret name
Some checks failed
Docker Build & Push / build-and-publish (push) Failing after 18s
2025-11-19 03:19:11 -05:00
Logan Cusano
5f1b22075a Add build
Some checks failed
Docker Build & Push / build-and-publish (push) Failing after 2m24s
2025-11-19 03:11:44 -05:00
Logan Cusano
5cf67c67d7 fix typo 2025-08-02 02:44:51 -04:00
Logan Cusano
466bc08324 Update model for new UI 2025-08-02 02:44:25 -04:00
Logan Cusano
d39413d0ef Fix video selection 2025-08-02 02:23:55 -04:00
Logan Cusano
1a5e460029 Fix bug in naming 2025-08-02 02:21:33 -04:00
5 changed files with 90 additions and 89 deletions

View File

@@ -0,0 +1,69 @@
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: .

View File

@@ -4,11 +4,6 @@ FROM python:3.13-slim
# Set the working directory in the container
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 ./requirements.txt /code/requirements.txt
@@ -17,7 +12,6 @@ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
# Copy the application code into the container
COPY ./app /code/app
COPY ./.env /code/.env
# Expose port 80 to the outside world
EXPOSE 80

View File

@@ -9,6 +9,7 @@ build:
run: build
docker run --rm -it \
-v /mnt/shadowplays/TWIMG-Eligible:/videos:ro \
-v .env:/code/.env:re \
--name $(CONTAINER_NAME) \
-p 8000:8000 \
$(IMAGE_NAME)

View File

@@ -33,8 +33,9 @@ class Video(BaseModel):
class VoteCreate(BaseModel):
decision: str
reason: str
reason: List[str]
recommended_game: Optional[str] = None
timestamp: Optional[float] = None
class Vote(BaseModel):
id: str

View File

@@ -1,8 +1,7 @@
import os
import random
import asyncio
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.responses import FileResponse
from typing import List
from ..models import Vote, VoteCreate, Video
from ..security import is_admin, is_user
@@ -17,14 +16,14 @@ VIDEO_DIRECTORY = os.environ.get("VIDEO_DIRECTORY")
async def scan_videos_directory(current_user: dict = Depends(is_admin)):
"""
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/clip[.mp4|.mkv]
"""
new_videos_count = 0
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")
db = get_db()
videos_collection = db.collection('videos')
@@ -38,7 +37,7 @@ async def scan_videos_directory(current_user: dict = Depends(is_admin)):
for item_name in os.listdir(person_path):
item_path = os.path.join(person_path, item_name)
game_name = None
# Structure 1: /person/game/clip.mkv
if os.path.isdir(item_path):
game_name = item_name
@@ -48,7 +47,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')):
# Construct relative path for DB: person/game/clip.mkv
relative_path = os.path.join(person_name, game_name, clip_name)
# Check for duplicates
existing = videos_collection.where('file_path', '==', relative_path).limit(1).get()
if not existing:
@@ -62,7 +61,7 @@ async def scan_videos_directory(current_user: dict = Depends(is_admin)):
}
new_video_ref.set(video_data)
new_videos_count += 1
# Structure 2: /person/clip.mp4
elif os.path.isfile(item_path) and item_name.lower().endswith(('.mp4', '.mkv')):
clip_name = item_name
@@ -89,21 +88,18 @@ async def scan_videos_directory(current_user: dict = Depends(is_admin)):
@router.get("/vote-next", response_model=Video)
async def get_random_unvoted_video(current_user: dict = Depends(is_user)):
"""
Retrieves a random, unvoted video document from Firestore efficiently using a small batch approach.
Retrieves a random, unvoted video document from Firestore.
"""
db = get_db()
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()]
videos_stream = db.collection('videos').where('has_been_voted', '==', False).limit(5).stream()
unvoted_videos = [doc.to_dict() for doc in videos_stream]
if not unvoted_videos:
raise HTTPException(status_code=404, detail="No more videos to vote on!")
# Randomly select one video from the fetched batch
random_video_data = random.choice(unvoted_videos)
return Video(**random_video_data)
random_video = random.choice(unvoted_videos)
return Video(**random_video)
@router.post("/{video_id}/vote", status_code=status.HTTP_201_CREATED)
@@ -138,7 +134,7 @@ async def submit_vote(video_id: str, vote_data: VoteCreate, current_user: dict =
@router.get("/{video_id}/stream")
async def stream_video(video_id: str, current_user: dict = Depends(is_user)):
"""
Streams a video file from the server with on-the-fly transcoding and chunking using FFmpeg.
Streams a video file from the server based on its Firestore document.
"""
db = get_db()
video_doc = db.collection('videos').document(video_id).get()
@@ -146,72 +142,12 @@ async def stream_video(video_id: str, current_user: dict = Depends(is_user)):
if not video_doc.exists:
raise HTTPException(status_code=404, detail="Video not found")
video_data = video_doc.to_dict()
full_path = os.path.join(VIDEO_DIRECTORY, video_data["file_path"])
full_path = os.path.join(VIDEO_DIRECTORY, video_doc.to_dict()["file_path"])
print(full_path)
if not os.path.exists(full_path):
raise HTTPException(status_code=404, detail="Video file not found on disk")
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")
return FileResponse(full_path, media_type="video/mp4")
@router.get("/votes", response_model=List[Vote])