Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
36 changes: 36 additions & 0 deletions picard/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,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 @@ -146,6 +147,8 @@
Option('persist', 'window_state', QtCore.QByteArray())
ListOption('persist', 'filters_FileTreeView', None)
ListOption('persist', 'filters_AlbumTreeView', None)
TextOption('persist', 'last_session_path', '')
TextOption('persist', 'session_autosave_path', '')

# picard/ui/metadatabox.py
#
Expand Down Expand Up @@ -491,6 +494,39 @@ 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=N_(SessionMessages.SESSION_SAFE_RESTORE_TITLE),
)
BoolOption(
'setting',
'session_load_last_on_startup',
False,
title=N_(SessionMessages.SESSION_LOAD_LAST_TITLE),
)
IntOption(
'setting',
'session_autosave_interval_min',
0,
title=N_(SessionMessages.SESSION_AUTOSAVE_TITLE),
)
BoolOption(
'setting',
'session_backup_on_crash',
True,
title=N_(SessionMessages.SESSION_BACKUP_TITLE),
)
BoolOption(
'setting',
'session_include_mb_data',
False,
title=N_(SessionMessages.SESSION_INCLUDE_MB_DATA_TITLE),
)

# picard/ui/searchdialog/album.py
#
Option('persist', 'albumsearchdialog_header_state', QtCore.QByteArray())
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."""
64 changes: 64 additions & 0 deletions picard/session/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# -*- 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.
"""


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

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

# Retry delays in milliseconds
DEFAULT_RETRY_DELAY_MS = 200
FAST_RETRY_DELAY_MS = 150

# Metadata handling
INTERNAL_TAG_PREFIX = "~"
EXCLUDED_OVERRIDE_TAGS = frozenset({"length", "~length"})

# 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 = "Honor local edits and placement on load (no auto-matching)"
SESSION_LOAD_LAST_TITLE = "Load last saved session on startup"
SESSION_AUTOSAVE_TITLE = "Auto-save session every N minutes (0 disables)"
SESSION_BACKUP_TITLE = "Attempt to keep a session backup on unexpected shutdown"
SESSION_INCLUDE_MB_DATA_TITLE = "Include MusicBrainz data in saved sessions (faster loads, risk of stale data)"
160 changes: 160 additions & 0 deletions picard/session/location_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# -*- 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.

"""Location detection for session management.

This module handles detecting where files should be placed within a session,
separating the complex location detection logic from other concerns.
"""

from __future__ import annotations

from picard.album import Album, NatAlbum
from picard.cluster import Cluster, UnclusteredFiles
from picard.file import File
from picard.session.constants import SessionConstants
from picard.session.session_data import SessionItemLocation
from picard.track import Track


class LocationDetector:
"""Detects the location type of files in the session."""

def detect(self, file: File) -> SessionItemLocation:
"""Detect where a file should be placed in the session.

Parameters
----------
file : File
The file to detect the location for.

Returns
-------
SessionItemLocation
The location information for the file.

Notes
-----
This method analyzes the file's parent item to determine its proper
location within the session structure.
"""
parent = file.parent_item
if parent is None:
return self._unclustered_location()

if self._is_track_parent(parent):
return self._detect_track_location(parent)
elif self._is_cluster_parent(parent):
return self._detect_cluster_location(parent)
else:
return self._unclustered_location()

def _is_track_parent(self, parent: object) -> bool:
"""Check if parent is a track (has album attribute).

Parameters
----------
parent : object
The parent item to check.

Returns
-------
bool
True if parent is a track.
"""
return hasattr(parent, 'album') and isinstance(parent.album, Album)

def _is_cluster_parent(self, parent: object) -> bool:
"""Check if parent is a cluster.

Parameters
----------
parent : object
The parent item to check.

Returns
-------
bool
True if parent is a cluster.
"""
return isinstance(parent, Cluster)

def _detect_track_location(self, parent: Track) -> SessionItemLocation:
"""Detect location for files under a track.

Parameters
----------
parent : Track
The track parent item.

Returns
-------
SessionItemLocation
The location information for the track.
"""
if isinstance(parent.album, NatAlbum):
# NAT special handling
return SessionItemLocation(type=SessionConstants.LOCATION_NAT, recording_id=parent.id)

# Track placement
if hasattr(parent, 'id') and parent.id:
return SessionItemLocation(
type=SessionConstants.LOCATION_TRACK, album_id=parent.album.id, recording_id=parent.id
)

# Fallback to album unmatched
return SessionItemLocation(type=SessionConstants.LOCATION_ALBUM_UNMATCHED, album_id=parent.album.id)

def _detect_cluster_location(self, parent: Cluster) -> SessionItemLocation:
"""Detect location for files under a cluster.

Parameters
----------
parent : Cluster
The cluster parent item.

Returns
-------
SessionItemLocation
The location information for the cluster.
"""
# Unmatched files inside an album
if parent.related_album:
return SessionItemLocation(type=SessionConstants.LOCATION_ALBUM_UNMATCHED, album_id=parent.related_album.id)

# Left pane cluster
if isinstance(parent, UnclusteredFiles):
return self._unclustered_location()

return SessionItemLocation(
type=SessionConstants.LOCATION_CLUSTER,
cluster_title=str(parent.metadata['album']),
cluster_artist=str(parent.metadata['albumartist']),
)

def _unclustered_location(self) -> SessionItemLocation:
"""Create an unclustered location.

Returns
-------
SessionItemLocation
Location for unclustered files.
"""
return SessionItemLocation(type=SessionConstants.LOCATION_UNCLUSTERED)
Loading
Loading