256 lines
8.1 KiB
Dart
256 lines
8.1 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:just_audio/just_audio.dart';
|
|
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 'melo_logger.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();
|
|
|
|
final player = AudioPlayer();
|
|
|
|
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'] ?? '',
|
|
dauerSekunden: await _ermittleDauer(player, pfad),
|
|
dateiPfad: pfad,
|
|
coverPfad: coverPfad,
|
|
groesseBytes: stat.size,
|
|
istHeruntergeladen: true,
|
|
istKorrupt: istKorrupt,
|
|
downloadQuelle: 'local',
|
|
));
|
|
} catch (e) {
|
|
MeloLogger().fehler('scanner_datei', e);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
try { await player.dispose(); } catch (_) {}
|
|
|
|
// 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)
|
|
if (Platform.isAndroid) {
|
|
final ordner = <String>[
|
|
'/storage/emulated/0/Music',
|
|
'/storage/emulated/0/Download',
|
|
'/storage/emulated/0/Musik',
|
|
'/storage/emulated/0/Downloads',
|
|
'/sdcard/Music',
|
|
'/sdcard/Download',
|
|
];
|
|
|
|
try {
|
|
final extern = await getExternalStorageDirectory();
|
|
if (extern != null) ordner.add(extern.path);
|
|
} 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');
|
|
}
|
|
}
|
|
|
|
Future<int> _ermittleDauer(AudioPlayer player, String pfad) async {
|
|
try {
|
|
await player.setFilePath(pfad).timeout(const Duration(milliseconds: 1500));
|
|
final dauer = player.duration;
|
|
return dauer?.inSeconds ?? 0;
|
|
} catch (e) {
|
|
try { await player.stop(); } catch (_) {}
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
String _dateiNameOhneEndung(String pfad) {
|
|
final name = pfad.split('/').last;
|
|
final dot = name.lastIndexOf('.');
|
|
return dot > 0 ? name.substring(0, dot) : name;
|
|
}
|
|
|
|
/// Sucht YouTube-Quell-URLs für Songs ohne ytUrl (max 50 pro Scan, 1/s Rate-Limit)
|
|
Future<void> _sucheYtUrls() async {
|
|
const maxSuchanfragen = 50;
|
|
const suchUrlBasis = 'https://yt.baka-net.de/api/search';
|
|
|
|
final songsOhneUrl = await _db.songsOhneYtUrl(limit: maxSuchanfragen);
|
|
if (songsOhneUrl.isEmpty) return;
|
|
|
|
int gefunden = 0;
|
|
for (final song in songsOhneUrl) {
|
|
if (gefunden >= maxSuchanfragen) break;
|
|
|
|
try {
|
|
final query = '${song.kuenstler} ${song.titel}';
|
|
final url = '$suchUrlBasis?q=${Uri.encodeQueryComponent(query)}';
|
|
|
|
final antwort = await http.get(Uri.parse(url)).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) {
|
|
await _db.ytUrlAktualisieren(song.id!, ytUrl);
|
|
gefunden++;
|
|
MeloLogger().zustand('yt_suche_treffer', {
|
|
'song_id': song.id,
|
|
'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});
|
|
}
|
|
}
|
|
}
|