Post

↻ ◁ || ▷ ↺ Musique - Générateur de playlist

↻ ◁ || ▷ ↺ Musique - Générateur de playlist

Web - Générateur playlist musical

Toutes les opérations sont effectuées sur le serveur cwwk (192.168.0.205)

“Playlists uniquement avec dossier absolu pour les applis de type Subsonic”
Ce code exporte des chemins absolus issus de music-path :
/sharenfs/multimedia/Music/musicyan/.../fichier.mp3

Dossier travail: /sharenfs/rnmkcy/web-music-playlist

Python venv + flask + uwsgi

1
2
3
4
5
6
7
8
# Créer le dossier projet
mkdir -p /sharenfs/rnmkcy/web-music-playlist
chown -R $USER:$USER /sharenfs/rnmkcy/web-music-playlist
cd /sharenfs/rnmkcy/web-music-playlist
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip wheel
pip install flask uwsgi

Écrire app.py (export .m3u avec chemins absolus)

Créer app.py dans /sharenfs/rnmkcy/web-music-playlist/ :

Etendre Réduire app.py
import os
from pathlib import Path

from flask import Flask, request, Response, render_template_string, abort, jsonify

app = Flask(__name__)

MUSIC_ROOT = Path(os.environ.get("MUSIC_ROOT", "/sharenfs/multimedia/Music/musicyan")).resolve()
M3U_ROOT = Path(os.environ.get("M3U_ROOT", "/sharenfs/multimedia/Music/playlists/2")).resolve()

# index : { artist: { album_or_None: [ {name, rel}...] } }
def build_index():
    index = {}
    if not MUSIC_ROOT.exists():
        return index

    mp3_exts = {".mp3"}

    def is_mp3(p: Path) -> bool:
        return p.is_file() and p.suffix.lower() in mp3_exts

    for artist_dir in sorted([p for p in MUSIC_ROOT.iterdir() if p.is_dir()]):
        artist = artist_dir.name
        index.setdefault(artist, {})

        # cas : mp3 directement dans /Artist/
        direct = []
        for f in sorted([p for p in artist_dir.iterdir() if is_mp3(p)]):
            rel = f.relative_to(MUSIC_ROOT).as_posix()
            direct.append({"name": f.stem, "rel": rel})
        if direct:
            index[artist]["~direct"] = direct

        # cas : /Artist/Album/*.mp3
        for album_dir in sorted([p for p in artist_dir.iterdir() if p.is_dir()]):
            album = album_dir.name
            tracks = []
            for f in sorted([p for p in album_dir.iterdir() if is_mp3(p)]):
                rel = f.relative_to(MUSIC_ROOT).as_posix()
                tracks.append({"name": f.stem, "rel": rel})
            if tracks:
                index[artist][album] = tracks

    return index


INDEX = build_index()

def make_m3u_from_rels(rels, title="Playlist"):
    lines = ["#EXTM3U"]
    # IMPORTANT : chemins absolus serveur
    for rel in rels:
        abs_path = (MUSIC_ROOT / rel).resolve()
        lines.append(str(abs_path))
    return "\n".join(lines) + "\n"

@app.route("/api/index")
def api_index():
    # rescan simple à la volée (sinon mets en cache + option)
    global INDEX
    INDEX = build_index()
    return jsonify(INDEX)

@app.route("/export", methods=["POST"])
def export():
    global INDEX
    if not INDEX:
        INDEX = build_index()

    # Nom donné par l'utilisateur (prompt JS)
    title = (request.form.get("title") or "Playlist").strip()[:120] or "Playlist"
    rels = request.form.getlist("track")

    # filtrage par sécurité
    valid = set()
    for _, albums in INDEX.items():
        for _, tracks in albums.items():
            for t in tracks:
                valid.add(t["rel"])

    chosen = [r for r in rels if r in valid]
    if not chosen:
        abort(400, "Aucun morceau sélectionné")

    m3u = make_m3u_from_rels(chosen, title=title)

    # construction du chemin cible sur le serveur
    safe_name = title.replace('"', "").replace("/", "_").replace("\\", "_")
    filename = safe_name + ".m3u"
    target_dir = M3U_ROOT
    target_dir.mkdir(parents=True, exist_ok=True)  # au cas où
    target_path = target_dir / filename

    # écriture du fichier m3u sur le serveur
    target_path.write_text(m3u, encoding="utf-8")  # Path.write_text [web:29]

    # On renvoie juste un petit JSON de confirmation
    return jsonify({
        "status": "ok",
        "filename": filename,
        "path": str(target_path),
    })

@app.route("/api/playlists", methods=["GET"])
def list_playlists():
    M3U_ROOT.mkdir(parents=True, exist_ok=True)
    files = []
    for p in sorted(M3U_ROOT.glob("*.m3u")):
        files.append({
            "name": p.name,
            "path": str(p),
        })
    return jsonify(files)

@app.route("/api/playlists/delete", methods=["POST"])
def delete_playlists():
    data = request.get_json(silent=True) or {}
    names = data.get("names") or []
    if not isinstance(names, list):
        abort(400, "Format invalide")

    deleted = []
    errors = []

    for name in names:
        # sécurité : pas de /, pas de chemin relatif
        if "/" in name or "\\" in name or name.startswith("."):
            errors.append({"name": name, "error": "Nom invalide"})
            continue

        p = M3U_ROOT / name
        try:
            if p.is_file() and p.suffix.lower() == ".m3u":
                p.unlink()  # Path.unlink pour supprimer [web:31][web:35]
                deleted.append(name)
            else:
                errors.append({"name": name, "error": "Fichier introuvable"})
        except Exception as e:
            errors.append({"name": name, "error": str(e)})

    return jsonify({
        "deleted": deleted,
        "errors": errors,
    })

@app.route("/")
def ui():
    return render_template_string("""
<!doctype html>
<html>
<head>
  <meta charset="utf-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
  <title>Playlist globale (.m3u)</title>
<style>
  :root{
    --pad: 16px;
    --gap: 16px;
    --radius: 8px;

    --bg: #ffffff;
    --text: #111111;
    --muted: #666666;
    --box-border: #dddddd;
    --box-bg: #ffffff;
    --btn-bg: #fafafa;
    --btn-border: #cccccc;
    --input-border: #cccccc;
  }

  html[data-theme="dark"]{
    --bg: #0f1115;
    --text: #e8eaf0;
    --muted: #a0a4b3;
    --box-border: #2a2f3a;
    --box-bg: #141824;
    --btn-bg: #1a2130;
    --btn-border: #323a4c;
    --input-border: #323a4c;
  }

  * { box-sizing: border-box; }

  html, body { height: 100%; }

  body {
    font-family: sans-serif;
    margin: var(--pad);
    color: var(--text);
    background: var(--bg);
  }

  h2 { margin-top: 0; }

  .grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--gap);
    align-items: start;
  }

  @media (max-width: 820px){
    body { margin: 12px; }
    .grid { grid-template-columns: 1fr; }
  }

  .box {
    border: 1px solid var(--box-border);
    padding: 12px;
    border-radius: var(--radius);
    background: var(--box-bg);
  }

  input[type="search"] {
    width: 100%;
    padding: 10px 10px;
    border-radius: 6px;
    border: 1px solid var(--input-border);
    background: transparent;
    color: var(--text);
    outline: none;
  }
  input[type="search"]::placeholder { color: var(--muted); }

  .muted { color: var(--muted); font-size: 12px; }

  button {
    padding: 10px 14px;
    cursor: pointer;
    margin-right: 8px;
    margin-bottom: 8px;
    border-radius: 6px;
    border: 1px solid var(--btn-border);
    background: var(--btn-bg);
    color: var(--text);
  }

  .actions{
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    align-items: center;
  }

  .topbar{
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    margin-bottom: 10px;
  }

  .themeBtn{
    margin: 0;
    white-space: nowrap;
  }

  ul { list-style: none; padding-left: 0; margin: 10px 0 0 0; }
  li { margin: 6px 0; }

  /*.track { display: flex; align-items: center; gap: 8px; }*/

  /*.track label {
    cursor: pointer;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 100%;
  }*/
/* Taille par défaut un peu plus grande */
.track {
  font-size: 15px;
}

/*.track input[type="checkbox"] {
  width: 22px;
  height: 22px;
}*/
.track input[type="checkbox"] {
  transform: scale(1.4);
  -webkit-transform: scale(1.4); /* WebKit/mobile */
  margin-right: 6px;
}

/* Spécifique aux petits écrans (mobile) */
@media (max-width: 600px){
  body {
    font-size: 18px;
  }

  .track {
    font-size: 18px;
  }

  .track input[type="checkbox"] {
    width: 26px;
    height: 26px;
  }

  /* Boutons un peu plus gros pour le doigt */
  button {
    padding: 12px 18px;
    font-size: 16px;
  }

  input[type="search"] {
    font-size: 16px;
  }

  #selection-panel {
    font-size: 14px;
  }
}

  #selection-panel {
    max-height: 180px;
    overflow: auto;
    font-size: 13px;
    line-height: 1.4;
  }

  .spacer { height: 16px; }
</style>
</head>
<body>
  <div class="topbar">
    <h2 style="margin:0;">Playlist globale (.m3u)</h2>
    <button id="themeToggle" class="themeBtn" type="button">Clair</button>
  </div>

  <div class="grid">
    <div class="box">
      <b>Recherche</b>
      <div class="spacer" style="height:8px"></div>
      <input id="q" type="search" placeholder="ex: Paranoid, Thunderstruck..."/>
      <div class="spacer" style="height:10px"></div>
      <div class="muted">Coche/décoche puis “Exporter .m3u”.</div>
    </div>

    <div class="box">
      <b>Sélection</b>
      <div class="spacer" style="height:8px"></div>
      <div class="muted" id="count">0 morceaux</div>
      <div class="spacer" style="height:12px"></div>

      <div class="actions">
        <button onclick="exportM3U()">Exporter .m3u</button>
        <button onclick="toggleAll(true)">Tout cocher</button>
        <button onclick="toggleAll(false)">Tout décocher</button>
        <button onclick="openDeleteDialog()">Effacer playlist</button>
      </div>
    </div>
  </div>

  <div class="spacer"></div>

  <div class="box">
    <b>Vue sur la sélection</b>
    <div class="spacer" style="height:8px"></div>
    <div class="muted"><span id="selection-count">0</span> morceaux cochés</div>
    <div class="spacer" style="height:10px"></div>
    <div id="selection-panel" style="max-height:180px; overflow:auto; font-size:13px; line-height:1.4;">
      <div id="selection-list"></div>
    </div>
  </div>

  <div class="spacer"></div>
  <div id="content" class="box"></div>
  <div id="delete-dialog" style="
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.55);
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 9999;
">
  <div style="
    background: var(--box-bg);
    color: var(--text);
    border-radius: 8px;
    padding: 16px;
    max-width: 420px;
    width: 90%;
    border: 1px solid var(--box-border);
  ">
    <h3 style="margin-top:0;">Effacer des playlists</h3>
    <div class="muted" style="margin-bottom:8px;">Coche les fichiers .m3u à supprimer :</div>
    <div id="delete-list" style="max-height:220px; overflow:auto; margin-bottom:12px; font-size:14px;"></div>
    <div style="display:flex; justify-content:flex-end; gap:8px;">
      <button type="button" onclick="closeDeleteDialog()">Annuler</button>
      <button type="button" onclick="confirmDeletePlaylists()">Supprimer</button>
    </div>
  </div>
</div>

<script>
let INDEX = null;
let collapsed = {}; // "artist|album"
let lastQuery = "";
let selectedRels = new Set(); // état global des morceaux sélectionnés

// --- thème sombre/claire
(function(){
  const saved = localStorage.getItem("theme");
  const theme = saved || "dark";
  document.documentElement.setAttribute("data-theme", theme);

  const btn = document.getElementById("themeToggle");
  const label = () => {
    const t = document.documentElement.getAttribute("data-theme");
    btn.textContent = (t === "dark") ? "Clair" : "Sombre";
  };

  label();

  btn.addEventListener("click", () => {
    const cur = document.documentElement.getAttribute("data-theme");
    const next = (cur === "dark") ? "light" : "dark";
    document.documentElement.setAttribute("data-theme", next);
    localStorage.setItem("theme", next);
    label();
  });
})();

// --- helpers
function keyFor(artist, album){ return artist + "|" + album; }

function ensureCollapsedIndex(index){
  collapsed = {};
  for (const artist of Object.keys(index).sort()){
    collapsed[keyFor(artist, "__artist__")] = true;
    const albums = index[artist] || {};
    for (const album of Object.keys(albums).sort()){
      collapsed[keyFor(artist, album)] = true;
    }
  }
}

function allTrackRels(index){
  const rels = [];
  for (const artist of Object.keys(index)){
    const albums = index[artist] || {};
    for (const album of Object.keys(albums)){
      const tracks = albums[album] || [];
      for (const t of tracks) rels.push(t.rel);
    }
  }
  return rels;
}

function getSelectedRels(){
  return new Set(selectedRels);
}

function setSelectedRelsToAll(on){
  const rels = allTrackRels(INDEX);
  selectedRels = new Set();
  if (on) rels.forEach(r => selectedRels.add(r));
  render(INDEX, document.getElementById("q").value.toLowerCase() || "");
}

function updateCount(){
  const count = selectedRels.size;
  document.getElementById("count").textContent = count + " morceaux";
  document.getElementById("selection-count").textContent = count;
}

function renderSelectionView(){
  const panel = document.getElementById("selection-panel");
  if (!panel) return;

  const relToTrack = new Map();
  for (const artist of Object.keys(INDEX)){
    const albums = INDEX[artist] || {};
    for (const album of Object.keys(albums)){
      const tracks = albums[album] || [];
      for (const t of tracks){
        relToTrack.set(t.rel, { artist, album, name: t.name });
      }
    }
  }

  const selected = Array.from(selectedRels);
  const arr = selected.map(rel => {
    const meta = relToTrack.get(rel) || { artist:"?", album:"?", name:rel };
    const albumLabel = (meta.album === "~direct") ? "(morceaux directs)" : meta.album;
    return meta.artist + " / " + albumLabel + "" + meta.name;
  }).sort();

  const list = document.getElementById("selection-list");
  list.innerHTML = "";
  if (arr.length === 0){
    const li = document.createElement("div");
    li.style.color = "#666";
    li.textContent = "Rien sélectionné.";
    list.appendChild(li);
    return;
  }

  const maxItems = 200;
  const slice = arr.slice(0, maxItems);
  slice.forEach(txt => {
    const div = document.createElement("div");
    div.textContent = txt;
    list.appendChild(div);
  });

  if (arr.length > maxItems){
    const more = document.createElement("div");
    more.style.color = "#666";
    more.textContent = "… et " + (arr.length - maxItems) + " autres";
    list.appendChild(more);
  }
}

function bindCount(){
  document.querySelectorAll('input[type="checkbox"][name="track"]').forEach(cb => {
    cb.onchange = () => {
      if (cb.checked){
        selectedRels.add(cb.value);
      } else {
        selectedRels.delete(cb.value);
      }
      updateCount();
      renderSelectionView();
    };
  });
}

// --- render
function render(index, q=""){
  lastQuery = q;
  const content = document.getElementById("content");
  content.innerHTML = "";

  const artists = Object.keys(index).sort();
  for (const artist of artists){
    const albums = index[artist] || {};

    const artistReduced = !!collapsed[keyFor(artist, "__artist__")];

    const artistBox = document.createElement("div");
    artistBox.style.marginBottom = "14px";

    const artistHeader = document.createElement("div");
    artistHeader.style.display = "flex";
    artistHeader.style.alignItems = "center";
    artistHeader.style.gap = "8px";

    const artistToggle = document.createElement("button");
    artistToggle.textContent = (artistReduced ? "+" : "");
    artistToggle.onclick = () => {
      collapsed[keyFor(artist, "__artist__")] = !collapsed[keyFor(artist, "__artist__")];
      render(INDEX, document.getElementById("q").value.toLowerCase() || "");
    };

    const artistTitle = document.createElement("div");
    artistTitle.innerHTML = "<b>" + artist + "</b>";

    artistHeader.appendChild(artistToggle);
    artistHeader.appendChild(artistTitle);
    artistBox.appendChild(artistHeader);

    let any = false;
    const searching = !!q;

    for (const album of Object.keys(albums).sort()){
      const tracks = albums[album] || [];

      // Filtrage pour la recherche
      const visible = tracks.filter(t => {
        if (!q) return true;
        const hay = (artist + " " + (album === "~direct" ? "" : album) + " " + t.name).toLowerCase();
        return hay.includes(q);
      });
      if (visible.length === 0) continue;

      any = true;
      if (artistReduced && !searching) continue;

      const collapsedAlbum = !!collapsed[keyFor(artist, album)];

      const albumWrapper = document.createElement("div");

      const albumHeader = document.createElement("div");
      albumHeader.style.display = "flex";
      albumHeader.style.alignItems = "center";
      albumHeader.style.gap = "8px";
      albumHeader.style.marginTop = "8px";

      const albumToggle = document.createElement("button");
      albumToggle.textContent = (collapsedAlbum ? "+" : "");
      albumToggle.onclick = () => {
        collapsed[keyFor(artist, album)] = !collapsed[keyFor(artist, album)];
        render(INDEX, document.getElementById("q").value.toLowerCase() || "");
      };

      const albumTitle = document.createElement("div");
      albumTitle.className = "muted";
      albumTitle.textContent = (album === "~direct") ? "(morceaux directs)" : album;

      // --- nouveaux boutons album qui pilotent selectedRels ---
      const albumSelectBtn = document.createElement("button");
      albumSelectBtn.textContent = "Cocher titres";
      albumSelectBtn.onclick = () => {
        // ajouter tous les rels de l'album dans selectedRels
        tracks.forEach(t => selectedRels.add(t.rel));
        render(INDEX, document.getElementById("q").value.toLowerCase() || "");
      };

      const albumUnselectBtn = document.createElement("button");
      albumUnselectBtn.textContent = "Décocher titres";
      albumUnselectBtn.onclick = () => {
        // retirer tous les rels de l'album de selectedRels
        tracks.forEach(t => selectedRels.delete(t.rel));
        render(INDEX, document.getElementById("q").value.toLowerCase() || "");
      };

      albumHeader.appendChild(albumToggle);
      albumHeader.appendChild(albumTitle);
      albumHeader.appendChild(albumSelectBtn);
      albumHeader.appendChild(albumUnselectBtn);

      albumWrapper.appendChild(albumHeader);

      if (!collapsedAlbum || searching){
        const ul = document.createElement("ul");
        for (const t of visible){
          const li = document.createElement("li");
          li.className = "track";

          const cb = document.createElement("input");
          cb.type = "checkbox";
          cb.name = "track";
          cb.value = t.rel;
          // l'état visuel dépend de selectedRels
          cb.checked = selectedRels.has(t.rel);

          const label = document.createElement("label");
          label.textContent = t.name;

          li.appendChild(cb);
          li.appendChild(label);
          ul.appendChild(li);
        }
        albumWrapper.appendChild(ul);
      }

      artistBox.appendChild(albumWrapper);
    }

    if (any) content.appendChild(artistBox);
  }

  // recoller les handlers de changement, mettre à jour les compteurs, vue sélection
  bindCount();
  updateCount();
  renderSelectionView();
}
function toggleAll(on){
  setSelectedRelsToAll(on);
}

// --- export
async function exportM3U(){
  let rels = Array.from(selectedRels);
  if (rels.length === 0){ alert("Aucun morceau sélectionné."); return; }

  // mélange optionnel
  for (let i = rels.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [rels[i], rels[j]] = [rels[j], rels[i]];
  }

  const title = prompt("Nom du fichier playlist (.m3u) :", "Playlist") || "Playlist";
  const form = new URLSearchParams();
  form.append("title", title);
  rels.forEach(r => form.append("track", r));

  const res = await fetch("/export", {
    method: "POST",
    headers: {"Content-Type": "application/x-www-form-urlencoded"},
    body: form.toString()
  });

  if (!res.ok){
    const txt = await res.text().catch(() => "");
    alert("Erreur export: " + res.status + (txt ? " - " + txt : ""));
    return;
  }

  const data = await res.json();
  alert("Playlist enregistrée sur le serveur :\\n" +
        data.filename + "\\n(" + data.path + ")");
}

// recherche
document.getElementById("q").addEventListener("input", (e) => {
  const qq = (e.target.value || "").toLowerCase();
  render(INDEX, qq);
});

// init
(async function init(){
  const res = await fetch("/api/index");
  INDEX = await res.json();

  ensureCollapsedIndex(INDEX);
  render(INDEX, "");
})();

//  logique d’affichage et de suppression playlist
async function openDeleteDialog(){
  const dialog = document.getElementById("delete-dialog");
  const listDiv = document.getElementById("delete-list");
  listDiv.innerHTML = "Chargement...";

  try {
    const res = await fetch("/api/playlists");
    if (!res.ok){
      listDiv.textContent = "Erreur lors du listing (" + res.status + ")";
      dialog.style.display = "flex";
      return;
    }
    const files = await res.json();

    listDiv.innerHTML = "";
    if (!files.length){
      const p = document.createElement("div");
      p.className = "muted";
      p.textContent = "Aucun fichier .m3u dans le dossier.";
      listDiv.appendChild(p);
    } else {
      files.forEach(f => {
        const row = document.createElement("div");
        row.style.display = "flex";
        row.style.alignItems = "center";
        row.style.gap = "6px";
        row.style.marginBottom = "4px";

        const cb = document.createElement("input");
        cb.type = "checkbox";
        cb.value = f.name;
        cb.name = "delete_m3u";

        const label = document.createElement("span");
        label.textContent = f.name;

        row.appendChild(cb);
        row.appendChild(label);
        listDiv.appendChild(row);
      });
    }
  } catch (e){
    listDiv.textContent = "Erreur réseau : " + e;
  }

  dialog.style.display = "flex";
}

function closeDeleteDialog(){
  const dialog = document.getElementById("delete-dialog");
  dialog.style.display = "none";
}

async function confirmDeletePlaylists(){
  const checked = Array.from(
    document.querySelectorAll('#delete-list input[type="checkbox"][name="delete_m3u"]:checked')
  ).map(cb => cb.value);

  if (!checked.length){
    alert("Aucun fichier sélectionné.");
    return;
  }

  if (!confirm("Supprimer " + checked.length + " fichier(s) .m3u ?")){
    return;
  }

  try {
    const res = await fetch("/api/playlists/delete", {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({ names: checked })
    });

    if (!res.ok){
      const txt = await res.text().catch(() => "");
      alert("Erreur suppression: " + res.status + (txt ? " - " + txt : ""));
      return;
    }

    const data = await res.json();
    let msg = "";
    if (data.deleted && data.deleted.length){
      msg += "Supprimé :\\n" + data.deleted.join("\\n") + "\\n";
    }
    if (data.errors && data.errors.length){
      msg += "\\nErreurs :\\n" +
             data.errors.map(e => e.name + " -> " + e.error).join("\\n");
    }
    if (!msg) msg = "Opération terminée.";
    alert(msg);

    // Rafraîchir la liste après suppression
    openDeleteDialog();
  } catch (e){
    alert("Erreur réseau : " + e);
  }
}

</script>

</body>
</html>
""")

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5100, debug=False)

Vérification syntaxe et indentation

une commande pour vérifier si aucune erreur de syntaxe ou indentation

1
2
3
4
5
# Si pas le prompt (.venv) yick@alder:/sharenfs/rnmkcy/web-music-playlist$
cd /sharenfs/rnmkcy/web-music-playlist
source .venv/bin/activate
# vérification syntaxe
python -m py_compile app.py # Ne renvoie rien -> OK

Lancer manuellement pour test

1
2
3
source /sharenfs/rnmkcy/web-music-playlist/.venv/bin/activate
cd /sharenfs/rnmkcy/web-music-playlist
python app.py

Si tout est ok

1
2
3
4
5
  * Serving Flask app 'app'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5100
Press CTRL+C to quit

Tester depuis PC1
lancer la commande suivante

1
ssh -L 9500:127.0.0.1:5100 yick@192.168.0.205 -p 55205 -i /home/yann/.ssh/yick-ed25519

Ouvrir un navigateur sur localhost:9500

uwsgi.ini

/sharenfs/rnmkcy/web-music-playlist/uwsgi.ini

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[uwsgi]
chdir = /sharenfs/rnmkcy/web-music-playlist
module = app:app
master = true
processes = 1
threads = 2
enable-threads = true
single-interpreter = true
die-on-term = true
need-app = true
vacuum = true
safe-pidfile = /run/uwsgi/web-music-playlist.pid
socket = /run/uwsgi/playlist-web.sock
chmod-socket = 660
chmod-socket = 660
stats = /run/uwsgi/web-music-playlist.stats
buffer-size = 65535
log-date = true
py-autoreload = 0
honour-stdin = true

