This commit is contained in:
parent
2d424d0be6
commit
7d60654d37
64 changed files with 4877 additions and 802 deletions
|
@ -1,3 +1,5 @@
|
||||||
data
|
data
|
||||||
uploads
|
uploads
|
||||||
.venv
|
.venv
|
||||||
|
client/node_modules
|
||||||
|
client/.vite
|
||||||
|
|
|
@ -11,5 +11,5 @@ charset = utf-8
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
[*.{yml,yaml}]
|
[*.{yml,yaml,ts,tsx,js,jsx,json}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
14
Dockerfile
14
Dockerfile
|
@ -1,3 +1,14 @@
|
||||||
|
FROM node:18-alpine AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY client/ .
|
||||||
|
|
||||||
|
RUN npm i -g corepack@latest \
|
||||||
|
&& corepack enable \
|
||||||
|
&& pnpm install --frozen-lockfile \
|
||||||
|
&& pnpm run build
|
||||||
|
|
||||||
FROM python:alpine
|
FROM python:alpine
|
||||||
|
|
||||||
WORKDIR /opt
|
WORKDIR /opt
|
||||||
|
@ -9,8 +20,9 @@ RUN apk add --update --no-cache ffmpeg \
|
||||||
&& uv sync --frozen
|
&& uv sync --frozen
|
||||||
|
|
||||||
COPY . /opt
|
COPY . /opt
|
||||||
|
COPY --from=frontend-build /app/dist /opt/dist
|
||||||
|
|
||||||
ENV PG_DIRECTORY=/work
|
ENV PG_DIRECTORY=/work
|
||||||
ENV PG_UPLOADS_DIRECTORY=/uploads
|
ENV PG_UPLOADS_DIRECTORY=/uploads
|
||||||
|
|
||||||
CMD ["uv", "run", "fastapi", "run", "/opt/src/main.py", "--port", "8000"]
|
CMD ["uv", "run", "uvicorn", "--app-dir", "/opt/src", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
69
README.md
69
README.md
|
@ -2,58 +2,51 @@
|
||||||
|
|
||||||
This is a really simple project allowing for quick creation of podcasts. The server provides a simple admin panel to manage podcasts, and the ability to upload episodes. Any uploaded episodes have their audio level normalised, and are re-encoded to AAC which tends to use less disk space than original formats.
|
This is a really simple project allowing for quick creation of podcasts. The server provides a simple admin panel to manage podcasts, and the ability to upload episodes. Any uploaded episodes have their audio level normalised, and are re-encoded to AAC which tends to use less disk space than original formats.
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
This is designed to sit behind a reverse proxy set up with forward authentication to protect the admin panel. Although, basic authentication should work too.
|
|
||||||
|
|
||||||
```docker
|
|
||||||
services:
|
|
||||||
server:
|
|
||||||
image: git.jakew.me/jakew/podcast-generator:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- PG_FEEDS=["mypodcast"]
|
|
||||||
volumes:
|
|
||||||
- data:/work
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
data:
|
|
||||||
```
|
|
||||||
|
|
||||||
Example reverse proxy configuration for Caddy:
|
|
||||||
|
|
||||||
```caddy
|
|
||||||
podcast.example.org {
|
|
||||||
# rewrite the base admin path to have a trailing slash
|
|
||||||
@admin path /admin /admin/*
|
|
||||||
|
|
||||||
# apply forward authentication to just admin routes
|
|
||||||
# this is only an example, refer to your auth provider documentation
|
|
||||||
forward_auth @admin https://auth.example.org {
|
|
||||||
copy_headers X-Auth-User ...
|
|
||||||
}
|
|
||||||
|
|
||||||
# proxy requests to the app
|
|
||||||
reverse_proxy podcast-server:8000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Name | Default | Description |
|
| Name | Default | Description |
|
||||||
| ---- | ------- | ----------- |
|
| ---- | ------- | ----------- |
|
||||||
| `PG_DIRECTORY` | `./data` (`/data` for Docker) | Where any files are stored. This includes episodes, images and application data. |
|
| `PG_DIRECTORY` | `./data` (`/data` for Docker) | Where any files are stored. This includes episodes, images and application data. |
|
||||||
| `PG_UPLOADS_DIRECTORY` | `./uploads` (`/uploads` for Docker) | Where any currently uploading files are stored. This directory does not need persistence in Docker. |
|
| `PG_UPLOADS_DIRECTORY` | `./uploads` (`/uploads` for Docker) | Where any currently uploading files are stored. This directory does not need persistence in Docker. |
|
||||||
| `PG_FEEDS` | `["default"]` | A JSON array of the podcast names to be initially created. |
|
| `PG_OIDC_AUTHORIZE_URL` _(required)_ | | The OpenID Connect authorize endpoint from the authentication provider. |
|
||||||
|
| `PG_OIDC_TOKEN_URL` _(required)_ | | The OpenID Connect token endpoint from the authentication provider. |
|
||||||
|
| `PG_OIDC_JWKS_URL` _(required)_ | | The OpenID Connect JWKS endpoint from the authentication provider. |
|
||||||
|
| `PG_OIDC_AUTHORITY` _(required)_ | | The OpenID Connect issuer URL from the authentication provider. |
|
||||||
|
| `PG_OIDC_PERMITTED_JWT_AUDIENCES` _(required)_ | | A list of valid audiences from the authentication provider. |
|
||||||
|
| `PG_OIDC_CLIENT_ID` _(required)_ | | The OpenID Connect client ID from the authentication provider. |
|
||||||
|
| `PG_OIDC_SUB_JWT_ATTRIBUTE` | `sub` | The JWT token attribute that contains the user's ID. |
|
||||||
|
| `PG_OIDC_NAME_JWT_ATTRIBUTE` | `name` | The JWT token attribute that contains the user's name or username. |
|
||||||
|
| `PG_OIDC_SCOPES` | `["openid", "email", "profile", "offline_access"]` | OpenID Connect scopes. |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
This project is made using Python and FastAPI. To get started, ensure you have [Python](https://www.python.org/) and the [uv](https://docs.astral.sh/uv/) package manager installed.
|
### Backend
|
||||||
|
|
||||||
|
This backend of the project is made using Python and FastAPI. To get started, ensure you have [Python](https://www.python.org/) and the [uv](https://docs.astral.sh/uv/) package manager installed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# install dependencies
|
# install dependencies
|
||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
|
# migrate the database
|
||||||
|
uv run alembic upgrade head
|
||||||
|
|
||||||
# run server in development mode
|
# run server in development mode
|
||||||
uv run fastapi dev main.py
|
uv run fastapi dev main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
The frontend of the project is made using Solid.js. To get started, ensure you have [Node.js]() installed, and corepack enabled (with `corepack enable`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# run server in development mode
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
After changing the backend, run `pnpm run generate-client` to generate the API client for the frontend from the server's OpenAPI schema.
|
||||||
|
|
43
Taskfile.yml
Normal file
43
Taskfile.yml
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
setup:
|
||||||
|
deps: [backend:setup, frontend:setup]
|
||||||
|
|
||||||
|
backend:setup:
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- uv sync
|
||||||
|
|
||||||
|
backend:migrate:
|
||||||
|
cmds:
|
||||||
|
- uv run alembic upgrade head
|
||||||
|
|
||||||
|
backend:dev:
|
||||||
|
deps: [backend:migrate]
|
||||||
|
cmds:
|
||||||
|
- uv run fastapi dev src/main.py
|
||||||
|
|
||||||
|
frontend:setup:
|
||||||
|
internal: true
|
||||||
|
dir: ./client
|
||||||
|
cmds:
|
||||||
|
- pnpm install
|
||||||
|
|
||||||
|
frontend:generate:
|
||||||
|
internal: true
|
||||||
|
dir: ./client
|
||||||
|
cmds:
|
||||||
|
- pnpm generate-client
|
||||||
|
|
||||||
|
frontend:dev:
|
||||||
|
dir: ./client
|
||||||
|
deps: [frontend:generate]
|
||||||
|
cmds:
|
||||||
|
- pnpm run dev
|
||||||
|
|
||||||
|
frontend:build:
|
||||||
|
dir: ./client
|
||||||
|
deps: [frontend:generate]
|
||||||
|
cmds:
|
||||||
|
- pnpm run build
|
119
alembic.ini
Normal file
119
alembic.ini
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||||
|
script_location = migrations
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = ./src
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||||
|
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to migrations/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
# version_path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
sqlalchemy.url = sqlite:///data/data.db
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
28
client/README.md
Normal file
28
client/README.md
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install # or pnpm install or yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm run dev`
|
||||||
|
|
||||||
|
Runs the app in the development mode.<br>
|
||||||
|
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `dist` folder.<br>
|
||||||
|
It correctly bundles Solid in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.<br>
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Learn more about deploying your application with the [documentations](https://vite.dev/guide/static-deploy.html)
|
14
client/index.html
Normal file
14
client/index.html
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<title>Vite + Solid + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
13
client/openapi-ts.config.ts
Normal file
13
client/openapi-ts.config.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
client: "@hey-api/client-fetch",
|
||||||
|
input: {
|
||||||
|
path: "http://localhost:8000/openapi.json"
|
||||||
|
},
|
||||||
|
output: "src/client",
|
||||||
|
plugins: [
|
||||||
|
...defaultPlugins,
|
||||||
|
"@tanstack/solid-query"
|
||||||
|
]
|
||||||
|
})
|
31
client/package.json
Normal file
31
client/package.json
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"generate-client": "openapi-ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hey-api/client-fetch": "^0.7.0",
|
||||||
|
"@solidjs/router": "^0.15.3",
|
||||||
|
"@soorria/solid-dropzone": "^1.0.1",
|
||||||
|
"@tanstack/solid-query": "^5.64.1",
|
||||||
|
"bulma": "^1.0.3",
|
||||||
|
"huge-uploader": "^1.0.6",
|
||||||
|
"lucide-solid": "^0.473.0",
|
||||||
|
"oidc-client-ts": "^3.1.0",
|
||||||
|
"solid-js": "^1.9.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@hey-api/openapi-ts": "^0.62.1",
|
||||||
|
"event-target-shim": "^6.0.2",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vite-plugin-solid": "^2.11.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@8.12.1+sha1.aa961ffce9b6eaa56307d9b5ff7e984f25b7eb58"
|
||||||
|
}
|
1518
client/pnpm-lock.yaml
Normal file
1518
client/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
1
client/public/vite.svg
Normal file
1
client/public/vite.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
55
client/src/admin-layout.tsx
Normal file
55
client/src/admin-layout.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { createQuery } from "@tanstack/solid-query";
|
||||||
|
import { children, createSignal, ErrorBoundary, ParentProps, Suspense } from "solid-js";
|
||||||
|
import { readUserOptions } from "./client/@tanstack/solid-query.gen";
|
||||||
|
import { logout } from "./auth";
|
||||||
|
|
||||||
|
export default function AdminLayout(props: ParentProps) {
|
||||||
|
const content = children(() => props.children);
|
||||||
|
const [navbarToggled, setNavbarToggled] = createSignal(false);
|
||||||
|
|
||||||
|
const userQuery = createQuery(() => ({
|
||||||
|
...readUserOptions(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a class="navbar-item" href="/admin">
|
||||||
|
<b>Podcast Server</b>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a role="button" class="navbar-burger" classList={{ "is-active": navbarToggled() }} aria-label="menu" aria-expanded="false" onClick={() => setNavbarToggled(!navbarToggled())}>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="navbarBasicExample" class="navbar-menu is-active" classList={{ "is-active": navbarToggled() }}>
|
||||||
|
<div class="navbar-start">
|
||||||
|
<A class="navbar-item" href="/admin">
|
||||||
|
Podcasts
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-end">
|
||||||
|
<ErrorBoundary fallback={() => <p class="navbar-item">Error</p>}>
|
||||||
|
<Suspense fallback={<p class="navbar-item">Loading...</p>}>
|
||||||
|
<p class="navbar-item">Hey {userQuery.data?.user_name.split(" ")[0]}!</p>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
<div class="navbar-item">
|
||||||
|
<a class="button is-light" onClick={logout}>
|
||||||
|
Log out
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{content()}
|
||||||
|
</main>
|
||||||
|
</>;
|
||||||
|
}
|
81
client/src/auth.ts
Normal file
81
client/src/auth.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { User, UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import { getAppConfig } from "./client";
|
||||||
|
|
||||||
|
let userManager: UserManager | null = null;
|
||||||
|
|
||||||
|
export async function getUserManager() {
|
||||||
|
if (userManager) return userManager;
|
||||||
|
|
||||||
|
const res = await getAppConfig();
|
||||||
|
const config = res.data;
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new Error("Failed to get app configuration from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
userManager = new UserManager({
|
||||||
|
authority: config.oidc_authority,
|
||||||
|
client_id: config.oidc_client_id,
|
||||||
|
redirect_uri: window.location.origin + "/callback",
|
||||||
|
response_type: "code",
|
||||||
|
scope: config.oidc_scopes.join(" "),
|
||||||
|
userStore: new WebStorageStateStore({
|
||||||
|
store: window.localStorage,
|
||||||
|
}),
|
||||||
|
automaticSilentRenew: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return userManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user, setUser] = createSignal<User | null>(null);
|
||||||
|
|
||||||
|
export async function login() {
|
||||||
|
await (await getUserManager()).signinRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
await (await getUserManager()).signoutRedirect();
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getToken() {
|
||||||
|
const user = await (await getUserManager()).getUser();
|
||||||
|
return user?.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUser() {
|
||||||
|
const currentUser = await (await getUserManager()).getUser();
|
||||||
|
if (currentUser && !currentUser.expired) {
|
||||||
|
setUser(currentUser);
|
||||||
|
} else {
|
||||||
|
await refreshToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshToken() {
|
||||||
|
try {
|
||||||
|
const currentUser = await (await getUserManager()).getUser();
|
||||||
|
if (!currentUser?.refresh_token) {
|
||||||
|
console.error("No refresh token available, re-authentication required");
|
||||||
|
setUser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedUser = await (await getUserManager()).signinSilent();
|
||||||
|
|
||||||
|
if (refreshedUser) {
|
||||||
|
console.log("Token refreshed");
|
||||||
|
setUser(refreshedUser);
|
||||||
|
} else {
|
||||||
|
console.error("Token refresh failed");
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error refreshing token:", err);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { user };
|
373
client/src/client/@tanstack/solid-query.gen.ts
Normal file
373
client/src/client/@tanstack/solid-query.gen.ts
Normal file
|
@ -0,0 +1,373 @@
|
||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Options } from '@hey-api/client-fetch';
|
||||||
|
import { queryOptions, type MutationOptions } from '@tanstack/solid-query';
|
||||||
|
import type { GetAppConfigData, ReadUserData, ReadPodcastsData, CreatePodcastData, CreatePodcastError, CreatePodcastResponse, DeletePodcastData, DeletePodcastError, ReadPodcastData, UpdatePodcastData, UpdatePodcastError, UpdatePodcastResponse, UpdatePodcastImageData, UpdatePodcastImageError, UpdatePodcastImageResponse, ReadEpisodesData, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, UpdateEpisodeData, UpdateEpisodeError, UpdateEpisodeResponse, EpisodeAdditionalUploadData, EpisodeAdditionalUploadError, EpisodeAdditionalUploadResponse, GetPodcastFeedData, GetEpisodeOrCoverData } from '../types.gen';
|
||||||
|
import { getAppConfig, readUser, readPodcasts, createPodcast, deletePodcast, readPodcast, updatePodcast, updatePodcastImage, readEpisodes, adminUploadEpisode, deleteEpisode, readEpisode, updateEpisode, episodeAdditionalUpload, getPodcastFeed, getEpisodeOrCover, client } from '../sdk.gen';
|
||||||
|
|
||||||
|
type QueryKey<TOptions extends Options> = [
|
||||||
|
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
||||||
|
_id: string;
|
||||||
|
_infinite?: boolean;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions, infinite?: boolean): QueryKey<TOptions>[0] => {
|
||||||
|
const params: QueryKey<TOptions>[0] = { _id: id, baseUrl: (options?.client ?? client).getConfig().baseUrl } as QueryKey<TOptions>[0];
|
||||||
|
if (infinite) {
|
||||||
|
params._infinite = infinite;
|
||||||
|
}
|
||||||
|
if (options?.body) {
|
||||||
|
params.body = options.body;
|
||||||
|
}
|
||||||
|
if (options?.headers) {
|
||||||
|
params.headers = options.headers;
|
||||||
|
}
|
||||||
|
if (options?.path) {
|
||||||
|
params.path = options.path;
|
||||||
|
}
|
||||||
|
if (options?.query) {
|
||||||
|
params.query = options.query;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAppConfigQueryKey = (options?: Options<GetAppConfigData>) => [
|
||||||
|
createQueryKey('getAppConfig', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getAppConfigOptions = (options?: Options<GetAppConfigData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getAppConfig({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getAppConfigQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readUserQueryKey = (options?: Options<ReadUserData>) => [
|
||||||
|
createQueryKey('readUser', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const readUserOptions = (options?: Options<ReadUserData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await readUser({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: readUserQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readPodcastsQueryKey = (options?: Options<ReadPodcastsData>) => [
|
||||||
|
createQueryKey('readPodcasts', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const readPodcastsOptions = (options?: Options<ReadPodcastsData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await readPodcasts({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: readPodcastsQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPodcastQueryKey = (options: Options<CreatePodcastData>) => [
|
||||||
|
createQueryKey('createPodcast', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const createPodcastOptions = (options: Options<CreatePodcastData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await createPodcast({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: createPodcastQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPodcastMutation = (options?: Partial<Options<CreatePodcastData>>) => {
|
||||||
|
const mutationOptions: MutationOptions<CreatePodcastResponse, CreatePodcastError, Options<CreatePodcastData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await createPodcast({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deletePodcastMutation = (options?: Partial<Options<DeletePodcastData>>) => {
|
||||||
|
const mutationOptions: MutationOptions<unknown, DeletePodcastError, Options<DeletePodcastData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await deletePodcast({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readPodcastQueryKey = (options: Options<ReadPodcastData>) => [
|
||||||
|
createQueryKey('readPodcast', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const readPodcastOptions = (options: Options<ReadPodcastData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await readPodcast({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: readPodcastQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePodcastMutation = (options?: Partial<Options<UpdatePodcastData>>) => {
|
||||||
|
const mutationOptions: MutationOptions<UpdatePodcastResponse, UpdatePodcastError, Options<UpdatePodcastData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await updatePodcast({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePodcastImageQueryKey = (options: Options<UpdatePodcastImageData>) => [
|
||||||
|
createQueryKey('updatePodcastImage', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const updatePodcastImageOptions = (options: Options<UpdatePodcastImageData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await updatePodcastImage({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: updatePodcastImageQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePodcastImageMutation = (options?: Partial<Options<UpdatePodcastImageData>>) => {
|
||||||
|
const mutationOptions: MutationOptions<UpdatePodcastImageResponse, UpdatePodcastImageError, Options<UpdatePodcastImageData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await updatePodcastImage({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readEpisodesQueryKey = (options: Options<ReadEpisodesData>) => [
|
||||||
|
createQueryKey('readEpisodes', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const readEpisodesOptions = (options: Options<ReadEpisodesData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await readEpisodes({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: readEpisodesQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminUploadEpisodeQueryKey = (options: Options<AdminUploadEpisodeData>) => [
|
||||||
|
createQueryKey('adminUploadEpisode', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const adminUploadEpisodeOptions = (options: Options<AdminUploadEpisodeData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await adminUploadEpisode({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: adminUploadEpisodeQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminUploadEpisodeMutation = (options?: Partial<Options<AdminUploadEpisodeData>>) => {
|
||||||
|
const mutationOptions: MutationOptions<unknown, AdminUploadEpisodeError, Options<AdminUploadEpisodeData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await adminUploadEpisode({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteEpisodeMutation = (options?: Partial<Options<DeleteEpisodeData>>) => {
|
||||||
|
const mutationOptions: MutationOptions<unknown, DeleteEpisodeError, Options<DeleteEpisodeData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await deleteEpisode({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readEpisodeQueryKey = (options: Options<ReadEpisodeData>) => [
|
||||||
|
createQueryKey('readEpisode', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const readEpisodeOptions = (options: Options<ReadEpisodeData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await readEpisode({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: readEpisodeQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEpisodeMutation = (options?: Partial<Options<UpdateEpisodeData>>) => {
|
||||||
|
const mutationOptions: MutationOptions<UpdateEpisodeResponse, UpdateEpisodeError, Options<UpdateEpisodeData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await updateEpisode({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const episodeAdditionalUploadQueryKey = (options: Options<EpisodeAdditionalUploadData>) => [
|
||||||
|
createQueryKey('episodeAdditionalUpload', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const episodeAdditionalUploadOptions = (options: Options<EpisodeAdditionalUploadData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await episodeAdditionalUpload({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: episodeAdditionalUploadQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const episodeAdditionalUploadMutation = (options?: Partial<Options<EpisodeAdditionalUploadData>>) => {
|
||||||
|
const mutationOptions: MutationOptions<EpisodeAdditionalUploadResponse, EpisodeAdditionalUploadError, Options<EpisodeAdditionalUploadData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await episodeAdditionalUpload({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPodcastFeedQueryKey = (options: Options<GetPodcastFeedData>) => [
|
||||||
|
createQueryKey('getPodcastFeed', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getPodcastFeedOptions = (options: Options<GetPodcastFeedData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getPodcastFeed({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getPodcastFeedQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEpisodeOrCoverQueryKey = (options: Options<GetEpisodeOrCoverData>) => [
|
||||||
|
createQueryKey('getEpisodeOrCover', options)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getEpisodeOrCoverOptions = (options: Options<GetEpisodeOrCoverData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getEpisodeOrCover({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getEpisodeOrCoverQueryKey(options)
|
||||||
|
});
|
||||||
|
};
|
3
client/src/client/index.ts
Normal file
3
client/src/client/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
export * from './types.gen';
|
||||||
|
export * from './sdk.gen';
|
193
client/src/client/sdk.gen.ts
Normal file
193
client/src/client/sdk.gen.ts
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { createClient, createConfig, type Options, formDataBodySerializer } from '@hey-api/client-fetch';
|
||||||
|
import type { GetAppConfigData, GetAppConfigResponse, ReadUserData, ReadUserResponse, ReadPodcastsData, ReadPodcastsResponse, CreatePodcastData, CreatePodcastResponse, CreatePodcastError, DeletePodcastData, DeletePodcastError, ReadPodcastData, ReadPodcastResponse, ReadPodcastError, UpdatePodcastData, UpdatePodcastResponse, UpdatePodcastError, UpdatePodcastImageData, UpdatePodcastImageResponse, UpdatePodcastImageError, ReadEpisodesData, ReadEpisodesResponse, ReadEpisodesError, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, ReadEpisodeResponse, ReadEpisodeError, UpdateEpisodeData, UpdateEpisodeResponse, UpdateEpisodeError, EpisodeAdditionalUploadData, EpisodeAdditionalUploadResponse, EpisodeAdditionalUploadError, GetPodcastFeedData, GetPodcastFeedError, GetEpisodeOrCoverData, GetEpisodeOrCoverError } from './types.gen';
|
||||||
|
|
||||||
|
export const client = createClient(createConfig());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get App Config
|
||||||
|
*/
|
||||||
|
export const getAppConfig = <ThrowOnError extends boolean = false>(options?: Options<GetAppConfigData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<GetAppConfigResponse, unknown, ThrowOnError>({
|
||||||
|
url: '/api/config',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read User
|
||||||
|
*/
|
||||||
|
export const readUser = <ThrowOnError extends boolean = false>(options?: Options<ReadUserData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<ReadUserResponse, unknown, ThrowOnError>({
|
||||||
|
url: '/api/user',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Podcasts
|
||||||
|
*/
|
||||||
|
export const readPodcasts = <ThrowOnError extends boolean = false>(options?: Options<ReadPodcastsData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<ReadPodcastsResponse, unknown, ThrowOnError>({
|
||||||
|
url: '/api/podcasts',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Podcast
|
||||||
|
*/
|
||||||
|
export const createPodcast = <ThrowOnError extends boolean = false>(options: Options<CreatePodcastData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).post<CreatePodcastResponse, CreatePodcastError, ThrowOnError>({
|
||||||
|
url: '/api/podcasts',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Podcast
|
||||||
|
*/
|
||||||
|
export const deletePodcast = <ThrowOnError extends boolean = false>(options: Options<DeletePodcastData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).delete<unknown, DeletePodcastError, ThrowOnError>({
|
||||||
|
url: '/api/podcasts/{podcast_id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Podcast
|
||||||
|
*/
|
||||||
|
export const readPodcast = <ThrowOnError extends boolean = false>(options: Options<ReadPodcastData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<ReadPodcastResponse, ReadPodcastError, ThrowOnError>({
|
||||||
|
url: '/api/podcasts/{podcast_id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Podcast
|
||||||
|
*/
|
||||||
|
export const updatePodcast = <ThrowOnError extends boolean = false>(options: Options<UpdatePodcastData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).patch<UpdatePodcastResponse, UpdatePodcastError, ThrowOnError>({
|
||||||
|
url: '/api/podcasts/{podcast_id}',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Podcast Image
|
||||||
|
*/
|
||||||
|
export const updatePodcastImage = <ThrowOnError extends boolean = false>(options: Options<UpdatePodcastImageData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).post<UpdatePodcastImageResponse, UpdatePodcastImageError, ThrowOnError>({
|
||||||
|
...formDataBodySerializer,
|
||||||
|
url: '/api/podcasts/{podcast_id}/image',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': null,
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Episodes
|
||||||
|
*/
|
||||||
|
export const readEpisodes = <ThrowOnError extends boolean = false>(options: Options<ReadEpisodesData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<ReadEpisodesResponse, ReadEpisodesError, ThrowOnError>({
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin Upload Episode
|
||||||
|
*/
|
||||||
|
export const adminUploadEpisode = <ThrowOnError extends boolean = false>(options: Options<AdminUploadEpisodeData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).post<unknown, AdminUploadEpisodeError, ThrowOnError>({
|
||||||
|
...formDataBodySerializer,
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': null,
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Episode
|
||||||
|
*/
|
||||||
|
export const deleteEpisode = <ThrowOnError extends boolean = false>(options: Options<DeleteEpisodeData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).delete<unknown, DeleteEpisodeError, ThrowOnError>({
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes/{episode_id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Episode
|
||||||
|
*/
|
||||||
|
export const readEpisode = <ThrowOnError extends boolean = false>(options: Options<ReadEpisodeData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<ReadEpisodeResponse, ReadEpisodeError, ThrowOnError>({
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes/{episode_id}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Episode
|
||||||
|
*/
|
||||||
|
export const updateEpisode = <ThrowOnError extends boolean = false>(options: Options<UpdateEpisodeData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).patch<UpdateEpisodeResponse, UpdateEpisodeError, ThrowOnError>({
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes/{episode_id}',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Episode Additional Upload
|
||||||
|
*/
|
||||||
|
export const episodeAdditionalUpload = <ThrowOnError extends boolean = false>(options: Options<EpisodeAdditionalUploadData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).post<EpisodeAdditionalUploadResponse, EpisodeAdditionalUploadError, ThrowOnError>({
|
||||||
|
...formDataBodySerializer,
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes/{episode_id}',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': null,
|
||||||
|
...options?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Podcast Feed
|
||||||
|
*/
|
||||||
|
export const getPodcastFeed = <ThrowOnError extends boolean = false>(options: Options<GetPodcastFeedData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<unknown, GetPodcastFeedError, ThrowOnError>({
|
||||||
|
url: '/{podcast_id}.xml',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Episode Or Cover
|
||||||
|
*/
|
||||||
|
export const getEpisodeOrCover = <ThrowOnError extends boolean = false>(options: Options<GetEpisodeOrCoverData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<unknown, GetEpisodeOrCoverError, ThrowOnError>({
|
||||||
|
url: '/{podcast_id}/{filename}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
464
client/src/client/types.gen.ts
Normal file
464
client/src/client/types.gen.ts
Normal file
|
@ -0,0 +1,464 @@
|
||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type AppConfig = {
|
||||||
|
oidc_authority: string;
|
||||||
|
oidc_client_id: string;
|
||||||
|
oidc_scopes: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BodyAdminUploadEpisodeApiPodcastsPodcastIdEpisodesPost = {
|
||||||
|
file: Blob | File;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BodyEpisodeAdditionalUploadApiPodcastsPodcastIdEpisodesEpisodeIdPost = {
|
||||||
|
file: Blob | File;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BodyUpdatePodcastImageApiPodcastsPodcastIdImagePost = {
|
||||||
|
image: Blob | File;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CurrentUser = {
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HttpValidationError = {
|
||||||
|
detail?: Array<ValidationError>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PodcastCreate = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
explicit?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PodcastEpisodePublic = {
|
||||||
|
name: string;
|
||||||
|
duration?: number | null;
|
||||||
|
description?: string | null;
|
||||||
|
file_hash: string;
|
||||||
|
file_size: number;
|
||||||
|
publish_date?: string;
|
||||||
|
id: string;
|
||||||
|
podcast_id: string;
|
||||||
|
description_html: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PodcastEpisodeUpdate = {
|
||||||
|
name: string | null;
|
||||||
|
description: string | null;
|
||||||
|
publish_date?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PodcastPublic = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
explicit?: boolean;
|
||||||
|
id: string;
|
||||||
|
image_filename?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PodcastUpdate = {
|
||||||
|
name: string | null;
|
||||||
|
description: string | null;
|
||||||
|
explicit?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationError = {
|
||||||
|
loc: Array<string | number>;
|
||||||
|
msg: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAppConfigData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/config';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAppConfigResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: AppConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAppConfigResponse = GetAppConfigResponses[keyof GetAppConfigResponses];
|
||||||
|
|
||||||
|
export type ReadUserData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/user';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadUserResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: CurrentUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadUserResponse = ReadUserResponses[keyof ReadUserResponses];
|
||||||
|
|
||||||
|
export type ReadPodcastsData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadPodcastsResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Array<PodcastPublic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadPodcastsResponse = ReadPodcastsResponses[keyof ReadPodcastsResponses];
|
||||||
|
|
||||||
|
export type CreatePodcastData = {
|
||||||
|
body: PodcastCreate;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatePodcastErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatePodcastError = CreatePodcastErrors[keyof CreatePodcastErrors];
|
||||||
|
|
||||||
|
export type CreatePodcastResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: PodcastPublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatePodcastResponse = CreatePodcastResponses[keyof CreatePodcastResponses];
|
||||||
|
|
||||||
|
export type DeletePodcastData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts/{podcast_id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeletePodcastErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeletePodcastError = DeletePodcastErrors[keyof DeletePodcastErrors];
|
||||||
|
|
||||||
|
export type DeletePodcastResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadPodcastData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts/{podcast_id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadPodcastErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadPodcastError = ReadPodcastErrors[keyof ReadPodcastErrors];
|
||||||
|
|
||||||
|
export type ReadPodcastResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: PodcastPublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadPodcastResponse = ReadPodcastResponses[keyof ReadPodcastResponses];
|
||||||
|
|
||||||
|
export type UpdatePodcastData = {
|
||||||
|
body?: PodcastUpdate;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts/{podcast_id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePodcastErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePodcastError = UpdatePodcastErrors[keyof UpdatePodcastErrors];
|
||||||
|
|
||||||
|
export type UpdatePodcastResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: PodcastPublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePodcastResponse = UpdatePodcastResponses[keyof UpdatePodcastResponses];
|
||||||
|
|
||||||
|
export type UpdatePodcastImageData = {
|
||||||
|
body: BodyUpdatePodcastImageApiPodcastsPodcastIdImagePost;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts/{podcast_id}/image';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePodcastImageErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePodcastImageError = UpdatePodcastImageErrors[keyof UpdatePodcastImageErrors];
|
||||||
|
|
||||||
|
export type UpdatePodcastImageResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: PodcastPublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePodcastImageResponse = UpdatePodcastImageResponses[keyof UpdatePodcastImageResponses];
|
||||||
|
|
||||||
|
export type ReadEpisodesData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadEpisodesErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadEpisodesError = ReadEpisodesErrors[keyof ReadEpisodesErrors];
|
||||||
|
|
||||||
|
export type ReadEpisodesResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Array<PodcastEpisodePublic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadEpisodesResponse = ReadEpisodesResponses[keyof ReadEpisodesResponses];
|
||||||
|
|
||||||
|
export type AdminUploadEpisodeData = {
|
||||||
|
body: BodyAdminUploadEpisodeApiPodcastsPodcastIdEpisodesPost;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminUploadEpisodeErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminUploadEpisodeError = AdminUploadEpisodeErrors[keyof AdminUploadEpisodeErrors];
|
||||||
|
|
||||||
|
export type AdminUploadEpisodeResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteEpisodeData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
episode_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes/{episode_id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteEpisodeErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteEpisodeError = DeleteEpisodeErrors[keyof DeleteEpisodeErrors];
|
||||||
|
|
||||||
|
export type DeleteEpisodeResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadEpisodeData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
episode_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes/{episode_id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadEpisodeErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadEpisodeError = ReadEpisodeErrors[keyof ReadEpisodeErrors];
|
||||||
|
|
||||||
|
export type ReadEpisodeResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: PodcastEpisodePublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadEpisodeResponse = ReadEpisodeResponses[keyof ReadEpisodeResponses];
|
||||||
|
|
||||||
|
export type UpdateEpisodeData = {
|
||||||
|
body: PodcastEpisodeUpdate;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
episode_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes/{episode_id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateEpisodeErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateEpisodeError = UpdateEpisodeErrors[keyof UpdateEpisodeErrors];
|
||||||
|
|
||||||
|
export type UpdateEpisodeResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: PodcastEpisodePublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateEpisodeResponse = UpdateEpisodeResponses[keyof UpdateEpisodeResponses];
|
||||||
|
|
||||||
|
export type EpisodeAdditionalUploadData = {
|
||||||
|
body: BodyEpisodeAdditionalUploadApiPodcastsPodcastIdEpisodesEpisodeIdPost;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
episode_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/podcasts/{podcast_id}/episodes/{episode_id}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpisodeAdditionalUploadErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpisodeAdditionalUploadError = EpisodeAdditionalUploadErrors[keyof EpisodeAdditionalUploadErrors];
|
||||||
|
|
||||||
|
export type EpisodeAdditionalUploadResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: PodcastEpisodePublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpisodeAdditionalUploadResponse = EpisodeAdditionalUploadResponses[keyof EpisodeAdditionalUploadResponses];
|
||||||
|
|
||||||
|
export type GetPodcastFeedData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/{podcast_id}.xml';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetPodcastFeedErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetPodcastFeedError = GetPodcastFeedErrors[keyof GetPodcastFeedErrors];
|
||||||
|
|
||||||
|
export type GetPodcastFeedResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEpisodeOrCoverData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
podcast_id: string;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/{podcast_id}/{filename}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEpisodeOrCoverErrors = {
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HttpValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetEpisodeOrCoverError = GetEpisodeOrCoverErrors[keyof GetEpisodeOrCoverErrors];
|
||||||
|
|
||||||
|
export type GetEpisodeOrCoverResponses = {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
28
client/src/components/AuthProvider.tsx
Normal file
28
client/src/components/AuthProvider.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { createContext, createEffect, createSignal, ParentComponent } from "solid-js";
|
||||||
|
import { fetchUser, login, logout, user } from "../auth";
|
||||||
|
import { User } from "oidc-client-ts";
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: () => User | null;
|
||||||
|
login: () => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const AuthProvider: ParentComponent = (props) => {
|
||||||
|
const [isLoading, setIsLoading] = createSignal(true);
|
||||||
|
|
||||||
|
createEffect(async () => {
|
||||||
|
await fetchUser();
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, login, logout }}>
|
||||||
|
{isLoading() ? <p>Loading...</p> : props.children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthProvider;
|
10
client/src/components/NotFound.tsx
Normal file
10
client/src/components/NotFound.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">Not Found</h1>
|
||||||
|
<button class="button">Go Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
7
client/src/components/Protected.tsx
Normal file
7
client/src/components/Protected.tsx
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { Navigate } from "@solidjs/router";
|
||||||
|
import { user } from "../auth";
|
||||||
|
import { ParentProps } from "solid-js";
|
||||||
|
|
||||||
|
export default function Protected(props: ParentProps) {
|
||||||
|
return user() ? props.children : <Navigate href="/login" />;
|
||||||
|
}
|
67
client/src/components/UploadEpisodeAdditional.tsx
Normal file
67
client/src/components/UploadEpisodeAdditional.tsx
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import { createDropzone } from "@soorria/solid-dropzone";
|
||||||
|
import { UploadIcon } from "lucide-solid";
|
||||||
|
import { createSignal, Show } from "solid-js";
|
||||||
|
import { createMutation, useQueryClient } from "@tanstack/solid-query";
|
||||||
|
import { episodeAdditionalUploadMutation, readEpisodesQueryKey, readEpisodeQueryKey } from "../client/@tanstack/solid-query.gen";
|
||||||
|
import { PodcastEpisodePublic } from "../client";
|
||||||
|
|
||||||
|
export default function UploadEpisodeAdditional(props: { podcastId: string, episodeId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [uploading, setUploading] = createSignal(false);
|
||||||
|
const [statusText, setStatusText] = createSignal<string | null>(null);
|
||||||
|
const uploadAdditional = createMutation(() => ({
|
||||||
|
...episodeAdditionalUploadMutation(),
|
||||||
|
onSuccess(data, vars) {
|
||||||
|
queryClient.setQueryData(readEpisodesQueryKey({ path: vars.path }), (oldData: PodcastEpisodePublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData);
|
||||||
|
queryClient.setQueryData(readEpisodeQueryKey({ path: vars.path }), data);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const dropzone = createDropzone({
|
||||||
|
onDrop(files: File[]) {
|
||||||
|
if (!files[0]) {
|
||||||
|
throw new Error("No file was uploaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusText("Uploading...");
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
uploadAdditional.mutate({
|
||||||
|
body: {
|
||||||
|
file: files[0],
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
podcast_id: props.podcastId,
|
||||||
|
episode_id: props.episodeId
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
onSuccess() {
|
||||||
|
setStatusText("Additional file uploaded!");
|
||||||
|
setUploading(false);
|
||||||
|
},
|
||||||
|
onError(error, _variables, _context) {
|
||||||
|
setStatusText(`Error uploading: ${error.detail}`);
|
||||||
|
setUploading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Show when={statusText()}>
|
||||||
|
<div class="notification is-info">
|
||||||
|
{statusText()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!uploading()}>
|
||||||
|
<div {...dropzone.getRootProps()} class="box is-flex is-flex-direction-column is-align-items-center p-6 mb-6">
|
||||||
|
<input {...dropzone.getInputProps()} />
|
||||||
|
<UploadIcon size="42" />
|
||||||
|
<p class="mt-4 has-text-centered">
|
||||||
|
{dropzone.isDragActive ? "Drop extra file here..." : "Drag an extra file here to upload, or click to open"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</>;
|
||||||
|
}
|
14
client/src/components/error.tsx
Normal file
14
client/src/components/error.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
export default function Error(props: { message?: any, reset?: () => void }) {
|
||||||
|
return (
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">Uh Oh!</h1>
|
||||||
|
<h2 class="subtitle">Something has gone wrong.</h2>
|
||||||
|
{props.message && <>
|
||||||
|
<p class="mb-4">Error: <code>{props.message.toString()}</code></p>
|
||||||
|
</>}
|
||||||
|
{props.reset && <button class="button" onClick={props.reset}>Retry</button>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
10
client/src/components/loading.tsx
Normal file
10
client/src/components/loading.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">One moment...</h1>
|
||||||
|
<progress class="progress is-primary" max="100" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
66
client/src/components/upload-image.tsx
Normal file
66
client/src/components/upload-image.tsx
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { createDropzone } from "@soorria/solid-dropzone";
|
||||||
|
import { UploadIcon } from "lucide-solid";
|
||||||
|
import { createSignal, Show } from "solid-js";
|
||||||
|
import { createMutation, useQueryClient } from "@tanstack/solid-query";
|
||||||
|
import { readPodcastQueryKey, readPodcastsQueryKey, updatePodcastImageMutation } from "../client/@tanstack/solid-query.gen";
|
||||||
|
import { PodcastPublic } from "../client";
|
||||||
|
|
||||||
|
export default function UploadImage(props: { podcastId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [uploading, setUploading] = createSignal(false);
|
||||||
|
const [statusText, setStatusText] = createSignal<string | null>(null);
|
||||||
|
const updatePodcastImage = createMutation(() => ({
|
||||||
|
...updatePodcastImageMutation(),
|
||||||
|
onSuccess(data, vars) {
|
||||||
|
queryClient.setQueryData(readPodcastQueryKey({ path: vars.path }), data);
|
||||||
|
queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData);
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const dropzone = createDropzone({
|
||||||
|
onDrop(files: File[]) {
|
||||||
|
if (!files[0]) {
|
||||||
|
throw new Error("No file was uploaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusText("Uploading...");
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
updatePodcastImage.mutate({
|
||||||
|
body: {
|
||||||
|
image: files[0],
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
podcast_id: props.podcastId,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
onSuccess() {
|
||||||
|
setStatusText("Podcast image uploaded!");
|
||||||
|
setUploading(false);
|
||||||
|
},
|
||||||
|
onError(error, _variables, _context) {
|
||||||
|
setStatusText(`Error uploading: ${error.detail}`);
|
||||||
|
setUploading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Show when={statusText()}>
|
||||||
|
<div class="notification is-info">
|
||||||
|
{statusText()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!uploading()}>
|
||||||
|
<div {...dropzone.getRootProps()} class="box is-flex is-flex-direction-column is-align-items-center p-6 mb-6">
|
||||||
|
<input {...dropzone.getInputProps()} />
|
||||||
|
<UploadIcon size="42" />
|
||||||
|
<p class="mt-4 has-text-centered">
|
||||||
|
{dropzone.isDragActive ? "Drop podcast image here..." : "Drag podcast image here to upload, or click to open"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</>;
|
||||||
|
}
|
71
client/src/components/upload.tsx
Normal file
71
client/src/components/upload.tsx
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import { createDropzone } from "@soorria/solid-dropzone";
|
||||||
|
import { UploadIcon } from "lucide-solid";
|
||||||
|
import HugeUploader from "huge-uploader";
|
||||||
|
import { SERVER_URL } from "../constants";
|
||||||
|
import { createSignal, Show } from "solid-js";
|
||||||
|
import { user } from "../auth";
|
||||||
|
|
||||||
|
export default function Upload(props: { podcastId: string }) {
|
||||||
|
var uploader: any = null;
|
||||||
|
const [uploading, setUploading] = createSignal(false);
|
||||||
|
const [statusText, setStatusText] = createSignal<string | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = createSignal<number | null>(null);
|
||||||
|
|
||||||
|
const dropzone = createDropzone({
|
||||||
|
onDrop(files: File[]) {
|
||||||
|
if (!files[0]) {
|
||||||
|
throw new Error("No file was uploaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setStatusText("Uploading...");
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
|
uploader = new HugeUploader({
|
||||||
|
endpoint: new URL(`/api/podcasts/${props.podcastId}/episodes`, SERVER_URL).href,
|
||||||
|
file: files[0],
|
||||||
|
headers: {
|
||||||
|
name: encodeURIComponent(files[0].name),
|
||||||
|
Authorization: `Bearer ${user()?.access_token}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
uploader.on("error", (err: any) => {
|
||||||
|
console.error(err);
|
||||||
|
setUploading(false);
|
||||||
|
setStatusText(`Something went wrong: ${err.detail}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploader.on("progress", (progress: { detail: number }) => {
|
||||||
|
if (progress.detail == 100) return;
|
||||||
|
setUploadProgress(progress.detail);
|
||||||
|
setStatusText(`Uploading (${progress.detail.toFixed(0)}%)...`);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploader.on("finish", (body: any) => {
|
||||||
|
console.log("File uploaded", body);
|
||||||
|
setUploadProgress(100);
|
||||||
|
setUploading(false);
|
||||||
|
setStatusText("Upload complete! The episode will be processed in the background. This may take a few minutes but it's safe to navigate away.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Show when={statusText()}>
|
||||||
|
<div class="notification is-info">
|
||||||
|
{uploadProgress() && <progress class="progress mb-4" value={uploadProgress()!} max="100">{uploadProgress()}%</progress>}
|
||||||
|
{statusText()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!uploading()}>
|
||||||
|
<div {...dropzone.getRootProps()} class="box is-flex is-flex-direction-column is-align-items-center p-6 mb-6">
|
||||||
|
<input {...dropzone.getInputProps()} />
|
||||||
|
<UploadIcon size="42" />
|
||||||
|
<p class="mt-4 has-text-centered">
|
||||||
|
{dropzone.isDragActive ? "Drop episode here..." : "Drag episode here to upload, or click to open"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</>;
|
||||||
|
}
|
1
client/src/constants.ts
Normal file
1
client/src/constants.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const SERVER_URL = import.meta.env.PROD ? "" : "http://localhost:8000";
|
21
client/src/helpers.ts
Normal file
21
client/src/helpers.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
export function humanFileSize(bytes: number, si=false, dp=1) {
|
||||||
|
const thresh = si ? 1000 : 1024;
|
||||||
|
|
||||||
|
if (Math.abs(bytes) < thresh) {
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = si
|
||||||
|
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||||
|
let u = -1;
|
||||||
|
const r = 10**dp;
|
||||||
|
|
||||||
|
do {
|
||||||
|
bytes /= thresh;
|
||||||
|
++u;
|
||||||
|
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||||
|
|
||||||
|
|
||||||
|
return bytes.toFixed(dp) + ' ' + units[u];
|
||||||
|
}
|
68
client/src/index.css
Normal file
68
client/src/index.css
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
:root {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
89
client/src/index.tsx
Normal file
89
client/src/index.tsx
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
/* @refresh reload */
|
||||||
|
import { render } from 'solid-js/web'
|
||||||
|
import { Navigate, Router } from '@solidjs/router'
|
||||||
|
import { lazy } from 'solid-js';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';
|
||||||
|
import { client as apiClient } from './client/sdk.gen';
|
||||||
|
|
||||||
|
import "bulma/css/bulma.min.css";
|
||||||
|
import { SERVER_URL } from './constants';
|
||||||
|
import Protected from './components/Protected';
|
||||||
|
import AuthProvider from './components/AuthProvider';
|
||||||
|
import { refreshToken, user } from './auth';
|
||||||
|
|
||||||
|
const wrapper = document.getElementById('root');
|
||||||
|
|
||||||
|
if (!wrapper) {
|
||||||
|
throw new Error("Wrapper div not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60, // 1 minute
|
||||||
|
suspense: true,
|
||||||
|
throwOnError(error: any, _query) {
|
||||||
|
if (error instanceof Object && error.hasOwnProperty("detail") && error["detail"].toLowerCase().includes("not found")) {
|
||||||
|
throw new Error("Not found");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.setConfig({
|
||||||
|
baseUrl: SERVER_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((request, _options) => {
|
||||||
|
const u = user();
|
||||||
|
if (u !== null) {
|
||||||
|
request.headers.set("Authorization", `Bearer ${u.access_token}`);
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(async (response, _request, _options) => {
|
||||||
|
if (response.status === 401 && user()) {
|
||||||
|
console.warn("Access token expired, trying refresh...");
|
||||||
|
await refreshToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: () => <Navigate href="/admin" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/callback",
|
||||||
|
component: lazy(() => import("./routes/Callback")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
component: lazy(() => import("./routes/Login")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/admin",
|
||||||
|
component: () => (
|
||||||
|
<Protected>{lazy(() => import("./routes/admin/podcasts"))()}</Protected>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/admin/:podcastId",
|
||||||
|
component: () => (
|
||||||
|
<Protected>{lazy(() => import("./routes/admin/podcast"))()}</Protected>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/admin/:podcastId/:episodeId",
|
||||||
|
component: () => (
|
||||||
|
<Protected>{lazy(() => import("./routes/admin/episode"))()}</Protected>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
render(() => <AuthProvider><QueryClientProvider client={client}><Router>{routes}</Router></QueryClientProvider></AuthProvider>, wrapper!)
|
17
client/src/routes/Callback.tsx
Normal file
17
client/src/routes/Callback.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { onMount } from "solid-js";
|
||||||
|
import { fetchUser, getUserManager } from "../auth";
|
||||||
|
|
||||||
|
const Callback = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await (await getUserManager()).signinRedirectCallback();
|
||||||
|
await fetchUser();
|
||||||
|
navigate("/admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
return <p>Signing in...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Callback;
|
16
client/src/routes/Login.tsx
Normal file
16
client/src/routes/Login.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { login } from "../auth"
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">Login</h1>
|
||||||
|
<button class="button is-primary" onClick={login}>Sign in with OIDC</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
173
client/src/routes/admin/episode.tsx
Normal file
173
client/src/routes/admin/episode.tsx
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query";
|
||||||
|
import AdminLayout from "../../admin-layout";
|
||||||
|
import { createEffect, createSignal, ErrorBoundary, Suspense } from "solid-js";
|
||||||
|
import { A, useNavigate, useParams } from "@solidjs/router";
|
||||||
|
import { deleteEpisodeMutation, readEpisodeOptions, readEpisodeQueryKey, readEpisodesQueryKey, updateEpisodeMutation } from "../../client/@tanstack/solid-query.gen";
|
||||||
|
import Error from "../../components/error";
|
||||||
|
import Loading from "../../components/loading";
|
||||||
|
import { Save, Trash } from "lucide-solid";
|
||||||
|
import { SERVER_URL } from "../../constants";
|
||||||
|
import { PodcastEpisodePublic } from "../../client";
|
||||||
|
import { humanFileSize } from "../../helpers";
|
||||||
|
import UploadEpisodeAdditional from "../../components/UploadEpisodeAdditional";
|
||||||
|
|
||||||
|
function convertDateToInput(d: Date): string {
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const day = d.getDate().toString().padStart(2, "0");
|
||||||
|
const hours = d.getHours().toString().padStart(2, "0");
|
||||||
|
const minutes = d.getMinutes().toString().padStart(2, "0");
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminEpisode() {
|
||||||
|
const params = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const episodeQuery = createQuery(() => ({
|
||||||
|
...readEpisodeOptions({
|
||||||
|
path: {
|
||||||
|
podcast_id: params.podcastId,
|
||||||
|
episode_id: params.episodeId,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const updateEpisode = createMutation(() => ({
|
||||||
|
...updateEpisodeMutation(),
|
||||||
|
onSuccess(data, vars) {
|
||||||
|
queryClient.setQueryData(readEpisodesQueryKey({ path: vars.path }), (oldData: PodcastEpisodePublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData);
|
||||||
|
queryClient.setQueryData(readEpisodeQueryKey({ path: vars.path }), data);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const deleteEpisode = createMutation(() => ({
|
||||||
|
...deleteEpisodeMutation(),
|
||||||
|
onSuccess(_, vars) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: readEpisodeQueryKey({ path: vars.path }) });
|
||||||
|
queryClient.setQueryData(readEpisodesQueryKey({ path: vars.path }), (oldData: PodcastEpisodePublic[]) => oldData.filter((x) => x.id !== vars.path.episode_id));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [name, setName] = createSignal("");
|
||||||
|
const [description, setDescription] = createSignal("");
|
||||||
|
const [publishDate, setPublishDate] = createSignal("");
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (episodeQuery.isSuccess && episodeQuery.data) {
|
||||||
|
setName(episodeQuery.data.name);
|
||||||
|
setDescription(episodeQuery.data.description || "");
|
||||||
|
const date = episodeQuery.data.publish_date ? new Date(episodeQuery.data.publish_date) : new Date();
|
||||||
|
setPublishDate(convertDateToInput(date));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteAction = () => {
|
||||||
|
if (confirm("Are you sure you want to delete the podcast?")) {
|
||||||
|
deleteEpisode.mutate({
|
||||||
|
path: {
|
||||||
|
podcast_id: params.podcastId,
|
||||||
|
episode_id: params.episodeId,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
navigate(`/admin/${params.podcastId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveChanges = () => {
|
||||||
|
updateEpisode.mutate({
|
||||||
|
body: {
|
||||||
|
name: name(),
|
||||||
|
description: description(),
|
||||||
|
publish_date: publishDate()
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
episode_id: params.episodeId,
|
||||||
|
podcast_id: params.podcastId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const audioUrl = () => new URL(params.podcastId + "/" + episodeQuery.data?.id + ".m4a", SERVER_URL).href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">{episodeQuery.data?.name}</h1>
|
||||||
|
<h2 class="subtitle">{episodeQuery.data?.publish_date && (new Date(episodeQuery.data?.publish_date)).toLocaleString()} • {episodeQuery.data?.duration && `${(episodeQuery.data.duration / 60).toFixed(0)} minutes` || "Unknown"}</h2>
|
||||||
|
{episodeQuery.data?.description_html && <div class="content" innerHTML={episodeQuery.data?.description_html} />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="title is-4">Update Episode</h2>
|
||||||
|
|
||||||
|
{updateEpisode.isSuccess && <div class="notification is-success">
|
||||||
|
<button class="delete" onClick={() => updateEpisode.reset()}></button>
|
||||||
|
This episode has been updated successfully.
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{updateEpisode.isError && <div class="notification is-danger">
|
||||||
|
<button class="delete" onClick={() => updateEpisode.reset()}></button>
|
||||||
|
Something went wrong. {JSON.stringify(updateEpisode.error)}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Name</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" value={name()} onInput={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Description</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea class="textarea" onChange={(e) => setDescription(e.target.value)}>{description()}</textarea>
|
||||||
|
</div>
|
||||||
|
<p class="help">The description supports <a href="https://www.markdownguide.org/cheat-sheet/">Markdown</a>. Leave this empty to unset the description.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Publish Date</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="datetime-local" value={publishDate()} onInput={(e) => setPublishDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button" onClick={() => saveChanges()}><Save /> Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="title is-4">Additional Upload</h2>
|
||||||
|
<p class="mb-4">Drag extra files here</p>
|
||||||
|
<UploadEpisodeAdditional podcastId={params.podcastId} episodeId={params.episodeId} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="title is-4">Info</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>Audio URL:</b> <A href={audioUrl()}>{audioUrl()}</A></li>
|
||||||
|
<li><b>SHA256 Checksum:</b> <code>{episodeQuery.data?.file_hash}</code></li>
|
||||||
|
<li><b>File Size:</b> {episodeQuery.data?.file_size ? humanFileSize(episodeQuery.data?.file_size, true) : "?"}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="title is-4">Danger Zone</h2>
|
||||||
|
<button class="button is-danger" onClick={() => deleteAction()}><Trash /> Delete</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
208
client/src/routes/admin/podcast.tsx
Normal file
208
client/src/routes/admin/podcast.tsx
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query";
|
||||||
|
import AdminLayout from "../../admin-layout";
|
||||||
|
import { createEffect, createSignal, ErrorBoundary, For, Match, Show, Suspense, Switch } from "solid-js";
|
||||||
|
import { A, useNavigate, useParams } from "@solidjs/router";
|
||||||
|
import { deletePodcastMutation, readEpisodesOptions, readPodcastOptions, readPodcastQueryKey, readPodcastsQueryKey, updatePodcastMutation } from "../../client/@tanstack/solid-query.gen";
|
||||||
|
import Error from "../../components/error";
|
||||||
|
import Loading from "../../components/loading";
|
||||||
|
import { List, Pencil, RefreshCw, Save, Settings, Trash } from "lucide-solid";
|
||||||
|
import { SERVER_URL } from "../../constants";
|
||||||
|
import Upload from "../../components/upload";
|
||||||
|
import UploadImage from "../../components/upload-image";
|
||||||
|
import { PodcastPublic } from "../../client";
|
||||||
|
|
||||||
|
export default function AdminPodcast() {
|
||||||
|
const params = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const podcastQuery = createQuery(() => ({
|
||||||
|
...readPodcastOptions({
|
||||||
|
path: {
|
||||||
|
podcast_id: params.podcastId
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const episodeQuery = createQuery(() => ({
|
||||||
|
...readEpisodesOptions({
|
||||||
|
path: {
|
||||||
|
podcast_id: params.podcastId
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const updatePodcast = createMutation(() => ({
|
||||||
|
...updatePodcastMutation(),
|
||||||
|
onSuccess(data, vars) {
|
||||||
|
queryClient.setQueryData(readPodcastQueryKey({ path: vars.path }), data);
|
||||||
|
queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const deletePodcast = createMutation(() => ({
|
||||||
|
...deletePodcastMutation(),
|
||||||
|
onSuccess(_, vars) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: readPodcastQueryKey({ path: vars.path }) });
|
||||||
|
queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? oldData.filter((x) => x.id !== vars.path.podcast_id) : oldData);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [tab, setTab] = createSignal(0);
|
||||||
|
const [name, setName] = createSignal("");
|
||||||
|
const [description, setDescription] = createSignal("");
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (podcastQuery.isSuccess && podcastQuery.data) {
|
||||||
|
setName(podcastQuery.data.name);
|
||||||
|
setDescription(podcastQuery.data.description);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteAction = () => {
|
||||||
|
if (confirm("Are you sure you want to delete the podcast?")) {
|
||||||
|
deletePodcast.mutate({
|
||||||
|
path: {
|
||||||
|
podcast_id: params.podcastId,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
navigate("/admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveChanges = () => {
|
||||||
|
updatePodcast.mutate({
|
||||||
|
body: {
|
||||||
|
name: name(),
|
||||||
|
description: description(),
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
podcast_id: params.podcastId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="columns">
|
||||||
|
<Show when={podcastQuery.data?.image_filename}>
|
||||||
|
<div class="column">
|
||||||
|
<figure class="image is-128x128">
|
||||||
|
<img src={new URL(params.podcastId + "/" + podcastQuery.data!.image_filename, SERVER_URL).href} />
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="column is-full">
|
||||||
|
<h1 class="title">{podcastQuery.data?.name}</h1>
|
||||||
|
<p>{podcastQuery.data?.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={episodeQuery.data?.length || 0 > 0}>
|
||||||
|
<p><strong>Subscribe at:</strong></p>
|
||||||
|
<pre><code>{new URL(`${params.podcastId}.xml`, SERVER_URL).href}</code></pre>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section py-0">
|
||||||
|
<div class="container">
|
||||||
|
<div class="tabs is-centered">
|
||||||
|
<ul>
|
||||||
|
<li classList={{ "is-active": tab() === 0 }}><a role="button" onClick={() => setTab(0)}><List class="icon is-small" /> Episodes</a></li>
|
||||||
|
<li classList={{ "is-active": tab() === 1 }}><a role="button" onClick={() => setTab(1)}><Settings class="icon is-small" /> Settings</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Switch>
|
||||||
|
<Match when={tab() === 0}>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<Upload podcastId={params.podcastId} />
|
||||||
|
<h2 class="title is-4">Published Episodes</h2>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="button is-primary" onClick={() => episodeQuery.refetch()}><RefreshCw /> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table is-narrow is-fullwidth">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Published</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={episodeQuery.data}>
|
||||||
|
{(episode) => (
|
||||||
|
<tr>
|
||||||
|
<td>{episode.name}</td>
|
||||||
|
<td>{episode.publish_date ? (new Date(episode.publish_date)).toLocaleString() : "?"}</td>
|
||||||
|
<td>{episode.duration ? `${(episode.duration / 60).toFixed(0)}min` : "?"}</td>
|
||||||
|
<td>
|
||||||
|
<A href={`/admin/${params.podcastId}/${episode.id}`}><Pencil class="icon is-small" /> Edit</A>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Match>
|
||||||
|
|
||||||
|
<Match when={tab() === 1}>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
{updatePodcast.isSuccess && <div class="notification is-success">
|
||||||
|
<button class="delete" onClick={() => updatePodcast.reset()}></button>
|
||||||
|
This episode has been updated successfully.
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{updatePodcast.isError && <div class="notification is-danger">
|
||||||
|
<button class="delete" onClick={() => updatePodcast.reset()}></button>
|
||||||
|
Something went wrong. {JSON.stringify(updatePodcast.error)}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Name</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" value={name()} onInput={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Description</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea class="textarea" onInput={(e) => setDescription(e.target.value)}>{description()}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button" onClick={() => saveChanges()}><Save /> Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="title is-4">Podcast Image</h2>
|
||||||
|
<UploadImage podcastId={params.podcastId} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="title is-4">Danger Zone</h2>
|
||||||
|
<button class="button is-danger" onClick={() => deleteAction()}><Trash /> Delete</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary >
|
||||||
|
</AdminLayout >
|
||||||
|
);
|
||||||
|
}
|
80
client/src/routes/admin/podcasts.tsx
Normal file
80
client/src/routes/admin/podcasts.tsx
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query";
|
||||||
|
import AdminLayout from "../../admin-layout";
|
||||||
|
import { ErrorBoundary, For, Show, Suspense } from "solid-js";
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { createPodcastMutation, readPodcastsOptions, readPodcastsQueryKey } from "../../client/@tanstack/solid-query.gen";
|
||||||
|
import Loading from "../../components/loading";
|
||||||
|
import Error from "../../components/error";
|
||||||
|
import { SERVER_URL } from "../../constants";
|
||||||
|
import { Eye, Plus } from "lucide-solid";
|
||||||
|
import { PodcastPublic } from "../../client";
|
||||||
|
|
||||||
|
export default function AdminPodcasts() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = createQuery(() => ({
|
||||||
|
...readPodcastsOptions(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createPodcast = createMutation(() => ({
|
||||||
|
...createPodcastMutation(),
|
||||||
|
onSuccess(data) {
|
||||||
|
queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? [...oldData, data] : oldData);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createAction = () => {
|
||||||
|
const name = prompt("Enter a podcast name");
|
||||||
|
|
||||||
|
if (name != null && name.trim().length > 0) {
|
||||||
|
createPodcast.mutate({
|
||||||
|
body: {
|
||||||
|
name,
|
||||||
|
description: name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">Podcasts</h1>
|
||||||
|
<div class="mb-4">
|
||||||
|
<button class="button" onClick={() => createAction()}>
|
||||||
|
<Plus />
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<For each={query.data}>
|
||||||
|
{(podcast) => (
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="columns">
|
||||||
|
<Show when={podcast.image_filename}>
|
||||||
|
<div class="column">
|
||||||
|
<figure class="image is-64x64">
|
||||||
|
<img src={new URL(podcast.id + "/" + podcast.image_filename, SERVER_URL).href} />
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="column is-full">
|
||||||
|
<h5 class="title is-4">{podcast.name}</h5>
|
||||||
|
<p class="mb-4">{podcast.description}</p>
|
||||||
|
<A href={`/admin/${podcast.id}`} class="button"><Eye /> View</A>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
48
client/src/types/huge-uploader.d.ts
vendored
Normal file
48
client/src/types/huge-uploader.d.ts
vendored
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { EventTarget } from "event-target-shim";
|
||||||
|
|
||||||
|
interface HugeUploaderEventMap {
|
||||||
|
progress: CustomEvent<{ detail: number }>;
|
||||||
|
finish: CustomEvent<{ detail: string }>;
|
||||||
|
error: CustomEvent<{ detail: string | Response }>;
|
||||||
|
fileRetry: CustomEvent<{ detail: { message: string; chunk: number; retriesLeft: number } }>;
|
||||||
|
online: Event;
|
||||||
|
offline: Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HugeUploaderParams {
|
||||||
|
endpoint: string;
|
||||||
|
file: File;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
postParams?: Record<string, string>;
|
||||||
|
chunkSize?: number;
|
||||||
|
retries?: number;
|
||||||
|
delayBeforeRetry?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HugeUploader {
|
||||||
|
private endpoint: string;
|
||||||
|
private file: File;
|
||||||
|
private headers: Record<string, string>;
|
||||||
|
private postParams?: Record<string, string>;
|
||||||
|
private chunkSize: number;
|
||||||
|
private retries: number;
|
||||||
|
private delayBeforeRetry: number;
|
||||||
|
private start: number;
|
||||||
|
private chunk: Blob | null;
|
||||||
|
private chunkCount: number;
|
||||||
|
private totalChunks: number;
|
||||||
|
private retriesCount: number;
|
||||||
|
private offline: boolean;
|
||||||
|
private paused: boolean;
|
||||||
|
private _reader: FileReader;
|
||||||
|
private _eventTarget: EventTarget;
|
||||||
|
|
||||||
|
constructor(params: HugeUploaderParams);
|
||||||
|
|
||||||
|
on<T extends keyof HugeUploaderEventMap>(eventType: T, listener: (event: HugeUploaderEventMap[T]) => void): void;
|
||||||
|
togglePause(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "huge-uploader" {
|
||||||
|
export default HugeUploader
|
||||||
|
}
|
1
client/src/vite-env.d.ts
vendored
Normal file
1
client/src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
32
client/tsconfig.app.json
Normal file
32
client/tsconfig.app.json
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "solid-js",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"*": ["src/types/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "src/types"]
|
||||||
|
}
|
7
client/tsconfig.json
Normal file
7
client/tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
24
client/tsconfig.node.json
Normal file
24
client/tsconfig.node.json
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
9
client/vite.config.ts
Normal file
9
client/vite.config.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import solid from 'vite-plugin-solid'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [solid()],
|
||||||
|
build: {
|
||||||
|
assetsDir: "static",
|
||||||
|
}
|
||||||
|
})
|
1
migrations/README
Normal file
1
migrations/README
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Generic single-database configuration.
|
77
migrations/env.py
Normal file
77
migrations/env.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
from models import * # noqa: F403
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = SQLModel.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
27
migrations/script.py.mako
Normal file
27
migrations/script.py.mako
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
57
migrations/versions/9efcecc1e58d_initial_migration.py
Normal file
57
migrations/versions/9efcecc1e58d_initial_migration.py
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
"""initial migration
|
||||||
|
|
||||||
|
Revision ID: 9efcecc1e58d
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-02-03 18:45:42.630841
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "9efcecc1e58d"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"podcast",
|
||||||
|
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column("explicit", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column("owner_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column("image_filename", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"podcastepisode",
|
||||||
|
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column("duration", sa.Float(), nullable=True),
|
||||||
|
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column("file_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column("file_size", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("publish_date", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column("podcast_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["podcast_id"],
|
||||||
|
["podcast.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table("podcastepisode")
|
||||||
|
op.drop_table("podcast")
|
||||||
|
# ### end Alembic commands ###
|
|
@ -6,16 +6,23 @@ readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiofiles>=24.1.0",
|
"aiofiles>=24.1.0",
|
||||||
|
"alembic>=1.14.1",
|
||||||
|
"bleach>=6.2.0",
|
||||||
|
"cryptography>=44.0.0",
|
||||||
"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",
|
||||||
|
"itsdangerous>=2.2.0",
|
||||||
"markdown>=3.7",
|
"markdown>=3.7",
|
||||||
"nanoid>=2.0.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",
|
||||||
|
"pyjwt>=2.10.1",
|
||||||
"python-multipart>=0.0.20",
|
"python-multipart>=0.0.20",
|
||||||
|
"requests>=2.32.3",
|
||||||
"sqlmodel>=0.0.22",
|
"sqlmodel>=0.0.22",
|
||||||
"structlog>=24.4.0",
|
"structlog>=24.4.0",
|
||||||
|
"uvicorn>=0.34.0",
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,90 +0,0 @@
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import ffmpeg
|
|
||||||
import structlog
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
# Add the src directory to the system path
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parent.parent / "src"))
|
|
||||||
|
|
||||||
import models as models
|
|
||||||
from settings import settings
|
|
||||||
|
|
||||||
log = structlog.get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
def import_episode(filename: Path, podcast_id: str, process: bool, move: bool = True):
|
|
||||||
if process:
|
|
||||||
raise NotImplementedError("Importing with processing is not implemented")
|
|
||||||
|
|
||||||
if filename.suffix != ".m4a" and not process:
|
|
||||||
log.error("Input file must be in an m4a container if not processing")
|
|
||||||
return
|
|
||||||
|
|
||||||
with Session(models.engine) as session:
|
|
||||||
podcast = session.exec(
|
|
||||||
select(models.Podcast).where(models.Podcast.id == podcast_id)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if podcast is None:
|
|
||||||
log.error("Failed importing episode, podcast does not exist.")
|
|
||||||
return
|
|
||||||
|
|
||||||
episode = models.PodcastEpisode(
|
|
||||||
name=filename.stem, file_size=0, file_hash="", podcast_id=podcast.id
|
|
||||||
)
|
|
||||||
|
|
||||||
episode_filename = settings.directory / f"{episode.id}.m4a"
|
|
||||||
|
|
||||||
if move:
|
|
||||||
log.info("Moving episode to %s...", episode_filename)
|
|
||||||
shutil.move(filename, episode_filename)
|
|
||||||
else:
|
|
||||||
log.info("Copying episode to %s...", episode_filename)
|
|
||||||
shutil.copyfile(filename, episode_filename)
|
|
||||||
|
|
||||||
probe = ffmpeg.probe(str(episode_filename))
|
|
||||||
stream = next(
|
|
||||||
(stream for stream in probe["streams"] if stream["codec_type"] == "audio"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
file_hash = hashlib.sha256()
|
|
||||||
with open(episode_filename, "rb") as f:
|
|
||||||
for byte_block in iter(lambda: f.read(4096), b""):
|
|
||||||
file_hash.update(byte_block)
|
|
||||||
|
|
||||||
episode.duration = (
|
|
||||||
float(stream["duration"])
|
|
||||||
if stream is not None and "duration" in stream
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
episode.file_hash = file_hash.hexdigest()
|
|
||||||
episode.file_size = episode_filename.stat().st_size
|
|
||||||
|
|
||||||
session.add(episode)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
log.info("Imported episode as %s", episode.id)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="import-episode.py",
|
|
||||||
description="Import an episode",
|
|
||||||
)
|
|
||||||
parser.add_argument("filename")
|
|
||||||
parser.add_argument("podcast_id")
|
|
||||||
parser.add_argument("--process", action="store_true")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
import_episode(Path(args.filename), args.podcast_id, args.process)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
|
@ -1,50 +0,0 @@
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
# Add the src directory to the system path
|
|
||||||
sys.path.append(str(Path(__file__).resolve().parent.parent / "src"))
|
|
||||||
|
|
||||||
import models as models
|
|
||||||
|
|
||||||
log = structlog.get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
def update_pub_date(episode_id: str, new_date: str):
|
|
||||||
with Session(models.engine) as session:
|
|
||||||
episode = session.exec(
|
|
||||||
select(models.PodcastEpisode).where(models.PodcastEpisode.id == episode_id)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if episode is None:
|
|
||||||
log.error("Could not find episode")
|
|
||||||
return
|
|
||||||
|
|
||||||
episode.publish_date = datetime.fromisoformat(new_date)
|
|
||||||
assert episode.publish_date.tzinfo is not None, "timezone is required"
|
|
||||||
|
|
||||||
session.add(episode)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
log.info("Updated episode", episode.id)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="update-pub-date.py",
|
|
||||||
description="Update an episode publish date",
|
|
||||||
)
|
|
||||||
parser.add_argument("episode_id")
|
|
||||||
parser.add_argument("new_date")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
update_pub_date(args.episode_id, args.new_date)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
57
src/auth.py
Normal file
57
src/auth.py
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
from typing import Annotated, Any, Dict
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import jwt.algorithms
|
||||||
|
from fastapi import HTTPException, Security, security
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from settings import settings
|
||||||
|
|
||||||
|
jwks_client = jwt.PyJWKClient(
|
||||||
|
settings.oidc_jwks_url, headers={"User-Agent": "PodcastGenerator"}
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth2_scheme = security.OAuth2AuthorizationCodeBearer(
|
||||||
|
authorizationUrl=settings.oidc_authorize_url, tokenUrl=settings.oidc_token_url
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUser(BaseModel):
|
||||||
|
user_id: str = Field()
|
||||||
|
user_name: str = Field()
|
||||||
|
|
||||||
|
|
||||||
|
def user_token(
|
||||||
|
token_str: Annotated[str, Security(oauth2_scheme)],
|
||||||
|
required_scopes: security.SecurityScopes,
|
||||||
|
) -> CurrentUser:
|
||||||
|
try:
|
||||||
|
token: Dict[Any, Any] = jwt.decode(
|
||||||
|
token_str,
|
||||||
|
jwks_client.get_signing_key_from_jwt(token_str).key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
audience=settings.oidc_permitted_jwt_audiences,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
) from e
|
||||||
|
|
||||||
|
for scope in required_scopes.scopes:
|
||||||
|
if scope not in token["scope"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Not enough permissions",
|
||||||
|
headers={
|
||||||
|
"WWW-Authenticate",
|
||||||
|
f'Bearer scope="{required_scopes.scope_str}"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return CurrentUser(
|
||||||
|
user_id=token.get(settings.oidc_sub_jwt_attribute),
|
||||||
|
user_name=token.get(settings.oidc_name_jwt_attribute),
|
||||||
|
)
|
76
src/episode_file.py
Normal file
76
src/episode_file.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Callable, List, Optional, Tuple
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
|
||||||
|
from models import PodcastEpisode
|
||||||
|
|
||||||
|
|
||||||
|
async def djuced_track_list(
|
||||||
|
episode: PodcastEpisode, file: UploadFile
|
||||||
|
) -> Optional[PodcastEpisode]:
|
||||||
|
root = ET.fromstring(await file.read())
|
||||||
|
|
||||||
|
# if this doesn't look like a djuced track list xml, return nothing
|
||||||
|
if root.tag != "recordEvents":
|
||||||
|
return None
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
|
||||||
|
for track in root.iter("track"):
|
||||||
|
title = track.get("song")
|
||||||
|
artist = track.get("artist")
|
||||||
|
|
||||||
|
intervals = track.findall("interval")
|
||||||
|
|
||||||
|
# if the title looks like it contains the artist too, overwrite the existing artist
|
||||||
|
title_segments = title.split(" - ")
|
||||||
|
if len(title_segments) == 2:
|
||||||
|
artist, title = title_segments
|
||||||
|
|
||||||
|
for interval in intervals:
|
||||||
|
tracks.append((float(interval.get("start")), title, artist))
|
||||||
|
|
||||||
|
# sort by start time
|
||||||
|
tracks = sorted(tracks, key=lambda x: x[0], reverse=False)
|
||||||
|
|
||||||
|
# update description
|
||||||
|
track_list_str = ""
|
||||||
|
|
||||||
|
for i, (t, title, artist) in enumerate(tracks):
|
||||||
|
time = timedelta(seconds=round(t))
|
||||||
|
track_list_str += f"{i + 1}. {title} _- {artist} [{time}]_\n"
|
||||||
|
|
||||||
|
episode.description += "\n\n**Track list**\n\n" + track_list_str
|
||||||
|
return episode
|
||||||
|
|
||||||
|
|
||||||
|
# list of file processors
|
||||||
|
# these are processed in order and only run if the file content type matches the first tuple string
|
||||||
|
# the second tuple item is the function to run which should return none if the file was not able to be processed, otherwise a mutated episode object
|
||||||
|
processors: List[
|
||||||
|
Tuple[str, Callable[[PodcastEpisode, UploadFile], Optional[PodcastEpisode]]]
|
||||||
|
] = [("text/xml", djuced_track_list)]
|
||||||
|
|
||||||
|
|
||||||
|
async def process_additional_episode_upload(
|
||||||
|
episode: PodcastEpisode, file: UploadFile
|
||||||
|
) -> PodcastEpisode:
|
||||||
|
for processor_content_type, do_process in processors:
|
||||||
|
if processor_content_type != file.content_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await do_process(episode, file)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to process using {do_process.__name__}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unable to process additional episode upload ({file.content_type})",
|
||||||
|
)
|
15
src/helpers.py
Normal file
15
src/helpers.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import bleach
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(content: Optional[str]) -> Optional[str]:
|
||||||
|
if content is None or content.strip() == "":
|
||||||
|
return None
|
||||||
|
|
||||||
|
return bleach.clean(
|
||||||
|
markdown.markdown(content),
|
||||||
|
tags=["p", "a", "code", "pre", "blockquote", "ol", "li", "ul", "strong", "em"],
|
||||||
|
attributes={"a": ["href"]},
|
||||||
|
)
|
610
src/main.py
610
src/main.py
|
@ -1,29 +1,26 @@
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from datetime import timedelta, timezone
|
from datetime import timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Generator, Optional
|
from typing import Annotated, Any, Generator, Optional
|
||||||
|
|
||||||
import markdown
|
|
||||||
import podgen
|
import podgen
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
|
from fastapi import Depends, FastAPI, HTTPException, Request, Response, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from sqlmodel import Session, and_, or_, select
|
from sqlmodel import Session, and_, desc, select
|
||||||
|
|
||||||
import models
|
import models
|
||||||
|
from auth import CurrentUser, user_token
|
||||||
|
from episode_file import process_additional_episode_upload
|
||||||
|
from helpers import render_markdown
|
||||||
from process import AudioProcessor
|
from process import AudioProcessor
|
||||||
from settings import settings
|
from settings import AppConfig, settings
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
models.setup_db(models.engine)
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
def get_session() -> Generator[Session, Any, None]:
|
def get_session() -> Generator[Session, Any, None]:
|
||||||
|
@ -31,26 +28,26 @@ def get_session() -> Generator[Session, Any, None]:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
def handle_user_auth(request: Request) -> tuple[str, str]:
|
def use_route_names_as_operation_ids(app: FastAPI) -> None:
|
||||||
if (
|
for route in app.routes:
|
||||||
settings.forward_auth_name_header is None
|
if isinstance(route, APIRoute):
|
||||||
or settings.forward_auth_uid_header is None
|
route.operation_id = route.name
|
||||||
):
|
|
||||||
return ("default", "Admin")
|
|
||||||
|
|
||||||
return (
|
|
||||||
request.headers.get(settings.forward_auth_uid_header, "default"),
|
|
||||||
request.headers.get(settings.forward_auth_name_header, "Admin"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
SessionDep = Annotated[Session, Depends(get_session)]
|
SessionDep = Annotated[Session, Depends(get_session)]
|
||||||
AuthDep = Annotated[tuple[str, str], Depends(handle_user_auth)]
|
AuthDep = Annotated[CurrentUser, Depends(user_token)]
|
||||||
|
|
||||||
|
|
||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(
|
||||||
|
title="Podcast Generator",
|
||||||
|
swagger_ui_init_oauth={
|
||||||
|
"appName": "PodcastGenerator",
|
||||||
|
"clientId": settings.oidc_client_id,
|
||||||
|
"usePkceWithAuthorizationCodeGrant": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||||
)
|
)
|
||||||
|
@ -59,103 +56,137 @@ templates = Jinja2Templates(directory="src/templates")
|
||||||
audio_processor = AudioProcessor()
|
audio_processor = AudioProcessor()
|
||||||
audio_processor.start_processing()
|
audio_processor.start_processing()
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory="dist/static"), name="static")
|
||||||
|
|
||||||
@app.get("/admin")
|
|
||||||
def admin_list_podcasts(session: SessionDep, request: Request, user: AuthDep):
|
@app.get("/api/config", response_model=AppConfig)
|
||||||
|
def get_app_config():
|
||||||
|
return AppConfig(
|
||||||
|
oidc_authority=settings.oidc_authority,
|
||||||
|
oidc_client_id=settings.oidc_client_id,
|
||||||
|
oidc_scopes=settings.oidc_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/user", response_model=CurrentUser)
|
||||||
|
def read_user(auth: AuthDep):
|
||||||
|
return auth
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/podcasts", response_model=models.PodcastPublic)
|
||||||
|
def create_podcast(auth: AuthDep, session: SessionDep, podcast: models.PodcastCreate):
|
||||||
|
db_podcast = models.Podcast.model_validate(podcast)
|
||||||
|
db_podcast.owner_id = auth.user_id
|
||||||
|
session.add(db_podcast)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_podcast)
|
||||||
|
return db_podcast
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/podcasts", response_model=list[models.PodcastPublic])
|
||||||
|
def read_podcasts(auth: AuthDep, session: SessionDep):
|
||||||
podcasts = session.exec(
|
podcasts = session.exec(
|
||||||
select(models.Podcast).where(
|
select(models.Podcast).where(models.Podcast.owner_id == auth.user_id)
|
||||||
or_(
|
|
||||||
models.Podcast.owner_id == user[0],
|
|
||||||
models.Podcast.owner_id == None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).all()
|
).all()
|
||||||
|
return podcasts
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="admin_feeds.html.j2",
|
|
||||||
context={
|
|
||||||
"podcasts": podcasts,
|
|
||||||
"user_name": user[1],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/new")
|
@app.get("/api/podcasts/{podcast_id}", response_model=models.PodcastPublic)
|
||||||
def admin_create_podcast(request: Request):
|
def read_podcast(session: SessionDep, podcast_id: str):
|
||||||
return templates.TemplateResponse(
|
podcast = session.get(models.Podcast, podcast_id)
|
||||||
request=request,
|
if not podcast:
|
||||||
name="admin_new.html.j2",
|
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||||
)
|
return podcast
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/new")
|
@app.patch("/api/podcasts/{podcast_id}", response_model=models.PodcastPublic)
|
||||||
def admin_create_podcast_post(
|
def update_podcast(
|
||||||
|
auth: AuthDep,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
request: Request,
|
podcast_id: str,
|
||||||
user: AuthDep,
|
podcast: models.PodcastUpdate = None,
|
||||||
name: Annotated[str, Form()],
|
|
||||||
):
|
):
|
||||||
if name.strip() == "":
|
db_podcast = session.get(models.Podcast, podcast_id)
|
||||||
return templates.TemplateResponse(
|
if not db_podcast:
|
||||||
request,
|
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||||
name="admin_error.html.j2",
|
|
||||||
status_code=400,
|
if db_podcast.owner_id != auth.user_id:
|
||||||
context={
|
raise HTTPException(
|
||||||
"title": "Invalid entry",
|
status_code=403, detail="You do not have permission to update this podcast"
|
||||||
"description": "Name must not be empty",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
podcast = models.Podcast(name=name, description=name, owner_id=user[0])
|
podcast_data = podcast.model_dump(exclude_unset=True)
|
||||||
|
db_podcast.sqlmodel_update(podcast_data)
|
||||||
|
|
||||||
|
session.add(db_podcast)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_podcast)
|
||||||
|
return db_podcast
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/podcasts/{podcast_id}/image", response_model=models.PodcastPublic)
|
||||||
|
def update_podcast_image(
|
||||||
|
auth: AuthDep,
|
||||||
|
session: SessionDep,
|
||||||
|
podcast_id: str,
|
||||||
|
image: UploadFile,
|
||||||
|
):
|
||||||
|
podcast = session.get(models.Podcast, podcast_id)
|
||||||
|
if not podcast:
|
||||||
|
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||||
|
|
||||||
|
if podcast.owner_id != auth.user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="You do not have permission to update this podcast"
|
||||||
|
)
|
||||||
|
|
||||||
|
if image is not None and image.size > 0:
|
||||||
|
if not (image.filename.endswith(".jpg") or image.filename.endswith(".png")):
|
||||||
|
return HTTPException(
|
||||||
|
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:
|
||||||
|
return HTTPException(
|
||||||
|
400,
|
||||||
|
detail="The uploaded podcast image must be square and between 1400x1400px and 3000x3000px in size",
|
||||||
|
)
|
||||||
|
|
||||||
|
if im.mode != "RGB":
|
||||||
|
return HTTPException(
|
||||||
|
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.add(podcast)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
session.refresh(podcast)
|
||||||
return RedirectResponse(f"/admin/{podcast.id}", status_code=303)
|
return podcast
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/{podcast_id}")
|
@app.delete("/api/podcasts/{podcast_id}")
|
||||||
def admin_list_podcast(
|
def delete_podcast(auth: AuthDep, session: SessionDep, podcast_id: str):
|
||||||
session: SessionDep, request: Request, podcast_id: str, user: AuthDep
|
podcast = session.get(models.Podcast, podcast_id)
|
||||||
):
|
|
||||||
podcast = session.exec(
|
|
||||||
select(models.Podcast).where(
|
|
||||||
and_(
|
|
||||||
models.Podcast.id == podcast_id,
|
|
||||||
or_(
|
|
||||||
models.Podcast.owner_id == user[0],
|
|
||||||
models.Podcast.owner_id == None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if podcast is None:
|
if not podcast:
|
||||||
return templates.TemplateResponse(
|
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||||
request,
|
|
||||||
name="admin_error.html.j2",
|
if podcast.owner_id != auth.user_id:
|
||||||
status_code=400,
|
raise HTTPException(
|
||||||
context={
|
status_code=403, detail="You do not have permission to delete this podcast"
|
||||||
"title": "Not found",
|
|
||||||
"description": "The podcast was not found.",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
episodes = podcast.episodes
|
session.delete(podcast)
|
||||||
episodes.sort(key=lambda e: e.publish_date, reverse=True)
|
session.commit()
|
||||||
|
return {"ok": 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(
|
def finish_processing(
|
||||||
|
@ -185,13 +216,13 @@ def finish_processing(
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/{podcast_id}/upload")
|
@app.post("/api/podcasts/{podcast_id}/episodes")
|
||||||
async def admin_upload_episode(
|
async def admin_upload_episode(
|
||||||
|
auth: AuthDep,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
request: Request,
|
request: Request,
|
||||||
podcast_id: str,
|
podcast_id: str,
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
user: AuthDep,
|
|
||||||
):
|
):
|
||||||
file_id = request.headers.get("uploader-file-id")
|
file_id = request.headers.get("uploader-file-id")
|
||||||
chunks_total = int(request.headers.get("uploader-chunks-total"))
|
chunks_total = int(request.headers.get("uploader-chunks-total"))
|
||||||
|
@ -204,20 +235,18 @@ async def admin_upload_episode(
|
||||||
file_id = "".join(c for c in file_id if c.isalnum()).strip()
|
file_id = "".join(c for c in file_id if c.isalnum()).strip()
|
||||||
|
|
||||||
podcast = session.exec(
|
podcast = session.exec(
|
||||||
select(models.Podcast).where(
|
select(models.Podcast).where(models.Podcast.id == podcast_id)
|
||||||
and_(
|
|
||||||
models.Podcast.id == podcast_id,
|
|
||||||
or_(
|
|
||||||
models.Podcast.owner_id == user[0],
|
|
||||||
models.Podcast.owner_id == None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if podcast is None:
|
if podcast is None:
|
||||||
raise HTTPException(status_code=404, detail="Podcast not found")
|
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||||
|
|
||||||
|
if podcast.owner_id != auth.user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="You do not have permission to add episodes to this podcast",
|
||||||
|
)
|
||||||
|
|
||||||
is_last = (chunk_number + 1) == chunks_total
|
is_last = (chunk_number + 1) == chunks_total
|
||||||
|
|
||||||
file_name = f"{file_id}_{chunk_number}"
|
file_name = f"{file_id}_{chunk_number}"
|
||||||
|
@ -253,256 +282,137 @@ async def admin_upload_episode(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse({"message": "File Uploaded"}, status_code=200)
|
return {"message": "File Uploaded"}
|
||||||
|
|
||||||
return JSONResponse({"message": "Chunk Uploaded"}, status_code=200)
|
return {"message": "Chunk Uploaded"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/{podcast_id}/{episode_id}/delete")
|
@app.get(
|
||||||
def admin_delete_episode(
|
"/api/podcasts/{podcast_id}/episodes",
|
||||||
|
response_model=list[models.PodcastEpisodePublic],
|
||||||
|
)
|
||||||
|
def read_episodes(session: SessionDep, podcast_id: str):
|
||||||
|
episodes = session.exec(
|
||||||
|
select(models.PodcastEpisode)
|
||||||
|
.where(models.PodcastEpisode.podcast_id == podcast_id)
|
||||||
|
.order_by(desc(models.PodcastEpisode.publish_date))
|
||||||
|
).all()
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/api/podcasts/{podcast_id}/episodes/{episode_id}",
|
||||||
|
response_model=models.PodcastEpisodePublic,
|
||||||
|
)
|
||||||
|
def read_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 not episode:
|
||||||
|
raise HTTPException(status_code=404, detail="Episode not found")
|
||||||
|
return episode
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch(
|
||||||
|
"/api/podcasts/{podcast_id}/episodes/{episode_id}",
|
||||||
|
response_model=models.PodcastEpisodePublic,
|
||||||
|
)
|
||||||
|
def update_episode(
|
||||||
|
auth: AuthDep,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
request: Request,
|
|
||||||
podcast_id: str,
|
podcast_id: str,
|
||||||
episode_id: str,
|
episode_id: str,
|
||||||
user: AuthDep,
|
episode: models.PodcastEpisodeUpdate,
|
||||||
|
):
|
||||||
|
db_episode = session.exec(
|
||||||
|
select(models.PodcastEpisode).where(
|
||||||
|
and_(
|
||||||
|
models.PodcastEpisode.id == episode_id,
|
||||||
|
models.PodcastEpisode.podcast_id == podcast_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not db_episode:
|
||||||
|
raise HTTPException(status_code=404, detail="Episode not found")
|
||||||
|
|
||||||
|
if db_episode.podcast.owner_id != auth.user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="You do not have permission to update this episode"
|
||||||
|
)
|
||||||
|
|
||||||
|
episode_data = episode.model_dump(exclude_unset=True)
|
||||||
|
db_episode.sqlmodel_update(episode_data)
|
||||||
|
session.add(db_episode)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_episode)
|
||||||
|
return db_episode
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/podcasts/{podcast_id}/episodes/{episode_id}",
|
||||||
|
response_model=models.PodcastEpisodePublic,
|
||||||
|
)
|
||||||
|
async def episode_additional_upload(
|
||||||
|
auth: AuthDep,
|
||||||
|
session: SessionDep,
|
||||||
|
podcast_id: str,
|
||||||
|
episode_id: str,
|
||||||
|
file: UploadFile,
|
||||||
|
):
|
||||||
|
db_episode = session.exec(
|
||||||
|
select(models.PodcastEpisode).where(
|
||||||
|
and_(
|
||||||
|
models.PodcastEpisode.id == episode_id,
|
||||||
|
models.PodcastEpisode.podcast_id == podcast_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not db_episode:
|
||||||
|
raise HTTPException(status_code=404, detail="Episode not found")
|
||||||
|
|
||||||
|
if db_episode.podcast.owner_id != auth.user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="You do not have permission to update this episode"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_episode = await process_additional_episode_upload(db_episode, file)
|
||||||
|
|
||||||
|
session.add(new_episode)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(new_episode)
|
||||||
|
return new_episode
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/podcasts/{podcast_id}/episodes/{episode_id}")
|
||||||
|
def delete_episode(
|
||||||
|
auth: AuthDep, session: SessionDep, podcast_id: str, episode_id: str
|
||||||
):
|
):
|
||||||
episode = session.exec(
|
episode = session.exec(
|
||||||
select(models.PodcastEpisode).where(
|
select(models.PodcastEpisode).where(
|
||||||
and_(
|
and_(
|
||||||
models.PodcastEpisode.id == episode_id,
|
models.PodcastEpisode.id == episode_id,
|
||||||
models.PodcastEpisode.podcast_id == podcast_id,
|
models.PodcastEpisode.podcast_id == podcast_id,
|
||||||
or_(
|
|
||||||
models.Podcast.owner_id == user[0],
|
|
||||||
models.Podcast.owner_id == None,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
if not episode:
|
||||||
|
raise HTTPException(status_code=404, detail="Episode not found")
|
||||||
|
|
||||||
if episode is None:
|
if episode.podcast.owner_id != auth.user_id:
|
||||||
return templates.TemplateResponse(
|
raise HTTPException(
|
||||||
request,
|
status_code=403, detail="You do not have permission to delete this episode"
|
||||||
name="admin_error.html.j2",
|
|
||||||
status_code=404,
|
|
||||||
context={
|
|
||||||
"title": "Not found",
|
|
||||||
"description": "The episode or podcast was not found.",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
(settings.directory / f"{episode_id}.m4a").unlink()
|
|
||||||
session.delete(episode)
|
session.delete(episode)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
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,
|
|
||||||
user: AuthDep,
|
|
||||||
):
|
|
||||||
episode = session.exec(
|
|
||||||
select(models.PodcastEpisode).where(
|
|
||||||
and_(
|
|
||||||
models.PodcastEpisode.id == episode_id,
|
|
||||||
models.PodcastEpisode.podcast_id == podcast_id,
|
|
||||||
or_(
|
|
||||||
models.Podcast.owner_id == user[0],
|
|
||||||
models.Podcast.owner_id == None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if episode is None:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
name="admin_error.html.j2",
|
|
||||||
status_code=404,
|
|
||||||
context={
|
|
||||||
"title": "Not found",
|
|
||||||
"description": "The episode or podcast was 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,
|
|
||||||
request: Request,
|
|
||||||
podcast_id: str,
|
|
||||||
episode_id: str,
|
|
||||||
user: AuthDep,
|
|
||||||
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,
|
|
||||||
or_(
|
|
||||||
models.Podcast.owner_id == user[0],
|
|
||||||
models.Podcast.owner_id == None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if episode is None:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
name="admin_error.html.j2",
|
|
||||||
status_code=404,
|
|
||||||
context={
|
|
||||||
"title": "Not found",
|
|
||||||
"description": "The episode or podcast was 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, user: AuthDep, podcast_id: str
|
|
||||||
):
|
|
||||||
podcast = session.exec(
|
|
||||||
select(models.Podcast).where(
|
|
||||||
and_(
|
|
||||||
models.Podcast.id == podcast_id,
|
|
||||||
or_(
|
|
||||||
models.Podcast.owner_id == user[0],
|
|
||||||
models.Podcast.owner_id == None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if podcast is None:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
name="admin_error.html.j2",
|
|
||||||
status_code=404,
|
|
||||||
context={
|
|
||||||
"title": "Not found",
|
|
||||||
"description": "The podcast was 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,
|
|
||||||
request: Request,
|
|
||||||
podcast_id: str,
|
|
||||||
user: AuthDep,
|
|
||||||
name: Annotated[str, Form()],
|
|
||||||
description: Annotated[str, Form()],
|
|
||||||
image: Optional[UploadFile] = None,
|
|
||||||
):
|
|
||||||
podcast = session.exec(
|
|
||||||
select(models.Podcast).where(
|
|
||||||
and_(
|
|
||||||
models.Podcast.id == podcast_id,
|
|
||||||
or_(
|
|
||||||
models.Podcast.owner_id == user[0],
|
|
||||||
models.Podcast.owner_id == None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if podcast is None:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
name="admin_error.html.j2",
|
|
||||||
status_code=404,
|
|
||||||
context={
|
|
||||||
"title": "Not found",
|
|
||||||
"description": "The podcast was 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")):
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
name="admin_error.html.j2",
|
|
||||||
status_code=400,
|
|
||||||
context={
|
|
||||||
"title": "Invalid entry",
|
|
||||||
"description": "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:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
name="admin_error.html.j2",
|
|
||||||
status_code=400,
|
|
||||||
context={
|
|
||||||
"title": "Invalid entry",
|
|
||||||
"description": "The uploaded podcast image must be square and between 1400x1400px and 3000x3000px in size.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if im.mode != "RGB":
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
name="admin_error.html.j2",
|
|
||||||
status_code=400,
|
|
||||||
context={
|
|
||||||
"title": "Invalid entry",
|
|
||||||
"description": "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")
|
@app.get("/{podcast_id}.xml")
|
||||||
|
@ -543,9 +453,7 @@ def get_podcast_feed(session: SessionDep, request: Request, podcast_id: str):
|
||||||
if episode.duration is not None
|
if episode.duration is not None
|
||||||
else None,
|
else None,
|
||||||
),
|
),
|
||||||
long_summary=markdown.markdown(episode.description)
|
long_summary=render_markdown(episode.description),
|
||||||
if episode.description is not None
|
|
||||||
else None,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -582,3 +490,11 @@ def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str):
|
||||||
return FileResponse(settings.directory / podcast.image_filename)
|
return FileResponse(settings.directory / podcast.image_filename)
|
||||||
|
|
||||||
return HTTPException(status_code=404, detail="File not found")
|
return HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/{catchall:path}")
|
||||||
|
async def serve_app(catchall: str):
|
||||||
|
return FileResponse("dist/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
use_route_names_as_operation_ids(app)
|
||||||
|
|
106
src/models.py
106
src/models.py
|
@ -1,72 +1,92 @@
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import nanoid
|
import nanoid
|
||||||
|
from pydantic import ConfigDict, computed_field
|
||||||
from sqlalchemy import Engine
|
from sqlalchemy import Engine
|
||||||
from sqlmodel import Field, Relationship, Session, SQLModel, create_engine
|
from sqlmodel import Field, Relationship, SQLModel, create_engine
|
||||||
|
|
||||||
|
from helpers import render_markdown
|
||||||
from settings import settings
|
from settings import settings
|
||||||
|
|
||||||
|
|
||||||
class Podcast(SQLModel, table=True):
|
class PodcastBase(SQLModel):
|
||||||
id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate())
|
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
explicit: bool = Field(default=True)
|
explicit: bool = Field(default=True)
|
||||||
image_filename: Optional[str] = Field(default=None)
|
|
||||||
owner_id: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
episodes: list["PodcastEpisode"] = Relationship(back_populates="podcast")
|
|
||||||
|
|
||||||
|
|
||||||
class PodcastEpisode(SQLModel, table=True):
|
class Podcast(PodcastBase, table=True):
|
||||||
id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate())
|
id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate())
|
||||||
|
owner_id: Optional[str] = Field(default=None)
|
||||||
|
episodes: List["PodcastEpisode"] = Relationship(
|
||||||
|
back_populates="podcast", cascade_delete=True
|
||||||
|
)
|
||||||
|
image_filename: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastPublic(PodcastBase):
|
||||||
|
id: str
|
||||||
|
image_filename: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastCreate(PodcastBase):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
description: str = Field(min_length=1, default_factory=lambda data: data["name"])
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastUpdate(SQLModel):
|
||||||
|
name: Optional[str] = Field(min_length=1)
|
||||||
|
description: Optional[str] = Field(min_length=1)
|
||||||
|
explicit: Optional[bool] = Field(default=True)
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastEpisodeBase(SQLModel):
|
||||||
name: str
|
name: str
|
||||||
duration: Optional[float] = Field(default=None)
|
duration: Optional[float] = Field(default=None)
|
||||||
description: Optional[str] = Field(default=None)
|
description: Optional[str] = Field(default=None)
|
||||||
file_hash: str
|
file_hash: str
|
||||||
file_size: int
|
file_size: int
|
||||||
publish_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
publish_date: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastEpisode(PodcastEpisodeBase, table=True):
|
||||||
|
id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate())
|
||||||
podcast_id: str = Field(foreign_key="podcast.id")
|
podcast_id: str = Field(foreign_key="podcast.id")
|
||||||
podcast: Podcast = Relationship(back_populates="episodes")
|
podcast: Podcast = Relationship(back_populates="episodes")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastEpisodePublic(PodcastEpisodeBase):
|
||||||
|
id: str
|
||||||
|
podcast_id: str
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
def description_html(self) -> Optional[str]:
|
||||||
|
return render_markdown(self.description)
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastEpisodeCreate(PodcastEpisodeBase):
|
||||||
|
podcast_id: str
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastEpisodeUpdate(SQLModel):
|
||||||
|
name: Optional[str] = Field(min_length=1)
|
||||||
|
description: Optional[str] = Field(min_length=1)
|
||||||
|
publish_date: Optional[datetime] = Field(default=None, nullable=True)
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
|
||||||
def setup_db(engine: Engine):
|
def setup_db(engine: Engine):
|
||||||
SQLModel.metadata.create_all(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'}")
|
engine = create_engine(f"sqlite:///{settings.directory / 'data.db'}")
|
||||||
|
|
|
@ -1,17 +1,32 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import List
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import BaseModel, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
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")
|
||||||
forward_auth_name_header: Optional[str] = Field(default=None)
|
oidc_authorize_url: str = Field()
|
||||||
forward_auth_uid_header: Optional[str] = Field(default=None)
|
oidc_token_url: str = Field()
|
||||||
|
oidc_jwks_url: str = Field()
|
||||||
|
oidc_permitted_jwt_audiences: List[str] = Field()
|
||||||
|
oidc_client_id: str = Field()
|
||||||
|
oidc_sub_jwt_attribute: str = Field(default="sub")
|
||||||
|
oidc_name_jwt_attribute: str = Field(default="name")
|
||||||
|
oidc_scopes: List[str] = Field(
|
||||||
|
default=["openid", "email", "profile", "offline_access"]
|
||||||
|
)
|
||||||
|
oidc_authority: str = Field()
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_nested_delimiter="__", env_prefix="PG_")
|
model_config = SettingsConfigDict(env_nested_delimiter="__", env_prefix="PG_")
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig(BaseModel):
|
||||||
|
oidc_authority: str = Field()
|
||||||
|
oidc_client_id: str = Field()
|
||||||
|
oidc_scopes: List[str] = Field()
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
{% extends 'admin_layout.html.j2' %}
|
|
||||||
{% block content %}
|
|
||||||
{{ super() }}
|
|
||||||
<h1>{{ episode.name }}</h1>
|
|
||||||
<form method="post">
|
|
||||||
<fieldset>
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input name="name" value="{{ episode.name }}" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Description
|
|
||||||
<textarea name="description"
|
|
||||||
aria-describedby="description-help">{% if episode.description %}{{ episode.description }}{% endif %}</textarea>
|
|
||||||
<small id="description-help"><a href="https://www.markdownguide.org/cheat-sheet/">Markdown</a> is supported
|
|
||||||
for any content in here.</small>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<input type="submit" value="Update" />
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
|
@ -1,7 +0,0 @@
|
||||||
{% extends 'admin_layout.html.j2' %}
|
|
||||||
{% block content %}
|
|
||||||
{{ super() }}
|
|
||||||
<h1>{{ title }}</h1>
|
|
||||||
<p>{{ description }}</p>
|
|
||||||
<p><a href="#" onclick="history.back()">Go Back</a></p>
|
|
||||||
{% endblock %}
|
|
|
@ -1,116 +0,0 @@
|
||||||
{% extends 'admin_layout.html.j2' %}
|
|
||||||
{% block content %}
|
|
||||||
{{ super() }}
|
|
||||||
{% if podcast.image_filename %}
|
|
||||||
<img src="/{{ podcast.id }}/{{ podcast.image_filename }}" width="256px" />
|
|
||||||
<br><br>
|
|
||||||
{% endif %}
|
|
||||||
<h1>{{ podcast.name }}</h1>
|
|
||||||
<p>
|
|
||||||
<b>Actions:</b>
|
|
||||||
<a href="/admin/{{ podcast.id }}/edit">Edit</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>Description:</b>
|
|
||||||
{{ podcast.description }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>Subscribe:</b>
|
|
||||||
<pre><code>{{ feed_uri }}</code></pre>
|
|
||||||
<small><i>For Apple Podcasts, open the app and click on the Library tab along the bottom. Select the ellipsis in the top
|
|
||||||
right and hit "Follow a Show by URL...". For other apps, look for an option allowing you to add a show by
|
|
||||||
URL.</i></small>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
|
||||||
<h2>Upload Episode</h2>
|
|
||||||
<div>
|
|
||||||
<label for="fileInput">Choose file to upload</label>
|
|
||||||
<input type="file" id="fileInput" name="fileInput" onchange="reset()">
|
|
||||||
<input type="button" id="submitButton" value="Upload" onclick="go()">
|
|
||||||
<p id="response"></p>
|
|
||||||
</div>
|
|
||||||
<h2>Episodes</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Name</th>
|
|
||||||
<th scope="col">Published</th>
|
|
||||||
<th scope="col">Length</th>
|
|
||||||
<th scope="col">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for episode in episodes %}
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{{ episode.name }}</th>
|
|
||||||
<td>{{ episode.publish_date.strftime("%H:%M %d/%m/%Y") }}</td>
|
|
||||||
<td>
|
|
||||||
{% if episode.duration %}
|
|
||||||
{{ (episode.duration / 60) | round | int }}min
|
|
||||||
{% else %}
|
|
||||||
?
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="/admin/{{ podcast.id }}/{{ episode.id }}/delete">Delete</a>
|
|
||||||
<a href="/admin/{{ podcast.id }}/{{ episode.id }}/edit">Edit</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<script type="module">
|
|
||||||
import HugeUploader from "https://cdn.skypack.dev/huge-uploader";
|
|
||||||
|
|
||||||
const resp = document.getElementById("response");
|
|
||||||
const fileInput = document.getElementById("fileInput");
|
|
||||||
const submitButton = document.getElementById("submitButton");
|
|
||||||
|
|
||||||
window.reset = () => {
|
|
||||||
resp.innerHTML = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFormEnabled(enabled) {
|
|
||||||
submitButton.disabled = !enabled;
|
|
||||||
fileInput.disabled = !enabled;
|
|
||||||
resp.setAttribute("aria-busy", JSON.stringify(!enabled));
|
|
||||||
}
|
|
||||||
|
|
||||||
window.go = () => {
|
|
||||||
const file = fileInput.files[0];
|
|
||||||
if (!file) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormEnabled(false);
|
|
||||||
|
|
||||||
const uploader = new HugeUploader({
|
|
||||||
endpoint: "/admin/{{ podcast.id }}/upload",
|
|
||||||
file: file,
|
|
||||||
headers: {
|
|
||||||
"name": encodeURI(file.name)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
uploader.on("error", (err) => {
|
|
||||||
console.error("Upload error", err);
|
|
||||||
resp.innerHTML = "Something has gone wrong!";
|
|
||||||
setFormEnabled(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
uploader.on("progress", (progress) => {
|
|
||||||
if (progress.detail == 100) return;
|
|
||||||
resp.innerHTML = `Uploading ${progress.detail}%...`;
|
|
||||||
});
|
|
||||||
|
|
||||||
uploader.on("finish", (body) => {
|
|
||||||
console.log("Upload complete", body);
|
|
||||||
resp.innerHTML = "Upload complete! The episode will be processed in the background. This may take a few minutes but it's safe to navigate away.";
|
|
||||||
setFormEnabled(true);
|
|
||||||
fileInput.value = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
|
@ -1,25 +0,0 @@
|
||||||
{% extends 'admin_layout.html.j2' %}
|
|
||||||
{% block content %}
|
|
||||||
{{ super() }}
|
|
||||||
<h1>{{ podcast.name }}</h1>
|
|
||||||
<form method="post" enctype="multipart/form-data">
|
|
||||||
<fieldset>
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input name="name" value="{{ podcast.name }}" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Description
|
|
||||||
<textarea name="description" required>{{ podcast.description }}</textarea>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Image
|
|
||||||
<input name="image" type="file" aria-describedby="image-help" />
|
|
||||||
<small id="image-help">This must be a square JPG or PNG in RGB format, and at least 1400x1400px in size and
|
|
||||||
3000x3000px at most.</small>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<input type="submit" value="Update" />
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
|
@ -1,14 +0,0 @@
|
||||||
{% extends 'admin_layout.html.j2' %}
|
|
||||||
{% block content %}
|
|
||||||
{{ super() }}
|
|
||||||
<h1>Hello {{ user_name }}!</h1>
|
|
||||||
<h2>My Podcasts</h2>
|
|
||||||
<p>
|
|
||||||
<b>Actions:</b> <a href="/admin/new">Create</a>
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
{% for podcast in podcasts %}
|
|
||||||
<li><a href="/admin/{{ podcast.id }}">{{ podcast.name }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endblock %}
|
|
|
@ -1,11 +0,0 @@
|
||||||
{% extends 'layout.html.j2' %}
|
|
||||||
{% block content %}
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Podcast Server</strong></li>
|
|
||||||
</ul>
|
|
||||||
<ul>
|
|
||||||
<li><a href="/admin">Podcasts</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{% endblock %}
|
|
|
@ -1,15 +0,0 @@
|
||||||
{% extends 'admin_layout.html.j2' %}
|
|
||||||
{% block content %}
|
|
||||||
{{ super() }}
|
|
||||||
<h1>New Podcast</h1>
|
|
||||||
<form method="post">
|
|
||||||
<fieldset>
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input name="name" required />
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<input type="submit" value="Create" />
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
|
@ -1,18 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="color-scheme" content="light dark">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
|
||||||
<title>Podcast Server</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<main class="container">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
141
uv.lock
141
uv.lock
|
@ -10,6 +10,20 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 },
|
{ url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.14.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/99/09/f844822e4e847a3f0bd41797f93c4674cd4d2462a3f6c459aa528cdf786e/alembic-1.14.1.tar.gz", hash = "sha256:496e888245a53adf1498fcab31713a469c65836f8de76e01399aa1c3e90dd213", size = 1918219 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/7e/ac0991d1745f7d755fc1cd381b3990a45b404b4d008fc75e2a983516fbfe/alembic-1.14.1-py3-none-any.whl", hash = "sha256:1acdd7a3a478e208b0503cd73614d5e4c6efafa4e73518bb60e4f2846a37b1c5", size = 233565 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
@ -32,6 +46,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
|
{ url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bleach"
|
||||||
|
version = "6.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "webencodings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2024.12.14"
|
version = "2024.12.14"
|
||||||
|
@ -41,6 +67,28 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 },
|
{ url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "1.17.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.1"
|
version = "3.4.1"
|
||||||
|
@ -96,6 +144,37 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424 },
|
{ url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "44.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dateutils"
|
name = "dateutils"
|
||||||
version = "0.6.12"
|
version = "0.6.12"
|
||||||
|
@ -304,6 +383,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsdangerous"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jinja2"
|
name = "jinja2"
|
||||||
version = "3.1.5"
|
version = "3.1.5"
|
||||||
|
@ -341,6 +429,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/db/214290d58ad68c587bd5d6af3d34e56830438733d0d0856c0275fde43652/lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d", size = 3814417 },
|
{ url = "https://files.pythonhosted.org/packages/7d/db/214290d58ad68c587bd5d6af3d34e56830438733d0d0856c0275fde43652/lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d", size = 3814417 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.3.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/d9/8518279534ed7dace1795d5a47e49d5299dd0994eed1053996402a8902f9/mako-1.3.8.tar.gz", hash = "sha256:577b97e414580d3e088d47c2dbbe9594aa7a5146ed2875d4dfa9075af2dd3cc8", size = 392069 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/bf/7a6a36ce2e4cafdfb202752be68850e22607fccd692847c45c1ae3c17ba6/Mako-1.3.8-py3-none-any.whl", hash = "sha256:42f48953c7eb91332040ff567eb7eea69b22e7a4affbc5ba8e845e8f730f6627", size = 78569 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown"
|
name = "markdown"
|
||||||
version = "3.7"
|
version = "3.7"
|
||||||
|
@ -441,35 +541,49 @@ version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
|
{ name = "alembic" },
|
||||||
|
{ name = "bleach" },
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "ffmpeg-normalize" },
|
{ name = "ffmpeg-normalize" },
|
||||||
{ name = "ffmpeg-python" },
|
{ name = "ffmpeg-python" },
|
||||||
|
{ name = "itsdangerous" },
|
||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
{ name = "nanoid" },
|
{ name = "nanoid" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "podgen" },
|
{ name = "podgen" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "pyjwt" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
|
{ name = "requests" },
|
||||||
{ name = "sqlmodel" },
|
{ name = "sqlmodel" },
|
||||||
{ name = "structlog" },
|
{ name = "structlog" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiofiles", specifier = ">=24.1.0" },
|
{ name = "aiofiles", specifier = ">=24.1.0" },
|
||||||
|
{ name = "alembic", specifier = ">=1.14.1" },
|
||||||
|
{ name = "bleach", specifier = ">=6.2.0" },
|
||||||
|
{ name = "cryptography", specifier = ">=44.0.0" },
|
||||||
{ 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 = "itsdangerous", specifier = ">=2.2.0" },
|
||||||
{ name = "markdown", specifier = ">=3.7" },
|
{ name = "markdown", specifier = ">=3.7" },
|
||||||
{ name = "nanoid", specifier = ">=2.0.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 = "pyjwt", specifier = ">=2.10.1" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||||
|
{ name = "requests", specifier = ">=2.32.3" },
|
||||||
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
||||||
{ name = "structlog", specifier = ">=24.4.0" },
|
{ name = "structlog", specifier = ">=24.4.0" },
|
||||||
|
{ name = "uvicorn", specifier = ">=0.34.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -489,6 +603,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/f9/c78c6c4ab0474dc095c8037e18484e02149c33f9a0870bdf1cc6ce7eb339/podgen-1.1.0-py2.py3-none-any.whl", hash = "sha256:0ee7b262c131773e5973a63a56a5ee96375ea522b1c30ccd4ec4c35e0c1bb2c6", size = 38268 },
|
{ url = "https://files.pythonhosted.org/packages/12/f9/c78c6c4ab0474dc095c8037e18484e02149c33f9a0870bdf1cc6ce7eb339/podgen-1.1.0-py2.py3-none-any.whl", hash = "sha256:0ee7b262c131773e5973a63a56a5ee96375ea522b1c30ccd4ec4c35e0c1bb2c6", size = 38268 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "2.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.10.5"
|
version = "2.10.5"
|
||||||
|
@ -550,6 +673,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
|
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyjwt"
|
||||||
|
version = "2.10.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
|
@ -845,6 +977,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/da/725f97a8b1b4e7b3e4331cce3ef921b12568af3af403b9f0f61ede036898/watchfiles-1.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:160eff7d1267d7b025e983ca8460e8cc67b328284967cbe29c05f3c3163711a3", size = 285246 },
|
{ url = "https://files.pythonhosted.org/packages/f0/da/725f97a8b1b4e7b3e4331cce3ef921b12568af3af403b9f0f61ede036898/watchfiles-1.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:160eff7d1267d7b025e983ca8460e8cc67b328284967cbe29c05f3c3163711a3", size = 285246 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webencodings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "14.1"
|
version = "14.1"
|
||||||
|
|
Loading…
Reference in a new issue