Merge pull request #69 from zadjii-msft/users/ethanfang/youtube-ext

YouTube Extension for Command Palette
This commit is contained in:
Jordi Adoumie
2024-09-20 15:53:13 -04:00
committed by GitHub
29 changed files with 1325 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
// 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.Actions;
internal sealed partial class GetVideoInfoAction : InvokableCommand
{
private readonly YouTubeVideo _video;
internal GetVideoInfoAction(YouTubeVideo video)
{
this._video = video;
this.Name = "See more information";
this.Icon = new("\uE946");
}
public override CommandResult Invoke()
{
return CommandResult.KeepOpen();
}
}

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.Actions;
internal sealed partial class OpenChannelLinkAction : InvokableCommand
{
private readonly string _channelurl;
internal OpenChannelLinkAction(string url)
{
this._channelurl = url;
this.Name = "Open channel";
this.Icon = new("\uF131");
}
public override CommandResult Invoke()
{
Process.Start(new ProcessStartInfo(_channelurl) { UseShellExecute = true });
return CommandResult.KeepOpen();
}
}

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.Actions;
internal sealed partial class OpenVideoLinkAction : InvokableCommand
{
private readonly string _videourl;
internal OpenVideoLinkAction(string url)
{
this._videourl = url;
this.Name = "Open video";
this.Icon = new("\uE714");
}
public override CommandResult Invoke()
{
Process.Start(new ProcessStartInfo(_videourl) { UseShellExecute = true });
return CommandResult.KeepOpen();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -0,0 +1,38 @@
// 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.Actions;
public sealed class YouTubeChannel
{
// The name of the channel
public string Name { get; set; } = string.Empty;
// The unique id of the channel
public string ChannelId { get; set; } = string.Empty;
// The URL link to the channel
public string ChannelUrl { get; set; } = string.Empty;
// The URL to the profile picture of the channel
public string ProfilePicUrl { get; set; } = string.Empty;
// The description of the channel
public string Description { get; set; } = string.Empty;
// Number of subscribers the channel has
public long SubscriberCount { get; set; }
// Number of views the channel has
public long ViewCount { get; set; }
// Number of videos the channel has
public long VideoCount { get; set; }
}

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.Actions;
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,53 @@
// 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.Actions;
public sealed class YouTubeVideo
{
// The title of the video
public string Title { get; set; } = string.Empty;
// The unique id of the video
public string VideoId { get; set; } = string.Empty;
// The URL link to the video
public string Link { get; set; } = string.Empty;
// The author or channel name of the video
public string Channel { get; set; } = string.Empty;
// The channel id (needed for the channel URL)
public string ChannelId { get; set; } = string.Empty;
// The URL link to the channel
public string ChannelUrl { get; set; } = string.Empty;
// The URL to the profile picture of the channel
public string ChannelProfilePicUrl { get; set; }
// Number of subscribers the channel has
public long SubscriberCount { get; set; }
// The URL to the thumbnail image of the video
public string ThumbnailUrl { get; set; } = string.Empty;
// Captions or subtitles associated with the video
public string Caption { get; set; } = string.Empty;
// The date and time the video was published
public DateTime PublishedAt { get; set; } = DateTime.MinValue;
// Number of views the video has
public long ViewCount { get; set; }
// Number of likes the video has
public long LikeCount { get; set; }
}

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.Actions;
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": "password",
"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 = "Edit YouTube API Key";
Icon = new("https://www.youtube.com/favicon.ico");
}
}

View File

