Build a WPF “YouTube Unsubscriber” App with OAuth 2.0 + YouTube Data API v3 (Visual Studio Community)
This post is a hands-on, end-to-end lab that shows how to build a WPF desktop app (Visual Studio Community) that:
- authenticates the user with OAuth 2.0
- lists all YouTube subscriptions in a
DataGrid - exports a CSV backup
- and (only with explicit confirmation) unsubscribes from channels using the YouTube Data API v3
The goal is a safe, reproducible baseline that you can extend later (unsubscribe selected items, search/filter, logs, retries, etc.).
1) Big picture: Google APIs vs. YouTube APIs
1.1 Google Cloud project as the “container”
Most Google APIs (YouTube, Drive, Gmail, etc.) follow the same model:
- Create/select a Google Cloud Project
- Enable the API you want (here: YouTube Data API v3)
- Configure OAuth consent (what the user sees when granting access)
- Create an OAuth Client (Desktop application)
- Your app uses OAuth to obtain tokens and call the API
1.2 Which YouTube API are we using?
For subscriptions management, you need YouTube Data API v3.
Key endpoints for this lab:
subscriptions.listwithmine=true
→ returns the subscriptions of the authenticated usersubscriptions.deletewithid=<subscriptionId>
→ removes a subscription
Important detail: to delete, you must pass the subscriptionId (the resource’s id), not the channelId.
1.3 “Cost”: money vs quota
For typical personal usage, the main constraint is quota, not billing. Your project gets a daily quota bucket for YouTube Data API requests. If you exceed it, requests start failing until the quota resets. For this use-case (list + delete), you’re very unlikely to hit daily limits.
To avoid accidental costs:
- do not enable unrelated paid Google Cloud products (Compute, databases, etc.)
- keep the project focused on YouTube Data API
2) Prerequisites
- Visual Studio Community (Desktop development workload)
- WPF App (.NET) — recommended: .NET 8
- A Google/YouTube account to authenticate
- Access to Google Cloud Console to create credentials
3) Google Cloud Console setup (Project, API, OAuth)
3.1 Create or select a Google Cloud Project
Open Google Cloud Console and select an existing project or create a new one (e.g., YouTubeToolsLab).
3.2 Enable YouTube Data API v3
Navigate to:
- APIs & Services → Library
- Search YouTube Data API v3
- Click Enable
If you skip this, authentication may work, but the API calls will fail.
3.3 Configure OAuth consent (authorize your own user)
This is the step that commonly blocks people with “user not authorized” errors.
Go to:
- APIs & Services → OAuth consent screen
(In newer UI versions you may see equivalent sections like “Branding / Audience / Data Access”.)
Recommended settings for a personal desktop tool:
- User Type: External
- Configure minimal required fields (App name, support email)
- If your app is in Testing, add your Google account email under:
- Test users
This makes your own account allowed to run the OAuth flow while the app is not “published.”
3.4 Create OAuth Client ID (Desktop app) and download JSON
Go to:
- APIs & Services → Credentials
- Create Credentials → OAuth client ID
- Application type: Desktop app
- Create
Then:
- Download the JSON file
- Rename it to:
client_secret.json
4) Visual Studio: create the WPF project
4.1 Create the project
- File → New → Project
- Choose WPF App (.NET)
- Name it:
YouTubeUnsubscriberWpf
4.2 Install NuGet packages
Open Package Manager Console:
Install-Package Google.Apis.YouTube.v3Install-Package Google.Apis.AuthInstall-Package CsvHelper
4.3 Add client_secret.json to the project output
- Add → Existing Item… → select
client_secret.json - In Solution Explorer, select the file and set:
- Build Action:
Content - Copy to Output Directory:
Copy if newer
- Build Action:
This ensures the file is present at runtime in bin\Debug\netX\.
5) Architecture (simple and safe baseline)
Why “safe baseline” matters
Unsubscribing from many channels is a destructive action. Good defaults:
- Always list first
- Always create a backup CSV
- Require explicit “I understand” checkbox
- Require a final confirmation dialog
We’ll implement these safety rails.
6) Code: Models + YouTube API client
6.1 Model: SubscriptionItem
Create Models/SubscriptionItem.cs:
namespace YouTubeUnsubscriberWpf.Models;public sealed class SubscriptionItem{ public string SubscriptionId { get; set; } = ""; public string ChannelId { get; set; } = ""; public string Title { get; set; } = "";}
6.2 Service: OAuth + list + delete
Create Services/YouTubeApiClient.cs:
using Google.Apis.Auth.OAuth2;using Google.Apis.Services;using Google.Apis.YouTube.v3;using Google.Apis.YouTube.v3.Data;using System;using System.Collections.Generic;using System.IO;using System.Threading;using System.Threading.Tasks;using YouTubeUnsubscriberWpf.Models;namespace YouTubeUnsubscriberWpf.Services;public sealed class YouTubeApiClient{ private const string AppName = "YouTubeUnsubscriberWpf"; private static readonly string[] Scopes = { YouTubeService.Scope.Youtube }; private YouTubeService _service; public async Task EnsureAuthenticatedAsync(string clientSecretPath, CancellationToken ct) { if (_service != null) return; if (!File.Exists(clientSecretPath)) throw new FileNotFoundException("client_secret.json not found in output folder.", clientSecretPath); using var stream = new FileStream(clientSecretPath, FileMode.Open, FileAccess.Read); // Stores tokens under the user's profile (AppData). Browser window will open on first run. var credential = await GoogleWebAuthorizationBroker.AuthorizeAsync( GoogleClientSecrets.FromStream(stream).Secrets, Scopes, "user", ct ); _service = new YouTubeService(new BaseClientService.Initializer { HttpClientInitializer = credential, ApplicationName = AppName }); } public async Task<List<SubscriptionItem>> GetMySubscriptionsAsync(CancellationToken ct) { if (_service == null) throw new InvalidOperationException("Not authenticated."); var items = new List<SubscriptionItem>(); string pageToken = null; do { var request = _service.Subscriptions.List("snippet"); request.Mine = true; request.MaxResults = 50; request.PageToken = pageToken; SubscriptionListResponse resp = await request.ExecuteAsync(ct); foreach (var it in resp.Items) { items.Add(new SubscriptionItem { SubscriptionId = it.Id, // required by subscriptions.delete ChannelId = it.Snippet?.ResourceId?.ChannelId ?? "", Title = it.Snippet?.Title ?? "" }); } pageToken = resp.NextPageToken; } while (!string.IsNullOrEmpty(pageToken)); return items; } public async Task DeleteSubscriptionAsync(string subscriptionId, CancellationToken ct) { if (_service == null) throw new InvalidOperationException("Not authenticated."); var request = _service.Subscriptions.Delete(subscriptionId); await request.ExecuteAsync(ct); }}
7) WPF UI: DataGrid + export + “Unsubscribe ALL” (with guardrails)
7.1 MainWindow.xaml
Replace MainWindow.xaml:
<Window x:Class="YouTubeUnsubscriberWpf.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="YouTube Unsubscriber" Height="720" Width="1050"> <Grid Margin="12"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <StackPanel Orientation="Horizontal" Grid.Row="0" VerticalAlignment="Center"> <Button x:Name="BtnAuth" Content="1) Authenticate" Width="150" Margin="0,0,8,0" Click="BtnAuth_Click"/> <Button x:Name="BtnLoad" Content="2) Load Subscriptions" Width="180" Margin="0,0,8,0" Click="BtnLoad_Click" IsEnabled="False"/> <Button x:Name="BtnExport" Content="Export CSV" Width="120" Margin="0,0,8,0" Click="BtnExport_Click" IsEnabled="False"/> <Button x:Name="BtnUnsub" Content="Unsubscribe ALL" Width="160" Margin="0,0,8,0" Click="BtnUnsub_Click" IsEnabled="False"/> <CheckBox x:Name="ChkConfirm" Content="I understand (enable delete)" Margin="12,0,0,0" VerticalAlignment="Center"/> </StackPanel> <TextBlock x:Name="TxtStatus" Grid.Row="1" Margin="0,10,0,10" Text="Ready." /> <DataGrid x:Name="GridSubs" Grid.Row="2" AutoGenerateColumns="False" IsReadOnly="True" CanUserAddRows="False" SelectionMode="Extended"> <DataGrid.Columns> <DataGridTextColumn Header="Title" Binding="{Binding Title}" Width="*"/> <DataGridTextColumn Header="ChannelId" Binding="{Binding ChannelId}" Width="320"/> <DataGridTextColumn Header="SubscriptionId" Binding="{Binding SubscriptionId}" Width="320"/> </DataGrid.Columns> </DataGrid> <ProgressBar x:Name="Progress" Grid.Row="3" Height="18" Margin="0,12,0,0" Minimum="0" Maximum="100" Value="0"/> </Grid></Window>
7.2 MainWindow.xaml.cs
Replace MainWindow.xaml.cs:
using CsvHelper;using System;using System.Collections.Generic;using System.Globalization;using System.IO;using System.Linq;using System.Threading;using System.Windows;using YouTubeUnsubscriberWpf.Models;using YouTubeUnsubscriberWpf.Services;namespace YouTubeUnsubscriberWpf;public partial class MainWindow : Window{ private readonly YouTubeApiClient _yt = new(); private List<SubscriptionItem> _subs = new(); private readonly CancellationTokenSource _cts = new(); public MainWindow() { InitializeComponent(); Closing += (_, __) => _cts.Cancel(); } private string ClientSecretPath => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "client_secret.json"); private async void BtnAuth_Click(object sender, RoutedEventArgs e) { try { SetUiBusy(true, "Authenticating..."); await _yt.EnsureAuthenticatedAsync(ClientSecretPath, _cts.Token); TxtStatus.Text = "Authenticated. Now load subscriptions."; BtnLoad.IsEnabled = true; } catch (Exception ex) { MessageBox.Show(ex.Message, "Auth error", MessageBoxButton.OK, MessageBoxImage.Error); TxtStatus.Text = "Auth failed."; } finally { SetUiBusy(false); } } private async void BtnLoad_Click(object sender, RoutedEventArgs e) { try { SetUiBusy(true, "Loading subscriptions..."); _subs = await _yt.GetMySubscriptionsAsync(_cts.Token); GridSubs.ItemsSource = _subs; TxtStatus.Text = $"Loaded: {_subs.Count} subscriptions."; BtnExport.IsEnabled = _subs.Count > 0; BtnUnsub.IsEnabled = _subs.Count > 0; } catch (Exception ex) { MessageBox.Show(ex.Message, "Load error", MessageBoxButton.OK, MessageBoxImage.Error); TxtStatus.Text = "Load failed."; } finally { SetUiBusy(false); } } private void BtnExport_Click(object sender, RoutedEventArgs e) { try { if (_subs.Count == 0) { MessageBox.Show("No subscriptions loaded.", "Info"); return; } var file = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "subscriptions_backup.csv"); using var writer = new StreamWriter(file); using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); csv.WriteRecords(_subs); TxtStatus.Text = $"CSV exported: {file}"; } catch (Exception ex) { MessageBox.Show(ex.Message, "Export error", MessageBoxButton.OK, MessageBoxImage.Error); } } private async void BtnUnsub_Click(object sender, RoutedEventArgs e) { if (_subs.Count == 0) { MessageBox.Show("No subscriptions loaded.", "Info"); return; } // Safety gate if (ChkConfirm.IsChecked != true) { MessageBox.Show("Check 'I understand' to enable deletion.", "Safety"); return; } // Always export before delete BtnExport_Click(sender, e); var confirm = MessageBox.Show( $"This will unsubscribe from {_subs.Count} channels.\n\nProceed?", "Confirm", MessageBoxButton.YesNo, MessageBoxImage.Warning); if (confirm != MessageBoxResult.Yes) return; try { SetUiBusy(true, "Unsubscribing..."); Progress.Value = 0; int total = _subs.Count; int ok = 0, fail = 0; // Copy list to avoid UI binding issues var list = _subs.ToList(); for (int i = 0; i < total; i++) { var s = list[i]; TxtStatus.Text = $"[{i + 1}/{total}] Unsubscribing: {s.Title}"; try { await _yt.DeleteSubscriptionAsync(s.SubscriptionId, _cts.Token); ok++; } catch { fail++; } Progress.Value = (i + 1) * 100.0 / total; await System.Threading.Tasks.Task.Delay(150, _cts.Token); // pacing } TxtStatus.Text = $"Done. Success: {ok}, Failed: {fail}. Reload to confirm."; } catch (Exception ex) { MessageBox.Show(ex.Message, "Delete error", MessageBoxButton.OK, MessageBoxImage.Error); TxtStatus.Text = "Unsubscribe failed."; } finally { SetUiBusy(false); } } private void SetUiBusy(bool busy, string status = null) { BtnAuth.IsEnabled = !busy; if (!string.IsNullOrEmpty(status)) TxtStatus.Text = status; }}
8) Common failure points (and how to fix them)
8.1 “User not authorized” / “Access blocked”
Your OAuth consent screen is in Testing and your account is not listed as a Test user.
Fix:
- Go to OAuth consent screen settings
- Add your Google account email under Test users
- Try again
8.2 “API not enabled”
You created OAuth credentials but forgot to enable YouTube Data API v3.
Fix:
- APIs & Services → Library → Enable YouTube Data API v3
8.3 client_secret.json not found
The JSON wasn’t copied to the output folder.
Fix:
- Build Action = Content
- Copy to Output Directory = Copy if newer
9) Why this baseline is a good starting point
This solution is intentionally small but “real”:
- OAuth done correctly for Desktop
- Pagination supported (50 items per page, loops on
nextPageToken) - CSV backup before destructive actions
- Strong guardrails (checkbox + confirm dialog)
- Easy to extend with UI features
10) Next enhancements (recommended roadmap)
Once your baseline runs cleanly, these upgrades make it “production-grade” for your personal workflow:
- Unsubscribe selected (instead of ALL)
- Search/filter in the grid (title contains, etc.)
- Retry strategy (transient failures, rate-limits)
- Detailed logging (success/fail per subscription exported to a second CSV)
- Dry-run mode (simulate deletes, show what would be removed)
Final checklist
- Project created in Google Cloud
- YouTube Data API v3 enabled
- OAuth consent configured + your email added as Test user (if Testing)
- OAuth Client ID created (Desktop) +
client_secret.jsondownloaded client_secret.jsonadded to WPF project and copied to output- App authenticates successfully
- App lists subscriptions
- CSV backup generated
- Unsubscribe flow works (with safety gates)
