frontend rewrite
All checks were successful
ci/woodpecker/push/build Pipeline was successful

This commit is contained in:
Jake Walker 2025-01-23 19:09:45 +00:00
parent 2d424d0be6
commit 7d60654d37
No known key found for this signature in database
64 changed files with 4877 additions and 802 deletions

View file

@ -1,3 +1,5 @@
data
uploads
.venv
client/node_modules
client/.vite

View file

@ -11,5 +11,5 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yml,yaml}]
[*.{yml,yaml,ts,tsx,js,jsx,json}]
indent_size = 2

View file

@ -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
WORKDIR /opt
@ -9,8 +20,9 @@ RUN apk add --update --no-cache ffmpeg \
&& uv sync --frozen
COPY . /opt
COPY --from=frontend-build /app/dist /opt/dist
ENV PG_DIRECTORY=/work
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"]

View file

@ -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.
## 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
| Name | Default | Description |
| ---- | ------- | ----------- |
| `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_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
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
# install dependencies
uv sync
# migrate the database
uv run alembic upgrade head
# run server in development mode
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
View 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
View 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
View 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
View 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
View 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>

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

File diff suppressed because it is too large Load diff

1
client/public/vite.svg Normal file
View 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

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

View 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)
});
};

View file

@ -0,0 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
export * from './types.gen';
export * from './sdk.gen';

View 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
});
};

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

View 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;

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

View 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" />;
}

View 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>
</>;
}

View 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>
);
}

View 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>
);
}

View 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>
</>;
}

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

@ -0,0 +1 @@
export const SERVER_URL = import.meta.env.PROD ? "" : "http://localhost:8000";

21
client/src/helpers.ts Normal file
View 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
View 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
View 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!)

View 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;

View 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;

View 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()}&ensp;&bull;&ensp;{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>
);
}

View 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 >
);
}

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

@ -0,0 +1 @@
/// <reference types="vite/client" />

32
client/tsconfig.app.json Normal file
View 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
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
client/tsconfig.node.json Normal file
View 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
View 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
View file

@ -0,0 +1 @@
Generic single-database configuration.

77
migrations/env.py Normal file
View 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
View 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"}

View 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 ###

View file

@ -6,16 +6,23 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"aiofiles>=24.1.0",
"alembic>=1.14.1",
"bleach>=6.2.0",
"cryptography>=44.0.0",
"fastapi[standard]>=0.115.6",
"ffmpeg-normalize>=1.31.0",
"ffmpeg-python>=0.2.0",
"itsdangerous>=2.2.0",
"markdown>=3.7",
"nanoid>=2.0.0",
"pillow>=11.1.0",
"podgen>=1.1.0",
"pydantic>=2.10.5",
"pydantic-settings>=2.7.1",
"pyjwt>=2.10.1",
"python-multipart>=0.0.20",
"requests>=2.32.3",
"sqlmodel>=0.0.22",
"structlog>=24.4.0",
"uvicorn>=0.34.0",
]

View file

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

View file

@ -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
View 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
View 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
View 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"]},
)

View file

@ -1,29 +1,26 @@
import urllib.parse
import uuid
from contextlib import asynccontextmanager
from datetime import timedelta, timezone
from pathlib import Path
from typing import Annotated, Any, Generator, Optional
import markdown
import podgen
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.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 PIL import Image
from sqlmodel import Session, and_, or_, select
from sqlmodel import Session, and_, desc, select
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 settings import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
models.setup_db(models.engine)
yield
from settings import AppConfig, settings
def get_session() -> Generator[Session, Any, None]:
@ -31,26 +28,26 @@ def get_session() -> Generator[Session, Any, None]:
yield session
def handle_user_auth(request: Request) -> tuple[str, str]:
if (
settings.forward_auth_name_header is None
or settings.forward_auth_uid_header is None
):
return ("default", "Admin")
return (
request.headers.get(settings.forward_auth_uid_header, "default"),
request.headers.get(settings.forward_auth_name_header, "Admin"),
)
def use_route_names_as_operation_ids(app: FastAPI) -> None:
for route in app.routes:
if isinstance(route, APIRoute):
route.operation_id = route.name
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()
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(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
@ -59,103 +56,137 @@ templates = Jinja2Templates(directory="src/templates")
audio_processor = AudioProcessor()
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(
select(models.Podcast).where(
or_(
models.Podcast.owner_id == user[0],
models.Podcast.owner_id == None,
)
)
select(models.Podcast).where(models.Podcast.owner_id == auth.user_id)
).all()
return templates.TemplateResponse(
request=request,
name="admin_feeds.html.j2",
context={
"podcasts": podcasts,
"user_name": user[1],
},
)
return podcasts
@app.get("/admin/new")
def admin_create_podcast(request: Request):
return templates.TemplateResponse(
request=request,
name="admin_new.html.j2",
)
@app.get("/api/podcasts/{podcast_id}", response_model=models.PodcastPublic)
def read_podcast(session: SessionDep, podcast_id: str):
podcast = session.get(models.Podcast, podcast_id)
if not podcast:
raise HTTPException(status_code=404, detail="Podcast not found")
return podcast
@app.post("/admin/new")
def admin_create_podcast_post(
@app.patch("/api/podcasts/{podcast_id}", response_model=models.PodcastPublic)
def update_podcast(
auth: AuthDep,
session: SessionDep,
request: Request,
user: AuthDep,
name: Annotated[str, Form()],
podcast_id: str,
podcast: models.PodcastUpdate = None,
):
if name.strip() == "":
return templates.TemplateResponse(
request,
name="admin_error.html.j2",
status_code=400,
context={
"title": "Invalid entry",
"description": "Name must not be empty",
},
db_podcast = session.get(models.Podcast, podcast_id)
if not db_podcast:
raise HTTPException(status_code=404, detail="Podcast not found")
if db_podcast.owner_id != auth.user_id:
raise HTTPException(
status_code=403, detail="You do not have permission to update this podcast"
)
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.commit()
return RedirectResponse(f"/admin/{podcast.id}", status_code=303)
session.refresh(podcast)
return podcast
@app.get("/admin/{podcast_id}")
def admin_list_podcast(
session: SessionDep, request: Request, podcast_id: str, user: AuthDep
):
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()
@app.delete("/api/podcasts/{podcast_id}")
def delete_podcast(auth: AuthDep, session: SessionDep, podcast_id: str):
podcast = session.get(models.Podcast, podcast_id)
if podcast is None:
return templates.TemplateResponse(
request,
name="admin_error.html.j2",
status_code=400,
context={
"title": "Not found",
"description": "The podcast was not found.",
},
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 delete this podcast"
)
episodes = podcast.episodes
episodes.sort(key=lambda e: e.publish_date, reverse=True)
return templates.TemplateResponse(
request=request,
name="admin_feed.html.j2",
context={
"podcast": podcast,
"episodes": episodes,
"feed_uri": urllib.parse.urljoin(
str(request.base_url), f"{podcast.id}.xml"
),
},
)
session.delete(podcast)
session.commit()
return {"ok": True}
def finish_processing(
@ -185,13 +216,13 @@ def finish_processing(
session.commit()
@app.post("/admin/{podcast_id}/upload")
@app.post("/api/podcasts/{podcast_id}/episodes")
async def admin_upload_episode(
auth: AuthDep,
session: SessionDep,
request: Request,
podcast_id: str,
file: UploadFile,
user: AuthDep,
):
file_id = request.headers.get("uploader-file-id")
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()
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,
),
)
)
select(models.Podcast).where(models.Podcast.id == podcast_id)
).first()
if podcast is None:
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
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")
def admin_delete_episode(
@app.get(
"/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,
request: Request,
podcast_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(
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 not episode:
raise HTTPException(status_code=404, detail="Episode not found")
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 episode.podcast.owner_id != auth.user_id:
raise HTTPException(
status_code=403, detail="You do not have permission to delete this episode"
)
(settings.directory / f"{episode_id}.m4a").unlink()
session.delete(episode)
session.commit()
return RedirectResponse(f"/admin/{podcast_id}", status_code=303)
@app.get("/admin/{podcast_id}/{episode_id}/edit")
def admin_edit_episode(
session: SessionDep,
request: Request,
podcast_id: str,
episode_id: str,
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)
return {"ok": True}
@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
else None,
),
long_summary=markdown.markdown(episode.description)
if episode.description is not None
else None,
long_summary=render_markdown(episode.description),
)
)
@ -582,3 +490,11 @@ def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str):
return FileResponse(settings.directory / podcast.image_filename)
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)

View file

@ -1,72 +1,92 @@
import json
from datetime import datetime, timezone
from typing import Optional
from typing import List, Optional
import nanoid
from pydantic import ConfigDict, computed_field
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
class Podcast(SQLModel, table=True):
id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate())
class PodcastBase(SQLModel):
name: str
description: str
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())
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
duration: Optional[float] = Field(default=None)
description: Optional[str] = Field(default=None)
file_hash: str
file_size: int
publish_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
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: 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):
SQLModel.metadata.create_all(engine)
# try and migrate old data
old_data = settings.directory / "data.json"
if old_data.is_file():
try:
session = Session(engine)
with open(old_data, "r") as f:
data = json.load(f)
for id, item in data["podcasts"].items():
podcast = Podcast(
id=id,
name=item.get("name"),
description=item.get("description"),
explicit=item.get("explicit"),
image_filename=item.get("image_filename"),
)
session.add(podcast)
session.commit()
for episode in item["episodes"]:
ep = PodcastEpisode.model_validate(
{**episode, "podcast_id": podcast.id, "id": nanoid.generate()}
)
session.add(ep)
session.commit()
old_data.unlink()
except Exception as ex:
print("Failed to migrate old data", ex)
engine = create_engine(f"sqlite:///{settings.directory / 'data.db'}")

View file

@ -1,17 +1,32 @@
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
class Settings(BaseSettings):
directory: Path = Field(default=Path.cwd() / "data")
uploads_directory: Path = Field(default=Path.cwd() / "uploads")
forward_auth_name_header: Optional[str] = Field(default=None)
forward_auth_uid_header: Optional[str] = Field(default=None)
oidc_authorize_url: str = Field()
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_")
class AppConfig(BaseModel):
oidc_authority: str = Field()
oidc_client_id: str = Field()
oidc_scopes: List[str] = Field()
settings = Settings()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -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 },
]
[[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]]
name = "annotated-types"
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 },
]
[[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]]
name = "certifi"
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 },
]
[[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]]
name = "charset-normalizer"
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 },
]
[[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]]
name = "dateutils"
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 },
]
[[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]]
name = "jinja2"
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 },
]
[[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]]
name = "markdown"
version = "3.7"
@ -441,35 +541,49 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },
{ name = "alembic" },
{ name = "bleach" },
{ name = "cryptography" },
{ name = "fastapi", extra = ["standard"] },
{ name = "ffmpeg-normalize" },
{ name = "ffmpeg-python" },
{ name = "itsdangerous" },
{ name = "markdown" },
{ name = "nanoid" },
{ name = "pillow" },
{ name = "podgen" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "python-multipart" },
{ name = "requests" },
{ name = "sqlmodel" },
{ name = "structlog" },
{ name = "uvicorn" },
]
[package.metadata]
requires-dist = [
{ 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 = "ffmpeg-normalize", specifier = ">=1.31.0" },
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
{ name = "itsdangerous", specifier = ">=2.2.0" },
{ name = "markdown", specifier = ">=3.7" },
{ name = "nanoid", specifier = ">=2.0.0" },
{ name = "pillow", specifier = ">=11.1.0" },
{ name = "podgen", specifier = ">=1.1.0" },
{ name = "pydantic", specifier = ">=2.10.5" },
{ name = "pydantic-settings", specifier = ">=2.7.1" },
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "sqlmodel", specifier = ">=0.0.22" },
{ name = "structlog", specifier = ">=24.4.0" },
{ name = "uvicorn", specifier = ">=0.34.0" },
]
[[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 },
]
[[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]]
name = "pydantic"
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 },
]
[[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]]
name = "python-dateutil"
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 },
]
[[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]]
name = "websockets"
version = "14.1"