Compare commits

..

6 commits
next ... main

Author SHA1 Message Date
e38edc45fa
Add rekordbox tracklist parsing
All checks were successful
ci/woodpecker/push/build Pipeline was successful
Co-authored-by: James Walker <james@noreply.git.jakew.me>
2025-02-08 16:59:55 +00:00
93bde35cbf
add podcast landing page
All checks were successful
ci/woodpecker/push/build Pipeline was successful
2025-02-08 16:28:38 +00:00
ecc4f47321
update page title
All checks were successful
ci/woodpecker/push/build Pipeline was successful
2025-02-05 23:33:22 +00:00
a7aca14def
fix http exceptions
Some checks failed
ci/woodpecker/push/build Pipeline failed
2025-02-05 23:32:27 +00:00
f0b4a38775
fix spa routing
Some checks failed
ci/woodpecker/push/build Pipeline failed
2025-02-05 23:29:48 +00:00
54bf779a55
fix frontend server url
All checks were successful
ci/woodpecker/push/build Pipeline was successful
2025-02-05 23:08:13 +00:00
13 changed files with 251 additions and 35 deletions

View file

@ -41,3 +41,5 @@ tasks:
deps: [frontend:generate]
cmds:
- pnpm run build
- rm -rf ../dist
- mv dist ../dist

View file

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Solid + TS</title>
<title>Podcast Server</title>
</head>
<body>
<div id="root"></div>

View file

@ -2,8 +2,8 @@
import type { Options } from '@hey-api/client-fetch';
import { queryOptions, type MutationOptions } from '@tanstack/solid-query';
import type { GetAppConfigData, ReadUserData, ReadPodcastsData, CreatePodcastData, CreatePodcastError, CreatePodcastResponse, DeletePodcastData, DeletePodcastError, ReadPodcastData, UpdatePodcastData, UpdatePodcastError, UpdatePodcastResponse, UpdatePodcastImageData, UpdatePodcastImageError, UpdatePodcastImageResponse, ReadEpisodesData, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, UpdateEpisodeData, UpdateEpisodeError, UpdateEpisodeResponse, EpisodeAdditionalUploadData, EpisodeAdditionalUploadError, EpisodeAdditionalUploadResponse, GetPodcastFeedData, GetEpisodeOrCoverData } from '../types.gen';
import { getAppConfig, readUser, readPodcasts, createPodcast, deletePodcast, readPodcast, updatePodcast, updatePodcastImage, readEpisodes, adminUploadEpisode, deleteEpisode, readEpisode, updateEpisode, episodeAdditionalUpload, getPodcastFeed, getEpisodeOrCover, client } from '../sdk.gen';
import type { GetAppConfigData, ReadUserData, ReadPodcastsData, CreatePodcastData, CreatePodcastError, CreatePodcastResponse, DeletePodcastData, DeletePodcastError, ReadPodcastData, UpdatePodcastData, UpdatePodcastError, UpdatePodcastResponse, UpdatePodcastImageData, UpdatePodcastImageError, UpdatePodcastImageResponse, ReadEpisodesData, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, UpdateEpisodeData, UpdateEpisodeError, UpdateEpisodeResponse, EpisodeAdditionalUploadData, EpisodeAdditionalUploadError, EpisodeAdditionalUploadResponse, GetPodcastFeedData, GetEpisodeOrCoverData, ServeAppData } from '../types.gen';
import { getAppConfig, readUser, readPodcasts, createPodcast, deletePodcast, readPodcast, updatePodcast, updatePodcastImage, readEpisodes, adminUploadEpisode, deleteEpisode, readEpisode, updateEpisode, episodeAdditionalUpload, getPodcastFeed, getEpisodeOrCover, serveApp, client } from '../sdk.gen';
type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -371,3 +371,22 @@ export const getEpisodeOrCoverOptions = (options: Options<GetEpisodeOrCoverData>
queryKey: getEpisodeOrCoverQueryKey(options)
});
};
export const serveAppQueryKey = (options: Options<ServeAppData>) => [
createQueryKey('serveApp', options)
];
export const serveAppOptions = (options: Options<ServeAppData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await serveApp({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: serveAppQueryKey(options)
});
};

