A WPF desktop app (.NET 10, Windows) for managing YouTube channel watchlists. Tracks unwatched videos across multiple channels organized into named lists.
- Organize YouTube channels into named Lists
- Lists display (x/y) — x channels with unwatched videos, y total unwatched — so you can see at a glance which lists need attention
- Refresh any list to pull recent videos, or use the split Refresh button to refresh a chosen subset:
- Click the main button to refresh using the currently selected scope
- Click the ▼ arrow on the left to choose Refresh All, Refresh Top 1/2/3, Refresh First Half, or Refresh Second Half — the selected option becomes the button's label
- Load Subscriptions — import all your YouTube subscriptions via browser sign-in, auto-split into lists of 30 (skips channels already in any list)
- Videos display oldest-first so you watch in order
- Mark each video as Watched, Skip (DontWatch), or Not Interested (✕)
- Show Watched toggle to reveal/hide watched videos
- Hide Shorts — per-channel toggle (saved in DB) that hides Shorts in both the video list and unwatched counts
- Mark All Watched button to bulk-clear a list
- Channel names are bold when they have unwatched videos, and wrap to multiple lines if long
- Drag channels between lists, or drag lists themselves to reorder — status bar shows live progress during the move
- Detects YouTube Shorts (≤3 min) — shows
(SHORT)after title, portrait thumbnail - Import Takeout — import your Google Takeout watch-history.json to auto-mark already-watched videos
- Sync Watch History — sign in to YouTube via browser to sync watched videos
- Message History —
?button on the status bar opens a scrollable log of all status messages with copy support - Persists all state in SQLite
The Refresh button fetches new videos from YouTube. To use it, you need a free YouTube Data API key from Google. You won't be charged — the free quota is more than enough for personal use.
Step 1 — Create a Google Cloud project
- Go to console.cloud.google.com
- Sign in with any Google account
- Click the project dropdown at the top of the page (it may say "Select a project")
- Click New Project
- Give it any name (e.g.
YouTubeTool) and click Create - Make sure your new project is selected in the dropdown before continuing
Step 2 — Enable the YouTube Data API
- In the left sidebar, click APIs & Services → Library
- Search for
YouTube Data API v3 - Click the result, then click Enable
Step 3 — Create an API Key
- In the left sidebar, click APIs & Services → Credentials
- Click + Create Credentials at the top
- Choose API key
- Google will show you your new API key — copy it
Step 4 — Enter the key in YouTubeTool
- Open YouTubeTool
- Click the ⚙ Settings button in the bottom-left
- Paste your API key into the YouTube API Key field
- Click Save
You're done. Click ↻ Refresh on any list to start fetching videos.
If you've been watching YouTube for a while, importing your watch history lets the app automatically mark videos you've already seen.
- Go to takeout.google.com
- Click Deselect all, then scroll down and check only YouTube and YouTube Music
- Click the All YouTube data included button and uncheck everything except history
- Click Next step → Create export
- Download the zip when it's ready and extract it
- Find the file
watch-history.json(usually insideTakeout/YouTube and YouTube Music/history/) - In YouTubeTool, click 📂 Import Takeout and select that file
The app will mark any videos it already knows about as Watched, and remember all imported IDs so future channel refreshes also auto-mark them.
- In Google Cloud Console → Credentials → Create OAuth 2.0 Client ID (Desktop app)
- Under OAuth Consent Screen → Add your Google account email as a Test User (must be lowercase)
- Paste Client ID and Client Secret into Settings
cd D:\Projects\YouTubeTool
dotnet run
Or open in Visual Studio / Rider and run from there.
YouTubeTool/
├── Models/
│ ├── ChannelList.cs # EF entity — named list of channels
│ ├── Channel.cs # EF entity — YouTube channel
│ ├── Video.cs # EF entity — video with Status, IsShort
│ ├── WatchHistoryEntry.cs # EF entity — imported watch history IDs
│ └── AppSettings.cs # POCO — API key, OAuth creds, MaxVideos
│
├── Data/
│ ├── AppDbContext.cs # EF DbContext (Videos, Channels, ChannelLists, WatchHistory)
│ └── AppDbContextFactory.cs # Design-time factory for EF migrations
│
├── Services/
│ ├── YouTubeService.cs # YouTube API calls (channels, videos, Shorts detection)
│ ├── DatabaseService.cs # All DB read/write operations
│ ├── SettingsService.cs # Loads/saves settings.json
│ ├── GoogleAuthService.cs # OAuth sign-in via GoogleWebAuthorizationBroker
│ └── TakeoutImportService.cs # Parses watch-history.json from Google Takeout
│
├── ViewModels/
│ ├── MainViewModel.cs # Main window: lists, channels, videos, all commands
│ ├── VideoViewModel.cs # Per-video: status commands, DisplayTitle, IsShort
│ ├── SettingsViewModel.cs # Settings window logic
│ ├── BaseViewModel.cs # INotifyPropertyChanged base
│ └── RelayCommand.cs # ICommand implementations (sync + async)
│
├── Views/
│ ├── SettingsWindow.xaml # API key + OAuth settings dialog
│ ├── InputDialog.xaml # Generic text-input dialog (used for Add List)
│ └── ErrorDialog.xaml # Custom error dialog (Copy + Exit buttons)
│
├── Converters/
│ └── TestResultColorConverter.cs # Also contains BoolToVisibilityConverter,
│ # IsShortToViewboxConverter (unused now but present)
│
├── MainWindow.xaml # 3-panel layout: Lists | Channels | Videos
├── App.xaml.cs # DI setup, EF migration on startup, global error handling
│
└── Migrations/
├── InitialCreate # Base schema
├── AddWatchHistory # WatchHistory table
├── AddIsShort # IsShort column on Videos
└── AddHideShortsToChannel # HideShorts column on Channels
- Location:
%APPDATA%\YouTubeTool\YouTubeTool.db - Migrations run automatically on startup
Tables:
| Table | Purpose |
|---|---|
| ChannelLists | Named lists |
| Channels | YouTube channels |
| ChannelListChannel | Join table (many-to-many) |
| Videos | Video records with status |
| WatchHistory | Imported video IDs (from Takeout) |
VideoStatus enum: Unwatched=0, Watched=1, DontWatch=2, NotInterested=3
Each Refresh call costs:
- 1 unit —
channels.listto get uploads playlist ID - 1 unit per 50 videos —
playlistItems.listto get video list - 1 unit per 50 videos —
videos.listwithcontentDetailsfor duration (Shorts detection)
Daily quota is 10,000 units. Refreshing a channel with 50 videos costs ~3 units.
Shorts are detected by calling videos.list with contentDetails to get each video's duration. Any video ≤ 180 seconds (3 minutes) is treated as a Short.
Shorts get:
(SHORT)appended to the title- Portrait thumbnail URL (
https://i.ytimg.com/vi/{id}/oar2.jpg) — original aspect ratio, no pillarboxing - A 32×57 portrait thumbnail box in the UI (vs 88×50 landscape for regular videos)
If a subscribed channel has been suspended or deleted by YouTube, Refresh and Refresh All will silently skip it (no error shown). The channel remains in your list but will never have new videos.
Google restricts the watchHistory playlist API endpoint — it returns empty results even with valid OAuth. The button exists but will always report no results. Use Google Takeout import instead.
If you see "YouTubeTool has not completed the Google verification process":
- Go to Google Cloud Console → OAuth Consent Screen → Test Users
- Add your Google account email (must be lowercase)
- Regular videos use the
Mediumthumbnail (320×180, 16:9) - Shorts use
oar2.jpg(portrait, original aspect ratio) — displayed in a portrait box - The
Default__thumbnail from the playlist API always returns 120×90 regardless of video type, so dimension-based Shorts detection doesn't work
YouTubeTool.Services.YouTubeService has the same name as Google.Apis.YouTube.v3.YouTubeService. Resolved with:
using GoogleYT = Google.Apis.YouTube.v3;EF Core 10 does not support .Include() after .SelectMany(). Queries that need related data must be split into two separate queries (get IDs first, then query with Include).
On some Windows hosts, WPF's tooltip code path (Popup.PopupSecurityHelper.ForceMsaaToUiaBridge) P/Invokes into a UI Automation bridge native DLL that isn't always present. When missing, ToolTipService's DispatcherTimer throws FileNotFoundException — sometimes while the app is idle in the background with no user input. The crash is harmless (the tooltip just doesn't show), so App.OnStartup filters this specific exception in DispatcherUnhandledException and marks it handled instead of showing the error dialog.
Location: %APPDATA%\YouTubeTool\settings.json
{
"YouTubeApiKey": "...",
"MaxVideosPerChannel": 50,
"OAuthClientId": "...",
"OAuthClientSecret": "..."
}OAuth token is cached by Google's library at:
%APPDATA%\YouTubeTool\oauth_token\Google.Apis.Auth.OAuth2.Responses.TokenResponse-user