Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 103 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,99 @@ accordingly.

Delete the whole file content before quitting to cancel the operation.

## Setting tags with MusicBrainz

`htagcli` can fetch tags from [MusicBrainz][musicbrainz] to automatically set
the tags of your files. To do this, one can search for an album/artist using
the `search` command:

```
$ htagcli search --album repeater --artist fugazi
Searching: "fugazi" - "repeater"

3 releases found

1. ID: 37e6a462-1417-45dc-9d88-4ef9aff4bc19
Artist: Fugazi
Album: Repeater
Year: 2005
Discs: 1
Tracks: 14

Disc 1: Tracks: 14

1. Turnover
2. Repeater
3. Brendan #1
...
```

If the search is run against an existing album, `htagcli` will show similarity
values to help you choose the best match. In the following case, the first
result shows a low similarity because the number of tracks is different from
the actual album. The second result is a perfect match.

```
$ htagcli search ./repeater/
Searching: "Fugazi" - "Repeater"

3 releases found

1. ID: 37e6a462-1417-45dc-9d88-4ef9aff4bc19 (87%)
Artist: Fugazi (100%)
Album: Repeater (100%)
Year: 2005 (1990)
Discs: 1
Tracks: 14 (11)

Disc 1: (79%) - Tracks: 14 (11)

1. Turnover (100%)
2. Repeater (100%)
3. Brendan #1 (100%)
4. Merchandise (100%)
5. Blueprint (100%)
6. Sieve-Fisted Find (100%)
7. Greed (100%)
8. Two Beats Off (100%)
9. Styrofoam (100%)
10. Reprovisional (100%)
11. Shut the Door (100%)
12. Song #1
13. Joe #1
14. Break-In

2. ID: 00baa173-29db-33a9-af6d-fe109e53a211 (100%)
Artist: Fugazi (100%)
Album: Repeater (100%)
Year: 1990
Discs: 1
Tracks: 11

Disc 1: (100%) - Tracks: 11

1. Turnover (100%)
2. Repeater (100%)
3. Brendan #1 (100%)
4. Merchandise (100%)
5. Blueprint (100%)
6. Sieve-Fisted Find (100%)
7. Greed (100%)
8. Two Beats Off (100%)
9. Styrofoam (100%)
10. Reprovisional (100%)
11. Shut the Door (100%)

...
```

Once you found a matching release, you can set the tags using the ID of the
release:

```
$ htagcli set --id 00baa173-29db-33a9-af6d-fe109e53a211 ./repeater/
```

## Configuration

The next commands require a configuration file. You can generate [a default
Expand All @@ -90,11 +183,17 @@ collection clean and well-organized. Available checks include:
- Missing tags: Detects files with missing tag fields
- Genre: Verifies that the genre exists in a predefined list
- File path: Ensures that the file path follows a given pattern
- Album level:
- Album directory: Checks that all files from an album are stored in the
- Disc level:
- Disc directory: Checks that all files from a disc are stored in the
same directory
- Cover file: Checks the presence of a cover image in the album directory.
- Cover file: Checks the presence of a cover image in the disc directory.
Also verifies that the cover image size is within specified limits.
- Disc tags: Checks that the tags from all files in a disc are the same
- Track numbers: Checks that the track numbers are sequential and start
from 1
- Album level:
- Disc numbers: Checks that the disc numbers are sequential and start from
1
- Album tags: Checks that the tags from all files in an album are the same
- Artist level:
- Genre: Ensures that all tracks from an artist share the same genre
Expand Down Expand Up @@ -158,6 +257,7 @@ This project uses [htaglib] as the underlying library to manipulate audio file.

[demo]: ./demo.png
[htaglib]: https://github.com/mrkkrp/htaglib
[musicbrainz]: https://musicbrainz.org/
[nix]: https://nixos.org/
[releases]: https://github.com/jecaro/htagcli/releases
[status-nix-png]: https://github.com/jecaro/htagcli/workflows/nix/badge.svg
Expand Down
40 changes: 37 additions & 3 deletions app/ConduitUtils.hs
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
module ConduitUtils (runConduitWithProgress, albumC, artistC) where
module ConduitUtils
( runConduitWithProgress,
filesC,
discC,
albumC,
artistC,
oneC,
)
where

import Conduit ((.|))
import Conduit qualified
import Data.Text qualified as Text
import Model.Album qualified as Album
import Model.Artist qualified as Artist
import Model.AudioTrack qualified as AudioTrack
import Model.Disc qualified as Disc
import Options qualified
import Path qualified
import Path.IO qualified as Path
import Progress qualified
import System.FilePath qualified as FilePath
import UnliftIO.Exception qualified as Exception

runConduitWithProgress ::
Options.Files ->
Expand Down Expand Up @@ -41,10 +51,34 @@ filesC Options.Files {..} = do
)
.| Conduit.mapMC Path.parseAbsFile

