├── .gitignore ├── images ├── config-page.png └── letterboxd-sync.png ├── Directory.Build.props ├── LetterboxdSync ├── Configuration │ ├── PluginConfiguration.cs │ └── Account.cs ├── API │ └── LetterboxdSyncController.cs ├── LetterboxdSync.csproj ├── Plugin.cs ├── Web │ ├── configLetterboxd.html │ └── configLetterboxd.js ├── LetterboxdSyncTask.cs └── LetterboxdApi.cs ├── LetterboxdSync.sln ├── LICENSE ├── README.md ├── manifest.json ├── jellyfin.ruleset └── .github └── workflows └── publish.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | .vs/ 4 | .idea/ 5 | artifacts 6 | .DS_Store 7 | build.sh -------------------------------------------------------------------------------- /images/config-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielveigasilva/jellyfin-plugin-letterboxd-sync/HEAD/images/config-page.png -------------------------------------------------------------------------------- /images/letterboxd-sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielveigasilva/jellyfin-plugin-letterboxd-sync/HEAD/images/letterboxd-sync.png -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1.1.2.0 4 | 1.1.2.0 5 | 1.1.2.0 6 | 7 | 8 | -------------------------------------------------------------------------------- /LetterboxdSync/Configuration/PluginConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using MediaBrowser.Model.Plugins; 3 | 4 | namespace LetterboxdSync.Configuration; 5 | 6 | public class PluginConfiguration : BasePluginConfiguration 7 | { 8 | public List Accounts { get; set; } = new List(); 9 | } 10 | -------------------------------------------------------------------------------- /LetterboxdSync/Configuration/Account.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace LetterboxdSync.Configuration; 4 | 5 | public class Account 6 | { 7 | public string? UserJellyfin { get; set; } 8 | 9 | public string? UserLetterboxd { get; set; } 10 | 11 | public string? PasswordLetterboxd { get; set; } 12 | 13 | public bool Enable { get; set; } 14 | 15 | public bool SendFavorite { get; set; } 16 | } 17 | -------------------------------------------------------------------------------- /LetterboxdSync.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # 3 | VisualStudioVersion = 17.5.002.0 4 | MinimumVisualStudioVersion = 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LetterboxdSync", "LetterboxdSync\LetterboxdSync.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU 17 | EndGlobalSection 18 | GlobalSection(SolutionProperties) = preSolution 19 | HideSolutionNode = FALSE 20 | EndGlobalSection 21 | EndGlobal 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Daniel Veiga 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 | -------------------------------------------------------------------------------- /LetterboxdSync/API/LetterboxdSyncController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Mime; 3 | using System.Threading.Tasks; 4 | using LetterboxdSync.Configuration; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.Mvc; 8 | 9 | namespace LetterboxdSync.API; 10 | 11 | [ApiController] 12 | [Produces(MediaTypeNames.Application.Json)] 13 | //[Authorize(Policy = Policies.SubtitleManagement)] 14 | public class LetterboxdSyncController : ControllerBase 15 | { 16 | [HttpPost("Jellyfin.Plugin.LetterboxdSync/Authenticate")] 17 | [ProducesResponseType(StatusCodes.Status200OK)] 18 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 19 | [ProducesResponseType(StatusCodes.Status401Unauthorized)] 20 | public async Task Authenticate([FromBody] Account body) 21 | { 22 | var api = new LetterboxdApi(); 23 | try 24 | { 25 | await api.Authenticate(body.UserLetterboxd, body.PasswordLetterboxd).ConfigureAwait(false); 26 | return Ok(); 27 | } 28 | catch(Exception ex) 29 | { 30 | return Unauthorized(new { Message = ex.Message }); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LetterboxdSync/LetterboxdSync.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | LetterboxdSync 6 | enable 7 | AllEnabledByDefault 8 | ../jellyfin.ruleset 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /LetterboxdSync/Plugin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using LetterboxdSync.Configuration; 5 | using MediaBrowser.Common.Configuration; 6 | using MediaBrowser.Common.Plugins; 7 | using MediaBrowser.Model.Plugins; 8 | using MediaBrowser.Model.Serialization; 9 | 10 | namespace LetterboxdSync; 11 | 12 | /// 13 | /// The main plugin. 14 | /// 15 | public class Plugin : BasePlugin, IHasWebPages 16 | { 17 | /// 18 | /// Initializes a new instance of the class. 19 | /// 20 | /// Instance of the interface. 21 | /// Instance of the interface. 22 | public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) 23 | : base(applicationPaths, xmlSerializer) 24 | { 25 | Instance = this; 26 | } 27 | 28 | /// 29 | public override string Name => "LetterboxdSync"; 30 | 31 | /// 32 | public override Guid Id => Guid.Parse("b1fb3d98-3336-4b87-a5c9-8a948bd87233"); 33 | 34 | /// 35 | /// Gets the current plugin instance. 36 | /// 37 | public static Plugin? Instance { get; private set; } 38 | 39 | /// 40 | public IEnumerable GetPages() 41 | { 42 | return new[] 43 | { 44 | new PluginPageInfo 45 | { 46 | Name = "configLetterboxd", 47 | EmbeddedResourcePath = $"{GetType().Namespace}.Web.configLetterboxd.html" 48 | }, 49 | new PluginPageInfo 50 | { 51 | Name = "configLetterboxdjs", 52 | EmbeddedResourcePath = $"{GetType().Namespace}.Web.configLetterboxd.js" 53 | } 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |
6 | GitHub Release 7 | GitHub Downloads (all assets, latest release) 8 |
9 | 10 |

