Zum Hauptinhalt springen
S-EDV news
← Alle News
Backup & Datensicherung 13.08.2026 · 37 min Lesezeit

Suno-Bibliothek komplett sichern: Anleitung für WAV, Texte und Metadaten

Praxisanleitung Teil 2 der Serie: Ein Browser-Konsolen-Skript liest die komplette Suno-Bibliothek aus, lädt Songs als WAV mit MP3-Fallback, sichert Texte und Metadaten und sortiert alles nach Projekt. Inklusive PowerShell-Skripten zum Einsortieren und Prüfen sowie Diagnosewerkzeug für viele Workspaces. Referenzlauf: 1835 Songs, 54,4 GB.

Suno-Bibliothek komplett sichern: WAV-Dateien, Songtexte und Metadaten für ein vollständiges Backup speichern KI-generiert

Worum es in dieser Anleitung geht

Im ersten Teil dieser Serie ging es um die neuen Suno-Download-Limits ab dem 3. September 2026: Free-Nutzer erhalten künftig nur noch sieben Downloads insgesamt, Pro-Nutzer 20 pro Monat, Premier-Nutzer 60 – und die Regel gilt auch rückwirkend für längst erzeugte Songs. Wer eine größere Suno-Bibliothek besitzt, sollte diese daher jetzt vollständig lokal sichern, solange der Export noch unbegrenzt möglich ist.

Diese Anleitung zeigt ein praxiserprobtes Vorgehen für genau diesen Fall: ein Browser-Konsolen-Skript liest die komplette Suno-Bibliothek aus, lädt Audio als WAV (mit MP3-Fallback), sichert Songtexte und Metadaten und sortiert alles automatisch nach Projekt beziehungsweise Workspace. Referenzlauf: 1835 Songs, 54,4 GB (1689 WAV, 146 MP3).

Wichtiger Hinweis vorab: Suno stellt dafür keine offizielle Export-API bereit. Das hier beschriebene Vorgehen nutzt interne, nicht dokumentierte Endpunkte der Suno-Web-App im eigenen, authentifizierten Browser-Kontext – vergleichbar mit einem Nutzer, der manuell auf „Download" klickt, nur automatisiert für die gesamte Bibliothek. Es werden keine Zugangsdaten Dritter verwendet und keine Schutzmechanismen umgangen; das Skript meldet sich mit der eigenen, bereits eingeloggten Session an.

Voraussetzungen und Vorbereitung

  1. Aktives Suno-Abo erforderlich für WAV. Songs ohne WAV-Recht werden automatisch als MP3 gesichert.
  2. Plattenplatz einplanen: rund 30 MB pro Song, bei 1800+ Songs also etwa 55 GB.
  3. Downloads-Ordner umstellen: In Chrome unter Einstellungen → Downloads einen Ordner auf einer großen Platte festlegen und „Vor dem Download fragen" ausschalten.
  4. Automatische Downloads erlauben: Beim ersten Stapel-Download fragt Chrome „Mehrere Dateien herunterladen?" – hier muss Zulassen gewählt werden, sonst bricht der Lauf nach der ersten Datei ab.
  5. Kein Standby während des Laufs. Der Bildschirm darf ausgehen, der Rechner nicht in den Ruhezustand wechseln.
  6. Browser-Tab mit suno.com muss während des gesamten Downloads geöffnet bleiben.

Schritt 1: Skript in die Browser-Konsole einfügen

Alles läuft über die Entwicklerkonsole des Browsers – kein Programm-Download, keine Installation nötig.

  1. Auf https://suno.com einloggen und dort bleiben.
  2. Taste F12 drücken, Reiter Konsole öffnen.
  3. Falls Chrome das Einfügen blockiert und eine Warnung vor „Selbst-XSS" zeigt: einmal allow pasting eintippen und Enter drücken.
  4. Den kompletten Inhalt von suno-backup.js (Codeblock weiter unten) in die Konsole einfügen und mit Enter bestätigen. Die Konsole meldet Bereit (v3).

Bild: Konsole vom Chrome

Chrome mit Konsole - allow pasting


Bild: Skript einfügen und dann SunoBackup.list() eingeben

SunoBackup - Script einfügen

Danach in der Konsole eintippen:

SunoBackup.list()

Das Skript blättert seitenweise durch die Bibliothek und lädt anschließend die Workspace-Namen nach. Am Ende erscheint eine Zusammenfassung ähnlich dieser:

SunoBackup -Script läuft

Projekte geladen: 17
Bestand: 1835 Songs | 2025-08-07 bis 2026-08-09 | 94.1 h Musik | WAV geschaetzt ~56 GB
Mit Projektzuordnung: 1830 / 1835 (17 Projekte bekannt)
Paginierung vollstaendig durchlaufen.

Erst wenn „vollständig durchlaufen" erscheint, speichert das Skript automatisch drei Dateien: suno-metadaten.json, suno-songs.csv und suno-texte.md. Der Grund: Die spätere Dateinummerierung (0001_) richtet sich nach dem Erstelldatum aufsteigend. Kämen nachträglich ältere Songs dazu, würden sich alle Nummern verschieben.

Bricht die Paginierung vorzeitig ab – Suno limitiert die Anfragerate ab etwa Seite 40 mit HTTP 429 – hilft:

SunoBackup.weiter()

Das setzt die Liste ab der Abbruchseite fort. Bei Bedarf mehrfach ausführen.

Schritt 2: Audio herunterladen

SunoBackup.run()

Der Browser-Tab muss während des gesamten Laufs offen bleiben. Pro Song stößt das Skript die WAV-Konvertierung an, wartet auf das Ergebnis und lädt die Datei – das dauert 10 bis 30 Sekunden pro Song. Bei 1800 Songs sind das mehrere Stunden Gesamtlaufzeit. Die Konsole zeigt laufend Fortschritt und Restzeitschätzung an.

BefehlWirkung
SunoBackup.stop()Sauber anhalten, Fortschritt bleibt gespeichert
SunoBackup.run()Fortsetzen – bereits geladene Songs werden übersprungen
SunoBackup.weiter()Paginierung nach einem Abbruch fortsetzen
SunoBackup.status()Zwischenstand anzeigen
SunoBackup.reset()Fortschrittsspeicher leeren (nur bei komplettem Neustart)

Der Fortschritt liegt im localStorage von suno.com. Ein Browser-Neustart ist daher unkritisch: Skript neu einfügen, run() aufrufen, es macht automatisch weiter. Fehlgeschlagene Songs landen zusätzlich in suno-fehlgeschlagen.json und werden beim nächsten run() erneut versucht.

Schritt 3 und 4: Einsortieren und prüfen (PowerShell)

Nach dem Download liegen alle Dateien lose im Downloads-Ordner. Zwei begleitende PowerShell-Skripte übernehmen das Aufräumen:

  1. Sortiere-SunoBackup.ps1 sortiert die Downloads in Audio\, Texte\ und Metadaten\ ein – auf Wunsch zusätzlich nach Projekt/Workspace (vollständiger Code weiter unten: Skript: Sortiere-SunoBackup.ps1).
  2. Pruefe-SunoBackup.ps1 gleicht ein fertiges Backup gegen die Metadaten ab und meldet Lücken (vollständiger Code weiter unten: Skript: Prüfe-SunoBackup.ps1).

PowerShell im Ordner mit den heruntergeladenen Dateien öffnen:

Set-ExecutionPolicy -Scope Process Bypass -Force
.\Sortiere-SunoBackup.ps1 -NachProjekt

Das Sortier-Skript sucht die Metadaten-Datei selbst – zuerst im eigenen Ordner, dann im Zielordner, dann im Arbeitsverzeichnis, zuletzt im Downloads-Ordner des Benutzerprofils. Gibt es davon mehrere Kopien, gewinnt die zuletzt geschriebene; welche das war, steht in der ersten Ausgabezeile. Nützliche Zusatzparameter:

.\Sortiere-SunoBackup.ps1 -NachProjekt -Downloads "D:\Suno-Dump" -Ziel "U:\Backup\Suno"
.\Sortiere-SunoBackup.ps1 -NachProjekt -Kopieren     # Originale bleiben liegen

Ergebnis ist eine klare Ordnerstruktur:

U:\Backup\Suno\
  Audio\My Workspace\0042_Titel_ab12cd34.wav
  Audio\Slow Traffic\0731_Titel_ef56gh78.wav
  Audio\_Ohne_Projekt\...
  Texte\Slow Traffic\0731_Titel_ef56gh78.txt
  Metadaten\suno-metadaten.json, suno-songs.csv, suno-texte.md, Backup-Index.csv

Wichtig für Folgeläufe: Bereits einsortierte Dateien werden an der Song-ID erkannt, nicht an der laufenden Nummer. Eine Datei behält also ihren Namen, auch wenn sich die Liste später ändert – sie wird nur verschoben, nie umbenannt. Neue Songs erhalten die nächste freie Nummer. Ein zweiter Lauf ohne neue Dateien ändert nichts.

Zum Netzlaufwerk (NAS): lieber lokal sortieren und danach die fertige Struktur kopieren – das Verschieben läuft dann auf derselben Platte statt über SMB. Ist lokal der Platz knapp, erst alles aufs NAS kopieren und dort mit Quelle und Ziel auf dem NAS sortieren.

