Compare commits

...

9 Commits

Author SHA1 Message Date
2af6dc9cb2 Merge pull request 'Release/v1' (#1) from Release/v1 into main
Reviewed-on: #1
2026-03-20 23:47:58 +00:00
fc0a3f4288 Fix readme 2026-03-21 00:47:33 +01:00
b4070dc0ba New version 2026-03-21 00:43:49 +01:00
garfi
51b1c274e9 Actualiser README.md 2025-03-10 12:00:37 +00:00
garfi
06a790374c Actualiser README.md 2024-09-07 23:43:20 +00:00
garfi
b021a702e1 Actualiser README.md 2024-09-07 23:43:08 +00:00
garfi
d53233f9b8 Actualiser app.py 2024-09-07 23:35:32 +00:00
garfi
663ba08525 Actualiser README.md 2024-09-07 23:27:01 +00:00
Jules
e571e2e003 1.2.0.0 2024-09-08 01:24:56 +02:00
14 changed files with 1589 additions and 228 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Environment
.env
*.env.local
# Database
*.db
*.sqlite
*.sqlite3
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
# Flask
instance/
.webassets-cache
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

View File

@@ -1,7 +1,17 @@
Petit applicatif pour télécharger depuis des url Spotify à l'aide de Youtube-music Petit applicatif pour télécharger depuis des url Spotify à l'aide de Youtube-music (flac ou mp3)
backend: spotdl, falsk , flask-login backend: spotdl, sacad, falsk, flask-login
Pour le lancer: python3 app.py Run:
Ou avec le run.sh pour modifier le port install requiments python:
```
pip install -r requirements.txt
```
Demo ```
python3 app.py
```
dispo sur 127.0.0.1:5000
Demo
![picture](https://git.zestes.fr/garfi/spotdl-py/raw/tag/1.1.0.0/demo.png))

499
app.py
View File

@@ -1,129 +1,438 @@
from flask import Flask, render_template, request, Response, redirect, url_for, send_file
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
import subprocess
import os import os
import re
import shutil import shutil
import zipfile import subprocess
from io import BytesIO import sys
import threading
from datetime import datetime
from queue import Queue
from dotenv import load_dotenv
from flask import Flask, render_template, request, redirect, url_for, flash, Response, jsonify, abort
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from flask_sqlalchemy import SQLAlchemy
load_dotenv()
app = Flask(__name__) app = Flask(__name__)
app.secret_key = 'your_secret_key' app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'change-me-in-production')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URI', 'sqlite:///users.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SESSION_COOKIE_SECURE'] = True
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
db = SQLAlchemy(app)
# Configuration Flask-Login
login_manager = LoginManager() login_manager = LoginManager()
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = 'login' login_manager.login_view = 'login'
# Utilisateur fictif
class User(UserMixin):
def __init__(self, id):
self.id = id
# Stockage simple d'utilisateurs (en production, utiliser une base de données) class User(db.Model, UserMixin):
users = {'garfi': {'admin': 'password123'}} id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class Download(db.Model):
id = db.Column(db.Integer, primary_key=True)
url = db.Column(db.String(512), nullable=False)
format_type = db.Column(db.String(10), nullable=False)
status = db.Column(db.String(20), default='pending')
progress = db.Column(db.Integer, default=0)
log = db.Column(db.Text, default='')
error = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
completed_at = db.Column(db.DateTime, nullable=True)
process_id = db.Column(db.Integer, nullable=True)
def append_log(self, message):
self.log += f"{datetime.now().strftime('%H:%M:%S')} {message}\n"
download_processes = {}
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
return User(user_id) return User.query.get(int(user_id))
def count_users():
return User.query.count()
@app.cli.command("init-db")
def init_db():
db.create_all()
print("Database initialized.")
@app.cli.command("create-admin")
def create_admin():
if count_users() > 0:
print("Users already exist.")
return
username = input("Username: ")
password = input("Password: ")
user = User(username=username)
user.set_password(password)
db.session.add(user)
db.session.commit()
print(f"Admin user '{username}' created.")
def validate_spotify_url(url):
patterns = [
r'https?://open\.spotify\.com/track/[\w-]+',
r'https?://open\.spotify\.com/album/[\w-]+',
r'https?://open\.spotify\.com/playlist/[\w-]+',
r'https?://spotify:[\w-]+',
]
return any(re.match(pattern, url) for pattern in patterns)
def run_download(download_id, urls, format_type, delete_old, copy_choice):
download = Download.query.get(download_id)
if not download:
return
download.status = 'running'
db.session.commit()
if format_type == 'flac':
download_dir = os.getenv('DOWNLOAD_DIR_FLAC', '/home/jules/Musique/flac')
else:
download_dir = os.getenv('DOWNLOAD_DIR_MP3', '/home/jules/Musique/mp3')
copy_destination = os.getenv('COPY_DESTINATION', '/mnt/data/Musique')
if delete_old and os.path.exists(download_dir):
shutil.rmtree(download_dir)
os.makedirs(download_dir, exist_ok=True)
total_urls = len(urls)
try:
for idx, url in enumerate(urls, 1):
download.append_log(f"[{idx}/{total_urls}] Téléchargement: {url}")
db.session.commit()
spotdl_command = [
'spotdl',
'--output', '{artist}/{album}/{track-number} - {title}.{output-ext}',
'--format', format_type,
url
]
process = subprocess.Popen(
['stdbuf', '-oL'] + spotdl_command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
cwd=download_dir,
bufsize=1
)
download_processes[download_id] = process
download.process_id = process.pid
db.session.commit()
for line in iter(process.stdout.readline, ""):
if download_id not in download_processes:
process.terminate()
break
line = line.strip()
if line:
download.append_log(line)
if 'Downloaded' in line or '100%' in line:
progress = int((idx / total_urls) * 100)
download.progress = progress
db.session.commit()
process.stdout.close()
process.wait()
if process.returncode != 0:
stderr = process.stderr.read()
download.append_log(f"Erreur: {stderr}")
db.session.commit()
download.progress = int((idx / total_urls) * 100)
db.session.commit()
download.append_log("Ajout des couvertures...")
db.session.commit()
sacad_command = ['sacad_r', download_dir, '900', 'cover.jpg']
sacad_process = subprocess.Popen(
['stdbuf', '-oL'] + sacad_command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
bufsize=1
)
for line in iter(sacad_process.stdout.readline, ""):
if download_id not in download_processes:
sacad_process.terminate()
break
line = line.strip()
if line:
download.append_log(line)
db.session.commit()
sacad_process.wait()
if copy_choice == 'yes':
download.append_log(f"Copie vers {copy_destination}...")
db.session.commit()
if os.path.exists(download_dir) and os.listdir(download_dir):
for root, dirs, files in os.walk(download_dir):
for file in files:
src = os.path.join(root, file)
dst = os.path.join(copy_destination, os.path.relpath(src, download_dir))
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
download.append_log("Copie terminée.")
else:
download.append_log("Aucune copie - répertoire vide.")
download.status = 'completed'
download.progress = 100
download.completed_at = datetime.utcnow()
download.append_log("=== Téléchargement terminé ===")
db.session.commit()
except Exception as e:
download.status = 'error'
download.error = str(e)
download.append_log(f"Erreur critique: {e}")
db.session.commit()
finally:
if download_id in download_processes:
del download_processes[download_id]
@app.route('/')
@login_required
def index():
recent = Download.query.order_by(Download.created_at.desc()).limit(5).all()
return render_template('index.html', recent_downloads=recent)
@app.route('/history')
@login_required
def history():
downloads = Download.query.order_by(Download.created_at.desc()).limit(50).all()
return render_template('history.html', downloads=downloads)
@app.route('/logs/<int:download_id>')
@login_required
def get_logs(download_id):
download = Download.query.get_or_404(download_id)
return Response(download.log, mimetype='text/plain')
@app.route('/login', methods=['GET', 'POST']) @app.route('/login', methods=['GET', 'POST'])
def login(): def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
if request.method == 'POST': if request.method == 'POST':
username = request.form['username'] username = request.form['username']
password = request.form['password'] password = request.form['password']
if username in users and users[username]['password'] == password:
user = User(username) user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user) login_user(user)
return redirect(url_for('index')) flash('Connexion réussie', 'success')
next_page = request.args.get('next')
return redirect(next_page or url_for('index'))
else: else:
return "Nom d'utilisateur ou mot de passe incorrect", 401 flash('Nom d\'utilisateur ou mot de passe incorrect', 'danger')
return render_template('login.html') return render_template('login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('index'))
if count_users() > 0:
flash('Un utilisateur existe déjà. Connectez-vous.', 'warning')
return redirect(url_for('login'))
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
confirm_password = request.form['confirm_password']
if password != confirm_password:
flash('Les mots de passe ne correspondent pas', 'danger')
return redirect(url_for('register'))
if User.query.filter_by(username=username).first():
flash('Ce nom d\'utilisateur est déjà pris', 'danger')
return redirect(url_for('register'))
user = User(username=username)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('Compte créé ! Vous pouvez maintenant vous connecter.', 'success')
return redirect(url_for('login'))
return render_template('register.html')
@app.route('/logout') @app.route('/logout')
@login_required @login_required
def logout(): def logout():
logout_user() logout_user()
flash('Vous êtes déconnecté', 'info')
return redirect(url_for('login')) return redirect(url_for('login'))
@app.route('/')
@app.route('/api/download', methods=['POST'])
@login_required @login_required
def index(): def api_download():
return render_template('stream.html') data = request.get_json()
@app.route('/download', methods=['POST'])
@login_required
def download():
url = request.form['url']
format_choice = request.form['format']
delete_choice = request.form['delete_choice']
copy_choice = request.form['copy_choice'] # Nouvelle option pour copier ou non
def generate_output():
# Répertoires pour le téléchargement en fonction du format choisi
output_dir = "/home/garfi/Musique/flac" if format_choice == "flac" else "/home/garfi/Musique/mp3"
# Si l'utilisateur a choisi de supprimer les anciens répertoires
if delete_choice == "1":
yield "Suppression des anciens répertoires...\n"
try:
if os.path.exists("/home/garfi/Musique/flac"):
shutil.rmtree("/home/garfi/Musique/flac")
yield "Le répertoire /home/garfi/Musique/flac a été supprimé.\n"
if os.path.exists("/home/garfi/Musique/mp3"):
shutil.rmtree("/home/garfi/Musique/mp3")
yield "Le répertoire /home/garfi/Musique/mp3 a été supprimé.\n"
except FileNotFoundError as e:
yield f"Erreur : {str(e)} - Le répertoire n'existe pas.\n"
except Exception as e:
yield f"Erreur inattendue lors de la suppression : {str(e)}\n"
# Créer le répertoire pour stocker les nouveaux fichiers
os.makedirs(output_dir, exist_ok=True)
# Lancer le téléchargement avec spotdl
command = f'spotdl --output "{{artist}}/{{album}}/{{track-number}} - {{title}}.{{output-ext}}" "{url}" --format={format_choice}'
process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=output_dir)
for line in process.stdout:
yield line # Envoyer les lignes de la commande en temps réel
process.wait()
if process.returncode == 0:
yield "\nTéléchargement terminé avec succès.\n"
# Si l'utilisateur a choisi de copier vers /mnt/data/Musique/
if copy_choice == "1":
share_dir = "/mnt/data/Musique/"
try:
shutil.copytree(output_dir, share_dir, dirs_exist_ok=True)
yield f"\nLes fichiers ont été copiés vers {share_dir}.\n"
except Exception as e:
yield f"\nErreur lors de la copie des fichiers vers {share_dir} : {str(e)}\n"
else:
yield f"\nUne erreur est survenue avec le code {process.returncode}.\n"
return Response(generate_output(), mimetype='text/plain')
@app.route('/download_zip')
@login_required
def download_zip():
format_choice = request.args.get('format')
output_dir = "/home/garfi/Musique/flac" if format_choice == "flac" else "/home/garfi/Musique/mp3"
# Créer un fichier ZIP dans la mémoire
memory_file = BytesIO()
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(output_dir):
for file in files:
file_path = os.path.join(root, file)
zf.write(file_path, arcname=os.path.relpath(file_path, output_dir))
memory_file.seek(0)
# Télécharger le fichier ZIP urls_raw = data.get('url', '')
return send_file(memory_file, mimetype='application/zip', as_attachment=True, download_name=f'musique_{format_choice}.zip') format_choice = data.get('format', 'mp3')
delete_old = data.get('delete_old', False)
copy_choice = data.get('copy_choice', 'no')
urls = [u.strip() for u in urls_raw.split('\n') if u.strip()]
if not urls:
return jsonify({'error': 'Aucune URL fournie'}), 400
for url in urls:
if not validate_spotify_url(url):
return jsonify({'error': f'URL invalide: {url}'}), 400
download = Download(
url=urls_raw,
format_type=format_choice,
status='pending',
progress=0
)
db.session.add(download)
db.session.commit()
thread = threading.Thread(
target=run_download,
args=(download.id, urls, format_choice, delete_old, copy_choice)
)
thread.daemon = True
thread.start()
return jsonify({'id': download.id, 'status': 'started'})
@app.route('/api/download/<int:download_id>/stream')
@login_required
def stream_download(download_id):
download = Download.query.get_or_404(download_id)
def generate():
last_len = 0
while True:
db.session.refresh(download)
current_len = len(download.log)
if current_len > last_len:
new_log = download.log[last_len:]
data = f"data: {download.id}|{download.status}|{download.progress}|{new_log}\n\n"
yield data
last_len = current_len
if download.status in ('completed', 'error'):
data = f"data: {download.id}|{download.status}|{download.progress}|__END__\n\n"
yield data
break
import time
time.sleep(0.5)
return Response(generate(), mimetype='text/event-stream')
@app.route('/api/download/<int:download_id>/cancel', methods=['POST'])
@login_required
def cancel_download(download_id):
download = Download.query.get_or_404(download_id)
if download_id in download_processes:
download_processes[download_id].terminate()
del download_processes[download_id]
download.status = 'cancelled'
download.completed_at = datetime.utcnow()
download.append_log("=== Annulé par l'utilisateur ===")
db.session.commit()
return jsonify({'status': 'cancelled'})
@app.route('/api/download/<int:download_id>', methods=['GET'])
@login_required
def get_download(download_id):
download = Download.query.get_or_404(download_id)
return jsonify({
'id': download.id,
'url': download.url,
'format': download.format_type,
'status': download.status,
'progress': download.progress,
'log': download.log,
'error': download.error,
'created_at': download.created_at.isoformat() if download.created_at else None,
'completed_at': download.completed_at.isoformat() if download.completed_at else None
})
@app.route('/api/download/<int:download_id>', methods=['DELETE'])
@login_required
def delete_download(download_id):
download = Download.query.get_or_404(download_id)
if download_id in download_processes:
return jsonify({'error': 'Impossible de supprimer un téléchargement en cours'}), 400
db.session.delete(download)
db.session.commit()
return jsonify({'status': 'deleted'})
def init_app():
with app.app_context():
db.create_all()
if count_users() == 0:
print("Aucun utilisateur trouvé. Accédez à /register pour créer un compte.")
if __name__ == '__main__': if __name__ == '__main__':
init_app()
app.run(debug=True) app.run(debug=True)

BIN
demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
flask>=2.3.0
flask-login>=0.6.0
flask-sqlalchemy>=3.0.0
python-dotenv>=1.0.0
werkzeug>=2.3.0

2
run.sh
View File

@@ -1,2 +0,0 @@
cd /home/garfi/sys/spotdl-py
flask run --host=192.168.1.10 --port=5024

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

177
templates/history.html Normal file
View File

@@ -0,0 +1,177 @@
{% extends "layout.html" %}
{% block title %}Historique - Spotify Downloader{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi bi-clock-history me-2"></i>Historique des téléchargements</h4>
<span class="badge bg-secondary">{{ downloads|length }} téléchargement(s)</span>
</div>
<div class="card-body p-0">
{% if downloads %}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th class="text-center" style="width: 80px;">Statut</th>
<th>URL</th>
<th style="width: 80px;">Format</th>
<th style="width: 100px;">Progression</th>
<th style="width: 150px;">Date</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
{% for dl in downloads %}
<tr data-download-id="{{ dl.id }}">
<td class="text-center">
{% if dl.status == 'completed' %}
<span class="badge bg-success" title="Terminé">
<i class="bi bi-check-circle"></i>
</span>
{% elif dl.status == 'running' %}
<span class="badge bg-warning" title="En cours">
<i class="bi bi-arrow-repeat spin"></i>
</span>
{% elif dl.status == 'error' %}
<span class="badge bg-danger" title="Erreur">
<i class="bi bi-x-circle"></i>
</span>
{% elif dl.status == 'cancelled' %}
<span class="badge bg-secondary" title="Annulé">
<i class="bi bi-slash-circle"></i>
</span>
{% else %}
<span class="badge bg-info" title="En attente">
<i class="bi bi-clock"></i>
</span>
{% endif %}
</td>
<td>
<small class="text-break" style="max-width: 400px; display: inline-block;">
{{ dl.url[:80] }}{% if dl.url|length > 80 %}...{% endif %}
</small>
</td>
<td>
<span class="badge bg-dark">{{ dl.format_type|upper }}</span>
</td>
<td>
<div class="progress" style="height: 20px;">
<div class="progress-bar {{ 'bg-success' if dl.status == 'completed' else 'bg-warning' }}"
style="width: {{ dl.progress }}%">
{{ dl.progress }}%
</div>
</div>
</td>
<td>
<small>
{{ dl.created_at.strftime('%d/%m/%Y') }}<br>
<span class="text-muted">{{ dl.created_at.strftime('%H:%M') }}</span>
</small>
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-light view-log" data-id="{{ dl.id }}" title="Voir les logs">
<i class="bi bi-file-text"></i>
</button>
{% if dl.status != 'running' %}
<button class="btn btn-outline-danger delete-download" data-id="{{ dl.id }}" title="Supprimer">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted"></i>
<p class="text-muted mt-2">Aucun téléchargement pour le moment</p>
<a href="{{ url_for('index') }}" class="btn btn-spotify">
<i class="bi bi-download me-1"></i>Télécharger
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="modal fade" id="logModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-terminal me-2"></i>Logs du téléchargement</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<pre id="logContent" class="output-box p-3 mb-0" style="white-space: pre-wrap; max-height: 500px; overflow-y: auto;"></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Fermer</button>
</div>
</div>
</div>
</div>
<style>
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const logModal = new bootstrap.Modal(document.getElementById('logModal'));
const logContent = document.getElementById('logContent');
document.querySelectorAll('.view-log').forEach(btn => {
btn.addEventListener('click', async function() {
const id = this.dataset.id;
try {
const response = await fetch(`/api/download/${id}`);
const data = await response.json();
logContent.textContent = data.log || 'Aucun log disponible';
logModal.show();
} catch (err) {
showToast('Erreur lors du chargement des logs', 'error');
}
});
});
document.querySelectorAll('.delete-download').forEach(btn => {
btn.addEventListener('click', async function() {
const id = this.dataset.id;
const row = this.closest('tr');
if (!confirm('Supprimer ce téléchargement de l\'historique ?')) return;
try {
const response = await fetch(`/api/download/${id}`, { method: 'DELETE' });
if (response.ok) {
row.remove();
showToast('Supprimé de l\'historique', 'success');
} else {
const data = await response.json();
showToast(data.error || 'Erreur', 'error');
}
} catch (err) {
showToast('Erreur réseau', 'error');
}
});
});
});
</script>
{% endblock %}

