Recursive staging scan, singles support, and improved import UI
Auto-import now scans the staging folder recursively — any folder structure depth works (Artist/Album/tracks, Album/tracks, etc.). Loose audio files are treated as singles with tag/filename/AcoustID identification. Import results UI redesigned: - Click cards to expand per-track match details with confidence scores - Shows identification method badge (Tags, Folder Name, AcoustID) - Per-track grid: track name, matched filename, confidence percentage - Time ago labels, folder path, better status badges - Approve/Dismiss buttons use event.stopPropagation for clean UX
This commit is contained in:
parent
d66adb3c6e
commit
d2c6979ce4
3 changed files with 231 additions and 69 deletions
|
|
@ -210,10 +210,12 @@ class AutoImportWorker:
|
||||||
"""One full scan of the staging folder."""
|
"""One full scan of the staging folder."""
|
||||||
staging = self._resolve_staging_path()
|
staging = self._resolve_staging_path()
|
||||||
if not staging or not os.path.isdir(staging):
|
if not staging or not os.path.isdir(staging):
|
||||||
|
logger.warning(f"[Auto-Import] Staging path not found or invalid: {self.staging_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find folder candidates
|
# Find folder candidates
|
||||||
candidates = self._enumerate_folders(staging)
|
candidates = self._enumerate_folders(staging)
|
||||||
|
logger.info(f"[Auto-Import] Scan cycle: {len(candidates)} candidates in {staging}")
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -316,68 +318,73 @@ class AutoImportWorker:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _enumerate_folders(self, staging: str) -> List[FolderCandidate]:
|
def _enumerate_folders(self, staging: str) -> List[FolderCandidate]:
|
||||||
"""Find album folder and single file candidates in staging directory."""
|
"""Find album folder and single file candidates in staging directory (recursive)."""
|
||||||
candidates = []
|
candidates = []
|
||||||
|
self._scan_directory(staging, candidates)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _scan_directory(self, directory: str, candidates: List[FolderCandidate]):
|
||||||
|
"""Recursively scan a directory for album folders and loose audio files."""
|
||||||
try:
|
try:
|
||||||
entries = sorted(os.listdir(staging))
|
entries = sorted(os.listdir(directory))
|
||||||
except OSError:
|
except OSError:
|
||||||
return candidates
|
return
|
||||||
|
|
||||||
|
# Collect loose audio files at this level
|
||||||
|
loose_files = []
|
||||||
|
subdirs = []
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
full_path = os.path.join(staging, entry)
|
full_path = os.path.join(directory, entry)
|
||||||
|
|
||||||
# Loose audio file in staging root → single track candidate
|
|
||||||
if os.path.isfile(full_path) and os.path.splitext(entry)[1].lower() in AUDIO_EXTENSIONS:
|
if os.path.isfile(full_path) and os.path.splitext(entry)[1].lower() in AUDIO_EXTENSIONS:
|
||||||
folder_hash = _compute_folder_hash([full_path])
|
loose_files.append(full_path)
|
||||||
candidates.append(FolderCandidate(
|
elif os.path.isdir(full_path):
|
||||||
path=full_path, name=entry, audio_files=[full_path],
|
subdirs.append((entry, full_path))
|
||||||
folder_hash=folder_hash, is_single=True
|
|
||||||
))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not os.path.isdir(full_path):
|
if loose_files:
|
||||||
continue
|
# This directory has audio files — treat it as an album folder candidate
|
||||||
|
audio_files = loose_files
|
||||||
audio_files = []
|
|
||||||
disc_structure = {}
|
disc_structure = {}
|
||||||
|
|
||||||
# Check for disc subfolders
|
# Check if any subdirs are disc folders
|
||||||
has_disc_folders = False
|
has_disc_folders = False
|
||||||
for sub in os.listdir(full_path):
|
for sub_name, sub_path in subdirs:
|
||||||
sub_path = os.path.join(full_path, sub)
|
disc_match = DISC_FOLDER_RE.match(sub_name)
|
||||||
disc_match = DISC_FOLDER_RE.match(sub)
|
if disc_match:
|
||||||
if disc_match and os.path.isdir(sub_path):
|
|
||||||
has_disc_folders = True
|
has_disc_folders = True
|
||||||
disc_num = int(disc_match.group(1))
|
disc_num = int(disc_match.group(1))
|
||||||
disc_files = [os.path.join(sub_path, f) for f in sorted(os.listdir(sub_path))
|
disc_files = [os.path.join(sub_path, f) for f in sorted(os.listdir(sub_path))
|
||||||
if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
|
if os.path.isfile(os.path.join(sub_path, f))
|
||||||
|
and os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
|
||||||
if disc_files:
|
if disc_files:
|
||||||
disc_structure[disc_num] = disc_files
|
disc_structure[disc_num] = disc_files
|
||||||
audio_files.extend(disc_files)
|
audio_files.extend(disc_files)
|
||||||
|
|
||||||
# Also collect top-level audio files
|
if has_disc_folders:
|
||||||
top_files = [os.path.join(full_path, f) for f in sorted(os.listdir(full_path))
|
disc_structure[0] = loose_files # Top-level files are disc 0
|
||||||
if os.path.isfile(os.path.join(full_path, f))
|
|
||||||
and os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
|
|
||||||
|
|
||||||
if not has_disc_folders:
|
|
||||||
audio_files = top_files
|
|
||||||
else:
|
|
||||||
# Add any stray top-level files to disc 0
|
|
||||||
if top_files:
|
|
||||||
disc_structure[0] = top_files
|
|
||||||
audio_files.extend(top_files)
|
|
||||||
|
|
||||||
if not audio_files:
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
# Determine if this is a single or album
|
||||||
|
is_single = len(audio_files) == 1 and not has_disc_folders
|
||||||
|
folder_name = os.path.basename(directory)
|
||||||
folder_hash = _compute_folder_hash(audio_files)
|
folder_hash = _compute_folder_hash(audio_files)
|
||||||
candidates.append(FolderCandidate(
|
|
||||||
path=full_path, name=entry, audio_files=audio_files,
|
|
||||||
disc_structure=disc_structure, folder_hash=folder_hash
|
|
||||||
))
|
|
||||||
|
|
||||||
return candidates
|
if is_single:
|
||||||
|
candidates.append(FolderCandidate(
|
||||||
|
path=audio_files[0], name=os.path.basename(audio_files[0]),
|
||||||
|
audio_files=audio_files, folder_hash=folder_hash, is_single=True
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
candidates.append(FolderCandidate(
|
||||||
|
path=directory, name=folder_name, audio_files=audio_files,
|
||||||
|
disc_structure=disc_structure, folder_hash=folder_hash
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# No audio files here — recurse into subdirectories
|
||||||
|
for sub_name, sub_path in subdirs:
|
||||||
|
# Skip disc folders at this level (they'll be handled by the parent album)
|
||||||
|
if DISC_FOLDER_RE.match(sub_name):
|
||||||
|
continue
|
||||||
|
self._scan_directory(sub_path, candidates)
|
||||||
|
|
||||||
def _is_folder_stable(self, candidate: FolderCandidate) -> bool:
|
def _is_folder_stable(self, candidate: FolderCandidate) -> bool:
|
||||||
"""Check if folder contents have stopped changing."""
|
"""Check if folder contents have stopped changing."""
|
||||||
|
|
|
||||||
|
|
@ -66528,54 +66528,112 @@ async function _autoImportLoadResults() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = data.results.map(r => {
|
container.innerHTML = data.results.map((r, idx) => {
|
||||||
const confPct = Math.round((r.confidence || 0) * 100);
|
const confPct = Math.round((r.confidence || 0) * 100);
|
||||||
const confClass = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low';
|
const confClass = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low';
|
||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
'completed': '✓ Imported', 'pending_review': '⚠ Review',
|
'completed': 'Imported', 'pending_review': 'Needs Review',
|
||||||
'needs_identification': '✗ Unidentified', 'failed': '✗ Failed',
|
'needs_identification': 'Unidentified', 'failed': 'Failed',
|
||||||
'scanning': '⌛ Scanning', 'matched': '✓ Matched',
|
'scanning': 'Scanning...', 'matched': 'Matched',
|
||||||
'rejected': '🚫 Rejected', 'approved': '✅ Approved',
|
'rejected': 'Dismissed', 'approved': 'Approved',
|
||||||
|
};
|
||||||
|
const statusIcons = {
|
||||||
|
'completed': '\u2713', 'pending_review': '\u26A0',
|
||||||
|
'needs_identification': '\u2717', 'failed': '\u2717',
|
||||||
|
'scanning': '\u231B', 'matched': '\u2713',
|
||||||
|
'rejected': '\u2715', 'approved': '\u2713',
|
||||||
};
|
};
|
||||||
const statusLabel = statusLabels[r.status] || r.status;
|
const statusLabel = statusLabels[r.status] || r.status;
|
||||||
|
const statusIcon = statusIcons[r.status] || '';
|
||||||
const statusClass = r.status === 'completed' ? 'completed' : r.status === 'pending_review' ? 'review' :
|
const statusClass = r.status === 'completed' ? 'completed' : r.status === 'pending_review' ? 'review' :
|
||||||
r.status === 'failed' || r.status === 'needs_identification' ? 'failed' : 'neutral';
|
r.status === 'failed' || r.status === 'needs_identification' ? 'failed' : 'neutral';
|
||||||
|
|
||||||
let matchInfo = '';
|
// Parse match data for track details
|
||||||
|
let matchCount = 0, totalTracks = 0, trackDetails = [];
|
||||||
if (r.match_data) {
|
if (r.match_data) {
|
||||||
try {
|
try {
|
||||||
const md = typeof r.match_data === 'string' ? JSON.parse(r.match_data) : r.match_data;
|
const md = typeof r.match_data === 'string' ? JSON.parse(r.match_data) : r.match_data;
|
||||||
matchInfo = `<div class="auto-import-match-info">${md.matched_count || 0}/${md.total_tracks || '?'} tracks matched</div>`;
|
matchCount = md.matched_count || 0;
|
||||||
|
totalTracks = md.total_tracks || 0;
|
||||||
|
if (md.matches) {
|
||||||
|
trackDetails = md.matches.map(m => ({
|
||||||
|
name: m.track?.name || 'Unknown',
|
||||||
|
file: m.file ? m.file.split(/[/\\]/).pop() : '?',
|
||||||
|
confidence: Math.round((m.confidence || 0) * 100),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchSummary = totalTracks > 0 ? `${matchCount}/${totalTracks} tracks` : `${r.total_files} files`;
|
||||||
|
const methodLabels = { tags: 'Tags', folder_name: 'Folder Name', acoustid: 'AcoustID', filename: 'Filename' };
|
||||||
|
const methodLabel = methodLabels[r.identification_method] || r.identification_method || '';
|
||||||
|
|
||||||
|
// Time ago
|
||||||
|
let timeAgo = '';
|
||||||
|
if (r.created_at) {
|
||||||
|
try {
|
||||||
|
const d = new Date(r.created_at);
|
||||||
|
const diffM = Math.floor((Date.now() - d) / 60000);
|
||||||
|
if (diffM < 1) timeAgo = 'just now';
|
||||||
|
else if (diffM < 60) timeAgo = `${diffM}m ago`;
|
||||||
|
else if (diffM < 1440) timeAgo = `${Math.floor(diffM / 60)}h ago`;
|
||||||
|
else timeAgo = `${Math.floor(diffM / 1440)}d ago`;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let actions = '';
|
let actions = '';
|
||||||
if (r.status === 'pending_review') {
|
if (r.status === 'pending_review') {
|
||||||
actions = `<div class="auto-import-actions">
|
actions = `<div class="auto-import-actions">
|
||||||
<button class="watchlist-action-btn watchlist-action-primary" onclick="_autoImportApprove(${r.id})">Approve</button>
|
<button class="watchlist-action-btn watchlist-action-primary" onclick="event.stopPropagation(); _autoImportApprove(${r.id})">Approve & Import</button>
|
||||||
<button class="watchlist-action-btn watchlist-action-secondary" onclick="_autoImportReject(${r.id})">Dismiss</button>
|
<button class="watchlist-action-btn watchlist-action-secondary" onclick="event.stopPropagation(); _autoImportReject(${r.id})">Dismiss</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<div class="auto-import-card auto-import-${statusClass}">
|
// Expanded track list (hidden by default)
|
||||||
<div class="auto-import-card-left">
|
let trackListHtml = '';
|
||||||
${r.image_url ? `<img class="auto-import-card-art" src="${r.image_url}" alt="">` : `<div class="auto-import-card-art-fallback">💿</div>`}
|
if (trackDetails.length > 0) {
|
||||||
</div>
|
trackListHtml = `<div class="auto-import-track-list" id="auto-import-tracks-${idx}">
|
||||||
<div class="auto-import-card-center">
|
<div class="auto-import-track-list-header">
|
||||||
<div class="auto-import-card-album">${escapeHtml(r.album_name || r.folder_name)}</div>
|
<span>Track</span><span>Matched File</span><span>Conf</span>
|
||||||
<div class="auto-import-card-artist">${escapeHtml(r.artist_name || 'Unknown Artist')}</div>
|
</div>
|
||||||
<div class="auto-import-card-folder">${escapeHtml(r.folder_name)} · ${r.total_files} files</div>
|
${trackDetails.map(t => {
|
||||||
${matchInfo}
|
const tConfClass = t.confidence >= 90 ? 'high' : t.confidence >= 70 ? 'medium' : 'low';
|
||||||
${r.error_message ? `<div class="auto-import-card-error">${escapeHtml(r.error_message)}</div>` : ''}
|
return `<div class="auto-import-track-row">
|
||||||
</div>
|
<span class="auto-import-track-name">${escapeHtml(t.name)}</span>
|
||||||
<div class="auto-import-card-right">
|
<span class="auto-import-track-file">${escapeHtml(t.file)}</span>
|
||||||
<div class="auto-import-confidence-bar">
|
<span class="auto-import-track-conf auto-import-conf-${tConfClass}">${t.confidence}%</span>
|
||||||
<div class="auto-import-confidence-fill auto-import-conf-${confClass}" style="width:${confPct}%"></div>
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="auto-import-card auto-import-${statusClass}" onclick="_autoImportToggleDetail(${idx})" style="cursor:pointer">
|
||||||
|
<div class="auto-import-card-top">
|
||||||
|
<div class="auto-import-card-left">
|
||||||
|
${r.image_url ? `<img class="auto-import-card-art" src="${r.image_url}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="auto-import-card-art-fallback" style="display:none">\uD83D\uDCBF</div>` : `<div class="auto-import-card-art-fallback">\uD83D\uDCBF</div>`}
|
||||||
|
</div>
|
||||||
|
<div class="auto-import-card-center">
|
||||||
|
<div class="auto-import-card-album">${escapeHtml(r.album_name || r.folder_name)}</div>
|
||||||
|
<div class="auto-import-card-artist">${escapeHtml(r.artist_name || 'Unknown Artist')}</div>
|
||||||
|
<div class="auto-import-card-meta">
|
||||||
|
<span>${matchSummary}</span>
|
||||||
|
${methodLabel ? `<span class="auto-import-method-badge">${methodLabel}</span>` : ''}
|
||||||
|
${timeAgo ? `<span>${timeAgo}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${r.error_message ? `<div class="auto-import-card-error">${escapeHtml(r.error_message)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="auto-import-card-right">
|
||||||
|
<div class="auto-import-status-badge auto-import-badge-${statusClass}">${statusIcon} ${statusLabel}</div>
|
||||||
|
<div class="auto-import-confidence-bar">
|
||||||
|
<div class="auto-import-confidence-fill auto-import-conf-${confClass}" style="width:${confPct}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="auto-import-confidence-text">${confPct}% confidence</div>
|
||||||
|
${actions}
|
||||||
</div>
|
</div>
|
||||||
<div class="auto-import-confidence-text">${confPct}%</div>
|
|
||||||
<div class="auto-import-status-badge auto-import-badge-${statusClass}">${statusLabel}</div>
|
|
||||||
${actions}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="auto-import-card-folder-path">${escapeHtml(r.folder_name)}</div>
|
||||||
|
${trackListHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
|
@ -66594,6 +66652,14 @@ async function _autoImportSaveSettings() {
|
||||||
} catch (e) { showToast('Error', 'error'); }
|
} catch (e) { showToast('Error', 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _autoImportToggleDetail(idx) {
|
||||||
|
const trackList = document.getElementById(`auto-import-tracks-${idx}`);
|
||||||
|
if (trackList) {
|
||||||
|
trackList.classList.toggle('expanded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window._autoImportToggleDetail = _autoImportToggleDetail;
|
||||||
|
|
||||||
async function _autoImportApprove(id) {
|
async function _autoImportApprove(id) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/auto-import/approve/${id}`, { method: 'POST' });
|
const res = await fetch(`/api/auto-import/approve/${id}`, { method: 'POST' });
|
||||||
|
|
|
||||||
|
|
@ -58229,13 +58229,18 @@ body.reduce-effects *::after {
|
||||||
/* Result cards */
|
/* Result cards */
|
||||||
.auto-import-card {
|
.auto-import-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
flex-direction: column;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
background: rgba(255,255,255,0.02);
|
background: rgba(255,255,255,0.02);
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-import-card-top {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58308,6 +58313,90 @@ body.reduce-effects *::after {
|
||||||
font-size: 10px; font-weight: 600; color: rgba(255,255,255,0.5);
|
font-size: 10px; font-weight: 600; color: rgba(255,255,255,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-import-card-meta {
|
||||||
|
display: flex; gap: 8px; align-items: center;
|
||||||
|
font-size: 10px; color: rgba(255,255,255,0.3); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-import-method-badge {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-import-card-folder-path {
|
||||||
|
font-size: 9px;
|
||||||
|
color: rgba(255,255,255,0.15);
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.03);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expandable track list */
|
||||||
|
.auto-import-track-list {
|
||||||
|
display: none;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-import-track-list.expanded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-import-track-list-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 50px;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255,255,255,0.3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-import-track-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 50px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-import-track-name {
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-import-track-file {
|
||||||
|
color: rgba(255,255,255,0.3);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-import-track-conf {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-import-track-conf.auto-import-conf-high { color: #4ade80; }
|
||||||
|
.auto-import-track-conf.auto-import-conf-medium { color: #fbbf24; }
|
||||||
|
.auto-import-track-conf.auto-import-conf-low { color: #f87171; }
|
||||||
|
|
||||||
.auto-import-status-badge {
|
.auto-import-status-badge {
|
||||||
font-size: 9px; font-weight: 600; padding: 2px 8px;
|
font-size: 9px; font-weight: 600; padding: 2px 8px;
|
||||||
border-radius: 6px; white-space: nowrap;
|
border-radius: 6px; white-space: nowrap;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue