spotube/lib/provider/server/routes/playback.dart
Tomasz Jabłonowski 19adaf5e54 fix(server): race condition and cache corruption in playback routes
Two related bugs in ServerPlaybackRoutes:

1. _getSourcedTrack looked up the requested media in
   audioPlayer.playlist.medias by URI, but MPV issues its HTTP request
   before media_kit's Dart-side state has synced with the native
   player, so the lookup threw "Bad state: No element" and the proxy
   returned 500 ("Failed to open" in MPV). Now resolves the track
   directly from playlist.tracks by trackId via firstWhereOrNull,
   independent of the player's media list.

2. MPV opens multiple concurrent range requests for the same track
   (buffering/seek/reconnect), each opening its own writeOnlyAppend
   sink to the same .part cache file; the final rename would then fail
   with a file-in-use error (or corrupt the cache from concurrent
   appends). Added a _cachingInProgress guard so only one request
   caches a given track at a time, cache only full requests
   (contentRange.start == 0), write with truncate instead of append,
   and clean up the in-progress marker/partial file in finally.

Fixes #3089
2026-07-26 10:39:07 +02:00

389 lines
12 KiB
Dart

import 'dart:async';
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/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/sourced_track_provider.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/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;
/// Track IDs that are currently being written to the music cache.
/// MediaKit/MPV opens several concurrent range requests for the same track
/// (buffering, seeking, reconnects); without this guard every request would
/// open its own sink to the same `.part` file and the final `rename` would
/// fail with a file-in-use error on Windows.
final Set<String> _cachingInProgress = {};
ServerPlaybackRoutes(this.ref) : dio = Dio();
Future<String> _getTrackCacheFilePath(SourcedTrack track) async {
return join(
await UserPreferencesNotifier.getMusicCacheDir(),
ServiceUtils.sanitizeFilename(
'${track.query.name} - ${track.query.artists.map((d) => d.name).join(",")} (${track.info.id}).${track.qualityPreset!.getFileExtension()}',
),
);
}
Future<SourcedTrack?> _getSourcedTrack(String trackId) async {
final track = playlist.tracks
.firstWhereOrNull((element) => element.id == trackId);
if (track == null) return null;
final activeSourcedTrack =
await ref.read(activeTrackSourcesProvider.future);
final sourcedTrack = activeSourcedTrack?.track.id == track.id
? activeSourcedTrack?.source
: await ref.read(
sourcedTrackProvider(track as SpotubeFullTrackObject).future,
);
return sourcedTrack;
}
Future<dio_lib.Response> streamTrackInformation(
Request request,
SourcedTrack track,
) async {
AppLogger.log.i(
"HEAD request for track: ${track.query.name}\n"
"Headers: ${request.headers}",
);
final trackCacheFile = File(await _getTrackCacheFilePath(track));
if (await trackCacheFile.exists() && userPreferences.cacheMusic) {
final fileLength = await trackCacheFile.length();
return dio_lib.Response(
statusCode: 200,
headers: Headers.fromMap({
"content-type": ["audio/${track.qualityPreset!.name}"],
"content-length": ["$fileLength"],
"accept-ranges": ["bytes"],
"content-range": ["bytes 0-$fileLength/$fileLength"],
}),
requestOptions: RequestOptions(path: request.requestedUri.toString()),
);
}
final swappedUrl0 = track.url ??
await ref
.read(sourcedTrackProvider(track.query).notifier)
.swapWithNextSibling()
.then((t) => t.url);
if (swappedUrl0 == null) {
AppLogger.log.e("No playable URL for ${track.query.name}");
throw Exception("No playable URL for ${track.query.name}");
}
String url = swappedUrl0;
final options = Options(
headers: {
"user-agent": _randomUserAgent,
"Cache-Control": "max-age=3600",
"Connection": "keep-alive",
"host": Uri.parse(url).host,
},
validateStatus: (status) => status! < 400,
);
final res = await dio.head(url, options: options);
return res;
}
Future<dio_lib.Response> streamTrack(
Request request,
SourcedTrack track,
Map<String, dynamic> headers,
) async {
AppLogger.log.i(
"GET request for track: ${track.query.name}\n"
"Headers: ${request.headers}",
);
final trackCacheFile = File(await _getTrackCacheFilePath(track));
if (await trackCacheFile.exists() && userPreferences.cacheMusic) {
final bytes = await trackCacheFile.readAsBytes();
final cachedFileLength = bytes.length;
return dio_lib.Response<Uint8List>(
statusCode: 200,
headers: Headers.fromMap({
"content-type": ["audio/${track.qualityPreset!.name}"],
"content-length": ["${cachedFileLength - 1}"],
"accept-ranges": ["bytes"],
"content-range": [
"bytes 0-${cachedFileLength - 1}/$cachedFileLength"
],
"connection": ["close"],
}),
requestOptions: RequestOptions(path: request.requestedUri.toString()),
data: bytes,
);
}
final swappedUrl1 = track.url ??
await ref
.read(sourcedTrackProvider(track.query).notifier)
.swapWithNextSibling()
.then((t) => t.url);
if (swappedUrl1 == null) {
AppLogger.log.e("No playable URL for ${track.query.name}");
throw Exception("No playable URL for ${track.query.name}");
}
String url = swappedUrl1;
final options = Options(
headers: {
...headers,
"user-agent": _randomUserAgent,
"Cache-Control": "max-age=3600",
"Connection": "keep-alive",
"host": Uri.parse(url).host,
},
responseType: ResponseType.stream,
validateStatus: (status) => status! < 400,
);
final contentLengthRes = await Future<dio_lib.Response?>.value(
dio.head(
url,
options: options.copyWith(responseType: ResponseType.bytes),
),
).catchError((e, stack) async {
AppLogger.reportError(e, stack);
final sourcedTrack = await ref
.read(sourcedTrackProvider(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 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,
);
}
final res = await dio.get<ResponseBody>(url, options: options);
AppLogger.log.i(
"Response for track: ${track.query.name}\n"
"Status Code: ${res.statusCode}\n"
"Headers: ${res.headers.map}",
);
final contentRange = res.headers.value("content-range") != null
? ContentRangeHeader.parse(res.headers.value("content-range") ?? "")
: ContentRangeHeader(0, 0, 0);
// Only cache full requests (range starting at 0) and only one writer per
// track at a time. Concurrent range requests just stream straight through.
final trackId = track.info.id;
final isFullRequest = contentRange.start == 0;
if (!userPreferences.cacheMusic ||
!isFullRequest ||
_cachingInProgress.contains(trackId)) {
return res;
}
_cachingInProgress.add(trackId);
final resStream = res.data!.stream.asBroadcastStream();
final trackPartialCacheFile = File("${trackCacheFile.path}.part");
if (!await trackPartialCacheFile.exists()) {
await trackPartialCacheFile.create(recursive: true);
}
// Write the stream to the file based on the range
final partialCacheFileSink =
trackPartialCacheFile.openWrite(mode: FileMode.writeOnly);
resStream.listen(
(data) {
partialCacheFileSink.add(data);
},
onError: (e, stack) async {
await partialCacheFileSink.close();
_cachingInProgress.remove(trackId);
},
onDone: () async {
try {
await partialCacheFileSink.close();
final fileLength = await trackPartialCacheFile.length();
if (fileLength != contentRange.total) {
await trackPartialCacheFile.delete().catchError((_) => trackPartialCacheFile);
return;
}
await trackPartialCacheFile.rename(trackCacheFile.path);
if (track.qualityPreset!.getFileExtension() == "weba") return;
final imageBytes = await ServiceUtils.downloadImage(
track.query.album.images.asUrlString(
placeholder: ImagePlaceholder.albumArt,
index: 1,
),
);
await MetadataGod.writeMetadata(
file: trackCacheFile.path,
metadata: track.query.toMetadata(
imageBytes: imageBytes,
fileLength: fileLength,
),
).catchError((e, stackTrace) {
AppLogger.reportError(e, stackTrace);
});
} catch (e, stack) {
AppLogger.reportError(e, stack);
} finally {
_cachingInProgress.remove(trackId);
}
},
cancelOnError: true,
);
res.data?.stream =
resStream; // To avoid Stream has been already listened to exception
return res;
}
/// @head('/stream/<trackId>')
Future<Response> headStreamTrackId(Request request, String trackId) async {
try {
final sourcedTrack = await _getSourcedTrack(trackId);
if (sourcedTrack == null) {
return Response.notFound("Track not found in the current queue");
}
final res = await streamTrackInformation(
request,
sourcedTrack,
);
return Response(
res.statusCode!,
headers: res.headers.map,
);
} catch (e, stack) {
AppLogger.reportError(e, stack);
return Response.internalServerError();
}
}
/// @get('/stream/<trackId>')
Future<Response> getStreamTrackId(Request request, String trackId) async {
try {
final sourcedTrack = await _getSourcedTrack(trackId);
if (sourcedTrack == null) {
return Response.notFound("Track not found in the current queue");
}
final res = await streamTrack(
request,
sourcedTrack,
request.headers,
);
if (res.data is ResponseBody) {
return Response(
res.statusCode!,
body: (res.data as ResponseBody).stream,
headers: res.headers.map,
);
}
return Response(
res.statusCode!,
body: res.data,
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));