commit 6b99cb9e58cdf50b7a03a51f49a8e42b117d97af
parent f055cbd9767fe690d2351bac8a2c794e4164aac0
Author: mtmn <miro@haravara.org>
Date: Sun, 19 Apr 2026 19:47:03 +0200
feat: add search to listens section
Diffstat:
7 files changed, 182 insertions(+), 27 deletions(-)
diff --git a/src/Client.elm b/src/Client.elm
@@ -123,6 +123,8 @@ type alias Model =
, userSlug : String
, similarStates : Dict String SimilarState
, allUsers : List String
+ , searchInput : String
+ , activeSearch : Maybe String
}
@@ -155,9 +157,11 @@ init flags =
, userSlug = flags.userSlug
, similarStates = Dict.empty
, allUsers = flags.allUsers
+ , searchInput = ""
+ , activeSearch = Nothing
}
, Cmd.batch
- [ fetchListens flags.userSlug 25 offset Nothing
+ [ fetchListens flags.userSlug 25 offset Nothing Nothing
, Task.perform GotTime Time.now
]
)
@@ -215,6 +219,9 @@ type Msg
| CollapseSection String
| FetchSimilar String String
| FetchSimilarTracks String String (Result Http.Error (List SimilarTrack))
+ | UpdateSearchInput String
+ | SubmitSearch
+ | ClearSearch
@@ -227,7 +234,7 @@ update msg model =
Tick time ->
if not model.loading then
( { model | currentTime = Just time, loading = True }
- , fetchListens model.userSlug model.limit model.offset model.activeFilter
+ , fetchListens model.userSlug model.limit model.offset model.activeFilter model.activeSearch
)
else
@@ -283,7 +290,7 @@ update msg model =
in
( { model | offset = newOffset, loading = True }
, Cmd.batch
- [ fetchListens model.userSlug model.limit newOffset model.activeFilter
+ [ fetchListens model.userSlug model.limit newOffset model.activeFilter model.activeSearch
, pushUrl ("?page=" ++ String.fromInt page)
]
)
@@ -298,7 +305,7 @@ update msg model =
in
( { model | offset = newOffset, loading = True }
, Cmd.batch
- [ fetchListens model.userSlug model.limit newOffset model.activeFilter
+ [ fetchListens model.userSlug model.limit newOffset model.activeFilter model.activeSearch
, pushUrl ("?page=" ++ String.fromInt page)
]
)
@@ -387,9 +394,9 @@ update msg model =
filter =
Just { field = field, value = value }
in
- ( { model | activeFilter = filter, offset = 0, activeTab = ListensTab, loading = True }
+ ( { model | activeFilter = filter, activeSearch = Nothing, searchInput = "", offset = 0, activeTab = ListensTab, loading = True }
, Cmd.batch
- [ fetchListens model.userSlug model.limit 0 filter
+ [ fetchListens model.userSlug model.limit 0 filter Nothing
, pushUrl "?page=1"
]
)
@@ -397,7 +404,39 @@ update msg model =
ClearFilter ->
( { model | activeFilter = Nothing, offset = 0, loading = True }
, Cmd.batch
- [ fetchListens model.userSlug model.limit 0 Nothing
+ [ fetchListens model.userSlug model.limit 0 Nothing model.activeSearch
+ , pushUrl "?page=1"
+ ]
+ )
+
+ UpdateSearchInput str ->
+ ( { model | searchInput = str }, Cmd.none )
+
+ SubmitSearch ->
+ let
+ q =
+ String.trim model.searchInput
+ in
+ if String.isEmpty q then
+ ( { model | activeSearch = Nothing, offset = 0, loading = True }
+ , Cmd.batch
+ [ fetchListens model.userSlug model.limit 0 Nothing Nothing
+ , pushUrl "?page=1"
+ ]
+ )
+
+ else
+ ( { model | activeSearch = Just q, activeFilter = Nothing, offset = 0, loading = True }
+ , Cmd.batch
+ [ fetchListens model.userSlug model.limit 0 Nothing (Just q)
+ , pushUrl "?page=1"
+ ]
+ )
+
+ ClearSearch ->
+ ( { model | searchInput = "", activeSearch = Nothing, offset = 0, loading = True }
+ , Cmd.batch
+ [ fetchListens model.userSlug model.limit 0 Nothing Nothing
, pushUrl "?page=1"
]
)
@@ -486,8 +525,8 @@ subscriptions _ =
-- HTTP
-fetchListens : String -> Int -> Int -> Maybe ActiveFilter -> Cmd Msg
-fetchListens userSlug limit offset mFilter =
+fetchListens : String -> Int -> Int -> Maybe ActiveFilter -> Maybe String -> Cmd Msg
+fetchListens userSlug limit offset mFilter mSearch =
let
filterParams =
case mFilter of
@@ -497,8 +536,16 @@ fetchListens userSlug limit offset mFilter =
Just { field, value } ->
"&filterField=" ++ field ++ "&filterValue=" ++ Url.percentEncode value
+ searchParam =
+ case mSearch of
+ Nothing ->
+ ""
+
+ Just q ->
+ "&search=" ++ Url.percentEncode q
+
url =
- "/proxy?user=" ++ userSlug ++ "&limit=" ++ String.fromInt limit ++ "&offset=" ++ String.fromInt offset ++ filterParams
+ "/proxy?user=" ++ userSlug ++ "&limit=" ++ String.fromInt limit ++ "&offset=" ++ String.fromInt offset ++ filterParams ++ searchParam
in
Http.get
{ url = url
@@ -736,7 +783,25 @@ view model =
, case model.activeTab of
ListensTab ->
div []
- [ case model.activeFilter of
+ [ div [ class "search-bar" ]
+ [ input
+ [ type_ "text"
+ , class "search-input"
+ , placeholder "search tracks, artists, albums, labels…"
+ , value model.searchInput
+ , onInput UpdateSearchInput
+ , onEnter SubmitSearch
+ ]
+ []
+ , button [ class "search-btn", onClick SubmitSearch ] [ text "search" ]
+ , case model.activeSearch of
+ Just _ ->
+ button [ class "filter-clear", onClick ClearSearch ] [ text "✕ clear" ]
+
+ Nothing ->
+ text ""
+ ]
+ , case model.activeFilter of
Nothing ->
text ""
@@ -1201,6 +1266,22 @@ renderPeriodSelector current showInput customVal mError =
+onEnter : msg -> Html.Attribute msg
+onEnter msg =
+ on "keydown"
+ (D.field "key" D.string
+ |> D.andThen
+ (\key ->
+ if key == "Enter" then
+ D.succeed msg
+
+ else
+ D.fail "not enter"
+ )
+ )
+
+
+
-- HELPERS
diff --git a/src/Db.purs b/src/Db.purs
@@ -177,13 +177,23 @@ upsertScrobble conn (Listen { listenedAt, trackMetadata: TrackMetadata track })
]
run conn "INSERT INTO scrobbles SELECT * FROM (SELECT ? as listened_at, ? as track_name, ? as artist_name, ? as release_name, ? as release_mbid, ? as caa_release_mbid) t WHERE NOT EXISTS (SELECT 1 FROM scrobbles WHERE listened_at = t.listened_at)" params
-getScrobbles :: Connection -> Int -> Int -> Maybe { field :: FilterField, value :: String } -> Aff (Array Listen)
-getScrobbles conn limit offset Nothing = do
+getScrobbles :: Connection -> Int -> Int -> Maybe { field :: FilterField, value :: String } -> Maybe String -> Aff (Array Listen)
+getScrobbles conn limit offset _ (Just q) = do
+ let pattern = "%" <> q <> "%"
+ rows <- queryAll conn
+ ( "SELECT s.listened_at, s.track_name, s.artist_name, s.release_name, s.release_mbid, s.caa_release_mbid, rm.genre"
+ <> " FROM scrobbles s LEFT JOIN release_metadata rm ON s.release_mbid = rm.release_mbid"
+ <> " WHERE (s.track_name ILIKE ? OR s.artist_name ILIKE ? OR s.release_name ILIKE ? OR rm.label ILIKE ?)"
+ <> " ORDER BY s.listened_at DESC LIMIT ? OFFSET ?"
+ )
+ [ unsafeCoerce pattern, unsafeCoerce pattern, unsafeCoerce pattern, unsafeCoerce pattern, unsafeCoerce limit, unsafeCoerce offset ]
+ pure $ mapMaybe rowToListen rows
+getScrobbles conn limit offset Nothing Nothing = do
rows <- queryAll conn
"SELECT s.listened_at, s.track_name, s.artist_name, s.release_name, s.release_mbid, s.caa_release_mbid, rm.genre FROM scrobbles s LEFT JOIN release_metadata rm ON s.release_mbid = rm.release_mbid ORDER BY s.listened_at DESC LIMIT ? OFFSET ?"
[ unsafeCoerce limit, unsafeCoerce offset ]
pure $ mapMaybe rowToListen rows
-getScrobbles conn limit offset (Just { field, value }) = do
+getScrobbles conn limit offset (Just { field, value }) Nothing = do
rows <- queryAll conn (filterQuery field) [ unsafeCoerce value, unsafeCoerce limit, unsafeCoerce offset ]
pure $ mapMaybe rowToListen rows
diff --git a/src/Main.purs b/src/Main.purs
@@ -597,7 +597,8 @@ serveProxy db url res = do
value <- getQueryParam "filterValue" url
pure { field, value }
- listens <- getScrobbles db limit offset mFilter
+ let mSearch = getQueryParam "search" url
+ listens <- getScrobbles db limit offset mFilter mSearch
let responseBody = stringify $ encodeJson { payload: { listens: listens } }
liftEffect $ do
diff --git a/src/Templates.purs b/src/Templates.purs
@@ -568,6 +568,50 @@ indexHtml userSlug allUsers =
margin: 0;
}
+ .search-bar {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 12px;
+ align-items: center;
+ }
+
+ .search-input {
+ flex: 1;
+ background: #521e40;
+ border: 1px solid #50447f;
+ color: #ffffff;
+ padding: 6px 10px;
+ border-radius: 4px;
+ font-family: inherit;
+ font-size: 13px;
+ }
+
+ .search-input::placeholder {
+ color: #9fbfe7;
+ opacity: 0.7;
+ }
+
+ .search-input:focus {
+ outline: none;
+ border-color: #ffffff;
+ }
+
+ .search-btn {
+ background: #50447f;
+ border: 1px solid #50447f;
+ color: #ffffff;
+ padding: 6px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 12px;
+ }
+
+ .search-btn:hover {
+ background: #6a5fa0;
+ border-color: #6a5fa0;
+ }
+
.about-description {
color: #a0c0d0;
font-size: 13px;
diff --git a/test/Main.purs b/test/Main.purs
@@ -140,12 +140,12 @@ main = runSpecAndExitProcess [consoleReporter] do
exists2 <- checkExists conn 12345
exists2 `shouldEqual` true
- listens <- getScrobbles conn 10 0 Nothing
+ listens <- getScrobbles conn 10 0 Nothing Nothing
length listens `shouldEqual` 1
upsertReleaseMetadata conn "rb1" (Just "Rock") (Just "Label") (Just 2023)
- listensWithGenre <- getScrobbles conn 10 0 Nothing
+ listensWithGenre <- getScrobbles conn 10 0 Nothing Nothing
case listensWithGenre of
[Listen { trackMetadata: TrackMetadata m }] -> m.genre `shouldEqual` Just "Rock"
_ -> fail "Expected 1 listen"
@@ -158,10 +158,10 @@ main = runSpecAndExitProcess [consoleReporter] do
length s.tracks `shouldEqual` 1
-- Test Filtering (as mentioned in architecture.md)
- listensFiltered <- getScrobbles conn 10 0 (Just { field: FilterGenre, value: "Rock" })
+ listensFiltered <- getScrobbles conn 10 0 (Just { field: FilterGenre, value: "Rock" }) Nothing
length listensFiltered `shouldEqual` 1
- listensEmpty <- getScrobbles conn 10 0 (Just { field: FilterGenre, value: "Jazz" })
+ listensEmpty <- getScrobbles conn 10 0 (Just { field: FilterGenre, value: "Jazz" }) Nothing
length listensEmpty `shouldEqual` 0
it "upsertScrobble is idempotent" do
@@ -180,7 +180,7 @@ main = runSpecAndExitProcess [consoleReporter] do
}
upsertScrobble conn listen
upsertScrobble conn listen
- listens <- getScrobbles conn 10 0 Nothing
+ listens <- getScrobbles conn 10 0 Nothing Nothing
length listens `shouldEqual` 1
describe "getScrobbles filter variants" do
@@ -201,9 +201,9 @@ main = runSpecAndExitProcess [consoleReporter] do
}
upsertScrobble conn (mkListen 1 "Alpha")
upsertScrobble conn (mkListen 2 "Beta")
- listens <- getScrobbles conn 10 0 (Just { field: FilterArtist, value: "Alpha" })
+ listens <- getScrobbles conn 10 0 (Just { field: FilterArtist, value: "Alpha" }) Nothing
length listens `shouldEqual` 1
- listensNone <- getScrobbles conn 10 0 (Just { field: FilterArtist, value: "Gamma" })
+ listensNone <- getScrobbles conn 10 0 (Just { field: FilterArtist, value: "Gamma" }) Nothing
length listensNone `shouldEqual` 0
it "filters by label" do
@@ -223,9 +223,9 @@ main = runSpecAndExitProcess [consoleReporter] do
}
)
upsertReleaseMetadata conn "mb-label" Nothing (Just "Warp") (Just 2000)
- listens <- getScrobbles conn 10 0 (Just { field: FilterLabel, value: "Warp" })
+ listens <- getScrobbles conn 10 0 (Just { field: FilterLabel, value: "Warp" }) Nothing
length listens `shouldEqual` 1
- listensNone <- getScrobbles conn 10 0 (Just { field: FilterLabel, value: "Columbia" })
+ listensNone <- getScrobbles conn 10 0 (Just { field: FilterLabel, value: "Columbia" }) Nothing
length listensNone `shouldEqual` 0
it "filters by year" do
@@ -245,9 +245,9 @@ main = runSpecAndExitProcess [consoleReporter] do
}
)
upsertReleaseMetadata conn "mb-year" Nothing Nothing (Just 1994)
- listens <- getScrobbles conn 10 0 (Just { field: FilterYear, value: "1994" })
+ listens <- getScrobbles conn 10 0 (Just { field: FilterYear, value: "1994" }) Nothing
length listens `shouldEqual` 1
- listensNone <- getScrobbles conn 10 0 (Just { field: FilterYear, value: "1999" })
+ listensNone <- getScrobbles conn 10 0 (Just { field: FilterYear, value: "1999" }) Nothing
length listensNone `shouldEqual` 0
describe "getOldestTs" do
@@ -497,7 +497,7 @@ main = runSpecAndExitProcess [consoleReporter] do
upsertScrobble conn listen
exists <- checkExists conn 1700000000
exists `shouldEqual` true
- listens <- getScrobbles conn 10 0 Nothing
+ listens <- getScrobbles conn 10 0 Nothing Nothing
length listens `shouldEqual` 1
describe "Corpus S3" do
diff --git a/users.dhall b/users.dhall
@@ -27,5 +27,13 @@ in { users =
with databaseFile = "corpus-mtmn.db"
with backupEnabled = True
}
+ , { slug = "mtmnn"
+ , config =
+ defaults
+ with lastfmUser = Some "mtmnn"
+ with databaseFile = "corpus-lastfm-mtmnn.db"
+ with backupEnabled = True
+ with initialSync = True
+ }
]
}
diff --git a/users.json b/users.json
@@ -10,6 +10,17 @@
"listenbrainzUser": "mtmn"
},
"slug": ""
+ },
+ {
+ "config": {
+ "backupEnabled": true,
+ "backupIntervalHours": 1,
+ "coverCacheEnabled": true,
+ "databaseFile": "corpus-lastfm-mtmnn.db",
+ "initialSync": true,
+ "lastfmUser": "mtmnn"
+ },
+ "slug": "mtmnn"
}
]
}