Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b049904
Complete session save/load roundtrip
knguyen1 Sep 8, 2025
16f21c7
Refactor sessions manager and add unit tests
knguyen1 Sep 8, 2025
a596368
Session files should be compressed
knguyen1 Sep 8, 2025
b44d36e
Enable caching of mb data in sessions
knguyen1 Sep 8, 2025
8383a97
Do not catch blind exceptions
knguyen1 Sep 8, 2025
1251e9c
Apply single quote on dict/attr keys
knguyen1 Sep 8, 2025
b8c0b42
Zas code review 20250908; own close session confirm box
knguyen1 Sep 9, 2025
7bff0c4
Use try...except...else pattern
knguyen1 Sep 9, 2025
86eef7a
Add flyout menu for recent sessions
knguyen1 Sep 9, 2025
1c14bc4
Make session_folder configurable and default
knguyen1 Sep 9, 2025
69ca212
Clean up menus; add default filenames
knguyen1 Sep 10, 2025
7b7506c
`load_session` should default to the last_session_path
knguyen1 Sep 10, 2025
11de913
Fix failing Windows tests
knguyen1 Sep 10, 2025
722b3fc
Add atomic writes; fix crash during load session
knguyen1 Sep 10, 2025
a644edb
Refactor ; remove redundant code
knguyen1 Sep 10, 2025
6da5c74
Switch from json to yaml for session files
knguyen1 Sep 10, 2025
f18eb91
Refactor session loader for dry/srp
knguyen1 Sep 10, 2025
f437291
Fix bug with blank albums (no cache, no web)
knguyen1 Sep 10, 2025
c3ca44e
Fix bug with web requests suppression
knguyen1 Sep 10, 2025
325bbc4
Fix some inconsistencies with `last_session_path`
knguyen1 Sep 10, 2025
847bd33
Apply zas code review recs 20250910
knguyen1 Sep 10, 2025
1bee982
Merge master into feat/PICARD-3118/add-save-session-feature
knguyen1 Sep 22, 2025
14e952b
Apply zas code review 20250924
knguyen1 Sep 25, 2025
31d7bc3
Rename `dont_write_tags` -> `enable_tag_saving`
knguyen1 Sep 25, 2025
499c052
Make `_atomic_write` its own utility
knguyen1 Sep 25, 2025
8eef846
Apply zas code review 20250925
knguyen1 Sep 25, 2025
cd61cb8
Improve type hints
knguyen1 Sep 26, 2025
9479f39
Make `restore_options` more generic
knguyen1 Sep 28, 2025
7e7654b
Fix: `dir` -> `directory`
knguyen1 Sep 28, 2025
3ebd96b
refactor: `session_loader` more readable
knguyen1 Sep 28, 2025
1c189b0
Refactor: Centralize RESTORABLE_CONFIG_KEYS for session management
knguyen1 Sep 28, 2025
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
2 changes: 2 additions & 0 deletions picard/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,8 @@ def _load_track(node, mm, artists, extra_metadata):
track.metadata['~totalalbumtracks'] = totalalbumtracks
if multiartists:
track.metadata['~multiartist'] = '1'
# Preserve release JSON for session export after load finished
self._release_node_cache = self._release_node
del self._release_node
del self._release_artist_nodes
self._tracks_loaded = True
Expand Down
22 changes: 21 additions & 1 deletion picard/const/appdirs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@


import os
import os.path
from pathlib import Path

from PyQt6.QtCore import (
QCoreApplication,
Expand Down Expand Up @@ -59,3 +59,23 @@ def plugin_folder():
# FIXME: This really should be in QStandardPaths.StandardLocation.AppDataLocation instead,
# but this is a breaking change that requires data migration
return os.path.normpath(os.environ.get('PICARD_PLUGIN_DIR', os.path.join(config_folder(), 'plugins')))


def sessions_folder():
"""Get the sessions folder path.

Returns
-------
str
The path to the sessions folder. If a custom path is configured,
returns that path. Otherwise, returns the default path
<config_folder>/sessions.
"""
from picard.config import get_config

config = get_config()
custom_path = config.setting['session_folder_path']
if custom_path:
return str(Path(custom_path).resolve())
else:
return str(Path(config_folder()) / 'sessions')
9 changes: 9 additions & 0 deletions picard/const/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,13 @@

DEFAULT_QUICK_MENU_ITEMS = ['save_images_to_tags', 'save_images_to_files']

# Metadata handling
# Prefix for internal/non-user-facing tags; filtered from exports and overrides.
INTERNAL_TAG_PREFIX = "~"

# Tags that must never be overridden from sessions. Include values that are
# computed or come from file info and must reflect the current file (e.g. duration).
# 'length' is audio duration; '~length' is its display alias. Add more if we expose
# additional non-internal computed fields that should not be user-overridable.
EXCLUDED_OVERRIDE_TAGS = frozenset({"length", "~length"})
DEFAULT_FILTER_COLUMNS = ['album', 'title', 'albumartist', 'artist']
49 changes: 49 additions & 0 deletions picard/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
DEFAULT_WIN_COMPAT_REPLACEMENTS,
)
from picard.i18n import N_
from picard.session.constants import SessionMessages

from picard.ui.colors import InterfaceColors

Expand Down Expand Up @@ -147,6 +148,9 @@
Option('persist', 'window_state', QtCore.QByteArray())
ListOption('persist', 'filters_FileTreeView', DEFAULT_FILTER_COLUMNS)
ListOption('persist', 'filters_AlbumTreeView', DEFAULT_FILTER_COLUMNS)
TextOption('persist', 'last_session_path', '')
ListOption('persist', 'recent_sessions', [])
TextOption('persist', 'session_autosave_path', '')

# picard/ui/metadatabox.py
#
Expand Down Expand Up @@ -492,6 +496,51 @@ def make_default_toolbar_layout():
Option('setting', 'file_renaming_scripts', {})
TextOption('setting', 'selected_file_naming_script_id', '', title=N_("Selected file naming script"))

# picard/ui/options/sessions.py
# Sessions
BoolOption(
'setting',
'session_safe_restore',
True,
title=SessionMessages.SESSION_SAFE_RESTORE_TITLE,
)
BoolOption(
'setting',
'session_load_last_on_startup',
False,
title=SessionMessages.SESSION_LOAD_LAST_TITLE,
)
IntOption(
'setting',
'session_autosave_interval_min',
0,
title=SessionMessages.SESSION_AUTOSAVE_TITLE,
)
BoolOption(
'setting',
'session_backup_on_crash',
True,
title=SessionMessages.SESSION_BACKUP_TITLE,
)
BoolOption(
'setting',
'session_include_mb_data',
True,
title=SessionMessages.SESSION_INCLUDE_MB_DATA_TITLE,
)
BoolOption(
'setting',
'session_no_mb_requests_on_load',
True,
title=SessionMessages.SESSION_NO_MB_REQUESTS_ON_LOAD,
)
TextOption(
'setting',
'session_folder_path',
'',
title=SessionMessages.SESSION_FOLDER_PATH_TITLE,
)

# picard/ui/searchdialog/album.py
#
Option('persist', 'albumsearchdialog_header_state', QtCore.QByteArray())
Expand Down
2 changes: 1 addition & 1 deletion picard/script/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ def export_script(self, parent=None):
filename, file_type = FileDialog.getSaveFileName(
parent=parent,
caption=dialog_title,
dir=default_path,
directory=default_path,
filter=dialog_file_types,
)
if not filename:
Expand Down
21 changes: 21 additions & 0 deletions picard/session/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2025 The MusicBrainz Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

"""Session management package for Picard."""
146 changes: 146 additions & 0 deletions picard/session/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2025 The MusicBrainz Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

"""Constants for session management.

This module contains all constants used throughout the session management system,
including retry delays, file extensions, and excluded tags.
"""

from picard.i18n import N_