discC ::
(Monad m) =>
Conduit.ConduitT AudioTrack.AudioTrack Disc.Disc m ()
discC = clusterC Disc.mkDisc Disc.addTrack

albumC ::
(Monad m) =>
Conduit.ConduitT AudioTrack.AudioTrack Album.Album m ()
albumC = clusterC Album.mkAlbum Album.addTrack
Conduit.ConduitT Disc.Disc Album.Album m ()
albumC = clusterC Album.mkAlbum Album.addDisc

oneC ::
( Conduit.MonadThrow m,
Conduit.MonadIO m,
Exception nothing,
Exception moreThanOne
) =>
nothing ->
moreThanOne ->
Conduit.ConduitT album Void m album
oneC nothing moreThanOne = do
mbFirst <- Conduit.await
case mbFirst of
Nothing -> Exception.throwIO nothing
Just album -> do
mbSecond <- Conduit.await
case mbSecond of
Nothing -> pure album
Just _ -> Exception.throwIO moreThanOne

artistC ::
(Monad m) =>
Expand Down
81 changes: 64 additions & 17 deletions app/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import Data.Conduit.Combinators qualified as Conduit
import Data.Either.Extra qualified as Either
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import Model.Album qualified as Album
import Model.AudioTrack qualified as AudioTrack
import Model.Cover qualified as Cover
import MusicBrainz qualified
import Options qualified
import Options.Applicative qualified as Options
import Path.IO qualified as Path
Expand All @@ -28,6 +30,8 @@ data Error
| MoveCoverWithoutCheck
| EditorExitError
| ParseError (Megaparsec.ParseErrorBundle Text.Text Void)
| NoAudioFiles
| NotSameAlbum
deriving (Show)

instance Exception.Exception Error
Expand All @@ -39,7 +43,9 @@ errorToText (ParseError parseError) =
"Failed to parse the edited tags:\n"
<> Text.pack (Megaparsec.errorBundlePretty parseError)
errorToText MoveCoverWithoutCheck =
"move_cover is enabled but checks.album_cover is disabled."
"move_cover is enabled but checks.disc_cover is disabled."
errorToText NoAudioFiles = "No audio files found"
errorToText NotSameAlbum = "Input files are not from the same album"

main :: IO ()
main = do
Expand All @@ -51,10 +57,15 @@ main = do
Options.GetTags files ->
ConduitUtils.runConduitWithProgress files $
Conduit.mapM_C Commands.getTags
Options.SetTags setTagsOptions files ->
ConduitUtils.runConduitWithProgress files $
Conduit.mapM_C $
Commands.setTags setTagsOptions
Options.SetTags fromOptions files ->
case fromOptions of
Options.SetTagsFromArgs options ->
ConduitUtils.runConduitWithProgress files $
Conduit.mapM_C $
Commands.setTags options
Options.SetTagsFromId releaseId -> do
album <- collectAlbum files
MusicBrainz.setTags releaseId album
Options.Edit files -> do
(editedContent, tempFilename) <- Temporary.withSystemTempFile "htagcli-edit-temp" $
\tempFilename tempHandle -> do
Expand Down Expand Up @@ -93,40 +104,52 @@ main = do
config <- Config.readConfig

-- Get the checks from the CLI and fallback to the config file
let (trackChecks, albumChecks, mbArtistCheck) = Options.checks config options
let Config.AllChecks {..} = Options.checks config options

when (null trackChecks && null albumChecks && null mbArtistCheck) $
Exception.throwIO NoCheckInConfig
when
( null alTrack
&& null alDisc
&& null alAlbum
&& null alArtist
)
$ Exception.throwIO NoCheckInConfig