11 | 12 |

13 | A unofficial plugin to keep your watched movie history from Jellyfin automatically updated to your Letterboxd diary. 14 |

15 | 16 | ## About 17 | 18 | This plugin sends daily updates to the Letterboxd diary informing the films watched on Jellyfin. Since the Letterboxd API is not publicly available, this project uses the HtmlAgilityPack package to interact directly with the website's interface. 19 | 20 | ## Installation 21 | 22 | 1. Open the dashboard in Jellyfin, then select `Catalog` and open `Settings` at the top with the `⚙️` button. 23 | 24 | 2. Click the `+` button and add the repository URL below, naming it whatever you like and save. 25 | 26 | ``` 27 | https://raw.githubusercontent.com/danielveigasilva/jellyfin-plugin-letterboxd-sync/master/manifest.json 28 | ``` 29 | 30 | 3. Go back to `Catalog`, click on 'LetterboxdSync' at 'General' group and install the most recent version. 31 | 32 | 4. Restart Jellyfin and go back to the plugin settings. Go to `My Plugins` and click on 'LetterboxdSync' to configure. 33 | 34 | ## Configure 35 | 36 | - You can associate one Letterboxd account for each Jellyfin user. You need click `Save` for each one. 37 | 38 | - The synchronization task runs every 24 hours and only for uses accounts marked as `Enable`. 39 | 40 | - Check `Send Favorite` if you want films marked as favorites on Jellyfin to be marked as favorites on Letterboxd. 41 | 42 |

43 | 44 |

45 | -------------------------------------------------------------------------------- /LetterboxdSync/Web/configLetterboxd.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |

LetterboxdSync

7 | 8 |
9 | ${LabelUsername} Jellyfin 10 | 13 |
14 | 15 |

Letterboxd Login

16 |
17 | 18 |
19 |
20 | 21 |
22 | 23 |

Options

