feat(users/Profpatsch/whatcd-resolver): serve torrent files

We want to be able to play the files directly from the web
browser (jukebox).

Luckily, transmission does not seem to change the filenames from the
ones given by the torrent file, so we can literally parse the torrent
file and construct a path to the media file, extraordinary.

Adjusts the caddy reverse proxy to serve the given transmission
directory (using my weird sshfs forwarding scheme in the shell.nix
preset lol), then redirect from a handler that maps from
torrentId/fileId to the actual file.

Change-Id: Iab5faf7cc06066f3253031af31e137c0e28f54e3
Reviewed-on: https://cl.tvl.fyi/c/depot/+/13270
Tested-by: BuildkiteCI
Reviewed-by: Profpatsch <mail@profpatsch.de>
This commit is contained in:
Profpatsch 2025-03-15 20:02:36 +01:00
parent 10c8f3386b
commit 498c8e05f8
7 changed files with 164 additions and 18 deletions

View file

@ -120,6 +120,7 @@ module MyPrelude
zipWith3NonEmpty, zipWith3NonEmpty,
zip4NonEmpty, zip4NonEmpty,
toList, toList,
atMay,
lengthNatural, lengthNatural,
maximum1, maximum1,
minimum1, minimum1,
@ -236,6 +237,7 @@ import Data.Text.Lazy.Encoding qualified
import Data.These (These (That, These, This)) import Data.These (These (That, These, This))
import Data.Traversable (for) import Data.Traversable (for)
import Data.Vector (Vector) import Data.Vector (Vector)
import Data.Vector qualified as Vector
import Data.Void (Void, absurd) import Data.Void (Void, absurd)
import Data.Word (Word8) import Data.Word (Word8)
import Divisive import Divisive
@ -452,13 +454,26 @@ zip4NonEmpty ~(a :| as) ~(b :| bs) ~(c :| cs) ~(d :| ds) = (a, b, c, d) :| zip4
-- | We dont want to use Foldables `length`, because it is too polymorphic and can lead to bugs. -- | We dont want to use Foldables `length`, because it is too polymorphic and can lead to bugs.
-- Only list-y things should have a length. -- Only list-y things should have a length.
class (Foldable f) => Lengthy f class (Foldable f) => Lengthy f where
atMay :: Natural -> f a -> Maybe a
atMay = atMayDefault (\idx' xs -> xs & toList & (!! idx'))
{-# INLINE atMay #-}
atMayDefault :: (Lengthy f) => (Int -> f a -> a) -> Natural -> f a -> Maybe a
atMayDefault lookupF idx f = do
let midx = integerToBounded @Int (idx & naturalToInteger)
if
| idx >= lengthNatural f -> Nothing
| Nothing <- midx -> Nothing
| Just idx' <- midx -> f & lookupF idx' & Just
{-# INLINE atMayDefault #-}
instance Lengthy [] instance Lengthy []
instance Lengthy NonEmpty instance Lengthy NonEmpty
instance Lengthy Vector instance Lengthy Vector where
atMay = atMayDefault (\idx' xs -> xs & (Vector.! idx'))
lengthNatural :: (Lengthy f) => f a -> Natural lengthNatural :: (Lengthy f) => f a -> Natural
lengthNatural xs = lengthNatural xs =

View file

@ -12,6 +12,7 @@ import FieldParser (FieldParser)
import FieldParser qualified as Field import FieldParser qualified as Field
import Json qualified import Json qualified
import Label import Label
import Parse (Parse, runParse)
import PossehlAnalyticsPrelude import PossehlAnalyticsPrelude
-- | A Decoder of postgres values. Allows embedding more complex parsers (like a 'Json.ParseT'). -- | A Decoder of postgres values. Allows embedding more complex parsers (like a 'Json.ParseT').
@ -36,21 +37,21 @@ textMay = fromField @(Maybe Text)
-- | Parse a `text` field, and then use a 'FieldParser' to convert the result further. -- | Parse a `text` field, and then use a 'FieldParser' to convert the result further.
textParse :: (Typeable to) => FieldParser Text to -> Decoder to textParse :: (Typeable to) => FieldParser Text to -> Decoder to
textParse = parse @Text textParse = parseField @Text
-- | Parse a nullable `text` field, and then use a 'FieldParser' to convert the result further. -- | Parse a nullable `text` field, and then use a 'FieldParser' to convert the result further.
textParseMay :: (Typeable to) => FieldParser Text to -> Decoder (Maybe to) textParseMay :: (Typeable to) => FieldParser Text to -> Decoder (Maybe to)
textParseMay = parseMay @Text textParseMay = parseFieldMay @Text
-- | Parse a type implementing 'FromField', and then use a 'FieldParser' to convert the result further. -- | Parse a type implementing 'FromField', and then use a 'FieldParser' to convert the result further.
parse :: parseField ::
forall from to. forall from to.
( PG.FromField from, ( PG.FromField from,
Typeable to Typeable to
) => ) =>
FieldParser from to -> FieldParser from to ->
Decoder to Decoder to
parse parser = Decoder $ PG.fieldWith $ \field bytes -> do parseField parser = Decoder $ PG.fieldWith $ \field bytes -> do
val <- PG.fromField @from field bytes val <- PG.fromField @from field bytes
case Field.runFieldParser parser val of case Field.runFieldParser parser val of
Left err -> Left err ->
@ -61,14 +62,14 @@ parse parser = Decoder $ PG.fieldWith $ \field bytes -> do
Right a -> pure a Right a -> pure a
-- | Parse a nullable type implementing 'FromField', and then use a 'FieldParser' to convert the result further. -- | Parse a nullable type implementing 'FromField', and then use a 'FieldParser' to convert the result further.
parseMay :: parseFieldMay ::
forall from to. forall from to.
( PG.FromField from, ( PG.FromField from,
Typeable to Typeable to
) => ) =>
FieldParser from to -> FieldParser from to ->
Decoder (Maybe to) Decoder (Maybe to)
parseMay parser = Decoder $ PG.fieldWith $ \field bytes -> do parseFieldMay parser = Decoder $ PG.fieldWith $ \field bytes -> do
val <- PG.fromField @(Maybe from) field bytes val <- PG.fromField @(Maybe from) field bytes
case Field.runFieldParser parser <$> val of case Field.runFieldParser parser <$> val of
Nothing -> pure Nothing Nothing -> pure Nothing
@ -79,6 +80,43 @@ parseMay parser = Decoder $ PG.fieldWith $ \field bytes -> do
(err & prettyError & textToString) (err & prettyError & textToString)
Just (Right a) -> pure (Just a) Just (Right a) -> pure (Just a)
-- | Parse a type implementing 'FromField', and then use a 'Parse' to convert the result further.
parse ::
forall from to.
( PG.FromField from,
Typeable to
) =>
Parse from to ->
Decoder to
parse parser = Decoder $ PG.fieldWith $ \field bytes -> do
val <- PG.fromField @from field bytes
case Parse.runParse "Cannot parse field" parser val of
Left err ->
PG.returnError
PG.ConversionFailed
field
(err & prettyErrorTree & textToString)
Right a -> pure a
-- | Parse a nullable type implementing 'FromField', and then use a 'Parse' to convert the result further.
parseMay ::
forall from to.
( PG.FromField from,
Typeable to
) =>
Parse from to ->
Decoder (Maybe to)
parseMay parser = Decoder $ PG.fieldWith $ \field bytes -> do
val <- PG.fromField @(Maybe from) field bytes
case Parse.runParse "Cannot parse field" parser <$> val of
Nothing -> pure Nothing
Just (Left err) ->
PG.returnError
PG.ConversionFailed
field
(err & prettyErrorTree & textToString)
Just (Right a) -> pure (Just a)
-- | Turn any type that implements 'PG.fromField' into a 'Decoder'. Use type applications to prevent accidental conversions: -- | Turn any type that implements 'PG.fromField' into a 'Decoder'. Use type applications to prevent accidental conversions:
-- --
-- @ -- @

View file

@ -85,6 +85,7 @@ pkgs.mkShell {
path = "${pkgs.pgformatter}/bin/pg_format"; path = "${pkgs.pgformatter}/bin/pg_format";
} }
]; ];
WHATCD_RESOLVER_TRANSMISSION_DOWNLOAD_DIRECTORY = "/home/philip/tmp/a/seeding";
# DECLIB_MASTODON_ACCESS_TOKEN read from `pass` in .envrc. # DECLIB_MASTODON_ACCESS_TOKEN read from `pass` in .envrc.

