diff --git a/lib/models/database/tables/preferences.dart b/lib/models/database/tables/preferences.dart index 85014920..82c90c55 100644 --- a/lib/models/database/tables/preferences.dart +++ b/lib/models/database/tables/preferences.dart @@ -12,12 +12,14 @@ enum CloseBehavior { } enum AudioSource { - youtube, - piped, - jiosaavn, - invidious; + youtube("YouTube"), + piped("Piped"), + jiosaavn("JioSaavn"), + invidious("Invidious"), + dabMusic("DAB Music"); - String get label => name[0].toUpperCase() + name.substring(1); + final String label; + const AudioSource(this.label); } enum YoutubeClientEngine { diff --git a/lib/models/database/tables/source_match.dart b/lib/models/database/tables/source_match.dart index 78d0eb05..fa659287 100644 --- a/lib/models/database/tables/source_match.dart +++ b/lib/models/database/tables/source_match.dart @@ -3,7 +3,8 @@ part of '../database.dart'; enum SourceType { youtube._("YouTube"), youtubeMusic._("YouTube Music"), - jiosaavn._("JioSaavn"); + jiosaavn._("JioSaavn"), + dabMusic._("DAB Music"); final String label; diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index 6acc70b1..2e1ddd27 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -8,7 +8,7 @@ import 'package:flutter/material.dart' show ListTile; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/models/database/database.dart'; @@ -396,7 +396,9 @@ class SettingsPlaybackSection extends HookConsumerWidget { onChanged: preferencesNotifier.setNormalizeAudio, ), ), - if (preferences.audioSource != AudioSource.jiosaavn) ...[ + if (const [AudioSource.jiosaavn, AudioSource.dabMusic] + .contains(preferences.audioSource) == + false) ...[ AdaptiveSelectTile( popupConstraints: const BoxConstraints(maxWidth: 300), secondary: const Icon(SpotubeIcons.stream), diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index c81d968f..9e1d96a9 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -135,23 +135,6 @@ class ServerPlaybackRoutes { ); } - final contentLength = contentLengthRes?.headers.value("content-length"); - - /// Forcing partial content range as mpv sometimes greedily wants - /// everything at one go. Slows down overall streaming. - final range = RangeHeader.parse(headers["range"] ?? ""); - final contentPartialLength = int.tryParse(contentLength ?? ""); - if ((range.end == null) && - contentPartialLength != null && - range.start == 0) { - options = options.copyWith( - headers: { - ...?options.headers, - "range": "$range${(contentPartialLength * 0.3).ceil()}", - }, - ); - } - final res = await dio.get(url, options: options); final bytes = res.data; @@ -183,7 +166,7 @@ class ServerPlaybackRoutes { await trackPartialCacheFile.rename(trackCacheFile.path); } - if (contentRange.total == fileLength && track.codec != SourceCodecs.weba) { + if (contentRange.total == fileLength && track.codec == SourceCodecs.m4a) { final playlistTrack = playlist.tracks.firstWhereOrNull( (element) => element.id == track.query.id, ); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 93a6417e..925d0761 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -56,6 +56,7 @@ abstract class AudioPlayerInterface { configuration: const mk.PlayerConfiguration( title: "Spotube", logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error, + bufferSize: 4 * 1024 * 1024, // 4MB buffer ), ) { _mkPlayer.stream.error.listen((event) { diff --git a/lib/services/sourced_track/enums.dart b/lib/services/sourced_track/enums.dart index d9ea079c..4c5ef8bc 100644 --- a/lib/services/sourced_track/enums.dart +++ b/lib/services/sourced_track/enums.dart @@ -2,7 +2,8 @@ import 'package:spotube/models/playback/track_sources.dart'; enum SourceCodecs { m4a._("M4a (Best for downloaded music)"), - weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"), + mp3._("MP3 (Widely supported audio format)"); final String label; const SourceCodecs._(this.label); diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index d979c007..0d8d0a73 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -5,6 +5,7 @@ import 'package:spotube/models/playback/track_sources.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/sources/dab_music.dart'; import 'package:spotube/services/sourced_track/sources/invidious.dart'; import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; @@ -74,6 +75,14 @@ abstract class SourcedTrack extends BasicSourcedTrack { query: query, sources: sources, ), + AudioSource.dabMusic => DABMusicSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + info: info, + query: query, + sources: sources, + ), }; } @@ -104,6 +113,8 @@ abstract class SourcedTrack extends BasicSourcedTrack { await InvidiousSourcedTrack.fetchFromTrack(query: query, ref: ref), AudioSource.jiosaavn => await JioSaavnSourcedTrack.fetchFromTrack(query: query, ref: ref), + AudioSource.dabMusic => + await DABMusicSourcedTrack.fetchFromTrack(query: query, ref: ref), }; } catch (e) { if (preferences.audioSource == AudioSource.youtube) { @@ -129,6 +140,8 @@ abstract class SourcedTrack extends BasicSourcedTrack { JioSaavnSourcedTrack.fetchSiblings(query: query, ref: ref), AudioSource.invidious => InvidiousSourcedTrack.fetchSiblings(query: query, ref: ref), + AudioSource.dabMusic => + DABMusicSourcedTrack.fetchSiblings(query: query, ref: ref), }; } @@ -198,9 +211,11 @@ abstract class SourcedTrack extends BasicSourcedTrack { SourceCodecs get codec { final preferences = ref.read(userPreferencesProvider); - return preferences.audioSource == AudioSource.jiosaavn - ? SourceCodecs.m4a - : preferences.streamMusicCodec; + return switch (preferences.audioSource) { + AudioSource.dabMusic => SourceCodecs.mp3, + AudioSource.jiosaavn => SourceCodecs.m4a, + _ => preferences.streamMusicCodec + }; } TrackSource get activeTrackSource { diff --git a/lib/services/sourced_track/sources/dab_music.dart b/lib/services/sourced_track/sources/dab_music.dart new file mode 100644 index 00000000..20ec68f1 --- /dev/null +++ b/lib/services/sourced_track/sources/dab_music.dart @@ -0,0 +1,203 @@ +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/models/playback/track_sources.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:dab_music_api/dab_music_api.dart'; + +final dabMusicApiClient = DabMusicApiClient( + Dio(), + baseUrl: "https://dab.yeet.su/api", +); + +/// Only Music source that can't support database caching due to having no endpoint. +/// But ISRC search is 100% reliable so caching is actually not necessary. +class DABMusicSourcedTrack extends SourcedTrack { + DABMusicSourcedTrack({ + required super.ref, + required super.source, + required super.siblings, + required super.info, + required super.query, + required super.sources, + }); + + static Future fetchFromTrack({ + required TrackSourceQuery query, + required Ref ref, + }) async { + try { + final siblings = await fetchSiblings(ref: ref, query: query); + + if (siblings.isEmpty) { + throw TrackNotFoundError(query); + } + return DABMusicSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + sources: siblings.first.source!, + info: siblings.first.info, + query: query, + source: AudioSource.dabMusic, + ); + } catch (e, stackTrace) { + AppLogger.reportError(e, stackTrace); + rethrow; + } + } + + static Future> fetchSources( + String id, + SourceQualities quality, + ) async { + try { + final streamResponse = await dabMusicApiClient.music.getStream( + trackId: id, + quality: "5", // mp3 320kbps (best available) + ); + if (streamResponse.url == null) { + throw Exception("No stream URL found for track ID: $id"); + } + return [ + TrackSource( + url: streamResponse.url!, + quality: SourceQualities.high, + bitrate: "320kbps", + codec: SourceCodecs.mp3, + ), + ]; + } catch (e, stackTrace) { + AppLogger.reportError(e, stackTrace); + rethrow; + } + } + + static Future toSiblingType( + Ref ref, + int index, + Track result, + ) async { + try { + List? source; + if (index == 0) { + source = await fetchSources( + result.id.toString(), + ref.read(userPreferencesProvider).audioQuality, + ); + } + + final SiblingType sibling = ( + info: TrackSourceInfo( + artists: result.artist!, + durationMs: Duration(seconds: result.duration!).inMilliseconds, + id: result.id.toString(), + pageUrl: "https://dab.yeet.su/music/${result.id}", + thumbnail: result.albumCover!, + title: result.title!, + ), + source: source, + ); + + return sibling; + } catch (e, stackTrace) { + AppLogger.reportError(e, stackTrace); + rethrow; + } + } + + static Future> fetchSiblings({ + required TrackSourceQuery query, + required Ref ref, + }) async { + try { + List results = []; + + if (query.isrc.isNotEmpty) { + final res = + await dabMusicApiClient.music.getSearch(q: query.isrc, limit: 1); + results = res.tracks ?? []; + } + + if (results.isEmpty) { + final res = await dabMusicApiClient.music.getSearch( + q: SourcedTrack.getSearchTerm(query), + limit: 20, + ); + results = res.tracks ?? []; + } + + if (results.isEmpty) { + return []; + } + + final matchedResults = + results.mapIndexed((index, d) => toSiblingType(ref, index, d)); + + return Future.wait(matchedResults); + } catch (e, stackTrace) { + AppLogger.reportError(e, stackTrace); + rethrow; + } + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, query: query); + + return DABMusicSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != info.id) + .map((s) => s.info) + .toList(), + source: source, + info: info, + query: query, + sources: sources, + ); + } + + @override + Future swapWithSibling(TrackSourceInfo sibling) async { + if (sibling.id == this.info.id) { + return null; + } + + // a sibling source that was fetched from the search results + final isStepSibling = siblings.none((s) => s.id == sibling.id); + + final newSourceInfo = isStepSibling + ? sibling + : siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, this.info); + + final source = await fetchSources( + sibling.id, + ref.read(userPreferencesProvider).audioQuality, + ); + + return DABMusicSourcedTrack( + ref: ref, + siblings: newSiblings, + sources: source, + info: newSourceInfo, + query: query, + source: AudioSource.dabMusic, + ); + } + + @override + Future refreshStream() async { + // There's no need to refresh the stream for DABMusicSourcedTrack + return this; + } +} diff --git a/pubspec.lock b/pubspec.lock index 4d900033..0ccd5c44 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -458,6 +458,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + dab_music_api: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "55f96368b7465eec2e5e81774f9f2a7b18acc4ab" + url: "https://github.com/KRTirtho/dab_music_api.git" + source: git + version: "0.1.0" dart_des: dependency: transitive description: @@ -2046,6 +2055,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + retrofit: + dependency: transitive + description: + name: retrofit + sha256: "699cf44ec6c7fc7d248740932eca75d334e36bdafe0a8b3e9ff93100591c8a25" + url: "https://pub.dev" + source: hosted + version: "4.7.2" riverpod: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9d3c1a3c..0877d736 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,10 @@ dependencies: bonsoir: ^5.1.10 cached_network_image: ^3.3.1 connectivity_plus: ^6.1.2 + dab_music_api: + git: + url: https://github.com/KRTirtho/dab_music_api.git + ref: main desktop_webview_window: git: path: packages/desktop_webview_window