diff --git a/apps/import/routes.py b/apps/import/routes.py index c5bb80fdb..1a720ea69 100644 --- a/apps/import/routes.py +++ b/apps/import/routes.py @@ -13,6 +13,7 @@ from flask import Blueprint, jsonify, render_template, request from werkzeug.utils import secure_filename from convey import emit, state +from media import MEDIA_EXTENSIONS from think.detect_created import detect_created from think.importers.utils import ( build_import_info, @@ -120,7 +121,7 @@ SOURCE_METADATA = [ "input_type": "file", "upload_prompt": "Upload an audio file (.m4a, .mp3, .wav)", "has_guide": False, - "accept": ".m4a,.mp3,.wav,.ogg,.webm", + "accept": ",".join(sorted(MEDIA_EXTENSIONS)), }, { "name": "document", diff --git a/media.py b/media.py new file mode 100644 index 000000000..7de600c87 --- /dev/null +++ b/media.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Media format registry - single source of truth for extensions, MIME types, and kind.""" + +FORMATS = [ + (".flac", "audio/flac", "audio"), + (".opus", "audio/opus", "audio"), + (".ogg", "audio/ogg", "audio"), + (".m4a", "audio/mp4", "audio"), + (".mp3", "audio/mpeg", "audio"), + (".wav", "audio/wav", "audio"), + (".webm", "video/webm", "video"), + (".mp4", "video/mp4", "video"), + (".mov", "video/quicktime", "video"), +] + +AUDIO_EXTENSIONS: frozenset[str] = frozenset( + ext for ext, _, kind in FORMATS if kind == "audio" +) +VIDEO_EXTENSIONS: frozenset[str] = frozenset( + ext for ext, _, kind in FORMATS if kind == "video" +) +MEDIA_EXTENSIONS: frozenset[str] = frozenset(ext for ext, _, _ in FORMATS) +MIME_TYPES: dict[str, str] = {ext: mime for ext, mime, _ in FORMATS} diff --git a/observe/transcribe/main.py b/observe/transcribe/main.py index dfa8e2a35..053148294 100644 --- a/observe/transcribe/main.py +++ b/observe/transcribe/main.py @@ -54,6 +54,7 @@ from pathlib import Path import numpy as np +from media import AUDIO_EXTENSIONS as SUPPORTED_AUDIO_FORMATS from observe.transcribe import ( BACKEND_REGISTRY, get_backend, @@ -99,9 +100,6 @@ MIN_STATEMENT_DURATION = 0.3 # Number of recent entity names to load for transcription context ENTITY_NAMES_LIMIT = 40 -# Supported audio file formats for transcription -SUPPORTED_AUDIO_FORMATS = {".flac", ".m4a", ".mp3", ".ogg", ".opus", ".wav"} - # Module-level voice encoder cache _voice_encoder = None diff --git a/observe/utils.py b/observe/utils.py index 1020b0b3c..396390987 100644 --- a/observe/utils.py +++ b/observe/utils.py @@ -16,6 +16,8 @@ from pathlib import Path import numpy as np import soundfile as sf +from media import AUDIO_EXTENSIONS as _AUDIO_EXTENSIONS +from media import VIDEO_EXTENSIONS as _VIDEO_EXTENSIONS from think.utils import day_path logger = logging.getLogger(__name__) @@ -23,8 +25,8 @@ logger = logging.getLogger(__name__) # Standard sample rate for audio processing SAMPLE_RATE = 16000 -VIDEO_EXTENSIONS = (".webm", ".mp4", ".mov") -AUDIO_EXTENSIONS = (".flac", ".ogg", ".m4a", ".opus", ".mp3") +VIDEO_EXTENSIONS = tuple(_VIDEO_EXTENSIONS) +AUDIO_EXTENSIONS = tuple(_AUDIO_EXTENSIONS) def audio_to_flac_bytes(audio: np.ndarray, sample_rate: int) -> bytes: diff --git a/pyproject.toml b/pyproject.toml index c5358e525..1b3448431 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ Documentation = "https://github.com/solpbc/solstone/blob/main/README.md" include = ["apps*", "think*", "convey*", "observe*", "muse*"] [tool.setuptools] -py-modules = ["sol"] +py-modules = ["media", "sol"] [tool.setuptools.package-data] apps = ["*/templates/*.html", "*/muse/*.md"] diff --git a/tests/test_retention.py b/tests/test_retention.py index 449bec6a2..14614bda3 100644 --- a/tests/test_retention.py +++ b/tests/test_retention.py @@ -26,7 +26,7 @@ from think.retention import ( class TestIsRawMedia: def test_audio_extensions(self, tmp_path): - for ext in (".flac", ".opus", ".ogg", ".m4a"): + for ext in (".flac", ".opus", ".ogg", ".m4a", ".mp3", ".wav"): p = tmp_path / f"audio{ext}" p.touch() assert is_raw_media(p), f"{ext} should be raw media" diff --git a/think/importers/shared.py b/think/importers/shared.py index b0cb0e8a3..ad8604444 100644 --- a/think/importers/shared.py +++ b/think/importers/shared.py @@ -12,6 +12,7 @@ import shutil from pathlib import Path from typing import TYPE_CHECKING, Any, Callable +from media import MIME_TYPES from think.importers.utils import save_import_file, write_import_metadata from think.utils import day_path, get_journal, now_ms @@ -283,15 +284,7 @@ def write_markdown_segments( # MIME type mapping for import metadata _MIME_TYPES = { - ".m4a": "audio/mp4", - ".mp3": "audio/mpeg", - ".wav": "audio/wav", - ".flac": "audio/flac", - ".ogg": "audio/ogg", - ".opus": "audio/opus", - ".mp4": "video/mp4", - ".webm": "video/webm", - ".mov": "video/quicktime", + **MIME_TYPES, ".txt": "text/plain", ".md": "text/markdown", ".pdf": "application/pdf", diff --git a/think/retention.py b/think/retention.py index 8eaf2bf71..e43622d76 100644 --- a/think/retention.py +++ b/think/retention.py @@ -23,6 +23,9 @@ from datetime import datetime from pathlib import Path from typing import Any +from media import AUDIO_EXTENSIONS as RAW_AUDIO_EXTENSIONS +from media import MEDIA_EXTENSIONS as RAW_MEDIA_EXTENSIONS +from media import VIDEO_EXTENSIONS as RAW_VIDEO_EXTENSIONS from think.utils import day_dirs, get_journal, iter_segments logger = logging.getLogger(__name__) @@ -31,15 +34,11 @@ logger = logging.getLogger(__name__) # Raw media file identification # --------------------------------------------------------------------------- -RAW_AUDIO_EXTENSIONS = frozenset({".flac", ".opus", ".ogg", ".m4a"}) -RAW_VIDEO_EXTENSIONS = frozenset({".webm", ".mov", ".mp4"}) -RAW_MEDIA_EXTENSIONS = RAW_AUDIO_EXTENSIONS | RAW_VIDEO_EXTENSIONS - def is_raw_media(path: Path) -> bool: """Check if a file is raw media (layer 1 capture). - Raw media: *.flac, *.opus, *.ogg, *.m4a (audio), + Raw media: *.flac, *.opus, *.ogg, *.m4a, *.mp3, *.wav (audio), *.webm, *.mov, *.mp4 (video), monitor_*_diff.png (screen diffs). """ if path.suffix.lower() in RAW_MEDIA_EXTENSIONS: diff --git a/think/utils.py b/think/utils.py index 0854e7cc2..eb777202c 100644 --- a/think/utils.py +++ b/think/utils.py @@ -26,6 +26,8 @@ import platformdirs from dotenv import load_dotenv from timefhuman import timefhuman +from media import MIME_TYPES + DATE_RE = re.compile(r"\d{8}") _journal_path_cache: str | None = None @@ -800,24 +802,8 @@ def get_raw_file(day: str, name: str) -> tuple[str, str, Any]: if not rel: raise ValueError(f"No 'raw' field found in metadata for {name}") - # Determine MIME type from raw file extension - if rel.endswith(".flac"): - mime = "audio/flac" - elif rel.endswith(".ogg"): - mime = "audio/ogg" - elif rel.endswith(".m4a"): - mime = "audio/mp4" - elif rel.endswith(".png"): - mime = "image/png" - elif rel.endswith(".webm"): - mime = "video/webm" - elif rel.endswith(".mp4"): - mime = "video/mp4" - elif rel.endswith(".mov"): - mime = "video/quicktime" - else: - # Default fallback for unknown types - mime = "application/octet-stream" + suffix = Path(rel).suffix.lower() + mime = {**MIME_TYPES, ".png": "image/png"}.get(suffix, "application/octet-stream") return rel, mime, meta