refactor: app orchestration into tui
This commit is contained in:
@@ -1,29 +1,33 @@
|
|||||||
module parasocial
|
module parasocial
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
golang.org/x/text v0.3.8 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,44 +2,51 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
|
|||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
|||||||
+3
-236
@@ -1,251 +1,18 @@
|
|||||||
// app.go wires together config loading, auth bootstrap, and the Bubble Tea program.
|
// app.go owns process-level application bootstrap before handing control to the TUI.
|
||||||
// It decides whether cached Twitch auth can be reused and, when needed,
|
|
||||||
// connects the interactive login flow to the TUI through auth update messages.
|
|
||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
|
|
||||||
"parasocial/internal/auth"
|
|
||||||
"parasocial/internal/config"
|
"parasocial/internal/config"
|
||||||
"parasocial/internal/gql"
|
|
||||||
"parasocial/internal/irc"
|
|
||||||
"parasocial/internal/tui"
|
"parasocial/internal/tui"
|
||||||
"parasocial/internal/twitch"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const streamerRefreshInterval = 5 * time.Minute
|
// Run loads configuration and starts the terminal UI.
|
||||||
|
|
||||||
// Run loads the application configuration and starts the terminal UI.
|
|
||||||
func Run(ctx context.Context) error {
|
func Run(ctx context.Context) error {
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cfg, err := config.LoadDefault()
|
cfg, err := config.LoadDefault()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return tui.Run(ctx, tui.Options{Streamers: cfg.Streamers})
|
||||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
authClient := auth.NewClient(httpClient)
|
|
||||||
authPath := auth.DefaultPath()
|
|
||||||
|
|
||||||
state, err := authClient.ReuseAuth(ctx, authPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
program := tea.NewProgram(
|
|
||||||
tui.New(tui.Options{
|
|
||||||
Streamers: twitch.LoadingStreamerEntries(cfg.Streamers),
|
|
||||||
AuthState: state,
|
|
||||||
StartAuth: func(ch chan<- tui.AuthUpdate) {
|
|
||||||
go func() {
|
|
||||||
defer close(ch)
|
|
||||||
|
|
||||||
state, err := authClient.EnsureAuth(ctx, authPath, func(line string) {
|
|
||||||
ch <- tui.AuthUpdate{Line: strings.TrimRight(line, "\n")}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
ch <- tui.AuthUpdate{
|
|
||||||
Line: fmt.Sprintf("Authentication failed: %v", err),
|
|
||||||
Err: err,
|
|
||||||
Done: true,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ch <- tui.AuthUpdate{State: state, Done: true}
|
|
||||||
}()
|
|
||||||
},
|
|
||||||
StartResolve: func(state *auth.State, ch chan<- tui.StreamerUpdate) {
|
|
||||||
go func() {
|
|
||||||
defer close(ch)
|
|
||||||
ircManager := newIRCManager(ch)
|
|
||||||
defer ircManager.Close()
|
|
||||||
|
|
||||||
service, err := newTwitchService(httpClient, state)
|
|
||||||
if err != nil {
|
|
||||||
ch <- tui.StreamerUpdate{Err: err, Done: true}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := resolveStreamerEntries(ctx, service, state, cfg.Streamers, ircManager, func(update tui.StreamerUpdate) {
|
|
||||||
ch <- update
|
|
||||||
}); err != nil {
|
|
||||||
if errors.Is(err, context.Canceled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch <- tui.StreamerUpdate{Err: err, Done: true}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch <- tui.StreamerUpdate{Done: true}
|
|
||||||
}()
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
tea.WithContext(ctx),
|
|
||||||
tea.WithInput(os.Stdin),
|
|
||||||
tea.WithOutput(os.Stdout),
|
|
||||||
)
|
|
||||||
_, err = program.Run()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// streamerService captures the Twitch lookups the app needs during UI resolution.
|
|
||||||
type streamerService interface {
|
|
||||||
CurrentUser(context.Context) (*twitch.Viewer, error)
|
|
||||||
ResolveStreamer(context.Context, string) (*twitch.Channel, error)
|
|
||||||
StreamInfo(context.Context, string) (*twitch.StreamInfo, error)
|
|
||||||
PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ircSyncer interface {
|
|
||||||
Sync(context.Context, string, string, []irc.Target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTwitchService builds a Twitch service from the authenticated session state.
|
|
||||||
func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Service, error) {
|
|
||||||
client := &gql.Client{
|
|
||||||
HTTPClient: httpClient,
|
|
||||||
Session: gql.Session{
|
|
||||||
AccessToken: state.AccessToken,
|
|
||||||
ClientID: state.ClientID,
|
|
||||||
DeviceID: state.DeviceID,
|
|
||||||
UserAgent: auth.TVUserAgent(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := client.Validate(); err != nil {
|
|
||||||
return nil, fmt.Errorf("configure graphql session: %w", err)
|
|
||||||
}
|
|
||||||
return &twitch.Service{GQL: client}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newIRCManager(ch chan<- tui.StreamerUpdate) *irc.Manager {
|
|
||||||
return &irc.Manager{
|
|
||||||
Addr: irc.DefaultAddr,
|
|
||||||
Events: func(event irc.Event) {
|
|
||||||
ch <- tui.StreamerUpdate{
|
|
||||||
IRC: &tui.IRCUpdate{
|
|
||||||
Login: event.Streamer,
|
|
||||||
State: tui.IRCState(event.State),
|
|
||||||
Line: event.Line,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveStreamerEntries streams viewer and streamer resolution results into the TUI.
|
|
||||||
func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(tui.StreamerUpdate)) error {
|
|
||||||
return resolveStreamerEntriesWithSleep(ctx, service, state, logins, syncer, send, streamerRefreshInterval, sleepContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(tui.StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error {
|
|
||||||
viewer, err := service.CurrentUser(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("resolve current user: %w", err)
|
|
||||||
}
|
|
||||||
send(tui.StreamerUpdate{Viewer: viewer})
|
|
||||||
|
|
||||||
entries := twitch.LoadingStreamerEntries(logins)
|
|
||||||
syncWatchedChannels(ctx, syncer, state, entries)
|
|
||||||
|
|
||||||
for {
|
|
||||||
for index, login := range logins {
|
|
||||||
entry, err := resolveStreamerEntry(ctx, service, login)
|
|
||||||
if errors.Is(err, context.Canceled) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
entries[index] = *entry
|
|
||||||
send(tui.StreamerUpdate{
|
|
||||||
Index: index,
|
|
||||||
Entry: entry,
|
|
||||||
})
|
|
||||||
syncWatchedChannels(ctx, syncer, state, entries)
|
|
||||||
}
|
|
||||||
if err := sleep(ctx, interval); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveStreamerEntry(ctx context.Context, service streamerService, login string) (*twitch.StreamerEntry, error) {
|
|
||||||
entry := &twitch.StreamerEntry{
|
|
||||||
ConfigLogin: login,
|
|
||||||
Status: twitch.StreamerLoading,
|
|
||||||
}
|
|
||||||
|
|
||||||
channel, err := service.ResolveStreamer(ctx, login)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
entry.Login = channel.Login
|
|
||||||
entry.ChannelID = channel.ID
|
|
||||||
case errors.Is(err, context.Canceled):
|
|
||||||
return nil, err
|
|
||||||
default:
|
|
||||||
entry.Status = twitch.StreamerError
|
|
||||||
entry.Error = err.Error()
|
|
||||||
return entry, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
stream, err := service.StreamInfo(ctx, channel.ID)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
entry.Status = twitch.StreamerReady
|
|
||||||
entry.Live = stream.Online
|
|
||||||
case errors.Is(err, context.Canceled):
|
|
||||||
return nil, err
|
|
||||||
default:
|
|
||||||
entry.Status = twitch.StreamerError
|
|
||||||
entry.Error = err.Error()
|
|
||||||
return entry, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !entry.Live {
|
|
||||||
return entry, nil
|
|
||||||
}
|
|
||||||
if _, err := service.PlaybackAccessToken(ctx, channel.Login); err != nil && errors.Is(err, context.Canceled) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return entry, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sleepContext(ctx context.Context, duration time.Duration) error {
|
|
||||||
timer := time.NewTimer(duration)
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-timer.C:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncWatchedChannels(ctx context.Context, syncer ircSyncer, state *auth.State, entries []twitch.StreamerEntry) {
|
|
||||||
if syncer == nil || state == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
syncer.Sync(ctx, state.Login, state.AccessToken, watchedIRCTargets(entries))
|
|
||||||
}
|
|
||||||
|
|
||||||
func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target {
|
|
||||||
targets := make([]irc.Target, 0, 2)
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
targets = append(targets, irc.Target{Login: entry.Login})
|
|
||||||
if len(targets) == 2 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return targets
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
// app_test.go covers the app-layer orchestration around Twitch viewer and streamer resolution.
|
|
||||||
// It exercises the background resolution loop independently of Bubble Tea startup
|
|
||||||
// so the app wiring can be validated without requiring a full interactive session.
|
|
||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"parasocial/internal/auth"
|
|
||||||
"parasocial/internal/irc"
|
|
||||||
"parasocial/internal/tui"
|
|
||||||
"parasocial/internal/twitch"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeStreamerService is a test double for the app's Twitch resolution dependency.
|
|
||||||
type fakeStreamerService struct {
|
|
||||||
viewer *twitch.Viewer
|
|
||||||
viewerErr error
|
|
||||||
channels map[string]*twitch.Channel
|
|
||||||
resolveErrs map[string]error
|
|
||||||
streams map[string][]streamResult
|
|
||||||
streamCalls map[string]int
|
|
||||||
playbackErr map[string]error
|
|
||||||
}
|
|
||||||
|
|
||||||
type streamResult struct {
|
|
||||||
info *twitch.StreamInfo
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// CurrentUser returns the configured fake viewer or viewer error.
|
|
||||||
func (f *fakeStreamerService) CurrentUser(context.Context) (*twitch.Viewer, error) {
|
|
||||||
if f.viewerErr != nil {
|
|
||||||
return nil, f.viewerErr
|
|
||||||
}
|
|
||||||
return f.viewer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveStreamer returns the configured fake channel or resolution error.
|
|
||||||
func (f *fakeStreamerService) ResolveStreamer(_ context.Context, login string) (*twitch.Channel, error) {
|
|
||||||
if err, ok := f.resolveErrs[login]; ok {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return f.channels[login], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStreamerService) StreamInfo(_ context.Context, channelID string) (*twitch.StreamInfo, error) {
|
|
||||||
if f.streamCalls == nil {
|
|
||||||
f.streamCalls = map[string]int{}
|
|
||||||
}
|
|
||||||
call := f.streamCalls[channelID]
|
|
||||||
f.streamCalls[channelID] = call + 1
|
|
||||||
|
|
||||||
results := f.streams[channelID]
|
|
||||||
if len(results) == 0 {
|
|
||||||
return &twitch.StreamInfo{Online: false}, nil
|
|
||||||
}
|
|
||||||
if call >= len(results) {
|
|
||||||
call = len(results) - 1
|
|
||||||
}
|
|
||||||
result := results[call]
|
|
||||||
if result.err != nil {
|
|
||||||
return nil, result.err
|
|
||||||
}
|
|
||||||
return result.info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login string) (*twitch.PlaybackToken, error) {
|
|
||||||
if err, ok := f.playbackErr[login]; ok {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &twitch.PlaybackToken{Signature: "sig", Value: "token"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeIRCSyncer struct {
|
|
||||||
calls [][]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []irc.Target) {
|
|
||||||
logins := make([]string, 0, len(targets))
|
|
||||||
for _, target := range targets {
|
|
||||||
logins = append(logins, target.Login)
|
|
||||||
}
|
|
||||||
f.calls = append(f.calls, logins)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestResolveStreamerEntries verifies that app resolution emits viewer, success, and error updates.
|
|
||||||
func TestResolveStreamerEntries(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
service := &fakeStreamerService{
|
|
||||||
viewer: &twitch.Viewer{ID: "7", Login: "viewer"},
|
|
||||||
channels: map[string]*twitch.Channel{
|
|
||||||
"alpha": {ID: "1", Login: "alpha_live"},
|
|
||||||
},
|
|
||||||
resolveErrs: map[string]error{
|
|
||||||
"beta": errors.New("lookup failed"),
|
|
||||||
},
|
|
||||||
streams: map[string][]streamResult{
|
|
||||||
"1": {{info: &twitch.StreamInfo{Online: true}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var updates []tui.StreamerUpdate
|
|
||||||
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta"}, nil, func(update tui.StreamerUpdate) {
|
|
||||||
updates = append(updates, update)
|
|
||||||
}, 0, func(context.Context, time.Duration) error {
|
|
||||||
return context.Canceled
|
|
||||||
})
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(updates) != 3 {
|
|
||||||
t.Fatalf("len(updates) = %d", len(updates))
|
|
||||||
}
|
|
||||||
if updates[0].Viewer == nil || updates[0].Viewer.Login != "viewer" {
|
|
||||||
t.Fatalf("viewer update = %#v", updates[0])
|
|
||||||
}
|
|
||||||
if updates[1].Entry == nil || updates[1].Entry.Status != twitch.StreamerReady || updates[1].Entry.Login != "alpha_live" || !updates[1].Entry.Live {
|
|
||||||
t.Fatalf("alpha update = %#v", updates[1])
|
|
||||||
}
|
|
||||||
if updates[2].Entry == nil || updates[2].Entry.Status != twitch.StreamerError {
|
|
||||||
t.Fatalf("beta update = %#v", updates[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveStreamerEntriesPlaybackFailureStillMarksLive(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
service := &fakeStreamerService{
|
|
||||||
viewer: &twitch.Viewer{ID: "7", Login: "viewer"},
|
|
||||||
channels: map[string]*twitch.Channel{
|
|
||||||
"alpha": {ID: "1", Login: "alpha_live"},
|
|
||||||
},
|
|
||||||
streams: map[string][]streamResult{
|
|
||||||
"1": {{info: &twitch.StreamInfo{Online: true}}},
|
|
||||||
},
|
|
||||||
playbackErr: map[string]error{
|
|
||||||
"alpha_live": errors.New("token lookup failed"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var updates []tui.StreamerUpdate
|
|
||||||
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha"}, nil, func(update tui.StreamerUpdate) {
|
|
||||||
updates = append(updates, update)
|
|
||||||
}, 0, func(context.Context, time.Duration) error {
|
|
||||||
return context.Canceled
|
|
||||||
})
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(updates) != 2 {
|
|
||||||
t.Fatalf("len(updates) = %d", len(updates))
|
|
||||||
}
|
|
||||||
entry := updates[1].Entry
|
|
||||||
if entry == nil || entry.Status != twitch.StreamerReady || !entry.Live {
|
|
||||||
t.Fatalf("entry = %#v", entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveStreamerEntriesRefreshesLiveState(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
service := &fakeStreamerService{
|
|
||||||
viewer: &twitch.Viewer{ID: "7", Login: "viewer"},
|
|
||||||
channels: map[string]*twitch.Channel{
|
|
||||||
"alpha": {ID: "1", Login: "alpha_live"},
|
|
||||||
},
|
|
||||||
streams: map[string][]streamResult{
|
|
||||||
"1": {
|
|
||||||
{info: &twitch.StreamInfo{Online: false}},
|
|
||||||
{info: &twitch.StreamInfo{Online: true}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var updates []tui.StreamerUpdate
|
|
||||||
sleepCalls := 0
|
|
||||||
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha"}, nil, func(update tui.StreamerUpdate) {
|
|
||||||
updates = append(updates, update)
|
|
||||||
}, 0, func(context.Context, time.Duration) error {
|
|
||||||
sleepCalls++
|
|
||||||
if sleepCalls == 1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return context.Canceled
|
|
||||||
})
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(updates) != 3 {
|
|
||||||
t.Fatalf("len(updates) = %d", len(updates))
|
|
||||||
}
|
|
||||||
if updates[1].Entry == nil || updates[1].Entry.Live {
|
|
||||||
t.Fatalf("first pass = %#v", updates[1])
|
|
||||||
}
|
|
||||||
if updates[2].Entry == nil || !updates[2].Entry.Live {
|
|
||||||
t.Fatalf("second pass = %#v", updates[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveStreamerEntriesSyncsTopTwoLiveChannels(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
service := &fakeStreamerService{
|
|
||||||
viewer: &twitch.Viewer{ID: "7", Login: "viewer"},
|
|
||||||
channels: map[string]*twitch.Channel{
|
|
||||||
"alpha": {ID: "1", Login: "alpha_live"},
|
|
||||||
"beta": {ID: "2", Login: "beta_live"},
|
|
||||||
"gamma": {ID: "3", Login: "gamma_live"},
|
|
||||||
},
|
|
||||||
streams: map[string][]streamResult{
|
|
||||||
"1": {{info: &twitch.StreamInfo{Online: true}}},
|
|
||||||
"2": {{info: &twitch.StreamInfo{Online: true}}},
|
|
||||||
"3": {{info: &twitch.StreamInfo{Online: true}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
syncer := &fakeIRCSyncer{}
|
|
||||||
|
|
||||||
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta", "gamma"}, syncer, func(tui.StreamerUpdate) {
|
|
||||||
}, 0, func(context.Context, time.Duration) error {
|
|
||||||
return context.Canceled
|
|
||||||
})
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := [][]string{
|
|
||||||
{},
|
|
||||||
{"alpha_live"},
|
|
||||||
{"alpha_live", "beta_live"},
|
|
||||||
{"alpha_live", "beta_live"},
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(syncer.calls, want) {
|
|
||||||
t.Fatalf("sync calls = %#v, want %#v", syncer.calls, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
service := &fakeStreamerService{
|
|
||||||
viewer: &twitch.Viewer{ID: "7", Login: "viewer"},
|
|
||||||
channels: map[string]*twitch.Channel{
|
|
||||||
"alpha": {ID: "1", Login: "alpha_live"},
|
|
||||||
"beta": {ID: "2", Login: "beta_live"},
|
|
||||||
},
|
|
||||||
streams: map[string][]streamResult{
|
|
||||||
"1": {
|
|
||||||
{info: &twitch.StreamInfo{Online: true}},
|
|
||||||
{info: &twitch.StreamInfo{Online: false}},
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
{info: &twitch.StreamInfo{Online: false}},
|
|
||||||
{info: &twitch.StreamInfo{Online: true}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
syncer := &fakeIRCSyncer{}
|
|
||||||
|
|
||||||
sleepCalls := 0
|
|
||||||
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, []string{"alpha", "beta"}, syncer, func(tui.StreamerUpdate) {
|
|
||||||
}, 0, func(context.Context, time.Duration) error {
|
|
||||||
sleepCalls++
|
|
||||||
if sleepCalls == 1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return context.Canceled
|
|
||||||
})
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := [][]string{
|
|
||||||
{},
|
|
||||||
{"alpha_live"},
|
|
||||||
{"alpha_live"},
|
|
||||||
{},
|
|
||||||
{"beta_live"},
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(syncer.calls, want) {
|
|
||||||
t.Fatalf("sync calls = %#v, want %#v", syncer.calls, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"parasocial/internal/auth"
|
||||||
|
"parasocial/internal/twitch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IRCState describes the current IRC join lifecycle for one streamer row.
|
||||||
|
type IRCState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IRCPending IRCState = "pending"
|
||||||
|
IRCJoined IRCState = "joined"
|
||||||
|
IRCDisconnected IRCState = "disconnected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthUpdate carries one incremental auth log line or completion result into the TUI.
|
||||||
|
type AuthUpdate struct {
|
||||||
|
Line string
|
||||||
|
State *auth.State
|
||||||
|
Err error
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamerUpdate carries one streamer resolution update into the TUI.
|
||||||
|
type StreamerUpdate struct {
|
||||||
|
Viewer *twitch.Viewer
|
||||||
|
Entry *twitch.StreamerEntry
|
||||||
|
IRC *IRCUpdate
|
||||||
|
Index int
|
||||||
|
Err error
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCUpdate carries one IRC connection state or log line into the TUI.
|
||||||
|
type IRCUpdate struct {
|
||||||
|
Login string
|
||||||
|
State IRCState
|
||||||
|
Line string
|
||||||
|
}
|
||||||
|
|
||||||
|
type authStartedMsg struct {
|
||||||
|
Updates <-chan AuthUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamerStartedMsg struct {
|
||||||
|
Updates <-chan StreamerUpdate
|
||||||
|
}
|
||||||
+113
-365
@@ -1,13 +1,10 @@
|
|||||||
// model.go contains the Bubble Tea state machine for the current terminal UI.
|
|
||||||
// It switches between the Twitch login log view and the streamer list view,
|
|
||||||
// and applies auth progress messages emitted by the app layer.
|
|
||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"parasocial/internal/auth"
|
"parasocial/internal/auth"
|
||||||
@@ -15,7 +12,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const defaultViewWidth = 80
|
const defaultViewWidth = 80
|
||||||
const greenDot = "\x1b[32m●\x1b[0m"
|
|
||||||
|
|
||||||
type viewMode int
|
type viewMode int
|
||||||
|
|
||||||
@@ -24,70 +20,16 @@ const (
|
|||||||
streamerView
|
streamerView
|
||||||
)
|
)
|
||||||
|
|
||||||
// IRCState describes the current IRC join lifecycle for one streamer row.
|
type modelRuntime interface {
|
||||||
type IRCState string
|
startAuth(chan<- AuthUpdate)
|
||||||
|
startResolve(*auth.State, chan<- StreamerUpdate)
|
||||||
const (
|
|
||||||
IRCPending IRCState = "pending"
|
|
||||||
IRCJoined IRCState = "joined"
|
|
||||||
IRCDisconnected IRCState = "disconnected"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StartAuthFunc begins the asynchronous Twitch login flow for the model.
|
|
||||||
type StartAuthFunc func(chan<- AuthUpdate)
|
|
||||||
|
|
||||||
// StartResolveFunc begins the asynchronous viewer and streamer resolution flow for the model.
|
|
||||||
type StartResolveFunc func(*auth.State, chan<- StreamerUpdate)
|
|
||||||
|
|
||||||
// AuthUpdate carries one incremental auth log line or completion result into the TUI.
|
|
||||||
type AuthUpdate struct {
|
|
||||||
Line string
|
|
||||||
State *auth.State
|
|
||||||
Err error
|
|
||||||
Done bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamerUpdate carries one streamer resolution update into the TUI.
|
|
||||||
type StreamerUpdate struct {
|
|
||||||
Viewer *twitch.Viewer
|
|
||||||
Entry *twitch.StreamerEntry
|
|
||||||
IRC *IRCUpdate
|
|
||||||
Index int
|
|
||||||
Err error
|
|
||||||
Done bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// IRCUpdate carries one IRC connection state or log line into the TUI.
|
|
||||||
type IRCUpdate struct {
|
|
||||||
Login string
|
|
||||||
State IRCState
|
|
||||||
Line string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options configures the initial streamer list, auth state, and worker starter hooks.
|
|
||||||
type Options struct {
|
|
||||||
Streamers []twitch.StreamerEntry
|
|
||||||
AuthState *auth.State
|
|
||||||
StartAuth StartAuthFunc
|
|
||||||
StartResolve StartResolveFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// authStartedMsg hands the model the channel that will stream auth updates.
|
|
||||||
type authStartedMsg struct {
|
|
||||||
Updates <-chan AuthUpdate
|
|
||||||
}
|
|
||||||
|
|
||||||
// streamerStartedMsg hands the model the channel that will stream streamer updates.
|
|
||||||
type streamerStartedMsg struct {
|
|
||||||
Updates <-chan StreamerUpdate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model is the terminal UI for auth and streamer display.
|
// Model is the terminal UI for auth and streamer display.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
streamers []twitch.StreamerEntry
|
streamers []twitch.StreamerEntry
|
||||||
authState *auth.State
|
authState *auth.State
|
||||||
startAuth StartAuthFunc
|
runtime modelRuntime
|
||||||
startResolve StartResolveFunc
|
|
||||||
authUpdates <-chan AuthUpdate
|
authUpdates <-chan AuthUpdate
|
||||||
streamerUpdates <-chan StreamerUpdate
|
streamerUpdates <-chan StreamerUpdate
|
||||||
authLogs []string
|
authLogs []string
|
||||||
@@ -99,44 +41,49 @@ type Model struct {
|
|||||||
height int
|
height int
|
||||||
selectedConfig string
|
selectedConfig string
|
||||||
ircDetails map[string]ircDetail
|
ircDetails map[string]ircDetail
|
||||||
|
authViewport viewport.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
type ircDetail struct {
|
type ircDetail struct {
|
||||||
joined bool
|
joined bool
|
||||||
}
|
line string
|
||||||
|
|
||||||
type streamerRow struct {
|
|
||||||
index int
|
|
||||||
entry twitch.StreamerEntry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Bubble Tea model for auth and streamer display.
|
// New returns a Bubble Tea model for auth and streamer display.
|
||||||
func New(options Options) Model {
|
func New(options Options) Model {
|
||||||
|
streamers := twitch.LoadingStreamerEntries(options.Streamers)
|
||||||
|
if len(options.initialStreamers) > 0 {
|
||||||
|
streamers = append([]twitch.StreamerEntry(nil), options.initialStreamers...)
|
||||||
|
}
|
||||||
|
|
||||||
model := Model{
|
model := Model{
|
||||||
streamers: append([]twitch.StreamerEntry(nil), options.Streamers...),
|
streamers: streamers,
|
||||||
authState: options.AuthState,
|
authState: options.AuthState,
|
||||||
startAuth: options.StartAuth,
|
runtime: options.runtime,
|
||||||
startResolve: options.StartResolve,
|
|
||||||
authLogs: []string{},
|
authLogs: []string{},
|
||||||
mode: authView,
|
mode: authView,
|
||||||
ircDetails: make(map[string]ircDetail),
|
ircDetails: make(map[string]ircDetail),
|
||||||
|
width: defaultViewWidth,
|
||||||
|
height: 24,
|
||||||
|
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)),
|
||||||
}
|
}
|
||||||
if options.AuthState != nil {
|
if options.AuthState != nil {
|
||||||
model.mode = streamerView
|
model.mode = streamerView
|
||||||
}
|
}
|
||||||
model.ensureSelection()
|
model.ensureSelection()
|
||||||
|
model.syncAuthViewport()
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init kicks off authentication only when the UI starts in the login state.
|
// Init kicks off authentication only when the UI starts in the login state.
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
if m.mode == authView {
|
if m.mode == authView {
|
||||||
return startAuthSession(m.startAuth)
|
return startAuthSession(m.runtime)
|
||||||
}
|
}
|
||||||
return startStreamerResolution(m.startResolve, m.authState)
|
return startStreamerResolution(m.runtime, m.authState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update applies auth progress events and handles the global quit keys.
|
// Update applies runtime progress events and handles global keys.
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case authStartedMsg:
|
case authStartedMsg:
|
||||||
@@ -146,12 +93,49 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.streamerUpdates = msg.Updates
|
m.streamerUpdates = msg.Updates
|
||||||
return m, waitForStreamerUpdate(msg.Updates)
|
return m, waitForStreamerUpdate(msg.Updates)
|
||||||
case AuthUpdate:
|
case AuthUpdate:
|
||||||
|
return m.updateAuth(msg)
|
||||||
|
case StreamerUpdate:
|
||||||
|
return m.updateStreamer(msg)
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.resizeComponents()
|
||||||
|
m.ensureSelection()
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if m.mode == streamerView {
|
||||||
|
m.moveSelection(-1)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "down", "j":
|
||||||
|
if m.mode == streamerView {
|
||||||
|
m.moveSelection(1)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "ctrl+c", "esc", "q":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.mode == authView {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.authViewport, cmd = m.authViewport.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) {
|
||||||
if msg.Line != "" {
|
if msg.Line != "" {
|
||||||
m.authLogs = append(m.authLogs, msg.Line)
|
m.authLogs = append(m.authLogs, msg.Line)
|
||||||
|
m.syncAuthViewport()
|
||||||
}
|
}
|
||||||
if msg.Done {
|
if msg.Done {
|
||||||
if msg.Err != nil {
|
if msg.Err != nil {
|
||||||
m.authErr = msg.Err
|
m.authErr = msg.Err
|
||||||
|
m.syncAuthViewport()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if msg.State != nil {
|
if msg.State != nil {
|
||||||
@@ -164,14 +148,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.ircDetails = make(map[string]ircDetail)
|
m.ircDetails = make(map[string]ircDetail)
|
||||||
m.selectedConfig = ""
|
m.selectedConfig = ""
|
||||||
m.ensureSelection()
|
m.ensureSelection()
|
||||||
return m, startStreamerResolution(m.startResolve, m.authState)
|
m.resizeComponents()
|
||||||
|
return m, startStreamerResolution(m.runtime, m.authState)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.authUpdates != nil {
|
if m.authUpdates != nil {
|
||||||
return m, waitForAuthUpdate(m.authUpdates)
|
return m, waitForAuthUpdate(m.authUpdates)
|
||||||
}
|
}
|
||||||
case StreamerUpdate:
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
|
||||||
if msg.Viewer != nil {
|
if msg.Viewer != nil {
|
||||||
m.viewer = msg.Viewer
|
m.viewer = msg.Viewer
|
||||||
m.resolveErr = nil
|
m.resolveErr = nil
|
||||||
@@ -186,120 +174,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.resolveErr = msg.Err
|
m.resolveErr = msg.Err
|
||||||
}
|
}
|
||||||
m.ensureSelection()
|
m.ensureSelection()
|
||||||
|
m.resizeComponents()
|
||||||
if msg.Done {
|
if msg.Done {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.streamerUpdates != nil {
|
if m.streamerUpdates != nil {
|
||||||
return m, waitForStreamerUpdate(m.streamerUpdates)
|
return m, waitForStreamerUpdate(m.streamerUpdates)
|
||||||
}
|
}
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width = msg.Width
|
|
||||||
m.height = msg.Height
|
|
||||||
m.ensureSelection()
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "up", "k":
|
|
||||||
if m.mode == streamerView {
|
|
||||||
m.moveSelection(-1)
|
|
||||||
}
|
|
||||||
case "down", "j":
|
|
||||||
if m.mode == streamerView {
|
|
||||||
m.moveSelection(1)
|
|
||||||
}
|
|
||||||
case "ctrl+c", "esc", "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// View renders either the login log screen or the authenticated streamer list.
|
|
||||||
func (m Model) View() string {
|
|
||||||
if m.mode == authView {
|
|
||||||
var builder strings.Builder
|
|
||||||
builder.WriteString("Twitch Login\n")
|
|
||||||
if len(m.authLogs) == 0 {
|
|
||||||
builder.WriteString("Starting authentication...\n")
|
|
||||||
} else {
|
|
||||||
for _, line := range m.authLogs {
|
|
||||||
builder.WriteString(line)
|
|
||||||
builder.WriteByte('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if m.authErr != nil {
|
|
||||||
builder.WriteString("\nLogin did not complete. Press q to quit.\n")
|
|
||||||
}
|
|
||||||
builder.WriteString("\n")
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.renderStreamerView()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderStreamerView() string {
|
|
||||||
var builder strings.Builder
|
|
||||||
switch {
|
|
||||||
case m.viewer != nil:
|
|
||||||
fmt.Fprintf(&builder, "Logged in as %s (%s)\n", m.viewer.Login, m.viewer.ID)
|
|
||||||
case m.authState != nil && m.authState.Login != "":
|
|
||||||
fmt.Fprintf(&builder, "Logged in as %s\n", m.authState.Login)
|
|
||||||
if m.resolveErr != nil {
|
|
||||||
fmt.Fprintf(&builder, "Viewer lookup failed: %v\n", m.resolveErr)
|
|
||||||
} else {
|
|
||||||
builder.WriteString("Resolving viewer identity...\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&builder, "Watching: %s\n", watchingSummary(m.streamers))
|
|
||||||
|
|
||||||
rows := m.orderedRows()
|
|
||||||
selectedIndex := m.selectedRowIndex(rows)
|
|
||||||
leftPane := m.leftPaneLines(rows)
|
|
||||||
rightPane := m.rightPaneLines(rows, selectedIndex)
|
|
||||||
|
|
||||||
if m.height > 0 {
|
|
||||||
available := m.height - 3
|
|
||||||
if available > 0 {
|
|
||||||
leftPane = clipListLines(leftPane, available, selectedIndex+1)
|
|
||||||
rightPane = clipTailLines(rightPane, available)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyWidth := m.width
|
|
||||||
if bodyWidth <= 0 {
|
|
||||||
bodyWidth = defaultViewWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.WriteString("\n")
|
|
||||||
for _, line := range renderColumns(leftPane, rightPane, bodyWidth) {
|
|
||||||
builder.WriteString(line)
|
|
||||||
builder.WriteByte('\n')
|
|
||||||
}
|
|
||||||
builder.WriteByte('\n')
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func watchingSummary(streamers []twitch.StreamerEntry) string {
|
|
||||||
live := make([]string, 0, 2)
|
|
||||||
for _, streamer := range streamers {
|
|
||||||
if streamer.Status != twitch.StreamerReady || !streamer.Live {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := streamer.Login
|
|
||||||
if name == "" {
|
|
||||||
name = streamer.ConfigLogin
|
|
||||||
}
|
|
||||||
live = append(live, name)
|
|
||||||
if len(live) == 2 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(live) == 0 {
|
|
||||||
return "no live streamers"
|
|
||||||
}
|
|
||||||
return strings.Join(live, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) applyIRCUpdate(update IRCUpdate) {
|
func (m *Model) applyIRCUpdate(update IRCUpdate) {
|
||||||
login := normalizeKey(update.Login)
|
login := normalizeKey(update.Login)
|
||||||
if login == "" {
|
if login == "" {
|
||||||
@@ -314,114 +198,89 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) {
|
|||||||
detail.joined = false
|
detail.joined = false
|
||||||
}
|
}
|
||||||
if update.Line != "" {
|
if update.Line != "" {
|
||||||
|
detail.line = update.Line
|
||||||
}
|
}
|
||||||
m.ircDetails[login] = detail
|
m.ircDetails[login] = detail
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) ensureSelection() {
|
func (m *Model) ensureSelection() {
|
||||||
rows := m.orderedRows()
|
entries := m.orderedStreamers()
|
||||||
if len(rows) == 0 {
|
if len(entries) == 0 {
|
||||||
m.selectedConfig = ""
|
m.selectedConfig = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if m.selectedConfig == "" {
|
if m.selectedConfig == "" {
|
||||||
m.selectedConfig = rows[0].entry.ConfigLogin
|
m.selectedConfig = entries[0].ConfigLogin
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, entry := range entries {
|
||||||
if row.entry.ConfigLogin == m.selectedConfig {
|
if entry.ConfigLogin == m.selectedConfig {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.selectedConfig = rows[0].entry.ConfigLogin
|
m.selectedConfig = entries[0].ConfigLogin
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) moveSelection(delta int) {
|
func (m *Model) moveSelection(delta int) {
|
||||||
rows := m.orderedRows()
|
entries := m.orderedStreamers()
|
||||||
if len(rows) == 0 {
|
if len(entries) == 0 {
|
||||||
m.selectedConfig = ""
|
m.selectedConfig = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.ensureSelection()
|
|
||||||
|
|
||||||
selected := m.selectedRowIndex(rows)
|
selected := m.selectedRowIndex(entries) + delta
|
||||||
if selected < 0 {
|
if selected < 0 {
|
||||||
selected = 0
|
selected = 0
|
||||||
}
|
}
|
||||||
selected += delta
|
if selected >= len(entries) {
|
||||||
if selected < 0 {
|
selected = len(entries) - 1
|
||||||
selected = 0
|
|
||||||
}
|
}
|
||||||
if selected >= len(rows) {
|
m.selectedConfig = entries[selected].ConfigLogin
|
||||||
selected = len(rows) - 1
|
|
||||||
}
|
|
||||||
m.selectedConfig = rows[selected].entry.ConfigLogin
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) orderedRows() []streamerRow {
|
func (m Model) orderedStreamers() []twitch.StreamerEntry {
|
||||||
rows := make([]streamerRow, 0, len(m.streamers))
|
entries := make([]twitch.StreamerEntry, 0, len(m.streamers))
|
||||||
for index, entry := range m.streamers {
|
for _, entry := range m.streamers {
|
||||||
if isActive(entry) {
|
if isActive(entry) {
|
||||||
rows = append(rows, streamerRow{index: index, entry: entry})
|
entries = append(entries, entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for index, entry := range m.streamers {
|
for _, entry := range m.streamers {
|
||||||
if isActive(entry) {
|
if isActive(entry) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rows = append(rows, streamerRow{index: index, entry: entry})
|
entries = append(entries, entry)
|
||||||
}
|
}
|
||||||
return rows
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) selectedRowIndex(rows []streamerRow) int {
|
func (m Model) selectedRowIndex(entries []twitch.StreamerEntry) int {
|
||||||
for index, row := range rows {
|
for index, entry := range entries {
|
||||||
if row.entry.ConfigLogin == m.selectedConfig {
|
if entry.ConfigLogin == m.selectedConfig {
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(rows) == 0 {
|
if len(entries) == 0 {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) leftPaneLines(rows []streamerRow) []string {
|
func (m *Model) resizeComponents() {
|
||||||
lines := []string{"Streamers"}
|
m.authViewport.Width = contentWidth(m.width)
|
||||||
for _, row := range rows {
|
m.authViewport.Height = authViewportHeight(m.height)
|
||||||
prefix := " "
|
|
||||||
if row.entry.ConfigLogin == m.selectedConfig {
|
|
||||||
prefix = "> "
|
|
||||||
}
|
}
|
||||||
|
|
||||||
status := "inactive"
|
func (m *Model) syncAuthViewport() {
|
||||||
if isActive(row.entry) {
|
m.authViewport.SetContent(m.authLogContent())
|
||||||
status = "active"
|
m.authViewport.GotoBottom()
|
||||||
}
|
|
||||||
lines = append(lines, prefix+streamerName(row.entry)+" ["+status+"]")
|
|
||||||
}
|
|
||||||
if len(rows) == 0 {
|
|
||||||
lines = append(lines, " none")
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) rightPaneLines(rows []streamerRow, selected int) []string {
|
func (m Model) authLogContent() string {
|
||||||
lines := []string{"IRC Chat"}
|
if len(m.authLogs) == 0 {
|
||||||
if selected < 0 || selected >= len(rows) {
|
return "Starting authentication..."
|
||||||
return append(lines, "")
|
|
||||||
}
|
}
|
||||||
|
return strings.Join(m.authLogs, "\n")
|
||||||
row := rows[selected].entry
|
|
||||||
if !isActive(row) {
|
|
||||||
return append(lines, "inactive")
|
|
||||||
}
|
|
||||||
|
|
||||||
detail := m.ircDetails[normalizeKey(row.Login)]
|
|
||||||
if !detail.joined {
|
|
||||||
return append(lines, "not joined")
|
|
||||||
}
|
|
||||||
return append(lines, greenDot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isActive(entry twitch.StreamerEntry) bool {
|
func isActive(entry twitch.StreamerEntry) bool {
|
||||||
@@ -439,140 +298,39 @@ func normalizeKey(value string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(value))
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderColumns(left, right []string, totalWidth int) []string {
|
func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry {
|
||||||
if totalWidth <= 0 {
|
logins := make([]string, 0, len(entries))
|
||||||
totalWidth = defaultViewWidth
|
for _, entry := range entries {
|
||||||
|
logins = append(logins, entry.ConfigLogin)
|
||||||
|
}
|
||||||
|
return twitch.LoadingStreamerEntries(logins)
|
||||||
}
|
}
|
||||||
|
|
||||||
leftWidth, rightWidth := splitWidths(totalWidth)
|
func startAuthSession(runtime modelRuntime) tea.Cmd {
|
||||||
height := len(left)
|
|
||||||
if len(right) > height {
|
|
||||||
height = len(right)
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := make([]string, 0, height)
|
|
||||||
for index := 0; index < height; index++ {
|
|
||||||
leftLine := ""
|
|
||||||
if index < len(left) {
|
|
||||||
leftLine = left[index]
|
|
||||||
}
|
|
||||||
rightLine := ""
|
|
||||||
if index < len(right) {
|
|
||||||
rightLine = right[index]
|
|
||||||
}
|
|
||||||
lines = append(lines, fitWidth(leftLine, leftWidth)+" │ "+fitWidth(rightLine, rightWidth))
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitWidths(totalWidth int) (int, int) {
|
|
||||||
if totalWidth < 8 {
|
|
||||||
return max(1, totalWidth-4), 1
|
|
||||||
}
|
|
||||||
|
|
||||||
leftWidth := totalWidth / 3
|
|
||||||
if leftWidth < 20 {
|
|
||||||
leftWidth = 20
|
|
||||||
}
|
|
||||||
if leftWidth > 32 {
|
|
||||||
leftWidth = 32
|
|
||||||
}
|
|
||||||
|
|
||||||
rightWidth := totalWidth - leftWidth - 3
|
|
||||||
if rightWidth < 8 {
|
|
||||||
rightWidth = 8
|
|
||||||
leftWidth = totalWidth - rightWidth - 3
|
|
||||||
if leftWidth < 1 {
|
|
||||||
leftWidth = 1
|
|
||||||
rightWidth = max(1, totalWidth-leftWidth-3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return leftWidth, rightWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
func fitWidth(value string, width int) string {
|
|
||||||
if width <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
runes := []rune(value)
|
|
||||||
if len(runes) > width {
|
|
||||||
return string(runes[:width])
|
|
||||||
}
|
|
||||||
return value + strings.Repeat(" ", width-len(runes))
|
|
||||||
}
|
|
||||||
|
|
||||||
func clipListLines(lines []string, height, selectedLine int) []string {
|
|
||||||
if height <= 0 || len(lines) <= height {
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
if height == 1 {
|
|
||||||
return lines[:1]
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := lines[1:]
|
|
||||||
visibleRows := height - 1
|
|
||||||
selectedRow := selectedLine - 1
|
|
||||||
if selectedRow < 0 {
|
|
||||||
selectedRow = 0
|
|
||||||
}
|
|
||||||
start := 0
|
|
||||||
if selectedRow >= visibleRows {
|
|
||||||
start = selectedRow - visibleRows + 1
|
|
||||||
}
|
|
||||||
if start+visibleRows > len(rows) {
|
|
||||||
start = len(rows) - visibleRows
|
|
||||||
}
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
return append([]string{lines[0]}, rows[start:start+visibleRows]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clipTailLines(lines []string, height int) []string {
|
|
||||||
if height <= 0 || len(lines) <= height {
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
if height == 1 {
|
|
||||||
return lines[:1]
|
|
||||||
}
|
|
||||||
|
|
||||||
body := lines[1:]
|
|
||||||
visibleRows := height - 1
|
|
||||||
if len(body) > visibleRows {
|
|
||||||
body = body[len(body)-visibleRows:]
|
|
||||||
}
|
|
||||||
return append([]string{lines[0]}, body...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// startAuthSession starts the background auth worker and returns its update channel to Bubble Tea.
|
|
||||||
func startAuthSession(start StartAuthFunc) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if start == nil {
|
if runtime == nil {
|
||||||
return AuthUpdate{Err: errors.New("auth start function is nil"), Done: true}
|
return AuthUpdate{Err: errors.New("auth runtime is nil"), Done: true}
|
||||||
}
|
}
|
||||||
updates := make(chan AuthUpdate, 32)
|
updates := make(chan AuthUpdate, 32)
|
||||||
start(updates)
|
runtime.startAuth(updates)
|
||||||
return authStartedMsg{Updates: updates}
|
return authStartedMsg{Updates: updates}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startStreamerResolution starts the background streamer resolver and returns its update channel.
|
func startStreamerResolution(runtime modelRuntime, state *auth.State) tea.Cmd {
|
||||||
func startStreamerResolution(start StartResolveFunc, state *auth.State) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if start == nil {
|
if runtime == nil {
|
||||||
return StreamerUpdate{Err: errors.New("streamer resolution start function is nil"), Done: true}
|
return StreamerUpdate{Err: errors.New("streamer runtime is nil"), Done: true}
|
||||||
}
|
}
|
||||||
if state == nil {
|
if state == nil {
|
||||||
return StreamerUpdate{Err: errors.New("auth state is nil"), Done: true}
|
return StreamerUpdate{Err: errors.New("auth state is nil"), Done: true}
|
||||||
}
|
}
|
||||||
updates := make(chan StreamerUpdate, 32)
|
updates := make(chan StreamerUpdate, 32)
|
||||||
start(state, updates)
|
runtime.startResolve(state, updates)
|
||||||
return streamerStartedMsg{Updates: updates}
|
return streamerStartedMsg{Updates: updates}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForAuthUpdate blocks until the next auth update is available from the worker.
|
|
||||||
func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
|
func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
update, ok := <-updates
|
update, ok := <-updates
|
||||||
@@ -583,7 +341,6 @@ func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForStreamerUpdate blocks until the next streamer update is available from the worker.
|
|
||||||
func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd {
|
func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
update, ok := <-updates
|
update, ok := <-updates
|
||||||
@@ -593,12 +350,3 @@ func waitForStreamerUpdate(updates <-chan StreamerUpdate) tea.Cmd {
|
|||||||
return update
|
return update
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadingEntries resets the UI rows back to their initial loading state.
|
|
||||||
func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry {
|
|
||||||
logins := make([]string, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
logins = append(logins, entry.ConfigLogin)
|
|
||||||
}
|
|
||||||
return twitch.LoadingStreamerEntries(logins)
|
|
||||||
}
|
|
||||||
|
|||||||
+101
-151
@@ -5,118 +5,94 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"parasocial/internal/auth"
|
"parasocial/internal/auth"
|
||||||
"parasocial/internal/twitch"
|
"parasocial/internal/twitch"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestViewDisplaysSplitPaneWithSelectedStreamerDetails(t *testing.T) {
|
type fakeModelRuntime struct {
|
||||||
model := New(Options{
|
authStarted bool
|
||||||
Streamers: []twitch.StreamerEntry{
|
resolveStarted *auth.State
|
||||||
{ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Live: true, Status: twitch.StreamerReady},
|
}
|
||||||
{ConfigLogin: "beta", Login: "beta_live", ChannelID: "2", Live: true, Status: twitch.StreamerReady},
|
|
||||||
{ConfigLogin: "gamma", Status: twitch.StreamerLoading},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
model.mode = streamerView
|
|
||||||
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
|
|
||||||
model.width = 80
|
|
||||||
|
|
||||||
got := model.View()
|
func (f *fakeModelRuntime) startAuth(ch chan<- AuthUpdate) {
|
||||||
if !strings.Contains(got, "Watching: alpha_live, beta_live\n\n") {
|
f.authStarted = true
|
||||||
t.Fatalf("View() missing watching summary:\n%s", got)
|
close(ch)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "> alpha_live [active]") {
|
|
||||||
t.Fatalf("View() missing selected active row:\n%s", got)
|
func (f *fakeModelRuntime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
|
||||||
}
|
f.resolveStarted = state
|
||||||
if !strings.Contains(got, "gamma [inactive]") {
|
close(ch)
|
||||||
t.Fatalf("View() missing inactive row:\n%s", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "IRC Chat") || !strings.Contains(got, "not joined") {
|
|
||||||
t.Fatalf("View() missing detail pane:\n%s", got)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) {
|
||||||
|
model := dashboardModel(
|
||||||
|
twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
|
||||||
|
twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
|
||||||
|
twitch.StreamerEntry{ConfigLogin: "gamma", Status: twitch.StreamerLoading},
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContainsAll(t, model.View(),
|
||||||
|
"Watching: alpha_live, beta_live",
|
||||||
|
"alpha_live",
|
||||||
|
"live | irc idle",
|
||||||
|
"gamma",
|
||||||
|
"loading",
|
||||||
|
"IRC Chat",
|
||||||
|
"not joined",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthUpdateAppendsLogLine(t *testing.T) {
|
func TestAuthUpdateAppendsLogLine(t *testing.T) {
|
||||||
model := New(Options{Streamers: twitch.LoadingStreamerEntries([]string{"alpha"})})
|
updated, cmd := New(Options{Streamers: []string{"alpha"}}).Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"})
|
||||||
|
|
||||||
updated, cmd := model.Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"})
|
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
t.Fatal("expected nil cmd after auth update without channel")
|
t.Fatal("expected nil cmd after auth update without channel")
|
||||||
}
|
}
|
||||||
|
assertContainsAll(t, updated.(Model).View(), "Twitch Login", "Open page: https://www.twitch.tv/activate")
|
||||||
next := updated.(Model)
|
|
||||||
got := next.View()
|
|
||||||
want := "Twitch Login\nOpen page: https://www.twitch.tv/activate\n\n"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("View() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) {
|
func TestAuthSuccessSwitchesToStreamerViewAndStartsResolution(t *testing.T) {
|
||||||
var started *auth.State
|
runtime := &fakeModelRuntime{}
|
||||||
model := New(Options{
|
|
||||||
Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}),
|
|
||||||
StartResolve: func(state *auth.State, ch chan<- StreamerUpdate) {
|
|
||||||
started = state
|
|
||||||
close(ch)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
state := &auth.State{Login: "viewer", UserID: "7"}
|
state := &auth.State{Login: "viewer", UserID: "7"}
|
||||||
|
updated, cmd := New(Options{Streamers: []string{"alpha"}, runtime: runtime}).Update(AuthUpdate{State: state, Done: true})
|
||||||
updated, cmd := model.Update(AuthUpdate{
|
|
||||||
State: state,
|
|
||||||
Done: true,
|
|
||||||
})
|
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
t.Fatal("expected streamer resolution command")
|
t.Fatal("expected streamer resolution command")
|
||||||
}
|
}
|
||||||
if _, ok := cmd().(streamerStartedMsg); !ok {
|
if _, ok := cmd().(streamerStartedMsg); !ok {
|
||||||
t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd())
|
t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd())
|
||||||
}
|
}
|
||||||
if started != state {
|
if runtime.resolveStarted != state {
|
||||||
t.Fatalf("started state = %#v, want %#v", started, state)
|
t.Fatalf("started state = %#v, want %#v", runtime.resolveStarted, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
next := updated.(Model)
|
next := updated.(Model)
|
||||||
got := next.View()
|
assertContainsAll(t, next.View(), "Watching: no live streamers")
|
||||||
if !strings.Contains(got, "Watching: no live streamers") {
|
|
||||||
t.Fatalf("View() = %q", got)
|
|
||||||
}
|
|
||||||
if next.selectedConfig != "alpha" {
|
if next.selectedConfig != "alpha" {
|
||||||
t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig)
|
t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitStartsResolutionWhenAlreadyAuthenticated(t *testing.T) {
|
func TestInitStartsAuthOrResolution(t *testing.T) {
|
||||||
var started *auth.State
|
runtime := &fakeModelRuntime{}
|
||||||
state := &auth.State{Login: "viewer", UserID: "7"}
|
if _, ok := New(Options{Streamers: []string{"alpha"}, runtime: runtime}).Init()().(authStartedMsg); !ok {
|
||||||
model := New(Options{
|
t.Fatal("unauthenticated Init() did not start auth")
|
||||||
Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}),
|
}
|
||||||
AuthState: state,
|
if !runtime.authStarted {
|
||||||
StartResolve: func(got *auth.State, ch chan<- StreamerUpdate) {
|
t.Fatal("expected auth runtime to start")
|
||||||
started = got
|
}
|
||||||
close(ch)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd := model.Init()
|
state := &auth.State{Login: "viewer", UserID: "7"}
|
||||||
if cmd == nil {
|
runtime = &fakeModelRuntime{}
|
||||||
t.Fatal("expected init command")
|
if _, ok := New(Options{Streamers: []string{"alpha"}, AuthState: state, runtime: runtime}).Init()().(streamerStartedMsg); !ok {
|
||||||
|
t.Fatal("authenticated Init() did not start streamer resolution")
|
||||||
}
|
}
|
||||||
if _, ok := cmd().(streamerStartedMsg); !ok {
|
if runtime.resolveStarted != state {
|
||||||
t.Fatalf("cmd() returned %T, want streamerStartedMsg", cmd())
|
t.Fatalf("started state = %#v, want %#v", runtime.resolveStarted, state)
|
||||||
}
|
|
||||||
if started != state {
|
|
||||||
t.Fatalf("started state = %#v, want %#v", started, state)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStreamerUpdateAppliesEntry(t *testing.T) {
|
func TestStreamerUpdateAppliesEntry(t *testing.T) {
|
||||||
model := New(Options{
|
model := New(Options{Streamers: []string{"alpha"}, AuthState: &auth.State{Login: "viewer"}})
|
||||||
Streamers: twitch.LoadingStreamerEntries([]string{"alpha"}),
|
|
||||||
AuthState: &auth.State{Login: "viewer"},
|
|
||||||
})
|
|
||||||
model.mode = streamerView
|
model.mode = streamerView
|
||||||
|
|
||||||
updated, cmd := model.Update(StreamerUpdate{
|
updated, cmd := model.Update(StreamerUpdate{
|
||||||
@@ -135,39 +111,29 @@ func TestStreamerUpdateAppliesEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
next := updated.(Model)
|
next := updated.(Model)
|
||||||
|
assertContainsAll(t, next.View(), "alpha_live", "live")
|
||||||
if next.selectedConfig != "alpha" {
|
if next.selectedConfig != "alpha" {
|
||||||
t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig)
|
t.Fatalf("selectedConfig = %q, want alpha", next.selectedConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestViewDisplaysInactiveDetailForOfflineStreamer(t *testing.T) {
|
func TestViewDisplaysInactiveDetailForOfflineStreamer(t *testing.T) {
|
||||||
model := New(Options{
|
model := dashboardModel(twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady})
|
||||||
Streamers: []twitch.StreamerEntry{
|
assertContainsAll(t, model.View(), "offline", "inactive")
|
||||||
{ConfigLogin: "alpha", Login: "alpha_live", ChannelID: "1", Status: twitch.StreamerReady},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
model.mode = streamerView
|
|
||||||
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
|
|
||||||
model.width = 80
|
|
||||||
|
|
||||||
got := model.View()
|
|
||||||
if !strings.Contains(got, "inactive") {
|
|
||||||
t.Fatalf("View() = %q", got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) {
|
func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) {
|
||||||
model := New(Options{
|
model := New(Options{initialStreamers: []twitch.StreamerEntry{
|
||||||
Streamers: []twitch.StreamerEntry{
|
|
||||||
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
|
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
|
||||||
{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
|
{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
|
||||||
{ConfigLogin: "gamma", Login: "gamma_live", Live: true, Status: twitch.StreamerReady},
|
{ConfigLogin: "gamma", Login: "gamma_live", Live: true, Status: twitch.StreamerReady},
|
||||||
{ConfigLogin: "delta", Status: twitch.StreamerLoading},
|
{ConfigLogin: "delta", Status: twitch.StreamerLoading},
|
||||||
},
|
}})
|
||||||
})
|
|
||||||
|
|
||||||
rows := model.orderedRows()
|
got := []string{}
|
||||||
got := []string{rows[0].entry.ConfigLogin, rows[1].entry.ConfigLogin, rows[2].entry.ConfigLogin, rows[3].entry.ConfigLogin}
|
for _, entry := range model.orderedStreamers() {
|
||||||
|
got = append(got, entry.ConfigLogin)
|
||||||
|
}
|
||||||
want := []string{"beta", "gamma", "alpha", "delta"}
|
want := []string{"beta", "gamma", "alpha", "delta"}
|
||||||
for index := range want {
|
for index := range want {
|
||||||
if got[index] != want[index] {
|
if got[index] != want[index] {
|
||||||
@@ -178,7 +144,7 @@ func TestActiveStreamersRenderBeforeInactiveInConfigOrder(t *testing.T) {
|
|||||||
|
|
||||||
func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) {
|
func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) {
|
||||||
model := New(Options{
|
model := New(Options{
|
||||||
Streamers: []twitch.StreamerEntry{
|
initialStreamers: []twitch.StreamerEntry{
|
||||||
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
|
{ConfigLogin: "alpha", Login: "alpha_live", Status: twitch.StreamerReady},
|
||||||
{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
|
{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
|
||||||
},
|
},
|
||||||
@@ -189,33 +155,19 @@ func TestSelectionTracksSameStreamerAcrossReorder(t *testing.T) {
|
|||||||
|
|
||||||
updated, _ := model.Update(StreamerUpdate{
|
updated, _ := model.Update(StreamerUpdate{
|
||||||
Index: 1,
|
Index: 1,
|
||||||
Entry: &twitch.StreamerEntry{
|
Entry: &twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Live: true, Status: twitch.StreamerReady},
|
||||||
ConfigLogin: "beta",
|
|
||||||
Login: "beta_live",
|
|
||||||
Live: true,
|
|
||||||
Status: twitch.StreamerReady,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
next := updated.(Model)
|
next := updated.(Model)
|
||||||
|
if next.selectedConfig != "beta" || next.selectedRowIndex(next.orderedStreamers()) != 0 {
|
||||||
if next.selectedConfig != "beta" {
|
t.Fatalf("selection moved after reorder: %q at %d", next.selectedConfig, next.selectedRowIndex(next.orderedStreamers()))
|
||||||
t.Fatalf("selectedConfig = %q, want beta", next.selectedConfig)
|
|
||||||
}
|
|
||||||
if next.selectedRowIndex(next.orderedRows()) != 0 {
|
|
||||||
t.Fatalf("selected row index = %d, want 0", next.selectedRowIndex(next.orderedRows()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) {
|
func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) {
|
||||||
model := New(Options{
|
model := dashboardModel(
|
||||||
Streamers: []twitch.StreamerEntry{
|
twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
|
||||||
{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
|
twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
|
||||||
{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
|
)
|
||||||
},
|
|
||||||
AuthState: &auth.State{Login: "viewer"},
|
|
||||||
})
|
|
||||||
model.mode = streamerView
|
|
||||||
model.selectedConfig = "alpha"
|
|
||||||
|
|
||||||
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
|
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||||
next := updated.(Model)
|
next := updated.(Model)
|
||||||
@@ -231,49 +183,47 @@ func TestUpDownNavigationMovesSelectedStreamer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIRCUpdatesShowJoinedStatusOnly(t *testing.T) {
|
func TestIRCUpdatesShowJoinedStatusOnly(t *testing.T) {
|
||||||
model := New(Options{
|
updated, _ := dashboardModel(twitch.StreamerEntry{
|
||||||
Streamers: []twitch.StreamerEntry{
|
ConfigLogin: "alpha",
|
||||||
{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
|
|
||||||
},
|
|
||||||
AuthState: &auth.State{Login: "viewer"},
|
|
||||||
})
|
|
||||||
model.mode = streamerView
|
|
||||||
model.width = 80
|
|
||||||
|
|
||||||
updated, _ := model.Update(StreamerUpdate{
|
|
||||||
IRC: &IRCUpdate{
|
|
||||||
Login: "alpha_live",
|
Login: "alpha_live",
|
||||||
State: IRCJoined,
|
Live: true,
|
||||||
},
|
Status: twitch.StreamerReady,
|
||||||
})
|
}).Update(StreamerUpdate{IRC: &IRCUpdate{Login: "alpha_live", State: IRCJoined}})
|
||||||
next := updated.(Model)
|
next := updated.(Model)
|
||||||
|
|
||||||
detail := next.ircDetails["alpha_live"]
|
if !next.ircDetails["alpha_live"].joined {
|
||||||
if !detail.joined {
|
|
||||||
t.Fatal("expected joined detail")
|
t.Fatal("expected joined detail")
|
||||||
}
|
}
|
||||||
if !strings.Contains(next.View(), greenDot) {
|
assertContainsAll(t, next.View(), "joined")
|
||||||
t.Fatalf("View() missing green status dot:\n%s", next.View())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWindowSizeClipsListAroundSelection(t *testing.T) {
|
func TestWindowSizeKeepsSelectionVisible(t *testing.T) {
|
||||||
model := New(Options{
|
model := dashboardModel(
|
||||||
Streamers: []twitch.StreamerEntry{
|
twitch.StreamerEntry{ConfigLogin: "alpha", Status: twitch.StreamerReady},
|
||||||
{ConfigLogin: "alpha", Status: twitch.StreamerReady},
|
twitch.StreamerEntry{ConfigLogin: "beta", Status: twitch.StreamerReady},
|
||||||
{ConfigLogin: "beta", Status: twitch.StreamerReady},
|
twitch.StreamerEntry{ConfigLogin: "gamma", Status: twitch.StreamerReady},
|
||||||
{ConfigLogin: "gamma", Status: twitch.StreamerReady},
|
twitch.StreamerEntry{ConfigLogin: "delta", Status: twitch.StreamerReady},
|
||||||
{ConfigLogin: "delta", Status: twitch.StreamerReady},
|
)
|
||||||
},
|
|
||||||
AuthState: &auth.State{Login: "viewer"},
|
|
||||||
})
|
|
||||||
model.mode = streamerView
|
|
||||||
model.selectedConfig = "delta"
|
model.selectedConfig = "delta"
|
||||||
|
|
||||||
updated, _ := model.Update(tea.WindowSizeMsg{Width: 60, Height: 6})
|
updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
|
||||||
next := updated.(Model)
|
assertContainsAll(t, updated.(Model).View(), "delta")
|
||||||
got := next.View()
|
}
|
||||||
if !strings.Contains(got, "> delta [inactive]") {
|
|
||||||
t.Fatalf("View() missing selected row after clipping:\n%s", got)
|
func dashboardModel(entries ...twitch.StreamerEntry) Model {
|
||||||
|
model := New(Options{initialStreamers: entries, AuthState: &auth.State{Login: "viewer"}})
|
||||||
|
model.mode = streamerView
|
||||||
|
model.viewer = &twitch.Viewer{ID: "7", Login: "viewer"}
|
||||||
|
model.width = 100
|
||||||
|
model.height = 28
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertContainsAll(t *testing.T, got string, wants ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, want := range wants {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("missing %q in:\n%s", want, got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"parasocial/internal/auth"
|
||||||
|
"parasocial/internal/gql"
|
||||||
|
"parasocial/internal/irc"
|
||||||
|
"parasocial/internal/twitch"
|
||||||
|
)
|
||||||
|
|
||||||
|
const streamerRefreshInterval = 5 * time.Minute
|
||||||
|
|
||||||
|
// Options configures the TUI runtime.
|
||||||
|
type Options struct {
|
||||||
|
Streamers []string
|
||||||
|
AuthState *auth.State
|
||||||
|
AuthPath string
|
||||||
|
Input io.Reader
|
||||||
|
Output io.Writer
|
||||||
|
|
||||||
|
runtime modelRuntime
|
||||||
|
initialStreamers []twitch.StreamerEntry
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type runtime struct {
|
||||||
|
ctx context.Context
|
||||||
|
logins []string
|
||||||
|
httpClient *http.Client
|
||||||
|
authClient *auth.Client
|
||||||
|
authPath string
|
||||||
|
refreshEvery time.Duration
|
||||||
|
sleep func(context.Context, time.Duration) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the Bubble Tea UI and keeps application orchestration inside the TUI package.
|
||||||
|
func Run(ctx context.Context, options Options) error {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rt := newRuntime(ctx, options)
|
||||||
|
state := options.AuthState
|
||||||
|
if state == nil {
|
||||||
|
var err error
|
||||||
|
state, err = rt.reuseAuth()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options.AuthState = state
|
||||||
|
options.runtime = rt
|
||||||
|
|
||||||
|
input := options.Input
|
||||||
|
if input == nil {
|
||||||
|
input = os.Stdin
|
||||||
|
}
|
||||||
|
output := options.Output
|
||||||
|
if output == nil {
|
||||||
|
output = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
program := tea.NewProgram(
|
||||||
|
New(options),
|
||||||
|
tea.WithContext(ctx),
|
||||||
|
tea.WithInput(input),
|
||||||
|
tea.WithOutput(output),
|
||||||
|
)
|
||||||
|
_, err := program.Run()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRuntime(ctx context.Context, options Options) *runtime {
|
||||||
|
httpClient := options.httpClient
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
authPath := options.AuthPath
|
||||||
|
if authPath == "" {
|
||||||
|
authPath = auth.DefaultPath()
|
||||||
|
}
|
||||||
|
return &runtime{
|
||||||
|
ctx: ctx,
|
||||||
|
logins: append([]string(nil), options.Streamers...),
|
||||||
|
httpClient: httpClient,
|
||||||
|
authClient: auth.NewClient(httpClient),
|
||||||
|
authPath: authPath,
|
||||||
|
refreshEvery: streamerRefreshInterval,
|
||||||
|
sleep: sleepContext,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runtime) reuseAuth() (*auth.State, error) {
|
||||||
|
return r.authClient.ReuseAuth(r.ctx, r.authPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runtime) startAuth(ch chan<- AuthUpdate) {
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
|
||||||
|
state, err := r.authClient.EnsureAuth(r.ctx, r.authPath, func(line string) {
|
||||||
|
ch <- AuthUpdate{Line: strings.TrimRight(line, "\n")}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ch <- AuthUpdate{
|
||||||
|
Line: fmt.Sprintf("Authentication failed: %v", err),
|
||||||
|
Err: err,
|
||||||
|
Done: true,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch <- AuthUpdate{State: state, Done: true}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
ircManager := newIRCManager(ch)
|
||||||
|
defer ircManager.Close()
|
||||||
|
|
||||||
|
service, err := newTwitchService(r.httpClient, state)
|
||||||
|
if err != nil {
|
||||||
|
ch <- StreamerUpdate{Err: err, Done: true}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := resolveStreamerEntriesWithSleep(r.ctx, service, state, r.logins, ircManager, func(update StreamerUpdate) {
|
||||||
|
ch <- update
|
||||||
|
}, r.refreshEvery, r.sleep); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- StreamerUpdate{Err: err, Done: true}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- StreamerUpdate{Done: true}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamerService interface {
|
||||||
|
CurrentUser(context.Context) (*twitch.Viewer, error)
|
||||||
|
ResolveStreamer(context.Context, string) (*twitch.Channel, error)
|
||||||
|
StreamInfo(context.Context, string) (*twitch.StreamInfo, error)
|
||||||
|
PlaybackAccessToken(context.Context, string) (*twitch.PlaybackToken, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ircSyncer interface {
|
||||||
|
Sync(context.Context, string, string, []irc.Target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTwitchService(httpClient *http.Client, state *auth.State) (*twitch.Service, error) {
|
||||||
|
client := &gql.Client{
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
Session: gql.Session{
|
||||||
|
AccessToken: state.AccessToken,
|
||||||
|
ClientID: state.ClientID,
|
||||||
|
DeviceID: state.DeviceID,
|
||||||
|
UserAgent: auth.TVUserAgent(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := client.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("configure graphql session: %w", err)
|
||||||
|
}
|
||||||
|
return &twitch.Service{GQL: client}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIRCManager(ch chan<- StreamerUpdate) *irc.Manager {
|
||||||
|
return &irc.Manager{
|
||||||
|
Addr: irc.DefaultAddr,
|
||||||
|
Events: func(event irc.Event) {
|
||||||
|
ch <- StreamerUpdate{
|
||||||
|
IRC: &IRCUpdate{
|
||||||
|
Login: event.Streamer,
|
||||||
|
State: IRCState(event.State),
|
||||||
|
Line: event.Line,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(StreamerUpdate)) error {
|
||||||
|
return resolveStreamerEntriesWithSleep(ctx, service, state, logins, syncer, send, streamerRefreshInterval, sleepContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStreamerEntriesWithSleep(ctx context.Context, service streamerService, state *auth.State, logins []string, syncer ircSyncer, send func(StreamerUpdate), interval time.Duration, sleep func(context.Context, time.Duration) error) error {
|
||||||
|
viewer, err := service.CurrentUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve current user: %w", err)
|
||||||
|
}
|
||||||
|
send(StreamerUpdate{Viewer: viewer})
|
||||||
|
|
||||||
|
entries := twitch.LoadingStreamerEntries(logins)
|
||||||
|
syncWatchedChannels(ctx, syncer, state, entries)
|
||||||
|
|
||||||
|
for {
|
||||||
|
for index, login := range logins {
|
||||||
|
entry, err := resolveStreamerEntry(ctx, service, login)
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries[index] = *entry
|
||||||
|
send(StreamerUpdate{
|
||||||
|
Index: index,
|
||||||
|
Entry: entry,
|
||||||
|
})
|
||||||
|
syncWatchedChannels(ctx, syncer, state, entries)
|
||||||
|
}
|
||||||
|
if err := sleep(ctx, interval); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStreamerEntry(ctx context.Context, service streamerService, login string) (*twitch.StreamerEntry, error) {
|
||||||
|
entry := &twitch.StreamerEntry{
|
||||||
|
ConfigLogin: login,
|
||||||
|
Status: twitch.StreamerLoading,
|
||||||
|
}
|
||||||
|
|
||||||
|
channel, err := service.ResolveStreamer(ctx, login)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
entry.Login = channel.Login
|
||||||
|
entry.ChannelID = channel.ID
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
return nil, err
|
||||||
|
default:
|
||||||
|
entry.Status = twitch.StreamerError
|
||||||
|
entry.Error = err.Error()
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := service.StreamInfo(ctx, channel.ID)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
entry.Status = twitch.StreamerReady
|
||||||
|
entry.Live = stream.Online
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
return nil, err
|
||||||
|
default:
|
||||||
|
entry.Status = twitch.StreamerError
|
||||||
|
entry.Error = err.Error()
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !entry.Live {
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
if _, err := service.PlaybackAccessToken(ctx, channel.Login); err != nil && errors.Is(err, context.Canceled) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleepContext(ctx context.Context, duration time.Duration) error {
|
||||||
|
timer := time.NewTimer(duration)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncWatchedChannels(ctx context.Context, syncer ircSyncer, state *auth.State, entries []twitch.StreamerEntry) {
|
||||||
|
if syncer == nil || state == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncer.Sync(ctx, state.Login, state.AccessToken, watchedIRCTargets(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchedIRCTargets(entries []twitch.StreamerEntry) []irc.Target {
|
||||||
|
targets := make([]irc.Target, 0, 2)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Status != twitch.StreamerReady || !entry.Live || entry.Login == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets = append(targets, irc.Target{Login: entry.Login})
|
||||||
|
if len(targets) == 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"parasocial/internal/auth"
|
||||||
|
"parasocial/internal/irc"
|
||||||
|
"parasocial/internal/twitch"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeStreamerService struct {
|
||||||
|
channels map[string]*twitch.Channel
|
||||||
|
resolveErrs map[string]error
|
||||||
|
streams map[string][]streamResult
|
||||||
|
streamCalls map[string]int
|
||||||
|
playbackErr map[string]error
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamResult struct {
|
||||||
|
info *twitch.StreamInfo
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStreamerService) CurrentUser(context.Context) (*twitch.Viewer, error) {
|
||||||
|
return &twitch.Viewer{ID: "7", Login: "viewer"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStreamerService) ResolveStreamer(_ context.Context, login string) (*twitch.Channel, error) {
|
||||||
|
if err, ok := f.resolveErrs[login]; ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.channels[login], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStreamerService) StreamInfo(_ context.Context, channelID string) (*twitch.StreamInfo, error) {
|
||||||
|
call := f.streamCalls[channelID]
|
||||||
|
f.streamCalls[channelID] = call + 1
|
||||||
|
results := f.streams[channelID]
|
||||||
|
if len(results) == 0 {
|
||||||
|
return &twitch.StreamInfo{Online: false}, nil
|
||||||
|
}
|
||||||
|
if call >= len(results) {
|
||||||
|
call = len(results) - 1
|
||||||
|
}
|
||||||
|
if results[call].err != nil {
|
||||||
|
return nil, results[call].err
|
||||||
|
}
|
||||||
|
return results[call].info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStreamerService) PlaybackAccessToken(_ context.Context, login string) (*twitch.PlaybackToken, error) {
|
||||||
|
if err, ok := f.playbackErr[login]; ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &twitch.PlaybackToken{Signature: "sig", Value: "token"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeIRCSyncer struct {
|
||||||
|
calls [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIRCSyncer) Sync(_ context.Context, _, _ string, targets []irc.Target) {
|
||||||
|
logins := make([]string, 0, len(targets))
|
||||||
|
for _, target := range targets {
|
||||||
|
logins = append(logins, target.Login)
|
||||||
|
}
|
||||||
|
f.calls = append(f.calls, logins)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStreamerEntries(t *testing.T) {
|
||||||
|
service := streamService(
|
||||||
|
channel("alpha", "1", "alpha_live"),
|
||||||
|
resolveErr("beta", errors.New("lookup failed")),
|
||||||
|
live("1", true),
|
||||||
|
)
|
||||||
|
|
||||||
|
updates := collectUpdates(t, service, []string{"alpha", "beta"}, nil, cancelAfter(0))
|
||||||
|
if len(updates) != 3 {
|
||||||
|
t.Fatalf("len(updates) = %d", len(updates))
|
||||||
|
}
|
||||||
|
if updates[0].Viewer == nil || updates[0].Viewer.Login != "viewer" {
|
||||||
|
t.Fatalf("viewer update = %#v", updates[0])
|
||||||
|
}
|
||||||
|
if entry := updates[1].Entry; entry == nil || entry.Status != twitch.StreamerReady || entry.Login != "alpha_live" || !entry.Live {
|
||||||
|
t.Fatalf("alpha update = %#v", updates[1])
|
||||||
|
}
|
||||||
|
if entry := updates[2].Entry; entry == nil || entry.Status != twitch.StreamerError {
|
||||||
|
t.Fatalf("beta update = %#v", updates[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStreamerEntriesPlaybackFailureStillMarksLive(t *testing.T) {
|
||||||
|
updates := collectUpdates(t, streamService(
|
||||||
|
channel("alpha", "1", "alpha_live"),
|
||||||
|
live("1", true),
|
||||||
|
playbackErr("alpha_live", errors.New("token lookup failed")),
|
||||||
|
), []string{"alpha"}, nil, cancelAfter(0))
|
||||||
|
|
||||||
|
if entry := updates[1].Entry; entry == nil || entry.Status != twitch.StreamerReady || !entry.Live {
|
||||||
|
t.Fatalf("entry = %#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStreamerEntriesRefreshesLiveState(t *testing.T) {
|
||||||
|
updates := collectUpdates(t, streamService(
|
||||||
|
channel("alpha", "1", "alpha_live"),
|
||||||
|
streams("1", false, true),
|
||||||
|
), []string{"alpha"}, nil, cancelAfter(1))
|
||||||
|
|
||||||
|
if len(updates) != 3 {
|
||||||
|
t.Fatalf("len(updates) = %d", len(updates))
|
||||||
|
}
|
||||||
|
if updates[1].Entry == nil || updates[1].Entry.Live {
|
||||||
|
t.Fatalf("first pass = %#v", updates[1])
|
||||||
|
}
|
||||||
|
if updates[2].Entry == nil || !updates[2].Entry.Live {
|
||||||
|
t.Fatalf("second pass = %#v", updates[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStreamerEntriesSyncsTopTwoLiveChannels(t *testing.T) {
|
||||||
|
syncer := &fakeIRCSyncer{}
|
||||||
|
collectUpdates(t, streamService(
|
||||||
|
channel("alpha", "1", "alpha_live"),
|
||||||
|
channel("beta", "2", "beta_live"),
|
||||||
|
channel("gamma", "3", "gamma_live"),
|
||||||
|
live("1", true),
|
||||||
|
live("2", true),
|
||||||
|
live("3", true),
|
||||||
|
), []string{"alpha", "beta", "gamma"}, syncer, cancelAfter(0))
|
||||||
|
|
||||||
|
assertSyncCalls(t, syncer.calls, [][]string{
|
||||||
|
{},
|
||||||
|
{"alpha_live"},
|
||||||
|
{"alpha_live", "beta_live"},
|
||||||
|
{"alpha_live", "beta_live"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(t *testing.T) {
|
||||||
|
syncer := &fakeIRCSyncer{}
|
||||||
|
collectUpdates(t, streamService(
|
||||||
|
channel("alpha", "1", "alpha_live"),
|
||||||
|
channel("beta", "2", "beta_live"),
|
||||||
|
streams("1", true, false),
|
||||||
|
streams("2", false, true),
|
||||||
|
), []string{"alpha", "beta"}, syncer, cancelAfter(1))
|
||||||
|
|
||||||
|
assertSyncCalls(t, syncer.calls, [][]string{
|
||||||
|
{},
|
||||||
|
{"alpha_live"},
|
||||||
|
{"alpha_live"},
|
||||||
|
{},
|
||||||
|
{"beta_live"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceOption func(*fakeStreamerService)
|
||||||
|
|
||||||
|
func streamService(options ...serviceOption) *fakeStreamerService {
|
||||||
|
service := &fakeStreamerService{
|
||||||
|
channels: map[string]*twitch.Channel{},
|
||||||
|
resolveErrs: map[string]error{},
|
||||||
|
streams: map[string][]streamResult{},
|
||||||
|
streamCalls: map[string]int{},
|
||||||
|
playbackErr: map[string]error{},
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
option(service)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func channel(login, id, resolved string) serviceOption {
|
||||||
|
return func(service *fakeStreamerService) {
|
||||||
|
service.channels[login] = &twitch.Channel{ID: id, Login: resolved}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveErr(login string, err error) serviceOption {
|
||||||
|
return func(service *fakeStreamerService) {
|
||||||
|
service.resolveErrs[login] = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func live(channelID string, online bool) serviceOption {
|
||||||
|
return streams(channelID, online)
|
||||||
|
}
|
||||||
|
|
||||||
|
func streams(channelID string, online ...bool) serviceOption {
|
||||||
|
return func(service *fakeStreamerService) {
|
||||||
|
for _, value := range online {
|
||||||
|
service.streams[channelID] = append(service.streams[channelID], streamResult{info: &twitch.StreamInfo{Online: value}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func playbackErr(login string, err error) serviceOption {
|
||||||
|
return func(service *fakeStreamerService) {
|
||||||
|
service.playbackErr[login] = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectUpdates(t *testing.T, service *fakeStreamerService, logins []string, syncer ircSyncer, sleep func(context.Context, time.Duration) error) []StreamerUpdate {
|
||||||
|
t.Helper()
|
||||||
|
var updates []StreamerUpdate
|
||||||
|
err := resolveStreamerEntriesWithSleep(context.Background(), service, &auth.State{Login: "viewer", AccessToken: "token"}, logins, syncer, func(update StreamerUpdate) {
|
||||||
|
updates = append(updates, update)
|
||||||
|
}, 0, sleep)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelAfter(allowedSleeps int) func(context.Context, time.Duration) error {
|
||||||
|
calls := 0
|
||||||
|
return func(context.Context, time.Duration) error {
|
||||||
|
calls++
|
||||||
|
if calls <= allowedSleeps {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return context.Canceled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSyncCalls(t *testing.T, got, want [][]string) {
|
||||||
|
t.Helper()
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("sync calls = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
pageStyle = lipgloss.NewStyle().
|
||||||
|
Padding(1, 2)
|
||||||
|
titleStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#E6EDF3"))
|
||||||
|
mutedStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#6E7681"))
|
||||||
|
accentStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#7CE38B"))
|
||||||
|
warnStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#F0883E"))
|
||||||
|
errorStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#FF7B72"))
|
||||||
|
panelStyle = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("#30363D")).
|
||||||
|
Padding(1, 2)
|
||||||
|
labelStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#8B949E")).
|
||||||
|
Bold(true)
|
||||||
|
statusLiveStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#7CE38B"))
|
||||||
|
statusIdleStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#8B949E"))
|
||||||
|
statusErrorStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#FF7B72"))
|
||||||
|
rowNameStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#C9D1D9")).
|
||||||
|
PaddingLeft(1)
|
||||||
|
rowDescStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#8B949E")).
|
||||||
|
PaddingLeft(1)
|
||||||
|
selectedRowNameStyle = rowNameStyle.
|
||||||
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
|
BorderForeground(lipgloss.Color("#7CE38B")).
|
||||||
|
Foreground(lipgloss.Color("#E6EDF3"))
|
||||||
|
selectedRowDescStyle = rowDescStyle.
|
||||||
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
|
BorderForeground(lipgloss.Color("#7CE38B"))
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAuthViewport(width, height int) viewport.Model {
|
||||||
|
vp := viewport.New(width, height)
|
||||||
|
vp.Style = lipgloss.NewStyle()
|
||||||
|
return vp
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"parasocial/internal/twitch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// View renders either the login log screen or the authenticated streamer dashboard.
|
||||||
|
func (m Model) View() string {
|
||||||
|
if m.mode == authView {
|
||||||
|
return m.renderAuthView()
|
||||||
|
}
|
||||||
|
return m.renderStreamerView()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderAuthView() string {
|
||||||
|
body := []string{
|
||||||
|
titleStyle.Render("Twitch Login"),
|
||||||
|
mutedStyle.Render("Complete the device authorization flow to continue."),
|
||||||
|
"",
|
||||||
|
panelStyle.Width(contentWidth(m.width)).Render(m.authViewport.View()),
|
||||||
|
}
|
||||||
|
if m.authErr != nil {
|
||||||
|
body = append(body, "", errorStyle.Render("Login did not complete. Press q to quit."))
|
||||||
|
}
|
||||||
|
return pageStyle.Render(strings.Join(body, "\n")) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderStreamerView() string {
|
||||||
|
header := m.renderDashboardHeader()
|
||||||
|
left := panelStyle.
|
||||||
|
Width(streamerListWidth(m.width)).
|
||||||
|
Height(panelHeight(m.height)).
|
||||||
|
Render(labelStyle.Render("Streamers") + "\n" + m.renderStreamerRows())
|
||||||
|
right := panelStyle.
|
||||||
|
Width(detailWidth(m.width)).
|
||||||
|
Height(panelHeight(m.height)).
|
||||||
|
Render(m.renderDetailPanel())
|
||||||
|
|
||||||
|
body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
|
||||||
|
return pageStyle.Render(header+"\n\n"+body) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderDashboardHeader() string {
|
||||||
|
var identity string
|
||||||
|
switch {
|
||||||
|
case m.viewer != nil:
|
||||||
|
identity = fmt.Sprintf("Logged in as %s (%s)", m.viewer.Login, m.viewer.ID)
|
||||||
|
case m.authState != nil && m.authState.Login != "":
|
||||||
|
identity = fmt.Sprintf("Logged in as %s", m.authState.Login)
|
||||||
|
default:
|
||||||
|
identity = "Logged in"
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := "Watching: " + watchingSummary(m.streamers)
|
||||||
|
if m.resolveErr != nil {
|
||||||
|
summary += " " + warnStyle.Render(fmt.Sprintf("Viewer lookup failed: %v", m.resolveErr))
|
||||||
|
} else if m.viewer == nil {
|
||||||
|
summary += " " + mutedStyle.Render("Resolving viewer identity...")
|
||||||
|
}
|
||||||
|
return titleStyle.Render(identity) + "\n" + mutedStyle.Render(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderDetailPanel() string {
|
||||||
|
entries := m.orderedStreamers()
|
||||||
|
selected := m.selectedRowIndex(entries)
|
||||||
|
if selected < 0 || selected >= len(entries) {
|
||||||
|
return labelStyle.Render("IRC Chat") + "\n\n" + mutedStyle.Render("No streamers configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := entries[selected]
|
||||||
|
lines := []string{
|
||||||
|
labelStyle.Render("IRC Chat"),
|
||||||
|
"",
|
||||||
|
titleStyle.Render(streamerName(entry)),
|
||||||
|
"Status: " + statusText(entry),
|
||||||
|
}
|
||||||
|
if entry.Error != "" {
|
||||||
|
lines = append(lines, "Error: "+errorStyle.Render(entry.Error))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isActive(entry) {
|
||||||
|
lines = append(lines, "IRC: "+mutedStyle.Render("inactive"))
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := m.ircDetails[normalizeKey(entry.Login)]
|
||||||
|
if detail.joined {
|
||||||
|
lines = append(lines, "IRC: "+accentStyle.Render("joined"))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "IRC: "+mutedStyle.Render("not joined"))
|
||||||
|
}
|
||||||
|
if detail.line != "" {
|
||||||
|
lines = append(lines, "", mutedStyle.Render(detail.line))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderStreamerRows() string {
|
||||||
|
entries := m.visibleStreamers()
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return mutedStyle.Render("none")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, 0, len(entries)*2)
|
||||||
|
for _, entry := range entries {
|
||||||
|
nameStyle, descStyle := rowNameStyle, rowDescStyle
|
||||||
|
if entry.ConfigLogin == m.selectedConfig {
|
||||||
|
nameStyle, descStyle = selectedRowNameStyle, selectedRowDescStyle
|
||||||
|
}
|
||||||
|
detail := m.ircDetails[normalizeKey(entry.Login)]
|
||||||
|
lines = append(lines,
|
||||||
|
nameStyle.Render(streamerName(entry)),
|
||||||
|
descStyle.Render(rawStatus(entry)+" | "+ircSummary(detail)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) visibleStreamers() []twitch.StreamerEntry {
|
||||||
|
entries := m.orderedStreamers()
|
||||||
|
visible := max(1, streamerListHeight(m.height)/2)
|
||||||
|
if len(entries) <= visible {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := max(0, m.selectedRowIndex(entries))
|
||||||
|
start := selected - visible + 1
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if start+visible > len(entries) {
|
||||||
|
start = len(entries) - visible
|
||||||
|
}
|
||||||
|
return entries[start : start+visible]
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchingSummary(streamers []twitch.StreamerEntry) string {
|
||||||
|
live := make([]string, 0, 2)
|
||||||
|
for _, streamer := range streamers {
|
||||||
|
if streamer.Status != twitch.StreamerReady || !streamer.Live {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := streamer.Login
|
||||||
|
if name == "" {
|
||||||
|
name = streamer.ConfigLogin
|
||||||
|
}
|
||||||
|
live = append(live, name)
|
||||||
|
if len(live) == 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(live) == 0 {
|
||||||
|
return "no live streamers"
|
||||||
|
}
|
||||||
|
return strings.Join(live, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusText(entry twitch.StreamerEntry) string {
|
||||||
|
switch {
|
||||||
|
case entry.Status == twitch.StreamerError:
|
||||||
|
return statusErrorStyle.Render("error")
|
||||||
|
case entry.Status == twitch.StreamerLoading:
|
||||||
|
return mutedStyle.Render("loading")
|
||||||
|
case entry.Live:
|
||||||
|
return statusLiveStyle.Render("live")
|
||||||
|
default:
|
||||||
|
return statusIdleStyle.Render("offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawStatus(entry twitch.StreamerEntry) string {
|
||||||
|
switch {
|
||||||
|
case entry.Status == twitch.StreamerError:
|
||||||
|
return "error"
|
||||||
|
case entry.Status == twitch.StreamerLoading:
|
||||||
|
return "loading"
|
||||||
|
case entry.Live:
|
||||||
|
return "live"
|
||||||
|
default:
|
||||||
|
return "offline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ircSummary(detail ircDetail) string {
|
||||||
|
if detail.joined {
|
||||||
|
return "irc joined"
|
||||||
|
}
|
||||||
|
return "irc idle"
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentWidth(width int) int {
|
||||||
|
if width <= 0 {
|
||||||
|
return defaultViewWidth
|
||||||
|
}
|
||||||
|
return max(24, width-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authViewportHeight(height int) int {
|
||||||
|
if height <= 0 {
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
return max(4, height-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamerListWidth(width int) int {
|
||||||
|
if width <= 0 {
|
||||||
|
return 34
|
||||||
|
}
|
||||||
|
if width < 72 {
|
||||||
|
return max(24, width/2-5)
|
||||||
|
}
|
||||||
|
return min(40, max(30, width/3))
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailWidth(width int) int {
|
||||||
|
if width <= 0 {
|
||||||
|
return defaultViewWidth - streamerListWidth(width) - 7
|
||||||
|
}
|
||||||
|
return max(24, width-streamerListWidth(width)-11)
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamerListHeight(height int) int {
|
||||||
|
if height <= 0 {
|
||||||
|
return 14
|
||||||
|
}
|
||||||
|
return max(4, height-10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func panelHeight(height int) int {
|
||||||
|
if height <= 0 {
|
||||||
|
return 16
|
||||||
|
}
|
||||||
|
return max(6, height-8)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user