Kaynağa Gözat

fix(ftp): a slow folder listing is reported as slow instead of "printer unavailable", and a refresh joins the listing still running instead of starting another; printer file times without a year are dated against UTC, so evening files on hosts behind UTC are not dated a year back

maziggy 2 gün önce
ebeveyn
işleme
27003de368

+ 2 - 0
CHANGELOG.md

@@ -164,6 +164,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **File manager: a slow folder listing showed as "printer unavailable"** — Listing a large folder (such as the camera recordings) on a slow SD card can outlast the wait, and was reported like an unreachable printer; every refresh also started another listing while the last one still ran. It now says the listing is taking too long, and a refresh waits for the listing already running.
+- **Printer files from the evening could be dated a year back** — File times on the printer carry no year, and the year was taken from the host's local clock while printers report UTC. On a host behind UTC, files written in the evening were dated a year back, so their camera recordings were not matched to their prints.
 - **SpoolBuddy: writing a sticker could break a Bambu spool's AMS link in Spoolman mode** — A Bambu spool created from the AMS is linked by its RFID tray ID; it was listed as untagged on the Write Tag page, and a sticker written to it replaced that link in Spoolman, so the next AMS sync created a duplicate spool. It is no longer listed as untagged, and on a Spoolman that does not also keep the tray ID as a native tag (before 0.27) the write is refused with the reason.
 - **SpoolBuddy: Write Tag warned "color_name not set" for every Spoolman spool** — The warning looked at a Spoolman field that is never filled; Bambuddy keeps the colour name with the spool. It now warns only when the name is missing, and says the tag then carries the subtype instead.
 - **SpoolBuddy: an update reported success after installing its dependencies timed out** — The device was restarted while the install could still be running. A timeout now stops the update with an error (and the time allowed went from 2 to 5 minutes); a failed install still completes, and its output is now in the log.

+ 4 - 1
backend/app/api/routes/printers.py