@@ -0,0 +1,97 @@
// 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.IO;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using System.Xml.Linq;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
using YouTubeExtension.Actions;
namespace YouTubeExtension.Pages;
internal sealed partial class YouTubeChannelInfoMarkdownPage : MarkdownPage
{
private readonly YouTubeChannel _channel;
private string _markdown = string.Empty;
public YouTubeChannelInfoMarkdownPage(YouTubeChannel channel)
{
Icon = new("\uE946");
Name = "See more information about this channel";
_channel = channel;
}
public override string[] Bodies()
{
var state = File.ReadAllText(YouTubeHelper.StateJsonPath());
var jsonState = JsonNode.Parse(state);
var apiKey = jsonState["apiKey"]?.ToString() ?? string.Empty;
FillInChannelDetailsAsync(_channel, apiKey).GetAwaiter().GetResult();
// Define the markdown content using the channel information
_markdown = $@"
# {_channel.Name}
![Profile Picture]({_channel.ProfilePicUrl})
---
**Channel Description**
{_channel.Description}
---
**Key Stats**
- **Subscribers:** {_channel.SubscriberCount:N0}
- **Total Views:** {_channel.ViewCount:N0}
- **Total Videos:** {_channel.VideoCount:N0}
[Visit Channel]({_channel.ChannelUrl})
---
_Last updated: {DateTime.Now:MMMM dd, yyyy}_
_Data sourced via YouTube API_
";
return new string[] { _markdown };
}
private async Task<YouTubeChannel> FillInChannelDetailsAsync(YouTubeChannel channel, string apiKey)
{
try
{
using var httpClient = new HttpClient();
// Fetch channel details from YouTube API
var channelUrl = $"https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&id={channel.ChannelId}&key={apiKey}";
var channelResponse = await httpClient.GetStringAsync(channelUrl);
var channelData = JsonNode.Parse(channelResponse);
if (channelData?["items"]?.AsArray().Count > 0)
{
var channelSnippet = channelData?["items"]?[0]?["snippet"];
var channelStatistics = channelData?["items"]?[0]?["statistics"];
// Update statistics
channel.ViewCount = long.TryParse(channelStatistics?["viewCount"]?.ToString(), out var views) ? views : 0;
channel.VideoCount = long.TryParse(channelStatistics?["videoCount"]?.ToString(), out var videos) ? videos : 0;
}
}
catch (Exception ex)
{
// Handle exceptions (e.g., log the error)
Console.WriteLine($"An error occurred while fetching channel details: {ex.Message}");
}
return channel;
}
}

View File

@@ -0,0 +1,145 @@
// 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.Globalization;
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.Actions;
namespace YouTubeExtension.Pages;
internal sealed partial class YouTubeChannelVideosPage : DynamicListPage
{
private readonly string _channelId;
private readonly string _channelName;
public YouTubeChannelVideosPage(string channelId = null, string channelName = null)
{
Icon = new("https://www.youtube.com/favicon.ico");
Name = $"Search for Videos by {channelName ?? "Channel"}";
this.ShowDetails = true;
// Ensure either a ChannelId or ChannelName is provided
if (string.IsNullOrEmpty(channelId) && string.IsNullOrEmpty(channelName))
{
throw new ArgumentException("Either channelId or channelName must be provided.");
}
_channelId = channelId;
_channelName = channelName;
}
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 scoped to the channel
List<YouTubeVideo> items = await GetYouTubeChannelVideos(query, _channelId, _channelName);
// Create a section and populate it with the video results
var section = new ListSection()
{
Title = $"Videos from {_channelName ?? _channelId}",
Items = items.Select(video => new ListItem(new OpenVideoLinkAction(video.Link))
{
Title = video.Title,
Subtitle = $"{video.Channel}",
Details = new Details()
{
Title = video.Title,
HeroImage = new(video.ThumbnailUrl),
Body = $"{video.Channel}",
},
Tags = [
new Tag()
{
Text = video.PublishedAt.ToString("MMMM dd, yyyy", CultureInfo.InvariantCulture), // Show the date of the video post
}
],
MoreCommands = [
new CommandContextItem(new OpenChannelLinkAction(video.ChannelUrl)),
new CommandContextItem(new YouTubeVideoInfoMarkdownPage(video)),
new CommandContextItem(new YouTubeAPIPage()),
],
}).ToArray(),
};
return new[] { section }; // Properly return an array of sections
}
// Method to fetch videos from a specific channel
private static async Task<List<YouTubeVideo>> GetYouTubeChannelVideos(string query, string channelId, string channelName)
{
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
{
// Build the YouTube API URL for fetching channel-specific videos
string requestUrl;
if (!string.IsNullOrEmpty(channelId))
{
// If ChannelId is provided, filter by channelId
requestUrl = $"https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&channelId={channelId}&q={query}&key={apiKey}&maxResults=20";
}
else
{
// If ChannelName is provided, search by the channel name
requestUrl = $"https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&q={query}+{channelName}&key={apiKey}&maxResults=20";
}
// Send the request to the YouTube API
var response = await client.GetStringAsync(requestUrl);
var json = JsonNode.Parse(response);
// Parse the response
if (json?["items"] is JsonArray itemsArray)
{
foreach (var item in itemsArray)
{
// Add each video to the list with title, link, author, thumbnail, and captions (if available)
videos.Add(new YouTubeVideo
{
Title = item["snippet"]?["title"]?.ToString() ?? string.Empty,
VideoId = item["id"]?["videoId"]?.ToString() ?? string.Empty,
Link = $"https://www.youtube.com/watch?v={item["id"]?["videoId"]?.ToString()}",
Channel = item["snippet"]?["channelTitle"]?.ToString() ?? string.Empty,
ChannelId = item["snippet"]?["channelId"]?.ToString() ?? string.Empty,
ChannelUrl = $"https://www.youtube.com/channel/{item["snippet"]?["channelId"]?.ToString()}" ?? string.Empty,
ThumbnailUrl = item["snippet"]?["thumbnails"]?["default"]?["url"]?.ToString() ?? string.Empty, // Get the default thumbnail URL
PublishedAt = DateTime.Parse(item["snippet"]?["publishedAt"]?.ToString(), CultureInfo.InvariantCulture), // Use CultureInfo.InvariantCulture
});
}
}
}
catch (Exception ex)
{
// Handle any errors from the API call or parsing
videos.Add(new YouTubeVideo
{
Title = "Error fetching data",
Channel = $"Error: {ex.Message}",
});
}
}
return videos;
}
}

View File

@@ -0,0 +1,140 @@
// 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.Globalization;
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.Actions;
namespace YouTubeExtension.Pages;
internal sealed partial class YouTubeChannelsPage : DynamicListPage
{
public YouTubeChannelsPage()
{
Icon = new("https://www.youtube.com/favicon.ico");
Name = "YouTube (Channel Search)";
this.ShowDetails = true;
}
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 channels based on the query
List<YouTubeChannel> items = await GetYouTubeChannels(query);
// Create a section and populate it with the channel results
var section = new ListSection()
{
Title = "Search Results",
Items = items.Select(channel => new ListItem(new OpenChannelLinkAction(channel.ChannelUrl))
{
Title = channel.Name,
Subtitle = $"{channel.SubscriberCount} subscribers",
Details = new Details()
{
Title = channel.Name,
HeroImage = new(channel.ProfilePicUrl),
Body = $"Subscribers: {channel.SubscriberCount}\nChannel Description: {channel.Description}",
},
MoreCommands = [
new CommandContextItem(new YouTubeChannelInfoMarkdownPage(channel)),
new CommandContextItem(new YouTubeChannelVideosPage(channel.ChannelId, channel.Name)),
new CommandContextItem(new YouTubeAPIPage()),
],
}).ToArray(),
};
return new[] { section }; // Properly return an array of sections
}
// Method to fetch channels from YouTube API
private static async Task<List<YouTubeChannel>> GetYouTubeChannels(string query)
{
var state = File.ReadAllText(YouTubeHelper.StateJsonPath());
var jsonState = JsonNode.Parse(state);
var apiKey = jsonState["apiKey"]?.ToString() ?? string.Empty;
var channels = new List<YouTubeChannel>();
using (HttpClient client = new HttpClient())
{
try
{
// Send the request to the YouTube API with the provided query to search for channels
var response = await client.GetStringAsync($"https://www.googleapis.com/youtube/v3/search?part=snippet&type=channel&q={query}&key={apiKey}&maxResults=20");
var json = JsonNode.Parse(response);
// Parse the response
if (json?["items"] is JsonArray itemsArray)
{
foreach (var item in itemsArray)
{
// Add each channel to the list with channel details
channels.Add(new YouTubeChannel
{
Name = item["snippet"]?["channelTitle"]?.ToString() ?? string.Empty,
ChannelId = item["snippet"]?["channelId"]?.ToString() ?? string.Empty,
ChannelUrl = $"https://www.youtube.com/channel/{item["snippet"]?["channelId"]?.ToString()}" ?? string.Empty,
ProfilePicUrl = item["snippet"]?["thumbnails"]?["default"]?["url"]?.ToString() ?? string.Empty, // Get the default profile picture
Description = item["snippet"]?["description"]?.ToString() ?? string.Empty,
SubscriberCount = await GetChannelSubscriberCount(apiKey, item["snippet"]?["channelId"]?.ToString()), // Fetch the subscriber count
});
}
}
}
catch (Exception ex)
{
// Handle any errors from the API call or parsing
channels.Add(new YouTubeChannel
{
Name = "Error fetching data",
Description = $"Error: {ex.Message}",
});
}
}
return channels;
}
// Fetch subscriber count for each channel using a separate API call
private static async Task<long> GetChannelSubscriberCount(string apiKey, string channelId)
{
using HttpClient client = new HttpClient();
{
try
{
var response = await client.GetStringAsync($"https://www.googleapis.com/youtube/v3/channels?part=statistics&id={channelId}&key={apiKey}");
var json = JsonNode.Parse(response);
if (json?["items"] is JsonArray itemsArray && itemsArray.Count > 0)
{
var statistics = itemsArray[0]?["statistics"];
if (long.TryParse(statistics?["subscriberCount"]?.ToString(), out var subscriberCount))
{
return subscriberCount;
}
}
}
catch
{
// In case of any error, return 0 subscribers
return 0;
}
}
return 0;
}
}

View File

@@ -0,0 +1,127 @@
// 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.IO;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
using YouTubeExtension.Actions;
namespace YouTubeExtension.Pages;
internal sealed partial class YouTubeVideoInfoMarkdownPage : MarkdownPage
{
private readonly YouTubeVideo _video;
private string _markdown = string.Empty;
public YouTubeVideoInfoMarkdownPage(YouTubeVideo video)
{
Icon = new("\uE946");
Name = "See more information about this video";
_video = video;
}
public override string[] Bodies()
{
var state = File.ReadAllText(YouTubeHelper.StateJsonPath());
var jsonState = JsonNode.Parse(state);
var apiKey = jsonState["apiKey"]?.ToString() ?? string.Empty;
FillInVideoDetailsAsync(_video, apiKey).GetAwaiter().GetResult();
// Refined markdown content for user-focused display
_markdown = $@"
# {_video.Title}
![Thumbnail]({_video.ThumbnailUrl})
---
**Video Description**
{_video.Caption}
---
**Key Stats**
- **Views:** {_video.ViewCount:N0}
- **Likes:** {_video.LikeCount:N0}
- **Published on:** {_video.PublishedAt:MMMM dd, yyyy}
---
**Channel Info**
- **Channel Name:** {_video.Channel}
- **Subscribers:** {_video.SubscriberCount:N0}
![Channel Profile Picture]({_video.ChannelProfilePicUrl})
[Visit Channel]({_video.ChannelUrl})
---
[Watch Video]({_video.Link})
---
_Last updated: {DateTime.Now:MMMM dd, yyyy}_
_Data sourced via YouTube API_
";
return new string[] { _markdown };
}
private async Task<YouTubeVideo> FillInVideoDetailsAsync(YouTubeVideo video, string apiKey)
{
try
{
using var httpClient = new HttpClient();
// Fetch channel details to get ChannelProfilePicUrl and SubscriberCount
var channelUrl = $"https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&id={video.ChannelId}&key={apiKey}";
var channelResponse = await httpClient.GetStringAsync(channelUrl);
var channelData = JsonNode.Parse(channelResponse);
if (channelData?["items"]?.AsArray().Count > 0)
{
var channelSnippet = channelData?["items"]?[0]?["snippet"];
var channelStatistics = channelData?["items"]?[0]?["statistics"];
// Update ChannelProfilePicUrl and SubscriberCount
video.ChannelProfilePicUrl = channelSnippet?["thumbnails"]?["default"]?["url"]?.ToString() ?? string.Empty;
video.SubscriberCount = long.TryParse(channelStatistics?["subscriberCount"]?.ToString(), out var subscribers) ? subscribers : 0;
}
// Fetch video details to get ViewCount, LikeCount, and Captions (description)
var videoUrl = $"https://www.googleapis.com/youtube/v3/videos?part=statistics,snippet&id={video.VideoId}&key={apiKey}";
var videoResponse = await httpClient.GetStringAsync(videoUrl);
var videoData = JsonNode.Parse(videoResponse);
if (videoData?["items"]?.AsArray().Count > 0)
{
var videoSnippet = videoData?["items"]?[0]?["snippet"];
var videoStatistics = videoData?["items"]?[0]?["statistics"];
// Update ViewCount and LikeCount
video.ViewCount = long.TryParse(videoStatistics?["viewCount"]?.ToString(), out var views) ? views : 0;
video.LikeCount = long.TryParse(videoStatistics?["likeCount"]?.ToString(), out var likes) ? likes : 0;
// Update Captions (description)
video.Caption = videoSnippet?["description"]?.ToString() ?? string.Empty;
}
}
catch (Exception ex)
{
// Handle errors
Console.WriteLine($"An error occurred while fetching video details: {ex.Message}");
}
return video;
}
}

View File

@@ -0,0 +1,119 @@
// 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.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.CmdPal.Extensions;
using Microsoft.CmdPal.Extensions.Helpers;
using YouTubeExtension.Actions;
namespace YouTubeExtension.Pages;
internal sealed partial class YouTubeVideosPage : DynamicListPage
{
public YouTubeVideosPage()
{
Icon = new("https://www.youtube.com/favicon.ico");
Name = "YouTube (Video Search)";
this.ShowDetails = true;
}
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 OpenVideoLinkAction(video.Link))
{
Title = video.Title,
Subtitle = $"{video.Channel}",
Details = new Details()
{
Title = video.Title,
HeroImage = new(video.ThumbnailUrl),
Body = $"{video.Channel}",
},
Tags = [new Tag()
{
Text = video.PublishedAt.ToString("MMMM dd, yyyy", CultureInfo.InvariantCulture), // Show the date of the video post
}
],
MoreCommands = [
new CommandContextItem(new OpenChannelLinkAction(video.ChannelUrl)),
new CommandContextItem(new YouTubeVideoInfoMarkdownPage(video)),
new CommandContextItem(new YouTubeAPIPage()),
],
}).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
{
// Send the request to the YouTube API with the provided query
var response = await client.GetStringAsync($"https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&q={query}&key={apiKey}&maxResults=20");
var json = JsonNode.Parse(response);
// Parse the response
if (json?["items"] is JsonArray itemsArray)
{
foreach (var item in itemsArray)
{
// Add each video to the list with title, link, author, thumbnail, and captions (if available)
videos.Add(new YouTubeVideo
{
Title = item["snippet"]?["title"]?.ToString() ?? string.Empty,
VideoId = item["id"]?["videoId"]?.ToString() ?? string.Empty,
Link = $"https://www.youtube.com/watch?v={item["id"]?["videoId"]?.ToString()}",
Channel = item["snippet"]?["channelTitle"]?.ToString() ?? string.Empty,
ChannelId = item["snippet"]?["channelId"]?.ToString() ?? string.Empty,
ChannelUrl = $"https://www.youtube.com/channel/{item["snippet"]?["channelId"]?.ToString()}" ?? string.Empty,
ThumbnailUrl = item["snippet"]?["thumbnails"]?["default"]?["url"]?.ToString() ?? string.Empty, // Get the default thumbnail URL
PublishedAt = DateTime.Parse(item["snippet"]?["publishedAt"]?.ToString(), CultureInfo.InvariantCulture), // Use CultureInfo.InvariantCulture
});
}
}
}
catch (Exception ex)
{
// Handle any errors from the API call or parsing
videos.Add(new YouTubeVideo
{
Title = "Error fetching data",
Channel = $"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,135 @@
// 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.Actions;
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 on YouTube",
Subtitle = "YouTube",
Tags = [new Tag()
{
Text = "Extension",
}
],
},
new ListItem(new YouTubeChannelsPage())
{
Title = "Search Channels on YouTube",
Subtitle = "YouTube",
Tags = [new Tag()
{
Text = "Extension",
}
],
},
];
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 Extension",
Subtitle = "Enter your API key.",
Tags = [new Tag()
{
Text = "Extension",
}
],
},
};
}
// 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 Extension",
Subtitle = "Enter your API key.",
Tags = [new Tag()
{
Text = "Extension",
}
],
},
};
}
// 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,7 @@ 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}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.UI", "Microsoft.CmdPal.UI\Microsoft.CmdPal.UI.csproj", "{1DF70F56-ABB2-4798-BBA5-0B9568715BA1}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.UI.ViewModels", "Microsoft.CmdPal.UI.ViewModels\Microsoft.CmdPal.UI.ViewModels.csproj", "{D2FC419D-0ABC-425F-9D43-A7782AC4A0AE}"
@@ -206,6 +207,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
{1DF70F56-ABB2-4798-BBA5-0B9568715BA1}.Debug|ARM64.ActiveCfg = Debug|ARM64
{1DF70F56-ABB2-4798-BBA5-0B9568715BA1}.Debug|ARM64.Build.0 = Debug|ARM64
{1DF70F56-ABB2-4798-BBA5-0B9568715BA1}.Debug|ARM64.Deploy.0 = Debug|ARM64
@@ -255,6 +268,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}
{1DF70F56-ABB2-4798-BBA5-0B9568715BA1} = {865E9369-C53A-40B8-829C-F7843F66D3A1}
{D2FC419D-0ABC-425F-9D43-A7782AC4A0AE} = {865E9369-C53A-40B8-829C-F7843F66D3A1}
{2012F5FE-BF53-4E51-868D-0E5A74C11878} = {272D0E9A-8FC3-49F5-8FAD-79ABAE8AB1E4}