Compare commits
3 commits
e38edc45fa
...
776cf139fa
Author | SHA1 | Date | |
---|---|---|---|
776cf139fa | |||
44827ef205 | |||
80d81712c7 |
26 changed files with 1309 additions and 453 deletions
|
@ -25,4 +25,7 @@ COPY --from=frontend-build /app/dist /opt/dist
|
|||
ENV PG_DIRECTORY=/work
|
||||
ENV PG_UPLOADS_DIRECTORY=/uploads
|
||||
|
||||
RUN chmod a+x /opt/docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/opt/docker-entrypoint.sh"]
|
||||
CMD ["uv", "run", "uvicorn", "--app-dir", "/opt/src", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
|
@ -13,12 +13,17 @@
|
|||
"@hey-api/client-fetch": "^0.7.0",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@soorria/solid-dropzone": "^1.0.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/solid-query": "^5.64.1",
|
||||
"bulma": "^1.0.3",
|
||||
"daisyui": "^5.0.46",
|
||||
"huge-uploader": "^1.0.6",
|
||||
"lucide-solid": "^0.473.0",
|
||||
"node-vibrant": "^4.0.3",
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"solid-js": "^1.9.3"
|
||||
"solid-js": "^1.9.3",
|
||||
"tailwindcss": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.62.1",
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,54 +1,51 @@
|
|||
import { A } from "@solidjs/router";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { children, createSignal, ErrorBoundary, ParentProps, Suspense } from "solid-js";
|
||||
import { children, ErrorBoundary, ParentProps, Suspense } from "solid-js";
|
||||
import { readUserOptions } from "./client/@tanstack/solid-query.gen";
|
||||
import { logout } from "./auth";
|
||||
import { Menu } from "lucide-solid";
|
||||
|
||||
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 bg-primary text-primary-content shadow-sm">
|
||||
<div class="navbar-start">
|
||||
<A class="navbar-item" href="/admin">
|
||||
Podcasts
|
||||
</A>
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<Menu class="h-5 w-5" />
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow text-base-content">
|
||||
<li><A href="/admin">Podcasts</A></li>
|
||||
</ul>
|
||||
</div>
|
||||
<A class="btn btn-ghost text-xl" href="/admin">Podcast Admin</A>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><A href="/admin">Podcasts</A></li>
|
||||
</ul>
|
||||
</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>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<ErrorBoundary fallback={() => <p>Error</p>}>
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<p>Hey {userQuery.data?.user_name.split(" ")[0]}!</p>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<div class="navbar-item">
|
||||
<a class="button is-light" onClick={logout}>
|
||||
<a class="btn" onClick={logout}>
|
||||
Log out
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
<main class="h-100">
|
||||
{content()}
|
||||
</main>
|
||||
</>;
|
||||
|
|
|
@ -40,6 +40,7 @@ export type PodcastEpisodePublic = {
|
|||
file_hash: string;
|
||||
file_size: number;
|
||||
publish_date?: string;
|
||||
request_count?: number;
|
||||
id: string;
|
||||
podcast_id: string;
|
||||
description_html: string | null;
|
||||
|
@ -57,6 +58,7 @@ export type PodcastPublic = {
|
|||
explicit?: boolean;
|
||||
id: string;
|
||||
image_filename?: string | null;
|
||||
episode_count: number;
|
||||
};
|
||||
|
||||
export type PodcastUpdate = {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { createContext, createEffect, createSignal, ParentComponent } from "solid-js";
|
||||
import { fetchUser, login, logout, user } from "../auth";
|
||||
import { User } from "oidc-client-ts";
|
||||
import Loading from "./loading";
|
||||
|
||||
interface AuthContextType {
|
||||
user: () => User | null;
|
||||
|
@ -20,7 +21,7 @@ const AuthProvider: ParentComponent = (props) => {
|
|||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout }}>
|
||||
{isLoading() ? <p>Loading...</p> : props.children}
|
||||
{isLoading() ? <Loading /> : props.children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createDropzone } from "@soorria/solid-dropzone";
|
||||
import { UploadIcon } from "lucide-solid";
|
||||
import { Info, 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";
|
||||
|
@ -50,15 +50,16 @@ export default function UploadEpisodeAdditional(props: { podcastId: string, epis
|
|||
|
||||
return <>
|
||||
<Show when={statusText()}>
|
||||
<div class="notification is-info">
|
||||
{statusText()}
|
||||
<div role="alert" class="alert">
|
||||
<Info class="stroke-primary h-6 w-6 shrink-0" />
|
||||
<p>{statusText()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!uploading()}>
|
||||
<div {...dropzone.getRootProps()} class="box is-flex is-flex-direction-column is-align-items-center p-6 mb-6">
|
||||
<div {...dropzone.getRootProps()} class="bg-base-200 rounded-lg shadow-sm flex flex-col items-center p-6 mb-6">
|
||||
<input {...dropzone.getInputProps()} />
|
||||
<UploadIcon size="42" />
|
||||
<p class="mt-4 has-text-centered">
|
||||
<p class="mt-4 text-center">
|
||||
{dropzone.isDragActive ? "Drop extra file here..." : "Drag an extra file here to upload, or click to open"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
import { XCircle } from "lucide-solid";
|
||||
|
||||
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>
|
||||
<div class="container mx-auto">
|
||||
<div role="alert" class="alert alert-error alert-vertical sm:alert-horizontal">
|
||||
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<div>
|
||||
<h3 class="font-bold">Uh Oh! Something has gone wrong.</h3>
|
||||
{props.message && <>
|
||||
<p class="mb-4">Error: <code>{props.message.toString()}</code></p>
|
||||
<div class="text-xs">Error: <code>{props.message.toString()}</code></div>
|
||||
</>}
|
||||
{props.reset && <button class="button" onClick={props.reset}>Retry</button>}
|
||||
</div>
|
||||
</section>
|
||||
{props.reset && <button class="btn btn-sm" onClick={props.reset}>Retry</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
export default function Loading() {
|
||||
export default function Loading(props: { loadingText?: string }) {
|
||||
return (
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">One moment...</h1>
|
||||
<progress class="progress is-primary" max="100" />
|
||||
<div class="container mx-auto h-full">
|
||||
<div class="w-full h-full flex items-center justify-center gap-2">
|
||||
<span class="loading loading-spinner"></span>
|
||||
<p>{props.loadingText || "One moment..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createDropzone } from "@soorria/solid-dropzone";
|
||||
import { UploadIcon } from "lucide-solid";
|
||||
import { Info, 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";
|
||||
|
@ -49,15 +49,16 @@ export default function UploadImage(props: { podcastId: string }) {
|
|||
|
||||
return <>
|
||||
<Show when={statusText()}>
|
||||
<div class="notification is-info">
|
||||
{statusText()}
|
||||
<div role="alert" class="alert">
|
||||
<Info class="stroke-primary h-6 w-6 shrink-0" />
|
||||
<p>{statusText()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!uploading()}>
|
||||
<div {...dropzone.getRootProps()} class="box is-flex is-flex-direction-column is-align-items-center p-6 mb-6">
|
||||
<div {...dropzone.getRootProps()} class="bg-base-200 rounded-lg shadow-sm flex flex-col items-center p-6 mb-6">
|
||||
<input {...dropzone.getInputProps()} />
|
||||
<UploadIcon size="42" />
|
||||
<p class="mt-4 has-text-centered">
|
||||
<p class="mt-4 text-center">
|
||||
{dropzone.isDragActive ? "Drop podcast image here..." : "Drag podcast image here to upload, or click to open"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createDropzone } from "@soorria/solid-dropzone";
|
||||
import { UploadIcon } from "lucide-solid";
|
||||
import { Info, UploadIcon } from "lucide-solid";
|
||||
import HugeUploader from "huge-uploader";
|
||||
import { SERVER_URL } from "../constants";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
|
@ -53,16 +53,20 @@ export default function Upload(props: { podcastId: string }) {
|
|||
|
||||
return <>
|
||||
<Show when={statusText()}>
|
||||
<div class="notification is-info">
|
||||
{uploadProgress() && <progress class="progress mb-4" value={uploadProgress()!} max="100">{uploadProgress()}%</progress>}
|
||||
<div role="alert" class="alert">
|
||||
<Info class="stroke-primary h-6 w-6 shrink-0" />
|
||||
<div class="w-full">
|
||||
{statusText()}
|
||||
{uploadProgress() && <progress class="progress progress-primary w-full" value={uploadProgress()!} max="100">{uploadProgress()}%</progress>}
|
||||
{uploading() && <p class="text-xs">Do not navigate away until the upload has completed as any progress will be lost.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!uploading()}>
|
||||
<div {...dropzone.getRootProps()} class="box is-flex is-flex-direction-column is-align-items-center p-6 mb-6">
|
||||
<div {...dropzone.getRootProps()} class="bg-base-200 rounded-lg shadow-sm flex flex-col items-center p-6 mb-6">
|
||||
<input {...dropzone.getInputProps()} />
|
||||
<UploadIcon size="42" />
|
||||
<p class="mt-4 has-text-centered">
|
||||
<p class="mt-4 text-center">
|
||||
{dropzone.isDragActive ? "Drop episode here..." : "Drag episode here to upload, or click to open"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
export function humanFileSize(bytes: number, si=false, dp=1) {
|
||||
const pluralRules = new Intl.PluralRules("en-GB");
|
||||
|
||||
export function humanFileSize(bytes: number, si = false, dp = 1) {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
|
@ -9,7 +11,7 @@ export function humanFileSize(bytes: number, si=false, dp=1) {
|
|||
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||
let u = -1;
|
||||
const r = 10**dp;
|
||||
const r = 10 ** dp;
|
||||
|
||||
do {
|
||||
bytes /= thresh;
|
||||
|
@ -19,3 +21,14 @@ export function humanFileSize(bytes: number, si=false, dp=1) {
|
|||
|
||||
return bytes.toFixed(dp) + ' ' + units[u];
|
||||
}
|
||||
|
||||
export function pluralize(count: number, singular: string, plural: string): string {
|
||||
const grammaticalNumber = pluralRules.select(count);
|
||||
|
||||
switch (grammaticalNumber) {
|
||||
case "one":
|
||||
return `${count} ${singular}`;
|
||||
default:
|
||||
return `${count} ${plural}`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,68 +1,11 @@
|
|||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "daisyui";
|
||||
|
||||
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;
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
.container {
|
||||
@apply py-8 px-4;
|
||||
}
|
||||
|
|
|
@ -5,12 +5,13 @@ 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';
|
||||
|
||||
import "./index.css";
|
||||
|
||||
const wrapper = document.getElementById('root');
|
||||
|
||||
if (!wrapper) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useNavigate } from "@solidjs/router"
|
||||
import { onMount } from "solid-js";
|
||||
import { fetchUser, getUserManager } from "../auth";
|
||||
import Loading from "../components/loading";
|
||||
|
||||
const Callback = () => {
|
||||
const navigate = useNavigate();
|
||||
|
@ -11,7 +12,7 @@ const Callback = () => {
|
|||
navigate("/admin");
|
||||
});
|
||||
|
||||
return <p>Signing in...</p>;
|
||||
return <Loading loadingText="Signing in..." />;
|
||||
}
|
||||
|
||||
export default Callback;
|
||||
|
|
|
@ -2,13 +2,11 @@ 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>
|
||||
<main class="flex items-center justify-center h-full">
|
||||
<div class="w-100 max-w-100 bg-base-100 shadow-sm p-8 rounded-md">
|
||||
<h1 class="text-2xl mb-2">Login</h1>
|
||||
<button class="btn btn-primary w-full" onClick={login}>Sign in with OIDC</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ 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 { AlertCircle, CircleCheck, Save, Trash } from "lucide-solid";
|
||||
import { SERVER_URL } from "../../constants";
|
||||
import { PodcastEpisodePublic } from "../../client";
|
||||
import { humanFileSize } from "../../helpers";
|
||||
|
@ -94,78 +94,62 @@ export default function AdminEpisode() {
|
|||
<AdminLayout>
|
||||
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">{episodeQuery.data?.name}</h1>
|
||||
<h2 class="subtitle">{episodeQuery.data?.publish_date && (new Date(episodeQuery.data?.publish_date)).toLocaleString()} • {episodeQuery.data?.duration && `${(episodeQuery.data.duration / 60).toFixed(0)} minutes` || "Unknown"}</h2>
|
||||
{episodeQuery.data?.description_html && <div class="content" innerHTML={episodeQuery.data?.description_html} />}
|
||||
<div class="container mx-auto">
|
||||
<h1 class="text-4xl line-clamp-2 overflow-ellipsis mb-2">{episodeQuery.data?.name}</h1>
|
||||
<p class="subtitle text-base-content/50 text-xl">{episodeQuery.data?.publish_date && (new Date(episodeQuery.data?.publish_date)).toLocaleString()} • {episodeQuery.data?.duration && `${(episodeQuery.data.duration / 60).toFixed(0)} minutes` || "Unknown"}</p>
|
||||
{episodeQuery.data?.description_html && <div class="mt-4 prose prose-neutral" innerHTML={episodeQuery.data?.description_html} />}
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h2 class="title is-4">Update Episode</h2>
|
||||
<div class="container mx-auto">
|
||||
<h2 class="text-2xl">Update Episode</h2>
|
||||
|
||||
{updateEpisode.isSuccess && <div class="notification is-success">
|
||||
<button class="delete" onClick={() => updateEpisode.reset()}></button>
|
||||
This episode has been updated successfully.
|
||||
{updateEpisode.isSuccess && <div role="alert" class="alert alert-success">
|
||||
<CircleCheck class="stroke-current h-6 w-6 shrink-0" />
|
||||
<p>This episode has been updated successfully.</p>
|
||||
<button class="btn btn-sm" onClick={() => updateEpisode.reset()}>Ok</button>
|
||||
</div>}
|
||||
|
||||
{updateEpisode.isError && <div class="notification is-danger">
|
||||
<button class="delete" onClick={() => updateEpisode.reset()}></button>
|
||||
Something went wrong. {JSON.stringify(updateEpisode.error)}
|
||||
{updateEpisode.isError && <div role="alert" class="alert alert-error">
|
||||
<AlertCircle class="stroke-current h-6 w-6 shrink-0" />
|
||||
<p>Something went wrong. {JSON.stringify(updateEpisode.error)}</p>
|
||||
<button class="btn btn-sm" onClick={() => updateEpisode.reset()}>Ok</button>
|
||||
</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>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Name</legend>
|
||||
<input class="input w-full" type="text" value={name()} onInput={(e) => setName(e.target.value)} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Description</legend>
|
||||
<textarea class="textarea h-24 w-full" onChange={(e) => setDescription(e.target.value)}>{description()}</textarea>
|
||||
<p class="label">The description supports <a href="https://www.markdownguide.org/cheat-sheet/" class="underline hover:no-underline">Markdown</a>. Leave this empty to unset the description.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Publish Date</legend>
|
||||
<input class="input w-full" type="datetime-local" value={publishDate()} onInput={(e) => setPublishDate(e.target.value)} />
|
||||
</fieldset>
|
||||
|
||||
<div class="flex flex-row justify-end mt-2">
|
||||
<button class="btn btn-primary" onClick={() => saveChanges()}><Save /> Save Changes</button>
|
||||
</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>
|
||||
<h2 class="text-2xl mt-8">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>
|
||||
<h2 class="text-2xl mb-4 mt-8">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>Audio URL:</b> <A href={audioUrl()} class="text-primary underline hover:no-underline">{audioUrl()}</A></li>
|
||||
<li><b>SHA256 Checksum:</b> <code class="overflow-x-scroll">{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 class="container mx-auto py-4! rounded-lg border-1 border-error text-error">
|
||||
<h2 class="text-2xl mb-4">Danger Zone</h2>
|
||||
<button class="btn btn-error" onClick={() => deleteAction()}><Trash /> Delete</button>
|
||||
</div>
|
||||
</section>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AdminLayout>
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
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 { createEffect, createMemo, 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 { AlertCircle, CheckCircle, Info, 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";
|
||||
|
||||
function calculateEpisodeRequestPercent(episodeRequestCount?: number, totalRequestCount?: number): number {
|
||||
const totalClipped = (totalRequestCount === 0 ? 1 : totalRequestCount) ?? 1;
|
||||
const percent = (episodeRequestCount ?? 0) / totalClipped;
|
||||
return percent;
|
||||
}
|
||||
|
||||
export default function AdminPodcast() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
@ -79,59 +85,58 @@ export default function AdminPodcast() {
|
|||
});
|
||||
}
|
||||
|
||||
const totalRequestCount = createMemo(() => {
|
||||
if (episodeQuery.isSuccess && episodeQuery.data && Array.isArray(episodeQuery.data)) {
|
||||
return episodeQuery.data.reduce((sum, episode) => sum + (episode.request_count ?? 0), 0);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="columns">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex flex-row gap-4">
|
||||
<Show when={podcastQuery.data?.image_filename}>
|
||||
<div class="column">
|
||||
<figure class="image is-128x128">
|
||||
<img src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
|
||||
</figure>
|
||||
<img class="size-48 md:size-72 rounded-lg" src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
|
||||
</Show>
|
||||
<div class="grow">
|
||||
<h1 class="text-4xl mb-2">{podcastQuery.data?.name}</h1>
|
||||
<p>{podcastQuery.data?.description}</p>
|
||||
|
||||
<Show when={episodeQuery.data?.length || 0 > 0}>
|
||||
<div role="alert" class="alert mt-4">
|
||||
<Info class="h-6 w-6 shrink-0 stroke-info" />
|
||||
<span>Subscribe at <code>{new URL(`${params.podcastId}.xml`, SERVER_URL).href}</code></span>
|
||||
</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 role="tablist" class="tabs tabs-box justify-center mt-4 w-full">
|
||||
<button role="tab" class="tab" classList={{ "tab-active": tab() === 0 }} onClick={() => setTab(0)}><List class="size-5 me-2" /> Episodes</button>
|
||||
<button role="tab" class="tab" classList={{ "tab-active": tab() === 1 }} onClick={() => setTab(1)}><Settings class="size-5 me-2" /> Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Switch>
|
||||
<Match when={tab() === 0}>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="container mx-auto">
|
||||
<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 class="flex flex-row mb-4">
|
||||
<h2 class="text-2xl grow">Published Episodes</h2>
|
||||
<button class="btn btn-sm" onClick={() => episodeQuery.refetch()}><RefreshCw class="size-4" /> Refresh</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table is-narrow is-fullwidth">
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Published</th>
|
||||
<th>Duration</th>
|
||||
<th>Requests</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -143,7 +148,13 @@ export default function AdminPodcast() {
|
|||
<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>
|
||||
{episode.request_count ? <>
|
||||
<div class="radial-progress me-1.5" style={`--value:${(calculateEpisodeRequestPercent(episode.request_count, totalRequestCount()) * 100).toFixed(1)}; --size:1rem; --thickness:0.2rem;`} aria-valuenow={(calculateEpisodeRequestPercent(episode.request_count, totalRequestCount()) * 100).toFixed(1)} role="progressbar"></div>
|
||||
<span>{episode.request_count.toLocaleString()} ({(calculateEpisodeRequestPercent(episode.request_count, totalRequestCount()) * 100).toFixed(1)}%)</span>
|
||||
</> : "-"}
|
||||
</td>
|
||||
<td>
|
||||
<A href={`/admin/${params.podcastId}/${episode.id}`} class="btn btn-ghost btn-xs"><Pencil class="size-4" /> Edit</A>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
@ -152,53 +163,48 @@ export default function AdminPodcast() {
|
|||
</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 class="container mx-auto">
|
||||
{updatePodcast.isSuccess && <div role="alert" class="alert alert-success alert-vertical sm:alert-horizontal">
|
||||
<CheckCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<span>The podcast has been updated successfully.</span>
|
||||
<div>
|
||||
<button class="btn btn-sm" onClick={() => updatePodcast.reset()}>Ok</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{updatePodcast.isError && <div class="notification is-danger">
|
||||
<button class="delete" onClick={() => updatePodcast.reset()}></button>
|
||||
Something went wrong. {JSON.stringify(updatePodcast.error)}
|
||||
{updatePodcast.isError && <div role="alert" class="alert alert-error alert-vertical sm:alert-horizontal">
|
||||
<AlertCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<span>Something went wrong. {JSON.stringify(updatePodcast.error)}</span>
|
||||
<div>
|
||||
<button class="btn btn-sm" onClick={() => updatePodcast.reset()}>Ok</button>
|
||||
</div>
|
||||
</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>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Name</legend>
|
||||
<input class="input w-full" type="text" value={name()} onInput={(e) => setName(e.target.value)} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Description</legend>
|
||||
<textarea class="textarea h-24 w-full" onInput={(e) => setDescription(e.target.value)}>{description()}</textarea>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex flex-row justify-end mt-2">
|
||||
<button class="btn btn-primary" onClick={() => saveChanges()}><Save /> Save Changes</button>
|
||||
</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>
|
||||
<h2 class="text-2xl mb-4 mt-8">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>
|
||||
<div class="container mx-auto py-4! rounded-lg border-1 border-error text-error">
|
||||
<h2 class="text-2xl mb-4">Danger Zone</h2>
|
||||
<button class="btn btn-error" onClick={() => deleteAction()}><Trash /> Delete</button>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
|
|
|
@ -8,6 +8,7 @@ import Error from "../../components/error";
|
|||
import { SERVER_URL } from "../../constants";
|
||||
import { Eye, Plus } from "lucide-solid";
|
||||
import { PodcastPublic } from "../../client";
|
||||
import { pluralize } from "../../helpers";
|
||||
|
||||
export default function AdminPodcasts() {
|
||||
const queryClient = useQueryClient();
|
||||
|
@ -40,39 +41,37 @@ export default function AdminPodcasts() {
|
|||
<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()}>
|
||||
<div class="container mx-auto">
|
||||
<div class="flex flex-row mb-4">
|
||||
<h1 class="text-2xl grow">Podcasts</h1>
|
||||
<button class="btn btn-sm" onClick={() => createAction()}>
|
||||
<Plus />
|
||||
Create
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<For each={query.data}>
|
||||
{(podcast) => (
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="columns">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<Show when={podcast.image_filename}>
|
||||
<div class="column">
|
||||
<figure class="image is-64x64">
|
||||
<figure>
|
||||
<img src={new URL("/files/" + 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 class="card-body">
|
||||
<h2 class="card-title mb-0">{podcast.name}</h2>
|
||||
<p class="text-base-content/50">{pluralize(podcast.episode_count, "episode", "episodes")}</p>
|
||||
<p>{podcast.description}</p>
|
||||
<div class="card-actions justify-end mt-2">
|
||||
<A class="btn btn-primary" href={`/admin/${podcast.id}`}><Eye /> View</A>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AdminLayout>
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
import { useParams } from "@solidjs/router";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { readPodcastOptions, readEpisodesOptions } from "../client/@tanstack/solid-query.gen";
|
||||
import { ErrorBoundary, For, Show, Suspense } from "solid-js";
|
||||
import { createEffect, ErrorBoundary, For, Show, Suspense } from "solid-js";
|
||||
import Error from "../components/error";
|
||||
import Loading from "../components/loading";
|
||||
import { DownloadIcon, MusicIcon } from "lucide-solid";
|
||||
import { SERVER_URL } from "../constants";
|
||||
import { Vibrant, WorkerPipeline } from "node-vibrant/worker";
|
||||
import PipelineWorker from "node-vibrant/worker.worker?worker";
|
||||
|
||||
Vibrant.use(new WorkerPipeline(PipelineWorker as never));
|
||||
|
||||
export default function Podcast() {
|
||||
const params = useParams();
|
||||
|
@ -23,61 +27,73 @@ export default function Podcast() {
|
|||
podcast_id: params.podcastId,
|
||||
}
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
createEffect(async () => {
|
||||
if (!podcastQuery.data?.image_filename) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageUrl = new URL(`/files/${params.podcastId}/${podcastQuery.data.image_filename}`, SERVER_URL).href;
|
||||
const palette = await Vibrant.from(imageUrl).getPalette();
|
||||
|
||||
console.log(palette);
|
||||
|
||||
const swatch = (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? palette.DarkVibrant : palette.Vibrant;
|
||||
|
||||
if (swatch) {
|
||||
document.documentElement.style.setProperty("--color-primary", swatch.hex);
|
||||
document.documentElement.style.setProperty("--color-primary-content", swatch.bodyTextColor)
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main>
|
||||
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<section class="hero is-medium is-primary">
|
||||
<div class="hero-body">
|
||||
<div class="columns">
|
||||
<div class="bg-primary transition-colors">
|
||||
<div class="container mx-auto py-12!">
|
||||
<div class="flex flex-row gap-8">
|
||||
<Show when={podcastQuery.data?.image_filename}>
|
||||
<div class="column">
|
||||
<figure class="image is-128x128">
|
||||
<img src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
|
||||
</figure>
|
||||
</div>
|
||||
<img class="size-64 md:size-72 rounded-2xl" src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
|
||||
</Show>
|
||||
<div class="column is-full">
|
||||
<h1 class="title">{podcastQuery.data?.name}</h1>
|
||||
<p class="subtitle">{podcastQuery.data?.description}</p>
|
||||
<div>
|
||||
<h1 class="text-4xl text-primary-content font-semibold transition-colors">{podcastQuery.data?.name}</h1>
|
||||
<p class="text-lg text-primary-content mt-2 transition-colors">{podcastQuery.data?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="container is-max-desktop">
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto">
|
||||
<For each={episodeQuery.data}>
|
||||
{(episode) => (
|
||||
<article class="media">
|
||||
<figure class="media-left">
|
||||
<p class="image is-64x64 has-background-primary is-flex is-align-content-center">
|
||||
<MusicIcon class="p-4 has-text-dark" />
|
||||
</p>
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<p class="title is-5">{episode.name}</p>
|
||||
<Show when={episode.publish_date}>
|
||||
<p class="subtitle is-6">{new Date(episode.publish_date!).toLocaleDateString()}</p>
|
||||
</Show>
|
||||
<div class="bg-base-200 flex flex-row p-4 gap-4 mb-16 rounded-lg">
|
||||
<div>
|
||||
<div class="bg-primary p-4 rounded-lg transition-colors">
|
||||
<MusicIcon class="stroke-primary-content size-8 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<div class="flex flex-row mb-4">
|
||||
<div class="grow">
|
||||
<h4 class="text-xl line-clamp-2 overflow-ellipsis mb-1">{episode.name}</h4>
|
||||
<p class="text-base-content/50">{new Date(episode.publish_date!).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<a class="bg-primary mb-auto rounded-full p-3 text-primary-content hover:text-primary hover:bg-primary/20 transition-colors" href={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()}><DownloadIcon /></a>
|
||||
</div>
|
||||
<figure class="m-0 mb-4">
|
||||
<audio controls src={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()}></audio>
|
||||
<audio controls src={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()} class="w-full"></audio>
|
||||
</figure>
|
||||
<Show when={episode.description_html}>
|
||||
<p innerHTML={episode.description_html!}></p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-right">
|
||||
<a href={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()}><DownloadIcon /></a>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import solid from 'vite-plugin-solid'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid()],
|
||||
plugins: [tailwindcss(), solid()],
|
||||
build: {
|
||||
assetsDir: "static",
|
||||
}
|
||||
|
|
5
docker-entrypoint.sh
Normal file
5
docker-entrypoint.sh
Normal file
|
@ -0,0 +1,5 @@
|
|||
#!/bin/sh
|
||||
set -x
|
||||
|
||||
uv run alembic upgrade head || exit 1
|
||||
exec "$@"
|
|
@ -0,0 +1,29 @@
|
|||
"""add request_count to episode
|
||||
|
||||
Revision ID: d58d2db9cd60
|
||||
Revises: 9efcecc1e58d
|
||||
Create Date: 2025-07-28 21:59:25.547437
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d58d2db9cd60"
|
||||
down_revision: Union[str, None] = "9efcecc1e58d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"podcastepisode",
|
||||
sa.Column("request_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("podcastepisode", "request_count")
|
|
@ -16,6 +16,35 @@ class TrackListItem(TypedDict):
|
|||
timestamp: Optional[timedelta]
|
||||
|
||||
|
||||
def clean_track_title(title: str) -> str:
|
||||
# remove track suffixes
|
||||
new_title = re.sub(
|
||||
r"\s*\((Clean Extended|Clean|Extended.*|Original Mix|Radio Edit|Free .*)\)",
|
||||
"",
|
||||
title,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
new_title = re.sub(
|
||||
r"\s*\[(Free .*|.*bandcamp.*|extended.*)\]",
|
||||
"",
|
||||
new_title,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# remove other parts of title
|
||||
new_title = re.sub(r"out now", "", new_title, flags=re.IGNORECASE)
|
||||
new_title = re.sub(
|
||||
r"^(Premiere|Free\s\w*|premear|\w*\sPremiere):\s",
|
||||
"",
|
||||
new_title,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# remove multiple spaces
|
||||
new_title = re.sub(" +", " ", new_title)
|
||||
return new_title.strip()
|
||||
|
||||
|
||||
def update_episode_tracklist(
|
||||
episode: PodcastEpisode, track_list: List[TrackListItem]
|
||||
) -> Optional[PodcastEpisode]:
|
||||
|
@ -31,16 +60,32 @@ def update_episode_tracklist(
|
|||
description += f"\n\n{TRACK_LIST_HEADING}\n\n"
|
||||
|
||||
sorted_tracks = sorted(track_list, key=lambda x: x["timestamp"].total_seconds())
|
||||
id_count = 1
|
||||
|
||||
for i, track in enumerate(sorted_tracks):
|
||||
description += f"{i + 1}. {track.get('title', 'ID')} _- {track.get('artist', 'ID')} [{str(track.get('timestamp', timedelta(seconds=0)))}]_\n"
|
||||
title_str = track.get("title")
|
||||
artist_str = track.get("artist", "Unknown Artist")
|
||||
|
||||
if title_str is None:
|
||||
title_str = f"ID{id_count}"
|
||||
id_count += 1
|
||||
else:
|
||||
# if the title looks like it contains the artist too, overwrite the existing artist
|
||||
title_segments = title_str.split(" - ")
|
||||
if len(title_segments) == 2:
|
||||
artist_str, title_str = title_segments
|
||||
|
||||
# clean up the title
|
||||
title_str = clean_track_title(title_str)
|
||||
|
||||
description += f"{i + 1}. {title_str} _- {artist_str} [{str(track.get('timestamp', timedelta(seconds=0)))}]_\n"
|
||||
|
||||
episode.description = description.strip()
|
||||
|
||||
return episode
|
||||
|
||||
|
||||
async def djuced_track_list(
|
||||
async def djuced_tracklist(
|
||||
episode: PodcastEpisode, file: UploadFile
|
||||
) -> Optional[PodcastEpisode]:
|
||||
root = ET.fromstring(await file.read())
|
||||
|
@ -57,11 +102,6 @@ async def djuced_track_list(
|
|||
|
||||
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
|
||||
|
||||
if len(intervals) > 0:
|
||||
tracks.append(
|
||||
{
|
||||
|
@ -74,7 +114,7 @@ async def djuced_track_list(
|
|||
return update_episode_tracklist(episode, tracks)
|
||||
|
||||
|
||||
async def rekordbox_track_list(
|
||||
async def rekordbox_tracklist(
|
||||
episode: PodcastEpisode, file: UploadFile
|
||||
) -> Optional[PodcastEpisode]:
|
||||
if not file.filename.endswith(".cue"):
|
||||
|
@ -89,9 +129,6 @@ async def rekordbox_track_list(
|
|||
line = line.strip()
|
||||
if line.startswith("TITLE"):
|
||||
title = re.search(r'"(.*?)"', line).group(1)
|
||||
title = re.sub(
|
||||
r"\s*\((Clean Extended|Clean|Extended)\)", "", title
|
||||
) # Remove specific suffixes
|
||||
current_track["title"] = title
|
||||
elif line.startswith("PERFORMER"):
|
||||
current_track["artist"] = re.search(r'"(.*?)"', line).group(1)
|
||||
|
@ -112,14 +149,34 @@ async def rekordbox_track_list(
|
|||
return update_episode_tracklist(episode, tracks)
|
||||
|
||||
|
||||
async def serato_m3u_tracklist(
|
||||
episode: PodcastEpisode, file: UploadFile
|
||||
) -> Optional[PodcastEpisode]:
|
||||
file_content = (await file.read()).decode("utf-8")
|
||||
|
||||
tracks: List[TrackListItem] = []
|
||||
|
||||
for line in file_content.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("#EXTINF:"):
|
||||
parts = ",".join(line.split(",")[1:]).split(" - ")
|
||||
artist = parts[0]
|
||||
title = " - ".join(parts[1:])
|
||||
|
||||
tracks.append(TrackListItem(title=title, artist=artist, timestamp=None))
|
||||
|
||||
return update_episode_tracklist(episode, tracks)
|
||||
|
||||
|
||||
# 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),
|
||||
("application/octet-stream", rekordbox_track_list),
|
||||
("text/xml", djuced_tracklist),
|
||||
("application/octet-stream", rekordbox_tracklist),
|
||||
("audio/mpegurl", serato_m3u_tracklist),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -482,12 +482,19 @@ def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str):
|
|||
if episode is None:
|
||||
raise HTTPException(status_code=404, detail="Episode or podcast not found")
|
||||
|
||||
# increment request count
|
||||
episode.request_count += 1
|
||||
session.add(episode)
|
||||
|
||||
return FileResponse(settings.directory / f"{episode.id}.m4a")
|
||||
|
||||
elif (
|
||||
filename.endswith(".jpg") or filename.endswith(".png")
|
||||
) and filename == podcast.image_filename:
|
||||
return FileResponse(settings.directory / podcast.image_filename)
|
||||
return FileResponse(
|
||||
settings.directory / podcast.image_filename,
|
||||
headers={"Access-Control-Allow-Origin": "*"},
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
|
|
|
@ -24,10 +24,16 @@ class Podcast(PodcastBase, table=True):
|
|||
)
|
||||
image_filename: Optional[str] = Field(default=None)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def episode_count(self) -> int:
|
||||
return len(self.episodes)
|
||||
|
||||
|
||||
class PodcastPublic(PodcastBase):
|
||||
id: str
|
||||
image_filename: Optional[str] = Field(default=None)
|
||||
episode_count: int
|
||||
|
||||
|
||||
class PodcastCreate(PodcastBase):
|
||||
|
@ -54,6 +60,7 @@ class PodcastEpisodeBase(SQLModel):
|
|||
publish_date: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
request_count: int = Field(default=0, nullable=False)
|
||||
|
||||
|
||||
class PodcastEpisode(PodcastEpisodeBase, table=True):
|
||||
|
|
Loading…
Reference in a new issue