Created
September 4, 2025 19:20
-
-
Save joshooaj/5be5bd624d70167a1e10696ccbb01726 to your computer and use it in GitHub Desktop.
A mix of PowerShell functions for working with my music library. Work in progress...
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| $script:itemFields = @' | |
| acoustid_fingerprint | |
| acoustid_id | |
| added | |
| album | |
| album_id | |
| albumartist | |
| albumartist_credit | |
| albumartist_sort | |
| albumdisambig | |
| albumstatus | |
| albumtype | |
| albumtypes | |
| arranger | |
| artist | |
| artist_credit | |
| artist_sort | |
| asin | |
| bitdepth | |
| bitrate | |
| bpm | |
| catalognum | |
| channels | |
| comments | |
| comp | |
| composer | |
| composer_sort | |
| country | |
| day | |
| disc | |
| discogs_albumid | |
| discogs_artistid | |
| discogs_labelid | |
| disctitle | |
| disctotal | |
| encoder | |
| filesize | |
| format | |
| genre | |
| grouping | |
| id | |
| initial_key | |
| isrc | |
| label | |
| language | |
| length | |
| lyricist | |
| lyrics | |
| mb_albumartistid | |
| mb_albumid | |
| mb_artistid | |
| mb_releasegroupid | |
| mb_releasetrackid | |
| mb_trackid | |
| mb_workid | |
| media | |
| month | |
| mtime | |
| original_day | |
| original_month | |
| original_year | |
| path | |
| r128_album_gain | |
| r128_track_gain | |
| releasegroupdisambig | |
| rg_album_gain | |
| rg_album_peak | |
| rg_track_gain | |
| rg_track_peak | |
| samplerate | |
| script | |
| singleton | |
| style | |
| title | |
| track | |
| trackdisambig | |
| tracktotal | |
| work | |
| work_disambig | |
| year | |
| '@ -split "`n" | |
| $script:albumFields = @' | |
| added | |
| album | |
| albumartist | |
| albumartist_credit | |
| albumartist_sort | |
| albumdisambig | |
| albumstatus | |
| albumtotal | |
| albumtype | |
| albumtypes | |
| artpath | |
| asin | |
| catalognum | |
| comp | |
| country | |
| day | |
| discogs_albumid | |
| discogs_artistid | |
| discogs_labelid | |
| disctotal | |
| genre | |
| id | |
| label | |
| language | |
| mb_albumartistid | |
| mb_albumid | |
| mb_releasegroupid | |
| month | |
| original_day | |
| original_month | |
| original_year | |
| path | |
| r128_album_gain | |
| releasegroupdisambig | |
| rg_album_gain | |
| rg_album_peak | |
| script | |
| style | |
| year | |
| '@ -split "`n" | |
| function Get-BeetsTrackInfo { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter()] | |
| [string[]] | |
| $Field = @('albumartist', 'album', 'title', 'length', 'genre', 'path'), | |
| [Parameter()] | |
| [string] | |
| $Query, | |
| [Parameter(DontShow)] | |
| [string] | |
| $FieldSeparator = ';;' | |
| ) | |
| process { | |
| # Example: Field = albumartist, album, title, length, genre, path | |
| # Format: $albumartist;;$album;;$title;;$length;;$genre;;$path | |
| $format = ($Field | ForEach-Object { "`$$($_.ToLower())" } ) -join ';;' | |
| beet ls '-f' $format $Query | ForEach-Object { | |
| $parts = $_ -split $FieldSeparator | |
| if ($parts.Count -ne $Field.Count) { | |
| throw "The number of fields parsed ($($parts.Count)) doesn't match the supplied number of fields ($($Field.Count)) when splitting fields with '$FieldSeparator'. Try providing an alternative FieldSeparator value." | |
| } | |
| $trackInfo = [ordered]@{} | |
| for ($i = 0; $i -lt $Field.Count; $i++) { | |
| if ($Field[$i] -eq 'length') { | |
| $trackInfo[$Field[$i]] = New-TimeSpan -Seconds ([math]::Round($parts[$i])) | |
| } else { | |
| $trackInfo[$Field[$i]] = $parts[$i] | |
| } | |
| } | |
| [pscustomobject]$trackInfo | |
| } | |
| } | |
| } | |
| function Get-BeetsLyrics { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [Alias('AlbumArtist')] | |
| [string] | |
| $Artist, | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [string] | |
| $Album, | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [string] | |
| $Title, | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [timespan] | |
| $Length, | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [string] | |
| $Path, | |
| [Parameter()] | |
| [switch] | |
| $Save, | |
| [Parameter()] | |
| [switch] | |
| $Embed | |
| ) | |
| process { | |
| $uriBuilder = [uribuilder]'https://lrclib.net/api/get' | |
| $query = [System.Web.HttpUtility]::ParseQueryString('') | |
| $query.Add('artist_name', $Artist) | |
| $query.Add('album_name', $Album) | |
| $query.Add('track_name', $Title) | |
| $query.Add('duration', [int]$Length.TotalSeconds) | |
| $uriBuilder.Query = $query.ToString() | |
| $response = Invoke-RestMethod -Uri $uriBuilder.Uri -UserAgent $script:USERAGENT -ErrorAction Stop | |
| if ($Save) { | |
| $saved = $false | |
| if ([string]::IsNullOrWhiteSpace($Path) -or !(Test-Path -LiteralPath $Path)) { | |
| Write-Error "Unable to save lyrics - the provided path does not exist: $Path" | |
| } else { | |
| $item = Get-Item -LiteralPath $Path | |
| $fileName = Join-Path $item.DirectoryName "$($item.BaseName).lrc" | |
| if (Test-Path -LiteralPath $fileName) { | |
| Write-Error "Lyrics already available for $Artist/$Album/$Title" | |
| } else { | |
| if (![string]::IsNullOrWhiteSpace($response.syncedLyrics)) { | |
| $response.syncedLyrics | Set-Content -LiteralPath $fileName | |
| $saved = $true | |
| } elseif (![string]::IsNullOrWhiteSpace($response.plainLyrics)) { | |
| $response.plainLyrics | Set-Content -LiteralPath $fileName | |
| $saved = $ture | |
| } else { | |
| Write-Error "Lyrics unavailable for the provided track." | |
| } | |
| } | |
| if ($saved -and $Embed) { | |
| metaflac --remove-tag=LYRICS $Path | |
| metaflac --set-tag-from-file="LYRICS=$fileName" $Path | |
| } | |
| } | |
| } | |
| $response | |
| } | |
| } | |
| function Get-LrcLyrics { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [string] | |
| $Artist, | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [string] | |
| $Album, | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [string] | |
| $Title, | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [timespan] | |
| $Length, | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [string] | |
| $Path, | |
| [Parameter()] | |
| [switch] | |
| $Save, | |
| [Parameter()] | |
| [switch] | |
| $Embed | |
| ) | |
| process { | |
| #?artist_name=Borislav+Slavov&track_name=I+Want+to+Live&album_name=Baldur%27s+Gate+3+(Original+Game+Soundtrack)&duration=233 | |
| $uriBuilder = [uribuilder]'https://lrclib.net/api/get' | |
| $query = [System.Web.HttpUtility]::ParseQueryString('') | |
| $query.Add('artist_name', $Artist) | |
| $query.Add('album_name', $Album) | |
| $query.Add('track_name', $Title) | |
| $query.Add('duration', [int]$Length.TotalSeconds) | |
| $uriBuilder.Query = $query.ToString() | |
| $response = Invoke-RestMethod -Uri $uriBuilder.Uri -ErrorAction Stop | |
| if ($Save) { | |
| $saved = $false | |
| if ([string]::IsNullOrWhiteSpace($Path) -or !(Test-Path -LiteralPath $Path)) { | |
| Write-Error "Unable to save lyrics - the provided path does not exist: $Path" | |
| } else { | |
| $item = Get-Item -LiteralPath $Path | |
| $fileName = Join-Path $item.DirectoryName "$($item.BaseName).lrc" | |
| if (Test-Path -LiteralPath $fileName) { | |
| Write-Error "Lyrics already available for $Artist/$Album/$Title" | |
| } else { | |
| if (![string]::IsNullOrWhiteSpace($response.syncedLyrics)) { | |
| $response.syncedLyrics | Set-Content -LiteralPath $fileName | |
| $saved = $true | |
| } elseif (![string]::IsNullOrWhiteSpace($response.plainLyrics)) { | |
| $response.plainLyrics | Set-Content -LiteralPath $fileName | |
| $saved = $ture | |
| } else { | |
| Write-Error "Lyrics unavailable for the provided track." | |
| } | |
| } | |
| if ($saved -and $Embed) { | |
| metaflac --remove-tag=LYRICS $Path | |
| metaflac --set-tag-from-file="LYRICS=$fileName" $Path | |
| } | |
| } | |
| } | |
| $response | |
| } | |
| } | |
| function EmbedLyrics { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(Mandatory)] | |
| [string] | |
| $Path | |
| ) | |
| process { | |
| $ErrorActionPreference = 'Stop' | |
| foreach ($flacFile in Get-ChildItem -Path $Path -Recurse -File -Filter '*.flac') { | |
| $lrcFile = Join-Path $flacFile.DirectoryName "$($flacFile.BaseName).lrc" | |
| if (!(Test-Path -LiteralPath $lrcFile)) { | |
| Write-Verbose "No .lrc file found for $flacFile" | |
| continue | |
| } | |
| Write-Verbose "Embedding lyrics: $flacFile" | |
| metaflac --remove-tag=LYRICS $flacFile.FullName | |
| metaflac --set-tag-from-file="LYRICS=$lrcFile" $flacFile.FullName | |
| } | |
| } | |
| } | |
| function Get-MBRating { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(Mandatory, ValueFromPipelineByPropertyName)] | |
| [string] | |
| $Path, | |
| [Parameter()] | |
| [switch] | |
| $Force | |
| ) | |
| begin { | |
| $lastResponseTime = [datetime]::MinValue | |
| } | |
| process { | |
| $tags = Get-FlacTags -Path $Path | |
| if (!$Force -and $tags.'MUSICBRAINZ_RATING') { | |
| Write-Verbose "Rating already available for $Path" | |
| return | |
| } | |
| $trackId = ($tags).MUSICBRAINZ_TRACKID | |
| if ([string]::IsNullOrWhiteSpace($trackId)) { | |
| Write-Warning "No MUSICBRAINZ_TRACKID value found for $Path" | |
| return | |
| } | |
| if ($lastResponseTime -gt (Get-Date).AddMilliseconds(-200)) { | |
| Start-Sleep -Milliseconds 200 | |
| } | |
| $response = Invoke-RestMethod "https://musicbrainz.org/ws/2/recording/$($trackId)?fmt=json&inc=ratings" -UserAgent $script:USERAGENT -Verbose:$false | |
| $lastResponseTime = Get-Date | |
| $rating = $response.rating | |
| if ($rating.'votes-count' -lt 2) { | |
| Write-Verbose "Ignoring rating with low vote count." | |
| return | |
| } | |
| Set-FlacTag -Path $Path -Name MUSICBRAINZ_RATING -Value $rating.Value | |
| [pscustomobject]@{ | |
| Artist = $tags.'Album_Artist' | |
| Album = $tags.Album | |
| Title = $tags.Title | |
| Rating = $rating.Value | |
| } | |
| } | |
| end { | |
| } | |
| } | |
| function Set-FlacTag { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory, ValueFromPipelineByPropertyName)] | |
| [string] | |
| $Path, | |
| [Parameter(Mandatory)] | |
| [string] | |
| $Name, | |
| [Parameter(Mandatory)] | |
| [string] | |
| $Value | |
| ) | |
| process { | |
| metaflac "--remove-tag=$Name" $Path | |
| metaflac "--set-tag=$Name=$Value" $Path | |
| } | |
| } | |
| function Get-FlacTags { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)] | |
| [string] | |
| $Path, | |
| [Parameter()] | |
| [switch] | |
| $IncludeEmptyTags | |
| ) | |
| process { | |
| foreach ($filePath in (Resolve-Path -Path $Path).Path) { | |
| $tags = [ordered]@{} | |
| metaflac --show-all-tags $filePath | ForEach-Object { | |
| $key, $value = $_ -split '=', 2 | |
| if ([string]::isnullorempty($value) -and !$IncludeEmptyTags) { return } | |
| if ($tags.Contains($key)) { | |
| $tags[$key] = $tags[$key] + " " + $value | |
| } else { | |
| $tags[$key] = $value | |
| } | |
| } | |
| $tags | |
| } | |
| } | |
| } | |
| Register-ArgumentCompleter -CommandName Get-BeetsTrackInfo -ParameterName Field -ScriptBlock { | |
| param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) | |
| # Trim single, or double quotes from the start/end of the word to complete. | |
| if ($wordToComplete -match '^[''"]') { | |
| $wordToComplete = $wordToComplete.Trim($Matches.Values[0]) | |
| } | |
| # Get all unique process names starting with the characters provided, if any. | |
| $script:itemFields | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { | |
| # Wrap the completion in single quotes if it contains any whitespace. | |
| if ($_ -match '\s') { | |
| "'{0}'" -f $_ | |
| } else { | |
| $_ | |
| } | |
| } | |
| } | |
| $script:USERAGENT = "PowerShell/$($PSVersionTable.PSVersion) ( joshooaj )" | |
| Export-ModuleMember -Function Get-BeetsTrackInfo, Get-BeetsLyrics, Get-FlacTags, Get-MBRating |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment