Skip to content

Commit 7e32e68

Browse files
authored
Merge pull request #1732 from Nikoh77/fix-scan-percentage
Fix scan progress percentage stuck below 100%
2 parents 4ef8fad + a80d847 commit 7e32e68

File tree

3 files changed

+203
-1
lines changed

3 files changed

+203
-1
lines changed

api/directory_watcher.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ def handle_new_image(user, path, job_id, photo=None):
149149
This function is used, when uploading a picture, because rescanning does not perform machine learning tasks
150150
151151
"""
152-
update_scan_counter(job_id)
153152
try:
154153
start = datetime.datetime.now()
155154
if photo is None:
@@ -201,6 +200,8 @@ def handle_new_image(user, path, job_id, photo=None):
201200
)
202201
except Exception:
203202
util.logger.exception(f"job {job_id}: could not load image {path}")
203+
finally:
204+
update_scan_counter(job_id)
204205

205206

206207
def walk_directory(directory, callback):
@@ -237,6 +238,7 @@ def wait_for_group_and_process_metadata(
237238
*,
238239
attempt: int = 1,
239240
max_attempts: int = 2,
241+
**kwargs # Django-Q may pass additional arguments like 'schedule'
240242
):
241243
"""
242244
Sentinel task: waits until the expected number of image/video tasks in the group complete,
@@ -470,6 +472,12 @@ def scan_photos(user, full_scan, job_id, scan_directory="", scan_files=[]):
470472

471473
util.logger.info(f"Scanned {files_found} files in : {scan_directory}")
472474

475+
# If no files were queued for processing (empty directory or all files already processed),
476+
# mark the job as finished immediately since progress_current will equal progress_target (both 0)
477+
LongRunningJob.objects.filter(
478+
job_id=job_id, progress_current=F("progress_target")
479+
).update(finished=True, finished_at=timezone.now())
480+
473481
util.logger.info("Finished updating album things")
474482

475483
# Check for photos with missing aspect ratios but existing thumbnails
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import os
2+
import uuid
3+
from unittest.mock import MagicMock, patch
4+
5+
from django.test import TestCase # type: ignore
6+
7+
from api.directory_watcher import handle_new_image
8+
from api.models import LongRunningJob, User
9+
10+
11+
class ScanPercentageProgressTestCase(TestCase):
12+
def setUp(self):
13+
self.user = User.objects.create_user(
14+
username="testuser",
15+
password="testpass",
16+
)
17+
self.user.skip_raw_files = True
18+
self.user.save()
19+
self.job_id = uuid.uuid4()
20+
21+
def _scan_file_list(self):
22+
return (
23+
[f"photo{i}.jpg" for i in range(10)]
24+
+ [f"photo{i}.raw" for i in range(7)]
25+
+ [f"photo{i}.xmp" for i in range(10)]
26+
+ [f"document{i}.pdf" for i in range(10)]
27+
)
28+
29+
def _simulate_pre_fix_progress(self, files):
30+
images_and_videos: list[str] = []
31+
metadata_paths: list[str] = []
32+
for path in files:
33+
if path.endswith(".xmp"):
34+
metadata_paths.append(path)
35+
else:
36+
images_and_videos.append(path)
37+
38+
processed = 0
39+
for path in images_and_videos:
40+
ext = os.path.splitext(path)[1].lower()
41+
if ext == ".raw" and self.user.skip_raw_files:
42+
continue
43+
if ext == ".pdf":
44+
continue
45+
if ext == ".jpg":
46+
processed += 1
47+
processed += len(metadata_paths)
48+
return processed
49+
50+
def test_scan_progress_counts_every_discovered_file(self):
51+
"""Ensure the scan job reaches 100% even when files are skipped or metadata."""
52+
53+
files = self._scan_file_list()
54+
pre_fix_processed = self._simulate_pre_fix_progress(files)
55+
pre_fix_percentage = (pre_fix_processed / len(files)) * 100
56+
pre_fix_summary = (
57+
f"Pre-fix simulated progress: {pre_fix_processed}/{len(files)} "
58+
f"({pre_fix_percentage:.1f}%) -> stuck"
59+
)
60+
discovered_by_ext: dict[str, int] = {}
61+
for path in files:
62+
ext = os.path.splitext(path)[1].lower()
63+
discovered_by_ext[ext] = discovered_by_ext.get(ext, 0) + 1
64+
lrj = LongRunningJob.objects.create(
65+
started_by=self.user,
66+
job_id=self.job_id,
67+
job_type=LongRunningJob.JOB_SCAN_PHOTOS,
68+
progress_current=0,
69+
progress_target=len(files),
70+
)
71+
72+
photos_created_by_ext: dict[str, int] = {}
73+
74+
def mock_create_new_image(user, path):
75+
ext = os.path.splitext(path)[1].lower()
76+
if ext == ".jpg":
77+
photos_created_by_ext[ext] = photos_created_by_ext.get(ext, 0) + 1
78+
return MagicMock()
79+
photos_created_by_ext[ext] = photos_created_by_ext.get(ext, 0)
80+
return None
81+
82+
thumbnail_mock = MagicMock()
83+
thumbnail_mock._generate_thumbnail.return_value = None
84+
thumbnail_mock._calculate_aspect_ratio.return_value = None
85+
thumbnail_mock._get_dominant_color.return_value = None
86+
search_instance_mock = MagicMock()
87+
88+
with patch(
89+
"api.directory_watcher.create_new_image",
90+
side_effect=mock_create_new_image,
91+
), patch(
92+
"api.models.Thumbnail.objects.get_or_create",
93+
return_value=(thumbnail_mock, True),
94+
), patch(
95+
"api.models.PhotoSearch.objects.get_or_create",
96+
return_value=(search_instance_mock, True),
97+
):
98+
for path in files:
99+
handle_new_image(self.user, path, self.job_id)
100+
101+
lrj.refresh_from_db()
102+
percentage = (
103+
(lrj.progress_current / lrj.progress_target) * 100
104+
if lrj.progress_target
105+
else 0
106+
)
107+
file_breakdown = ["File breakdown:" ]
108+
for ext, total in sorted(discovered_by_ext.items()):
109+
created = photos_created_by_ext.get(ext, 0)
110+
behavior = "creates Photo" if created else "skipped"
111+
file_breakdown.append(
112+
f" - {ext}: discovered={total}, create_new_image={behavior}"
113+
)
114+
breakdown_summary = "\n".join(file_breakdown)
115+
progress_summary = (
116+
f"Scan job progress: {lrj.progress_current}/{lrj.progress_target} "
117+
f"({percentage:.1f}%) finished={lrj.finished}"
118+
)
119+
print(pre_fix_summary)
120+
print(breakdown_summary)
121+
print(progress_summary)
122+
123+
self.assertLess(
124+
pre_fix_processed,
125+
len(files),
126+
"Pre-fix simulation should demonstrate the bug (progress < total).",
127+
)
128+
self.assertEqual(lrj.progress_target, len(files), progress_summary)
129+
self.assertEqual(
130+
lrj.progress_current,
131+
len(files),
132+
f"{breakdown_summary}\n{progress_summary} -> Every discovered file should advance the counter.",
133+
)
134+
self.assertTrue(
135+
lrj.finished,
136+
f"{breakdown_summary}\n{progress_summary} -> Scan job must finish when current equals target.",
137+
)