299
templates/index.html Normal file
View File

@@ -0,0 +1,299 @@
{% extends "layout.html" %}
{% block title %}Télécharger - Spotify Downloader{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0"><i class="bi bi-download me-2"></i>Nouveau téléchargement</h4>
</div>
<div class="card-body">
<form id="downloadForm">
<div class="mb-3">
<label for="url" class="form-label">
<i class="bi bi-link-45deg me-1"></i>URL Spotify
</label>
<textarea
class="form-control"
id="url"
name="url"
rows="4"
placeholder="Collez une ou plusieurs URLs Spotify (une par ligne)&#10;Exemple:&#10;https://open.spotify.com/track/...&#10;https://open.spotify.com/playlist/..."
required
></textarea>
<div class="form-text">Accepte les tracks, albums et playlists Spotify</div>
<div id="urlError" class="text-danger mt-1" style="display: none;"></div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label for="format" class="form-label">
<i class="bi bi-music-note me-1"></i>Format
</label>
<select class="form-select" id="format" name="format">
<option value="mp3">MP3</option>
<option value="flac">FLAC</option>
</select>
</div>
<div class="col-md-4">
<label for="copy_choice" class="form-label">
<i class="bi bi-folder-plus me-1"></i>Copier vers NAS
</label>
<select class="form-select" id="copy_choice" name="copy_choice">
<option value="no">Non</option>
<option value="yes">Oui</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="delete_old" name="delete_old">
<label class="form-check-label" for="delete_old">Supprimer anciens fichiers</label>
</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="button" id="cancelBtn" class="btn btn-outline-danger me-md-2" disabled>
<i class="bi bi-x-circle me-1"></i>Annuler
</button>
<button type="submit" id="submitBtn" class="btn btn-spotify">
<i class="bi bi-download me-1"></i>Télécharger
</button>
</div>
</form>
<div id="downloadStatus" class="mt-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-2">
<span id="statusText" class="badge bg-info">En attente...</span>
<span id="progressText" class="text-muted small">0%</span>
</div>
<div id="progressContainer" class="progress mb-3 progress-indeterminate" style="height: 25px;">
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%;"></div>
</div>
<div class="output-box rounded p-3" id="logOutput" style="max-height: 400px; overflow-y: auto;">
<div class="text-muted text-center">En attente du téléchargement...</div>
</div>
</div>
</div>
</div>
{% if recent_downloads %}
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Téléchargements récents</h5>
<a href="{{ url_for('history') }}" class="btn btn-sm btn-outline-light">Voir tout</a>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% for dl in recent_downloads %}
<div class="list-group-item bg-transparent d-flex justify-content-between align-items-center">
<div>
<span class="badge bg-{{ 'success' if dl.status == 'completed' else 'danger' if dl.status == 'error' else 'warning' if dl.status == 'running' else 'secondary' }}">
{{ dl.status }}
</span>
<small class="text-muted ms-2">{{ dl.format_type.upper() }}</small>
</div>
<small class="text-muted">{{ dl.created_at.strftime('%d/%m %H:%M') }}</small>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function() {
'use strict';
const form = document.getElementById('downloadForm');
const submitBtn = document.getElementById('submitBtn');
const cancelBtn = document.getElementById('cancelBtn');
const downloadStatus = document.getElementById('downloadStatus');
const statusText = document.getElementById('statusText');
const progressText = document.getElementById('progressText');
const progressBar = document.getElementById('progressBar');
const progressContainer = document.getElementById('progressContainer');
const logOutput = document.getElementById('logOutput');
const urlError = document.getElementById('urlError');
let currentDownloadId = null;
let eventSource = null;
const spotifyPattern = /^https?:\/\/open\.spotify\.com\/(track|album|playlist)\/[\w-]+/;
function validateUrls(text) {
const urls = text.split('\n').filter(u => u.trim());
for (const url of urls) {
if (!spotifyPattern.test(url.trim())) {
return { valid: false, invalid: url };
}
}
return { valid: true, count: urls.length };
}
document.getElementById('url').addEventListener('input', function() {
const result = validateUrls(this.value);
if (this.value.trim() && !result.valid) {
urlError.textContent = `URL invalide: ${result.invalid.substring(0, 50)}...`;
urlError.style.display = 'block';
} else {
urlError.style.display = 'none';
}
});
function appendLog(message, type = 'info') {
const lines = message.split('\n').filter(l => l.trim());
lines.forEach(line => {
const span = document.createElement('div');
span.className = `log-${type}`;
span.textContent = line;
logOutput.appendChild(span);
});
logOutput.scrollTop = logOutput.scrollHeight;
}
function setStatus(status, progress) {
const badges = {
'pending': ['bg-secondary', 'En attente...'],
'running': ['bg-warning', 'En cours...'],
'completed': ['bg-success', 'Terminé !'],
'error': ['bg-danger', 'Erreur'],
'cancelled': ['bg-secondary', 'Annulé']
};
const [color, text] = badges[status] || ['bg-secondary', status];
statusText.className = `badge ${color}`;
statusText.textContent = text;
progressText.textContent = `${progress}%`;
if (status === 'running') {
progressBar.classList.add('progress-bar-animated');
progressBar.style.width = '100%';
progressContainer.classList.add('progress-indeterminate');
} else {
progressBar.classList.remove('progress-bar-animated');
progressBar.style.width = `${progress}%`;
progressContainer.classList.remove('progress-indeterminate');
if (status === 'completed') {
progressBar.classList.remove('progress-bar-striped');
showToast('Téléchargement terminé avec succès !', 'success');
} else if (status === 'error') {
showToast('Erreur lors du téléchargement', 'error');
} else if (status === 'cancelled') {
showToast('Téléchargement annulé', 'warning');
}
}
}
function startStream(downloadId) {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource(`/api/download/${downloadId}/stream`);
eventSource.onmessage = function(e) {
const [id, status, progress, log] = e.data.split('|');
if (log !== '__END__' && log) {
let type = 'info';
if (log.includes('Erreur') || log.includes('Error')) type = 'error';
else if (log.includes('terminé') || log.includes('Terminé')) type = 'success';
else if (log.includes('Downloaded')) type = 'success';
else if (log.includes('Ajout')) type = 'info';
appendLog(log, type);
}
setStatus(status, parseInt(progress));
if (status === 'completed' || status === 'error' || status === 'cancelled') {
eventSource.close();
eventSource = null;
submitBtn.disabled = false;
cancelBtn.disabled = true;
}
};
eventSource.onerror = function() {
eventSource.close();
eventSource = null;
};
}
form.addEventListener('submit', async function(e) {
e.preventDefault();
const urlValue = document.getElementById('url').value;
const validation = validateUrls(urlValue);
if (!validation.valid) {
urlError.textContent = `URL invalide: ${validation.invalid.substring(0, 50)}...`;
urlError.style.display = 'block';
return;
}
const formData = {
url: urlValue,
format: document.getElementById('format').value,
delete_old: document.getElementById('delete_old').checked,
copy_choice: document.getElementById('copy_choice').value
};
submitBtn.disabled = true;
cancelBtn.disabled = false;
downloadStatus.style.display = 'block';
logOutput.innerHTML = '';
setStatus('pending', 0);
appendLog(`Démarrage de ${validation.count} téléchargement(s)...`, 'info');
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.error) {
appendLog(`Erreur: ${data.error}`, 'error');
setStatus('error', 0);
submitBtn.disabled = false;
cancelBtn.disabled = true;
return;
}
currentDownloadId = data.id;
setStatus('running', 0);
startStream(currentDownloadId);
} catch (err) {
appendLog(`Erreur réseau: ${err.message}`, 'error');
setStatus('error', 0);
submitBtn.disabled = false;
cancelBtn.disabled = true;
}
});
cancelBtn.addEventListener('click', async function() {
if (!currentDownloadId) return;
try {
await fetch(`/api/download/${currentDownloadId}/cancel`, {
method: 'POST'
});
appendLog('Annulation en cours...', 'warning');
} catch (err) {
appendLog(`Erreur: ${err.message}`, 'error');
}
});
})();
</script>
{% endblock %}

