From 2b9c5730c9ba7516209cece9c8698d33aeddaf35 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 7 Dec 2025 19:58:05 +0600 Subject: [PATCH] feat: add yt engine plugin support --- lib/main.dart | 69 ----------- lib/provider/server/router.dart | 15 +++ .../server/routes/plugin_apis/yt_engine.dart | 114 ++++++++++++++++++ .../youtube_engine/youtube_engine.dart | 3 +- rust/src/api/plugin/plugin.rs | 3 +- rust/src/internal/apis/mod.rs | 3 +- rust/src/internal/apis/yt_engine.rs | 36 ++++++ 7 files changed, 171 insertions(+), 72 deletions(-) create mode 100644 lib/provider/server/routes/plugin_apis/yt_engine.dart create mode 100644 rust/src/internal/apis/yt_engine.rs diff --git a/lib/main.dart b/lib/main.dart index a9c4b65e..459da966 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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; diff --git a/lib/provider/server/router.dart b/lib/provider/server/router.dart index 6cc626f8..9db32145 100644 --- a/lib/provider/server/router.dart +++ b/lib/provider/server/router.dart @@ -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(() { diff --git a/lib/provider/server/routes/plugin_apis/yt_engine.dart b/lib/provider/server/routes/plugin_apis/yt_engine.dart new file mode 100644 index 00000000..a8beaac2 --- /dev/null +++ b/lib/provider/server/routes/plugin_apis/yt_engine.dart @@ -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 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 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 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( + (ref) => ServerYTEngineRoutes(ref: ref), +); diff --git a/lib/provider/youtube_engine/youtube_engine.dart b/lib/provider/youtube_engine/youtube_engine.dart index 0aa37db5..f3b59a07 100644 --- a/lib/provider/youtube_engine/youtube_engine.dart +++ b/lib/provider/youtube_engine/youtube_engine.dart @@ -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((ref) { final engineMode = ref.watch( userPreferencesProvider.select((value) => value.youtubeClientEngine), ); diff --git a/rust/src/api/plugin/plugin.rs b/rust/src/api/plugin/plugin.rs index 29055a38..e2a7ae6e 100644 --- a/rust/src/api/plugin/plugin.rs +++ b/rust/src/api/plugin/plugin.rs @@ -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(); diff --git a/rust/src/internal/apis/mod.rs b/rust/src/internal/apis/mod.rs index dc3ef54e..31c99e9c 100644 --- a/rust/src/internal/apis/mod.rs +++ b/rust/src/internal/apis/mod.rs @@ -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(()) } diff --git a/rust/src/internal/apis/yt_engine.rs b/rust/src/internal/apis/yt_engine.rs new file mode 100644 index 00000000..40975af6 --- /dev/null +++ b/rust/src/internal/apis/yt_engine.rs @@ -0,0 +1,36 @@ +use rquickjs::{Ctx, Value}; + +pub fn init(ctx: &Ctx) -> rquickjs::Result<()> { + ctx.eval::( + 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(()) +}