class SessionConstants:
"""Constants for session management operations.

Retry delays
------------
These delays govern how often we re-check readiness during session
load/restore using Qt timers. They coordinate operations across
asynchronous components (file scanning, network lookups, album/track
population, UI creation) without requiring deep refactors.

Attributes
----------
DEFAULT_RETRY_DELAY_MS : int
General-purpose delay (milliseconds) for deferred actions that need
other subsystems to settle first. Used for:
- Applying saved metadata / tag deltas once files are loaded
(see `MetadataHandler.apply_saved_metadata_if_any`,
`MetadataHandler.apply_tag_deltas_if_any`).
- Restoring UI state (expanding albums) once UI items exist
(see `SessionLoader._restore_ui_state`).
- Finalizing the restoring flag when network/disk operations are idle
(see `SessionLoader._unset_restoring_flag_when_idle`).

Trade-offs
----------
- Too short: Excess CPU wake-ups, risk of race-condition flapping,
unnecessary network/UI churn.
- Too long: Noticeable lag for metadata application and UI finalize.

Tuning
------
- Shorten for tests, small sessions, fast machines (snappier UI).
- Lengthen for very large sessions, slow I/O/network (reduce churn).

FAST_RETRY_DELAY_MS : int
Lower-latency delay (milliseconds) for local readiness checks where
objects stabilize quickly (e.g., file/album becomes ready) and we want
prompt feedback. Used for:
- Moving files to tracks once file/album are ready
(see `TrackMover.move_files_to_tracks`).
- Specialized helpers like `RetryHelper.retry_until_file_ready` and
`RetryHelper.retry_until_album_ready`.

Trade-offs
----------
- Too short: High-frequency polling of local state, potential CPU
spikes on large batches.
- Too long: Sluggish track moves and perceived restore latency.

Notes
-----
What is being retried
Readiness checks and deferred execution (polling until conditions are
true), not re-execution of failed logic.

Why retries are needed
In an event-driven Qt architecture not all components emit precise
"ready" signals, and many operations require multiple conditions to be
true simultaneously (e.g., file loaded AND album tracks available AND
UI node created). Timed re-checks are a pragmatic coordination
mechanism.

Alternative (fully async/signals)
We could replace polls with explicit signals/awaitables
(e.g., file_ready, album_tracks_loaded, ui_item_created, webservice_idle),
but this requires cross-cutting changes across `File`, `Album`, UI,
WebService, and `Tagger`. Incremental migration is possible; until then
these delays balance responsiveness and load.
"""

# File handling
SESSION_FILE_EXTENSION = ".mbps.gz"
SESSION_FORMAT_VERSION = 1

# Recent sessions
# Number of recent session entries shown in the UI flyout menu.
RECENT_SESSIONS_MAX = 5

# Retry delays in milliseconds
# Used by Qt timers for retry/poll loops during session load/restore.
# Balance responsiveness with CPU/network load: shorter feels snappier
# but risks busy-looping and churn; longer reduces load but adds visible lag.
DEFAULT_RETRY_DELAY_MS = 200

# General retries (e.g. metadata application, UI finalize).
# Adjust up for huge sessions/slow I/O; down for tests/small sessions/fast
# machines.
FAST_RETRY_DELAY_MS = 150
# Local readiness checks (files/albums becoming ready, track moves).
# Too short ⇒ high CPU/race flapping; too long ⇒ sluggish moves/restore.

# Location types
LOCATION_UNCLUSTERED = "unclustered"
LOCATION_TRACK = "track"
LOCATION_ALBUM_UNMATCHED = "album_unmatched"
LOCATION_CLUSTER = "cluster"
LOCATION_NAT = "nat"


class SessionMessages:
"""Centralized session-related message strings.

Define raw, untranslated strings. Call sites should mark for translation:
- API/config titles: wrap with N_()
- UI labels: wrap with _()
"""

# Option titles (API/config)
SESSION_SAFE_RESTORE_TITLE = N_("Honor local edits and placement on load (no auto-matching)")
SESSION_LOAD_LAST_TITLE = N_("Load last saved session on startup")
SESSION_AUTOSAVE_TITLE = N_("Auto-save session every N minutes (0 disables)")
SESSION_BACKUP_TITLE = N_("Attempt to keep a session backup on unexpected shutdown")
SESSION_INCLUDE_MB_DATA_TITLE = N_("Include MusicBrainz data in saved sessions (warm cache)")
SESSION_NO_MB_REQUESTS_ON_LOAD = N_(
"Do not make MusicBrainz requests on restore (faster loads, risk of stale data)"
)
SESSION_FOLDER_PATH_TITLE = N_("Sessions folder path (leave empty for default)")
Loading
Loading