Abschließend prüfen:

.\Pruefe-SunoBackup.ps1 -Ziel "U:\Backup\Suno"

Das Prüfskript meldet Songs pro Projektordner, Größe, das WAV/MP3-Verhältnis sowie:

  1. Songs aus der Liste ohne zugehörige Audiodatei
  2. Songs ohne Textdatei
  3. verwaiste Dateien (falscher Name, doppelte Song-ID, nicht in den Metadaten enthalten)
  4. Dateien unter 200 KB – meist ein Hinweis auf abgebrochene Downloads

Mit dem Zusatzparameter -Bericht wird das Ergebnis zusätzlich als Pruefbericht.txt im Backup-Ordner abgelegt. Fehlt etwas, reicht im Browser ein erneutes SunoBackup.run() – bereits gesicherte Songs werden automatisch übersprungen.

Viele Projekte/Workspaces: Zusatz-Diagnoseskript

Wer seine Suno-Songs auf viele Projekte beziehungsweise Workspaces verteilt hat, ist auf eine korrekte Projektzuordnung angewiesen – sonst landet alles gesammelt im Ordner _Ohne_Projekt. Das Hauptskript suno-backup.js lädt diese Zuordnung normalerweise automatisch mit. Bricht das aus irgendeinem Grund ab oder ändert Suno die zugrunde liegenden API-Pfade, hilft das separate Diagnosewerkzeug suno-projekte.js (Codeblock weiter unten).

Es wird nur im Ausnahmefall gebraucht und dient dazu, herauszufinden, über welchen Endpunkt sich Projektnamen aktuell abrufen lassen:

