Files
tidal-dl/tidal_dl_ng/history.py
T

438 lines
16 KiB
Python
Raw Normal View History

"""
history.py
Implements the download history tracking service with JSON persistence.
Classes:
HistoryService: Main service for managing download history with atomic JSON operations.
"""
import json
import os
import shutil
import tempfile
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from threading import Lock
from typing import Any
from tidal_dl_ng.helper.decorator import SingletonMeta
from tidal_dl_ng.helper.path import path_config_base
class HistoryFormatError(TypeError):
"""Exception raised when history file format is invalid."""
def __init__(self) -> None:
"""Initialize with predefined message."""
super().__init__("Invalid history file format")
@dataclass
class DownloadHistoryEntry:
"""Represents a single entry in the download history.
Attributes:
source_type: Type of source (playlist, album, manual, mix).
source_id: ID of the source (UUID or None for manual).
source_name: Name of the source (playlist name, album name, etc.).
download_date: ISO 8601 timestamp of download.
"""
source_type: str
source_id: str | None
source_name: str | None
download_date: str
class HistoryService(metaclass=SingletonMeta):
"""Service for managing download history with JSON persistence.
This service provides thread-safe operations for tracking downloaded tracks.
The history is stored in a track-centric JSON file (trackId -> metadata).
All write operations are atomic to prevent corruption.
Attributes:
history_data: In-memory dictionary of track IDs to history entries.
file_path: Path to the JSON history file.
_lock: Thread lock for concurrent access safety.
"""
SCHEMA_VERSION = 1
def __init__(self):
"""Initialize the history service and load existing data."""
self.file_path: Path = Path(path_config_base()) / "downloaded_history.json"
self.history_data: dict[str, dict[str, Any]] = {}
self.settings_data: dict[str, Any] = {"preventDuplicates": True}
self._lock: Lock = Lock()
self._load_history()
def _load_history(self) -> None:
"""Load history from JSON file.
If the file doesn't exist, creates a new empty history.
If the file is corrupted, backs it up and starts fresh.
Thread-safe operation.
"""
with self._lock:
try:
if not self.file_path.exists():
# Create directory if needed
self.file_path.parent.mkdir(parents=True, exist_ok=True)
# Initialize with empty schema
self._save_history_internal()
return
with open(self.file_path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise HistoryFormatError
settings = data.get("settings", {})
self.settings_data = {"preventDuplicates": bool(settings.get("preventDuplicates", True))}
tracks_section = data.get("tracks")
if isinstance(tracks_section, dict):
self.history_data = tracks_section
else:
self.history_data = {k: v for k, v in data.items() if not k.startswith("_") and k != "settings"}
except (json.JSONDecodeError, ValueError, FileNotFoundError):
# Backup corrupted file
if self.file_path.exists():
backup_path = self.file_path.with_suffix(".json.bak")
counter = 1
while backup_path.exists():
backup_path = self.file_path.with_suffix(f".json.bak.{counter}")
counter += 1
shutil.copy2(self.file_path, backup_path)
print(f"Warning: Download history file was corrupted. Backup saved to: {backup_path}")
# Start with empty history
self.history_data = {}
self.settings_data = {"preventDuplicates": True}
self._save_history_internal()
def _save_history_internal(self) -> None:
"""Internal method to save history to JSON file atomically.
Uses atomic write (write to temp file, then rename) to prevent corruption.
Assumes lock is already held by caller.
"""
try:
# Ensure directory exists
self.file_path.parent.mkdir(parents=True, exist_ok=True)
# Prepare data with metadata
data_to_save = {
"_schema_version": self.SCHEMA_VERSION,
"_last_updated": datetime.now(UTC).isoformat(),
"settings": self.settings_data,
"tracks": self.history_data,
}
# Atomic write: write to temp file, then rename
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=self.file_path.parent, delete=False, suffix=".tmp"
) as tmp_file:
json.dump(data_to_save, tmp_file, indent=2, ensure_ascii=False)
tmp_path = tmp_file.name
# Atomic rename (on Windows, need to remove target first if exists)
if os.name == "nt" and self.file_path.exists():
os.replace(tmp_path, self.file_path)
else:
os.rename(tmp_path, self.file_path)
except Exception:
# Clean up temp file if it exists
if "tmp_path" in locals() and os.path.exists(tmp_path):
from contextlib import suppress
with suppress(OSError):
os.remove(tmp_path)
raise
def save_history(self) -> None:
"""Public method to save history (with lock acquisition)."""
with self._lock:
self._save_history_internal()
def get_settings(self) -> dict[str, Any]:
"""Return a copy of history-related settings."""
with self._lock:
return dict(self.settings_data)
def update_settings(self, **kwargs: Any) -> None:
"""Update history settings and persist immediately."""
with self._lock:
for key, value in kwargs.items():
if key in self.settings_data:
self.settings_data[key] = bool(value)
self._save_history_internal()
def should_skip_download(self, track_id: str) -> bool:
"""Return True if track should be skipped based on history and settings."""
with self._lock:
prevent_duplicates = self.settings_data.get("preventDuplicates", True)
return prevent_duplicates and track_id in self.history_data
def is_downloaded(self, track_id: str) -> bool:
"""Check if a track has been downloaded.
Args:
track_id: The TIDAL track ID to check.
Returns:
True if track is in download history, False otherwise.
"""
with self._lock:
return track_id in self.history_data
def add_track_to_history(
self, track_id: str, source_type: str = "manual", source_id: str | None = None, source_name: str | None = None
) -> None:
"""Add a track to download history.
Args:
track_id: The TIDAL track ID.
source_type: Type of source (playlist, album, manual, mix, track).
source_id: ID of the source (UUID for playlist/album, None for manual).
source_name: Name of the source (for display purposes).
"""
with self._lock:
self.history_data[track_id] = {
"sourceType": source_type,
"sourceId": source_id,
"sourceName": source_name,
"downloadDate": datetime.now(UTC).isoformat(),
}
# Save immediately to persist
self._save_history_internal()
def remove_track_from_history(self, track_id: str) -> bool:
"""Remove a track from download history.
Args:
track_id: The TIDAL track ID to remove.
Returns:
True if track was removed, False if not found.
"""
with self._lock:
if track_id in self.history_data:
del self.history_data[track_id]
self._save_history_internal()
return True
return False
def get_history_by_source(self) -> dict[str, list[dict[str, Any]]]:
"""Transform track-centric history to source-centric view.
Returns:
Dictionary grouped by source with format:
{
"source_key": [
{
"track_id": "123",
"download_date": "2025-11-15T...",
...
}
]
}
where source_key is "{sourceType}_{sourceId}" or "manual" for manual downloads.
"""
with self._lock:
grouped: dict[str, list[dict[str, Any]]] = {}
for track_id, entry in self.history_data.items():
source_type = entry.get("sourceType", "manual")
source_id = entry.get("sourceId")
source_name = entry.get("sourceName", "Unknown")
# Create source key
source_key = f"{source_type}_{source_id}" if source_id else f"{source_type}_manual"
# Initialize list if needed
if source_key not in grouped:
grouped[source_key] = []
# Add track info
grouped[source_key].append(
{
"track_id": track_id,
"source_type": source_type,
"source_id": source_id,
"source_name": source_name,
"download_date": entry.get("downloadDate", ""),
}
)
return grouped
def get_track_info(self, track_id: str) -> dict[str, Any] | None:
"""Get download history info for a specific track.
Args:
track_id: The TIDAL track ID.
Returns:
Dictionary with history entry or None if not found.
"""
with self._lock:
return self.history_data.get(track_id)
def get_history_file_path(self) -> str:
"""Get the absolute path to the history file.
Returns:
Absolute path as string.
"""
return str(self.file_path.absolute())
def _extract_tracks_from_data(self, data: dict) -> dict:
"""Extract tracks from import data.
Args:
data: Import data dictionary.
Returns:
Dictionary of tracks.
"""
tracks_node = data.get("tracks")
if isinstance(tracks_node, dict):
return tracks_node
return {k: v for k, v in data.items() if not k.startswith("_") and k != "settings"}
def _validate_tracks(self, tracks: dict) -> tuple[bool, str]:
"""Validate track entries.
Args:
tracks: Dictionary of tracks to validate.
Returns:
Tuple of (valid, error_message).
"""
required_keys = {"sourceType", "downloadDate"}
for track_id, entry in tracks.items():
if not isinstance(entry, dict):
return False, f"Invalid entry format for track {track_id}"
if not required_keys.issubset(entry.keys()):
return False, f"Missing required fields for track {track_id}"
return True, ""
def import_history(self, file_path: str, merge: bool = True) -> tuple[bool, str]:
"""Import history from an external JSON file.
Args:
file_path: Path to the JSON file to import.
merge: If True, merge with existing history. If False, replace.
Returns:
Tuple of (success: bool, message: str).
"""
try:
with open(file_path, encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
return False, f"Invalid JSON file: {e!s}"
except Exception as e:
return False, f"Import failed: {e!s}"
else:
# Validate format
if not isinstance(data, dict):
return False, "Invalid file format: expected JSON object"
# Extract and validate
imported_settings = data.get("settings", {})
imported_tracks = self._extract_tracks_from_data(data)
valid, error_msg = self._validate_tracks(imported_tracks)
if not valid:
return False, error_msg
# Apply import
with self._lock:
if merge:
self.history_data.update(imported_tracks)
message = f"Successfully merged {len(imported_tracks)} tracks"
else:
self.history_data = imported_tracks
message = f"Successfully imported {len(imported_tracks)} tracks (replaced existing)"
if isinstance(imported_settings, dict) and "preventDuplicates" in imported_settings:
self.settings_data["preventDuplicates"] = bool(imported_settings.get("preventDuplicates", True))
self._save_history_internal()
return True, message
def export_history(self, file_path: str) -> tuple[bool, str]:
"""Export history to an external JSON file.
Args:
file_path: Destination path for the exported JSON file.
Returns:
Tuple of (success: bool, message: str).
"""
try:
with self._lock:
# Prepare export data (same format as internal storage)
data_to_export = {
"_schema_version": self.SCHEMA_VERSION,
"_exported_date": datetime.now(UTC).isoformat(),
"_total_tracks": len(self.history_data),
"settings": self.settings_data,
"tracks": self.history_data,
}
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data_to_export, f, indent=2, ensure_ascii=False)
return True, f"Successfully exported {len(self.history_data)} tracks"
except Exception as e:
return False, f"Export failed: {e!s}"
def clear_history(self) -> None:
"""Clear all download history.
This is a destructive operation - use with caution.
"""
with self._lock:
self.history_data = {}
self._save_history_internal()
def get_statistics(self) -> dict[str, Any]:
"""Get statistics about the download history.
Returns:
Dictionary with statistics (total tracks, by source type, etc.).
"""
with self._lock:
stats = {
"total_tracks": len(self.history_data),
"by_source_type": {},
"oldest_download": None,
"newest_download": None,
}
dates = []
for entry in self.history_data.values():
source_type = entry.get("sourceType", "unknown")
stats["by_source_type"][source_type] = stats["by_source_type"].get(source_type, 0) + 1
download_date = entry.get("downloadDate")
if download_date:
dates.append(download_date)
if dates:
stats["oldest_download"] = min(dates)
stats["newest_download"] = max(dates)
return stats