C# Youtube 下載

      在〈C# Youtube 下載〉中有 2 則留言

本篇以 C# 程式碼說明如何搜尋 Youtube 歌曲及下載 mp3/mp4 格式檔

爬蟲程式

爬蟲程式並不是 Python 的專利,其實 Java, C# 等程式語言都支援。使用 C# 有個方便之處,就是可以直接編譯成 .exe 檔,讓使用者按二下就可以直接執行。這可比 Python 方便多了。

安裝Selenium 套件

請進入 “工具/NuGet套件管理員/管理方案的NuGet套件”,然後在瀏覽頁籤中搜尋 Selenium,安裝 Selenium.WebDriver 及 DotNetSeleniumExtras.WaitHelpers 二個套件

請注意 Selenium.Support 已被淘汰掉了,改用 DotNetSeleniumExtras.WaitHelpers

下載 WebDriver.exe

網路上的教學都說需先下載 chromedriver.exe檔,但本人測試後是不需要安裝 chromedriver 的。因為 Selenium.WebDriver 套件會自動下載與 Chrome 相同版本的 chromedriver.exe,如此也省去了 Chrome 瀏覽器版本更新的問題。

開啟Browser

開啟 Browser 會花費蠻久的時間(1~2秒),所以必需將開啟 browser 的功能寫在新的執行緒上。本例使用 async Task 來執行。但建構子 MainWindow 是不淮加入 async 的,所以必需把開啟 browser 寫在 Window_Loaded 方法中,如下代碼

public MainWindow()
{
    InitializeComponent();
    disableUI();
}
private async void Window_Loaded(object sender, RoutedEventArgs e)
{
    lblPath.Content = path.Replace("_", "__");
    if (!Directory.Exists(path))
    {
        Directory.CreateDirectory(path);
    }
    //底下的版本取得方式,會在發佈後無法使用,造成程式閃退
    //lblVersion.Content= string.Format("Version : {0}",
    //    FileVersionInfo.GetVersionInfo(
    //        Assembly.GetExecutingAssembly().Location
    //    ).FileVersion.ToString());
            
    //底下才是取得版本的正確方式
    lblVersion.Content = string.Format(
        "Version : {0}",
        Assembly.GetExecutingAssembly().GetName().Version.ToString()
    );
    await Browser();
    enableUI();
}
private async Task Browser()
{
    await Task.Run(() => {
        ChromeOptions options = new ChromeOptions();
        ChromeDriverService service = ChromeDriverService.CreateDefaultService();
        service.SuppressInitialDiagnosticInformation = true;
        service.EnableVerboseLogging = false;
        service.HideCommandPromptWindow = true;
        options.AddArgument("--disable-logging");
        options.AddArgument("--output=/dev/null");
        options.AddArgument("--headless");
        browser = new ChromeDriver(service, options);
    });
}

搜尋歌曲

搜尋歌曲需使用 Selenium 操作 browser, 代碼如下

private async void btnSearch_Click(object sender, RoutedEventArgs e)
{
    lblStatus.Content = "Searching....";
    disableUI();
    lsSong.Items.Clear();
    String url = string.Format(
        "https://www.youtube.com/results?search_query={0}", txtSong.Text
    );
    await Search(url);
    enableUI();
    lblStatus.Content = "";

    foreach (var item in dic)
    {
        StackPanel panel = new StackPanel();
        panel.Orientation = Orientation.Horizontal;
        panel.Children.Add(new CheckBox());
        TextBox txt = new TextBox();
        txt.Text = item.Value;
        panel.Children.Add(txt);
        lsSong.Items.Add(panel);
    }
}

private async Task Search(string url)
{
    await Task.Run(() =>
    {
        dic.Clear();
        browser.Navigate().GoToUrl(url);
        WebDriverWait wait = new WebDriverWait(
            browser, 
            TimeSpan.FromSeconds(10)
        );
        var element = wait.Until(
            SeleniumExtras.WaitHelpers.ExpectedConditions.ElementIsVisible(
                By.Id("video-title")
            )
        );
        var tags = browser.FindElements(By.TagName("a"));
        foreach (var tag in tags)
        {
            string href = tag.GetAttribute("href");
            var title = tag.GetAttribute("title");
            if (title != "" && 
                href != null && 
                href.Contains("watch") && 
                !dic.ContainsKey(href))
            {
                dic.Add(href, string.Format("{0} url={1}", title, href));
            }
        }
    });
}

YoutubeExplode

C# 支援 YouTube 影片下載的套件不多,而且大都不能用了,比如 VideoLibrary 下載高清影片時沒有聲音,sharpGrabber 則無法使用。

本人測試目前最好用的套件,就屬 YoutubeExplode 這個套件,請由 NuGet 搜尋並安裝。

官方文件說明網址如下 : https://github.com/Tyrrrz/YoutubeExplode

下載 影片/音樂 的代碼如下

private async void btnDownload_Click(object sender, RoutedEventArgs e)
{
    List titles = new List();
    for (int i = 0; i < lsSong.Items.Count; i++)
    {
        var item = lsSong.Items[i] as StackPanel;
        var chk = item.Children[0] as CheckBox;
        var txt = item.Children[1] as TextBox;
        if (chk.IsChecked is true)
        {
            titles.Add(txt.Text);
        }
    }
    var style = 0;
    if (rbMp3.IsChecked == true) style = 0;
    else style = 1;
    foreach (var title in titles)
    {
        var ts = title.Split(" url=");
        var file = ts[0];
        var url = ts[1];
        lblStatus.Content = string.Format("Download {0}...", file);
        await Download(url, style, path);
    }
    lblStatus.Content = string.Format("Download suscessful");
}
private async Task Download(string url, int style, string path)
{
    await Task.Run(async () => {
        var youtube = new YoutubeClient();
        var video = await youtube.Videos.GetAsync(url);
        var author = video.Author.ChannelTitle;
        var streamManifest = await youtube.Videos.Streams.GetManifestAsync(url);
        var title = video.Title
        .Replace("\\", "")
        .Replace("/", "")
        .Replace(":", "")
        .Replace("*", "")
        .Replace("?", "")
        .Replace("\"", "")
        .Replace("<", "")
        .Replace(">", "")
        .Replace("|", "")
        .Replace(" ", "");

        IStreamInfo streamInfo=null;
        if (style == 0)
        {
            streamInfo = streamManifest.GetAudioOnlyStreams()
                .GetWithHighestBitrate();
        }
        else
        {
            streamInfo = streamManifest.GetMuxedStreams()
                .GetWithHighestVideoQuality();
        }
        //var stream = await youtube.Videos.Streams.GetAsync(streamInfo);
        await youtube.Videos.Streams.DownloadAsync(
            streamInfo, $"{path}\\{title}.{streamInfo.Container}"
        );
    });
}

FolderBrowserDialog

FolderBrowserDialog 可以開啟檔案總管的瀏覽對話方塊,但只有 Windows Form 支援這個對話方框, WPF 一直不支援,所以只能在 WPF 中啟用 Windows Form。

自 .net 6.0 開始,要在 WPF 中要啟用 Windows Form,需使用 notepad++ 開啟 WPF 專案下的 .csproj 檔,然後在 PropertyGroup 區塊中加入如下藍色代碼

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>

UI 版面設計

UI 的畫面設計如下代碼

<Window x:Class="Youtube.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Youtube"
mc:Ignorable="d"
Loaded="Window_Loaded"
Title="MahalYT" Height="450" Width="800" WindowState="Maximized">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="26"/>
<RowDefinition/>
<RowDefinition Height="28"/>
<RowDefinition Height="26"/>
<RowDefinition Height="26"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Background="#e0e0e0">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="300"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Label Grid.Column="0" Name="lblVersion"/>
<Grid Grid.Column="1" Background="#a0a0ff">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="50"/>
<ColumnDefinition/>
<ColumnDefinition Width="50"/>
</Grid.ColumnDefinitions>
<Label Grid.Column="0" Content="Song"/>
<TextBox Grid.Column="1" VerticalContentAlignment="Center" Name="txtSong"/>
<Button Grid.Column="2" Content="Search" Name="btnSearch" Click="btnSearch_Click"/>
</Grid>
<Label Grid.Column="2" Content="Author : Thomas (mahaljsp@gamil.com)" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
<ListBox Grid.Row="1" x:Name="lsSong"/>
<Grid Grid.Row="2" Margin="0,2,0,1" Background="#E0E0E0">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Background="#80ff80" >
<RadioButton GroupName="rbStyle" Name="rbMp3" Margin="10,0,10,0">mp3</RadioButton>
<RadioButton GroupName="rbStyle" IsChecked="True" Name="rbMp4" Margin="10,0,10,0">mp4</RadioButton>
<Button Name="btnDownload" Width="80" Margin="20,0,0,0" Click="btnDownload_Click" >Download</Button>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Right" Background="#f0f0f0">
<Label Width="200" Name="lblPath"/>
<Button Name="btnPath" Width="100" Click="btnPath_Click">Path</Button>
</StackPanel>
</Grid>
<Grid Grid.Row="3" Margin="0,1,0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="50"/>
<ColumnDefinition/>
<ColumnDefinition Width="84"/>
<ColumnDefinition Width="16"/>
</Grid.ColumnDefinitions>
<Label Grid.Column="0" Content="URL" Background="#E0E0E0"/>
<TextBox Grid.Column="1" Text="" TextWrapping="Wrap" VerticalContentAlignment="Center" Name="txtUrl"/>
<Button Grid.Column="2" Content="URL Download" Name="btnUrlDownload" Click="btnUrlDownload_Click" Grid.ColumnSpan="2"/>
</Grid>
<Label Grid.Row="4" Name="lblStatus" Background="#d5d5d5" HorizontalContentAlignment="Center" />
</Grid>
</Window>

