Rebuild the import feature and move it to its own page.

This commit is contained in:
Broque Thomas 2026-03-01 14:18:55 -08:00
parent a91b4fae2d
commit 1aa6cdc9b6
5 changed files with 1744 additions and 1031 deletions

View file

@ -28411,13 +28411,13 @@ def import_staging_files():
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/import/staging/suggestions', methods=['GET'])
def import_staging_suggestions():
"""Suggest albums based on staging folder contents (tags + folder names)."""
@app.route('/api/import/staging/hints', methods=['GET'])
def import_staging_hints():
"""Extract album search hints from staging folder (tags + folder names). Fast — no Spotify calls."""
try:
staging_path = _get_staging_path()
if not os.path.isdir(staging_path):
return jsonify({'success': True, 'suggestions': []})
return jsonify({'success': True, 'hints': []})
# Collect hints from tags and folder structure
tag_albums = {} # (album, artist) -> file count
@ -28463,7 +28463,6 @@ def import_staging_suggestions():
# Folder-based: parse "Artist - Album" pattern or use as-is
for folder, count in sorted(folder_hints.items(), key=lambda x: -x[1]):
# Try to parse "Artist - Album" folder name
q = folder.replace('_', ' ')
if q.lower() not in seen_queries_lower:
seen_queries_lower.add(q.lower())
@ -28472,34 +28471,9 @@ def import_staging_suggestions():
# Cap at 5 queries to keep it fast
queries = queries[:5]
if not queries:
return jsonify({'success': True, 'suggestions': []})
# Search Spotify for each hint, take top 1-2 results per query
suggestions = []
seen_ids = set()
for q in queries:
try:
albums = spotify_client.search_albums(q, limit=2)
for a in albums:
if a.id not in seen_ids:
seen_ids.add(a.id)
suggestions.append({
'id': a.id,
'name': a.name,
'artist': ', '.join(a.artists) if a.artists else 'Unknown Artist',
'release_date': a.release_date or '',
'total_tracks': a.total_tracks,
'image_url': a.image_url,
'album_type': a.album_type or 'album',
'hint_query': q
})
except Exception as search_err:
logger.warning(f"Suggestion search failed for '{q}': {search_err}")
return jsonify({'success': True, 'suggestions': suggestions[:8]})
return jsonify({'success': True, 'hints': queries})
except Exception as e:
logger.error(f"Error getting staging suggestions: {e}")
logger.error(f"Error getting staging hints: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@ -28837,6 +28811,10 @@ def import_album_process():
add_activity_item("📥", "Album Imported", f"{album_name} by {artist_name} ({processed}/{len(matches)} tracks)", "Now")
# Rebuild suggestions cache since staging contents changed
if processed > 0:
refresh_import_suggestions_cache()
return jsonify({
'success': True,
'processed': processed,
@ -28848,6 +28826,42 @@ def import_album_process():
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/import/search/tracks', methods=['GET'])
def import_search_tracks():
"""Search Spotify for individual tracks (used for manual singles identification)."""
try:
query = request.args.get('q', '').strip()
if not query:
return jsonify({'success': False, 'error': 'Missing query parameter'}), 400
limit = min(int(request.args.get('limit', 10)), 30)
if _is_hydrabase_active():
tracks = hydrabase_client.search_tracks(query, limit=limit)
else:
if hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'track')
tracks = spotify_client.search_tracks(query, limit=limit)
results = []
for t in tracks:
results.append({
'id': t.id,
'name': t.name,
'artist': ', '.join(t.artists) if hasattr(t, 'artists') and t.artists else 'Unknown Artist',
'album': t.album if hasattr(t, 'album') else '',
'album_id': t.album_id if hasattr(t, 'album_id') else '',
'duration_ms': t.duration_ms if hasattr(t, 'duration_ms') else 0,
'image_url': t.image_url if hasattr(t, 'image_url') else '',
'track_number': t.track_number if hasattr(t, 'track_number') else 1,
})
return jsonify({'success': True, 'tracks': results})
except Exception as e:
logger.error(f"Error searching tracks for import: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/import/singles/process', methods=['POST'])
def import_singles_process():
"""Process individual staging files as singles through the post-processing pipeline."""
@ -28869,9 +28883,10 @@ def import_singles_process():
title = file_info.get('title', '')
artist = file_info.get('artist', '')
spotify_override = file_info.get('spotify_override', None)
# Fallback to filename parsing if no metadata
if not title:
if not title and not spotify_override:
parsed = _parse_filename_metadata(file_info.get('filename', ''))
title = parsed.get('title', os.path.splitext(file_info.get('filename', 'Unknown'))[0])
if not artist:
@ -28882,7 +28897,51 @@ def import_singles_process():
spotify_artist_data = None
spotify_album_data = None
if title:
# If a manual spotify_override is provided, look up that specific track
if spotify_override and spotify_override.get('id'):
try:
override_id = spotify_override['id']
sp_track = spotify_client.sp.track(override_id) if hasattr(spotify_client, 'sp') and spotify_client.sp else None
if sp_track:
sp_track_artists = sp_track.get('artists', [])
spotify_track_data = {
'name': sp_track.get('name', ''),
'id': override_id,
'track_number': sp_track.get('track_number', 1),
'disc_number': sp_track.get('disc_number', 1),
'duration_ms': sp_track.get('duration_ms', 0),
'artists': [{'name': a.get('name', '')} for a in sp_track_artists],
'uri': sp_track.get('uri', f"spotify:track:{override_id}")
}
title = sp_track.get('name', title)
artist = sp_track_artists[0].get('name', artist) if sp_track_artists else artist
# Get album info
sp_album_info = sp_track.get('album', {})
if sp_album_info:
spotify_album_data = {
'id': sp_album_info.get('id', ''),
'name': sp_album_info.get('name', ''),
'release_date': sp_album_info.get('release_date', ''),
'total_tracks': sp_album_info.get('total_tracks', 1),
'image_url': (sp_album_info.get('images', [{}])[0].get('url') if sp_album_info.get('images') else '')
}
album_artists = sp_album_info.get('artists', [])
if album_artists:
spotify_artist_data = {
'name': album_artists[0].get('name', artist),
'id': album_artists[0].get('id', ''),
'genres': []
}
try:
sp_a = spotify_client.sp.artist(album_artists[0]['id']) if hasattr(spotify_client, 'sp') and spotify_client.sp else None
if sp_a:
spotify_artist_data['genres'] = sp_a.get('genres', [])
except Exception:
pass
except Exception as override_err:
logger.warning(f"Spotify override lookup failed for track {spotify_override.get('id')}: {override_err}")
if not spotify_track_data and title:
try:
search_q = f"{title} {artist}" if artist else title
tracks = spotify_client.search_tracks(search_q, limit=1)
@ -28997,6 +29056,10 @@ def import_singles_process():
add_activity_item("📥", "Singles Imported", f"{processed}/{len(files)} tracks processed", "Now")
# Rebuild suggestions cache since staging contents changed
if processed > 0:
refresh_import_suggestions_cache()
return jsonify({
'success': True,
'processed': processed,
@ -29007,6 +29070,138 @@ def import_singles_process():
logger.error(f"Error processing singles import: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# --- Import Suggestion Cache (server-side background builder) ---
_import_suggestions_cache = {
'suggestions': [],
'building': False,
'built': False,
}
def _build_import_suggestions_background():
"""Background thread: extract hints from staging folder, search Spotify, cache results."""
cache = _import_suggestions_cache
if cache['building']:
return
cache['building'] = True
try:
staging_path = _get_staging_path()
if not os.path.isdir(staging_path):
cache['suggestions'] = []
cache['built'] = True
cache['building'] = False
return
# Reuse the hint extraction logic
tag_albums = {}
folder_hints = {}
for root, _dirs, filenames in os.walk(staging_path):
audio_files = [f for f in filenames if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
if not audio_files:
continue
rel_dir = os.path.relpath(root, staging_path)
if rel_dir != '.':
top_folder = rel_dir.split(os.sep)[0]
folder_hints[top_folder] = folder_hints.get(top_folder, 0) + len(audio_files)
for fname in audio_files:
full_path = os.path.join(root, fname)
try:
from mutagen import File as MutagenFile
tags = MutagenFile(full_path, easy=True)
if tags:
album = (tags.get('album') or [None])[0]
artist = (tags.get('artist') or (tags.get('albumartist') or [None]))[0]
if album:
key = (album.strip(), (artist or '').strip())
tag_albums[key] = tag_albums.get(key, 0) + 1
except Exception:
pass
queries = []
seen_lower = set()
for (album, artist), _ in sorted(tag_albums.items(), key=lambda x: -x[1]):
q = f"{album} {artist}".strip() if artist else album
if q.lower() not in seen_lower:
seen_lower.add(q.lower())
queries.append(q)
for folder, _ in sorted(folder_hints.items(), key=lambda x: -x[1]):
q = folder.replace('_', ' ')
if q.lower() not in seen_lower:
seen_lower.add(q.lower())
queries.append(q)
queries = queries[:5]
if not queries:
cache['suggestions'] = []
cache['built'] = True
cache['building'] = False
return
suggestions = []
seen_ids = set()
for q in queries:
try:
if not spotify_client:
break
albums = spotify_client.search_albums(q, limit=2)
for a in albums:
if a.id not in seen_ids:
seen_ids.add(a.id)
suggestions.append({
'id': a.id,
'name': a.name,
'artist': ', '.join(a.artists) if a.artists else 'Unknown Artist',
'release_date': a.release_date or '',
'total_tracks': a.total_tracks,
'image_url': a.image_url,
'album_type': a.album_type or 'album',
})
except Exception as e:
logger.warning(f"Import suggestion search failed for '{q}': {e}")
cache['suggestions'] = suggestions[:8]
cache['built'] = True
logger.info(f"Import suggestions cache built: {len(cache['suggestions'])} suggestions from {len(queries)} hints")
except Exception as e:
logger.error(f"Error building import suggestions cache: {e}")
cache['suggestions'] = []
cache['built'] = True
finally:
cache['building'] = False
def start_import_suggestions_cache():
"""Start building the import suggestions cache in a background thread (called on server startup)."""
threading.Thread(
target=_build_import_suggestions_background,
daemon=True,
name='import-suggestions-cache'
).start()
def refresh_import_suggestions_cache():
"""Invalidate and rebuild the suggestions cache (called after imports change staging contents)."""
_import_suggestions_cache['built'] = False
threading.Thread(
target=_build_import_suggestions_background,
daemon=True,
name='import-suggestions-cache'
).start()
@app.route('/api/import/staging/suggestions', methods=['GET'])
def import_staging_suggestions():
"""Return cached import suggestions. If cache isn't built yet, returns partial/empty with a flag."""
cache = _import_suggestions_cache
return jsonify({
'success': True,
'suggestions': cache['suggestions'],
'ready': cache['built'],
})
# ================================================================================================
# END IMPORT / STAGING SYSTEM
# ================================================================================================
@ -29049,6 +29244,10 @@ if __name__ == '__main__':
start_watchlist_auto_scanning()
print("✅ Automatic watchlist scanning started (5 minute initial delay, 24 hour cycles)")
# Pre-build import suggestions cache in background
print("🔧 Pre-building import suggestions cache...")
start_import_suggestions_cache()
# Initialize app start time for uptime tracking
import time
app.start_time = time.time()

View file

@ -54,6 +54,10 @@
<span class="nav-icon">📚</span>
<span class="nav-text">Library</span>
</button>
<button class="nav-button" data-page="import">
<span class="nav-icon">📂</span>
<span class="nav-text">Import</span>
</button>
<button class="nav-button" data-page="settings">
<span class="nav-icon">⚙️</span>
<span class="nav-text">Settings</span>
@ -330,7 +334,7 @@
</div>
</div>
</div>
<button class="import-button" id="import-button" onclick="openImportModal()"
<button class="import-button" id="import-button" onclick="navigateToPage('import')"
title="Import Music from Staging">
<img src="https://cdn-icons-png.flaticon.com/512/8765/8765164.png" alt="Import"
class="import-logo">
@ -3650,9 +3654,106 @@
</div>
</div>
<!-- Import Page -->
<div class="page" id="import-page">
<div class="import-page-container">
<!-- Header with staging info -->
<div class="import-page-header">
<div class="import-page-title-row">
<h1 class="import-page-title">Import Music</h1>
<button class="import-page-refresh-btn" onclick="importPageRefreshStaging()" title="Re-scan staging folder">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z"/></svg>
Refresh
</button>
</div>
<div class="import-page-staging-bar" id="import-staging-bar">
<span class="import-staging-path" id="import-page-staging-path">Staging folder: loading...</span>
<span class="import-staging-stats" id="import-page-staging-stats"></span>
</div>
</div>
<!-- Processing Queue -->
<div class="import-page-queue hidden" id="import-page-queue">
<div class="import-page-queue-header">
<span class="import-page-queue-title">Processing</span>
<button class="import-page-queue-clear" id="import-page-queue-clear" onclick="importPageClearFinishedJobs()">Clear finished</button>
</div>
<div class="import-page-queue-list" id="import-page-queue-list"></div>
</div>
<!-- Tab Bar -->
<div class="import-page-tab-bar">
<button class="import-page-tab active" id="import-page-tab-album" onclick="importPageSwitchTab('album')">Albums</button>
<button class="import-page-tab" id="import-page-tab-singles" onclick="importPageSwitchTab('singles')">Singles</button>
</div>
<!-- Album Tab -->
<div class="import-page-tab-content active" id="import-page-album-content">
<!-- Search state -->
<div id="import-page-album-search-section">
<div class="import-page-suggestions" id="import-page-suggestions">
<div class="import-page-section-label">Suggested from your staging folder</div>
<div class="import-page-album-grid" id="import-page-suggestions-grid"></div>
</div>
<div class="import-page-search-bar">
<input type="text" id="import-page-album-search-input" class="import-page-search-input"
placeholder="Search for an album..." onkeydown="if(event.key==='Enter')importPageSearchAlbum()">
<button class="import-page-search-btn" onclick="importPageSearchAlbum()">Search</button>
<button class="import-page-clear-btn hidden" id="import-page-album-clear-btn"
onclick="importPageResetAlbumSearch()" title="Clear search">✕</button>
</div>
<div class="import-page-album-grid" id="import-page-album-results"></div>
</div>
<!-- Match state (hidden initially) -->
<div id="import-page-album-match-section" class="hidden">
<div class="import-page-album-hero" id="import-page-album-hero"></div>
<div class="import-page-match-header">
<h3>Track Matching</h3>
<div class="import-page-match-actions">
<button class="import-page-secondary-btn" onclick="importPageAutoRematch()">Re-match Automatically</button>
<button class="import-page-back-btn" onclick="importPageResetAlbumSearch()">Back to Search</button>
</div>
</div>
<div class="import-page-match-list" id="import-page-match-list"></div>
<!-- Unmatched file pool for drag-drop -->
<div class="import-page-unmatched-pool" id="import-page-unmatched-pool">
<div class="import-page-pool-label">Unmatched Files (<span id="import-page-unmatched-count">0</span>)</div>
<div class="import-page-pool-chips" id="import-page-pool-chips"></div>
</div>
<div class="import-page-match-footer">
<div class="import-page-match-stats" id="import-page-match-stats"></div>
<button class="import-page-process-btn" id="import-page-album-process-btn"
onclick="importPageProcessAlbum()">Process Album</button>
</div>
</div>
</div>
<!-- Singles Tab -->
<div class="import-page-tab-content" id="import-page-singles-content">
<div class="import-page-singles-header">
<div class="import-page-singles-actions">
<button class="import-page-secondary-btn" onclick="importPageSelectAllSingles()">
<span id="import-page-select-all-text">Select All</span>
</button>
<button class="import-page-process-btn" id="import-page-singles-process-btn"
onclick="importPageProcessSingles()" disabled>Process Selected (0)</button>
</div>
</div>
<div class="import-page-singles-list" id="import-page-singles-list">
<div class="import-page-empty-state">Navigate to this page to scan your staging folder for audio files.</div>
</div>
</div>
</div>
</div>
</div> <!-- /main-content -->
</div> <!-- /main-container -->
<!-- Loading Overlay -->
<div class="loading-overlay hidden" id="loading-overlay">
<div class="loading-spinner"></div>
@ -3757,104 +3858,6 @@
</div>
</div>
<!-- Import Modal -->
<div class="modal-overlay hidden" id="import-modal-overlay" onclick="if(event.target===this)closeImportModal()">
<div class="import-modal" id="import-modal">
<div class="import-modal-header">
<h2 class="import-modal-title">Import Music</h2>
<div class="import-tab-bar">
<button class="import-tab-btn active" id="import-tab-album-btn"
onclick="switchImportTab('album')">Album</button>
<button class="import-tab-btn" id="import-tab-singles-btn"
onclick="switchImportTab('singles')">Singles</button>
</div>
<span class="import-modal-close" onclick="closeImportModal()">&times;</span>
</div>
<div class="import-modal-body">
<!-- Album Tab -->
<div class="import-tab-content active" id="import-tab-album">
<!-- Search state -->
<div id="import-album-search-section">
<div class="import-suggestions-section" id="import-suggestions-section">
<div class="import-suggestions-label">Suggested from your staging folder</div>
<div class="import-album-grid" id="import-suggestions-grid"></div>
</div>
<div class="import-search-bar">
<input type="text" id="import-album-search-input" placeholder="Search for an album..."
onkeydown="if(event.key==='Enter')searchImportAlbum()">
<button class="import-search-btn" onclick="searchImportAlbum()">Search</button>
<button class="import-clear-btn hidden" id="import-album-clear-btn"
onclick="clearImportAlbumSearch()" title="Clear search">✕</button>
</div>
<div class="import-album-grid" id="import-album-results"></div>
</div>
<!-- Match state (hidden initially) -->
<div id="import-album-match-section" class="hidden">
<div class="import-album-hero" id="import-album-hero"></div>
<div class="import-match-header">
<h3>Track Matching</h3>
<button class="import-back-btn" onclick="resetImportAlbumSearch()">← Back to Search</button>
</div>
<div class="import-match-list" id="import-match-list"></div>
<div class="import-match-footer">
<div class="import-match-stats" id="import-match-stats"></div>
<button class="import-process-btn" id="import-album-process-btn"
onclick="processImportAlbum()">Process Album</button>
</div>
</div>
<!-- Progress state -->
<div id="import-album-progress-section" class="hidden">
<div class="import-progress-container">
<div class="import-progress-text" id="import-album-progress-text">Processing...</div>
<div class="import-progress-bar" id="import-album-progress-bar">
<div class="import-progress-fill" id="import-album-progress-fill"></div>
</div>
<div class="import-progress-result" id="import-album-progress-result"></div>
<div class="import-progress-actions hidden" id="import-album-progress-actions">
<button class="import-secondary-btn" onclick="resetImportAlbumSearch()">Import
Another</button>
<button class="import-process-btn" onclick="closeImportModal()">Done</button>
</div>
</div>
</div>
</div>
<!-- Singles Tab -->
<div class="import-tab-content" id="import-tab-singles">
<div id="import-singles-controls">
<div class="import-singles-header">
<div class="import-singles-info">
<span id="import-staging-path-display"></span>
</div>
<div class="import-singles-actions">
<button class="import-action-btn" onclick="loadStagingFiles()">Refresh</button>
<button class="import-action-btn" onclick="selectAllStagingFiles()">Select All</button>
<button class="import-process-btn" id="import-singles-process-btn"
onclick="processImportSingles()" disabled>Process Selected (0)</button>
</div>
</div>
</div>
<div class="import-singles-list" id="import-singles-list">
<div class="import-singles-empty">Click "Refresh" to scan staging folder for audio files</div>
</div>
<!-- Singles Progress -->
<div id="import-singles-progress-section" class="hidden">
<div class="import-progress-container">
<div class="import-progress-text" id="import-singles-progress-text">Processing...</div>
<div class="import-progress-bar" id="import-singles-progress-bar">
<div class="import-progress-fill" id="import-singles-progress-fill"></div>
</div>
<div class="import-progress-result" id="import-singles-progress-result"></div>
<div class="import-progress-actions hidden" id="import-singles-progress-actions">
<button class="import-secondary-btn" onclick="resetImportSingles()">Import More</button>
<button class="import-process-btn" onclick="closeImportModal()">Done</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add to Wishlist Modal -->
<div class="modal-overlay hidden" id="add-to-wishlist-modal-overlay">
<div class="add-to-wishlist-modal" id="add-to-wishlist-modal">

View file

@ -1925,41 +1925,82 @@
transition: opacity 0.1s ease;
}
/* Import Modal - Mobile */
.import-modal {
width: 100vw;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
height: 100vh;
/* Import Page - Touch fallback */
.import-page-file-chip {
cursor: pointer;
}
.import-album-grid {
.import-page-match-row {
cursor: pointer;
}
}
/* Import Page — small screen */
@media (max-width: 768px) {
.import-page-container {
padding: 16px;
}
.import-page-title {
font-size: 22px;
}
.import-page-album-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
.import-singles-header {
.import-page-album-hero {
flex-direction: column;
text-align: center;
}
.import-page-album-hero img {
width: 100px;
height: 100px;
}
.import-page-match-row {
grid-template-columns: 28px 1fr;
gap: 8px;
}
.import-page-match-file {
grid-column: 1 / -1;
padding-left: 28px;
}
.import-page-match-unmatch {
grid-column: 1 / -1;
justify-self: end;
}
.import-page-singles-header {
flex-direction: column;
align-items: flex-start;
}
.import-singles-actions {
.import-page-singles-actions {
width: 100%;
flex-wrap: wrap;
}
.import-match-item {
flex-wrap: wrap;
.import-page-single-item {
grid-template-columns: 28px 1fr;
}
.import-match-file {
text-align: left;
flex-basis: 100%;
.import-page-single-actions {
grid-column: 1 / -1;
justify-self: end;
}
.import-album-hero {
.import-page-single-search-panel {
padding-left: 0;
}
.import-page-staging-bar {
flex-direction: column;
text-align: center;
gap: 4px;
align-items: flex-start;
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff