Skip to content

Commit 3a1cc3c

Browse files
committed
Check size constraints about the album cover image
1 parent 8f1d4c8 commit 3a1cc3c

File tree

9 files changed

+182
-25
lines changed

9 files changed

+182
-25
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ collection clean and well-organized. Available checks include:
9595
- Album level:
9696
- Album directory: Checks that all files from an album are stored in the
9797
same directory
98-
- Cover file: Checks the presence of a cover image in the album directory
98+
- Cover file: Checks the presence of a cover image in the album directory.
99+
Also verifies that the cover image size is within specified limits.
99100
- Album tags: Checks that the tags from all files in an album are the same
100101
- Artist level:
101102
- Genre: Ensures that all tracks from an artist share the same genre

app/Options.hs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import Check.Artist qualified as Artist
1515
import Check.Track qualified as Track
1616
import Config qualified
1717
import Data.List.Extra qualified as List
18+
import Data.Text qualified as Text
19+
import Model.Cover qualified as Cover
1820
import Model.Pattern qualified as Pattern
1921
import Model.SetTagsOptions qualified as SetTagsOptions
2022
import Model.Tag qualified as Tag
@@ -137,17 +139,43 @@ trackFilenameP =
137139
"Check that filenames match the specified pattern"
138140
)
139141

