feat: add plugin audio source models and api service

This commit is contained in:
Kingkor Roy Tirtho 2025-10-19 13:48:53 +06:00
parent 88699e9a3b
commit 439de5d7f7
10 changed files with 1827 additions and 47 deletions

View File

@ -0,0 +1,84 @@
part of 'metadata.dart';
enum SpotubeMediaCompressionType {
lossy,
lossless,
}
@Freezed(unionKey: 'type')
class SpotubeAudioSourceContainerPreset
with _$SpotubeAudioSourceContainerPreset {
@FreezedUnionValue("lossy")
factory SpotubeAudioSourceContainerPreset.lossy({
required SpotubeMediaCompressionType type,
required String name,
required List<SpotubeAudioLossyContainerQuality> qualities,
}) = SpotubeAudioSourceContainerPresetLossy;
@FreezedUnionValue("lossless")
factory SpotubeAudioSourceContainerPreset.lossless({
required SpotubeMediaCompressionType type,
required String name,
required List<SpotubeAudioLosslessContainerQuality> qualities,
}) = SpotubeAudioSourceContainerPresetLossless;
factory SpotubeAudioSourceContainerPreset.fromJson(
Map<String, dynamic> json) =>
_$SpotubeAudioSourceContainerPresetFromJson(json);
}
@freezed
class SpotubeAudioLossyContainerQuality
with _$SpotubeAudioLossyContainerQuality {
factory SpotubeAudioLossyContainerQuality({
required double bitrate,
}) = _SpotubeAudioLossyContainerQuality;
factory SpotubeAudioLossyContainerQuality.fromJson(
Map<String, dynamic> json) =>
_$SpotubeAudioLossyContainerQualityFromJson(json);
}
@freezed
class SpotubeAudioLosslessContainerQuality
with _$SpotubeAudioLosslessContainerQuality {
factory SpotubeAudioLosslessContainerQuality({
required int bitDepth,
required double sampleRate,
}) = _SpotubeAudioLosslessContainerQuality;
factory SpotubeAudioLosslessContainerQuality.fromJson(
Map<String, dynamic> json) =>
_$SpotubeAudioLosslessContainerQualityFromJson(json);
}
@freezed
class SpotubeAudioSourceMatchObject with _$SpotubeAudioSourceMatchObject {
factory SpotubeAudioSourceMatchObject({
required String id,
required String title,
required List<String> artists,
required Duration duration,
String? thumbnail,
required String externalUri,
}) = _SpotubeAudioSourceMatchObject;
factory SpotubeAudioSourceMatchObject.fromJson(Map<String, dynamic> json) =>
_$SpotubeAudioSourceMatchObjectFromJson(json);
}
@freezed
class SpotubeAudioSourceStreamObject with _$SpotubeAudioSourceStreamObject {
factory SpotubeAudioSourceStreamObject({
required String url,
required String container,
required SpotubeMediaCompressionType type,
String? codec,
double? bitrate,
int? bitDepth,
double? sampleRate,
}) = _SpotubeAudioSourceStreamObject;
factory SpotubeAudioSourceStreamObject.fromJson(Map<String, dynamic> json) =>
_$SpotubeAudioSourceStreamObjectFromJson(json);
}

View File