BefehlZweck
SunoProjekte.rohclip()Zeigt einen Song im Rohformat aus dem Feed – prüft, ob ein Feld wie project_id direkt enthalten ist
SunoProjekte.netz()Listet alle API-Aufrufe der aktuellen Seite auf (vorher im UI auf „Workspaces" klicken)
SunoProjekte.test(pfad)Testet einen einzelnen API-Pfad gezielt
SunoProjekte.konfig(...)Setzt gefundene Endpunkte, danach holen() aufrufen
SunoProjekte.holen()Erstellt die vollständige Zuordnung und speichert suno-projekte.json

Empfohlene Reihenfolge bei Problemen mit der Projektzuordnung: zuerst rohclip(), dann bei Bedarf netz() nach einem Klick auf „Workspaces" im Suno-UI, anschließend gefundene Pfade mit test() verifizieren und über konfig() übernehmen. Das Ergebnis lässt sich anschließend mit dem Hauptskript kombinieren.

Häufige Probleme und Lösungen

MeldungUrsache / Lösung
Keine Suno-Session gefundenNicht eingeloggt, oder das Skript läuft nicht auf einem suno.com-Tab
Kein passender Library-Endpunkt gefundenSuno hat die interne API geändert; Endpunktliste im Skript muss angepasst werden
HTTP 429 (Too Many Requests)Rate-Limit. Das Skript wartet automatisch und wiederholt (bis zu 7 Versuche, Wartezeit verdoppelt sich je Versuch)
WAV nicht verfügbar (HTTP 402/403)Das Abo deckt WAV für diesen Song nicht ab, Fallback lädt automatisch MP3
Download bricht nach einer Datei abChrome-Berechtigung „Automatische Downloads" für suno.com auf Zulassen setzen
suno-metadaten.json nicht gefundenSkript listet durchsuchte Ordner auf; mit Parameter den richtigen Pfad angeben
Alle Songs landen unter _Ohne_ProjektProjektfeld fehlt in den Metadaten; Projektzuordnung erneut mit aktueller Skriptversion laden

Skript: suno-backup.js (Bibliothek einlesen und Audio laden)

Vollständiger Code zum Einfügen in die Browser-Konsole:

/* ============================================================================
   SUNO BACKUP  –  Browser-Konsolen-Skript   (v3: Rate-Limit + Projektzuordnung)
   ----------------------------------------------------------------------------
   Ablauf:
     1. Auf https://suno.com eingeloggt sein (Tab offen lassen!)
     2. F12 -> Konsole -> dieses Skript komplett einfuegen -> Enter
     3. Danach:  SunoBackup.list()    -> Bibliothek einlesen + Metadaten/Texte sichern
                 SunoBackup.weiter()  -> Paginierung ab der letzten Seite fortsetzen
                 SunoBackup.run()     -> Audio herunterladen (resume-faehig)
                 SunoBackup.stop()    -> sauber abbrechen
                 SunoBackup.status()  -> Zwischenstand
                 SunoBackup.reset()   -> Fortschrittsspeicher loeschen
   ========================================================================== */

(() => {
  'use strict';

  const CFG = {
    FORMAT:         'wav',   // 'wav' oder 'mp3'
    FALLBACK_MP3:   true,    // wenn WAV nicht verfuegbar -> MP3 laden
    DELAY_MS:       1200,    // Pause zwischen zwei Songs
    PAGE_DELAY_MS:  700,     // Pause zwischen zwei Bibliotheksseiten
    WAV_POLL_MAX:   60,      // max. Abfragen auf die WAV-Konvertierung
    WAV_POLL_MS:    3000,    // Abstand der Abfragen
    RETRY_MAX:      7,       // Wiederholungen bei HTTP 429/5xx
    RETRY_BASE_MS:  5000,    // Basiswartezeit, verdoppelt sich je Versuch
    MAX_PAGES:      2000,    // Sicherheitslimit der Paginierung
    LIMIT:          0,       // 0 = alle, sonst nur die ersten N Songs
    NEWEST_FIRST:   false,   // false = aeltester Song bekommt Nummer 0001
  };

  const API_BASES = [
    'https://studio-api-prod.suno.com',
    'https://studio-api.prod.suno.com',
    'https://studio-api.suno.ai',
  ];
  const FEED_PATHS = [
    '/api/feed/v2/?page=',
    '/api/feed/v2?page=',
    '/api/feed/?page=',
    '/api/project/default/clips?page=',
  ];

  const LS_DONE   = 'sunoBackup.done';
  const LS_FAILED = 'sunoBackup.failed';

  let API = null, FEED = null, stopped = false;

  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const log   = (...a) => console.log('%c[Suno-Backup]', 'color:#8b5cf6;font-weight:bold', ...a);
  const warn  = (...a) => console.warn('[Suno-Backup]', ...a);

  const loadJSON = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } };
  const saveJSON = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { warn('localStorage voll:', e.message); } };

  /* ---------- Authentifizierung (Clerk-Session der laufenden Seite) -------- */
  async function authHeaders() {
    if (!window.Clerk || !window.Clerk.session) {
      throw new Error('Keine Suno-Session gefunden. Bist du auf suno.com eingeloggt?');
    }
    const token = await window.Clerk.session.getToken();
    const h = {
      'Authorization': 'Bearer ' + token,
      'browser-token': JSON.stringify({ token: btoa(JSON.stringify({ timestamp: Date.now() })) }),
    };
    const dev = document.cookie.match(/suno_device_id=([^;]+)/);
    if (dev) h['device-id'] = dev[1];
    return h;
  }

  /* ---------- HTTP mit Backoff bei 429 / 5xx ------------------------------ */
  async function req(url, opts = {}, label = '') {
    const { auth = false, ...rest } = opts;
    for (let t = 0; ; t++) {
      if (stopped) throw new Error('abgebrochen');
      const o = { ...rest };
      if (auth) o.headers = { ...(await authHeaders()), ...(rest.headers || {}) };

      let res;
      try {
        res = await fetch(url, o);
      } catch (e) {
        if (t >= CFG.RETRY_MAX) throw e;
        const wait = CFG.RETRY_BASE_MS * Math.pow(2, t);
        warn((label || url) + ' Netzfehler (' + e.message + ') – neuer Versuch in ' + Math.round(wait / 1000) + 's');
        await sleep(wait);
        continue;
      }

      const retrybar = res.status === 429 || res.status === 500 || res.status === 502 || res.status === 503 || res.status === 504;
      if (retrybar && t < CFG.RETRY_MAX) {
        const ra = parseInt(res.headers.get('retry-after') || '', 10);
        const wait = (ra > 0 ? ra * 1000 : CFG.RETRY_BASE_MS * Math.pow(2, t));
        warn((label || url) + ' HTTP ' + res.status + ' – warte ' + Math.round(wait / 1000) + 's (Versuch ' + (t + 1) + '/' + CFG.RETRY_MAX + ')');
        await sleep(wait);
        continue;
      }
      return res;
    }
  }

  const api = (path, opts = {}, label = '') => req(API + path, { auth: true, ...opts }, label || path);

  /* ---------- Endpunkt-Erkennung ------------------------------------------ */
  function extractClips(json) {
    if (Array.isArray(json)) return json;
    if (!json || typeof json !== 'object') return null;
    for (const k of ['clips', 'items', 'results', 'data', 'songs', 'objects']) {
      if (Array.isArray(json[k])) return json[k];
    }
    return null;
  }

  async function discover() {
    if (API && FEED) return true;
    for (const base of API_BASES) {
      for (const path of FEED_PATHS) {
        try {
          const res = await fetch(base + path + '0', { headers: await authHeaders() });
          if (!res.ok) continue;
          const clips = extractClips(await res.json());
          if (clips && clips.length && clips[0] && clips[0].id) {
            API = base; FEED = path;
            log('Endpunkt gefunden:', base + path + 'N');
            return true;
          }
        } catch (_) { /* naechster Kandidat */ }
      }
    }
    return false;
  }

  /* ---------- Projekte / Workspaces ---------------------------------------- */
  const PROJEKT_PATHS = [
    '/api/project/me?page={page}&sort=max_created_at_last_updated_clip&show_trashed=false&exclude_shared=false',
    '/api/project/me?page={page}',
    '/api/project/me/?page={page}',
  ];
  const projektNamen = new Map();   // id -> Name

  function projektId(c) {
    const p = c.project;
    if (!p) return '';
    if (typeof p === 'string') return p;
    if (typeof p === 'object') return p.id || p.project_id || '';
    return '';
  }
  function projektName(c) {
    const p = c.project;
    if (p && typeof p === 'object' && (p.name || p.title)) return String(p.name || p.title).trim();
    return '';
  }

  async function projekteLaden() {
    for (const muster of PROJEKT_PATHS) {
      for (const start of [1, 0]) {
        projektNamen.clear();
        let ok = false;
        for (let page = start; page < 200; page++) {
          let res;
          try { res = await api(muster.replace('{page}', page), {}, 'Projekte S' + page); } catch (_) { break; }
          if (!res.ok) break;
          const liste = extractProjects(await res.json());
          if (!liste) break;
          ok = true;
          if (!liste.length) break;
          let neu = 0;
          for (const p of liste) {
            if (p && p.id && !projektNamen.has(p.id)) {
              projektNamen.set(p.id, String(p.name || p.title || p.display_name || ('Projekt_' + String(p.id).slice(0, 8))).trim());
              neu++;
            }
          }
          if (!neu) break;
          await sleep(300);
        }
        if (ok && projektNamen.size) {
          log('Projekte geladen: ' + projektNamen.size + ' (' + muster.split('?')[0] + ')');
          return true;
        }
      }
    }
    warn('Projektliste nicht lesbar – Songs behalten nur die Projekt-ID.');
    return false;
  }

  function extractProjects(json) {
    if (Array.isArray(json)) return json;
    if (!json || typeof json !== 'object') return null;
    for (const k of ['projects', 'items', 'results', 'data', 'objects', 'workspaces']) {
      if (Array.isArray(json[k])) return json[k];
    }
    return null;
  }

  function projekteZuordnen(songs) {
    if (!projektNamen.size) return 0;
    let n = 0;
    for (const s of songs) {
      if (!s.projekt && s.projekt_id && projektNamen.has(s.projekt_id)) { s.projekt = projektNamen.get(s.projekt_id); }
      if (s.projekt) n++;
    }
    return n;
  }

  /* ---------- Songs einsammeln -------------------------------------------- */
  function norm(c) {
    const m = c.metadata || {};
    return {
      id:            c.id,
      titel:         String(c.title || '').trim() || 'Ohne Titel',
      erstellt:      c.created_at || '',
      modell:        c.major_model_version || c.model_name || '',
      status:        c.status || '',
      dauer_sek:     m.duration != null ? m.duration : (c.duration != null ? c.duration : ''),
      stil:          m.tags || c.tags || '',
      text:          m.prompt || '',
      beschreibung:  m.gpt_description_prompt || '',
      typ:           m.type || '',
      persona:       m.persona_name || '',
      projekt_id:    projektId(c),
      projekt:       projektName(c),
      oeffentlich:   c.is_public === true,
      audio_url:     c.audio_url || ('https://cdn1.suno.ai/' + c.id + '.mp3'),
      video_url:     c.video_url || '',
      cover_url:     c.image_large_url || c.image_url || '',
      abspielungen:  c.play_count != null ? c.play_count : '',
      likes:         c.upvote_count != null ? c.upvote_count : '',
      suno_link:     'https://suno.com/song/' + c.id,
    };
  }

  const store = new Map();          // id -> Song
  let letzteSeite = -1;             // zuletzt erfolgreich gelesene Seite
  let vollstaendig = false;         // true, wenn die Paginierung sauber zu Ende lief

  async function paginieren(startSeite) {
    if (!(await discover())) {
      throw new Error('Kein passender Library-Endpunkt gefunden. Bitte melden – dann passe ich die Kandidatenliste an.');
    }
    let leerInFolge = 0;

    for (let page = startSeite; page < CFG.MAX_PAGES; page++) {
      if (stopped) { warn('Gestoppt bei Seite ' + page + '. SunoBackup.weiter() setzt fort.'); return; }

      const res = await api(FEED + page, {}, 'Seite ' + page);
      if (!res.ok) {
        warn('Seite ' + page + ' HTTP ' + res.status + ' – Abbruch. SunoBackup.weiter() versucht es erneut ab hier.');
        return;
      }
      const clips = extractClips(await res.json());

      if (!clips || clips.length === 0) { vollstaendig = true; letzteSeite = page; break; }

      let neu = 0;
      for (const c of clips) {
        if (!c || !c.id || store.has(c.id)) continue;
        store.set(c.id, norm(c)); neu++;
      }
      letzteSeite = page;
      log('Seite ' + page + ': ' + clips.length + ' Eintraege, ' + neu + ' neu (gesamt ' + store.size + ')');

      if (neu === 0) { if (++leerInFolge >= 2) { vollstaendig = true; break; } } else { leerInFolge = 0; }
      await sleep(CFG.PAGE_DELAY_MS);
    }
  }

  function sortiert() {
    const arr = [...store.values()].sort((a, b) => {
      const d = new Date(a.erstellt) - new Date(b.erstellt);
      return CFG.NEWEST_FIRST ? -d : d;
    });
    return CFG.LIMIT > 0 ? arr.slice(0, CFG.LIMIT) : arr;
  }

  /* ---------- Dateien speichern ------------------------------------------- */
  function saveBlob(name, blob) {
    const href = URL.createObjectURL(blob);
    const a = Object.assign(document.createElement('a'), { href, download: name });
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(href), 30000);
  }

  const csvCell = v => '"' + String(v == null ? '' : v).replace(/"/g, '""').replace(/\r?\n/g, ' / ') + '"';

  function saveMetadata(songs) {
    saveBlob('suno-metadaten.json', new Blob([JSON.stringify(songs, null, 2)], { type: 'application/json' }));

    const spalten = ['nr', 'projekt', 'id', 'titel', 'erstellt', 'modell', 'dauer_sek', 'stil', 'typ', 'persona',
                     'oeffentlich', 'abspielungen', 'likes', 'suno_link', 'text'];
    const zeilen = [spalten.join(';')];
    songs.forEach((s, i) => {
      zeilen.push(spalten.map(k => csvCell(k === 'nr' ? String(i + 1).padStart(4, '0') : s[k])).join(';'));
    });
    saveBlob('suno-songs.csv', new Blob(['\uFEFF' + zeilen.join('\r\n')], { type: 'text/csv;charset=utf-8' }));

    const md = songs.map((s, i) => {
      const nr = String(i + 1).padStart(4, '0');
      return '# ' + nr + ' – ' + s.titel + '\n\n' +
             '- Projekt: ' + (s.projekt || '(keins)') + '\n' +
             '- Erstellt: ' + s.erstellt + '\n' +
             '- Stil: ' + s.stil + '\n' +
             '- Modell: ' + s.modell + '\n' +
             '- Link: ' + s.suno_link + '\n\n' +
             '```\n' + (s.text || '(kein Text)') + '\n```\n';
    }).join('\n---\n\n');
    saveBlob('suno-texte.md', new Blob([md], { type: 'text/markdown;charset=utf-8' }));
    log('Metadaten gespeichert: suno-metadaten.json, suno-songs.csv, suno-texte.md');
  }

  /* ---------- Audio -------------------------------------------------------- */
  function sanitize(t) {
    const s = String(t == null ? '' : t)
      .replace(/[\u0000-\u001f]/g, '')
      .replace(/[\\/:*?"<>|]/g, '')
      .replace(/\s+/g, '_')
      .replace(/_+/g, '_')
      .replace(/^[._]+|[._]+$/g, '')
      .slice(0, 70);
    return s || 'Ohne_Titel';
  }

  async function wavUrl(id) {
    try { await api('/api/gen/' + id + '/convert_wav/', { method: 'POST' }); } catch (_) {}
    for (let n = 0; n < CFG.WAV_POLL_MAX; n++) {
      if (stopped) throw new Error('abgebrochen');
      const res = await api('/api/gen/' + id + '/wav_file/');
      if (res.ok) {
        const j = await res.json().catch(() => ({}));
        if (j && j.wav_file_url) return j.wav_file_url;
      } else if (res.status === 401 || res.status === 402 || res.status === 403 || res.status === 404) {
        throw new Error('WAV nicht verfuegbar (HTTP ' + res.status + ')');
      }
      await sleep(CFG.WAV_POLL_MS);
    }
    throw new Error('WAV-Konvertierung Zeitueberschreitung');
  }

  async function holen(song, nr) {
    const basis = String(nr).padStart(4, '0') + '_' + sanitize(song.titel) + '_' + song.id.slice(0, 8);
    let url, ext = CFG.FORMAT;

    if (CFG.FORMAT === 'wav') {
      try {
        url = await wavUrl(song.id);
      } catch (e) {
        if (!CFG.FALLBACK_MP3 || stopped) throw e;
        warn(basis + ': ' + e.message + ' -> MP3');
        url = song.audio_url; ext = 'mp3';
      }
    } else {
      url = song.audio_url;
    }

    const res = await req(url, {}, 'Download ' + basis);
    if (!res.ok) throw new Error('Download HTTP ' + res.status);
    const blob = await res.blob();
    if (blob.size < 10000) throw new Error('Datei verdaechtig klein (' + blob.size + ' Byte)');
    saveBlob(basis + '.' + ext, blob);
    return { datei: basis + '.' + ext, bytes: blob.size };
  }

  /* ---------- Steuerung ---------------------------------------------------- */
  function bericht() {
    const songs = sortiert();
    const d = songs.map(s => s.erstellt).filter(Boolean).sort();
    const std = songs.reduce((a, s) => a + (Number(s.dauer_sek) || 0), 0) / 3600;
    log('Bestand: ' + songs.length + ' Songs | ' + (d[0] || '?').slice(0, 10) + ' bis ' + (d[d.length - 1] || '?').slice(0, 10) +
        ' | ' + std.toFixed(1) + ' h Musik | WAV geschaetzt ~' + (std * 0.6).toFixed(1) + ' GB');
    const mitProjekt = songs.filter(x => x.projekt || x.projekt_id).length;
    log('Mit Projektzuordnung: ' + mitProjekt + ' / ' + songs.length +
        (projektNamen.size ? ' (' + projektNamen.size + ' Projekte bekannt)' : ''));
    log(vollstaendig ? 'Paginierung vollstaendig durchlaufen.'
                     : 'ACHTUNG: Paginierung unterbrochen bei Seite ' + letzteSeite + ' – SunoBackup.weiter() ausfuehren!');
    return songs;
  }

  async function list() {
    stopped = false;
    store.clear(); letzteSeite = -1; vollstaendig = false;
    log('Lese Bibliothek ...');
    await paginieren(0);
    await projekteLaden();
    projekteZuordnen([...store.values()]);
    const songs = bericht();
    window.SunoBackup.songs = songs;
    if (vollstaendig) saveMetadata(songs);
    else warn('Metadaten noch nicht gespeichert. Erst SunoBackup.weiter(), danach werden sie automatisch gespeichert.');
    return songs.length;
  }

  async function weiter() {
    stopped = false;
    if (!store.size) return list();
    log('Setze Paginierung ab Seite ' + (letzteSeite + 1) + ' fort ...');
    await paginieren(letzteSeite + 1);
    if (!projektNamen.size) await projekteLaden();
    projekteZuordnen([...store.values()]);
    const songs = bericht();
    window.SunoBackup.songs = songs;
    if (vollstaendig) saveMetadata(songs);
    return songs.length;
  }

  async function run() {
    stopped = false;
    if (!window.SunoBackup.songs || !window.SunoBackup.songs.length) await list();
    if (!vollstaendig) {
      warn('Bibliothek unvollstaendig eingelesen. Erst SunoBackup.weiter() bis "vollstaendig" – sonst verschiebt sich spaeter die Nummerierung.');
      warn('Trotzdem starten? -> SunoBackup.runTrotzdem()');
      return;
    }
    return download();
  }

  async function download() {
    const songs = window.SunoBackup.songs;
    const done   = loadJSON(LS_DONE, {});
    const failed = loadJSON(LS_FAILED, {});
    const offen  = songs.filter(s => !done[s.id]);
    let ok = 0, fehler = 0, bytes = 0;
    const t0 = Date.now();

    log('Audio-Download: ' + offen.length + ' offen, ' + (songs.length - offen.length) + ' bereits gesichert.');
    if (!offen.length) { log('Nichts zu tun.'); return; }

    for (let i = 0; i < songs.length; i++) {
      if (stopped) { log('Gestoppt. Fortschritt gespeichert – run() setzt fort.'); break; }
      const s = songs[i];
      if (done[s.id]) continue;
      const tag = '[' + (i + 1) + '/' + songs.length + '] ' + s.titel;

      try {
        const r = await holen(s, i + 1);
        done[s.id] = r.datei; delete failed[s.id];
        saveJSON(LS_DONE, done); saveJSON(LS_FAILED, failed);
        ok++; bytes += r.bytes;
        const restMin = Math.round((offen.length - ok) * ((Date.now() - t0) / ok) / 60000);
        log(tag + ' -> ' + r.datei + '  (' + (r.bytes / 1048576).toFixed(1) + ' MB, Rest ca. ' + restMin + ' min)');
      } catch (e) {
        if (stopped) break;
        fehler++; failed[s.id] = { titel: s.titel, fehler: e.message };
        saveJSON(LS_FAILED, failed);
        warn(tag + ' FEHLER: ' + e.message);
      }
      await sleep(CFG.DELAY_MS);
    }

    log('Fertig. ' + ok + ' geladen (' + (bytes / 1073741824).toFixed(2) + ' GB), ' + fehler + ' Fehler.');
    const rest = Object.keys(failed);
    if (rest.length) {
      saveBlob('suno-fehlgeschlagen.json', new Blob([JSON.stringify(failed, null, 2)], { type: 'application/json' }));
      log(rest.length + ' Fehler in suno-fehlgeschlagen.json – ein erneutes run() versucht sie nochmal.');
    }
  }

  window.SunoBackup = {
    cfg: CFG,
    songs: [],
    list,
    weiter,
    run,
    runTrotzdem: download,
    speichern: () => saveMetadata(window.SunoBackup.songs),
    bericht,
    stop:  () => { stopped = true; log('Stopp angefordert ...'); },
    reset: () => { localStorage.removeItem(LS_DONE); localStorage.removeItem(LS_FAILED); log('Fortschritt geloescht.'); },
    status: () => {
      const d = Object.keys(loadJSON(LS_DONE, {})).length, f = Object.keys(loadJSON(LS_FAILED, {})).length;
      log('gesichert: ' + d + ' | fehlgeschlagen: ' + f + ' | erkannt: ' + window.SunoBackup.songs.length +
          ' | Paginierung: ' + (vollstaendig ? 'vollstaendig' : 'offen ab Seite ' + (letzteSeite + 1)));
    },
  };

  log('Bereit (v3). Format: ' + CFG.FORMAT.toUpperCase() +
      '  |  SunoBackup.list() -> einlesen, SunoBackup.weiter() -> fortsetzen, SunoBackup.run() -> laden');
})();

Skript: Sortiere-SunoBackup.ps1

Vollständiger Code zum Speichern als Sortiere-SunoBackup.ps1. Sortiert die im Downloads-Ordner liegenden Dateien in Audio\, Texte\ und Metadaten\ ein, erkennt bereits einsortierte Songs an der Song-ID (nicht an der Nummer) und schreibt pro Song eine Textdatei mit Songtext, Stil, Datum und Link.

<#
.SYNOPSIS
    Sortiert die per Browser-Skript heruntergeladenen Suno-Dateien in eine saubere
    Backup-Struktur und schreibt pro Song eine Textdatei.

.BESCHREIBUNG
    Erwartet im Downloads-Ordner:
        suno-metadaten.json   (vom Skript suno-backup.js erzeugt)
        NNNN_Titel_xxxxxxxx.wav / .mp3

    Erzeugt im Zielordner:
        Audio\      alle Musikdateien   (mit -NachProjekt: Audio\<Projekt>\)
        Texte\      pro Song eine .txt mit Songtext, Stil, Datum, Link
        Metadaten\  suno-metadaten.json, suno-songs.csv, Backup-Index.csv

.HINWEIS
    Ohne -Downloads sucht das Skript suno-metadaten*.json im eigenen Ordner, im Zielordner und
    zuletzt in %USERPROFILE%\Downloads. Gibt es mehrere (z.B. "suno-metadaten (1).json"), gewinnt
    die zuletzt geschriebene.

.BEISPIEL
    .\Sortiere-SunoBackup.ps1
    .\Sortiere-SunoBackup.ps1 -Downloads "D:\Downloads" -Ziel "E:\SUNO-BACKUP" -Kopieren
    .\Sortiere-SunoBackup.ps1 -NachProjekt
#>

[CmdletBinding()]
param(
    [string] $Downloads    = '',
    [string] $Ziel         = $PSScriptRoot,
    [switch] $Kopieren,
    [switch] $NachProjekt,
    [string] $ProjektDatei = ''
)

$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($Ziel)) { $Ziel = (Get-Location).Path }

function Write-Info { param($t) Write-Host "[Suno] $t" -ForegroundColor Cyan }
function Write-Warn { param($t) Write-Host "[Suno] $t" -ForegroundColor Yellow }

# --- Metadaten suchen -------------------------------------------------------
# Reihenfolge: -Downloads (falls angegeben) > Skriptordner > Zielordner > %USERPROFILE%\Downloads
$suchorte = @()
if (-not [string]::IsNullOrWhiteSpace($Downloads)) { $suchorte += $Downloads }
$suchorte += @($PSScriptRoot, $Ziel, (Get-Location).Path)
if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { $suchorte += (Join-Path $env:USERPROFILE 'Downloads') }
$suchorte = $suchorte | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and (Test-Path -LiteralPath $_) } |
            ForEach-Object { (Resolve-Path -LiteralPath $_).Path } | Select-Object -Unique

$jsonDatei = $null
foreach ($ort in $suchorte) {
    # nimmt auch "suno-metadaten (1).json" mit und waehlt die zuletzt geschriebene Datei
    $kandidaten = Get-ChildItem -LiteralPath $ort -Filter 'suno-metadaten*.json' -File -ErrorAction SilentlyContinue |
                  Sort-Object LastWriteTime -Descending
    if ($kandidaten) { $jsonDatei = $kandidaten[0]; break }
}
if (-not $jsonDatei) {
    throw ("suno-metadaten.json nicht gefunden. Gesucht in:`n  " + ($suchorte -join "`n  ") +
           "`nZuerst SunoBackup.list() im Browser ausfuehren, oder -Downloads <Pfad> angeben.")
}
$Downloads = $jsonDatei.DirectoryName
$jsonPfad  = $jsonDatei.FullName
Write-Info "Metadaten: $($jsonDatei.Name)  ($($jsonDatei.LastWriteTime.ToString('yyyy-MM-dd HH:mm')), $([math]::Round($jsonDatei.Length/1MB,1)) MB)"
Write-Info "Quellordner: $Downloads"

$songs = Get-Content -LiteralPath $jsonPfad -Raw -Encoding UTF8 | ConvertFrom-Json
Write-Info "$($songs.Count) Songs in den Metadaten."

# --- Dateinamen bereinigen (identisch zur Logik im Browser-Skript) ----------
function ConvertTo-Dateiname {
    param([string] $Text, [string] $Standard = 'Ohne_Titel', [int] $MaxLaenge = 70)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $Standard }
    $t = [regex]::Replace($Text, '[\u0000-\u001f]', '')
    $t = [regex]::Replace($t, '[\\/:*?"<>|]', '')
    $t = [regex]::Replace($t, '\s+', '_')
    $t = [regex]::Replace($t, '_+', '_').Trim('_', '.')
    if ($t.Length -gt $MaxLaenge) { $t = $t.Substring(0, $MaxLaenge) }
    if ([string]::IsNullOrWhiteSpace($t)) { return $Standard }
    return $t
}

# --- Projektzuordnung laden (optional) --------------------------------------
$projektVon = @{}
if ($NachProjekt) {
    # a) bevorzugt: Feld "projekt" direkt aus den Metadaten (suno-backup.js ab v3)
    foreach ($s in $songs) {
        if ($s.PSObject.Properties.Name -contains 'projekt' -and -not [string]::IsNullOrWhiteSpace($s.projekt)) {
            $projektVon[$s.id] = [string]$s.projekt
        }
    }
    if ($projektVon.Count) {
        $anzProjekte = ($projektVon.Values | Sort-Object -Unique).Count
        Write-Info "Projekte aus den Metadaten: $anzProjekte Projekte, $($projektVon.Count) Songs zugeordnet."
    }

    # b) Ergaenzung aus suno-projekte.json, falls vorhanden
    if ([string]::IsNullOrWhiteSpace($ProjektDatei)) {
        foreach ($k in @((Join-Path $Downloads 'suno-projekte.json'),
                         (Join-Path $Ziel 'Metadaten\suno-projekte.json'),
                         (Join-Path $Ziel 'suno-projekte.json'))) {
            if (Test-Path -LiteralPath $k) { $ProjektDatei = $k; break }
        }
    }
    if (-not [string]::IsNullOrWhiteSpace($ProjektDatei) -and (Test-Path -LiteralPath $ProjektDatei)) {
        $pj = Get-Content -LiteralPath $ProjektDatei -Raw -Encoding UTF8 | ConvertFrom-Json
        $ergaenzt = 0
        if ($pj.zuordnung) {
            foreach ($e in $pj.zuordnung.PSObject.Properties) {
                if (-not $projektVon.ContainsKey($e.Name)) { $projektVon[$e.Name] = [string]$e.Value; $ergaenzt++ }
            }
        }
        if ($ergaenzt) { Write-Info "Aus suno-projekte.json ergaenzt: $ergaenzt Songs." }
    }

    if (-not $projektVon.Count) {
        throw "Keine Projektzuordnung gefunden. Metadaten mit suno-backup.js v3 neu erzeugen (SunoBackup.list())."
    }
}

# --- Zielstruktur -----------------------------------------------------------
$dirAudio = Join-Path $Ziel 'Audio'
$dirTexte = Join-Path $Ziel 'Texte'
$dirMeta  = Join-Path $Ziel 'Metadaten'
foreach ($d in @($dirAudio, $dirTexte, $dirMeta)) {
    if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
}

# --- Audiodateien im Downloads-Ordner indizieren ----------------------------
# Dateiname: 0001_Titel_ab12cd34.wav   (Chrome haengt bei Dubletten " (1)" an)
$muster = '^(?<nr>\d{4})_.*_(?<kurz>[0-9a-zA-Z]{8})(?<dup>\s\(\d+\))?\.(?<ext>wav|mp3)$'
$index  = @{}
$dubletten = 0

$audioDateien = Get-ChildItem -LiteralPath $Downloads -File -ErrorAction SilentlyContinue |
    Where-Object { $_.Extension -match '^\.(wav|mp3)$' } |
    Sort-Object Length -Descending

foreach ($datei in $audioDateien) {
    $treffer = [regex]::Match($datei.Name, $muster)
    if (-not $treffer.Success) { continue }
    $kurz = $treffer.Groups['kurz'].Value
    if ($index.ContainsKey($kurz)) { $dubletten++ } else { $index[$kurz] = $datei }
}

Write-Info "$($index.Count) Audiodateien im Downloads-Ordner ($dubletten Dubletten werden ignoriert)."

# --- Bereits einsortierte Dateien indizieren (nach Song-ID, nicht nach Nummer)
$bereits = @{}
Get-ChildItem -LiteralPath $dirAudio -File -Recurse -ErrorAction SilentlyContinue |
    Where-Object { $_.Extension -match '^\.(wav|mp3)$' } |
    ForEach-Object {
        $t = [regex]::Match($_.Name, $muster)
        if ($t.Success -and -not $bereits.ContainsKey($t.Groups['kurz'].Value)) { $bereits[$t.Groups['kurz'].Value] = $_ }
    }
$prefixBenutzt = @{}
$maxNr = 0
function Register-Nummer {
    param([string] $Basis)
    if ($Basis.Length -lt 4) { return }
    $pfx = $Basis.Substring(0, 4)
    $script:prefixBenutzt[$pfx] = $true
    $n = 0
    if ([int]::TryParse($pfx, [ref]$n) -and $n -gt $script:maxNr) { $script:maxNr = $n }
}
foreach ($d in $bereits.Values) { Register-Nummer -Basis $d.Name }
if ($bereits.Count) { Write-Info "$($bereits.Count) Dateien liegen bereits im Backup (hoechste Nummer $maxNr)." }

# --- Verarbeiten ------------------------------------------------------------
$zeilen   = New-Object System.Collections.Generic.List[object]
$verschob = 0; $umsortiert = 0; $fehlend = 0; $gesamtBytes = 0L
$nr = 0

