Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions GUI/widgets/MBListView.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
QVBoxLayout,
QWidget,
)

from GUI.widgets.playlistEditor import MEDIA_TYPE_FLAGS
from ..hidpi import scale_pixmap_for_display
from ..styles import Colors, FONT_FAMILY, Metrics, table_css

Expand Down Expand Up @@ -2098,6 +2100,9 @@ def _on_track_context_menu(self, pos) -> None:
# ── Track Flags ──
menu.addSeparator()
self._build_flag_menu(menu, menu_style, selected, cache)

# ── Media Type ──
self._build_media_type_menu(menu, menu_style, selected, cache)

# ── Rating ──
self._build_rating_menu(menu, menu_style, selected, cache)
Expand Down Expand Up @@ -2195,6 +2200,43 @@ def _build_flag_menu(self, menu: QMenu, style: str, selected: list[dict], cache)
lambda _=False, v=new_val: self._set_track_flag("not_played_flag", v) # was playedMark
)

def _build_media_type_menu(
self,
menu: QMenu,
style: str,
selected: list[dict[str, object]],
_cache: object,
) -> None:
"""Add a Media Type submenu with options for video, audio, and other."""
media_type_menu = menu.addMenu("Media Type")
if not media_type_menu:
return
media_type_menu.setStyleSheet(style)

# Current media type (show check for unanimous value)
current_media_types: set[int] = set()
for t in selected:
raw = t.get("media_type", 0)
if isinstance(raw, int):
current_media_types.add(raw)
elif isinstance(raw, str):
try:
current_media_types.add(int(raw))
except ValueError:
current_media_types.add(0)
else:
current_media_types.add(0)
unanimous: int | None = current_media_types.pop() if len(current_media_types) == 1 else None

for value, label in MEDIA_TYPE_FLAGS:
prefix = "✓ " if unanimous == value else " "
act = media_type_menu.addAction(f"{prefix}{label}")
if act:
act.triggered.connect(
lambda _=False, v=value: self._set_track_flag("media_type", v)
)


def _build_rating_menu(self, menu: QMenu, style: str, selected: list[dict], cache) -> None:
"""Add a Rating submenu with 0-5 star options."""
rating_menu = menu.addMenu("Rating")
Expand Down
11 changes: 10 additions & 1 deletion GUI/widgets/syncReview.py
Original file line number Diff line number Diff line change
Expand Up @@ -1108,12 +1108,14 @@ def _setup_ui(self):

loading_layout.addSpacing(16)

# Detail — current item / worker lines
# Detail — current item / worker lines (multiline during parallel work)
self.progress_detail = QLabel("", loading_widget)
self.progress_detail.setStyleSheet(
f"color: {Colors.TEXT_TERTIARY}; font-size: {Metrics.FONT_LG}px;"
)
self.progress_detail.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.progress_detail.setWordWrap(True)
self.progress_detail.setMaximumWidth(480)
loading_layout.addWidget(self.progress_detail)

# Hint label (shown only during automatic pre-sync backup stage)
Expand Down Expand Up @@ -1497,6 +1499,9 @@ def _setup_ui(self):
"backup": "Creating pre-sync backup",
"transcode": "Transcoding",
"scrobble": "Scrobbling to ListenBrainz",
"load_database": "Loading iPod database",
"podcast_download": "Downloading podcasts",
"update_artwork": "Updating artwork records",
}

def _friendly_stage(self, stage: str) -> str:
Expand Down Expand Up @@ -2034,6 +2039,10 @@ def update_execute_progress(self, prog):
message = getattr(prog, 'message', '') or ''
worker_lines = getattr(prog, 'worker_lines', None)
size_progress = getattr(prog, 'size_progress', None)
if not message:
current_item = getattr(prog, 'current_item', None)
if current_item is not None:
message = getattr(current_item, 'description', '') or ''

# Transcode is a sub-stage — update the bar without changing
# the headline.
Expand Down
95 changes: 85 additions & 10 deletions SyncEngine/fingerprint_diff_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from pathlib import Path
import logging
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