Ce modèle correspond aux configurations uWSGI standards avec socket UNIX, permissions et mode daemonisé pour nginx

Mettre en service systemd (recommandé)

Créer /etc/systemd/system/web-music-playlist.service :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[Unit]
Description=Flask playlist generator
After=network.target

[Service]
Type=notify
User=yick
Group=yick
WorkingDirectory=/sharenfs/rnmkcy/web-music-playlist
RuntimeDirectory=uwsgi
RuntimeDirectoryMode=0755
Environment="PATH=/sharenfs/rnmkcy/web-music-playlist/venv/bin"
ExecStart=/sharenfs/rnmkcy/web-music-playlist/venv/bin/uwsgi --ini /sharenfs/rnmkcy/web-music-playlist/uwsgi.ini
ExecStop=/sharenfs/rnmkcy/web-music-playlist/venv/bin/uwsgi --stop /run/uwsgi/web-music-playlist.pid
PIDFile=/run/uwsgi/web-music-playlist.pid
Restart=always
KillSignal=SIGQUIT
TimeoutStartSec=30
TimeoutStopSec=20

[Install]
WantedBy=multi-user.target

Puis :

1
2
3
4
sudo systemctl daemon-reload
sudo systemctl enable --now web-music-playlist
sudo systemctl status web-music-playlist --no-pager
sudo journalctl -u web-music-playlist -f --no-pager

Après toutes modifications

1
sudo systemctl restart web-music-playlist

Nginx (vhost)

exposition /music/ + reverse proxy Flask)
Créer /etc/nginx/conf.d/playlist.rnmkcy.eu.conf :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name playlist.rnmkcy.eu;

    include /etc/nginx/conf.d/ssl-modern.inc;

    location /music/ {
        alias /sharenfs/multimedia/Music/musicyan/;
        autoindex off;
        add_header Cache-Control "public, max-age=31536000";
    }

    location / {
        include uwsgi_params;
        uwsgi_pass unix:/run/uwsgi/playlist-web.sock;
        uwsgi_read_timeout 120s;
        uwsgi_connect_timeout 30s;
    }
}

Vérifier puis reload :

1
2
sudo nginx -t
sudo systemctl reload nginx

Lien :

Cet article est sous licence CC BY 4.0 par l'auteur.