View file

@ -0,0 +1,17 @@
{
# Global options block
auto_https off
}
:9092 {
# Serve files from the directory specified by WHATCD_RESOLVER_TRANSMISSION_DOWNLOAD_DIRECTORY
handle_path /files/* {
root * {env.WHATCD_RESOLVER_TRANSMISSION_DOWNLOAD_DIRECTORY}
file_server {
browse off
}
}
# Reverse proxy from localhost:9092 to localhost:9093
reverse_proxy * localhost:9093
}

View file

@ -1,2 +1,2 @@
#!/usr/bin/env execlineb #!/usr/bin/env execlineb
caddy reverse-proxy --from :9092 --to :9093 caddy run --config ./caddyfile

View file

@ -26,6 +26,7 @@ import Data.Text.IO qualified as Text.IO
import Data.Time (NominalDiffTime, UTCTime) import Data.Time (NominalDiffTime, UTCTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import Database.PostgreSQL.Simple (Binary (Binary), Only (..)) import Database.PostgreSQL.Simple (Binary (Binary), Only (..))
import Database.PostgreSQL.Simple qualified as Postgres
import Database.PostgreSQL.Simple.Types (PGArray (PGArray)) import Database.PostgreSQL.Simple.Types (PGArray (PGArray))
import FieldParser (FieldParser) import FieldParser (FieldParser)
import FieldParser qualified as Field import FieldParser qualified as Field
@ -46,6 +47,7 @@ import Postgres.MonadPostgres
import Pretty import Pretty
import RevList (RevList) import RevList (RevList)
import RevList qualified import RevList qualified
import System.FilePath ((</>))
import UnliftIO (MonadUnliftIO, askRunInIO, async, newQSem, withQSem) import UnliftIO (MonadUnliftIO, askRunInIO, async, newQSem, withQSem)
import UnliftIO.Async (Async) import UnliftIO.Async (Async)
import UnliftIO.Async qualified as Async import UnliftIO.Async qualified as Async
@ -1141,3 +1143,26 @@ bencodeTorrentParser =
source <- mapLookupMay "source" bencodeTextLenient source <- mapLookupMay "source" bencodeTextLenient
pure Info {..} pure Info {..}
pure TorrentFile {..} pure TorrentFile {..}
getTorrentFilePath :: (MonadPostgres m, HasField "torrentId" dat Int, HasField "fileId" dat Natural) => dat -> m (Maybe FilePath)
getTorrentFilePath dat = do
mTorrent <-
runTransaction $
queryFirstRowWithMaybe
[fmt|
SELECT torrent_file FROM redacted.torrents
WHERE torrent_file IS NOT NULL
AND torrent_id = ?::int
|]
( Only $ (dat.torrentId :: Int)
)
( Dec.parse @(Postgres.Binary ByteString)
(lmap (Postgres.fromBinary) parseBencode >>> bencodeTorrentParser)
)
if
| Just torrent <- mTorrent ->
pure $
torrent.info.files
& atMay dat.fileId
<&> (\f -> (torrent.info.name & textToString) </> (f.path <&> textToString & foldl' (</>) ""))
| otherwise -> pure Nothing

View file

@ -292,10 +292,7 @@ htmlUi = do
( label @"artistRedactedId" ( label @"artistRedactedId"
<$> ( singleQueryArgument <$> ( singleQueryArgument
"redacted_id" "redacted_id"
( Field.utf8 parseRedactedId
>>> (Field.decimalNatural <&> toInteger)
>>> (Field.bounded @Int "Int")
)
) )
) )
$ \dat _span -> $ \dat _span ->
@ -329,14 +326,31 @@ htmlUi = do
( label @"artistId" ( label @"artistId"
<$> Multipart.field <$> Multipart.field
"artist-id" "artist-id"
( Field.utf8 parseRedactedId
>>> (Field.decimalNatural <&> toInteger)
>>> (Field.bounded @Int "Int")
)
) )
runTransaction $ redactedRefreshArtist dat runTransaction $ redactedRefreshArtist dat
pure $ E22 (label @"redirectTo" $ textToBytesUtf8 $ mkArtistLink dat) pure $ E22 (label @"redirectTo" $ textToBytesUtf8 $ mkArtistLink dat)
), ),
( "serve/torrent",
HtmlWithQueryArgsRedirect
( do
torrentId <- singleQueryArgument "torrent-id" parseRedactedId
fileId <- singleQueryArgument "file-id" (Field.utf8 >>> Field.decimalNatural)
pure $ t2 #torrentId torrentId #fileId fileId
)
( \dat _span -> do
let transmissionTorrentEndpoint :: Text
transmissionTorrentEndpoint = "/files"
mFilePath <- getTorrentFilePath dat.queryArgs
case mFilePath of
Nothing -> do
pure $ e21 #err "Torrent file not found"
Just filePath -> do
let redirectPath = transmissionTorrentEndpoint <> "/" <> (filePath & stringToText)
pure $ e22 #redirectTo (textToBytesUtf8 redirectPath)
)
),
( "autorefresh", ( "autorefresh",
Plain $ do Plain $ do
qry <- qry <-
@ -414,6 +428,13 @@ htmlUi = do
} }
) )
parseRedactedId :: Field.FieldParser' Error ByteString Int
parseRedactedId =
( Field.utf8
>>> (Field.decimalNatural <&> toInteger)
>>> (Field.bounded @Int "Int")
)
data MainHtml = MainHtml data MainHtml = MainHtml
{ returnUrl :: ByteString, { returnUrl :: ByteString,
counterHtml :: Html, counterHtml :: Html,
@ -622,6 +643,8 @@ data HandlerResponse m where
HtmlOrRedirect :: (Otel.Span -> m (E2 "respond" Html "redirectTo" ByteString)) -> HandlerResponse m HtmlOrRedirect :: (Otel.Span -> m (E2 "respond" Html "redirectTo" ByteString)) -> HandlerResponse m
-- | render html after parsing some query arguments -- | render html after parsing some query arguments
HtmlWithQueryArgs :: Parse Query a -> (QueryArgsDat a -> Otel.Span -> m Html) -> HandlerResponse m HtmlWithQueryArgs :: Parse Query a -> (QueryArgsDat a -> Otel.Span -> m Html) -> HandlerResponse m
-- | Redirect (HTTP 302) to the given path or show 404 with error message
HtmlWithQueryArgsRedirect :: Parse Query a -> (QueryArgsDat a -> Otel.Span -> m (E2 "err" Error "redirectTo" ByteString)) -> HandlerResponse m
-- | 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
HtmlOrReferer :: (Otel.Span -> m Html) -> HandlerResponse m HtmlOrReferer :: (Otel.Span -> m Html) -> HandlerResponse m
-- | render html and stream the head before even doing any work in the handler -- | render html and stream the head before even doing any work in the handler
@ -673,6 +696,14 @@ runHandlers defaultHandler handlers req respond = withRunInIO $ \runInIO -> do
r.redirectTo r.redirectTo
(\status header -> Wai.responseLBS status [header] "") (\status header -> Wai.responseLBS status [header] "")
req req
let redirectOr404 :: (Otel.Span -> m (E2 "err" Error "redirectTo" ByteString)) -> m ResponseReceived
redirectOr404 = html' $ \res -> case res.html of
E21 h ->
Wai.responseLBS
Http.notFound404
[("Content-Type", "text/plain")]
(h.err & prettyError & textToBytesUtf8 & toLazyBytes)
E22 r -> Wai.responseLBS Http.seeOther303 [("Location", r.redirectTo)] ""
let postAndRedirect :: let postAndRedirect ::
MultipartParseT m dat -> MultipartParseT m dat ->
(Otel.Span -> dat -> m (Label "redirectTo" ByteString)) -> (Otel.Span -> dat -> m (Label "redirectTo" ByteString)) ->
@ -708,6 +739,10 @@ runHandlers defaultHandler handlers req respond = withRunInIO $ \runInIO -> do
Right dat -> html (act dat) Right dat -> html (act dat)
Left act' -> html act' Left act' -> html act'
let htmlWithQueryArgsRedirect parser act = case htmlWithQueryArgs' parser of
Right dat -> redirectOr404 (act dat)
Left act' -> html act'
let htmlStream :: Parse Query a -> (QueryArgsDat a -> Otel.Span -> (m HtmlHead, m Html)) -> m ResponseReceived let htmlStream :: Parse Query a -> (QueryArgsDat a -> Otel.Span -> (m HtmlHead, m Html)) -> m ResponseReceived
htmlStream parser act = inRouteSpan $ \span -> do htmlStream parser act = inRouteSpan $ \span -> do
case htmlWithQueryArgs' parser of case htmlWithQueryArgs' parser of
@ -746,6 +781,7 @@ runHandlers defaultHandler handlers req respond = withRunInIO $ \runInIO -> do
Html act -> html act Html act -> html act
HtmlOrRedirect act -> htmlOrRedirect act HtmlOrRedirect act -> htmlOrRedirect act
HtmlWithQueryArgs parser act -> htmlWithQueryArgs parser act HtmlWithQueryArgs parser act -> htmlWithQueryArgs parser act
HtmlWithQueryArgsRedirect parser act -> htmlWithQueryArgsRedirect parser act
HtmlOrReferer act -> htmlOrReferer act HtmlOrReferer act -> htmlOrReferer act
HtmlStream parser act -> htmlStream parser act HtmlStream parser act -> htmlStream parser act
PostAndRedirect mParser act -> mParser >>= \parser -> postAndRedirect parser act PostAndRedirect mParser act -> mParser >>= \parser -> postAndRedirect parser act
@ -1185,11 +1221,25 @@ runAppWith appT = withTracer $ \tracer -> withDb $ \db -> do
Nothing -> runStderrLoggingT $ do Nothing -> runStderrLoggingT $ do
logInfo "WHATCD_RESOLVER_REDACTED_API_KEY was not set, trying pass" logInfo "WHATCD_RESOLVER_REDACTED_API_KEY was not set, trying pass"
runCommandExpect0 "pass" ["internet/redacted/api-keys/whatcd-resolver"] runCommandExpect0 "pass" ["internet/redacted/api-keys/whatcd-resolver"]
transmissionDownloadDirectory <- do
mPath <- Env.lookupEnv "WHATCD_RESOLVER_TRANSMISSION_DOWNLOAD_DIRECTORY"
case mPath of
Nothing -> pure $ Left [fmt|WHATCD_RESOLVER_TRANSMISSION_DOWNLOAD_DIRECTORY not set, no file streaming available|]
Just path -> do
Dir.doesDirectoryExist path >>= \case
False -> pure $ Left [fmt|WHATCD_RESOLVER_TRANSMISSION_DOWNLOAD_DIRECTORY directory does not exist: {path}, no file streaming available|]
True -> pure $ Right path
let newAppT = do let newAppT = do
logInfo [fmt|Running with config: {showPretty pgConfig}|] logInfo [fmt|Running with config: {showPretty pgConfig}|]
logInfo [fmt|Connected to database at {db & TmpPg.toDataDirectory} on socket {db & TmpPg.toConnectionString}|] logInfo [fmt|Connected to database at {db & TmpPg.toDataDirectory} on socket {db & TmpPg.toConnectionString}|]
case transmissionDownloadDirectory of
Left errmsg -> logInfo errmsg
Right dir -> logInfo [fmt|Streaming torrent files from {dir}|]
appT appT
runReaderT newAppT.unAppT Context {..} runReaderT
newAppT.unAppT
Context {..}
`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)