View file

@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createClient, createConfig, type Options, formDataBodySerializer } from '@hey-api/client-fetch';
import type { GetAppConfigData, GetAppConfigResponse, ReadUserData, ReadUserResponse, ReadPodcastsData, ReadPodcastsResponse, CreatePodcastData, CreatePodcastResponse, CreatePodcastError, DeletePodcastData, DeletePodcastError, ReadPodcastData, ReadPodcastResponse, ReadPodcastError, UpdatePodcastData, UpdatePodcastResponse, UpdatePodcastError, UpdatePodcastImageData, UpdatePodcastImageResponse, UpdatePodcastImageError, ReadEpisodesData, ReadEpisodesResponse, ReadEpisodesError, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, ReadEpisodeResponse, ReadEpisodeError, UpdateEpisodeData, UpdateEpisodeResponse, UpdateEpisodeError, EpisodeAdditionalUploadData, EpisodeAdditionalUploadResponse, EpisodeAdditionalUploadError, GetPodcastFeedData, GetPodcastFeedError, GetEpisodeOrCoverData, GetEpisodeOrCoverError } from './types.gen';
import type { GetAppConfigData, GetAppConfigResponse, ReadUserData, ReadUserResponse, ReadPodcastsData, ReadPodcastsResponse, CreatePodcastData, CreatePodcastResponse, CreatePodcastError, DeletePodcastData, DeletePodcastError, ReadPodcastData, ReadPodcastResponse, ReadPodcastError, UpdatePodcastData, UpdatePodcastResponse, UpdatePodcastError, UpdatePodcastImageData, UpdatePodcastImageResponse, UpdatePodcastImageError, ReadEpisodesData, ReadEpisodesResponse, ReadEpisodesError, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, ReadEpisodeResponse, ReadEpisodeError, UpdateEpisodeData, UpdateEpisodeResponse, UpdateEpisodeError, EpisodeAdditionalUploadData, EpisodeAdditionalUploadResponse, EpisodeAdditionalUploadError, GetPodcastFeedData, GetPodcastFeedError, GetEpisodeOrCoverData, GetEpisodeOrCoverError, ServeAppData, ServeAppError } from './types.gen';
export const client = createClient(createConfig());
@ -187,7 +187,17 @@ export const getPodcastFeed = <ThrowOnError extends boolean = false>(options: Op
*/
export const getEpisodeOrCover = <ThrowOnError extends boolean = false>(options: Options<GetEpisodeOrCoverData, ThrowOnError>) => {
return (options?.client ?? client).get<unknown, GetEpisodeOrCoverError, ThrowOnError>({
url: '/{podcast_id}/{filename}',
url: '/files/{podcast_id}/{filename}',
...options
});
};
/**
* Serve App
*/
export const serveApp = <ThrowOnError extends boolean = false>(options: Options<ServeAppData, ThrowOnError>) => {
return (options?.client ?? client).get<unknown, ServeAppError, ThrowOnError>({
url: '/{full_path}',
...options
});
};

View file

@ -444,7 +444,7 @@ export type GetEpisodeOrCoverData = {
filename: string;
};
query?: never;
url: '/{podcast_id}/{filename}';
url: '/files/{podcast_id}/{filename}';
};
export type GetEpisodeOrCoverErrors = {
@ -462,3 +462,28 @@ export type GetEpisodeOrCoverResponses = {
*/
200: unknown;
};
export type ServeAppData = {
body?: never;
path: {
full_path: string;
};
query?: never;
url: '/{full_path}';
};
export type ServeAppErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ServeAppError = ServeAppErrors[keyof ServeAppErrors];
export type ServeAppResponses = {
/**
* Successful Response
*/
200: unknown;
};

View file

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

View file

@ -83,6 +83,10 @@ const routes = [
component: () => (
<Protected>{lazy(() => import("./routes/admin/episode"))()}</Protected>
),
},
{
path: "/:podcastId",
component: lazy(() => import("./routes/podcast")),
}
];

