This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/services/musik_scanner.dart
T
Dustin bf0f544e4b v2.52.1 — F2: YT-Links nur als Server-Fallback
## YT-Quell-URL (Server ist die Quelle)
- cloud_service.ytUrlSetzen(): POST /api/v1/cloud/yt-url (per-User UPDATE user_songs.yt_url)
- musik_scanner._sucheYtUrls(): durchsucht NUR Songs mit cloud_id, die auf dem Server noch keine yt_url haben; Treffer werden auf den Server hochgeladen statt lokal gespeichert
- db_helper: ytUrlAktualisieren/songsOhneYtUrl entfernt, songsMitCloudId() ergänzt
- download_service: yt_url wird beim Download nicht mehr lokal persistiert

## Fallback-Kette reDownload (korrupte/fehlende Datei)
- 1) Server-Download via cloud_id + Magic-Byte-Check (primäre Quelle)
- 2) Server-ytUrl (aus listSongs) → yt-proxy als letzte Chance
- 3) Legacy: lokal gespeicherte ytUrl (Bestandsdaten vor v2.52)
- song_tile: „Neu laden“ erscheint bei istKorrupt && (cloudId != null || ytUrl != null)
- 3 neue Tests (ytUrlSetzen: ok/error/401) → 111 Tests grün, analyze 0 Issues
2026-08-05 10:40:37 +02:00