24 |
25 | 29 |
When active, it synchronizes the films watched by the selected user
30 |
31 |
32 | 36 |
When active it sends favorite status
37 |
38 | 39 |
40 | 43 |
44 |
45 |
46 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "guid": "b1fb3d98-3336-4b87-a5c9-8a948bd87233", 4 | "name": "LetterboxdSync", 5 | "overview": "Syncs watched movies with Letterboxd diary", 6 | "description": "A third party plugin to keep your watched movie history automatically updated to a Letterboxd account", 7 | "owner": "danielveigasilva", 8 | "category": "General", 9 | "imageUrl": "https://raw.githubusercontent.com/danielveigasilva/jellyfin-plugin-letterboxd-sync/master/images/letterboxd-sync.png", 10 | "versions": [ 11 | { 12 | "version": "1.1.2.0", 13 | "changelog": "- fix: check date last review correct (f9f499e)", 14 | "targetAbi": "10.9.0.0", 15 | "sourceUrl": "https://github.com/danielveigasilva/jellyfin-plugin-letterboxd-sync/releases/download/v1.1.2/jellyfin-plugin-letterboxd-sync-v1.1.2.zip", 16 | "checksum": "ca795994cd1c9ac01a586e28ba9b2a8b", 17 | "timestamp": "2025-06-15T00:45:35Z" 18 | }, 19 | { 20 | "version": "1.1.1.0", 21 | "changelog": "- fix: log movies watched just one day before (b817d28)\n- fix: ignore readme in publish (c2d97b0)\n- fix: update release names (8ac3ba7)\n- docs: update README with count downloads (357a8cd)", 22 | "targetAbi": "10.9.0.0", 23 | "sourceUrl": "https://github.com/danielveigasilva/jellyfin-plugin-letterboxd-sync/releases/download/v1.1.1/jellyfin-plugin-letterboxd-sync-v1.1.1.zip", 24 | "checksum": "fdfbf9b188fecd6cde3116a8773a729b", 25 | "timestamp": "2025-06-11T17:38:14Z" 26 | }, 27 | { 28 | "version": "1.1.0.0", 29 | "changelog": "- feat(publish): automate publish release (ca44bc3)\n- feat(search): find movie only by tmdbid (7cc9d9d)", 30 | "targetAbi": "10.9.0.0", 31 | "sourceUrl": "https://github.com/danielveigasilva/jellyfin-plugin-letterboxd-sync/releases/download/v1.1.0/jellyfin-plugin-letterboxd-sync-v1.1.0.zip", 32 | "checksum": "d5514462054943fc4ef88e1ca7d7a206", 33 | "timestamp": "2025-03-20T22:26:45Z" 34 | }, 35 | { 36 | "version": "1.0.2.0", 37 | "changelog": "- Fix: prevent failure when review id is blank (#1)\n- Refactor: clearer logs", 38 | "targetAbi": "10.9.0.0", 39 | "sourceUrl": "https://github.com/danielveigasilva/jellyfin-plugin-letterboxd-sync/releases/download/v1.0.2/jellyfin-plugin-letterboxd-sync-v1.0.2.0.zip", 40 | "checksum": "a42d4734f41a1f45131abf142ba62fe6", 41 | "timestamp": "2025-03-07T00:00:00Z" 42 | }, 43 | { 44 | "version": "1.0.1.0", 45 | "changelog": "- Fix #2: All research with a TMDB id return no results", 46 | "targetAbi": "10.9.0.0", 47 | "sourceUrl": "https://github.com/danielveigasilva/jellyfin-plugin-letterboxd-sync/releases/download/v1.0.1/jellyfin-plugin-letterboxd-sync-v1.0.1.0.zip", 48 | "checksum": "c998b14487096209b505a02513a05026", 49 | "timestamp": "2025-03-05T00:00:00Z" 50 | }, 51 | { 52 | "version": "1.0.0.0", 53 | "changelog": "- Initial version", 54 | "targetAbi": "10.9.0.0", 55 | "sourceUrl": "https://github.com/danielveigasilva/jellyfin-plugin-letterboxd-sync/releases/download/v1.0.0/jellyfin-plugin-letterboxd-sync-v1.0.0.0.zip", 56 | "checksum": "89f9d6e5b02c6907c6952703f30a8c9e", 57 | "timestamp": "2025-01-18T00:00:00Z" 58 | } 59 | ] 60 | } 61 | ] 62 | -------------------------------------------------------------------------------- /LetterboxdSync/Web/configLetterboxd.js: -------------------------------------------------------------------------------- 1 | 2 | export const pluginId = 'b1fb3d98-3336-4b87-a5c9-8a948bd87233'; 3 | 4 | export default function (view, params) { 5 | 6 | view.addEventListener('viewshow', function (e) { 7 | 8 | const selectUsers = view.querySelector('#usersJellyfin'); 9 | selectUsers.innerHTML = ''; 10 | 11 | ApiClient.getUsers().then(users => { 12 | for (let user of users){ 13 | const option = document.createElement('option'); 14 | option.value = user.Id; 15 | option.textContent = user.Name; 16 | selectUsers.appendChild(option); 17 | } 18 | 19 | const userSelectedId = document.getElementById('usersJellyfin').value; 20 | ApiClient.getPluginConfiguration(pluginId).then(config => { 21 | let configUserFilter = config.Accounts.filter(function (item) { 22 | return item.UserJellyfin == userSelectedId; 23 | }); 24 | view.querySelector('#username').value = configUserFilter[0].UserLetterboxd; 25 | view.querySelector('#password').value = configUserFilter[0].PasswordLetterboxd; 26 | view.querySelector('#enable').checked = configUserFilter[0].Enable; 27 | view.querySelector('#sendfavorite').checked = configUserFilter[0].SendFavorite; 28 | }); 29 | }); 30 | }); 31 | 32 | 33 | view.querySelector('#usersJellyfin').addEventListener('change', function(e) { 34 | 35 | e.preventDefault(); 36 | const userSelectedId = e.target.value; 37 | 38 | ApiClient.getPluginConfiguration(pluginId).then(config => { 39 | 40 | let configUserFilter = config.Accounts.filter(function (item) { 41 | return item.UserJellyfin == userSelectedId; 42 | }); 43 | 44 | if (configUserFilter.length > 0) { 45 | view.querySelector('#username').value = configUserFilter[0].UserLetterboxd; 46 | view.querySelector('#password').value = configUserFilter[0].PasswordLetterboxd; 47 | view.querySelector('#enable').checked = configUserFilter[0].Enable; 48 | view.querySelector('#sendfavorite').checked = configUserFilter[0].SendFavorite; 49 | } 50 | else { 51 | view.querySelector('#username').value = ''; 52 | view.querySelector('#password').value = ''; 53 | view.querySelector('#enable').checked = false; 54 | view.querySelector('#sendfavorite').checked = false; 55 | } 56 | 57 | }); 58 | }); 59 | 60 | view.querySelector('#LetterboxdSyncConfigForm').addEventListener('submit', function (e) { 61 | 62 | e.preventDefault(); 63 | 64 | Dashboard.showLoadingMsg(); 65 | 66 | const userSelectedId = document.getElementById('usersJellyfin').value; 67 | 68 | ApiClient.getPluginConfiguration(pluginId).then(config => { 69 | 70 | let AccountsUpdate = []; 71 | 72 | for (let account of config.Accounts) 73 | if (account.UserJellyfin != userSelectedId) 74 | AccountsUpdate.push(account); 75 | 76 | let configUser = {}; 77 | configUser.UserJellyfin = userSelectedId; 78 | configUser.UserLetterboxd = view.querySelector('#username').value; 79 | configUser.PasswordLetterboxd = view.querySelector('#password').value; 80 | configUser.Enable = view.querySelector('#enable').checked; 81 | configUser.SendFavorite = view.querySelector('#sendfavorite').checked; 82 | 83 | const data = JSON.stringify(configUser); 84 | const url = ApiClient.getUrl('Jellyfin.Plugin.LetterboxdSync/Authenticate'); 85 | 86 | console.log(configUser); 87 | if (!configUser.Enable){ 88 | Dashboard.hideLoadingMsg(); 89 | 90 | AccountsUpdate.push(configUser); 91 | config.Accounts = AccountsUpdate; 92 | 93 | ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) { 94 | Dashboard.processPluginConfigurationUpdateResult(result); 95 | }); 96 | } 97 | else { 98 | ApiClient.ajax({ type: 'POST', url, data, contentType: 'application/json'}).then(function (response) { 99 | 100 | Dashboard.hideLoadingMsg(); 101 | 102 | AccountsUpdate.push(configUser); 103 | config.Accounts = AccountsUpdate; 104 | 105 | ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) { 106 | Dashboard.processPluginConfigurationUpdateResult(result); 107 | }); 108 | 109 | }).catch(function (response) { 110 | response.json().then(res => { 111 | console.log(res); 112 | Dashboard.hideLoadingMsg(); 113 | Dashboard.processErrorResponse({statusText: `${response.statusText} - ${res.Message}`}); 114 | }); 115 | }); 116 | } 117 | }) 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /jellyfin.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /LetterboxdSync/LetterboxdSyncTask.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using MediaBrowser.Model.Tasks; 6 | using Microsoft.Extensions.Logging; 7 | using LetterboxdSync.Configuration; 8 | using Jellyfin.Data.Entities; 9 | using Jellyfin.Data.Enums; 10 | using MediaBrowser.Controller.Entities; 11 | using MediaBrowser.Controller.Library; 12 | using System.Linq; 13 | using MediaBrowser.Model.Entities; 14 | using System.Globalization; 15 | using MediaBrowser.Model.Activity; 16 | 17 | namespace LetterboxdSync; 18 | 19 | public class LetterboxdSyncTask : IScheduledTask 20 | { 21 | private readonly ILogger _logger; 22 | private readonly ILoggerFactory _loggerFactory; 23 | private readonly ILibraryManager _libraryManager; 24 | private readonly IUserManager _userManager; 25 | private readonly IUserDataManager _userDataManager; 26 | private readonly IActivityManager _activityManager; 27 | 28 | public LetterboxdSyncTask( 29 | IUserManager userManager, 30 | ILoggerFactory loggerFactory, 31 | ILibraryManager libraryManager, 32 | IActivityManager activityManager, 33 | IUserDataManager userDataManager) 34 | { 35 | _logger = loggerFactory.CreateLogger(); 36 | _loggerFactory = loggerFactory; 37 | _userManager = userManager; 38 | _libraryManager = libraryManager; 39 | _activityManager = activityManager; 40 | _userDataManager = userDataManager; 41 | } 42 | 43 | private static PluginConfiguration Configuration => 44 | Plugin.Instance!.Configuration; 45 | 46 | public string Name => "Played media sync with letterboxd"; 47 | 48 | public string Key => "LetterboxdSync"; 49 | 50 | public string Description => "Sync movies with Letterboxd"; 51 | 52 | public string Category => "LetterboxdSync"; 53 | 54 | 55 | public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) 56 | { 57 | var lstUsers = _userManager.Users; 58 | foreach (var user in lstUsers) 59 | { 60 | var account = Configuration.Accounts.FirstOrDefault(account => account.UserJellyfin == user.Id.ToString("N") && account.Enable); 61 | 62 | if (account == null) 63 | continue; 64 | 65 | var lstMoviesPlayed = _libraryManager.GetItemList(new InternalItemsQuery(user) 66 | { 67 | IncludeItemTypes = new List() { BaseItemKind.Movie }.ToArray(), 68 | IsVirtualItem = false, 69 | IsPlayed = true, 70 | }); 71 | 72 | if (lstMoviesPlayed.Count == 0) 73 | continue; 74 | 75 | var api = new LetterboxdApi(); 76 | try 77 | { 78 | await api.Authenticate(account.UserLetterboxd, account.PasswordLetterboxd).ConfigureAwait(false); 79 | } 80 | catch (Exception ex) 81 | { 82 | _logger.LogError( 83 | @"{Message} 84 | User: {Username} ({UserId})", 85 | ex.Message, 86 | user.Username, user.Id.ToString("N")); 87 | 88 | continue; 89 | } 90 | 91 | foreach (var movie in lstMoviesPlayed) 92 | { 93 | int tmdbid; 94 | string title = movie.OriginalTitle; 95 | bool favorite = movie.IsFavoriteOrLiked(user) && account.SendFavorite; 96 | DateTime? viewingDate = _userDataManager.GetUserData(user, movie).LastPlayedDate; 97 | string[] tags = new List() { "" }.ToArray(); 98 | 99 | if (int.TryParse(movie.GetProviderId(MetadataProvider.Tmdb), out tmdbid)) 100 | { 101 | try 102 | { 103 | var filmResult = await api.SearchFilmByTmdbId(tmdbid).ConfigureAwait(false); 104 | 105 | var dateLastLog = await api.GetDateLastLog(filmResult.filmSlug).ConfigureAwait(false); 106 | viewingDate = new DateTime(viewingDate.Value.Year, viewingDate.Value.Month, viewingDate.Value.Day); 107 | 108 | if (dateLastLog != null && dateLastLog >= viewingDate) 109 | { 110 | _logger.LogWarning( 111 | @"Film has been logged into Letterboxd previously ({Date}) 112 | User: {Username} ({UserId}) 113 | Movie: {Movie} ({TmdbId})", 114 | ((DateTime)dateLastLog).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), 115 | user.Username, user.Id.ToString("N"), 116 | title, tmdbid); 117 | } 118 | else 119 | { 120 | await api.MarkAsWatched(filmResult.filmId, viewingDate, tags, favorite).ConfigureAwait(false); 121 | 122 | await _activityManager.CreateAsync(new ActivityLog($"\"{title}\" log in Letterboxd", "LetterboxdSync", Guid.Empty) { 123 | ShortOverview = $"Last played by {user.Username} at {viewingDate}", 124 | Overview = $"Movie \"{title}\"({tmdbid}) played by Jellyfin user {user.Username} at {viewingDate} was log in Letterboxd diary of {account.UserLetterboxd} account", 125 | }).ConfigureAwait(false); 126 | } 127 | } 128 | catch (Exception ex) 129 | { 130 | _logger.LogError( 131 | @"{Message} 132 | User: {Username} ({UserId}) 133 | Movie: {Movie} ({TmdbId}) 134 | StackTrace: {StackTrace}", 135 | ex.Message, 136 | user.Username, user.Id.ToString("N"), 137 | title, tmdbid, 138 | ex.StackTrace); 139 | } 140 | } 141 | else 142 | { 143 | _logger.LogWarning( 144 | @"Film does not have TmdbId 145 | User: {Username} ({UserId}) 146 | Movie: {Movie}", 147 | user.Username, user.Id.ToString("N"), 148 | title); 149 | } 150 | } 151 | } 152 | 153 | progress.Report(100); 154 | return; 155 | } 156 | 157 | public IEnumerable GetDefaultTriggers() => new[] 158 | { 159 | new TaskTriggerInfo 160 | { 161 | Type = TaskTriggerInfo.TriggerInterval, 162 | IntervalTicks = TimeSpan.FromDays(1).Ticks 163 | } 164 | }; 165 | } 166 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: '🚀 Publish' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | paths-ignore: 9 | - 'README.md' 10 | 11 | permissions: 12 | contents: write 13 | 14 | env: 15 | DOTNET_VERSION: '6.0.x' 16 | 17 | jobs: 18 | build: 19 | if: ${{ github.actor != 'github-actions[bot]' }} 20 | runs-on: ubuntu-latest 21 | outputs: 22 | release-version: ${{ steps.ged-version-changelog.outputs.release-version }} 23 | changelog: ${{ steps.ged-version-changelog.outputs.changelog }} 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: 'Generate Version and Changelog' 30 | id: ged-version-changelog 31 | run: | 32 | npm install -g conventional-recommended-bump conventional-changelog-angular semver 33 | 34 | CUR_TAG=$(git describe --tags --abbrev=0 || echo "v0.0.0") 35 | CUR_VERSION=${CUR_TAG#v} 36 | 37 | INCREMENT_LEVEL=$(conventional-recommended-bump -p angular --tag-prefix "v") 38 | NEW_VERSION=$(npx semver $CUR_VERSION -i $INCREMENT_LEVEL) 39 | 40 | CHANGELOG=$(git log $CUR_TAG..HEAD --no-merges --pretty=format:"- %s (%h)") 41 | 42 | echo "release-version=$NEW_VERSION" >> $GITHUB_OUTPUT 43 | printf "changelog<> $GITHUB_OUTPUT 44 | 45 | echo "$CUR_VERSION -> $NEW_VERSION" 46 | echo "$CHANGELOG" 47 | 48 | - name: 'Update Directory.Build.props and build.yaml' 49 | run: | 50 | sed "s/.*<\/Version>/${{ steps.ged-version-changelog.outputs.release-version }}.0<\/Version>/" Directory.Build.props > Directory.Build.props.temp && mv Directory.Build.props.temp Directory.Build.props 51 | sed "s/.*<\/AssemblyVersion>/${{ steps.ged-version-changelog.outputs.release-version }}.0<\/AssemblyVersion>/" Directory.Build.props > Directory.Build.props.temp && mv Directory.Build.props.temp Directory.Build.props 52 | sed "s/.*<\/FileVersion>/${{ steps.ged-version-changelog.outputs.release-version }}.0<\/FileVersion>/" Directory.Build.props > Directory.Build.props.temp && mv Directory.Build.props.temp Directory.Build.props 53 | sed "s/version: \".*\"/version: \"${{ steps.ged-version-changelog.outputs.release-version }}.0\"/" build.yaml > build.yaml.temp && mv build.yaml.temp build.yaml 54 | 55 | - name: 'Setup .NET (${{env.DOTNET_VERSION}})' 56 | uses: actions/setup-dotnet@v4 57 | with: 58 | dotnet-version: ${{env.DOTNET_VERSION}} 59 | 60 | - name: 'Build project' 61 | run: | 62 | dotnet restore 63 | dotnet publish --configuration Release --output publish 64 | 65 | - name: 'Generate Artefacts' 66 | run: | 67 | cd publish 68 | zip ${{ github.event.repository.name }}-v${{ steps.ged-version-changelog.outputs.release-version }}.zip \ 69 | HtmlAgilityPack.dll \ 70 | LetterboxdSync.dll 71 | 72 | - name: 'Upload Artefacts' 73 | uses: actions/upload-artifact@v4 74 | with: 75 | name: ${{ github.event.repository.name }}-v${{ steps.ged-version-changelog.outputs.release-version }} 76 | path: publish/${{ github.event.repository.name }}-v${{ steps.ged-version-changelog.outputs.release-version }}.zip 77 | 78 | publish: 79 | name: 'publish v${{needs.build.outputs.release-version}}' 80 | runs-on: ubuntu-latest 81 | environment: prd 82 | needs: build 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | fetch-depth: 0 87 | 88 | - uses: actions/download-artifact@v4 89 | with: 90 | name: ${{ github.event.repository.name }}-v${{ needs.build.outputs.release-version }} 91 | path: publish 92 | 93 | - name: 'Update Directory.Build.props and build.yaml' 94 | run: | 95 | sed "s/.*<\/Version>/${{ needs.build.outputs.release-version }}.0<\/Version>/" Directory.Build.props > Directory.Build.props.temp && mv Directory.Build.props.temp Directory.Build.props 96 | sed "s/.*<\/AssemblyVersion>/${{ needs.build.outputs.release-version }}.0<\/AssemblyVersion>/" Directory.Build.props > Directory.Build.props.temp && mv Directory.Build.props.temp Directory.Build.props 97 | sed "s/.*<\/FileVersion>/${{ needs.build.outputs.release-version }}.0<\/FileVersion>/" Directory.Build.props > Directory.Build.props.temp && mv Directory.Build.props.temp Directory.Build.props 98 | sed "s/version: \".*\"/version: \"${{ needs.build.outputs.release-version }}.0\"/" build.yaml > build.yaml.temp && mv build.yaml.temp build.yaml 99 | 100 | - name: 'Update manifest.json' 101 | run: | 102 | TARGETABI="10.9.0.0" 103 | TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 104 | 105 | CHECKSUM=$(md5sum publish/${{ github.event.repository.name }}-v${{ needs.build.outputs.release-version }}.zip | awk '{print $1}') 106 | SOURCE_URL="https://github.com/${{ github.repository_owner }}/${{ github.event.repository.name }}/releases/download/v${{ needs.build.outputs.release-version }}/${{ github.event.repository.name }}-v${{ needs.build.outputs.release-version }}.zip" 107 | 108 | NEW_ENTRY="{ 109 | "version": \"${{ needs.build.outputs.release-version }}.0\", 110 | "changelog": \"${{needs.build.outputs.changelog}}\", 111 | "targetAbi": \"${TARGETABI}\", 112 | "sourceUrl": \"${SOURCE_URL}\", 113 | "checksum": \"${CHECKSUM}\", 114 | "timestamp": \"${TIMESTAMP}\" 115 | }" 116 | 117 | jq ".[0].versions = [${NEW_ENTRY}] + .[0].versions" manifest.json > manifest.json.temp && mv manifest.json.temp manifest.json 118 | 119 | - name: 'Commit Changes' 120 | run: | 121 | git config user.name "GitHub Actions" 122 | git config user.email "actions@github.com" 123 | 124 | git add Directory.Build.props build.yaml manifest.json 125 | 126 | git commit -m "chore(release): v${{ needs.build.outputs.release-version }}" 127 | git tag v${{ needs.build.outputs.release-version }} 128 | 129 | git push origin HEAD 130 | git push origin v${{ needs.build.outputs.release-version }} 131 | 132 | - name: 'Create Release' 133 | id: create-release 134 | uses: actions/create-release@v1 135 | with: 136 | tag_name: v${{ needs.build.outputs.release-version }} 137 | release_name: v${{ needs.build.outputs.release-version }} 138 | draft: false 139 | prerelease: false 140 | body: ${{needs.build.outputs.changelog}} 141 | env: 142 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 143 | 144 | - name: 'Upload Release Asset' 145 | uses: actions/upload-release-asset@v1 146 | with: 147 | upload_url: ${{ steps.create-release.outputs.upload_url }} 148 | asset_path: publish/${{ github.event.repository.name }}-v${{ needs.build.outputs.release-version }}.zip 149 | asset_name: ${{ github.event.repository.name }}-v${{ needs.build.outputs.release-version }}.zip 150 | asset_content_type: application/zip 151 | env: 152 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /LetterboxdSync/LetterboxdApi.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Net.Http; 7 | using System.Text; 8 | using System.Text.Json; 9 | using System.Text.RegularExpressions; 10 | using System.Threading.Tasks; 11 | using HtmlAgilityPack; 12 | 13 | namespace LetterboxdSync; 14 | 15 | public class LetterboxdApi 16 | { 17 | private string cookie = string.Empty; 18 | private string csrf = string.Empty; 19 | 20 | private string username = string.Empty; 21 | 22 | public async Task Authenticate(string username, string password) 23 | { 24 | string url = "https://letterboxd.com/user/login.do"; 25 | 26 | var cookieContainer = new CookieContainer(); 27 | this.username = username; 28 | 29 | using (var client = new HttpClient(new HttpClientHandler { CookieContainer = cookieContainer })) 30 | { 31 | var response = await client.PostAsync(url, new FormUrlEncodedContent(new Dictionary { })).ConfigureAwait(false); 32 | if (response.StatusCode != HttpStatusCode.OK) 33 | throw new Exception($"Letterbox return {(int)response.StatusCode}"); 34 | 35 | this.cookie = CookieToString(cookieContainer.GetCookies(new Uri(url))); 36 | using (JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false))) 37 | this.csrf = GetElementFromJson(doc.RootElement, "csrf"); 38 | } 39 | 40 | using (var client = new HttpClient(new HttpClientHandler { CookieContainer = cookieContainer })) 41 | { 42 | client.DefaultRequestHeaders.Add("DNT", "1"); 43 | client.DefaultRequestHeaders.Add("Host", "letterboxd.com"); 44 | client.DefaultRequestHeaders.Add("Origin", "https://letterboxd.com"); 45 | client.DefaultRequestHeaders.Add("Priority", "u=0"); 46 | client.DefaultRequestHeaders.Add("Referer", "https://letterboxd.com/"); 47 | client.DefaultRequestHeaders.Add("Sec-Fetch-Dest", "empty"); 48 | client.DefaultRequestHeaders.Add("Sec-Fetch-Mode", "cors"); 49 | client.DefaultRequestHeaders.Add("Sec-Fetch-Site", "same-origin"); 50 | client.DefaultRequestHeaders.Add("Sec-GPC", "1"); 51 | client.DefaultRequestHeaders.Add("TE", "trailers"); 52 | client.DefaultRequestHeaders.Add("Cookie", this.cookie); 53 | 54 | var content = new FormUrlEncodedContent(new Dictionary 55 | { 56 | { "username", username }, 57 | { "password", password }, 58 | { "__csrf", this.csrf }, 59 | { "authenticationCode", " " } 60 | }); 61 | 62 | var response = await client.PostAsync(url, content).ConfigureAwait(false); 63 | if (response.StatusCode != HttpStatusCode.OK) 64 | throw new Exception($"Letterbox return {(int)response.StatusCode}"); 65 | 66 | this.cookie = CookieToString(cookieContainer.GetCookies(new Uri(url))); 67 | 68 | using (JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false))) 69 | { 70 | var json = doc.RootElement; 71 | if (SucessOperation(json, out string message)) 72 | this.csrf = GetElementFromJson(doc.RootElement, "csrf"); 73 | else 74 | throw new Exception(message); 75 | } 76 | } 77 | } 78 | 79 | public async Task SearchFilmByTmdbId(int tmdbid) 80 | { 81 | string tmdbUrl = $"https://letterboxd.com/tmdb/{tmdbid}"; 82 | 83 | var handler = new HttpClientHandler() 84 | { 85 | AllowAutoRedirect = true 86 | }; 87 | 88 | using (var client = new HttpClient(handler)) 89 | { 90 | var res = await client.GetAsync(tmdbUrl).ConfigureAwait(false); 91 | 92 | string letterboxdUrl = res?.RequestMessage?.RequestUri?.ToString(); 93 | var filmSlugRegex = Regex.Match(letterboxdUrl, @"https:\/\/letterboxd\.com\/film\/([^\/]+)\/"); 94 | 95 | string filmSlug = filmSlugRegex.Groups[1].Value; 96 | if (string.IsNullOrEmpty(filmSlug)) 97 | throw new Exception("The search returned no results"); 98 | 99 | var htmlDoc = new HtmlDocument(); 100 | htmlDoc.LoadHtml(await res.Content.ReadAsStringAsync().ConfigureAwait(false)); 101 | 102 | var span = htmlDoc.DocumentNode.SelectSingleNode("//div[@data-film-slug='" + filmSlug + "']"); 103 | if (span == null) 104 | throw new Exception("The search returned no results"); 105 | 106 | string filmId = span.GetAttributeValue("data-film-id", string.Empty); 107 | if (string.IsNullOrEmpty(filmId)) 108 | throw new Exception("The search returned no results"); 109 | 110 | return new FilmResult(filmSlug, filmId); 111 | } 112 | } 113 | 114 | public async Task MarkAsWatched(string filmId, DateTime? date, string[] tags, bool liked = false) 115 | { 116 | string url = $"https://letterboxd.com/s/save-diary-entry"; 117 | DateTime viewingDate = date == null ? DateTime.Now : (DateTime) date; 118 | 119 | using (var client = new HttpClient()) 120 | { 121 | client.DefaultRequestHeaders.Add("Cookie", this.cookie); 122 | 123 | var content = new FormUrlEncodedContent(new Dictionary 124 | { 125 | { "__csrf", this.csrf }, 126 | { "json", "true" }, 127 | { "viewingId", string.Empty }, 128 | { "filmId", filmId }, 129 | { "specifiedDate", date == null ? "false" : "true" }, 130 | { "viewingDateStr", viewingDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) }, 131 | { "review", string.Empty }, 132 | { "tags", date != null && tags.Length > 0 ? $"[{string.Join(",", tags)}]" : string.Empty }, 133 | { "rating", "0" }, 134 | { "liked", liked.ToString() } 135 | }); 136 | 137 | var response = await client.PostAsync(url, content).ConfigureAwait(false); 138 | if (response.StatusCode != HttpStatusCode.OK) 139 | throw new Exception($"Letterbox return {(int)response.StatusCode}"); 140 | 141 | using (JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false))) 142 | { 143 | if (!SucessOperation(doc.RootElement, out string message)) 144 | throw new Exception(message); 145 | } 146 | } 147 | } 148 | 149 | public async Task GetDateLastLog(string filmSlug) 150 | { 151 | string url = $"https://letterboxd.com/{this.username}/film/{filmSlug}/diary/"; 152 | 153 | using (var client = new HttpClient()) 154 | { 155 | client.DefaultRequestHeaders.Add("Cookie", this.cookie); 156 | var lstDates = new List(); 157 | 158 | var response = await client.GetStringAsync(url).ConfigureAwait(false); 159 | 160 | var htmlDoc = new HtmlDocument(); 161 | htmlDoc.LoadHtml(response); 162 | 163 | var trReviews = htmlDoc.DocumentNode.SelectNodes("//tr[contains(@class, 'diary-entry-row')]"); 164 | if (trReviews == null) 165 | return null; 166 | 167 | foreach (var trReview in trReviews) 168 | { 169 | var tdDayReview = trReview.SelectSingleNode("//td[contains(@class, 'td-day')]"); 170 | if (tdDayReview == null) 171 | break; 172 | 173 | var linkNode = tdDayReview.SelectSingleNode(".//a"); 174 | if (linkNode == null) 175 | break; 176 | 177 | string linkReview = $"https://letterboxd.com{linkNode.GetAttributeValue("href", "")}"; 178 | 179 | response = await client.GetStringAsync(linkReview).ConfigureAwait(false); 180 | 181 | htmlDoc = new HtmlDocument(); 182 | htmlDoc.LoadHtml(response); 183 | 184 | var section = htmlDoc.DocumentNode.SelectSingleNode("//section[@class='film-viewing-info-wrapper']"); 185 | if (section == null) 186 | break; 187 | 188 | var meta = section.SelectSingleNode("meta"); 189 | if (meta == null) 190 | break; 191 | 192 | var date = meta.GetAttributeValue("content", string.Empty); 193 | if (date == null) 194 | break; 195 | 196 | lstDates.Add(DateTime.Parse(date, CultureInfo.InvariantCulture)); 197 | } 198 | 199 | return lstDates.Max(); 200 | } 201 | } 202 | 203 | private string CookieToString(CookieCollection cookies) 204 | { 205 | StringBuilder cookieString = new StringBuilder(); 206 | foreach (Cookie cookie in cookies) 207 | { 208 | cookieString.Append(new CultureInfo("en-US"), $"{cookie.Name}={cookie.Value}"); 209 | cookieString.Append("; "); 210 | } 211 | 212 | return cookieString.ToString(); 213 | } 214 | 215 | private string GetElementFromJson(JsonElement json, string property) 216 | { 217 | if (json.TryGetProperty(property, out JsonElement element)) 218 | return element.GetString() ?? string.Empty; 219 | return string.Empty; 220 | } 221 | 222 | private bool SucessOperation(JsonElement json, out string message) 223 | { 224 | message = string.Empty; 225 | 226 | if (json.TryGetProperty("messages", out JsonElement messagesElement)) 227 | { 228 | StringBuilder erroMessages = new StringBuilder(); 229 | foreach (var i in messagesElement.EnumerateArray()) 230 | erroMessages.Append(i.GetString()); 231 | message = erroMessages.ToString(); 232 | } 233 | 234 | if (json.TryGetProperty("result", out JsonElement statusElement)) 235 | { 236 | switch (statusElement.ValueKind) 237 | { 238 | case JsonValueKind.String: 239 | return statusElement.GetString() == "error" ? false : true; 240 | case JsonValueKind.True: 241 | return true; 242 | case JsonValueKind.False: 243 | return false; 244 | } 245 | } 246 | 247 | return false; 248 | } 249 | } 250 | 251 | public class FilmResult { 252 | public string filmSlug = string.Empty; 253 | public string filmId = string.Empty; 254 | 255 | public FilmResult(string filmSlug, string filmId){ 256 | this.filmSlug = filmSlug; 257 | this.filmId = filmId; 258 | } 259 | } 260 | --------------------------------------------------------------------------------