From dcaf78d7b7d121712237f2e3e0f14dd23395062e Mon Sep 17 00:00:00 2001 From: aohmcareer Date: Tue, 17 Feb 2026 18:38:38 -0500 Subject: [PATCH] Added support for https://github.com/ytmdesktop/ytmdesktop/releases. A user must enable the Companion Server in Settings -> Integrations and approve a token prompt for this to work. Tested locally and it works, will commit more if I find any bugs in my testing. I intended to use this for my own purposes at home, but committed and pushed this as a branch in case anyone would like this feature in a future release (likely tidied up some)? I used to use the Google Play Desktop Player integration that I think existed for this many years ago and thought since the YTMDP app is getting updates again this could use the same. --- Snip/Globals.cs | 3 +- Snip/LocalizedMessages.cs | 1 + Snip/Players/YouTubeMusic.cs | 624 +++++++++++++++++++++++++++++++++++ Snip/Resources/Strings.txt | 1 + Snip/Snip.Designer.cs | 10 + Snip/Snip.cs | 16 +- Snip/Snip.csproj | 1 + 7 files changed, 652 insertions(+), 4 deletions(-) create mode 100644 Snip/Players/YouTubeMusic.cs diff --git a/Snip/Globals.cs b/Snip/Globals.cs index 0e3b3ae..082d0c2 100644 --- a/Snip/Globals.cs +++ b/Snip/Globals.cs @@ -90,7 +90,8 @@ public enum MediaPlayerSelection : int { NoPlayer = 0, Spotify = 1, - Itunes = 2 + Itunes = 2, + YouTubeMusic = 3 } public enum MediaCommand : int diff --git a/Snip/LocalizedMessages.cs b/Snip/LocalizedMessages.cs index 6c5a3ba..8bc2e3c 100644 --- a/Snip/LocalizedMessages.cs +++ b/Snip/LocalizedMessages.cs @@ -27,6 +27,7 @@ public static class LocalizedMessages public static string NoPlayer { get; set; } public static string Spotify { get; set; } public static string Itunes { get; set; } + public static string YouTubeMusic { get; set; } public static string SwitchedToPlayer { get; set; } public static string PlayerIsNotRunning { get; set; } public static string NoTrackPlaying { get; set; } diff --git a/Snip/Players/YouTubeMusic.cs b/Snip/Players/YouTubeMusic.cs new file mode 100644 index 0000000..1b6be6a --- /dev/null +++ b/Snip/Players/YouTubeMusic.cs @@ -0,0 +1,624 @@ +#region File Information +/* + * Copyright (C) 2012-2026 David Rudie + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02111, USA. + */ +#endregion + +namespace Winter +{ + using System; + using System.ComponentModel; + using System.Globalization; + using System.IO; + using System.Net; + using System.Text; + using System.Timers; + using System.Windows.Forms; + using SimpleJson; + + using Timer = System.Timers.Timer; + + internal sealed class YouTubeMusic : MediaPlayer, IDisposable + { + #region Fields + + // ytmdesktop companion server defaults + private string companionServerAddress = "http://127.0.0.1:9863"; + + private string authorizationToken = string.Empty; + + private Timer updateTrackInformation; + private double updateTrackInformationDefaultInterval = 3000; + + // Auth flow state + private bool isAuthorizing = false; + private bool authorizationFailed = false; + + private string appId = "snip_media_player"; + private string appName = "Snip"; + private string appVersion = "1.0.0"; + + #endregion + + #region Methods + + public override void Load() + { + base.Load(); + + // Try to load saved token from registry + this.LoadAuthToken(); + + if (string.IsNullOrEmpty(this.authorizationToken)) + { + // No saved token; we'll attempt authorization on first update + this.authorizationFailed = false; + } + + this.updateTrackInformation = new Timer(this.updateTrackInformationDefaultInterval); + this.updateTrackInformation.Elapsed += this.UpdateTrackInformation_Elapsed; + this.updateTrackInformation.AutoReset = true; + this.updateTrackInformation.Enabled = true; + } + + public override void Unload() + { + base.Unload(); + + if (this.updateTrackInformation != null) + { + this.updateTrackInformation.Stop(); + } + } + + public void Dispose() + { + if (this.updateTrackInformation != null) + { + this.updateTrackInformation.Dispose(); + this.updateTrackInformation = null; + } + } + + private void LoadAuthToken() + { + try + { + Microsoft.Win32.RegistryKey registryKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( + string.Format( + CultureInfo.InvariantCulture, + "SOFTWARE\\{0}\\{1}", + AssemblyInformation.AssemblyTitle, + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.Major)); + + if (registryKey != null) + { + string savedToken = Convert.ToString(registryKey.GetValue("YouTubeMusic Auth Token", string.Empty), CultureInfo.CurrentCulture); + registryKey.Close(); + + if (!string.IsNullOrEmpty(savedToken)) + { + this.authorizationToken = savedToken; + } + } + } + catch + { + // Silently fail; we'll try to get a new token + } + } + + private void SaveAuthToken() + { + try + { + Microsoft.Win32.RegistryKey registryKey = Microsoft.Win32.Registry.CurrentUser.CreateSubKey( + string.Format( + CultureInfo.InvariantCulture, + "SOFTWARE\\{0}\\{1}", + AssemblyInformation.AssemblyTitle, + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.Major)); + + registryKey.SetValue("YouTubeMusic Auth Token", this.authorizationToken); + registryKey.Close(); + } + catch + { + // Silently fail + } + } + + private bool TryAuthorize() + { + if (this.isAuthorizing) + { + return false; + } + + this.isAuthorizing = true; + + try + { + // Step 1: Request a code + string requestCodeJson = this.PostJson( + this.companionServerAddress + "/api/v1/auth/requestcode", + string.Format( + CultureInfo.InvariantCulture, + "{{\"appId\":\"{0}\",\"appName\":\"{1}\",\"appVersion\":\"{2}\"}}", + this.appId, + this.appName, + this.appVersion), + false); + + if (string.IsNullOrEmpty(requestCodeJson)) + { + this.isAuthorizing = false; + return false; + } + + dynamic codeResponse = SimpleJson.DeserializeObject(requestCodeJson); + string code = codeResponse.code.ToString(); + + // Show the code to the user so they can confirm in ytmdesktop + Globals.SnipNotifyIcon.ShowBalloonTip( + 30000, + "Snip - YouTube Music Authorization", + string.Format( + CultureInfo.InvariantCulture, + "Please check YouTube Music Desktop App and confirm the authorization.\nCode: {0}", + code), + ToolTipIcon.Info); + + // Step 2: Request the auth token (long timeout — blocks until user approves in ytmdesktop) + string requestTokenJson = this.PostJson( + this.companionServerAddress + "/api/v1/auth/request", + string.Format( + CultureInfo.InvariantCulture, + "{{\"appId\":\"{0}\",\"code\":\"{1}\"}}", + this.appId, + code), + false, + 60); + + if (!string.IsNullOrEmpty(requestTokenJson)) + { + dynamic tokenResponse = SimpleJson.DeserializeObject(requestTokenJson); + this.authorizationToken = tokenResponse.token.ToString(); + this.SaveAuthToken(); + + Globals.SnipNotifyIcon.ShowBalloonTip( + 5000, + "Snip", + "Successfully authorized with YouTube Music Desktop App!", + ToolTipIcon.Info); + + this.isAuthorizing = false; + this.authorizationFailed = false; + return true; + } + } + catch (WebException) + { + // Authorization denied, timed out, or ytmdesktop not running + } + catch + { + // Other errors + } + + this.isAuthorizing = false; + this.authorizationFailed = true; + return false; + } + + private void UpdateTrackInformation_Elapsed(object sender, ElapsedEventArgs e) + { + // If we don't have an auth token, try to authorize + if (string.IsNullOrEmpty(this.authorizationToken)) + { + if (!this.authorizationFailed) + { + // Only try once - the user needs to enable companion authorization in ytmdesktop + TextHandler.UpdateTextAndEmptyFilesMaybe( + string.Format( + CultureInfo.InvariantCulture, + LocalizedMessages.PlayerIsNotRunning, + LocalizedMessages.YouTubeMusic)); + + this.TryAuthorize(); + } + return; + } + + string downloadedJson = this.DownloadJson(this.companionServerAddress + "/api/v1/state"); + + if (!string.IsNullOrEmpty(downloadedJson)) + { + try + { + dynamic jsonSummary = SimpleJson.DeserializeObject(downloadedJson); + + if (jsonSummary == null) + { + this.ResetSnipSinceYouTubeMusicIsNotPlaying(); + return; + } + + dynamic playerObj = jsonSummary.player; + if (playerObj == null) + { + this.ResetSnipSinceYouTubeMusicIsNotPlaying(); + return; + } + + // trackState: -1=Unknown, 0=Paused, 1=Playing, 2=Buffering + object trackStateObj = playerObj.trackState; + if (trackStateObj == null) + { + this.ResetSnipSinceYouTubeMusicIsNotPlaying(); + return; + } + + long trackStateLong = Convert.ToInt64(trackStateObj, CultureInfo.InvariantCulture); + + if (trackStateLong == 1) // Playing + { + dynamic videoObj = jsonSummary.video; + if (videoObj != null) + { + string videoId = string.Empty; + string title = string.Empty; + string artist = string.Empty; + string album = string.Empty; + + if (videoObj.id != null) + { + videoId = videoObj.id.ToString(); + } + + if (videoObj.title != null) + { + title = videoObj.title.ToString(); + } + + if (videoObj.author != null) + { + artist = videoObj.author.ToString(); + } + + if (videoObj.album != null) + { + album = videoObj.album.ToString(); + } + + if (this.LastTitle != videoId) + { + // Track changed - update text + TextHandler.UpdateText( + title, + artist, + album, + videoId, + downloadedJson); + + // Download album artwork if enabled + if (Globals.SaveAlbumArtwork) + { + this.DownloadAlbumArtwork(videoObj); + } + + this.LastTitle = videoId; + } + } + else + { + this.ResetSnipSinceYouTubeMusicIsNotPlaying(); + } + } + else + { + this.ResetSnipSinceYouTubeMusicIsNotPlaying(); + } + } + catch + { + // Don't reset on parse errors — could be transient + } + } + else + { + // Download failed — don't reset. Keep showing last known track info. + // Only reset when we get a valid response indicating nothing is playing. + } + } + + private void DownloadAlbumArtwork(dynamic videoInfo) + { + try + { + // The video info contains a thumbnails array with url, width, height + if (videoInfo.thumbnails != null) + { + // Find the best thumbnail based on the configured resolution + string imageUrl = SelectBestThumbnailUrl(videoInfo.thumbnails); + + if (!string.IsNullOrEmpty(imageUrl)) + { + using (WebClientWithShortTimeout webClient = new WebClientWithShortTimeout()) + { + webClient.Headers.Add("User-Agent", "Snip/" + AssemblyInformation.AssemblyVersion); + webClient.DownloadFileAsync(new Uri(imageUrl), this.DefaultArtworkFilePath); + this.SavedBlankImage = false; + } + } + else + { + this.SaveBlankImage(); + } + } + else + { + this.SaveBlankImage(); + } + } + catch (WebException) + { + this.SaveBlankImage(); + } + catch + { + this.SaveBlankImage(); + } + } + + private static string SelectBestThumbnailUrl(dynamic thumbnails) + { + // Thumbnails come as an array with url, width, height + // Select based on the configured artwork resolution + string bestUrl = string.Empty; + int targetSize = (int)Globals.ArtworkResolution; + + if (targetSize == 0) + { + targetSize = 120; // Default to small + } + + int bestDiff = int.MaxValue; + + foreach (dynamic thumbnail in thumbnails) + { + int width = 0; + int height = 0; + + if (thumbnail.width != null) + { + width = (int)(long)thumbnail.width; + } + + if (thumbnail.height != null) + { + height = (int)(long)thumbnail.height; + } + + int maxDimension = Math.Max(width, height); + int diff = Math.Abs(maxDimension - targetSize); + + if (diff < bestDiff) + { + bestDiff = diff; + if (thumbnail.url != null) + { + bestUrl = thumbnail.url.ToString(); + } + } + } + + // If no good match found, just take the last one (usually largest) + if (string.IsNullOrEmpty(bestUrl)) + { + foreach (dynamic thumbnail in thumbnails) + { + if (thumbnail.url != null) + { + bestUrl = thumbnail.url.ToString(); + } + } + } + + return bestUrl; + } + + private string DownloadJson(string jsonAddress) + { + try + { + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(jsonAddress); + request.Method = "GET"; + request.Timeout = 10000; + request.UserAgent = "Snip/" + AssemblyInformation.AssemblyVersion; + request.Accept = "application/json"; + request.KeepAlive = false; + + if (!string.IsNullOrEmpty(this.authorizationToken)) + { + request.Headers.Add("Authorization", this.authorizationToken); + } + + using (HttpWebResponse response = (HttpWebResponse)request.GetResponse()) + using (Stream responseStream = response.GetResponseStream()) + using (StreamReader reader = new StreamReader(responseStream, Encoding.UTF8)) + { + string downloadedJson = reader.ReadToEnd(); + + if (!string.IsNullOrEmpty(downloadedJson)) + { + return downloadedJson; + } + + return string.Empty; + } + } + catch (WebException webException) + { + HttpWebResponse response = webException.Response as HttpWebResponse; + if (response != null && response.StatusCode == HttpStatusCode.Forbidden) + { + // Token is invalid; clear it and try to re-authorize + this.authorizationToken = string.Empty; + this.authorizationFailed = false; + } + + return string.Empty; + } + catch + { + return string.Empty; + } + } + + private string PostJson(string address, string jsonBody, bool useAuth, int timeoutSeconds = 10) + { + using (WebClientWithConfigurableTimeout webClient = new WebClientWithConfigurableTimeout(timeoutSeconds)) + { + try + { + webClient.Headers.Add("Content-Type", "application/json"); + webClient.Headers.Add("User-Agent", "Snip/" + AssemblyInformation.AssemblyVersion); + webClient.Encoding = Encoding.UTF8; + + if (useAuth && !string.IsNullOrEmpty(this.authorizationToken)) + { + webClient.Headers.Add("Authorization", this.authorizationToken); + } + + string result = webClient.UploadString(address, "POST", jsonBody); + return result; + } + catch (WebException) + { + return string.Empty; + } + } + } + + private void SendCommand(string command) + { + try + { + string jsonBody = string.Format( + CultureInfo.InvariantCulture, + "{{\"command\":\"{0}\"}}", + command); + + this.PostJson(this.companionServerAddress + "/api/v1/command", jsonBody, true); + } + catch + { + // Silently fail + } + } + + private void ResetSnipSinceYouTubeMusicIsNotPlaying() + { + if (!this.SavedBlankImage) + { + if (Globals.SaveAlbumArtwork) + { + this.SaveBlankImage(); + } + } + + this.LastTitle = string.Empty; + + TextHandler.UpdateTextAndEmptyFilesMaybe(LocalizedMessages.NoTrackPlaying); + } + + #endregion + + #region Player Control Methods + + public override void ChangeToNextTrack() + { + this.SendCommand("next"); + } + + public override void ChangeToPreviousTrack() + { + this.SendCommand("previous"); + } + + public override void PlayOrPauseTrack() + { + this.SendCommand("playPause"); + } + + public override void PauseTrack() + { + this.SendCommand("pause"); + } + + public override void IncreasePlayerVolume() + { + this.SendCommand("volumeUp"); + } + + public override void DecreasePlayerVolume() + { + this.SendCommand("volumeDown"); + } + + public override void MutePlayerAudio() + { + this.SendCommand("mute"); + } + + #endregion + + #region Classes + + private class WebClientWithShortTimeout : WebClient + { + private const int WebClientTimeoutSeconds = 10; + + protected override WebRequest GetWebRequest(Uri address) + { + WebRequest webRequest = base.GetWebRequest(address); + webRequest.Timeout = WebClientTimeoutSeconds * 1000; + return webRequest; + } + } + + private class WebClientWithConfigurableTimeout : WebClient + { + private readonly int timeoutMs; + + public WebClientWithConfigurableTimeout(int timeoutSeconds) + { + this.timeoutMs = timeoutSeconds * 1000; + } + + protected override WebRequest GetWebRequest(Uri address) + { + WebRequest webRequest = base.GetWebRequest(address); + webRequest.Timeout = this.timeoutMs; + return webRequest; + } + } + + #endregion + } +} diff --git a/Snip/Resources/Strings.txt b/Snip/Resources/Strings.txt index df8699e..4526176 100644 --- a/Snip/Resources/Strings.txt +++ b/Snip/Resources/Strings.txt @@ -14,6 +14,7 @@ NewVersionAvailable=New Version Available! NoPlayer=No Player Selected Spotify=Spotify Itunes=iTunes +YouTubeMusic=YouTube Music ; This text is saved to the Snip.txt file when the user switches media players ; from the right-click context menu. diff --git a/Snip/Snip.Designer.cs b/Snip/Snip.Designer.cs index 6a9c0c8..f6be55c 100644 --- a/Snip/Snip.Designer.cs +++ b/Snip/Snip.Designer.cs @@ -9,6 +9,7 @@ public partial class Snip private System.Windows.Forms.ToolStripMenuItem toolStripMenuItemNoPlayer; private System.Windows.Forms.ToolStripMenuItem toolStripMenuItemSpotify; private System.Windows.Forms.ToolStripMenuItem toolStripMenuItemItunes; + private System.Windows.Forms.ToolStripMenuItem toolStripMenuItemYouTubeMusic; private System.Windows.Forms.ToolStripSeparator toolStripSeparator1; private System.Windows.Forms.ToolStripMenuItem toolStripMenuItemSetFormat; private System.Windows.Forms.ToolStripSeparator toolStripSeparator2; @@ -67,6 +68,7 @@ private void InitializeComponent() this.toolStripMenuItemNoPlayer = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripMenuItemSpotify = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripMenuItemItunes = new System.Windows.Forms.ToolStripMenuItem(); + this.toolStripMenuItemYouTubeMusic = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator(); this.toolStripMenuItemSetFormat = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); @@ -101,6 +103,7 @@ private void InitializeComponent() this.toolStripMenuItemNoPlayer, this.toolStripMenuItemSpotify, this.toolStripMenuItemItunes, + this.toolStripMenuItemYouTubeMusic, this.toolStripSeparator1, this.toolStripMenuItemSetFormat, this.toolStripSeparator2, @@ -147,6 +150,13 @@ private void InitializeComponent() this.toolStripMenuItemItunes.Text = LocalizedMessages.Itunes; this.toolStripMenuItemItunes.Click += new System.EventHandler(this.PlayerSelectionCheck); // + // toolStripMenuItemYouTubeMusic + // + this.toolStripMenuItemYouTubeMusic.Name = "toolStripMenuItemYouTubeMusic"; + this.toolStripMenuItemYouTubeMusic.Size = new System.Drawing.Size(67, 22); + this.toolStripMenuItemYouTubeMusic.Text = LocalizedMessages.YouTubeMusic; + this.toolStripMenuItemYouTubeMusic.Click += new System.EventHandler(this.PlayerSelectionCheck); + // // toolStripSeparator // this.toolStripSeparator.Name = "toolStripSeparator"; diff --git a/Snip/Snip.cs b/Snip/Snip.cs index 4f7b5c2..fb12044 100644 --- a/Snip/Snip.cs +++ b/Snip/Snip.cs @@ -89,6 +89,7 @@ private static void SetLocalizedMessages() LocalizedMessages.NoPlayer = Globals.ResourceManager.GetString("NoPlayer"); LocalizedMessages.Spotify = Globals.ResourceManager.GetString("Spotify"); LocalizedMessages.Itunes = Globals.ResourceManager.GetString("Itunes"); + LocalizedMessages.YouTubeMusic = Globals.ResourceManager.GetString("YouTubeMusic") ?? "YouTube Music"; LocalizedMessages.SwitchedToPlayer = Globals.ResourceManager.GetString("SwitchedToPlayer"); LocalizedMessages.PlayerIsNotRunning = Globals.ResourceManager.GetString("PlayerIsNotRunning"); LocalizedMessages.NoTrackPlaying = Globals.ResourceManager.GetString("NoTrackPlaying"); @@ -233,13 +234,18 @@ private void PlayerSelectionCheck(object sender, EventArgs e) { this.TogglePlayer(Globals.MediaPlayerSelection.Itunes); } + else if (sender == this.toolStripMenuItemYouTubeMusic) + { + this.TogglePlayer(Globals.MediaPlayerSelection.YouTubeMusic); + } } private void TogglePlayer(Globals.MediaPlayerSelection player) { - this.toolStripMenuItemNoPlayer.Checked = player == Globals.MediaPlayerSelection.NoPlayer; - this.toolStripMenuItemSpotify.Checked = player == Globals.MediaPlayerSelection.Spotify; - this.toolStripMenuItemItunes.Checked = player == Globals.MediaPlayerSelection.Itunes; + this.toolStripMenuItemNoPlayer.Checked = player == Globals.MediaPlayerSelection.NoPlayer; + this.toolStripMenuItemSpotify.Checked = player == Globals.MediaPlayerSelection.Spotify; + this.toolStripMenuItemItunes.Checked = player == Globals.MediaPlayerSelection.Itunes; + this.toolStripMenuItemYouTubeMusic.Checked = player == Globals.MediaPlayerSelection.YouTubeMusic; Globals.CurrentPlayer.Unload(); string playerName = string.Empty; @@ -258,6 +264,10 @@ private void TogglePlayer(Globals.MediaPlayerSelection player) Globals.CurrentPlayer = new Itunes(); playerName = LocalizedMessages.Itunes; break; + case Globals.MediaPlayerSelection.YouTubeMusic: + Globals.CurrentPlayer = new YouTubeMusic(); + playerName = LocalizedMessages.YouTubeMusic; + break; default: break; } diff --git a/Snip/Snip.csproj b/Snip/Snip.csproj index 1e1e2a2..82dcf1c 100644 --- a/Snip/Snip.csproj +++ b/Snip/Snip.csproj @@ -150,6 +150,7 @@ + True True