فهرست منبع

Support non-0.4mm nozzles in AMS Slot config + guard dispatch (#1899)

The Configure AMS Slot picker was hardwired to 0.4mm (nozzleDiameter
prop never passed from PrintersPage / SpoolBuddyAmsPage), so a 0.6
machine could only set 0.4 profiles on its trays. Resolve the real
installed nozzle per-AMS (ams_extruder_map on dual-nozzle) and pass it
in. Separately, nothing validated the sliced nozzle against the
installed one, so a mismatch reached the printer as a cryptic HMS
_8012 "Failed to get AMS mapping table". Add a fail-safe pre-dispatch
guard in _start_print that fails the item with an actionable message
before upload; no slice diameter or no reported nozzles = no-op.
maziggy 2 ماه پیش
والد
کامیت
bb3e2a710e

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 85 - 0
backend/app/services/print_scheduler.py

@@ -144,6 +144,50 @@ def _canonical_filament_type(ftype: str) -> str:
     return _FILAMENT_EQUIV_MAP.get(upper, upper)
 
 
+def _installed_nozzle_diameters(status) -> list[float]:
+    """Parse the installed nozzle diameters from a PrinterState (#1899).
+
+    Returns the diameters the printer actually reports (e.g. [0.4] single-nozzle,
+    [0.4, 0.6] dual-nozzle), skipping the empty-string defaults that populate a
+    NozzleInfo before MQTT fills it in. An empty list means "the printer hasn't
+    told us its nozzle hardware" — callers must treat that as unknown, not as a
+    mismatch, so we never block a print on missing data.
+    """
+    diameters: list[float] = []
+    for nozzle in getattr(status, "nozzles", None) or []:
+        raw = getattr(nozzle, "nozzle_diameter", "") or ""
+        try:
+            value = float(raw)
+        except (TypeError, ValueError):
+            continue
+        if value > 0:
+            diameters.append(value)
+    return diameters
+
+
+def _nozzle_mismatch_message(sliced_nozzle: float | None, installed: list[float]) -> str | None:
+    """Return an actionable error message when the sliced nozzle can't be
+    printed on any installed nozzle, else None (#1899).
+
+    Fail-safe: returns None whenever we lack the data to judge — no sliced
+    diameter, or the printer reported no nozzles — so a print is only ever
+    blocked on a POSITIVE mismatch. On dual-nozzle printers a match against
+    EITHER installed nozzle passes (a 0.6 slice is fine if one hotend is 0.6).
+    The 0.05 tolerance absorbs float noise while staying well inside the 0.2
+    gap between adjacent nozzle sizes (0.2/0.4/0.6/0.8).
+    """
+    if not sliced_nozzle or not installed:
+        return None
+    if any(abs(d - sliced_nozzle) < 0.05 for d in installed):
+        return None
+    installed_str = " / ".join(f"{d:g}mm" for d in installed)
+    return (
+        f"File sliced for a {sliced_nozzle:g}mm nozzle, but the printer has "
+        f"{installed_str} installed. Re-slice for the installed nozzle, or "
+        f"install the matching nozzle before printing."
+    )
+
+
 class PrintScheduler:
     """Background scheduler that processes the print queue."""
 
@@ -2629,6 +2673,47 @@ class PrintScheduler:
             await self._power_off_if_needed(db, item)
             return
 
+        # Nozzle-diameter mismatch guard (#1899). A file sliced for one nozzle
+        # size dispatched to a printer with a different nozzle installed is
+        # rejected by the firmware with a cryptic HMS ("Failed to get AMS mapping
+        # table" 0700_8012, or "nozzle diameter … not consistent" 0500_4038) that
+        # gives the user no idea what went wrong. Catch it here, before we spend
+        # time preheating and uploading, and fail with an actionable message.
+        # Fail-safe by construction: only a POSITIVE mismatch blocks — when the
+        # slice carries no nozzle diameter (archive.nozzle_diameter is None) or
+        # the printer hasn't reported its nozzles yet, we fall through and let the
+        # print proceed exactly as before. On dual-nozzle printers (H2D) a match
+        # against EITHER installed nozzle passes, so a 0.6 slice is fine as long
+        # as one of the two hotends is a 0.6.
+        sliced_nozzle = archive.nozzle_diameter if archive else None
+        if sliced_nozzle:
+            installed = _installed_nozzle_diameters(printer_manager.get_status(item.printer_id))
+            mismatch_msg = _nozzle_mismatch_message(sliced_nozzle, installed)
+            if mismatch_msg:
+                item.status = "failed"
+                item.error_message = mismatch_msg
+                item.completed_at = datetime.now(timezone.utc)
+                await db.commit()
+                logger.warning("Queue item %s: nozzle mismatch — %s", item.id, mismatch_msg)
+                await notification_service.on_queue_job_failed(
+                    job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
+                    printer_id=printer.id,
+                    printer_name=printer.name,
+                    reason=mismatch_msg,
+                    db=db,
+                )
+                try:
+                    await ws_manager.send_queue_item_failed(
+                        user_id=item.created_by_id,
+                        queue_item_id=item.id,
+                        printer_id=item.printer_id,
+                        reason="nozzle_mismatch",
+                    )
+                except Exception:
+                    pass
+                await self._power_off_if_needed(db, item)
+                return
+
         # Preheat / heat-soak (#1468) — fires before upload so the printer's
         # bed (and chamber, if applicable) is at temperature when the firmware
         # starts the actual print routine. Best-effort: any failure logs and

+ 248 - 0
backend/tests/unit/test_scheduler_nozzle_mismatch.py

@@ -0,0 +1,248 @@
+"""Tests for the nozzle-diameter mismatch guard (#1899).
+
+A file sliced for one nozzle size dispatched to a printer with a different
+nozzle installed is rejected by the firmware with a cryptic HMS ("Failed to get
+AMS mapping table" 0700_8012). The scheduler catches this before upload and
+fails the queue item with an actionable message instead.
+
+These cover the two pure helpers that make the decision. The guard is fail-safe
+by construction: it only blocks on a POSITIVE mismatch, never on missing data.
+"""
+
+from contextlib import ExitStack
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.services.print_scheduler as scheduler_module
+from backend.app.core.database import Base
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import (
+    PrintScheduler,
+    _installed_nozzle_diameters,
+    _nozzle_mismatch_message,
+)
+
+
+def _state(*diameters: str):
+    """PrinterState-shaped namespace with the given nozzle diameter strings."""
+    return SimpleNamespace(nozzles=[SimpleNamespace(nozzle_diameter=d) for d in diameters])
+
+
+# ---------------------------------------------------------------------------
+# _installed_nozzle_diameters
+# ---------------------------------------------------------------------------
+
+
+def test_installed_parses_single_nozzle():
+    assert _installed_nozzle_diameters(_state("0.6")) == [0.6]
+
+
+def test_installed_parses_dual_nozzle():
+    assert _installed_nozzle_diameters(_state("0.4", "0.6")) == [0.4, 0.6]
+
+
+def test_installed_skips_empty_default_stub():
+    # Single-nozzle printers still emit a 2-entry array; the second is an
+    # empty-string default until MQTT fills it in.
+    assert _installed_nozzle_diameters(_state("0.4", "")) == [0.4]
+
+
+def test_installed_skips_unparseable_and_zero():
+    assert _installed_nozzle_diameters(_state("", "abc", "0", "0.4")) == [0.4]
+
+
+def test_installed_handles_no_status_or_no_nozzles():
+    assert _installed_nozzle_diameters(None) == []
+    assert _installed_nozzle_diameters(SimpleNamespace()) == []
+    assert _installed_nozzle_diameters(SimpleNamespace(nozzles=[])) == []
+
+
+# ---------------------------------------------------------------------------
+# _nozzle_mismatch_message
+# ---------------------------------------------------------------------------
+
+
+def test_mismatch_blocks_single_nozzle():
+    msg = _nozzle_mismatch_message(0.6, [0.4])
+    assert msg is not None
+    assert "0.6mm" in msg
+    assert "0.4mm" in msg
+
+
+def test_match_single_nozzle_passes():
+    assert _nozzle_mismatch_message(0.4, [0.4]) is None
+
+
+def test_match_within_float_tolerance_passes():
+    # 0.4 slice vs a 0.40000001 reported diameter must not trip.
+    assert _nozzle_mismatch_message(0.4, [0.40000001]) is None
+
+
+def test_dual_nozzle_match_on_either_passes():
+    # 0.6 slice on a printer with a 0.4 and a 0.6 hotend is fine.
+    assert _nozzle_mismatch_message(0.6, [0.4, 0.6]) is None
+
+
+def test_dual_nozzle_mismatch_on_both_blocks():
+    msg = _nozzle_mismatch_message(0.8, [0.4, 0.6])
+    assert msg is not None
+    assert "0.4mm / 0.6mm" in msg
+
+
+def test_no_sliced_diameter_is_failsafe_none():
+    # Slice didn't declare a nozzle diameter → never block.
+    assert _nozzle_mismatch_message(None, [0.4]) is None
+    assert _nozzle_mismatch_message(0.0, [0.4]) is None
+
+
+def test_no_installed_nozzles_is_failsafe_none():
+    # Printer hasn't reported nozzles → unknown, never block.
+    assert _nozzle_mismatch_message(0.6, []) is None
+
+
+def test_adjacent_sizes_are_distinguished():
+    # 0.2 gap between adjacent sizes stays well outside the 0.05 tolerance.
+    assert _nozzle_mismatch_message(0.4, [0.6]) is not None
+    assert _nozzle_mismatch_message(0.6, [0.8]) is not None
+
+
+# ---------------------------------------------------------------------------
+# End-to-end: the guard fires inside _start_print BEFORE upload
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+async def archive_case(tmp_path):
+    """Build an archive-based queue item on a real in-memory DB + on-disk 3MF."""
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    async def make_case(*, sliced_nozzle: float | None):
+        base_dir = tmp_path / "case"
+        base_dir.mkdir(exist_ok=True)
+        archive_rel = Path("archives") / "job.3mf"
+        archive_abs = base_dir / archive_rel
+        archive_abs.parent.mkdir(parents=True, exist_ok=True)
+        archive_abs.write_bytes(b"sliced 3mf")
+
+        async with session_maker() as db:
+            printer = Printer(
+                name="H2S",
+                serial_number="SN-H2S",
+                ip_address="127.0.0.1",
+                access_code="ac",
+                model="H2S",
+            )
+            db.add(printer)
+            await db.flush()
+            archive = PrintArchive(
+                printer_id=printer.id,
+                filename="job.3mf",
+                file_path=str(archive_rel),
+                file_size=archive_abs.stat().st_size,
+                nozzle_diameter=sliced_nozzle,
+                status="completed",
+            )
+            db.add(archive)
+            await db.flush()
+            item = PrintQueueItem(
+                printer_id=printer.id,
+                archive_id=archive.id,
+                status="pending",
+                bed_levelling=True,
+                flow_cali=False,
+                vibration_cali=True,
+                layer_inspect=False,
+                timelapse=False,
+                use_ams=True,
+                nozzle_offset_cali=True,
+            )
+            db.add(item)
+            await db.commit()
+            return SimpleNamespace(
+                session_maker=session_maker,
+                base_dir=base_dir,
+                archive_abs=archive_abs,
+                printer_id=printer.id,
+                queue_item_id=item.id,
+                start_print=MagicMock(return_value=True),
+                upload=AsyncMock(return_value=True),
+            )
+
+    try:
+        yield make_case
+    finally:
+        await engine.dispose()
+
+
+async def _run_start_print(ctx, *, installed_nozzles):
+    scheduler = PrintScheduler()
+    status = SimpleNamespace(nozzles=[SimpleNamespace(nozzle_diameter=d) for d in installed_nozzles])
+    # The mismatch case returns before the upload path; the match case drives it
+    # to start_print, so mirror the post-guard dependency patches the
+    # cleanup-library harness uses (get_ftp_retry_settings et al. open their own
+    # DB session, not our in-memory one, so they must be stubbed).
+    patches = [
+        patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
+        patch("backend.app.services.print_scheduler.printer_manager.start_print", ctx.start_print),
+        patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
+        patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
+        patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
+        patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+        patch(
+            "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
+        ),
+        patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
+        patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
+        patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
+        patch("backend.app.services.print_scheduler.ws_manager.send_queue_item_failed", AsyncMock()),
+        patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
+        patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
+        patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
+    ]
+    with ExitStack() as stack:
+        for p in patches:
+            stack.enter_context(p)
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, ctx.queue_item_id)
+            await scheduler._start_print(db, item)
+
+
+@pytest.mark.asyncio
+async def test_start_print_blocks_on_nozzle_mismatch_before_upload(archive_case):
+    """0.6 slice on a 0.4-only printer: item fails with an actionable message,
+    and neither upload nor start_print is reached."""
+    ctx = await archive_case(sliced_nozzle=0.6)
+    await _run_start_print(ctx, installed_nozzles=["0.4"])
+
+    async with ctx.session_maker() as db:
+        item = await db.get(PrintQueueItem, ctx.queue_item_id)
+    assert item.status == "failed"
+    assert "0.6mm" in item.error_message and "0.4mm" in item.error_message
+    ctx.upload.assert_not_called()
+    ctx.start_print.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_start_print_proceeds_when_nozzle_matches(archive_case):
+    """0.6 slice on a 0.6 printer: the guard is a no-op and dispatch proceeds
+    (item leaves 'pending', start_print is reached)."""
+    ctx = await archive_case(sliced_nozzle=0.6)
+    await _run_start_print(ctx, installed_nozzles=["0.6"])
+
+    async with ctx.session_maker() as db:
+        item = await db.get(PrintQueueItem, ctx.queue_item_id)
+    assert item.status != "failed"
+    ctx.start_print.assert_called_once()

+ 63 - 0
frontend/src/__tests__/utils/resolveSlotNozzleDiameter.test.ts

@@ -0,0 +1,63 @@
+/**
+ * Tests for resolveSlotNozzleDiameter helper (#1899).
+ *
+ * The AMS Slot config picker must filter filament presets by the nozzle that
+ * actually feeds a given AMS, not the hardcoded 0.4mm default. This resolver
+ * reads the installed nozzle diameter from the printer status, honouring the
+ * per-AMS extruder binding on dual-nozzle printers. It returns undefined when
+ * the hardware hasn't been reported, so the caller keeps its own default.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import { resolveSlotNozzleDiameter } from '../../utils/amsHelpers';
+
+describe('resolveSlotNozzleDiameter', () => {
+  it('returns undefined when status is null or undefined', () => {
+    expect(resolveSlotNozzleDiameter(null, 0)).toBeUndefined();
+    expect(resolveSlotNozzleDiameter(undefined, 0)).toBeUndefined();
+  });
+
+  it('returns undefined when no nozzles are reported', () => {
+    expect(resolveSlotNozzleDiameter({ nozzles: [] }, 0)).toBeUndefined();
+    expect(resolveSlotNozzleDiameter({}, 0)).toBeUndefined();
+  });
+
+  it('returns undefined when the reported nozzle diameter is an empty default', () => {
+    expect(resolveSlotNozzleDiameter({ nozzles: [{ nozzle_diameter: '' }] }, 0)).toBeUndefined();
+  });
+
+  it('returns the single-nozzle diameter regardless of amsId (no extruder map)', () => {
+    const status = { nozzles: [{ nozzle_diameter: '0.6' }] };
+    expect(resolveSlotNozzleDiameter(status, 0)).toBe('0.6');
+    expect(resolveSlotNozzleDiameter(status, 3)).toBe('0.6');
+  });
+
+  it('resolves the per-AMS nozzle on a dual-nozzle printer via ams_extruder_map', () => {
+    // AMS 0 → left nozzle (0.4), AMS 1 → right nozzle (0.6)
+    const status = {
+      nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.6' }],
+      ams_extruder_map: { '0': 0, '1': 1 },
+    };
+    expect(resolveSlotNozzleDiameter(status, 0)).toBe('0.4');
+    expect(resolveSlotNozzleDiameter(status, 1)).toBe('0.6');
+  });
+
+  it('falls back to the primary nozzle when the AMS is not in the extruder map', () => {
+    const status = {
+      nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.6' }],
+      ams_extruder_map: { '0': 0 },
+    };
+    // AMS 5 has no mapping → index 0 (primary)
+    expect(resolveSlotNozzleDiameter(status, 5)).toBe('0.4');
+  });
+
+  it('falls back to the primary nozzle when the mapped nozzle has no diameter', () => {
+    // Dual-nozzle stub where the second entry is still an empty default.
+    const status = {
+      nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '' }],
+      ams_extruder_map: { '1': 1 },
+    };
+    expect(resolveSlotNozzleDiameter(status, 1)).toBe('0.4');
+  });
+});

+ 2 - 1
frontend/src/pages/PrintersPage.tsx

@@ -115,7 +115,7 @@ import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModa
 import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
-import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool } from '../utils/amsHelpers';
+import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter } from '../utils/amsHelpers';
 import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
@@ -6255,6 +6255,7 @@ function PrinterCard({
           printerId={printer.id}
           slotInfo={configureSlotModal}
           printerModel={mapModelCode(printer.model) || undefined}
+          nozzleDiameter={resolveSlotNozzleDiameter(status, configureSlotModal.amsId)}
           onSuccess={() => {
             // Refresh slot presets to show updated profile name
             queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });

+ 2 - 1
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -6,7 +6,7 @@ import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api } from '../../api/client';
 import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
-import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool } from '../../utils/amsHelpers';
+import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter } from '../../utils/amsHelpers';
 import { getSwatchStyle } from '../../utils/colors';
 import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
 import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
@@ -687,6 +687,7 @@ export function SpoolBuddyAmsPage() {
           printerId={selectedPrinterId}
           slotInfo={configureSlotModal}
           printerModel={mapModelCode(printer?.model ?? null) || undefined}
+          nozzleDiameter={resolveSlotNozzleDiameter(status, configureSlotModal.amsId)}
           fullScreen
           onSuccess={() => {
             queryClient.invalidateQueries({ queryKey: ['slotPresets', selectedPrinterId] });

+ 26 - 0
frontend/src/utils/amsHelpers.ts

@@ -343,6 +343,32 @@ export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
   );
 }
 
+/**
+ * Resolve the installed nozzle diameter feeding a given AMS unit, so the
+ * Configure-AMS-Slot picker filters filament presets by the nozzle actually on
+ * the machine instead of assuming 0.4mm (#1899).
+ *
+ * On dual-nozzle printers (H2D) each AMS is bound to one extruder via
+ * `ams_extruder_map` (amsId → extruder index, 0=left/primary, 1=right), so we
+ * read that nozzle's diameter. Single-nozzle printers have no map entry and
+ * fall back to the primary nozzle (index 0). Returns undefined when the printer
+ * hasn't reported nozzle hardware yet, letting the caller keep its own default.
+ * Diameter is the bare decimal string the status carries, e.g. "0.4" / "0.6".
+ */
+export function resolveSlotNozzleDiameter(
+  status: {
+    nozzles?: { nozzle_diameter?: string }[];
+    ams_extruder_map?: Record<string, number>;
+  } | null | undefined,
+  amsId: number,
+): string | undefined {
+  const nozzles = status?.nozzles;
+  if (!nozzles || nozzles.length === 0) return undefined;
+  const extruderIdx = status?.ams_extruder_map?.[String(amsId)] ?? 0;
+  const diameter = nozzles[extruderIdx]?.nozzle_diameter || nozzles[0]?.nozzle_diameter;
+  return diameter || undefined;
+}
+
 /**
  * Detect Bambu Lab RFID-tagged spool by tray_uuid (32 hex) or tag_uid (16 hex).
  *

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-CvBwRD12.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-nQKiiuS3.js"></script>
+    <script type="module" crossorigin src="/assets/index-CvBwRD12.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DvjR9OL3.css">
   </head>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است