Skip to main content

torq_core/
daemon.rs

1//! Download queue, daemon state, and event broadcast.
2//!
3//! The queue uses a fixed number of active slots; torrents
4//! beyond the cap are engine-paused and auto-promoted as slots free. Status is
5//! derived per torrent: user pause, engine pause (queued), error, completed.
6//! Our own metadata (user_paused, added_at) persists to `queue.json`; the
7//! engine session persists the torrents themselves.
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use anyhow::{Context, Result};
15use librqbit::api::{ApiTorrentListOpts, TorrentDetailsResponse, TorrentIdOrHash};
16use librqbit::{AddTorrentResponse, TorrentStats, TorrentStatsState};
17use parking_lot::Mutex;
18use serde::{Deserialize, Serialize};
19use tokio::sync::broadcast;
20use tracing::{debug, info, warn};
21
22use crate::config::Config;
23use crate::engine::Engine;
24use crate::library::Library;
25use crate::rss::Subscriptions;
26
27/// How many torrents download at once (tunable later).
28pub const DEFAULT_MAX_ACTIVE: usize = 3;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum Status {
33    Downloading,
34    Queued,
35    Paused,
36    Completed,
37    Failed,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct TorrentView {
42    pub id: usize,
43    pub info_hash: String,
44    pub name: String,
45    pub status: Status,
46    /// 0..=1, 0 while metadata is still being fetched.
47    pub progress: f32,
48    pub total_bytes: u64,
49    pub downloaded_bytes: u64,
50    pub upload_mbps: Option<f32>,
51    pub download_mbps: Option<f32>,
52    pub peers: usize,
53    pub error: Option<String>,
54    pub added_at: i64,
55}
56
57impl TorrentView {
58    /// Lowercase human status ("downloading", "seeding", …).
59    pub fn status_label(&self) -> &'static str {
60        match self.status {
61            Status::Downloading => "downloading",
62            Status::Queued => "queued",
63            Status::Paused => "paused",
64            Status::Completed => "seeding",
65            Status::Failed => "failed",
66        }
67    }
68}
69
70#[derive(Debug, Clone, Serialize)]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum Event {
73    TorrentAdded { id: usize },
74    TorrentUpdated { id: usize },
75    TorrentCompleted { id: usize },
76    TorrentFailed { id: usize, error: String },
77    TorrentRemoved { id: usize },
78}
79
80/// Per-torrent daemon metadata, keyed by info hash (stable across restarts,
81/// unlike engine torrent ids). Only `user_paused`/`queued`/`added_at` persist.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83struct Meta {
84    user_paused: bool,
85    /// Waiting for an active slot (over the cap at add time).
86    #[serde(default)]
87    queued: bool,
88    added_at: i64,
89    #[serde(skip)]
90    last_status: Option<Status>,
91}
92
93impl Default for Meta {
94    fn default() -> Self {
95        Self {
96            user_paused: false,
97            queued: false,
98            added_at: now_secs(),
99            last_status: None,
100        }
101    }
102}
103
104pub struct Daemon {
105    engine: Arc<Engine>,
106    max_active: usize,
107    state_dir: PathBuf,
108    meta: Mutex<HashMap<String, Meta>>,
109    events: broadcast::Sender<Event>,
110    /// RSS subscriptions; polled by a background task.
111    pub rss: Arc<Subscriptions>,
112    /// Cross-seed index of .torrent files whose data is already on disk.
113    pub library: Arc<Library>,
114}
115
116impl Daemon {
117    /// Start the daemon: hydrate metadata for restored torrents, promote any
118    /// queued into free slots, and spawn the transition + RSS poll + schedule
119    /// ticks.
120    pub async fn start(config: &Config, engine: Arc<Engine>) -> Result<Arc<Self>> {
121        let (events, _) = broadcast::channel(512);
122        let rss = Subscriptions::load(config.state_dir.join("subscriptions.json"));
123        let library = Library::new(config.library_dirs.clone());
124        let daemon = Arc::new(Self {
125            engine,
126            max_active: DEFAULT_MAX_ACTIVE,
127            state_dir: config.state_dir.clone(),
128            meta: Mutex::new(HashMap::new()),
129            events,
130            rss,
131            library,
132        });
133
134        daemon.load_meta();
135        // Metadata for torrents restored by the session (no prior meta file).
136        {
137            let mut meta = daemon.meta.lock();
138            let resp = daemon
139                .engine
140                .api()
141                .api_torrent_list_ext(ApiTorrentListOpts { with_stats: true });
142            for t in &resp.torrents {
143                meta.entry(t.info_hash.clone()).or_default();
144            }
145        }
146        daemon.save_meta();
147        daemon.try_promote().await;
148
149        tokio::spawn(tick_loop(daemon.clone()));
150        tokio::spawn(rss_poll_loop(daemon.clone(), config.socks_proxy.clone()));
151        if !config.library_dirs.is_empty() {
152            let lib = daemon.library.clone();
153            tokio::spawn(async move {
154                let _ = lib.scan();
155            });
156        }
157        if !config.schedule.is_empty() {
158            spawn_scheduler(daemon.clone(), config.schedule.clone());
159        }
160        Ok(daemon)
161    }
162
163    pub fn engine(&self) -> &Arc<Engine> {
164        &self.engine
165    }
166
167    pub fn subscribe(&self) -> broadcast::Receiver<Event> {
168        self.events.subscribe()
169    }
170
171    // -- mutations ----------------------------------------------------------
172
173    pub async fn add_magnet(&self, magnet: &str, paused: bool) -> Result<TorrentView> {
174        let magnet = magnet.trim();
175        // Cross-seed: if the hash exists in the library, download "into" the
176        // existing data dir so the piece check finds it instead of fetching.
177        let resp = match self.cross_seed_dir(magnet) {
178            Some(dir) => self.engine.add_magnet_with_output(magnet, dir).await?,
179            None => self.engine.add_magnet(magnet).await?,
180        };
181        self.finish_add(resp, paused).await
182    }
183
184    /// Library data dir for a magnet's infohash, if indexed.
185    fn cross_seed_dir(&self, magnet: &str) -> Option<PathBuf> {
186        let hash = magnet
187            .split("urn:btih:")
188            .nth(1)
189            .and_then(|s| s.split('&').next())
190            .and_then(torq_sources::util::canonicalize_hash)?;
191        let entry = self.library.lookup(&hash)?;
192        info!(%hash, dir = %entry.data_dir.display(), "cross-seed: data already in library");
193        Some(entry.data_dir)
194    }
195
196    pub async fn add_torrent_bytes(&self, bytes: Vec<u8>, paused: bool) -> Result<TorrentView> {
197        let resp = self.engine.add_torrent_bytes(bytes).await?;
198        self.finish_add(resp, paused).await
199    }
200
201    async fn finish_add(&self, resp: AddTorrentResponse, paused: bool) -> Result<TorrentView> {
202        let (id, info_hash) = match &resp {
203            AddTorrentResponse::Added(id, h) | AddTorrentResponse::AlreadyManaged(id, h) => {
204                (*id, h.info_hash().as_string())
205            }
206            AddTorrentResponse::ListOnly(_) => anyhow::bail!("torrent added in list-only mode"),
207        };
208
209        // Record intent only; librqbit refuses to pause a torrent that is still
210        // initializing, so the reconcile tick applies pause/queue once the
211        // torrent is ready. An explicit `paused` add reads back as "paused"
212        // immediately via the meta flag. Re-adding an existing torrent must
213        // never clobber the user's pause state.
214        //
215        // NOTE: active_count() takes the meta lock (via views()), so it must
216        // run before we hold the guard — parking_lot is not reentrant.
217        let is_new = matches!(resp, AddTorrentResponse::Added(_, _));
218        let queued = is_new && !paused && self.active_count() > self.max_active;
219        if queued {
220            debug!(id, "over active cap, queueing");
221        }
222        {
223            let mut meta = self.meta.lock();
224            let entry = meta.entry(info_hash.clone()).or_default();
225            if is_new {
226                entry.user_paused = paused;
227                entry.queued = queued;
228                entry.last_status = None; // force a transition broadcast on next tick
229            }
230        }
231
232        self.save_meta();
233        let _ = self.events.send(Event::TorrentAdded { id });
234        debug!(id, "finish_add: returning view");
235        self.view(id).context("torrent disappeared after add")
236    }
237
238    pub async fn pause(&self, id: TorrentIdOrHash) -> Result<()> {
239        let (id_num, hash) = self.locate(&id)?;
240        self.meta
241            .lock()
242            .get_mut(&hash)
243            .expect("meta exists")
244            .user_paused = true;
245        self.save_meta();
246        // Best-effort: pausing a still-initializing torrent fails in librqbit;
247        // the reconcile tick retries until it takes.
248        if let Err(e) = self.engine.pause(TorrentIdOrHash::Id(id_num)).await {
249            debug!(id = id_num, "deferred pause not yet possible: {e}");
250        }
251        let _ = self.events.send(Event::TorrentUpdated { id: id_num });
252        Ok(())
253    }
254
255    pub async fn resume(&self, id: TorrentIdOrHash) -> Result<()> {
256        let (id_num, hash) = self.locate(&id)?;
257        {
258            let mut meta = self.meta.lock();
259            let entry = meta.get_mut(&hash).expect("meta exists");
260            entry.user_paused = false;
261            entry.queued = false;
262        }
263        self.save_meta();
264        self.try_promote().await;
265        let _ = self.events.send(Event::TorrentUpdated { id: id_num });
266        Ok(())
267    }
268
269    pub async fn remove(&self, id: TorrentIdOrHash, delete_files: bool) -> Result<()> {
270        let (id_num, hash) = self.locate(&id)?;
271        self.engine
272            .remove(TorrentIdOrHash::Id(id_num), delete_files)
273            .await?;
274        self.meta.lock().remove(&hash);
275        self.save_meta();
276        let _ = self.events.send(Event::TorrentRemoved { id: id_num });
277        self.try_promote().await;
278        Ok(())
279    }
280
281    // -- reads ---------------------------------------------------------------
282
283    /// How many torrents may transfer concurrently; the rest wait in queue.
284    pub fn max_active(&self) -> usize {
285        self.max_active
286    }
287
288    pub fn views(&self) -> Vec<TorrentView> {
289        let resp = self
290            .engine
291            .api()
292            .api_torrent_list_ext(ApiTorrentListOpts { with_stats: true });
293        let meta = self.meta.lock();
294        resp.torrents
295            .iter()
296            .filter_map(|t| {
297                let m = meta.get(&t.info_hash).cloned().unwrap_or_default();
298                view_from(t, &m)
299            })
300            .collect()
301    }
302
303    pub fn view(&self, id: usize) -> Option<TorrentView> {
304        self.views().into_iter().find(|v| v.id == id)
305    }
306
307    /// Resolve a torrent by numeric id or info hash to (id, info_hash).
308    fn locate(&self, id: &TorrentIdOrHash) -> Result<(usize, String)> {
309        let handle = self.engine.api().mgr_handle(*id).map_err(|e| {
310            anyhow::anyhow!(if e.to_string().contains("not found") {
311                "torrent not found"
312            } else {
313                "torrent lookup failed"
314            })
315        })?;
316        Ok((handle.id(), handle.info_hash().as_string()))
317    }
318
319    fn active_count(&self) -> usize {
320        self.views()
321            .iter()
322            .filter(|v| v.status == Status::Downloading)
323            .count()
324    }
325
326    // -- queue promotion ------------------------------------------------------
327
328    /// Resume queued torrents into free slots, oldest first.
329    async fn try_promote(&self) {
330        let active = self.active_count();
331        if active >= self.max_active {
332            return;
333        }
334        let mut queued: Vec<TorrentView> = self
335            .views()
336            .into_iter()
337            .filter(|v| v.status == Status::Queued)
338            .collect();
339        queued.sort_by_key(|v| v.added_at);
340        for v in queued.into_iter().take(self.max_active - active) {
341            if let Err(e) = self.engine.resume(TorrentIdOrHash::Id(v.id)).await {
342                warn!(id = v.id, "promote failed: {e:#}");
343            } else {
344                if let Some(entry) = self.meta.lock().get_mut(&v.info_hash) {
345                    entry.queued = false;
346                }
347                info!(id = v.id, "promoted from queue");
348            }
349        }
350        self.save_meta();
351    }
352
353    // -- persistence ----------------------------------------------------------
354
355    fn meta_file(&self) -> PathBuf {
356        self.state_dir.join("queue.json")
357    }
358
359    fn load_meta(&self) {
360        let path = self.meta_file();
361        let Ok(raw) = std::fs::read_to_string(&path) else {
362            return;
363        };
364        match serde_json::from_str::<HashMap<String, Meta>>(&raw) {
365            Ok(loaded) => {
366                *self.meta.lock() = loaded;
367            }
368            Err(e) => warn!(path = %path.display(), "ignoring unparsable queue.json: {e}"),
369        }
370    }
371
372    fn save_meta(&self) {
373        let map = self.meta.lock().clone();
374        let raw = match serde_json::to_vec(&map) {
375            Ok(v) => v,
376            Err(e) => {
377                warn!("queue.json serialization failed: {e}");
378                return;
379            }
380        };
381        let path = self.meta_file();
382        let tmp = path.with_extension("json.tmp");
383        if let Err(e) = std::fs::write(&tmp, raw).and_then(|()| std::fs::rename(&tmp, &path)) {
384            warn!(path = %path.display(), "queue.json write failed: {e:#}");
385        }
386    }
387
388    // -- transition tick --------------------------------------------------------
389
390    /// One pass over session state: detect status transitions, broadcast them,
391    /// promote completions. Runs every second; does nothing when nothing changed.
392    async fn reconcile(&self) {
393        let resp = self
394            .engine
395            .api()
396            .api_torrent_list_ext(ApiTorrentListOpts { with_stats: true });
397        let mut completed = Vec::new();
398        let mut failed = Vec::new();
399        let mut updated = Vec::new();
400
401        {
402            let mut meta = self.meta.lock();
403            for t in &resp.torrents {
404                let Some(stats) = t.stats.as_ref() else {
405                    continue;
406                };
407                let Some(id) = t.id else { continue };
408                let entry = meta.entry(t.info_hash.clone()).or_default();
409                let status = derive_status(stats, entry);
410                let prev = entry.last_status;
411                entry.last_status = Some(status);
412                match (prev, status) {
413                    (Some(_), Status::Completed) => completed.push(id),
414                    (Some(_), Status::Failed) => {
415                        failed.push((id, stats.error.clone().unwrap_or_default()))
416                    }
417                    (Some(p), s) if p != s => updated.push((id, status)),
418                    _ => {}
419                }
420            }
421        }
422
423        for id in &completed {
424            let _ = self.events.send(Event::TorrentCompleted { id: *id });
425        }
426        for (id, error) in &failed {
427            let _ = self.events.send(Event::TorrentFailed {
428                id: *id,
429                error: error.clone(),
430            });
431        }
432        for (id, _) in &updated {
433            let _ = self.events.send(Event::TorrentUpdated { id: *id });
434        }
435
436        // Enforce recorded intent the engine hasn't applied yet: pausing a
437        // torrent mid-initialization fails in librqbit, so retry each tick.
438        for t in &resp.torrents {
439            let Some(id) = t.id else { continue };
440            let Some(stats) = t.stats.as_ref() else {
441                continue;
442            };
443            let meta = self.meta.lock().get(&t.info_hash).cloned();
444            let Some(meta) = meta else { continue };
445            if (meta.user_paused || meta.queued)
446                && !matches!(
447                    stats.state,
448                    TorrentStatsState::Paused | TorrentStatsState::Error
449                )
450                && let Err(e) = self.engine.pause(TorrentIdOrHash::Id(id)).await
451            {
452                debug!(id, "deferred pause not yet possible: {e}");
453            }
454        }
455
456        // Fill any free slots (also covers torrents restored paused by the
457        // session with no recorded intent).
458        self.try_promote().await;
459
460        if !completed.is_empty() {
461            debug!("{} torrent(s) completed", completed.len());
462        }
463    }
464}
465
466async fn tick_loop(daemon: Arc<Daemon>) {
467    let mut interval = tokio::time::interval(Duration::from_secs(1));
468    loop {
469        interval.tick().await;
470        daemon.reconcile().await;
471    }
472}
473
474/// Poll due RSS subscriptions every 30s; each subscription staggers itself.
475async fn rss_poll_loop(daemon: Arc<Daemon>, socks_proxy: Option<String>) {
476    let Ok(client) = torq_sources::types::http_client(socks_proxy.as_deref()) else {
477        warn!("rss polling disabled: no HTTP client");
478        return;
479    };
480    let mut interval = tokio::time::interval(Duration::from_secs(30));
481    loop {
482        interval.tick().await;
483        daemon.rss.poll_due(&client, &daemon).await;
484    }
485}
486
487/// Apply the active bandwidth schedule every minute; outside any window the
488/// flat `upload_bps`/`download_bps` limits from config apply (already set on
489/// the session at startup).
490fn spawn_scheduler(daemon: Arc<Daemon>, schedule: Vec<crate::config::ScheduleEntry>) {
491    tokio::spawn(async move {
492        let mut tick = tokio::time::interval(Duration::from_secs(60));
493        loop {
494            tick.tick().await;
495            let (up, down) = active_limits(&schedule, local_minutes());
496            daemon.engine.set_limits(up, down);
497        }
498    });
499}
500
501/// Minutes since local midnight (wall-clock): schedules are defined in local
502/// time, so a UTC clock would shift the window with timezone/DST.
503fn local_minutes() -> u32 {
504    use chrono::Timelike;
505    chrono::Local::now().num_seconds_from_midnight() / 60
506}
507
508/// Limits for the current minute: the first matching schedule window, else
509/// None (meaning "revert to the flat config limits", which the engine holds).
510fn active_limits(
511    schedule: &[crate::config::ScheduleEntry],
512    now: u32,
513) -> (Option<u32>, Option<u32>) {
514    for e in schedule {
515        let (Some(s), Some(end)) = (parse_hhmm(&e.start), parse_hhmm(&e.end)) else {
516            continue;
517        };
518        let active = if s < end {
519            now >= s && now < end
520        } else {
521            now >= s || now < end // overnight window
522        };
523        if active {
524            return (e.upload_bps, e.download_bps);
525        }
526    }
527    (None, None)
528}
529
530fn parse_hhmm(s: &str) -> Option<u32> {
531    let (h, m) = s.split_once(':')?;
532    Some(h.parse::<u32>().ok()? * 60 + m.parse::<u32>().ok()?)
533}
534
535/// Status precedence: error → completed → user-paused → engine-paused (queued)
536/// → downloading. A completed torrent that the user paused stays completed.
537fn derive_status(stats: &TorrentStats, meta: &Meta) -> Status {
538    if stats.error.is_some() && !stats.finished {
539        return Status::Failed;
540    }
541    if stats.finished {
542        return Status::Completed;
543    }
544    if meta.user_paused {
545        return Status::Paused;
546    }
547    if meta.queued {
548        return Status::Queued;
549    }
550    match stats.state {
551        TorrentStatsState::Paused => Status::Queued,
552        _ => Status::Downloading,
553    }
554}
555
556fn view_from(details: &TorrentDetailsResponse, meta: &Meta) -> Option<TorrentView> {
557    let id = details.id?;
558    let stats = details.stats.as_ref()?;
559    let (download_mbps, upload_mbps, peers) = match &stats.live {
560        Some(live) => (
561            Some(live.download_speed.mbps as f32),
562            Some(live.upload_speed.mbps as f32),
563            live.snapshot.peer_stats.live,
564        ),
565        None => (None, None, 0),
566    };
567    Some(TorrentView {
568        id,
569        info_hash: details.info_hash.clone(),
570        name: details
571            .name
572            .clone()
573            .unwrap_or_else(|| details.info_hash.clone()),
574        status: derive_status(stats, meta),
575        progress: if stats.total_bytes > 0 {
576            stats.progress_bytes as f32 / stats.total_bytes as f32
577        } else {
578            0.0
579        },
580        total_bytes: stats.total_bytes,
581        downloaded_bytes: stats.progress_bytes,
582        upload_mbps,
583        download_mbps,
584        peers,
585        error: stats.error.clone(),
586        added_at: meta.added_at,
587    })
588}
589
590fn now_secs() -> i64 {
591    SystemTime::now()
592        .duration_since(UNIX_EPOCH)
593        .map(|d| d.as_secs() as i64)
594        .unwrap_or(0)
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    fn stats(state: TorrentStatsState) -> TorrentStats {
602        TorrentStats {
603            state,
604            file_progress: vec![],
605            error: None,
606            progress_bytes: 0,
607            uploaded_bytes: 0,
608            total_bytes: 100,
609            finished: false,
610            live: None,
611        }
612    }
613
614    fn meta(user_paused: bool) -> Meta {
615        Meta {
616            user_paused,
617            queued: false,
618            added_at: 1,
619            last_status: None,
620        }
621    }
622
623    fn meta_queued() -> Meta {
624        Meta {
625            user_paused: false,
626            queued: true,
627            added_at: 1,
628            last_status: None,
629        }
630    }
631
632    #[test]
633    fn status_precedence() {
634        assert_eq!(
635            derive_status(&stats(TorrentStatsState::Live), &meta(false)),
636            Status::Downloading
637        );
638        assert_eq!(
639            derive_status(&stats(TorrentStatsState::Initializing), &meta(false)),
640            Status::Downloading
641        );
642        // engine-paused = queued (waiting for a slot)
643        assert_eq!(
644            derive_status(&stats(TorrentStatsState::Paused), &meta(false)),
645            Status::Queued
646        );
647        // user-paused wins over engine pause
648        assert_eq!(
649            derive_status(&stats(TorrentStatsState::Paused), &meta(true)),
650            Status::Paused
651        );
652        assert_eq!(
653            derive_status(&stats(TorrentStatsState::Live), &meta(true)),
654            Status::Paused
655        );
656        // over-cap intent reads as queued even while the engine catches up
657        assert_eq!(
658            derive_status(&stats(TorrentStatsState::Live), &meta_queued()),
659            Status::Queued
660        );
661        // user pause wins over queued intent
662        let mut m = meta_queued();
663        m.user_paused = true;
664        assert_eq!(
665            derive_status(&stats(TorrentStatsState::Live), &m),
666            Status::Paused
667        );
668    }
669
670    #[test]
671    fn finished_beats_paused_and_error_clears() {
672        let mut s = stats(TorrentStatsState::Paused);
673        s.finished = true;
674        assert_eq!(derive_status(&s, &meta(true)), Status::Completed);
675
676        s.error = Some("boom".into());
677        s.finished = false;
678        assert_eq!(derive_status(&s, &meta(false)), Status::Failed);
679    }
680
681    #[test]
682    fn view_maps_progress_and_speeds() {
683        let mut s = stats(TorrentStatsState::Live);
684        s.progress_bytes = 50;
685        s.total_bytes = 200;
686        let details = TorrentDetailsResponse {
687            id: Some(7),
688            info_hash: "abc".into(),
689            name: Some("x".into()),
690            output_folder: "/tmp".into(),
691            files: None,
692            stats: Some(s),
693        };
694        let v = view_from(&details, &meta(false)).unwrap();
695        assert_eq!(v.id, 7);
696        assert_eq!(v.status, Status::Downloading);
697        assert!((v.progress - 0.25).abs() < f32::EPSILON);
698        assert_eq!(v.downloaded_bytes, 50);
699    }
700
701    #[test]
702    fn schedule_windows_and_midnight() {
703        use crate::config::ScheduleEntry;
704        let entry = |start: &str, end: &str, up: u32| ScheduleEntry {
705            start: start.into(),
706            end: end.into(),
707            upload_bps: Some(up),
708            download_bps: None,
709        };
710        let sched = vec![entry("08:00", "12:00", 100), entry("23:00", "02:00", 200)];
711        assert_eq!(active_limits(&sched, 9 * 60), (Some(100), None));
712        assert_eq!(active_limits(&sched, 12 * 60), (None, None)); // end exclusive
713        assert_eq!(active_limits(&sched, 23 * 60 + 30), (Some(200), None)); // overnight
714        assert_eq!(active_limits(&sched, 60), (Some(200), None));
715        assert_eq!(active_limits(&sched, 3 * 60), (None, None));
716        assert_eq!(parse_hhmm("08:30"), Some(510));
717        assert_eq!(parse_hhmm("x"), None);
718    }
719}