Compare commits

..

3 commits

Author SHA1 Message Date
776cf139fa
add request counting
Some checks failed
ci/woodpecker/push/build Pipeline failed
2025-07-28 22:12:08 +01:00
44827ef205
add serato tracklist parsing 2025-07-28 21:48:23 +01:00
80d81712c7
switch to daisyui 2025-07-24 17:54:24 +01:00
26 changed files with 1309 additions and 453 deletions

View file

@ -25,4 +25,7 @@ COPY --from=frontend-build /app/dist /opt/dist
ENV PG_DIRECTORY=/work ENV PG_DIRECTORY=/work
ENV PG_UPLOADS_DIRECTORY=/uploads ENV PG_UPLOADS_DIRECTORY=/uploads
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"] CMD ["uv", "run", "uvicorn", "--app-dir", "/opt/src", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -13,12 +13,17 @@
"@hey-api/client-fetch": "^0.7.0", "@hey-api/client-fetch": "^0.7.0",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"@soorria/solid-dropzone": "^1.0.1", "@soorria/solid-dropzone": "^1.0.1",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/solid-query": "^5.64.1", "@tanstack/solid-query": "^5.64.1",
"bulma": "^1.0.3", "bulma": "^1.0.3",
"daisyui": "^5.0.46",
"huge-uploader": "^1.0.6", "huge-uploader": "^1.0.6",
"lucide-solid": "^0.473.0", "lucide-solid": "^0.473.0",
"node-vibrant": "^4.0.3",
"oidc-client-ts": "^3.1.0", "oidc-client-ts": "^3.1.0",
"solid-js": "^1.9.3" "solid-js": "^1.9.3",
"tailwindcss": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "^0.62.1", "@hey-api/openapi-ts": "^0.62.1",

File diff suppressed because it is too large Load diff

View file

@ -1,54 +1,51 @@
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query"; 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 { readUserOptions } from "./client/@tanstack/solid-query.gen";
import { logout } from "./auth"; import { logout } from "./auth";
import { Menu } from "lucide-solid";
export default function AdminLayout(props: ParentProps) { export default function AdminLayout(props: ParentProps) {
const content = children(() => props.children); const content = children(() => props.children);
const [navbarToggled, setNavbarToggled] = createSignal(false);
const userQuery = createQuery(() => ({ const userQuery = createQuery(() => ({
...readUserOptions(), ...readUserOptions(),
})); }));
return <> return <>
<nav class="navbar" role="navigation" aria-label="main navigation"> <div class="navbar bg-primary text-primary-content shadow-sm">
<div class="navbar-brand"> <div class="navbar-start">
<a class="navbar-item" href="/admin"> <div class="dropdown">
<b>Podcast Server</b> <div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
</a> <Menu class="h-5 w-5" />
</div>
<a role="button" class="navbar-burger" classList={{ "is-active": navbarToggled() }} aria-label="menu" aria-expanded="false" onClick={() => setNavbarToggled(!navbarToggled())}> <ul
<span aria-hidden="true"></span> tabindex="0"
<span aria-hidden="true"></span> class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow text-base-content">
<span aria-hidden="true"></span> <li><A href="/admin">Podcasts</A></li>
<span aria-hidden="true"></span> </ul>
</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>
<A class="btn btn-ghost text-xl" href="/admin">Podcast Admin</A>
<div class="navbar-end"> </div>
<ErrorBoundary fallback={() => <p class="navbar-item">Error</p>}> <div class="navbar-center hidden lg:flex">
<Suspense fallback={<p class="navbar-item">Loading...</p>}> <ul class="menu menu-horizontal px-1">
<p class="navbar-item">Hey {userQuery.data?.user_name.split(" ")[0]}!</p> <li><A href="/admin">Podcasts</A></li>
</ul>
</div>
<div class="navbar-end">
<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> </Suspense>
</ErrorBoundary> </ErrorBoundary>
<div class="navbar-item"> <a class="btn" onClick={logout}>
<a class="button is-light" onClick={logout}> Log out
Log out </a>
</a>
</div>
</div> </div>
</div> </div>
</nav> </div>
<main> <main class="h-100">
{content()} {content()}
</main> </main>
</>; </>;

View file

@ -40,6 +40,7 @@ export type PodcastEpisodePublic = {
file_hash: string; file_hash: string;
file_size: number; file_size: number;
publish_date?: string; publish_date?: string;
request_count?: number;
id: string; id: string;
podcast_id: string; podcast_id: string;
description_html: string | null; description_html: string | null;
@ -57,6 +58,7 @@ export type PodcastPublic = {
explicit?: boolean; explicit?: boolean;
id: string; id: string;
image_filename?: string | null; image_filename?: string | null;
episode_count: number;
}; };
export type PodcastUpdate = { export type PodcastUpdate = {

View file

@ -1,6 +1,7 @@
import { createContext, createEffect, createSignal, ParentComponent } from "solid-js"; import { createContext, createEffect, createSignal, ParentComponent } from "solid-js";
import { fetchUser, login, logout, user } from "../auth"; import { fetchUser, login, logout, user } from "../auth";
import { User } from "oidc-client-ts"; import { User } from "oidc-client-ts";
import Loading from "./loading";
interface AuthContextType { interface AuthContextType {
user: () => User | null; user: () => User | null;
@ -20,7 +21,7 @@ const AuthProvider: ParentComponent = (props) => {
return ( return (
<AuthContext.Provider value={{ user, login, logout }}> <AuthContext.Provider value={{ user, login, logout }}>
{isLoading() ? <p>Loading...</p> : props.children} {isLoading() ? <Loading /> : props.children}
</AuthContext.Provider> </AuthContext.Provider>
); );
} }

View file

@ -1,5 +1,5 @@
import { createDropzone } from "@soorria/solid-dropzone"; import { createDropzone } from "@soorria/solid-dropzone";
import { UploadIcon } from "lucide-solid"; import { Info, UploadIcon } from "lucide-solid";
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
import { createMutation, useQueryClient } from "@tanstack/solid-query"; import { createMutation, useQueryClient } from "@tanstack/solid-query";
import { episodeAdditionalUploadMutation, readEpisodesQueryKey, readEpisodeQueryKey } from "../client/@tanstack/solid-query.gen"; import { episodeAdditionalUploadMutation, readEpisodesQueryKey, readEpisodeQueryKey } from "../client/@tanstack/solid-query.gen";
@ -50,15 +50,16 @@ export default function UploadEpisodeAdditional(props: { podcastId: string, epis
return <> return <>
<Show when={statusText()}> <Show when={statusText()}>
<div class="notification is-info"> <div role="alert" class="alert">
{statusText()} <Info class="stroke-primary h-6 w-6 shrink-0" />
<p>{statusText()}</p>
</div> </div>
</Show> </Show>
<Show when={!uploading()}> <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()} /> <input {...dropzone.getInputProps()} />
<UploadIcon size="42" /> <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"} {dropzone.isDragActive ? "Drop extra file here..." : "Drag an extra file here to upload, or click to open"}
</p> </p>
</div> </div>

View file

@ -1,14 +1,18 @@
import { XCircle } from "lucide-solid";
export default function Error(props: { message?: any, reset?: () => void }) { export default function Error(props: { message?: any, reset?: () => void }) {
return ( return (
<section class="section"> <div class="container mx-auto">
<div class="container"> <div role="alert" class="alert alert-error alert-vertical sm:alert-horizontal">
<h1 class="title">Uh Oh!</h1> <XCircle class="h-6 w-6 shrink-0 stroke-current" />
<h2 class="subtitle">Something has gone wrong.</h2> <div>
{props.message && <> <h3 class="font-bold">Uh Oh! Something has gone wrong.</h3>
<p class="mb-4">Error: <code>{props.message.toString()}</code></p> {props.message && <>
</>} <div class="text-xs">Error: <code>{props.message.toString()}</code></div>
{props.reset && <button class="button" onClick={props.reset}>Retry</button>} </>}
</div>
{props.reset && <button class="btn btn-sm" onClick={props.reset}>Retry</button>}
</div> </div>
</section> </div>
); );
} }

View file

@ -1,10 +1,10 @@
export default function Loading() { export default function Loading(props: { loadingText?: string }) {
return ( return (
<section class="section"> <div class="container mx-auto h-full">
<div class="container"> <div class="w-full h-full flex items-center justify-center gap-2">
<h1 class="title">One moment...</h1> <span class="loading loading-spinner"></span>
<progress class="progress is-primary" max="100" /> <p>{props.loadingText || "One moment..."}</p>
</div> </div>
</section> </div>
); );
} }

View file

@ -1,5 +1,5 @@
import { createDropzone } from "@soorria/solid-dropzone"; import { createDropzone } from "@soorria/solid-dropzone";
import { UploadIcon } from "lucide-solid"; import { Info, UploadIcon } from "lucide-solid";
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
import { createMutation, useQueryClient } from "@tanstack/solid-query"; import { createMutation, useQueryClient } from "@tanstack/solid-query";
import { readPodcastQueryKey, readPodcastsQueryKey, updatePodcastImageMutation } from "../client/@tanstack/solid-query.gen"; import { readPodcastQueryKey, readPodcastsQueryKey, updatePodcastImageMutation } from "../client/@tanstack/solid-query.gen";
@ -49,15 +49,16 @@ export default function UploadImage(props: { podcastId: string }) {
return <> return <>
<Show when={statusText()}> <Show when={statusText()}>
<div class="notification is-info"> <div role="alert" class="alert">
{statusText()} <Info class="stroke-primary h-6 w-6 shrink-0" />
<p>{statusText()}</p>
</div> </div>
</Show> </Show>
<Show when={!uploading()}> <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()} /> <input {...dropzone.getInputProps()} />
<UploadIcon size="42" /> <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"} {dropzone.isDragActive ? "Drop podcast image here..." : "Drag podcast image here to upload, or click to open"}
</p> </p>
</div> </div>

View file

@ -1,5 +1,5 @@
import { createDropzone } from "@soorria/solid-dropzone"; import { createDropzone } from "@soorria/solid-dropzone";
import { UploadIcon } from "lucide-solid"; import { Info, UploadIcon } from "lucide-solid";
import HugeUploader from "huge-uploader"; import HugeUploader from "huge-uploader";
import { SERVER_URL } from "../constants"; import { SERVER_URL } from "../constants";
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
@ -53,16 +53,20 @@ export default function Upload(props: { podcastId: string }) {
return <> return <>
<Show when={statusText()}> <Show when={statusText()}>
<div class="notification is-info"> <div role="alert" class="alert">
{uploadProgress() && <progress class="progress mb-4" value={uploadProgress()!} max="100">{uploadProgress()}%</progress>} <Info class="stroke-primary h-6 w-6 shrink-0" />
{statusText()} <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> </div>
</Show> </Show>
<Show when={!uploading()}> <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()} /> <input {...dropzone.getInputProps()} />
<UploadIcon size="42" /> <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"} {dropzone.isDragActive ? "Drop episode here..." : "Drag episode here to upload, or click to open"}
</p> </p>
</div> </div>

View file

@ -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; const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) { 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'] ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1; let u = -1;
const r = 10**dp; const r = 10 ** dp;
do { do {
bytes /= thresh; bytes /= thresh;
@ -19,3 +21,14 @@ export function humanFileSize(bytes: number, si=false, dp=1) {
return bytes.toFixed(dp) + ' ' + units[u]; 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}`;
}
}

View file

@ -1,68 +1,11 @@
:root { @import "tailwindcss";
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; @plugin "@tailwindcss/typography";
line-height: 1.5; @plugin "daisyui";
font-weight: 400;
color-scheme: light dark; html, body, #root {
color: rgba(255, 255, 255, 0.87); height: 100%;
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
a { .container {
font-weight: 500; @apply py-8 px-4;
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;
}
} }

View file

@ -5,12 +5,13 @@ import { lazy } from 'solid-js';
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';
import { client as apiClient } from './client/sdk.gen'; import { client as apiClient } from './client/sdk.gen';
import "bulma/css/bulma.min.css";
import { SERVER_URL } from './constants'; import { SERVER_URL } from './constants';
import Protected from './components/Protected'; import Protected from './components/Protected';
import AuthProvider from './components/AuthProvider'; import AuthProvider from './components/AuthProvider';
import { refreshToken, user } from './auth'; import { refreshToken, user } from './auth';
import "./index.css";
const wrapper = document.getElementById('root'); const wrapper = document.getElementById('root');
if (!wrapper) { if (!wrapper) {

View file

@ -1,6 +1,7 @@
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { onMount } from "solid-js"; import { onMount } from "solid-js";
import { fetchUser, getUserManager } from "../auth"; import { fetchUser, getUserManager } from "../auth";
import Loading from "../components/loading";
const Callback = () => { const Callback = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -11,7 +12,7 @@ const Callback = () => {
navigate("/admin"); navigate("/admin");
}); });
return <p>Signing in...</p>; return <Loading loadingText="Signing in..." />;
} }
export default Callback; export default Callback;

View file

@ -2,13 +2,11 @@ import { login } from "../auth"
const Login = () => { const Login = () => {
return ( return (
<main> <main class="flex items-center justify-center h-full">
<section class="section"> <div class="w-100 max-w-100 bg-base-100 shadow-sm p-8 rounded-md">
<div class="container"> <h1 class="text-2xl mb-2">Login</h1>
<h1 class="title">Login</h1> <button class="btn btn-primary w-full" onClick={login}>Sign in with OIDC</button>
<button class="button is-primary" onClick={login}>Sign in with OIDC</button> </div>
</div>
</section>
</main> </main>
); );
} }

View file

@ -5,7 +5,7 @@ import { A, useNavigate, useParams } from "@solidjs/router";
import { deleteEpisodeMutation, readEpisodeOptions, readEpisodeQueryKey, readEpisodesQueryKey, updateEpisodeMutation } from "../../client/@tanstack/solid-query.gen"; import { deleteEpisodeMutation, readEpisodeOptions, readEpisodeQueryKey, readEpisodesQueryKey, updateEpisodeMutation } from "../../client/@tanstack/solid-query.gen";
import Error from "../../components/error"; import Error from "../../components/error";
import Loading from "../../components/loading"; 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 { SERVER_URL } from "../../constants";
import { PodcastEpisodePublic } from "../../client"; import { PodcastEpisodePublic } from "../../client";
import { humanFileSize } from "../../helpers"; import { humanFileSize } from "../../helpers";
@ -94,78 +94,62 @@ export default function AdminEpisode() {
<AdminLayout> <AdminLayout>
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}> <ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<section class="section"> <div class="container mx-auto">
<div class="container"> <h1 class="text-4xl line-clamp-2 overflow-ellipsis mb-2">{episodeQuery.data?.name}</h1>
<h1 class="title">{episodeQuery.data?.name}</h1> <p class="subtitle text-base-content/50 text-xl">{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"}</p>
<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="mt-4 prose prose-neutral" innerHTML={episodeQuery.data?.description_html} />}
{episodeQuery.data?.description_html && <div class="content" innerHTML={episodeQuery.data?.description_html} />} </div>
<div class="container mx-auto">
<h2 class="text-2xl">Update Episode</h2>
{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 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>}
<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>
</section>
<section class="section">
<div class="container">
<h2 class="title is-4">Update Episode</h2>
{updateEpisode.isSuccess && <div class="notification is-success"> <h2 class="text-2xl mt-8">Additional Upload</h2>
<button class="delete" onClick={() => updateEpisode.reset()}></button> <p class="mb-4">Drag extra files here</p>
This episode has been updated successfully. <UploadEpisodeAdditional podcastId={params.podcastId} episodeId={params.episodeId} />
</div>}
{updateEpisode.isError && <div class="notification is-danger"> <h2 class="text-2xl mb-4 mt-8">Info</h2>
<button class="delete" onClick={() => updateEpisode.reset()}></button> <ul>
Something went wrong. {JSON.stringify(updateEpisode.error)} <li><b>Audio URL:</b> <A href={audioUrl()} class="text-primary underline hover:no-underline">{audioUrl()}</A></li>
</div>} <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>
<div class="field"> <div class="container mx-auto py-4! rounded-lg border-1 border-error text-error">
<label class="label">Name</label> <h2 class="text-2xl mb-4">Danger Zone</h2>
<div class="control"> <button class="btn btn-error" onClick={() => deleteAction()}><Trash /> Delete</button>
<input class="input" type="text" value={name()} onInput={(e) => setName(e.target.value)} /> </div>
</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> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</AdminLayout> </AdminLayout>

View file

@ -1,16 +1,22 @@
import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query"; import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query";
import AdminLayout from "../../admin-layout"; 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 { A, useNavigate, useParams } from "@solidjs/router";
import { deletePodcastMutation, readEpisodesOptions, readPodcastOptions, readPodcastQueryKey, readPodcastsQueryKey, updatePodcastMutation } from "../../client/@tanstack/solid-query.gen"; import { deletePodcastMutation, readEpisodesOptions, readPodcastOptions, readPodcastQueryKey, readPodcastsQueryKey, updatePodcastMutation } from "../../client/@tanstack/solid-query.gen";
import Error from "../../components/error"; import Error from "../../components/error";
import Loading from "../../components/loading"; 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 { SERVER_URL } from "../../constants";
import Upload from "../../components/upload"; import Upload from "../../components/upload";
import UploadImage from "../../components/upload-image"; import UploadImage from "../../components/upload-image";
import { PodcastPublic } from "../../client"; 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() { export default function AdminPodcast() {
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -79,126 +85,126 @@ 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 ( return (
<AdminLayout> <AdminLayout>
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}> <ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<section class="section"> <div class="container mx-auto">
<div class="container"> <div class="flex flex-row gap-4">
<div class="columns"> <Show when={podcastQuery.data?.image_filename}>
<Show when={podcastQuery.data?.image_filename}> <img class="size-48 md:size-72 rounded-lg" src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
<div class="column"> </Show>
<figure class="image is-128x128"> <div class="grow">
<img src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} /> <h1 class="text-4xl mb-2">{podcastQuery.data?.name}</h1>
</figure> <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> </div>
</Show> </Show>
<div class="column is-full">
<h1 class="title">{podcastQuery.data?.name}</h1>
<p>{podcastQuery.data?.description}</p>
</div>
</div> </div>
</div>
<Show when={episodeQuery.data?.length || 0 > 0}> <div role="tablist" class="tabs tabs-box justify-center mt-4 w-full">
<p><strong>Subscribe at:</strong></p> <button role="tab" class="tab" classList={{ "tab-active": tab() === 0 }} onClick={() => setTab(0)}><List class="size-5 me-2" /> Episodes</button>
<pre><code>{new URL(`${params.podcastId}.xml`, SERVER_URL).href}</code></pre> <button role="tab" class="tab" classList={{ "tab-active": tab() === 1 }} onClick={() => setTab(1)}><Settings class="size-5 me-2" /> Settings</button>
</Show>
</div> </div>
</section> </div>
<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> <Switch>
<Match when={tab() === 0}> <Match when={tab() === 0}>
<section class="section"> <div class="container mx-auto">
<div class="container"> <Upload podcastId={params.podcastId} />
<Upload podcastId={params.podcastId} /> <div class="flex flex-row mb-4">
<h2 class="title is-4">Published Episodes</h2> <h2 class="text-2xl grow">Published Episodes</h2>
<div class="buttons"> <button class="btn btn-sm" onClick={() => episodeQuery.refetch()}><RefreshCw class="size-4" /> Refresh</button>
<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> </div>
</section>
<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>
<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>
{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>
)}
</For>
</tbody>
</table>
</div>
</div>
</Match> </Match>
<Match when={tab() === 1}> <Match when={tab() === 1}>
<section class="section"> <div class="container mx-auto">
<div class="container"> {updatePodcast.isSuccess && <div role="alert" class="alert alert-success alert-vertical sm:alert-horizontal">
{updatePodcast.isSuccess && <div class="notification is-success"> <CheckCircle class="h-6 w-6 shrink-0 stroke-current" />
<button class="delete" onClick={() => updatePodcast.reset()}></button> <span>The podcast has been updated successfully.</span>
This episode has been updated successfully. <div>
</div>} <button class="btn btn-sm" onClick={() => updatePodcast.reset()}>Ok</button>
{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>
</div>}
<div class="field"> {updatePodcast.isError && <div role="alert" class="alert alert-error alert-vertical sm:alert-horizontal">
<label class="label">Description</label> <AlertCircle class="h-6 w-6 shrink-0 stroke-current" />
<div class="control"> <span>Something went wrong. {JSON.stringify(updatePodcast.error)}</span>
<textarea class="textarea" onInput={(e) => setDescription(e.target.value)}>{description()}</textarea> <div>
</div> <button class="btn btn-sm" onClick={() => updatePodcast.reset()}>Ok</button>
</div> </div>
</div>}
<button class="button" onClick={() => saveChanges()}><Save /> Save Changes</button> <fieldset class="fieldset">
</div> <legend class="fieldset-legend">Name</legend>
</section> <input class="input w-full" type="text" value={name()} onInput={(e) => setName(e.target.value)} />
</fieldset>
<section class="section"> <fieldset class="fieldset">
<div class="container"> <legend class="fieldset-legend">Description</legend>
<h2 class="title is-4">Podcast Image</h2> <textarea class="textarea h-24 w-full" onInput={(e) => setDescription(e.target.value)}>{description()}</textarea>
<UploadImage podcastId={params.podcastId} /> </fieldset>
</div>
</section>
<section class="section"> <div class="flex flex-row justify-end mt-2">
<div class="container"> <button class="btn btn-primary" onClick={() => saveChanges()}><Save /> Save Changes</button>
<h2 class="title is-4">Danger Zone</h2>
<button class="button is-danger" onClick={() => deleteAction()}><Trash /> Delete</button>
</div> </div>
</section>
<h2 class="text-2xl mb-4 mt-8">Podcast Image</h2>
<UploadImage podcastId={params.podcastId} />
</div>
<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> </Match>
</Switch> </Switch>
</Suspense> </Suspense>

View file

@ -8,6 +8,7 @@ import Error from "../../components/error";
import { SERVER_URL } from "../../constants"; import { SERVER_URL } from "../../constants";
import { Eye, Plus } from "lucide-solid"; import { Eye, Plus } from "lucide-solid";
import { PodcastPublic } from "../../client"; import { PodcastPublic } from "../../client";
import { pluralize } from "../../helpers";
export default function AdminPodcasts() { export default function AdminPodcasts() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -40,39 +41,37 @@ export default function AdminPodcasts() {
<AdminLayout> <AdminLayout>
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}> <ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<section class="section"> <div class="container mx-auto">
<div class="container"> <div class="flex flex-row mb-4">
<h1 class="title">Podcasts</h1> <h1 class="text-2xl grow">Podcasts</h1>
<div class="mb-4"> <button class="btn btn-sm" onClick={() => createAction()}>
<button class="button" onClick={() => createAction()}> <Plus />
<Plus /> New
Create </button>
</button> </div>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
<For each={query.data}> <For each={query.data}>
{(podcast) => ( {(podcast) => (
<div class="card"> <div class="card bg-base-100 shadow-sm">
<div class="card-content"> <Show when={podcast.image_filename}>
<div class="columns"> <figure>
<Show when={podcast.image_filename}> <img src={new URL("/files/" + podcast.id + "/" + podcast.image_filename, SERVER_URL).href} />
<div class="column"> </figure>
<figure class="image is-64x64"> </Show>
<img src={new URL("/files/" + podcast.id + "/" + podcast.image_filename, SERVER_URL).href} /> <div class="card-body">
</figure> <h2 class="card-title mb-0">{podcast.name}</h2>
</div> <p class="text-base-content/50">{pluralize(podcast.episode_count, "episode", "episodes")}</p>
</Show> <p>{podcast.description}</p>
<div class="column is-full"> <div class="card-actions justify-end mt-2">
<h5 class="title is-4">{podcast.name}</h5> <A class="btn btn-primary" href={`/admin/${podcast.id}`}><Eye /> View</A>
<p class="mb-4">{podcast.description}</p>
<A href={`/admin/${podcast.id}`} class="button"><Eye /> View</A>
</div>
</div> </div>
</div> </div>
</div> </div>
)} )}
</For> </For>
</div> </div>
</section> </div>
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</AdminLayout> </AdminLayout>

View file

@ -1,11 +1,15 @@
import { useParams } from "@solidjs/router"; import { useParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
import { readPodcastOptions, readEpisodesOptions } from "../client/@tanstack/solid-query.gen"; 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 Error from "../components/error";
import Loading from "../components/loading"; import Loading from "../components/loading";
import { DownloadIcon, MusicIcon } from "lucide-solid"; import { DownloadIcon, MusicIcon } from "lucide-solid";
import { SERVER_URL } from "../constants"; 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() { export default function Podcast() {
const params = useParams(); const params = useParams();
@ -23,61 +27,73 @@ export default function Podcast() {
podcast_id: params.podcastId, 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 ( return (
<main> <main>
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}> <ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<section class="hero is-medium is-primary"> <div class="bg-primary transition-colors">
<div class="hero-body"> <div class="container mx-auto py-12!">
<div class="columns"> <div class="flex flex-row gap-8">
<Show when={podcastQuery.data?.image_filename}> <Show when={podcastQuery.data?.image_filename}>
<div class="column"> <img class="size-64 md:size-72 rounded-2xl" src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
<figure class="image is-128x128">
<img src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
</figure>
</div>
</Show> </Show>
<div class="column is-full"> <div>
<h1 class="title">{podcastQuery.data?.name}</h1> <h1 class="text-4xl text-primary-content font-semibold transition-colors">{podcastQuery.data?.name}</h1>
<p class="subtitle">{podcastQuery.data?.description}</p> <p class="text-lg text-primary-content mt-2 transition-colors">{podcastQuery.data?.description}</p>
</div> </div>
</div> </div>
</div> </div>
</section> </div>
<section class="section">
<div class="container is-max-desktop"> <div class="container mx-auto">
<For each={episodeQuery.data}> <For each={episodeQuery.data}>
{(episode) => ( {(episode) => (
<article class="media"> <div class="bg-base-200 flex flex-row p-4 gap-4 mb-16 rounded-lg">
<figure class="media-left"> <div>
<p class="image is-64x64 has-background-primary is-flex is-align-content-center"> <div class="bg-primary p-4 rounded-lg transition-colors">
<MusicIcon class="p-4 has-text-dark" /> <MusicIcon class="stroke-primary-content size-8 transition-colors" />
</p> </div>
</figure> </div>
<div class="media-content">
<div class="content"> <div class="grow">
<p class="title is-5">{episode.name}</p> <div class="flex flex-row mb-4">
<Show when={episode.publish_date}> <div class="grow">
<p class="subtitle is-6">{new Date(episode.publish_date!).toLocaleDateString()}</p> <h4 class="text-xl line-clamp-2 overflow-ellipsis mb-1">{episode.name}</h4>
</Show> <p class="text-base-content/50">{new Date(episode.publish_date!).toLocaleDateString()}</p>
<figure class="m-0 mb-4">
<audio controls src={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()}></audio>
</figure>
<Show when={episode.description_html}>
<p innerHTML={episode.description_html!}></p>
</Show>
</div> </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> </div>
<div class="media-right"> <figure class="m-0 mb-4">
<a href={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()}><DownloadIcon /></a> <audio controls src={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()} class="w-full"></audio>
</div> </figure>
</article> <Show when={episode.description_html}>
)} <p innerHTML={episode.description_html!}></p>
</For> </Show>
</div> </div>
</section> </div>
)}
</For>
</div>
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</main> </main>

View file

@ -1,8 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid' import solid from 'vite-plugin-solid'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig({
plugins: [solid()], plugins: [tailwindcss(), solid()],
build: { build: {
assetsDir: "static", assetsDir: "static",
} }

5
docker-entrypoint.sh Normal file
View file

@ -0,0 +1,5 @@
#!/bin/sh
set -x
uv run alembic upgrade head || exit 1
exec "$@"

View file

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

View file

@ -16,6 +16,35 @@ class TrackListItem(TypedDict):
timestamp: Optional[timedelta] 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( def update_episode_tracklist(
episode: PodcastEpisode, track_list: List[TrackListItem] episode: PodcastEpisode, track_list: List[TrackListItem]
) -> Optional[PodcastEpisode]: ) -> Optional[PodcastEpisode]:
@ -31,16 +60,32 @@ def update_episode_tracklist(
description += f"\n\n{TRACK_LIST_HEADING}\n\n" description += f"\n\n{TRACK_LIST_HEADING}\n\n"
sorted_tracks = sorted(track_list, key=lambda x: x["timestamp"].total_seconds()) sorted_tracks = sorted(track_list, key=lambda x: x["timestamp"].total_seconds())
id_count = 1
for i, track in enumerate(sorted_tracks): 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() episode.description = description.strip()
return episode return episode
async def djuced_track_list( async def djuced_tracklist(
episode: PodcastEpisode, file: UploadFile episode: PodcastEpisode, file: UploadFile
) -> Optional[PodcastEpisode]: ) -> Optional[PodcastEpisode]:
root = ET.fromstring(await file.read()) root = ET.fromstring(await file.read())
@ -57,11 +102,6 @@ async def djuced_track_list(
intervals = track.findall("interval") 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: if len(intervals) > 0:
tracks.append( tracks.append(
{ {
@ -74,7 +114,7 @@ async def djuced_track_list(
return update_episode_tracklist(episode, tracks) return update_episode_tracklist(episode, tracks)
async def rekordbox_track_list( async def rekordbox_tracklist(
episode: PodcastEpisode, file: UploadFile episode: PodcastEpisode, file: UploadFile
) -> Optional[PodcastEpisode]: ) -> Optional[PodcastEpisode]:
if not file.filename.endswith(".cue"): if not file.filename.endswith(".cue"):
@ -89,9 +129,6 @@ async def rekordbox_track_list(
line = line.strip() line = line.strip()
if line.startswith("TITLE"): if line.startswith("TITLE"):
title = re.search(r'"(.*?)"', line).group(1) title = re.search(r'"(.*?)"', line).group(1)
title = re.sub(
r"\s*\((Clean Extended|Clean|Extended)\)", "", title
) # Remove specific suffixes
current_track["title"] = title current_track["title"] = title
elif line.startswith("PERFORMER"): elif line.startswith("PERFORMER"):
current_track["artist"] = re.search(r'"(.*?)"', line).group(1) current_track["artist"] = re.search(r'"(.*?)"', line).group(1)
@ -112,14 +149,34 @@ async def rekordbox_track_list(
return update_episode_tracklist(episode, tracks) 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 # list of file processors
# these are processed in order and only run if the file content type matches the first tuple string # 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 # 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[ processors: List[
Tuple[str, Callable[[PodcastEpisode, UploadFile], Optional[PodcastEpisode]]] Tuple[str, Callable[[PodcastEpisode, UploadFile], Optional[PodcastEpisode]]]
] = [ ] = [
("text/xml", djuced_track_list), ("text/xml", djuced_tracklist),
("application/octet-stream", rekordbox_track_list), ("application/octet-stream", rekordbox_tracklist),
("audio/mpegurl", serato_m3u_tracklist),
] ]

View file

@ -482,12 +482,19 @@ def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str):
if episode is None: if episode is None:
raise HTTPException(status_code=404, detail="Episode or podcast not found") 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") return FileResponse(settings.directory / f"{episode.id}.m4a")
elif ( elif (
filename.endswith(".jpg") or filename.endswith(".png") filename.endswith(".jpg") or filename.endswith(".png")
) and filename == podcast.image_filename: ) 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") raise HTTPException(status_code=404, detail="File not found")

View file

@ -24,10 +24,16 @@ class Podcast(PodcastBase, table=True):
) )
image_filename: Optional[str] = Field(default=None) image_filename: Optional[str] = Field(default=None)
@computed_field
@property
def episode_count(self) -> int:
return len(self.episodes)
class PodcastPublic(PodcastBase): class PodcastPublic(PodcastBase):
id: str id: str
image_filename: Optional[str] = Field(default=None) image_filename: Optional[str] = Field(default=None)
episode_count: int
class PodcastCreate(PodcastBase): class PodcastCreate(PodcastBase):
@ -54,6 +60,7 @@ class PodcastEpisodeBase(SQLModel):
publish_date: datetime = Field( publish_date: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), nullable=False default_factory=lambda: datetime.now(timezone.utc), nullable=False
) )
request_count: int = Field(default=0, nullable=False)
class PodcastEpisode(PodcastEpisodeBase, table=True): class PodcastEpisode(PodcastEpisodeBase, table=True):