Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ COPY --chown=app:app ./alembic.ini /app/alembic.ini
COPY --chown=app:app ./backend /app/backend
COPY --chown=app:app --from=node_builder /app/exported /app/frontend/exported
COPY --chown=app:app --from=python_builder /opt/python /opt/python
COPY --from=ghcr.io/arabcoders/jellyfin-ffmpeg /usr/bin/ffmpeg /usr/bin/ffmpeg
COPY --from=ghcr.io/arabcoders/jellyfin-ffmpeg /usr/bin/ffprobe /usr/bin/ffprobe

# Install fbc CLI script
Expand Down
4 changes: 3 additions & 1 deletion backend/app/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ async def _remove_stale_uploads(session: AsyncSession) -> int:
total_removed = 0

stmt: Select[tuple[models.UploadRecord]] = (
select(models.UploadRecord).where(models.UploadRecord.status != "completed").where(models.UploadRecord.created_at < cutoff_naive)
select(models.UploadRecord)
.where(models.UploadRecord.status.in_(["pending", "in_progress"]))
.where(models.UploadRecord.created_at < cutoff_naive)
)
res: Result[tuple[models.UploadRecord]] = await session.execute(stmt)

Expand Down
159 changes: 137 additions & 22 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import os
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from typing import Annotated

from fastapi import FastAPI, HTTPException, Request, status
from fastapi import FastAPI, Header, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.templating import Jinja2Templates

from backend.app import version

Expand All @@ -16,6 +18,7 @@
from .config import settings
from .db import engine
from .migrate import run_migrations
from .postprocessing import ProcessingQueue


def create_app() -> FastAPI:
Expand All @@ -24,6 +27,8 @@ def create_app() -> FastAPI:
Path(settings.storage_path).mkdir(parents=True, exist_ok=True)
Path(settings.config_path).mkdir(parents=True, exist_ok=True)

templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))

@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Expand All @@ -36,11 +41,17 @@ async def lifespan(app: FastAPI):
if not settings.skip_migrations:
await run_in_threadpool(run_migrations)

queue = ProcessingQueue()
queue.start_worker()
app.state.processing_queue = queue

if not settings.skip_cleanup:
app.state.cleanup_task = asyncio.create_task(start_cleanup_loop(), name="cleanup_loop")

yield

await queue.stop_worker()

if not settings.skip_cleanup:
task: asyncio.Task | None = getattr(app.state, "cleanup_task", None)
if task:
Expand Down Expand Up @@ -139,39 +150,143 @@ def app_version() -> dict[str, str]:
app.include_router(getattr(routers, _route).router)

frontend_dir: Path = Path(settings.frontend_export_path).resolve()
if frontend_dir.exists():

@app.get("/{full_path:path}", name="static_frontend")
async def frontend(full_path: str) -> FileResponse:
"""
Serve static frontend files.
@app.get("/f/{token}", name="share_page")
@app.get("/f/{token}/")
async def share_page(token: str, request: Request, user_agent: Annotated[str | None, Header()] = None):
"""Handle /f/{token} with bot detection for embed preview."""
from sqlalchemy import select

from backend.app import models, utils
from backend.app.db import get_db

user_agent_lower: str = (user_agent or "").lower()
is_bot = any(bot in user_agent_lower for bot in ["discordbot", "twitterbot", "slackbot", "facebookexternalhit", "whatsapp"])

if is_bot and settings.allow_public_downloads:
async for db in get_db():
stmt = select(models.UploadToken).where((models.UploadToken.token == token) | (models.UploadToken.download_token == token))
result = await db.execute(stmt)
token_row = result.scalar_one_or_none()

if token_row:
uploads_stmt = (
select(models.UploadRecord)
.where(models.UploadRecord.token_id == token_row.id, models.UploadRecord.status == "completed")
.order_by(models.UploadRecord.created_at.desc())
)
uploads_result = await db.execute(uploads_stmt)
uploads = uploads_result.scalars().all()

media_files = [u for u in uploads if u.mimetype and utils.is_multimedia(u.mimetype)]

if media_files:
first_media = media_files[0]

is_video = first_media.mimetype.startswith("video/")
ffprobe_data = None
if first_media.meta_data and isinstance(first_media.meta_data, dict):
ffprobe_data = first_media.meta_data.get("ffprobe")

video_metadata = utils.extract_video_metadata(ffprobe_data)

other_files = [
{
"name": u.filename or "Unknown",
"size": utils.format_file_size(u.size_bytes) if u.size_bytes else "Unknown",
}
for u in uploads
if u.public_id != first_media.public_id
]

media_url = str(
request.url_for("download_file", download_token=token_row.download_token, upload_id=first_media.public_id)
)
share_url = str(request.url_for("share_page", token=token))

is_video = first_media.mimetype.startswith("video/")
is_audio = first_media.mimetype.startswith("audio/")

context = {
"request": request,
"title": first_media.filename or "Shared Media",
"description": f"{len(uploads)} file(s) shared" if len(uploads) > 1 else "Shared file",
"og_type": "video.other" if is_video else "music.song",
"share_url": share_url,
"media_url": media_url,
"mime_type": first_media.mimetype,
"is_video": is_video,
"is_audio": is_audio,
"width": video_metadata.get("width"),
"height": video_metadata.get("height"),
"duration": video_metadata.get("duration"),
"duration_formatted": utils.format_duration(video_metadata["duration"])
if video_metadata.get("duration")
else None,
"file_size": utils.format_file_size(first_media.size_bytes) if first_media.size_bytes else None,
"other_files": other_files,
}

return templates.TemplateResponse(
request=request,
name="share_preview.html",
context=context,
status_code=status.HTTP_200_OK,
)

if frontend_dir.exists():
index_file = frontend_dir / "index.html"
if index_file.exists():
return FileResponse(index_file, status_code=status.HTTP_200_OK)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

Args:
full_path (str): The requested file path.
@app.get("/t/{token}", name="upload_page")
@app.get("/t/{token}/")
async def upload_page(token: str, request: Request, user_agent: Annotated[str | None, Header()] = None):
"""Handle /t/{token} with bot detection for embed preview."""
if not frontend_dir.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

Returns:
FileResponse: The response containing the requested file.
index_file = frontend_dir / "index.html"
if not index_file.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

"""
if full_path.startswith("api/"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return FileResponse(index_file, status_code=status.HTTP_200_OK)

@app.get("/{full_path:path}", name="static_frontend")
async def frontend(full_path: str) -> FileResponse:
"""
Serve static frontend files.

Args:
full_path (str): The requested file path.

if not full_path or "/" == full_path:
index_file: Path = frontend_dir / "index.html"
if index_file.exists():
return FileResponse(index_file, status_code=status.HTTP_200_OK)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
Returns:
FileResponse: The response containing the requested file.

"""
if full_path.startswith("api/"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

requested_file: Path = frontend_dir / full_path
if requested_file.is_file():
return FileResponse(requested_file, status_code=status.HTTP_200_OK)
if not frontend_dir.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

if not full_path or "/" == full_path:
index_file: Path = frontend_dir / "index.html"
if index_file.exists():
return FileResponse(index_file, status_code=status.HTTP_200_OK)

raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

requested_file: Path = frontend_dir / full_path
if requested_file.is_file():
return FileResponse(requested_file, status_code=status.HTTP_200_OK)

index_file: Path = frontend_dir / "index.html"
if index_file.exists():
return FileResponse(index_file, status_code=status.HTTP_200_OK)

raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

return app


Expand Down
6 changes: 3 additions & 3 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class UploadToken(Base):
uploads_used: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
allowed_mime: Mapped[list | None] = mapped_column("allowed_mime", JSON, nullable=True)
disabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))

uploads: Mapped[list["UploadRecord"]] = relationship("UploadRecord", back_populates="token", cascade="all, delete-orphan")

Expand All @@ -42,7 +42,7 @@ class UploadRecord(Base):
upload_length: Mapped[int | None] = mapped_column(BigInteger)
upload_offset: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

token: Mapped[UploadToken] = relationship("UploadToken", back_populates="uploads")
Loading