fix: correct playlist creation on iPod (real db_track_id, metadata fallback, Settings hover fix)
This commit is contained in:
parent
8861e55f32
commit
b7367e63c4
53
src/app.py
53
src/app.py
@ -48,6 +48,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.playlist_manager = PlaylistManager()
|
||||
self._sidebar_visible = True
|
||||
self._pending_playlist_id: str = ""
|
||||
self._pending_playlist_tracks: list = []
|
||||
|
||||
self._saved_volume: int = 80
|
||||
self._saved_track_path: str = ""
|
||||
@ -141,7 +143,9 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.sidebar.tracks_dropped_on_playlist.connect(self._on_tracks_dropped_on_playlist)
|
||||
self.sidebar.tracks_dropped_for_nav_transfer.connect(self._on_tracks_dropped_for_nav_transfer)
|
||||
self.sidebar.playlist_dropped_for_nav_transfer.connect(self._on_playlist_dropped_for_nav_transfer)
|
||||
self.ipod_tab.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_transfer)
|
||||
self.library_tab.transfer_finished.connect(self._on_transfer_finished_for_playlist)
|
||||
|
||||
def _on_sidebar_item_selected(self, kind: str, value: str):
|
||||
if kind == "tab":
|
||||
@ -197,6 +201,55 @@ class MainWindow(QMainWindow):
|
||||
self.stack.setCurrentIndex(0)
|
||||
self.library_tab.transfer_tracks_by_paths(paths)
|
||||
|
||||
def _on_playlist_dropped_for_nav_transfer(self, playlist_id: str):
|
||||
playlist = self.playlist_manager.get_by_id(playlist_id)
|
||||
if not playlist or not playlist.track_paths:
|
||||
return
|
||||
|
||||
self.stack.setCurrentIndex(0)
|
||||
self._pending_playlist_id = playlist_id
|
||||
self._pending_playlist_tracks = list(playlist.track_paths)
|
||||
self.library_tab.transfer_tracks_by_paths(playlist.track_paths)
|
||||
|
||||
def _on_transfer_finished_for_playlist(self):
|
||||
playlist_id = self._pending_playlist_id
|
||||
track_paths = self._pending_playlist_tracks
|
||||
self._pending_playlist_id = ""
|
||||
self._pending_playlist_tracks = []
|
||||
|
||||
if not playlist_id or not track_paths:
|
||||
return
|
||||
|
||||
mount_point = getattr(self.ipod_tab, "current_mount_point", None)
|
||||
if not mount_point:
|
||||
self.statusBar().showMessage("Cannot create playlist: iPod not mounted", 5000)
|
||||
return
|
||||
|
||||
playlist = self.playlist_manager.get_by_id(playlist_id)
|
||||
if not playlist:
|
||||
return
|
||||
|
||||
self.statusBar().showMessage(f"Creating playlist '{playlist.name}' on iPod...")
|
||||
|
||||
from worker import WorkerThread
|
||||
self._pl_worker = WorkerThread(
|
||||
task_type="create_playlist",
|
||||
mount_point=mount_point,
|
||||
playlist_name=playlist.name,
|
||||
track_paths=track_paths,
|
||||
)
|
||||
self._pl_worker.finished_signal.connect(self._on_playlist_creation_finished)
|
||||
self._pl_worker.start()
|
||||
|
||||
def _on_playlist_creation_finished(self, success, message, result):
|
||||
self.statusBar().showMessage(message, 8000)
|
||||
try:
|
||||
self._pl_worker.quit()
|
||||
self._pl_worker.wait()
|
||||
except Exception:
|
||||
pass
|
||||
self._pl_worker = None
|
||||
|
||||
def _load_playlists_into_sidebar(self):
|
||||
for pl in self.playlist_manager.get_all():
|
||||
self.sidebar.add_playlist_item(pl.id, pl.name)
|
||||
|
||||
@ -32,7 +32,7 @@ import sqlite3
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.easyid3 import EasyID3
|
||||
@ -627,6 +627,114 @@ class Nano7Database:
|
||||
deleted += 1
|
||||
return deleted
|
||||
|
||||
# ── playlist creation ────────────────────────────────────────────
|
||||
|
||||
def create_playlist(self, name: str, track_paths: list[str]) -> bool:
|
||||
"""Create a user playlist on the iPod.
|
||||
|
||||
Two-pass matching: content hash first, then title+artist+album
|
||||
fallback. Only tracks already present on the iPod will be
|
||||
included in the playlist.
|
||||
|
||||
Args:
|
||||
name: Playlist name.
|
||||
track_paths: Local file paths of the tracks to include.
|
||||
|
||||
Returns:
|
||||
True if the playlist was created, False otherwise.
|
||||
"""
|
||||
scanned = self._scan_ipod_files()
|
||||
if not scanned:
|
||||
logger.warning("No tracks on iPod — cannot create playlist")
|
||||
return False
|
||||
|
||||
playlist_created = False
|
||||
self._merge_play_stats(scanned)
|
||||
|
||||
def _build_pl_cb(scanned, tracks):
|
||||
"""Build playlist using real db_track_id from TrackInfo."""
|
||||
nonlocal playlist_created
|
||||
|
||||
try:
|
||||
IopPlaylistInfo = _import_iop(
|
||||
"iTunesDB_Writer.mhyp_writer"
|
||||
).PlaylistInfo
|
||||
except Exception as e:
|
||||
logger.exception("Failed to import PlaylistInfo: %s", e)
|
||||
return None
|
||||
|
||||
# Pass 1: content_hash → db_track_id
|
||||
hash_to_db_id: dict[str, int] = {}
|
||||
for i, s in enumerate(scanned):
|
||||
ch = s.get("content_hash", "")
|
||||
if ch:
|
||||
hash_to_db_id[ch] = tracks[i].db_track_id
|
||||
|
||||
found_ids: list[int] = []
|
||||
unmatched: list[str] = []
|
||||
|
||||
for path in track_paths:
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
ch = content_hash(path)
|
||||
db_id = hash_to_db_id.get(ch)
|
||||
if db_id is not None:
|
||||
found_ids.append(db_id)
|
||||
else:
|
||||
unmatched.append(path)
|
||||
|
||||
hash_matched_count = len(found_ids)
|
||||
|
||||
# Pass 2: metadata fallback
|
||||
if unmatched:
|
||||
scanned_meta = []
|
||||
for i, s in enumerate(scanned):
|
||||
scanned_meta.append((
|
||||
s.get("title", "").lower(),
|
||||
s.get("artist", "").lower(),
|
||||
(s.get("album") or "").lower(),
|
||||
tracks[i].db_track_id,
|
||||
))
|
||||
|
||||
found_set = set(found_ids)
|
||||
for path in unmatched:
|
||||
tags = _read_audio_tags(path)
|
||||
title = (tags.get("title") or "").lower()
|
||||
artist = (tags.get("artist") or "").lower()
|
||||
album = (tags.get("album") or "").lower()
|
||||
if not title or not artist:
|
||||
continue
|
||||
for s_title, s_artist, s_album, db_id in scanned_meta:
|
||||
if (db_id not in found_set
|
||||
and s_title == title
|
||||
and s_artist == artist
|
||||
and s_album == album):
|
||||
found_ids.append(db_id)
|
||||
found_set.add(db_id)
|
||||
break
|
||||
|
||||
if not found_ids:
|
||||
logger.warning(
|
||||
"No tracks from playlist '%s' found on iPod",
|
||||
name,
|
||||
)
|
||||
return None
|
||||
|
||||
matched_by_hash = hash_matched_count
|
||||
matched_by_meta = len(found_ids) - hash_matched_count
|
||||
logger.info(
|
||||
"Playlist '%s': %d track(s) matched (%d by hash, %d by metadata)",
|
||||
name, len(found_ids), matched_by_hash, matched_by_meta,
|
||||
)
|
||||
playlist_created = True
|
||||
return [IopPlaylistInfo(name=name, track_ids=found_ids)]
|
||||
|
||||
db_ok = self._write_databases_from_tracks(
|
||||
scanned, build_extra_playlists=_build_pl_cb,
|
||||
)
|
||||
self._cleanup_play_counts_file()
|
||||
return db_ok and playlist_created
|
||||
|
||||
# ── sync: regenerate everything via iOpenPod ──────────────────────
|
||||
|
||||
def sync_itunescdb(self) -> None:
|
||||
@ -694,7 +802,9 @@ class Nano7Database:
|
||||
return
|
||||
|
||||
self._merge_play_stats(scanned)
|
||||
self._write_databases_from_tracks(scanned)
|
||||
if not self._write_databases_from_tracks(scanned):
|
||||
logger.error("iTunesDB sync failed — play counts not cleaned up")
|
||||
return
|
||||
self._cleanup_play_counts_file()
|
||||
|
||||
def find_track_by_content_hash(self, content_hash: str) -> dict | None:
|
||||
@ -901,7 +1011,9 @@ class Nano7Database:
|
||||
self,
|
||||
scanned: list[dict],
|
||||
artwork_overrides: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
extra_playlists: list | None = None,
|
||||
build_extra_playlists: Callable | None = None,
|
||||
) -> bool:
|
||||
"""Build artwork, convert to TrackInfo, write iTunesDB + SQLite.
|
||||
|
||||
Args:
|
||||
@ -911,6 +1023,15 @@ class Nano7Database:
|
||||
artwork should come from a local file instead of the iPod file.
|
||||
Use the ``_REMOVE_ARTWORK`` sentinel to explicitly remove
|
||||
artwork for a track. Omit a key to keep existing iPod artwork.
|
||||
extra_playlists: Pre-built list of PlaylistInfo (ignored when
|
||||
build_extra_playlists is provided).
|
||||
build_extra_playlists: Optional callable(scanned, tracks) → list
|
||||
of PlaylistInfo. Called after TrackInfo conversion and
|
||||
db_track_id generation, so the callback can use real
|
||||
db_track_id values from TrackInfo objects.
|
||||
|
||||
Returns:
|
||||
True if databases were written successfully.
|
||||
"""
|
||||
artwork_overrides = artwork_overrides or {}
|
||||
# 1. Prepare artwork for all tracks
|
||||
@ -970,6 +1091,20 @@ class Nano7Database:
|
||||
tracks = _scanned_tracks_to_iop(self.mountpoint, scanned, artwork_map)
|
||||
logger.info("Built %d iOpenPod TrackInfo objects", len(tracks))
|
||||
|
||||
# 2b. Pre-generate db_track_id for all tracks so callbacks can use them
|
||||
generate_db_track_id = _import_iop("iTunesDB_Writer.mhit_writer").generate_db_track_id
|
||||
for track in tracks:
|
||||
if track.db_track_id == 0:
|
||||
track.db_track_id = generate_db_track_id()
|
||||
|
||||
# 2c. Build extra playlists via callback if provided
|
||||
if build_extra_playlists is not None:
|
||||
result = build_extra_playlists(scanned, tracks)
|
||||
if result is not None:
|
||||
extra_playlists = result
|
||||
else:
|
||||
extra_playlists = None
|
||||
|
||||
# 3. Determine capabilities for this device
|
||||
ipod_device = _import_iop("ipod_device")
|
||||
capabilities = None
|
||||
@ -998,10 +1133,11 @@ class Nano7Database:
|
||||
backup=True,
|
||||
capabilities=capabilities,
|
||||
master_playlist_name="iPod",
|
||||
playlists=extra_playlists or None,
|
||||
)
|
||||
if not ok:
|
||||
logger.error("iTunesDB write failed")
|
||||
return
|
||||
return False
|
||||
logger.info("iTunesDB written (%d tracks)", len(tracks))
|
||||
|
||||
# 5. Extract db_pid from the binary DB for SQLite cross-reference
|
||||
@ -1029,13 +1165,18 @@ class Nano7Database:
|
||||
capabilities=capabilities,
|
||||
firewire_id=firewire_id,
|
||||
backup=True,
|
||||
playlists=extra_playlists or None,
|
||||
)
|
||||
if sqlite_ok:
|
||||
logger.info("SQLite databases written")
|
||||
else:
|
||||
logger.error("SQLite database write failed")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.exception("SQLite write error: %s", e)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _write_empty_databases(self) -> None:
|
||||
"""Write minimal empty databases when no tracks exist."""
|
||||
|
||||
@ -27,6 +27,14 @@ class PlaylistDropListWidget(QListWidget):
|
||||
self.setAcceptDrops(True)
|
||||
self._hovered_item = None
|
||||
|
||||
def mimeData(self, items):
|
||||
mime = super().mimeData(items)
|
||||
if items:
|
||||
playlist_id = items[0].data(Qt.ItemDataRole.UserRole)
|
||||
if playlist_id:
|
||||
mime.setData("application/x-neopod-playlist", playlist_id.encode())
|
||||
return mime
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||
if event.source() is not self and event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
@ -83,6 +91,7 @@ class PlaylistDropListWidget(QListWidget):
|
||||
class NavDropListWidget(QListWidget):
|
||||
"""QListWidget that accepts external track drops on the iPod nav item."""
|
||||
tracks_dropped_for_transfer = pyqtSignal(list)
|
||||
playlist_dropped_for_transfer = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@ -95,7 +104,19 @@ class NavDropListWidget(QListWidget):
|
||||
return item
|
||||
return None
|
||||
|
||||
def _repolish(self):
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||
if event.mimeData().hasFormat("application/x-neopod-playlist"):
|
||||
self.setProperty("dndActive", True)
|
||||
self._repolish()
|
||||
pos = event.position().toPoint()
|
||||
if self._target_item_at(pos):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
# Let default handler accept so dragMoveEvent still fires
|
||||
if event.source() is not self and event.mimeData().hasUrls():
|
||||
pos = event.position().toPoint()
|
||||
if self._target_item_at(pos):
|
||||
@ -104,6 +125,7 @@ class NavDropListWidget(QListWidget):
|
||||
super().dragEnterEvent(event)
|
||||
|
||||
def dragMoveEvent(self, event: QDragMoveEvent):
|
||||
accept = False
|
||||
if event.source() is not self and event.mimeData().hasUrls():
|
||||
pos = event.position().toPoint()
|
||||
item = self._target_item_at(pos)
|
||||
@ -115,15 +137,34 @@ class NavDropListWidget(QListWidget):
|
||||
item.setBackground(bg)
|
||||
if item:
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
super().dragMoveEvent(event)
|
||||
accept = True
|
||||
if not accept and event.mimeData().hasFormat("application/x-neopod-playlist"):
|
||||
pos = event.position().toPoint()
|
||||
item = self._target_item_at(pos)
|
||||
if item != self._hovered_item:
|
||||
self._clear_hover()
|
||||
if item:
|
||||
self._hovered_item = item
|
||||
bg = self.palette().color(self.palette().ColorRole.Highlight)
|
||||
item.setBackground(bg)
|
||||
if item:
|
||||
event.acceptProposedAction()
|
||||
accept = True
|
||||
if not accept:
|
||||
event.ignore()
|
||||
|
||||
def dragLeaveEvent(self, event: QDragLeaveEvent):
|
||||
self._clear_hover()
|
||||
if self.property("dndActive"):
|
||||
self.setProperty("dndActive", False)
|
||||
self._repolish()
|
||||
super().dragLeaveEvent(event)
|
||||
|
||||
def dropEvent(self, event: QDropEvent):
|
||||
self._clear_hover()
|
||||
if self.property("dndActive"):
|
||||
self.setProperty("dndActive", False)
|
||||
self._repolish()
|
||||
if event.source() is not self and event.mimeData().hasUrls():
|
||||
pos = event.position().toPoint()
|
||||
if self._target_item_at(pos):
|
||||
@ -133,6 +174,14 @@ class NavDropListWidget(QListWidget):
|
||||
self.tracks_dropped_for_transfer.emit(paths)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
if event.mimeData().hasFormat("application/x-neopod-playlist"):
|
||||
pos = event.position().toPoint()
|
||||
if self._target_item_at(pos):
|
||||
playlist_id = bytes(event.mimeData().data("application/x-neopod-playlist")).decode()
|
||||
if playlist_id:
|
||||
self.playlist_dropped_for_transfer.emit(playlist_id)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
super().dropEvent(event)
|
||||
|
||||
def _clear_hover(self):
|
||||
@ -146,6 +195,7 @@ class SidebarWidget(QWidget):
|
||||
playlist_selected = pyqtSignal(str)
|
||||
tracks_dropped_on_playlist = pyqtSignal(str, list)
|
||||
tracks_dropped_for_nav_transfer = pyqtSignal(list)
|
||||
playlist_dropped_for_nav_transfer = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@ -205,6 +255,9 @@ class SidebarWidget(QWidget):
|
||||
QListWidget::item:hover:!selected {{
|
||||
background: {hl_light_name};
|
||||
}}
|
||||
#navList[dndActive="true"]::item:hover:!selected {{
|
||||
background: transparent;
|
||||
}}
|
||||
""")
|
||||
|
||||
def _build_ui(self):
|
||||
@ -292,6 +345,7 @@ class SidebarWidget(QWidget):
|
||||
layout.addWidget(line2)
|
||||
|
||||
self._nav_list = NavDropListWidget()
|
||||
self._nav_list.setObjectName("navList")
|
||||
items = [
|
||||
("🎸 iPod", "ipod"),
|
||||
("⚙️ Settings", "settings"),
|
||||
@ -303,6 +357,7 @@ class SidebarWidget(QWidget):
|
||||
self._nav_list.addItem(item)
|
||||
self._nav_list.itemClicked.connect(self._on_nav_item_clicked)
|
||||
self._nav_list.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_nav_transfer)
|
||||
self._nav_list.playlist_dropped_for_transfer.connect(self._on_playlist_dropped_for_transfer)
|
||||
layout.addWidget(self._nav_list)
|
||||
|
||||
self._playlist_items: dict = {}
|
||||
@ -396,6 +451,9 @@ class SidebarWidget(QWidget):
|
||||
def _on_tracks_dropped_for_nav_transfer(self, paths: list):
|
||||
self.tracks_dropped_for_nav_transfer.emit(paths)
|
||||
|
||||
def _on_playlist_dropped_for_transfer(self, playlist_id: str):
|
||||
self.playlist_dropped_for_nav_transfer.emit(playlist_id)
|
||||
|
||||
def _on_playlists_reordered(self):
|
||||
order = self.get_playlist_order_ids()
|
||||
self.item_selected.emit("playlist_reorder", "|".join(order))
|
||||
|
||||
@ -44,6 +44,7 @@ class WorkerThread(QThread):
|
||||
"delete_tracks": self._run_delete_tracks,
|
||||
"remove_duplicates": self._run_remove_duplicates,
|
||||
"sync_metadata": self._run_sync_metadata,
|
||||
"create_playlist": self._run_create_playlist,
|
||||
}
|
||||
handler = handlers.get(self.task_type)
|
||||
if handler:
|
||||
@ -362,6 +363,26 @@ class WorkerThread(QThread):
|
||||
self.progress_signal.emit(100, msg)
|
||||
self.finished_signal.emit(synced > 0, msg, {"synced": synced, "errors": errors})
|
||||
|
||||
def _run_create_playlist(self):
|
||||
mount_point = self.kwargs.get("mount_point")
|
||||
name = self.kwargs.get("playlist_name", "")
|
||||
track_paths = self.kwargs.get("track_paths", [])
|
||||
|
||||
if not mount_point or not name:
|
||||
self.finished_signal.emit(False, "Missing parameters for playlist creation", None)
|
||||
return
|
||||
|
||||
from ipod_nano7_db import Nano7Database
|
||||
db = Nano7Database(mount_point)
|
||||
|
||||
self.progress_signal.emit(0, f"Creating playlist '{name}' on iPod...")
|
||||
success = db.create_playlist(name, track_paths)
|
||||
|
||||
if success:
|
||||
self.finished_signal.emit(True, f"Playlist '{name}' created on iPod", None)
|
||||
else:
|
||||
self.finished_signal.emit(False, f"Failed to create playlist '{name}'", None)
|
||||
|
||||
def stop(self):
|
||||
self.is_running = False
|
||||
self.wait()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user