use sqlite database
This commit is contained in:
parent
bbfbeddc55
commit
7c0ba4c2aa
15 changed files with 560 additions and 438 deletions
|
@ -13,4 +13,4 @@ COPY . /opt
|
|||
ENV PG_DIRECTORY=/work
|
||||
ENV PG_UPLOADS_DIRECTORY=/uploads
|
||||
|
||||
CMD ["uv", "run", "fastapi", "run", "/opt/main.py", "--port", "8000"]
|
||||
CMD ["uv", "run", "fastapi", "run", "/opt/src/main.py", "--port", "8000"]
|
||||
|
|
53
data.py
53
data.py
|
@ -1,53 +0,0 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from settings import settings
|
||||
|
||||
REPO_DATA_FILENAME = settings.directory / "data.json"
|
||||
|
||||
|
||||
class Episode(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str
|
||||
duration: Optional[float] = Field(default=None)
|
||||
description: Optional[str] = Field(default=None)
|
||||
file_hash: str
|
||||
file_size: int
|
||||
publish_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Podcast(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
explicit: bool
|
||||
image_filename: Optional[str] = Field(default=None)
|
||||
episodes: List[Episode] = list()
|
||||
|
||||
|
||||
class PodcastRepository(BaseModel):
|
||||
podcasts: Dict[str, Podcast] = dict()
|
||||
|
||||
|
||||
def load_repository() -> PodcastRepository:
|
||||
settings.directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not REPO_DATA_FILENAME.is_file():
|
||||
new_repo = PodcastRepository(
|
||||
podcasts={
|
||||
str(uuid.uuid4()): Podcast(name=name, description=name, explicit=True)
|
||||
for name in settings.feeds
|
||||
}
|
||||
)
|
||||
save_repository(new_repo)
|
||||
return new_repo
|
||||
|
||||
with open(REPO_DATA_FILENAME, "r") as f:
|
||||
return PodcastRepository.model_validate_json(f.read())
|
||||
|
||||
|
||||
def save_repository(repository: PodcastRepository) -> None:
|
||||
with open(REPO_DATA_FILENAME, "w") as f:
|
||||
f.write(repository.model_dump_json())
|
361
main.py
361
main.py
|
@ -1,361 +0,0 @@
|
|||
import urllib.parse
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import podgen
|
||||
import structlog
|
||||
from fastapi import FastAPI, Form, HTTPException, Request, Response, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from PIL import Image
|
||||
|
||||
import data
|
||||
from process import AudioProcessor
|
||||
from settings import settings
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
audio_processor = AudioProcessor()
|
||||
audio_processor.start_processing()
|
||||
|
||||
|
||||
@app.get("/admin")
|
||||
def admin_list_feeds(request: Request):
|
||||
repo = data.load_repository()
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admin_feeds.html.j2",
|
||||
context={"repo": repo},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/admin/{feed_id}")
|
||||
def admin_list_feed(request: Request, feed_id: str):
|
||||
repo = data.load_repository()
|
||||
feed = next(
|
||||
(podcast for id, podcast in repo.podcasts.items() if id == feed_id), None
|
||||
)
|
||||
|
||||
if feed is None:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admin_feed.html.j2",
|
||||
context={
|
||||
"feed": feed,
|
||||
"id": feed_id,
|
||||
"feed_uri": urllib.parse.urljoin(str(request.base_url), f"{feed_id}.xml"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def finish_processing(
|
||||
feed_id: str,
|
||||
episode: data.Episode,
|
||||
duration: Optional[float],
|
||||
file_hash: str,
|
||||
file_size: int,
|
||||
):
|
||||
log.info("Saving episode %s", episode.id)
|
||||
repo = data.load_repository()
|
||||
episode.duration = duration
|
||||
episode.file_hash = file_hash
|
||||
episode.file_size = file_size
|
||||
repo.podcasts[feed_id].episodes.insert(0, episode)
|
||||
data.save_repository(repo)
|
||||
|
||||
|
||||
@app.post("/admin/{feed_id}/upload")
|
||||
async def admin_upload_episode(request: Request, feed_id: str, file: UploadFile):
|
||||
file_id = request.headers.get("uploader-file-id")
|
||||
chunks_total = int(request.headers.get("uploader-chunks-total"))
|
||||
chunk_number = int(request.headers.get("uploader-chunk-number"))
|
||||
episode_name = request.headers.get("name")
|
||||
|
||||
if file_id is None or episode_name is None:
|
||||
raise HTTPException(400, "Invalid request")
|
||||
|
||||
file_id = "".join(c for c in file_id if c.isalnum()).strip()
|
||||
|
||||
repo = data.load_repository()
|
||||
|
||||
if feed_id not in repo.podcasts:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
is_last = (chunk_number + 1) == chunks_total
|
||||
|
||||
file_name = f"{file_id}_{chunk_number}"
|
||||
|
||||
settings.uploads_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(settings.uploads_directory / file_name, "wb") as buf:
|
||||
buf.write(await file.read())
|
||||
|
||||
if is_last:
|
||||
episode = data.Episode(
|
||||
name=Path(urllib.parse.unquote(episode_name)).stem,
|
||||
file_size=0,
|
||||
file_hash="",
|
||||
)
|
||||
upload_path = settings.uploads_directory / episode.id
|
||||
with open(upload_path, "wb") as buf:
|
||||
chunk = 0
|
||||
while chunk < chunks_total:
|
||||
chunk_path = settings.uploads_directory / f"{file_id}_{chunk}"
|
||||
with open(chunk_path, "rb") as infile:
|
||||
buf.write(infile.read())
|
||||
infile.close()
|
||||
|
||||
chunk_path.unlink()
|
||||
chunk += 1
|
||||
|
||||
audio_processor.add_file(
|
||||
upload_path,
|
||||
settings.directory / f"{episode.id}.m4a",
|
||||
lambda duration, file_hash, file_size: finish_processing(
|
||||
feed_id, episode, duration, file_hash, file_size
|
||||
),
|
||||
)
|
||||
|
||||
return JSONResponse({"message": "File Uploaded"}, status_code=200)
|
||||
|
||||
return JSONResponse({"message": "Chunk Uploaded"}, status_code=200)
|
||||
|
||||
|
||||
@app.get("/admin/{feed_id}/{episode_id}/delete")
|
||||
def admin_delete_episode(feed_id: str, episode_id: str):
|
||||
repo = data.load_repository()
|
||||
|
||||
if feed_id not in repo.podcasts:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
(settings.directory / f"{episode_id}.m4a").unlink()
|
||||
|
||||
new_feed = repo.podcasts[feed_id]
|
||||
new_feed.episodes = [
|
||||
episode for episode in new_feed.episodes if episode.id != episode_id
|
||||
]
|
||||
repo.podcasts[feed_id] = new_feed
|
||||
data.save_repository(repo)
|
||||
|
||||
return RedirectResponse(f"/admin/{feed_id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/{feed_id}/{episode_id}/edit")
|
||||
def admin_edit_episode(request: Request, feed_id: str, episode_id: str):
|
||||
repo = data.load_repository()
|
||||
|
||||
if feed_id not in repo.podcasts:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
episode = next(
|
||||
(
|
||||
episode
|
||||
for episode in repo.podcasts[feed_id].episodes
|
||||
if episode.id == episode_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if episode is None:
|
||||
raise HTTPException(status_code=404, detail="Episode not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admin_episode_edit.html.j2",
|
||||
context={"episode": episode},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/{feed_id}/{episode_id}/edit")
|
||||
def admin_edit_episode_post(
|
||||
feed_id: str,
|
||||
episode_id: str,
|
||||
name: Annotated[str, Form()],
|
||||
description: Annotated[str, Form()],
|
||||
):
|
||||
repo = data.load_repository()
|
||||
|
||||
if feed_id not in repo.podcasts:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
episode_idx = next(
|
||||
(
|
||||
idx
|
||||
for idx, episode in enumerate(repo.podcasts[feed_id].episodes)
|
||||
if episode.id == episode_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if episode_idx is None:
|
||||
raise HTTPException(status_code=404, detail="Episode not found")
|
||||
|
||||
if name.strip() != "":
|
||||
repo.podcasts[feed_id].episodes[episode_idx].name = name
|
||||
|
||||
if description.strip() != "":
|
||||
repo.podcasts[feed_id].episodes[episode_idx].description = description
|
||||
else:
|
||||
repo.podcasts[feed_id].episodes[episode_idx].description = None
|
||||
|
||||
data.save_repository(repo)
|
||||
|
||||
return RedirectResponse(f"/admin/{feed_id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/{feed_id}/edit")
|
||||
def admin_edit_feed(request: Request, feed_id: str):
|
||||
repo = data.load_repository()
|
||||
|
||||
if feed_id not in repo.podcasts:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admin_feed_edit.html.j2",
|
||||
context={"feed": repo.podcasts[feed_id]},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/{feed_id}/edit")
|
||||
def admin_edit_feed_post(
|
||||
feed_id: str,
|
||||
name: Annotated[str, Form()],
|
||||
description: Annotated[str, Form()],
|
||||
image: Optional[UploadFile] = None,
|
||||
):
|
||||
repo = data.load_repository()
|
||||
|
||||
if feed_id not in repo.podcasts:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
new_feed = repo.podcasts[feed_id]
|
||||
|
||||
if name.strip() != "":
|
||||
new_feed.name = name
|
||||
|
||||
new_feed.description = description
|
||||
|
||||
if image is not None and image.size > 0:
|
||||
if not (image.filename.endswith(".jpg") or image.filename.endswith(".png")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The uploaded podcast image must be a jpg or png",
|
||||
)
|
||||
|
||||
im = Image.open(image.file)
|
||||
|
||||
if im.size[0] != im.size[1] or im.size[0] < 1400 or im.size[0] > 3000:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The uploaded podcast image must be square and between 1400x1400px and 3000x3000px in size",
|
||||
)
|
||||
|
||||
if im.mode != "RGB":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The uploaded podcast image must be in RGB format",
|
||||
)
|
||||
|
||||
if new_feed.image_filename is not None:
|
||||
(settings.directory / new_feed.image_filename).unlink(missing_ok=True)
|
||||
|
||||
filename = f"img_{uuid.uuid4()}" + Path(image.filename).suffix
|
||||
im.save(settings.directory / filename)
|
||||
new_feed.image_filename = filename
|
||||
|
||||
repo.podcasts[feed_id] = new_feed
|
||||
|
||||
data.save_repository(repo)
|
||||
|
||||
return RedirectResponse(f"/admin/{feed_id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/{feed_id}.xml")
|
||||
def get_feed(request: Request, feed_id: str):
|
||||
repo = data.load_repository()
|
||||
feed = next(
|
||||
(podcast for id, podcast in repo.podcasts.items() if id == feed_id), None
|
||||
)
|
||||
|
||||
if feed is None:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
podcast = podgen.Podcast(
|
||||
name=feed.name,
|
||||
description=feed.description,
|
||||
image=urllib.parse.urljoin(
|
||||
str(request.base_url),
|
||||
f"/{feed_id}/{feed.image_filename}",
|
||||
)
|
||||
if feed.image_filename is not None
|
||||
else None,
|
||||
website=urllib.parse.urljoin(str(request.base_url), feed_id),
|
||||
explicit=feed.explicit,
|
||||
feed_url=str(request.url),
|
||||
)
|
||||
|
||||
for episode in feed.episodes:
|
||||
podcast.add_episode(
|
||||
podgen.Episode(
|
||||
id=episode.id,
|
||||
title=episode.name,
|
||||
publication_date=episode.publish_date,
|
||||
media=podgen.Media(
|
||||
urllib.parse.urljoin(
|
||||
str(request.base_url), f"{feed_id}/{episode.id}.m4a"
|
||||
),
|
||||
episode.file_size,
|
||||
duration=timedelta(seconds=episode.duration)
|
||||
if episode.duration is not None
|
||||
else None,
|
||||
),
|
||||
long_summary=episode.description,
|
||||
)
|
||||
)
|
||||
|
||||
return Response(content=podcast.rss_str(), media_type="application/xml")
|
||||
|
||||
|
||||
@app.get("/{feed_id}/{filename}")
|
||||
def get_episode_or_cover(feed_id: str, filename: str):
|
||||
repo = data.load_repository()
|
||||
feed = next(
|
||||
(podcast for id, podcast in repo.podcasts.items() if id == feed_id), None
|
||||
)
|
||||
|
||||
if feed is None:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
if filename.endswith(".m4a"):
|
||||
episode = next(
|
||||
(
|
||||
episode
|
||||
for episode in feed.episodes
|
||||
if episode.id == filename.removesuffix(".m4a")
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if episode is None:
|
||||
raise HTTPException(status_code=404, detail="Episode not found")
|
||||
|
||||
return FileResponse(settings.directory / f"{episode.id}.m4a")
|
||||
|
||||
elif (
|
||||
filename.endswith(".jpg") or filename.endswith(".png")
|
||||
) and filename == feed.image_filename:
|
||||
return FileResponse(settings.directory / feed.image_filename)
|
||||
|
||||
return HTTPException(status_code=404, detail="File not found")
|
|
@ -9,10 +9,12 @@ dependencies = [
|
|||
"fastapi[standard]>=0.115.6",
|
||||
"ffmpeg-normalize>=1.31.0",
|
||||
"ffmpeg-python>=0.2.0",
|
||||
"nanoid>=2.0.0",
|
||||
"pillow>=11.1.0",
|
||||
"podgen>=1.1.0",
|
||||
"pydantic>=2.10.5",
|
||||
"pydantic-settings>=2.7.1",
|
||||
"python-multipart>=0.0.20",
|
||||
"sqlmodel>=0.0.22",
|
||||
"structlog>=24.4.0",
|
||||
]
|
||||
|
|
394
src/main.py
Normal file
394
src/main.py
Normal file
|
@ -0,0 +1,394 @@
|
|||
import urllib.parse
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Generator, Optional
|
||||
|
||||
import podgen
|
||||
import structlog
|
||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from PIL import Image
|
||||
from sqlmodel import Session, and_, select
|
||||
|
||||
import models
|
||||
from process import AudioProcessor
|
||||
from settings import settings
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
models.setup_db(models.engine)
|
||||
yield
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, Any, None]:
|
||||
with Session(models.engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
SessionDep = Annotated[Session, Depends(get_session)]
|
||||
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
templates = Jinja2Templates(directory="src/templates")
|
||||
|
||||
audio_processor = AudioProcessor()
|
||||
audio_processor.start_processing()
|
||||
|
||||
|
||||
@app.get("/admin")
|
||||
def admin_list_podcasts(session: SessionDep, request: Request):
|
||||
podcasts = session.exec(select(models.Podcast)).all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admin_feeds.html.j2",
|
||||
context={"podcasts": podcasts},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/admin/{podcast_id}")
|
||||
def admin_list_podcast(session: SessionDep, request: Request, podcast_id: str):
|
||||
podcast = session.exec(
|
||||
select(models.Podcast).where(models.Podcast.id == podcast_id)
|
||||
).first()
|
||||
|
||||
if podcast is None:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
episodes = podcast.episodes
|
||||
episodes.sort(key=lambda e: e.publish_date, reverse=True)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admin_feed.html.j2",
|
||||
context={
|
||||
"podcast": podcast,
|
||||
"episodes": episodes,
|
||||
"feed_uri": urllib.parse.urljoin(
|
||||
str(request.base_url), f"{podcast.id}.xml"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def finish_processing(
|
||||
podcast_id: str,
|
||||
episode: models.PodcastEpisode,
|
||||
duration: Optional[float],
|
||||
file_hash: str,
|
||||
file_size: int,
|
||||
):
|
||||
with Session(models.engine) as session:
|
||||
log.info("Saving episode %s", episode.id)
|
||||
|
||||
podcast = session.exec(
|
||||
select(models.Podcast).where(models.Podcast.id == podcast_id)
|
||||
).first()
|
||||
|
||||
if podcast is None:
|
||||
log.error("Failed processing episode, podcast does not exist.")
|
||||
return
|
||||
|
||||
episode.duration = duration
|
||||
episode.file_hash = file_hash
|
||||
episode.file_size = file_size
|
||||
episode.podcast_id = podcast.id
|
||||
|
||||
session.add(episode)
|
||||
session.commit()
|
||||
|
||||
|
||||
@app.post("/admin/{podcast_id}/upload")
|
||||
async def admin_upload_episode(
|
||||
session: SessionDep, request: Request, podcast_id: str, file: UploadFile
|
||||
):
|
||||
file_id = request.headers.get("uploader-file-id")
|
||||
chunks_total = int(request.headers.get("uploader-chunks-total"))
|
||||
chunk_number = int(request.headers.get("uploader-chunk-number"))
|
||||
episode_name = request.headers.get("name")
|
||||
|
||||
if file_id is None or episode_name is None:
|
||||
raise HTTPException(400, "Invalid request")
|
||||
|
||||
file_id = "".join(c for c in file_id if c.isalnum()).strip()
|
||||
|
||||
podcast = session.exec(
|
||||
select(models.Podcast).where(models.Podcast.id == podcast_id)
|
||||
).first()
|
||||
|
||||
if podcast is None:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
is_last = (chunk_number + 1) == chunks_total
|
||||
|
||||
file_name = f"{file_id}_{chunk_number}"
|
||||
|
||||
settings.uploads_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(settings.uploads_directory / file_name, "wb") as buf:
|
||||
buf.write(await file.read())
|
||||
|
||||
if is_last:
|
||||
episode = models.PodcastEpisode(
|
||||
name=Path(urllib.parse.unquote(episode_name)).stem,
|
||||
file_size=0,
|
||||
file_hash="",
|
||||
)
|
||||
upload_path = settings.uploads_directory / episode.id
|
||||
with open(upload_path, "wb") as buf:
|
||||
chunk = 0
|
||||
while chunk < chunks_total:
|
||||
chunk_path = settings.uploads_directory / f"{file_id}_{chunk}"
|
||||
with open(chunk_path, "rb") as infile:
|
||||
buf.write(infile.read())
|
||||
infile.close()
|
||||
|
||||
chunk_path.unlink()
|
||||
chunk += 1
|
||||
|
||||
audio_processor.add_file(
|
||||
upload_path,
|
||||
settings.directory / f"{episode.id}.m4a",
|
||||
lambda duration, file_hash, file_size: finish_processing(
|
||||
podcast_id, episode, duration, file_hash, file_size
|
||||
),
|
||||
)
|
||||
|
||||
return JSONResponse({"message": "File Uploaded"}, status_code=200)
|
||||
|
||||
return JSONResponse({"message": "Chunk Uploaded"}, status_code=200)
|
||||
|
||||
|
||||
@app.get("/admin/{podcast_id}/{episode_id}/delete")
|
||||
def admin_delete_episode(session: SessionDep, podcast_id: str, episode_id: str):
|
||||
episode = session.exec(
|
||||
select(models.PodcastEpisode).where(
|
||||
and_(
|
||||
models.PodcastEpisode.id == episode_id,
|
||||
models.PodcastEpisode.podcast_id == podcast_id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if episode is None:
|
||||
raise HTTPException(status_code=404, detail="Episode or podcast not found")
|
||||
|
||||
(settings.directory / f"{episode_id}.m4a").unlink()
|
||||
session.delete(episode)
|
||||
session.commit()
|
||||
|
||||
return RedirectResponse(f"/admin/{podcast_id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/{podcast_id}/{episode_id}/edit")
|
||||
def admin_edit_episode(
|
||||
session: SessionDep, request: Request, podcast_id: str, episode_id: str
|
||||
):
|
||||
episode = session.exec(
|
||||
select(models.PodcastEpisode).where(
|
||||
and_(
|
||||
models.PodcastEpisode.id == episode_id,
|
||||
models.PodcastEpisode.podcast_id == podcast_id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if episode is None:
|
||||
raise HTTPException(status_code=404, detail="Episode or podcast not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admin_episode_edit.html.j2",
|
||||
context={"episode": episode},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/{podcast_id}/{episode_id}/edit")
|
||||
def admin_edit_episode_post(
|
||||
session: SessionDep,
|
||||
podcast_id: str,
|
||||
episode_id: str,
|
||||
name: Annotated[str, Form()],
|
||||
description: Annotated[str, Form()],
|
||||
):
|
||||
episode = session.exec(
|
||||
select(models.PodcastEpisode).where(
|
||||
and_(
|
||||
models.PodcastEpisode.id == episode_id,
|
||||
models.PodcastEpisode.podcast_id == podcast_id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if episode is None:
|
||||
raise HTTPException(status_code=404, detail="Episode or podcast not found")
|
||||
|
||||
if name.strip() != "":
|
||||
episode.name = name
|
||||
|
||||
if description.strip() != "":
|
||||
episode.description = description
|
||||
else:
|
||||
episode.description = None
|
||||
|
||||
session.add(episode)
|
||||
session.commit()
|
||||
|
||||
return RedirectResponse(f"/admin/{podcast_id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/{podcast_id}/edit")
|
||||
def admin_edit_podcast(session: SessionDep, request: Request, podcast_id: str):
|
||||
podcast = session.exec(
|
||||
select(models.Podcast).where(models.Podcast.id == podcast_id)
|
||||
).first()
|
||||
|
||||
if podcast is None:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admin_feed_edit.html.j2",
|
||||
context={"podcast": podcast},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/{podcast_id}/edit")
|
||||
def admin_edit_podcast_post(
|
||||
session: SessionDep,
|
||||
podcast_id: str,
|
||||
name: Annotated[str, Form()],
|
||||
description: Annotated[str, Form()],
|
||||
image: Optional[UploadFile] = None,
|
||||
):
|
||||
podcast = session.exec(
|
||||
select(models.Podcast).where(models.Podcast.id == podcast_id)
|
||||
).first()
|
||||
|
||||
if podcast is None:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
if name.strip() != "":
|
||||
podcast.name = name
|
||||
|
||||
podcast.description = description
|
||||
|
||||
if image is not None and image.size > 0:
|
||||
if not (image.filename.endswith(".jpg") or image.filename.endswith(".png")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The uploaded podcast image must be a jpg or png",
|
||||
)
|
||||
|
||||
im = Image.open(image.file)
|
||||
|
||||
if im.size[0] != im.size[1] or im.size[0] < 1400 or im.size[0] > 3000:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The uploaded podcast image must be square and between 1400x1400px and 3000x3000px in size",
|
||||
)
|
||||
|
||||
if im.mode != "RGB":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The uploaded podcast image must be in RGB format",
|
||||
)
|
||||
|
||||
if podcast.image_filename is not None:
|
||||
(settings.directory / podcast.image_filename).unlink(missing_ok=True)
|
||||
|
||||
filename = f"img_{uuid.uuid4()}" + Path(image.filename).suffix
|
||||
im.save(settings.directory / filename)
|
||||
podcast.image_filename = filename
|
||||
|
||||
session.add(podcast)
|
||||
session.commit()
|
||||
|
||||
return RedirectResponse(f"/admin/{podcast_id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/{podcast_id}.xml")
|
||||
def get_feed(session: SessionDep, request: Request, podcast_id: str):
|
||||
podcast = session.exec(
|
||||
select(models.Podcast).where(models.Podcast.id == podcast_id)
|
||||
).first()
|
||||
|
||||
if podcast is None:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
feed = podgen.Podcast(
|
||||
name=podcast.name,
|
||||
description=podcast.description,
|
||||
image=urllib.parse.urljoin(
|
||||
str(request.base_url),
|
||||
f"/{podcast.id}/{podcast.image_filename}",
|
||||
)
|
||||
if podcast.image_filename is not None
|
||||
else None,
|
||||
website=urllib.parse.urljoin(str(request.base_url), podcast.id),
|
||||
explicit=podcast.explicit,
|
||||
feed_url=str(request.url),
|
||||
)
|
||||
|
||||
for episode in podcast.episodes:
|
||||
feed.add_episode(
|
||||
podgen.Episode(
|
||||
id=episode.id,
|
||||
title=episode.name,
|
||||
publication_date=episode.publish_date,
|
||||
media=podgen.Media(
|
||||
urllib.parse.urljoin(
|
||||
str(request.base_url), f"{podcast.id}/{episode.id}.m4a"
|
||||
),
|
||||
episode.file_size,
|
||||
duration=timedelta(seconds=episode.duration)
|
||||
if episode.duration is not None
|
||||
else None,
|
||||
),
|
||||
long_summary=episode.description,
|
||||
)
|
||||
)
|
||||
|
||||
return Response(content=feed.rss_str(), media_type="application/xml")
|
||||
|
||||
|
||||
@app.get("/{podcast_id}/{filename}")
|
||||
def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str):
|
||||
podcast = session.exec(
|
||||
select(models.Podcast).where(models.Podcast.id == podcast_id)
|
||||
).first()
|
||||
|
||||
if podcast is None:
|
||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||
|
||||
if filename.endswith(".m4a"):
|
||||
episode = session.exec(
|
||||
select(models.PodcastEpisode).where(
|
||||
and_(
|
||||
models.PodcastEpisode.podcast_id == podcast_id,
|
||||
models.PodcastEpisode.id == filename.removesuffix(".m4a"),
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if episode is None:
|
||||
raise HTTPException(status_code=404, detail="Episode or podcast not found")
|
||||
|
||||
return FileResponse(settings.directory / f"{episode.id}.m4a")
|
||||
|
||||
elif (
|
||||
filename.endswith(".jpg") or filename.endswith(".png")
|
||||
) and filename == podcast.image_filename:
|
||||
return FileResponse(settings.directory / podcast.image_filename)
|
||||
|
||||
return HTTPException(status_code=404, detail="File not found")
|
71
src/models.py
Normal file
71
src/models.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import nanoid
|
||||
from sqlalchemy import Engine
|
||||
from sqlmodel import Field, Relationship, Session, SQLModel, create_engine
|
||||
|
||||
from settings import settings
|
||||
|
||||
|
||||
class Podcast(SQLModel, table=True):
|
||||
id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate())
|
||||
name: str
|
||||
description: str
|
||||
explicit: bool = Field(default=True)
|
||||
image_filename: Optional[str] = Field(default=False)
|
||||
|
||||
episodes: list["PodcastEpisode"] = Relationship(back_populates="podcast")
|
||||
|
||||
|
||||
class PodcastEpisode(SQLModel, table=True):
|
||||
id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate())
|
||||
name: str
|
||||
duration: Optional[float] = Field(default=None)
|
||||
description: Optional[float] = Field(default=None)
|
||||
file_hash: str
|
||||
file_size: int
|
||||
publish_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
podcast_id: str = Field(foreign_key="podcast.id")
|
||||
podcast: Podcast = Relationship(back_populates="episodes")
|
||||
|
||||
|
||||
def setup_db(engine: Engine):
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
# try and migrate old data
|
||||
old_data = settings.directory / "data.json"
|
||||
if old_data.is_file():
|
||||
try:
|
||||
session = Session(engine)
|
||||
|
||||
with open(old_data, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
for id, item in data["podcasts"].items():
|
||||
podcast = Podcast(
|
||||
id=id,
|
||||
name=item.get("name"),
|
||||
description=item.get("description"),
|
||||
explicit=item.get("explicit"),
|
||||
image_filename=item.get("image_filename"),
|
||||
)
|
||||
session.add(podcast)
|
||||
session.commit()
|
||||
|
||||
for episode in item["episodes"]:
|
||||
ep = PodcastEpisode.model_validate(
|
||||
{**episode, "podcast_id": podcast.id, "id": nanoid.generate()}
|
||||
)
|
||||
session.add(ep)
|
||||
|
||||
session.commit()
|
||||
|
||||
old_data.unlink()
|
||||
except Exception as ex:
|
||||
print("Failed to migrate old data", ex)
|
||||
|
||||
|
||||
engine = create_engine(f"sqlite:///{settings.directory / 'data.db'}")
|
|
@ -1,5 +1,4 @@
|
|||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
@ -8,7 +7,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||
class Settings(BaseSettings):
|
||||
directory: Path = Field(default=Path.cwd() / "data")
|
||||
uploads_directory: Path = Field(default=Path.cwd() / "uploads")
|
||||
feeds: Set[str] = Field(default={"default"})
|
||||
|
||||
model_config = SettingsConfigDict(env_nested_delimiter="__", env_prefix="PG_")
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
{% extends 'layout.html.j2' %}
|
||||
{% block content %}
|
||||
{% if feed.image_filename %}
|
||||
<img src="/{{ id }}/{{ feed.image_filename }}" width="256px" />
|
||||
{% if podcast.image_filename %}
|
||||
<img src="/{{ podcast.id }}/{{ podcast.image_filename }}" width="256px" />
|
||||
<br><br>
|
||||
{% endif %}
|
||||
<h1>{{ feed.name }}</h1>
|
||||
<h1>{{ podcast.name }}</h1>
|
||||
<p>
|
||||
<b>Actions:</b>
|
||||
<a href="/admin/{{ id }}/edit">Edit</a>
|
||||
<a href="/admin/{{ podcast.id }}/edit">Edit</a>
|
||||
</p>
|
||||
<p>
|
||||
<b>Description:</b>
|
||||
{{ feed.description }}
|
||||
{{ podcast.description }}
|
||||
</p>
|
||||
<p>
|
||||
<b>Subscribe:</b>
|
||||
|
@ -40,7 +40,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for episode in feed.episodes %}
|
||||
{% for episode in episodes %}
|
||||
<tr>
|
||||
<th scope="row">{{ episode.name }}</th>
|
||||
<td>{{ episode.publish_date.strftime("%H:%M %d/%m/%Y") }}</td>
|
||||
|
@ -52,8 +52,8 @@
|
|||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/{{ id }}/{{ episode.id }}/delete">Delete</a>
|
||||
<a href="/admin/{{ id }}/{{ episode.id }}/edit">Edit</a>
|
||||
<a href="/admin/{{ podcast.id }}/{{ episode.id }}/delete">Delete</a>
|
||||
<a href="/admin/{{ podcast.id }}/{{ episode.id }}/edit">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
@ -85,7 +85,7 @@
|
|||
setFormEnabled(false);
|
||||
|
||||
const uploader = new HugeUploader({
|
||||
endpoint: "/admin/{{ id }}/upload",
|
||||
endpoint: "/admin/{{ podcast.id }}/upload",
|
||||
file: file,
|
||||
headers: {
|
||||
"name": encodeURI(file.name)
|
|
@ -1,15 +1,15 @@
|
|||
{% extends 'layout.html.j2' %}
|
||||
{% block content %}
|
||||
<h1>{{ feed.name }}</h1>
|
||||
<h1>{{ podcast.name }}</h1>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<fieldset>
|
||||
<label>
|
||||
Name
|
||||
<input name="name" value="{{ feed.name }}" required />
|
||||
<input name="name" value="{{ podcast.name }}" required />
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea name="description" required>{{ feed.description }}</textarea>
|
||||
<textarea name="description" required>{{ podcast.description }}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
Image
|
9
src/templates/admin_feeds.html.j2
Normal file
9
src/templates/admin_feeds.html.j2
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends 'layout.html.j2' %}
|
||||
{% block content %}
|
||||
<h1>Podcasts</h1>
|
||||
<ul>
|
||||
{% for podcast in podcasts %}
|
||||
<li><a href="/admin/{{ podcast.id }}">{{ podcast.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
|
@ -1,9 +0,0 @@
|
|||
{% extends 'layout.html.j2' %}
|
||||
{% block content %}
|
||||
<h1>Podcasts</h1>
|
||||
<ul>
|
||||
{% for id, podcast in repo.podcasts.items() %}
|
||||
<li><a href="/admin/{{ id }}">{{ podcast.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
71
uv.lock
71
uv.lock
|
@ -219,6 +219,30 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039 },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.14.0"
|
||||
|
@ -366,6 +390,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nanoid"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/9d/0250bf5935d88e214df469d35eccc0f6ff7e9db046fc8a9aeb4b2a192775/nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68", size = 3290 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/0d/8630f13998638dc01e187fadd2e5c6d42d127d08aeb4943d231664d6e539/nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb", size = 5844 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "11.1.0"
|
||||
|
@ -402,11 +435,13 @@ dependencies = [
|
|||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "ffmpeg-normalize" },
|
||||
{ name = "ffmpeg-python" },
|
||||
{ name = "nanoid" },
|
||||
{ name = "pillow" },
|
||||
{ name = "podgen" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sqlmodel" },
|
||||
{ name = "structlog" },
|
||||
]
|
||||
|
||||
|
@ -416,11 +451,13 @@ requires-dist = [
|
|||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
|
||||
{ name = "ffmpeg-normalize", specifier = ">=1.31.0" },
|
||||
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
|
||||
{ name = "nanoid", specifier = ">=2.0.0" },
|
||||
{ name = "pillow", specifier = ">=11.1.0" },
|
||||
{ name = "podgen", specifier = ">=1.1.0" },
|
||||
{ name = "pydantic", specifier = ">=2.10.5" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.7.1" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
||||
{ name = "structlog", specifier = ">=24.4.0" },
|
||||
]
|
||||
|
||||
|
@ -627,6 +664,40 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.37"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/20/93ea2518df4d7a14ebe9ace9ab8bb92aaf7df0072b9007644de74172b06c/sqlalchemy-2.0.37.tar.gz", hash = "sha256:12b28d99a9c14eaf4055810df1001557176716de0167b91026e648e65229bffb", size = 9626249 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/d1/e63e56ceab148e69f545703a74b90c8c6dc0a04a857e4e63a4c07a23cf91/SQLAlchemy-2.0.37-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c4096727193762e72ce9437e2a86a110cf081241919ce3fab8e89c02f6b6658", size = 2097968 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e5/93ce63310347062bd42aaa8b6785615c78539787ef4380252fcf8e2dcee3/SQLAlchemy-2.0.37-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4fb5ac86d8fe8151966814f6720996430462e633d225497566b3996966b9bdb", size = 2088445 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8c/d0e0081c09188dd26040fc8a09c7d87f539e1964df1ac60611b98ff2985a/SQLAlchemy-2.0.37-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e56a139bfe136a22c438478a86f8204c1eb5eed36f4e15c4224e4b9db01cb3e4", size = 3174880 },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/f7/3396038d8d4ea92c72f636a007e2fac71faae0b59b7e21af46b635243d09/SQLAlchemy-2.0.37-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f95fc8e3f34b5f6b3effb49d10ac97c569ec8e32f985612d9b25dd12d0d2e94", size = 3188226 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/33/7a1d85716b29c86a744ed43690e243cb0e9c32e3b68a67a97eaa6b49ef66/SQLAlchemy-2.0.37-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c505edd429abdfe3643fa3b2e83efb3445a34a9dc49d5f692dd087be966020e0", size = 3121425 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/11/fa63a77c88eb2f79bb8b438271fbacd66a546a438e4eaba32d62f11298e2/SQLAlchemy-2.0.37-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:12b0f1ec623cccf058cf21cb544f0e74656618165b083d78145cafde156ea7b6", size = 3149589 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/04/fcdd103b6871f2110460b8275d1c4828daa806997b0fa5a01c1cd7fd522d/SQLAlchemy-2.0.37-cp313-cp313-win32.whl", hash = "sha256:293f9ade06b2e68dd03cfb14d49202fac47b7bb94bffcff174568c951fbc7af2", size = 2070746 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/7c/e024719205bdc1465b7b7d3d22ece8e1ad57bc7d76ef6ed78bb5f812634a/SQLAlchemy-2.0.37-cp313-cp313-win_amd64.whl", hash = "sha256:d70f53a0646cc418ca4853da57cf3ddddbccb8c98406791f24426f2dd77fd0e2", size = 2094612 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/36/59cc97c365f2f79ac9f3f51446cae56dfd82c4f2dd98497e6be6de20fb91/SQLAlchemy-2.0.37-py3-none-any.whl", hash = "sha256:a8998bf9f8658bd3839cbc44ddbe982955641863da0c1efe5b00c1ab4f5c16b1", size = 1894113 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlmodel"
|
||||
version = "0.0.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "sqlalchemy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/39/8641040ab0d5e1d8a1c2325ae89a01ae659fc96c61a43d158fb71c9a0bf0/sqlmodel-0.0.22.tar.gz", hash = "sha256:7d37c882a30c43464d143e35e9ecaf945d88035e20117bf5ec2834a23cbe505e", size = 116392 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/b1/3af5104b716c420e40a6ea1b09886cae3a1b9f4538343875f637755cae5b/sqlmodel-0.0.22-py3-none-any.whl", hash = "sha256:a1ed13e28a1f4057cbf4ff6cdb4fc09e85702621d3259ba17b3c230bfb2f941b", size = 28276 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.41.3"
|
||||
|
|
Loading…
Reference in a new issue