@@ -1658,7 +1658,10 @@ async def list_printer_files(
     return {
         "path": path,
         "files": files,
-        "warnings": [] if listing.available else ["printer_unavailable"],
+        # Slow is not gone: a large folder on a slow card can outlast the wait (TO-C13).
+        "warnings": (
+            [] if listing.available else ["listing_timed_out"] if listing.timed_out else ["printer_unavailable"]
+        ),
     }
 
 

+ 67 - 35
backend/app/services/bambu_ftp.py

@@ -12,6 +12,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable
 from concurrent.futures import ThreadPoolExecutor
 from contextlib import asynccontextmanager
 from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
 from enum import Enum
 from ftplib import FTP, FTP_TLS  # nosec B402
 from io import BytesIO
@@ -250,12 +251,44 @@ class DownloadDeadlineExceeded(Exception):
     """
 
 
+def _parse_list_mtime(month: str, day: str, time_or_year: str, *, now_utc: datetime | None = None) -> datetime | None:
+    """The time of an FTP LIST entry, naive, in the printer's own basis.
+
+    "Nov 30 10:15" carries no year: it is the year the entry is not in the
+    future in. Printers report UTC (tested X1C/H2D firmware; see
+    printer_media.match_ipcam_chunks), so "now" is UTC, not the host's local
+    clock -- on a host behind UTC an evening entry read as a future one and
+    went a year back (DB-C8). A day of slack keeps firmware that reports its
+    local time, up to 14 h ahead, in this year too. "Nov 30 2024" has a year
+    and no time. None when the entry cannot be read; mtime is best-effort.
+    """
+    now_utc = now_utc or datetime.now(timezone.utc).replace(tzinfo=None)
+    try:
+        if ":" in time_or_year:
+            mtime = datetime.strptime(f"{month} {day} {now_utc.year} {time_or_year}", "%b %d %Y %H:%M")
+            if mtime > now_utc + timedelta(days=1):
+                mtime = mtime.replace(year=now_utc.year - 1)
+            return mtime
+        return datetime.strptime(f"{month} {day} {time_or_year}", "%b %d %Y")
+    except ValueError:
+        return None
+
+
 @dataclass(frozen=True)
 class FileListResult:
     """A directory listing that distinguishes empty from unreachable."""
 
     files: list[dict]
     available: bool
+    # The printer did not answer within the wait: slow, not necessarily gone
+    # (a large /ipcam on a slow card), so not reported as unavailable (TO-C13).
+    timed_out: bool = False
+
+
+# Listings still running, by printer and folder: a refresh joins the one in
+# progress instead of starting another thread, which ftplib cannot cancel and
+# which would otherwise pile up in the shared FTP pool (TO-C13).
+_inflight_listings: dict[tuple[str, str, str], tuple[asyncio.Future, threading.Event]] = {}
 
 
 class DeleteResult(Enum):
@@ -864,29 +897,7 @@ class BambuFTPClient:
 
                     # Parse modification time from FTP listing
                     # Format: "Nov 30 10:15" or "Nov 30  2024"
-                    mtime = None
-                    try:
-                        from datetime import datetime
-
-                        month = parts[5]
-                        day = parts[6]
-                        time_or_year = parts[7]
-
-                        # Determine if it's time (HH:MM) or year
-                        if ":" in time_or_year:
-                            # Recent file: "Nov 30 10:15" - assume current year
-                            year = datetime.now().year
-                            time_str = f"{month} {day} {year} {time_or_year}"
-                            mtime = datetime.strptime(time_str, "%b %d %Y %H:%M")
-                            # If parsed date is in the future, use last year
-                            if mtime > datetime.now():
-                                mtime = mtime.replace(year=year - 1)
-                        else:
-                            # Older file: "Nov 30 2024" - no time, just date
-                            time_str = f"{month} {day} {time_or_year}"
-                            mtime = datetime.strptime(time_str, "%b %d %Y")
-                    except (ValueError, IndexError):
-                        pass  # Non-critical: mtime parsing is best-effort; file entry works without it
+                    mtime = _parse_list_mtime(parts[5], parts[6], parts[7])
 
                     file_entry = {
                         "name": name,
@@ -2286,23 +2297,44 @@ async def list_files_result_async(
     """List a directory without collapsing transport failure into empty."""
 
     loop = asyncio.get_event_loop()
+    key = (ip_address, access_code, path)
+    running = _inflight_listings.get(key)
+    if running is None or running[0].done():
+        # Set once the printer has answered: only then is a timeout a slow
+        # listing rather than a printer that is not there.
+        connected = threading.Event()
+
+        def _list() -> FileListResult:
+            client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
+            if not client.connect():
+                return FileListResult(files=[], available=False)
+            connected.set()
+            try:
+                return FileListResult(files=client.list_files(path, raise_on_error=True), available=True)
+            except (OSError, ftplib.Error):
+                return FileListResult(files=[], available=False)
+            finally:
+                client.disconnect()
 
-    def _list() -> FileListResult:
-        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
-        if not client.connect():
-            return FileListResult(files=[], available=False)
-        try:
-            return FileListResult(files=client.list_files(path, raise_on_error=True), available=True)
-        except (OSError, ftplib.Error):
-            return FileListResult(files=[], available=False)
-        finally:
-            client.disconnect()
+        running = (loop.run_in_executor(_ftp_executor, _list), connected)
+        _inflight_listings[key] = running
 
+        def _forget(done: asyncio.Future, key: tuple[str, str, str] = key) -> None:
+            entry = _inflight_listings.get(key)
+            if entry is not None and entry[0] is done:
+                del _inflight_listings[key]
+
+        running[0].add_done_callback(_forget)
+    future, connected = running
     try:
-        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _list), timeout=timeout)
+        # shield: one caller giving up must not cancel the listing another
+        # caller joined.
+        result = await asyncio.wait_for(asyncio.shield(future), timeout=timeout)
     except TimeoutError:
         logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
-        return FileListResult(files=[], available=False)
+        return FileListResult(files=[], available=False, timed_out=connected.is_set())
+    # Joined callers share the listing; each gets entries of its own to edit.
+    return FileListResult(files=[dict(f) for f in result.files], available=result.available)
 
 
 async def find_remote_file_async(

+ 27 - 0
backend/tests/integration/test_ftp_list_timeout_route.py

@@ -0,0 +1,27 @@
+"""The file manager says a slow listing is slow, not that the printer is gone (TO-C13)."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.bambu_ftp import FileListResult
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+@pytest.mark.parametrize(
+    ("result", "warnings"),
+    [
+        (FileListResult(files=[], available=False, timed_out=True), ["listing_timed_out"]),
+        (FileListResult(files=[], available=False), ["printer_unavailable"]),
+        (FileListResult(files=[], available=True), []),
+    ],
+)
+async def test_the_listing_warning_says_which(async_client: AsyncClient, printer_factory, result, warnings):
+    printer = await printer_factory()
+    with patch("backend.app.api.routes.printers.list_files_result_async", new=AsyncMock(return_value=result)):
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/files?path=/ipcam")
+
+    assert response.status_code == 200
+    assert response.json()["warnings"] == warnings

+ 38 - 0
backend/tests/unit/services/test_ftp_list_mtime_year.py

@@ -0,0 +1,38 @@
+"""An FTP LIST time without a year gets the right year on any host (DB-C8).
+
+Recent entries list as "Oct 20 23:20" with no year. The year was taken from
+the host's local clock, and an entry "in the future" was moved a year back.
+Printers report these times in UTC (tested X1C/H2D firmware), so on a host
+behind UTC every evening entry -- 23:20 UTC is still 19:20 in New York -- was
+dated a year back, and its ipcam recording no longer matched its print.
+"""
+
+from datetime import datetime
+
+from backend.app.services.bambu_ftp import _parse_list_mtime
+
+
+def test_a_utc_time_later_than_the_hosts_local_clock_stays_this_year():
+    # 19:30 in New York is 23:30 UTC; the printer's entry is 23:20 UTC.
+    assert _parse_list_mtime("Oct", "20", "23:20", now_utc=datetime(2026, 10, 20, 23, 30)) == datetime(
+        2026, 10, 20, 23, 20
+    )
+
+
+def test_firmware_on_local_time_a_few_hours_ahead_stays_this_year():
+    # Some firmware lists printer-local time, up to 14 h ahead of UTC.
+    assert _parse_list_mtime("Oct", "21", "09:00", now_utc=datetime(2026, 10, 20, 20, 0)).year == 2026
+
+
+def test_a_date_months_ahead_is_last_year():
+    assert _parse_list_mtime("Dec", "30", "10:15", now_utc=datetime(2026, 1, 5, 12, 0)) == datetime(
+        2025, 12, 30, 10, 15
+    )
+
+
+def test_an_entry_with_a_year_keeps_it():
+    assert _parse_list_mtime("Nov", "30", "2024", now_utc=datetime(2026, 10, 20)) == datetime(2024, 11, 30)
+
+
+def test_garbage_is_no_time():
+    assert _parse_list_mtime("Foo", "99", "xx:yy", now_utc=datetime(2026, 10, 20)) is None

+ 97 - 0
backend/tests/unit/services/test_ftp_list_timeout_state.py

@@ -0,0 +1,97 @@
+"""A slow FTP listing is reported as slow, and refreshes do not stack (TO-C13).
+
+Listing a large /ipcam on a slow SD card can take longer than the wait. That
+came back as "printer unavailable", the same as a printer that is not there,
+and the worker thread -- not cancellable -- kept running while every refresh
+started another one in the shared FTP pool.
+"""
+
+import asyncio
+import threading
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.services import bambu_ftp
+
+pytestmark = pytest.mark.asyncio
+
+
+class SlowClient:
+    made = 0
+    release = threading.Event()
+
+    def __init__(self, *args, **kwargs):
+        type(self).made += 1
+
+    def connect(self):
+        return True
+
+    def list_files(self, path, raise_on_error=False):
+        type(self).release.wait(5)
+        return [{"name": "ipcam-record.1.mp4"}]
+
+    def disconnect(self):
+        pass
+
+
+@pytest.fixture
+def slow():
+    SlowClient.made = 0
+    SlowClient.release = threading.Event()
+    bambu_ftp._inflight_listings.clear()
+    with patch.object(bambu_ftp, "BambuFTPClient", SlowClient):
+        yield SlowClient
+    SlowClient.release.set()
+    bambu_ftp._inflight_listings.clear()
+
+
+async def test_a_listing_that_takes_too_long_says_so(slow):
+    result = await bambu_ftp.list_files_result_async("10.0.0.5", "code", "/ipcam", timeout=0.1)
+
+    assert result.available is False
+    assert result.timed_out is True
+
+
+async def test_a_refresh_joins_the_listing_still_running(slow):
+    first, second = await asyncio.gather(
+        bambu_ftp.list_files_result_async("10.0.0.5", "code", "/ipcam", timeout=0.2),
+        bambu_ftp.list_files_result_async("10.0.0.5", "code", "/ipcam", timeout=0.2),
+    )
+    third = await bambu_ftp.list_files_result_async("10.0.0.5", "code", "/ipcam", timeout=0.1)
+
+    assert slow.made == 1
+    assert first.timed_out and second.timed_out and third.timed_out
+
+
+async def test_once_it_finishes_a_new_listing_starts(slow):
+    slow.release.set()
+    done = await bambu_ftp.list_files_result_async("10.0.0.5", "code", "/ipcam", timeout=2)
+    again = await bambu_ftp.list_files_result_async("10.0.0.5", "code", "/ipcam", timeout=2)
+
+    assert done.available and again.available
+    assert slow.made == 2
+
+
+class NeverConnects(SlowClient):
+    def connect(self):
+        # A printer behind a router that drops its packets: the connect blocks.
+        type(self).release.wait(5)
+        return False
+
+
+async def test_a_printer_that_never_answered_is_unavailable_not_slow():
+    """Only a listing that got through connecting is "slow"; one still
+    waiting for the printer to answer is a printer that is not there."""
+    NeverConnects.made = 0
+    NeverConnects.release = threading.Event()
+    bambu_ftp._inflight_listings.clear()
+    try:
+        with patch.object(bambu_ftp, "BambuFTPClient", NeverConnects):
+            result = await bambu_ftp.list_files_result_async("10.0.0.6", "code", "/ipcam", timeout=0.1)
+    finally:
+        NeverConnects.release.set()
+        bambu_ftp._inflight_listings.clear()
+
+    assert result.available is False
+    assert result.timed_out is False

+ 20 - 0
frontend/src/__tests__/components/FileManagerModal.test.tsx

@@ -438,6 +438,26 @@ describe('FileManagerModal', () => {
       expect(screen.getByText('1 selected')).toBeInTheDocument();
     });
 
+    it('says a slow listing is slow, and keeps the selection (TO-C13)', async () => {
+      render(
+        <FileManagerModal printerId={1} printerName="X1 Carbon" onClose={mockOnClose} />
+      );
+
+      fireEvent.click(await screen.findByRole('button', { name: 'Select benchy.3mf' }));
+      expect(screen.getByText('1 selected')).toBeInTheDocument();
+
+      server.use(
+        http.get('/api/v1/printers/:id/files', () =>
+          HttpResponse.json({ files: [], warnings: ['listing_timed_out'] })
+        )
+      );
+      fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
+
+      expect(await screen.findByText(/listing this folder is taking too long/)).toBeInTheDocument();
+      expect(screen.queryByText(/file service is unavailable/)).not.toBeInTheDocument();
+      expect(screen.getByText('1 selected')).toBeInTheDocument();
+    });
+
     it('drops hidden selections when the filter changes', async () => {
       render(
         <FileManagerModal printerId={1} printerName="X1 Carbon" onClose={mockOnClose} />

+ 1 - 1
frontend/src/api/client.ts

@@ -5377,7 +5377,7 @@ export const api = {
         path: string;
         mtime?: string;
       }>;
-      warnings: Array<'printer_unavailable'>;
+      warnings: Array<'printer_unavailable' | 'listing_timed_out'>;
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,

+ 5 - 1
frontend/src/components/FileManagerModal.tsx

@@ -353,7 +353,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   // real. An unreachable printer answers with an empty file list and a warning,
   // and treating that as "those files are gone" would throw away a selection
   // the user made moments ago because one poll happened to fail.
-  const listingIsReal = !!data && !data.warnings?.includes('printer_unavailable');
+  const listingIsReal = !!data && !data.warnings?.length;
   useEffect(() => {
     if (!listingIsReal) return;
     const visiblePaths = new Set(visibleFiles.filter(file => !file.is_directory).map(file => file.path));
@@ -624,6 +624,10 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               <div className="text-center py-12 text-amber-600 dark:text-amber-400">
                 {t('printerFiles.printerUnavailable')}
               </div>
+            ) : data?.warnings?.includes('listing_timed_out') ? (
+              <div className="text-center py-12 text-amber-600 dark:text-amber-400">
+                {t('printerFiles.listingTimedOut')}
+              </div>
             ) : !data?.files?.length ? (
               <div className="text-center py-12 text-bambu-gray">
                 {t('printerFiles.noFiles')}

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -3380,6 +3380,7 @@ export default {
     deleteFilesConfirm: '{{count}} ausgewählte Dateien löschen? Dies kann nicht rückgängig gemacht werden.',
     noFiles: 'Keine Dateien auf dem Drucker',
     printerUnavailable: 'Der Dateidienst des Druckers ist nicht erreichbar. Versuchen Sie es erneut, wenn der Drucker verfügbar ist.',
+    listingTimedOut: 'Der Drucker antwortet, aber das Auflisten dieses Ordners dauert zu lange (ein großer Ordner auf einer langsamen SD-Karte). Versuchen Sie es gleich noch einmal.',
     loadingFiles: 'Dateien werden geladen...',
     failedToLoad: 'Dateien konnten nicht geladen werden',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -3412,6 +3412,7 @@ export default {
     deleteFilesConfirm: 'Delete {{count}} selected files? This cannot be undone.',
     noFiles: 'No files on printer',
     printerUnavailable: 'The printer file service is unavailable. Try again when the printer is reachable.',
+    listingTimedOut: 'The printer is answering, but listing this folder is taking too long (a large folder on a slow SD card). Try again in a moment.',
     loadingFiles: 'Loading files...',
     failedToLoad: 'Failed to load files',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -3382,6 +3382,7 @@ export default {
     deleteFilesConfirm: '¿Eliminar {{count}} archivos seleccionados? Esto no se puede deshacer.',
     noFiles: 'No hay archivos en la impresora',
     printerUnavailable: 'El servicio de archivos de la impresora no está disponible. Inténtelo de nuevo cuando la impresora esté accesible.',
+    listingTimedOut: 'La impresora responde, pero listar esta carpeta tarda demasiado (una carpeta grande en una tarjeta SD lenta). Inténtelo de nuevo en un momento.',
     loadingFiles: 'Cargando archivos...',
     failedToLoad: 'Error al cargar los archivos',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -3368,6 +3368,7 @@ export default {
     deleteFilesConfirm: 'Supprimer les {{count}} fichiers sélectionnés ?',
     noFiles: 'Aucun fichier sur l\'imprimante',
     printerUnavailable: 'Le service de fichiers de l\'imprimante est indisponible. Réessayez lorsque l\'imprimante est accessible.',
+    listingTimedOut: "L'imprimante répond, mais lister ce dossier prend trop de temps (un dossier volumineux sur une carte SD lente). Réessayez dans un instant.",
     loadingFiles: 'Chargement...',
     failedToLoad: 'Échec chargement fichiers',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -3367,6 +3367,7 @@ export default {
     deleteFilesConfirm: 'Eliminare {{count}} file selezionati? Questa azione non può essere annullata.',
     noFiles: 'Nessun file sulla stampante',
     printerUnavailable: 'Il servizio file della stampante non è disponibile. Riprova quando la stampante è raggiungibile.',
+    listingTimedOut: "La stampante risponde, ma l'elenco di questa cartella richiede troppo tempo (una cartella grande su una scheda SD lenta). Riprova tra un momento.",
     loadingFiles: 'Caricamento file...',
     failedToLoad: 'Caricamento file fallito',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -3380,6 +3380,7 @@ export default {
     deleteFilesConfirm: '選択した{{count}}件のファイルを削除しますか?元に戻せません。',
     noFiles: 'このディレクトリにファイルがありません',
     printerUnavailable: 'プリンターのファイルサービスを利用できません。プリンターに接続できる状態で再試行してください。',
+    listingTimedOut: 'プリンターは応答していますが、このフォルダーの一覧取得に時間がかかりすぎています(遅い SD カード上の大きなフォルダー)。少し待ってから再試行してください。',
     loadingFiles: 'ファイルを読み込み中...',
     failedToLoad: 'ファイルの読み込みに失敗しました',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -3224,6 +3224,7 @@ export default {
     deleteFilesConfirm: '선택된 {{count}}개 파일을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
     noFiles: '프린터에 파일 없음',
     printerUnavailable: '프린터 파일 서비스를 사용할 수 없습니다. 프린터에 연결할 수 있을 때 다시 시도하세요.',
+    listingTimedOut: '프린터는 응답하지만 이 폴더 목록을 가져오는 데 너무 오래 걸립니다(느린 SD 카드의 큰 폴더). 잠시 후 다시 시도하세요.',
     loadingFiles: '파일 로딩 중...',
     failedToLoad: '파일 로드 실패',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -3410,6 +3410,7 @@ export default {
     deleteFilesConfirm: '{{count}} geselecteerde bestanden verwijderen? Dit kan niet ongedaan worden gemaakt.',
     noFiles: 'Geen bestanden op printer',
     printerUnavailable: 'De bestandsservice van de printer is niet beschikbaar. Probeer het opnieuw wanneer de printer bereikbaar is.',
+    listingTimedOut: 'De printer antwoordt, maar het tonen van deze map duurt te lang (een grote map op een trage SD-kaart). Probeer het zo opnieuw.',
     loadingFiles: 'Bestanden laden...',
     failedToLoad: 'Bestanden laden mislukt',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3367,6 +3367,7 @@ export default {
     deleteFilesConfirm: 'Excluir {{count}} arquivos selecionados? Isso não pode ser desfeito.',
     noFiles: 'Nenhum arquivo na impressora',
     printerUnavailable: 'O serviço de arquivos da impressora está indisponível. Tente novamente quando a impressora estiver acessível.',
+    listingTimedOut: 'A impressora está respondendo, mas listar esta pasta está demorando demais (uma pasta grande em um cartão SD lento). Tente novamente em instantes.',
     loadingFiles: 'Carregando arquivos...',
     failedToLoad: 'Falha ao carregar arquivos',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -3216,6 +3216,7 @@ export default {
     deleteFilesConfirm: "Удалить выбранные файлы ({{count}})? Это действие нельзя отменить.",
     noFiles: "На принтере нет файлов",
     printerUnavailable: 'Файловая служба принтера недоступна. Повторите попытку, когда принтер будет доступен.',
+    listingTimedOut: 'Принтер отвечает, но получение списка этой папки занимает слишком много времени (большая папка на медленной SD-карте). Повторите попытку чуть позже.',
     loadingFiles: "Загрузка файлов...",
     failedToLoad: "Не удалось загрузить файлы",
     toast: {

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -3409,6 +3409,7 @@ errors: {
     deleteFilesConfirm: 'Ta bort {{count}} markerade filer? Detta kan inte ångras.',
     noFiles: 'Inga filer på skrivaren',
     printerUnavailable: 'Skrivarfiltjänsten är inte tillgänglig. Försök igen när skrivaren är tillgänglig.',
+    listingTimedOut: 'Skrivaren svarar, men det tar för lång tid att lista den här mappen (en stor mapp på ett långsamt SD-kort). Försök igen om en stund.',
     loadingFiles: 'Laddar filer...',
     failedToLoad: 'Kunde inte ladda filer',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -3383,6 +3383,7 @@ export default {
     deleteFilesConfirm: 'Seçilen {{count}} dosya silinsin mi? Bu geri alınamaz.',
     noFiles: 'Yazıcıda dosya yok',
     printerUnavailable: 'Yazıcı dosya hizmeti kullanılamıyor. Yazıcıya erişilebildiğinde tekrar deneyin.',
+    listingTimedOut: 'Yazıcı yanıt veriyor, ancak bu klasörü listelemek çok uzun sürüyor (yavaş bir SD karttaki büyük bir klasör). Biraz sonra tekrar deneyin.',
     loadingFiles: 'Dosyalar yükleniyor...',
     failedToLoad: 'Dosyalar yüklenemedi',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -3407,6 +3407,7 @@ export default {
     deleteFilesConfirm: "Видалити вибрані файли {{count}}? Це неможливо скасувати.",
     noFiles: "Немає файлів на принтері",
     printerUnavailable: 'Файлова служба принтера недоступна. Повторіть спробу, коли принтер буде доступний.',
+    listingTimedOut: 'Принтер відповідає, але отримання списку цієї папки триває надто довго (велика папка на повільній SD-картці). Спробуйте ще раз за мить.',
     loadingFiles: "Завантаження файлів...",
     failedToLoad: "Не вдалося завантажити файли",
     toast: {

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3367,6 +3367,7 @@ export default {
     deleteFilesConfirm: '删除 {{count}} 个选中的文件?此操作无法撤销。',
     noFiles: '打印机上没有文件',
     printerUnavailable: '打印机文件服务不可用。请在打印机可访问时重试。',
+    listingTimedOut: '打印机有响应,但列出此文件夹耗时过长(慢速 SD 卡上的大文件夹)。请稍后重试。',
     loadingFiles: '加载文件中...',
     failedToLoad: '加载文件失败',
     toast: {

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3367,6 +3367,7 @@ export default {
     deleteFilesConfirm: '刪除 {{count}} 個選中的檔案?此操作無法復原。',
     noFiles: '印表機上沒有檔案',
     printerUnavailable: '印表機檔案服務無法使用。請在可連線至印表機時重試。',
+    listingTimedOut: '印表機有回應,但列出此資料夾耗時過長(慢速 SD 卡上的大型資料夾)。請稍後再試。',
     loadingFiles: '載入檔案中...',
     failedToLoad: '載入檔案失敗',
     toast: {