Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


backend/runner.py (24342 bytes)

1 """Runs one job: stage inputs, drive the aligner, collect the artifacts.
2 
3 Nothing here knows which alignment backend is in use. The command to run, how
4 to read its progress and where it writes the subtitles all come from
5 `aligner.Aligner`, so swapping subplz out does not touch this file.
6 
7 The backend is always a subprocess. That boundary is also what lets the public
8 deployment move alignment onto separate GPU workers without code changes.
9 """
10 
11 from __future__ import annotations
12 
13 import json
14 import logging
15 import os
16 import re
17 import shutil
18 import subprocess
19 import time
20 from dataclasses import dataclass
21 from datetime import timezone
22 from pathlib import Path
23 
24 from . import billing, languages, render
25 from .aligner import AlignRequest, aligner
26 from .db import Artifact, Job, JobStatus, SessionLocal, utcnow
27 from .settings import settings
28 from .storage import storage
29 
30 log = logging.getLogger(__name__)
31 
32 # Audio containers subplz/ffmpeg handle. Checked at upload time.
33 AUDIO_SUFFIXES = {
34     ".m4b", ".m4a", ".mp3", ".opus", ".ogg", ".oga", ".flac", ".wav",
35     ".aac", ".wma", ".mka", ".mkv", ".mp4", ".webm", ".avi", ".mov",
36 }
37 # Formats subplz reads directly, plus the ones convert.py turns into epub on
38 # the way in (fb2/mobi/azw3).
39 TEXT_SUFFIXES = {
40     ".epub", ".txt", ".srt", ".vtt", ".ass",
41     ".fb2", ".fb2.zip", ".mobi", ".azw", ".azw3", ".prc",
42 }
43 
44 
45 def text_suffix(name: str) -> str:
46     """Suffix used when staging a text file, keeping the .fb2.zip double."""
47     lowered = name.lower()
48     if lowered.endswith(".fb2.zip"):
49         return ".fb2.zip"
50     return Path(lowered).suffix
51 
52 # Fixed stem for staged inputs: keeps non-ASCII filenames out of the subprocess
53 # command line entirely, and makes the output path deterministic.
54 STAGE_STEM = "source"
55 
56 
57 class JobFailed(RuntimeError):
58     pass
59 
60 
61 @dataclass
62 class Paths:
63     root: Path
64     inp: Path
65     out: Path
66 
67     @classmethod
68     def for_job(cls, job_id: str) -> "Paths":
69         root = settings.data_dir / "work" / job_id
70         return cls(root=root, inp=root / "input", out=root / "out")
71 
72     def create(self) -> None:
73         self.inp.mkdir(parents=True, exist_ok=True)
74         self.out.mkdir(parents=True, exist_ok=True)
75 
76 
77 def input_prefix(job_id: str) -> str:
78     """Storage prefix mirroring a job's staged `input/` tree.
79 
80     Only used when the queue is external: the worker that runs the job is
81     probably not the machine that received the upload.
82     """
83     return f"{job_id}/input"
84 
85 
86 def ensure_inputs(job_id: str) -> None:
87     """Make sure the staged inputs are on this machine's disk."""
88     paths = Paths.for_job(job_id)
89     paths.create()
90     if any(p.is_file() for p in paths.inp.rglob("*")):
91         return  # this process received the upload
92 
93     prefix = input_prefix(job_id)
94     keys = storage.list_prefix(prefix)
95     if not keys:
96         raise JobFailed(
97             "the uploaded files for this job are no longer available. "
98             "Upload them again."
99         )
100     for key in keys:
101         storage.fetch_to(key, paths.inp / key[len(prefix) + 1 :])
102 
103 
104 def staged_audio_path(job_id: str, original_name: str) -> Path:
105     return Paths.for_job(job_id).inp / f"{STAGE_STEM}{Path(original_name).suffix.lower()}"
106 
107 
108 def staged_text_path(job_id: str, original_name: str) -> Path:
109     return Paths.for_job(job_id).inp / f"{STAGE_STEM}{text_suffix(original_name)}"
110 
111 
112 def staged_cover_path(job_id: str, original_name: str) -> Path:
113     """Where a user-supplied cover image is staged.
114 
115     Kept out of the `input/` root alongside audio and text so the aligner's
116     directory scan cannot mistake a picture for content.
117     """
118     inp = Paths.for_job(job_id).inp
119     return inp / "cover" / f"cover{Path(original_name).suffix.lower()}"
120 
121 
122 def find_cover(job_id: str) -> Path | None:
123     folder = Paths.for_job(job_id).inp / "cover"
124     if not folder.is_dir():
125         return None
126     return next((p for p in sorted(folder.iterdir()) if p.is_file()), None)
127 
128 
129 def staged_part_path(job_id: str, index: int, original_name: str) -> Path:
130     """Where chapter file `index` (1-based) of a multi-part audiobook is staged.
131 
132     Parts get plain numeric names so the ffmpeg concat list never needs quoting
133     and playback order is unambiguous.
134     """
135     inp = Paths.for_job(job_id).inp
136     return inp / "parts" / f"{index:04d}{Path(original_name).suffix.lower()}"
137 
138 
139 # Tolerate damaged input rather than aborting the whole book. ffmpeg's default
140 # max_error_rate (0.667) kills a run over one bad chapter.
141 _FFMPEG_TOLERANT = [
142     "-max_error_rate", "1.0",
143     "-err_detect", "ignore_err",
144     "-fflags", "+discardcorrupt",
145 ]
146 
147 
148 def merge_parts(job_id: str, parts: list[Path]) -> Path:
149     """Join a per-chapter audiobook into one file, one chapter per part.
150 
151     Keeping chapter marks matters twice over: subplz processes an m4b chapter by
152     chapter, and the runner reads chapter completions to drive progress.
153     """
154     paths = Paths.for_job(job_id)
155     dest = paths.inp / f"{STAGE_STEM}.m4b"
156     if dest.exists():
157         return dest
158 
159     durations: list[float] = []
160     for p in parts:
161         d = probe_duration(p)
162         if d is None:
163             raise JobFailed(f"could not read the duration of part {p.name}")
164         durations.append(d)
165 
166     scratch = paths.root / "merge"
167     scratch.mkdir(parents=True, exist_ok=True)
168 
169     listing = scratch / "parts.txt"
170     # Absolute paths: ffmpeg resolves a relative entry against the directory of
171     # the list file, not the working directory.
172     listing.write_text(
173         "".join(f"file '{p.resolve().as_posix()}'\n" for p in parts),
174         encoding="utf-8",
175     )
176 
177     # Chapter marks at the part boundaries, in milliseconds.
178     meta = [";FFMETADATA1"]
179     start_ms = 0
180     for i, seconds in enumerate(durations, start=1):
181         end_ms = start_ms + int(round(seconds * 1000))
182         meta += [
183             "[CHAPTER]",
184             "TIMEBASE=1/1000",
185             f"START={start_ms}",
186             f"END={end_ms}",
187             f"title=Part {i:02d}",
188         ]
189         start_ms = end_ms
190     metadata = scratch / "chapters.txt"
191     metadata.write_text("\n".join(meta) + "\n", encoding="utf-8")
192 
193     cmd = [
194         "ffmpeg", "-hide_banner", "-v", "error", "-y",
195         *_FFMPEG_TOLERANT,
196         "-f", "concat", "-safe", "0", "-i", str(listing),
197         "-i", str(metadata),
198         "-map", "0:a:0", "-map_metadata", "1", "-map_chapters", "1",
199         # Drop cover art and any stray tracks the parts carry, so the only
200         # media stream is the audio. (MP4 still writes a bin_data chapter
201         # track; that one is how the container stores chapters and must stay.)
202         "-vn", "-sn",
203         # Keep listenable quality: the aligner downsamples to 16 kHz mono itself
204         # when it reads the file, but the rendered video uses this same audio,
205         # and 16 kHz mono would sound awful on YouTube.
206         "-c:a", "aac", "-b:a", "128k",
207         str(dest),
208     ]
209     proc = subprocess.run(cmd, capture_output=True, timeout=settings.job_timeout_seconds)
210     if proc.returncode != 0 or not dest.exists():
211         err = proc.stderr.decode("utf-8", errors="replace").strip().splitlines()
212         tail = " | ".join(err[-3:]) if err else "no stderr"
213         raise JobFailed(f"could not join the {len(parts)} audio parts: {tail}")
214 
215     shutil.rmtree(scratch, ignore_errors=True)
216     return dest
217 
218 
219 def normalize_for_alignment(job_id: str, source: Path) -> Path:
220     """Produce the 16 kHz mono copy the aligner gets fed.
221 
222     Two jobs at once:
223 
224     * Format. The acoustic model consumes 16 kHz mono no matter what, and
225       handing subplz a 44.1 kHz stereo file has crashed ctranslate2 here
226       (integer divide by zero, part-way through a chapter). Doing the
227       conversion ourselves, once, keeps the aligner on the input that works.
228     * Repair. The error-tolerant flags drop corrupt frames rather than letting
229       ffmpeg abort the run, which is what a damaged chapter would otherwise do.
230 
231     The video keeps using `source`, which stays at listenable quality.
232     """
233     dest = Paths.for_job(job_id).inp / f"{STAGE_STEM}.align.m4a"
234     if dest.exists():
235         return dest
236 
237     cmd = [
238         "ffmpeg", "-hide_banner", "-v", "error", "-y",
239         *_FFMPEG_TOLERANT,
240         "-i", str(source),
241         "-map", "0:a:0", "-map_chapters", "0",
242         "-vn", "-sn",
243         "-c:a", "aac", "-b:a", "64k", "-ar", "16000", "-ac", "1",
244         str(dest),
245     ]
246     proc = subprocess.run(
247         cmd, capture_output=True, timeout=settings.job_timeout_seconds
248     )
249     if proc.returncode != 0 or not dest.exists():
250         err = proc.stderr.decode("utf-8", errors="replace").strip().splitlines()
251         tail = " | ".join(err[-3:]) if err else "no stderr"
252         raise JobFailed(f"could not prepare the audio for alignment: {tail}")
253     return dest
254 
255 
256 def probe_chapters(path: Path) -> int:
257     """Chapter count, or 1 for a flat file.
258 
259     subplz processes chaptered m4b files one chapter at a time, so this is what
260     turns a stream of per-chapter progress bars into an overall percentage.
261     """
262     try:
263         out = subprocess.run(
264             ["ffprobe", "-v", "error", "-show_chapters", "-print_format", "json",
265              str(path)],
266             capture_output=True, text=True, encoding="utf-8", errors="replace",
267             timeout=120, check=True,
268         )
269         return max(1, len(json.loads(out.stdout or "{}").get("chapters", [])))
270     except (subprocess.SubprocessError, ValueError, OSError):
271         return 1
272 
273 
274 def probe_duration(path: Path) -> float | None:
275     """Audio length in seconds, via ffprobe. Used for progress and metadata."""
276     try:
277         out = subprocess.run(
278             [
279                 "ffprobe", "-v", "error",
280                 "-show_entries", "format=duration",
281                 "-of", "default=noprint_wrappers=1:nokey=1",
282                 str(path),
283             ],
284             capture_output=True, text=True, timeout=120, check=True,
285         )
286         return float(out.stdout.strip())
287     except (subprocess.SubprocessError, ValueError, FileNotFoundError, OSError):
288         return None
289 
290 
291 def _staged(inp: Path, suffixes: set[str]) -> Path:
292     """The one staged input with a suffix in `suffixes`."""
293     for p in sorted(inp.iterdir()):
294         if p.is_file() and p.suffix.lower() in suffixes:
295             return p
296     raise JobFailed(f"no staged input found with a supported extension in {inp.name}")
297 
298 
299 def build_request(job: Job, paths: Paths, audio: Path, chapters: int) -> AlignRequest:
300     """Describe the job in backend-neutral terms."""
301     languages.require(job.language)  # reject an unsupported code before we run
302     return AlignRequest(
303         audio=audio,
304         text=_staged(paths.inp, TEXT_SUFFIXES),
305         out_dir=paths.out,
306         language=job.language,
307         model=job.model,
308         device=settings.device,
309         threads=settings.threads or max(1, (os.cpu_count() or 4) - 1),
310         chapters=chapters,
311     )
312 
313 
314 def _set(job_id: str, **fields) -> None:
315     with SessionLocal() as s:
316         job = s.get(Job, job_id)
317         if job is None:
318             return
319         for k, v in fields.items():
320             setattr(job, k, v)
321         s.commit()
322 
323 
324 def _is_canceled(job_id: str) -> bool:
325     with SessionLocal() as s:
326         job = s.get(Job, job_id)
327         return job is not None and job.status == JobStatus.canceled
328 
329 
330 def run_job(job_id: str) -> None:
331     """Execute one job end to end. Never raises; failures land on the Job row."""
332     with SessionLocal() as s:
333         job = s.get(Job, job_id)
334         if job is None:
335             return
336         if job.status == JobStatus.canceled:
337             return
338         audio_name, text_name = job.audio_filename, job.text_filename
339         parts_count = job.audio_parts or 1
340 
341     paths = Paths.for_job(job_id)
342     log_path = paths.root / "subplz.log"
343     _set(job_id, status=JobStatus.running, started_at=utcnow(),
344          stage="Preparing", progress=0.01)
345 
346     try:
347         # No-op when this process received the upload; pulls from shared storage
348         # when the job was enqueued on another machine.
349         ensure_inputs(job_id)
350 
351         if parts_count > 1:
352             _set(job_id, stage=f"Joining {parts_count} audio parts", progress=0.02)
353             parts = sorted(p for p in (paths.inp / "parts").iterdir() if p.is_file())
354             if len(parts) != parts_count:
355                 raise JobFailed(
356                     f"expected {parts_count} audio parts but found {len(parts)}"
357                 )
358             audio_in = merge_parts(job_id, parts)
359         else:
360             audio_in = staged_audio_path(job_id, audio_name)
361 
362         if not audio_in.exists():
363             raise JobFailed(f"staged audio missing: {audio_in.name}")
364 
365         duration = probe_duration(audio_in)
366         chapters = probe_chapters(audio_in)
367 
368         # The aligner gets a normalised 16 kHz mono copy; the video keeps the
369         # full-quality one.
370         _set(job_id, stage="Preparing audio", progress=0.03)
371         align_audio = normalize_for_alignment(job_id, audio_in)
372 
373         _set(job_id, audio_duration_seconds=duration,
374              stage=f"Starting {aligner.name}", progress=0.04)
375 
376         with SessionLocal() as s:
377             job = s.get(Job, job_id)
378             request = build_request(job, paths, align_audio, chapters)
379 
380         returncode = _stream_aligner(job_id, request, log_path)
381 
382         if _is_canceled(job_id):
383             return
384 
385         if returncode != 0:
386             raise JobFailed(
387                 f"{aligner.name} exited with code {returncode}. "
388                 f"See the run log for details."
389             )
390 
391         _set(job_id, stage="Collecting output", progress=0.92)
392         _collect_artifacts(job_id, paths, log_path, duration, request, audio_in)
393 
394         # The audio, the book and the subtitles stay in the folder of the job
395         # for debugging. The operator deletes them by hand.
396         _set(job_id, status=JobStatus.succeeded, stage="Done", progress=1.0,
397              finished_at=utcnow())
398 
399     except Exception as exc:  # noqa: BLE001 - surfaced to the user on the Job row
400         if _is_canceled(job_id):
401             return
402         # Keep the log even on failure: it is the only way to debug an alignment.
403         try:
404             _store_log(job_id, log_path)
405         except Exception:
406             pass
407         _set(job_id, status=JobStatus.failed, error=str(exc)[:4000],
408              stage="Failed", finished_at=utcnow())
409         # Our failure, not theirs: a credit spent on this run goes back.
410         try:
411             billing.refund_job(job_id)
412         except Exception:  # noqa: BLE001
413             log.exception("job %s: could not refund after failure", job_id)
414 
415 
416 def _stream_aligner(job_id: str, request: AlignRequest, log_path: Path) -> int:
417     """Run the aligner, mirroring its output to a log file and to job progress.
418 
419     The backend decides what its output means; this only moves bytes and
420     watches for cancellation and the time limit.
421     """
422     cmd = aligner.build_command(request)
423     reader = aligner.progress_reader(request)
424 
425     log_path.parent.mkdir(parents=True, exist_ok=True)
426     deadline = time.monotonic() + settings.job_timeout_seconds
427 
428     with log_path.open("w", encoding="utf-8", errors="replace") as log:
429         log.write("$ " + " ".join(cmd) + "\n\n")
430         log.flush()
431 
432         proc = subprocess.Popen(
433             cmd,
434             stdout=subprocess.PIPE,
435             stderr=subprocess.STDOUT,
436             text=True,
437             encoding="utf-8",
438             errors="replace",
439             bufsize=1,
440             env=aligner.environment(),
441             cwd=str(log_path.parent),
442         )
443 
444         try:
445             assert proc.stdout is not None
446             for raw in proc.stdout:
447                 log.write(raw)
448                 log.flush()
449 
450                 if time.monotonic() > deadline:
451                     proc.kill()
452                     raise JobFailed(
453                         f"job exceeded the {settings.job_timeout_seconds}s time limit"
454                     )
455 
456                 if _is_canceled(job_id):
457                     proc.kill()
458                     return proc.wait()
459 
460                 line = raw.strip()
461                 if not line:
462                     continue
463 
464                 update = reader.feed(line)
465                 if update is None:
466                     continue
467                 fields: dict = {"stage": update.stage}
468                 if update.fraction is not None:
469                     fields["progress"] = update.fraction
470                 _set(job_id, **fields)
471         finally:
472             if proc.poll() is None:
473                 proc.kill()
474             proc.wait()
475 
476         return proc.returncode
477 
478 
479 def _collect_artifacts(job_id: str, paths: Paths, log_path: Path,
480                        duration: float | None, request: AlignRequest,
481                        video_audio: Path) -> None:
482     with SessionLocal() as s:
483         job = s.get(Job, job_id)
484         if job is None:
485             raise JobFailed("job vanished while collecting output")
486         language, model = job.language, job.model
487         audio_name, text_name = job.audio_filename, job.text_filename
488         splitter = job.splitter
489         parts_count = job.audio_parts or 1
490 
491     produced = aligner.locate_output(request)
492     if produced is None or not produced.exists():
493         # The backend may exit 0 and still have failed, so ask it why before
494         # falling back to guessing at the content.
495         reason = aligner.failure_reason(request)
496         if reason:
497             raise JobFailed(f"{aligner.name} failed: {reason}")
498         raise JobFailed(
499             f"{aligner.name} finished but produced no subtitle file. The audio "
500             "and text may not match, or the language may be wrong for this book."
501         )
502 
503     # Give the download a filename the user will recognise. A per-chapter
504     # audiobook has no single audio name worth using, so the book names it.
505     stem = Path(text_name if parts_count > 1 else audio_name).stem
506     download_name = f"{stem}.{language}{aligner.output_suffix}"
507 
508     cues, first_cue, last_cue = _summarize_srt(produced)
509 
510     srt_key = f"{job_id}/{download_name}"
511     size = storage.put_file(srt_key, produced)
512 
513     # Three deliverables, because they serve three different jobs:
514     #   .srt  - the timing file on its own (HoshiReader whispersync)
515     #   .mp4  - clean video, no subtitle track, for YouTube (captions are
516     #           uploaded separately there)
517     #   .mkv  - the same video with the subtitles embedded, for local players
518     # The mkv is a stream copy of the mp4, so the second file is nearly free.
519     video_name = video_key = None
520     embed_name = embed_key = None
521     video_size = embed_size = 0
522 
523     if settings.render_video:
524         try:
525             _set(job_id, stage="Rendering video", progress=0.94)
526             scratch = paths.root / "video"
527             # A cover the user supplied wins over the one inside the epub.
528             cover = find_cover(job_id) or render.extract_cover(request.text, scratch)
529             canvas = render.build_canvas(cover, scratch / "canvas.png")
530 
531             video_name = f"{stem}.{language}.mp4"
532             out_video = scratch / video_name
533             render.render_video(
534                 audio=video_audio, canvas=canvas,
535                 dest=out_video, duration=duration,
536             )
537             video_key = f"{job_id}/{video_name}"
538             video_size = storage.put_file(video_key, out_video)
539 
540             try:
541                 _set(job_id, stage="Embedding subtitles", progress=0.97)
542                 embed_name = f"{stem}.{language}.mkv"
543                 out_embed = scratch / embed_name
544                 render.mux_subtitles(out_video, produced, out_embed)
545                 embed_key = f"{job_id}/{embed_name}"
546                 embed_size = storage.put_file(embed_key, out_embed)
547             except Exception as exc:  # noqa: BLE001
548                 embed_name = embed_key = None
549                 log.warning("job %s: subtitle embed failed: %s", job_id, exc)
550 
551         except Exception as exc:  # noqa: BLE001 - never fail a job over the video
552             # The subtitles are the product, so a failed render is not fatal -
553             # but it must not be silent either, or it looks like it never ran.
554             video_name = video_key = None
555             log.warning("job %s: video render failed: %s", job_id, exc,
556                         exc_info=True)
557 
558     metadata = {
559         "job_id": job_id,
560         "created_at": utcnow().astimezone(timezone.utc).isoformat(),
561         "source": {
562             "audio_filename": audio_name,
563             "audio_parts": parts_count,
564             "text_filename": text_name,
565             "audio_duration_seconds": duration,
566         },
567         "alignment": {
568             "backend": aligner.name,
569             "mode": "forced alignment against supplied text (nothing transcribed from scratch)",
570             "model": model,
571             "device": settings.device,
572             "language": language,
573             "language_name": (languages.get(language).name if languages.get(language) else language),
574             "sentence_splitter": splitter,
575         },
576         "output": {
577             "filename": download_name,
578             "format": "srt",
579             "cue_count": cues,
580             "first_cue_start": first_cue,
581             "last_cue_end": last_cue,
582             "size_bytes": size,
583         },
584         "video": (
585             {
586                 "filename": video_name,
587                 "container": "mp4",
588                 "subtitles": "none - upload the .srt to YouTube separately",
589                 "for": "youtube",
590                 "size_bytes": video_size,
591             }
592             if video_name
593             else None
594         ),
595         "video_embedded": (
596             {
597                 "filename": embed_name,
598                 "container": "mkv",
599                 "subtitles": "embedded SRT track, enabled by default",
600                 "for": "local playback (MPV, VLC, Jellyfin)",
601                 "size_bytes": embed_size,
602             }
603             if embed_name
604             else None
605         ),
606     }
607     meta_path = paths.root / "metadata.json"
608     meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2),
609                          encoding="utf-8")
610     meta_key = f"{job_id}/metadata.json"
611     meta_size = storage.put_file(meta_key, meta_path)
612 
613     rows = [
614         Artifact(job_id=job_id, kind="srt", filename=download_name,
615                  storage_key=srt_key, size_bytes=size),
616         Artifact(job_id=job_id, kind="metadata", filename="metadata.json",
617                  storage_key=meta_key, size_bytes=meta_size),
618     ]
619     if video_name and video_key:
620         rows.append(
621             Artifact(job_id=job_id, kind="video", filename=video_name,
622                      storage_key=video_key, size_bytes=video_size)
623         )
624     if embed_name and embed_key:
625         rows.append(
626             Artifact(job_id=job_id, kind="video_embedded", filename=embed_name,
627                      storage_key=embed_key, size_bytes=embed_size)
628         )
629     with SessionLocal() as s:
630         for r in rows:
631             s.add(r)
632         s.commit()
633 
634     _store_log(job_id, log_path)
635 
636 
637 def _store_log(job_id: str, log_path: Path) -> None:
638     if not log_path.exists():
639         return
640     with SessionLocal() as s:
641         existing = (
642             s.query(Artifact)
643             .filter(Artifact.job_id == job_id, Artifact.kind == "log")
644             .first()
645         )
646         if existing is not None:
647             return
648     key = f"{job_id}/subplz.log"
649     size = storage.put_file(key, log_path)
650     with SessionLocal() as s:
651         s.add(Artifact(job_id=job_id, kind="log", filename="subplz.log",
652                        storage_key=key, size_bytes=size))
653         s.commit()
654 
655 
656 _TIME = re.compile(r"(\d{2}):(\d{2}):(\d{2}),(\d{3})")
657 
658 
659 def _summarize_srt(path: Path) -> tuple[int, float | None, float | None]:
660     """Cue count and span - cheap sanity signal that the alignment covered the book."""
661     def to_seconds(m: re.Match) -> float:
662         h, mi, s, ms = (int(g) for g in m.groups())
663         return h * 3600 + mi * 60 + s + ms / 1000
664 
665     text = path.read_text(encoding="utf-8", errors="replace")
666     stamps = list(_TIME.finditer(text))
667     count = text.count(" --> ")
668     if not stamps:
669         return count, None, None
670     return count, to_seconds(stamps[0]), to_seconds(stamps[-1])