Compare commits
No commits in common. "main" and "next" have entirely different histories.
13 changed files with 35 additions and 251 deletions
|
@ -41,5 +41,3 @@ tasks:
|
|||
deps: [frontend:generate]
|
||||
cmds:
|
||||
- pnpm run build
|
||||
- rm -rf ../dist
|
||||
- mv dist ../dist
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
};
|
|
@ -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
|
||||
});
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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";
|
||||
|
|
|
@ -83,10 +83,6 @@ const routes = [
|
|||
component: () => (
|
||||
<Protected>{lazy(() => import("./routes/admin/episode"))()}</Protected>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/:podcastId",
|
||||
component: lazy(() => import("./routes/podcast")),
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -23,8 +23,9 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"*": ["./src/types/*"]
|
||||
"*": ["src/types/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "src/types"]
|
||||
|
|
|
@ -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(
|
||||
|
|
23
src/main.py
23
src/main.py
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue