7158f1fff92dd10a4e1afa8e1ae52046ac326227
Ветки
Перейти к файлуt
A
anc
Запись всего звука, папка хранения, точнее распознавание
- Звук собеседников — весь звук компьютера, кроме самого Skillok Meet:
Zoom, Google Meet, Телемост, что угодно. Windows — process loopback в
режиме исключения своего процесса, macOS — ScreenCaptureKit без фильтра
по приложению. Поиск Zoom и зависимость windows убраны.
- Если звук компьютера недоступен (на Mac — нет разрешения), запись идёт
с микрофона, а интерфейс говорит почему.
- Настройки: папка хранения записей (выбрать, открыть, вернуть по
умолчанию; записи переносятся целиком), точность распознавания —
по умолчанию полная whisper-large-v3.
- «Открыть папку с файлами» у записи; микрофон на Mac просит у CoreAudio
сразу 16 кГц.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
96a28a9
30 сентября 2026 г.
meetings.rs
//! Встречи на диске. Встреча — это заметка в Markdown и, если есть, звук:
//!
//! ```text
//! <корень>/20260930-154300/
//! meeting.json — название, даты, дорожки звука, когда расшифрована
//! note.md — результаты разделами: «Расшифровка», «Конспект», «Задачи»…
//! Человек может их править; единственный источник текста
//! audio/ — дорожки: me.wav и others.wav из записи или исходный файл
//! ```
//!
//! Программа ничего не делает сама: каждое изменение — по действию человека
//! (записать, добавить аудио, расшифровать, получить результат, правка).
//! Изменения «прочитал — поменял — записал» идут под одним замком.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use crate::transcript::Speaker;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AudioTrack {
/// Имя файла в папке `audio/`.
pub file: String,
pub speaker: Speaker,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Meeting {
pub id: String,
pub title: String,
pub created_at: DateTime<Local>,
pub updated_at: DateTime<Local>,
pub audio: Vec<AudioTrack>,
/// Идёт запись: дорожки ещё пишутся.
#[serde(default)]
pub recording: bool,
pub transcribed_at: Option<DateTime<Local>>,
}
impl Meeting {
pub fn duration_ms(&self) -> u64 {
self.audio.iter().map(|a| a.duration_ms).max().unwrap_or(0)
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct MeetingDetail {
pub meeting: Meeting,
pub note: String,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("встреча «{0}» не найдена")]
NotFound(String),
#[error("название не может быть пустым")]
BlankTitle,
#[error("файл встречи: {0}")]
Io(#[from] std::io::Error),
#[error("файл встречи повреждён: {0}")]
Corrupt(#[from] serde_json::Error),
}
pub struct Store {
root: PathBuf,
write: Mutex<()>,
}
impl Store {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
write: Mutex::new(()),
}
}
pub fn root(&self) -> &Path {
&self.root
}
fn lock(&self) -> std::sync::MutexGuard<'_, ()> {
self.write.lock().unwrap_or_else(|p| p.into_inner())
}
/// Все встречи, свежие сверху. Повреждённая папка пропускается.
pub fn list(&self) -> Result<Vec<Meeting>, Error> {
let entries = match std::fs::read_dir(&self.root) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut out: Vec<Meeting> = entries
.flatten()
.filter_map(|e| std::fs::read_to_string(e.path().join("meeting.json")).ok())
.filter_map(|t| serde_json::from_str(&t).ok())
.collect();
out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id)));
Ok(out)
}
pub fn get(&self, id: &str) -> Result<MeetingDetail, Error> {
let dir = self.dir(id)?;
Ok(MeetingDetail {
meeting: read_json(&dir.join("meeting.json"))?,
note: std::fs::read_to_string(dir.join("note.md")).unwrap_or_default(),
})
}
/// Новая встреча: пустая заметка, звука нет.
pub fn create(&self, title: &str, now: DateTime<Local>) -> Result<Meeting, Error> {
let title = title.trim();
if title.is_empty() {
return Err(Error::BlankTitle);
}
let _guard = self.lock();
let id = self.free_id(now);
let dir = self.root.join(&id);
std::fs::create_dir_all(dir.join("audio"))?;
let meeting = Meeting {
id,
title: title.to_string(),
created_at: now,
updated_at: now,
audio: Vec::new(),
recording: false,
transcribed_at: None,
};
std::fs::write(dir.join("note.md"), "")?;
// meeting.json последним: пока его нет, список встречу не видит.
write_json(&dir.join("meeting.json"), &meeting)?;
Ok(meeting)
}
/// Папка встречи: заметка и звук.
pub fn folder(&self, id: &str) -> Result<PathBuf, Error> {
self.dir(id)
}
/// Путь для дорожки внутри встречи.
pub fn audio_path(&self, id: &str, file: &str) -> Result<PathBuf, Error> {
let safe = !file.is_empty() && !file.contains(['/', '\\']) && file != "." && file != "..";
if !safe {
return Err(Error::NotFound(file.to_string()));
}
Ok(self.dir(id)?.join("audio").join(file))
}
/// Копирует аудиофайл во встречу и записывает его дорожкой.
pub fn attach_audio_file(&self, id: &str, source: &Path, duration_ms: u64) -> Result<Meeting, Error> {
let name = source
.file_name()
.and_then(|n| n.to_str())
.map(sanitize_file_name)
.unwrap_or_else(|| "audio".into());
let target = self.audio_path(id, &name)?;
std::fs::copy(source, &target)?;
self.update(id, |m| {
m.audio.push(AudioTrack {
file: name.clone(),
speaker: Speaker::Unknown,
duration_ms,
});
})
}
pub fn set_recording(
&self,
id: &str,
recording: bool,
tracks: Vec<AudioTrack>,
) -> Result<Meeting, Error> {
self.update(id, |m| {
m.recording = recording;
m.audio = tracks.clone();
})
}
pub fn save_note(&self, id: &str, text: &str) -> Result<Meeting, Error> {
let _guard = self.lock();
let dir = self.dir(id)?;
std::fs::write(dir.join("note.md"), text)?;
let mut m: Meeting = read_json(&dir.join("meeting.json"))?;
m.updated_at = Local::now();
write_json(&dir.join("meeting.json"), &m)?;
Ok(m)
}
/// Ставит раздел на место прежнего с тем же заголовком или в конец.
/// Возвращает заметку целиком.
pub fn upsert_section(&self, id: &str, heading: &str, block: &str) -> Result<String, Error> {
self.change_note(id, |note| crate::notes::upsert(note, heading, block))
}
pub fn remove_section(&self, id: &str, heading: &str) -> Result<String, Error> {
self.change_note(id, |note| crate::notes::remove(note, heading))
}
fn change_note(&self, id: &str, change: impl Fn(&str) -> String) -> Result<String, Error> {
let _guard = self.lock();
let dir = self.dir(id)?;
let note = change(&std::fs::read_to_string(dir.join("note.md")).unwrap_or_default());
std::fs::write(dir.join("note.md"), ¬e)?;
let mut m: Meeting = read_json(&dir.join("meeting.json"))?;
m.updated_at = Local::now();
write_json(&dir.join("meeting.json"), &m)?;
Ok(note)
}
pub fn mark_transcribed(&self, id: &str, at: DateTime<Local>) -> Result<Meeting, Error> {
self.update(id, |m| m.transcribed_at = Some(at))
}
pub fn rename(&self, id: &str, title: &str) -> Result<Meeting, Error> {
let title = title.trim().to_string();
if title.is_empty() {
return Err(Error::BlankTitle);
}
self.update(id, |m| m.title = title.clone())
}
pub fn delete(&self, id: &str) -> Result<(), Error> {
let _guard = self.lock();
let dir = self.dir(id)?;
std::fs::remove_dir_all(dir)?;
Ok(())
}
/// Переносит все записи в другую папку — когда человек выбрал новую папку
/// хранения. Запись переносится целиком или не переносится: при переносе
/// на другой диск папка сначала копируется, и только потом удаляется
/// старая. Возвращает число перенесённых записей.
pub fn move_all_to(&self, target: &Path) -> Result<usize, Error> {
let _guard = self.lock();
std::fs::create_dir_all(target)?;
if same_dir(&self.root, target) {
return Ok(0);
}
let ids: Vec<String> = match std::fs::read_dir(&self.root) {
Ok(entries) => entries
.flatten()
.filter(|e| e.path().join("meeting.json").is_file())
.filter_map(|e| e.file_name().to_str().map(str::to_string))
.collect(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e.into()),
};
if let Some(clash) = ids.iter().find(|id| target.join(id).exists()) {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("в новой папке уже есть запись «{clash}» — выберите пустую папку"),
)));
}
for id in &ids {
let from = self.root.join(id);
let to = target.join(id);
if std::fs::rename(&from, &to).is_err() {
copy_dir(&from, &to)?;
std::fs::remove_dir_all(&from)?;
}
}
Ok(ids.len())
}
/// Запись, оборванная выключением компьютера, перестаёт считаться идущей;
/// дорожки на диске остаются — их можно расшифровать.
pub fn recover_interrupted(&self) -> Result<Vec<String>, Error> {
let mut ids = Vec::new();
for m in self.list()? {
if m.recording {
self.update(&m.id, |m| m.recording = false)?;
ids.push(m.id);
}
}
Ok(ids)
}
fn update(&self, id: &str, change: impl Fn(&mut Meeting)) -> Result<Meeting, Error> {
let _guard = self.lock();
let path = self.dir(id)?.join("meeting.json");
let mut m: Meeting = read_json(&path)?;
change(&mut m);
m.updated_at = Local::now();
write_json(&path, &m)?;
Ok(m)
}
/// Папка встречи. Идентификатор приходит из интерфейса: в путь попадают
/// только цифры и дефис.
fn dir(&self, id: &str) -> Result<PathBuf, Error> {
let valid = !id.is_empty() && id.len() <= 32 && id.chars().all(|c| c.is_ascii_digit() || c == '-');
let dir = self.root.join(id);
if valid && dir.join("meeting.json").is_file() {
Ok(dir)
} else {
Err(Error::NotFound(id.to_string()))
}
}
fn free_id(&self, now: DateTime<Local>) -> String {
let base = now.format("%Y%m%d-%H%M%S").to_string();
let mut id = base.clone();
let mut n = 1;
while self.root.join(&id).exists() {
n += 1;
id = format!("{base}-{n}");
}
id
}
}
fn same_dir(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let target = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), &target)?;
}
}
Ok(())
}
/// Имя файла без символов, которые Windows не разрешает.
fn sanitize_file_name(name: &str) -> String {
let clean: String = name
.chars()
.map(|c| {
if r#"<>:"/\|?*"#.contains(c) || c.is_control() {
'_'
} else {
c
}
})
.collect();
let clean = clean.trim_matches(['.', ' ']).to_string();
if clean.is_empty() {
"audio".into()
} else {
clean
}
}
fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, Error> {
Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
}
fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), Error> {
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(value)?)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn at(h: u32, m: u32) -> DateTime<Local> {
Local.with_ymd_and_hms(2026, 9, 30, h, m, 0).unwrap()
}
#[test]
fn create_list_get() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path().join("m"));
assert!(store.list().unwrap().is_empty(), "нет папки — пустой список");
let a = store.create("Планёрка", at(10, 0)).unwrap();
let b = store.create("Созвон", at(11, 0)).unwrap();
assert_eq!(a.id, "20260930-100000");
let ids: Vec<_> = store.list().unwrap().into_iter().map(|m| m.id).collect();
assert_eq!(ids, vec![b.id, a.id.clone()], "свежие сверху");
assert_eq!(store.get(&a.id).unwrap().note, "");
assert!(matches!(store.create(" ", at(12, 0)), Err(Error::BlankTitle)));
}
#[test]
fn same_second_gets_a_new_id() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
assert_eq!(store.create("Раз", at(10, 0)).unwrap().id, "20260930-100000");
assert_eq!(store.create("Два", at(10, 0)).unwrap().id, "20260930-100000-2");
}
#[test]
fn sections_are_placed_and_removed() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
let m = store.create("Встреча", at(10, 0)).unwrap();
store
.upsert_section(&m.id, "Расшифровка", "## Расшифровка\n\nтекст")
.unwrap();
store
.upsert_section(&m.id, "Задачи", "## Задачи\n\n- [ ] отчёт")
.unwrap();
let note = store
.upsert_section(&m.id, "Расшифровка", "## Расшифровка\n\nновый текст")
.unwrap();
assert_eq!(
note,
"## Расшифровка\n\nновый текст\n\n## Задачи\n\n- [ ] отчёт\n"
);
assert_eq!(
store.remove_section(&m.id, "Задачи").unwrap(),
"## Расшифровка\n\nновый текст\n"
);
assert_eq!(store.get(&m.id).unwrap().note, "## Расшифровка\n\nновый текст\n");
}
#[test]
fn audio_file_is_copied_in_with_a_safe_name() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path().join("m"));
let m = store.create("Диктофон", at(10, 0)).unwrap();
let src = dir.path().join("Запись: 1?.m4a");
std::fs::write(&src, b"audio").unwrap();
let m = store.attach_audio_file(&m.id, &src, 61_000).unwrap();
assert_eq!(m.audio[0].file, "Запись_ 1_.m4a");
assert_eq!(m.audio[0].speaker, Speaker::Unknown);
assert_eq!(m.duration_ms(), 61_000);
assert_eq!(
std::fs::read(store.audio_path(&m.id, &m.audio[0].file).unwrap()).unwrap(),
b"audio"
);
}
#[test]
fn rename_delete_and_bad_paths() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path().join("m"));
let m = store.create("Старое", at(10, 0)).unwrap();
assert_eq!(store.rename(&m.id, " Новое ").unwrap().title, "Новое");
assert!(matches!(store.rename(&m.id, ""), Err(Error::BlankTitle)));
assert!(store.audio_path(&m.id, "../meeting.json").is_err());
assert!(store.audio_path(&m.id, "..").is_err());
for id in ["../x", "", "a/b"] {
assert!(matches!(store.get(id), Err(Error::NotFound(_))), "{id}");
}
store.delete(&m.id).unwrap();
assert!(store.list().unwrap().is_empty());
}
#[test]
fn meetings_move_to_a_new_folder() {
let dir = tempfile::tempdir().unwrap();
let old = Store::new(dir.path().join("old"));
let a = old.create("Раз", at(10, 0)).unwrap();
let b = old.create("Два", at(11, 0)).unwrap();
std::fs::write(old.audio_path(&a.id, "me.wav").unwrap(), b"sound").unwrap();
old.upsert_section(&b.id, "Задачи", "## Задачи\n\n- [ ] x")
.unwrap();
let target = dir.path().join("Записи встреч");
assert_eq!(old.move_all_to(&target).unwrap(), 2);
let new = Store::new(&target);
assert_eq!(new.list().unwrap().len(), 2);
assert_eq!(
std::fs::read(new.audio_path(&a.id, "me.wav").unwrap()).unwrap(),
b"sound"
);
assert!(new.get(&b.id).unwrap().note.contains("- [ ] x"));
assert!(
old.list().unwrap().is_empty(),
"в старой папке ничего не осталось"
);
assert_eq!(
new.move_all_to(&target).unwrap(),
0,
"та же папка — ничего не делаем"
);
}
#[test]
fn move_refuses_to_overwrite() {
let dir = tempfile::tempdir().unwrap();
let old = Store::new(dir.path().join("old"));
let a = old.create("Раз", at(10, 0)).unwrap();
let target = dir.path().join("new");
std::fs::create_dir_all(target.join(&a.id)).unwrap();
assert!(old.move_all_to(&target).is_err());
assert_eq!(old.list().unwrap().len(), 1, "ничего не тронуто");
}
#[test]
fn interrupted_recording_is_released_on_start() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
let m = store.create("Запись", at(10, 0)).unwrap();
let track = AudioTrack {
file: "me.wav".into(),
speaker: Speaker::Me,
duration_ms: 5_000,
};
store.set_recording(&m.id, true, vec![track.clone()]).unwrap();
assert_eq!(store.recover_interrupted().unwrap(), vec![m.id.clone()]);
let after = store.get(&m.id).unwrap().meeting;
assert!(!after.recording);
assert_eq!(
after.audio,
vec![track],
"дорожки остаются — их можно расшифровать"
);
}
}