@ -15,6 +15,7 @@ import 'package:spotube/utils/primitive_utils.dart';
part 'metadata.g.dart';
part 'metadata.freezed.dart';
part 'audio_source.dart';
part 'album.dart';
part 'artist.dart';
part 'browse.dart';

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,123 @@ part of 'metadata.dart';
// JsonSerializableGenerator
// **************************************************************************
_$SpotubeAudioSourceContainerPresetLossyImpl
_$$SpotubeAudioSourceContainerPresetLossyImplFromJson(Map json) =>
_$SpotubeAudioSourceContainerPresetLossyImpl(
type: $enumDecode(_$SpotubeMediaCompressionTypeEnumMap, json['type']),
name: json['name'] as String,
qualities: (json['qualities'] as List<dynamic>)
.map((e) => SpotubeAudioLossyContainerQuality.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList(),
);
Map<String, dynamic> _$$SpotubeAudioSourceContainerPresetLossyImplToJson(
_$SpotubeAudioSourceContainerPresetLossyImpl instance) =>
<String, dynamic>{
'type': _$SpotubeMediaCompressionTypeEnumMap[instance.type]!,
'name': instance.name,
'qualities': instance.qualities.map((e) => e.toJson()).toList(),
};
const _$SpotubeMediaCompressionTypeEnumMap = {
SpotubeMediaCompressionType.lossy: 'lossy',
SpotubeMediaCompressionType.lossless: 'lossless',
};
_$SpotubeAudioSourceContainerPresetLosslessImpl
_$$SpotubeAudioSourceContainerPresetLosslessImplFromJson(Map json) =>
_$SpotubeAudioSourceContainerPresetLosslessImpl(
type: $enumDecode(_$SpotubeMediaCompressionTypeEnumMap, json['type']),
name: json['name'] as String,
qualities: (json['qualities'] as List<dynamic>)
.map((e) => SpotubeAudioLosslessContainerQuality.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList(),
);
Map<String, dynamic> _$$SpotubeAudioSourceContainerPresetLosslessImplToJson(
_$SpotubeAudioSourceContainerPresetLosslessImpl instance) =>
<String, dynamic>{
'type': _$SpotubeMediaCompressionTypeEnumMap[instance.type]!,
'name': instance.name,
'qualities': instance.qualities.map((e) => e.toJson()).toList(),
};
_$SpotubeAudioLossyContainerQualityImpl
_$$SpotubeAudioLossyContainerQualityImplFromJson(Map json) =>
_$SpotubeAudioLossyContainerQualityImpl(
bitrate: (json['bitrate'] as num).toDouble(),
);
Map<String, dynamic> _$$SpotubeAudioLossyContainerQualityImplToJson(
_$SpotubeAudioLossyContainerQualityImpl instance) =>
<String, dynamic>{
'bitrate': instance.bitrate,
};
_$SpotubeAudioLosslessContainerQualityImpl
_$$SpotubeAudioLosslessContainerQualityImplFromJson(Map json) =>
_$SpotubeAudioLosslessContainerQualityImpl(
bitDepth: (json['bitDepth'] as num).toInt(),
sampleRate: (json['sampleRate'] as num).toDouble(),
);
Map<String, dynamic> _$$SpotubeAudioLosslessContainerQualityImplToJson(
_$SpotubeAudioLosslessContainerQualityImpl instance) =>
<String, dynamic>{
'bitDepth': instance.bitDepth,
'sampleRate': instance.sampleRate,
};
_$SpotubeAudioSourceMatchObjectImpl
_$$SpotubeAudioSourceMatchObjectImplFromJson(Map json) =>
_$SpotubeAudioSourceMatchObjectImpl(
id: json['id'] as String,
title: json['title'] as String,
artists: (json['artists'] as List<dynamic>)
.map((e) => e as String)
.toList(),
duration: Duration(microseconds: (json['duration'] as num).toInt()),
thumbnail: json['thumbnail'] as String?,
externalUri: json['externalUri'] as String,
);
Map<String, dynamic> _$$SpotubeAudioSourceMatchObjectImplToJson(
_$SpotubeAudioSourceMatchObjectImpl instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'artists': instance.artists,
'duration': instance.duration.inMicroseconds,
'thumbnail': instance.thumbnail,
'externalUri': instance.externalUri,
};
_$SpotubeAudioSourceStreamObjectImpl
_$$SpotubeAudioSourceStreamObjectImplFromJson(Map json) =>
_$SpotubeAudioSourceStreamObjectImpl(
url: json['url'] as String,
container: json['container'] as String,
type: $enumDecode(_$SpotubeMediaCompressionTypeEnumMap, json['type']),
codec: json['codec'] as String?,
bitrate: (json['bitrate'] as num?)?.toDouble(),
bitDepth: (json['bitDepth'] as num?)?.toInt(),
sampleRate: (json['sampleRate'] as num?)?.toDouble(),
);
Map<String, dynamic> _$$SpotubeAudioSourceStreamObjectImplToJson(
_$SpotubeAudioSourceStreamObjectImpl instance) =>
<String, dynamic>{
'url': instance.url,
'container': instance.container,
'type': _$SpotubeMediaCompressionTypeEnumMap[instance.type]!,
'codec': instance.codec,
'bitrate': instance.bitrate,
'bitDepth': instance.bitDepth,
'sampleRate': instance.sampleRate,
};
_$SpotubeFullAlbumObjectImpl _$$SpotubeFullAlbumObjectImplFromJson(Map json) =>
_$SpotubeFullAlbumObjectImpl(
id: json['id'] as String,
@ -419,7 +536,6 @@ Map<String, dynamic> _$$SpotubeUserObjectImplToJson(
_$PluginConfigurationImpl _$$PluginConfigurationImplFromJson(Map json) =>
_$PluginConfigurationImpl(
type: $enumDecode(_$PluginTypeEnumMap, json['type']),
name: json['name'] as String,
description: json['description'] as String,
version: json['version'] as String,
@ -440,7 +556,6 @@ _$PluginConfigurationImpl _$$PluginConfigurationImplFromJson(Map json) =>
Map<String, dynamic> _$$PluginConfigurationImplToJson(
_$PluginConfigurationImpl instance) =>
<String, dynamic>{
'type': _$PluginTypeEnumMap[instance.type]!,
'name': instance.name,
'description': instance.description,
'version': instance.version,
@ -453,10 +568,6 @@ Map<String, dynamic> _$$PluginConfigurationImplToJson(
'repository': instance.repository,
};
const _$PluginTypeEnumMap = {
PluginType.metadata: 'metadata',
};
const _$PluginApisEnumMap = {
PluginApis.webview: 'webview',
PluginApis.localstorage: 'localstorage',
@ -466,6 +577,8 @@ const _$PluginApisEnumMap = {
const _$PluginAbilitiesEnumMap = {
PluginAbilities.authentication: 'authentication',
PluginAbilities.scrobbling: 'scrobbling',
PluginAbilities.metadata: 'metadata',
PluginAbilities.audioSource: 'audio-source',
};
_$PluginUpdateAvailableImpl _$$PluginUpdateAvailableImplFromJson(Map json) =>

View File

@ -1,17 +1,20 @@
part of 'metadata.dart';
enum PluginType { metadata }
enum PluginApis { webview, localstorage, timezone }
enum PluginAbilities { authentication, scrobbling }
enum PluginAbilities {
authentication,
scrobbling,
metadata,
@JsonValue('audio-source')
audioSource,
}
@freezed
class PluginConfiguration with _$PluginConfiguration {
const PluginConfiguration._();
factory PluginConfiguration({
required PluginType type,
required String name,
required String description,
required String version,

View File

@ -10,6 +10,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/youtube_engine/youtube_engine.dart';
import 'package:spotube/services/dio/dio.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
@ -97,7 +98,6 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
final plugin = plugins[i];
final pluginConfig = PluginConfiguration(
type: PluginType.metadata,
name: plugin.name,
author: plugin.author,
description: plugin.description,
@ -447,6 +447,7 @@ final metadataPluginProvider = FutureProvider<MetadataPlugin?>(
final defaultPlugin = await ref.watch(
metadataPluginsProvider.selectAsync((data) => data.defaultPluginConfig),
);
final youtubeEngine = ref.read(youtubeEngineProvider);
if (defaultPlugin == null) {
return null;
@ -456,6 +457,10 @@ final metadataPluginProvider = FutureProvider<MetadataPlugin?>(
final pluginByteCode =
await pluginsNotifier.getPluginByteCode(defaultPlugin);
return await MetadataPlugin.create(defaultPlugin, pluginByteCode);
return await MetadataPlugin.create(
youtubeEngine,
defaultPlugin,
pluginByteCode,
);
},
);

View File

@ -0,0 +1,38 @@
import 'package:hetu_script/hetu_script.dart';
import 'package:hetu_script/values.dart';
import 'package:spotube/models/metadata/metadata.dart';
class MetadataPluginAudioSourceEndpoint {
final Hetu hetu;
MetadataPluginAudioSourceEndpoint(this.hetu);
HTInstance get hetuMetadataAudioSource =>
(hetu.fetch("metadataPlugin") as HTInstance).memberGet("audioSource")
as HTInstance;
List<SpotubeAudioSourceContainerPreset> get supportedPresets {
final raw = hetuMetadataAudioSource.memberGet("supportedPresets") as List;
return raw
.map((e) => SpotubeAudioSourceContainerPreset.fromJson(e))
.toList();
}
Future<List<SpotubeAudioSourceMatchObject>> matches(
SpotubeFullTrackObject track,
) async {
final raw = await hetuMetadataAudioSource
.invoke("matches", positionalArgs: [track]) as List;
return raw.map((e) => SpotubeAudioSourceMatchObject.fromJson(e)).toList();
}
Future<List<SpotubeAudioSourceStreamObject>> streams(
SpotubeAudioSourceMatchObject match,
) async {
final raw = await hetuMetadataAudioSource
.invoke("streams", positionalArgs: [match]) as List;
return raw.map((e) => SpotubeAudioSourceStreamObject.fromJson(e)).toList();
}
}

View File

@ -3,7 +3,9 @@ import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
import 'package:hetu_otp_util/hetu_otp_util.dart';
import 'package:hetu_script/hetu_script.dart';
import 'package:hetu_spotube_plugin/hetu_spotube_plugin.dart';
import 'package:hetu_spotube_plugin/hetu_spotube_plugin.dart' as spotube_plugin;
import 'package:hetu_spotube_plugin/hetu_spotube_plugin.dart'
hide YouTubeEngine;
import 'package:hetu_std/hetu_std.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
@ -15,6 +17,7 @@ import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/services/metadata/apis/localstorage.dart';
import 'package:spotube/services/metadata/endpoints/album.dart';
import 'package:spotube/services/metadata/endpoints/artist.dart';
import 'package:spotube/services/metadata/endpoints/audio_source.dart';
import 'package:spotube/services/metadata/endpoints/auth.dart';
import 'package:spotube/services/metadata/endpoints/browse.dart';
import 'package:spotube/services/metadata/endpoints/playlist.dart';
@ -22,13 +25,15 @@ import 'package:spotube/services/metadata/endpoints/search.dart';
import 'package:spotube/services/metadata/endpoints/track.dart';
import 'package:spotube/services/metadata/endpoints/core.dart';
import 'package:spotube/services/metadata/endpoints/user.dart';
import 'package:spotube/services/youtube_engine/youtube_engine.dart';
const defaultMetadataLimit = "20";
class MetadataPlugin {
static final pluginApiVersion = Version.parse("1.0.0");
static final pluginApiVersion = Version.parse("2.0.0");
static Future<MetadataPlugin> create(
YouTubeEngine youtubeEngine,
PluginConfiguration config,
Uint8List byteCode,
) async {
@ -76,6 +81,58 @@ class MetadataPlugin {
),
);
},
createYoutubeEngine: () {
return spotube_plugin.YouTubeEngine(
search: (query) async {
final result = await youtubeEngine.searchVideos(query);
return result
.map((video) => {
'id': video.id.value,
'title': video.title,
'author': video.author,
'duration': video.duration?.inSeconds,
'description': video.description,
'uploadDate': video.uploadDate?.toIso8601String(),
'viewCount': video.engagement.viewCount,
'likeCount': video.engagement.likeCount,
'isLive': video.isLive,
})
.toList();
},
getVideo: (videoId) async {
final video = await youtubeEngine.getVideo(videoId);
return {
'id': video.id.value,
'title': video.title,
'author': video.author,
'duration': video.duration?.inSeconds,
'description': video.description,
'uploadDate': video.uploadDate?.toIso8601String(),
'viewCount': video.engagement.viewCount,
'likeCount': video.engagement.likeCount,
'isLive': video.isLive,
};
},
streamManifest: (videoId) {
return youtubeEngine.getStreamManifest(videoId).then(
(manifest) {
final streams = manifest.audioOnly
.map(
(stream) => {
'url': stream.url.toString(),
'quality': stream.qualityLabel,
'bitrate': stream.bitrate.bitsPerSecond,
'container': stream.container.name,
'videoId': stream.videoId,
},
)
.toList();
return streams;
},
);
},
);
},
);
await HetuStdLoader.loadBytecodeFlutter(hetu);
@ -98,6 +155,7 @@ class MetadataPlugin {
late final MetadataAuthEndpoint auth;
late final MetadataPluginAudioSourceEndpoint audioSource;
late final MetadataPluginAlbumEndpoint album;
late final MetadataPluginArtistEndpoint artist;
late final MetadataPluginBrowseEndpoint browse;
@ -110,6 +168,7 @@ class MetadataPlugin {
MetadataPlugin._(this.hetu) {
auth = MetadataAuthEndpoint(hetu);
audioSource = MetadataPluginAudioSourceEndpoint(hetu);
artist = MetadataPluginArtistEndpoint(hetu);
album = MetadataPluginAlbumEndpoint(hetu);
browse = MetadataPluginBrowseEndpoint(hetu);

View File

@ -1230,10 +1230,10 @@ packages:
description:
path: "."
ref: main
resolved-ref: "01935a75640092af7947bfb21a497240376f0c83"
resolved-ref: "32828156bc111d147709f8d644804227bbdfe8f1"
url: "https://github.com/KRTirtho/hetu_spotube_plugin.git"
source: git
version: "0.0.1"
version: "0.0.2"
hetu_std:
dependency: "direct main"
description:
@ -2888,10 +2888,11 @@ packages:
youtube_explode_dart:
dependency: "direct main"
description:
name: youtube_explode_dart
sha256: "9ff345caf8351c59eb1b7560837f761e08d2beaea3b4187637942715a31a6f58"
url: "https://pub.dev"
source: hosted
path: "."
ref: HEAD
resolved-ref: caa3023386dbc10e69c99f49f491148094874671
url: "https://github.com/Coronon/youtube_explode_dart"
source: git
version: "2.5.2"
yt_dlp_dart:
dependency: "direct main"

View File

@ -138,7 +138,8 @@ dependencies:
wikipedia_api: ^0.1.0
win32_registry: ^1.1.5
window_manager: ^0.4.3
youtube_explode_dart: ^2.5.1
youtube_explode_dart:
git: https://github.com/Coronon/youtube_explode_dart
yt_dlp_dart:
git:
url: https://github.com/KRTirtho/yt_dlp_dart.git