使用者控制項

顯示在 ScrollViewer 裏的 item 蠻複雜的,所以就請使用者控制項先制定其樣式,再由主程式產生 item 並加入 ScrollViewer 中。

請在專案 Youtube 處按右鍵/加入/新增項目/使用者控制項(WPF),名稱鍵入 SearchItem。
SearchItem.xaml 詳細內容如下

<UserControl x:Class="Youtube.SearchItem"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:Youtube"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<Border BorderBrush="Black" BorderThickness="0.5" Height="Auto" Margin="2,1,2,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40"/>
<ColumnDefinition Width="50"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Label Grid.Column="0" Name="item" Background="#c0c0c0" VerticalContentAlignment="Center" />
<CheckBox Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" Name="chk"/>
<StackPanel Grid.Column="2" VerticalAlignment="Center" Orientation="Vertical" HorizontalAlignment="Stretch">
<TextBox Name="txtTitle" Background="#f9f9f9" IsReadOnly="True" Height="30" VerticalContentAlignment="Center" FontSize="14" BorderThickness="0"/>
<TextBox Name="txtUrl" Background="#eeeeee" IsReadOnly="True" Height="30" VerticalContentAlignment="Center" FontSize="14" BorderThickness="0"/>
</StackPanel>
</Grid>
</Border>
</UserControl>

完整程式碼

底下是本範例的完整程式碼

using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.Serialization.Formatters.Binary;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using YoutubeExplode;
using YoutubeExplode.Videos.Streams;

namespace Youtube
{
    ///<summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        IWebDriver browser;
        Dictionary<String, String> dicts = new Dictionary<String, String>();
        string path = "c:\\youtube_download";
        public MainWindow()
        {
            InitializeComponent();
            disableUI();
        }
        private async void Window_Loaded(object sender, RoutedEventArgs e)
        {
            //txtStatus.Text=$"MahalYT Install Path : {AppDomain.CurrentDomain.BaseDirectory}";
            if (File.Exists("previous.json"))
            {
                var jsonString = File.ReadAllText("previous.json");
                PathInfo info = JsonSerializer.Deserialize<PathInfo>(jsonString);
                path = info.infoPath;
            }
            
            if (!Directory.Exists(path))
            {
                Directory.CreateDirectory(path);
            }
            lblPath.Content = path.Replace("_", "__");
            //底下的版本取得方式,會在發佈後無法使用,造成程式閃退
            //lblVersion.Content= string.Format("Version : {0}",
            //    FileVersionInfo.GetVersionInfo(
            //        Assembly.GetExecutingAssembly().Location
            //    ).FileVersion.ToString());

            //底下才是取得版本的正確方式
            lblVersion.Content = string.Format(
                "Version : {0}",
                Assembly.GetExecutingAssembly().GetName().Version.ToString()
            );
            await Browser();
            enableUI();
        }
        private async Task Browser()
        {
            await Task.Run(() => {
                ChromeOptions options = new ChromeOptions();
                ChromeDriverService service = ChromeDriverService.CreateDefaultService();
                service.SuppressInitialDiagnosticInformation = true;
                service.EnableVerboseLogging = false;
                service.HideCommandPromptWindow = true;
                options.AddArgument("--disable-logging");
                options.AddArgument("--output=/dev/null");
                options.AddArgument("--headless");
                browser = new ChromeDriver(service, options);
            });
        }
        private async void btnSearch_Click(object sender, RoutedEventArgs e)
        {
            if (txtSong.Text == "") {
                MessageBox.Show("Please input Song/Singer", "MahalYT", MessageBoxButton.OK, MessageBoxImage.Warning);
                return;
            }
            txtStatus.Text = "Searching....";
            disableUI();
            //lsSong.Items.Clear();
            lsSong.Children.Clear();
            String url = string.Format(
                "https://www.youtube.com/results?search_query={0}", txtSong.Text
            );
            chAll.IsChecked = false;
            chAll.IsEnabled = false;
            await Search(url);
            enableUI();
            txtStatus.Text = "";
            var index = 0;
            foreach (var dict in dicts)
            {
                var values = dict.Value.Split(" url=");
                SearchItem item = new SearchItem();
                item.item.Content = index + 1;
                item.txtTitle.Text = values[0];
                item.txtUrl.Text = values[1];
                lsSong.Children.Add(item);
                index++;
            }
            if (lsSong.Children.Count > 0) chAll.IsEnabled = true;
        }

