mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
cuekit and cuenorm: three subtitle formats, one cue stream
This commit is contained in:
@@ -26,18 +26,12 @@ pub struct Chunk {
|
||||
/// What part of the work the passage belongs to.
|
||||
pub role: Role,
|
||||
/// Seconds from the start of the recording, for timed sources.
|
||||
#[serde(
|
||||
default,
|
||||
serialize_with = "crate::float::serialize_option",
|
||||
deserialize_with = "crate::float::deserialize_option"
|
||||
)]
|
||||
#[serde(default, with = "crate::finite::option")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub start_time: Option<f64>,
|
||||
/// Seconds from the start of the recording, for timed sources.
|
||||
#[serde(
|
||||
default,
|
||||
serialize_with = "crate::float::serialize_option",
|
||||
deserialize_with = "crate::float::deserialize_option"
|
||||
)]
|
||||
#[serde(default, with = "crate::finite::option")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub end_time: Option<f64>,
|
||||
/// Set tags; see [`flags`]. Sorted and deduplicated on the way in and on
|
||||
/// the way out, so the same set of flags is always the same bytes
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Serde plumbing for floats that JSON can actually carry.
|
||||
//!
|
||||
//! Use it as `#[serde(with = "corpus_core::finite")]` on an `f64`, or
|
||||
//! `#[serde(default, with = "corpus_core::finite::option")]` on an
|
||||
//! `Option<f64>`.
|
||||
//!
|
||||
//! JSON has no spelling for NaN or infinity, and `serde_json` writes them as
|
||||
//! `null`. That is a silent corruption in both directions: a score written as
|
||||
//! `null` fails to read back and kills the stream at the consumer, and a
|
||||
//! timestamp written as `null` reads back as "no timestamp" with nothing to
|
||||
//! say it was ever anything else. So a non-finite number is refused where it
|
||||
//! is produced, which is the only place anyone can still fix it.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serializer, de::Error as _, ser::Error as _};
|
||||
|
||||
/// Write a float, refusing one JSON cannot carry.
|
||||
pub fn serialize<S: Serializer>(value: &f64, out: S) -> Result<S::Ok, S::Error> {
|
||||
if !value.is_finite() {
|
||||
return Err(S::Error::custom(format!(
|
||||
"{value} cannot be written as JSON"
|
||||
)));
|
||||
}
|
||||
out.serialize_f64(*value)
|
||||
}
|
||||
|
||||
/// Read a float, refusing one that is not finite.
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<f64, D::Error> {
|
||||
let value = f64::deserialize(input)?;
|
||||
if !value.is_finite() {
|
||||
return Err(D::Error::custom(format!("{value} is not a finite number")));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// The same, for a field that may be absent.
|
||||
pub mod option {
|
||||
use serde::{Deserialize, Deserializer, Serializer, de::Error as _};
|
||||
|
||||
/// Write an optional float, refusing one JSON cannot carry.
|
||||
pub fn serialize<S: Serializer>(value: &Option<f64>, out: S) -> Result<S::Ok, S::Error> {
|
||||
match value {
|
||||
Some(value) => super::serialize(value, out),
|
||||
None => out.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an optional float, refusing one that is not finite.
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<Option<f64>, D::Error> {
|
||||
let value = Option::<f64>::deserialize(input)?;
|
||||
match value {
|
||||
Some(value) if !value.is_finite() => {
|
||||
Err(D::Error::custom(format!("{value} is not a finite number")))
|
||||
}
|
||||
value => Ok(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//! Serde plumbing for floats that JSON can actually carry.
|
||||
//!
|
||||
//! JSON has no spelling for NaN or infinity, and `serde_json` writes them as
|
||||
//! `null`. That is a silent corruption in both directions: a score written as
|
||||
//! `null` fails to read back and kills the stream at the consumer, and a
|
||||
//! timestamp written as `null` reads back as "no timestamp" with nothing to
|
||||
//! say it was ever anything else. So a non-finite number is refused where it
|
||||
//! is produced, which is the only place anyone can still fix it.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serializer, de::Error as _, ser::Error as _};
|
||||
|
||||
pub(crate) fn serialize<S: Serializer>(value: &f64, out: S) -> Result<S::Ok, S::Error> {
|
||||
if !value.is_finite() {
|
||||
return Err(S::Error::custom(format!(
|
||||
"{value} cannot be written as JSON"
|
||||
)));
|
||||
}
|
||||
out.serialize_f64(*value)
|
||||
}
|
||||
|
||||
pub(crate) fn serialize_option<S: Serializer>(
|
||||
value: &Option<f64>,
|
||||
out: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
match value {
|
||||
Some(value) => serialize(value, out),
|
||||
None => out.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<f64, D::Error> {
|
||||
let value = f64::deserialize(input)?;
|
||||
if !value.is_finite() {
|
||||
return Err(D::Error::custom(format!("{value} is not a finite number")));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(crate) fn deserialize_option<'de, D: Deserializer<'de>>(
|
||||
input: D,
|
||||
) -> Result<Option<f64>, D::Error> {
|
||||
let value = Option::<f64>::deserialize(input)?;
|
||||
match value {
|
||||
Some(value) if !value.is_finite() => {
|
||||
Err(D::Error::custom(format!("{value} is not a finite number")))
|
||||
}
|
||||
value => Ok(value),
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,8 @@ pub struct Hit {
|
||||
/// Position within the channel's own ranking, from one.
|
||||
pub rank: u32,
|
||||
/// The channel's raw score, on whatever scale that channel uses.
|
||||
#[serde(
|
||||
serialize_with = "crate::float::serialize",
|
||||
deserialize_with = "crate::float::deserialize"
|
||||
)]
|
||||
#[serde(with = "crate::finite")]
|
||||
#[schemars(with = "f64")]
|
||||
pub score: f64,
|
||||
/// Anything the producer wants to carry through fusion untouched.
|
||||
///
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
pub mod chunk;
|
||||
pub mod doc;
|
||||
pub mod exit;
|
||||
mod float;
|
||||
pub mod finite;
|
||||
pub mod hit;
|
||||
pub mod ndjson;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "cuekit"
|
||||
description = "The cue model everything timed shares, and the subtitle formats it is read from."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
corpus-core.workspace = true
|
||||
regex.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
textkit.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Advanced SubStation Alpha, which is a spreadsheet pretending to be a
|
||||
//! subtitle file.
|
||||
//!
|
||||
//! Events are rows under a `Format:` line that names the columns, so the
|
||||
//! column order has to be read rather than assumed — a file written by one
|
||||
//! tool puts the text last after nine fields, and one written by another puts
|
||||
//! it last after eight.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::cue::Cue;
|
||||
use crate::text;
|
||||
|
||||
/// An override block: `{\an8}`, `{\pos(120,400)}`, `{\i1}`.
|
||||
static OVERRIDE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{[^}]*\}").expect("valid"));
|
||||
|
||||
/// A drawing command block, which is a shape rather than speech.
|
||||
static DRAWING: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)\{[^}]*\\p[1-9]").expect("valid"));
|
||||
|
||||
/// Parse an ASS or SSA file.
|
||||
pub fn parse(content: &str) -> Vec<Cue> {
|
||||
let mut columns: Option<Vec<String>> = None;
|
||||
let mut in_events = false;
|
||||
let mut cues = Vec::new();
|
||||
|
||||
for line in content.split('\n') {
|
||||
let line = line.trim_matches(textkit::is_space);
|
||||
if line.starts_with('[') {
|
||||
in_events = line.eq_ignore_ascii_case("[events]");
|
||||
continue;
|
||||
}
|
||||
if !in_events {
|
||||
continue;
|
||||
}
|
||||
if let Some(format) = line.strip_prefix("Format:") {
|
||||
columns = Some(
|
||||
format
|
||||
.split(',')
|
||||
.map(|name| name.trim_matches(textkit::is_space).to_lowercase())
|
||||
.collect(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let Some(event) = line.strip_prefix("Dialogue:") else {
|
||||
continue;
|
||||
};
|
||||
let Some(columns) = columns.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(cue) = dialogue(event, columns) {
|
||||
cues.push(cue);
|
||||
}
|
||||
}
|
||||
cues
|
||||
}
|
||||
|
||||
fn dialogue(event: &str, columns: &[String]) -> Option<Cue> {
|
||||
let start_at = columns.iter().position(|name| name == "start")?;
|
||||
let end_at = columns.iter().position(|name| name == "end")?;
|
||||
let text_at = columns.iter().position(|name| name == "text")?;
|
||||
|
||||
// The text field is last and may itself contain commas, so the row is
|
||||
// split only as many times as there are columns before it.
|
||||
let mut fields: Vec<&str> = event.splitn(columns.len(), ',').collect();
|
||||
if fields.len() < columns.len() {
|
||||
return None;
|
||||
}
|
||||
let text = if text_at == columns.len() - 1 {
|
||||
fields.pop().unwrap_or("")
|
||||
} else {
|
||||
fields.get(text_at).copied().unwrap_or("")
|
||||
};
|
||||
|
||||
let start = timestamp(fields.get(start_at)?.trim_matches(textkit::is_space));
|
||||
let end = timestamp(fields.get(end_at)?.trim_matches(textkit::is_space));
|
||||
if !start.is_finite() || !end.is_finite() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// A drawing command is a shape, not speech, and reads as a string of
|
||||
// coordinates if it is kept.
|
||||
if DRAWING.is_match(text) {
|
||||
return None;
|
||||
}
|
||||
let text = OVERRIDE.replace_all(text, "");
|
||||
let text = text
|
||||
.replace("\\N", " ")
|
||||
.replace("\\n", " ")
|
||||
.replace("\\h", " ");
|
||||
let text = text::clean(&text);
|
||||
(!text.is_empty()).then(|| Cue::new(start, end, text))
|
||||
}
|
||||
|
||||
/// Seconds from an `H:MM:SS.cc` timestamp.
|
||||
pub fn timestamp(text: &str) -> f64 {
|
||||
let parts: Vec<&str> = text.split(':').collect();
|
||||
match parts.as_slice() {
|
||||
[hours, minutes, seconds] => {
|
||||
crate::jsnum::number(hours) * 3600.0
|
||||
+ crate::jsnum::number(minutes) * 60.0
|
||||
+ crate::jsnum::parse_float(seconds)
|
||||
}
|
||||
[minutes, seconds] => {
|
||||
crate::jsnum::number(minutes) * 60.0 + crate::jsnum::parse_float(seconds)
|
||||
}
|
||||
_ => f64::NAN,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FILE: &str = "[Script Info]\n\
|
||||
Title: A Recording\n\
|
||||
\n\
|
||||
[V4+ Styles]\n\
|
||||
Format: Name, Fontname\n\
|
||||
Style: Default,Arial\n\
|
||||
\n\
|
||||
[Events]\n\
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
|
||||
Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,The mill stood at the northern end.\n\
|
||||
Dialogue: 0,0:00:03.00,0:00:05.50,Default,,0,0,0,,{\\an8}Its wheel was undershot,\\Nwhich was wasteful.\n";
|
||||
|
||||
#[test]
|
||||
fn events_become_cues() {
|
||||
let cues = parse(FILE);
|
||||
assert_eq!(cues.len(), 2);
|
||||
assert_eq!(cues[0].start, 1.0);
|
||||
assert_eq!(cues[0].end, 3.0);
|
||||
assert_eq!(cues[0].text, "The mill stood at the northern end.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_blocks_and_line_breaks_come_out() {
|
||||
let cues = parse(FILE);
|
||||
assert_eq!(cues[1].text, "Its wheel was undershot, which was wasteful.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comma_in_the_text_is_not_a_column_break() {
|
||||
let cues = parse(FILE);
|
||||
assert!(cues[1].text.contains(','), "{:?}", cues[1].text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawing_command_is_not_speech() {
|
||||
let file = FILE.to_string()
|
||||
+ "Dialogue: 0,0:00:06.00,0:00:07.00,Default,,0,0,0,,{\\p1}m 0 0 l 100 0 100 100\n";
|
||||
assert_eq!(parse(&file).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_outside_the_events_section_is_ignored() {
|
||||
let file = "[Script Info]\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,no\n";
|
||||
assert!(parse(file).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_order_of_its_own_is_honoured() {
|
||||
let file = "[Events]\n\
|
||||
Format: Start, End, Text\n\
|
||||
Dialogue: 0:00:01.00,0:00:02.00,Reordered, with a comma\n";
|
||||
let cues = parse(file);
|
||||
assert_eq!(cues.len(), 1);
|
||||
assert_eq!(cues[0].text, "Reordered, with a comma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timestamp_is_read_in_seconds() {
|
||||
assert_eq!(timestamp("0:00:01.50"), 1.5);
|
||||
assert_eq!(timestamp("1:02:03.04"), 3723.04);
|
||||
assert!(timestamp("rubbish").is_nan());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! One line of a transcript, and when it was said.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A subtitle cue: a span of time and what was said in it.
|
||||
///
|
||||
/// Deliberately smaller than a chunk. A cue is what a subtitle file holds —
|
||||
/// a few seconds, often half a sentence — and turning cues into passages
|
||||
/// worth retrieving is a later stage's job.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Cue {
|
||||
/// Record version.
|
||||
#[serde(default = "version")]
|
||||
pub v: u32,
|
||||
/// Seconds from the start of the recording.
|
||||
#[serde(with = "corpus_core::finite")]
|
||||
#[schemars(with = "f64")]
|
||||
pub start: f64,
|
||||
/// Seconds from the start of the recording.
|
||||
#[serde(with = "corpus_core::finite")]
|
||||
#[schemars(with = "f64")]
|
||||
pub end: f64,
|
||||
/// What was said, with the markup taken out.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
fn version() -> u32 {
|
||||
corpus_core::V
|
||||
}
|
||||
|
||||
impl Cue {
|
||||
/// A cue.
|
||||
pub fn new(start: f64, end: f64, text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
v: corpus_core::V,
|
||||
start,
|
||||
end,
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the cue lasts. Negative if the file says it ends before it
|
||||
/// starts, which some do.
|
||||
pub fn seconds(&self) -> f64 {
|
||||
self.end - self.start
|
||||
}
|
||||
}
|
||||
|
||||
impl corpus_core::Record for Cue {
|
||||
fn version(&self) -> u32 {
|
||||
self.v
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_field_is_emitted() {
|
||||
let cue = Cue::new(12.5, 15.0, "the mill stood at the northern end");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&cue).unwrap(),
|
||||
r#"{"v":1,"start":12.5,"end":15.0,"text":"the mill stood at the northern end"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cue_reads_back_identical() {
|
||||
let cue = Cue::new(0.0, 1.0 / 3.0, "x");
|
||||
let text = serde_json::to_string(&cue).unwrap();
|
||||
let back: Cue = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.end.to_bits(), cue.end.to_bits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_that_json_cannot_carry_is_refused() {
|
||||
let cue = Cue::new(f64::NAN, 1.0, "x");
|
||||
assert!(serde_json::to_string(&cue).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Collapsing the repetition automatic captioning produces.
|
||||
|
||||
use crate::cue::Cue;
|
||||
|
||||
/// Merge consecutive cues whose text is identical, keeping the earliest start
|
||||
/// and the latest end.
|
||||
///
|
||||
/// Automatic captioning emits a rolling window: the same line appears in cue
|
||||
/// after cue as the window slides, so a ten-minute stretch of speech can
|
||||
/// arrive as three times as many cues as there are sentences. Indexed as they
|
||||
/// come, every one of those is a separate hit for the same words at almost
|
||||
/// the same timestamp, which buries everything else in a result list.
|
||||
pub fn merge_repeats(cues: &[Cue]) -> Vec<Cue> {
|
||||
let mut merged: Vec<Cue> = Vec::with_capacity(cues.len());
|
||||
for cue in cues {
|
||||
match merged.last_mut() {
|
||||
Some(previous) if previous.text == cue.text => {
|
||||
previous.end = previous.end.max(cue.end);
|
||||
}
|
||||
_ => merged.push(cue.clone()),
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_repeated_line_becomes_one_cue_spanning_both() {
|
||||
let cues = [
|
||||
Cue::new(0.0, 2.0, "the mill stood"),
|
||||
Cue::new(1.0, 3.0, "the mill stood"),
|
||||
Cue::new(3.0, 4.0, "at the northern end"),
|
||||
];
|
||||
let merged = merge_repeats(&cues);
|
||||
assert_eq!(merged.len(), 2);
|
||||
assert_eq!(merged[0].start, 0.0);
|
||||
assert_eq!(merged[0].end, 3.0);
|
||||
assert_eq!(merged[1].text, "at the northern end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_comes_back_later_is_kept_separately() {
|
||||
let cues = [
|
||||
Cue::new(0.0, 1.0, "yes"),
|
||||
Cue::new(1.0, 2.0, "no"),
|
||||
Cue::new(2.0, 3.0, "yes"),
|
||||
];
|
||||
assert_eq!(merge_repeats(&cues).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_end_that_goes_backwards_does_not_shorten_the_merged_cue() {
|
||||
let cues = [Cue::new(0.0, 9.0, "x"), Cue::new(1.0, 2.0, "x")];
|
||||
let merged = merge_repeats(&cues);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].end, 9.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_in_nothing_out() {
|
||||
assert!(merge_repeats(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Reading numbers the way the JavaScript this was ported from reads them.
|
||||
//!
|
||||
//! The timestamp parser it came from calls `Number()` on the hours and the
|
||||
//! minutes and `parseFloat()` on the seconds. The two disagree about almost
|
||||
//! everything an ill-formed timestamp can contain — `Number("")` is zero
|
||||
//! where `parseFloat("")` is not a number, `Number("1a")` is not a number
|
||||
//! where `parseFloat("1a")` is one — and a subtitle file that has been cut,
|
||||
//! concatenated or hand-edited produces exactly those. Reproducing both is
|
||||
//! what makes a malformed file drop the same cues here as there.
|
||||
|
||||
/// What JavaScript's `Number(text)` gives.
|
||||
///
|
||||
/// Whitespace is trimmed, an empty string is zero, the radix prefixes are
|
||||
/// honoured, and anything else that is not a complete numeric literal is not
|
||||
/// a number. Note that `Number` accepts `Infinity` but not `NaN`, and neither
|
||||
/// of the spellings Rust's own parser accepts for them.
|
||||
pub fn number(text: &str) -> f64 {
|
||||
let text = text.trim_matches(is_js_space);
|
||||
if text.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
if let Some(value) = radix_literal(text) {
|
||||
return value;
|
||||
}
|
||||
let body = text.strip_prefix(['+', '-']).unwrap_or(text);
|
||||
// Rust's parser accepts spellings JavaScript's does not.
|
||||
if body.eq_ignore_ascii_case("inf")
|
||||
|| body.eq_ignore_ascii_case("nan")
|
||||
|| body.eq_ignore_ascii_case("infinity")
|
||||
{
|
||||
return if body.eq_ignore_ascii_case("infinity") {
|
||||
match text.starts_with('-') {
|
||||
true => f64::NEG_INFINITY,
|
||||
false => f64::INFINITY,
|
||||
}
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
}
|
||||
text.parse().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// What JavaScript's `parseFloat(text)` gives: the longest decimal number at
|
||||
/// the front, ignoring whatever follows, and not a number when there is none.
|
||||
pub fn parse_float(text: &str) -> f64 {
|
||||
let text = text.trim_start_matches(is_js_space);
|
||||
let prefix = decimal_prefix(text);
|
||||
if prefix.is_empty() {
|
||||
return f64::NAN;
|
||||
}
|
||||
prefix.parse().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// The longest leading run that is a decimal number, by JavaScript's grammar.
|
||||
fn decimal_prefix(text: &str) -> &str {
|
||||
let bytes = text.as_bytes();
|
||||
let mut at = 0;
|
||||
if matches!(bytes.first(), Some(b'+' | b'-')) {
|
||||
at = 1;
|
||||
}
|
||||
if text[at..].starts_with("Infinity") {
|
||||
return &text[..at + "Infinity".len()];
|
||||
}
|
||||
|
||||
let mut end = at;
|
||||
let digits = |from: usize| {
|
||||
let mut to = from;
|
||||
while to < bytes.len() && bytes[to].is_ascii_digit() {
|
||||
to += 1;
|
||||
}
|
||||
to
|
||||
};
|
||||
end = digits(end);
|
||||
if end < bytes.len() && bytes[end] == b'.' {
|
||||
end = digits(end + 1);
|
||||
}
|
||||
// A run with no digits at all on either side of the point is not a
|
||||
// number: `.` and `+.` are nothing, `.5` and `5.` are numbers.
|
||||
if !text[at..end].chars().any(|c| c.is_ascii_digit()) {
|
||||
return "";
|
||||
}
|
||||
if end < bytes.len() && (bytes[end] | 0x20) == b'e' {
|
||||
let mut exponent = end + 1;
|
||||
if matches!(bytes.get(exponent), Some(b'+' | b'-')) {
|
||||
exponent += 1;
|
||||
}
|
||||
let after = digits(exponent);
|
||||
if after > exponent {
|
||||
end = after;
|
||||
}
|
||||
}
|
||||
&text[..end]
|
||||
}
|
||||
|
||||
fn radix_literal(text: &str) -> Option<f64> {
|
||||
let (radix, digits) = match text.get(..2)?.to_ascii_lowercase().as_str() {
|
||||
"0x" => (16, &text[2..]),
|
||||
"0o" => (8, &text[2..]),
|
||||
"0b" => (2, &text[2..]),
|
||||
_ => return None,
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return Some(f64::NAN);
|
||||
}
|
||||
Some(match u64::from_str_radix(digits, radix) {
|
||||
Ok(value) => value as f64,
|
||||
Err(_) => f64::NAN,
|
||||
})
|
||||
}
|
||||
|
||||
/// JavaScript's whitespace for the purpose of trimming a numeric string:
|
||||
/// Unicode whitespace plus the byte-order mark.
|
||||
fn is_js_space(c: char) -> bool {
|
||||
c.is_whitespace() || c == '\u{feff}'
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn number_reads_ordinary_numbers() {
|
||||
assert_eq!(number("12"), 12.0);
|
||||
assert_eq!(number(" 07 "), 7.0);
|
||||
assert_eq!(number("1.5"), 1.5);
|
||||
assert_eq!(number("1e2"), 100.0);
|
||||
assert_eq!(number("-3"), -3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_of_nothing_is_zero() {
|
||||
assert_eq!(number(""), 0.0);
|
||||
assert_eq!(number(" "), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_refuses_a_partial_literal() {
|
||||
assert!(number("1a").is_nan());
|
||||
assert!(number("12:").is_nan());
|
||||
assert!(number("--1").is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_does_not_accept_the_rust_spellings() {
|
||||
assert!(number("inf").is_nan());
|
||||
assert!(number("NaN").is_nan());
|
||||
assert_eq!(number("Infinity"), f64::INFINITY);
|
||||
assert_eq!(number("-Infinity"), f64::NEG_INFINITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_honours_the_radix_prefixes() {
|
||||
assert_eq!(number("0x10"), 16.0);
|
||||
assert_eq!(number("0b101"), 5.0);
|
||||
assert_eq!(number("0o17"), 15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_float_takes_the_longest_prefix() {
|
||||
assert_eq!(parse_float("23.456"), 23.456);
|
||||
assert_eq!(parse_float("23.456abc"), 23.456);
|
||||
assert_eq!(parse_float(" 1.5xyz"), 1.5);
|
||||
assert_eq!(parse_float("5."), 5.0);
|
||||
assert_eq!(parse_float(".5"), 0.5);
|
||||
assert_eq!(parse_float("1e3x"), 1000.0);
|
||||
assert_eq!(parse_float("1e"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_float_of_nothing_is_not_a_number() {
|
||||
assert!(parse_float("").is_nan());
|
||||
assert!(parse_float("abc").is_nan());
|
||||
assert!(parse_float(".").is_nan());
|
||||
assert!(parse_float("+").is_nan());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! The cue model everything timed shares, and the subtitle formats it is
|
||||
//! read from.
|
||||
//!
|
||||
//! Three formats go in — SubRip, WebVTT and SubStation Alpha — and one cue
|
||||
//! stream comes out. Everything downstream of this works on cues and never
|
||||
//! has to know which of the three a recording arrived with.
|
||||
//!
|
||||
//! The SubRip reader is a port, quirks kept, of the parser a podcast archive
|
||||
//! was indexed with; the other two are new, and are written to the same
|
||||
//! shape. Where the port deliberately differs, `parity/KNOWN.md` says so.
|
||||
|
||||
pub mod ass;
|
||||
pub mod cue;
|
||||
pub mod dedup;
|
||||
pub mod jsnum;
|
||||
pub mod srt;
|
||||
pub mod text;
|
||||
|
||||
pub use cue::Cue;
|
||||
pub use dedup::merge_repeats;
|
||||
|
||||
/// Which subtitle format a file is written in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
/// SubRip.
|
||||
SubRip,
|
||||
/// WebVTT, which this reads with the SubRip reader.
|
||||
WebVtt,
|
||||
/// SubStation Alpha, advanced or otherwise.
|
||||
SubStationAlpha,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// What this format is called.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Format::SubRip => "srt",
|
||||
Format::WebVtt => "vtt",
|
||||
Format::SubStationAlpha => "ass",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Work out which format a file is, from its content rather than its name.
|
||||
///
|
||||
/// A subtitle file arrives named for the format someone expected it to be in.
|
||||
/// The content says what it actually is, and the three are easy to tell
|
||||
/// apart: WebVTT announces itself, SubStation Alpha has sections in square
|
||||
/// brackets, and SubRip has neither.
|
||||
pub fn detect(content: &str) -> Format {
|
||||
let head = content.trim_start_matches(|c: char| textkit::is_space(c) || c == '\u{feff}');
|
||||
if head.starts_with("WEBVTT") {
|
||||
return Format::WebVtt;
|
||||
}
|
||||
for line in head.split('\n').take(40) {
|
||||
let line = line.trim_matches(textkit::is_space);
|
||||
if line.starts_with("[Script Info]") || line.eq_ignore_ascii_case("[events]") {
|
||||
return Format::SubStationAlpha;
|
||||
}
|
||||
if line.starts_with("Dialogue:") || line.starts_with("ScriptType:") {
|
||||
return Format::SubStationAlpha;
|
||||
}
|
||||
}
|
||||
Format::SubRip
|
||||
}
|
||||
|
||||
/// Read a subtitle file of any supported format into cues.
|
||||
pub fn parse(content: &str) -> Vec<Cue> {
|
||||
parse_as(content, detect(content))
|
||||
}
|
||||
|
||||
/// Read a subtitle file that is known to be in `format`.
|
||||
pub fn parse_as(content: &str, format: Format) -> Vec<Cue> {
|
||||
match format {
|
||||
Format::SubRip | Format::WebVtt => srt::parse(content),
|
||||
Format::SubStationAlpha => ass::parse(content),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_file_is_recognised_by_what_is_in_it() {
|
||||
assert_eq!(
|
||||
detect("WEBVTT\n\n00:00.000 --> 00:01.000\nx"),
|
||||
Format::WebVtt
|
||||
);
|
||||
assert_eq!(detect("\u{feff}WEBVTT\n"), Format::WebVtt);
|
||||
assert_eq!(detect("[Script Info]\nTitle: x\n"), Format::SubStationAlpha);
|
||||
assert_eq!(
|
||||
detect("[Events]\nFormat: Start, End, Text\n"),
|
||||
Format::SubStationAlpha
|
||||
);
|
||||
assert_eq!(
|
||||
detect("1\n00:00:01,000 --> 00:00:02,000\nx\n"),
|
||||
Format::SubRip
|
||||
);
|
||||
assert_eq!(detect(""), Format::SubRip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_three_formats_give_the_same_cue() {
|
||||
let srt = "1\n00:00:01,000 --> 00:00:03,000\nThe mill stood.\n";
|
||||
let vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nThe mill stood.\n";
|
||||
let ass = "[Events]\nFormat: Start, End, Text\n\
|
||||
Dialogue: 0:00:01.00,0:00:03.00,The mill stood.\n";
|
||||
let expected = vec![Cue::new(1.0, 3.0, "The mill stood.")];
|
||||
assert_eq!(parse(srt), expected);
|
||||
assert_eq!(parse(vtt), expected);
|
||||
assert_eq!(parse(ass), expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! SubRip, and the WebVTT that is close enough to share its reader.
|
||||
//!
|
||||
//! A port of the parser the podcast archive was indexed with, quirks kept:
|
||||
//! the timestamp line is found by looking for an arrow anywhere in the block
|
||||
//! rather than at a fixed position, only the first comma in a timestamp
|
||||
//! becomes a point, and a block whose times do not read as numbers is
|
||||
//! dropped whole rather than guessed at.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::cue::Cue;
|
||||
use crate::jsnum;
|
||||
use crate::text;
|
||||
|
||||
/// Blocks are separated by one or more blank lines.
|
||||
static BLOCKS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\n\n+").expect("a valid pattern"));
|
||||
|
||||
/// Parse a SubRip file.
|
||||
pub fn parse(content: &str) -> Vec<Cue> {
|
||||
let mut cues = Vec::new();
|
||||
for block in BLOCKS.split(content) {
|
||||
let lines: Vec<&str> = block.trim_matches(textkit::is_space).split('\n').collect();
|
||||
let Some(at) = lines.iter().position(|line| line.contains("-->")) else {
|
||||
continue;
|
||||
};
|
||||
let Some((start, end)) = times(lines[at]) else {
|
||||
continue;
|
||||
};
|
||||
let text = text::clean(&lines[at + 1..].join(" "));
|
||||
if !text.is_empty() {
|
||||
cues.push(Cue::new(start, end, text));
|
||||
}
|
||||
}
|
||||
cues
|
||||
}
|
||||
|
||||
/// Read a timestamp line, or `None` if either end is not a number.
|
||||
///
|
||||
/// Each half is reduced to the one token that could be a timestamp, which is
|
||||
/// what lets the same reader take a WebVTT cue line — where the end time is
|
||||
/// followed by positioning settings — and an old SubRip file with screen
|
||||
/// coordinates after it. See `parity/KNOWN.md`: the original reads each half
|
||||
/// whole and drops such a cue entirely.
|
||||
fn times(line: &str) -> Option<(f64, f64)> {
|
||||
let (before, after) = line.split_once("-->")?;
|
||||
let start = timestamp(before.split_whitespace().next_back().unwrap_or(""));
|
||||
let end = timestamp(after.split_whitespace().next().unwrap_or(""));
|
||||
(start.is_finite() && end.is_finite()).then_some((start, end))
|
||||
}
|
||||
|
||||
/// Seconds from a `HH:MM:SS,mmm` or `MM:SS.mmm` timestamp.
|
||||
///
|
||||
/// Only the first comma is replaced, exactly as the original does — a
|
||||
/// timestamp written with a comma for the thousands separator as well as for
|
||||
/// the decimal point therefore parses as far as the second comma and no
|
||||
/// further.
|
||||
pub fn timestamp(text: &str) -> f64 {
|
||||
let text = text.trim_matches(textkit::is_space);
|
||||
let cleaned = text.replacen(',', ".", 1);
|
||||
let parts: Vec<&str> = cleaned.split(':').collect();
|
||||
match parts.len() {
|
||||
3 => {
|
||||
jsnum::number(parts[0]) * 3600.0
|
||||
+ jsnum::number(parts[1]) * 60.0
|
||||
+ jsnum::parse_float(parts[2])
|
||||
}
|
||||
2 => jsnum::number(parts[0]) * 60.0 + jsnum::parse_float(parts[1]),
|
||||
_ => f64::NAN,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_timestamp_is_read_in_seconds() {
|
||||
assert_eq!(timestamp("00:01:23,456"), 83.456);
|
||||
assert_eq!(timestamp("01:00:00,000"), 3600.0);
|
||||
assert_eq!(timestamp("02:03.500"), 123.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timestamp_that_is_not_one_is_not_a_number() {
|
||||
assert!(timestamp("").is_nan());
|
||||
assert!(timestamp("nonsense").is_nan());
|
||||
assert!(timestamp("00:00:00:00").is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_parses_into_cues() {
|
||||
let srt = "1\n00:00:01,000 --> 00:00:03,000\nThe mill stood at the northern end.\n\n\
|
||||
2\n00:00:03,000 --> 00:00:05,500\nIts wheel was undershot,\nwhich was wasteful.\n";
|
||||
let cues = parse(srt);
|
||||
assert_eq!(cues.len(), 2);
|
||||
assert_eq!(cues[0].start, 1.0);
|
||||
assert_eq!(cues[0].end, 3.0);
|
||||
assert_eq!(cues[0].text, "The mill stood at the northern end.");
|
||||
assert_eq!(cues[1].text, "Its wheel was undershot, which was wasteful.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_with_no_arrow_is_skipped() {
|
||||
assert!(parse("WEBVTT\n\nsome preamble\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_with_unreadable_times_is_dropped_whole() {
|
||||
let srt = "1\nnonsense --> also nonsense\ntext\n\n\
|
||||
2\n00:00:03,000 --> 00:00:05,000\nkept\n";
|
||||
let cues = parse(srt);
|
||||
assert_eq!(cues.len(), 1);
|
||||
assert_eq!(cues[0].text, "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_with_no_text_is_dropped() {
|
||||
assert!(parse("1\n00:00:01,000 --> 00:00:03,000\n[Music]\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_blank_lines_still_separate_one_block_from_the_next() {
|
||||
let srt = "1\n00:00:01,000 --> 00:00:02,000\none\n\n\n\n\
|
||||
2\n00:00:02,000 --> 00:00:03,000\ntwo\n";
|
||||
assert_eq!(parse(srt).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webvtt_timing_and_settings_read_too() {
|
||||
let vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:0 position:20%\nThe mill.\n";
|
||||
let cues = parse(vtt);
|
||||
assert_eq!(cues.len(), 1);
|
||||
assert_eq!(cues[0].start, 1.0);
|
||||
assert_eq!(cues[0].end, 3.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Cleaning the markup out of a cue's text.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// Styling and speaker tags: `<i>`, `<v Speaker>`, `<c.colour>`.
|
||||
static TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").expect("a valid pattern"));
|
||||
|
||||
/// Bracketed sound cues: `[Music]`, `[Applause]`, `[inaudible]`.
|
||||
///
|
||||
/// Not speech, and a search that matches them matches every recording with a
|
||||
/// theme tune.
|
||||
static BRACKETED: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\[.*?\]").expect("a valid pattern"));
|
||||
|
||||
/// Strip markup from a cue's text.
|
||||
pub fn clean(text: &str) -> String {
|
||||
let text = TAG.replace_all(text, "");
|
||||
let text = BRACKETED.replace_all(&text, "");
|
||||
text.trim_matches(is_js_space).to_string()
|
||||
}
|
||||
|
||||
/// What JavaScript's `String.trim` removes: Unicode whitespace and the
|
||||
/// byte-order mark.
|
||||
fn is_js_space(c: char) -> bool {
|
||||
c.is_whitespace() || c == '\u{feff}'
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn styling_tags_go() {
|
||||
assert_eq!(clean("<i>The mill</i> stood"), "The mill stood");
|
||||
assert_eq!(clean("<v Speaker>Hello"), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracketed_sound_cues_go() {
|
||||
assert_eq!(clean("[Music] The mill stood [Applause]"), "The mill stood");
|
||||
assert_eq!(clean("[Music]"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bracket_match_is_not_greedy() {
|
||||
assert_eq!(clean("[a] kept [b]"), "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stray_pair_of_angle_brackets_takes_what_is_between_them() {
|
||||
// Faithful to the original, which uses the same pattern. A spoken
|
||||
// line with a less-than and a greater-than in it is vanishingly rare
|
||||
// next to the styling tags this exists to remove.
|
||||
assert_eq!(clean("a < b and c > d"), "a d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_angle_bracket_is_left_alone() {
|
||||
assert_eq!(clean("a < b"), "a < b");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "cuenorm"
|
||||
description = "Any subtitle format in, one normalised cue stream out."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
corpus-core.workspace = true
|
||||
cuekit.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Any subtitle format in, one normalised cue stream out.
|
||||
|
||||
use std::io::{BufWriter, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use corpus_core::{exit, ndjson};
|
||||
|
||||
/// Read a subtitle file — SubRip, WebVTT or SubStation Alpha — and write one
|
||||
/// cue per line as JSON.
|
||||
///
|
||||
/// The format is worked out from the content, not from the name, because a
|
||||
/// subtitle file is named for the format somebody expected it to be in.
|
||||
/// Markup and bracketed sound cues are taken out, and consecutive cues with
|
||||
/// identical text are merged, which is what automatic captioning needs: it
|
||||
/// repeats each line across overlapping windows, so the same sentence arrives
|
||||
/// three or four times over.
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about,
|
||||
long_about = None,
|
||||
after_help = "\
|
||||
Example:
|
||||
|
||||
cuenorm episode.vtt | cuepack --seconds 60 > passages.ndjson"
|
||||
)]
|
||||
struct Args {
|
||||
/// Subtitle file to read. Reads stdin when absent.
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Keep consecutive repeats instead of merging them.
|
||||
#[arg(long)]
|
||||
keep_repeats: bool,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args = Args::parse();
|
||||
|
||||
let content = match read(args.path.as_deref()) {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
eprintln!("cuenorm: {e}");
|
||||
return ExitCode::from(exit::IO);
|
||||
}
|
||||
};
|
||||
|
||||
let cues = cuekit::parse(&content);
|
||||
let cues = if args.keep_repeats {
|
||||
cues
|
||||
} else {
|
||||
cuekit::merge_repeats(&cues)
|
||||
};
|
||||
|
||||
let mut out = BufWriter::new(std::io::stdout().lock());
|
||||
for cue in &cues {
|
||||
if let Err(e) = ndjson::write_line(&mut out, cue) {
|
||||
// A consumer that stopped reading is not a failure.
|
||||
if e.is_broken_pipe() {
|
||||
return ExitCode::from(exit::OK);
|
||||
}
|
||||
eprintln!("cuenorm: {e}");
|
||||
return ExitCode::from(exit::IO);
|
||||
}
|
||||
}
|
||||
match out.flush() {
|
||||
Ok(()) => ExitCode::from(exit::OK),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::from(exit::OK),
|
||||
Err(e) => {
|
||||
eprintln!("cuenorm: {e}");
|
||||
ExitCode::from(exit::IO)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the file as text, tolerating the encodings a subtitle file arrives in.
|
||||
///
|
||||
/// Not UTF-8 is common: a subtitle file downloaded from anywhere is as likely
|
||||
/// to be Latin-1, and refusing it would make the tool useless for exactly the
|
||||
/// archives that need it. Undecodable bytes become the replacement character
|
||||
/// rather than an error, because a cue with one wrong character still has its
|
||||
/// timing and most of its words.
|
||||
fn read(path: Option<&Path>) -> std::io::Result<String> {
|
||||
let bytes = match path {
|
||||
Some(path) => std::fs::read(path)?,
|
||||
None => {
|
||||
let mut bytes = Vec::new();
|
||||
std::io::stdin().read_to_end(&mut bytes)?;
|
||||
bytes
|
||||
}
|
||||
};
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
Reference in New Issue
Block a user