152
templates/layout.html Normal file
View File

@@ -0,0 +1,152 @@
{% macro navbar() %}
<nav class="navbar navbar-expand navbar-dark" style="background-color: #1a1a1a; border-bottom: 1px solid #333;">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('index') }}" style="font-size: 1rem; color: #fff;">
<img src="{{ url_for('static', filename='images/spotify_pirate.png') }}" alt="Logo" height="32" class="me-2">
<span style="font-weight: 500;">Spotify Downloader</span>
</a>
<ul class="navbar-nav ms-auto flex-row gap-3">
<li class="nav-item">
<a class="nav-link px-3 py-2 rounded" href="{{ url_for('index') }}" style="color: #aaa;">
<i class="bi bi-download me-1"></i> <span class="d-none d-md-inline">Télécharger</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 py-2 rounded" href="{{ url_for('history') }}" style="color: #aaa;">
<i class="bi bi-clock-history me-1"></i> <span class="d-none d-md-inline">Historique</span>
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link px-3 py-2 rounded dropdown-toggle" href="#" data-bs-toggle="dropdown" style="color: #aaa;">
<i class="bi bi-person-circle me-1"></i> <span class="d-none d-md-inline">{{ current_user.username }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end" style="background: #2a2a2a; border: 1px solid #444;">
<li><a class="dropdown-item" href="{{ url_for('logout') }}" style="color: #dc3545;">
<i class="bi bi-box-arrow-right me-2"></i>Déconnexion
</a></li>
</ul>
</li>
</ul>
</div>
</nav>
<style>
.navbar-nav .nav-link:hover {
color: #1DB954 !important;
background-color: rgba(29, 185, 84, 0.1);
}
</style>
{% endmacro %}
{% macro toast_container() %}
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="toast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<i class="bi bi-bell me-2"></i>
<strong class="me-auto">Notification</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body" id="toast-message"></div>
</div>
</div>
{% endmacro %}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Spotify Downloader{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
:root {
--spotify-green: #1DB954;
--spotify-dark: #121212;
--spotify-dark-secondary: #1a1a1a;
}
body {
background-color: var(--spotify-dark);
color: white;
min-height: 100vh;
}
.card {
background-color: var(--spotify-dark-secondary);
border: 1px solid #333;
}
.output-box {
background-color: #1a1a1a;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.875rem;
}
.log-info { color: #17a2b8; }
.log-success { color: var(--spotify-green); }
.log-error { color: #dc3545; }
.log-warning { color: #ffc107; }
.toast {
background-color: var(--spotify-dark-secondary);
color: white;
}
.toast-header {
background-color: #2a2a2a;
color: white;
}
.btn-spotify {
background-color: var(--spotify-green);
color: black;
border: none;
}
.btn-spotify:hover {
background-color: #1ed760;
color: black;
}
@keyframes progress-indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
.progress-indeterminate .progress-bar {
animation: progress-indeterminate 1.5s infinite linear;
}
.dropdown-menu a:hover {
background-color: #3a3a3a;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
{{ navbar() }}
<main class="container py-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{{ toast_container() }}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script>
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
const toastMessage = document.getElementById('toast-message');
toastMessage.textContent = message;
const header = toast.querySelector('.toast-header');
header.className = 'toast-header bg-' + (type === 'error' ? 'danger' : type);
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
}
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -1,36 +1,243 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion</title> <title>Connexion - Spotify Downloader</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, #121212 0%, #1a1a2e 50%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.login-container {
animation: fadeIn 0.6s ease-out;
width: 100%;
max-width: 400px;
padding: 1rem;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-card {
background: rgba(30, 30, 30, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 3rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.logo-container {
text-align: center;
margin-bottom: 2rem;
}
.logo-container img {
width: 80px;
height: 80px;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.login-title {
text-align: center;
margin-bottom: 2rem;
font-weight: 300;
letter-spacing: 1px;
color: #fff;
}
.input-group-text {
background: #2a2a2a;
border: 1px solid #333;
border-right: none;
color: #888;
}
.form-control {
background: #2a2a2a;
border: 1px solid #333;
color: white;
padding: 0.75rem 1rem;
}
.form-control:focus {
background: #2a2a2a;
border-color: #1DB954;
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.2);
color: white;
}
.form-control::placeholder {
color: #666;
}
.input-group:focus-within .input-group-text {
border-color: #1DB954;
color: #1DB954;
}
.btn-spotify-login {
background: linear-gradient(135deg, #1DB954 0%, #1ed760 100%);
color: black;
font-weight: 600;
padding: 0.875rem 2rem;
border: none;
border-radius: 50px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(29, 185, 84, 0.4);
width: 100%;
}
.btn-spotify-login:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(29, 185, 84, 0.5);
background: linear-gradient(135deg, #1ed760 0%, #2ecc71 100%);
color: black;
}
.btn-spotify-login:disabled {
background: #333;
color: #666;
box-shadow: none;
}
.form-divider {
display: flex;
align-items: center;
margin: 1.5rem 0;
color: #666;
font-size: 0.875rem;
}
.form-divider::before,
.form-divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, #333, transparent);
}
.form-divider span {
padding: 0 1rem;
}
.footer-text {
text-align: center;
color: #555;
font-size: 0.75rem;
margin-top: 2rem;
}
.footer-text a {
color: #1DB954;
text-decoration: none;
}
.footer-text a:hover {
text-decoration: underline;
}
.alert {
border-radius: 10px;
border: none;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="login-container">
<div class="row justify-content-center"> <div class="login-card">
<div class="col-md-6"> <div class="logo-container">
<div class="card mt-5"> <img src="{{ url_for('static', filename='images/spotify_pirate.png') }}" alt="Logo">
<div class="card-body"> </div>
<h3 class="card-title text-center">Connexion</h3>
<form method="POST"> <h2 class="login-title">Spotify Downloader</h2>
<div class="mb-3">
<label for="username" class="form-label">Nom d'utilisateur</label> {% with messages = get_flashed_messages(with_categories=true) %}
<input type="text" class="form-control" id="username" name="username" required> {% if messages %}
</div> {% for category, message in messages %}
<div class="mb-3"> <div class="alert alert-{{ 'danger' if category == 'error' else category }} mb-3">
<label for="password" class="form-label">Mot de passe</label> <i class="bi bi-exclamation-circle me-2"></i>{{ message }}
<input type="password" class="form-control" id="password" name="password" required> </div>
</div> {% endfor %}
<button type="submit" class="btn btn-primary w-100">Se connecter</button> {% endif %}
</form> {% endwith %}
<form action="/login" method="POST">
<div class="mb-3">
<label for="username" class="visually-hidden">Nom d'utilisateur</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-person"></i>
</span>
<input
type="text"
class="form-control"
id="username"
name="username"
placeholder="Nom d'utilisateur"
required
autocomplete="username"
>
</div> </div>
</div> </div>
<div class="mb-4">
<label for="password" class="visually-hidden">Mot de passe</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-lock"></i>
</span>
<input
type="password"
class="form-control"
id="password"
name="password"
placeholder="Mot de passe"
required
autocomplete="current-password"
>
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-spotify-login">
<i class="bi bi-box-arrow-in-right me-2"></i>Se connecter
</button>
</div>
</form>
<div class="form-divider">
<span><i class="bi bi-music-note"></i></span>
</div>
<div class="footer-text">
<a href="{{ url_for('register') }}">
<i class="bi bi-person-plus me-1"></i>Créer un compte
</a>
<br><br>
v1.0.0
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.querySelector('form').addEventListener('submit', function() {
const btn = this.querySelector('button[type="submit"]');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Connexion...';
});
</script>
</body> </body>
</html> </html>

247
templates/register.html Normal file
View File

@@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inscription - Spotify Downloader</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, #121212 0%, #1a1a2e 50%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.register-container {
animation: fadeIn 0.6s ease-out;
width: 100%;
max-width: 400px;
padding: 1rem;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.register-card {
background: rgba(30, 30, 30, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 3rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.logo-container {
text-align: center;
margin-bottom: 2rem;
}
.logo-container img {
width: 80px;
height: 80px;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.register-title {
text-align: center;
margin-bottom: 2rem;
font-weight: 300;
letter-spacing: 1px;
color: #fff;
}
.input-group-text {
background: #2a2a2a;
border: 1px solid #333;
border-right: none;
color: #888;
}
.form-control {
background: #2a2a2a;
border: 1px solid #333;
color: white;
padding: 0.75rem 1rem;
}
.form-control:focus {
background: #2a2a2a;
border-color: #1DB954;
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.2);
color: white;
}
.form-control::placeholder {
color: #666;
}
.input-group:focus-within .input-group-text {
border-color: #1DB954;
color: #1DB954;
}
.btn-spotify-register {
background: linear-gradient(135deg, #1DB954 0%, #1ed760 100%);
color: black;
font-weight: 600;
padding: 0.875rem 2rem;
border: none;
border-radius: 50px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(29, 185, 84, 0.4);
width: 100%;
}
.btn-spotify-register:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(29, 185, 84, 0.5);
background: linear-gradient(135deg, #1ed760 0%, #2ecc71 100%);
color: black;
}
.form-divider {
display: flex;
align-items: center;
margin: 1.5rem 0;
color: #666;
font-size: 0.875rem;
}
.form-divider::before,
.form-divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, #333, transparent);
}
.form-divider span {
padding: 0 1rem;
}
.footer-text {
text-align: center;
color: #555;
font-size: 0.75rem;
margin-top: 2rem;
}
.footer-text a {
color: #1DB954;
text-decoration: none;
}
.footer-text a:hover {
text-decoration: underline;
}
.alert {
border-radius: 10px;
border: none;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
</style>
</head>
<body>
<div class="register-container">
<div class="register-card">
<div class="logo-container">
<img src="{{ url_for('static', filename='images/spotify_pirate.png') }}" alt="Logo">
</div>
<h2 class="register-title">Créer un compte</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} mb-3">
<i class="bi bi-exclamation-circle me-2"></i>{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form action="/register" method="POST">
<div class="mb-3">
<label for="username" class="visually-hidden">Nom d'utilisateur</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-person"></i>
</span>
<input
type="text"
class="form-control"
id="username"
name="username"
placeholder="Nom d'utilisateur"
required
autocomplete="username"
>
</div>
</div>
<div class="mb-3">
<label for="password" class="visually-hidden">Mot de passe</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-lock"></i>
</span>
<input
type="password"
class="form-control"
id="password"
name="password"
placeholder="Mot de passe"
required
autocomplete="new-password"
>
</div>
</div>
<div class="mb-4">
<label for="confirm_password" class="visually-hidden">Confirmer le mot de passe</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-lock-fill"></i>
</span>
<input
type="password"
class="form-control"
id="confirm_password"
name="confirm_password"
placeholder="Confirmer le mot de passe"
required
autocomplete="new-password"
>
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-spotify-register">
<i class="bi bi-person-plus me-2"></i>S'inscrire
</button>
</div>
</form>
<div class="form-divider">
<span><i class="bi bi-music-note"></i></span>
</div>
<div class="footer-text">
<a href="{{ url_for('login') }}">
<i class="bi bi-box-arrow-in-left me-1"></i>Déjà inscrit ? Se connecter
</a>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

34
templates/result.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Résultats</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #121212;
color: white;
}
.container {
margin-top: 50px;
}
.output-box {
background-color: #333;
padding: 20px;
border-radius: 8px;
white-space: pre-wrap;
color: lightgreen;
}
</style>
</head>
<body>
<div class="container">
<h1 class="text-center">Résultats des commandes</h1>
<div class="output-box">
{{ output }}
</div>
<a href="{{ zip_url }}" class="btn btn-primary mt-3">Télécharger les fichiers en ZIP</a>
</div>
</body>
</html>

View File

@@ -1,105 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Téléchargement en temps réel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background-color: #f8f9fa; }
.container { margin-top: 50px; }
.card { border-radius: 10px; }
.output-container {
background-color: #f1f1f1;
padding: 20px;
border-radius: 10px;
white-space: pre-wrap;
font-family: monospace;
height: 400px;
overflow-y: scroll;
}
</style>
</head>
<body>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-lg p-3 mb-5 bg-white rounded">
<div class="card-body">
<h2 class="text-center mb-4">Télécharger Musique Spotify</h2>
<form id="download-form">
<div class="mb-3">
<label for="url" class="form-label">Saisir l'URL Spotify :</label>
<input type="text" class="form-control" id="url" name="url" required>
</div>
<div class="mb-3">
<label for="format" class="form-label">Choisissez le format :</label>
<select class="form-select" id="format" name="format" required>
<option value="flac">FLAC</option>
<option value="mp3">MP3</option>
</select>
</div>
<div class="mb-3">
<label for="delete_choice" class="form-label">Supprimer les anciens répertoires :</label>
<select class="form-select" id="delete_choice" name="delete_choice" required>
<option value="1">Oui</option>
<option value="2">Non</option>
</select>
</div>
<div class="mb-3">
<label for="copy_choice" class="form-label">Copier vers /mnt/data/Musique/ :</label>
<select class="form-select" id="copy_choice" name="copy_choice" required>
<option value="1">Oui</option>
<option value="2">Non</option>
</select>
</div>
<button type="submit" class="btn btn-primary w-100">Lancer le téléchargement</button>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<h4>Output :</h4>
<div id="output" class="output-container"></div>
</div>
</div>
<!-- Button to download the ZIP -->
<button class="btn btn-secondary w-100 mt-3" onclick="window.location.href='/download_zip?format=' + document.getElementById('format').value">Télécharger ZIP</button>
</div>
</div>
</div>
<script>
document.getElementById('download-form').addEventListener('submit', function (event) {
event.preventDefault();
const url = document.getElementById('url').value;
const format = document.getElementById('format').value;
const delete_choice = document.getElementById('delete_choice').value;
const copy_choice = document.getElementById('copy_choice').value;
const formData = new URLSearchParams();
formData.append('url', url);
formData.append('format', format);
formData.append('delete_choice', delete_choice);
formData.append('copy_choice', copy_choice);
fetch('/download', {
method: 'POST',
body: formData
})
.then(response => {
const outputContainer = document.getElementById('output');
const reader = response.body.getReader();
const decoder = new TextDecoder();
function read() {
reader.read().then(({ done, value }) => {
if (done) return;
outputContainer.textContent += decoder.decode(value, { stream: true });
outputContainer.scrollTop = outputContainer.scrollHeight;
read();
});
}
read();
})
.catch(error => console.error('Erreur:', error));
});
</script>
</body>
</html>