Navidrome-Integration, Widgets (Recent + Tag-Statistik)
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/song.dart';
|
||||
import '../database/db_helper.dart';
|
||||
|
||||
/// Ein Song aus der Subsonic-API
|
||||
class SubsonicSong {
|
||||
final String id;
|
||||
final String titel;
|
||||
final String kuenstler;
|
||||
final String album;
|
||||
final int dauerSekunden;
|
||||
final int groesseBytes;
|
||||
final String? coverId;
|
||||
|
||||
const SubsonicSong({
|
||||
required this.id,
|
||||
required this.titel,
|
||||
this.kuenstler = '',
|
||||
this.album = '',
|
||||
this.dauerSekunden = 0,
|
||||
this.groesseBytes = 0,
|
||||
this.coverId,
|
||||
});
|
||||
|
||||
factory SubsonicSong.fromJson(Map<String, dynamic> j) => SubsonicSong(
|
||||
id: j['id'] as String,
|
||||
titel: j['title'] as String? ?? 'Unbekannt',
|
||||
kuenstler: j['artist'] as String? ?? '',
|
||||
album: j['album'] as String? ?? '',
|
||||
dauerSekunden: (j['duration'] as int?) ?? 0,
|
||||
groesseBytes: (j['size'] as int?) ?? 0,
|
||||
coverId: j['coverArt'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ein Album aus der Subsonic-API
|
||||
class SubsonicAlbum {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? coverId;
|
||||
final int songCount;
|
||||
|
||||
const SubsonicAlbum({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.coverId,
|
||||
this.songCount = 0,
|
||||
});
|
||||
|
||||
factory SubsonicAlbum.fromJson(Map<String, dynamic> j) => SubsonicAlbum(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String? ?? j['title'] as String? ?? 'Unbekannt',
|
||||
coverId: j['coverArt'] as String?,
|
||||
songCount: (j['songCount'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class NavidromeService {
|
||||
final DbHelper _db = DbHelper();
|
||||
|
||||
String _serverUrl = '';
|
||||
String _user = '';
|
||||
String _password = '';
|
||||
String _salt = '';
|
||||
String? _token;
|
||||
|
||||
static const _version = '1.16.1';
|
||||
static const _client = 'Melo';
|
||||
|
||||
void setCredentials(String url, String user, String password) {
|
||||
_serverUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
_user = user;
|
||||
_password = password;
|
||||
_salt = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
_token = md5.convert(utf8.encode(_password + _salt)).toString();
|
||||
}
|
||||
|
||||
bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty;
|
||||
|
||||
Uri _uri(String endpoint, [Map<String, String>? extra]) {
|
||||
final params = {
|
||||
'u': _user,
|
||||
't': _token!,
|
||||
's': _salt,
|
||||
'v': _version,
|
||||
'c': _client,
|
||||
'f': 'json',
|
||||
};
|
||||
if (extra != null) params.addAll(extra);
|
||||
return Uri.parse('$_serverUrl/rest/$endpoint').replace(queryParameters: params);
|
||||
}
|
||||
|
||||
/// Verbindung testen
|
||||
Future<bool> ping() async {
|
||||
try {
|
||||
final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10));
|
||||
return r.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Alle Alben abrufen
|
||||
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50}) async {
|
||||
final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15));
|
||||
if (r.statusCode != 200) return [];
|
||||
final data = jsonDecode(r.body);
|
||||
final list = data['subsonic-response']?['albumList']?['album'] as List? ?? [];
|
||||
return list.map((j) => SubsonicAlbum.fromJson(j)).toList();
|
||||
}
|
||||
|
||||
/// Songs eines Albums abrufen
|
||||
Future<List<SubsonicSong>> getSongs(String albumId) async {
|
||||
final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15));
|
||||
if (r.statusCode != 200) return [];
|
||||
final data = jsonDecode(r.body);
|
||||
final songs = data['subsonic-response']?['album']?['song'] as List? ?? [];
|
||||
return songs.map((j) => SubsonicSong.fromJson(j)).toList();
|
||||
}
|
||||
|
||||
/// Song-Stream-URL
|
||||
Uri streamUrl(String songId) {
|
||||
return _uri('stream.view', {'id': songId, 'format': 'raw'});
|
||||
}
|
||||
|
||||
/// Cover-URL
|
||||
Uri? coverUrl(String coverId) {
|
||||
return _uri('getCoverArt.view', {'id': coverId});
|
||||
}
|
||||
|
||||
/// Song von Navidrome runterladen und lokal speichern
|
||||
Future<Song?> downloadSong(SubsonicSong s) async {
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final musikDir = Directory('${dir.path}/music');
|
||||
if (!await musikDir.exists()) await musikDir.create(recursive: true);
|
||||
|
||||
final safeName = s.titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim();
|
||||
final dateiName = '${safeName.isEmpty ? "song" : safeName}.mp3';
|
||||
final dateiPfad = '${musikDir.path}/$dateiName';
|
||||
|
||||
final file = File(dateiPfad);
|
||||
if (await file.exists()) {
|
||||
final localSong = await _db.songNachPfad(dateiPfad);
|
||||
if (localSong != null) return localSong;
|
||||
}
|
||||
|
||||
// Stream + speichern
|
||||
final uri = streamUrl(s.id);
|
||||
final response = await http.Client().send(http.Request('GET', uri));
|
||||
final sink = file.openWrite();
|
||||
await for (final chunk in response.stream) {
|
||||
sink.add(chunk);
|
||||
}
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
|
||||
final song = Song(
|
||||
titel: s.titel,
|
||||
kuenstler: s.kuenstler,
|
||||
album: s.album,
|
||||
dauerSekunden: s.dauerSekunden,
|
||||
dateiPfad: dateiPfad,
|
||||
groesseBytes: await file.length(),
|
||||
istHeruntergeladen: true,
|
||||
downloadQuelle: 'server',
|
||||
);
|
||||
|
||||
// Dauer per just_audio ermitteln wenn Navidrome keine liefert
|
||||
// (optional, für später)
|
||||
|
||||
await _db.songEinfuegen(song);
|
||||
return song;
|
||||
} catch (e) {
|
||||
debugPrint('Navidrome Download Fehler: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ class PlayerService {
|
||||
void dispose() {
|
||||
_autoNextSub?.cancel();
|
||||
_player?.dispose();
|
||||
_player = null;
|
||||
_songWechsel.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import '../database/db_helper.dart';
|
||||
import '../models/song.dart';
|
||||
import '../models/playlist.dart';
|
||||
|
||||
class PlaylistService {
|
||||
final DbHelper _db = DbHelper();
|
||||
|
||||
Future<List<Playlist>> allePlaylists() async {
|
||||
final rows = await _db.allePlaylists();
|
||||
final listen = <Playlist>[];
|
||||
for (final r in rows) {
|
||||
final id = r['id'] as int;
|
||||
final songs = await _db.songsDerPlaylist(id);
|
||||
listen.add(Playlist.fromMap(r, songCount: songs.length));
|
||||
}
|
||||
return listen;
|
||||
}
|
||||
|
||||
Future<int> erstellen(String name) async {
|
||||
return _db.playlistErstellen(name);
|
||||
}
|
||||
|
||||
Future<List<Song>> songs(int playlistId) async {
|
||||
return _db.songsDerPlaylist(playlistId);
|
||||
}
|
||||
|
||||
Future<void> songHinzufuegen(int playlistId, int songId) async {
|
||||
final songs = await _db.songsDerPlaylist(playlistId);
|
||||
await _db.songZurPlaylist(playlistId, songId, songs.length);
|
||||
}
|
||||
|
||||
Future<void> songEntfernen(int playlistId, int songId) async {
|
||||
await _db.songAusPlaylistEntfernen(playlistId, songId);
|
||||
}
|
||||
|
||||
Future<bool> istInPlaylist(int playlistId, int songId) async {
|
||||
final songs = await _db.songsDerPlaylist(playlistId);
|
||||
return songs.any((s) => s.id == songId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user