use sqlite database

This commit is contained in:
Jake Walker 2025-01-14 18:23:51 +00:00
parent bbfbeddc55
commit 7c0ba4c2aa
15 changed files with 560 additions and 438 deletions

View file

@ -13,4 +13,4 @@ COPY . /opt
ENV PG_DIRECTORY=/work ENV PG_DIRECTORY=/work
ENV PG_UPLOADS_DIRECTORY=/uploads 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
View file

@ -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
View file

@ -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")

View file

@ -9,10 +9,12 @@ dependencies = [
"fastapi[standard]>=0.115.6", "fastapi[standard]>=0.115.6",
"ffmpeg-normalize>=1.31.0", "ffmpeg-normalize>=1.31.0",
"ffmpeg-python>=0.2.0", "ffmpeg-python>=0.2.0",
"nanoid>=2.0.0",
"pillow>=11.1.0", "pillow>=11.1.0",
"podgen>=1.1.0", "podgen>=1.1.0",
"pydantic>=2.10.5", "pydantic>=2.10.5",
"pydantic-settings>=2.7.1", "pydantic-settings>=2.7.1",
"python-multipart>=0.0.20", "python-multipart>=0.0.20",
"sqlmodel>=0.0.22",
"structlog>=24.4.0", "structlog>=24.4.0",
] ]

394
src/main.py Normal file
View 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
View 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'}")

View file

@ -1,5 +1,4 @@
from pathlib import Path from pathlib import Path
from typing import Set
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@ -8,7 +7,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
directory: Path = Field(default=Path.cwd() / "data") directory: Path = Field(default=Path.cwd() / "data")
uploads_directory: Path = Field(default=Path.cwd() / "uploads") uploads_directory: Path = Field(default=Path.cwd() / "uploads")
feeds: Set[str] = Field(default={"default"})
model_config = SettingsConfigDict(env_nested_delimiter="__", env_prefix="PG_") model_config = SettingsConfigDict(env_nested_delimiter="__", env_prefix="PG_")

View file

@ -1,17 +1,17 @@
{% extends 'layout.html.j2' %} {% extends 'layout.html.j2' %}
{% block content %} {% block content %}
{% if feed.image_filename %} {% if podcast.image_filename %}
<img src="/{{ id }}/{{ feed.image_filename }}" width="256px" /> <img src="/{{ podcast.id }}/{{ podcast.image_filename }}" width="256px" />
<br><br> <br><br>
{% endif %} {% endif %}
<h1>{{ feed.name }}</h1> <h1>{{ podcast.name }}</h1>
<p> <p>
<b>Actions:</b> <b>Actions:</b>
<a href="/admin/{{ id }}/edit">Edit</a> <a href="/admin/{{ podcast.id }}/edit">Edit</a>
</p> </p>
<p> <p>
<b>Description:</b> <b>Description:</b>
{{ feed.description }} {{ podcast.description }}
</p> </p>
<p> <p>
<b>Subscribe:</b> <b>Subscribe:</b>
@ -40,7 +40,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for episode in feed.episodes %} {% for episode in episodes %}
<tr> <tr>
<th scope="row">{{ episode.name }}</th> <th scope="row">{{ episode.name }}</th>
<td>{{ episode.publish_date.strftime("%H:%M %d/%m/%Y") }}</td> <td>{{ episode.publish_date.strftime("%H:%M %d/%m/%Y") }}</td>
@ -52,8 +52,8 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<a href="/admin/{{ id }}/{{ episode.id }}/delete">Delete</a> <a href="/admin/{{ podcast.id }}/{{ episode.id }}/delete">Delete</a>
<a href="/admin/{{ id }}/{{ episode.id }}/edit">Edit</a> <a href="/admin/{{ podcast.id }}/{{ episode.id }}/edit">Edit</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -85,7 +85,7 @@
setFormEnabled(false); setFormEnabled(false);
const uploader = new HugeUploader({ const uploader = new HugeUploader({
endpoint: "/admin/{{ id }}/upload", endpoint: "/admin/{{ podcast.id }}/upload",
file: file, file: file,
headers: { headers: {
"name": encodeURI(file.name) "name": encodeURI(file.name)

View file

@ -1,15 +1,15 @@
{% extends 'layout.html.j2' %} {% extends 'layout.html.j2' %}
{% block content %} {% block content %}
<h1>{{ feed.name }}</h1> <h1>{{ podcast.name }}</h1>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<fieldset> <fieldset>
<label> <label>
Name Name
<input name="name" value="{{ feed.name }}" required /> <input name="name" value="{{ podcast.name }}" required />
</label> </label>
<label> <label>
Description Description
<textarea name="description" required>{{ feed.description }}</textarea> <textarea name="description" required>{{ podcast.description }}</textarea>
</label> </label>
<label> <label>
Image Image

View 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 %}

View file

@ -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
View file

@ -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 }, { 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]] [[package]]
name = "h11" name = "h11"
version = "0.14.0" 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 }, { 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]] [[package]]
name = "pillow" name = "pillow"
version = "11.1.0" version = "11.1.0"
@ -402,11 +435,13 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "ffmpeg-normalize" }, { name = "ffmpeg-normalize" },
{ name = "ffmpeg-python" }, { name = "ffmpeg-python" },
{ name = "nanoid" },
{ name = "pillow" }, { name = "pillow" },
{ name = "podgen" }, { name = "podgen" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "sqlmodel" },
{ name = "structlog" }, { name = "structlog" },
] ]
@ -416,11 +451,13 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
{ name = "ffmpeg-normalize", specifier = ">=1.31.0" }, { name = "ffmpeg-normalize", specifier = ">=1.31.0" },
{ name = "ffmpeg-python", specifier = ">=0.2.0" }, { name = "ffmpeg-python", specifier = ">=0.2.0" },
{ name = "nanoid", specifier = ">=2.0.0" },
{ name = "pillow", specifier = ">=11.1.0" }, { name = "pillow", specifier = ">=11.1.0" },
{ name = "podgen", specifier = ">=1.1.0" }, { name = "podgen", specifier = ">=1.1.0" },
{ name = "pydantic", specifier = ">=2.10.5" }, { name = "pydantic", specifier = ">=2.10.5" },
{ name = "pydantic-settings", specifier = ">=2.7.1" }, { name = "pydantic-settings", specifier = ">=2.7.1" },
{ name = "python-multipart", specifier = ">=0.0.20" }, { name = "python-multipart", specifier = ">=0.0.20" },
{ name = "sqlmodel", specifier = ">=0.0.22" },
{ name = "structlog", specifier = ">=24.4.0" }, { 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 }, { 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]] [[package]]
name = "starlette" name = "starlette"
version = "0.41.3" version = "0.41.3"