feat: customizable stream/download file formats (#757)

* feat: add codec configuration in settings

* fix: show no value for codec configuration in smaller screen

* feat: implement configurable codec for download & streaming music
This commit is contained in:
Kingkor Roy Tirtho 2023-09-28 17:02:41 +06:00 committed by GitHub
parent 5a758d8671
commit e54762be6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 210 additions and 76 deletions

View File

@ -98,4 +98,6 @@ abstract class SpotubeIcons {
static const edit = FeatherIcons.edit; static const edit = FeatherIcons.edit;
static const web = FeatherIcons.globe; static const web = FeatherIcons.globe;
static const amoled = FeatherIcons.sunset; static const amoled = FeatherIcons.sunset;
static const file = FeatherIcons.file;
static const stream = Icons.stream_rounded;
} }

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

@ -268,5 +268,7 @@
"normalize_audio": "Normalize audio", "normalize_audio": "Normalize audio",
"change_cover": "Change cover", "change_cover": "Change cover",
"add_cover": "Add cover", "add_cover": "Add cover",
"restore_defaults": "Restore defaults" "restore_defaults": "Restore defaults",
"download_music_codec": "Download music codec",
"streaming_music_codec": "Streaming music codec"
} }

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

@ -459,6 +459,44 @@ class SettingsPage extends HookConsumerWidget {
value: preferences.normalizeAudio, value: preferences.normalizeAudio,
onChanged: preferences.setNormalizeAudio, onChanged: preferences.setNormalizeAudio,
), ),
AdaptiveSelectTile<MusicCodec>(
secondary: const Icon(SpotubeIcons.stream),
title: Text(context.l10n.streaming_music_codec),
value: preferences.streamMusicCodec,
showValueWhenUnfolded: false,
options: MusicCodec.values
.map((e) => DropdownMenuItem(
value: e,
child: Text(
e.label,
style: theme.textTheme.labelMedium,
),
))
.toList(),
onChanged: (value) {
if (value == null) return;
preferences.setStreamMusicCodec(value);
},
),
AdaptiveSelectTile<MusicCodec>(
secondary: const Icon(SpotubeIcons.file),
title: Text(context.l10n.download_music_codec),
value: preferences.downloadMusicCodec,
showValueWhenUnfolded: false,
options: MusicCodec.values
.map((e) => DropdownMenuItem(
value: e,
child: Text(
e.label,
style: theme.textTheme.labelMedium,
),
))
.toList(),
onChanged: (value) {
if (value == null) return;
preferences.setDownloadMusicCodec(value);
},
),
], ],
), ),
SectionCardWithHeading( SectionCardWithHeading(

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

@ -40,39 +40,36 @@ enum YoutubeApiType {
String get label => name[0].toUpperCase() + name.substring(1); String get label => name[0].toUpperCase() + name.substring(1);
} }
enum MusicCodec {
m4a._("M4a (Best for downloaded music)"),
weba._("WebA (Best for streamed music)\nDoesn't support audio metadata");
final String label;
const MusicCodec._(this.label);
}
class UserPreferences extends PersistedChangeNotifier { class UserPreferences extends PersistedChangeNotifier {
ThemeMode themeMode;
Market recommendationMarket;
bool saveTrackLyrics;
bool checkUpdate;
AudioQuality audioQuality; AudioQuality audioQuality;
late SpotubeColor accentColorScheme;
bool albumColorSync; bool albumColorSync;
String downloadLocation;
LayoutMode layoutMode;
CloseBehavior closeBehavior;
bool showSystemTrayIcon;
Locale locale;
String pipedInstance;
SearchMode searchMode;
bool skipNonMusic;
YoutubeApiType youtubeApiType;
bool systemTitleBar;
bool amoledDarkTheme; bool amoledDarkTheme;
bool checkUpdate;
bool normalizeAudio; bool normalizeAudio;
bool saveTrackLyrics;
bool showSystemTrayIcon;
bool skipNonMusic;
bool systemTitleBar;
CloseBehavior closeBehavior;
late SpotubeColor accentColorScheme;
LayoutMode layoutMode;
Locale locale;
Market recommendationMarket;
SearchMode searchMode;
String downloadLocation;
String pipedInstance;
ThemeMode themeMode;
YoutubeApiType youtubeApiType;
MusicCodec streamMusicCodec;
MusicCodec downloadMusicCodec;
final Ref ref; final Ref ref;
@ -96,6 +93,8 @@ class UserPreferences extends PersistedChangeNotifier {
this.systemTitleBar = false, this.systemTitleBar = false,
this.amoledDarkTheme = false, this.amoledDarkTheme = false,
this.normalizeAudio = true, this.normalizeAudio = true,
this.streamMusicCodec = MusicCodec.weba,
this.downloadMusicCodec = MusicCodec.m4a,
SpotubeColor? accentColorScheme, SpotubeColor? accentColorScheme,
}) : super() { }) : super() {
this.accentColorScheme = this.accentColorScheme =
@ -129,6 +128,20 @@ class UserPreferences extends PersistedChangeNotifier {
setAmoledDarkTheme(false); setAmoledDarkTheme(false);
setNormalizeAudio(true); setNormalizeAudio(true);
setAccentColorScheme(SpotubeColor(Colors.blue.value, name: "Blue")); setAccentColorScheme(SpotubeColor(Colors.blue.value, name: "Blue"));
setStreamMusicCodec(MusicCodec.weba);
setDownloadMusicCodec(MusicCodec.m4a);
}
void setStreamMusicCodec(MusicCodec codec) {
streamMusicCodec = codec;
notifyListeners();
updatePersistence();
}
void setDownloadMusicCodec(MusicCodec codec) {
downloadMusicCodec = codec;
notifyListeners();
updatePersistence();
} }
void setThemeMode(ThemeMode mode) { void setThemeMode(ThemeMode mode) {
@ -327,6 +340,16 @@ class UserPreferences extends PersistedChangeNotifier {
normalizeAudio = map["normalizeAudio"] ?? normalizeAudio; normalizeAudio = map["normalizeAudio"] ?? normalizeAudio;
audioPlayer.setAudioNormalization(normalizeAudio); audioPlayer.setAudioNormalization(normalizeAudio);
streamMusicCodec = MusicCodec.values.firstWhere(
(codec) => codec.name == map["streamMusicCodec"],
orElse: () => MusicCodec.weba,
);
downloadMusicCodec = MusicCodec.values.firstWhere(
(codec) => codec.name == map["downloadMusicCodec"],
orElse: () => MusicCodec.m4a,
);
} }
@override @override
@ -352,6 +375,8 @@ class UserPreferences extends PersistedChangeNotifier {
'systemTitleBar': systemTitleBar, 'systemTitleBar': systemTitleBar,
"amoledDarkTheme": amoledDarkTheme, "amoledDarkTheme": amoledDarkTheme,
"normalizeAudio": normalizeAudio, "normalizeAudio": normalizeAudio,
"streamMusicCodec": streamMusicCodec.name,
"downloadMusicCodec": downloadMusicCodec.name,
}; };
} }

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);

View File

@ -4,7 +4,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"bn": [ "bn": [
@ -12,7 +14,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"ca": [ "ca": [
@ -20,7 +24,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"de": [ "de": [
@ -28,7 +34,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"es": [ "es": [
@ -36,7 +44,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"fr": [ "fr": [
@ -44,7 +54,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"hi": [ "hi": [
@ -52,7 +64,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"ja": [ "ja": [
@ -60,7 +74,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"pl": [ "pl": [
@ -68,7 +84,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"pt": [ "pt": [
@ -76,7 +94,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"ru": [ "ru": [
@ -84,7 +104,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"uk": [ "uk": [
@ -92,7 +114,9 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
], ],
"zh": [ "zh": [
@ -100,6 +124,8 @@
"normalize_audio", "normalize_audio",
"change_cover", "change_cover",
"add_cover", "add_cover",
"restore_defaults" "restore_defaults",
"download_music_codec",
"streaming_music_codec"
] ]
} }