From 6fe6a31fd1ec387ad08c0ef7a5f5eee6772c422b Mon Sep 17 00:00:00 2001 From: "cool.gitter.choco" Date: Mon, 3 Feb 2025 18:29:37 -0600 Subject: [PATCH] here comes the refactor! --- app.py | 7 +- routes/utils/artist.py | 2 +- static/css/config.css | 389 +++++++++ static/css/main.css | 802 ++++++++++++++++++ static/css/queue.css | 323 ++++++++ static/css/style.css | 1094 ------------------------- static/js/app.js | 980 ---------------------- static/js/config.js | 301 +++++++ static/js/main.js | 303 +++++++ static/js/queue.js | 315 +++++++ templates/{index.html => config.html} | 83 +- templates/main.html | 47 ++ 12 files changed, 2521 insertions(+), 2125 deletions(-) create mode 100644 static/css/config.css create mode 100644 static/css/main.css create mode 100644 static/css/queue.css delete mode 100755 static/css/style.css delete mode 100755 static/js/app.js create mode 100644 static/js/config.js create mode 100644 static/js/main.js create mode 100644 static/js/queue.js rename templates/{index.html => config.html} (51%) mode change 100755 => 100644 create mode 100755 templates/main.html diff --git a/app.py b/app.py index dfcc7c4..da2799d 100755 --- a/app.py +++ b/app.py @@ -46,7 +46,12 @@ def create_app(): # Serve frontend @app.route('/') def serve_index(): - return render_template('index.html') + return render_template('main.html') + + # Add this new route for config page + @app.route('/config') + def serve_config(): + return render_template('config.html') @app.route('/static/') def serve_static(path): diff --git a/routes/utils/artist.py b/routes/utils/artist.py index 4c376aa..7636376 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -68,7 +68,7 @@ def download_artist_albums(service, artist_url, main, fallback=None, quality=Non }) return - log_json({"status": "initializing", "type": "artist", "artist": artist_name, "total_albums": len(albums)}) + log_json({"status": "initializing", "type": "artist", "artist": artist_name, "total_albums": len(albums), "album_type": album_type}) for album in albums: try: diff --git a/static/css/config.css b/static/css/config.css new file mode 100644 index 0000000..770658e --- /dev/null +++ b/static/css/config.css @@ -0,0 +1,389 @@ +/* CONFIGURATION PAGE STYLES */ +/* Base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + } + + body { + background-color: #121212; + color: #ffffff; + min-height: 100vh; + } + + /* Config Container */ + .config-container { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1.5rem; + } + + /* Header */ + .config-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid #2a2a2a; + } + + .back-button { + background: #1DB954; + color: white; + padding: 0.8rem 1.5rem; + border-radius: 25px; + text-decoration: none; + font-weight: 500; + transition: background 0.3s ease; + } + + .back-button:hover { + background: #1ed760; + } + + /* Queue Icon in Header */ + #queueIcon { + background: none; + border: none; + cursor: pointer; + padding: 4px; + } + + #queueIcon img { + width: 24px; + height: 24px; + filter: invert(1); + transition: opacity 0.3s; + } + + #queueIcon:hover img { + opacity: 0.8; + } + + /* Account Configuration Section */ + .account-config { + background: #181818; + padding: 1.5rem; + border-radius: 12px; + margin-bottom: 2rem; + } + + .config-item { + margin-bottom: 1.5rem; + position: relative; + } + + .config-item label { + display: block; + margin-bottom: 0.5rem; + color: #b3b3b3; + font-size: 0.95rem; + } + + /* Enhanced Dropdown Styling */ + #spotifyAccountSelect, + #deezerAccountSelect, + #spotifyQualitySelect, + #deezerQualitySelect { + background: #2a2a2a; + color: white; + border: 1px solid #404040; + border-radius: 8px; + padding: 0.8rem 2.5rem 0.8rem 1rem; + width: 100%; + font-size: 0.95rem; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 1rem center; + background-size: 12px; + transition: all 0.3s ease; + } + + #spotifyAccountSelect:focus, + #deezerAccountSelect:focus, + #spotifyQualitySelect:focus, + #deezerQualitySelect:focus { + outline: none; + border-color: #1DB954; + box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2); + } + + #spotifyAccountSelect option, + #deezerAccountSelect option, + #spotifyQualitySelect option, + #deezerQualitySelect option { + background: #181818; + color: white; + padding: 0.8rem; + } + + #spotifyAccountSelect option:hover, + #deezerAccountSelect option:hover, + #spotifyQualitySelect option:hover, + #deezerQualitySelect option:hover { + background: #1DB954; + } + + /* Improved Toggle Switches */ + .switch { + position: relative; + display: inline-block; + width: 40px; + height: 20px; + margin-left: 1rem; + vertical-align: middle; + overflow: hidden; + } + + .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #666; + transition: 0.4s; + border-radius: 20px; + } + + .slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: white; + transition: 0.4s; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + } + + input:checked + .slider { + background-color: #1DB954; + } + + input:checked + .slider:before { + transform: translateX(20px); + } + + /* Service Tabs */ + .service-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + } + + .tab-button { + padding: 0.8rem 1.5rem; + border: none; + border-radius: 25px; + background: #2a2a2a; + color: #ffffff; + cursor: pointer; + font-size: 0.95rem; + transition: all 0.3s ease; + } + + .tab-button.active { + background: #1DB954; + } + + /* Credentials List */ + .credentials-list { + margin-bottom: 2rem; + } + + .credential-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: #2a2a2a; + border-radius: 8px; + margin-bottom: 0.75rem; + } + + .credential-actions button { + margin-left: 0.5rem; + padding: 0.4rem 0.8rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: opacity 0.3s ease; + } + + .edit-btn { + background: #1DB954; + color: white; + } + + .delete-btn { + background: #ff5555; + color: white; + } + + .credential-actions button:hover { + opacity: 0.9; + } + + /* Credentials Form */ + .credentials-form { + background: #181818; + padding: 1.5rem; + border-radius: 12px; + } + + #serviceFields { + margin: 1.5rem 0; + } + + .form-group { + margin-bottom: 1.2rem; + } + + .form-group label { + display: block; + margin-bottom: 0.5rem; + color: #b3b3b3; + } + + .form-group input { + width: 100%; + padding: 0.8rem; + background: #2a2a2a; + border: 1px solid #404040; + border-radius: 8px; + color: white; + transition: border-color 0.3s ease; + } + + .form-group input:focus { + outline: none; + border-color: #1DB954; + } + + .save-btn { + background: #1DB954; + color: white; + padding: 0.8rem 1.5rem; + border: none; + border-radius: 25px; + width: 100%; + cursor: pointer; + font-weight: 500; + transition: background 0.3s ease; + } + + .save-btn:hover { + background: #1ed760; + } + + /* Error Messages */ + #configError { + color: #ff5555; + margin-top: 1rem; + text-align: center; + font-size: 0.9rem; + } + + /* MOBILE RESPONSIVENESS */ + @media (max-width: 768px) { + .config-container { + padding: 1.5rem 1rem; + } + + .config-header { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + + /* Increase touch target sizes for buttons and selects */ + .back-button { + width: 100%; + text-align: center; + padding: 0.8rem; + } + + #spotifyAccountSelect, + #deezerAccountSelect, + #spotifyQualitySelect, + #deezerQualitySelect { + padding: 0.8rem 2rem 0.8rem 1rem; + font-size: 0.9rem; + } + + .service-tabs { + flex-wrap: wrap; + } + + .tab-button { + flex: 1 1 auto; + text-align: center; + margin-bottom: 0.5rem; + } + + .credential-item { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .credential-actions { + width: 100%; + display: flex; + justify-content: flex-end; + } + + /* Adjust toggle switch size for better touch support */ + .switch { + width: 50px; + height: 24px; + } + + .slider:before { + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + } + } + + @media (max-width: 480px) { + .config-container { + padding: 1rem; + } + + .account-config, + .credentials-form { + padding: 1rem; + } + + .form-group input { + padding: 0.7rem; + } + + .save-btn, + .back-button { + padding: 0.7rem; + font-size: 0.9rem; + } + + /* Reduce dropdown padding for very small screens */ + #spotifyAccountSelect, + #deezerAccountSelect, + #spotifyQualitySelect, + #deezerQualitySelect { + padding: 0.7rem 1.8rem 0.7rem 0.8rem; + } + } + \ No newline at end of file diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..4a9fa3d --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,802 @@ +/* GENERAL STYLING */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + } + + body { + background-color: #121212; + color: #ffffff; + min-height: 100vh; + } + + .container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; + position: relative; + z-index: 1; + } + + /* SEARCH HEADER */ + .search-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 30px; + position: sticky; + top: 0; + background-color: #121212; + padding: 20px 0; + z-index: 100; + } + + .search-input { + flex: 1; + padding: 12px 20px; + border: none; + border-radius: 25px; + background: #2a2a2a; + color: white; + font-size: 16px; + } + + .search-type { + padding: 12px 15px; + background: #2a2a2a; + border: none; + border-radius: 25px; + color: white; + cursor: pointer; + } + + .search-button { + padding: 12px 30px; + background-color: #1DB954; + border: none; + border-radius: 25px; + color: white; + font-weight: bold; + cursor: pointer; + transition: background-color 0.3s; + } + + .search-button:hover { + background-color: #1ed760; + } + + /* ICONS */ + .settings-icon img, + #queueIcon img { + width: 24px; + height: 24px; + vertical-align: middle; + filter: invert(1); + transition: opacity 0.3s; + } + + .settings-icon:hover img, + #queueIcon:hover img { + opacity: 0.8; + } + + /* Queue Icon in Header */ + #queueIcon { + background: none; + border: none; + cursor: pointer; + padding: 4px; + } + + #queueIcon img { + width: 24px; + height: 24px; + filter: invert(1); + transition: opacity 0.3s; + } + + #queueIcon:hover img { + opacity: 0.8; + } + + /* RESULTS GRID */ + .results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 20px; + } + + /* ENHANCED SEARCH RESULT CARD STYLES */ + .result-card { + background-color: #181818; + border: 1px solid #2a2a2a; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + display: flex; + flex-direction: column; + cursor: pointer; + } + + .result-card:hover { + transform: translateY(-4px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25); + } + + /* Album Art Wrapper & Image */ + .album-art-wrapper { + position: relative; + width: 100%; + overflow: hidden; + border-radius: 4px; + margin-bottom: 15px; + } + + /* This pseudo-element creates the square aspect ratio */ + .album-art-wrapper::before { + content: ""; + display: block; + padding-top: 100%; + } + + .album-art { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + /* Text & Details Sections */ + .track-title { + padding: 0.5rem 1rem; + font-size: 1.1rem; + font-weight: 600; + color: #fff; + margin-bottom: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .track-artist { + padding: 0 1rem; + font-size: 0.95rem; + color: #b3b3b3; + margin-bottom: 0.5rem; + } + + .track-details { + padding: 0.5rem 1rem; + font-size: 0.85rem; + color: #ccc; + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid #2a2a2a; + } + + /* Duration text styling */ + .duration { + font-style: italic; + color: #999; + } + + /* Download Button Styling */ + .download-btn { + margin: 0.75rem 1rem 1rem; + padding: 0.5rem 1rem; + background-color: #1db954; + color: #fff; + border: none; + border-radius: 4px; + font-size: 0.95rem; + cursor: pointer; + transition: background-color 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + } + + /* Prevent non-artist download buttons from overflowing their container */ + .result-card .download-btn { + width: calc(100% - 2rem); + box-sizing: border-box; + } + + .download-btn:hover { + background-color: #17a44b; + } + + /* Specific style for main download button on artist cards */ + .download-btn.main-download { + background-color: #1db954; + margin: 0.75rem auto 1rem; + display: block; + } + + .download-btn.main-download:hover { + background-color: #17a44b; + } + + /* Download icon inside buttons */ + .download-icon { + width: 18px; + height: 18px; + fill: currentColor; + margin-right: 0.5rem; + } + + /* ARTIST DOWNLOAD OPTIONS */ + .artist-download-buttons { + border-top: 1px solid #2a2a2a; + padding: 0.5rem 1rem; + } + + .options-toggle { + width: 100%; + background: none; + border: none; + color: #b3b3b3; + padding: 8px 16px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + } + + .options-toggle .toggle-chevron { + width: 16px; + height: 16px; + fill: currentColor; + transition: transform 0.2s ease; + } + + .download-options-container { + margin-top: 0.5rem; + } + + .secondary-options { + display: none; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; + } + + .secondary-options.expanded, + .download-options-container .expanded { + display: flex; + } + + /* Option buttons inside artist card */ + .option-btn { + flex: 1; + background-color: #2a2a2a; + color: #fff; + padding: 0.4rem 0.6rem; + border: none; + border-radius: 4px; + font-size: 0.85rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; + } + + .option-btn:hover { + background-color: #3a3a3a; + } + + /* Icons inside option buttons */ + .option-btn .type-icon { + width: 18px; + height: 18px; + margin-right: 0.3rem; + } + + /* LOADING & ERROR STATES */ + .loading, + .error { + width: 100%; + text-align: center; + font-size: 1rem; + padding: 1rem; + } + + .error { + color: #c0392b; + } + + /* RESPONSIVE DESIGN FOR RESULTS */ + @media (max-width: 600px) { + .search-header { + flex-wrap: wrap; + justify-content: center; + padding: 10px 0; + } + + .search-input, + .search-type, + .search-button { + flex: 1 1 100%; + margin-bottom: 10px; + } + + .search-type, + .search-button { + padding: 10px; + font-size: 15px; + } + + .search-header > *:last-child { + margin-bottom: 0; + } + + /* Center the grid items */ + .results-grid { + justify-content: center; + } + + /* Center individual result cards */ + .result-card { + width: 90%; + margin: 0 auto; + } + } + + + /* EXISTING STYLES (Results Grid & Others) */ + .result-card { + max-width: 100%; + overflow: hidden; + } + + .album-art-wrapper { + max-width: 100%; + overflow: hidden; + } + + .album-art { + max-width: 100%; + height: auto; + display: block; + } + + /* CONFIGURATION PAGE STYLES */ + /* Base styles */ + * { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + } + + body { + background-color: #121212; + color: #ffffff; + min-height: 100vh; + } + + /* Config Container */ + .config-container { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1.5rem; + } + + /* Header */ + .config-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid #2a2a2a; + } + + .back-button { + background: #1DB954; + color: white; + padding: 0.8rem 1.5rem; + border-radius: 25px; + text-decoration: none; + font-weight: 500; + transition: background 0.3s ease; + } + + .back-button:hover { + background: #1ed760; + } + + /* Queue Icon in Header */ + #queueIcon { + background: none; + border: none; + cursor: pointer; + padding: 4px; + } + + #queueIcon img { + width: 24px; + height: 24px; + filter: invert(1); + transition: opacity 0.3s; + } + + #queueIcon:hover img { + opacity: 0.8; + } + + /* Account Configuration Section */ + .account-config { + background: #181818; + padding: 1.5rem; + border-radius: 12px; + margin-bottom: 2rem; + } + + .config-item { + margin-bottom: 1.5rem; + position: relative; + } + + .config-item label { + display: block; + margin-bottom: 0.5rem; + color: #b3b3b3; + font-size: 0.95rem; + } + + /* Enhanced Dropdown Styling */ + #spotifyAccountSelect, + #deezerAccountSelect, + #spotifyQualitySelect, + #deezerQualitySelect { + background: #2a2a2a; + color: white; + border: 1px solid #404040; + border-radius: 8px; + padding: 0.8rem 2.5rem 0.8rem 1rem; + width: 100%; + font-size: 0.95rem; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 1rem center; + background-size: 12px; + transition: all 0.3s ease; + } + + #spotifyAccountSelect:focus, + #deezerAccountSelect:focus, + #spotifyQualitySelect:focus, + #deezerQualitySelect:focus { + outline: none; + border-color: #1DB954; + box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2); + } + + #spotifyAccountSelect option, + #deezerAccountSelect option, + #spotifyQualitySelect option, + #deezerQualitySelect option { + background: #181818; + color: white; + padding: 0.8rem; + } + + #spotifyAccountSelect option:hover, + #deezerAccountSelect option:hover, + #spotifyQualitySelect option:hover, + #deezerQualitySelect option:hover { + background: #1DB954; + } + + /* Improved Toggle Switches */ + .switch { + position: relative; + display: inline-block; + width: 40px; + height: 20px; + margin-left: 1rem; + vertical-align: middle; + overflow: hidden; + } + + .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #666; + transition: 0.4s; + border-radius: 20px; + } + + .slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: white; + transition: 0.4s; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + } + + input:checked + .slider { + background-color: #1DB954; + } + + input:checked + .slider:before { + transform: translateX(20px); + } + + /* Service Tabs */ + .service-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + } + + .tab-button { + padding: 0.8rem 1.5rem; + border: none; + border-radius: 25px; + background: #2a2a2a; + color: #ffffff; + cursor: pointer; + font-size: 0.95rem; + transition: all 0.3s ease; + } + + .tab-button.active { + background: #1DB954; + } + + /* Credentials List */ + .credentials-list { + margin-bottom: 2rem; + } + + .credential-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: #2a2a2a; + border-radius: 8px; + margin-bottom: 0.75rem; + } + + .credential-actions button { + margin-left: 0.5rem; + padding: 0.4rem 0.8rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: opacity 0.3s ease; + } + + .edit-btn { + background: #1DB954; + color: white; + } + + .delete-btn { + background: #ff5555; + color: white; + } + + .credential-actions button:hover { + opacity: 0.9; + } + + /* Credentials Form */ + .credentials-form { + background: #181818; + padding: 1.5rem; + border-radius: 12px; + } + + #serviceFields { + margin: 1.5rem 0; + } + + .form-group { + margin-bottom: 1.2rem; + } + + .form-group label { + display: block; + margin-bottom: 0.5rem; + color: #b3b3b3; + } + + .form-group input { + width: 100%; + padding: 0.8rem; + background: #2a2a2a; + border: 1px solid #404040; + border-radius: 8px; + color: white; + transition: border-color 0.3s ease; + } + + .form-group input:focus { + outline: none; + border-color: #1DB954; + } + + .save-btn { + background: #1DB954; + color: white; + padding: 0.8rem 1.5rem; + border: none; + border-radius: 25px; + width: 100%; + cursor: pointer; + font-weight: 500; + transition: background 0.3s ease; + } + + .save-btn:hover { + background: #1ed760; + } + + /* Error Messages */ + #configError { + color: #ff5555; + margin-top: 1rem; + text-align: center; + font-size: 0.9rem; + } + + /* MOBILE RESPONSIVENESS */ + @media (max-width: 768px) { + .config-container { + padding: 1.5rem 1rem; + } + + .config-header { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + + .back-button { + width: 100%; + text-align: center; + padding: 0.8rem; + } + + #spotifyAccountSelect, + #deezerAccountSelect, + #spotifyQualitySelect, + #deezerQualitySelect { + padding: 0.8rem 2rem 0.8rem 1rem; + font-size: 0.9rem; + } + + .service-tabs { + flex-wrap: wrap; + } + + .tab-button { + flex: 1 1 auto; + text-align: center; + margin-bottom: 0.5rem; + } + + .credential-item { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .credential-actions { + width: 100%; + display: flex; + justify-content: flex-end; + } + + .switch { + width: 50px; + height: 24px; + } + + .slider:before { + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + } + } + + @media (max-width: 480px) { + .config-container { + padding: 1rem; + } + + .account-config, + .credentials-form { + padding: 1rem; + } + + .form-group input { + padding: 0.7rem; + } + + .save-btn, + .back-button { + padding: 0.7rem; + font-size: 0.9rem; + } + + #spotifyAccountSelect, + #deezerAccountSelect, + #spotifyQualitySelect, + #deezerQualitySelect { + padding: 0.7rem 1.8rem 0.7rem 0.8rem; + } + } + + /* NEW MOBILE STYLING FOR SEARCH HEADER */ + @media (max-width: 600px) { + .search-header { + flex-wrap: wrap; + justify-content: center; + padding: 10px 0; + } + + .search-input, + .search-type, + .search-button { + flex: 1 1 100%; + margin-bottom: 10px; + } + + /* Optional: Adjust button paddings for better touch targets */ + .search-type, + .search-button { + padding: 10px; + font-size: 15px; + } + + /* Remove the gap between items when stacked */ + .search-header > *:last-child { + margin-bottom: 0; + } + } + + /* Download and Type Icon Styles */ + .download-icon, + .type-icon, + .toggle-chevron { + width: 16px; + height: 16px; + vertical-align: middle; + margin-right: 6px; + } + + /* Adjust option button inner styling */ + .artist-download-buttons .option-btn { + font-size: 0.85em; + padding: 6px 10px; + display: flex; + align-items: center; + gap: 4px; + } + \ No newline at end of file diff --git a/static/css/queue.css b/static/css/queue.css new file mode 100644 index 0000000..b22dd00 --- /dev/null +++ b/static/css/queue.css @@ -0,0 +1,323 @@ +/* ---------------------- */ +/* DOWNLOAD QUEUE STYLES */ +/* ---------------------- */ + +/* Container for the download queue sidebar */ +#downloadQueue { + position: fixed; + top: 0; + right: -350px; /* Hidden offscreen by default */ + width: 350px; + height: 100vh; + background: #181818; + padding: 20px; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1001; + overflow-y: auto; + box-shadow: -20px 0 30px rgba(0, 0, 0, 0.4); +} + +/* When active, the sidebar slides into view */ +#downloadQueue.active { + right: 0; +} + +/* Header inside the queue sidebar */ +.queue-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 15px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 20px; +} + +.queue-title { + font-size: 1.25rem; + font-weight: 600; + color: #fff; +} + +/* Close button for the queue sidebar */ +.queue-close { + background: #2a2a2a; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.3s ease; + cursor: pointer; +} + +.queue-close:hover { + background-color: #333; +} + +/* X Icon style for the close button */ +.queue-close::before { + content: "×"; + font-size: 20px; + color: #fff; + line-height: 32px; /* Center the icon vertically within the button */ +} + +/* Container for all queue items */ +#queueItems { + max-height: 60vh; + overflow-y: auto; +} + +/* Each download queue item */ +.queue-item { + background: #2a2a2a; + padding: 15px; + border-radius: 8px; + margin-bottom: 15px; + transition: background-color 0.3s ease, transform 0.2s ease; + display: flex; + flex-direction: column; + gap: 6px; +} + +.queue-item:hover { + background-color: #333; + transform: translateY(-2px); +} + +/* Title text in a queue item */ +.queue-item .title { + font-weight: 500; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #fff; +} + +/* Type indicator (e.g. track, album) */ +.queue-item .type { + font-size: 12px; + color: #1DB954; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Log text for status messages */ +.queue-item .log { + font-size: 13px; + color: #b3b3b3; + line-height: 1.4; + font-family: 'SF Mono', Menlo, monospace; +} + +/* Optional state indicators for each queue item */ +.queue-item--complete { + border-left: 4px solid #1DB954; +} + +.queue-item--error { + border-left: 4px solid #ff5555; +} + +.queue-item--processing { + border-left: 4px solid #4a90e2; +} + +/* Progress bar for downloads */ +.status-bar { + height: 3px; + background: #1DB954; + width: 0; + transition: width 0.3s ease; + margin-top: 8px; +} + +/* Progress percentage text */ +.progress-percent { + text-align: right; + font-weight: bold; + color: #1DB954; +} + +/* Download button inside queue items (if needed) */ +.download-btn { + background-color: #1DB954; + color: white; + border: none; + padding: 8px 15px; + border-radius: 20px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background-color 0.3s ease, transform 0.2s ease, box-shadow 0.3s ease; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + justify-content: center; +} + +.download-btn:hover { + background-color: #1ed760; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(29, 185, 84, 0.3); +} + +.download-btn:active { + transform: translateY(0); + box-shadow: none; +} + +/* Optional: icon before the download button text */ +.download-btn::before { + content: "↓"; + font-weight: bold; + font-size: 16px; +} + +/* Optional status message colors (if using state classes) */ +.log--success { + color: #1DB954 !important; +} + +.log--error { + color: #ff5555 !important; +} + +.log--warning { + color: #ffaa00 !important; +} + +.log--info { + color: #4a90e2 !important; +} + +/* Loader animations for real-time progress */ +@keyframes progress-pulse { + 0% { opacity: 0.5; } + 50% { opacity: 1; } + 100% { opacity: 0.5; } +} + +.progress-indicator { + display: inline-block; + margin-left: 8px; + animation: progress-pulse 1.5s infinite; +} + +/* Loading spinner style */ +.loading-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: #1DB954; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.cancel-btn { + background: none; + border: none; + cursor: pointer; + padding: 5px; + outline: none; + margin-top: 10px; + /* Optionally constrain the overall size */ + max-width: 24px; + max-height: 24px; +} + +.cancel-btn img { + width: 16px; /* Reduced from 24px */ + height: 16px; /* Reduced from 24px */ + filter: invert(1); + transition: transform 0.3s ease; +} + +.cancel-btn:hover img { + transform: scale(1.1); +} + +.cancel-btn:active img { + transform: scale(0.9); +} + +/* Close button for the download queue sidebar */ +.close-btn { + background: #2a2a2a; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 20px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.close-btn:hover { + background-color: #333; +} + +/* ------------------------------- */ +/* MOBILE RESPONSIVE ADJUSTMENTS */ +/* ------------------------------- */ +@media (max-width: 600px) { + /* Make the sidebar full width on mobile */ + #downloadQueue { + width: 100%; + right: -100%; /* Off-screen fully */ + padding: 15px; + } + + /* When active, the sidebar slides into view from full width */ + #downloadQueue.active { + right: 0; + } + + /* Adjust header and title for smaller screens */ + .queue-header { + flex-direction: column; + align-items: flex-start; + } + + .queue-title { + font-size: 1.1rem; + } + + /* Reduce the size of the close buttons */ + .queue-close, + .close-btn { + width: 28px; + height: 28px; + font-size: 18px; + } + + /* Adjust queue items padding */ + .queue-item { + padding: 12px; + margin-bottom: 12px; + } + + /* Adjust the download button padding and font size */ + .download-btn { + padding: 8px 10px; + font-size: 13px; + } + + /* Ensure text remains legible on smaller screens */ + .queue-item .log, + .queue-item .type { + font-size: 12px; + } +} diff --git a/static/css/style.css b/static/css/style.css deleted file mode 100755 index 2304eb4..0000000 --- a/static/css/style.css +++ /dev/null @@ -1,1094 +0,0 @@ -/* GENERAL STYLING */ - -* { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -} - -body { - background-color: #121212; - color: #ffffff; - min-height: 100vh; -} - -.container { - max-width: 1200px; - margin: 0 auto; - padding: 20px; -} - -/* SEARCH AREA */ - -.search-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 30px; - position: sticky; - top: 0; - background-color: #121212; - padding: 20px 0; - z-index: 100; -} - -.search-input { - flex: 1; - padding: 12px 20px; - border: none; - border-radius: 25px; - background: #2a2a2a; - color: white; - font-size: 16px; -} - -.search-type { - padding: 12px 15px; - background: #2a2a2a; - border: none; - border-radius: 25px; - color: white; - cursor: pointer; -} - -.search-button { - padding: 12px 30px; - background-color: #1DB954; - border: none; - border-radius: 25px; - color: white; - font-weight: bold; - cursor: pointer; - transition: background-color 0.3s; -} - -.search-button:hover { - background-color: #1ed760; -} - -/* RESULTS GRID */ - -.results-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 20px; -} - -.result-card { - background: #181818; - padding: 15px; - border-radius: 8px; - transition: background-color 0.3s; - cursor: pointer; -} - -.result-card:hover { - background-color: #282828; -} - -.album-art { - width: 100%; - border-radius: 4px; - margin-bottom: 15px; -} - -.track-title { - font-size: 16px; - font-weight: 600; - margin-bottom: 8px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.track-artist { - color: #b3b3b3; - font-size: 14px; - margin-bottom: 4px; -} - -.track-details { - color: #b3b3b3; - font-size: 12px; - display: flex; - justify-content: space-between; - align-items: center; -} - -.duration { - background: #2a2a2a; - padding: 4px 8px; - border-radius: 4px; -} - -.loading { - text-align: center; - padding: 50px; - font-size: 18px; - color: #1DB954; -} - -.error { - color: #ff5555; - text-align: center; - padding: 20px; -} - -/* SETTINGS ICON */ - -.settings-icon { - position: static; - order: -1; - margin-right: 15px; - font-size: 22px; - background: none; - border: none; - color: #ffffff; - cursor: pointer; - z-index: 1000; - transition: transform 0.3s; -} - -.settings-icon img, -.queue-icon img { - width: 24px; - height: 24px; - vertical-align: middle; - filter: invert(1); -} - -.settings-icon:hover img, -.queue-icon:hover img { - opacity: 0.8; -} - -/* SIDEBAR */ - -.sidebar { - position: fixed; - top: 0; - width: 350px; - height: 100vh; - background: #181818; - padding: 20px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - z-index: 1001; - overflow-y: auto; - box-shadow: 0 0 15px rgba(0,0,0,0.3); -} - -#settingsSidebar { - left: -350px; -} - -#settingsSidebar.active { - left: 0; - box-shadow: 20px 0 30px rgba(0,0,0,0.4); -} - -#downloadQueue { - right: -350px; - left: auto; -} - -#downloadQueue.active { - right: 0; - box-shadow: -20px 0 30px rgba(0,0,0,0.4); -} - -.sidebar.active { - left: 0; -} - -.sidebar-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; -} - -.close-btn { - background: none; - border: none; - color: #ffffff; - font-size: 24px; - cursor: pointer; -} - -/* SERVICE TABS */ - -.service-tabs { - display: flex; - gap: 10px; - margin-bottom: 20px; -} - -.tab-button { - padding: 10px 20px; - border: none; - border-radius: 20px; - background: #2a2a2a; - color: #ffffff; - cursor: pointer; -} - -.tab-button.active { - background: #1DB954; -} - -/* CREDENTIALS LIST */ - -.credentials-list { - margin-bottom: 20px; -} - -.credential-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 15px; - background: #2a2a2a; - border-radius: 8px; - margin-bottom: 10px; -} - -.credential-actions button { - margin-left: 10px; - padding: 5px 10px; - border: none; - border-radius: 4px; - cursor: pointer; -} - -.edit-btn { - background: #1DB954; - color: white; -} - -.delete-btn { - background: #ff5555; - color: white; -} - -/* FORM STYLES */ - -.form-group { - margin-bottom: 15px; -} - -.form-group label { - display: block; - margin-bottom: 5px; -} - -.form-group input, -.form-group select, -.form-group textarea { - width: 100%; - padding: 8px; - border-radius: 4px; - border: none; - background: #2a2a2a; - color: white; -} - -.form-group select { - padding-right: 25px; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M5 6L0 0h10z' fill='%23fff'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 10px center; - background-size: 10px 6px; -} - -.save-btn { - background: #1DB954; - color: white; - padding: 10px 20px; - border: none; - border-radius: 20px; - cursor: pointer; - width: 100%; -} - -/* CONFIG BAR */ - -.config-bar { - position: fixed; - top: 20px; - right: 20px; - background: #181818; - padding: 15px; - border-radius: 8px; - z-index: 1000; -} - -.config-item { - margin-bottom: 10px; -} - -.config-item label { - display: block; - margin-bottom: 5px; -} - -/* TOGGLE SWITCH */ - -.switch { - position: relative; - display: inline-block; - width: 40px; - height: 20px; -} - -.switch input { - opacity: 0; - width: 0; - height: 0; -} - -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #ccc; - transition: .4s; - border-radius: 20px; -} - -.slider:before { - position: absolute; - content: ""; - height: 16px; - width: 16px; - left: 2px; - bottom: 2px; - background-color: white; - transition: .4s; - border-radius: 50%; -} - -input:checked + .slider { - background-color: #1DB954; -} - -input:checked + .slider:before { - transform: translateX(20px); -} - -/* DOWNLOAD QUEUE */ - -.queue-icon { - position: static; - order: 2; - margin-left: 15px; - font-size: 22px; - background: none; - border: none; - color: white; - cursor: pointer; - z-index: 1000; -} - -.queue-icon:hover { - color: #1DB954; -} - -.queue-header { - display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: 15px; - border-bottom: 1px solid #2a2a2a; - margin-bottom: 20px; -} - -.queue-title { - font-size: 1.25rem; - font-weight: 600; - color: #fff; -} - -.queue-close { - background: #2a2a2a; - border-radius: 50%; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.3s ease; -} - -.queue-close:hover { - background-color: #333; -} - -#queueItems { - max-height: 60vh; - overflow-y: auto; -} - -.queue-item { - background: #2a2a2a; - padding: 15px; - border-radius: 8px; - margin-bottom: 15px; - transition: background-color 0.3s ease; -} - -.queue-item:hover { - background-color: #333; -} - -.queue-item .title { - font-weight: 500; - margin-bottom: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.queue-item .type { - font-size: 12px; - color: #1DB954; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 6px; -} - -.queue-item .log { - font-size: 13px; - color: #b3b3b3; - line-height: 1.4; - font-family: 'SF Mono', Menlo, monospace; -} - -/* STATUS MESSAGE COLORS */ - -.log--success { color: #1DB954 !important; } -.log--error { color: #ff5555 !important; } -.log--warning { color: #ffaa00 !important; } -.log--info { color: #4a90e2 !important; } - -/* PROGRESS ANIMATION */ - -@keyframes progress-pulse { - 0% { opacity: 0.5; } - 50% { opacity: 1; } - 100% { opacity: 0.5; } -} - -.progress-indicator { - display: inline-block; - margin-left: 8px; - animation: progress-pulse 1.5s infinite; -} - -/* LOADING SPINNER */ - -.loading-spinner { - display: inline-block; - width: 16px; - height: 16px; - border: 3px solid rgba(255,255,255,0.3); - border-radius: 50%; - border-top-color: #1DB954; - animation: spin 1s ease-in-out infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* TIMEOUT WARNING */ - -.timeout-warning { - position: relative; - padding-left: 24px; -} - -.timeout-warning::before { - content: "⚠️"; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); -} - -/* STATUS BAR ANIMATIONS */ - -.status-bar { - height: 3px; - background: #1DB954; - width: 0; - transition: width 0.3s ease; - margin-top: 8px; -} - -/* ERROR TRACEBACK STYLING */ - -.traceback { - background: #2a2a2a; - padding: 10px; - border-radius: 4px; - font-family: monospace; - font-size: 12px; - color: #ff5555; - margin-top: 10px; - max-height: 200px; - overflow-y: auto; -} - -/* QUEUE ITEM STATES */ - -.queue-item--complete { - border-left: 4px solid #1DB954; -} - -.queue-item--error { - border-left: 4px solid #ff5555; -} - -.queue-item--processing { - border-left: 4px solid #4a90e2; -} - -/* PROGRESS PERCENTAGE STYLING */ - -.progress-percent { - float: right; - font-weight: bold; - color: #1DB954; -} - -/* DOWNLOAD BUTTON */ - -.download-btn { - background-color: #1DB954; - color: white; - border: none; - padding: 8px 15px; - border-radius: 20px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: all 0.3s ease; - display: flex; - align-items: center; - gap: 8px; - width: 100%; - justify-content: center; -} - -.download-btn:hover { - background-color: #1ed760; - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(29, 185, 84, 0.3); -} - -.download-btn:active { - transform: translateY(0); - box-shadow: none; -} - -.download-btn::before { - content: "↓"; - font-weight: bold; - font-size: 16px; -} - -/* ACCOUNT SELECTORS */ - -#spotifyAccountSelect, -#deezerAccountSelect { - background: #2a2a2a; - color: white; - border: 1px solid #404040; - border-radius: 8px; - padding: 10px 15px; - font-size: 14px; - width: 100%; - margin: 5px 0 15px; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right 12px center; - background-size: 12px; - transition: border-color 0.3s ease; -} - -#spotifyAccountSelect:focus, -#deezerAccountSelect:focus { - outline: none; - border-color: #1DB954; - box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2); -} - -#spotifyAccountSelect option, -#deezerAccountSelect option { - background: #181818; - color: white; - padding: 10px; -} - -#spotifyAccountSelect option:hover, -#deezerAccountSelect option:hover { - background: #1DB954; -} - -#spotifyAccountSelect:disabled, -#deezerAccountSelect:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -/* RETRY AND CLOSE BUTTONS */ - -.retry-btn { - padding: 4px 12px; - margin: 0 8px; - background-color: #4CAF50; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.9em; - transition: background-color 0.3s ease; -} - -.retry-btn:hover { - background-color: #45a049; -} - -.retry-btn:active { - background-color: #3d8b40; - transform: translateY(1px); -} - -.close-btn { - padding: 4px 12px; - margin: 0 8px; - background-color: #ff4444; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.9em; - transition: background-color 0.3s ease; - line-height: 1; -} - -.close-btn:hover { - background-color: #cc0000; -} - -.close-btn:active { - background-color: #b30000; - transform: translateY(1px); -} - -/* SCROLLBAR STYLING */ - -::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -::-webkit-scrollbar-track { - background: #181818; - border-radius: 4px; -} - -::-webkit-scrollbar-thumb { - background: #2a2a2a; - border-radius: 4px; - border: 2px solid #181818; -} - -::-webkit-scrollbar-thumb:hover { - background: #3a3a3a; -} - -* { - scrollbar-width: thin; - scrollbar-color: #2a2a2a #181818; -} - -.sidebar::-webkit-scrollbar { - width: 8px; -} - -.sidebar::-webkit-scrollbar-thumb { - border-width: 1px; -} - -#queueItems::-webkit-scrollbar { - width: 6px; -} - -#queueItems::-webkit-scrollbar-thumb { - background: #3a3a3a; -} - -.results-container::-webkit-scrollbar { - width: 8px; -} - -html { - scroll-behavior: smooth; -} - -::-webkit-scrollbar-corner { - background: #121212; -} - -/* MOBILE RESPONSIVENESS */ - -@media (max-width: 768px) { - .search-header { - flex-wrap: wrap; - /* Optional: ensure items are aligned properly */ - align-items: center; - } - - /* First row: search input (full width) */ - .search-input { - order: 1; - width: 100%; - } - - /* Second row: search type selector (full width) */ - .search-type { - order: 2; - width: 100%; - } - - /* Third row: search button (full width) */ - .search-button { - order: 3; - width: 100%; - } - - /* Fourth row: settings and queue icons */ - .settings-icon { - order: 4; - /* Optional: remove any extra margins to ensure neat alignment */ - margin: 0; - } - - .queue-icon { - order: 4; - margin-left: auto; /* pushes the queue icon to the right */ - } -} - -@media screen and (max-width: 480px) { - .results-grid { - grid-template-columns: 1fr 1fr; - } - - body { - font-size: 16px; - } - - button, - .result-card { - min-height: 48px; - } - - .track-title, - .track-artist { - white-space: normal; - overflow: visible; - text-overflow: clip; - } -} - -input, -select, -textarea { - font-size: 16px !important; -} - -button { - touch-action: manipulation; -} - -.result-card { - -webkit-tap-highlight-color: transparent; -} - -html { - overflow-y: scroll; -} - -/* Add to existing form styles */ -.config-item { - margin-bottom: 1.5rem; -} - -.config-item label { - display: block; - margin-bottom: 0.5rem; - color: #b3b3b3; - font-size: 0.9rem; -} - -/* Quality selectors - match account select style */ -#spotifyQualitySelect, -#deezerQualitySelect { - background: #2a2a2a; - color: white; - border: 1px solid #404040; - border-radius: 8px; - padding: 10px 15px; - font-size: 14px; - width: 100%; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right 12px center; - background-size: 12px; - transition: all 0.3s ease; -} - -#spotifyQualitySelect:focus, -#deezerQualitySelect:focus { - outline: none; - border-color: #1DB954; - box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2); -} - -/* Quality options styling */ -#spotifyQualitySelect option, -#deezerQualitySelect option { - background: #181818; - color: white; - padding: 10px; -} - -/* Config group spacing */ -.account-config { - display: grid; - gap: 1.2rem; - margin-bottom: 2rem; -} - -/* Mobile adjustments */ -@media (max-width: 768px) { - .config-item { - margin-bottom: 1rem; - } - - #spotifyQualitySelect, - #deezerQualitySelect { - padding: 12px; - font-size: 15px; - } -} - -.cancel-btn { - background: none; - border: none; - cursor: pointer; - padding: 5px; - outline: none; - margin-top: 10px; -} - -.cancel-btn img { - width: 24px; - height: 24px; - filter: invert(1); - transition: transform 0.3s ease; -} - -.cancel-btn:hover img { - transform: scale(1.1); -} - -.cancel-btn:active img { - transform: scale(0.9); -} - -/* --- ICON SIZING FOR ARTIST DOWNLOAD OPTION BUTTONS --- */ -/* This ensures that any inside an option-download button is small */ -.option-download img { - width: 16px; - height: 16px; -} -/* --- ARTIST DOWNLOAD OPTIONS SEPARATION --- */ -.artist-download-buttons { - display: flex; - flex-direction: column; - gap: 10px; - width: 100%; -} - -.artist-download-buttons .option-buttons { - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -/* Optional: ensure that each option button takes up a reasonable amount of space */ -.artist-download-buttons .option-download { - flex: 1 1 auto; - max-width: 32%; -} -/* Artist Download Buttons */ -.artist-download-buttons { - width: 100%; - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 12px; -} - -/* Main Download Button */ -.main-download { - background: #1DB954; - color: #fff; - border-radius: 8px; - padding: 10px 16px; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: background 0.2s ease; -} - -.main-download:hover { - background: #1ed760; -} - -.download-icon { - width: 18px; - height: 18px; - fill: currentColor; -} - -/* Options Container */ -.download-options-container { - width: 100%; - background: #282828; - border-radius: 8px; - overflow: hidden; -} - -/* Options Toggle */ -.options-toggle { - width: 100%; - background: none; - border: none; - color: #b3b3b3; - padding: 8px 16px; - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - transition: color 0.2s ease; -} - -.options-toggle:hover { - color: #fff; - background: rgba(255,255,255,0.05); -} - -.toggle-chevron { - width: 16px; - height: 16px; - fill: currentColor; - transition: transform 0.3s ease; -} - -.options-toggle[aria-expanded="true"] .toggle-chevron { - transform: rotate(180deg); -} - -/* Secondary Options */ -.secondary-options { - display: none; - flex-wrap: wrap; - gap: 6px; - padding: 0 12px 12px; - background: #181818; - border-radius: 0 0 8px 8px; -} - -.secondary-options.expanded { - display: flex; -} - -.option-btn { - flex: 1 1 calc(33.333% - 4px); - min-width: 100px; - background: #2a2a2a; - color: #fff; - border: none; - border-radius: 6px; - padding: 8px 12px; - display: flex; - align-items: center; - gap: 8px; - font-size: 0.85em; - transition: background 0.2s ease; -} - -.option-btn:hover { - background: #333; -} - -.type-icon { - width: 16px; - height: 16px; - flex-shrink: 0; -} - -/* Mobile Adjustments */ -@media (max-width: 768px) { - .option-btn { - flex: 1 1 100%; - } - - .secondary-options { - gap: 4px; - } - - .main-download { - padding: 12px 16px; - font-size: 0.9em; - } - - .options-toggle { - padding: 10px 16px; - } -} -/* Add margin to the options container */ -.download-options-container { - margin-top: 8px; /* Adds separation from main button */ -} - -/* Add padding to top of expanded options */ -.secondary-options.expanded { - padding: 12px 12px 12px 12px; /* Added top padding */ - gap: 8px; /* Ensure gap between buttons */ -} - -/* Add subtle separation between toggle and options */ -.secondary-options { - border-top: 1px solid rgba(255,255,255,0.05); - margin-top: 4px; /* Small separation from toggle */ - padding-top: 8px !important; -} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js deleted file mode 100755 index 8d196b9..0000000 --- a/static/js/app.js +++ /dev/null @@ -1,980 +0,0 @@ -const serviceConfig = { - spotify: { - fields: [ - { id: 'username', label: 'Username', type: 'text' }, - { id: 'credentials', label: 'Credentials', type: 'text' } - ], - validator: (data) => ({ - username: data.username, - credentials: data.credentials - }) - }, - deezer: { - fields: [ - { id: 'arl', label: 'ARL', type: 'text' } - ], - validator: (data) => ({ - arl: data.arl - }) - } -}; - -let currentService = 'spotify'; -let currentCredential = null; -let downloadQueue = {}; -let prgInterval = null; - -document.addEventListener('DOMContentLoaded', () => { - - const searchButton = document.getElementById('searchButton'); - const searchInput = document.getElementById('searchInput'); - const settingsIcon = document.getElementById('settingsIcon'); - const sidebar = document.getElementById('settingsSidebar'); - const closeSidebar = document.getElementById('closeSidebar'); - const serviceTabs = document.querySelectorAll('.tab-button'); - - // Initialize configuration - initConfig(); - - // Search functionality - searchButton.addEventListener('click', performSearch); - searchInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') performSearch(); - }); - - // Settings functionality - settingsIcon.addEventListener('click', () => { - if (sidebar.classList.contains('active')) { - // Collapse sidebar if already expanded - sidebar.classList.remove('active'); - resetForm(); - } else { - // Expand sidebar and load credentials - sidebar.classList.add('active'); - loadCredentials(currentService); - updateFormFields(); - } - }); - - closeSidebar.addEventListener('click', () => { - sidebar.classList.remove('active'); - resetForm(); - }); - - serviceTabs.forEach(tab => { - tab.addEventListener('click', () => { - serviceTabs.forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - currentService = tab.dataset.service; - loadCredentials(currentService); - updateFormFields(); - }); - }); - - document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit); -}); - -async function initConfig() { - loadConfig(); - await updateAccountSelectors(); - - // Existing listeners - const fallbackToggle = document.getElementById('fallbackToggle'); - if (fallbackToggle) { - fallbackToggle.addEventListener('change', () => { - saveConfig(); - updateAccountSelectors(); - }); - } - - const accountSelects = ['spotifyAccountSelect', 'deezerAccountSelect']; - accountSelects.forEach(id => { - const element = document.getElementById(id); - if (element) { - element.addEventListener('change', () => { - saveConfig(); - updateAccountSelectors(); - }); - } - }); - - const spotifyQuality = document.getElementById('spotifyQualitySelect'); - if (spotifyQuality) { - spotifyQuality.addEventListener('change', saveConfig); - } - - const deezerQuality = document.getElementById('deezerQualitySelect'); - if (deezerQuality) { - deezerQuality.addEventListener('change', saveConfig); - } - - // Load existing PRG files after initial setup - await loadExistingPrgFiles(); -} - -async function updateAccountSelectors() { - try { - // Get current saved configuration - const saved = JSON.parse(localStorage.getItem('activeConfig')) || {}; - - // Fetch available credentials - const [spotifyResponse, deezerResponse] = await Promise.all([ - fetch('/api/credentials/spotify'), - fetch('/api/credentials/deezer') - ]); - - const spotifyAccounts = await spotifyResponse.json(); - const deezerAccounts = await deezerResponse.json(); - - // Update Spotify selector - const spotifySelect = document.getElementById('spotifyAccountSelect'); - const isValidSpotify = spotifyAccounts.includes(saved.spotify); - spotifySelect.innerHTML = spotifyAccounts.map(a => - `` - ).join(''); - - // Validate/correct Spotify selection - if (!isValidSpotify && spotifyAccounts.length > 0) { - spotifySelect.value = spotifyAccounts[0]; - saved.spotify = spotifyAccounts[0]; - localStorage.setItem('activeConfig', JSON.stringify(saved)); - } - - // Update Deezer selector - const deezerSelect = document.getElementById('deezerAccountSelect'); - const isValidDeezer = deezerAccounts.includes(saved.deezer); - deezerSelect.innerHTML = deezerAccounts.map(a => - `` - ).join(''); - - // Validate/correct Deezer selection - if (!isValidDeezer && deezerAccounts.length > 0) { - deezerSelect.value = deezerAccounts[0]; - saved.deezer = deezerAccounts[0]; - localStorage.setItem('activeConfig', JSON.stringify(saved)); - } - - // Handle empty states - [spotifySelect, deezerSelect].forEach((select, index) => { - const accounts = index === 0 ? spotifyAccounts : deezerAccounts; - if (accounts.length === 0) { - select.innerHTML = ''; - select.value = ''; - } - }); - - } catch (error) { - console.error('Error updating account selectors:', error); - } -} - - -function toggleDownloadQueue() { - const queueSidebar = document.getElementById('downloadQueue'); - queueSidebar.classList.toggle('active'); -} - -function performSearch() { - const query = document.getElementById('searchInput').value.trim(); - const searchType = document.getElementById('searchType').value; - const resultsContainer = document.getElementById('resultsContainer'); - - if (!query) { - showError('Please enter a search term'); - return; - } - - // Handle direct Spotify URLs for tracks, albums, playlists, and artists - if (isSpotifyUrl(query)) { - try { - const type = getResourceTypeFromUrl(query); - const supportedTypes = ['track', 'album', 'playlist', 'artist']; - if (!supportedTypes.includes(type)) { - throw new Error('Unsupported URL type'); - } - - const item = { - name: `Direct URL (${type})`, - external_urls: { spotify: query } - }; - - // For artist URLs, download all album types by default - const albumType = type === 'artist' ? 'album,single,compilation' : undefined; - startDownload(query, type, item, albumType); - document.getElementById('searchInput').value = ''; - return; - - } catch (error) { - showError(`Invalid Spotify URL: ${error.message}`); - return; - } - } - - // Standard search - resultsContainer.innerHTML = '
Searching...
'; - - fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=50`) - .then(response => response.json()) - .then(data => { - if (data.error) throw new Error(data.error); - const items = data.data[`${searchType}s`]?.items; - - if (!items || !items.length) { - resultsContainer.innerHTML = '
No results found
'; - return; - } - - resultsContainer.innerHTML = items.map(item => createResultCard(item, searchType)).join(''); - - // Attach event listeners for every download button in each card - const cards = resultsContainer.querySelectorAll('.result-card'); - cards.forEach((card, index) => { - card.querySelectorAll('.download-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const url = e.currentTarget.dataset.url; - const type = e.currentTarget.dataset.type; - const albumType = e.currentTarget.dataset.albumType; - - // Check if the clicked button is the main download button - const isMainButton = e.currentTarget.classList.contains('main-download'); - - if (isMainButton) { - // Remove the entire card for main download button - card.remove(); - } else { - // Only remove the clicked specific button - e.currentTarget.remove(); - } - - startDownload(url, type, items[index], albumType); - }); - }); - }); - }) - .catch(error => showError(error.message)); -} - -function createResultCard(item, type) { - let imageUrl, title, subtitle, details; - - switch(type) { - case 'track': - imageUrl = item.album.images[0]?.url || ''; - title = item.name; - subtitle = item.artists.map(a => a.name).join(', '); - details = ` - ${item.album.name} - ${msToMinutesSeconds(item.duration_ms)} - `; - return ` -
- ${type} cover -
${title}
-
${subtitle}
-
${details}
- -
- `; - case 'playlist': - imageUrl = item.images[0]?.url || ''; - title = item.name; - subtitle = item.owner.display_name; - details = ` - ${item.tracks.total} tracks - ${item.description || 'No description'} - `; - return ` -
- ${type} cover -
${title}
-
${subtitle}
-
${details}
- -
- `; - case 'album': - imageUrl = item.images[0]?.url || ''; - title = item.name; - subtitle = item.artists.map(a => a.name).join(', '); - details = ` - ${item.release_date} - ${item.total_tracks} tracks - `; - return ` -
- ${type} cover -
${title}
-
${subtitle}
-
${details}
- -
- `; - case 'artist': - imageUrl = item.images && item.images.length ? item.images[0].url : ''; - title = item.name; - subtitle = item.genres && item.genres.length ? item.genres.join(', ') : 'Unknown genres'; - details = `Followers: ${item.followers?.total || 'N/A'}`; - return ` -
- ${type} cover -
${title}
-
${subtitle}
-
${details}
-
- - - - -
- - -
- - - - - -
-
-
-
- `; - default: - title = item.name || 'Unknown'; - subtitle = ''; - details = ''; - return ` -
-
${title}
-
${subtitle}
-
${details}
- -
- `; - } -} - -async function startDownload(url, type, item, albumType) { - const fallbackEnabled = document.getElementById('fallbackToggle').checked; - const spotifyAccount = document.getElementById('spotifyAccountSelect').value; - const deezerAccount = document.getElementById('deezerAccountSelect').value; - - // Determine service from URL - let service; - if (url.includes('open.spotify.com')) { - service = 'spotify'; - } else if (url.includes('deezer.com')) { - service = 'deezer'; - } else { - showError('Unsupported service URL'); - return; - } - - let apiUrl = ''; - if (type === 'artist') { - // Build the API URL for artist downloads. - // Use albumType if provided; otherwise, default to "compilation" (or you could default to "album,single,compilation") - const albumParam = albumType || 'compilation'; - apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumParam)}`; - } else { - apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; - } - - // Get quality settings - const spotifyQuality = document.getElementById('spotifyQualitySelect').value; - const deezerQuality = document.getElementById('deezerQualitySelect').value; - - if (fallbackEnabled && service === 'spotify') { - // Deezer fallback for Spotify URLs - apiUrl += `&main=${deezerAccount}&fallback=${spotifyAccount}`; - apiUrl += `&quality=${encodeURIComponent(deezerQuality)}`; - apiUrl += `&fall_quality=${encodeURIComponent(spotifyQuality)}`; - } else { - // Standard download without fallback - const mainAccount = service === 'spotify' ? spotifyAccount : deezerAccount; - apiUrl += `&main=${mainAccount}`; - apiUrl += `&quality=${encodeURIComponent(service === 'spotify' ? spotifyQuality : deezerQuality)}`; - } - - // New: append real_time parameter if Real time downloading is enabled - const realTimeEnabled = document.getElementById('realTimeToggle').checked; - if (realTimeEnabled) { - apiUrl += `&real_time=true`; - } - - try { - const response = await fetch(apiUrl); - const data = await response.json(); - addToQueue(item, type, data.prg_file); - } catch (error) { - showError('Download failed: ' + error.message); - } -} - -function addToQueue(item, type, prgFile) { - const queueId = Date.now().toString() + Math.random().toString(36).substr(2, 9); - const entry = { - item, - type, - prgFile, - element: createQueueItem(item, type, prgFile, queueId), - lastStatus: null, - lastUpdated: Date.now(), - hasEnded: false, - intervalId: null, - uniqueId: queueId // Add unique identifier - }; - - downloadQueue[queueId] = entry; - document.getElementById('queueItems').appendChild(entry.element); - startEntryMonitoring(queueId); -} - -async function startEntryMonitoring(queueId) { - const entry = downloadQueue[queueId]; - if (!entry || entry.hasEnded) return; - - entry.intervalId = setInterval(async () => { - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); - if (entry.hasEnded) { - clearInterval(entry.intervalId); - return; - } - - try { - const response = await fetch(`/api/prgs/${entry.prgFile}`); - const data = await response.json(); - // data contains: { type, name, last_line } - const progress = data.last_line; - - if (entry.type !== 'track' && progress?.type === 'track') { - return; // Skip track-type messages for non-track downloads - } - // If there is no progress data, handle as inactivity. - if (!progress) { - handleInactivity(entry, queueId, logElement); - return; - } - - // Check for unchanged status to handle inactivity. - if (JSON.stringify(entry.lastStatus) === JSON.stringify(progress)) { - handleInactivity(entry, queueId, logElement); - return; - } - - // Update entry state and log. - entry.lastStatus = progress; - entry.lastUpdated = Date.now(); - entry.status = progress.status; - logElement.textContent = getStatusMessage(progress); - - // Handle terminal states. - if (progress.status === 'error' || progress.status === 'complete' || progress.status === 'cancel') { - handleTerminalState(entry, queueId, progress); - } - } catch (error) { - console.error('Status check failed:', error); - handleTerminalState(entry, queueId, { - status: 'error', - message: 'Status check error' - }); - } - }, 2000); -} - - - -function handleInactivity(entry, queueId, logElement) { - // Check if real time downloading is enabled - const realTimeEnabled = document.getElementById('realTimeToggle')?.checked; - if (realTimeEnabled) { - // Do nothing if real time downloading is enabled (no timeout) - return; - } - // Only trigger timeout if more than 3 minutes (180000 ms) of inactivity - if (Date.now() - entry.lastUpdated > 180000) { - logElement.textContent = 'Download timed out (3 minutes inactivity)'; - handleTerminalState(entry, queueId, { status: 'timeout' }); - } -} - -// Update the handleTerminalState function to handle 'cancel' status: -function handleTerminalState(entry, queueId, data) { - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); - - entry.hasEnded = true; - entry.status = data.status; - - if (data.status === 'error') { - logElement.innerHTML = ` - ${getStatusMessage(data)} - - - `; - - logElement.querySelector('.retry-btn').addEventListener('click', () => { - startDownload(entry.item.external_urls.spotify, entry.type, entry.item); - cleanupEntry(queueId); - }); - - logElement.querySelector('.close-btn').addEventListener('click', () => { - cleanupEntry(queueId); - }); - - entry.element.classList.add('failed'); - } else if (data.status === 'cancel') { - logElement.textContent = 'Download cancelled by user'; - setTimeout(() => cleanupEntry(queueId), 5000); - } else if (data.status === 'complete') { - setTimeout(() => cleanupEntry(queueId), 5000); - } - - clearInterval(entry.intervalId); -} - -function cleanupEntry(queueId) { - const entry = downloadQueue[queueId]; - if (entry) { - clearInterval(entry.intervalId); - entry.element.remove(); - const prgFile = entry.prgFile; - delete downloadQueue[queueId]; - // Send delete request for the PRG file - fetch(`/api/prgs/delete/${encodeURIComponent(prgFile)}`, { method: 'DELETE' }) - .catch(err => console.error('Error deleting PRG file:', err)); - } -} - -async function loadExistingPrgFiles() { - try { - const response = await fetch('/api/prgs/list'); - if (!response.ok) throw new Error('Failed to fetch PRG files'); - const prgFiles = await response.json(); - for (const prgFile of prgFiles) { - try { - const prgResponse = await fetch(`/api/prgs/${prgFile}`); - const prgData = await prgResponse.json(); - // If name is empty, fallback to using the prgFile as title. - const title = prgData.name || prgFile; - const type = prgData.type || "unknown"; - const dummyItem = { - name: title, - external_urls: {} // You can expand this if needed. - }; - addToQueue(dummyItem, type, prgFile); - } catch (innerError) { - console.error('Error processing PRG file', prgFile, ':', innerError); - } - } - } catch (error) { - console.error('Error loading existing PRG files:', error); - } -} - -function createQueueItem(item, type, prgFile, queueId) { - const div = document.createElement('div'); - div.className = 'queue-item'; - div.innerHTML = ` -
${item.name}
-
${type.charAt(0).toUpperCase() + type.slice(1)}
-
Initializing download...
- - `; - - // Attach cancel event listener - const cancelBtn = div.querySelector('.cancel-btn'); - cancelBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - // Hide the cancel button immediately so the user can’t click it again. - cancelBtn.style.display = 'none'; - - const prg = e.target.closest('button').dataset.prg; - const type = e.target.closest('button').dataset.type; - const queueId = e.target.closest('button').dataset.queueid; - // Determine the correct cancel endpoint based on the type. - // For example: `/api/album/download/cancel`, `/api/playlist/download/cancel`, `/api/track/download/cancel`, or `/api/artist/download/cancel` - const cancelEndpoint = `/api/${type}/download/cancel?prg_file=${encodeURIComponent(prg)}`; - try { - const response = await fetch(cancelEndpoint); - const data = await response.json(); - if (data.status === "cancel") { - const logElement = document.getElementById(`log-${queueId}-${prg}`); - logElement.textContent = "Download cancelled"; - // Mark the entry as ended and clear its monitoring interval. - const entry = downloadQueue[queueId]; - if (entry) { - entry.hasEnded = true; - clearInterval(entry.intervalId); - } - // Remove the queue item after 5 seconds, same as when a download finishes. - setTimeout(() => cleanupEntry(queueId), 5000); - } else { - alert("Cancel error: " + (data.error || "Unknown error")); - } - } catch (error) { - alert("Cancel error: " + error.message); - } - }); - - return div; -} - - - - -async function loadCredentials(service) { - try { - const response = await fetch(`/api/credentials/${service}`); - renderCredentialsList(service, await response.json()); - } catch (error) { - showSidebarError(error.message); - } -} - -function renderCredentialsList(service, credentials) { - const list = document.querySelector('.credentials-list'); - list.innerHTML = credentials.map(name => ` -
- ${name} -
- - -
-
- `).join(''); - - list.querySelectorAll('.delete-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - try { - const service = e.target.dataset.service; - const name = e.target.dataset.name; - - if (!service || !name) { - throw new Error('Missing credential information'); - } - - const response = await fetch(`/api/credentials/${service}/${name}`, { - method: 'DELETE' - }); - - if (!response.ok) { - throw new Error('Failed to delete credential'); - } - - // Update active account if deleted credential was selected - const accountSelect = document.getElementById(`${service}AccountSelect`); - if (accountSelect.value === name) { - accountSelect.value = ''; - saveConfig(); - } - - // Refresh UI - loadCredentials(service); - await updateAccountSelectors(); - - } catch (error) { - showSidebarError(error.message); - console.error('Delete error:', error); - } - }); - }); - - list.querySelectorAll('.edit-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { - const service = e.target.dataset.service; - const name = e.target.dataset.name; - - try { - // Switch to correct service tab - document.querySelector(`[data-service="${service}"]`).click(); - await new Promise(resolve => setTimeout(resolve, 50)); - - // Load credential data - const response = await fetch(`/api/credentials/${service}/${name}`); - const data = await response.json(); - - currentCredential = name; - document.getElementById('credentialName').value = name; - document.getElementById('credentialName').disabled = true; - populateFormFields(service, data); - } catch (error) { - showSidebarError(error.message); - } - }); - }); -} - -function updateFormFields() { - const serviceFields = document.getElementById('serviceFields'); - serviceFields.innerHTML = serviceConfig[currentService].fields.map(field => ` -
- - -
- `).join(''); -} - -function populateFormFields(service, data) { - serviceConfig[service].fields.forEach(field => { - const element = document.getElementById(field.id); - if (element) element.value = data[field.id] || ''; - }); -} - -async function handleCredentialSubmit(e) { - e.preventDefault(); - const service = document.querySelector('.tab-button.active').dataset.service; - const nameInput = document.getElementById('credentialName'); - const name = nameInput.value.trim(); - - try { - if (!currentCredential && !name) { - throw new Error('Credential name is required'); - } - - const formData = {}; - serviceConfig[service].fields.forEach(field => { - formData[field.id] = document.getElementById(field.id).value.trim(); - }); - - const data = serviceConfig[service].validator(formData); - const endpointName = currentCredential || name; - const method = currentCredential ? 'PUT' : 'POST'; - - const response = await fetch(`/api/credentials/${service}/${endpointName}`, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to save credentials'); - } - - // Refresh and persist after credential changes - await updateAccountSelectors(); - saveConfig(); - loadCredentials(service); - resetForm(); - - } catch (error) { - showSidebarError(error.message); - console.error('Submission error:', error); - } -} - - -function resetForm() { - currentCredential = null; - const nameInput = document.getElementById('credentialName'); - nameInput.value = ''; - nameInput.disabled = false; - document.getElementById('credentialForm').reset(); -} - - -// Helper functions -function msToMinutesSeconds(ms) { - const minutes = Math.floor(ms / 60000); - const seconds = ((ms % 60000) / 1000).toFixed(0); - return `${minutes}:${seconds.padStart(2, '0')}`; -} - -function showError(message) { - document.getElementById('resultsContainer').innerHTML = `
${message}
`; -} - -function showSidebarError(message) { - const errorDiv = document.getElementById('sidebarError'); - errorDiv.textContent = message; - setTimeout(() => errorDiv.textContent = '', 3000); -} - -function getStatusMessage(data) { - switch (data.status) { - case 'downloading': - // For track downloads only. - if (data.type === 'track') { - return `Downloading track "${data.song}" by ${data.artist}...`; - } - return `Downloading ${data.type}...`; - - case 'initializing': - if (data.type === 'playlist') { - return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`; - } else if (data.type === 'album') { - return `Initializing album download "${data.album}" by ${data.artist}...`; - } else if (data.type === 'artist') { - return `Initializing artist download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`; - } - return `Initializing ${data.type} download...`; - - case 'progress': - // Expect progress messages for playlists, albums (or artist’s albums) to include a "track" and "current_track". - if (data.track && data.current_track) { - // current_track is a string in the format "current/total" - const parts = data.current_track.split('/'); - const current = parts[0]; - const total = parts[1] || '?'; - - if (data.type === 'playlist') { - return `Downloading playlist: Track ${current} of ${total} - ${data.track}`; - } else if (data.type === 'album') { - // For album progress, the "album" and "artist" fields may be available on a done message. - // In some cases (like artist downloads) only track info is passed. - if (data.album && data.artist) { - return `Downloading album "${data.album}" by ${data.artist}: track ${current} of ${total} - ${data.track}`; - } else { - return `Downloading track ${current} of ${total}: ${data.track} from ${data.album}`; - } - } - } - // Fallback if fields are missing: - return `Progress: ${data.status}...`; - - case 'done': - if (data.type === 'track') { - return `Finished track "${data.song}" by ${data.artist}`; - } else if (data.type === 'playlist') { - return `Finished playlist "${data.name}" with ${data.total_tracks} tracks`; - } else if (data.type === 'album') { - return `Finished album "${data.album}" by ${data.artist}`; - } else if (data.type === 'artist') { - return `Finished artist "${data.artist}" (${data.album_type})`; - } - return `Finished ${data.type}`; - - case 'retrying': - return `Track "${data.song}" by ${data.artist}" failed, retrying (${data.retry_count}/10) in ${data.seconds_left}s`; - - case 'error': - return `Error: ${data.message || 'Unknown error'}`; - - case 'complete': - return 'Download completed successfully'; - - case 'skipped': - return `Track "${data.song}" skipped, it already exists!`; - - case 'real_time': { - // Convert milliseconds to minutes and seconds. - const totalMs = data.time_elapsed; - const minutes = Math.floor(totalMs / 60000); - const seconds = Math.floor((totalMs % 60000) / 1000); - const paddedSeconds = seconds < 10 ? '0' + seconds : seconds; - return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`; - } - - default: - return data.status; - } - } - - -function saveConfig() { - const config = { - spotify: document.getElementById('spotifyAccountSelect').value, - deezer: document.getElementById('deezerAccountSelect').value, - fallback: document.getElementById('fallbackToggle').checked, - spotifyQuality: document.getElementById('spotifyQualitySelect').value, - deezerQuality: document.getElementById('deezerQualitySelect').value, - realTime: document.getElementById('realTimeToggle').checked // new property - }; - localStorage.setItem('activeConfig', JSON.stringify(config)); -} - -function loadConfig() { - const saved = JSON.parse(localStorage.getItem('activeConfig')) || {}; - - // Account selects - const spotifySelect = document.getElementById('spotifyAccountSelect'); - if (spotifySelect) spotifySelect.value = saved.spotify || ''; - - const deezerSelect = document.getElementById('deezerAccountSelect'); - if (deezerSelect) deezerSelect.value = saved.deezer || ''; - - // Fallback toggle - const fallbackToggle = document.getElementById('fallbackToggle'); - if (fallbackToggle) fallbackToggle.checked = !!saved.fallback; - - // Quality selects - const spotifyQuality = document.getElementById('spotifyQualitySelect'); - if (spotifyQuality) spotifyQuality.value = saved.spotifyQuality || 'NORMAL'; - - const deezerQuality = document.getElementById('deezerQualitySelect'); - if (deezerQuality) deezerQuality.value = saved.deezerQuality || 'MP3_128'; - - // New: Real time downloading toggle - const realTimeToggle = document.getElementById('realTimeToggle'); - if (realTimeToggle) realTimeToggle.checked = !!saved.realTime; -} - -function isSpotifyUrl(url) { - return url.startsWith('https://open.spotify.com/'); -} - -function getResourceTypeFromUrl(url) { - const pathParts = new URL(url).pathname.split('/'); - return pathParts[1]; // Returns 'track', 'album', 'playlist', or 'artist' -} diff --git a/static/js/config.js b/static/js/config.js new file mode 100644 index 0000000..74b65a9 --- /dev/null +++ b/static/js/config.js @@ -0,0 +1,301 @@ +import { downloadQueue } from './queue.js'; + +const serviceConfig = { + spotify: { + fields: [ + { id: 'username', label: 'Username', type: 'text' }, + { id: 'credentials', label: 'Credentials', type: 'text' } + ], + validator: (data) => ({ + username: data.username, + credentials: data.credentials + }) + }, + deezer: { + fields: [ + { id: 'arl', label: 'ARL', type: 'text' } + ], + validator: (data) => ({ + arl: data.arl + }) + } +}; + +let currentService = 'spotify'; +let currentCredential = null; + +document.addEventListener('DOMContentLoaded', () => { + initConfig(); + setupServiceTabs(); + setupEventListeners(); + + // Attach click listener for the queue icon to toggle the download queue sidebar. + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); + }); + } +}); + +function initConfig() { + loadConfig(); + updateAccountSelectors(); + loadCredentials(currentService); + updateFormFields(); +} + +function setupServiceTabs() { + const serviceTabs = document.querySelectorAll('.tab-button'); + serviceTabs.forEach(tab => { + tab.addEventListener('click', () => { + serviceTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + currentService = tab.dataset.service; + loadCredentials(currentService); + updateFormFields(); + }); + }); +} + +function setupEventListeners() { + document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit); + + // Config change listeners + document.getElementById('fallbackToggle').addEventListener('change', saveConfig); + document.getElementById('realTimeToggle').addEventListener('change', saveConfig); + document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig); + document.getElementById('deezerQualitySelect').addEventListener('change', saveConfig); + + // Account select changes + document.getElementById('spotifyAccountSelect').addEventListener('change', saveConfig); + document.getElementById('deezerAccountSelect').addEventListener('change', saveConfig); +} + +async function updateAccountSelectors() { + try { + const saved = JSON.parse(localStorage.getItem('activeConfig')) || {}; + + const [spotifyResponse, deezerResponse] = await Promise.all([ + fetch('/api/credentials/spotify'), + fetch('/api/credentials/deezer') + ]); + + const spotifyAccounts = await spotifyResponse.json(); + const deezerAccounts = await deezerResponse.json(); + + // Update Spotify selector + const spotifySelect = document.getElementById('spotifyAccountSelect'); + const isValidSpotify = spotifyAccounts.includes(saved.spotify); + spotifySelect.innerHTML = spotifyAccounts.map(a => + `` + ).join(''); + + if (!isValidSpotify && spotifyAccounts.length > 0) { + spotifySelect.value = spotifyAccounts[0]; + saved.spotify = spotifyAccounts[0]; + localStorage.setItem('activeConfig', JSON.stringify(saved)); + } + + // Update Deezer selector + const deezerSelect = document.getElementById('deezerAccountSelect'); + const isValidDeezer = deezerAccounts.includes(saved.deezer); + deezerSelect.innerHTML = deezerAccounts.map(a => + `` + ).join(''); + + if (!isValidDeezer && deezerAccounts.length > 0) { + deezerSelect.value = deezerAccounts[0]; + saved.deezer = deezerAccounts[0]; + localStorage.setItem('activeConfig', JSON.stringify(saved)); + } + + [spotifySelect, deezerSelect].forEach((select, index) => { + const accounts = index === 0 ? spotifyAccounts : deezerAccounts; + if (accounts.length === 0) { + select.innerHTML = ''; + select.value = ''; + } + }); + } catch (error) { + showConfigError('Error updating accounts: ' + error.message); + } +} + +async function loadCredentials(service) { + try { + const response = await fetch(`/api/credentials/${service}`); + renderCredentialsList(service, await response.json()); + } catch (error) { + showConfigError(error.message); + } +} + +function renderCredentialsList(service, credentials) { + const list = document.querySelector('.credentials-list'); + list.innerHTML = credentials.map(name => ` +
+ ${name} +
+ + +
+
+ `).join(''); + + list.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', handleDeleteCredential); + }); + + list.querySelectorAll('.edit-btn').forEach(btn => { + btn.addEventListener('click', handleEditCredential); + }); +} + +async function handleDeleteCredential(e) { + try { + const service = e.target.dataset.service; + const name = e.target.dataset.name; + + if (!service || !name) { + throw new Error('Missing credential information'); + } + + const response = await fetch(`/api/credentials/${service}/${name}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error('Failed to delete credential'); + } + + const accountSelect = document.getElementById(`${service}AccountSelect`); + if (accountSelect.value === name) { + accountSelect.value = ''; + saveConfig(); + } + + loadCredentials(service); + await updateAccountSelectors(); + } catch (error) { + showConfigError(error.message); + } +} + +async function handleEditCredential(e) { + const service = e.target.dataset.service; + const name = e.target.dataset.name; + + try { + // Switch to the appropriate service tab + document.querySelector(`[data-service="${service}"]`).click(); + await new Promise(resolve => setTimeout(resolve, 50)); + + const response = await fetch(`/api/credentials/${service}/${name}`); + const data = await response.json(); + + currentCredential = name; + document.getElementById('credentialName').value = name; + document.getElementById('credentialName').disabled = true; + populateFormFields(service, data); + } catch (error) { + showConfigError(error.message); + } +} + +function updateFormFields() { + const serviceFields = document.getElementById('serviceFields'); + serviceFields.innerHTML = serviceConfig[currentService].fields.map(field => ` +
+ + +
+ `).join(''); +} + +function populateFormFields(service, data) { + serviceConfig[service].fields.forEach(field => { + const element = document.getElementById(field.id); + if (element) element.value = data[field.id] || ''; + }); +} + +async function handleCredentialSubmit(e) { + e.preventDefault(); + const service = document.querySelector('.tab-button.active').dataset.service; + const nameInput = document.getElementById('credentialName'); + const name = nameInput.value.trim(); + + try { + if (!currentCredential && !name) { + throw new Error('Credential name is required'); + } + + const formData = {}; + serviceConfig[service].fields.forEach(field => { + formData[field.id] = document.getElementById(field.id).value.trim(); + }); + + const data = serviceConfig[service].validator(formData); + const endpointName = currentCredential || name; + const method = currentCredential ? 'PUT' : 'POST'; + + const response = await fetch(`/api/credentials/${service}/${endpointName}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save credentials'); + } + + await updateAccountSelectors(); + saveConfig(); + loadCredentials(service); + resetForm(); + } catch (error) { + showConfigError(error.message); + } +} + +function resetForm() { + currentCredential = null; + const nameInput = document.getElementById('credentialName'); + nameInput.value = ''; + nameInput.disabled = false; + document.getElementById('credentialForm').reset(); +} + +function saveConfig() { + const config = { + spotify: document.getElementById('spotifyAccountSelect').value, + deezer: document.getElementById('deezerAccountSelect').value, + fallback: document.getElementById('fallbackToggle').checked, + spotifyQuality: document.getElementById('spotifyQualitySelect').value, + deezerQuality: document.getElementById('deezerQualitySelect').value, + realTime: document.getElementById('realTimeToggle').checked + }; + localStorage.setItem('activeConfig', JSON.stringify(config)); +} + +function loadConfig() { + const saved = JSON.parse(localStorage.getItem('activeConfig')) || {}; + document.getElementById('spotifyAccountSelect').value = saved.spotify || ''; + document.getElementById('deezerAccountSelect').value = saved.deezer || ''; + document.getElementById('fallbackToggle').checked = !!saved.fallback; + document.getElementById('spotifyQualitySelect').value = saved.spotifyQuality || 'NORMAL'; + document.getElementById('deezerQualitySelect').value = saved.deezerQuality || 'MP3_128'; + document.getElementById('realTimeToggle').checked = !!saved.realTime; +} + +function showConfigError(message) { + const errorDiv = document.getElementById('configError'); + errorDiv.textContent = message; + setTimeout(() => errorDiv.textContent = '', 3000); +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..744cf52 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,303 @@ +// main.js +import { downloadQueue } from './queue.js'; + +document.addEventListener('DOMContentLoaded', () => { + const searchButton = document.getElementById('searchButton'); + const searchInput = document.getElementById('searchInput'); + const queueIcon = document.getElementById('queueIcon'); + + // Initialize queue icon + queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility()); + + // Search functionality + searchButton.addEventListener('click', performSearch); + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') performSearch(); + }); +}); + +async function performSearch() { + const query = document.getElementById('searchInput').value.trim(); + const searchType = document.getElementById('searchType').value; + const resultsContainer = document.getElementById('resultsContainer'); + + if (!query) { + showError('Please enter a search term'); + return; + } + + if (isSpotifyUrl(query)) { + try { + const type = getResourceTypeFromUrl(query); + const supportedTypes = ['track', 'album', 'playlist', 'artist']; + if (!supportedTypes.includes(type)) throw new Error('Unsupported URL type'); + + const item = { name: `Direct URL (${type})`, external_urls: { spotify: query } }; + startDownload(query, type, item, type === 'artist' ? 'album,single,compilation' : undefined); + document.getElementById('searchInput').value = ''; + return; + } catch (error) { + showError(`Invalid Spotify URL: ${error.message}`); + return; + } + } + + resultsContainer.innerHTML = '
Searching...
'; + + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=50`); + const data = await response.json(); + if (data.error) throw new Error(data.error); + + const items = data.data[`${searchType}s`]?.items; + if (!items?.length) { + resultsContainer.innerHTML = '
No results found
'; + return; + } + + resultsContainer.innerHTML = items.map(item => createResultCard(item, searchType)).join(''); + attachDownloadListeners(items); + } catch (error) { + showError(error.message); + } +} + +function attachDownloadListeners(items) { + document.querySelectorAll('.download-btn').forEach((btn, index) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const url = e.currentTarget.dataset.url; + const type = e.currentTarget.dataset.type; + const albumType = e.currentTarget.dataset.albumType; + + if (e.currentTarget.classList.contains('main-download')) { + e.currentTarget.closest('.result-card').remove(); + } else { + e.currentTarget.remove(); + } + + startDownload(url, type, items[index], albumType); + }); + }); +} + +async function startDownload(url, type, item, albumType) { + const config = JSON.parse(localStorage.getItem('activeConfig')) || {}; + const { + fallback = false, + spotify = '', + deezer = '', + spotifyQuality = 'NORMAL', + deezerQuality = 'MP3_128', + realTime = false + } = config; + + let service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; + let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; + + if (type === 'artist') { + apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`; + } + + if (fallback && service === 'spotify') { + apiUrl += `&main=${deezer}&fallback=${spotify}`; + apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`; + } else { + const mainAccount = service === 'spotify' ? spotify : deezer; + apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`; + } + + if (realTime) apiUrl += '&real_time=true'; + + try { + const response = await fetch(apiUrl); + const data = await response.json(); + downloadQueue.addDownload(item, type, data.prg_file); + } catch (error) { + showError('Download failed: ' + error.message); + } +} + +// UI Helper Functions +function showError(message) { + document.getElementById('resultsContainer').innerHTML = `
${message}
`; +} + +function isSpotifyUrl(url) { + return url.startsWith('https://open.spotify.com/'); +} + +function getResourceTypeFromUrl(url) { + const pathParts = new URL(url).pathname.split('/'); + return pathParts[1]; +} + +function msToMinutesSeconds(ms) { + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(0); + return `${minutes}:${seconds.padStart(2, '0')}`; +} + +function createResultCard(item, type) { + let imageUrl, title, subtitle, details; + + switch(type) { + case 'track': + imageUrl = item.album.images[0]?.url || ''; + title = item.name; + subtitle = item.artists.map(a => a.name).join(', '); + details = ` + ${item.album.name} + ${msToMinutesSeconds(item.duration_ms)} + `; + return ` +
+
+ ${type} cover +
+
${title}
+
${subtitle}
+
${details}
+ +
+ `; + case 'playlist': + imageUrl = item.images[0]?.url || ''; + title = item.name; + subtitle = item.owner.display_name; + details = ` + ${item.tracks.total} tracks + ${item.description || 'No description'} + `; + return ` +
+
+ ${type} cover +
+
${title}
+
${subtitle}
+
${details}
+ +
+ `; + case 'album': + imageUrl = item.images[0]?.url || ''; + title = item.name; + subtitle = item.artists.map(a => a.name).join(', '); + details = ` + ${item.release_date} + ${item.total_tracks} tracks + `; + return ` +
+
+ ${type} cover +
+
${title}
+
${subtitle}
+
${details}
+ +
+ `; + case 'artist': + imageUrl = item.images && item.images.length ? item.images[0].url : ''; + title = item.name; + subtitle = item.genres && item.genres.length ? item.genres.join(', ') : 'Unknown genres'; + details = `Followers: ${item.followers?.total || 'N/A'}`; + return ` +
+
+ ${type} cover +
+
${title}
+
${subtitle}
+
${details}
+
+ + + + +
+ + +
+ + + + + +
+
+
+
+ `; + default: + title = item.name || 'Unknown'; + subtitle = ''; + details = ''; + return ` +
+
+ ${type} cover +
+
${title}
+
${subtitle}
+
${details}
+ +
+ `; + } +} diff --git a/static/js/queue.js b/static/js/queue.js new file mode 100644 index 0000000..cc5b5e9 --- /dev/null +++ b/static/js/queue.js @@ -0,0 +1,315 @@ +// queue.js +class DownloadQueue { + constructor() { + this.downloadQueue = {}; + this.prgInterval = null; + this.initDOM(); + this.initEventListeners(); + this.loadExistingPrgFiles(); + } + + /* DOM Management */ + initDOM() { + const queueHTML = ` + + `; + document.body.insertAdjacentHTML('beforeend', queueHTML); + } + + /* Event Handling */ + initEventListeners() { + // Escape key handler + document.addEventListener('keydown', (e) => { + const queueSidebar = document.getElementById('downloadQueue'); + if (e.key === 'Escape' && queueSidebar.classList.contains('active')) { + this.toggleVisibility(); + } + }); + + // Close button handler + document.getElementById('downloadQueue').addEventListener('click', (e) => { + if (e.target.closest('.close-btn')) { + this.toggleVisibility(); + } + }); + } + + /* Public API */ + toggleVisibility() { + const queueSidebar = document.getElementById('downloadQueue'); + queueSidebar.classList.toggle('active'); + queueSidebar.hidden = !queueSidebar.classList.contains('active'); + this.dispatchEvent('queueVisibilityChanged', { visible: queueSidebar.classList.contains('active') }); + } + + addDownload(item, type, prgFile) { + const queueId = this.generateQueueId(); + const entry = this.createQueueEntry(item, type, prgFile, queueId); + + this.downloadQueue[queueId] = entry; + document.getElementById('queueItems').appendChild(entry.element); + this.startEntryMonitoring(queueId); + this.dispatchEvent('downloadAdded', { queueId, item, type }); + } + + /* Core Functionality */ + async startEntryMonitoring(queueId) { + const entry = this.downloadQueue[queueId]; + if (!entry || entry.hasEnded) return; + + entry.intervalId = setInterval(async () => { + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + if (entry.hasEnded) { + clearInterval(entry.intervalId); + return; + } + + try { + const response = await fetch(`/api/prgs/${entry.prgFile}`); + const data = await response.json(); + const progress = data.last_line; + + if (!progress) { + this.handleInactivity(entry, queueId, logElement); + return; + } + + if (JSON.stringify(entry.lastStatus) === JSON.stringify(progress)) { + this.handleInactivity(entry, queueId, logElement); + return; + } + + entry.lastStatus = progress; + entry.lastUpdated = Date.now(); + entry.status = progress.status; + logElement.textContent = this.getStatusMessage(progress); + + if (['error', 'complete', 'cancel'].includes(progress.status)) { + this.handleTerminalState(entry, queueId, progress); + } + } catch (error) { + console.error('Status check failed:', error); + this.handleTerminalState(entry, queueId, { + status: 'error', + message: 'Status check error' + }); + } + }, 2000); + } + + /* Helper Methods */ + generateQueueId() { + return Date.now().toString() + Math.random().toString(36).substr(2, 9); + } + + createQueueEntry(item, type, prgFile, queueId) { + return { + item, + type, + prgFile, + element: this.createQueueItem(item, type, prgFile, queueId), + lastStatus: null, + lastUpdated: Date.now(), + hasEnded: false, + intervalId: null, + uniqueId: queueId + }; + } + + createQueueItem(item, type, prgFile, queueId) { + const div = document.createElement('article'); + div.className = 'queue-item'; + div.setAttribute('aria-live', 'polite'); + div.setAttribute('aria-atomic', 'true'); + div.innerHTML = ` +
${item.name}
+
${type.charAt(0).toUpperCase() + type.slice(1)}
+
Initializing download...
+ + `; + + div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e)); + return div; + } + + async handleCancelDownload(e) { + const btn = e.target.closest('button'); + btn.style.display = 'none'; + + const { prg, type, queueid } = btn.dataset; + try { + const response = await fetch(`/api/${type}/download/cancel?prg_file=${encodeURIComponent(prg)}`); + const data = await response.json(); + + if (data.status === "cancel") { + const logElement = document.getElementById(`log-${queueid}-${prg}`); + logElement.textContent = "Download cancelled"; + const entry = this.downloadQueue[queueid]; + if (entry) { + entry.hasEnded = true; + clearInterval(entry.intervalId); + } + setTimeout(() => this.cleanupEntry(queueid), 5000); + } + } catch (error) { + console.error('Cancel error:', error); + } + } + + /* State Management */ + async loadExistingPrgFiles() { + try { + const response = await fetch('/api/prgs/list'); + const prgFiles = await response.json(); + + for (const prgFile of prgFiles) { + const prgResponse = await fetch(`/api/prgs/${prgFile}`); + const prgData = await prgResponse.json(); + const dummyItem = { name: prgData.name || prgFile, external_urls: {} }; + this.addDownload(dummyItem, prgData.type || "unknown", prgFile); + } + } catch (error) { + console.error('Error loading existing PRG files:', error); + } + } + + cleanupEntry(queueId) { + const entry = this.downloadQueue[queueId]; + if (entry) { + clearInterval(entry.intervalId); + entry.element.remove(); + delete this.downloadQueue[queueId]; + fetch(`/api/prgs/delete/${encodeURIComponent(entry.prgFile)}`, { method: 'DELETE' }) + .catch(console.error); + } + } + + /* Event Dispatching */ + dispatchEvent(name, detail) { + document.dispatchEvent(new CustomEvent(name, { detail })); + } + + /* Status Message Handling */ + getStatusMessage(data) { + // Helper function to format an array into a human-readable list without a comma before "and". + function formatList(items) { + if (!items || items.length === 0) return ''; + if (items.length === 1) return items[0]; + if (items.length === 2) return `${items[0]} and ${items[1]}`; + // For three or more items: join all but the last with commas, then " and " the last item. + return items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1]; + } + + // Helper function for a simple pluralization: + function pluralize(word) { + // If the word already ends with an "s", assume it's plural. + return word.endsWith('s') ? word : word + 's'; + } + + switch (data.status) { + case 'downloading': + // For track downloads only. + if (data.type === 'track') { + return `Downloading track "${data.song}" by ${data.artist}...`; + } + return `Downloading ${data.type}...`; + + case 'initializing': + if (data.type === 'playlist') { + return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`; + } else if (data.type === 'album') { + return `Initializing album download "${data.album}" by ${data.artist}...`; + } else if (data.type === 'artist') { + let subsets = []; + // Prefer an explicit subsets array if available. + if (data.subsets && Array.isArray(data.subsets) && data.subsets.length > 0) { + subsets = data.subsets; + } + // Otherwise, if album_type is provided, split it into an array. + else if (data.album_type) { + subsets = data.album_type + .split(',') + .map(item => item.trim()) + .map(item => pluralize(item)); + } + if (subsets.length > 0) { + const subsetsMessage = formatList(subsets); + return `Initializing download for ${data.artist}'s ${subsetsMessage}`; + } + // Fallback message if neither subsets nor album_type are provided. + return `Initializing download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`; + } + return `Initializing ${data.type} download...`; + + case 'progress': + // Expect progress messages for playlists, albums (or artist’s albums) to include a "track" and "current_track". + if (data.track && data.current_track) { + // current_track is a string in the format "current/total" + const parts = data.current_track.split('/'); + const current = parts[0]; + const total = parts[1] || '?'; + + if (data.type === 'playlist') { + return `Downloading playlist: Track ${current} of ${total} - ${data.track}`; + } else if (data.type === 'album') { + // For album progress, the "album" and "artist" fields may be available on a done message. + // In some cases (like artist downloads) only track info is passed. + if (data.album && data.artist) { + return `Downloading album "${data.album}" by ${data.artist}: track ${current} of ${total} - ${data.track}`; + } else { + return `Downloading track ${current} of ${total}: ${data.track} from ${data.album}`; + } + } + } + // Fallback if fields are missing: + return `Progress: ${data.status}...`; + + case 'done': + if (data.type === 'track') { + return `Finished track "${data.song}" by ${data.artist}`; + } else if (data.type === 'playlist') { + return `Finished playlist "${data.name}" with ${data.total_tracks} tracks`; + } else if (data.type === 'album') { + return `Finished album "${data.album}" by ${data.artist}`; + } else if (data.type === 'artist') { + return `Finished artist "${data.artist}" (${data.album_type})`; + } + return `Finished ${data.type}`; + + case 'retrying': + return `Track "${data.song}" by ${data.artist}" failed, retrying (${data.retry_count}/10) in ${data.seconds_left}s`; + + case 'error': + return `Error: ${data.message || 'Unknown error'}`; + + case 'complete': + return 'Download completed successfully'; + + case 'skipped': + return `Track "${data.song}" skipped, it already exists!`; + + case 'real_time': { + // Convert milliseconds to minutes and seconds. + const totalMs = data.time_elapsed; + const minutes = Math.floor(totalMs / 60000); + const seconds = Math.floor((totalMs % 60000) / 1000); + const paddedSeconds = seconds < 10 ? '0' + seconds : seconds; + return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`; + } + + default: + return data.status; + } + } + } + + // Singleton instance + export const downloadQueue = new DownloadQueue(); \ No newline at end of file diff --git a/templates/index.html b/templates/config.html old mode 100755 new mode 100644 similarity index 51% rename from templates/index.html rename to templates/config.html index 253b849..1d7114a --- a/templates/index.html +++ b/templates/config.html @@ -1,20 +1,31 @@ - - - Spotizerr - + + + Configuration - Spotizerr + + -