mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-05 23:19:42 +00:00
383 lines
11 KiB
Dart
383 lines
11 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:drift/drift.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotube/models/database/database.dart';
|
|
import 'package:spotube/models/metadata/metadata.dart';
|
|
import 'package:spotube/models/playback/track_sources.dart';
|
|
import 'package:spotube/provider/database/database.dart';
|
|
import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart';
|
|
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
|
|
import 'package:spotube/services/dio/dio.dart';
|
|
import 'package:spotube/services/logger/logger.dart';
|
|
|
|
import 'package:spotube/services/sourced_track/exceptions.dart';
|
|
import 'package:spotube/utils/service_utils.dart';
|
|
|
|
final officialMusicRegex = RegExp(
|
|
r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)",
|
|
caseSensitive: false,
|
|
);
|
|
|
|
class SourcedTrack extends BasicSourcedTrack {
|
|
final Ref ref;
|
|
|
|
SourcedTrack({
|
|
required this.ref,
|
|
required super.info,
|
|
required super.query,
|
|
required super.source,
|
|
required super.siblings,
|
|
required super.sources,
|
|
});
|
|
|
|
static Future<SourcedTrack> fetchFromTrack({
|
|
required SpotubeFullTrackObject query,
|
|
required Ref ref,
|
|
}) async {
|
|
final audioSource = await ref.read(audioSourcePluginProvider.future);
|
|
final audioSourceConfig = await ref.read(metadataPluginsProvider
|
|
.selectAsync((data) => data.defaultAudioSourcePluginConfig));
|
|
if (audioSource == null || audioSourceConfig == null) {
|
|
throw Exception("Dude wat?");
|
|
}
|
|
|
|
final database = ref.read(databaseProvider);
|
|
final cachedSource = await (database.select(database.sourceMatchTable)
|
|
..where((s) =>
|
|
s.trackId.equals(query.id) &
|
|
s.sourceType.equals(audioSourceConfig.slug))
|
|
..limit(1)
|
|
..orderBy([
|
|
(s) =>
|
|
OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
|
|
]))
|
|
.get()
|
|
.then((s) => s.firstOrNull);
|
|
|
|
if (cachedSource == null) {
|
|
final siblings = await fetchSiblings(ref: ref, query: query);
|
|
if (siblings.isEmpty) {
|
|
throw TrackNotFoundError(query);
|
|
}
|
|
|
|
await database.into(database.sourceMatchTable).insert(
|
|
SourceMatchTableCompanion.insert(
|
|
trackId: query.id,
|
|
sourceInfo: Value(jsonEncode(siblings.first)),
|
|
sourceType: audioSourceConfig.slug,
|
|
),
|
|
);
|
|
|
|
final manifest = await audioSource.audioSource.streams(siblings.first);
|
|
|
|
return SourcedTrack(
|
|
ref: ref,
|
|
siblings: siblings.skip(1).toList(),
|
|
info: siblings.first,
|
|
source: audioSourceConfig.slug,
|
|
sources: manifest,
|
|
query: query,
|
|
);
|
|
}
|
|
final item = SpotubeAudioSourceMatchObject.fromJson(
|
|
jsonDecode(cachedSource.sourceInfo),
|
|
);
|
|
final manifest = await audioSource.audioSource.streams(item);
|
|
|
|
final sourcedTrack = SourcedTrack(
|
|
ref: ref,
|
|
siblings: [],
|
|
sources: manifest,
|
|
info: item,
|
|
query: query,
|
|
source: audioSourceConfig.slug,
|
|
);
|
|
|
|
AppLogger.log.i("${query.name}: ${sourcedTrack.url}");
|
|
|
|
return sourcedTrack;
|
|
}
|
|
|
|
static List<SpotubeAudioSourceMatchObject> rankResults(
|
|
List<SpotubeAudioSourceMatchObject> results,
|
|
SpotubeFullTrackObject track,
|
|
) {
|
|
return results
|
|
.map((sibling) {
|
|
int score = 0;
|
|
|
|
for (final artist in track.artists) {
|
|
final isSameChannelArtist =
|
|
sibling.artists.any((a) => a.toLowerCase() == artist.name);
|
|
|
|
if (isSameChannelArtist) {
|
|
score += 1;
|
|
}
|
|
|
|
final titleContainsArtist =
|
|
sibling.title.toLowerCase().contains(artist.name.toLowerCase());
|
|
|
|
if (titleContainsArtist) {
|
|
score += 1;
|
|
}
|
|
}
|
|
|
|
final titleContainsTrackName =
|
|
sibling.title.toLowerCase().contains(track.name.toLowerCase());
|
|
|
|
final hasOfficialFlag =
|
|
officialMusicRegex.hasMatch(sibling.title.toLowerCase());
|
|
|
|
if (titleContainsTrackName) {
|
|
score += 3;
|
|
}
|
|
|
|
if (hasOfficialFlag) {
|
|
score += 1;
|
|
}
|
|
|
|
if (hasOfficialFlag && titleContainsTrackName) {
|
|
score += 2;
|
|
}
|
|
|
|
return (sibling: sibling, score: score);
|
|
})
|
|
.sorted((a, b) => b.score.compareTo(a.score))
|
|
.map((e) => e.sibling)
|
|
.toList();
|
|
}
|
|
|
|
static Future<List<SpotubeAudioSourceMatchObject>> fetchSiblings({
|
|
required SpotubeFullTrackObject query,
|
|
required Ref ref,
|
|
}) async {
|
|
final audioSource = await ref.read(audioSourcePluginProvider.future);
|
|
|
|
if (audioSource == null) {
|
|
throw Exception("Dude wat?");
|
|
}
|
|
|
|
final videoResults = <SpotubeAudioSourceMatchObject>[];
|
|
|
|
final searchResults = await audioSource.audioSource.matches(query);
|
|
|
|
if (ServiceUtils.onlyContainsEnglish(query.name)) {
|
|
videoResults.addAll(searchResults);
|
|
} else {
|
|
videoResults.addAll(rankResults(searchResults, query));
|
|
}
|
|
|
|
return videoResults.toSet().toList();
|
|
}
|
|
|
|
Future<SourcedTrack> copyWithSibling() async {
|
|
if (siblings.isNotEmpty) {
|
|
return this;
|
|
}
|
|
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
|
|
|
|
return SourcedTrack(
|
|
ref: ref,
|
|
siblings: fetchedSiblings.where((s) => s.id != info.id).toList(),
|
|
source: source,
|
|
sources: sources,
|
|
info: info,
|
|
query: query,
|
|
);
|
|
}
|
|
|
|
Future<SourcedTrack?> swapWithSibling(
|
|
SpotubeAudioSourceMatchObject sibling) async {
|
|
if (sibling.id == info.id) {
|
|
return null;
|
|
}
|
|
|
|
final audioSource = await ref.read(audioSourcePluginProvider.future);
|
|
final audioSourceConfig = await ref.read(metadataPluginsProvider
|
|
.selectAsync((data) => data.defaultAudioSourcePluginConfig));
|
|
if (audioSource == null || audioSourceConfig == null) {
|
|
throw Exception("Dude wat?");
|
|
}
|
|
|
|
// 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, info);
|
|
|
|
final manifest = await audioSource.audioSource.streams(newSourceInfo);
|
|
|
|
final database = ref.read(databaseProvider);
|
|
|
|
await database.into(database.sourceMatchTable).insert(
|
|
SourceMatchTableCompanion.insert(
|
|
trackId: query.id,
|
|
sourceInfo: Value(jsonEncode(siblings.first)),
|
|
sourceType: audioSourceConfig.slug,
|
|
createdAt: Value(DateTime.now()),
|
|
),
|
|
mode: InsertMode.replace,
|
|
);
|
|
|
|
return SourcedTrack(
|
|
ref: ref,
|
|
source: source,
|
|
siblings: newSiblings,
|
|
sources: manifest,
|
|
info: newSourceInfo,
|
|
query: query,
|
|
);
|
|
}
|
|
|
|
Future<SourcedTrack?> swapWithSiblingOfIndex(int index) {
|
|
return swapWithSibling(siblings[index]);
|
|
}
|
|
|
|
Future<SourcedTrack> refreshStream() async {
|
|
final audioSource = await ref.read(audioSourcePluginProvider.future);
|
|
final audioSourceConfig = await ref.read(metadataPluginsProvider
|
|
.selectAsync((data) => data.defaultAudioSourcePluginConfig));
|
|
if (audioSource == null || audioSourceConfig == null) {
|
|
throw Exception("Dude wat?");
|
|
}
|
|
|
|
List<SpotubeAudioSourceStreamObject> validStreams = [];
|
|
|
|
final stringBuffer = StringBuffer();
|
|
for (final source in sources) {
|
|
final res = await globalDio.head(
|
|
source.url,
|
|
options:
|
|
Options(validateStatus: (status) => status != null && status < 500),
|
|
);
|
|
|
|
stringBuffer.writeln(
|
|
"[${query.id}] ${res.statusCode} ${source.container} ${source.codec} ${source.bitrate}",
|
|
);
|
|
|
|
if (res.statusCode! < 400) {
|
|
validStreams.add(source);
|
|
}
|
|
}
|
|
|
|
AppLogger.log.d(stringBuffer.toString());
|
|
|
|
if (validStreams.isEmpty) {
|
|
validStreams = await audioSource.audioSource.streams(info);
|
|
}
|
|
|
|
final sourcedTrack = SourcedTrack(
|
|
ref: ref,
|
|
siblings: siblings,
|
|
source: source,
|
|
sources: validStreams,
|
|
info: info,
|
|
query: query,
|
|
);
|
|
|
|
AppLogger.log.i("Refreshing ${query.name}: ${sourcedTrack.url}");
|
|
|
|
return sourcedTrack;
|
|
}
|
|
|
|
String? get url {
|
|
final preferences = ref.read(audioSourcePresetsProvider);
|
|
|
|
return getUrlOfQuality(
|
|
preferences.presets[preferences.selectedStreamingContainerIndex],
|
|
preferences.selectedStreamingQualityIndex,
|
|
);
|
|
}
|
|
|
|
/// Returns the URL of the track based on the codec and quality preferences.
|
|
/// If an exact match is not found, it will return the closest match based on
|
|
/// the user's audio quality preference.
|
|
///
|
|
/// If no sources match the codec, it will return the first or last source
|
|
/// based on the user's audio quality preference.
|
|
SpotubeAudioSourceStreamObject? getStreamOfQuality(
|
|
SpotubeAudioSourceContainerPreset preset,
|
|
int qualityIndex,
|
|
) {
|
|
final quality = preset.qualities[qualityIndex];
|
|
|
|
final exactMatch = sources.firstWhereOrNull(
|
|
(source) {
|
|
if (source.container != preset.name) return false;
|
|
|
|
if (quality case SpotubeAudioLosslessContainerQuality()) {
|
|
return source.sampleRate == quality.sampleRate &&
|
|
source.bitDepth == quality.bitDepth;
|
|
} else {
|
|
return source.bitrate ==
|
|
(preset as SpotubeAudioLossyContainerQuality).bitrate;
|
|
}
|
|
},
|
|
);
|
|
|
|
if (exactMatch != null) {
|
|
return exactMatch;
|
|
}
|
|
|
|
// Find the closest to preset
|
|
SpotubeAudioSourceStreamObject? closest;
|
|
for (final source in sources) {
|
|
if (source.container != preset.name) continue;
|
|
|
|
if (quality case SpotubeAudioLosslessContainerQuality()) {
|
|
final sourceBps = (source.bitDepth ?? 0) * (source.sampleRate ?? 0);
|
|
final qualityBps = quality.bitDepth * quality.sampleRate;
|
|
final closestBps =
|
|
(closest?.bitDepth ?? 0) * (closest?.sampleRate ?? 0);
|
|
|
|
if (sourceBps == qualityBps) {
|
|
closest = source;
|
|
break;
|
|
}
|
|
final closestDiff = (closestBps - qualityBps).abs();
|
|
final sourceDiff = (sourceBps - qualityBps).abs();
|
|
|
|
if (sourceDiff < closestDiff) {
|
|
closest = source;
|
|
}
|
|
} else {
|
|
final presetBitrate =
|
|
(preset as SpotubeAudioLossyContainerQuality).bitrate;
|
|
if (presetBitrate == source.bitrate) {
|
|
closest = source;
|
|
break;
|
|
}
|
|
|
|
final closestDiff = (closest?.bitrate ?? 0) - presetBitrate;
|
|
final sourceDiff = (source.bitrate ?? 0) - presetBitrate;
|
|
|
|
if (sourceDiff < closestDiff) {
|
|
closest = source;
|
|
}
|
|
}
|
|
}
|
|
|
|
return closest;
|
|
}
|
|
|
|
String? getUrlOfQuality(
|
|
SpotubeAudioSourceContainerPreset preset,
|
|
int qualityIndex,
|
|
) {
|
|
return getStreamOfQuality(preset, qualityIndex)?.url;
|
|
}
|
|
|
|
SpotubeAudioSourceContainerPreset? get qualityPreset {
|
|
final presetState = ref.read(audioSourcePresetsProvider);
|
|
return presetState.presets
|
|
.elementAtOrNull(presetState.selectedStreamingContainerIndex);
|
|
}
|
|
}
|