• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

AlexeyBond / Irene-Voice-Assistant / 7150844465

09 Dec 2023 11:18AM UTC coverage: 64.307%. Remained the same
7150844465

push

github

AlexeyBond
60 * 60 != 360;

0 of 1 new or added line in 1 file covered. (0.0%)

3007 of 4676 relevant lines covered (64.31%)

0.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/irene/embedded_plugins/plugin_tts_cache.py
1
"""
2
Добавляет кеширование результатов работы TTS.
3

4
Кеш реализован в виде папки с файлами, где имя файла является хешем от типа TTS, его настроек и озвученной фразы.
5

6
Плагин так же осуществляет периодическую очистку папки кеша. В зависимости от настроек, файлы могут удаляться если:
7
- они не использовались дольше заданного времени
8
- кеш занимает больше места, чем разрешено
9
- файлов накопилось больше заданного количества
10
"""
11

12
import asyncio
×
13
import os
×
14
from datetime import datetime
×
15
from hashlib import sha256
×
16
from logging import getLogger
×
17
from pathlib import Path
×
18
from shutil import copy
×
19
from typing import TypedDict, Optional, Any
×
20

21
from irene.face.abc import FileWritingTTS, TTSResultFile
×
22
from irene.face.tts_helpers import PersistentTTSResultFile, create_disposable_tts_result_file
×
23
from irene.plugin_loader.file_patterns import first_substitution
×
24
from irene.utils.metadata import MetadataMapping
×
25

26
name = 'tts_cache'
×
27
version = '0.2.0'
×
28

29
_logger = getLogger(name)
×
30

31

32
class _Config(TypedDict):
×
33
    cache_path: str
×
34
    max_files: Optional[int]
×
35
    max_size: Optional[float]
×
36
    max_age: Optional[float]
×
37
    cleanup_interval: float
×
38

39

40
config: _Config = {
×
41
    'cache_path': '{irene_home}/cache/tts',
42
    'max_files': -1,
43
    'max_size': -1,
44
    'max_age': -1,
45
    'cleanup_interval': 1.0,
46
}
47

48
config_comment = """
×
49
Настройки кеширования файлов TTS.
50

51
Доступные параметры:
52
- `cache_path`        - путь к папке, где хранятся файлы кеша
53
- `max_files`         - максимальное количество хранимых файлов кеша.
54
                        0, `null` или значение меньше 0 означают, что количество файлов не ограничено.
55
- `max_size`          - максимальный суммарный размер (в мибибайтах), всех файлов кеша.
56
                        0, `null` или значение меньше 0 означают, что размер файлов не ограничен.
57
- `max_age`           - максимальное время хранения (в сутках с последнего использования) файлов в кеше.
58
                        0, `null` или значение меньше 0 означают, что файлы кеша могут храниться сколь угодно долго.
59
- `cleanup_interval`  - интервал (в часах) с которым происходит очистка кеша.
60
"""
61

62

63
def _ensure_cache_dir() -> Path:
×
64
    """
65
    Убеждается, что папка кеша существует и возвращает путь к ней.
66
    """
67
    cache_dir_path = Path(first_substitution(config['cache_path'])).absolute()
×
68

69
    cache_dir_path.mkdir(parents=True, exist_ok=True)
×
70

71
    return cache_dir_path
×
72

73

74
def _find_existing_file(base_name: str) -> Path:
×
75
    """
76
    Ищет файл с заданным базовым именем в кеше.
77

78
    Args:
79
        base_name:
80
    Returns:
81
        путь к найденному файлу
82
    Raises:
83
        FileNotFoundError - если подходящего файла не найдено
84
    """
85
    # В кеше может лежать несколько версий файла - оригинальный файл, созданный TTS и его варианты, преобразованные в
86
    # другие форматы AudioConverter'ом.
87
    matching = list(_ensure_cache_dir().glob(f'{base_name}.*'))
×
88

89
    if len(matching) == 0:
×
90
        raise FileNotFoundError()
×
91

92
    time = datetime.now().timestamp()
×
93

94
    for file_path in matching:
×
95
        # Нельзя использовать file_path.touch() так как он создаст файл если он был только что удалён потоком очистки
96
        os.utime(file_path, (time, time))
×
97

98
    # Имена преобразованных файлов будут длиннее, чем у оригинала. Поэтому, возвращаем к файлу с самым коротким именем.
99
    return min(matching, key=lambda it: len(it.name))
×
100

101

102
def _cache_file_sort(path: Path) -> Any:
×
103
    """
104
    Возвращает ключ сортировки, используемый при очистке кеша.
105

106
    Файлы сортируются от новых к старым (по mtime), среди файлов с одинаковым mtime первыми идут файлы с более коротким
107
    именем. Таким образом, оригинальный файл будет ближе к началу списка (и удалится с меньшей вероятностью), чем
108
    варианты, преобразованные в другие форматы - AudioConverter гарантирует, что mtime новых преобразованных файлов
109
    совпадает с mtime оригинала.
110
    """
111
    return -path.stat().st_mtime, len(path.name)
×
112

113

114
def _do_cleanup() -> None:
×
115
    _logger.info("Ищу файлы кеша, которые пора удалить")
×
116

117
    cache_files: list[Path] = list(_ensure_cache_dir().iterdir())
×
118
    cache_files.sort(key=_cache_file_sort)
×
119
    n_files_to_delete = 0
×
120

121
    if (files_limit := (config['max_files'] or 0)) > 0:
×
122
        if len(cache_files) > files_limit:
×
123
            n_files_to_delete = max(n_files_to_delete, len(cache_files) - files_limit)
×
124

125
            _logger.debug(
×
126
                "В кеше %d файлов, %d из них будут удалены, чтобы оставить не более %d файлов",
127
                len(cache_files), n_files_to_delete, files_limit,
128
            )