from .pc_library import PCLibrary, PCTrack
Expand Down Expand Up @@ -461,9 +462,24 @@ def compute_diff(
return plan

if progress_callback:
progress_callback("scan_pc", 0, 0, "Scanning PC library...")
progress_callback("scan_pc", 0, 0, "Counting media files in PC library")

pc_tracks = list(self.pc_library.scan(include_video=self.supports_video))
total_media = self.pc_library.count_audio_files(
include_video=self.supports_video,
)

def _on_scan_progress(current: int, total: int, track: PCTrack) -> None:
if progress_callback:
label = track.relative_path or track.filename or str(track.path)
progress_callback("scan_pc", current, total, label)

pc_tracks = list(
self.pc_library.scan(
include_video=self.supports_video,
progress_callback=_on_scan_progress,
total_hint=total_media,
)
)

# Filter out podcast tracks when the device doesn't support podcasts.
# This mirrors the include_video filter: no point syncing content the
Expand All @@ -486,15 +502,50 @@ def compute_diff(
completed = 0
completed_lock = threading.Lock()
total = len(pc_tracks)

def _fingerprint_one(track: PCTrack) -> tuple[PCTrack, Optional[str]]:
fp = get_or_compute_fingerprint(track.path, write_to_file=write_fingerprints)
return (track, fp)
worker_status: dict[int, str] = {}
status_lock = threading.Lock()
last_fp_emit = [0.0]

def _emit_fingerprint_progress(current: int) -> None:
if not progress_callback:
return
now = time.monotonic()
if (now - last_fp_emit[0]) < 0.05 and current < total:
return
last_fp_emit[0] = now
with status_lock:
lines = list(worker_status.values())
if lines:
# Show what each worker is doing while others run (reduces “silent” gaps).
body = "\n".join(lines[:fp_workers])
msg = f"{current} of {total} done\n{body}"
else:
msg = f"{current} of {total} done"
progress_callback("fingerprint", current, total, msg)

def _fingerprint_job(worker_id: int, track: PCTrack) -> tuple[PCTrack, Optional[str]]:
label = track.filename or track.relative_path or str(track.path)
with status_lock:
worker_status[worker_id] = f"Fingerprinting: {label}"
with completed_lock:
done_snapshot = completed
_emit_fingerprint_progress(done_snapshot)
try:
fp = get_or_compute_fingerprint(
track.path, write_to_file=write_fingerprints,
)
return (track, fp)
finally:
with status_lock:
worker_status.pop(worker_id, None)

logger.info(f"Fingerprinting {total} tracks with {fp_workers} workers")

with ThreadPoolExecutor(max_workers=fp_workers) as pool:
futures = {pool.submit(_fingerprint_one, t): t for t in pc_tracks}
futures = {
pool.submit(_fingerprint_job, i, t): t
for i, t in enumerate(pc_tracks)
}

for future in as_completed(futures):
if is_cancelled and is_cancelled():
Expand All @@ -513,7 +564,7 @@ def _fingerprint_one(track: PCTrack) -> tuple[PCTrack, Optional[str]]:
track, fp = future.result()

if progress_callback:
progress_callback("fingerprint", current, total, track.filename)
_emit_fingerprint_progress(current)

if not fp:
plan.fingerprint_errors.append((track.path, "Could not compute fingerprint"))
Expand Down Expand Up @@ -549,8 +600,12 @@ def _fingerprint_one(track: PCTrack) -> tuple[PCTrack, Optional[str]]:
if is_cancelled and is_cancelled():
return plan

n_identity = len(identity_groups)
if progress_callback:
progress_callback("diff", 0, 0, "Computing differences...")
progress_callback(
"diff", 0, max(n_identity, 1),
"Computing differences…",
)

# For fingerprints with multiple album groups, we need to track which
# mapping entries have already been claimed so each PC track gets its own.
Expand All @@ -572,10 +627,30 @@ def _album_match_priority(item):
return 1 # no match → process after confident matches

sorted_groups = sorted(identity_groups.items(), key=_album_match_priority)
n_groups = len(sorted_groups)
last_diff_emit = 0.0
emit_every = 1 if n_groups <= 600 else max(1, n_groups // 400)

for (fp, _album_key), pc_tracks_for_group in sorted_groups:
for gi, ((fp, _album_key), pc_tracks_for_group) in enumerate(sorted_groups):
# Pick representative track (first one from this album group)
pc_track = pc_tracks_for_group[0]
if progress_callback and n_groups:
now = time.monotonic()
if (
gi % emit_every == 0
or gi == n_groups - 1
or (now - last_diff_emit) >= 0.25
):
last_diff_emit = now
label = pc_track.filename or pc_track.relative_path or str(
pc_track.path,
)
progress_callback(
"diff",
gi + 1,
n_groups,
f"Matching PC ↔ iPod ({gi + 1} of {n_groups}) — {label}",
)
mapping_entries = mapping.get_entries(fp)

if not mapping_entries:
Expand Down
53 changes: 44 additions & 9 deletions SyncEngine/integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import logging
from dataclasses import dataclass, field
from pathlib import Path
import time
from typing import Optional, Callable

from .mapping import MappingFile
Expand Down Expand Up @@ -110,7 +111,9 @@ def _cancelled() -> bool:
if progress_callback:
progress_callback("integrity", 0, 0, "Checking iTunesDB against filesystem…")

_check_db_files_exist(ipod_root, ipod_tracks, report)
_check_db_files_exist(
ipod_root, ipod_tracks, report, progress_callback=progress_callback,
)

if _cancelled():
return report
Expand All @@ -128,7 +131,10 @@ def _cancelled() -> bool:
if progress_callback:
progress_callback("integrity", 0, 0, "Scanning for orphan files…")

_check_orphan_files(ipod_root, music_dir, ipod_tracks, report, delete_orphans, _cancelled)
_check_orphan_files(
ipod_root, music_dir, ipod_tracks, report, delete_orphans, _cancelled,
progress_callback=progress_callback,
)

if not report.is_clean:
logger.warning(report.summary)
Expand All @@ -145,11 +151,24 @@ def _check_db_files_exist(
ipod_root: Path,
ipod_tracks: list[dict],
report: IntegrityReport,
*,
progress_callback: Optional[Callable] = None,
) -> None:
"""Remove tracks from *ipod_tracks* whose audio file is missing."""
to_remove_indices: list[int] = []
total = len(ipod_tracks)
last_emit = 0.0

for idx, track in enumerate(ipod_tracks):
if progress_callback and total > 0:
now = time.monotonic()
if idx == 0 or idx == total - 1 or idx % 64 == 0 or (now - last_emit) >= 0.2:
last_emit = now
title = track.get("Title") or track.get("Artist") or "track"
progress_callback(
"integrity", idx + 1, total,
f"Verifying iPod files ({idx + 1} of {total}) — {title}",
)
location = track.get("Location")
if not location:
continue
Expand Down Expand Up @@ -219,6 +238,8 @@ def _check_orphan_files(
report: IntegrityReport,
delete_orphans: bool,
is_cancelled: Callable[[], bool] = lambda: False,
*,
progress_callback: Optional[Callable] = None,
) -> None:
"""Find and optionally delete files in Music/F** not referenced by iTunesDB."""
if not music_dir.exists():
Expand All @@ -240,14 +261,22 @@ def _check_orphan_files(

# Scan F00–F## for actual audio files
orphans: list[Path] = []
for folder in sorted(music_dir.iterdir()):
f_folders = sorted(
d for d in music_dir.iterdir()
if d.is_dir()
and len(d.name) >= 2
and d.name[0] == "F"
and d.name[1:].isdigit()
)
n_folders = len(f_folders)
for fi, folder in enumerate(f_folders):
if is_cancelled():
return
if not folder.is_dir():
continue
# Only look in F## folders
if not (len(folder.name) >= 2 and folder.name[0] == "F" and folder.name[1:].isdigit()):
continue
if progress_callback and n_folders:
progress_callback(
"integrity", fi + 1, n_folders,
f"Scanning {folder.name} for files not in iTunesDB…",
)
for file in folder.iterdir():
if is_cancelled():
return
Expand All @@ -269,7 +298,13 @@ def _check_orphan_files(

if delete_orphans:
deleted = 0
for orphan in orphans:
n_or = len(orphans)
for oi, orphan in enumerate(orphans):
if progress_callback and n_or and (oi % 32 == 0 or oi == n_or - 1):
progress_callback(
"integrity", oi + 1, n_or,
f"Removing orphan file: {orphan.name}",
)
try:
orphan.unlink()
deleted += 1
Expand Down
14 changes: 12 additions & 2 deletions SyncEngine/pc_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,8 @@ def scan(
self,
progress_callback: Optional[Callable[[int, int, PCTrack], None]] = None,
include_video: bool = True,
*,
total_hint: Optional[int] = None,
) -> Iterator[PCTrack]:
"""
Scan the library and yield PCTrack objects.
Expand All @@ -559,14 +561,22 @@ def scan(
progress_callback: Optional callback(current, total, track) for progress updates
include_video: When False, skip video files entirely.
Set to False when syncing to iPods that don't support video.
total_hint: When set with *progress_callback*, use this as the item total instead
of running a separate full-tree count (avoids a long silent pass).
"""
if not MUTAGEN_AVAILABLE:
raise RuntimeError("mutagen is required for library scanning. Install with: pip install mutagen")

extensions = MEDIA_EXTENSIONS if include_video else AUDIO_EXTENSIONS

# First count files for progress
total = self.count_audio_files(include_video=include_video) if progress_callback else 0
# Count files for progress unless the caller already counted (e.g. diff engine).
if progress_callback:
if total_hint is not None:
total = total_hint
else:
total = self.count_audio_files(include_video=include_video)
else:
total = 0
current = 0

for root, _, files in os.walk(self.root_path):
Expand Down
Loading