        private async Task Search(string url)
        {
            await Task.Run(() =>
            {
                dicts.Clear();
                browser.Navigate().GoToUrl(url);
                WebDriverWait wait = new WebDriverWait(
                    browser, TimeSpan.FromSeconds(10)
                );
                var element = wait.Until(
                    SeleniumExtras.WaitHelpers.ExpectedConditions.ElementIsVisible(
                        By.Id("video-title")
                    )
                );
                var tags = browser.FindElements(By.TagName("a"));
                foreach (var tag in tags)
                {
                    string href = tag.GetAttribute("href");
                    var title = tag.GetAttribute("title");
                    if (title != "" &&
                        href != null &&
                        href.Contains("watch") &&
                        !dicts.ContainsKey(href))
                    {
                        dicts.Add(href, string.Format("{0} url={1}", title, href));
                    }
                }
            });
        }
        private async void btnDownload_Click(object sender, RoutedEventArgs e)
        {
            if (lsSong.Children.Count == 0)
            {
                MessageBox.Show("Please Search Song/Singer first", "MahalYT", MessageBoxButton.OK, MessageBoxImage.Warning);
                return;
            }
            int style = 1;
            if (rbMp3.IsChecked == true) style = 0;
            chAll.IsEnabled = false;
            disableUI();
            int downloadCount = 0;
            foreach (SearchItem item in lsSong.Children)
            {
                if (item.chk.IsChecked is true)
                {
                    txtStatus.Text = string.Format("Download {0}...", item.txtTitle.Text);
                    await Download(item.txtUrl.Text, style, path);
                    downloadCount++;
                }
            }
            if (downloadCount > 0)
                txtStatus.Text = string.Format("Download suscessful");
            enableUI();
            chAll.IsEnabled = true;
        }
        private async Task Download(string url, int style, string path)
        {
            await Task.Run(async () => {
                var youtube = new YoutubeClient();
                var video = await youtube.Videos.GetAsync(url);
                var author = video.Author.ChannelTitle;
                var streamManifest = await youtube.Videos.Streams.GetManifestAsync(url);
                var title = video.Title
                .Replace("\\", "")
                .Replace("/", "")
                .Replace(":", "")
                .Replace("*", "")
                .Replace("?", "")
                .Replace("\"", "")
                .Replace("<", "")
                .Replace(">", "")
                .Replace("|", "")
                .Replace(" ", "");
                IStreamInfo streamInfo = null;
                if (style == 0)
                {
                    streamInfo = streamManifest.GetAudioOnlyStreams()
                        .GetWithHighestBitrate();
                }
                else
                {
                    streamInfo = streamManifest.GetMuxedStreams()
                        .GetWithHighestVideoQuality();
                }
                //var stream = await youtube.Videos.Streams.GetAsync(streamInfo);
                await youtube.Videos.Streams.DownloadAsync(
                    streamInfo, $"{path}\\{title}.{streamInfo.Container}"
                );
            });
        }
        private async void btnUrlDownload_Click(object sender, RoutedEventArgs e)
        {
            if (txtUrl.Text == "")
            {
                MessageBox.Show("Please input URL", "MahalYT", MessageBoxButton.OK, MessageBoxImage.Warning);
                return;
            }
            try
            {
                txtStatus.Text = string.Format("Download {0}...", txtUrl.Text);
                int style = 0;
                if (rbMp3.IsChecked == true) style = 0;
                else style = 1;
                await Download(txtUrl.Text, style, path);
                txtStatus.Text = string.Format("Download suscessful");
            }
            catch {
                MessageBox.Show("YouTube address error", "Download Fail", MessageBoxButton.OK, MessageBoxImage.Error);
            }
            txtUrl.Text = "";

        }
        private void btnPath_Click(object sender, RoutedEventArgs e)
        {
            using (var dialog = new System.Windows.Forms.FolderBrowserDialog())
            {
                if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
                {
                    path = dialog.SelectedPath;
                    var info = new PathInfo
                    {
                        infoPath = path
                    };
                    File.WriteAllText("previous.json", JsonSerializer.Serialize(info));
                    lblPath.Content = path.Replace("_", "__");
                }
            }
        }
        private void disableUI()
        {
            btnDownload.IsEnabled = false;
            btnPath.IsEnabled = false;
            btnSearch.IsEnabled = false;
            btnUrlDownload.IsEnabled = false;
        }
        private void enableUI()
        {
            btnDownload.IsEnabled = true;
            btnPath.IsEnabled = true;
            btnSearch.IsEnabled = true;
            btnUrlDownload.IsEnabled = true;
        }