test_empty_scan.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env python
2+
import os
3+
import sys
4+
import django
5+
import uuid
6+
import time
7+
8+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "librephotos.settings")
9+
django.setup()
10+
11+
from api.models import User, LongRunningJob
12+
from api.directory_watcher import scan_photos
13+
14+
# Create test user
15+
user, created = User.objects.get_or_create(
16+
username="empty_scan_user",
17+
defaults={"scan_directory": "/tmp/empty_scan_test"}
18+
)
19+
user.scan_directory = "/tmp/empty_scan_test"
20+
user.save()
21+
22+
print(f"Testing scan with empty directory: {user.scan_directory}")
23+
print(f"Files in directory: {len(os.listdir(user.scan_directory))}")
24+
25+
# Start scan
26+
job_id = uuid.uuid4()
27+
print(f"\nStarting scan with job_id: {job_id}")
28+
29+
scan_photos(user, full_scan=True, job_id=job_id, scan_directory="/tmp/empty_scan_test")
30+
31+
# Wait a moment for job to complete
32+
time.sleep(2)
33+
34+
# Check job status
35+
try:
36+
job = LongRunningJob.objects.get(job_id=job_id)
37+
percentage = (job.progress_current / job.progress_target * 100) if job.progress_target > 0 else 0
38+
39+
print("\n=== Scan Results ===")
40+
print(f"Job ID: {job.job_id}")
41+
print(f"Job Type: {job.job_type}")
42+
print(f"Progress: {job.progress_current}/{job.progress_target}")
43+
print(f"Percentage: {percentage:.1f}%")
44+
print(f"Started: {job.started_at}")
45+
print(f"Finished: {job.finished}")
46+
print(f"Finished at: {job.finished_at}")
47+
print(f"Failed: {job.failed}")
48+
49+
if job.progress_target == 0 and job.finished:
50+
print("\n✓ PASS: Empty directory scan handled correctly (0/0, finished=True)")
51+
elif job.progress_target == 0 and not job.finished:
52+
print("\n✗ FAIL: Empty directory scan not marked as finished")
53+
else:
54+
print(f"\n? UNEXPECTED: progress_target={job.progress_target} (expected 0)")
55+
56+
except LongRunningJob.DoesNotExist:
57+
print(f"\n✗ FAIL: Job {job_id} not found in database")

0 commit comments

Comments
 (0)