corpus

self-hosted listenbrainz and last.fm proxy
Log | Files | Refs | README | LICENSE

State.elm (16021B)


      1 module State exposing (init, subscriptions, update)
      2 
      3 import Api exposing (fetchListens, fetchSectionData, fetchSimilarTracks, fetchStats, httpErrorToString)
      4 import Browser
      5 import Browser.Navigation as Nav
      6 import Dict
      7 import Set
      8 import Task
      9 import Time
     10 import Types exposing (Flags, Model, Msg(..), Period(..), SimilarState(..), Stats, StatsEntry, Tab(..))
     11 import Url
     12 import Url.Parser exposing ((</>), (<?>))
     13 import Url.Parser.Query as Query
     14 
     15 
     16 init : Flags -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
     17 init flags url navKey =
     18     let
     19         page =
     20             parsePageParam url
     21 
     22         offset =
     23             max 0 ((page - 1) * 25)
     24     in
     25     ( { navKey = navKey
     26       , listens = []
     27       , stats = Nothing
     28       , error = Nothing
     29       , loading = True
     30       , currentTime = Nothing
     31       , failedCovers = Set.empty
     32       , hoveredCover = Nothing
     33       , expandedSections = Set.empty
     34       , loadedSections = Set.empty
     35       , offset = offset
     36       , limit = 25
     37       , activeTab = ListensTab
     38       , activeFilter = Nothing
     39       , statsPeriod = AllTime
     40       , customInput = ""
     41       , showCustomInput = False
     42       , customError = Nothing
     43       , userSlug = flags.userSlug
     44       , similarStates = Dict.empty
     45       , allUsers = flags.allUsers
     46       , searchInput = ""
     47       , activeSearch = Nothing
     48       , tabsVisible = False
     49       }
     50     , Cmd.batch
     51         [ fetchListens flags.userSlug 25 offset Nothing Nothing
     52         , Task.perform GotTime Time.now
     53         ]
     54     )
     55 
     56 
     57 parsePageParam : Url.Url -> Int
     58 parsePageParam url =
     59     let
     60         pageParser =
     61             Url.Parser.oneOf
     62                 [ Url.Parser.top <?> Query.int "page"
     63                 , (Url.Parser.s "u" </> Url.Parser.string <?> Query.int "page") |> Url.Parser.map (\_ p -> p)
     64                 ]
     65     in
     66     Url.Parser.parse pageParser url
     67         |> Maybe.andThen identity
     68         |> Maybe.withDefault 1
     69 
     70 
     71 
     72 -- MSG
     73 
     74 
     75 update : Msg -> Model -> ( Model, Cmd Msg )
     76 update msg model =
     77     case msg of
     78         UrlRequested urlRequest ->
     79             case urlRequest of
     80                 Browser.Internal url ->
     81                     ( model, Nav.pushUrl model.navKey (Url.toString url) )
     82 
     83                 Browser.External href ->
     84                     ( model, Nav.load href )
     85 
     86         UrlChanged url ->
     87             let
     88                 newUserSlug =
     89                     if String.startsWith "/u/" url.path then
     90                         String.dropLeft 3 url.path
     91 
     92                     else
     93                         ""
     94 
     95                 page =
     96                     parsePageParam url
     97 
     98                 targetOffset =
     99                     max 0 ((page - 1) * model.limit)
    100 
    101                 isNaked =
    102                     url.query == Nothing
    103 
    104                 userChanged =
    105                     newUserSlug /= model.userSlug
    106 
    107                 needsCleanStart =
    108                     userChanged || isNaked
    109 
    110                 finalFilter =
    111                     if needsCleanStart then
    112                         Nothing
    113 
    114                     else
    115                         model.activeFilter
    116 
    117                 finalSearch =
    118                     if needsCleanStart then
    119                         Nothing
    120 
    121                     else
    122                         model.activeSearch
    123 
    124                 finalOffset =
    125                     if needsCleanStart then
    126                         0
    127 
    128                     else
    129                         targetOffset
    130             in
    131             if userChanged || model.offset /= finalOffset || model.activeFilter /= finalFilter || model.activeSearch /= finalSearch then
    132                 let
    133                     newModel =
    134                         { model
    135                             | userSlug = newUserSlug
    136                             , offset = finalOffset
    137                             , activeFilter = finalFilter
    138                             , activeSearch = finalSearch
    139                             , searchInput =
    140                                 if needsCleanStart then
    141                                     ""
    142 
    143                                 else
    144                                     model.searchInput
    145                             , activeTab = ListensTab
    146                             , loading = True
    147                         }
    148 
    149                     clearedModel =
    150                         if userChanged then
    151                             { newModel
    152                                 | stats = Nothing
    153                                 , expandedSections = Set.empty
    154                                 , loadedSections = Set.empty
    155                                 , similarStates = Dict.empty
    156                             }
    157 
    158                         else
    159                             newModel
    160                 in
    161                 ( clearedModel
    162                 , fetchListens newUserSlug model.limit finalOffset finalFilter finalSearch
    163                 )
    164 
    165             else
    166                 ( { model | activeTab = ListensTab }, Cmd.none )
    167 
    168         Tick time ->
    169             if not model.loading then
    170                 ( { model | currentTime = Just time, loading = True }
    171                 , fetchListens model.userSlug model.limit model.offset model.activeFilter model.activeSearch
    172                 )
    173 
    174             else
    175                 ( { model | currentTime = Just time }, Cmd.none )
    176 
    177         GotTime time ->
    178             ( { model | currentTime = Just time }, Cmd.none )
    179 
    180         GotListens result ->
    181             let
    182                 newModel =
    183                     case result of
    184                         Err err ->
    185                             { model | loading = False, error = Just (httpErrorToString err) }
    186 
    187                         Ok listens ->
    188                             { model | loading = False, listens = listens, error = Nothing }
    189             in
    190             ( newModel, Task.perform GotTime Time.now )
    191 
    192         GotStats result ->
    193             case result of
    194                 Err err ->
    195                     ( { model | error = Just (httpErrorToString err) }, Cmd.none )
    196 
    197                 Ok stats ->
    198                     ( { model | stats = Just stats }, Cmd.none )
    199 
    200         GotSectionData section result ->
    201             case result of
    202                 Err err ->
    203                     ( { model | error = Just (httpErrorToString err) }, Cmd.none )
    204 
    205                 Ok entries ->
    206                     ( { model
    207                         | expandedSections = Set.insert section model.expandedSections
    208                         , loadedSections = Set.insert section model.loadedSections
    209                         , stats = Maybe.map (patchStatSection section entries) model.stats
    210                       }
    211                     , Cmd.none
    212                     )
    213 
    214         ImageError url ->
    215             ( { model | failedCovers = Set.insert url model.failedCovers }, Cmd.none )
    216 
    217         NextPage ->
    218             let
    219                 newOffset =
    220                     model.offset + model.limit
    221 
    222                 page =
    223                     newOffset // model.limit + 1
    224 
    225                 prefix =
    226                     if String.isEmpty model.userSlug then
    227                         "/"
    228 
    229                     else
    230                         "/u/" ++ model.userSlug
    231             in
    232             ( { model | offset = newOffset, loading = True }
    233             , Cmd.batch
    234                 [ fetchListens model.userSlug model.limit newOffset model.activeFilter model.activeSearch
    235                 , Nav.pushUrl model.navKey (prefix ++ "?page=" ++ String.fromInt page)
    236                 ]
    237             )
    238 
    239         PrevPage ->
    240             let
    241                 newOffset =
    242                     max 0 (model.offset - model.limit)
    243 
    244                 page =
    245                     newOffset // model.limit + 1
    246 
    247                 prefix =
    248                     if String.isEmpty model.userSlug then
    249                         "/"
    250 
    251                     else
    252                         "/u/" ++ model.userSlug
    253             in
    254             ( { model | offset = newOffset, loading = True }
    255             , Cmd.batch
    256                 [ fetchListens model.userSlug model.limit newOffset model.activeFilter model.activeSearch
    257                 , Nav.pushUrl model.navKey (prefix ++ "?page=" ++ String.fromInt page)
    258                 ]
    259             )
    260 
    261         SwitchTab tab ->
    262             ( { model | activeTab = tab }
    263             , case tab of
    264                 StatsTab ->
    265                     if model.stats == Nothing then
    266                         fetchStats model.userSlug model.statsPeriod
    267 
    268                     else
    269                         Cmd.none
    270 
    271                 ListensTab ->
    272                     Cmd.none
    273 
    274                 AboutTab ->
    275                     Cmd.none
    276             )
    277 
    278         ToggleTabs ->
    279             ( { model | tabsVisible = not model.tabsVisible }, Cmd.none )
    280 
    281         SetStatsPeriod period ->
    282             ( { model
    283                 | statsPeriod = period
    284                 , stats = Nothing
    285                 , expandedSections = Set.empty
    286                 , loadedSections = Set.empty
    287                 , showCustomInput = False
    288               }
    289             , fetchStats model.userSlug period
    290             )
    291 
    292         OpenCustomInput ->
    293             let
    294                 prefill =
    295                     case model.statsPeriod of
    296                         CustomRange from to ->
    297                             from ++ " " ++ to
    298 
    299                         _ ->
    300                             model.customInput
    301             in
    302             ( { model | showCustomInput = True, customInput = prefill, customError = Nothing }
    303             , Cmd.none
    304             )
    305 
    306         UpdateCustomInput str ->
    307             ( { model | customInput = str, customError = Nothing }, Cmd.none )
    308 
    309         ApplyCustomPeriod ->
    310             let
    311                 parts =
    312                     model.customInput
    313                         |> String.split " "
    314                         |> List.filter (not << String.isEmpty)
    315             in
    316             case parts of
    317                 [ from, to ] ->
    318                     if String.length from /= 10 || String.length to /= 10 then
    319                         ( { model | customError = Just "Dates must be in YYYY-MM-DD format" }, Cmd.none )
    320 
    321                     else if from > to then
    322                         ( { model | customError = Just "'from' must be before 'to'" }, Cmd.none )
    323 
    324                     else
    325                         let
    326                             period =
    327                                 CustomRange from to
    328                         in
    329                         ( { model
    330                             | statsPeriod = period
    331                             , stats = Nothing
    332                             , expandedSections = Set.empty
    333                             , loadedSections = Set.empty
    334                             , showCustomInput = False
    335                             , customError = Nothing
    336                           }
    337                         , fetchStats model.userSlug period
    338                         )
    339 
    340                 _ ->
    341                     ( { model | customError = Just "Enter two dates separated by a space" }, Cmd.none )
    342 
    343         FilterBy field value ->
    344             let
    345                 filter =
    346                     Just { field = field, value = value }
    347 
    348                 prefix =
    349                     if String.isEmpty model.userSlug then
    350                         "/"
    351 
    352                     else
    353                         "/u/" ++ model.userSlug
    354             in
    355             ( { model | activeFilter = filter, activeSearch = Nothing, searchInput = "", offset = 0, activeTab = ListensTab, loading = True }
    356             , Cmd.batch
    357                 [ fetchListens model.userSlug model.limit 0 filter Nothing
    358                 , Nav.pushUrl model.navKey (prefix ++ "?page=1")
    359                 ]
    360             )
    361 
    362         ClearFilter ->
    363             let
    364                 prefix =
    365                     if String.isEmpty model.userSlug then
    366                         "/"
    367 
    368                     else
    369                         "/u/" ++ model.userSlug
    370             in
    371             ( { model | activeFilter = Nothing, offset = 0, loading = True }
    372             , Cmd.batch
    373                 [ fetchListens model.userSlug model.limit 0 Nothing model.activeSearch
    374                 , Nav.pushUrl model.navKey (prefix ++ "?page=1")
    375                 ]
    376             )
    377 
    378         UpdateSearchInput str ->
    379             ( { model | searchInput = str }, Cmd.none )
    380 
    381         SubmitSearch ->
    382             let
    383                 q =
    384                     String.trim model.searchInput
    385 
    386                 prefix =
    387                     if String.isEmpty model.userSlug then
    388                         "/"
    389 
    390                     else
    391                         "/u/" ++ model.userSlug
    392             in
    393             if String.isEmpty q then
    394                 ( { model | activeSearch = Nothing, offset = 0, loading = True }
    395                 , Cmd.batch
    396                     [ fetchListens model.userSlug model.limit 0 Nothing Nothing
    397                     , Nav.pushUrl model.navKey (prefix ++ "?page=1")
    398                     ]
    399                 )
    400 
    401             else
    402                 ( { model | activeSearch = Just q, activeFilter = Nothing, offset = 0, loading = True }
    403                 , Cmd.batch
    404                     [ fetchListens model.userSlug model.limit 0 Nothing (Just q)
    405                     , Nav.pushUrl model.navKey (prefix ++ "?page=1")
    406                     ]
    407                 )
    408 
    409         ClearSearch ->
    410             let
    411                 prefix =
    412                     if String.isEmpty model.userSlug then
    413                         "/"
    414 
    415                     else
    416                         "/u/" ++ model.userSlug
    417             in
    418             ( { model | searchInput = "", activeSearch = Nothing, offset = 0, loading = True }
    419             , Cmd.batch
    420                 [ fetchListens model.userSlug model.limit 0 Nothing Nothing
    421                 , Nav.pushUrl model.navKey (prefix ++ "?page=1")
    422                 ]
    423             )
    424 
    425         HoverCover idx ->
    426             ( { model | hoveredCover = Just idx }, Cmd.none )
    427 
    428         UnhoverCover ->
    429             ( { model | hoveredCover = Nothing }, Cmd.none )
    430 
    431         ExpandSection section ->
    432             ( { model | expandedSections = Set.insert section model.expandedSections }, Cmd.none )
    433 
    434         ShowAllSection section ->
    435             ( model, fetchSectionData model.userSlug model.statsPeriod section )
    436 
    437         CollapseSection section ->
    438             ( { model
    439                 | expandedSections = Set.remove section model.expandedSections
    440                 , loadedSections = Set.remove section model.loadedSections
    441               }
    442             , Cmd.none
    443             )
    444 
    445         FetchSimilar idx artist track ->
    446             let
    447                 key =
    448                     String.fromInt idx ++ "\t" ++ artist ++ "\t" ++ track
    449             in
    450             if Dict.member key model.similarStates then
    451                 ( { model | similarStates = Dict.remove key model.similarStates }, Cmd.none )
    452 
    453             else
    454                 ( { model | similarStates = Dict.insert key SimilarLoading model.similarStates }
    455                 , fetchSimilarTracks idx artist track
    456                 )
    457 
    458         FetchSimilarTracks idx artist track result ->
    459             let
    460                 key =
    461                     String.fromInt idx ++ "\t" ++ artist ++ "\t" ++ track
    462 
    463                 newStates =
    464                     case result of
    465                         Ok tracks ->
    466                             Dict.insert key (SimilarLoaded tracks) model.similarStates
    467 
    468                         Err err ->
    469                             Dict.insert key (SimilarError (httpErrorToString err)) model.similarStates
    470             in
    471             ( { model | similarStates = newStates }, Cmd.none )
    472 
    473 
    474 patchStatSection : String -> List StatsEntry -> Stats -> Stats
    475 patchStatSection section entries stats =
    476     case section of
    477         "artist" ->
    478             { stats | artists = entries }
    479 
    480         "track" ->
    481             { stats | tracks = entries }
    482 
    483         "genre" ->
    484             { stats | genres = entries }
    485 
    486         "label" ->
    487             { stats | labels = entries }
    488 
    489         "year" ->
    490             { stats | years = entries }
    491 
    492         _ ->
    493             stats
    494 
    495 
    496 
    497 -- SUBSCRIPTIONS
    498 
    499 
    500 subscriptions : Model -> Sub Msg
    501 subscriptions _ =
    502     Time.every 30000 Tick
    503 
    504 
    505 
    506 -- HTTP