foreach ($s in $songs) {
    $nr++
    $nrTxt = '{0:d4}' -f $nr
    $kurz  = $s.id.Substring(0, 8)
    $erstellt = if ($s.erstellt -is [datetime]) { $s.erstellt.ToString('yyyy-MM-dd HH:mm') } else { [string]$s.erstellt }

    $titel = ConvertTo-Dateiname -Text $s.titel
    $basis = "${nrTxt}_${titel}_${kurz}"

    # Projektordner bestimmen
    $projekt = ''
    $unter   = ''
    if ($NachProjekt) {
        $projekt = if ($projektVon.ContainsKey($s.id)) { $projektVon[$s.id] } else { '' }
        $unter   = if ($projekt) { ConvertTo-Dateiname -Text $projekt -Standard '_Ohne_Projekt' -MaxLaenge 60 } else { '_Ohne_Projekt' }
    }
    $audioDir = if ($unter) { Join-Path $dirAudio $unter } else { $dirAudio }
    $texteDir = if ($unter) { Join-Path $dirTexte $unter } else { $dirTexte }
    foreach ($d in @($audioDir, $texteDir)) {
        if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
    }
    $sollAudio = (Resolve-Path -LiteralPath $audioDir).Path
    $sollTexte = (Resolve-Path -LiteralPath $texteDir).Path

    $audioName = ''
    $groesse   = 0L

    if ($bereits.ContainsKey($kurz)) {
        # schon im Backup: vorhandenen Dateinamen (und damit die Nummer) beibehalten
        $datei     = $bereits[$kurz]
        $audioName = $datei.Name
        $basis     = [System.IO.Path]::GetFileNameWithoutExtension($audioName)
        Register-Nummer -Basis $basis
        if ((Resolve-Path -LiteralPath $datei.DirectoryName).Path -ne $sollAudio) {
            Move-Item -LiteralPath $datei.FullName -Destination (Join-Path $audioDir $audioName) -Force
            $umsortiert++
        }
        $groesse = (Get-Item -LiteralPath (Join-Path $audioDir $audioName)).Length
        $gesamtBytes += $groesse
    }
    elseif ($index.ContainsKey($kurz)) {
        $quelle    = $index[$kurz]
        $qBasis    = [System.IO.Path]::GetFileNameWithoutExtension($quelle.Name) -replace '\s\(\d+\)$', ''
        if ($qBasis -match ('_' + [regex]::Escape($kurz) + '$')) { $basis = $qBasis }
        # Nummernkollision vermeiden (bereits einsortiert oder in diesem Lauf vergeben)
        if ($prefixBenutzt.ContainsKey($basis.Substring(0, 4))) {
            $maxNr++
            $basis = ('{0:d4}' -f $maxNr) + $basis.Substring(4)
        }
        Register-Nummer -Basis $basis
        $audioName = "$basis$($quelle.Extension.ToLower())"
        $zielDatei = Join-Path $audioDir $audioName
        if (-not (Test-Path -LiteralPath $zielDatei)) {
            if ($Kopieren) { Copy-Item -LiteralPath $quelle.FullName -Destination $zielDatei }
            else           { Move-Item -LiteralPath $quelle.FullName -Destination $zielDatei }
            $verschob++
        }
        $groesse = (Get-Item -LiteralPath $zielDatei).Length
        $gesamtBytes += $groesse
    }
    else {
        $fehlend++
        Write-Warn "Keine Audiodatei fuer $nrTxt - $($s.titel)"
    }

    # veraltete Textdateien aus frueheren Laeufen entfernen (anderer Ordner)
    Get-ChildItem -LiteralPath $dirTexte -Filter "*_$kurz.txt" -File -Recurse -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -ne "$basis.txt" -or (Resolve-Path -LiteralPath $_.DirectoryName).Path -ne $sollTexte } |
        Remove-Item -Force -ErrorAction SilentlyContinue

    # Textdatei schreiben
    $txt = @(
        "Titel:     $($s.titel)"
        "Erstellt:  $erstellt"
        "Stil:      $($s.stil)"
        "Modell:    $($s.modell)"
        "Dauer:     $($s.dauer_sek) s"
        "Projekt:   $(if ($projekt) { $projekt } else { '(keins)' })"
        "Suno-Link: $($s.suno_link)"
        "Audio:     $(if ($audioName) { $audioName } else { '(fehlt)' })"
        ''
        '--- Songtext ---'
        ''
        $(if ([string]::IsNullOrWhiteSpace($s.text)) { '(kein Text hinterlegt)' } else { $s.text })
    ) -join "`r`n"
    Set-Content -LiteralPath (Join-Path $texteDir "$basis.txt") -Value $txt -Encoding UTF8

    $zeilen.Add([pscustomobject]@{
        Nr        = $nrTxt
        Projekt   = $projekt
        Titel     = $s.titel
        Erstellt  = $erstellt
        Stil      = $s.stil
        Modell    = $s.modell
        DauerSek  = $s.dauer_sek
        Audio     = $audioName
        GroesseMB = if ($groesse) { [math]::Round($groesse / 1MB, 2) } else { 0 }
        HatText   = -not [string]::IsNullOrWhiteSpace($s.text)
        SunoLink  = $s.suno_link
        Id        = $s.id
    })
}

# --- Metadaten sichern + Index ---------------------------------------------
Copy-Item -LiteralPath $jsonPfad -Destination (Join-Path $dirMeta 'suno-metadaten.json') -Force
foreach ($f in @('suno-songs.csv', 'suno-texte.md', 'suno-fehlgeschlagen.json', 'suno-projekte.json')) {
    $q = Join-Path $Downloads $f
    if (Test-Path -LiteralPath $q) { Copy-Item -LiteralPath $q -Destination (Join-Path $dirMeta $f) -Force }
}
$zeilen | Export-Csv -LiteralPath (Join-Path $dirMeta 'Backup-Index.csv') -Delimiter ';' -NoTypeInformation -Encoding UTF8

# --- Zusammenfassung --------------------------------------------------------
Write-Host ''
Write-Info "Fertig."
Write-Info "  Songs gesamt:      $($songs.Count)"
Write-Info "  Neu einsortiert:   $verschob"
Write-Info "  Umsortiert:        $umsortiert"
Write-Info "  Audio vorhanden:   $($songs.Count - $fehlend)"
Write-Info "  Ohne Audiodatei:   $fehlend"
Write-Info "  Gesamtgroesse:     $([math]::Round($gesamtBytes / 1GB, 2)) GB"
Write-Info "  Ziel:              $Ziel"
if ($fehlend -gt 0) {
    Write-Warn "Fuer die fehlenden Songs im Browser erneut SunoBackup.run() starten - bereits geladene werden uebersprungen."
}

Skript: Prüfe-SunoBackup.ps1

Vollständiger Code zum Speichern als Pruefe-SunoBackup.ps1 im Backup-Ordner. Prüft ein fertiges Backup gegen die Metadaten: fehlende Audio- und Textdateien, verwaiste Dateien, Größen pro Projektordner sowie auffällig kleine (möglicherweise abgebrochene) Downloads.

<#
.SYNOPSIS
    Prueft ein fertiges Suno-Backup gegen die Metadaten: Vollstaendigkeit, Projektordner,
    verwaiste Dateien, Formate und Groessen.

.BEISPIEL
    .\Pruefe-SunoBackup.ps1
    .\Pruefe-SunoBackup.ps1 -Ziel "U:\Backup\Suno"
#>

[CmdletBinding()]
param(
    [string] $Ziel      = $PSScriptRoot,
    [string] $Metadaten = '',
    [switch] $Bericht
)

$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($Ziel)) { $Ziel = (Get-Location).Path }

$zeilen = New-Object System.Collections.Generic.List[string]
function Sag { param($t, $farbe = 'Gray') Write-Host $t -ForegroundColor $farbe; $zeilen.Add($t) }

Sag "=== Suno-Backup Pruefung ===" 'Cyan'
Sag "Ordner: $Ziel"
Sag ""

# --- Metadaten finden -------------------------------------------------------
if ([string]::IsNullOrWhiteSpace($Metadaten)) {
    foreach ($ort in @((Join-Path $Ziel 'Metadaten'), $Ziel, $PSScriptRoot)) {
        if (-not (Test-Path -LiteralPath $ort)) { continue }
        $k = Get-ChildItem -LiteralPath $ort -Filter 'suno-metadaten*.json' -File -ErrorAction SilentlyContinue |
             Sort-Object LastWriteTime -Descending
        if ($k) { $Metadaten = $k[0].FullName; break }
    }
}
if ([string]::IsNullOrWhiteSpace($Metadaten) -or -not (Test-Path -LiteralPath $Metadaten)) {
    throw "Keine suno-metadaten*.json gefunden. Mit -Metadaten <Pfad> angeben."
}
$songs = Get-Content -LiteralPath $Metadaten -Raw -Encoding UTF8 | ConvertFrom-Json
Sag ("Metadaten: {0} ({1} Songs)" -f (Split-Path $Metadaten -Leaf), $songs.Count) 'Cyan'

$dirAudio = Join-Path $Ziel 'Audio'
$dirTexte = Join-Path $Ziel 'Texte'
if (-not (Test-Path -LiteralPath $dirAudio)) { throw "Ordner 'Audio' fehlt unter '$Ziel'." }

# --- Dateien einlesen -------------------------------------------------------
$muster = '^(?<nr>\d{4})_.*_(?<kurz>[0-9a-zA-Z]{8})\.(?<ext>wav|mp3)$'
$audioIdx = @{}
$verwaist = New-Object System.Collections.Generic.List[string]

$alleAudio = Get-ChildItem -LiteralPath $dirAudio -File -Recurse -ErrorAction SilentlyContinue |
             Where-Object { $_.Extension -match '^\.(wav|mp3)$' }
foreach ($d in $alleAudio) {
    $t = [regex]::Match($d.Name, $muster)
    if (-not $t.Success) { $verwaist.Add($d.FullName); continue }
    $kurz = $t.Groups['kurz'].Value
    if ($audioIdx.ContainsKey($kurz)) { $verwaist.Add($d.FullName + '  (doppelte Song-ID)') }
    else { $audioIdx[$kurz] = $d }
}

$texteIdx = @{}
if (Test-Path -LiteralPath $dirTexte) {
    Get-ChildItem -LiteralPath $dirTexte -File -Recurse -Filter '*.txt' -ErrorAction SilentlyContinue |
        ForEach-Object {
            $t = [regex]::Match($_.Name, '_(?<kurz>[0-9a-zA-Z]{8})\.txt$')
            if ($t.Success) { $texteIdx[$t.Groups['kurz'].Value] = $_ }
        }
}

# --- Abgleich ---------------------------------------------------------------
$fehlendAudio = New-Object System.Collections.Generic.List[string]
$fehlendText  = New-Object System.Collections.Generic.List[string]
$falscherOrdner = New-Object System.Collections.Generic.List[string]
$proProjekt = @{}
$bekannteIds = @{}
$hatProjektFeld = $false