        private void chAll_Click(object sender, RoutedEventArgs e)
        {
            var chk = false;
            if (chAll.IsChecked == true) chk = true;
            foreach (SearchItem item in lsSong.Children)
            {
                item.chk.IsChecked = chk;
            }
        }
    }
    class PathInfo
    {
        public string infoPath { get; set; }
    }
}

Icon 注意事項

Icon 圖示是這個專案的代表,此 Icon 也會顯示在桌面的快捷鍵上。

請由右方方案總管的專案按右鍵/屬性/應用程式,在圖示選擇 .ico 檔,此檔會被 copy 到專案之下。然後記得要在方案總管的 .icon 檔點一下,下方檔案屬性 “複製到輸出目錄” 要選擇 “永遠複製”。如果沒有作此步驟,使用者在執行 setup.exe時,會出現 “缺少需要的套件” 之類的錯誤。

發佈專案

專案完成後,就是要發佈安裝檔給人下載安裝並執行,如何發佈,有許多要注意的事項。

請從建置/發佈/ClickOnce開始,發佈位置使用預設的 bin\publish。安裝位置請選擇從網站,指定 URL 如https://mahaljsp.ddns.net/static/mahalyt。

設定/選項/資訊清單,請勾選 “建立桌面捷徑”。然後也是在設定/更新設定/更新位置輸入上述的 URL : https://mahaljsp.ddns.net/static/mahalyt,指定此應用程式所需的最低版本請輸入比發佈版本少一號。

然後在最後的設定/目標執行階段,選擇win-x64,然後檔案發行選項要勾選產生單一檔案。如果產生單一檔案沒有勾,則安裝後是無法執行的。

最後再按發佈,就會在專案下的 bin下產生 publish 目錄。再使用 filezilla 將 publish 下的所有檔案目錄都 copy 到網站的 \static\mahalyt 下。

執行檔下載

本範例已編譯成執行檔,在執行此應用程式前,會自動提示下載 .net 7.0.10 sdk framework,請依指示下載並安裝

然後請到本站 https://mahaljsp.ddns.net/static/mahalyt/setup.exe 下載並執行 setup.exe 安裝檔。

下載時瀏覽器會警告此程式 “可能不安全”,請按 “保留”。請放心,本程式絕對沒有病毒,瀏覽器只要是下載 .exe 檔都會提出這樣的警告。

安裝時,請選擇 “其他資訊”,再選擇 “仍要執行”

安裝完就會自動開啟本程式,桌面上也會自動產生快捷鍵 “MahalYT”。

2 thoughts on “C# Youtube 下載

  1. 無法使用 Selenium Manager 取得 chromedriver

    老師您好,
    執行過程
    browser = new ChromeDriver(service, options);這段出現下方錯誤:
    QA.Selenium.NoSuchDriverException: ‘Unable to obtain chrome using Selenium Manager; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location
    是否須要先下載安裝 chromedriver.exe檔後,並於下述指定完整路徑
    ChromeDriverService service = ChromeDriverService.CreateDefaultService(@” chromedriver.exe檔完整路徑”);
    以上,請問還是有其他學生未注意到的地方,敬請老師賜教,感謝

  2. thomas Post author

    如果使用舊版的 Selenium.WebDriver 套件,會找不到適合新 Chrome 的 webdriver.exe。這是因為新的 webdriver.exe 下載位置被 Google 變更了。

    只要把 Selenium.WebDriver 套件更新到最新版本,就可以自動下載了。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *