feat: remove SponsorBlock in favor of YT Music and remove pocketbase backend track support

This commit is contained in:
Kingkor Roy Tirtho 2023-05-16 10:50:17 +06:00
parent b058517912
commit fb780da327
17 changed files with 132 additions and 343 deletions

View File

@ -1,7 +1,3 @@
POCKETBASE_URL=
USERNAME=
PASSWORD=
# The format:
# SPOTIFY_SECRETS=clintId1:clientSecret1,clientId2:clientSecret2
SPOTIFY_SECRETS=

View File

@ -118,7 +118,7 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/KRTirt
Do the following:
- Download the latest Flutter SDK (>=2.15.1) & enable desktop support
- Download the latest Flutter SDK (>=3.10.0) & enable desktop support
- Install Development dependencies in linux
- Debian (>=12/Bookworm)/Ubuntu
```bash
@ -137,7 +137,7 @@ Do the following:
- Create a `.env` in root of the project following the `.env.example` template
- Now run the following to bootstrap the project
```bash
flutter pub get && flutter pub run build_runner build --delete-conflicting-outputs
flutter pub get && dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns
```
- Finally run these following commands in the root of the project to start the Spotube Locally
```bash

View File

@ -4,15 +4,6 @@ part 'env.g.dart';
@Envied(obfuscate: true, requireEnvFile: true, path: ".env")
abstract class Env {
@EnviedField(varName: 'POCKETBASE_URL', defaultValue: 'http://127.0.0.1:8090')
static final pocketbaseUrl = _Env.pocketbaseUrl;
@EnviedField(varName: 'USERNAME', defaultValue: 'root')
static final username = _Env.username;
@EnviedField(varName: 'PASSWORD', defaultValue: '12345678')
static final password = _Env.password;
@EnviedField(varName: 'SPOTIFY_SECRETS')
static final spotifySecrets = _Env.spotifySecrets.split(',').map((e) {
final secrets = e.trim().split(":").map((e) => e.trim());

View File

@ -1,10 +0,0 @@
import 'package:piped_client/piped_client.dart';
import 'package:spotube/models/track.dart';
import 'package:spotube/services/youtube.dart';
extension PipedStreamResponseExtension on PipedStreamResponse {
static Future<PipedStreamResponse> fromBackendTrack(
BackendTrack track) async {
return await PipedSpotube.client.streams(track.youtubeId);
}
}

View File

@ -9,6 +9,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:metadata_god/metadata_god.dart';
@ -19,11 +20,11 @@ import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/intents.dart';
import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/downloader_provider.dart';
import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/pocketbase.dart';
import 'package:spotube/services/youtube.dart';
import 'package:spotube/themes/theme.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
@ -93,6 +94,13 @@ Future<void> main(List<String> rawArgs) async {
cachePrefix: "oss.krtirtho.spotube",
cacheDir: (await getApplicationSupportDirectory()).path,
);
Hive.registerAdapter(MatchedTrackAdapter());
await Hive.openLazyBox<MatchedTrack>(
MatchedTrack.boxName,
path: (await getApplicationSupportDirectory()).path,
);
await PersistedStateNotifier.initializeBoxes();
Catcher(
@ -164,7 +172,6 @@ Future<void> main(List<String> rawArgs) async {
);
},
);
await initializePocketBase();
}
class Spotube extends StatefulHookConsumerWidget {

View File

@ -0,0 +1,17 @@
import "package:hive/hive.dart";
part "matched_track.g.dart";
@HiveType(typeId: 1)
class MatchedTrack {
@HiveField(0)
String youtubeId;
@HiveField(1)
String spotifyId;
static const boxName = "oss.krtirtho.spotube.matched_tracks";
static LazyBox<MatchedTrack> get box => Hive.lazyBox<MatchedTrack>(boxName);
MatchedTrack({required this.youtubeId, required this.spotifyId});
}

View File

@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'matched_track.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class MatchedTrackAdapter extends TypeAdapter<MatchedTrack> {
@override
final int typeId = 1;
@override
MatchedTrack read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return MatchedTrack(
youtubeId: fields[0] as String,
spotifyId: fields[1] as String,
);
}
@override
void write(BinaryWriter writer, MatchedTrack obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.youtubeId)
..writeByte(1)
..write(obj.spotifyId);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MatchedTrackAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@ -1,45 +1,29 @@
import 'dart:async';
import 'dart:convert';
import 'package:catcher/catcher.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:http/http.dart';
import 'package:piped_client/piped_client.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/piped.dart';
import 'package:spotube/extensions/album_simple.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/track.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/pocketbase.dart';
import 'package:spotube/services/youtube.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:collection/collection.dart';
enum SpotubeTrackMatchAlgorithm {
// selects the first result returned from YouTube
youtube,
// selects the most popular one
popular,
// selects the most popular one from the author of the track
authenticPopular,
}
typedef SkipSegment = ({int start, int end});
class SpotubeTrack extends Track {
final PipedStreamResponse ytTrack;
final String ytUri;
final List<SkipSegment> skipSegments;
final List<PipedSearchItemStream> siblings;
SpotubeTrack(
this.ytTrack,
this.ytUri,
this.skipSegments,
this.siblings,
) : super();
@ -47,7 +31,6 @@ class SpotubeTrack extends Track {
required Track track,
required this.ytTrack,
required this.ytUri,
required this.skipSegments,
required this.siblings,
}) : super() {
album = track.album;
@ -84,53 +67,7 @@ class SpotubeTrack extends Track {
}
}
static Future<List<SkipSegment>> getSkipSegments(
String id,
UserPreferences preferences,
) async {
if (!preferences.skipSponsorSegments) return [];
try {
final res = await get(Uri(
scheme: "https",
host: "sponsor.ajay.app",
path: "/api/skipSegments",
queryParameters: {
"videoID": id,
"category": [
'sponsor',
'selfpromo',
'interaction',
'intro',
'outro',
'music_offtopic'
],
"actionType": 'skip'
},
));
if (res.body == "Not Found") {
return List.castFrom<dynamic, SkipSegment>([]);
}
final data = jsonDecode(res.body) as List;
final segments = data.map((obj) {
return (
start: obj["segment"].first.toInt(),
end: obj["segment"].last.toInt(),
);
}).toList();
getLogger(SpotubeTrack).v(
"[SponsorBlock] successfully fetched skip segments for $id",
);
return List.castFrom<dynamic, SkipSegment>(segments);
} catch (e, stack) {
Catcher.reportCheckedError(e, stack);
return List.castFrom<dynamic, SkipSegment>([]);
}
}
static Future<SpotubeTrack> fetchFromTrack(
Track track, UserPreferences preferences) async {
static Future<List<PipedSearchItemStream>> fetchSiblings(Track track) async {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
@ -143,69 +80,59 @@ class SpotubeTrack extends Track {
onlyCleanArtist: true,
).trim();
final cachedTracks = await Future<RecordModel?>.value(
pb
.collection(BackendTrack.collection)
.getFirstListItem("spotify_id = '${track.id}'"),
).catchError((e, stack) {
return null;
});
final List<PipedSearchItemStream> siblings = await PipedSpotube.client
.search(
"$title - ${artists.join(", ")}",
PipedFilter.musicSongs,
)
.then(
(res) {
final siblings = res.items
.whereType<PipedSearchItemStream>()
.where((item) {
return artists.any(
(artist) =>
artist.toLowerCase() == item.uploaderName.toLowerCase(),
);
})
.take(10)
.toList();
final cachedTrack =
cachedTracks != null ? BackendTrack.fromRecord(cachedTracks) : null;
if (siblings.isEmpty) {
return res.items.whereType<PipedSearchItemStream>().take(10).toList();
}
return siblings;
},
);
return siblings;
}
static Future<SpotubeTrack> fetchFromTrack(
Track track,
UserPreferences preferences,
) async {
final matchedCachedTrack = await MatchedTrack.box.get(track.id!);
var siblings = <PipedSearchItemStream>[];
PipedStreamResponse ytVideo;
PipedAudioStream ytStream;
List<PipedSearchItemStream> siblings = [];
List<SkipSegment> skipSegments = [];
if (cachedTrack != null) {
final responses = await Future.wait(
[
PipedStreamResponseExtension.fromBackendTrack(cachedTrack),
if (preferences.skipSponsorSegments)
getSkipSegments(cachedTrack.youtubeId, preferences)
else
Future.value([])
],
);
ytVideo = responses.first as PipedStreamResponse;
skipSegments = responses.last as List<SkipSegment>;
ytStream = getStreamInfo(ytVideo, preferences.audioQuality);
if (matchedCachedTrack != null) {
ytVideo = await PipedSpotube.client.streams(matchedCachedTrack.youtubeId);
} else {
final videos = await PipedSpotube.client
.search("${artists.join(", ")} - $title", PipedFilter.musicSongs);
// await PrimitiveUtils.raceMultiple(
// () => youtube.search.search("${artists.join(", ")} - $title"),
// );
siblings =
videos.items.whereType<PipedSearchItemStream>().take(10).toList();
final responses = await Future.wait(
[
PipedSpotube.client.streams(siblings.first.id),
if (preferences.skipSponsorSegments)
getSkipSegments(siblings.first.id, preferences)
else
Future.value([])
],
siblings = await fetchSiblings(track);
ytVideo = await PipedSpotube.client.streams(siblings.first.id);
await MatchedTrack.box.put(
track.id!,
MatchedTrack(
youtubeId: ytVideo.id,
spotifyId: track.id!,
),
);
ytVideo = responses.first as PipedStreamResponse;
skipSegments = responses.last as List<SkipSegment>;
ytStream = getStreamInfo(ytVideo, preferences.audioQuality);
}
if (cachedTrack == null) {
await Future<RecordModel?>.value(
pb.collection(BackendTrack.collection).create(
body: BackendTrack(
spotifyId: track.id!,
youtubeId: ytVideo.id,
votes: 0,
).toJson(),
)).catchError((e, stack) {
Catcher.reportCheckedError(e, stack);
return null;
});
}
final PipedAudioStream ytStream =
getStreamInfo(ytVideo, preferences.audioQuality);
if (preferences.predownload &&
ytVideo.duration < const Duration(minutes: 15)) {
@ -230,7 +157,6 @@ class SpotubeTrack extends Track {
track: track,
ytTrack: ytVideo,
ytUri: ytStream.url,
skipSegments: skipSegments,
siblings: siblings,
);
}
@ -241,59 +167,19 @@ class SpotubeTrack extends Track {
) async {
if (siblings.none((element) => element.id == video.id)) return null;
final [PipedStreamResponse ytVideo, List<SkipSegment> skipSegments] =
await Future.wait<dynamic>(
[
PipedSpotube.client.streams(video.id),
if (preferences.skipSponsorSegments)
getSkipSegments(video.id, preferences)
else
Future.value(<Map<String, int>>[])
],
);
// await PrimitiveUtils.raceMultiple(
// () => youtube.videos.streams.getManifest(video.id),
// );
final ytVideo = await PipedSpotube.client.streams(video.id);
final ytStream = getStreamInfo(ytVideo, preferences.audioQuality);
final ytUri = ytStream.url;
final cachedTracks = await Future<RecordModel?>.value(
pb.collection(BackendTrack.collection).getFirstListItem(
"spotify_id = '$id' && youtube_id = '${video.id}'",
),
).catchError((e, stack) {
Catcher.reportCheckedError(e, stack);
return null;
});
final cachedTrack =
cachedTracks != null ? BackendTrack.fromRecord(cachedTracks) : null;
if (cachedTrack == null) {
await Future<RecordModel?>.value(
pb.collection(BackendTrack.collection).create(
body: BackendTrack(
spotifyId: id!,
await MatchedTrack.box.put(
id!,
MatchedTrack(
youtubeId: video.id,
votes: 1,
).toJson(),
)).catchError((e, stack) {
Catcher.reportCheckedError(e, stack);
return null;
});
} else {
await Future<RecordModel?>.value(
pb.collection(BackendTrack.collection).update(
cachedTrack.id,
body: {"votes": cachedTrack.votes + 1},
)).catchError((e, stack) {
Catcher.reportCheckedError(e, stack);
return null;
});
}
spotifyId: id!,
),
);
if (preferences.predownload &&
video.duration < const Duration(minutes: 15)) {
@ -318,7 +204,6 @@ class SpotubeTrack extends Track {
track: this,
ytTrack: ytVideo,
ytUri: ytUri,
skipSegments: skipSegments,
siblings: [
video,
...siblings.where((element) => element.id != video.id),
@ -331,7 +216,6 @@ class SpotubeTrack extends Track {
track: Track.fromJson(map),
ytTrack: PipedStreamResponse.fromJson(map["ytTrack"]),
ytUri: map["ytUri"],
skipSegments: List.castFrom<dynamic, SkipSegment>(map["skipSegments"]),
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
.map((sibling) => PipedSearchItemStream.fromJson(sibling))
.toList(),
@ -340,30 +224,13 @@ class SpotubeTrack extends Track {
Future<SpotubeTrack> populatedCopy() async {
if (this.siblings.isNotEmpty) return this;
final artists = (this.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
final title = ServiceUtils.getTitle(
name!,
artists: artists,
onlyCleanArtist: true,
).trim();
final videos = await PipedSpotube.client.search(
"${artists.join(", ")} - $title",
PipedFilter.musicSongs,
);
final siblings =
videos.items.whereType<PipedSearchItemStream>().take(10).toList();
final siblings = await fetchSiblings(this);
return SpotubeTrack.fromTrack(
track: this,
ytTrack: ytTrack,
ytUri: ytUri,
skipSegments: skipSegments,
siblings: siblings,
);
}
@ -388,7 +255,6 @@ class SpotubeTrack extends Track {
"uri": uri,
"ytTrack": ytTrack.toJson(),
"ytUri": ytUri,
"skipSegments": skipSegments,
"siblings": siblings.map((sibling) => sibling.toJson()).toList(),
};
}

View File

@ -1,29 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pocketbase/pocketbase.dart';
part 'track.g.dart';
@JsonSerializable()
class BackendTrack extends RecordModel {
@JsonKey(name: "spotify_id")
final String spotifyId;
@JsonKey(name: "youtube_id")
final String youtubeId;
final int votes;
BackendTrack({
required this.spotifyId,
required this.youtubeId,
required this.votes,
});
factory BackendTrack.fromRecord(RecordModel record) =>
BackendTrack.fromJson(record.toJson());
factory BackendTrack.fromJson(Map<String, dynamic> json) =>
_$BackendTrackFromJson(json);
@override
Map<String, dynamic> toJson() => _$BackendTrackToJson(this);
static String collection = "tracks";
}

View File

@ -1,30 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'track.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BackendTrack _$BackendTrackFromJson(Map<String, dynamic> json) => BackendTrack(
spotifyId: json['spotify_id'] as String,
youtubeId: json['youtube_id'] as String,
votes: json['votes'] as int,
)
..id = json['id'] as String
..created = json['created'] as String
..updated = json['updated'] as String
..collectionId = json['collectionId'] as String
..collectionName = json['collectionName'] as String;
Map<String, dynamic> _$BackendTrackToJson(BackendTrack instance) =>
<String, dynamic>{
'id': instance.id,
'created': instance.created,
'updated': instance.updated,
'collectionId': instance.collectionId,
'collectionName': instance.collectionName,
'spotify_id': instance.spotifyId,
'youtube_id': instance.youtubeId,
'votes': instance.votes,
};

View File

@ -296,14 +296,6 @@ class SettingsPage extends HookConsumerWidget {
preferences.setPredownload(state);
},
),
SwitchListTile(
secondary: const Icon(SpotubeIcons.fastForward),
title: Text(context.l10n.skip_non_music),
value: preferences.skipSponsorSegments,
onChanged: (state) {
preferences.setSkipSponsorSegments(state);
},
),
ListTile(
leading: const Icon(SpotubeIcons.playlistRemove),
title: Text(context.l10n.blacklist),

View File

@ -104,27 +104,6 @@ class ProxyPlaylistNotifier extends StateNotifier<ProxyPlaylist>
if (nextSource == null || isPlayable(nextSource)) return;
await audioPlayer.pause();
});
audioPlayer.positionStream.listen((pos) async {
if (audioPlayer.currentIndex == -1) return;
final activeSource =
audioPlayer.sources.elementAtOrNull(audioPlayer.currentIndex);
if (activeSource == null) return;
final activeTrack = state.tracks.firstWhereOrNull(
(element) => element is SpotubeTrack && element.ytUri == activeSource,
) as SpotubeTrack?;
if (activeTrack == null) return;
// skip all the activeTrack.skipSegments
if (activeTrack.skipSegments.isNotEmpty == true &&
preferences.skipSponsorSegments) {
for (final segment in activeTrack.skipSegments) {
if (pos.inSeconds < segment.start || pos.inSeconds >= segment.end) {
continue;
}
await audioPlayer.seek(Duration(seconds: segment.end));
}
}
});
}();
}

View File

@ -37,7 +37,6 @@ class UserPreferences extends PersistedChangeNotifier {
SpotubeColor accentColorScheme;
bool albumColorSync;
bool skipSponsorSegments;
String downloadLocation;
@ -64,7 +63,6 @@ class UserPreferences extends PersistedChangeNotifier {
this.saveTrackLyrics = false,
this.checkUpdate = true,
this.audioQuality = AudioQuality.high,
this.skipSponsorSegments = true,
this.downloadLocation = "",
this.closeBehavior = CloseBehavior.minimizeToTray,
this.showSystemTrayIcon = true,
@ -132,12 +130,6 @@ class UserPreferences extends PersistedChangeNotifier {
updatePersistence();
}
void setSkipSponsorSegments(bool should) {
skipSponsorSegments = should;
notifyListeners();
updatePersistence();
}
void setDownloadLocation(String downloadDir) {
if (downloadDir.isEmpty) return;
downloadLocation = downloadDir;
@ -195,7 +187,6 @@ class UserPreferences extends PersistedChangeNotifier {
audioQuality = map["audioQuality"] != null
? AudioQuality.values[map["audioQuality"]]
: audioQuality;
skipSponsorSegments = map["skipSponsorSegments"] ?? skipSponsorSegments;
downloadLocation =
map["downloadLocation"] ?? await _getDefaultDownloadDirectory();
@ -227,7 +218,6 @@ class UserPreferences extends PersistedChangeNotifier {
"albumColorSync": albumColorSync,
"checkUpdate": checkUpdate,
"audioQuality": audioQuality.index,
"skipSponsorSegments": skipSponsorSegments,
"downloadLocation": downloadLocation,
"layoutMode": layoutMode.name,
"predownload": predownload,

View File

@ -1,14 +0,0 @@
import 'package:catcher/catcher.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:spotube/collections/env.dart';
final pb = PocketBase(Env.pocketbaseUrl);
bool isLoggedIn = false;
Future<void> initializePocketBase() async {
try {
await pb.collection("users").authWithPassword(Env.username, Env.password);
isLoggedIn = true;
} catch (e, stack) {
Catcher.reportCheckedError(e, stack);
}
}

View File

@ -152,7 +152,6 @@ abstract class TypeConversionUtils {
),
file.path,
[],
[],
);
track.album = Album()
..name = metadata?.album ?? "Spotube"

View File

@ -1296,14 +1296,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
pocketbase:
dependency: "direct main"
description:
name: pocketbase
sha256: "125e32fe39393cc54436ce518ccfd5649419f15879a1278599f2f58564e5231c"
url: "https://pub.dev"
source: hosted
version: "0.7.1+1"
pool:
dependency: transitive
description:
@ -1945,4 +1937,4 @@ packages:
version: "3.1.1"
sdks:
dart: ">=3.0.0 <4.0.0"
flutter: ">=3.3.0"
flutter: ">=3.10.0"

View File

@ -71,7 +71,6 @@ dependencies:
path: ^1.8.0
path_provider: ^2.0.8
permission_handler: ^10.2.0
pocketbase: ^0.7.1+1
popover: ^0.2.6+3
queue: ^3.1.0+1
scroll_to_index: ^3.0.1