mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
275 lines
8.7 KiB
Dart
275 lines
8.7 KiB
Dart
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:dio/dio.dart' hide Response;
|
|
import 'package:dio/dio.dart' as dio_lib;
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:metadata_god/metadata_god.dart';
|
|
import 'package:path/path.dart';
|
|
import 'package:shelf/shelf.dart';
|
|
import 'package:spotube/models/metadata/metadata.dart';
|
|
import 'package:spotube/models/parser/range_headers.dart';
|
|
import 'package:spotube/models/playback/track_sources.dart';
|
|
import 'package:spotube/provider/audio_player/audio_player.dart';
|
|
import 'package:spotube/provider/audio_player/state.dart';
|
|
|
|
import 'package:spotube/provider/server/active_track_sources.dart';
|
|
import 'package:spotube/provider/server/track_sources.dart';
|
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
|
import 'package:spotube/services/logger/logger.dart';
|
|
import 'package:spotube/services/sourced_track/enums.dart';
|
|
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
|
import 'package:spotube/utils/service_utils.dart';
|
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
|
|
|
final _deviceClients = Set.unmodifiable({
|
|
YoutubeApiClient.ios,
|
|
YoutubeApiClient.android,
|
|
YoutubeApiClient.mweb,
|
|
YoutubeApiClient.safari,
|
|
});
|
|
|
|
String? get _randomUserAgent => _deviceClients
|
|
.elementAt(
|
|
Random().nextInt(_deviceClients.length),
|
|
)
|
|
.payload["context"]["client"]["userAgent"];
|
|
|
|
class ServerPlaybackRoutes {
|
|
final Ref ref;
|
|
UserPreferences get userPreferences => ref.read(userPreferencesProvider);
|
|
AudioPlayerState get playlist => ref.read(audioPlayerProvider);
|
|
final Dio dio;
|
|
|
|
ServerPlaybackRoutes(this.ref) : dio = Dio();
|
|
|
|
Future<({dio_lib.Response<Uint8List> response, Uint8List? bytes})>
|
|
streamTrack(
|
|
Request request,
|
|
SourcedTrack track,
|
|
Map<String, dynamic> headers,
|
|
) async {
|
|
final trackCacheFile = File(
|
|
join(
|
|
await UserPreferencesNotifier.getMusicCacheDir(),
|
|
ServiceUtils.sanitizeFilename(
|
|
'${track.query.title} - ${track.query.artists.join(",")} (${track.info.id}).${track.codec.name}',
|
|
),
|
|
),
|
|
);
|
|
|
|
if (await trackCacheFile.exists() && userPreferences.cacheMusic) {
|
|
final bytes = await trackCacheFile.readAsBytes();
|
|
final cachedFileLength = bytes.length;
|
|
|
|
return (
|
|
response: dio_lib.Response<Uint8List>(
|
|
statusCode: 200,
|
|
headers: Headers.fromMap({
|
|
"content-type": ["audio/${track.codec.name}"],
|
|
"content-length": ["$cachedFileLength"],
|
|
"accept-ranges": ["bytes"],
|
|
"content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"],
|
|
}),
|
|
requestOptions: RequestOptions(path: request.requestedUri.toString()),
|
|
),
|
|
bytes: bytes,
|
|
);
|
|
}
|
|
|
|
final trackPartialCacheFile = File("${trackCacheFile.path}.part");
|
|
|
|
String url = track.url ??
|
|
await ref
|
|
.read(trackSourcesProvider(track.query).notifier)
|
|
.swapWithNextSibling()
|
|
.then((track) => track.url!);
|
|
|
|
var options = Options(
|
|
headers: {
|
|
...headers,
|
|
"user-agent": _randomUserAgent,
|
|
"Cache-Control": "max-age=3600",
|
|
"Connection": "keep-alive",
|
|
"host": Uri.parse(url).host,
|
|
},
|
|
responseType: ResponseType.bytes,
|
|
validateStatus: (status) => status! < 400,
|
|
);
|
|
|
|
final contentLengthRes = await Future<dio_lib.Response?>.value(
|
|
dio.head(
|
|
url,
|
|
options: options,
|
|
),
|
|
).catchError((e, stack) async {
|
|
AppLogger.reportError(e, stack);
|
|
|
|
final sourcedTrack = await ref
|
|
.read(trackSourcesProvider(track.query).notifier)
|
|
.refreshStreamingUrl();
|
|
|
|
url = sourcedTrack.url!;
|
|
|
|
return dio.head(url, options: options);
|
|
});
|
|
|
|
// Redirect to m3u8 link directly as it handles range requests internally
|
|
if (contentLengthRes?.headers.value("content-type") ==
|
|
"application/vnd.apple.mpegurl") {
|
|
return (
|
|
response: dio_lib.Response<Uint8List>(
|
|
statusCode: 301,
|
|
statusMessage: "M3U8 Redirect",
|
|
headers: Headers.fromMap({
|
|
"location": [url],
|
|
"content-type": ["application/vnd.apple.mpegurl"],
|
|
}),
|
|
requestOptions: RequestOptions(path: request.requestedUri.toString()),
|
|
isRedirect: true,
|
|
),
|
|
bytes: null,
|
|
);
|
|
}
|
|
|
|
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<Uint8List>(url, options: options);
|
|
|
|
final bytes = res.data;
|
|
|
|
if (bytes == null || !userPreferences.cacheMusic) {
|
|
return (response: res, bytes: bytes);
|
|
}
|
|
|
|
final contentRange =
|
|
ContentRangeHeader.parse(res.headers.value("content-range") ?? "");
|
|
|
|
if (!await trackPartialCacheFile.exists()) {
|
|
await trackPartialCacheFile.create(recursive: true);
|
|
}
|
|
|
|
// Write the stream to the file based on the range
|
|
final partialCacheFile =
|
|
await trackPartialCacheFile.open(mode: FileMode.writeOnlyAppend);
|
|
int fileLength = 0;
|
|
try {
|
|
await partialCacheFile.setPosition(contentRange.start);
|
|
await partialCacheFile.writeFrom(bytes);
|
|
fileLength = await partialCacheFile.length();
|
|
} finally {
|
|
await partialCacheFile.close();
|
|
}
|
|
|
|
if (fileLength == contentRange.total) {
|
|
await trackPartialCacheFile.rename(trackCacheFile.path);
|
|
}
|
|
|
|
if (contentRange.total == fileLength && track.codec != SourceCodecs.weba) {
|
|
final playlistTrack = playlist.tracks.firstWhereOrNull(
|
|
(element) => element.id == track.query.id,
|
|
);
|
|
if (playlistTrack == null) {
|
|
AppLogger.log.e(
|
|
"Track ${track.query.id} not found in playlist, cannot write metadata.",
|
|
);
|
|
return (response: res, bytes: bytes);
|
|
}
|
|
|
|
final imageBytes = await ServiceUtils.downloadImage(
|
|
(playlistTrack.album.images).asUrlString(
|
|
placeholder: ImagePlaceholder.albumArt,
|
|
index: 1,
|
|
),
|
|
);
|
|
|
|
await MetadataGod.writeMetadata(
|
|
file: trackCacheFile.path,
|
|
metadata: (playlistTrack as SpotubeFullTrackObject).toMetadata(
|
|
imageBytes: imageBytes,
|
|
fileLength: fileLength,
|
|
),
|
|
);
|
|
}
|
|
|
|
return (bytes: bytes, response: res);
|
|
}
|
|
|
|
/// @get('/stream/<trackId>')
|
|
Future<Response> getStreamTrackId(Request request, String trackId) async {
|
|
try {
|
|
final track =
|
|
playlist.tracks.firstWhere((element) => element.id == trackId);
|
|
|
|
final activeSourcedTrack =
|
|
await ref.read(activeTrackSourcesProvider.future);
|
|
final sourcedTrack = activeSourcedTrack?.track.id == track.id
|
|
? activeSourcedTrack?.source
|
|
: await ref.read(
|
|
trackSourcesProvider(
|
|
//! Use [Request.requestedUri] as it contains full https url.
|
|
//! [Request.url] will exclude and starts relatively. (streams/<trackId>... basically)
|
|
TrackSourceQuery.parseUri(request.requestedUri.toString()),
|
|
).future,
|
|
);
|
|
|
|
final (bytes: audioBytes, response: res) = await streamTrack(
|
|
request,
|
|
sourcedTrack!,
|
|
request.headers,
|
|
);
|
|
|
|
return Response(
|
|
res.statusCode!,
|
|
body: audioBytes,
|
|
headers: res.headers.map,
|
|
);
|
|
} catch (e, stack) {
|
|
AppLogger.reportError(e, stack);
|
|
return Response.internalServerError();
|
|
}
|
|
}
|
|
|
|
/// @get('/playback/toggle-playback')
|
|
Future<Response> togglePlayback(Request request) async {
|
|
audioPlayer.isPlaying
|
|
? await audioPlayer.pause()
|
|
: await audioPlayer.resume();
|
|
|
|
return Response.ok("Playback toggled");
|
|
}
|
|
|
|
/// @get('/playback/previous')
|
|
Future<Response> previousTrack(Request request) async {
|
|
await audioPlayer.skipToPrevious();
|
|
return Response.ok("Previous track");
|
|
}
|
|
|
|
/// @get('/playback/next')
|
|
Future<Response> nextTrack(Request request) async {
|
|
await audioPlayer.skipToNext();
|
|
return Response.ok("Next track");
|
|
}
|
|
}
|
|
|
|
final serverPlaybackRoutesProvider =
|
|
Provider((ref) => ServerPlaybackRoutes(ref));
|