foreach ($s in $songs) {
    $kurz = $s.id.Substring(0, 8)
    $bekannteIds[$kurz] = $true
    $projekt = ''
    if ($s.PSObject.Properties.Name -contains 'projekt') {
        $hatProjektFeld = $true
        $projekt = [string]$s.projekt
    }
    if ([string]::IsNullOrWhiteSpace($projekt)) { $projekt = '_Ohne_Projekt' }

    if (-not $audioIdx.ContainsKey($kurz)) {
        $fehlendAudio.Add(("{0}  {1}" -f $kurz, $s.titel))
    }
    else {
        $d = $audioIdx[$kurz]
        $ordner = Split-Path $d.DirectoryName -Leaf
        if (-not $proProjekt.ContainsKey($ordner)) { $proProjekt[$ordner] = @{ Anzahl = 0; Bytes = 0L; Wav = 0; Mp3 = 0 } }
        $proProjekt[$ordner].Anzahl++
        $proProjekt[$ordner].Bytes += $d.Length
        if ($d.Extension -eq '.wav') { $proProjekt[$ordner].Wav++ } else { $proProjekt[$ordner].Mp3++ }
    }
    if ($texteIdx.Count -and -not $texteIdx.ContainsKey($kurz)) {
        $fehlendText.Add(("{0}  {1}" -f $kurz, $s.titel))
    }
}

foreach ($kurz in $audioIdx.Keys) {
    if (-not $bekannteIds.ContainsKey($kurz)) { $verwaist.Add($audioIdx[$kurz].FullName + '  (nicht in den Metadaten)') }
}

# --- Ausgabe ----------------------------------------------------------------
$gesamtBytes = ($alleAudio | Measure-Object -Property Length -Sum).Sum
$wav = ($alleAudio | Where-Object { $_.Extension -eq '.wav' }).Count
$mp3 = ($alleAudio | Where-Object { $_.Extension -eq '.mp3' }).Count
$klein = $alleAudio | Where-Object { $_.Length -lt 200000 }

Sag ""
Sag "--- Bestand ---" 'Cyan'
Sag ("Audiodateien:      {0}   ({1} WAV / {2} MP3)" -f $alleAudio.Count, $wav, $mp3)
Sag ("Gesamtgroesse:     {0} GB" -f [math]::Round($gesamtBytes / 1GB, 2))
Sag ("Textdateien:       {0}" -f $texteIdx.Count)
Sag ("Songs laut Liste:  {0}" -f $songs.Count)

