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

{{ feed.name }}

+

{{ podcast.name }}

Actions: - Edit + Edit

Description: - {{ feed.description }} + {{ podcast.description }}

Subscribe: @@ -40,7 +40,7 @@ - {% for episode in feed.episodes %} + {% for episode in episodes %} {{ episode.name }} {{ episode.publish_date.strftime("%H:%M %d/%m/%Y") }} @@ -52,8 +52,8 @@ {% endif %} - Delete - Edit + Delete + Edit {% 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) diff --git a/templates/admin_feed_edit.html.j2 b/src/templates/admin_feed_edit.html.j2 similarity index 76% rename from templates/admin_feed_edit.html.j2 rename to src/templates/admin_feed_edit.html.j2 index 45355b8..a19edac 100644 --- a/templates/admin_feed_edit.html.j2 +++ b/src/templates/admin_feed_edit.html.j2 @@ -1,15 +1,15 @@ {% extends 'layout.html.j2' %} {% block content %} -

{{ feed.name }}

+

{{ podcast.name }}