Refactor components to TypeScript, enhance media request handling, and improve UI elements
- Updated Radio component to TypeScript and added WebSocket connection checks. - Enhanced DiscordLogs component with thread detection and improved bot presence checks. - Modified Login component to include a client ID for authentication. - Refactored RadioBanner for better styling and accessibility. - Improved BreadcrumbNav with Astro reload attribute for better navigation. - Enhanced MediaRequestForm to prevent rapid clicks during track play/pause. - Updated RequestManagement to handle track lists and finalizing job status more effectively. - Improved CSS for RequestManagement to enhance progress bar and track list display.
This commit is contained in:
@@ -11,6 +11,15 @@ import BreadcrumbNav from "./BreadcrumbNav";
|
||||
import { API_URL } from "@/config";
|
||||
import "./RequestManagement.css";
|
||||
|
||||
interface TrackInfo {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
filename?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RequestJob {
|
||||
id: string | number;
|
||||
target: string;
|
||||
@@ -22,6 +31,7 @@ interface RequestJob {
|
||||
tarball?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
track_list?: TrackInfo[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -38,6 +48,8 @@ export default function RequestManagement() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollingDetailRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Track finalizing job polls to actively refresh job status when progress hits 100% but status hasn't updated yet
|
||||
const finalizingPollsRef = useRef<Record<string | number, ReturnType<typeof setInterval> | null>>({});
|
||||
|
||||
|
||||
const resolveTarballPath = (job: RequestJob) => job.tarball;
|
||||
@@ -47,6 +59,13 @@ export default function RequestManagement() {
|
||||
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
|
||||
// If the backend already stores a fully qualified URL, return as-is
|
||||
if (/^https?:\/\//i.test(absPath)) return absPath;
|
||||
|
||||
// Check if path is /storage/music/TRIP
|
||||
if (absPath.includes("/storage/music/TRIP/")) {
|
||||
return `https://music.boatson.boats/TRIP/${filename}`;
|
||||
}
|
||||
|
||||
// Otherwise, assume /storage/music2/completed/{quality} format
|
||||
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
||||
};
|
||||
|
||||
@@ -182,30 +201,105 @@ export default function RequestManagement() {
|
||||
|
||||
const computePct = (p: unknown) => {
|
||||
if (p === null || p === undefined || p === "") return 0;
|
||||
// Handle "X / Y" format (e.g., "9 / 545") - note spaces around slash from backend
|
||||
if (typeof p === 'string' && p.includes('/')) {
|
||||
const parts = p.split('/').map(s => s.trim());
|
||||
const current = parseFloat(parts[0]);
|
||||
const total = parseFloat(parts[1]);
|
||||
if (Number.isFinite(current) && Number.isFinite(total) && total > 0) {
|
||||
return Math.min(100, Math.max(0, Math.round((current / total) * 100)));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const num = Number(p);
|
||||
if (!Number.isFinite(num)) return 0;
|
||||
const normalized = num > 1 ? num : num * 100;
|
||||
return Math.min(100, Math.max(0, Math.round(normalized)));
|
||||
// Backend sends progress as 0-100 directly, so just clamp it
|
||||
return Math.min(100, Math.max(0, Math.round(num)));
|
||||
};
|
||||
|
||||
// Visual pct used for display/fill. Prevent briefly showing 100% unless status is Finished
|
||||
const displayPct = (p: unknown, status?: string) => {
|
||||
const pct = computePct(p);
|
||||
const statusNorm = String(status || "").trim();
|
||||
// If the backend reports 100% but the job hasn't reached 'Finished', show 99 to avoid flash
|
||||
if (pct >= 100 && statusNorm.toLowerCase() !== 'finished') return 99;
|
||||
return pct;
|
||||
};
|
||||
const isFinalizingJob = (job: RequestJob | { progress?: unknown; status?: string }) => {
|
||||
const pct = computePct(job.progress);
|
||||
const statusNorm = String(job.status || "").trim().toLowerCase();
|
||||
// Only treat as finalizing when status is explicitly "Compressing"
|
||||
// This is set by the backend only when progress == 100 and tarball isn't ready yet
|
||||
return statusNorm === 'compressing';
|
||||
};
|
||||
|
||||
const startFinalizingPoll = (jobId: string | number) => {
|
||||
if (finalizingPollsRef.current[jobId]) return; // already polling
|
||||
|
||||
let attempts = 0;
|
||||
const iv = setInterval(async () => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const updated = await fetchJobDetail(jobId);
|
||||
if (updated) {
|
||||
// Merge the updated job into requests list so UI refreshes
|
||||
setRequests((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
|
||||
// If it's no longer finalizing, stop this poll
|
||||
if (!isFinalizingJob(updated)) {
|
||||
if (finalizingPollsRef.current[jobId]) {
|
||||
clearInterval(finalizingPollsRef.current[jobId] as ReturnType<typeof setInterval>);
|
||||
finalizingPollsRef.current[jobId] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore individual errors; we'll retry a few times
|
||||
}
|
||||
// safety cap: stop after ~20 attempts (~30s)
|
||||
if (attempts >= 20) {
|
||||
if (finalizingPollsRef.current[jobId]) {
|
||||
clearInterval(finalizingPollsRef.current[jobId] as ReturnType<typeof setInterval>);
|
||||
finalizingPollsRef.current[jobId] = null;
|
||||
}
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
finalizingPollsRef.current[jobId] = iv;
|
||||
};
|
||||
|
||||
// stop all finalizing polls on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(finalizingPollsRef.current).forEach((iv) => {
|
||||
if (iv) clearInterval(iv);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const progressBarTemplate = (rowData: RequestJob) => {
|
||||
const p = rowData.progress;
|
||||
if (p === null || p === undefined || p === "") return "—";
|
||||
const pct = computePct(p);
|
||||
const pctRaw = computePct(p);
|
||||
const isFinalizing = isFinalizingJob(rowData);
|
||||
const pct = isFinalizing ? 99 : pctRaw;
|
||||
|
||||
const getProgressColor = () => {
|
||||
if (rowData.status === "Failed") return "bg-red-500";
|
||||
if (rowData.status === "Finished") return "bg-green-500";
|
||||
if (pct < 30) return "bg-blue-400";
|
||||
if (pct < 70) return "bg-blue-500";
|
||||
if (isFinalizing) return "bg-yellow-500"; // finalizing indicator
|
||||
if (pctRaw < 30) return "bg-blue-400";
|
||||
if (pctRaw < 70) return "bg-blue-500";
|
||||
return "bg-blue-600";
|
||||
};
|
||||
|
||||
// If this job appears to be finalizing, ensure a poll is active to get the real status
|
||||
if (isFinalizing) startFinalizingPoll(rowData.id);
|
||||
|
||||
return (
|
||||
<div className="rm-progress-container">
|
||||
<div className="rm-progress-track" style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
className={`rm-progress-fill ${getProgressColor()}`}
|
||||
className={`rm-progress-fill ${getProgressColor()} ${isFinalizing ? 'rm-finalizing' : ''}`}
|
||||
style={{
|
||||
// CSS custom property for progress animation
|
||||
['--rm-progress' as string]: (pct / 100).toString(),
|
||||
@@ -213,12 +307,12 @@ export default function RequestManagement() {
|
||||
borderBottomRightRadius: pct === 100 ? '999px' : 0
|
||||
}}
|
||||
data-pct={pct}
|
||||
aria-valuenow={pct}
|
||||
aria-valuenow={pctRaw}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
</div>
|
||||
<span className="rm-progress-text" style={{ marginLeft: 8, flex: 'none' }}>{pct}%</span>
|
||||
<span className="rm-progress-text" style={{ marginLeft: 8, flex: 'none' }}>{pct}%{isFinalizing ? ' (finalizing...)' : ''}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -411,26 +505,34 @@ export default function RequestManagement() {
|
||||
<div className="rm-progress-container mt-2">
|
||||
<div className="rm-progress-track rm-progress-track-lg">
|
||||
{(() => {
|
||||
const pctDialog = computePct(selectedRequest.progress);
|
||||
const pctRawDialog = computePct(selectedRequest.progress);
|
||||
const isFinalizingDialog = isFinalizingJob(selectedRequest);
|
||||
const pctDialog = isFinalizingDialog ? 99 : pctRawDialog;
|
||||
const status = selectedRequest.status;
|
||||
const fillColor = status === "Failed" ? "bg-red-500" : status === "Finished" ? "bg-green-500" : "bg-blue-500";
|
||||
const fillColor = status === "Failed" ? "bg-red-500" : status === "Finished" ? "bg-green-500" : isFinalizingDialog ? "bg-yellow-500" : "bg-blue-500";
|
||||
|
||||
// Ensure we poll for finalizing jobs to get the real status update
|
||||
if (isFinalizingDialog) startFinalizingPoll(selectedRequest.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rm-progress-fill ${fillColor}`}
|
||||
style={{
|
||||
['--rm-progress' as string]: (pctDialog / 100).toString(),
|
||||
borderTopRightRadius: pctDialog >= 100 ? '999px' : 0,
|
||||
borderBottomRightRadius: pctDialog >= 100 ? '999px' : 0
|
||||
}}
|
||||
data-pct={pctDialog}
|
||||
aria-valuenow={pctDialog}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className={`rm-progress-fill ${fillColor} ${isFinalizingDialog ? 'rm-finalizing' : ''}`}
|
||||
style={{
|
||||
['--rm-progress' as string]: (pctDialog / 100).toString(),
|
||||
borderTopRightRadius: pctDialog >= 100 ? '999px' : 0,
|
||||
borderBottomRightRadius: pctDialog >= 100 ? '999px' : 0
|
||||
}}
|
||||
data-pct={pctDialog}
|
||||
aria-valuenow={pctRawDialog}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<span className="rm-progress-text">{formatProgress(selectedRequest.progress)}</span>
|
||||
<span className="rm-progress-text">{formatProgress(selectedRequest.progress)}{isFinalizingJob(selectedRequest) ? ' — finalizing' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -462,6 +564,66 @@ export default function RequestManagement() {
|
||||
)
|
||||
}
|
||||
|
||||
{/* --- Track List Card --- */}
|
||||
{selectedRequest.track_list && selectedRequest.track_list.length > 0 && (
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md">
|
||||
<p className="mb-2"><strong>Tracks ({selectedRequest.track_list.length}):</strong></p>
|
||||
<div className="max-h-60 overflow-y-auto space-y-2" data-lenis-prevent>
|
||||
{selectedRequest.track_list.map((track, idx) => {
|
||||
const rawStatus = String(track.status || "pending");
|
||||
const statusNorm = rawStatus.trim().toLowerCase();
|
||||
|
||||
const isError = statusNorm === "failed" || statusNorm === "error" || !!track.error;
|
||||
const isSuccess = ["done", "success", "completed", "finished"].includes(statusNorm);
|
||||
const isPending = ["pending", "queued"].includes(statusNorm);
|
||||
const isDownloading = ["downloading", "in_progress", "started"].includes(statusNorm);
|
||||
|
||||
const statusBadgeClass = isError
|
||||
? "bg-red-600 text-white"
|
||||
: isSuccess
|
||||
? "bg-green-600 text-white"
|
||||
: isDownloading
|
||||
? "bg-blue-600 text-white"
|
||||
: isPending
|
||||
? "bg-yellow-600 text-white"
|
||||
: "bg-gray-500 text-white";
|
||||
|
||||
const trackTitle = track.title || track.filename || `Track ${idx + 1}`;
|
||||
const trackArtist = track.artist;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-2 rounded border ${isError ? "border-red-500/50 bg-red-500/10" : "border-neutral-300 dark:border-neutral-600"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" title={trackTitle}>
|
||||
{trackTitle}
|
||||
</p>
|
||||
{trackArtist && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate" title={trackArtist}>
|
||||
{trackArtist}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 pr-3 mr-2 rounded text-xs font-semibold whitespace-nowrap ${statusBadgeClass} rm-track-status`} title={rawStatus}>
|
||||
{rawStatus}
|
||||
</span>
|
||||
</div>
|
||||
{track.error && (
|
||||
<p className="mt-1 text-xs text-red-400 break-words">
|
||||
<i className="pi pi-exclamation-triangle mr-1" />
|
||||
{track.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div >
|
||||
) : (
|
||||
<p>Loading...</p>
|
||||
|
||||
Reference in New Issue
Block a user