Sag ""
Sag "--- Projektordner ---" 'Cyan'
if (-not $hatProjektFeld) { Sag "(Metadaten ohne Projektfeld - Ordner werden nur gezaehlt)" 'Yellow' }
$proProjekt.GetEnumerator() | Sort-Object { -$_.Value.Anzahl } | ForEach-Object {
    Sag ("{0,-42} {1,5} Songs  {2,8} GB   ({3} WAV / {4} MP3)" -f `
         $_.Key, $_.Value.Anzahl, [math]::Round($_.Value.Bytes / 1GB, 2), $_.Value.Wav, $_.Value.Mp3)
}

Sag ""
Sag "--- Befunde ---" 'Cyan'
$ok = $true
if ($fehlendAudio.Count) {
    $ok = $false
    Sag ("FEHLT: {0} Songs ohne Audiodatei" -f $fehlendAudio.Count) 'Red'
    $fehlendAudio | Select-Object -First 20 | ForEach-Object { Sag "   $_" }
    if ($fehlendAudio.Count -gt 20) { Sag ("   ... und {0} weitere" -f ($fehlendAudio.Count - 20)) }
} else { Sag "Alle Songs aus der Liste haben eine Audiodatei." 'Green' }

if ($fehlendText.Count) {
    $ok = $false
    Sag ("FEHLT: {0} Songs ohne Textdatei" -f $fehlendText.Count) 'Yellow'
    $fehlendText | Select-Object -First 10 | ForEach-Object { Sag "   $_" }
} elseif ($texteIdx.Count) { Sag "Zu jedem Song existiert eine Textdatei." 'Green' }

if ($verwaist.Count) {
    $ok = $false
    Sag ("AUFFAELLIG: {0} verwaiste oder doppelte Dateien" -f $verwaist.Count) 'Yellow'
    $verwaist | Select-Object -First 15 | ForEach-Object { Sag "   $_" }
    if ($verwaist.Count -gt 15) { Sag ("   ... und {0} weitere" -f ($verwaist.Count - 15)) }
} else { Sag "Keine verwaisten Dateien." 'Green' }

if ($klein.Count) {
    Sag ("HINWEIS: {0} Dateien unter 200 KB - bitte stichprobenartig anhoeren" -f $klein.Count) 'Yellow'
    $klein | Select-Object -First 10 | ForEach-Object { Sag ("   {0}  ({1} KB)" -f $_.Name, [math]::Round($_.Length / 1KB)) }
}

Sag ""
if ($ok) { Sag "ERGEBNIS: Backup ist vollstaendig und sauber sortiert." 'Green' }
else     { Sag "ERGEBNIS: Backup nutzbar, aber siehe Befunde oben." 'Yellow' }

if ($Bericht) {
    $pfad = Join-Path $Ziel 'Pruefbericht.txt'
    Set-Content -LiteralPath $pfad -Value ($zeilen -join "`r`n") -Encoding UTF8
    Write-Host "Bericht gespeichert: $pfad" -ForegroundColor Cyan
}

Skript: suno-projekte.js (Diagnose bei vielen Workspaces)

Nur bei Bedarf einsetzen, wenn die Projektzuordnung im Hauptskript nicht funktioniert:

/* ============================================================================
   SUNO PROJEKTE  –  v5
   ----------------------------------------------------------------------------
   Befehle (in dieser Reihenfolge ausprobieren):

     SunoProjekte.rohclip()    Zeigt einen Song im Rohformat aus dem Feed.
                               Wenn dort ein Feld wie project_id auftaucht,
                               brauchen wir gar keinen Projekt-Endpunkt.

     SunoProjekte.netz()       Listet ALLE API-Aufrufe der Seite auf (aus der
                               Performance-Timeline, unabhaengig von fetch-Hooks).
                               Vorher im UI auf "Workspaces" klicken.

     SunoProjekte.test(pfad)   Einzelnen Pfad ausprobieren.
     SunoProjekte.konfig(...)  Endpunkte setzen, danach holen().
     SunoProjekte.holen()      Zuordnung erstellen -> suno-projekte.json
   ========================================================================== */

(() => {
  'use strict';

  const CFG = { DELAY_MS: 400, RETRY_MAX: 6, RETRY_BASE_MS: 5000, MAX_PAGES: 500 };
  const BASIS = 'https://studio-api-prod.suno.com';

  let LISTE = null;   // { methode, pfad, body }
  let CLIPS = null;   // { methode, pfad, body }  mit {id} / {page}

  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const log   = (...a) => console.log('%c[Suno-Projekte]', 'color:#0ea5e9;font-weight:bold', ...a);
  const warn  = (...a) => console.warn('[Suno-Projekte]', ...a);

  try { performance.setResourceTimingBufferSize(3000); } catch (_) {}

  async function authHeaders() {
    if (!window.Clerk || !window.Clerk.session) throw new Error('Keine Suno-Session gefunden.');
    const h = {
      'Authorization': 'Bearer ' + (await window.Clerk.session.getToken()),
      'browser-token': JSON.stringify({ token: btoa(JSON.stringify({ timestamp: Date.now() })) }),
    };
    const dev = document.cookie.match(/suno_device_id=([^;]+)/);
    if (dev) h['device-id'] = dev[1];
    return h;
  }

  async function req(url, opts = {}) {
    for (let t = 0; ; t++) {
      let res;
      try {
        res = await fetch(url, { ...opts, headers: { ...(await authHeaders()), ...(opts.headers || {}) } });
      } catch (e) {
        if (t >= CFG.RETRY_MAX) throw e;
        await sleep(CFG.RETRY_BASE_MS * Math.pow(2, t));
        continue;
      }
      if ((res.status === 429 || res.status >= 500) && t < CFG.RETRY_MAX) {
        const ra = parseInt(res.headers.get('retry-after') || '', 10);
        await sleep(ra > 0 ? ra * 1000 : CFG.RETRY_BASE_MS * Math.pow(2, t));
        continue;
      }
      return res;
    }
  }

  function struktur(json) {
    if (Array.isArray(json)) {
      return { typ: 'array', laenge: json.length, felder: json[0] && typeof json[0] === 'object' ? Object.keys(json[0]) : [] };
    }
    if (json && typeof json === 'object') {
      const arrays = {};
      for (const k of Object.keys(json)) {
        if (Array.isArray(json[k])) {
          arrays[k] = { laenge: json[k].length, felder: json[k][0] && typeof json[k][0] === 'object' ? Object.keys(json[k][0]) : [] };
        }
      }
      return { typ: 'object', felder: Object.keys(json), arrays: arrays };
    }
    return { typ: typeof json };
  }

  /* ---------- 1) Rohdaten eines Songs -------------------------------------- */
  async function rohclip() {
    for (const pfad of ['/api/feed/v2/?page=0', '/api/feed/v3?page=0', '/api/feed/v3']) {
      try {
        const res = await req(BASIS + pfad);
        if (!res.ok) { log(pfad + ' -> HTTP ' + res.status); continue; }
        const json = await res.json();
        const liste = Array.isArray(json) ? json : (json.clips || json.items || json.results || json.data || null);
        if (!liste || !liste.length) { log(pfad + ' -> keine Clips', struktur(json)); continue; }
        const c = liste[0];
        log(pfad + ' -> ' + liste.length + ' Clips');
        log('Felder des Clips:', Object.keys(c));
        const treffer = Object.keys(c).filter(k => /project|workspace|folder|album|collection/i.test(k));
        log(treffer.length ? 'INTERESSANT – Projektfelder gefunden: ' + treffer.join(', ')
                           : 'Keine Projektfelder auf oberster Ebene.');
        if (c.metadata) {
          const t2 = Object.keys(c.metadata).filter(k => /project|workspace|folder|album|collection/i.test(k));
          log('metadata-Felder:', Object.keys(c.metadata));
          if (t2.length) log('INTERESSANT – in metadata: ' + t2.join(', '));
        }
        log('Vollstaendiger Clip:', c);
        return c;
      } catch (e) { log(pfad + ' -> ' + e.message); }
    }
    warn('Kein Feed lesbar.');
  }

  /* ---------- 2) Alle API-Aufrufe der Seite -------------------------------- */
  function netz(filter) {
    const eintraege = performance.getEntriesByType('resource')
      .map(e => e.name)
      .filter(u => u.indexOf('/api/') !== -1)
      .filter(u => u.indexOf('google') === -1 && u.indexOf('clerk') === -1 && u.indexOf('sentry') === -1);
    const zaehler = new Map();
    for (const u of eintraege) {
      const kurz = u.replace(BASIS, '');
      if (filter && kurz.indexOf(filter) === -1) continue;
      zaehler.set(kurz, (zaehler.get(kurz) || 0) + 1);
    }
    const arr = [...zaehler.entries()].sort((a, b) => b[1] - a[1]);
    if (!arr.length) { warn('Keine API-Aufrufe in der Timeline. Im UI navigieren und erneut aufrufen.'); return []; }
    log(arr.length + ' unterschiedliche API-Aufrufe seit dem Seitenaufbau:');
    arr.forEach(([u, n]) => console.log('   ' + String(n).padStart(3) + 'x  ' + u));
    return arr.map(([u]) => u);
  }

  /* ---------- 3) Einzeltest ------------------------------------------------ */
  async function test(pfad, methode, body) {
    const url = pfad.indexOf('http') === 0 ? pfad : BASIS + pfad;
    const opts = { method: methode || 'GET' };
    if (body) { opts.body = typeof body === 'string' ? body : JSON.stringify(body); opts.headers = { 'Content-Type': 'application/json' }; }
    const res = await req(url, opts);
    log(opts.method + ' ' + url + ' -> HTTP ' + res.status);
    if (!res.ok) return null;
    const json = await res.json();
    log('Struktur:', struktur(json));
    log('Rohdaten:', json);
    return json;
  }

  /* ---------- 4) Zuordnung ------------------------------------------------- */
  function konfig(liste, clips) {
    LISTE = liste || LISTE; CLIPS = clips || CLIPS;
    log('Konfiguration gesetzt.', { LISTE, CLIPS });
  }

  function alsListe(json) {
    if (Array.isArray(json)) return json;
    if (!json || typeof json !== 'object') return null;
    for (const k of ['projects', 'workspaces', 'clips', 'playlist_clips', 'playlists',
                     'items', 'results', 'data', 'objects', 'songs', 'pinned_clips']) {
      if (Array.isArray(json[k])) return json[k];
    }
    return null;
  }

  const clipIdVon = c => c && (c.clip && c.clip.id ? c.clip.id : (c.id || c.clip_id || null));
  const nameVon = p => String(p.name || p.title || p.display_name || ('Projekt_' + String(p.id).slice(0, 8))).trim();

  async function abfragen(cfg, ersatz) {
    let pfad = cfg.pfad, body = cfg.body || null;
    for (const [k, v] of Object.entries(ersatz)) {
      pfad = pfad.split('{' + k + '}').join(v);
      if (body) body = body.split('{' + k + '}').join(v);
    }
    const opts = { method: cfg.methode || 'GET' };
    if (body) { opts.body = body; opts.headers = { 'Content-Type': 'application/json' }; }
    const res = await req(BASIS + pfad, opts);
    if (!res.ok) return null;
    return res.json();
  }

  function speichern(ergebnis) {
    const blob = new Blob([JSON.stringify(ergebnis, null, 2)], { type: 'application/json' });
    const href = URL.createObjectURL(blob);
    const a = Object.assign(document.createElement('a'), { href, download: 'suno-projekte.json' });
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(href), 30000);
  }

  function bilanz(ergebnis) {
    log('Fertig: ' + ergebnis.projekte.length + ' Projekte, ' + Object.keys(ergebnis.zuordnung).length + ' Songs zugeordnet.');
    const bekannt = (window.SunoBackup && window.SunoBackup.songs && window.SunoBackup.songs.length) || 0;
    if (bekannt) {
      const fehlend = window.SunoBackup.songs.filter(s => !ergebnis.zuordnung[s.id]).length;
      log('Von ' + bekannt + ' Songs haben ' + fehlend + ' kein Projekt (landen unter _Ohne_Projekt).');
    }
  }

  async function holen() {
    if (!LISTE || !CLIPS) {
      warn('Endpunkte noch nicht bekannt. Erst rohclip() bzw. netz() ausfuehren.');
      warn("Manuell: SunoProjekte.konfig({pfad:'/api/project/list'}, {pfad:'/api/project/{id}/clips?page={page}'})");
      return;
    }
    const eintraege = alsListe(await abfragen(LISTE, {}));
    if (!eintraege) { warn('Projektliste nicht lesbar.'); return; }
    log(eintraege.length + ' Projekte. Lese Songs ...');

    const zuordnung = {}, mehrfach = {}, uebersicht = [];
    for (const [i, p] of eintraege.entries()) {
      const name = nameVon(p);
      const ids = [];
      for (let page = 0; page < CFG.MAX_PAGES; page++) {
        let json = null;
        try { json = await abfragen(CLIPS, { id: p.id, page: page }); } catch (e) { warn(name + ': ' + e.message); break; }
        const l = json ? alsListe(json) : null;
        if (!l || !l.length) break;
        let neu = 0;
        for (const c of l) { const cid = clipIdVon(c); if (cid && ids.indexOf(cid) === -1) { ids.push(cid); neu++; } }
        if (!neu) break;
        await sleep(CFG.DELAY_MS);
      }
      if (!ids.length) { warn('[' + (i + 1) + '/' + eintraege.length + '] ' + name + ': keine Songs lesbar'); continue; }
      for (const cid of ids) {
        if (!zuordnung[cid]) zuordnung[cid] = name;
        (mehrfach[cid] = mehrfach[cid] || []).push(name);
      }
      uebersicht.push({ id: p.id, name: name, anzahl: ids.length });
      log('[' + (i + 1) + '/' + eintraege.length + '] ' + name + ': ' + ids.length + ' Songs');
      await sleep(CFG.DELAY_MS);
    }

    const ergebnis = {
      erstellt: new Date().toISOString(),
      endpunkt: { basis: BASIS, liste: LISTE, clips: CLIPS },
      projekte: uebersicht, zuordnung: zuordnung,
      mehrfach: Object.fromEntries(Object.entries(mehrfach).filter(([, v]) => v.length > 1)),
    };
    speichern(ergebnis);
    bilanz(ergebnis);
    window.SunoProjekte.ergebnis = ergebnis;
    return ergebnis;
  }

  window.SunoProjekte = { cfg: CFG, rohclip, netz, test, konfig, holen, speichern, ergebnis: null };
  log('Bereit (v5).  Start mit:  SunoProjekte.rohclip()');
})();

Rechtliche Einordnung und Ausblick

Das beschriebene Vorgehen sichert ausschließlich Inhalte des eigenen Accounts über die eigene, bereits authentifizierte Browser-Session – vergleichbar mit einem manuellen Download, nur automatisiert. Es werden keine fremden Zugangsdaten verwendet, keine Bezahlschranken umgangen und keine technischen Schutzmaßnahmen ausgehebelt. Wer Songs kommerziell nutzen will, sollte weiterhin die jeweils zum Downloadzeitpunkt gültigen Suno-Nutzungsbedingungen beachten, insbesondere die im ersten Teil dieser Serie beschriebene Kopplung der kommerziellen Nutzungsrechte an den gewählten Tarif.

Da sich interne API-Pfade von Web-Diensten jederzeit ändern können, ist die Endpunkt-Erkennung im Skript bewusst mit mehreren Kandidaten und einer Diagnosefunktion ausgestattet. Bricht ein Lauf plötzlich mit „Kein passender Library-Endpunkt gefunden" ab, ist das kein Zeichen für einen grundsätzlichen Fehler, sondern ein Hinweis, dass Suno seine Struktur angepasst hat – die Kandidatenlisten müssten dann aktualisiert werden.

Passende Anleitungen auf S-EDV

  1. Suno kappt Downloads ab September: Was das für jede Cloud-Bibliothek bedeutet
  2. Suno vor dem 3. September sichern: Diese Songs solltest du zuerst herunterladen
  3. Microsoft 365 Backup: warum es nötig ist und wie es geht

Quellen

  1. Suno: An update to our downloads policy and Terms of Service
  2. MDN Web Docs: Blob-API (Grundlage für den Datei-Download im Skript)
  3. Microsoft Learn: PowerShell-Skripting-Übersicht