556 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../models/song.dart';
import '../database/db_helper.dart';
import '../utils/audio_validator.dart';
import 'id3_reader.dart';
import '../services/melo_logger.dart';
import 'auth_service.dart';
import 'cloud_service.dart';
class MusikScanner {
static final MusikScanner _instanz = MusikScanner._();
factory MusikScanner() => _instanz;
MusikScanner._();
final DbHelper _db = DbHelper();
int _neueSongs = 0;
int get anzahlNeueSongs => _neueSongs;
Future<bool> frageSpeicherZugriff() async {
// App-interner Speicher (Cloud-Downloads) benötigt NIE Berechtigungen
if (Platform.isIOS) return true;
if (Platform.isAndroid) {
// Android 13+: READ_MEDIA_AUDIO
var status = await Permission.audio.status;
if (!status.isGranted) status = await Permission.audio.request();
if (status.isGranted) return true;
// Fallback für Android 10 und älter
var storageStatus = await Permission.storage.status;
if (!storageStatus.isGranted) storageStatus = await Permission.storage.request();
return storageStatus.isGranted;
}
return true;
}
Future<List<Song>> scanneMusikOrdner() async {
_neueSongs = 0;
final gefunden = <Song>[];
final pfade = await _sammleMusikPfade();
for (final pfad in pfade) {
try {
final file = File(pfad);
if (!await file.exists()) continue;
final stat = await file.stat();
final tags = Id3Reader.lesen(pfad);
// ── Magic-Byte-Prüfung ──
final istKorrupt = !hatValideMagicBytes(file);
if (istKorrupt) {
MeloLogger().fehler('magic_bytes_scan', 'Ungültiger Audio-Header: $pfad');
}
// Cover-Bytes als Bilddatei speichern
String? coverPfad;
if (tags['cover'] != null && tags['cover'] is List<int>) {
final bytes = tags['cover'] as List<int>;
if (bytes.isNotEmpty) {
try {
final docDir = await getApplicationDocumentsDirectory();
final coversDir = Directory('${docDir.path}/covers');
if (!await coversDir.exists()) await coversDir.create(recursive: true);
final fileHash = pfad.hashCode.abs();
final coverFile = File('${coversDir.path}/cover_$fileHash.jpg');
await coverFile.writeAsBytes(bytes);
coverPfad = coverFile.path;
} catch (e) {
MeloLogger().fehler('cover_speichern', e);
}
}
}
gefunden.add(Song(
titel: (tags['titel'] as String).isNotEmpty ? tags['titel'] : _dateiNameOhneEndung(pfad),
kuenstler: (tags['kuenstler'] as String).isNotEmpty ? tags['kuenstler'] : 'Unbekannt',
album: tags['album'] ?? '',
jahr: _nichtLeer(tags['jahr']),
genre: _nichtLeer(tags['genre']),
track: _nichtLeer(tags['track']),
dauerSekunden: await _ermittleDauer(pfad),
dateiPfad: pfad,
coverPfad: coverPfad,
groesseBytes: stat.size,
istHeruntergeladen: true,
istKorrupt: istKorrupt,
downloadQuelle: 'local',
));
} catch (e) {
MeloLogger().fehler('scanner_datei', e);
continue;
}
}
// In DB speichern
final vorhandene = await _db.alleSongs();
final vorhandenePfade = vorhandene.map((s) => s.dateiPfad).toSet();
final neue = gefunden.where((s) => !vorhandenePfade.contains(s.dateiPfad)).toList();
if (neue.isNotEmpty) {
await _db.songsEinfuegen(neue);
_neueSongs = neue.length;
}
// ── YouTube-Quell-URLs für Songs ohne ytUrl nachschlagen ──
await _sucheYtUrls();
return gefunden;
}
Future<List<String>> _sammleMusikPfade() async {
final pfade = <String>{};
// 1. PRIMÄR: App-interner + Downloads-Ordner
try {
final appDir = await getApplicationDocumentsDirectory();
final internDir = Directory('${appDir.path}/music');
if (await internDir.exists()) await _durchsucheOrdner(internDir, pfade);
// Downloads/Melo (für Dateimanager sichtbar)
if (Platform.isAndroid) {
final dlDir = await getDownloadsDirectory();
if (dlDir != null) {
final meloDir = Directory('${dlDir.path}/Melo');
if (await meloDir.exists()) await _durchsucheOrdner(meloDir, pfade);
}
}
} catch (e) {
MeloLogger().fehler('scanner_intern_pfad', e);
}
// 2. Externe System-Ordner (nur Android, nur wenn Berechtigung vorhanden)
if (Platform.isAndroid) {
final hatZugriff = await frageSpeicherZugriff();
if (!hatZugriff) return pfade.toList();
final ordner = <String>[
'/storage/emulated/0/Music',
'/storage/emulated/0/Download',
'/storage/emulated/0/Musik',
'/storage/emulated/0/Downloads',
'/sdcard/Music',
'/sdcard/Download',
];
// Externen Speicherpfad NUR für App-eigene Daten, nicht ganz /storage
try {
final extern = await getExternalStorageDirectory();
if (extern != null) {
// Nur scannen wenn es ein spezifischer Unterordner ist (nicht Root)
final externPath = extern.path;
if (externPath.contains('Android/data') || externPath.contains('Melo')) {
ordner.add(externPath);
}
}
} catch (_) {}
for (final ord in ordner) {
try {
final dir = Directory(ord);
if (await dir.exists()) {
await _durchsucheOrdner(dir, pfade);
}
} catch (e) {
MeloLogger().fehler('scanner_ordner_zugriff_${ord.split('/').last}', e);
}
}
}
return pfade.toList();
}
Future<void> _durchsucheOrdner(Directory dir, Set<String> pfade, {int tiefe = 0}) async {
if (tiefe > 4) return;
try {
final stream = dir.list(followLinks: false);
await for (final entity in stream.handleError((_) {})) {
if (entity is File) {
final ext = entity.path.toLowerCase();
if (ext.endsWith('.mp3') || ext.endsWith('.m4a') ||
ext.endsWith('.flac') || ext.endsWith('.wav') ||
ext.endsWith('.aac') || ext.endsWith('.ogg')) {
pfade.add(entity.path);
}
} else if (entity is Directory) {
await _durchsucheOrdner(entity, pfade, tiefe: tiefe + 1);
}
}
} catch (e) {
MeloLogger().fehler('scanner_durchsuchen_fehler', '$dir: $e');
}
}
/// Leichtgewichtige Dauer-Ermittlung OHNE AudioPlayer (Issue #15).
/// Parst Container-Header direkt aus der Datei nur stdlib, kein neues Package:
/// - MP3: MPEG-Frame-Header (Bitrate) → CBR-Schätzung
/// - M4A: moov/mvhd-Box (Timescale + Duration)
/// - FLAC: STREAMINFO (TotalSamples / SampleRate)
/// - WAV: data-Chunk / ByteRate
/// - OGG/AAC: 0 (überspringen, Dauer unbekannt)
Future<int> _ermittleDauer(String pfad) async {
try {
final file = File(pfad);
if (!await file.exists()) return 0;
final size = await file.length();
if (size < 16) return 0;
final raf = await file.open();
try {
await raf.setPosition(0);
final kopf = await raf.read(12);
if (kopf.length < 4) return 0;
// MP3: ID3v2-Tag oder direkter MPEG-Frame-Sync
if (kopf[0] == 0x49 && kopf[1] == 0x44 && kopf[2] == 0x33) {
return await _dauerMp3(raf, size, id3v2: true);
}
if (kopf[0] == 0xFF && (kopf[1] & 0xE0) == 0xE0) {
return await _dauerMp3(raf, size, id3v2: false);
}
// FLAC: "fLaC"
if (kopf[0] == 0x66 && kopf[1] == 0x4C &&
kopf[2] == 0x61 && kopf[3] == 0x43) {
return await _dauerFlac(raf);
}
// WAV: "RIFF"
if (kopf[0] == 0x52 && kopf[1] == 0x49 &&
kopf[2] == 0x46 && kopf[3] == 0x46) {
return await _dauerWav(raf, size);
}
// M4A/AAC: ftyp-Box an Position 4
if (kopf.length >= 8 &&
kopf[4] == 0x66 && kopf[5] == 0x74 &&
kopf[6] == 0x79 && kopf[7] == 0x70) {
return await _dauerM4a(raf, size);
}
return 0; // OGG/AAC: überspringen
} finally {
await raf.close();
}
} catch (_) {
return 0;
}
}
// ── MP3: MPEG-Frame-Header (Bitraten-Tabellen, kbps) ──
static const List<int> _mpeg1Bitraten = [
// Index 0 = free-format (nicht unterstützt)
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320,
];
static const List<int> _mpeg2Bitraten = [
0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160,
];
/// Parst einen MPEG-Audio-Frame-Header ab Position [i].
/// Liefert (bitrateKbps, frameLen) oder null bei ungültigem Header.
(int, int)? _parseMpegHeader(Uint8List d, int i) {
if (i + 3 >= d.length) return null;
if (d[i] != 0xFF) return null;
if ((d[i + 1] & 0xE0) != 0xE0) return null;
final versionBits = (d[i + 1] >> 3) & 0x03;
final layerBits = (d[i + 1] >> 1) & 0x03;
if (versionBits == 1) return null; // reserviert
if (layerBits == 0) return null; // reserviert
final bitrateIdx = d[i + 2] >> 4;
final sampleIdx = (d[i + 2] >> 2) & 0x03;
final padding = (d[i + 2] >> 1) & 0x01;
if (bitrateIdx == 15 || bitrateIdx == 0 || sampleIdx == 3) return null;
final isMpeg1 = versionBits == 3;
final layer1 = layerBits == 3; // Layer I
final bitrate = (isMpeg1 ? _mpeg1Bitraten : _mpeg2Bitraten)[bitrateIdx];
final sampleRate = isMpeg1
? (sampleIdx == 0 ? 44100 : sampleIdx == 1 ? 48000 : 32000)
: versionBits == 2
? (sampleIdx == 0 ? 22050 : sampleIdx == 1 ? 24000 : 16000)
: (sampleIdx == 0 ? 11025 : sampleIdx == 1 ? 12000 : 8000);
final frameLen = layer1
? (12 * bitrate * 1000 ~/ sampleRate + padding) * 4
: 144 * bitrate * 1000 ~/ sampleRate + padding;
if (frameLen < 24) return null;
return (bitrate, frameLen);
}
Future<int> _dauerMp3(RandomAccessFile raf, int dateiGroesse,
{required bool id3v2}) async {
// ID3v2-Tag überspringen (Syncsafe-Größe in Bytes 6-9)
var start = 0;
if (id3v2) {
await raf.setPosition(0);
final id3 = await raf.read(10);
if (id3.length >= 10) {
var tagSize = 0;
for (var i = 6; i < 10; i++) {
tagSize = (tagSize << 7) | (id3[i] & 0x7F);
}
start = 10 + tagSize;
}
}
// Ersten gültigen Frame-Header suchen (max. 2 MB Scan für große Cover-Tags)
const maxScan = 2 * 1024 * 1024;
final scanBis = (start + maxScan < dateiGroesse) ? start + maxScan : dateiGroesse;
var scanPos = start;
var rest = Uint8List(0); // bis zu 3 Bytes Überlappung zwischen Chunks
while (scanPos < scanBis) {
await raf.setPosition(scanPos);
final chunk = await raf.read(4096);
if (chunk.isEmpty) break;
final data = Uint8List.fromList([...rest, ...chunk]);
for (var i = 0; i < data.length - 4; i++) {
final header = _parseMpegHeader(data, i);
if (header == null) continue;
final abs = scanPos - rest.length + i;
final frameLen = header.$2;
// 2 Folge-Frames müssen ebenfalls Sync haben (False-Positive-Schutz)
if (abs + 2 * frameLen > dateiGroesse) continue;
if (!await _hatFrameSync(raf, abs + frameLen)) continue;
if (!await _hatFrameSync(raf, abs + 2 * frameLen)) continue;
final audioBytes = dateiGroesse - abs;
if (audioBytes <= 0) return 0;
// CBR-Schätzung: audioBytes * 8 Bit / Bitrate
return audioBytes * 8 ~/ (header.$1 * 1000);
}
// Nächster Chunk mit 3-Byte-Überlappung
final uLen = data.length > 3 ? 3 : data.length;
rest = Uint8List.fromList(data.sublist(data.length - uLen));
scanPos += chunk.length;
}
return 0;
}
Future<bool> _hatFrameSync(RandomAccessFile raf, int pos) async {
if (pos < 0) return false;
await raf.setPosition(pos);
final b = await raf.read(2);
return b.length == 2 && b[0] == 0xFF && (b[1] & 0xE0) == 0xE0;
}
// ── M4A: moov → mvhd (Timescale + Duration) ──
Future<int> _dauerM4a(RandomAccessFile raf, int dateiGroesse) async {
var pos = 0;
while (pos + 8 <= dateiGroesse) {
await raf.setPosition(pos);
final kopf = await raf.read(8);
if (kopf.length < 8) break;
var boxGroesse = _u32(kopf, 0);
final typ = String.fromCharCodes(kopf.sublist(4, 8));
if (boxGroesse == 1) {
// 64-Bit-Größe (nur bei >4GB-Dateien relevant)
final ext = await raf.read(8);
if (ext.length < 8) break;
final high = _u32(ext, 0);
boxGroesse = high > 0 ? dateiGroesse - pos : _u32(ext, 4);
} else if (boxGroesse == 0) {
boxGroesse = dateiGroesse - pos; // Box reicht bis EOF
}
if (boxGroesse < 8) break;
if (typ == 'moov') {
return await _dauerMvhd(raf, pos + 8, boxGroesse - 8);
}
pos += boxGroesse;
}
return 0;
}
Future<int> _dauerMvhd(RandomAccessFile raf, int start, int laenge) async {
final ende = start + laenge;
var pos = start;
while (pos + 8 <= ende) {
await raf.setPosition(pos);
final kopf = await raf.read(8);
if (kopf.length < 8) break;
var boxGroesse = _u32(kopf, 0);
final typ = String.fromCharCodes(kopf.sublist(4, 8));
if (boxGroesse == 0) boxGroesse = ende - pos;
if (boxGroesse < 8) break;
if (typ == 'mvhd') {
await raf.setPosition(pos + 8);
final payload = await raf.read(32);
if (payload.length < 20) return 0;
final version = payload[0];
if (version == 1) {
if (payload.length < 32) return 0;
final timescale = _u32(payload, 20);
final duration = _u64(payload, 24);
if (timescale == 0) return 0;
return duration ~/ timescale;
}
final timescale = _u32(payload, 12);
final duration = _u32(payload, 16);
if (timescale == 0) return 0;
return duration ~/ timescale;
}
pos += boxGroesse;
}
return 0;
}
// ── FLAC: STREAMINFO (TotalSamples / SampleRate) ──
Future<int> _dauerFlac(RandomAccessFile raf) async {
await raf.setPosition(4);
final blockKopf = await raf.read(4);
if (blockKopf.length < 4) return 0;
final blockTyp = blockKopf[0] & 0x7F;
final blockLen = (blockKopf[1] << 16) | (blockKopf[2] << 8) | blockKopf[3];
if (blockTyp != 0 || blockLen < 18) return 0; // STREAMINFO erwartet
await raf.setPosition(8);
final d = await raf.read(blockLen);
if (d.length < 18) return 0;
final sampleRate = (d[10] << 12) | (d[11] << 4) | (d[12] >> 4);
final totalSamples = ((d[13] & 0x0F) << 32) |
(d[14] << 24) | (d[15] << 16) | (d[16] << 8) | d[17];
if (sampleRate == 0) return 0;
return totalSamples ~/ sampleRate;
}
// ── WAV: data-Chunk / ByteRate ──
Future<int> _dauerWav(RandomAccessFile raf, int dateiGroesse) async {
// fmt-Chunk: ByteRate liegt bei Offset 28 (nach RIFF+WAVE+fmt -Header)
await raf.setPosition(24);
final b = await raf.read(8);
if (b.length < 8) return 0;
final byteRate = _u32le(b, 4);
// data-Chunk finden (Chunks word-aligned)
var pos = 12;
while (pos + 8 <= dateiGroesse) {
await raf.setPosition(pos);
final kopf = await raf.read(8);
if (kopf.length < 8) break;
final size = _u32le(kopf, 4);
final typ = String.fromCharCodes(kopf.sublist(0, 4));
if (typ == 'data') {
if (byteRate == 0) return 0;
return size ~/ byteRate;
}
if (size < 8) break;
pos += 8 + size + (size % 2);
}
return 0;
}
// ── Byte-Helfer ──
static int _u32(List<int> b, int o) =>
(b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3];
static int _u32le(List<int> b, int o) =>
(b[o + 3] << 24) | (b[o + 2] << 16) | (b[o + 1] << 8) | b[o];
static int _u64(List<int> b, int o) =>
(b[o] << 56) | (b[o + 1] << 48) | (b[o + 2] << 40) | (b[o + 3] << 32) |
(b[o + 4] << 24) | (b[o + 5] << 16) | (b[o + 6] << 8) | b[o + 7];
String _dateiNameOhneEndung(String pfad) {
final name = pfad.split('/').last;
final dot = name.lastIndexOf('.');
return dot > 0 ? name.substring(0, dot) : name;
}
String? _nichtLeer(dynamic wert) {
if (wert == null) return null;
final s = wert.toString().trim();
return s.isEmpty ? null : s;
}
/// Sucht YouTube-Quell-URLs für Songs MIT cloud_id, die auf dem Server
/// noch keine yt_url haben (max 50 pro Scan, 1/s Rate-Limit). Treffer
/// werden auf den SERVER hochgeladen (POST /api/v1/cloud/yt-url) statt
/// lokal gespeichert (F2: yt_url ist eine Server-Eigenschaft).
Future<void> _sucheYtUrls() async {
const maxSuchanfragen = 50;
const suchUrlBasis = 'https://yt.baka-net.de/api/search';
final cloud = CloudService();
// Server-Songs, die bereits eine yt_url haben → nicht erneut suchen
final serverSongs = await cloud.listSongs();
final serverMitYt = serverSongs
.where((s) => (s['ytUrl']?.toString() ?? '').isNotEmpty)
.map((s) => s['id']?.toString() ?? '')
.where((id) => id.isNotEmpty)
.toSet();
final kandidaten = await _db.songsMitCloudId(limit: maxSuchanfragen);
if (kandidaten.isEmpty) return;
int gefunden = 0;
for (final song in kandidaten) {
if (gefunden >= maxSuchanfragen) break;
final cloudId = song.cloudId ?? '';
if (cloudId.isEmpty || serverMitYt.contains(cloudId)) continue;
try {
final query = '${song.kuenstler} ${song.titel}';
final url = '$suchUrlBasis?q=${Uri.encodeQueryComponent(query)}';
final antwort = await http.get(
Uri.parse(url),
headers: AuthService().authHeader,
).timeout(
const Duration(seconds: 10),
);
if (antwort.statusCode == 200) {
final daten = jsonDecode(antwort.body);
if (daten is List && daten.isNotEmpty) {
final erstes = daten[0];
final ytUrl = erstes['url'] as String?;
if (ytUrl != null && ytUrl.isNotEmpty) {
final ok = await cloud.ytUrlSetzen(cloudId, ytUrl);
if (ok) {
gefunden++;
MeloLogger().zustand('yt_suche_treffer', {
'song_id': song.id,
'cloud_id': cloudId,
'titel': song.titel,
'yt_url': ytUrl,
});
}
}
}
}
} catch (e) {
MeloLogger().fehler('yt_suche_fehler', '${song.titel}: $e');
}
// Rate-Limit: 1 Anfrage pro Sekunde
await Future.delayed(const Duration(seconds: 1));
}
if (gefunden > 0) {
MeloLogger().zustand('yt_suche_abschluss', {'gefunden': gefunden});
}
}
}