feat(download): per-file download status and multi-file selection (#1058)

This commit is contained in:
Will Miao
2026-08-19 17:51:31 +08:00
parent cef4129fc9
commit e7c26bf722
23 changed files with 22259 additions and 21031 deletions
@@ -0,0 +1,317 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
mockApiClient,
mockLoadingManager,
showToastMock,
showDownloadBatchSummaryMock,
resetAndReloadMock,
} = vi.hoisted(() => {
// Shared API client returned by the mocked getModelApiClient factory.
const mockApiClient = {
modelType: 'loras',
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
},
},
fetchCivitaiVersions: vi.fn(),
fetchModelRoots: vi.fn(async () => ({ roots: ['/models/loras'] })),
fetchUnifiedFolderTree: vi.fn(async () => ({ success: false })),
downloadModel: vi.fn(),
cancelDownload: vi.fn(),
getPageState: vi.fn(() => ({})),
};
const mockLoadingManager = {
showSimpleLoading: vi.fn(),
setStatus: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
showCancelButton: vi.fn(),
};
return {
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
mockApiClient,
mockLoadingManager,
showToastMock: vi.fn(),
showDownloadBatchSummaryMock: vi.fn(),
resetAndReloadMock: vi.fn(),
};
});
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn(),
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
setupAutoNewlineOnPaste: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
state: {
global: {
settings: {},
},
loadingManager: mockLoadingManager,
},
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => mockLoadingManager),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => mockApiClient),
resetAndReload: resetAndReloadMock,
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({
clearSelection: vi.fn(),
init: vi.fn(),
getSelectedPath: vi.fn(() => ''),
})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: showDownloadBatchSummaryMock,
}));
/** DOM covering the file-selection, version and location steps. */
function setupDownloadDom() {
document.body.innerHTML = `
<div id="downloadModal">
<div class="download-step" id="urlStep"></div>
<div class="download-step" id="versionStep"></div>
<div class="download-step" id="fileSelectionStep"></div>
<div class="download-step" id="downloadLocationStep"></div>
<div id="fileSelectionList"></div>
<div id="fileSelectionVersionName"></div>
<button id="nextFromVersion"></button>
<div id="downloadModalTitle"></div>
<select id="modelRoot"></select>
<input id="folderPath" />
<div id="targetPathDisplay"></div>
<input id="useDefaultPath" type="checkbox" />
<div id="manualPathSelection"></div>
</div>
`;
}
function makeMultiFileVersion(overrides = {}) {
return {
id: 201,
name: 'Multi-file version',
baseModel: 'SDXL',
images: [],
files: [
{ id: 1001, type: 'Model', sizeKB: 2048, name: 'file-a.safetensors' },
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' },
],
createdAt: '2026-01-01T00:00:00Z',
existsLocally: true,
...overrides,
};
}
function getFileOption(fileId) {
return document.querySelector(`.file-option[data-file-id="${fileId}"]`);
}
describe('DownloadManager multi-select file dialog (#1058)', () => {
let DownloadManager;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
setupDownloadDom();
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
});
afterEach(() => {
document.body.innerHTML = '';
});
it('renders downloaded files disabled with an In Library tag', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
],
})];
manager.showFileSelectionStep('201');
const downloadedOption = getFileOption('1001');
expect(downloadedOption.classList.contains('disabled')).toBe(true);
expect(downloadedOption.querySelector('input[type="checkbox"]').disabled).toBe(true);
expect(downloadedOption.querySelector('.file-tag.in-library').textContent).toBe('In Library');
// Remaining files stay selectable
const otherOption = getFileOption('1002');
expect(otherOption.classList.contains('disabled')).toBe(false);
expect(otherOption.querySelector('input[type="checkbox"]').disabled).toBe(false);
});
it('ignores clicks on already-downloaded options', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
],
})];
manager.showFileSelectionStep('201');
getFileOption('1001').click();
expect(getFileOption('1001').querySelector('input[type="checkbox"]').checked).toBe(false);
expect(manager.selectedFiles).toHaveLength(0);
});
it('confirmFileSelection collects multiple checked files into selectedFiles', () => {
const manager = new DownloadManager();
manager.apiClient = mockApiClient;
manager.versions = [makeMultiFileVersion({ downloadedFiles: [] })];
manager.showFileSelectionStep('201');
getFileOption('1001').click();
getFileOption('1003').click();
manager.confirmFileSelection();
expect(manager.selectedFiles.map(f => f.id)).toEqual([1001, 1003]);
// selectedFile stays the first selected file for single-file flows
expect(manager.selectedFile?.id).toBe(1001);
expect(document.getElementById('fileSelectionStep').style.display).toBe('none');
expect(document.getElementById('downloadLocationStep').style.display).toBe('block');
});
it('confirmFileSelection warns when nothing is selected', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({ downloadedFiles: [] })];
manager.showFileSelectionStep('201');
manager.confirmFileSelection();
expect(showToastMock).toHaveBeenCalledWith('toast.loras.pleaseSelectFile', {}, 'error');
expect(manager.selectedFiles).toHaveLength(0);
expect(document.getElementById('downloadLocationStep').style.display).not.toBe('block');
});
it('disables the other routing group once a file is checked and re-enables when unchecked', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({
downloadedFiles: [],
files: [
{ id: 1001, type: 'UNet', sizeKB: 2048, name: 'unet-a.safetensors' },
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' },
],
})];
manager.showFileSelectionStep('201');
// Checking a regular Model file disables the UNet option
getFileOption('1002').click();
expect(getFileOption('1001').classList.contains('group-disabled')).toBe(true);
expect(getFileOption('1001').querySelector('input[type="checkbox"]').disabled).toBe(true);
expect(getFileOption('1003').classList.contains('group-disabled')).toBe(false);
// Clicking a group-disabled option does nothing
getFileOption('1001').click();
expect(manager.selectedFiles.map(f => f.id)).toEqual([1002]);
// Unchecking everything re-enables the other group
getFileOption('1002').click();
expect(manager.selectedFiles).toHaveLength(0);
expect(getFileOption('1001').classList.contains('group-disabled')).toBe(false);
expect(getFileOption('1001').querySelector('input[type="checkbox"]').disabled).toBe(false);
});
it('keeps Next enabled for a partially downloaded multi-file version', () => {
const manager = new DownloadManager();
manager.currentVersion = makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
],
});
manager.updateNextButtonState();
const nextButton = document.getElementById('nextFromVersion');
expect(nextButton.disabled).toBe(false);
expect(nextButton.classList.contains('disabled')).toBe(false);
});
it('disables Next when every weight file is already downloaded', () => {
const manager = new DownloadManager();
manager.currentVersion = makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
{ fileId: 1002, fileName: 'file-b.safetensors', filePath: '/models/loras/file-b.safetensors' },
{ fileId: 1003, fileName: 'file-c.safetensors', filePath: '/models/loras/file-c.safetensors' },
],
});
manager.updateNextButtonState();
const nextButton = document.getElementById('nextFromVersion');
expect(nextButton.disabled).toBe(true);
expect(nextButton.classList.contains('disabled')).toBe(true);
});
it('disables Next for an in-library single-file version', () => {
const manager = new DownloadManager();
manager.currentVersion = {
id: 202,
name: 'Single-file version',
files: [{ id: 1004, type: 'Model', sizeKB: 2048, name: 'file-d.safetensors' }],
existsLocally: true,
downloadedFiles: [],
};
manager.updateNextButtonState();
const nextButton = document.getElementById('nextFromVersion');
expect(nextButton.disabled).toBe(true);
});
});
@@ -0,0 +1,77 @@
"""Unit tests for per-file downloaded-state matching (#1058)."""
from py.routes.handlers.model_handlers import ModelCivitaiHandler
VERSION = {
"id": 42,
"files": [
{
"id": 1001,
"name": "file-a.safetensors",
"hashes": {"SHA256": "AAA111"},
},
{
"id": 1002,
"name": "file-b.safetensors",
"hashes": {"SHA256": "BBB222"},
},
],
}
def _entry(file_name: str, sha256: str = "", file_path: str | None = None):
return {
"file_name": file_name,
"file_path": file_path or f"/models/{file_name}.safetensors",
"sha256": sha256,
}
def test_matches_by_sha256():
entries = [_entry("renamed-locally", "bbb222")]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert result == [
{
"fileId": 1002,
"fileName": "file-b.safetensors",
"filePath": "/models/renamed-locally.safetensors",
}
]
def test_falls_back_to_name_when_hash_missing():
entries = [_entry("file-a", "")]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1001]
def test_hash_takes_precedence_over_name():
# Hash points at file-b while the name points at file-a: hash wins.
entries = [_entry("file-a", "bbb222")]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1002]
def test_unmatched_entries_are_skipped():
entries = [
_entry("unrelated", "ccc333"),
_entry("file-b", ""), # name match
]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1002]
def test_multiple_files_of_same_version():
entries = [
_entry("file-a", "aaa111"),
_entry("file-b", "bbb222"),
]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1001, 1002]
def test_empty_inputs():
assert ModelCivitaiHandler._match_downloaded_files(VERSION, []) == []
assert ModelCivitaiHandler._match_downloaded_files({"id": 1}, [_entry("x")]) == []
assert ModelCivitaiHandler._match_downloaded_files(VERSION, None) == []
@@ -68,3 +68,94 @@ async def test_download_history_bulk_lookup(tmp_path: Path) -> None:
5: {501, 502},
6: {601},
}
@pytest.mark.asyncio
async def test_per_file_history_tracking(tmp_path: Path) -> None:
"""Per-file records coexist with the version-level row (#1058)."""
db_path = tmp_path / "download-history.sqlite"
service = DownloadedVersionHistoryService(
str(db_path),
settings_manager=DummySettings(),
)
await service.mark_downloaded(
"lora", 101, model_id=11, source="download",
file_path="/models/a.safetensors", file_id=1001, file_name="a.safetensors",
)
await service.mark_downloaded(
"lora", 101, model_id=11, source="download",
file_path="/models/b.safetensors", file_id=1002, file_name="b.safetensors",
)
assert await service.get_downloaded_file_ids("lora", 101) == [1001, 1002]
# Version-level tracking remains single-row per version
assert await service.get_downloaded_version_ids("lora", 11) == [101]
# Re-downloading the same file updates in place, no duplicate
await service.mark_downloaded(
"lora", 101, source="download", file_id=1001, file_name="a.safetensors",
)
assert await service.get_downloaded_file_ids("lora", 101) == [1001, 1002]
# Single-file deletion keeps the sibling record
await service.mark_file_deleted("lora", 101, 1001)
assert await service.get_downloaded_file_ids("lora", 101) == [1002]
# Whole-version deletion clears per-file records
await service.mark_as_deleted("lora", 101)
assert await service.get_downloaded_file_ids("lora", 101) == []
assert await service.has_been_downloaded("lora", 101) is False
@pytest.mark.asyncio
async def test_per_file_history_ignores_invalid_ids(tmp_path: Path) -> None:
service = DownloadedVersionHistoryService(
str(tmp_path / "download-history.sqlite"),
settings_manager=DummySettings(),
)
# mark_downloaded without a file id only touches the version-level table
await service.mark_downloaded("lora", 201, model_id=21, source="scan")
assert await service.get_downloaded_file_ids("lora", 201) == []
# Invalid inputs are no-ops
await service.mark_file_deleted("lora", 201, None) # type: ignore[arg-type]
assert await service.get_downloaded_file_ids("unknown-type", 201) == []
@pytest.mark.asyncio
async def test_file_history_table_created_for_legacy_db(tmp_path: Path) -> None:
"""Existing databases gain the per-file table via CREATE IF NOT EXISTS."""
import sqlite3
db_path = tmp_path / "download-history.sqlite"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
CREATE TABLE downloaded_model_versions (
model_type TEXT NOT NULL,
version_id INTEGER NOT NULL,
model_id INTEGER,
first_seen_at REAL NOT NULL,
last_seen_at REAL NOT NULL,
source TEXT NOT NULL,
last_file_path TEXT,
last_library_name TEXT,
is_deleted_override INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_type, version_id)
);
"""
)
conn.close()
service = DownloadedVersionHistoryService(
str(db_path),
settings_manager=DummySettings(),
)
await service.mark_downloaded(
"lora", 301, model_id=31, source="download",
file_id=9001, file_name="file.safetensors",
)
assert await service.get_downloaded_file_ids("lora", 301) == [9001]
assert await service.has_been_downloaded("lora", 301) is True
+84
View File
@@ -61,3 +61,87 @@ async def test_model_cache_tracks_versions_by_model_id():
assert cache.get_versions_by_model_id(2) == [
{'versionId': 201, 'name': 'Gamma', 'fileName': 'model-b'},
]
@pytest.mark.asyncio
async def test_version_files_index_tracks_multiple_files_per_version():
"""Two downloaded files of the same version both stay indexed (#1058)."""
item_a = {
'file_path': '/models/v1-a.safetensors',
'file_name': 'model-v1-a',
'folder': '',
'civitai': {'id': 301, 'modelId': 3, 'name': 'Multi'},
}
item_b = {
'file_path': '/models/v1-b.safetensors',
'file_name': 'model-v1-b',
'folder': '',
'civitai': {'id': 301, 'modelId': 3, 'name': 'Multi'},
}
cache = ModelCache(
raw_data=[item_a, item_b],
folders=[],
name_display_mode='model_name',
)
files = cache.get_files_by_version_id(301)
assert {f['file_path'] for f in files} == {
'/models/v1-a.safetensors',
'/models/v1-b.safetensors',
}
# Re-adding an existing entry must not duplicate it
cache.add_to_version_index(item_a)
assert len(cache.get_files_by_version_id(301)) == 2
# Removing the indexed file re-points version_index to the sibling
indexed = cache.version_index[301]
sibling = item_b if indexed is item_a else item_a
cache.remove_from_version_index(indexed)
assert 301 in cache.version_index
assert cache.version_index[301]['file_path'] == sibling['file_path']
assert cache.get_versions_by_model_id(3) == [
{'versionId': 301, 'name': 'Multi', 'fileName': sibling['file_name']},
]
remaining = cache.get_files_by_version_id(301)
assert [f['file_path'] for f in remaining] == [sibling['file_path']]
# Removing the last file drops the version from all indexes
cache.remove_from_version_index(sibling)
assert 301 not in cache.version_index
assert cache.get_files_by_version_id(301) == []
assert cache.get_versions_by_model_id(3) == []
assert 3 not in cache.model_id_index
@pytest.mark.asyncio
async def test_version_files_index_rebuild_from_raw_data():
"""rebuild_version_index reconstructs the multi-valued index (#1058)."""
item_a = {
'file_path': '/models/v1-a.safetensors',
'file_name': 'model-v1-a',
'folder': '',
'civitai': {'id': 401, 'modelId': 4, 'name': 'Multi'},
}
item_b = {
'file_path': '/models/v1-b.safetensors',
'file_name': 'model-v1-b',
'folder': '',
'civitai': {'id': 401, 'modelId': 4, 'name': 'Multi'},
}
cache = ModelCache(
raw_data=[item_a, item_b],
folders=[],
name_display_mode='model_name',
)
cache.version_files_index = {}
cache.rebuild_version_index()
assert len(cache.get_files_by_version_id(401)) == 2
# Invalid ids normalize to empty results
assert cache.get_files_by_version_id('not-an-int') == []
assert cache.get_files_by_version_id(None) == []