140-
albumHaveCoverP :: Options.Parser (NonEmpty (Path.Path Path.Rel Path.File))
142+
albumHaveCoverP :: Options.Parser Cover.Cover
141143
albumHaveCoverP =
144+
Cover.Cover
145+
<$> coverPathsP
146+
<*> Options.optional (coverSizeP "album-cover-min-size")
147+
<*> Options.optional (coverSizeP "album-cover-max-size")
148+
149+
coverPathsP :: Options.Parser (NonEmpty (Path.Path Path.Rel Path.File))
150+
coverPathsP =
142151
Options.some1
143152
( Options.option
144153
(Options.maybeReader Path.parseRelFile)
145-
( Options.long "album-have-cover"
154+
( Options.long "album-cover-filename"
146155
<> Options.metavar "FILENAME"
147156
<> Options.help "Check that the specified cover file exists"
148157
)
149158
)
150159

160+
coverSizeP :: String -> Options.Parser Cover.Size
161+
coverSizeP option =
162+
Options.option
163+
(Options.eitherReader $ first toString . parse . toText)
164+
( Options.long option
165+
<> Options.metavar "WIDTHxHEIGHT"
166+
<> Options.help
167+
("Specify the " <> option <> " in the form WIDTHxHEIGHT")
168+
)
169+
where
170+
parse :: Text -> Either Text Cover.Size
171+
parse text =
172+
case Text.splitOn "x" text of
173+
[widthTxt, heightTxt] ->
174+
case (readMaybe (toString widthTxt), readMaybe (toString heightTxt)) of
175+
(Just siWidth, Just siHeight) -> Right $ Cover.Size {..}
176+
_ -> Left "Width and height must be integers"
177+
_ -> Left "Size must be in the form WIDTHxHEIGHT"
178+
151179
albumSameDirP :: Options.Parser Bool
152180
albumSameDirP =
153181
Options.switch

data/cover.png

135 Bytes
Loading

data/htagcli.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ enable = true
8585
[checks.album_cover]
8686
enable = true
8787
filenames = ["cover.jpg", "cover.png", "cover.gif"]
88+
# optional size constraints in pixels, not taken into account if left
89+
# unspecified
90+
min_size = { width = 300, height = 300 }
91+
max_size = { width = 1000, height = 1000 }
8892

8993
# Verify that all tracks from the same album reside in a single directory
9094
# Note: htagcli groups files by album while scanning input paths recursively.

htagcli.cabal

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ library
4949
Model.Album
5050
Model.Artist
5151
Model.AudioTrack
52+
Model.Cover
5253
Model.Pattern
5354
Model.SetTagsOptions
5455
Model.Tag
@@ -57,6 +58,7 @@ library
5758
Toml.Extra
5859

5960
build-depends:
61+
JuicyPixels,
6062
extra,
6163
file-embed,
6264
filepath,
@@ -66,6 +68,7 @@ library
6668
path,
6769
path-io,
6870
tomland,
71+
transformers,
6972
unliftio,
7073
validation-selective,
7174

lib/Check/Album.hs

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
module Check.Album
22
( Check (..),
3+
Cover (..),
4+
Size (..),
35
check,
46
Error (..),
57
errorToText,
68
)
79
where
810

11+
import Codec.Picture qualified as Picture
12+
import Control.Monad.Extra qualified as Monad
13+
import Control.Monad.Trans.Except qualified as Except
914
import Data.Text qualified as Text
1015
import Model.Album qualified as Album
1116
import Model.AudioTrack qualified as AudioTrack
17+
import Model.Cover as Cover
1218
import Model.Tag qualified as Tag
1319
import Path ((</>))
1420
import Path qualified
1521
import Path.IO qualified as Path
1622
import Sound.HTagLib qualified as HTagLib
1723

1824
data Check
19-
= HaveCover (NonEmpty (Path.Path Path.Rel Path.File))
25+
= HaveCover Cover.Cover
2026
| InSameDir
2127
| SameTags (NonEmpty Tag.Tag)
2228
| TracksSequential
@@ -25,6 +31,8 @@ data Check
2531
data Error
2632
= NotInSameDir
2733
| MissingCover (Path.Path Path.Abs Path.Dir)
34+
| BadCoverSize (Path.Path Path.Abs Path.File) Size
35+
| UnableToReadCover (Path.Path Path.Abs Path.File) Text
2836
| SameTagsError (NonEmpty Tag.Tag)
2937
| TracksNotSequential
3038
deriving (Eq, Show)
@@ -34,6 +42,16 @@ errorToText NotInSameDir =
3442
"Audio tracks are not all in the same directory"
3543
errorToText (MissingCover directory) =
3644
"Missing cover in directory: " <> Text.pack (Path.toFilePath directory)
45+
errorToText (BadCoverSize file size) =
46+
"Cover file "
47+
<> toText (Path.toFilePath file)
48+
<> " has size out of range: "
49+
<> Cover.sizeToText size
50+
errorToText (UnableToReadCover file err) =
51+
"Unable to read cover file "
52+
<> toText (Path.toFilePath file)
53+
<> ": "
54+
<> err
3755
errorToText (SameTagsError tags) =
3856
"These tags are not the same for all tracks in the album: "
3957
<> Text.intercalate ", " (Tag.asText <$> toList tags)
@@ -47,14 +65,26 @@ check ::
4765
check InSameDir album
4866
| isJust $ Album.directory album = pure $ Right ()
4967
| otherwise = pure $ Left NotInSameDir
50-
check (HaveCover coverFilenames) album
51-
| Just dir <- Album.directory album = do
52-
let absFiles = (dir </>) <$> coverFilenames
53-
ifM
54-
(anyM Path.doesFileExist absFiles)
55-
(pure $ Right ())
56-
(pure $ Left (MissingCover dir))
68+
check (HaveCover cover@Cover {..}) album
69+
| Just dir <- Album.directory album = runExceptT $ do
70+
let absFiles = (dir </>) <$> coPaths
71+
72+
coverFile <-
73+
maybeToExceptT (MissingCover dir) $
74+
MaybeT $
75+
Monad.findM Path.doesFileExist (toList absFiles)
76+
picture <-
77+
Except.withExceptT (UnableToReadCover coverFile . toText) $
78+
ExceptT $
79+
readImage coverFile
80+
81+
let size = Cover.pictureSize picture
82+
unless (Cover.withinRange cover size) $
83+
Except.throwE $
84+
BadCoverSize coverFile size
5785
| otherwise = pure $ Left NotInSameDir
86+
where
87+
readImage = liftIO . Picture.readImage . Path.toFilePath
5888
check (SameTags tagsToCheck) album = pure $ case checkedTags of
5989
[] -> Right ()
6090
(tag : tags) -> Left (SameTagsError (tag :| tags))

lib/Config.hs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import Data.ByteString qualified as ByteString
2323
import Data.FileEmbed qualified as FileEmbed
2424
import Data.Text qualified as Text
2525
import GHC.IO.Exception qualified as Exception
26+
import Model.Cover qualified as Cover
2627
import Model.Pattern qualified as Pattern
2728
import Model.Tag qualified as Tag
2829
import Path ((</>))
@@ -71,8 +72,8 @@ data Checks = Checks
7172
-- one given in the formatting section. This way it is possible to ignore
7273
-- the padding when checking the filename and still have it when fixing it.
7374
chTrackFilename :: Bool,
74-
-- | The album have a cover file with one of the given names
75-
chAlbumHaveCover :: Maybe (NonEmpty (Path.Path Path.Rel Path.File)),
75+
-- | The album have a cover file
76+
chAlbumHaveCover :: Maybe Cover.Cover,
7677
-- | All the audio tracks of the album are in the same directory
7778
chAlbumSameDir :: Bool,
7879
-- | All the audio tracks of the album have the same value for the given
@@ -228,20 +229,31 @@ checksC =
228229
<$> maybeValidatedC "track_tags" tagsC chTrackTags
229230
<*> maybeValidatedC "track_genre" amongC chTrackGenreAmong
230231
<*> trackFilenameC .= chTrackFilename
231-
<*> maybeValidatedC "album_cover" filenamesC chAlbumHaveCover
232+
<*> maybeValidatedC "album_cover" albumHaveCoverC chAlbumHaveCover
232233
<*> albumSameDirC .= chAlbumSameDir
233234
<*> maybeValidatedC "album_tags" tagsC chAlbumSameTags
234235
<*> albumTracksSequentialC .= chAlbumTracksSequential
235236
<*> artistSameGenreC .= chArtistSameGenre
236237
where
237238
trackFilenameC = Toml.table (Toml.bool "enable") "track_filename"
238-
filenamesC = Toml.arrayNonEmptyOf relFileB "filenames"
239239
albumSameDirC = Toml.table (Toml.bool "enable") "album_same_dir"
240240
tagsC = Toml.arrayNonEmptyOf tagB "tags"
241241
albumTracksSequentialC =
242242
Toml.table (Toml.bool "enable") "album_tracks_sequential"
243243
artistSameGenreC = Toml.table (Toml.bool "enable") "artist_same_genre"
244244
amongC = Toml.arrayNonEmptyOf Toml._Text "among"
245+
albumHaveCoverC =
246+
Cover.Cover
247+
<$> filenamesC .= Cover.coPaths
248+
<*> Toml.dioptional (sizeC "min_size") .= Cover.coMinSize
249+
<*> Toml.dioptional (sizeC "max_size") .= Cover.coMaxSize
250+
filenamesC = Toml.arrayNonEmptyOf relFileB "filenames"
251+
sizeC =
252+
Toml.table
253+
( Cover.Size
254+
<$> Toml.int "width" .= Cover.siWidth
255+
<*> Toml.int "height" .= Cover.siHeight
256+
)
245257

246258
-- | Unwrap the Maybe value according to the enable flag.
247259
maybeValidatedC ::

lib/Model/Cover.hs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
module Model.Cover
2+
( Cover (..),
3+
Size (..),
4+
sizeToText,
5+
pictureSize,
6+
withinRange,
7+
)
8+
where
9+
10+
import Codec.Picture qualified as Picture
11+
import Path qualified
12+
13+
data Cover = Cover
14+
{ coPaths :: NonEmpty (Path.Path Path.Rel Path.File),
15+
coMinSize :: Maybe Size,
16+
coMaxSize :: Maybe Size
17+
}
18+
deriving (Eq, Show)
19+
20+
data Size = Size {siWidth :: Int, siHeight :: Int}
21+
deriving (Eq, Show)
22+
23+
sizeToText :: Size -> Text
24+
sizeToText (Size width height) = show width <> "x" <> show height
25+
26+
greaterThan :: Size -> Size -> Bool
27+
greaterThan (Size width1 height1) (Size width2 height2) =
28+
width1 >= width2 && height1 >= height2
29+
30+
lowerThan :: Size -> Size -> Bool
31+
lowerThan (Size width1 height1) (Size width2 height2) =
32+
width1 <= width2 && height1 <= height2
33+
34+
pictureSize :: Picture.DynamicImage -> Size
35+
pictureSize picture =
36+
Size
37+
{ siWidth = Picture.dynamicMap Picture.imageWidth picture,
38+
siHeight = Picture.dynamicMap Picture.imageHeight picture
39+
}
40+
41+
withinRange :: Cover -> Size -> Bool
42+
withinRange Cover {..} size =
43+
maybe True (size `greaterThan`) coMinSize
44+
&& maybe True (size `lowerThan`) coMaxSize

tests/Tests/Check/Album.hs

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import Model.AudioTrack qualified as AudioTrack
1616
import Model.Tag qualified as Tag
1717
import Path (reldir, relfile, (</>))
1818
import Path qualified
19+
import Path.IO qualified as Path
1920
import Sound.HTagLib qualified as HTagLib
20-
import System.IO qualified as System
21-
import Test.Hspec.Expectations (shouldBe)
21+
import Test.Hspec.Expectations (shouldBe, shouldSatisfy)
2222
import Test.Tasty qualified as Tasty
2323
import Test.Tasty.HUnit qualified as Tasty
2424
import Tests.Common qualified as Common
@@ -27,22 +27,57 @@ test :: TestTree
2727
test =
2828
Tasty.testGroup
2929
"check cover"
30-
[ Tasty.testCase "check an album without a cover.jpg" $
30+
[ Tasty.testCase "check an album without a cover.png" $
3131
Common.withTenTracksFiles $
3232
\dir album -> do
33-
result <- Album.check (Album.HaveCover covers) album
33+
result <- Album.check (Album.HaveCover coverNoSize) album
3434
result `shouldBe` Left (Album.MissingCover dir),
35-
Tasty.testCase "check an album with a cover.jpg" $
35+
Tasty.testCase "check an album with a cover.png" $
3636
Common.withTenTracksFiles $
3737
\dir album -> do
38-
System.writeFile
39-
(Path.toFilePath $ dir </> head covers)
40-
"dummy content"
38+
let coverFile = [relfile|./data/cover.png|]
39+
Path.copyFile coverFile $ dir </> Path.filename coverFile
4140

42-
result <- Album.check (Album.HaveCover covers) album
43-
result `shouldBe` Right ()
41+
result <- Album.check (Album.HaveCover coverNoSize) album
42+
result `shouldBe` Right (),
43+
Tasty.testCase "check an album with a cover.png but too small" $
44+
Common.withTenTracksFiles $
45+
\dir album -> do
46+
let coverFile = [relfile|./data/cover.png|]
47+
Path.copyFile coverFile $ dir </> Path.filename coverFile
48+
49+
result <- Album.check (Album.HaveCover coverTooSmall) album
50+
result `shouldSatisfy` isBadCoverSize,
51+
Tasty.testCase "check an album with a cover.png but too big" $
52+
Common.withTenTracksFiles $
53+
\dir album -> do
54+
let coverFile = [relfile|./data/cover.png|]
55+
Path.copyFile coverFile $ dir </> Path.filename coverFile
56+
57+
result <- Album.check (Album.HaveCover coverTooBig) album
58+
result `shouldSatisfy` isBadCoverSize
4459
]
4560
where
61+
isBadCoverSize (Left (Album.BadCoverSize _ _)) = True
62+
isBadCoverSize _ = False
63+
coverNoSize =
64+
Album.Cover
65+
{ Album.coPaths = covers,
66+
Album.coMinSize = Nothing,
67+
Album.coMaxSize = Nothing
68+
}
69+
coverTooSmall =
70+
Album.Cover
71+
{ Album.coPaths = covers,
72+
Album.coMinSize = Just (Album.Size 200 200),
73+
Album.coMaxSize = Nothing
74+
}
75+
coverTooBig =
76+
Album.Cover
77+
{ Album.coPaths = covers,
78+
Album.coMinSize = Nothing,
79+
Album.coMaxSize = Just (Album.Size 50 50)
80+
}
4681
covers = fromList [[relfile|cover.jpg|], [relfile|cover.png|]]
4782

4883
test :: TestTree

0 commit comments

Comments
 (0)