Cleanup
This commit is contained in:
@@ -1,4 +0,0 @@
|
|||||||
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
if "listSegmentEdits" in line:
|
|
||||||
print(line.strip())
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
with open("backend/app/mkv.py", "r", encoding="utf-8") as f:
|
|
||||||
for i, line in enumerate(f):
|
|
||||||
if "core_ranges" in line:
|
|
||||||
print(f"{i}: {line.strip()}")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
|
||||||
for i, line in enumerate(f):
|
|
||||||
if "value={segmentDrafts" in line:
|
|
||||||
print(f"{i}: {line.strip()}")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
with open("backend/app/main.py", "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
if "set_progress" in line or "job_manager.log" in line:
|
|
||||||
print(line.strip())
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
if "const match = /^(" in line:
|
|
||||||
print(line.strip())
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
import sys
|
|
||||||
|
|
||||||
def patch_file():
|
|
||||||
with open('frontend/src/App.tsx', 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# 1. Add state variables
|
|
||||||
old1 = ''' const [job, setJob] = useState<Job | null>(null);
|
|
||||||
const [outputs, setOutputs] = useState<string[]>([]);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);'''
|
|
||||||
new1 = ''' const [job, setJob] = useState<Job | null>(null);
|
|
||||||
const [outputs, setOutputs] = useState<string[]>([]);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
type SegmentRow = {
|
|
||||||
kind: "intro" | "core" | "outro";
|
|
||||||
label: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
startEditable: boolean;
|
|
||||||
endEditable: boolean;
|
|
||||||
coreIndex?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [isLinked, setIsLinked] = useState(true);
|
|
||||||
const [customSegmentRows, setCustomSegmentRows] = useState<SegmentRow[]>([]);
|
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);'''
|
|
||||||
if old1 not in content: print('Failed old1')
|
|
||||||
content = content.replace(old1, new1)
|
|
||||||
|
|
||||||
# 2. Remove duplicate SegmentRow type declaration
|
|
||||||
old2 = ''' type SegmentRow = {
|
|
||||||
kind: "intro" | "core" | "outro";
|
|
||||||
label: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
startEditable: boolean;
|
|
||||||
endEditable: boolean;
|
|
||||||
coreIndex?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const introBoundary = useMemo(() => {'''
|
|
||||||
new2 = ''' const introBoundary = useMemo(() => {'''
|
|
||||||
if old2 not in content: print('Failed old2')
|
|
||||||
content = content.replace(old2, new2)
|
|
||||||
|
|
||||||
# 3. Rename segmentRows to derivedSegmentRows
|
|
||||||
old3 = ''' const segmentRows = useMemo(() => {
|
|
||||||
if (!duration || !project || coreBoundaries.length < 2) return [] as SegmentRow[];'''
|
|
||||||
new3 = ''' const derivedSegmentRows = useMemo(() => {
|
|
||||||
if (!duration || !project || coreBoundaries.length < 2) return [] as SegmentRow[];'''
|
|
||||||
if old3 not in content: print('Failed old3')
|
|
||||||
content = content.replace(old3, new3)
|
|
||||||
|
|
||||||
# 4. Add segmentRows constant
|
|
||||||
old4 = ''' });
|
|
||||||
return rows;
|
|
||||||
}, [duration, project, coreBoundaries, introBoundary, alignedOutroBoundary, lastFrameTime, frameStep, segments]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!segmentRows.length) {'''
|
|
||||||
new4 = ''' });
|
|
||||||
return rows;
|
|
||||||
}, [duration, project, coreBoundaries, introBoundary, alignedOutroBoundary, lastFrameTime, frameStep, segments]);
|
|
||||||
|
|
||||||
const segmentRows = isLinked ? derivedSegmentRows : customSegmentRows;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!segmentRows.length) {'''
|
|
||||||
if old4 not in content: print('Failed old4')
|
|
||||||
content = content.replace(old4, new4)
|
|
||||||
|
|
||||||
# 5. handleUpload changes
|
|
||||||
old5 = ''' const handleUpload = async () => {
|
|
||||||
if (!project) {
|
|
||||||
setError("Configure project settings before uploading.");
|
|
||||||
setProjectDrawerOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (videoFiles.length === 0) return;
|
|
||||||
setError(null);
|
|
||||||
setIsUploading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const uploadedItems = await uploadVideos(videoFiles);'''
|
|
||||||
new5 = ''' const handleUpload = async () => {
|
|
||||||
if (!project) {
|
|
||||||
setError("Configure project settings before uploading.");
|
|
||||||
setProjectDrawerOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (videoFiles.length === 0) return;
|
|
||||||
setError(null);
|
|
||||||
setIsUploading(true);
|
|
||||||
setUploadProgress(0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const uploadedItems = await uploadVideos(videoFiles, (index, total, progress) => {
|
|
||||||
setUploadProgress((index + progress) / total);
|
|
||||||
});'''
|
|
||||||
if old5 not in content: print('Failed old5')
|
|
||||||
content = content.replace(old5, new5)
|
|
||||||
|
|
||||||
# 6. handleUpload linked reset
|
|
||||||
old6 = ''' setJob(null);
|
|
||||||
setOutputPrefix(uploaded.filename.replace(/\.[^.]+$/, ""));
|
|
||||||
}
|
|
||||||
setVideoFiles([]);'''
|
|
||||||
new6 = ''' setJob(null);
|
|
||||||
setOutputPrefix(uploaded.filename.replace(/\.[^.]+$/, ""));
|
|
||||||
setIsLinked(true);
|
|
||||||
}
|
|
||||||
setVideoFiles([]);'''
|
|
||||||
if old6 not in content: print('Failed old6')
|
|
||||||
content = content.replace(old6, new6)
|
|
||||||
|
|
||||||
# 7. handleSelectVideo reset
|
|
||||||
old7 = ''' setOutputPrefix(item.filename.replace(/\.[^.]+$/, ""));
|
|
||||||
setVideoFiles([]);
|
|
||||||
setPreviewUrl("");
|
|
||||||
try {'''
|
|
||||||
new7 = ''' setOutputPrefix(item.filename.replace(/\.[^.]+$/, ""));
|
|
||||||
setVideoFiles([]);
|
|
||||||
setPreviewUrl("");
|
|
||||||
setIsLinked(true);
|
|
||||||
try {'''
|
|
||||||
if old7 not in content: print('Failed old7')
|
|
||||||
content = content.replace(old7, new7)
|
|
||||||
|
|
||||||
# 8. commitSegmentDraft bypass
|
|
||||||
old8 = ''' const snapped = snapToFrame(parsed);
|
|
||||||
let boundaryIndex = -1;
|
|
||||||
let boundaryValue = snapped;'''
|
|
||||||
new8 = ''' const snapped = snapToFrame(parsed);
|
|
||||||
|
|
||||||
if (!isLinked) {
|
|
||||||
setCustomSegmentRows((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
next[rowIndex] = { ...next[rowIndex], [field]: snapped };
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setPendingSegmentEditPersist(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let boundaryIndex = -1;
|
|
||||||
let boundaryValue = snapped;'''
|
|
||||||
if old8 not in content: print('Failed old8')
|
|
||||||
content = content.replace(old8, new8)
|
|
||||||
|
|
||||||
# 9. handleDeleteSegment bypass
|
|
||||||
old9 = ''' const handleDeleteSegment = async (row: SegmentRow) => {
|
|
||||||
setError(null);
|
|
||||||
if (row.kind === "intro") {'''
|
|
||||||
new9 = ''' const handleDeleteSegment = async (row: SegmentRow) => {
|
|
||||||
setError(null);
|
|
||||||
if (!isLinked) {
|
|
||||||
setCustomSegmentRows((prev) => prev.filter((r) => r !== row));
|
|
||||||
setPendingSegmentEditPersist(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.kind === "intro") {'''
|
|
||||||
if old9 not in content: print('Failed old9')
|
|
||||||
content = content.replace(old9, new9)
|
|
||||||
|
|
||||||
# 10. upload progress UI
|
|
||||||
old10 = ''' <Box>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={videoFiles.length === 0 || isBusy || !project}
|
|
||||||
>
|
|
||||||
{isUploading ? "Uploading..." : "Upload to Backend"}
|
|
||||||
</Button>
|
|
||||||
<Button sx={{ ml: 1 }} onClick={() => setProjectDrawerOpen(true)}>
|
|
||||||
Project settings
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>'''
|
|
||||||
new10 = ''' <Box>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={videoFiles.length === 0 || isBusy || !project}
|
|
||||||
>
|
|
||||||
{isUploading ? "Uploading..." : "Upload to Backend"}
|
|
||||||
</Button>
|
|
||||||
<Button sx={{ ml: 1 }} onClick={() => setProjectDrawerOpen(true)}>
|
|
||||||
Project settings
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{isUploading && (
|
|
||||||
<Box sx={{ mt: 1 }}>
|
|
||||||
<LinearProgress variant="determinate" value={uploadProgress * 100} />
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
||||||
Uploading... {Math.round(uploadProgress * 100)}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box>
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>'''
|
|
||||||
if old10 not in content: print('Failed old10')
|
|
||||||
content = content.replace(old10, new10)
|
|
||||||
|
|
||||||
# 11. segments table unlink button
|
|
||||||
old11 = ''' <Grid item xs={12}>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }}>
|
|
||||||
<Button variant="outlined" onClick={handleAddMarker} disabled={!duration}>
|
|
||||||
Add marker at playhead
|
|
||||||
</Button>
|
|
||||||
<Button variant="outlined" onClick={handleSaveMarkers} disabled={!video || markers.length === 0}>
|
|
||||||
Save markers
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Grid>'''
|
|
||||||
new11 = ''' <Grid item xs={12}>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }}>
|
|
||||||
<Button variant="outlined" onClick={handleAddMarker} disabled={!duration}>
|
|
||||||
Add marker at playhead
|
|
||||||
</Button>
|
|
||||||
<Button variant="outlined" onClick={handleSaveMarkers} disabled={!video || markers.length === 0}>
|
|
||||||
Save markers
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={isLinked ? <LinkOff /> : <Link />}
|
|
||||||
onClick={() => {
|
|
||||||
if (isLinked) {
|
|
||||||
setCustomSegmentRows(derivedSegmentRows);
|
|
||||||
}
|
|
||||||
setIsLinked(!isLinked);
|
|
||||||
}}
|
|
||||||
disabled={!duration}
|
|
||||||
>
|
|
||||||
{isLinked ? "Unlink timestamps" : "Link timestamps"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Grid>'''
|
|
||||||
if old11 not in content: print('Failed old11')
|
|
||||||
content = content.replace(old11, new11)
|
|
||||||
|
|
||||||
with open('frontend/src/App.tsx', 'w', encoding='utf-8') as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
patch_file()
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
with open("frontend/src/api.ts", "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
import re
|
|
||||||
content = re.sub(r"return request\(.*?/segment-edits\);", "return request(`/api/videos/${videoId}/segment-edits`);", content)
|
|
||||||
|
|
||||||
with open("frontend/src/api.ts", "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("api.ts fixed properly")
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
old_add_marker = """ const handleAddMarker = () => {
|
|
||||||
if (!duration) return;
|
|
||||||
const snapped = snapToFrame(cursorTime);
|
|
||||||
setMarkers((prev) => normalizeMarkers([...prev, snapped]));
|
|
||||||
};"""
|
|
||||||
|
|
||||||
new_add_marker = """ const handleAddMarker = async () => {
|
|
||||||
if (!video || !duration) return;
|
|
||||||
const snapped = snapToFrame(cursorTime);
|
|
||||||
const nextMarkers = normalizeMarkers([...markers, snapped]);
|
|
||||||
setMarkers(nextMarkers);
|
|
||||||
try {
|
|
||||||
await replaceMarkers(video.id, nextMarkers);
|
|
||||||
setPendingSegmentEditPersist(true);
|
|
||||||
} catch (err) {
|
|
||||||
setError((err as Error).message);
|
|
||||||
}
|
|
||||||
};"""
|
|
||||||
|
|
||||||
content = content.replace(old_add_marker, new_add_marker)
|
|
||||||
|
|
||||||
# Remove "Save markers" button
|
|
||||||
old_button = """ <Button variant="outlined" onClick={handleSaveMarkers} disabled={!video || markers.length === 0}>
|
|
||||||
Save markers
|
|
||||||
</Button>"""
|
|
||||||
|
|
||||||
content = content.replace(old_button, "")
|
|
||||||
|
|
||||||
with open("frontend/src/App.tsx", "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("App.tsx add marker autosave patched")
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
old_export_progress = """ {(job?.kind === "split" || job?.kind === "split_all") && job.status !== "completed" && (
|
|
||||||
<Box>
|
|
||||||
<LinearProgress variant="determinate" value={Math.round(job.progress * 100)} />
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
|
||||||
{job.message || `Status: ${job.status}`}
|
|
||||||
</Typography>
|
|
||||||
{job.details && (
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 1 }}>
|
|
||||||
<Chip size="small" label={`Stage: ${job.details.stage ?? "working"}`} />
|
|
||||||
<Chip size="small" label={`Stage ETA: ${job.details.stage_eta ?? "calculating"}`} />
|
|
||||||
<Chip size="small" label={`Total ETA: ${job.details.total_eta ?? "calculating"}`} />
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}"""
|
|
||||||
|
|
||||||
new_export_progress = """ {(job?.kind === "split" || job?.kind === "split_all") && job.status !== "completed" && (
|
|
||||||
<Box sx={{ p: 2, bgcolor: "background.paper", borderRadius: 4, border: "1px solid", borderColor: "divider" }}>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
{job.kind === "split_all" ? "Bulk Export Progress" : "Export Progress"}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{job.kind === "split_all" && job.details?.label && (() => {
|
|
||||||
const match = /^(\d+)\\/(\d+) (.+)$/.exec(job.details.label);
|
|
||||||
if (match) {
|
|
||||||
const index = Number(match[1]);
|
|
||||||
const total = Number(match[2]);
|
|
||||||
const filename = match[3];
|
|
||||||
const videoProgress = Math.max(0, Math.min(100, (job.progress - (index - 1) / total) * total * 100));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ mb: 3 }}>
|
|
||||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
||||||
Overall Bulk Progress ({index}/{total} videos)
|
|
||||||
</Typography>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={Math.round(job.progress * 100)}
|
|
||||||
sx={{ height: 12, borderRadius: 6, mb: 2, bgcolor: "primary.light" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="body2" color="text.primary" sx={{ fontWeight: "bold" }}>
|
|
||||||
Current Video: {filename}
|
|
||||||
</Typography>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={Math.round(videoProgress)}
|
|
||||||
color="secondary"
|
|
||||||
sx={{ height: 8, borderRadius: 4, mb: 2 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Current Stage: {job.details.stage || "working"}
|
|
||||||
</Typography>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={job.details.stage_percent || 0}
|
|
||||||
color="info"
|
|
||||||
sx={{ height: 4, borderRadius: 2 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{job.kind === "split" && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="body2" color="text.primary" gutterBottom>
|
|
||||||
Video Progress
|
|
||||||
</Typography>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={Math.round(job.progress * 100)}
|
|
||||||
color="secondary"
|
|
||||||
sx={{ height: 8, borderRadius: 4, mb: 2 }}
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Current Stage: {job.details?.stage || "working"}
|
|
||||||
</Typography>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={job.details?.stage_percent || 0}
|
|
||||||
color="info"
|
|
||||||
sx={{ height: 4, borderRadius: 2 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 2 }}>
|
|
||||||
{job.details?.stage_eta && <Chip size="small" label={`Stage ETA: ${job.details.stage_eta}`} />}
|
|
||||||
{job.details?.total_eta && <Chip size="small" label={`Total ETA: ${job.details.total_eta}`} />}
|
|
||||||
<Chip size="small" color={job.status === "running" ? "primary" : "default"} label={`Status: ${job.status}`} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{job.kind === "split_all" && (
|
|
||||||
<Box sx={{ mt: 3, p: 2, bgcolor: "background.default", borderRadius: 2 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
Queue Queue
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={0.5}>
|
|
||||||
{videoList.map((v, i) => {
|
|
||||||
const match = job.details?.label ? /^(\d+)\\/(\d+) (.+)$/.exec(job.details.label) : null;
|
|
||||||
const currentIndex = match ? Number(match[1]) - 1 : -1;
|
|
||||||
const status = i < currentIndex ? "completed" : i === currentIndex ? "running" : "pending";
|
|
||||||
return (
|
|
||||||
<Typography
|
|
||||||
key={v.id}
|
|
||||||
variant="body2"
|
|
||||||
color={status === "completed" ? "success.main" : status === "running" ? "primary.main" : "text.disabled"}
|
|
||||||
sx={{ fontWeight: status === "running" ? "bold" : "normal" }}
|
|
||||||
>
|
|
||||||
{status === "completed" ? "✓ " : status === "running" ? "▶ " : "• "}
|
|
||||||
{v.filename}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}"""
|
|
||||||
|
|
||||||
content = content.replace(old_export_progress, new_export_progress)
|
|
||||||
|
|
||||||
with open("frontend/src/App.tsx", "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("App.tsx export UI patched")
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Add listSegmentEdits to import
|
|
||||||
content = content.replace("listMarkers,", "listMarkers,\n listSegmentEdits,")
|
|
||||||
|
|
||||||
# Fetch segment_edits in handleSelectVideo
|
|
||||||
old_handle_select = """ try {
|
|
||||||
const response = await listMarkers(item.id);
|
|
||||||
setMarkers(normalizeMarkers(response.markers ?? []));
|
|
||||||
} catch (err) {"""
|
|
||||||
|
|
||||||
new_handle_select = """ try {
|
|
||||||
const response = await listMarkers(item.id);
|
|
||||||
setMarkers(normalizeMarkers(response.markers ?? []));
|
|
||||||
|
|
||||||
const editsResponse = await listSegmentEdits(item.id);
|
|
||||||
if (editsResponse.segments && editsResponse.segments.length > 0) {
|
|
||||||
setIsLinked(false);
|
|
||||||
setCustomSegmentRows(
|
|
||||||
editsResponse.segments.map((se) => ({
|
|
||||||
kind: se.segment_key.startsWith("segment_") ? "core" : (se.segment_key as any),
|
|
||||||
label: se.segment_key,
|
|
||||||
start: se.start_seconds,
|
|
||||||
end: se.end_seconds,
|
|
||||||
startEditable: true,
|
|
||||||
endEditable: true,
|
|
||||||
coreIndex: se.segment_key.startsWith("segment_") ? parseInt(se.segment_key.split("_")[1]) - 1 : undefined,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setIsLinked(true);
|
|
||||||
setCustomSegmentRows([]);
|
|
||||||
}
|
|
||||||
} catch (err) {"""
|
|
||||||
|
|
||||||
content = content.replace(old_handle_select, new_handle_select)
|
|
||||||
|
|
||||||
with open("frontend/src/App.tsx", "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("App.tsx fetch segment_edits patched")
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
old_btn = """ <Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={isLinked ? <LinkOff /> : <Link />}
|
|
||||||
onClick={() => {
|
|
||||||
if (isLinked) {
|
|
||||||
setCustomSegmentRows(derivedSegmentRows);
|
|
||||||
}
|
|
||||||
setIsLinked(!isLinked);
|
|
||||||
}}
|
|
||||||
disabled={!duration}
|
|
||||||
>
|
|
||||||
{isLinked ? "Unlink timestamps" : "Link timestamps"}
|
|
||||||
</Button>"""
|
|
||||||
|
|
||||||
content = content.replace(old_btn, "")
|
|
||||||
|
|
||||||
with open("frontend/src/App.tsx", "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("Removed global unlink button")
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
with open('backend/app/db.py', 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
new_func = '''def list_segment_edits(video_id: str) -> list[dict]:
|
|
||||||
with _DB_LOCK:
|
|
||||||
conn = get_conn()
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT * FROM segment_edits WHERE video_id = ? ORDER BY segment_key",
|
|
||||||
(video_id,),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
conn.close()
|
|
||||||
return [_row_to_dict(row) for row in rows]
|
|
||||||
|
|
||||||
def replace_segment_edits'''
|
|
||||||
|
|
||||||
content = content.replace('def replace_segment_edits', new_func)
|
|
||||||
|
|
||||||
with open('backend/app/db.py', 'w', encoding='utf-8') as f:
|
|
||||||
f.write(content)
|
|
||||||
print('db.py patched')
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
with open('backend/app/main.py', 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
new_func = '''@app.get("/api/videos/{video_id}/segment-edits", response_model=dict[str, list[schemas.SegmentEditOut]])
|
|
||||||
def list_segment_edits(video_id: str) -> dict:
|
|
||||||
_get_video_or_404(video_id)
|
|
||||||
return {"segments": db.list_segment_edits(video_id)}
|
|
||||||
|
|
||||||
@app.put("/api/videos/{video_id}/segment-edits"'''
|
|
||||||
|
|
||||||
content = content.replace('@app.put("/api/videos/{video_id}/segment-edits"', new_func)
|
|
||||||
|
|
||||||
with open('backend/app/main.py', 'w', encoding='utf-8') as f:
|
|
||||||
f.write(content)
|
|
||||||
print('main.py patched')
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
with open("backend/app/main.py", "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
old_split_logic = """ def run_job() -> dict:
|
|
||||||
project_dir = _project_dir(project["id"])
|
|
||||||
output_dir = project_dir / "outputs" / video_id
|
|
||||||
temp_dir = JOBS_DIR / job_id
|
|
||||||
|
|
||||||
progress = _make_progress_updater(job_id, Path(video["filename"]).stem)
|
|
||||||
|
|
||||||
outputs = mkv.build_episodes(
|
|
||||||
video["file_path"],
|
|
||||||
video["duration_seconds"],
|
|
||||||
project["intro_seconds"],
|
|
||||||
project["outro_seconds"],
|
|
||||||
markers,
|
|
||||||
output_dir,
|
|
||||||
temp_dir,
|
|
||||||
project_dir,
|
|
||||||
output_prefix=output_prefix,
|
|
||||||
reencode=project["reencode_enabled"],
|
|
||||||
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
|
|
||||||
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
|
||||||
progress_cb=progress,
|
|
||||||
log_cb=lambda message: job_manager.log(job_id, message),
|
|
||||||
)"""
|
|
||||||
|
|
||||||
new_split_logic = """ segment_edits = db.list_segment_edits(video_id)
|
|
||||||
custom_segments = None
|
|
||||||
if segment_edits:
|
|
||||||
custom_segments = [(se["start_seconds"], se["end_seconds"]) for se in segment_edits if se["segment_key"].startswith("segment_")]
|
|
||||||
|
|
||||||
def run_job() -> dict:
|
|
||||||
project_dir = _project_dir(project["id"])
|
|
||||||
output_dir = project_dir / "outputs" / video_id
|
|
||||||
temp_dir = JOBS_DIR / job_id
|
|
||||||
|
|
||||||
progress = _make_progress_updater(job_id, Path(video["filename"]).stem)
|
|
||||||
|
|
||||||
outputs = mkv.build_episodes(
|
|
||||||
video["file_path"],
|
|
||||||
video["duration_seconds"],
|
|
||||||
project["intro_seconds"],
|
|
||||||
project["outro_seconds"],
|
|
||||||
markers,
|
|
||||||
output_dir,
|
|
||||||
temp_dir,
|
|
||||||
project_dir,
|
|
||||||
output_prefix=output_prefix,
|
|
||||||
reencode=project["reencode_enabled"],
|
|
||||||
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
|
|
||||||
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
|
||||||
progress_cb=progress,
|
|
||||||
log_cb=lambda message: job_manager.log(job_id, message),
|
|
||||||
custom_segments=custom_segments,
|
|
||||||
)"""
|
|
||||||
|
|
||||||
content = content.replace(old_split_logic, new_split_logic)
|
|
||||||
|
|
||||||
old_split_all_ready = """ ready: list[tuple[dict, list[float]]] = []
|
|
||||||
skipped: list[dict] = []
|
|
||||||
for video in videos:
|
|
||||||
try:
|
|
||||||
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
|
|
||||||
except HTTPException as exc:
|
|
||||||
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": exc.detail})
|
|
||||||
continue
|
|
||||||
markers = sorted(db.list_markers(video["id"]))
|
|
||||||
if not markers:
|
|
||||||
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers"})
|
|
||||||
continue
|
|
||||||
ready.append((video, markers))"""
|
|
||||||
|
|
||||||
new_split_all_ready = """ ready: list[tuple[dict, list[float], list[tuple[float, float]] | None]] = []
|
|
||||||
skipped: list[dict] = []
|
|
||||||
for video in videos:
|
|
||||||
try:
|
|
||||||
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
|
|
||||||
except HTTPException as exc:
|
|
||||||
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": exc.detail})
|
|
||||||
continue
|
|
||||||
markers = sorted(db.list_markers(video["id"]))
|
|
||||||
segment_edits = db.list_segment_edits(video["id"])
|
|
||||||
custom_segments = None
|
|
||||||
if segment_edits:
|
|
||||||
custom_segments = [(se["start_seconds"], se["end_seconds"]) for se in segment_edits if se["segment_key"].startswith("segment_")]
|
|
||||||
|
|
||||||
if not markers and not custom_segments:
|
|
||||||
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers or segments"})
|
|
||||||
continue
|
|
||||||
ready.append((video, markers, custom_segments))"""
|
|
||||||
|
|
||||||
content = content.replace(old_split_all_ready, new_split_all_ready)
|
|
||||||
|
|
||||||
old_split_all_loop = """ for video_index, (video, markers) in enumerate(ready, start=1):
|
|
||||||
label = f"{video_index}/{total_videos} {Path(video['filename']).stem}"
|
|
||||||
output_dir = project_dir / "outputs" / video["id"]
|
|
||||||
temp_dir = JOBS_DIR / job_id / video["id"]
|
|
||||||
output_prefix = _sanitize_prefix(Path(video["filename"]).stem)
|
|
||||||
job_manager.log(job_id, f"Starting export for {video['filename']}")
|
|
||||||
outputs = mkv.build_episodes(
|
|
||||||
video["file_path"],
|
|
||||||
video["duration_seconds"],
|
|
||||||
project["intro_seconds"],
|
|
||||||
project["outro_seconds"],
|
|
||||||
markers,
|
|
||||||
output_dir,
|
|
||||||
temp_dir,
|
|
||||||
project_dir,
|
|
||||||
output_prefix=output_prefix,
|
|
||||||
reencode=project["reencode_enabled"],
|
|
||||||
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
|
|
||||||
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
|
||||||
progress_cb=progress,
|
|
||||||
log_cb=lambda message: job_manager.log(job_id, message),
|
|
||||||
)"""
|
|
||||||
|
|
||||||
new_split_all_loop = """ for video_index, (video, markers, custom_segments) in enumerate(ready, start=1):
|
|
||||||
label = f"{video_index}/{total_videos} {Path(video['filename']).stem}"
|
|
||||||
output_dir = project_dir / "outputs" / video["id"]
|
|
||||||
temp_dir = JOBS_DIR / job_id / video["id"]
|
|
||||||
output_prefix = _sanitize_prefix(Path(video["filename"]).stem)
|
|
||||||
job_manager.log(job_id, f"Starting export for {video['filename']}")
|
|
||||||
outputs = mkv.build_episodes(
|
|
||||||
video["file_path"],
|
|
||||||
video["duration_seconds"],
|
|
||||||
project["intro_seconds"],
|
|
||||||
project["outro_seconds"],
|
|
||||||
markers,
|
|
||||||
output_dir,
|
|
||||||
temp_dir,
|
|
||||||
project_dir,
|
|
||||||
output_prefix=output_prefix,
|
|
||||||
reencode=project["reencode_enabled"],
|
|
||||||
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
|
|
||||||
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
|
||||||
progress_cb=progress,
|
|
||||||
log_cb=lambda message: job_manager.log(job_id, message),
|
|
||||||
custom_segments=custom_segments,
|
|
||||||
)"""
|
|
||||||
|
|
||||||
content = content.replace(old_split_all_loop, new_split_all_loop)
|
|
||||||
|
|
||||||
with open("backend/app/main.py", "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("main.py export logic patched")
|
|
||||||
-102
@@ -1,102 +0,0 @@
|
|||||||
with open("backend/app/mkv.py", "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
old_sig = """def build_episodes(
|
|
||||||
video_path: str,
|
|
||||||
total_duration: float,
|
|
||||||
intro_seconds: float,
|
|
||||||
outro_seconds: float,
|
|
||||||
cut_points: list[float],
|
|
||||||
output_dir: Path,
|
|
||||||
temp_dir: Path,
|
|
||||||
project_dir: Path,
|
|
||||||
output_prefix: str = "episode",
|
|
||||||
reencode: bool = False,
|
|
||||||
ffmpeg_pass1_template: str | None = None,
|
|
||||||
ffmpeg_pass2_template: str | None = None,
|
|
||||||
progress_cb: ProgressCallback | None = None,
|
|
||||||
log_cb: LogCallback | None = None,
|
|
||||||
) -> list[str]:"""
|
|
||||||
|
|
||||||
new_sig = """def build_episodes(
|
|
||||||
video_path: str,
|
|
||||||
total_duration: float,
|
|
||||||
intro_seconds: float,
|
|
||||||
outro_seconds: float,
|
|
||||||
cut_points: list[float],
|
|
||||||
output_dir: Path,
|
|
||||||
temp_dir: Path,
|
|
||||||
project_dir: Path,
|
|
||||||
output_prefix: str = "episode",
|
|
||||||
reencode: bool = False,
|
|
||||||
ffmpeg_pass1_template: str | None = None,
|
|
||||||
ffmpeg_pass2_template: str | None = None,
|
|
||||||
progress_cb: ProgressCallback | None = None,
|
|
||||||
log_cb: LogCallback | None = None,
|
|
||||||
custom_segments: list[tuple[float, float]] | None = None,
|
|
||||||
) -> list[str]:"""
|
|
||||||
|
|
||||||
content = content.replace(old_sig, new_sig)
|
|
||||||
|
|
||||||
old_logic = """ core_end = max(intro_seconds, total_duration - outro_seconds)
|
|
||||||
boundaries = [p for p in sorted(cut_points) if intro_seconds < p < core_end]
|
|
||||||
boundaries.append(core_end)
|
|
||||||
|
|
||||||
outputs: list[str] = []
|
|
||||||
prev = intro_seconds
|
|
||||||
safe_boundaries: list[float] = []
|
|
||||||
|
|
||||||
for end in boundaries:
|
|
||||||
if end - prev <= min_segment:
|
|
||||||
continue
|
|
||||||
safe_boundaries.append(end)
|
|
||||||
prev = end
|
|
||||||
|
|
||||||
if not safe_boundaries:
|
|
||||||
raise RuntimeError("No valid segments after filtering short ranges")
|
|
||||||
|
|
||||||
prev = intro_seconds
|
|
||||||
total_segments = len(safe_boundaries)
|
|
||||||
core_ranges: list[tuple[int, float, float]] = []
|
|
||||||
for index, end in enumerate(safe_boundaries, start=1):
|
|
||||||
core_ranges.append((index, prev, end))
|
|
||||||
prev = end"""
|
|
||||||
|
|
||||||
new_logic = """ outputs: list[str] = []
|
|
||||||
core_ranges: list[tuple[int, float, float]] = []
|
|
||||||
min_segment = 0.001
|
|
||||||
|
|
||||||
if custom_segments and len(custom_segments) > 0:
|
|
||||||
total_segments = len(custom_segments)
|
|
||||||
for index, (start, end) in enumerate(custom_segments, start=1):
|
|
||||||
core_ranges.append((index, start, end))
|
|
||||||
else:
|
|
||||||
core_end = max(intro_seconds, total_duration - outro_seconds)
|
|
||||||
boundaries = [p for p in sorted(cut_points) if intro_seconds < p < core_end]
|
|
||||||
boundaries.append(core_end)
|
|
||||||
|
|
||||||
prev = intro_seconds
|
|
||||||
safe_boundaries: list[float] = []
|
|
||||||
|
|
||||||
for end in boundaries:
|
|
||||||
if end - prev <= min_segment:
|
|
||||||
continue
|
|
||||||
safe_boundaries.append(end)
|
|
||||||
prev = end
|
|
||||||
|
|
||||||
if not safe_boundaries:
|
|
||||||
raise RuntimeError("No valid segments after filtering short ranges")
|
|
||||||
|
|
||||||
prev = intro_seconds
|
|
||||||
total_segments = len(safe_boundaries)
|
|
||||||
for index, end in enumerate(safe_boundaries, start=1):
|
|
||||||
core_ranges.append((index, prev, end))
|
|
||||||
prev = end"""
|
|
||||||
|
|
||||||
content = content.replace(old_logic, new_logic)
|
|
||||||
|
|
||||||
with open("backend/app/mkv.py", "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("mkv.py patched")
|
|
||||||
-114
@@ -1,114 +0,0 @@
|
|||||||
with open("frontend/src/App.tsx", "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Add ContentPaste to imports
|
|
||||||
content = content.replace("CallSplit,", "CallSplit,\n ContentPaste,")
|
|
||||||
content = content.replace("import { IconButton, Tooltip } from \"@mui/material\";", "import { IconButton, Tooltip, InputAdornment } from \"@mui/material\";")
|
|
||||||
if "InputAdornment" not in content:
|
|
||||||
content = content.replace("TextField,", "TextField,\n InputAdornment,")
|
|
||||||
|
|
||||||
old_start_input = """ <TextField
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
value={segmentDrafts[rowIndex]?.start ?? formatTime(row.start)}
|
|
||||||
onChange={(e) => handleSegmentDraftChange(rowIndex, "start", e.target.value)}
|
|
||||||
onBlur={() => commitSegmentDraft(rowIndex, "start")}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
commitSegmentDraft(rowIndex, "start");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!row.startEditable}
|
|
||||||
inputProps={{ style: { fontFamily: "monospace" } }}
|
|
||||||
/>"""
|
|
||||||
|
|
||||||
new_start_input = """ <TextField
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
value={segmentDrafts[rowIndex]?.start ?? formatTime(row.start)}
|
|
||||||
onChange={(e) => handleSegmentDraftChange(rowIndex, "start", e.target.value)}
|
|
||||||
onBlur={() => commitSegmentDraft(rowIndex, "start")}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
commitSegmentDraft(rowIndex, "start");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!row.startEditable}
|
|
||||||
inputProps={{ style: { fontFamily: "monospace" } }}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: row.startEditable ? (
|
|
||||||
<InputAdornment position="end" sx={{ opacity: 0, transition: "opacity 0.2s", ".MuiInputBase-root:hover &": { opacity: 1 } }}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onMouseDown={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
const text = await navigator.clipboard.readText();
|
|
||||||
handleSegmentDraftChange(rowIndex, "start", text);
|
|
||||||
setTimeout(() => commitSegmentDraft(rowIndex, "start"), 50);
|
|
||||||
} catch (err) {}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ContentPaste fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
) : null
|
|
||||||
}}
|
|
||||||
/>"""
|
|
||||||
|
|
||||||
content = content.replace(old_start_input, new_start_input)
|
|
||||||
|
|
||||||
old_end_input = """ <TextField
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
value={segmentDrafts[rowIndex]?.end ?? formatTime(row.end)}
|
|
||||||
onChange={(e) => handleSegmentDraftChange(rowIndex, "end", e.target.value)}
|
|
||||||
onBlur={() => commitSegmentDraft(rowIndex, "end")}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
commitSegmentDraft(rowIndex, "end");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!row.endEditable}
|
|
||||||
inputProps={{ style: { fontFamily: "monospace" } }}
|
|
||||||
/>"""
|
|
||||||
|
|
||||||
new_end_input = """ <TextField
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
value={segmentDrafts[rowIndex]?.end ?? formatTime(row.end)}
|
|
||||||
onChange={(e) => handleSegmentDraftChange(rowIndex, "end", e.target.value)}
|
|
||||||
onBlur={() => commitSegmentDraft(rowIndex, "end")}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
commitSegmentDraft(rowIndex, "end");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!row.endEditable}
|
|
||||||
inputProps={{ style: { fontFamily: "monospace" } }}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: row.endEditable ? (
|
|
||||||
<InputAdornment position="end" sx={{ opacity: 0, transition: "opacity 0.2s", ".MuiInputBase-root:hover &": { opacity: 1 } }}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onMouseDown={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
const text = await navigator.clipboard.readText();
|
|
||||||
handleSegmentDraftChange(rowIndex, "end", text);
|
|
||||||
setTimeout(() => commitSegmentDraft(rowIndex, "end"), 50);
|
|
||||||
} catch (err) {}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ContentPaste fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
) : null
|
|
||||||
}}
|
|
||||||
/>"""
|
|
||||||
|
|
||||||
content = content.replace(old_end_input, new_end_input)
|
|
||||||
|
|
||||||
with open("frontend/src/App.tsx", "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
print("App.tsx paste buttons patched")
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
with open("backend/app/mkv.py", "r", encoding="utf-8") as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
print("".join(lines[350:400]))
|
|
||||||
Reference in New Issue
Block a user