View file

@ -89,7 +89,7 @@ export default function AdminPodcast() {
<Show when={podcastQuery.data?.image_filename}>
<div class="column">
<figure class="image is-128x128">
<img src={new URL(params.podcastId + "/" + podcastQuery.data!.image_filename, SERVER_URL).href} />
<img src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
</figure>
</div>
</Show>

View file

@ -57,7 +57,7 @@ export default function AdminPodcasts() {
<Show when={podcast.image_filename}>
<div class="column">
<figure class="image is-64x64">
<img src={new URL(podcast.id + "/" + podcast.image_filename, SERVER_URL).href} />
<img src={new URL("/files/" + podcast.id + "/" + podcast.image_filename, SERVER_URL).href} />
</figure>
</div>
</Show>

View file

@ -0,0 +1,85 @@
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 Error from "../components/error";
import Loading from "../components/loading";
import { DownloadIcon, MusicIcon } from "lucide-solid";
import { SERVER_URL } from "../constants";
export default function Podcast() {
const params = useParams();
const podcastQuery = createQuery(() => ({
...readPodcastOptions({
path: {
podcast_id: params.podcastId,
}
})
}));
const episodeQuery = createQuery(() => ({
...readEpisodesOptions({
path: {
podcast_id: params.podcastId,
}
})
}))
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">
<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>
</Show>
<div class="column is-full">
<h1 class="title">{podcastQuery.data?.name}</h1>
<p class="subtitle">{podcastQuery.data?.description}</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container is-max-desktop">
<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>
<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>
<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>
)
}

View file

@ -23,9 +23,8 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": "./",
"paths": {
"*": ["src/types/*"]
"*": ["./src/types/*"]
}
},
"include": ["src", "src/types"]

View file

