Compare commits

..

No commits in common. "main" and "next" have entirely different histories.
main ... next

13 changed files with 35 additions and 251 deletions

View file

@ -41,5 +41,3 @@ 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>Podcast Server</title>
<title>Vite + Solid + TS</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, 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';
import type { GetAppConfigData, ReadUserData, ReadPodcastsData, CreatePodcastData, CreatePodcastError, CreatePodcastResponse, DeletePodcastData, DeletePodcastError, ReadPodcastData, UpdatePodcastData, UpdatePodcastError, UpdatePodcastResponse, UpdatePodcastImageData, UpdatePodcastImageError, UpdatePodcastImageResponse, ReadEpisodesData, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, UpdateEpisodeData, UpdateEpisodeError, UpdateEpisodeResponse, EpisodeAdditionalUploadData, EpisodeAdditionalUploadError, EpisodeAdditionalUploadResponse, GetPodcastFeedData, GetEpisodeOrCoverData } from '../types.gen';
import { getAppConfig, readUser, readPodcasts, createPodcast, deletePodcast, readPodcast, updatePodcast, updatePodcastImage, readEpisodes, adminUploadEpisode, deleteEpisode, readEpisode, updateEpisode, episodeAdditionalUpload, getPodcastFeed, getEpisodeOrCover, client } from '../sdk.gen';
type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -370,23 +370,4 @@ 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, ServeAppData, ServeAppError } 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 } from './types.gen';
export const client = createClient(createConfig());
@ -187,17 +187,7 @@ 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: '/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}',
url: '/{podcast_id}/{filename}',
...options
});
};

View file

@ -444,7 +444,7 @@ export type GetEpisodeOrCoverData = {
filename: string;
};
query?: never;
url: '/files/{podcast_id}/{filename}';
url: '/{podcast_id}/{filename}';
};
export type GetEpisodeOrCoverErrors = {
@ -461,29 +461,4 @@ export type GetEpisodeOrCoverResponses = {
* Successful Response
*/
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 ? window.location.origin : "http://localhost:8000";
export const SERVER_URL = import.meta.env.PROD ? "" : "http://localhost:8000";

View file

@ -83,10 +83,6 @@ 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(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
<img src={new URL(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("/files/" + podcast.id + "/" + podcast.image_filename, SERVER_URL).href} />
<img src={new URL(podcast.id + "/" + podcast.image_filename, SERVER_URL).href} />
</figure>
</div>
</Show>

View file

@ -1,85 +0,0 @@
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,8 +23,9 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": "./",
"paths": {
"*": ["./src/types/*"]
"*": ["src/types/*"]
}
},
"include": ["src", "src/types"]

View file

@ -1,44 +1,11 @@
import re
import xml.etree.ElementTree as ET
from datetime import timedelta
from typing import Callable, Final, List, Optional, Tuple, TypedDict
from typing import Callable, List, Optional, Tuple
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
@ -49,7 +16,7 @@ async def djuced_track_list(
if root.tag != "recordEvents":
return None
tracks: List[TrackListItem] = []
tracks = []
for track in root.iter("track"):
title = track.get("song")
@ -62,54 +29,21 @@ async def djuced_track_list(
if len(title_segments) == 2:
artist, title = title_segments
if len(intervals) > 0:
tracks.append(
{
"title": title,
"artist": artist,
"timestamp": timedelta(seconds=float(intervals[0].get("start"))),
}
)
for interval in intervals:
tracks.append((float(interval.get("start")), title, artist))
return update_episode_tracklist(episode, tracks)
# sort by start time
tracks = sorted(tracks, key=lambda x: x[0], reverse=False)
# update description
track_list_str = ""
async def rekordbox_track_list(
episode: PodcastEpisode, file: UploadFile
) -> Optional[PodcastEpisode]:
if not file.filename.endswith(".cue"):
return None
for i, (t, title, artist) in enumerate(tracks):
time = timedelta(seconds=round(t))
track_list_str += f"{i + 1}. {title} _- {artist} [{time}]_\n"
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)
episode.description += "\n\n**Track list**\n\n" + track_list_str
return episode
# list of file processors
@ -117,10 +51,7 @@ async def rekordbox_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),
("application/octet-stream", rekordbox_track_list),
]
] = [("text/xml", djuced_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")):
raise HTTPException(
return HTTPException(
400, detail="The uploaded podcast image must be a jpg or png"
)
im = Image.open(image.file)
if im.size[0] != im.size[1] or im.size[0] < 1400 or im.size[0] > 3000:
raise HTTPException(
return HTTPException(
400,
detail="The uploaded podcast image must be square and between 1400x1400px and 3000x3000px in size",
)
if im.mode != "RGB":
raise HTTPException(
return 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"/files/{podcast.id}/{podcast.image_filename}",
f"/{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"/files/{podcast.id}/{episode.id}.m4a"
str(request.base_url), f"{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("/files/{podcast_id}/{filename}")
@app.get("/{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,15 +489,12 @@ def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str):
) and filename == podcast.image_filename:
return FileResponse(settings.directory / podcast.image_filename)
raise HTTPException(status_code=404, detail="File not found")
return HTTPException(status_code=404, detail="File not found")
@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")
@app.get("/{catchall:path}")
async def serve_app(catchall: str):
return FileResponse("dist/index.html")
use_route_names_as_operation_ids(app)