YouTube Extension with API Auth (& verification) as well as "Search Videos" command (working)

This commit is contained in:
Ethan Fang
2024-09-17 12:04:34 -05:00
parent fed659662f
commit 54e7f694c8
22 changed files with 616 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CmdPal.Extensions.Helpers;
namespace YouTubeExtension.Helper;
internal sealed partial class LinkAction : InvokableCommand
{
private readonly YouTubeVideo _video;
internal LinkAction(YouTubeVideo video)
{
this._video = video;
this.Name = "Open link";
this.Icon = new("\uE8A7");
}
public override CommandResult Invoke()
{
Process.Start(new ProcessStartInfo(_video.Link) { UseShellExecute = true });
return CommandResult.KeepOpen();
}
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YouTubeExtension.Helper;
internal sealed class YouTubeHelper
{
internal static string StateJsonPath()
{
// Get the path to our exe
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
// Get the directory of the exe
var directory = Path.GetDirectoryName(path) ?? string.Empty;
// now, the state is just next to the exe
return Path.Combine(directory, "state.json");
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YouTubeExtension.Helper;
public sealed class YouTubeVideo
{
public string Title { get; init; } = string.Empty;
public string Link { get; init; } = string.Empty;
public string Author { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap uap3 rescap">
<Identity
Name="YouTubeExtension"
Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US"
Version="0.0.1.0" />
<Properties>
<DisplayName>YouTube extension for cmdpal</DisplayName>
<PublisherDisplayName>A Lone Developer</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="YouTube extension for cmdpal"
Description="YouTube extension for cmdpal"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:ExeServer Executable="YouTubeExtension.exe" Arguments="-RegisterProcessAsComServer" DisplayName="ClementineExtensionApp">
<com:Class Id="95696eff-5c44-41ad-8f64-c2182ec9e3fd" DisplayName="YouTube extension for cmdpal" />
</com:ExeServer>
</com:ComServer>
</com:Extension>
<uap3:Extension Category="windows.appExtension">
<uap3:AppExtension Name="com.microsoft.windows.commandpalette"
Id="PG-SP-ID"
PublicFolder="Public"
DisplayName="YouTube extension for cmdpal"
Description="YouTube extension for cmdpal">
<uap3:Properties>
<CmdPalProvider>
<Activation>
<CreateInstance ClassId="95696eff-5c44-41ad-8f64-c2182ec9e3fd" />
</Activation>
<SupportedInterfaces>
<Commands/>
</SupportedInterfaces>
</CmdPalProvider>
</uap3:Properties>
</uap3:AppExtension>
</uap3:Extension>
</Extensions>
</Application>
</Applications>
<Capabilities>
<Capability Name="internetClient" />
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Microsoft.CmdPal.Extensions.Helpers;
using YouTubeExtension.Helper;
namespace YouTubeExtension.Pages;
internal sealed partial class YouTubeAPIForm : Form
{
public override string TemplateJson()
{
var json = $$"""
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "Input.Text",
"style": "text",
"id": "apiKey",
"label": "API Key",
"isRequired": true,
"errorMessage": "API Key required"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Save",
"data": {
"apiKey": "apiKey"
}
}
]
}
""";
return json;
}
public override string DataJson() => throw new NotImplementedException();
public override string StateJson() => throw new NotImplementedException();
public override CommandResult SubmitForm(string payload)
{
var formInput = JsonNode.Parse(payload);
if (formInput == null)
{
return CommandResult.GoHome();
}
// get the name and url out of the values
var formApiKey = formInput["apiKey"] ?? string.Empty;
// Construct a new json blob with the name and url
var json = $$"""
{
"apiKey": "{{formApiKey}}"
}
""";
File.WriteAllText(YouTubeHelper.StateJsonPath(), json);
return CommandResult.GoHome();
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
namespace YouTubeExtension.Pages;
internal sealed partial class YouTubeAPIPage : FormPage
{
private readonly YouTubeAPIForm apiForm = new();
public override IForm[] Forms() => [apiForm];
public YouTubeAPIPage()
{
Name = "YouTube API";
Icon = new("https://www.youtube.com/favicon.ico");
}
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
using YouTubeExtension.Helper;
namespace YouTubeExtension;
internal sealed partial class YouTubeVideosPage : DynamicListPage
{
public YouTubeVideosPage()
{
Icon = new("https://www.youtube.com/favicon.ico");
Name = "YouTube";
}
public override ISection[] GetItems(string query)
{
return DoGetItems(query).GetAwaiter().GetResult(); // Fetch and await the task synchronously
}
private async Task<ISection[]> DoGetItems(string query)
{
// Fetch YouTube videos based on the query
List<YouTubeVideo> items = await GetYouTubeVideos(query);
// Create a section and populate it with the video results
var section = new ListSection()
{
Title = "Search Results",
Items = items.Select(video => new ListItem(new LinkAction(video))
{
Title = video.Title,
Subtitle = $"{video.Author}",
}).ToArray(),
};
return new[] { section }; // Properly return an array of sections
}
// Method to fetch videos from YouTube API
private static async Task<List<YouTubeVideo>> GetYouTubeVideos(string query)
{
var state = File.ReadAllText(YouTubeHelper.StateJsonPath());
var jsonState = JsonNode.Parse(state);
var apiKey = jsonState["apiKey"]?.ToString() ?? string.Empty;
var videos = new List<YouTubeVideo>();
using HttpClient client = new HttpClient();
{
try
{
var response = await client.GetStringAsync($"https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&q={query}&key={apiKey}");
var json = JsonNode.Parse(response);
if (json?["items"] is JsonArray itemsArray)
{
foreach (var item in itemsArray)
{
videos.Add(new YouTubeVideo
{
Title = item["snippet"]?["title"]?.ToString() ?? string.Empty,
Link = $"https://www.youtube.com/watch?v={item["id"]?["videoId"]?.ToString()}",
Author = item["snippet"]?["channelTitle"]?.ToString() ?? string.Empty,
});
}
}
}
catch (Exception ex)
{
// Handle any errors from the API call or parsing
videos.Add(new YouTubeVideo
{
Title = "Error fetching data",
Link = string.Empty,
Author = $"Error: {ex.Message}",
});
}
}
return videos;
}
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using Microsoft.CmdPal.Extensions;
namespace YouTubeExtension;
public class Program
{
[MTAThread]
public static void Main(string[] args)
{
if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer")
{
using ExtensionServer server = new();
var extensionDisposedEvent = new ManualResetEvent(false);
var extensionInstance = new SampleExtension(extensionDisposedEvent);
// We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.
// This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object.
// If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.
server.RegisterExtension(() => extensionInstance);
// This will make the main thread wait until the event is signalled by the extension class.
// Since we have single instance of the extension object, we exit as sooon as it is disposed.
extensionDisposedEvent.WaitOne();
}
else
{
Console.WriteLine("Not being launched as a Extension... exiting.");
}
}
}

View File

@@ -0,0 +1,11 @@
{
"profiles": {
"YouTubeExtension (Package)": {
"commandName": "MsixPackage",
"commandLineArgs": "-RegisterProcessAsComServer"
},
"YouTubeExtension (Unpackaged)": {
"commandName": "Project"
}
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.CmdPal.Extensions;
namespace YouTubeExtension;
[ComVisible(true)]
[Guid("95696eff-5c44-41ad-8f64-c2182ec9e3fd")]
[ComDefaultInterface(typeof(IExtension))]
public sealed partial class SampleExtension : IExtension
{
private readonly ManualResetEvent _extensionDisposedEvent;
public SampleExtension(ManualResetEvent extensionDisposedEvent)
{
this._extensionDisposedEvent = extensionDisposedEvent;
}
public object GetProvider(ProviderType providerType)
{
switch (providerType)
{
case ProviderType.Commands:
return new YouTubeExtensionActionsProvider();
default:
return null;
}
}
public void Dispose()
{
this._extensionDisposedEvent.Set();
}
}

View File

@@ -0,0 +1,53 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>YouTubeExtension</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
<UseWinUI>false</UseWinUI>
<EnableMsixTooling>true</EnableMsixTooling>
</PropertyGroup>
<ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" />
<Content Include="Assets\LockScreenLogo.scale-200.png" />
<Content Include="Assets\Square150x150Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
<Content Include="Assets\StoreLogo.png" />
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
</ItemGroup>
<ItemGroup>
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\extensionsdk\Microsoft.CmdPal.Extensions.Helpers\Microsoft.CmdPal.Extensions.Helpers.csproj" />
</ItemGroup>
<!--
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
Tools extension to be activated for this project even if the Windows App SDK Nuget
package has not yet been restored.
-->
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
<ItemGroup>
<Folder Include="Data\" />
<Folder Include="Forms\" />
<Folder Include="Commands\" />
</ItemGroup>
<!--
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
Explorer "Package and Publish" context menu entry to be enabled for this project even if
the Windows App SDK Nuget package has not yet been restored.
-->
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,92 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
using Windows.UI.ApplicationSettings;
using YouTubeExtension.Helper;
using YouTubeExtension.Pages;
namespace YouTubeExtension;
public partial class YouTubeExtensionActionsProvider : ICommandProvider
{
public string DisplayName => $"YouTube";
public IconDataType Icon => new(string.Empty);
private readonly IListItem[] _commands = [
new ListItem(new YouTubeVideosPage()) { Title = "Search Videos", Subtitle = "YouTube" },
];
private readonly YouTubeAPIPage apiPage = new();
#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize
public void Dispose() => throw new NotImplementedException();
#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize
public IListItem[] TopLevelCommands()
{
return TopLevelCommandsAsync().GetAwaiter().GetResult();
}
public async Task<IListItem[]> TopLevelCommandsAsync()
{
var settingsPath = YouTubeHelper.StateJsonPath();
// Check if the settings file exists
if (!File.Exists(settingsPath))
{
return new[] { new ListItem(apiPage) { Title = "YouTube", Subtitle = "Enter your API key." } };
}
// Read the file and parse the API key
var state = File.ReadAllText(settingsPath);
var jsonState = System.Text.Json.Nodes.JsonNode.Parse(state);
var apiKey = jsonState?["apiKey"]?.ToString() ?? string.Empty;
// Validate the API key using YouTube API
if (string.IsNullOrWhiteSpace(apiKey) || !await IsApiKeyValid(apiKey))
{
return new[] { new ListItem(apiPage) { Title = "YouTube", Subtitle = "Current API Invalid. Please enter a new API key." } };
}
// If file exists and API key is valid, return commands
return _commands;
}
// Method to check if the API key is valid by making a simple request to the YouTube API
private static async Task<bool> IsApiKeyValid(string apiKey)
{
using HttpClient client = new HttpClient();
{
try
{
// Make a simple request to verify the API key, such as fetching a video
var response = await client.GetAsync($"https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&q=test&key={apiKey}");
// If the response status code is 200, the API key is valid
if (response.IsSuccessStatusCode)
{
return true;
}
// Optionally, handle other status codes and log errors
return false;
}
catch
{
// If any exception occurs (e.g., network error), consider the API key invalid
return false;
}
}
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="HackerNewsExtension.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -42,6 +42,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SSHKeychainExtension", "ext
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SamplePagesExtension", "exts\SamplePagesExtension\SamplePagesExtension.csproj", "{399B53F1-5AA6-4FE0-8C3C-66E07B84E6F1}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YouTubeExtension", "Exts\YouTubeExtension\YouTubeExtension.csproj", "{276243F6-4B25-411C-B110-1E7DB575F13D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|ARM64 = Debug|ARM64
@@ -198,6 +200,18 @@ Global
{399B53F1-5AA6-4FE0-8C3C-66E07B84E6F1}.Release|x64.ActiveCfg = Release|x64
{399B53F1-5AA6-4FE0-8C3C-66E07B84E6F1}.Release|x64.Build.0 = Release|x64
{399B53F1-5AA6-4FE0-8C3C-66E07B84E6F1}.Release|x64.Deploy.0 = Release|x64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Debug|ARM64.ActiveCfg = Debug|ARM64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Debug|ARM64.Build.0 = Debug|ARM64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Debug|ARM64.Deploy.0 = Debug|ARM64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Debug|x64.ActiveCfg = Debug|x64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Debug|x64.Build.0 = Debug|x64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Debug|x64.Deploy.0 = Debug|x64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Release|ARM64.ActiveCfg = Release|ARM64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Release|ARM64.Build.0 = Release|ARM64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Release|ARM64.Deploy.0 = Release|ARM64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Release|x64.ActiveCfg = Release|x64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Release|x64.Build.0 = Release|x64
{276243F6-4B25-411C-B110-1E7DB575F13D}.Release|x64.Deploy.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -219,6 +233,7 @@ Global
{7F6796A4-4233-4CEC-914F-95EC7A5283A0} = {272D0E9A-8FC3-49F5-8FAD-79ABAE8AB1E4}
{77D99BE0-F69C-4F27-8153-951CEC5110FE} = {B7FF739F-7716-4FC3-B622-705486187B87}
{399B53F1-5AA6-4FE0-8C3C-66E07B84E6F1} = {B7FF739F-7716-4FC3-B622-705486187B87}
{276243F6-4B25-411C-B110-1E7DB575F13D} = {B7FF739F-7716-4FC3-B622-705486187B87}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {BC94BFC2-A741-4978-B6A4-9E01B7660E6B}