feat: add yt engine plugin support

This commit is contained in:
Kingkor Roy Tirtho 2025-12-07 19:58:05 +06:00
parent 4129a61d85
commit 2b9c5730c9
7 changed files with 171 additions and 72 deletions

View File

@ -45,8 +45,6 @@ import 'package:spotube/services/kv_store/encrypted_kv_store.dart';
import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/wm_tools/wm_tools.dart';
import 'package:spotube/src/rust/api/plugin/models/core.dart';
import 'package:spotube/src/rust/api/plugin/plugin.dart';
import 'package:spotube/src/rust/frb_generated.dart';
import 'package:spotube/utils/migrations/sandbox.dart';
import 'package:spotube/utils/platform.dart';
@ -178,73 +176,6 @@ class Spotube extends HookConsumerWidget {
HomeWidget.registerInteractivityCallback(glanceBackgroundCallback);
}
start() async {
final server = await ref.read(serverProvider.future);
final plugin = SpotubePlugin();
const pluginConfiguration = PluginConfiguration(
name: "Spotube Plugin",
description: "Spotube Plugin",
version: "1.0.0",
author: "Spotube",
entryPoint: "Plugin",
pluginApiVersion: "2.0.0",
apis: [PluginApi.localstorage, PluginApi.webview],
abilities: [PluginAbility.metadata],
);
final pluginContext = plugin.createContext(
serverEndpointUrl:
"http://${server.server.address.host}:${server.port}",
serverSecret: ref.read(serverRandomSecretProvider),
pluginScript: """
console.log("Local Timezone", Timezone.getLocalTimezone());
console.log("Available Timezones", Timezone.getAvailableTimezones());
class AuthEndpoint {
}
class CoreEndpoint {
async checkUpdate() {
console.log(globalThis);
const webview = await WebView.create("https://spotube.krtirtho.dev");
webview.onUrlChange(async (url) => {
console.log("url_request: ", url);
if (url.includes("/about")) {
console.log(await webview.cookies())
webview.close();
}
});
await webview.open();
// const res = await SpotubeForm.show("Hello", [
// {
// objectType: "input",
// id: "email",
// variant: "text",
// placeholder: "Enter your email",
// defaultValue: null,
// required: true,
// regex: null,
// }
// ])
// console.log("Form Result: ", res);
// console.log("LocalStorage Value: ", localStorage.getItem("test_key"));
// localStorage.setItem("test_key", "test_value");
}
}
class Plugin {
constructor() {
this.auth = new AuthEndpoint();
this.core = new CoreEndpoint();
}
}
""",
pluginConfig: pluginConfiguration,
);
await plugin.core.checkUpdate(
mpscTx: pluginContext, pluginConfig: pluginConfiguration);
}
start();
return () {
/// For enabling hot reload for audio player
if (!kDebugMode) return;

View File

@ -6,6 +6,7 @@ import 'package:spotube/provider/server/routes/playback.dart';
import 'package:spotube/provider/server/routes/plugin_apis/form.dart';
import 'package:spotube/provider/server/routes/plugin_apis/path_provider.dart';
import 'package:spotube/provider/server/routes/plugin_apis/webview.dart';
import 'package:spotube/provider/server/routes/plugin_apis/yt_engine.dart';
Handler pluginApiAuthMiddleware(Handler handler) {
return (Request request) {
@ -23,6 +24,7 @@ final serverRouterProvider = Provider((ref) {
final connectRoutes = ref.watch(serverConnectRoutesProvider);
final webviewRoutes = ref.watch(serverWebviewRoutesProvider);
final formRoutes = ref.watch(serverFormRoutesProvider);
final ytEngineRoutes = ref.watch(serverYTEngineRoutesProvider);
final router = Router();
@ -64,6 +66,19 @@ final serverRouterProvider = Provider((ref) {
pluginApiAuthMiddleware(ServerPathProviderRoutes.getDirectories),
);
router.get(
"/plugin-api/yt-engine/search",
pluginApiAuthMiddleware(ytEngineRoutes.search),
);
router.get(
"/plugin-api/yt-engine/video",
pluginApiAuthMiddleware(ytEngineRoutes.getVideo),
);
router.get(
"/plugin-api/yt-engine/stream-manifest",
pluginApiAuthMiddleware(ytEngineRoutes.streamManifest),
);
router.all("/ws", connectRoutes.websocket);
ref.onDispose(() {

View File

@ -0,0 +1,114 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelf/shelf.dart';
import 'package:spotube/provider/youtube_engine/youtube_engine.dart';
import 'package:spotube/services/youtube_engine/youtube_engine.dart';
class ServerYTEngineRoutes {
final Ref ref;
ServerYTEngineRoutes({required this.ref});
YouTubeEngine get youtubeEngine => ref.read(youtubeEngineProvider);
Future<Response> search(Request request) async {
final query = request.url.queryParameters["query"];
if (query == null || query.isEmpty) {
return Response.badRequest(
body: 'Query parameter "query" is required',
);
}
final result = await youtubeEngine.searchVideos(query);
final mappedResult = 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();
return Response.ok(
jsonEncode(mappedResult),
encoding: utf8,
headers: {
'Content-Type': 'application/json',
},
);
}
Future<Response> getVideo(Request request) async {
final videoId = request.url.queryParameters["videoId"];
if (videoId == null || videoId.isEmpty) {
return Response.badRequest(
body: 'Query parameter "videoId" is required',
);
}
final video = await youtubeEngine.getVideo(videoId);
final res = {
'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,
};
return Response.ok(
jsonEncode(res),
encoding: utf8,
headers: {
'Content-Type': 'application/json',
},
);
}
Future<Response> streamManifest(Request request) async {
final videoId = request.url.queryParameters["videoId"];
if (videoId == null || videoId.isEmpty) {
return Response.badRequest(
body: 'Query parameter "videoId" is required',
);
}
final streamManifest =
await 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;
});
return Response.ok(
jsonEncode(streamManifest),
encoding: utf8,
headers: {
'Content-Type': 'application/json',
},
);
}
}
final serverYTEngineRoutesProvider = Provider<ServerYTEngineRoutes>(
(ref) => ServerYTEngineRoutes(ref: ref),
);

View File

@ -2,10 +2,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/youtube_engine/newpipe_engine.dart';
import 'package:spotube/services/youtube_engine/youtube_engine.dart';
import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart';
import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart';
final youtubeEngineProvider = Provider((ref) {
final youtubeEngineProvider = Provider<YouTubeEngine>((ref) {
final engineMode = ref.watch(
userPreferencesProvider.select((value) => value.youtubeClientEngine),
);

View File

@ -12,7 +12,7 @@ use crate::api::plugin::senders::{
};
use crate::frb_generated::StreamSink;
use crate::internal::apis;
use crate::internal::apis::{form, get_platform_directories, timezone, webview};
use crate::internal::apis::{form, get_platform_directories, timezone, webview, yt_engine};
use anyhow::anyhow;
use flutter_rust_bridge::{frb, Rust2DartSendError};
use llrt_modules::module_builder::ModuleBuilder;
@ -56,6 +56,7 @@ async fn create_context(
.with_global(util::init)
.with_global(form::init)
.with_global(webview::init)
.with_global(yt_engine::init)
.with_global(timezone::init);
let (module_resolver, module_loader, global_attachment) = module_builder.build();

View File

@ -5,11 +5,12 @@ pub mod form;
pub mod local_storage;
pub mod webview;
pub mod timezone;
pub mod yt_engine;
pub fn init(ctx: &Ctx, endpoint_url: String, secret: String) -> rquickjs::Result<()> {
ctx.globals().set("__serverUrl", endpoint_url)?;
ctx.globals().set("__serverSecret", secret)?;
Ok(())
}

View File

@ -0,0 +1,36 @@
use rquickjs::{Ctx, Value};
pub fn init(ctx: &Ctx) -> rquickjs::Result<()> {
ctx.eval::<Value, _>(
r#"
globalThis.YouTubeEngine = class YouTubeEngine {
async request(endpoint, qName, qValue) {
return await fetch(
`${__serverUrl}/plugin-api/yt-engine/${endpoint}?${qName}=${encodeURIComponent(qValue)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Plugin-Secret': __serverSecret,
},
}
).then(res=>res.json())
}
async search(query) {
return await this.request('search', 'query', query);
}
async video(videoId) {
return await this.request('video', 'videoId', videoId);
}
async streamManifest(videoId) {
return await this.request('stream-manifest', 'videoId', videoId);
}
}
"#,
)?;
Ok(())
}