feat(users/Profpatsch/whatcd-resolver): implement artist refresh v0
This is kind of a chonker because I went into so many rabbit holes. Foremost this implements a simple “Refresh Artist” button that fetches current artist torrent groups. BUG: the `artist` endpoint torrent struct is shite, it’s missing most info that we get in the `search` endpoint torrent struct, plus it’s organized differently (e.g. the `artists` thingy is in the torrent_group not the torrent). I should switch everything over to fetching the `torrent_group.id`s first and then going through and slowly fetching every torrent group separately … however that might time out very quickly. ugh. There doesn’t seem to be a way of fetching multiple torrent groups. Random other shit & improvements: * intersperse for builders * fix json errors so that the structs don’t get too big (`restrictJson`) * show error messages as json so jaeger displays it with nested UI * color pretty-printed json outpt on command line * add some important integral functions to MyPrelude * add `sintersperse` and `mintersperse` to MyPrelude Change-Id: If8bfcd68dc5c905e118ad86d50d7416962bf55d4 Reviewed-on: https://cl.tvl.fyi/c/depot/+/12960 Tested-by: BuildkiteCI Reviewed-by: Profpatsch <mail@profpatsch.de>
This commit is contained in:
parent
cabb8cd3d0
commit
3040fe2e90
9 changed files with 584 additions and 163 deletions
|
|
@ -21,6 +21,8 @@ module Builder
|
||||||
naturalDecimalB,
|
naturalDecimalB,
|
||||||
scientificDecimalT,
|
scientificDecimalT,
|
||||||
scientificDecimalB,
|
scientificDecimalB,
|
||||||
|
intersperseT,
|
||||||
|
intersperseB,
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
|
|
||||||
|
|
@ -126,3 +128,17 @@ scientificDecimalT = TextBuilder Scientific.Text.scientificBuilder
|
||||||
|
|
||||||
scientificDecimalB :: BytesBuilder Scientific
|
scientificDecimalB :: BytesBuilder Scientific
|
||||||
scientificDecimalB = BytesBuilder Scientific.Bytes.scientificBuilder
|
scientificDecimalB = BytesBuilder Scientific.Bytes.scientificBuilder
|
||||||
|
|
||||||
|
-- TODO: can these be abstracted over Divisible & Semigroup? Or something?
|
||||||
|
|
||||||
|
intersperseT :: (forall b. TextBuilder b) -> TextBuilder a -> TextBuilder [a]
|
||||||
|
intersperseT sep a = ((),) >$< intersperseT' sep a
|
||||||
|
|
||||||
|
intersperseT' :: TextBuilder b -> TextBuilder a -> TextBuilder (b, [a])
|
||||||
|
intersperseT' (TextBuilder sep) (TextBuilder a) = TextBuilder $ \(b, as) -> mintersperse (sep b) (fmap a as)
|
||||||
|
|
||||||
|
intersperseB :: (forall b. BytesBuilder b) -> BytesBuilder a -> BytesBuilder [a]
|
||||||
|
intersperseB sep a = ((),) >$< intersperseB' sep a
|
||||||
|
|
||||||
|
intersperseB' :: BytesBuilder b -> BytesBuilder a -> BytesBuilder (b, [a])
|
||||||
|
intersperseB' (BytesBuilder sep) (BytesBuilder a) = BytesBuilder $ \(b, as) -> mintersperse (sep b) (fmap a as)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
{-# LANGUAGE KindSignatures #-}
|
{-# LANGUAGE KindSignatures #-}
|
||||||
|
{-# LANGUAGE QuasiQuotes #-}
|
||||||
{-# LANGUAGE UndecidableInstances #-}
|
{-# LANGUAGE UndecidableInstances #-}
|
||||||
|
|
||||||
module Json where
|
module Json where
|
||||||
|
|
||||||
|
import Builder
|
||||||
import Data.Aeson (FromJSON (parseJSON), ToJSON (toEncoding, toJSON), Value (..), withObject)
|
import Data.Aeson (FromJSON (parseJSON), ToJSON (toEncoding, toJSON), Value (..), withObject)
|
||||||
import Data.Aeson qualified as Json
|
import Data.Aeson qualified as Json
|
||||||
import Data.Aeson.BetterErrors qualified as Json
|
import Data.Aeson.BetterErrors qualified as Json
|
||||||
|
|
@ -19,9 +21,10 @@ import Data.Time (UTCTime)
|
||||||
import Data.Vector qualified as Vector
|
import Data.Vector qualified as Vector
|
||||||
import FieldParser (FieldParser)
|
import FieldParser (FieldParser)
|
||||||
import FieldParser qualified as Field
|
import FieldParser qualified as Field
|
||||||
|
import Json.Enc (Enc)
|
||||||
|
import Json.Enc qualified as Enc
|
||||||
import Label
|
import Label
|
||||||
import MyPrelude
|
import MyPrelude
|
||||||
import Pretty
|
|
||||||
|
|
||||||
-- | Use a "Data.Aeson.BetterErrors" parser to implement 'FromJSON'’s 'parseJSON' method.
|
-- | Use a "Data.Aeson.BetterErrors" parser to implement 'FromJSON'’s 'parseJSON' method.
|
||||||
--
|
--
|
||||||
|
|
@ -71,11 +74,12 @@ parseErrorTree contextMsg errs =
|
||||||
& singleError
|
& singleError
|
||||||
& nestedError contextMsg
|
& nestedError contextMsg
|
||||||
|
|
||||||
-- | Convert a 'Json.ParseError' to a corresponding 'ErrorTree'
|
-- | Convert a 'Json.ParseError' to a pair of error message and a shrunken-down
|
||||||
|
-- version of the value at the path where the error occurred.
|
||||||
--
|
--
|
||||||
-- This version shows some of the value at the path where the error occurred.
|
-- This version shows some of the value at the path where the error occurred.
|
||||||
parseErrorTreeValCtx :: Error -> Json.Value -> Json.ParseError ErrorTree -> ErrorTree
|
parseErrorTreeValCtx :: Json.Value -> Json.ParseError ErrorTree -> T2 "errorMessage" Enc "valueAtErrorPath" (Maybe Json.Value)
|
||||||
parseErrorTreeValCtx contextMsg origValue errs = do
|
parseErrorTreeValCtx origValue errs = do
|
||||||
let ctxPath = case errs of
|
let ctxPath = case errs of
|
||||||
Json.BadSchema path _spec -> Just path
|
Json.BadSchema path _spec -> Just path
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
|
|
@ -88,33 +92,57 @@ parseErrorTreeValCtx contextMsg origValue errs = do
|
||||||
Nothing -> v
|
Nothing -> v
|
||||||
Just v' -> go v' path
|
Just v' -> go v' path
|
||||||
|
|
||||||
( ( errs
|
T2
|
||||||
& Json.displayError prettyErrorTree
|
( label @"errorMessage" $
|
||||||
& Text.intercalate "\n"
|
errs
|
||||||
& newError
|
& displayErrorCustom
|
||||||
)
|
|
||||||
:| ( maybe
|
|
||||||
[]
|
|
||||||
( \ctx ->
|
|
||||||
[ go origValue ctx
|
|
||||||
& Pretty.showPrettyJson
|
|
||||||
& newError
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
( label @"valueAtErrorPath" $
|
||||||
ctxPath
|
ctxPath
|
||||||
|
<&> ( go origValue
|
||||||
|
-- make sure we don’t explode the error message by showing too much of the value
|
||||||
|
>>> restrictJson restriction
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
-- We nest this here because the json errors is multiline, so the result looks like
|
where
|
||||||
--
|
restriction =
|
||||||
-- @
|
RestrictJsonOpts
|
||||||
-- contextMsg
|
{ maxDepth = 2,
|
||||||
-- \|
|
maxSizeObject = 10,
|
||||||
-- `- At the path: ["foo"]["bar"]
|
maxSizeArray = 3,
|
||||||
-- Type mismatch:
|
maxStringLength = 100
|
||||||
-- Expected a value of type object
|
}
|
||||||
-- Got: true
|
displayErrorCustom :: Json.ParseError ErrorTree -> Enc
|
||||||
-- @
|
displayErrorCustom = \case
|
||||||
& errorTree contextMsg
|
Json.InvalidJSON str ->
|
||||||
|
["The input could not be parsed as JSON: " <> str & stringToText]
|
||||||
|
& Enc.list Enc.text
|
||||||
|
Json.BadSchema path spec -> do
|
||||||
|
let pieceEnc = \case
|
||||||
|
Json.ObjectKey k -> Enc.text k
|
||||||
|
Json.ArrayIndex i -> Enc.int i
|
||||||
|
case spec of
|
||||||
|
Json.WrongType t val ->
|
||||||
|
Enc.object
|
||||||
|
[ ("@", Enc.list pieceEnc path),
|
||||||
|
( "error",
|
||||||
|
-- not showing the value here, because we are gonna show it anyway in the valueAtErrorPath
|
||||||
|
[fmt|Expected a value of type `{displayJSONType t}` but got one of type `{val & Json.jsonTypeOf & displayJSONType}`|]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
other ->
|
||||||
|
Json.displaySpecifics prettyErrorTree other
|
||||||
|
& Text.intercalate "\n"
|
||||||
|
& Enc.text
|
||||||
|
|
||||||
|
displayJSONType :: Json.JSONType -> Text
|
||||||
|
displayJSONType t = case t of
|
||||||
|
Json.TyObject -> "object"
|
||||||
|
Json.TyArray -> "array"
|
||||||
|
Json.TyString -> "string"
|
||||||
|
Json.TyNumber -> "number"
|
||||||
|
Json.TyBool -> "boolean"
|
||||||
|
Json.TyNull -> "null"
|
||||||
|
|
||||||
-- | Lift the parser error to an error tree
|
-- | Lift the parser error to an error tree
|
||||||
asErrorTree :: (Functor m) => Json.ParseT Error m a -> Json.ParseT ErrorTree m a
|
asErrorTree :: (Functor m) => Json.ParseT Error m a -> Json.ParseT ErrorTree m a
|
||||||
|
|
@ -304,3 +332,75 @@ instance ToJSON EmptyObject where
|
||||||
-- | Create a json array from a list of json values.
|
-- | Create a json array from a list of json values.
|
||||||
mkJsonArray :: [Value] -> Value
|
mkJsonArray :: [Value] -> Value
|
||||||
mkJsonArray xs = xs & Vector.fromList & Array
|
mkJsonArray xs = xs & Vector.fromList & Array
|
||||||
|
|
||||||
|
data RestrictJsonOpts = RestrictJsonOpts
|
||||||
|
{ maxDepth :: Natural,
|
||||||
|
maxSizeObject :: Natural,
|
||||||
|
maxSizeArray :: Natural,
|
||||||
|
maxStringLength :: Natural
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | Restrict a json object so that its depth and size are within the given bounds.
|
||||||
|
--
|
||||||
|
-- Bounds are maximum 'Int' width.
|
||||||
|
restrictJson ::
|
||||||
|
RestrictJsonOpts ->
|
||||||
|
Value ->
|
||||||
|
Value
|
||||||
|
restrictJson opts = do
|
||||||
|
let maxSizeObject = opts.maxSizeObject & naturalToInteger & integerToBoundedClamped
|
||||||
|
let maxSizeArray = opts.maxSizeArray & naturalToInteger & integerToBoundedClamped
|
||||||
|
let maxStringLength = opts.maxStringLength & naturalToInteger & integerToBoundedClamped
|
||||||
|
go (opts.maxDepth, maxSizeObject, maxSizeArray, maxStringLength)
|
||||||
|
where
|
||||||
|
go (0, _, _, strLen) (Json.String s) = truncateString strLen s
|
||||||
|
go (0, _, _, _) (Json.Array arr) = Array $ Vector.singleton [fmt|<{Vector.length arr} elements elided>|]
|
||||||
|
go (0, _, _, _) (Json.Object obj) =
|
||||||
|
obj
|
||||||
|
& buildText (KeyMap.keys >$< ("<object {" <> intersperseT ", " (Key.toText >$< textT) <> "}>"))
|
||||||
|
& String
|
||||||
|
go (depth, sizeObject, sizeArray, strLen) val = case val of
|
||||||
|
Object obj ->
|
||||||
|
obj
|
||||||
|
& ( \m ->
|
||||||
|
if KeyMap.size m > sizeObject
|
||||||
|
then
|
||||||
|
m
|
||||||
|
& KeyMap.toList
|
||||||
|
& take sizeObject
|
||||||
|
& KeyMap.fromList
|
||||||
|
& KeyMap.map (go (depth - 1, sizeObject, sizeArray, strLen))
|
||||||
|
& \smol ->
|
||||||
|
smol
|
||||||
|
& KeyMap.insert
|
||||||
|
"<some fields elided>"
|
||||||
|
(m `KeyMap.difference` smol & KeyMap.keys & toJSON)
|
||||||
|
else
|
||||||
|
m
|
||||||
|
& KeyMap.map (go (depth - 1, sizeObject, sizeArray, strLen))
|
||||||
|
)
|
||||||
|
& Json.Object
|
||||||
|
Array arr ->
|
||||||
|
arr
|
||||||
|
& ( \v ->
|
||||||
|
if Vector.length v > sizeArray
|
||||||
|
then
|
||||||
|
v
|
||||||
|
& Vector.take sizeArray
|
||||||
|
& Vector.map (go (depth - 1, sizeObject, sizeArray, strLen))
|
||||||
|
& (\v' -> Vector.snoc v' ([fmt|<{Vector.length v - sizeArray} more elements elided>|] & String))
|
||||||
|
else
|
||||||
|
v
|
||||||
|
& Vector.map (go (depth - 1, sizeObject, sizeArray, strLen))
|
||||||
|
)
|
||||||
|
& Array
|
||||||
|
String txt -> truncateString strLen txt
|
||||||
|
other -> other
|
||||||
|
|
||||||
|
truncateString strLen txt =
|
||||||
|
let truncatedTxt = Text.take strLen txt
|
||||||
|
finalTxt =
|
||||||
|
if Text.length txt > strLen
|
||||||
|
then Text.append truncatedTxt "…"
|
||||||
|
else truncatedTxt
|
||||||
|
in String finalTxt
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import Data.Time qualified as Time
|
||||||
import Data.Time.Format.ISO8601 qualified as ISO8601
|
import Data.Time.Format.ISO8601 qualified as ISO8601
|
||||||
import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
|
import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
|
||||||
import PossehlAnalyticsPrelude
|
import PossehlAnalyticsPrelude
|
||||||
|
import Pretty (hscolour')
|
||||||
|
|
||||||
-- | A JSON encoder.
|
-- | A JSON encoder.
|
||||||
--
|
--
|
||||||
|
|
@ -53,18 +54,18 @@ instance RationalLiteral Enc where
|
||||||
|
|
||||||
-- | Convert an 'Enc' to a strict UTF8-bytestring which is valid JSON (minified).
|
-- | Convert an 'Enc' to a strict UTF8-bytestring which is valid JSON (minified).
|
||||||
encToBytesUtf8 :: Enc -> ByteString
|
encToBytesUtf8 :: Enc -> ByteString
|
||||||
encToBytesUtf8 enc = enc & encToBytesUtf8Lazy & toStrictBytes
|
encToBytesUtf8 enc' = enc' & encToBytesUtf8Lazy & toStrictBytes
|
||||||
|
|
||||||
-- | Convert an 'Enc' to a lazy UTF8-bytestring which is valid JSON (minified).
|
-- | Convert an 'Enc' to a lazy UTF8-bytestring which is valid JSON (minified).
|
||||||
encToBytesUtf8Lazy :: Enc -> LazyBytes.ByteString
|
encToBytesUtf8Lazy :: Enc -> LazyBytes.ByteString
|
||||||
encToBytesUtf8Lazy enc = enc.unEnc & Json.Enc.encodingToLazyByteString
|
encToBytesUtf8Lazy enc' = enc'.unEnc & Json.Enc.encodingToLazyByteString
|
||||||
|
|
||||||
-- | Convert an 'Enc' to a strict Text which is valid JSON (prettyfied).
|
-- | Convert an 'Enc' to a strict Text which is valid JSON (prettyfied).
|
||||||
--
|
--
|
||||||
-- __ATTN__: will re-parse the json through 'Json.Value', so only use for user-interactions like pretty-printing.
|
-- __ATTN__: will re-parse the json through 'Json.Value', so only use for user-interactions like pretty-printing.
|
||||||
encToTextPretty :: Enc -> Text
|
encToTextPretty :: Enc -> Text
|
||||||
encToTextPretty enc =
|
encToTextPretty enc' =
|
||||||
enc
|
enc'
|
||||||
& encToTextPrettyLazy
|
& encToTextPrettyLazy
|
||||||
& toStrict
|
& toStrict
|
||||||
|
|
||||||
|
|
@ -72,8 +73,8 @@ encToTextPretty enc =
|
||||||
--
|
--
|
||||||
-- __ATTN__: will re-parse the json through 'Json.Value', so only use for user-interactions like pretty-printing.
|
-- __ATTN__: will re-parse the json through 'Json.Value', so only use for user-interactions like pretty-printing.
|
||||||
encToTextPrettyLazy :: Enc -> Lazy.Text
|
encToTextPrettyLazy :: Enc -> Lazy.Text
|
||||||
encToTextPrettyLazy enc =
|
encToTextPrettyLazy enc' =
|
||||||
enc
|
enc'
|
||||||
& encToBytesUtf8Lazy
|
& encToBytesUtf8Lazy
|
||||||
& Json.decode @Json.Value
|
& Json.decode @Json.Value
|
||||||
& annotate "the json parser can’t parse json encodings??"
|
& annotate "the json parser can’t parse json encodings??"
|
||||||
|
|
@ -81,6 +82,17 @@ encToTextPrettyLazy enc =
|
||||||
& Aeson.Pretty.encodePrettyToTextBuilder
|
& Aeson.Pretty.encodePrettyToTextBuilder
|
||||||
& Text.Builder.toLazyText
|
& Text.Builder.toLazyText
|
||||||
|
|
||||||
|
-- | Convert an 'Enc' to a strict Text which is valid JSON (prettyfied and colored).
|
||||||
|
--
|
||||||
|
-- __ATTN__: will re-parse the json through 'Json.Value', so only use for user-interactions like pretty-printing.
|
||||||
|
encToTextPrettyColored :: Enc -> Text
|
||||||
|
encToTextPrettyColored enc' =
|
||||||
|
enc'
|
||||||
|
& encToTextPretty
|
||||||
|
& textToString
|
||||||
|
& hscolour'
|
||||||
|
& stringToText
|
||||||
|
|
||||||
-- | Embed a 'Json.Encoding' verbatim (it’s a valid JSON value)
|
-- | Embed a 'Json.Encoding' verbatim (it’s a valid JSON value)
|
||||||
encoding :: Encoding -> Enc
|
encoding :: Encoding -> Enc
|
||||||
encoding = Enc
|
encoding = Enc
|
||||||
|
|
@ -89,6 +101,10 @@ encoding = Enc
|
||||||
value :: Value -> Enc
|
value :: Value -> Enc
|
||||||
value = Enc . AesonEnc.value
|
value = Enc . AesonEnc.value
|
||||||
|
|
||||||
|
-- | Encode an Enc verbatim (for completeness’ sake)
|
||||||
|
enc :: Enc -> Enc
|
||||||
|
enc = id
|
||||||
|
|
||||||
-- | Encode an empty json list
|
-- | Encode an empty json list
|
||||||
emptyArray :: Enc
|
emptyArray :: Enc
|
||||||
emptyArray = Enc AesonEnc.emptyArray_
|
emptyArray = Enc AesonEnc.emptyArray_
|
||||||
|
|
|
||||||
|
|
@ -130,11 +130,16 @@ module MyPrelude
|
||||||
mconcat,
|
mconcat,
|
||||||
ifTrue,
|
ifTrue,
|
||||||
ifExists,
|
ifExists,
|
||||||
|
sintersperse,
|
||||||
|
mintersperse,
|
||||||
Void,
|
Void,
|
||||||
absurd,
|
absurd,
|
||||||
Identity (Identity, runIdentity),
|
Identity (Identity, runIdentity),
|
||||||
Natural,
|
Natural,
|
||||||
|
naturalToInteger,
|
||||||
intToNatural,
|
intToNatural,
|
||||||
|
integerToBounded,
|
||||||
|
integerToBoundedClamped,
|
||||||
Scientific,
|
Scientific,
|
||||||
Contravariant,
|
Contravariant,
|
||||||
contramap,
|
contramap,
|
||||||
|
|
@ -196,6 +201,7 @@ import Data.Functor ((<&>))
|
||||||
import Data.Functor.Contravariant (Contravariant (contramap), (>$<))
|
import Data.Functor.Contravariant (Contravariant (contramap), (>$<))
|
||||||
import Data.Functor.Identity (Identity (runIdentity))
|
import Data.Functor.Identity (Identity (runIdentity))
|
||||||
import Data.List (zip4)
|
import Data.List (zip4)
|
||||||
|
import Data.List qualified as List
|
||||||
import Data.List.NonEmpty (NonEmpty ((:|)), nonEmpty)
|
import Data.List.NonEmpty (NonEmpty ((:|)), nonEmpty)
|
||||||
import Data.List.NonEmpty qualified as NonEmpty
|
import Data.List.NonEmpty qualified as NonEmpty
|
||||||
import Data.Map.Strict
|
import Data.Map.Strict
|
||||||
|
|
@ -207,7 +213,7 @@ import Data.Maybe qualified as Maybe
|
||||||
import Data.Profunctor (Profunctor, dimap, lmap, rmap)
|
import Data.Profunctor (Profunctor, dimap, lmap, rmap)
|
||||||
import Data.Scientific (Scientific)
|
import Data.Scientific (Scientific)
|
||||||
import Data.Semigroup (sconcat)
|
import Data.Semigroup (sconcat)
|
||||||
import Data.Semigroup.Foldable (Foldable1 (fold1), foldMap1)
|
import Data.Semigroup.Foldable (Foldable1 (fold1, toNonEmpty), foldMap1)
|
||||||
import Data.Semigroup.Traversable (Traversable1)
|
import Data.Semigroup.Traversable (Traversable1)
|
||||||
import Data.Semigroupoid (Semigroupoid (o))
|
import Data.Semigroupoid (Semigroupoid (o))
|
||||||
import Data.Text
|
import Data.Text
|
||||||
|
|
@ -227,6 +233,7 @@ import Divisive
|
||||||
import GHC.Exception (errorCallWithCallStackException)
|
import GHC.Exception (errorCallWithCallStackException)
|
||||||
import GHC.Exts (Any, RuntimeRep, TYPE, raise#)
|
import GHC.Exts (Any, RuntimeRep, TYPE, raise#)
|
||||||
import GHC.Generics (Generic)
|
import GHC.Generics (Generic)
|
||||||
|
import GHC.Natural (naturalToInteger)
|
||||||
import GHC.Records (HasField)
|
import GHC.Records (HasField)
|
||||||
import GHC.Stack (HasCallStack)
|
import GHC.Stack (HasCallStack)
|
||||||
import GHC.TypeLits
|
import GHC.TypeLits
|
||||||
|
|
@ -653,6 +660,27 @@ intToNatural i =
|
||||||
then Nothing
|
then Nothing
|
||||||
else Just $ fromIntegral i
|
else Just $ fromIntegral i
|
||||||
|
|
||||||
|
-- | Convert an Integer to a bounded type if possible.
|
||||||
|
--
|
||||||
|
-- taken from 'Scientific.toBoundedInteger'.
|
||||||
|
integerToBounded :: forall i. (Bounded i, Integral i) => Integer -> Maybe i
|
||||||
|
integerToBounded i
|
||||||
|
| i < iMinBound || i > iMaxBound = Nothing
|
||||||
|
| otherwise = Just $ fromInteger i
|
||||||
|
where
|
||||||
|
iMinBound = toInteger (minBound :: i)
|
||||||
|
iMaxBound = toInteger (maxBound :: i)
|
||||||
|
|
||||||
|
-- | Convert an Integer to a bounded type, clamping to the bounds if necessary.
|
||||||
|
integerToBoundedClamped :: forall i. (Bounded i, Integral i) => Integer -> i
|
||||||
|
integerToBoundedClamped i
|
||||||
|
| i < iMinBound = minBound
|
||||||
|
| i > iMaxBound = maxBound
|
||||||
|
| otherwise = fromInteger i
|
||||||
|
where
|
||||||
|
iMinBound = toInteger (minBound :: i)
|
||||||
|
iMaxBound = toInteger (maxBound :: i)
|
||||||
|
|
||||||
-- | @inverseFunction f@ creates a function that is the inverse of a given function
|
-- | @inverseFunction f@ creates a function that is the inverse of a given function
|
||||||
-- @f@. It does so by constructing 'M.Map' internally for each value @f a@. The
|
-- @f@. It does so by constructing 'M.Map' internally for each value @f a@. The
|
||||||
-- implementation makes sure that the 'M.Map' is constructed only once and then
|
-- implementation makes sure that the 'M.Map' is constructed only once and then
|
||||||
|
|
@ -758,7 +786,6 @@ mapFromListOnMerge f xs =
|
||||||
-- >>> mconcat [ Sum 1, ifTrue (1 == 1) (Sum 2), Sum 3 ]
|
-- >>> mconcat [ Sum 1, ifTrue (1 == 1) (Sum 2), Sum 3 ]
|
||||||
|
|
||||||
-- Sum {getSum = 6}
|
-- Sum {getSum = 6}
|
||||||
|
|
||||||
ifTrue :: (Monoid m) => Bool -> m -> m
|
ifTrue :: (Monoid m) => Bool -> m -> m
|
||||||
ifTrue pred' m = if pred' then m else mempty
|
ifTrue pred' m = if pred' then m else mempty
|
||||||
|
|
||||||
|
|
@ -775,10 +802,21 @@ ifTrue pred' m = if pred' then m else mempty
|
||||||
--
|
--
|
||||||
-- >>> mconcat [ Sum 1, ifExists id (Just 2), Sum 3 ]
|
-- >>> mconcat [ Sum 1, ifExists id (Just 2), Sum 3 ]
|
||||||
-- Sum {getSum = 6}
|
-- Sum {getSum = 6}
|
||||||
|
|
||||||
ifExists :: (Monoid (f b), Applicative f) => (a -> b) -> Maybe a -> f b
|
ifExists :: (Monoid (f b), Applicative f) => (a -> b) -> Maybe a -> f b
|
||||||
ifExists f m = m & foldMap @Maybe (pure . f)
|
ifExists f m = m & foldMap @Maybe (pure . f)
|
||||||
|
|
||||||
|
-- | Intersperse a monoidal value between each element of a list.
|
||||||
|
--
|
||||||
|
-- Generalization of 'Data.List.intersperse' to any 'Foldable' and 'Semigroup'.
|
||||||
|
sintersperse :: (Foldable1 t, Semigroup m) => m -> t m -> m
|
||||||
|
sintersperse sep xs = xs & toNonEmpty & NonEmpty.intersperse sep & sconcat
|
||||||
|
|
||||||
|
-- | Intersperse a monoidal value between each element of a list. If the list is empty, return 'mempty'.
|
||||||
|
--
|
||||||
|
-- Generalization of 'Data.List.intersperse' to any 'Foldable' and 'Monoid'.
|
||||||
|
mintersperse :: (Foldable t, Monoid m) => m -> t m -> m
|
||||||
|
mintersperse sep xs = xs & toList & List.intersperse sep & mconcat
|
||||||
|
|
||||||
-- | Get the text of a symbol via TypeApplications
|
-- | Get the text of a symbol via TypeApplications
|
||||||
symbolText :: forall sym. (KnownSymbol sym) => Text
|
symbolText :: forall sym. (KnownSymbol sym) => Text
|
||||||
symbolText = do
|
symbolText = do
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ module Pretty
|
||||||
Err,
|
Err,
|
||||||
showPretty,
|
showPretty,
|
||||||
showPrettyJson,
|
showPrettyJson,
|
||||||
|
showPrettyJsonColored,
|
||||||
showedStringPretty,
|
showedStringPretty,
|
||||||
printPretty,
|
printPretty,
|
||||||
printShowedStringPretty,
|
printShowedStringPretty,
|
||||||
|
|
@ -63,6 +64,17 @@ showPrettyJson val =
|
||||||
& Text.Builder.toLazyText
|
& Text.Builder.toLazyText
|
||||||
& toStrict
|
& toStrict
|
||||||
|
|
||||||
|
-- | Shows a pretty json string with some color (very inefficient!)
|
||||||
|
showPrettyJsonColored :: Json.Value -> Text
|
||||||
|
showPrettyJsonColored val =
|
||||||
|
val
|
||||||
|
& Aeson.Pretty.encodePrettyToTextBuilder
|
||||||
|
& Text.Builder.toLazyText
|
||||||
|
& toStrict
|
||||||
|
& textToString
|
||||||
|
& hscolour'
|
||||||
|
& stringToText
|
||||||
|
|
||||||
-- | Display a list of 'Err's as a colored error message
|
-- | Display a list of 'Err's as a colored error message
|
||||||
prettyErrs :: [Err] -> String
|
prettyErrs :: [Err] -> String
|
||||||
prettyErrs errs = res
|
prettyErrs errs = res
|
||||||
|
|
@ -71,7 +83,8 @@ prettyErrs errs = res
|
||||||
one = \case
|
one = \case
|
||||||
ErrMsg s -> color Red s
|
ErrMsg s -> color Red s
|
||||||
ErrPrettyString s -> prettyShowString s
|
ErrPrettyString s -> prettyShowString s
|
||||||
-- Pretty print a String that was produced by 'show'
|
|
||||||
|
-- \| Pretty print a String that was produced by 'show'
|
||||||
prettyShowString :: String -> String
|
prettyShowString :: String -> String
|
||||||
prettyShowString = hscolour' . nicify
|
prettyShowString = hscolour' . nicify
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ newtype AppT m a = AppT {unAppT :: ReaderT Context m a}
|
||||||
data AppException
|
data AppException
|
||||||
= AppExceptionTree ErrorTree
|
= AppExceptionTree ErrorTree
|
||||||
| AppExceptionPretty [Pretty.Err]
|
| AppExceptionPretty [Pretty.Err]
|
||||||
|
| AppExceptionEnc Enc
|
||||||
deriving anyclass (Exception)
|
deriving anyclass (Exception)
|
||||||
|
|
||||||
instance IsString AppException where
|
instance IsString AppException where
|
||||||
|
|
@ -59,6 +60,7 @@ instance IsString AppException where
|
||||||
instance Show AppException where
|
instance Show AppException where
|
||||||
showsPrec _ (AppExceptionTree t) = ("AppException: " ++) . ((textToString $ prettyErrorTree t) ++)
|
showsPrec _ (AppExceptionTree t) = ("AppException: " ++) . ((textToString $ prettyErrorTree t) ++)
|
||||||
showsPrec _ (AppExceptionPretty t) = ("AppException: " ++) . ((Pretty.prettyErrsNoColor t) ++)
|
showsPrec _ (AppExceptionPretty t) = ("AppException: " ++) . ((Pretty.prettyErrsNoColor t) ++)
|
||||||
|
showsPrec _ (AppExceptionEnc e) = ((textToString $ Enc.encToTextPretty e) ++)
|
||||||
|
|
||||||
instance (MonadIO m) => MonadLogger (AppT m) where
|
instance (MonadIO m) => MonadLogger (AppT m) where
|
||||||
monadLoggerLog loc src lvl msg = liftIO $ Logger.defaultOutput IO.stderr loc src lvl (Logger.toLogStr msg)
|
monadLoggerLog loc src lvl msg = liftIO $ Logger.defaultOutput IO.stderr loc src lvl (Logger.toLogStr msg)
|
||||||
|
|
@ -119,6 +121,7 @@ appThrowNewSpan spanName exc = inSpan' spanName $ \span -> do
|
||||||
let msg = case exc of
|
let msg = case exc of
|
||||||
AppExceptionTree e -> prettyErrorTree e
|
AppExceptionTree e -> prettyErrorTree e
|
||||||
AppExceptionPretty p -> Pretty.prettyErrsNoColor p & stringToText
|
AppExceptionPretty p -> Pretty.prettyErrsNoColor p & stringToText
|
||||||
|
AppExceptionEnc e -> Enc.encToTextPretty e
|
||||||
recordException
|
recordException
|
||||||
span
|
span
|
||||||
( T2
|
( T2
|
||||||
|
|
@ -132,6 +135,7 @@ appThrow span exc = do
|
||||||
let msg = case exc of
|
let msg = case exc of
|
||||||
AppExceptionTree e -> prettyErrorTree e
|
AppExceptionTree e -> prettyErrorTree e
|
||||||
AppExceptionPretty p -> Pretty.prettyErrsNoColor p & stringToText
|
AppExceptionPretty p -> Pretty.prettyErrsNoColor p & stringToText
|
||||||
|
AppExceptionEnc e -> Enc.encToTextPretty e
|
||||||
recordException
|
recordException
|
||||||
span
|
span
|
||||||
( T2
|
( T2
|
||||||
|
|
|
||||||
|
|
@ -127,8 +127,16 @@ httpJson opts parser req = inSpan' "HTTP Request (JSON)" $ \span -> do
|
||||||
let res = Json.parseValue parser val
|
let res = Json.parseValue parser val
|
||||||
case res of
|
case res of
|
||||||
Left e -> do
|
Left e -> do
|
||||||
let prettyErr = Json.parseErrorTreeValCtx "could not parse HTTP response" val e
|
let err = Json.parseErrorTreeValCtx val e
|
||||||
appThrow span (AppExceptionTree prettyErr)
|
appThrow
|
||||||
|
span
|
||||||
|
( AppExceptionEnc $
|
||||||
|
Enc.tuple3
|
||||||
|
Enc.text
|
||||||
|
Enc.enc
|
||||||
|
(Enc.nullOr Enc.value)
|
||||||
|
("Could not parse HTTP response", err.errorMessage, err.valueAtErrorPath)
|
||||||
|
)
|
||||||
Right a -> pure a
|
Right a -> pure a
|
||||||
|
|
||||||
hush :: Either e a -> Maybe a
|
hush :: Either e a -> Maybe a
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@ import Control.Monad.Logger.CallStack
|
||||||
import Control.Monad.Reader
|
import Control.Monad.Reader
|
||||||
import Data.Aeson qualified as Json
|
import Data.Aeson qualified as Json
|
||||||
import Data.Aeson.BetterErrors qualified as Json
|
import Data.Aeson.BetterErrors qualified as Json
|
||||||
|
import Data.Aeson.Key qualified as Key
|
||||||
import Data.Aeson.KeyMap qualified as KeyMap
|
import Data.Aeson.KeyMap qualified as KeyMap
|
||||||
import Data.Error.Tree
|
import Data.Error.Tree
|
||||||
import Data.List qualified as List
|
import Data.List qualified as List
|
||||||
|
import Data.List.NonEmpty qualified as NonEmpty
|
||||||
import Data.Maybe (catMaybes)
|
import Data.Maybe (catMaybes)
|
||||||
import Database.PostgreSQL.Simple (Binary (Binary), Only (..))
|
import Database.PostgreSQL.Simple (Binary (Binary), Only (..))
|
||||||
import Database.PostgreSQL.Simple.Types (PGArray (PGArray))
|
import Database.PostgreSQL.Simple.Types (PGArray (PGArray))
|
||||||
|
|
@ -37,17 +39,72 @@ instance (MonadIO m) => MonadRedacted (AppT m) where
|
||||||
getRedactedApiKey = AppT (asks (.redactedApiKey))
|
getRedactedApiKey = AppT (asks (.redactedApiKey))
|
||||||
|
|
||||||
redactedSearch ::
|
redactedSearch ::
|
||||||
(MonadThrow m, MonadOtel m, MonadRedacted m) =>
|
( MonadThrow m,
|
||||||
[(ByteString, ByteString)] ->
|
MonadOtel m,
|
||||||
|
MonadRedacted m,
|
||||||
|
HasField "actionArgs" extraArguments [(ByteString, ByteString)],
|
||||||
|
HasField "page" dat (Maybe Natural)
|
||||||
|
) =>
|
||||||
|
extraArguments ->
|
||||||
|
dat ->
|
||||||
Json.Parse ErrorTree a ->
|
Json.Parse ErrorTree a ->
|
||||||
m a
|
m a
|
||||||
redactedSearch advanced parser =
|
redactedSearch extraArguments dat parser =
|
||||||
inSpan' "Redacted API Search" $ \span ->
|
inSpan' "Redacted API Search" $ \span ->
|
||||||
|
redactedPagedRequest
|
||||||
|
span
|
||||||
|
( T3
|
||||||
|
(label @"action" "browse")
|
||||||
|
(getLabel @"actionArgs" extraArguments)
|
||||||
|
(getLabel @"page" dat)
|
||||||
|
)
|
||||||
|
parser
|
||||||
|
|
||||||
|
redactedGetArtist ::
|
||||||
|
( MonadOtel m,
|
||||||
|
MonadThrow m,
|
||||||
|
MonadRedacted m,
|
||||||
|
HasField "artistId" r Text,
|
||||||
|
HasField "page" r (Maybe Natural)
|
||||||
|
) =>
|
||||||
|
r ->
|
||||||
|
Json.Parse ErrorTree a ->
|
||||||
|
m a
|
||||||
|
redactedGetArtist dat parser =
|
||||||
|
inSpan' "Redacted Get Artist" $ \span -> do
|
||||||
|
redactedPagedRequest
|
||||||
|
span
|
||||||
|
( T3
|
||||||
|
(label @"action" "artist")
|
||||||
|
(label @"actionArgs" [("id", buildBytes utf8B dat.artistId)])
|
||||||
|
(getLabel @"page" dat)
|
||||||
|
)
|
||||||
|
parser
|
||||||
|
|
||||||
|
redactedPagedRequest ::
|
||||||
|
( MonadThrow m,
|
||||||
|
MonadOtel m,
|
||||||
|
MonadRedacted m,
|
||||||
|
HasField "action" dat ByteString,
|
||||||
|
HasField "actionArgs" dat [(ByteString, ByteString)],
|
||||||
|
HasField "page" dat (Maybe Natural)
|
||||||
|
) =>
|
||||||
|
Otel.Span ->
|
||||||
|
dat ->
|
||||||
|
Json.Parse ErrorTree a ->
|
||||||
|
m a
|
||||||
|
redactedPagedRequest span dat parser =
|
||||||
redactedApiRequestJson
|
redactedApiRequestJson
|
||||||
span
|
span
|
||||||
( T2
|
( T2
|
||||||
(label @"action" "browse")
|
(label @"action" dat.action)
|
||||||
(label @"actionArgs" ((advanced <&> second Just)))
|
( label @"actionArgs" $
|
||||||
|
(dat.actionArgs <&> second Just)
|
||||||
|
<> ( dat.page
|
||||||
|
& ifExists
|
||||||
|
(\page -> ("page", Just $ buildBytes naturalDecimalB page))
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
parser
|
parser
|
||||||
|
|
||||||
|
|
@ -108,55 +165,93 @@ exampleSearch = do
|
||||||
-- ("releasetype", "album"),
|
-- ("releasetype", "album"),
|
||||||
("order_by", "year")
|
("order_by", "year")
|
||||||
]
|
]
|
||||||
pure (t1 >> t2 >> t3)
|
pure (t1 >> t2 >> t3 >> pure ())
|
||||||
|
|
||||||
-- | Do the search, return a transaction that inserts all results from all pages of the search.
|
redactedRefreshArtist ::
|
||||||
redactedSearchAndInsert ::
|
|
||||||
forall m.
|
|
||||||
( MonadLogger m,
|
( MonadLogger m,
|
||||||
MonadPostgres m,
|
MonadPostgres m,
|
||||||
MonadThrow m,
|
MonadThrow m,
|
||||||
MonadOtel m,
|
MonadOtel m,
|
||||||
MonadRedacted m
|
MonadRedacted m,
|
||||||
|
HasField "artistId" dat Text
|
||||||
) =>
|
) =>
|
||||||
[(ByteString, ByteString)] ->
|
dat ->
|
||||||
m (Transaction m ())
|
m (Transaction m (Label "newTorrents" [Label "torrentId" Int]))
|
||||||
redactedSearchAndInsert extraArguments = do
|
redactedRefreshArtist dat = do
|
||||||
logInfo [fmt|Doing redacted search with arguments: {showPretty extraArguments}|]
|
redactedPagedSearchAndInsert
|
||||||
-- The first search returns the amount of pages, so we use that to query all results piece by piece.
|
(Json.key "torrentgroup" $ parseTourGroups (T2 (label @"torrentFieldName" "torrent") (label @"torrentIdName" "id")))
|
||||||
firstPage <- go Nothing
|
( \page ->
|
||||||
let remainingPages = firstPage.pages - 1
|
redactedGetArtist
|
||||||
logInfo [fmt|Got the first page, found {remainingPages} more pages|]
|
( T2
|
||||||
let otherPagesNum = [(2 :: Natural) .. remainingPages]
|
(getLabel @"artistId" dat)
|
||||||
otherPages <- traverse go (Just <$> otherPagesNum)
|
page
|
||||||
pure $
|
|
||||||
(firstPage : otherPages)
|
|
||||||
& concatMap (.tourGroups)
|
|
||||||
& \case
|
|
||||||
IsNonEmpty tgs -> tgs & insertTourGroupsAndTorrents
|
|
||||||
IsEmpty -> pure ()
|
|
||||||
where
|
|
||||||
go mpage =
|
|
||||||
redactedSearch
|
|
||||||
( extraArguments
|
|
||||||
-- pass the page (for every search but the first one)
|
|
||||||
<> (mpage & ifExists (\page -> ("page", buildBytes naturalDecimalB page)))
|
|
||||||
)
|
)
|
||||||
( do
|
)
|
||||||
|
|
||||||
|
-- | Do the search, return a transaction that inserts all results from all pages of the search.
|
||||||
|
redactedSearchAndInsert ::
|
||||||
|
(MonadLogger m, MonadPostgres m, MonadThrow m, MonadOtel m, MonadRedacted m) =>
|
||||||
|
[(ByteString, ByteString)] ->
|
||||||
|
m (Transaction m (Label "newTorrents" [Label "torrentId" Int]))
|
||||||
|
redactedSearchAndInsert extraArguments =
|
||||||
|
redactedPagedSearchAndInsert
|
||||||
|
(Json.key "results" $ parseTourGroups (T2 (label @"torrentFieldName" "torrents") (label @"torrentIdName" "torrentId")))
|
||||||
|
( redactedSearch
|
||||||
|
(label @"actionArgs" extraArguments)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- | Parse the standard Redacted reply object, @{ status: "success", response: ... }@ or throw an error.
|
||||||
|
--
|
||||||
|
-- The response might contain a `pages` field, if not we’ll return 1.
|
||||||
|
parseRedactedReplyStatus ::
|
||||||
|
(Monad m) =>
|
||||||
|
Json.ParseT ErrorTree m b ->
|
||||||
|
Json.ParseT ErrorTree m (T2 "pages" Natural "response" b)
|
||||||
|
parseRedactedReplyStatus inner = do
|
||||||
status <- Json.key "status" Json.asText
|
status <- Json.key "status" Json.asText
|
||||||
when (status /= "success") $ do
|
when (status /= "success") $ do
|
||||||
Json.throwCustomError [fmt|Status was not "success", but {status}|]
|
Json.throwCustomError ([fmt|Status was not "success", but {status}|] :: ErrorTree)
|
||||||
Json.key "response" $ do
|
Json.key "response" $ do
|
||||||
pages <-
|
pages <-
|
||||||
Json.keyMay "pages" (Field.toJsonParser (Field.mapError singleError $ Field.jsonNumber >>> Field.boundedScientificIntegral @Int "not an Integer" >>> Field.integralToNatural))
|
Json.keyMay
|
||||||
|
"pages"
|
||||||
|
( Field.toJsonParser
|
||||||
|
( Field.mapError singleError $
|
||||||
|
Field.jsonNumber >>> Field.boundedScientificIntegral @Int "not an Integer" >>> Field.integralToNatural
|
||||||
|
)
|
||||||
|
)
|
||||||
-- in case the field is missing, let’s assume there is only one page
|
-- in case the field is missing, let’s assume there is only one page
|
||||||
<&> fromMaybe 1
|
<&> fromMaybe 1
|
||||||
Json.key "results" $ do
|
res <- inner
|
||||||
tourGroups <-
|
pure $
|
||||||
|
T2
|
||||||
|
(label @"pages" pages)
|
||||||
|
(label @"response" res)
|
||||||
|
|
||||||
|
type TourGroups =
|
||||||
|
( Label
|
||||||
|
"tourGroups"
|
||||||
|
[ T2
|
||||||
|
"tourGroup"
|
||||||
|
(T3 "groupId" Int "groupName" Text "fullJsonResult" Json.Value)
|
||||||
|
"torrents"
|
||||||
|
[T2 "torrentId" Int "fullJsonResult" Json.Value]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
parseTourGroups ::
|
||||||
|
( Monad m,
|
||||||
|
HasField "torrentFieldName" opts Text,
|
||||||
|
HasField "torrentIdName" opts Text
|
||||||
|
) =>
|
||||||
|
opts ->
|
||||||
|
Json.ParseT err m TourGroups
|
||||||
|
parseTourGroups opts =
|
||||||
|
do
|
||||||
label @"tourGroups"
|
label @"tourGroups"
|
||||||
<$> ( catMaybes
|
<$> ( catMaybes
|
||||||
<$> ( Json.eachInArray $ do
|
<$> ( Json.eachInArray $ do
|
||||||
Json.keyMay "torrents" (pure ()) >>= \case
|
Json.keyMay opts.torrentFieldName (pure ()) >>= \case
|
||||||
-- not a torrent group, maybe some files or something (e.g. guitar tabs see Dream Theater Systematic Chaos)
|
-- not a torrent group, maybe some files or something (e.g. guitar tabs see Dream Theater Systematic Chaos)
|
||||||
Nothing -> pure Nothing
|
Nothing -> pure Nothing
|
||||||
Just () -> do
|
Just () -> do
|
||||||
|
|
@ -166,23 +261,66 @@ redactedSearchAndInsert extraArguments = do
|
||||||
label @"fullJsonResult"
|
label @"fullJsonResult"
|
||||||
<$> ( Json.asObject
|
<$> ( Json.asObject
|
||||||
-- remove torrents cause they are inserted separately below
|
-- remove torrents cause they are inserted separately below
|
||||||
<&> KeyMap.filterWithKey (\k _ -> k /= "torrents")
|
<&> KeyMap.filterWithKey (\k _ -> k /= (opts.torrentFieldName & Key.fromText))
|
||||||
<&> Json.Object
|
<&> Json.Object
|
||||||
)
|
)
|
||||||
let tourGroup = T3 groupId groupName fullJsonResult
|
let tourGroup = T3 groupId groupName fullJsonResult
|
||||||
torrents <- Json.keyLabel @"torrents" "torrents" $
|
torrents <- Json.keyLabel @"torrents" opts.torrentFieldName $
|
||||||
Json.eachInArray $ do
|
Json.eachInArray $ do
|
||||||
torrentId <- Json.keyLabel @"torrentId" "torrentId" (Json.asIntegral @_ @Int)
|
torrentId <- Json.keyLabel @"torrentId" opts.torrentIdName (Json.asIntegral @_ @Int)
|
||||||
fullJsonResultT <- label @"fullJsonResult" <$> Json.asValue
|
fullJsonResultT <-
|
||||||
|
label @"fullJsonResult"
|
||||||
|
<$> ( Json.asObject
|
||||||
|
<&> KeyMap.mapKeyVal
|
||||||
|
( \k ->
|
||||||
|
if
|
||||||
|
-- some torrent objects use “snatched” instead of “snatches”
|
||||||
|
| k == "snatched" -> "snatches"
|
||||||
|
-- normalize the torrent id field
|
||||||
|
| k == (opts.torrentIdName & Key.fromText) -> "torrentId"
|
||||||
|
| otherwise -> k
|
||||||
|
)
|
||||||
|
id
|
||||||
|
<&> Json.Object
|
||||||
|
)
|
||||||
pure $ T2 torrentId fullJsonResultT
|
pure $ T2 torrentId fullJsonResultT
|
||||||
pure $ Just (T2 (label @"tourGroup" tourGroup) torrents)
|
pure $ Just (T2 (label @"tourGroup" tourGroup) torrents)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pure
|
|
||||||
( T2
|
redactedPagedSearchAndInsert ::
|
||||||
(label @"pages" pages)
|
forall m.
|
||||||
tourGroups
|
( MonadLogger m,
|
||||||
)
|
MonadPostgres m
|
||||||
|
) =>
|
||||||
|
Json.Parse ErrorTree TourGroups ->
|
||||||
|
-- | A redacted request that returns a paged result
|
||||||
|
( forall a.
|
||||||
|
Label "page" (Maybe Natural) ->
|
||||||
|
Json.Parse ErrorTree a ->
|
||||||
|
m a
|
||||||
|
) ->
|
||||||
|
m (Transaction m (Label "newTorrents" [Label "torrentId" Int]))
|
||||||
|
redactedPagedSearchAndInsert innerParser pagedRequest = do
|
||||||
|
-- The first search returns the amount of pages, so we use that to query all results piece by piece.
|
||||||
|
firstPage <- go Nothing
|
||||||
|
let remainingPages = firstPage.pages - 1
|
||||||
|
logInfo [fmt|Got the first page, found {remainingPages} more pages|]
|
||||||
|
let otherPagesNum = [(2 :: Natural) .. remainingPages]
|
||||||
|
otherPages <- traverse go (Just <$> otherPagesNum)
|
||||||
|
pure $
|
||||||
|
(firstPage : otherPages)
|
||||||
|
& concatMap (.response.tourGroups)
|
||||||
|
& \case
|
||||||
|
IsNonEmpty tgs -> do
|
||||||
|
tgs & insertTourGroupsAndTorrents
|
||||||
|
pure $ label @"newTorrents" (tgs & concatMap (\tg -> tg.torrents <&> getLabel @"torrentId"))
|
||||||
|
IsEmpty -> pure $ label @"newTorrents" []
|
||||||
|
where
|
||||||
|
go mpage =
|
||||||
|
pagedRequest
|
||||||
|
(label @"page" mpage)
|
||||||
|
( parseRedactedReplyStatus $ innerParser
|
||||||
)
|
)
|
||||||
insertTourGroupsAndTorrents ::
|
insertTourGroupsAndTorrents ::
|
||||||
NonEmpty
|
NonEmpty
|
||||||
|
|
@ -238,12 +376,16 @@ redactedSearchAndInsert extraArguments = do
|
||||||
full_json_result = excluded.full_json_result
|
full_json_result = excluded.full_json_result
|
||||||
RETURNING (id)
|
RETURNING (id)
|
||||||
|]
|
|]
|
||||||
( dats <&> \dat ->
|
( dats
|
||||||
|
-- make sure we don’t have the same conflict target twice
|
||||||
|
& NonEmpty.nubBy (\a b -> a.groupId == b.groupId)
|
||||||
|
<&> ( \dat ->
|
||||||
( dat.groupId,
|
( dat.groupId,
|
||||||
dat.groupName,
|
dat.groupName,
|
||||||
dat.fullJsonResult
|
dat.fullJsonResult
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
(label @"tourGroupIdPg" <$> Dec.fromField @Int)
|
(label @"tourGroupIdPg" <$> Dec.fromField @Int)
|
||||||
|
|
||||||
insertTorrents ::
|
insertTorrents ::
|
||||||
|
|
@ -292,6 +434,7 @@ redactedSearchAndInsert extraArguments = do
|
||||||
| dat <- dats,
|
| dat <- dats,
|
||||||
group <- dat.torrents
|
group <- dat.torrents
|
||||||
]
|
]
|
||||||
|
& List.nubBy (\a b -> a.torrentId == b.torrentId)
|
||||||
& unzip3PGArray
|
& unzip3PGArray
|
||||||
@"torrentGroupIdPg"
|
@"torrentGroupIdPg"
|
||||||
@Int
|
@Int
|
||||||
|
|
@ -455,7 +598,8 @@ getTorrentById dat = do
|
||||||
|
|
||||||
data GetBestTorrentsFilter = GetBestTorrentsFilter
|
data GetBestTorrentsFilter = GetBestTorrentsFilter
|
||||||
{ onlyDownloaded :: Bool,
|
{ onlyDownloaded :: Bool,
|
||||||
onlyArtist :: Maybe (Label "artistRedactedId" Natural)
|
onlyArtist :: Maybe (Label "artistRedactedId" Natural),
|
||||||
|
onlyTheseTorrents :: Maybe ([Label "torrentId" Int])
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Find the best torrent for each torrent group (based on the seeding_weight)
|
-- | Find the best torrent for each torrent group (based on the seeding_weight)
|
||||||
|
|
@ -477,6 +621,9 @@ getBestTorrents opts = do
|
||||||
-- filter by artist id
|
-- filter by artist id
|
||||||
AND
|
AND
|
||||||
(?::bool OR (to_jsonb(?::int) <@ (jsonb_path_query_array(full_json_result, '$.artists[*].id'))))
|
(?::bool OR (to_jsonb(?::int) <@ (jsonb_path_query_array(full_json_result, '$.artists[*].id'))))
|
||||||
|
-- filter by torrent ids
|
||||||
|
AND
|
||||||
|
(?::bool OR torrent_id = ANY (?::int[]))
|
||||||
ORDER BY
|
ORDER BY
|
||||||
torrent_group,
|
torrent_group,
|
||||||
-- prefer torrents which we already downloaded
|
-- prefer torrents which we already downloaded
|
||||||
|
|
@ -488,7 +635,12 @@ getBestTorrents opts = do
|
||||||
t.torrent_id,
|
t.torrent_id,
|
||||||
t.seeding_weight,
|
t.seeding_weight,
|
||||||
tg.full_json_result->>'releaseType' AS release_type,
|
tg.full_json_result->>'releaseType' AS release_type,
|
||||||
t.full_json_result->'artists' AS artists,
|
-- TODO: different endpoints handle this differently (e.g. action=search and action=artist), we should unify this while parsing
|
||||||
|
COALESCE(
|
||||||
|
t.full_json_result->'artists',
|
||||||
|
tg.full_json_result->'artists',
|
||||||
|
'[]'::jsonb
|
||||||
|
) as artists,
|
||||||
tg.full_json_result->>'groupName' AS group_name,
|
tg.full_json_result->>'groupName' AS group_name,
|
||||||
tg.full_json_result->>'groupYear' AS group_year,
|
tg.full_json_result->>'groupYear' AS group_year,
|
||||||
t.torrent_file IS NOT NULL AS has_torrent_file,
|
t.torrent_file IS NOT NULL AS has_torrent_file,
|
||||||
|
|
@ -503,9 +655,14 @@ getBestTorrents opts = do
|
||||||
let (onlyArtistB, onlyArtistId) = case opts.onlyArtist of
|
let (onlyArtistB, onlyArtistId) = case opts.onlyArtist of
|
||||||
Nothing -> (True, 0)
|
Nothing -> (True, 0)
|
||||||
Just a -> (False, a.artistRedactedId)
|
Just a -> (False, a.artistRedactedId)
|
||||||
|
let (onlyTheseTorrentsB, onlyTheseTorrents) = case opts.onlyTheseTorrents of
|
||||||
|
Nothing -> (True, PGArray [])
|
||||||
|
Just a -> (False, a <&> (.torrentId) & PGArray)
|
||||||
( opts.onlyDownloaded :: Bool,
|
( opts.onlyDownloaded :: Bool,
|
||||||
onlyArtistB :: Bool,
|
onlyArtistB :: Bool,
|
||||||
onlyArtistId & fromIntegral @Natural @Int
|
onlyArtistId & fromIntegral @Natural @Int,
|
||||||
|
onlyTheseTorrentsB :: Bool,
|
||||||
|
onlyTheseTorrents
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
( do
|
( do
|
||||||
|
|
@ -610,12 +767,12 @@ redactedApiRequestJson span dat parser = do
|
||||||
mkRedactedApiRequest dat
|
mkRedactedApiRequest dat
|
||||||
>>= Http.httpJson defaults parser
|
>>= Http.httpJson defaults parser
|
||||||
|
|
||||||
-- test :: (MonadThrow m, MonadRedacted m, MonadOtel m) => m ()
|
test :: (MonadThrow m, MonadRedacted m, MonadOtel m) => m ()
|
||||||
-- test =
|
test =
|
||||||
-- inSpan' "test" $ \span -> do
|
inSpan' "test" $ \span -> do
|
||||||
-- redactedApiRequestJson
|
redactedApiRequestJson
|
||||||
-- span
|
span
|
||||||
-- (T2 (label @"action" "browse") (label @"actionArgs" [("searchstr", Just "dream theater")]))
|
(T2 (label @"action" "artist") (label @"actionArgs" [("id", Just "2785")]))
|
||||||
-- (Json.asValue)
|
(Json.asValue)
|
||||||
-- <&> Pretty.showPrettyJson
|
<&> Pretty.showPrettyJsonColored
|
||||||
-- >>= liftIO . putStderrLn
|
>>= liftIO . putStderrLn
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import Control.Monad.Reader
|
||||||
import Data.Aeson qualified as Json
|
import Data.Aeson qualified as Json
|
||||||
import Data.Aeson.BetterErrors qualified as Json
|
import Data.Aeson.BetterErrors qualified as Json
|
||||||
import Data.Aeson.KeyMap qualified as KeyMap
|
import Data.Aeson.KeyMap qualified as KeyMap
|
||||||
|
import Data.CaseInsensitive (CI)
|
||||||
import Data.Error.Tree
|
import Data.Error.Tree
|
||||||
import Data.HashMap.Strict qualified as HashMap
|
import Data.HashMap.Strict qualified as HashMap
|
||||||
import Data.List qualified as List
|
import Data.List qualified as List
|
||||||
|
|
@ -100,6 +101,9 @@ htmlUi = do
|
||||||
Left (AppExceptionPretty err) -> do
|
Left (AppExceptionPretty err) -> do
|
||||||
runInIO (logError (err & Pretty.prettyErrsNoColor & stringToText))
|
runInIO (logError (err & Pretty.prettyErrsNoColor & stringToText))
|
||||||
respondOrig (Wai.responseLBS Http.status500 [] "")
|
respondOrig (Wai.responseLBS Http.status500 [] "")
|
||||||
|
Left (AppExceptionEnc err) -> do
|
||||||
|
runInIO (logError (Enc.encToTextPrettyColored err))
|
||||||
|
respondOrig (Wai.responseLBS Http.status500 [] "")
|
||||||
|
|
||||||
catchAppException $ do
|
catchAppException $ do
|
||||||
let mp span parser =
|
let mp span parser =
|
||||||
|
|
@ -132,7 +136,27 @@ htmlUi = do
|
||||||
( do
|
( do
|
||||||
label @"searchstr" <$> Multipart.field "redacted-search" Cat.id
|
label @"searchstr" <$> Multipart.field "redacted-search" Cat.id
|
||||||
)
|
)
|
||||||
snipsRedactedSearch dat
|
t <- redactedSearchAndInsert [("searchstr", dat.searchstr)]
|
||||||
|
runTransaction $ do
|
||||||
|
res <- t
|
||||||
|
table <-
|
||||||
|
getBestTorrentsTable
|
||||||
|
(label @"groupByReleaseType" True)
|
||||||
|
( Just (E21 (label @"onlyTheseTorrents" res.newTorrents)) ::
|
||||||
|
( Maybe
|
||||||
|
( E2
|
||||||
|
"onlyTheseTorrents"
|
||||||
|
[Label "torrentId" Int]
|
||||||
|
"artistRedactedId"
|
||||||
|
Natural
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pure
|
||||||
|
[hsx|
|
||||||
|
<h1>Search results for <pre>{dat.searchstr}</pre></h1>
|
||||||
|
{table}
|
||||||
|
|]
|
||||||
),
|
),
|
||||||
( "snips/redacted/torrentDataJson",
|
( "snips/redacted/torrentDataJson",
|
||||||
respond.html $ \span -> do
|
respond.html $ \span -> do
|
||||||
|
|
@ -226,6 +250,18 @@ htmlUi = do
|
||||||
$ \qry _span -> do
|
$ \qry _span -> do
|
||||||
artistPage qry
|
artistPage qry
|
||||||
),
|
),
|
||||||
|
( "artist/refresh",
|
||||||
|
respond.htmlOrRedirect $
|
||||||
|
\span -> do
|
||||||
|
dat <-
|
||||||
|
mp
|
||||||
|
span
|
||||||
|
(label @"artistId" <$> Multipart.field "artist-id" Field.utf8)
|
||||||
|
t <- redactedRefreshArtist dat
|
||||||
|
runTransaction $ do
|
||||||
|
t
|
||||||
|
pure $ E22 (label @"redirectTo" [fmt|/artist?redacted_id={dat.artistId}|])
|
||||||
|
),
|
||||||
( "autorefresh",
|
( "autorefresh",
|
||||||
respond.plain $ do
|
respond.plain $ do
|
||||||
qry <-
|
qry <-
|
||||||
|
|
@ -264,7 +300,7 @@ htmlUi = do
|
||||||
-- "https://musicbrainz.org/work/92000fd4-d304-406d-aeb4-6bdbeed318ec"
|
-- "https://musicbrainz.org/work/92000fd4-d304-406d-aeb4-6bdbeed318ec"
|
||||||
-- )
|
-- )
|
||||||
-- <&> renderJsonld
|
-- <&> renderJsonld
|
||||||
bestTorrentsTable <- getBestTorrentsTable Nothing
|
bestTorrentsTable <- getBestTorrentsTable (label @"groupByReleaseType" False) Nothing
|
||||||
-- transmissionTorrentsTable <- lift @Transaction getTransmissionTorrentsTable
|
-- transmissionTorrentsTable <- lift @Transaction getTransmissionTorrentsTable
|
||||||
pure $
|
pure $
|
||||||
htmlPageChrome
|
htmlPageChrome
|
||||||
|
|
@ -299,15 +335,23 @@ htmlUi = do
|
||||||
|
|
||||||
-- | Reload the current page (via the Referer header) if the browser has Javascript disabled (and thus htmx does not work). This should make post requests work out of the box.
|
-- | Reload the current page (via the Referer header) if the browser has Javascript disabled (and thus htmx does not work). This should make post requests work out of the box.
|
||||||
htmxOrReferer :: Wai.Request -> Wai.Response -> Wai.Response
|
htmxOrReferer :: Wai.Request -> Wai.Response -> Wai.Response
|
||||||
htmxOrReferer req act = do
|
htmxOrReferer req resp = do
|
||||||
let fnd h = req & Wai.requestHeaders & List.find (\(hdr, _) -> hdr == h)
|
let fnd h = req & Wai.requestHeaders & List.find (\(hdr, _) -> hdr == h)
|
||||||
let referer = fnd "Referer"
|
let referer = fnd "Referer"
|
||||||
if
|
if
|
||||||
| Just _ <- fnd "Hx-Request" -> act
|
| Just _ <- fnd "Hx-Request" -> resp
|
||||||
| Nothing <- referer -> act
|
| Nothing <- referer -> resp
|
||||||
| Just (_, rfr) <- referer -> do
|
| Just (_, rfr) <- referer -> do
|
||||||
Wai.responseLBS seeOther303 [("Location", rfr)] ""
|
Wai.responseLBS seeOther303 [("Location", rfr)] ""
|
||||||
|
|
||||||
|
-- | Redirect to the given page, if the browser has Javascript enabled use HTMX client side redirect, otherwise use a normal HTTP redirect.
|
||||||
|
redirectOrFallback :: ByteString -> (Status -> (CI ByteString, ByteString) -> Wai.Response) -> Wai.Request -> Wai.Response
|
||||||
|
redirectOrFallback target responseFn req = do
|
||||||
|
let fnd h = req & Wai.requestHeaders & List.find (\(hdr, _) -> hdr == h)
|
||||||
|
case fnd "Hx-Request" of
|
||||||
|
Just _ -> responseFn Http.ok200 ("Hx-Redirect", target)
|
||||||
|
Nothing -> responseFn Http.seeOther303 ("Location", target)
|
||||||
|
|
||||||
htmlPageChrome :: (ToHtml a) => Text -> a -> Html
|
htmlPageChrome :: (ToHtml a) => Text -> a -> Html
|
||||||
htmlPageChrome title body =
|
htmlPageChrome title body =
|
||||||
Html.docTypeHtml $
|
Html.docTypeHtml $
|
||||||
|
|
@ -352,9 +396,10 @@ artistPage ::
|
||||||
artistPage dat = runTransaction $ do
|
artistPage dat = runTransaction $ do
|
||||||
fresh <-
|
fresh <-
|
||||||
getBestTorrentsData
|
getBestTorrentsData
|
||||||
(Just $ getLabel @"artistRedactedId" dat)
|
(Just $ E22 (getLabel @"artistRedactedId" dat))
|
||||||
let artistName = fresh & findMaybe (\t -> t.artists & findMaybe (\a -> if a.artistId == (dat.artistRedactedId & fromIntegral @Natural @Int) then Just a.artistName else Nothing))
|
let artistName = fresh & findMaybe (\t -> t.artists & findMaybe (\a -> if a.artistId == (dat.artistRedactedId & fromIntegral @Natural @Int) then Just a.artistName else Nothing))
|
||||||
let torrents = mkBestTorrentsTable (label @"groupByReleaseType" True) fresh
|
let torrents = mkBestTorrentsTable (label @"groupByReleaseType" True) fresh
|
||||||
|
|
||||||
pure $
|
pure $
|
||||||
htmlPageChrome
|
htmlPageChrome
|
||||||
( case artistName of
|
( case artistName of
|
||||||
|
|
@ -362,9 +407,22 @@ artistPage dat = runTransaction $ do
|
||||||
Just a -> [fmt|{a} - Artist Page - whatcd-resolver|]
|
Just a -> [fmt|{a} - Artist Page - whatcd-resolver|]
|
||||||
)
|
)
|
||||||
[hsx|
|
[hsx|
|
||||||
Artist ID: {dat.artistRedactedId}
|
<p>Artist ID: {dat.artistRedactedId}</p>
|
||||||
|
|
||||||
|
<div id="artist-torrents">
|
||||||
{torrents}
|
{torrents}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="artist/refresh" hx-post="artist/refresh">
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
type="text"
|
||||||
|
name="artist-id"
|
||||||
|
value={dat.artistRedactedId & buildText naturalDecimalT}
|
||||||
|
/>
|
||||||
|
<button type="submit" hx-disabled-elt="this">Refresh Artist Page</button>
|
||||||
|
<div class="htmx-indicator">Refreshing!</div>
|
||||||
|
</form>
|
||||||
|]
|
|]
|
||||||
|
|
||||||
type Handlers m = HandlerResponses m -> Map Text (m ResponseReceived)
|
type Handlers m = HandlerResponses m -> Map Text (m ResponseReceived)
|
||||||
|
|
@ -372,6 +430,8 @@ type Handlers m = HandlerResponses m -> Map Text (m ResponseReceived)
|
||||||
data HandlerResponses m = HandlerResponses
|
data HandlerResponses m = HandlerResponses
|
||||||
{ -- | render html
|
{ -- | render html
|
||||||
html :: (Otel.Span -> m Html) -> m ResponseReceived,
|
html :: (Otel.Span -> m Html) -> m ResponseReceived,
|
||||||
|
-- | either render html or redirect to another page
|
||||||
|
htmlOrRedirect :: (Otel.Span -> m (E2 "respond" Html "redirectTo" ByteString)) -> m ResponseReceived,
|
||||||
-- | render html after parsing some query arguments
|
-- | render html after parsing some query arguments
|
||||||
htmlWithQueryArgs :: forall a. (Parse Query a -> (a -> Otel.Span -> m Html) -> m ResponseReceived),
|
htmlWithQueryArgs :: forall a. (Parse Query a -> (a -> Otel.Span -> m Html) -> m ResponseReceived),
|
||||||
-- | render html or reload the page via the Referer header if no htmx
|
-- | render html or reload the page via the Referer header if no htmx
|
||||||
|
|
@ -381,6 +441,7 @@ data HandlerResponses m = HandlerResponses
|
||||||
}
|
}
|
||||||
|
|
||||||
runHandlers ::
|
runHandlers ::
|
||||||
|
forall m.
|
||||||
(MonadOtel m) =>
|
(MonadOtel m) =>
|
||||||
(HandlerResponses m -> m ResponseReceived) ->
|
(HandlerResponses m -> m ResponseReceived) ->
|
||||||
(HandlerResponses m -> Map Text (m ResponseReceived)) ->
|
(HandlerResponses m -> Map Text (m ResponseReceived)) ->
|
||||||
|
|
@ -401,18 +462,26 @@ runHandlers defaultHandler handlers req respond = withRunInIO $ \runInIO -> do
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
( \span -> do
|
( \span -> do
|
||||||
res <- act span <&> (\h -> T2 (label @"html" h) (label @"extraHeaders" []))
|
res <- act span <&> (\h -> label @"html" h)
|
||||||
addEventSimple span "Got Html result, rendering…"
|
addEventSimple span "Got Html result, rendering…"
|
||||||
liftIO $ respond (resp res)
|
liftIO $ respond (resp res)
|
||||||
)
|
)
|
||||||
let htmlResp res = Wai.responseLBS Http.ok200 ([("Content-Type", "text/html")] <> res.extraHeaders) . Html.renderHtml $ res.html
|
let htmlResp res = Wai.responseLBS Http.ok200 ([("Content-Type", "text/html")]) . Html.renderHtml $ res.html
|
||||||
let html = html' htmlResp
|
let html = html' htmlResp
|
||||||
let htmlOrReferer = html' $ \res -> htmxOrReferer req (htmlResp res)
|
let htmlOrReferer = html' $ \res -> htmxOrReferer req (htmlResp res)
|
||||||
|
let htmlOrRedirect :: (Otel.Span -> m (E2 "respond" Html "redirectTo" ByteString)) -> m ResponseReceived
|
||||||
|
htmlOrRedirect = html' $ \res -> case res.html of
|
||||||
|
E21 h -> htmlResp (label @"html" h.respond)
|
||||||
|
E22 r ->
|
||||||
|
redirectOrFallback
|
||||||
|
r.redirectTo
|
||||||
|
(\status header -> Wai.responseLBS status [header] "")
|
||||||
|
req
|
||||||
let handlerResponses =
|
let handlerResponses =
|
||||||
( HandlerResponses
|
( HandlerResponses
|
||||||
{ plain = (\m -> liftIO $ runInIO m >>= respond),
|
{ plain = (\m -> liftIO $ runInIO m >>= respond),
|
||||||
html,
|
html,
|
||||||
|
htmlOrRedirect,
|
||||||
htmlWithQueryArgs = \parser act ->
|
htmlWithQueryArgs = \parser act ->
|
||||||
case req & Parse.runParse "Unable to find the right request query arguments" (lmap Wai.queryString parser) of
|
case req & Parse.runParse "Unable to find the right request query arguments" (lmap Wai.queryString parser) of
|
||||||
Right a -> html (act a)
|
Right a -> html (act a)
|
||||||
|
|
@ -513,26 +582,6 @@ checkException some = case fromException some of
|
||||||
Nothing -> Left some
|
Nothing -> Left some
|
||||||
Just e -> Right e
|
Just e -> Right e
|
||||||
|
|
||||||
snipsRedactedSearch ::
|
|
||||||
( MonadLogger m,
|
|
||||||
MonadPostgres m,
|
|
||||||
HasField "searchstr" r ByteString,
|
|
||||||
MonadThrow m,
|
|
||||||
MonadTransmission m,
|
|
||||||
MonadOtel m,
|
|
||||||
MonadRedacted m
|
|
||||||
) =>
|
|
||||||
r ->
|
|
||||||
m Html
|
|
||||||
snipsRedactedSearch dat = do
|
|
||||||
t <-
|
|
||||||
redactedSearchAndInsert
|
|
||||||
[ ("searchstr", dat.searchstr)
|
|
||||||
]
|
|
||||||
runTransaction $ do
|
|
||||||
t
|
|
||||||
getBestTorrentsTable (Nothing :: Maybe (Label "artistRedactedId" Natural))
|
|
||||||
|
|
||||||
data ArtistFilter = ArtistFilter
|
data ArtistFilter = ArtistFilter
|
||||||
{ onlyArtist :: Maybe (Label "artistId" Text)
|
{ onlyArtist :: Maybe (Label "artistId" Text)
|
||||||
}
|
}
|
||||||
|
|
@ -542,13 +591,15 @@ getBestTorrentsTable ::
|
||||||
MonadThrow m,
|
MonadThrow m,
|
||||||
MonadLogger m,
|
MonadLogger m,
|
||||||
MonadPostgres m,
|
MonadPostgres m,
|
||||||
MonadOtel m
|
MonadOtel m,
|
||||||
|
HasField "groupByReleaseType" opts Bool
|
||||||
) =>
|
) =>
|
||||||
Maybe (Label "artistRedactedId" Natural) ->
|
opts ->
|
||||||
|
Maybe (E2 "onlyTheseTorrents" [Label "torrentId" Int] "artistRedactedId" Natural) ->
|
||||||
Transaction m Html
|
Transaction m Html
|
||||||
getBestTorrentsTable dat = do
|
getBestTorrentsTable opts dat = do
|
||||||
fresh <- getBestTorrentsData dat
|
fresh <- getBestTorrentsData dat
|
||||||
pure $ mkBestTorrentsTable (label @"groupByReleaseType" False) fresh
|
pure $ mkBestTorrentsTable opts fresh
|
||||||
|
|
||||||
doIfJust :: (Applicative f) => (a -> f ()) -> Maybe a -> f ()
|
doIfJust :: (Applicative f) => (a -> f ()) -> Maybe a -> f ()
|
||||||
doIfJust = traverse_
|
doIfJust = traverse_
|
||||||
|
|
@ -560,11 +611,15 @@ getBestTorrentsData ::
|
||||||
MonadPostgres m,
|
MonadPostgres m,
|
||||||
MonadOtel m
|
MonadOtel m
|
||||||
) =>
|
) =>
|
||||||
Maybe (Label "artistRedactedId" Natural) ->
|
Maybe (E2 "onlyTheseTorrents" [Label "torrentId" Int] "artistRedactedId" Natural) ->
|
||||||
Transaction m [TorrentData (Label "percentDone" Percentage)]
|
Transaction m [TorrentData (Label "percentDone" Percentage)]
|
||||||
getBestTorrentsData artistFilter = inSpan' "get torrents table data" $ \span -> do
|
getBestTorrentsData filters = inSpan' "get torrents table data" $ \span -> do
|
||||||
artistFilter & doIfJust (\a -> addAttribute span "artist-filter.redacted-id" (a.artistRedactedId, naturalDecimalT))
|
let onlyArtist = label @"artistRedactedId" <$> (filters >>= getE22 @"artistRedactedId")
|
||||||
let getBest = getBestTorrents GetBestTorrentsFilter {onlyArtist = artistFilter, onlyDownloaded = False}
|
onlyArtist & doIfJust (\a -> addAttribute span "artist-filter.redacted-id" (a.artistRedactedId, naturalDecimalT))
|
||||||
|
let onlyTheseTorrents = filters >>= getE21 @"onlyTheseTorrents"
|
||||||
|
onlyTheseTorrents & doIfJust (\a -> addAttribute span "torrent-filter.ids" (a <&> (getLabel @"torrentId") & showToText & Otel.toAttribute))
|
||||||
|
|
||||||
|
let getBest = getBestTorrents GetBestTorrentsFilter {onlyDownloaded = False, ..}
|
||||||
bestStale :: [TorrentData ()] <- getBest
|
bestStale :: [TorrentData ()] <- getBest
|
||||||
(statusInfo, transmissionStatus) <-
|
(statusInfo, transmissionStatus) <-
|
||||||
getAndUpdateTransmissionTorrentsStatus
|
getAndUpdateTransmissionTorrentsStatus
|
||||||
|
|
@ -589,7 +644,16 @@ getBestTorrentsData artistFilter = inSpan' "get torrents table data" $ \span ->
|
||||||
else pure bestStale
|
else pure bestStale
|
||||||
pure $
|
pure $
|
||||||
bestBest
|
bestBest
|
||||||
& filter (\td -> td.releaseType /= releaseTypeCompilation)
|
-- filter out some kinds we don’t really care about
|
||||||
|
& filter
|
||||||
|
( \td ->
|
||||||
|
td.releaseType
|
||||||
|
`List.notElem` [ releaseTypeCompilation,
|
||||||
|
releaseTypeDJMix,
|
||||||
|
releaseTypeMixtape,
|
||||||
|
releaseTypeRemix
|
||||||
|
]
|
||||||
|
)
|
||||||
-- we have to update the status of every torrent that’s not in tranmission anymore
|
-- we have to update the status of every torrent that’s not in tranmission anymore
|
||||||
-- TODO I feel like it’s easier (& more correct?) to just do the database request again …
|
-- TODO I feel like it’s easier (& more correct?) to just do the database request again …
|
||||||
<&> ( \td -> case td.torrentStatus of
|
<&> ( \td -> case td.torrentStatus of
|
||||||
|
|
@ -603,7 +667,11 @@ getBestTorrentsData artistFilter = inSpan' "get torrents table data" $ \span ->
|
||||||
NoTorrentFileYet -> td {torrentStatus = NoTorrentFileYet}
|
NoTorrentFileYet -> td {torrentStatus = NoTorrentFileYet}
|
||||||
)
|
)
|
||||||
|
|
||||||
mkBestTorrentsTable :: Label "groupByReleaseType" Bool -> [TorrentData (Label "percentDone" Percentage)] -> Html
|
mkBestTorrentsTable ::
|
||||||
|
(HasField "groupByReleaseType" opts Bool) =>
|
||||||
|
opts ->
|
||||||
|
[TorrentData (Label "percentDone" Percentage)] ->
|
||||||
|
Html
|
||||||
mkBestTorrentsTable opts fresh = do
|
mkBestTorrentsTable opts fresh = do
|
||||||
let localTorrent b = case b.torrentStatus of
|
let localTorrent b = case b.torrentStatus of
|
||||||
NoTorrentFileYet ->
|
NoTorrentFileYet ->
|
||||||
|
|
@ -806,7 +874,7 @@ migrate = inSpan "Database Migration" $ do
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
ALTER TABLE redacted.torrents_json
|
ALTER TABLE redacted.torrents_json
|
||||||
ADD COLUMN IF NOT EXISTS seeding_weight int GENERATED ALWAYS AS (calc_seeding_weight(full_json_result)) STORED;
|
ADD COLUMN IF NOT EXISTS seeding_weight int NOT NULL GENERATED ALWAYS AS (calc_seeding_weight(full_json_result)) STORED;
|
||||||
|
|
||||||
-- inflect out values of the full json
|
-- inflect out values of the full json
|
||||||
CREATE OR REPLACE VIEW redacted.torrents AS
|
CREATE OR REPLACE VIEW redacted.torrents AS
|
||||||
|
|
@ -862,6 +930,7 @@ runAppWith appT = withTracer $ \tracer -> withDb $ \db -> do
|
||||||
`catch` ( \case
|
`catch` ( \case
|
||||||
AppExceptionPretty p -> throwM $ EscapedException (p & Pretty.prettyErrs)
|
AppExceptionPretty p -> throwM $ EscapedException (p & Pretty.prettyErrs)
|
||||||
AppExceptionTree t -> throwM $ EscapedException (t & prettyErrorTree & textToString)
|
AppExceptionTree t -> throwM $ EscapedException (t & prettyErrorTree & textToString)
|
||||||
|
AppExceptionEnc e -> throwM $ EscapedException (e & Enc.encToTextPrettyColored & textToString)
|
||||||
)
|
)
|
||||||
|
|
||||||
-- | Just a silly wrapper so that correctly format any 'AppException' that would escape the runAppWith scope.
|
-- | Just a silly wrapper so that correctly format any 'AppException' that would escape the runAppWith scope.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue