Skip to content

Commit 8ca8105

Browse files
authored
Merge pull request #7 from arabcoders/dev
Feat: add og share meta
2 parents 4915284 + 0de8dfc commit 8ca8105

38 files changed

+2489
-490
lines changed

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ COPY --chown=app:app ./alembic.ini /app/alembic.ini
6060
COPY --chown=app:app ./backend /app/backend
6161
COPY --chown=app:app --from=node_builder /app/exported /app/frontend/exported
6262
COPY --chown=app:app --from=python_builder /opt/python /opt/python
63+
COPY --from=ghcr.io/arabcoders/jellyfin-ffmpeg /usr/bin/ffmpeg /usr/bin/ffmpeg
6364
COPY --from=ghcr.io/arabcoders/jellyfin-ffmpeg /usr/bin/ffprobe /usr/bin/ffprobe
6465

6566
# Install fbc CLI script

backend/app/cleanup.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ async def _remove_stale_uploads(session: AsyncSession) -> int:
7676
total_removed = 0
7777

7878
stmt: Select[tuple[models.UploadRecord]] = (
79-
select(models.UploadRecord).where(models.UploadRecord.status != "completed").where(models.UploadRecord.created_at < cutoff_naive)
79+
select(models.UploadRecord)
80+
.where(models.UploadRecord.status.in_(["pending", "in_progress"]))
81+
.where(models.UploadRecord.created_at < cutoff_naive)
8082
)
8183
res: Result[tuple[models.UploadRecord]] = await session.execute(stmt)
8284

backend/app/main.py

Lines changed: 137 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import os
44
from contextlib import asynccontextmanager, suppress
55
from pathlib import Path
6+
from typing import Annotated
67

7-
from fastapi import FastAPI, HTTPException, Request, status
8+
from fastapi import FastAPI, Header, HTTPException, Request, status
89
from fastapi.concurrency import run_in_threadpool
910
from fastapi.middleware.cors import CORSMiddleware
1011
from fastapi.responses import FileResponse, JSONResponse
12+
from fastapi.templating import Jinja2Templates
1113

1214
from backend.app import version
1315

@@ -16,6 +18,7 @@
1618
from .config import settings
1719
from .db import engine
1820
from .migrate import run_migrations
21+
from .postprocessing import ProcessingQueue
1922

2023

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

30+
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
31+
2732
@asynccontextmanager
2833
async def lifespan(app: FastAPI):
2934
"""
@@ -36,11 +41,17 @@ async def lifespan(app: FastAPI):
3641
if not settings.skip_migrations:
3742
await run_in_threadpool(run_migrations)
3843

44+
queue = ProcessingQueue()
45+
queue.start_worker()
46+
app.state.processing_queue = queue
47+
3948
if not settings.skip_cleanup:
4049
app.state.cleanup_task = asyncio.create_task(start_cleanup_loop(), name="cleanup_loop")
4150

4251
yield
4352

53+
await queue.stop_worker()
54+
4455
if not settings.skip_cleanup:
4556
task: asyncio.Task | None = getattr(app.state, "cleanup_task", None)
4657
if task:
@@ -139,39 +150,143 @@ def app_version() -> dict[str, str]:
139150
app.include_router(getattr(routers, _route).router)
140151

141152
frontend_dir: Path = Path(settings.frontend_export_path).resolve()
142-
if frontend_dir.exists():
143153

144-
@app.get("/{full_path:path}", name="static_frontend")
145-
async def frontend(full_path: str) -> FileResponse:
146-
"""
147-
Serve static frontend files.
154+
@app.get("/f/{token}", name="share_page")
155+
@app.get("/f/{token}/")
156+
async def share_page(token: str, request: Request, user_agent: Annotated[str | None, Header()] = None):
157+
"""Handle /f/{token} with bot detection for embed preview."""
158+
from sqlalchemy import select
159+
160+
from backend.app import models, utils
161+
from backend.app.db import get_db
162+
163+
user_agent_lower: str = (user_agent or "").lower()
164+
is_bot = any(bot in user_agent_lower for bot in ["discordbot", "twitterbot", "slackbot", "facebookexternalhit", "whatsapp"])
165+
166+
if is_bot and settings.allow_public_downloads:
167+
async for db in get_db():
168+
stmt = select(models.UploadToken).where((models.UploadToken.token == token) | (models.UploadToken.download_token == token))
169+
result = await db.execute(stmt)
170+
token_row = result.scalar_one_or_none()
171+
172+
if token_row:
173+
uploads_stmt = (
174+
select(models.UploadRecord)
175+
.where(models.UploadRecord.token_id == token_row.id, models.UploadRecord.status == "completed")
176+
.order_by(models.UploadRecord.created_at.desc())
177+
)
178+
uploads_result = await db.execute(uploads_stmt)
179+
uploads = uploads_result.scalars().all()
180+
181+
media_files = [u for u in uploads if u.mimetype and utils.is_multimedia(u.mimetype)]
182+
183+
if media_files:
184+
first_media = media_files[0]
185+
186+
is_video = first_media.mimetype.startswith("video/")
187+
ffprobe_data = None
188+
if first_media.meta_data and isinstance(first_media.meta_data, dict):
189+
ffprobe_data = first_media.meta_data.get("ffprobe")
190+
191+
video_metadata = utils.extract_video_metadata(ffprobe_data)
192+
193+
other_files = [
194+
{
195+
"name": u.filename or "Unknown",
196+
"size": utils.format_file_size(u.size_bytes) if u.size_bytes else "Unknown",
197+
}
198+
for u in uploads
199+
if u.public_id != first_media.public_id
200+
]
201+
202+
media_url = str(
203+
request.url_for("download_file", download_token=token_row.download_token, upload_id=first_media.public_id)
204+
)
205+
share_url = str(request.url_for("share_page", token=token))
206+
207+
is_video = first_media.mimetype.startswith("video/")
208+
is_audio = first_media.mimetype.startswith("audio/")
209+
210+
context = {
211+
"request": request,
212+
"title": first_media.filename or "Shared Media",
213+
"description": f"{len(uploads)} file(s) shared" if len(uploads) > 1 else "Shared file",
214+
"og_type": "video.other" if is_video else "music.song",
215+
"share_url": share_url,
216+
"media_url": media_url,
217+
"mime_type": first_media.mimetype,
218+
"is_video": is_video,
219+
"is_audio": is_audio,
220+
"width": video_metadata.get("width"),
221+
"height": video_metadata.get("height"),
222+
"duration": video_metadata.get("duration"),
223+
"duration_formatted": utils.format_duration(video_metadata["duration"])
224+
if video_metadata.get("duration")
225+
else None,
226+
"file_size": utils.format_file_size(first_media.size_bytes) if first_media.size_bytes else None,
227+
"other_files": other_files,
228+
}
229+
230+
return templates.TemplateResponse(
231+
request=request,
232+
name="share_preview.html",
233+
context=context,
234+
status_code=status.HTTP_200_OK,
235+
)
236+
237+
if frontend_dir.exists():
238+
index_file = frontend_dir / "index.html"
239+
if index_file.exists():
240+
return FileResponse(index_file, status_code=status.HTTP_200_OK)
241+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
148242

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

152-
Returns:
153-
FileResponse: The response containing the requested file.
250+
index_file = frontend_dir / "index.html"
251+
if not index_file.exists():
252+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
154253

155-
"""
156-
if full_path.startswith("api/"):
157-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
254+
return FileResponse(index_file, status_code=status.HTTP_200_OK)
255+
256+
@app.get("/{full_path:path}", name="static_frontend")
257+
async def frontend(full_path: str) -> FileResponse:
258+
"""
259+
Serve static frontend files.
260+
261+
Args:
262+
full_path (str): The requested file path.
158263
159-
if not full_path or "/" == full_path:
160-
index_file: Path = frontend_dir / "index.html"
161-
if index_file.exists():
162-
return FileResponse(index_file, status_code=status.HTTP_200_OK)
163-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
264+
Returns:
265+
FileResponse: The response containing the requested file.
266+
267+
"""
268+
if full_path.startswith("api/"):
269+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
164270

165-
requested_file: Path = frontend_dir / full_path
166-
if requested_file.is_file():
167-
return FileResponse(requested_file, status_code=status.HTTP_200_OK)
271+
if not frontend_dir.exists():
272+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
168273

274+
if not full_path or "/" == full_path:
169275
index_file: Path = frontend_dir / "index.html"
170276
if index_file.exists():
171277
return FileResponse(index_file, status_code=status.HTTP_200_OK)
172-
173278
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
174279

280+
requested_file: Path = frontend_dir / full_path
281+
if requested_file.is_file():
282+
return FileResponse(requested_file, status_code=status.HTTP_200_OK)
283+
284+
index_file: Path = frontend_dir / "index.html"
285+
if index_file.exists():
286+
return FileResponse(index_file, status_code=status.HTTP_200_OK)
287+
288+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
289+
175290
return app
176291

177292

backend/app/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class UploadToken(Base):
1818
uploads_used: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
1919
allowed_mime: Mapped[list | None] = mapped_column("allowed_mime", JSON, nullable=True)
2020
disabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
21-
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
21+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
2222

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

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

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

0 commit comments

Comments
 (0)