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/viewmodels/melo_home_viewmodel.dart
T
Hermes (Server) 9a880b0f21 Security: Cloud-Auth auf Bearer-JWT umgestellt (IDOR-Luecke geschlossen)
- cloud_service: echter Login gegen baka-auth, Token statt X-API-Key/X-User
- app_config: hartcodierten API-Key-Default entfernt
- download_service + melo_logger: Bearer-Token statt X-API-Key
- navidrome: Passwort in flutter_secure_storage (Keychain/Keystore)
- song: token-haltige stream_url wird nicht mehr in SQLite persistiert
- cloud_screen: Pfad-Traversal beim Download-Dateinamen gefixt (p.basename)
- home_screen: Login-Dialog mit Passwort-Feld, Auto-Sync nutzt restoreLogin
2026-07-31 14:31:12 +02:00

324 lines
9.4 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:async';
import 'package:flutter/foundation.dart';
import '../database/db_helper.dart';
import '../services/player_service.dart';
import '../services/musik_scanner.dart';
import '../services/favoriten_service.dart';
import '../services/download_service.dart';
import '../services/navidrome_service.dart';
import '../services/playlist_service.dart';
import '../services/cloud_service.dart';
import '../services/melo_logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import '../models/song.dart';
import '../models/tag.dart';
class MeloHomeViewModel extends ChangeNotifier {
final PlayerService player = PlayerService();
final DbHelper db = DbHelper();
final FavoritenService favoriten = FavoritenService();
final MusikScanner scanner = MusikScanner();
final DownloadService downloader = DownloadService();
final NavidromeService navidrome = NavidromeService();
final PlaylistService playlists = PlaylistService();
final CloudService cloud = CloudService();
Timer? _autoSyncTimer;
List<Song> songs = [];
Set<String> aktiveTags = {};
bool ladt = true;
Set<int> favoritenIds = {};
List<Map<String, String>> tags = [];
List<Song> letzteSongs = [];
Map<String, int> tagCounts = {};
bool zeigeBotschaft = false;
bool serverLadt = false;
List<SubsonicAlbum> navidromeAlben = [];
Map<String, Tag> _tagsMap = {}; // Name → Tag für Filter-Lookup
Map<String, Tag> get tagsMap => _tagsMap;
int _playCount = 0;
static const int _botschaftSchwellwert = 10;
String? _nutzerBotschaft;
/// Per-User Easteregg-Botschaften
static const _botschaften = {
'Baka': '💌 Seit 2008 Danke, dass du immer da bist ♥',
'Tinker': '🎀 Für meine beste Freundin Melo & Melo 💕',
};
String? get nutzerBotschaft => _nutzerBotschaft;
void setzeNutzer(String name) {
_nutzerBotschaft = _botschaften[name];
}
StreamSubscription<Duration>? _positionsSub;
int _letzteGespeicherteSekunde = -1;
void _startePositionTracking() {
_positionsSub?.cancel();
_letzteGespeicherteSekunde = -1;
_positionsSub = player.positionStream.listen((pos) {
final s = player.aktuellerSong;
final sek = pos.inSeconds;
if (s?.id != null && sek > 0 && sek % 10 == 0 && sek != _letzteGespeicherteSekunde) {
_letzteGespeicherteSekunde = sek;
db.positionAktualisieren(s!.id!, sek);
}
});
}
static const _defaultTags = [
{'name': 'Alle', 'icon': ''},
{'name': '❤️ Für uns', 'icon': '❤️'},
{'name': 'Nightcore', 'icon': '⚡'},
{'name': 'Traurig', 'icon': '😢'},
{'name': 'Party', 'icon': '🎉'},
{'name': 'Mitsingen', 'icon': '🎤'},
{'name': '2000er', 'icon': '📀'},
];
List<Song> get gefilterteSongs {
if (aktiveTags.isEmpty) return songs;
// Favoriten-Filter
if (aktiveTags.contains('★ Favoriten')) {
return songs.where((s) => s.id != null && favoritenIds.contains(s.id)).toList();
}
// Filter: Songs mit ALLEN aktiven Tags (AND-Logik)
return songs.where((s) {
if (s.tagIds == null) return false;
return aktiveTags.every((tagName) {
final tag = _tagsMap[tagName];
return tag != null && s.tagIds!.contains(tag.id);
});
}).toList();
}
Future<void> ladeSongs() async {
ladt = true;
notifyListeners();
try {
// Auto-Restore der Navidrome-Session beim Start
// Dummies bereinigen
await db.alteDummiesLoeschen();
await navidrome.ladeGespeicherteZugangsdaten();
if (navidrome.istVerbunden) {
ladeNavidromeAlben();
}
// Echte Songs aus SQLite laden (ohne unspielbare Fake-Dummies)
songs = await db.alleSongs();
favoritenIds = await favoriten.favoritenIds();
letzteSongs = await db.letzteWiedergaben();
await ladeTags();
_starteCloudSyncScheduler();
} catch (e, stack) {
debugPrint('ladeSongs Fehler: $e\n$stack');
}
ladt = false;
notifyListeners();
}
Map<String, int> _berechneTagCounts() {
final counts = <String, int>{};
for (final s in songs) {
if (s.tagIds == null) continue;
for (final tagId in s.tagIds!) {
for (final entry in _tagsMap.entries) {
if (entry.value.id == tagId) {
counts[entry.key] = (counts[entry.key] ?? 0) + 1;
}
}
}
}
return counts;
}
Future<void> ladeTags() async {
var dbTags = await db.alleTags();
if (dbTags.isEmpty) {
for (final t in _defaultTags) {
if (t['name'] != 'Alle') {
await db.tagErstellen(t['name']!, icon: t['icon']);
}
}
dbTags = await db.alleTags();
}
// Tags-Map füllen
_tagsMap = {for (final t in dbTags) t.name: t};
// Tag-IDs für jeden Song laden
for (final song in songs) {
if (song.id != null) {
final songTags = await db.tagsFuerSong(song.id!);
song.tagIds = songTags.map((t) => t.id!).toSet();
}
}
tags = _defaultTags;
tagCounts = _berechneTagCounts();
notifyListeners();
}
void spieleSong(Song song, {List<Song>? warteschlange}) {
player.setWarteschlange(warteschlange ?? songs,
startIndex: (warteschlange ?? songs).indexWhere((s) => s.id == song.id));
// Wiederaufnahme
final pos = (song.zuletztPosition != null && song.zuletztPosition! > 5)
? song.zuletztPosition! : 0;
player.spiele(song, position: pos);
// Position-Tracking starten (alle 10s speichern)
_startePositionTracking();
// In Verlauf speichern
if (song.id != null) {
db.positionAktualisieren(song.id!, song.zuletztPosition ?? 0);
}
// Play-Counter für Hidden Message
_playCount++;
if (_playCount >= _botschaftSchwellwert && !zeigeBotschaft) {
zeigeBotschaft = true;
notifyListeners();
}
// Letzte Songs aktualisieren
db.letzteWiedergaben().then((liste) {
letzteSongs = liste;
notifyListeners();
});
}
/// Manuell die Botschaft "Seit 2008" anzeigen
void botschaftAnzeigen() {
zeigeBotschaft = true;
notifyListeners();
}
/// Botschaft ausblenden
void botschaftAusblenden() {
zeigeBotschaft = false;
notifyListeners();
}
void toggleTag(String tag) {
if (tag == 'Alle') {
aktiveTags.clear();
} else if (aktiveTags.contains(tag)) {
aktiveTags.remove(tag);
} else {
aktiveTags.add(tag);
}
notifyListeners();
}
Future<void> favoritenUmschalten(Song song) async {
if (song.id == null) return;
await favoriten.umschalten(song.id!);
favoritenIds = await favoriten.favoritenIds();
notifyListeners();
}
// ─── Navidrome Server-Sync ─────────────────────────
/// Navidrome-Zugangsdaten setzen
void verbindeNavidrome(String url, String user, String password) {
navidrome.setCredentials(url, user, password);
}
/// Verbindung testen und Alben laden
Future<bool> ladeNavidromeAlben() async {
if (!navidrome.istVerbunden) return false;
serverLadt = true;
notifyListeners();
final ok = await navidrome.ping();
if (ok) {
navidromeAlben = await navidrome.getAlben();
}
serverLadt = false;
notifyListeners();
return ok;
}
/// Songs eines Albums laden
Future<List<SubsonicSong>> ladeAlbumSongs(String albumId) async {
return navidrome.getSongs(albumId);
}
/// Song von Navidrome runterladen
Future<bool> downloadNavidromeSong(SubsonicSong s) async {
final song = await navidrome.downloadSong(s);
if (song != null) {
await ladeSongs();
return true;
}
return false;
}
/// Song direkt vom Server streamen (ohne Download)
void starteNavidromeStream(SubsonicSong s) {
final uri = navidrome.streamUrl(s.id);
final song = Song(
titel: s.titel,
kuenstler: s.kuenstler,
album: s.album,
dauerSekunden: s.dauerSekunden,
dateiPfad: '',
groesseBytes: s.groesseBytes,
downloadQuelle: 'server',
streamUrl: uri.toString(),
);
player.spiele(song);
}
@override
void dispose() {
_autoSyncTimer?.cancel();
_positionsSub?.cancel();
player.dispose();
super.dispose();
}
Future<void> _starteCloudSyncScheduler() async {
_autoSyncTimer?.cancel();
final prefs = await SharedPreferences.getInstance();
final autoSync = prefs.getBool('cloud_auto') ?? true;
final intervalStunden = prefs.getInt('cloud_interval') ?? 6;
if (!autoSync || intervalStunden <= 0) return;
_autoSyncTimer = Timer.periodic(Duration(hours: intervalStunden), (_) async {
try {
await cloud.restoreLogin();
if (!cloud.istAngemeldet) return;
final serverSongs = await cloud.listSongs();
if (serverSongs.isEmpty) return;
// Nur neue Songs herunterladen
final dir = Directory('${(await getApplicationDocumentsDirectory()).path}/music');
if (!await dir.exists()) await dir.create(recursive: true);
final localFiles = dir.listSync().whereType<File>()
.map((f) => f.path.split('/').last).toSet();
for (final s in serverSongs) {
final title = s['title'].toString();
if (localFiles.contains(title)) continue; // schon lokal
await cloud.download(s['id'].toString(), '${dir.path}/$title');
}
await ladeSongs(); // UI aktualisieren
} catch (e) {
MeloLogger().fehler('background_auto_sync', e);
}
});
}
}