feat: implement configurable codec for download & streaming music

This commit is contained in:
Kingkor Roy Tirtho 2023-09-28 16:17:40 +06:00
parent 25a105b247
commit 181169cbab
8 changed files with 77 additions and 36 deletions

View File

@ -13,7 +13,6 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';

View File

@ -23,6 +23,7 @@ class TrackNotFoundException implements Exception {
class SpotubeTrack extends Track { class SpotubeTrack extends Track {
final YoutubeVideoInfo ytTrack; final YoutubeVideoInfo ytTrack;
final String ytUri; final String ytUri;
final MusicCodec codec;
final List<YoutubeVideoInfo> siblings; final List<YoutubeVideoInfo> siblings;
@ -30,6 +31,7 @@ class SpotubeTrack extends Track {
this.ytTrack, this.ytTrack,
this.ytUri, this.ytUri,
this.siblings, this.siblings,
this.codec,
) : super(); ) : super();
SpotubeTrack.fromTrack({ SpotubeTrack.fromTrack({
@ -37,6 +39,7 @@ class SpotubeTrack extends Track {
required this.ytTrack, required this.ytTrack,
required this.ytUri, required this.ytUri,
required this.siblings, required this.siblings,
required this.codec,
}) : super() { }) : super() {
album = track.album; album = track.album;
artists = track.artists; artists = track.artists;
@ -149,6 +152,7 @@ class SpotubeTrack extends Track {
static Future<SpotubeTrack> fetchFromTrack( static Future<SpotubeTrack> fetchFromTrack(
Track track, Track track,
YoutubeEndpoints client, YoutubeEndpoints client,
MusicCodec codec,
) async { ) async {
final matchedCachedTrack = await MatchedTrack.box.get(track.id!); final matchedCachedTrack = await MatchedTrack.box.get(track.id!);
var siblings = <YoutubeVideoInfo>[]; var siblings = <YoutubeVideoInfo>[];
@ -157,16 +161,17 @@ class SpotubeTrack extends Track {
if (matchedCachedTrack != null && if (matchedCachedTrack != null &&
matchedCachedTrack.searchMode == client.preferences.searchMode) { matchedCachedTrack.searchMode == client.preferences.searchMode) {
(ytVideo, ytStreamUrl) = await client.video( (ytVideo, ytStreamUrl) = await client.video(
matchedCachedTrack.youtubeId, matchedCachedTrack.youtubeId, matchedCachedTrack.searchMode, codec);
matchedCachedTrack.searchMode,
);
} else { } else {
siblings = await fetchSiblings(track, client); siblings = await fetchSiblings(track, client);
if (siblings.isEmpty) { if (siblings.isEmpty) {
throw TrackNotFoundException(track); throw TrackNotFoundException(track);
} }
(ytVideo, ytStreamUrl) = (ytVideo, ytStreamUrl) = await client.video(
await client.video(siblings.first.id, siblings.first.searchMode); siblings.first.id,
siblings.first.searchMode,
codec,
);
await MatchedTrack.box.put( await MatchedTrack.box.put(
track.id!, track.id!,
@ -183,6 +188,7 @@ class SpotubeTrack extends Track {
ytTrack: ytVideo, ytTrack: ytVideo,
ytUri: ytStreamUrl, ytUri: ytStreamUrl,
siblings: siblings, siblings: siblings,
codec: codec,
); );
} }
@ -193,8 +199,12 @@ class SpotubeTrack extends Track {
// sibling tracks that were manually searched and swapped // sibling tracks that were manually searched and swapped
final isStepSibling = siblings.none((element) => element.id == video.id); final isStepSibling = siblings.none((element) => element.id == video.id);
final (ytVideo, ytStreamUrl) = final (ytVideo, ytStreamUrl) = await client.video(
await client.video(video.id, siblings.first.searchMode); video.id,
siblings.first.searchMode,
// siblings are always swapped when streaming
client.preferences.streamMusicCodec,
);
if (!isStepSibling) { if (!isStepSibling) {
await MatchedTrack.box.put( await MatchedTrack.box.put(
@ -215,6 +225,7 @@ class SpotubeTrack extends Track {
video, video,
...siblings.where((element) => element.id != video.id), ...siblings.where((element) => element.id != video.id),
], ],
codec: client.preferences.streamMusicCodec,
); );
} }
@ -226,6 +237,10 @@ class SpotubeTrack extends Track {
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"]) siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
.map((sibling) => YoutubeVideoInfo.fromJson(sibling)) .map((sibling) => YoutubeVideoInfo.fromJson(sibling))
.toList(), .toList(),
codec: MusicCodec.values.firstWhere(
(element) => element.name == map["codec"],
orElse: () => MusicCodec.m4a,
),
); );
} }
@ -242,6 +257,7 @@ class SpotubeTrack extends Track {
ytTrack: ytTrack, ytTrack: ytTrack,
ytUri: ytUri, ytUri: ytUri,
siblings: siblings, siblings: siblings,
codec: codec,
); );
} }
@ -268,6 +284,7 @@ class SpotubeTrack extends Track {
"ytTrack": ytTrack.toJson(), "ytTrack": ytTrack.toJson(),
"ytUri": ytUri, "ytUri": ytUri,
"siblings": siblings.map((sibling) => sibling.toJson()).toList(), "siblings": siblings.map((sibling) => sibling.toJson()).toList(),
"codec": codec.name,
}; };
} }
} }

View File

@ -40,7 +40,11 @@ class DownloadManagerProvider extends ChangeNotifier {
await oldFile.exists()) { await oldFile.exists()) {
await oldFile.rename(savePath); await oldFile.rename(savePath);
} }
if (status != DownloadStatus.completed) return; if (status != DownloadStatus.completed ||
//? WebA audiotagging is not supported yet
//? Although in future by converting weba to opus & then tagging it
//? is possible using vorbis comments
downloadCodec == MusicCodec.weba) return;
final file = File(request.path); final file = File(request.path);
@ -89,6 +93,8 @@ class DownloadManagerProvider extends ChangeNotifier {
YoutubeEndpoints get yt => ref.read(youtubeProvider); YoutubeEndpoints get yt => ref.read(youtubeProvider);
String get downloadDirectory => String get downloadDirectory =>
ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); ref.read(userPreferencesProvider.select((s) => s.downloadLocation));
MusicCodec get downloadCodec =>
ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec));
int get $downloadCount => dl int get $downloadCount => dl
.getAllDownloads() .getAllDownloads()
@ -130,7 +136,7 @@ class DownloadManagerProvider extends ChangeNotifier {
String getTrackFileUrl(Track track) { String getTrackFileUrl(Track track) {
final name = final name =
"${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[])}.m4a"; "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[])}.${downloadCodec.name}";
return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name));
} }
@ -166,7 +172,7 @@ class DownloadManagerProvider extends ChangeNotifier {
await oldFile.rename("$savePath.old"); await oldFile.rename("$savePath.old");
} }
if (track is SpotubeTrack) { if (track is SpotubeTrack && track.codec == downloadCodec) {
final downloadTask = await dl.addDownload(track.ytUri, savePath); final downloadTask = await dl.addDownload(track.ytUri, savePath);
if (downloadTask != null) { if (downloadTask != null) {
$history.add(track); $history.add(track);
@ -174,7 +180,7 @@ class DownloadManagerProvider extends ChangeNotifier {
} else { } else {
$backHistory.add(track); $backHistory.add(track);
final spotubeTrack = final spotubeTrack =
await SpotubeTrack.fetchFromTrack(track, yt).then((d) { await SpotubeTrack.fetchFromTrack(track, yt, downloadCodec).then((d) {
$backHistory.remove(track); $backHistory.remove(track);
return d; return d;
}); });

View File

@ -30,6 +30,7 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
final future = SpotubeTrack.fetchFromTrack( final future = SpotubeTrack.fetchFromTrack(
track, track,
youtube, youtube,
preferences.streamMusicCodec,
); );
if (i == 0) { if (i == 0) {
return await future; return await future;

View File

@ -185,10 +185,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
), ),
); );
} catch (e) { } catch (e) {
currentSegments.value = ( if (audioPlayer.currentSource != null) {
source: audioPlayer.currentSource!, currentSegments.value = (
segments: [], source: audioPlayer.currentSource!,
); segments: [],
);
}
} finally { } finally {
isFetchingSegments.value = false; isFetchingSegments.value = false;
} }
@ -223,7 +225,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
final nthFetchedTrack = switch (track.runtimeType) { final nthFetchedTrack = switch (track.runtimeType) {
SpotubeTrack => track as SpotubeTrack, SpotubeTrack => track as SpotubeTrack,
_ => await SpotubeTrack.fetchFromTrack(track, youtube), _ => await SpotubeTrack.fetchFromTrack(
track,
youtube,
preferences.streamMusicCodec,
),
}; };
await audioPlayer.replaceSource( await audioPlayer.replaceSource(
@ -309,10 +315,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
final addableTrack = await SpotubeTrack.fetchFromTrack( final addableTrack = await SpotubeTrack.fetchFromTrack(
tracks.elementAtOrNull(initialIndex) ?? tracks.first, tracks.elementAtOrNull(initialIndex) ?? tracks.first,
youtube, youtube,
preferences.streamMusicCodec,
).catchError((e, stackTrace) { ).catchError((e, stackTrace) {
return SpotubeTrack.fetchFromTrack( return SpotubeTrack.fetchFromTrack(
tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first,
youtube, youtube,
preferences.streamMusicCodec,
); );
}); });

View File

@ -41,8 +41,8 @@ enum YoutubeApiType {
} }
enum MusicCodec { enum MusicCodec {
m4a._("M4a\n(best for downloaded music)"), m4a._("M4a (Best for downloaded music)"),
weba._("WebA\n(best for streamed music)"); weba._("WebA (Best for streamed music)\nDoesn't support audio metadata");
final String label; final String label;
const MusicCodec._(this.label); const MusicCodec._(this.label);

View File

@ -181,24 +181,33 @@ class YoutubeEndpoints {
} }
} }
String _pipedStreamResponseToStreamUrl(PipedStreamResponse stream) { String _pipedStreamResponseToStreamUrl(
PipedStreamResponse stream,
MusicCodec codec,
) {
final pipedStreamFormat = switch (codec) {
MusicCodec.m4a => PipedAudioStreamFormat.m4a,
MusicCodec.weba => PipedAudioStreamFormat.webm,
};
return switch (preferences.audioQuality) { return switch (preferences.audioQuality) {
AudioQuality.high => stream AudioQuality.high =>
.highestBitrateAudioStreamOfFormat(PipedAudioStreamFormat.m4a)! stream.highestBitrateAudioStreamOfFormat(pipedStreamFormat)!.url,
.url, AudioQuality.low =>
AudioQuality.low => stream stream.lowestBitrateAudioStreamOfFormat(pipedStreamFormat)!.url,
.lowestBitrateAudioStreamOfFormat(PipedAudioStreamFormat.m4a)!
.url,
}; };
} }
Future<String> streamingUrl(String id) async { Future<String> streamingUrl(String id, MusicCodec codec) async {
if (youtube != null) { if (youtube != null) {
final res = await PrimitiveUtils.raceMultiple( final res = await PrimitiveUtils.raceMultiple(
() => youtube!.videos.streams.getManifest(id), () => youtube!.videos.streams.getManifest(id),
); );
final audioOnlyManifests = res.audioOnly.where((info) { final audioOnlyManifests = res.audioOnly.where((info) {
return info.codec.mimeType == "audio/mp4"; return switch (codec) {
MusicCodec.m4a => info.codec.mimeType == "audio/mp4",
MusicCodec.weba => info.codec.mimeType == "audio/webm",
};
}); });
return switch (preferences.audioQuality) { return switch (preferences.audioQuality) {
@ -208,26 +217,27 @@ class YoutubeEndpoints {
audioOnlyManifests.sortByBitrate().last.url.toString(), audioOnlyManifests.sortByBitrate().last.url.toString(),
}; };
} else { } else {
return _pipedStreamResponseToStreamUrl(await piped!.streams(id)); return _pipedStreamResponseToStreamUrl(await piped!.streams(id), codec);
} }
} }
Future<(YoutubeVideoInfo info, String streamingUrl)> video( Future<(YoutubeVideoInfo info, String streamingUrl)> video(
String id, String id,
SearchMode searchMode, SearchMode searchMode,
MusicCodec codec,
) async { ) async {
if (youtube != null) { if (youtube != null) {
final res = await youtube!.videos.get(id); final res = await youtube!.videos.get(id);
return ( return (
YoutubeVideoInfo.fromVideo(res), YoutubeVideoInfo.fromVideo(res),
await streamingUrl(id), await streamingUrl(id, codec),
); );
} else { } else {
try { try {
final res = await piped!.streams(id); final res = await piped!.streams(id);
return ( return (
YoutubeVideoInfo.fromStreamResponse(res, searchMode), YoutubeVideoInfo.fromStreamResponse(res, searchMode),
_pipedStreamResponseToStreamUrl(res), _pipedStreamResponseToStreamUrl(res, codec),
); );
} on Exception catch (e) { } on Exception catch (e) {
await showPipedErrorDialog(e); await showPipedErrorDialog(e);

View File

@ -126,21 +126,21 @@ abstract class TypeConversionUtils {
}) { }) {
final track = Track(); final track = Track();
track.album = Album() track.album = Album()
..name = metadata?.album ?? "Spotube" ..name = metadata?.album ?? "Unknown"
..images = [if (art != null) Image()..url = art] ..images = [if (art != null) Image()..url = art]
..genres = [if (metadata?.genre != null) metadata!.genre!] ..genres = [if (metadata?.genre != null) metadata!.genre!]
..artists = [ ..artists = [
Artist() Artist()
..name = metadata?.albumArtist ?? "Spotube" ..name = metadata?.albumArtist ?? "Unknown"
..id = metadata?.albumArtist ?? "Spotube" ..id = metadata?.albumArtist ?? "Unknown"
..type = "artist", ..type = "artist",
] ]
..id = metadata?.album ..id = metadata?.album
..releaseDate = metadata?.year?.toString(); ..releaseDate = metadata?.year?.toString();
track.artists = [ track.artists = [
Artist() Artist()
..name = metadata?.artist ?? "Spotube" ..name = metadata?.artist ?? "Unknown"
..id = metadata?.artist ?? "Spotube" ..id = metadata?.artist ?? "Unknown"
]; ];
track.id = metadata?.title ?? basenameWithoutExtension(file.path); track.id = metadata?.title ?? basenameWithoutExtension(file.path);