use derive_more::IsVariant;
use indexmap::IndexMap;
use mediaframe::{
codec::SubtitleCodec,
disposition::TrackDisposition,
lang::Language,
subtitle::{Format, TrackOrigin},
};
use mediatime::Timestamp;
use smol_str::SmolStr;
#[cfg(any(feature = "std", feature = "alloc"))]
use crate::domain::SubtitleIndexStage;
use crate::domain::{
primitives::{ErrorInfo, FileChecksum},
vo::Provenance,
SubtitleIndexStatus, SubtitleKind, Uuid7,
};
#[derive(Debug, Clone, PartialEq)]
pub struct SubtitleTrack<Id = Uuid7> {
id: Id,
subtitle_id: Id,
stream_index: Option<u32>,
container_track_id: Option<u64>,
codec: SubtitleCodec,
format: Format,
origin: TrackOrigin,
language: Language,
title: SmolStr,
disposition: TrackDisposition,
is_primary: bool,
auto_selected: bool,
duration: Option<Timestamp>,
cue_count: u32,
cues: std::vec::Vec<Id>,
provenance: Provenance,
source_checksum: Option<FileChecksum>,
character_encoding: SmolStr,
bom_present: bool,
is_sdh: bool,
is_closed_caption: bool,
is_translation: bool,
kind: SubtitleKind,
coverage_ratio: Option<f32>,
is_empty: bool,
first_cue: Option<Timestamp>,
last_cue: Option<Timestamp>,
metadata: IndexMap<SmolStr, SmolStr>,
index_status: SubtitleIndexStatus,
index_errors: std::vec::Vec<ErrorInfo>,
}
impl SubtitleTrack<Uuid7> {
pub fn try_new(id: Uuid7, subtitle_id: Uuid7) -> Result<Self, SubtitleTrackError> {
if id.is_nil() {
return Err(SubtitleTrackError::NilId);
}
if subtitle_id.is_nil() {
return Err(SubtitleTrackError::NilSubtitleId);
}
Ok(Self {
id,
subtitle_id,
stream_index: None,
container_track_id: None,
codec: SubtitleCodec::Other(SmolStr::default()),
format: Format::default(),
origin: TrackOrigin::default(),
language: Language::default(),
title: SmolStr::default(),
disposition: TrackDisposition::default(),
is_primary: false,
auto_selected: false,
duration: None,
cue_count: 0,
cues: std::vec::Vec::new(),
provenance: Provenance::new(),
source_checksum: None,
character_encoding: SmolStr::default(),
bom_present: false,
is_sdh: false,
is_closed_caption: false,
is_translation: false,
kind: SubtitleKind::default(),
coverage_ratio: None,
is_empty: false,
first_cue: None,
last_cue: None,
metadata: IndexMap::new(),
index_status: SubtitleIndexStatus::new(),
index_errors: std::vec::Vec::new(),
})
}
}
impl<Id> SubtitleTrack<Id> {
#[inline(always)]
pub const fn id_ref(&self) -> &Id {
&self.id
}
#[inline(always)]
pub const fn subtitle_id_ref(&self) -> &Id {
&self.subtitle_id
}
#[inline(always)]
pub const fn stream_index(&self) -> Option<u32> {
self.stream_index
}
#[inline(always)]
pub const fn container_track_id(&self) -> Option<u64> {
self.container_track_id
}
#[inline(always)]
pub const fn codec_ref(&self) -> &SubtitleCodec {
&self.codec
}
#[inline(always)]
pub const fn format_ref(&self) -> &Format {
&self.format
}
#[inline(always)]
pub const fn origin_ref(&self) -> &TrackOrigin {
&self.origin
}
#[inline(always)]
pub const fn language_ref(&self) -> &Language {
&self.language
}
#[inline(always)]
pub fn title(&self) -> &str {
self.title.as_str()
}
#[inline]
pub fn image_based(&self) -> Option<bool> {
match (self.codec.is_image_based(), self.format.is_image_based()) {
(Some(true), _) | (_, Some(true)) => Some(true),
(Some(false), _) | (_, Some(false)) => Some(false),
(None, None) => None,
}
}
#[inline]
pub fn requires_ocr(&self) -> bool {
self.image_based().unwrap_or(true)
}
#[inline]
pub fn is_fully_indexed(&self) -> bool {
self.index_status.is_fully_indexed(self.requires_ocr())
}
#[cfg(any(feature = "std", feature = "alloc"))]
#[cfg_attr(docsrs, doc(cfg(any(feature = "std", feature = "alloc"))))]
#[inline]
pub fn index_stage(&self) -> SubtitleIndexStage {
SubtitleIndexStage::from_status(self.index_status, self.requires_ocr(), &self.index_errors)
}
#[inline(always)]
pub const fn disposition(&self) -> TrackDisposition {
self.disposition
}
#[inline(always)]
pub const fn is_primary(&self) -> bool {
self.is_primary
}
#[inline(always)]
pub const fn auto_selected(&self) -> bool {
self.auto_selected
}
#[inline(always)]
pub const fn duration_ref(&self) -> Option<&Timestamp> {
self.duration.as_ref()
}
#[inline(always)]
pub const fn cue_count(&self) -> u32 {
self.cue_count
}
#[inline(always)]
pub const fn cues_slice(&self) -> &[Id] {
self.cues.as_slice()
}
#[inline(always)]
pub const fn provenance_ref(&self) -> &Provenance {
&self.provenance
}
#[inline(always)]
pub const fn source_checksum_ref(&self) -> Option<&FileChecksum> {
self.source_checksum.as_ref()
}
#[inline(always)]
pub fn character_encoding(&self) -> &str {
self.character_encoding.as_str()
}
#[inline(always)]
pub const fn bom_present(&self) -> bool {
self.bom_present
}
#[inline(always)]
pub const fn is_sdh(&self) -> bool {
self.is_sdh
}
#[inline(always)]
pub const fn is_closed_caption(&self) -> bool {
self.is_closed_caption
}
#[inline(always)]
pub const fn is_translation(&self) -> bool {
self.is_translation
}
#[inline(always)]
pub const fn kind(&self) -> SubtitleKind {
self.kind
}
#[inline(always)]
pub const fn coverage_ratio(&self) -> Option<f32> {
self.coverage_ratio
}
#[inline(always)]
pub const fn is_empty(&self) -> bool {
self.is_empty
}
#[inline(always)]
pub const fn first_cue_ref(&self) -> Option<&Timestamp> {
self.first_cue.as_ref()
}
#[inline(always)]
pub const fn last_cue_ref(&self) -> Option<&Timestamp> {
self.last_cue.as_ref()
}
#[inline(always)]
pub const fn metadata_ref(&self) -> &IndexMap<SmolStr, SmolStr> {
&self.metadata
}
#[inline(always)]
pub const fn index_status(&self) -> SubtitleIndexStatus {
self.index_status
}
#[inline(always)]
pub const fn index_errors_slice(&self) -> &[ErrorInfo] {
self.index_errors.as_slice()
}
#[must_use]
#[inline(always)]
pub const fn with_stream_index(mut self, v: Option<u32>) -> Self {
self.stream_index = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_container_track_id(mut self, v: Option<u64>) -> Self {
self.container_track_id = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_codec(mut self, v: SubtitleCodec) -> Self {
self.codec = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_format(mut self, v: Format) -> Self {
self.format = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_origin(mut self, v: TrackOrigin) -> Self {
self.origin = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_language(mut self, v: Language) -> Self {
self.language = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_title(mut self, v: impl Into<SmolStr>) -> Self {
self.title = v.into();
self
}
#[must_use]
#[inline(always)]
pub const fn with_disposition(mut self, v: TrackDisposition) -> Self {
self.disposition = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_primary(mut self, v: bool) -> Self {
self.is_primary = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_auto_selected(mut self, v: bool) -> Self {
self.auto_selected = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_duration(mut self, v: Option<Timestamp>) -> Self {
self.duration = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_cue_count(mut self, v: u32) -> Self {
self.cue_count = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_cues(mut self, v: impl Into<std::vec::Vec<Id>>) -> Self {
self.cues = v.into();
self
}
#[must_use]
#[inline(always)]
pub fn with_provenance(mut self, v: Provenance) -> Self {
self.provenance = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_source_checksum(mut self, v: Option<FileChecksum>) -> Self {
self.source_checksum = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_character_encoding(mut self, v: impl Into<SmolStr>) -> Self {
self.character_encoding = v.into();
self
}
#[must_use]
#[inline(always)]
pub const fn with_bom_present(mut self, v: bool) -> Self {
self.bom_present = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_sdh(mut self, v: bool) -> Self {
self.is_sdh = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_closed_caption(mut self, v: bool) -> Self {
self.is_closed_caption = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_translation(mut self, v: bool) -> Self {
self.is_translation = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_kind(mut self, v: SubtitleKind) -> Self {
self.kind = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_coverage_ratio(mut self, v: Option<f32>) -> Self {
self.coverage_ratio = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_empty(mut self, v: bool) -> Self {
self.is_empty = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_first_cue(mut self, v: Option<Timestamp>) -> Self {
self.first_cue = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_last_cue(mut self, v: Option<Timestamp>) -> Self {
self.last_cue = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_metadata(mut self, v: IndexMap<SmolStr, SmolStr>) -> Self {
self.metadata = v;
self
}
#[inline(always)]
pub fn set_metadata(&mut self, v: IndexMap<SmolStr, SmolStr>) -> &mut Self {
self.metadata = v;
self
}
#[must_use]
#[inline(always)]
pub const fn with_index_status(mut self, v: SubtitleIndexStatus) -> Self {
self.index_status = v;
self
}
#[must_use]
#[inline(always)]
pub fn with_index_errors(mut self, v: impl Into<std::vec::Vec<ErrorInfo>>) -> Self {
self.index_errors = v.into();
self
}
#[inline(always)]
pub const fn set_stream_index(&mut self, v: Option<u32>) -> &mut Self {
self.stream_index = v;
self
}
#[inline(always)]
pub const fn set_container_track_id(&mut self, v: Option<u64>) -> &mut Self {
self.container_track_id = v;
self
}
#[inline(always)]
pub fn set_codec(&mut self, v: SubtitleCodec) -> &mut Self {
self.codec = v;
self
}
#[inline(always)]
pub fn set_format(&mut self, v: Format) -> &mut Self {
self.format = v;
self
}
#[inline(always)]
pub fn set_origin(&mut self, v: TrackOrigin) -> &mut Self {
self.origin = v;
self
}
#[inline(always)]
pub fn set_language(&mut self, v: Language) -> &mut Self {
self.language = v;
self
}
#[inline(always)]
pub fn set_title(&mut self, v: impl Into<SmolStr>) -> &mut Self {
self.title = v.into();
self
}
#[inline(always)]
pub fn set_disposition(&mut self, v: TrackDisposition) -> &mut Self {
self.disposition = v;
self
}
#[inline(always)]
pub const fn set_primary(&mut self, v: bool) -> &mut Self {
self.is_primary = v;
self
}
#[inline(always)]
pub const fn set_auto_selected(&mut self, v: bool) -> &mut Self {
self.auto_selected = v;
self
}
#[inline(always)]
pub fn set_duration(&mut self, v: Option<Timestamp>) -> &mut Self {
self.duration = v;
self
}
#[inline(always)]
pub const fn set_cue_count(&mut self, v: u32) -> &mut Self {
self.cue_count = v;
self
}
#[inline(always)]
pub fn set_cues(&mut self, v: impl Into<std::vec::Vec<Id>>) -> &mut Self {
self.cues = v.into();
self
}
#[inline(always)]
pub fn set_provenance(&mut self, v: Provenance) -> &mut Self {
self.provenance = v;
self
}
#[inline(always)]
pub fn set_source_checksum(&mut self, v: Option<FileChecksum>) -> &mut Self {
self.source_checksum = v;
self
}
#[inline(always)]
pub fn set_character_encoding(&mut self, v: impl Into<SmolStr>) -> &mut Self {
self.character_encoding = v.into();
self
}
#[inline(always)]
pub const fn set_bom_present(&mut self, v: bool) -> &mut Self {
self.bom_present = v;
self
}
#[inline(always)]
pub const fn set_sdh(&mut self, v: bool) -> &mut Self {
self.is_sdh = v;
self
}
#[inline(always)]
pub const fn set_closed_caption(&mut self, v: bool) -> &mut Self {
self.is_closed_caption = v;
self
}
#[inline(always)]
pub const fn set_translation(&mut self, v: bool) -> &mut Self {
self.is_translation = v;
self
}
#[inline(always)]
pub const fn set_kind(&mut self, v: SubtitleKind) -> &mut Self {
self.kind = v;
self
}
#[inline(always)]
pub const fn set_coverage_ratio(&mut self, v: Option<f32>) -> &mut Self {
self.coverage_ratio = v;
self
}
#[inline(always)]
pub const fn set_empty(&mut self, v: bool) -> &mut Self {
self.is_empty = v;
self
}
#[inline(always)]
pub fn set_first_cue(&mut self, v: Option<Timestamp>) -> &mut Self {
self.first_cue = v;
self
}
#[inline(always)]
pub fn set_last_cue(&mut self, v: Option<Timestamp>) -> &mut Self {
self.last_cue = v;
self
}
#[inline(always)]
pub const fn set_index_status(&mut self, v: SubtitleIndexStatus) -> &mut Self {
self.index_status = v;
self
}
#[inline(always)]
pub fn set_index_errors(&mut self, v: impl Into<std::vec::Vec<ErrorInfo>>) -> &mut Self {
self.index_errors = v.into();
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, IsVariant, thiserror::Error)]
#[non_exhaustive]
pub enum SubtitleTrackError {
#[error("SubtitleTrack id must not be the nil UUID")]
NilId,
#[error("SubtitleTrack `subtitle_id` (FK → Subtitle) must not be the nil UUID")]
NilSubtitleId,
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
use crate::domain::ErrorCode;
#[test]
fn try_new_happy_path() {
let subtitle_id = Uuid7::new();
let t =
SubtitleTrack::try_new(Uuid7::new(), subtitle_id).expect("valid construction must succeed");
assert_eq!(t.subtitle_id_ref(), &subtitle_id);
assert_eq!(t.codec_ref(), &SubtitleCodec::Other(SmolStr::default()));
assert_eq!(t.format_ref(), &Format::default());
assert_eq!(t.origin_ref(), &TrackOrigin::default());
assert_eq!(t.language_ref(), &Language::default());
assert!(t.title().is_empty());
assert_eq!(t.image_based(), None);
assert!(t.requires_ocr());
assert!(!t.is_fully_indexed());
assert!(t.index_stage().is_pending());
assert_eq!(t.disposition(), TrackDisposition::default());
assert!(!t.is_primary());
assert!(!t.auto_selected());
assert!(t.duration_ref().is_none());
assert_eq!(t.cue_count(), 0);
assert!(t.cues_slice().is_empty());
assert_eq!(t.provenance_ref(), &Provenance::new());
assert!(t.source_checksum_ref().is_none());
assert!(t.character_encoding().is_empty());
assert_eq!(t.index_status(), SubtitleIndexStatus::new());
assert!(t.index_errors_slice().is_empty());
}
#[test]
fn try_new_rejects_nil_id() {
let r = SubtitleTrack::try_new(Uuid7::nil(), Uuid7::new());
assert_eq!(r.err(), Some(SubtitleTrackError::NilId));
assert!(SubtitleTrackError::NilId.is_nil_id());
}
#[test]
fn try_new_rejects_nil_subtitle_id() {
let r = SubtitleTrack::try_new(Uuid7::new(), Uuid7::nil());
assert_eq!(r.err(), Some(SubtitleTrackError::NilSubtitleId));
assert!(SubtitleTrackError::NilSubtitleId.is_nil_subtitle_id());
}
#[test]
fn descriptor_builders_chain() {
let lang = Language::from_bcp47("en").unwrap();
let t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new())
.unwrap()
.with_codec(SubtitleCodec::Subrip)
.with_format(Format::Srt)
.with_origin(TrackOrigin::External)
.with_language(lang)
.with_title("English (SDH)")
.with_disposition(TrackDisposition::from_u32(0x0040))
.with_primary(true)
.with_auto_selected(true)
.with_kind(SubtitleKind::ForcedNarrative)
.with_sdh(true);
assert_eq!(t.codec_ref(), &SubtitleCodec::Subrip);
assert_eq!(t.format_ref(), &Format::Srt);
assert_eq!(t.origin_ref(), &TrackOrigin::External);
assert_eq!(t.language_ref(), &lang);
assert_eq!(t.title(), "English (SDH)");
assert_eq!(t.image_based(), Some(false));
assert!(!t.requires_ocr());
assert_eq!(t.disposition(), TrackDisposition::from_u32(0x0040));
assert!(t.is_primary());
assert!(t.auto_selected());
assert!(t.kind().is_forced_narrative());
assert!(t.is_sdh());
}
#[test]
fn cue_rollup_builders() {
let c1 = Uuid7::new();
let c2 = Uuid7::new();
let t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new())
.unwrap()
.with_cues(std::vec![c1, c2])
.with_cue_count(2);
assert_eq!(t.cues_slice().len(), 2);
assert_eq!(t.cue_count(), 2);
assert!(t.cues_slice().contains(&c1));
assert!(t.cues_slice().contains(&c2));
}
#[test]
fn provenance_is_per_track() {
let prov = Provenance::from_parts("tesseract", "5.3.0", "", "indexer-0.4.2");
let t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new())
.unwrap()
.with_provenance(prov.clone());
assert_eq!(t.provenance_ref(), &prov);
}
#[test]
fn index_state_builders() {
let err = ErrorInfo::code_only(ErrorCode::ProbeCorrupt);
let t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new())
.unwrap()
.with_index_status(SubtitleIndexStatus::TRACKS_DISCOVERED)
.with_index_errors(std::vec![err.clone()]);
assert!(t
.index_status()
.contains(SubtitleIndexStatus::TRACKS_DISCOVERED));
assert_eq!(t.index_errors_slice().len(), 1);
assert_eq!(t.index_errors_slice()[0], err);
}
#[test]
fn image_based_known_bitmap_codec_forces_ocr_despite_default_format() {
let t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new())
.unwrap()
.with_codec(SubtitleCodec::HdmvPgsSubtitle)
.with_format(Format::default());
assert_eq!(t.format_ref(), &Format::default());
assert_eq!(t.codec_ref().is_image_based(), Some(true));
assert_eq!(t.image_based(), Some(true));
assert!(t.requires_ocr(), "known bitmap codec must require OCR");
}
#[test]
fn image_based_dvbsub_codec_with_other_format() {
let t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new())
.unwrap()
.with_codec(SubtitleCodec::DvbSubtitle)
.with_format(Format::Other(SmolStr::new("weird")));
assert_eq!(t.image_based(), Some(true));
assert!(t.requires_ocr());
}
#[test]
fn image_based_text_codec_and_format_not_ocr() {
let t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new())
.unwrap()
.with_codec(SubtitleCodec::Ass)
.with_format(Format::Ass);
assert_eq!(t.image_based(), Some(false));
assert!(!t.requires_ocr());
}
#[test]
fn image_based_unknown_on_both_axes_conservatively_requires_ocr() {
let t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new())
.unwrap()
.with_codec(SubtitleCodec::Other(SmolStr::new("mystery")))
.with_format(Format::Other(SmolStr::new("mystery")));
assert_eq!(t.image_based(), None);
assert!(
t.requires_ocr(),
"an unclassified track must never under-require OCR"
);
}
#[test]
fn unknown_track_with_text_complete_bits_is_not_done() {
use SubtitleIndexStatus as S;
let t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new())
.unwrap()
.with_codec(SubtitleCodec::Other(SmolStr::new("mystery")))
.with_format(Format::Other(SmolStr::new("mystery")))
.with_index_status(S::TRACKS_DISCOVERED | S::CUES_EXTRACTED | S::SEARCH_INDEXED);
assert!(t.requires_ocr());
assert!(
!t.is_fully_indexed(),
"unclassified track without OCR_DONE must not be fully indexed"
);
assert_ne!(
t.index_stage(),
SubtitleIndexStage::Done,
"unclassified track without OCR_DONE must not reach Done"
);
let done = t.with_index_status(S::fully_indexed_mask(true));
assert!(done.is_fully_indexed());
assert_eq!(done.index_stage(), SubtitleIndexStage::Done);
}
#[test]
fn image_based_known_bitmap_format_with_other_codec() {
let t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new())
.unwrap()
.with_codec(SubtitleCodec::Other(SmolStr::default()))
.with_format(Format::PgsSub);
assert_eq!(t.image_based(), Some(true));
assert!(t.requires_ocr());
}
#[test]
fn setters_mutate_in_place() {
let lang = Language::from_bcp47("ja").unwrap();
let mut t = SubtitleTrack::try_new(Uuid7::new(), Uuid7::new()).unwrap();
t.set_codec(SubtitleCodec::Ass);
t.set_format(Format::Ass);
t.set_origin(TrackOrigin::Embedded);
t.set_language(lang);
t.set_title("Japanese");
t.set_disposition(TrackDisposition::from_u32(0x0001));
t.set_primary(true);
t.set_kind(SubtitleKind::CommentaryText);
t.set_index_status(SubtitleIndexStatus::CUES_EXTRACTED);
assert_eq!(t.codec_ref(), &SubtitleCodec::Ass);
assert_eq!(t.format_ref(), &Format::Ass);
assert_eq!(t.origin_ref(), &TrackOrigin::Embedded);
assert_eq!(t.language_ref(), &lang);
assert_eq!(t.title(), "Japanese");
assert_eq!(t.disposition(), TrackDisposition::from_u32(0x0001));
assert!(t.is_primary());
assert!(t.kind().is_commentary_text());
assert!(t
.index_status()
.contains(SubtitleIndexStatus::CUES_EXTRACTED));
}
}