@ -1,11 +1,44 @@
import re
import xml.etree.ElementTree as ET
from datetime import timedelta
from typing import Callable, List, Optional, Tuple
from typing import Callable, Final, List, Optional, Tuple, TypedDict
from fastapi import HTTPException, UploadFile
from models import PodcastEpisode
TRACK_LIST_HEADING: Final[str] = "**Track list**"
class TrackListItem(TypedDict):
title: Optional[str]
artist: Optional[str]
timestamp: Optional[timedelta]
def update_episode_tracklist(
episode: PodcastEpisode, track_list: List[TrackListItem]
) -> Optional[PodcastEpisode]:
if len(track_list) == 0:
return None
description = (
episode.description.split(TRACK_LIST_HEADING)[0].strip()
if episode.description is not None
else ""
)
description += f"\n\n{TRACK_LIST_HEADING}\n\n"
sorted_tracks = sorted(track_list, key=lambda x: x["timestamp"].total_seconds())
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"
episode.description = description.strip()
return episode
async def djuced_track_list(
episode: PodcastEpisode, file: UploadFile
@ -16,7 +49,7 @@ async def djuced_track_list(
if root.tag != "recordEvents":
return None
tracks = []
tracks: List[TrackListItem] = []
for track in root.iter("track"):
title = track.get("song")
@ -29,21 +62,54 @@ async def djuced_track_list(
if len(title_segments) == 2:
artist, title = title_segments
for interval in intervals:
tracks.append((float(interval.get("start")), title, artist))
if len(intervals) > 0:
tracks.append(
{
"title": title,
"artist": artist,
"timestamp": timedelta(seconds=float(intervals[0].get("start"))),
}
)
# sort by start time
tracks = sorted(tracks, key=lambda x: x[0], reverse=False)
return update_episode_tracklist(episode, tracks)
# update description
track_list_str = ""
for i, (t, title, artist) in enumerate(tracks):
time = timedelta(seconds=round(t))
track_list_str += f"{i + 1}. {title} _- {artist} [{time}]_\n"
async def rekordbox_track_list(
episode: PodcastEpisode, file: UploadFile
) -> Optional[PodcastEpisode]:
if not file.filename.endswith(".cue"):
return None
episode.description += "\n\n**Track list**\n\n" + track_list_str
return episode
tracks: List[TrackListItem] = []
current_track: TrackListItem = {}
content = (await file.read()).decode("utf-8")
for line in content.splitlines():
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)
elif line.startswith("INDEX 01"):
time_match = re.search(r"INDEX 01 (\d{2}):(\d{2}):(\d{2})", line)
if time_match:
hours = int(time_match.group(1))
minutes = int(time_match.group(2))
seconds = int(time_match.group(3))
current_track["timestamp"] = timedelta(
hours=hours, minutes=minutes, seconds=seconds
)
tracks.append(
current_track.copy()
) # Ensure current track is added properly
current_track = {}
return update_episode_tracklist(episode, tracks)
# list of file processors
@ -51,7 +117,10 @@ async def djuced_track_list(
# 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)]
] = [
("text/xml", djuced_track_list),
("application/octet-stream", rekordbox_track_list),
]
async def process_additional_episode_upload(

View file

@ -142,20 +142,20 @@ def update_podcast_image(
if image is not None and image.size > 0:
if not (image.filename.endswith(".jpg") or image.filename.endswith(".png")):
return HTTPException(
raise HTTPException(
400, detail="The uploaded podcast image must be a jpg or png"
)
im = Image.open(image.file)
if im.size[0] != im.size[1] or im.size[0] < 1400 or im.size[0] > 3000:
return HTTPException(
raise HTTPException(
400,
detail="The uploaded podcast image must be square and between 1400x1400px and 3000x3000px in size",
)
if im.mode != "RGB":
return HTTPException(
raise HTTPException(
400, detail="The uploaded podcast image must be in RGB format"
)
@ -429,7 +429,7 @@ def get_podcast_feed(session: SessionDep, request: Request, podcast_id: str):
description=podcast.description,
image=urllib.parse.urljoin(
str(request.base_url),
f"/{podcast.id}/{podcast.image_filename}",
f"/files/{podcast.id}/{podcast.image_filename}",
)
if podcast.image_filename is not None
else None,
@ -446,7 +446,7 @@ def get_podcast_feed(session: SessionDep, request: Request, podcast_id: str):
publication_date=episode.publish_date.astimezone(tz=timezone.utc),
media=podgen.Media(
urllib.parse.urljoin(
str(request.base_url), f"{podcast.id}/{episode.id}.m4a"
str(request.base_url), f"/files/{podcast.id}/{episode.id}.m4a"
),
episode.file_size,
duration=timedelta(seconds=episode.duration)
@ -460,7 +460,7 @@ def get_podcast_feed(session: SessionDep, request: Request, podcast_id: str):
return Response(content=feed.rss_str(), media_type="application/xml")
@app.get("/{podcast_id}/{filename}")
@app.get("/files/{podcast_id}/{filename}")
def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str):
podcast = session.exec(
select(models.Podcast).where(models.Podcast.id == podcast_id)
@ -489,12 +489,15 @@ def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str):
) and filename == podcast.image_filename:
return FileResponse(settings.directory / podcast.image_filename)
return HTTPException(status_code=404, detail="File not found")
raise HTTPException(status_code=404, detail="File not found")
@app.get("/{catchall:path}")
async def serve_app(catchall: str):
return FileResponse("dist/index.html")
@app.get("/{full_path:path}")
async def serve_app(full_path: str):
if not full_path.startswith("api/"):
return FileResponse("dist/index.html")
raise HTTPException(status_code=404, detail="Not found")
use_route_names_as_operation_ids(app)