129

130
    if (size_limit := (config['max_size'] or 0)) > 0:
×
131
        size_accumulator = 0.0  # MiB
×
132

133
        for i, file in enumerate(cache_files):
×
134
            size_accumulator += file.stat().st_size / 1024 / 1024  # Bytes -> MiBytes
×
135

136
            if size_accumulator > size_limit:
×
137
                n_files_to_delete = max(n_files_to_delete, len(cache_files) - i)
×
138

139
                _logger.debug(
×
140
                    "%d из %d самых новых файлов занимают %f Мибибайт, один из них и ещё %d файл(ов) будут удалены",
141
                    i + 1, len(cache_files), size_accumulator, len(cache_files) - (i + 1),
142
                )
143

144
                break
×
145

146
    if (age_limit := (config['max_age'] or 0)) > 0:
×
147
        max_mtime = datetime.now().timestamp() - age_limit * 60 * 60 * 24
×
148

149
        for i, file in enumerate(cache_files):
×
150
            if file.stat().st_mtime < max_mtime:
×
151
                n_files_to_delete = max(n_files_to_delete, len(cache_files) - i)
×
152

153
                _logger.debug(
×
154
                    "Все файлы кеша начиная с %dго из %d старше %f часов и будут удалены",
155
                    i + 1, len(cache_files), age_limit,
156
                )
157

158
    if n_files_to_delete == 0:
×
159
        _logger.debug("Нет файлов, требующих удаления")
×
160
        return
×
161

162
    _logger.info("Собираюсь удалить %d файл(ов) кеша", n_files_to_delete)
×
163

164
    if n_files_to_delete == len(cache_files):
×
165
        _logger.warning(
×
166
            "Собираюсь удалить все файлы кеша (%d). Возможно, стратегия очистки кеша настроена не верно.",
167
            n_files_to_delete,
168
        )
169

170
    for file in cache_files[-n_files_to_delete:]:
×
171
        _logger.debug("Удаляю файл %s", str(file))
×
172

173
        file.unlink(missing_ok=True)
×
174

175
    _logger.debug("Удаление файлов закончено")
×
176

177

178
def _respond_with_cached_file(file_path: Path, file_base_path: Optional[str]) -> TTSResultFile:
×
179
    """
180
    Создаёт объект TTSResultFile для файла, хранящегося в кеше.
181

182
    Args:
183
        file_path: путь к файлу в кеше
184
        file_base_path: базовый путь файла, запрошенный клиентом
185
    """
186
    if file_base_path is not None:
×
187
        # Если клиент требует файл, лежащий в определённом месте, то копируем файл из кеша туда и возвращаем
188
        # DisposableTTSResultFile, чтобы клиент удалил его после использования.
189
        result_file = create_disposable_tts_result_file(file_base_path)
×
190
        copy(file_path, result_file.get_full_path())
×
191
        return result_file
×
192

193
    # Если клиенту не важно, где лежит файл - то возвращаем PersistentTTSResultFile, напрямую указывающий на файл в кеше
194
    return PersistentTTSResultFile(str(file_path))
×
195

196

197
class _CachingFileTTS(FileWritingTTS):
×
198
    def __init__(self, wrapped: FileWritingTTS):
×
199
        self._wrapped = wrapped
×
200

201
    def _get_cache_file_base_name(self, text: str) -> str:
×
202
        args_hash = sha256(self._wrapped.get_name().encode('utf-8'))
×
203
        args_hash.update(self._wrapped.get_settings_hash().encode('utf-8'))
×
204
        args_hash.update(text.encode('utf-8'))
×
205

206
        return args_hash.hexdigest()
×
207

208
    def say_to_file(self, text: str, file_base_path: Optional[str] = None, **kwargs) -> TTSResultFile:
×
209
        if kwargs.get('no_cache', False):
×
210
            return self._wrapped.say_to_file(text, file_base_path, **kwargs)
×
211

212
        cached_file_base_name = self._get_cache_file_base_name(text)
×
213

214
        try:
×
215
            cached_file_path = _find_existing_file(cached_file_base_name)
×
216
        except FileNotFoundError:
×
217
            cached_file_path = Path(
×
218
                self._wrapped.say_to_file(
219
                    text,
220
                    file_base_path=str(_ensure_cache_dir().joinpath(cached_file_base_name)),
221
                    **kwargs
222
                ).get_full_path()
223
            )
224

225
        return _respond_with_cached_file(cached_file_path, file_base_path)
×
226

227
    @property
×
228
    def meta(self) -> MetadataMapping:
×
229
        return self._wrapped.meta
×
230

231
    def get_name(self) -> str:
×
232
        return self._wrapped.get_name()
×
233

234
    def get_settings_hash(self) -> str:
×
235
        return self._wrapped.get_settings_hash()
×
236

237

238
def create_file_tts(nxt, prev: Optional[FileWritingTTS], config: dict[str, Any], *args, **kwargs):
×
239
    if (tts := nxt(prev, config, *args, **kwargs)) is None:
×
240
        return None
×
241

242
    if config.get('no_cache', False):
×
243
        return tts
×
244

245
    return _CachingFileTTS(tts)
×
246

247

248
def init(*_args, **_kwargs):
×
249
    _do_cleanup()
×
250

251

252
async def run(*_args, **_kwargs):
×
253
    try:
×
254
        while True:
NEW
255
            await asyncio.sleep(config['cleanup_interval'] * 60 * 60)
×
256

257
            try:
×
258
                await asyncio.get_running_loop().run_in_executor(None, _do_cleanup)
×
259
            except Exception:
×
260
                _logger.exception("Ошибка в процессе очистки кеша")
×
261
    finally:
262
        _logger.info("Задача очистки кеша завершена.")
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc