diff --git a/Dockerfile b/Dockerfile
index 2c4f623..06e15c4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/data.py b/data.py
deleted file mode 100644
index 80d04da..0000000
--- a/data.py
+++ /dev/null
@@ -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())
diff --git a/main.py b/main.py
deleted file mode 100644
index 32aca1c..0000000
--- a/main.py
+++ /dev/null
@@ -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")
diff --git a/pyproject.toml b/pyproject.toml
index 9f2e451..880d717 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
]
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..3fdd2b2
--- /dev/null
+++ b/src/main.py
@@ -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")
diff --git a/src/models.py b/src/models.py
new file mode 100644
index 0000000..8f0a8ab
--- /dev/null
+++ b/src/models.py
@@ -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'}")
diff --git a/process.py b/src/process.py
similarity index 100%
rename from process.py
rename to src/process.py
diff --git a/settings.py b/src/settings.py
similarity index 84%
rename from settings.py
rename to src/settings.py
index cecc00f..ca12d4c 100644
--- a/settings.py
+++ b/src/settings.py
@@ -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_")
diff --git a/templates/admin_episode_edit.html.j2 b/src/templates/admin_episode_edit.html.j2
similarity index 100%
rename from templates/admin_episode_edit.html.j2
rename to src/templates/admin_episode_edit.html.j2
diff --git a/templates/admin_feed.html.j2 b/src/templates/admin_feed.html.j2
similarity index 86%
rename from templates/admin_feed.html.j2
rename to src/templates/admin_feed.html.j2
index 14e8528..c79f137 100644
--- a/templates/admin_feed.html.j2
+++ b/src/templates/admin_feed.html.j2
@@ -1,17 +1,17 @@
{% extends 'layout.html.j2' %}
{% block content %}
-{% if feed.image_filename %}
-
+{% if podcast.image_filename %}
+
{% endif %}
-
Description: - {{ feed.description }} + {{ podcast.description }}
Subscribe: @@ -40,7 +40,7 @@
- {% for episode in feed.episodes %} + {% for episode in episodes %}