stats <- newIORef Stats.empty
let modifyStats = modifyIORef' stats
addTrackErrors = modifyStats . Stats.addTrackErrors
addDiscErrors = modifyStats . Stats.addDiscErrors
addAlbumErrors = modifyStats . Stats.addAlbumErrors
incArtistErrors = modifyStats Stats.incArtistErrors

ConduitUtils.runConduitWithProgress files $
Conduit.mapM AudioTrack.getTags
.| Conduit.iterM
(addTrackErrors <=< Commands.checkTrack trackChecks)
(addTrackErrors <=< Commands.checkTrack alTrack)
.| ConduitUtils.discC
.| Conduit.iterM
(addDiscErrors <=< Commands.checkDisc alDisc)
.| ConduitUtils.albumC
.| Conduit.iterM
(addAlbumErrors <=< Commands.checkAlbum albumChecks)
(addAlbumErrors <=< Commands.checkAlbum alAlbum)
.| ConduitUtils.artistC
.| Conduit.mapM_C
(flip when incArtistErrors <=< Commands.checkArtist mbArtistCheck)
(flip when incArtistErrors <=< Commands.checkArtist alArtist)

Stats.CheckErrors {..} <- readIORef stats
unless (null trackChecks) $
unless (null alTrack) $
putTextLn $
"Track errors: " <> show ceTrackErrors
unless (null albumChecks) $
unless (null alDisc) $
putTextLn $
"Disc errors: " <> show ceDiscErrors
unless (null alAlbum) $
putTextLn $
"Album errors: " <> show ceAlbumErrors
when (isJust mbArtistCheck) $
when (isJust alArtist) $
putTextLn $
"Artist errors: " <> show ceArtistErrors

let total = ceTrackErrors + ceAlbumErrors + ceArtistErrors
let total = ceTrackErrors + ceDiscErrors + ceAlbumErrors + ceArtistErrors
when (total > 0) $ System.exitWith $ System.ExitFailure total
Options.FixFilePaths Options.FixFilePathsOptions {..} files -> do
Config.Config
Expand All @@ -136,8 +159,8 @@ main = do
} <-
Config.readConfig
let pattern = fromMaybe fiPattern foPattern
coverImages = guard fiMoveCover *> (Cover.coPaths <$> chAlbumHaveCover)
when (fiMoveCover && isNothing chAlbumHaveCover) $
coverImages = guard fiMoveCover *> (Cover.coPaths <$> chDiscHaveCover)
when (fiMoveCover && isNothing chDiscHaveCover) $
Exception.throwIO MoveCoverWithoutCheck

-- Get the base directory from the cli and fallback to the config file
Expand All @@ -154,11 +177,33 @@ main = do
ConduitUtils.runConduitWithProgress files $
Conduit.mapM_C $
Commands.fixFilePaths fixFilePathOptions
Options.Search options -> do
case options of
Options.SeSearchMany (Options.SearchMany {..}) ->
case smSource of
Options.SearchManyFromFiles files -> do
album <- collectAlbum files
MusicBrainz.searchAlbum smMaxResults album
Options.SearchManyFromArgs albumArtist album ->
MusicBrainz.search smMaxResults albumArtist album Nothing
Options.SeSearchOne (Options.SearchOne {..}) -> do
mbAlbum <- traverse collectAlbum soFiles
MusicBrainz.searchId soId mbAlbum
where
getTagsAsText filename = do
content <- encodeUtf8 . AudioTrack.asText <$> AudioTrack.getTags filename
pure $ content <> "\n"

collectAlbum :: Options.Files -> IO Album.Album
collectAlbum files =
Conduit.runResourceT $
Conduit.runConduit $
ConduitUtils.filesC files
.| Conduit.mapM AudioTrack.getTags
.| ConduitUtils.discC
.| ConduitUtils.albumC
.| ConduitUtils.oneC NoAudioFiles NotSameAlbum

exceptions :: SomeException -> IO ()
exceptions someException
-- Rethrow exit failures to preserve the exit code
Expand All @@ -175,4 +220,6 @@ exceptions someException
Config.errorToText configException <> "\n"
| Just commandsException <- fromException someException =
Commands.errorToText commandsException <> "\n"
| Just musicBrainzException <- fromException someException =
MusicBrainz.errorToText musicBrainzException <> "\n"
| otherwise = "Unknown exception: " <> show someException <> "\n"
Loading